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..a84264033 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -113,6 +113,13 @@ 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. +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. + 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..8914bbcf6 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 = re.split(r"[ \t]+#", retention, maxsplit=1)[0].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..72b21008c 100644 --- a/tools/ci/tests/test_release_contract.py +++ b/tools/ci/tests/test_release_contract.py @@ -413,6 +413,36 @@ def test_release_workflows_are_pinned_and_fail_closed() -> None: assert contract.publisher_job_errors() == [] +@pytest.mark.parametrize( + "retention", + [ + "5", + "5 # too short for release recovery", + "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", + "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") == [] + + @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()