From 7773d3cc3dd1f4e808dcb158f63afacbf618ee20 Mon Sep 17 00:00:00 2001 From: Marton Mayer Date: Mon, 19 Oct 2026 09:49:34 +0200 Subject: [PATCH 1/3] ci: expire non-release build artifacts after five days --- .github/workflows/release-audio.yml | 2 +- .github/workflows/release-docker.yml | 8 ++++---- .github/workflows/release-helm.yml | 2 +- .github/workflows/release-native.yml | 2 +- .github/workflows/release-npm.yml | 2 +- .github/workflows/release-python.yml | 2 +- RELEASE.md | 6 ++++++ tools/ci/check_release_contract.py | 20 +++++++++++++++++--- tools/ci/tests/test_release_contract.py | 20 ++++++++++++++++++++ 9 files changed, 52 insertions(+), 12 deletions(-) diff --git a/.github/workflows/release-audio.yml b/.github/workflows/release-audio.yml index f4f30d2d1..d32b0c571 100644 --- a/.github/workflows/release-audio.yml +++ b/.github/workflows/release-audio.yml @@ -70,4 +70,4 @@ jobs: name: audio-prep-${{ inputs.version }} path: dist/ if-no-files-found: error - retention-days: 30 + retention-days: ${{ github.event_name == 'release' && 30 || 5 }} diff --git a/.github/workflows/release-docker.yml b/.github/workflows/release-docker.yml index fc76d2948..601a68df5 100644 --- a/.github/workflows/release-docker.yml +++ b/.github/workflows/release-docker.yml @@ -88,7 +88,7 @@ jobs: name: docker-server-${{ matrix.platform }}-${{ matrix.bundle }}-${{ inputs.version }} path: artifact/ if-no-files-found: error - retention-days: 30 + retention-days: ${{ github.event_name == 'release' && 30 || 5 }} compression-level: 0 - id: restore-evidence name: Reuse original image verification evidence on retry @@ -112,7 +112,7 @@ jobs: name: docker-evidence-server-${{ matrix.platform }}-${{ matrix.bundle }}-${{ inputs.version }} path: evidence/ if-no-files-found: error - retention-days: 30 + retention-days: ${{ github.event_name == 'release' && 30 || 5 }} build-service: needs: matrix @@ -159,7 +159,7 @@ jobs: name: docker-service-${{ matrix.service }}-${{ inputs.version }} path: artifact/ if-no-files-found: error - retention-days: 30 + retention-days: ${{ github.event_name == 'release' && 30 || 5 }} compression-level: 0 - id: restore-evidence name: Reuse original image verification evidence on retry @@ -183,4 +183,4 @@ jobs: name: docker-evidence-service-${{ matrix.service }}-${{ inputs.version }} path: evidence/ if-no-files-found: error - retention-days: 30 + retention-days: ${{ github.event_name == 'release' && 30 || 5 }} diff --git a/.github/workflows/release-helm.yml b/.github/workflows/release-helm.yml index 9dcf130cc..d9271c7a9 100644 --- a/.github/workflows/release-helm.yml +++ b/.github/workflows/release-helm.yml @@ -51,4 +51,4 @@ jobs: name: helm-sie-cluster-${{ inputs.version }} path: artifact/ if-no-files-found: error - retention-days: 30 + retention-days: ${{ github.event_name == 'release' && 30 || 5 }} diff --git a/.github/workflows/release-native.yml b/.github/workflows/release-native.yml index 19fc7b2ff..7c155fbc9 100644 --- a/.github/workflows/release-native.yml +++ b/.github/workflows/release-native.yml @@ -50,4 +50,4 @@ jobs: name: native-sidecar-${{ inputs.version }} path: artifact/ if-no-files-found: error - retention-days: 30 + retention-days: ${{ github.event_name == 'release' && 30 || 5 }} diff --git a/.github/workflows/release-npm.yml b/.github/workflows/release-npm.yml index a6548bc20..f2dd7aae4 100644 --- a/.github/workflows/release-npm.yml +++ b/.github/workflows/release-npm.yml @@ -65,4 +65,4 @@ jobs: name: npm-distributions path: artifacts/* if-no-files-found: error - retention-days: 30 + retention-days: ${{ github.event_name == 'release' && 30 || 5 }} diff --git a/.github/workflows/release-python.yml b/.github/workflows/release-python.yml index 8187ab54c..b69b5a8db 100644 --- a/.github/workflows/release-python.yml +++ b/.github/workflows/release-python.yml @@ -65,4 +65,4 @@ jobs: name: python-distributions path: artifacts/* if-no-files-found: error - retention-days: 30 + retention-days: ${{ github.event_name == 'release' && 30 || 5 }} diff --git a/RELEASE.md b/RELEASE.md index 9a33f2160..1d2130e75 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -113,6 +113,12 @@ native sidecar from its tested image, as well as building Python, npm, audio, and the packaged Helm chart. Require its final `Release candidate / Complete` check on the final release PR head before merging that PR. +PR and manually dispatched rehearsal artifacts expire after five days. This +includes Docker archives and verification evidence; the native sidecar still +consumes its tested image within the same run. After expiry, start a new +rehearsal instead of relying on its old artifacts. Published-release builds +and their original-run retries retain artifacts for 30 days for recovery. + On a stable release, every builder must pass the `artifacts-ready` gate before any publisher can start. Publishers consume the retained archives. This keeps a failed image or chart build from leaving packages partially published. diff --git a/tools/ci/check_release_contract.py b/tools/ci/check_release_contract.py index 433a2a229..ba2b5c505 100755 --- a/tools/ci/check_release_contract.py +++ b/tools/ci/check_release_contract.py @@ -130,6 +130,10 @@ ACTION_PIN = re.compile(r"^[^@\s]+@[0-9a-f]{40}$") JOB_FIELD_INDENT = 4 ARTIFACT_RETENTION_DAYS = 30 +REHEARSAL_ARTIFACT_RETENTION_DAYS = 5 +ARTIFACT_RETENTION_EXPRESSION = ( + f"${{{{ github.event_name == 'release' && {ARTIFACT_RETENTION_DAYS} || {REHEARSAL_ARTIFACT_RETENTION_DAYS} }}}}" +) REQUIRED_OCI_LABEL_OCCURRENCES = 2 RELEASE_BOOTSTRAP_SHA = "60996d9c30168e0f8e85b680295f147fdee87f61" MPL_LICENSE_EXCEPTION_NAMES = frozenset( @@ -535,6 +539,18 @@ def release_queue_errors(top: str, ci: str) -> list[str]: return errors +def artifact_retention_errors(name: str, text: str) -> list[str]: + errors = [] + for retention in re.findall(r"^\s*retention-days:\s*([^\n]*)$", text, re.MULTILINE): + retention = retention.strip() + if retention == ARTIFACT_RETENTION_EXPRESSION: + continue + if retention.isdecimal() and int(retention) >= ARTIFACT_RETENTION_DAYS: + continue + errors.append(f"{name} must retain published-release retry artifacts for at least 30 days") + return errors + + def release_workflow_errors() -> list[str]: errors: list[str] = [] top = (ROOT / ".github/workflows/release.yml").read_text() @@ -557,9 +573,7 @@ def release_workflow_errors() -> list[str]: for token_name in ("PYPI_TOKEN", "NPM_TOKEN", "NODE_AUTH_TOKEN"): if token_name in text: errors.append(f"{path.name} references forbidden publication token {token_name}") - for retention in re.findall(r"retention-days:\s*(\d+)", text): - if int(retention) < ARTIFACT_RETENTION_DAYS: - errors.append(f"{path.name} expires retry artifacts before 30 days") + errors.extend(artifact_retention_errors(path.name, text)) if path.name not in {"release.yml", "release-candidate.yml"} and "workflow_dispatch" in text: errors.append(f"{path.name} must not expose a separate recovery writer") blocks = workflow_job_blocks(".github/workflows/release.yml") diff --git a/tools/ci/tests/test_release_contract.py b/tools/ci/tests/test_release_contract.py index 8140d0d96..e5e9224e2 100644 --- a/tools/ci/tests/test_release_contract.py +++ b/tools/ci/tests/test_release_contract.py @@ -413,6 +413,26 @@ def test_release_workflows_are_pinned_and_fail_closed() -> None: assert contract.publisher_job_errors() == [] +@pytest.mark.parametrize( + "retention", + [ + "5", + "29", + "${{ github.event_name == 'release' && 5 || 30 }}", + "${{ github.event_name == 'release' && 29 || 5 }}", + "${{ github.event_name == 'workflow_dispatch' && 30 || 5 }}", + "${{ inputs.retention_days }}", + ], +) +def test_artifact_retention_rejects_short_or_unverified_release_recovery(retention): + assert contract.artifact_retention_errors("release-docker.yml", f" retention-days: {retention}\n") + + +@pytest.mark.parametrize("retention", ["30", "90", "${{ github.event_name == 'release' && 30 || 5 }}"]) +def test_artifact_retention_allows_full_release_recovery(retention): + assert contract.artifact_retention_errors("release-docker.yml", f" retention-days: {retention}\n") == [] + + @pytest.mark.parametrize("replacement", [" queue: single", " queue: arbitrary", "", " queue: max\n queue: single"]) def test_release_queue_must_retain_pending_runs(replacement): top = (contract.ROOT / ".github/workflows/release.yml").read_text() From 1221ef697d6343b5ef2811d83c7eae8dcbfd4712 Mon Sep 17 00:00:00 2001 From: Marton Mayer Date: Mon, 19 Oct 2026 09:49:56 +0200 Subject: [PATCH 2/3] docs: clarify non-release artifact retention scope --- RELEASE.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/RELEASE.md b/RELEASE.md index 1d2130e75..a84264033 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -113,8 +113,9 @@ native sidecar from its tested image, as well as building Python, npm, audio, and the packaged Helm chart. Require its final `Release candidate / Complete` check on the final release PR head before merging that PR. -PR and manually dispatched rehearsal artifacts expire after five days. This -includes Docker archives and verification evidence; the native sidecar still +Non-release build artifacts, including CI package archives and PR or manually +dispatched rehearsals, expire after five days. This includes Docker archives +and verification evidence; the native sidecar still consumes its tested image within the same run. After expiry, start a new rehearsal instead of relying on its old artifacts. Published-release builds and their original-run retries retain artifacts for 30 days for recovery. From 71fc1ab0c4d9665c2aa21fcf1b1d75295f168078 Mon Sep 17 00:00:00 2001 From: Marton Mayer Date: Mon, 19 Oct 2026 10:02:09 +0200 Subject: [PATCH 3/3] fix(ci): allow comments on artifact retention values --- tools/ci/check_release_contract.py | 2 +- tools/ci/tests/test_release_contract.py | 12 +++++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/tools/ci/check_release_contract.py b/tools/ci/check_release_contract.py index ba2b5c505..8914bbcf6 100755 --- a/tools/ci/check_release_contract.py +++ b/tools/ci/check_release_contract.py @@ -542,7 +542,7 @@ def release_queue_errors(top: str, ci: str) -> list[str]: def artifact_retention_errors(name: str, text: str) -> list[str]: errors = [] for retention in re.findall(r"^\s*retention-days:\s*([^\n]*)$", text, re.MULTILINE): - retention = retention.strip() + retention = re.split(r"[ \t]+#", retention, maxsplit=1)[0].strip() if retention == ARTIFACT_RETENTION_EXPRESSION: continue if retention.isdecimal() and int(retention) >= ARTIFACT_RETENTION_DAYS: diff --git a/tools/ci/tests/test_release_contract.py b/tools/ci/tests/test_release_contract.py index e5e9224e2..72b21008c 100644 --- a/tools/ci/tests/test_release_contract.py +++ b/tools/ci/tests/test_release_contract.py @@ -417,6 +417,7 @@ def test_release_workflows_are_pinned_and_fail_closed() -> None: "retention", [ "5", + "5 # too short for release recovery", "29", "${{ github.event_name == 'release' && 5 || 30 }}", "${{ github.event_name == 'release' && 29 || 5 }}", @@ -428,7 +429,16 @@ def test_artifact_retention_rejects_short_or_unverified_release_recovery(retenti assert contract.artifact_retention_errors("release-docker.yml", f" retention-days: {retention}\n") -@pytest.mark.parametrize("retention", ["30", "90", "${{ github.event_name == 'release' && 30 || 5 }}"]) +@pytest.mark.parametrize( + "retention", + [ + "30", + "30 # retain release recovery artifacts", + "90", + "${{ github.event_name == 'release' && 30 || 5 }}", + "${{ github.event_name == 'release' && 30 || 5 }} # shorter rehearsals", + ], +) def test_artifact_retention_allows_full_release_recovery(retention): assert contract.artifact_retention_errors("release-docker.yml", f" retention-days: {retention}\n") == []