Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions .github/scripts/release_candidate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}$")


Expand Down Expand Up @@ -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:
Expand All @@ -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")

Expand Down
22 changes: 18 additions & 4 deletions .github/scripts/tests/test_release_candidate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))


Expand Down
15 changes: 10 additions & 5 deletions .github/workflows/promote-worker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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"

Expand Down Expand Up @@ -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
Expand Down
5 changes: 1 addition & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -136,17 +136,14 @@ 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
staged=false
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"
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/repair-worker-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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" \
Expand Down
9 changes: 5 additions & 4 deletions docs/sops/release.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<worker>-<version>` artifact records `succeeded`,
`partial`, or `failed` plus each external surface. Release Control is the
Expand Down
Loading