From bb1a28ebb99185e817f94e1568fd2bf32b3ef7b9 Mon Sep 17 00:00:00 2001 From: Ytallo Layon Date: Fri, 7 Aug 2026 11:28:39 -0300 Subject: [PATCH] fix: allow prerelease candidate promotion --- .github/scripts/release_candidate.py | 15 ++++++++----- .../scripts/tests/test_release_candidate.py | 22 +++++++++++++++---- .github/workflows/promote-worker.yml | 15 ++++++++----- .github/workflows/release.yml | 5 +---- .github/workflows/repair-worker-release.yml | 2 +- docs/sops/release.md | 9 ++++---- 6 files changed, 45 insertions(+), 23 deletions(-) diff --git a/.github/scripts/release_candidate.py b/.github/scripts/release_candidate.py index 34d2d98de..da6589526 100644 --- a/.github/scripts/release_candidate.py +++ b/.github/scripts/release_candidate.py @@ -10,7 +10,7 @@ SHA_RE = re.compile(r"^[0-9a-f]{40}$") -VERSION_RE = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+$") +VERSION_RE = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+(?:-(experimental|alpha|beta))?$") DIGEST_RE = re.compile(r"^sha256:[0-9a-f]{64}$") @@ -90,8 +90,9 @@ def validate_evidence(args: argparse.Namespace) -> dict: f"operation_id: expected {args.operation_id!r}, got {evidence.get('operation_id')!r}" ) - if not VERSION_RE.fullmatch(args.version): - failures.append("version must be stable semver MAJOR.MINOR.PATCH") + version_match = VERSION_RE.fullmatch(args.version) + if not version_match: + failures.append("version must use MAJOR.MINOR.PATCH[-experimental|-alpha|-beta]") if not SHA_RE.fullmatch(str(evidence.get("tag_sha", ""))): failures.append("tag_sha must be a full lowercase commit SHA") if schema_version == 2: @@ -100,8 +101,12 @@ def validate_evidence(args: argparse.Namespace) -> dict: failures.append("source_sha must be unknown or a full lowercase commit SHA") if not isinstance(evidence.get("run_attempt"), int) or evidence["run_attempt"] < 1: failures.append("run_attempt must be a positive integer") - if schema_version == 2 and evidence.get("maturity") != "stable": - failures.append("maturity must be stable for promotion") + if schema_version == 2 and version_match: + expected_maturity = version_match.group(1) or "stable" + if evidence.get("maturity") != expected_maturity: + failures.append( + f"maturity: expected {expected_maturity!r}, got {evidence.get('maturity')!r}" + ) if schema_version == 2 and not str(evidence.get("operation_id", "")).strip(): failures.append("operation_id must be present") diff --git a/.github/scripts/tests/test_release_candidate.py b/.github/scripts/tests/test_release_candidate.py index 137373a05..041ee0615 100644 --- a/.github/scripts/tests/test_release_candidate.py +++ b/.github/scripts/tests/test_release_candidate.py @@ -94,17 +94,31 @@ def test_validate_rejects_mismatched_or_unready_evidence(tmp_path, override, mes validate_evidence(validate_args(path)) -def test_validate_rejects_prerelease_version(tmp_path): +@pytest.mark.parametrize("maturity", ["experimental", "alpha", "beta"]) +def test_validate_accepts_prerelease_version(tmp_path, maturity): + version = f"1.2.3-{maturity}" + evidence = build_evidence( + build_args( + version=version, + maturity=maturity, + release_tag=f"harness/v{version}", + ) + ) + path = write_evidence(tmp_path, evidence) + result = validate_evidence(validate_args(path, version=version)) + assert result["maturity"] == maturity + + +def test_validate_rejects_maturity_that_does_not_match_version(tmp_path): evidence = build_evidence( build_args( version="1.2.3-alpha", - maturity="alpha", + maturity="beta", release_tag="harness/v1.2.3-alpha", - promotable=False, ) ) path = write_evidence(tmp_path, evidence) - with pytest.raises(SystemExit, match="stable semver"): + with pytest.raises(SystemExit, match="maturity: expected 'alpha', got 'beta'"): validate_evidence(validate_args(path, version="1.2.3-alpha")) diff --git a/.github/workflows/promote-worker.yml b/.github/workflows/promote-worker.yml index 167e833e5..c6da4c547 100644 --- a/.github/workflows/promote-worker.yml +++ b/.github/workflows/promote-worker.yml @@ -116,8 +116,8 @@ jobs: ) echo "::notice::next resolves to ${WORKER}@${version}" fi - [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { - echo "::error::candidate must be stable semver MAJOR.MINOR.PATCH (got ${version}); prereleases are not promotable" + [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-(experimental|alpha|beta))?$ ]] || { + echo "::error::candidate must use MAJOR.MINOR.PATCH[-experimental|-alpha|-beta] (got ${version})" exit 2 } @@ -270,10 +270,12 @@ jobs: } deploy=$(jq -r .deploy validated-candidate.json) + maturity=$(jq -r .maturity validated-candidate.json) image_digest=$(jq -r '.image_digest // empty' validated-candidate.json) { echo "tag=$tag" echo "deploy=$deploy" + echo "maturity=$maturity" echo "image_digest=$image_digest" } >>"$GITHUB_OUTPUT" @@ -372,14 +374,17 @@ jobs: env: GH_TOKEN: ${{ github.token }} TAG: ${{ steps.candidate.outputs.tag }} + MATURITY: ${{ steps.candidate.outputs.maturity }} run: | set -euo pipefail + prerelease=false + if [[ "$MATURITY" != stable ]]; then prerelease=true; fi gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" \ - --prerelease=false --latest=false + --prerelease="$prerelease" --latest=false gh release view "$TAG" --repo "$GITHUB_REPOSITORY" \ --json tagName,isDraft,isPrerelease,url >github-release-after.json - jq -e --arg tag "$TAG" \ - '.tagName == $tag and .isDraft == false and .isPrerelease == false' \ + jq -e --arg tag "$TAG" --argjson prerelease "$prerelease" \ + '.tagName == $tag and .isDraft == false and .isPrerelease == $prerelease' \ github-release-after.json >/dev/null - name: Write promotion summary diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index afa6236b9..f51351818 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -136,7 +136,6 @@ jobs: env: REGISTRY_TAG: ${{ steps.meta.outputs.registry_tag }} INTERFACE_SMOKE: ${{ steps.smoke.outputs.interface_smoke }} - IS_PRERELEASE: ${{ steps.meta.outputs.is_prerelease }} DRY_RUN: ${{ steps.meta.outputs.dry_run }} run: | set -euo pipefail @@ -144,9 +143,7 @@ jobs: promotable=false if [[ "$REGISTRY_TAG" == next && "$INTERFACE_SMOKE" == true && "$DRY_RUN" != true ]]; then staged=true - if [[ "$IS_PRERELEASE" != true ]]; then - promotable=true - fi + promotable=true fi echo "staged=$staged" >>"$GITHUB_OUTPUT" echo "promotable=$promotable" >>"$GITHUB_OUTPUT" diff --git a/.github/workflows/repair-worker-release.yml b/.github/workflows/repair-worker-release.yml index 95672adb7..495e3a280 100644 --- a/.github/workflows/repair-worker-release.yml +++ b/.github/workflows/repair-worker-release.yml @@ -257,7 +257,7 @@ jobs: --operation-id "${{ needs.setup.outputs.release_operation_id }}" \ --step-id "repair-candidate-smoke" \ --image-digest "${{ needs.setup.outputs.image_digest }}" \ - --promotable "${{ needs.setup.outputs.maturity == 'stable' }}" \ + --promotable true \ --publish-result success \ --candidate-smoke-result success \ --container-alias-result "$ALIAS_RESULT" \ diff --git a/docs/sops/release.md b/docs/sops/release.md index 41960ea3e..0dd9bb542 100644 --- a/docs/sops/release.md +++ b/docs/sops/release.md @@ -172,14 +172,15 @@ Promotion performs these guarded, idempotent changes: 1. Validate the candidate artifact, evidence-producing run attempt, and current Git tag SHA. -2. Require stable `MAJOR.MINOR.PATCH` maturity and confirm Registry `next` still - points to the candidate. +2. Require the release version grammar and confirm Registry `next` still points + to the candidate. 3. For Harness, validate a deployed-E2E evidence artifact tied to the same release and E2E run attempt. `e2e_run_id` can be supplied or auto-located. 4. Move Registry `latest` with source and destination preconditions. 5. For images, move GHCR `latest` from the recorded immutable digest. -6. Convert the GitHub prerelease to a normal release without changing the - repository-global GitHub Latest release. +6. Convert a stable candidate's GitHub prerelease to a normal release. An + experimental, alpha, or beta release remains marked as a GitHub prerelease. + Neither path changes the repository-global GitHub Latest release. The terminal `promotion--` artifact records `succeeded`, `partial`, or `failed` plus each external surface. Release Control is the