feat(release): add --skip-bundles and --skip-tests to stable releases

`release.py release` gains two flags. They can be used together.

--skip-bundles ships only the claim, the GitHub release, the final tag
and the Docker image. No desktop, Termux or PM bundle job runs. The
final tag records candidateManifestSha256: null. Publication moves only
the Docker stable/latest aliases. The R2 stable head, feeds, APT, the
downloads page, the signed-package baseline and the Store stay on the
previous bundle release.

--skip-tests builds, signs and publishes every artifact and runs no
test job: source CI, Nix, PM bundle check, Termux, Windows live,
install/update E2E, bootstrap identity, native smokes, upgrade
acceptance, tests/docker and the in-build vitest step. The candidate
manifest records each smoke as skipped, never as passed.

The flags live in the claim message (skipBundles, skipTests), next to
autopublish. They are not workflow inputs, so a rerun cannot change
them. admit emits them, and every job condition and gate reads them.
stable.validate_claim and stable.validate_final are now the one shape
check for stable.py and the sequencer.

The gates stay strict. SKIPPED_BY in stable.py maps each job to the
flags that remove it. `gate` requires those jobs to report skipped and
every other gated job to report success. A job that ran although a flag
removes it blocks the release.

A release that skipped bundles never moves the R2 stable head. Two
readers depended on that head:

- The next version was derived from it, so the next cut would reuse the
  version. It now takes the newer of the R2 head and the newest
  published non-prerelease GitHub release with a vX.Y.Z tag. Bare v*
  tags do not count, because those refs are not protected yet.
- The sequencer used it to decide which published releases still need
  their publication pass, so a bundle-less release would re-advance
  every 15 minutes. The head is now the newer of the R2 head and the
  published release whose final tag binds the Docker stable alias
  digest.

`release` also refuses a cut when its next version already has a final
tag. That closes the window between the final tag and the public
release, where the published identity still names the old version.

Tests: 42 release test files, 546 passed. Three tests fail on this
Windows host, and they fail the same way on a clean HEAD worktree:

- test_stable_release_graph::test_docker_recovery_refuses_to_replace_a_divergent_version_tag
- test_release_artifacts::test_windows_metadata_is_read_from_package_and_stale_stamp_is_rejected
- test_tag_builds_summary::test_admitted_failure_publishes_tag_info_without_promoting_channel[True]

Not verified: no real Stable Release dispatch ran with either flag, and
actionlint is not installed on this host. The workflow changes are
checked by the graph tests and by running the phase-result step script.
This commit is contained in:
ethernet
2026-09-24 13:31:33 -04:00
parent 05a78671a7
commit dc11e3b3bc
22 changed files with 708 additions and 128 deletions
+12 -1
View File
@@ -97,6 +97,10 @@ on:
description: 'Comma-separated job groups. Default: every group.'
type: string
default: 'darwin-arm64,darwin-x64,win32-arm64,win32-x64,win32-bundle,linux-x64,linux-arm64,termux'
skip-tests:
description: 'Stable candidates only: build and stage without native smokes or in-build test suites (the claim skipped tests).'
type: boolean
default: false
outputs:
darwin-arm64-receipt-url:
value: ${{ jobs.stage-receipt-darwin-arm64.outputs.receipt-url }}
@@ -643,6 +647,7 @@ jobs:
}
- name: Verify native signature cache contracts
if: inputs.skip-tests != true
shell: bash
working-directory: apps/desktop
run: node ../../node_modules/vitest/vitest.mjs run --project electron scripts/payload-sign-cache.test.mjs scripts/batch-sign-binaries.test.mjs
@@ -1305,6 +1310,7 @@ jobs:
&& needs.stage-receipt-darwin-arm64.result == 'success'
&& (inputs.release-phase == '' || inputs.release-phase == 'candidate')
&& (inputs.upload_release == true || inputs.release-phase == 'candidate' || inputs.build_commit != '' || inputs.channel != '')
&& inputs.skip-tests != true
permissions:
contents: read
strategy:
@@ -1337,6 +1343,7 @@ jobs:
&& needs.stage-receipt-darwin-x64.result == 'success'
&& (inputs.release-phase == '' || inputs.release-phase == 'candidate')
&& (inputs.upload_release == true || inputs.release-phase == 'candidate' || inputs.build_commit != '' || inputs.channel != '')
&& inputs.skip-tests != true
permissions:
contents: read
strategy:
@@ -1368,6 +1375,7 @@ jobs:
&& needs.build-win32-x64.result == 'success' && needs.validate.outputs.win32-x64 == 'true'
&& (inputs.release-phase == '' || inputs.release-phase == 'candidate')
&& (inputs.upload_release == true || inputs.release-phase == 'candidate' || inputs.build_commit != '' || inputs.channel != '')
&& inputs.skip-tests != true
permissions:
contents: read
uses: ./.github/workflows/desktop-bundle-smoke.yml
@@ -1395,6 +1403,7 @@ jobs:
&& needs.build-win32-arm64.result == 'success' && needs.validate.outputs.win32-arm64 == 'true'
&& (inputs.release-phase == '' || inputs.release-phase == 'candidate')
&& (inputs.upload_release == true || inputs.release-phase == 'candidate' || inputs.build_commit != '' || inputs.channel != '')
&& inputs.skip-tests != true
permissions:
contents: read
uses: ./.github/workflows/desktop-bundle-smoke.yml
@@ -2430,6 +2439,7 @@ jobs:
env:
RELEASE_NEEDS: ${{ toJSON(needs) }}
RELEASE_PHASE: ${{ inputs.release-phase }}
SKIP_TESTS: ${{ inputs.skip-tests }}
run: |
python - <<'PY'
import json, os
@@ -2438,7 +2448,8 @@ jobs:
needs = json.loads(os.environ['RELEASE_NEEDS'])
outputs = needs['validate'].get('outputs', {})
selected = {group: outputs.get(group) == 'true' for group in JOB_GROUPS}
require_success(needs, phase_jobs(selected, os.environ['RELEASE_PHASE']))
require_success(needs, phase_jobs(selected, os.environ['RELEASE_PHASE'],
skip_tests=os.environ['SKIP_TESTS'] == 'true'))
PY
publish-canary:
+7
View File
@@ -34,6 +34,11 @@ on:
required: false
type: string
default: ''
skip-tests:
description: "Release test phase only: build and archive the image without its integration tests (the claim skipped tests)."
required: false
type: boolean
default: false
outputs:
manifest-digest:
description: "Immutable digest of the published versioned multi-arch manifest."
@@ -228,6 +233,7 @@ jobs:
run: mv install-stamp.json "$RUNNER_TEMP/install-stamp.json"
- name: Set up locked Python and test dependencies
if: inputs.skip-tests != true
uses: ./.github/actions/setup-pm
with:
extras: '[]'
@@ -238,6 +244,7 @@ jobs:
run: mv "$RUNNER_TEMP/install-stamp.json" install-stamp.json
- name: Run docker integration tests
if: inputs.skip-tests != true
env:
# Skip rebuild; use the image already loaded by the build step.
HERMES_TEST_IMAGE: ${{ env.IMAGE_NAME }}:test
+58 -2
View File
@@ -38,6 +38,8 @@ jobs:
version: ${{ steps.admit.outputs.version }}
release-id: ${{ steps.admit.outputs.release-id }}
release-epoch: ${{ steps.admit.outputs.release-epoch }}
skip-bundles: ${{ steps.admit.outputs.skip-bundles }}
skip-tests: ${{ steps.admit.outputs.skip-tests }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
@@ -55,6 +57,7 @@ jobs:
ci:
name: Full CI pipeline
needs: admit
if: needs.admit.outputs.skip-tests != 'true'
permissions:
contents: read
pull-requests: write
@@ -67,9 +70,15 @@ jobs:
docker:
name: Docker build and tests
needs: [admit, ci]
# The image publish-docker pushes is built here, so a claim that skipped
# tests still runs this job with its tests off.
if: >-
!cancelled() && needs.admit.result == 'success'
&& (needs.ci.result == 'success' || needs.admit.outputs.skip-tests == 'true')
uses: ./.github/workflows/docker.yml
with:
release-phase: test
skip-tests: ${{ needs.admit.outputs.skip-tests == 'true' }}
tag: ${{ needs.admit.outputs.tag }}
version: ${{ needs.admit.outputs.version }}
@@ -82,6 +91,8 @@ jobs:
pm-bundle:
needs: [admit, ci, docker]
# Needing ci already removes it under skip-tests.
if: needs.admit.outputs.skip-bundles != 'true'
uses: ./.github/workflows/pm-bundle.yml
with:
release: true
@@ -118,6 +129,10 @@ jobs:
candidates-darwin-arm64:
name: Build signed release candidates (darwin-arm64)
needs: [admit, ci, docker]
if: >-
!cancelled() && needs.admit.result == 'success' && needs.admit.outputs.skip-bundles != 'true'
&& needs.docker.result == 'success'
&& (needs.ci.result == 'success' || needs.admit.outputs.skip-tests == 'true')
permissions:
contents: write
actions: read
@@ -130,10 +145,15 @@ jobs:
claim-object: ${{ needs.admit.outputs.claim-object }}
release-phase: candidate
jobs: darwin-arm64
skip-tests: ${{ needs.admit.outputs.skip-tests == 'true' }}
candidates-darwin-x64:
name: Build signed release candidates (darwin-x64)
needs: [admit, ci, docker]
if: >-
!cancelled() && needs.admit.result == 'success' && needs.admit.outputs.skip-bundles != 'true'
&& needs.docker.result == 'success'
&& (needs.ci.result == 'success' || needs.admit.outputs.skip-tests == 'true')
permissions:
contents: write
actions: read
@@ -146,10 +166,15 @@ jobs:
claim-object: ${{ needs.admit.outputs.claim-object }}
release-phase: candidate
jobs: darwin-x64
skip-tests: ${{ needs.admit.outputs.skip-tests == 'true' }}
candidates-win32-arm64:
name: Build signed release candidates (win32-arm64)
needs: [admit, ci, docker]
if: >-
!cancelled() && needs.admit.result == 'success' && needs.admit.outputs.skip-bundles != 'true'
&& needs.docker.result == 'success'
&& (needs.ci.result == 'success' || needs.admit.outputs.skip-tests == 'true')
permissions:
contents: write
actions: read
@@ -162,10 +187,15 @@ jobs:
claim-object: ${{ needs.admit.outputs.claim-object }}
release-phase: candidate
jobs: win32-arm64
skip-tests: ${{ needs.admit.outputs.skip-tests == 'true' }}
candidates-win32-x64:
name: Build signed release candidates (win32-x64)
needs: [admit, ci, docker]
if: >-
!cancelled() && needs.admit.result == 'success' && needs.admit.outputs.skip-bundles != 'true'
&& needs.docker.result == 'success'
&& (needs.ci.result == 'success' || needs.admit.outputs.skip-tests == 'true')
permissions:
contents: write
actions: read
@@ -178,10 +208,17 @@ jobs:
claim-object: ${{ needs.admit.outputs.claim-object }}
release-phase: candidate
jobs: win32-x64
skip-tests: ${{ needs.admit.outputs.skip-tests == 'true' }}
candidates-win32-bundle:
name: Build signed release candidates (win32-bundle)
needs: [admit, ci, docker, candidates-win32-arm64, candidates-win32-x64]
if: >-
!cancelled() && needs.admit.result == 'success' && needs.admit.outputs.skip-bundles != 'true'
&& needs.docker.result == 'success'
&& (needs.ci.result == 'success' || needs.admit.outputs.skip-tests == 'true')
&& needs.candidates-win32-arm64.result == 'success'
&& needs.candidates-win32-x64.result == 'success'
permissions:
contents: write
actions: read
@@ -194,10 +231,15 @@ jobs:
claim-object: ${{ needs.admit.outputs.claim-object }}
release-phase: candidate
jobs: win32-bundle
skip-tests: ${{ needs.admit.outputs.skip-tests == 'true' }}
candidates-termux:
name: Build signed release candidates (termux)
needs: [admit, ci, docker]
if: >-
!cancelled() && needs.admit.result == 'success' && needs.admit.outputs.skip-bundles != 'true'
&& needs.docker.result == 'success'
&& (needs.ci.result == 'success' || needs.admit.outputs.skip-tests == 'true')
permissions:
contents: write
actions: read
@@ -210,10 +252,12 @@ jobs:
claim-object: ${{ needs.admit.outputs.claim-object }}
release-phase: candidate
jobs: termux
skip-tests: ${{ needs.admit.outputs.skip-tests == 'true' }}
transitions-darwin-arm64:
name: Pin actual OLD and NEW signed packages (darwin-arm64)
needs: [admit, candidates-darwin-arm64]
if: needs.admit.outputs.skip-tests != 'true'
runs-on: ubuntu-24.04
environment: release-signing
outputs:
@@ -247,6 +291,7 @@ jobs:
transitions-darwin-x64:
name: Pin actual OLD and NEW signed packages (darwin-x64)
needs: [admit, candidates-darwin-x64]
if: needs.admit.outputs.skip-tests != 'true'
runs-on: ubuntu-24.04
environment: release-signing
outputs:
@@ -280,6 +325,7 @@ jobs:
transitions-win32:
name: Pin actual OLD and NEW signed packages (win32)
needs: [admit, candidates-win32-bundle]
if: needs.admit.outputs.skip-tests != 'true'
runs-on: ubuntu-24.04
environment: release-signing
outputs:
@@ -316,7 +362,7 @@ jobs:
# The recorded smoke results are the calls' own workflow results: a
# call's stable-phase-result fails when its smokes fail, so a failed
# smoke fails this job's RELEASE_NEEDS check and stages no manifest.
if: always()
if: always() && needs.admit.outputs.skip-bundles != 'true'
needs: [admit, candidates-darwin-arm64, candidates-darwin-x64, candidates-win32-arm64,
candidates-win32-x64, candidates-win32-bundle, candidates-termux]
runs-on: ubuntu-24.04
@@ -398,6 +444,7 @@ jobs:
bootstrap-version:
name: Bootstrap installer release identity
needs: admit
if: needs.admit.outputs.skip-tests != 'true'
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -448,6 +495,8 @@ jobs:
- run: python -m scripts.releases.stable gate admit ci docker nix pm-bundle termux-checks windows-live install-e2e candidates-darwin-arm64 candidates-darwin-x64 candidates-win32-arm64 candidates-win32-x64 candidates-win32-bundle candidates-termux candidate-manifest transitions-darwin-arm64 transitions-darwin-x64 transitions-win32 windows-packaged macos-packaged-arm64 macos-packaged-x64 bootstrap-version
env:
RELEASE_NEEDS: ${{ toJSON(needs) }}
SKIP_BUNDLES: ${{ needs.admit.outputs.skip-bundles }}
SKIP_TESTS: ${{ needs.admit.outputs.skip-tests }}
publish-docker:
name: Publish tested Docker image
@@ -463,6 +512,7 @@ jobs:
publish-bundles:
name: Publish tested bundle artifacts
needs: [admit, acceptance, candidate-manifest]
if: needs.admit.outputs.skip-bundles != 'true'
permissions:
contents: write
actions: read
@@ -479,7 +529,7 @@ jobs:
publication:
name: All artifact publication succeeded
if: always()
needs: [acceptance, publish-docker, publish-bundles]
needs: [admit, acceptance, publish-docker, publish-bundles]
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -491,6 +541,8 @@ jobs:
- run: python -m scripts.releases.stable gate acceptance publish-docker publish-bundles
env:
RELEASE_NEEDS: ${{ toJSON(needs) }}
SKIP_BUNDLES: ${{ needs.admit.outputs.skip-bundles }}
SKIP_TESTS: ${{ needs.admit.outputs.skip-tests }}
complete:
name: Stable release is green
@@ -518,7 +570,10 @@ jobs:
- run: python -m scripts.releases.stable gate admit ci docker acceptance candidate-manifest publication publish-docker
env:
RELEASE_NEEDS: ${{ toJSON(needs) }}
SKIP_BUNDLES: ${{ needs.admit.outputs.skip-bundles }}
SKIP_TESTS: ${{ needs.admit.outputs.skip-tests }}
- name: Validate the accepted candidate archive
if: needs.admit.outputs.skip-bundles != 'true'
run: python -m scripts.releases.stable complete
env:
RELEASE_TAG: ${{ needs.admit.outputs.tag }}
@@ -532,6 +587,7 @@ jobs:
CLOUDFLARE_R2_BUCKET: ${{ vars.CLOUDFLARE_R2_BUCKET }}
CLOUDFLARE_R2_PUBLIC_URL: ${{ vars.CLOUDFLARE_R2_PUBLIC_URL }}
- name: Render the admitted candidate smoke results
if: needs.admit.outputs.skip-bundles != 'true'
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ needs.admit.outputs.tag }}
+2 -2
View File
@@ -14,7 +14,7 @@ import xml.etree.ElementTree as ET
import zipfile
from pathlib import Path
from scripts.releases.stable import read_admitted_candidate, successful_smoke_results, validate_candidates
from scripts.releases.stable import read_admitted_candidate, accepted_smoke_results, validate_candidates
def sha256_file(file: Path) -> str:
@@ -165,7 +165,7 @@ def assemble(root: Path, tag: str, commit: str, public_base: str, out: Path,
from scripts.releases.handoff import receipt_name, validate_receipt
from scripts.releases.r2 import put, staging_key_for
smoke_results = successful_smoke_results(smoke_results)
smoke_results = accepted_smoke_results(smoke_results)
expected = ("win32-x64", "win32-arm64", "darwin-x64", "darwin-arm64", "termux", "windows-universal")
by_name = {}
for name in expected:
+8
View File
@@ -2742,6 +2742,14 @@ def main():
release_cmd.add_argument("--bump", choices=["major", "minor", "patch"], default="patch")
release_cmd.add_argument("--autopublish", action="store_true",
help="Publish on green instead of leaving a draft")
release_cmd.add_argument("--skip-bundles", action="store_true",
help="Release only the tag and the Docker image: no desktop, Termux or "
"PM bundle builds, smokes, feeds or Store check. The desktop update "
"channel stays on the previous bundle release.")
release_cmd.add_argument("--skip-tests", action="store_true",
help="Emergency release: skip CI, Nix, PM bundle, install/update E2E, "
"Termux, Windows, native smoke and upgrade acceptance jobs. "
"Artifacts still build and publish.")
# SUPPRESS keeps a --no-changelog given before the subcommand from being
# reset by this parser's default.
release_cmd.add_argument("--no-changelog", action="store_true", default=argparse.SUPPRESS,
+11 -5
View File
@@ -183,7 +183,7 @@ def admit_transaction(policy: str, env: dict, *, require_published: bool = False
def accepted_stable(publisher: ChannelPublisher, env: dict, tag: str, commit: str,
release_epoch: int) -> dict:
release_epoch: int, *, skip_tests: bool) -> dict:
"""Read the accepted candidate from the attempt-scoped release archive."""
from hermes_cli.release_channels import decode_json, require_sha256
@@ -196,6 +196,7 @@ def accepted_stable(publisher: ChannelPublisher, env: dict, tag: str, commit: st
candidate = decode_json(publisher.reader.read_bytes(key, digest))
stable.validate_candidates(candidate, payload_tag, commit, publisher.public_base, release_epoch,
archive=tag)
stable.require_smokes_match_claim(candidate, skip_tests=skip_tests)
return candidate
@@ -345,10 +346,14 @@ def publish_release(policy: str, env: dict, root: Path) -> dict:
identity["token"] = current[0]["identity"]["token"]
if identity != current[0]["identity"]:
raise ChannelError("Protected R2 identity differs from the existing product")
release_epoch = stable.final_context({**env, "RELEASE_TAG": payload_tag})[2]["claim_epoch"] \
final_claim = stable.final_context({**env, "RELEASE_TAG": payload_tag})[2] \
if policy == "stable-release" else None
accepted = accepted_stable(publisher, env, tag, commit, release_epoch) \
if release_epoch is not None else None
if final_claim is not None and final_claim["skip_bundles"]:
raise ChannelError("A release that skipped bundles has no native bytes to advance a protected head")
release_epoch = final_claim["claim_epoch"] if final_claim is not None else None
accepted = accepted_stable(publisher, env, tag, commit, release_epoch,
skip_tests=final_claim["skip_tests"]) \
if final_claim is not None else None
# Native handoffs were staged under the attempt ref for stable attempts
# (identical for canary, where tag == payload_tag).
handoff.fetch(tag, commit, list(NATIVE_LEGS), root,
@@ -371,7 +376,8 @@ def publish_release(policy: str, env: dict, root: Path) -> dict:
if policy == "stable-release":
if release_epoch is None:
raise ChannelError("Stable release epoch is unavailable")
return accepted_stable(publisher, env, tag, commit, release_epoch) == accepted
return accepted_stable(publisher, env, tag, commit, release_epoch,
skip_tests=final_claim["skip_tests"]) == accepted
return True
source_version = payload_tag[1:].split("+", 1)[0]
+11
View File
@@ -147,6 +147,17 @@ def promote_stable(tag: str, digest: str, *, run=output, sleep=time.sleep) -> No
raise DockerReleaseError(f"Docker {alias}{suffix} alias read-back mismatch")
def stable_alias_digest(run=output) -> str | None:
"""The slim ``stable`` alias digest, or None when the alias does not exist yet."""
try:
digest = _inspect(f"{IMAGE}:stable", run)
except subprocess.CalledProcessError:
return None
if not re.fullmatch(r"sha256:[a-f0-9]{64}", digest):
raise DockerReleaseError(f"Docker stable alias digest is invalid: {digest!r}")
return digest
def published_digest(tag: str, run=output) -> str:
"""Read both attempt images; return the slim digest bound to the release receipt.
+20 -2
View File
@@ -179,11 +179,13 @@ def _changelog(repo: Path, repository: str, *, commit: str, tag: str, version: s
def release(commit: str, *, bump: str, repo: Path, remote: str, repository: str,
execute, autopublish: bool = False, no_changelog: bool = False,
skip_bundles: bool = False, skip_tests: bool = False,
published: tuple[str, str | None] = (SEED, None)) -> dict:
"""Claim the next attempt of the derived version, cut its draft, and start the gate.
``published`` is the stable channel's ``(version, commit)``; the commit is
None before the first publication.
None before the first publication. ``skip_bundles`` and ``skip_tests`` are
written into the claim, which is the one record every later job reads.
"""
_refresh_claims(repo, remote)
_require_remote_main(repo, commit)
@@ -194,6 +196,12 @@ def release(commit: str, *, bump: str, repo: Path, remote: str, repository: str,
published_version, published_commit = published
_require_ancestry(repo, commit, published_commit)
version = derive_next_version(published=published_version, bump=bump)
if _git(repo, "ls-remote", remote, f"refs/tags/v{version}"):
# A final tag records a started publication. Until its GitHub release is
# public the published identity still names the previous version.
raise ReleaseRefused(
f"v{version} already has a final tag. Its publication is still finishing. "
"Wait for Stable Release Publication, then cut again.")
attempt = next_attempt(version, _claims(repo))
tag = attempt_ref(version, attempt)
# Built before the claim: a body GitHub refuses would otherwise burn the attempt.
@@ -212,6 +220,8 @@ def release(commit: str, *, bump: str, repo: Path, remote: str, repository: str,
"attempt": attempt,
"commit": commit,
"autopublish": autopublish,
"skipBundles": skip_bundles,
"skipTests": skip_tests,
"claimEpoch": claim_epoch,
}, sort_keys=True, separators=(",", ":"))
subprocess.check_output(
@@ -266,7 +276,8 @@ def release(commit: str, *, bump: str, repo: Path, remote: str, repository: str,
run_url = _dispatched_run(found, tag)
return {"version": version, "tag": tag, "commit": commit, "url": url,
"final_url": f"https://github.com/{repository}/releases/tag/v{version}",
"run_url": run_url, "autopublish": autopublish}
"run_url": run_url, "autopublish": autopublish,
"skip_bundles": skip_bundles, "skip_tests": skip_tests}
def _dispatched_run(raw: str, tag: str) -> str:
@@ -388,6 +399,12 @@ def next_steps(result: dict) -> str:
"The draft exists now. Edit its notes while the workflow runs; the edits carry through to publication.",
"Wait for that workflow to finish. It builds and tests this commit.",
]
if result["skip_bundles"]:
lines.append("Bundles are skipped. Only the tag, the GitHub release and the Docker image "
"ship; the desktop, Termux and Store channels stay on the previous release.")
if result["skip_tests"]:
lines.append("Tests are skipped. No CI, E2E, native smoke or upgrade acceptance job runs. "
"The build is published untested.")
if result["autopublish"]:
lines.append("Autopublish is on. A green workflow publishes the release. You do not run publish.")
else:
@@ -419,6 +436,7 @@ def cmd_release(args) -> None:
result = release(
commit, bump=args.bump, repo=repo, remote=remote, repository=repository,
execute=execute, autopublish=args.autopublish, no_changelog=args.no_changelog,
skip_bundles=args.skip_bundles, skip_tests=args.skip_tests,
published=published_stable_identity(repository),
)
print(next_steps(result))
+17 -6
View File
@@ -18,13 +18,21 @@ ALL_JOBS = ",".join(JOB_GROUPS)
# declares. `phase_jobs` turns a selection into the list the stable phase
# result requires.
GROUP_JOBS = {
"darwin-arm64": ("build-darwin-arm64", "smoke-darwin-arm64"),
"darwin-x64": ("build-darwin-x64", "smoke-darwin-x64"),
"win32-arm64": ("build-win32-arm64", "smoke-win32-arm64"),
"win32-x64": ("build-win32-x64", "smoke-win32-x64"),
"darwin-arm64": ("build-darwin-arm64",),
"darwin-x64": ("build-darwin-x64",),
"win32-arm64": ("build-win32-arm64",),
"win32-x64": ("build-win32-x64",),
"win32-bundle": ("assemble-win32-bundle",),
"termux": ("termux-deb",),
}
# The native smoke each group runs after its build. A claim that skipped
# tests runs none of them.
GROUP_SMOKES = {
"darwin-arm64": ("smoke-darwin-arm64",),
"darwin-x64": ("smoke-darwin-x64",),
"win32-arm64": ("smoke-win32-arm64",),
"win32-x64": ("smoke-win32-x64",),
}
def parse_jobs(raw: str | None) -> dict[str, bool]:
@@ -55,11 +63,12 @@ def selects_all(raw: str | None) -> bool:
return all(parse_jobs(raw).values())
def phase_jobs(selected: dict[str, bool], phase: str) -> list[str]:
def phase_jobs(selected: dict[str, bool], phase: str, *, skip_tests: bool = False) -> list[str]:
"""Jobs the stable phase result judges: an unselected group never fails it.
B4 moved candidate-manifest into stable-release.yml, so the desktop
workflow no longer owns it and the phase result never names it.
workflow no longer owns it and the phase result never names it. A claim
that skipped tests runs no smoke, so the smokes are not judged.
"""
required = ["validate"]
if phase == "publish":
@@ -69,6 +78,8 @@ def phase_jobs(selected: dict[str, bool], phase: str) -> list[str]:
for group, jobs in GROUP_JOBS.items():
if selected.get(group):
required.extend(jobs)
if not skip_tests:
required.extend(GROUP_SMOKES.get(group, ()))
return required
+42 -35
View File
@@ -8,7 +8,6 @@ from __future__ import annotations
import json
import os
import re
import subprocess
import sys
import tempfile
@@ -21,8 +20,6 @@ from scripts.releases.versioning import (
version_from_tag,
)
SHA256 = re.compile(r"[a-f0-9]{64}")
DOCKER_DIGEST = re.compile(r"sha256:[a-f0-9]{64}")
MAX_ATTEMPTS = 3
CLAIM_GRACE = timedelta(hours=1)
@@ -214,7 +211,7 @@ def _tag_message(tag: str, expected_object: str, run=output) -> dict:
def discover(repository: str, run=output) -> list[dict]:
"""Derive every stable claim state from remote refs and GitHub objects."""
from scripts.releases.stable import tagger_epoch
from scripts.releases.stable import tagger_epoch, validate_claim, validate_final
run([
"git", "fetch", "origin", "+refs/tags/v*:refs/tags/v*",
@@ -240,14 +237,11 @@ def discover(repository: str, run=output) -> list[dict]:
tag = f"v{version}"
commit = claim_ref["commit"]
claim = _tag_message(claim_tag, claim_ref["object"], run)
claim_epoch = claim.get("claimEpoch")
expected_claim = {
"schema": 1, "version": version, "attempt": attempt, "commit": commit,
"autopublish": claim["autopublish"], "claimEpoch": claim_epoch,
}
if (claim != expected_claim or not isinstance(claim["autopublish"], bool)
or not isinstance(claim_epoch, int) or claim_epoch <= 0):
raise ValueError(f"{claim_tag} metadata is invalid")
try:
validate_claim(claim, version=version, attempt=attempt, commit=commit)
except ValueError as error:
raise ValueError(f"{claim_tag} metadata is invalid") from error
claim_epoch = claim["claimEpoch"]
if tagger_epoch(claim_ref["object"], run) != claim_epoch:
raise ValueError(f"{claim_tag} epoch differs from its annotated tagger timestamp")
@@ -266,21 +260,11 @@ def discover(repository: str, run=output) -> list[dict]:
if final_ref["commit"] != commit:
raise ValueError(f"{tag} points at a different commit than {claim_tag}")
final = _tag_message(tag, final_ref["object"], run)
expected_final = {
"schema": 1, "version": version, "commit": commit,
"claimTag": claim_tag, "claimTagObject": claim_ref["object"],
"autopublish": claim["autopublish"],
"claimEpoch": claim_epoch,
"releaseId": final.get("releaseId"),
"candidateManifestSha256": final.get("candidateManifestSha256"),
"dockerManifestDigest": final.get("dockerManifestDigest"),
"archive": f"releases/tag/{claim_tag}/",
}
if (final != expected_final
or not isinstance(final["releaseId"], int) or final["releaseId"] <= 0
or not SHA256.fullmatch(final["candidateManifestSha256"] or "")
or not DOCKER_DIGEST.fullmatch(final["dockerManifestDigest"] or "")):
raise ValueError(f"{tag} metadata differs from {claim_tag}")
try:
validate_final(final, version=version, commit=commit, claim_tag=claim_tag,
claim_object=claim_ref["object"], claim=claim)
except ValueError as error:
raise ValueError(f"{tag} metadata differs from {claim_tag}") from error
if release is None or release.get("id") != final["releaseId"]:
state, needs_retarget = "burned", False
else:
@@ -306,6 +290,8 @@ def discover(repository: str, run=output) -> list[dict]:
"attempt": attempt,
"state": state,
"autopublish": claim["autopublish"],
"skip_bundles": claim["skipBundles"],
"skip_tests": claim["skipTests"],
"claim_tag": claim_tag,
"claim_object": claim_ref["object"],
"claim_epoch": claim_epoch,
@@ -321,6 +307,22 @@ def discover(repository: str, run=output) -> list[dict]:
return sorted(records, key=lambda record: _key(record["version"]))
def channel_head(desktop_head: str | None, records: list[dict],
stable_alias_digest: str | None) -> str | None:
"""The newest version whose publication pass finished.
A bundle release finishes when the protected R2 head names it. A release
that skipped bundles never moves that head. It finishes when the Docker
``stable`` alias carries the digest its final receipt binds.
"""
versions = [desktop_head] if desktop_head is not None else []
if stable_alias_digest is not None:
versions += [record["version"] for record in records
if record["state"] == "published"
and record["docker_manifest_digest"] == stable_alias_digest]
return max(versions, key=_key, default=None)
def reconcile(env: dict, *, run=output, read_head=None, advance_head=None,
read_archive=None) -> list[dict]:
"""Converge GitHub publication and protected heads oldest-first."""
@@ -362,7 +364,9 @@ def reconcile(env: dict, *, run=output, read_head=None, advance_head=None,
if any(record["needs_retarget"] for record in records):
records = discover(repository, run)
read_head = read_head or (lambda: channel_releases.stable_head_version(env))
read_head = read_head or (lambda: channel_head(
channel_releases.stable_head_version(env), discover(repository, run),
docker.stable_alias_digest()))
head = read_head()
requested = env.get("REQUESTED_VERSION") or None
steps = plan(records, head=head, requested_version=requested)
@@ -370,16 +374,19 @@ def reconcile(env: dict, *, run=output, read_head=None, advance_head=None,
if advance_head is None:
def production_advance(record: dict) -> None:
with tempfile.TemporaryDirectory() as directory:
channel_releases.advance_stable(env, record, Path(directory))
if not record["skip_bundles"]:
with tempfile.TemporaryDirectory() as directory:
channel_releases.advance_stable(env, record, Path(directory))
# The stable/latest aliases move onto the attempt's image here, in
# the publication pass with the feed pointer, never in the green
# build that pushed the image under the attempt ref.
# build that pushed the image under the attempt ref. A release
# that skipped bundles moves only these aliases.
docker.promote_stable(record["claim_tag"], record["docker_manifest_digest"])
# The Store check joins the pass here (after the feeds and aliases
# move). It never releases the held submission: the API cannot,
# so it prints the Publish now step. A failed submission is red.
store.check_from_env(env)
if not record["skip_bundles"]:
# The Store check joins the pass here (after the feeds and aliases
# move). It never releases the held submission: the API cannot,
# so it prints the Publish now step. A failed submission is red.
store.check_from_env(env)
advance_head = production_advance
for step in steps:
+151 -40
View File
@@ -62,18 +62,72 @@ def require_success(needs: dict, required: list[str]) -> None:
raise ValueError("Release blocked: " + ", ".join(failures))
def successful_smoke_results(needs: object) -> dict:
"""Persist only observed successful native groups, never infer them from artifacts."""
# The claim flags that remove stable-release.yml jobs, per job. A job one of
# the claim's active flags removes must report `skipped`. Every other gated job
# must report `success`. Jobs not listed here never skip.
CLAIM_FLAGS = ("autopublish", "skipBundles", "skipTests")
_TESTS, _BUNDLES = frozenset({"skipTests"}), frozenset({"skipBundles"})
SKIPPED_BY = {
**{job: _TESTS for job in ("ci", "nix", "termux-checks", "windows-live", "install-e2e",
"bootstrap-version")},
**{job: _BUNDLES for job in ("candidates-darwin-arm64", "candidates-darwin-x64",
"candidates-win32-arm64", "candidates-win32-x64",
"candidates-win32-bundle", "candidates-termux",
"candidate-manifest", "publish-bundles")},
# Bundle acceptance is a test of bundles, so either flag removes it.
**{job: _TESTS | _BUNDLES for job in ("pm-bundle", "transitions-darwin-arm64",
"transitions-darwin-x64", "transitions-win32",
"windows-packaged", "macos-packaged-arm64",
"macos-packaged-x64")},
}
def gate_expectations(required: list[str], *, skip_bundles: bool, skip_tests: bool) -> dict:
"""Each gated job's required result under the claim's flags."""
active = {flag for flag, on in (("skipBundles", skip_bundles), ("skipTests", skip_tests)) if on}
return {name: "skipped" if SKIPPED_BY.get(name, frozenset()) & active else "success"
for name in required}
def require_gate(needs: dict, required: list[str], *, skip_bundles: bool, skip_tests: bool) -> None:
"""``require_success`` that also demands a flag-removed job really was skipped."""
if not required or len(set(required)) != len(required):
raise ValueError("Invalid required-job list")
expected = gate_expectations(required, skip_bundles=skip_bundles, skip_tests=skip_tests)
failures = [f"{name}={needs.get(name, {}).get('result', 'missing')} (expected {want})"
for name, want in expected.items() if needs.get(name, {}).get("result") != want]
if failures:
raise ValueError("Release blocked: " + ", ".join(failures))
def accepted_smoke_results(needs: object) -> dict:
"""Persist only observed native groups, never infer them from artifacts.
Every group passed, or every group was skipped. Only a claim that skipped
tests may produce the second shape. ``smokes_skipped`` lets claim-aware
readers check that.
"""
if not isinstance(needs, dict):
raise ValueError("Candidate smoke results must be a job-result object")
results = {}
for job in SMOKE_JOBS:
row = needs.get(job)
results[job] = {"result": row.get("result") if isinstance(row, dict) else None}
require_success(results, list(SMOKE_JOBS))
if {row["result"] for row in results.values()} != {"skipped"}:
require_success(results, list(SMOKE_JOBS))
return results
def smokes_skipped(manifest: dict) -> bool:
results = manifest.get("smoke_results") or {}
return all((results.get(job) or {}).get("result") == "skipped" for job in SMOKE_JOBS)
def require_smokes_match_claim(manifest: dict, *, skip_tests: bool) -> None:
if smokes_skipped(manifest) != skip_tests:
raise ValueError("Candidate smoke results differ from the claim's test policy")
def stable_windows_version(epoch: object) -> str:
if isinstance(epoch, bool) or not isinstance(epoch, int) or epoch < 0:
raise ValueError("Stable release epoch must be a non-negative integer")
@@ -159,7 +213,7 @@ def validate_candidates(manifest: dict, tag: str, commit: str, public_base: str,
never is.
"""
rows = _validated_rows(manifest, tag, commit, public_base, release_epoch, archive=archive)
successful_smoke_results(manifest.get("smoke_results"))
accepted_smoke_results(manifest.get("smoke_results"))
if any(target not in rows for target in DESKTOP_TARGETS):
raise ValueError("Candidate manifest must cover Windows and macOS on both architectures")
return rows
@@ -331,20 +385,54 @@ def output(argv: list[str]) -> str:
return subprocess.check_output(argv, text=True, encoding="utf-8").strip()
def validate_claim(metadata: object, *, version: str, attempt: int, commit: str) -> dict:
"""The claim message's exact shape. It is the one record of the attempt's policy."""
expected = {"schema": 1, "version": version, "attempt": attempt, "commit": commit}
if (not isinstance(metadata, dict)
or any(metadata.get(key) != value for key, value in expected.items())
or any(not isinstance(metadata.get(flag), bool) for flag in CLAIM_FLAGS)
or not isinstance(metadata.get("claimEpoch"), int)
or metadata["claimEpoch"] <= 0
or set(metadata) != {*expected, *CLAIM_FLAGS, "claimEpoch"}):
raise ValueError("Stable claim metadata is invalid")
return metadata
def _claim_metadata(raw: str, *, version: str, attempt: int, commit: str) -> dict:
try:
metadata = json.loads(raw)
except (TypeError, json.JSONDecodeError) as error:
raise ValueError("Stable claim metadata is invalid") from error
expected = {"schema": 1, "version": version, "attempt": attempt, "commit": commit}
if (not isinstance(metadata, dict)
or any(metadata.get(key) != value for key, value in expected.items())
or not isinstance(metadata.get("autopublish"), bool)
or not isinstance(metadata.get("claimEpoch"), int)
or metadata["claimEpoch"] <= 0
or set(metadata) != {*expected, "autopublish", "claimEpoch"}):
raise ValueError("Stable claim metadata is invalid")
return metadata
return validate_claim(metadata, version=version, attempt=attempt, commit=commit)
def validate_final(final: object, *, version: str, commit: str, claim_tag: str,
claim_object: str, claim: dict) -> dict:
"""The final receipt tag's exact shape, bound to its validated claim.
A claim that skipped bundles has no candidate manifest, so its receipt
records ``candidateManifestSha256: null``. Every other receipt pins one.
"""
if not isinstance(final, dict):
raise ValueError("Final tag metadata differs from its claim")
expected = {
"schema": 1, "version": version, "commit": commit,
"claimTag": claim_tag, "claimTagObject": claim_object,
"autopublish": claim["autopublish"],
"claimEpoch": claim["claimEpoch"],
"releaseId": final.get("releaseId"),
"candidateManifestSha256": final.get("candidateManifestSha256"),
"dockerManifestDigest": final.get("dockerManifestDigest"),
"archive": f"releases/tag/{claim_tag}/",
}
manifest = final.get("candidateManifestSha256")
manifest_ok = manifest is None if claim["skipBundles"] else bool(DIGEST.fullmatch(manifest or ""))
if (final != expected
or not isinstance(final["releaseId"], int) or final["releaseId"] <= 0
or not manifest_ok
or not re.fullmatch(r"sha256:[a-f0-9]{64}", final["dockerManifestDigest"] or "")):
raise ValueError("Final tag metadata differs from its claim")
return final
def tagger_epoch(tag_object: str, run=output) -> int:
@@ -400,7 +488,8 @@ def check_claim(env: dict, run=output) -> dict:
if metadata["claimEpoch"] != claim_epoch:
raise ValueError("Stable claim epoch differs from its annotated tagger timestamp")
return {**admitted, "claim_object": local_object,
"autopublish": metadata["autopublish"], "claim_epoch": claim_epoch}
"autopublish": metadata["autopublish"], "skip_bundles": metadata["skipBundles"],
"skip_tests": metadata["skipTests"], "claim_epoch": claim_epoch}
def stable_context(env: dict, run=output) -> tuple[str, str, dict]:
@@ -448,22 +537,11 @@ def final_context(env: dict, run=output) -> tuple[str, str, dict]:
run(["git", "tag", "-l", claim_tag, "--format=%(contents)"]),
version=admitted["version"], attempt=admitted["attempt"], commit=commit,
)
final = json.loads(run(["git", "tag", "-l", tag, "--format=%(contents)"]))
expected = {
"schema": 1, "version": admitted["version"], "commit": commit,
"claimTag": claim_tag, "claimTagObject": claim_object,
"autopublish": claim["autopublish"],
"claimEpoch": claim["claimEpoch"],
"releaseId": final.get("releaseId"),
"candidateManifestSha256": final.get("candidateManifestSha256"),
"dockerManifestDigest": final.get("dockerManifestDigest"),
"archive": f"releases/tag/{claim_tag}/",
}
if (final != expected
or not isinstance(final["releaseId"], int) or final["releaseId"] <= 0
or not DIGEST.fullmatch(final["candidateManifestSha256"] or "")
or not re.fullmatch(r"sha256:[a-f0-9]{64}", final["dockerManifestDigest"] or "")):
raise ValueError("Final tag metadata differs from its claim")
final = validate_final(
json.loads(run(["git", "tag", "-l", tag, "--format=%(contents)"])),
version=admitted["version"], commit=commit, claim_tag=claim_tag,
claim_object=claim_object, claim=claim,
)
release = json.loads(run([
"gh", "api", f"repos/{repository}/releases/tags/{tag}",
]))
@@ -473,6 +551,8 @@ def final_context(env: dict, run=output) -> tuple[str, str, dict]:
raise ValueError("Stable channel requires the published final release")
return tag, commit, {**admitted, "claim_object": claim_object,
"autopublish": claim["autopublish"],
"skip_bundles": claim["skipBundles"],
"skip_tests": claim["skipTests"],
"claim_epoch": claim["claimEpoch"],
"release_id": final["releaseId"],
"candidate_manifest_sha256": final["candidateManifestSha256"],
@@ -524,10 +604,15 @@ def admit(env: dict) -> None:
"claim-tag": admitted["claim_tag"], "claim-object": admitted["claim_object"],
"tag": admitted["tag"], "commit": admitted["commit"], "version": admitted["version"],
"release-id": release["databaseId"], "release-epoch": admitted["claim_epoch"],
"skip-bundles": "true" if admitted["skip_bundles"] else "false",
"skip-tests": "true" if admitted["skip_tests"] else "false",
}, env)
skipped = [name for name, on in (("bundles", admitted["skip_bundles"]),
("tests", admitted["skip_tests"])) if on]
summary(
f"## Stable candidate {admitted['claim_tag']}\nCommit: {admitted['commit']}\n"
f"Version: {admitted['version']}\nPayload tag: {admitted['tag']}\n",
f"Version: {admitted['version']}\nPayload tag: {admitted['tag']}\n"
f"Skipped: {', '.join(skipped) or 'nothing'}\n",
env,
)
@@ -621,9 +706,18 @@ def candidate_manifest(env: dict) -> None:
needs = json.loads(env.get("RELEASE_NEEDS", "{}"))
if not isinstance(needs, dict):
raise ValueError("Candidate call results must be a needs object")
smoke = {job: {"result": (needs.get(call) or {}).get("result")}
for call, job in CALL_SMOKE_JOBS.items()}
tag, commit, claim = stable_context(env)
if claim["skip_bundles"]:
raise ValueError("A claim that skipped bundles has no candidate manifest")
if claim["skip_tests"]:
# The calls built and staged their groups but ran no smoke. Record
# that as skipped. Never let a green call stand in for a smoke.
require_success(needs, list(CALL_SMOKE_JOBS))
smoke = {job: {"result": "skipped"} for job in CALL_SMOKE_JOBS.values()}
else:
smoke = {job: {"result": (needs.get(call) or {}).get("result")}
for call, job in CALL_SMOKE_JOBS.items()}
require_success(smoke, list(smoke))
base = env["CLOUDFLARE_R2_PUBLIC_URL"].rstrip("/")
archive = claim["claim_tag"]
with tempfile.TemporaryDirectory() as directory:
@@ -638,9 +732,12 @@ def candidate_manifest(env: dict) -> None:
"manifest-sha256": digest}, env)
def _final_metadata(tag: str, commit: str, claim: dict, candidate_manifest_sha256: str,
def _final_metadata(tag: str, commit: str, claim: dict, candidate_manifest_sha256: str | None,
docker_manifest_digest: str, release_id: int) -> dict:
if not DIGEST.fullmatch(candidate_manifest_sha256):
if claim["skip_bundles"]:
if candidate_manifest_sha256 is not None:
raise ValueError("A claim that skipped bundles cannot bind a candidate manifest")
elif not DIGEST.fullmatch(candidate_manifest_sha256 or ""):
raise ValueError("Final tag candidate manifest digest is invalid")
if not re.fullmatch(r"sha256:[a-f0-9]{64}", docker_manifest_digest):
raise ValueError("Final tag Docker manifest digest is invalid")
@@ -658,7 +755,7 @@ def _final_metadata(tag: str, commit: str, claim: dict, candidate_manifest_sha25
}
def ensure_final_tag(tag: str, commit: str, claim: dict, *, candidate_manifest_sha256: str,
def ensure_final_tag(tag: str, commit: str, claim: dict, *, candidate_manifest_sha256: str | None,
docker_manifest_digest: str, release_id: int, run=output) -> str:
"""Create or verify the immutable annotated final tag."""
require_stable_identity(tag, commit)
@@ -764,11 +861,14 @@ def publish_attempt(record: dict, *, repository: str, run=output, read_archive)
from scripts.releases import docker
claim = {"claim_tag": record["claim_tag"], "claim_object": record["claim_object"],
"autopublish": record["autopublish"], "claim_epoch": record["claim_epoch"]}
manifest = read_archive(f"releases/tag/{record['claim_tag']}/release-candidates.json")
"autopublish": record["autopublish"], "skip_bundles": record["skip_bundles"],
"claim_epoch": record["claim_epoch"]}
# A claim that skipped bundles staged no archive, so its receipt binds no manifest.
manifest_sha256 = None if record["skip_bundles"] else hashlib.sha256(
read_archive(f"releases/tag/{record['claim_tag']}/release-candidates.json")).hexdigest()
docker_digest = docker.published_digest(record["claim_tag"], run)
ensure_final_tag(record["tag"], record["commit"], claim,
candidate_manifest_sha256=hashlib.sha256(manifest).hexdigest(),
candidate_manifest_sha256=manifest_sha256,
docker_manifest_digest=docker_digest,
release_id=record["release_id"], run=run)
edit_draft_release(repository, record["release_id"], record["tag"], record["commit"], run=run)
@@ -779,9 +879,19 @@ def publish_attempt(record: dict, *, repository: str, run=output, read_archive)
def complete(env: dict) -> None:
"""Validate the accepted candidate archive. The final tag moves to publish."""
tag, commit, claim = stable_context(env)
if claim["skip_bundles"]:
raise ValueError("A claim that skipped bundles has no candidate archive to validate")
base = env["CLOUDFLARE_R2_PUBLIC_URL"].rstrip("/")
candidate = read_candidate(env)
validate_candidates(candidate, tag, commit, base, claim["claim_epoch"], archive=claim["claim_tag"])
require_smokes_match_claim(candidate, skip_tests=claim["skip_tests"])
def _flag(env: dict, name: str) -> bool:
value = env.get(name)
if value not in ("true", "false"):
raise ValueError(f"{name} must be the admitted claim's true or false, not {value!r}")
return value == "true"
def main(argv: list[str] | None = None, env: dict | None = None) -> None:
@@ -790,7 +900,8 @@ def main(argv: list[str] | None = None, env: dict | None = None) -> None:
if argv and argv[0] == "gate":
needs = json.loads(env["RELEASE_NEEDS"])
summary("\n".join(f"- {name}: {needs.get(name, {}).get('result', 'missing')}" for name in argv[1:]), env)
require_success(needs, argv[1:])
require_gate(needs, argv[1:], skip_bundles=_flag(env, "SKIP_BUNDLES"),
skip_tests=_flag(env, "SKIP_TESTS"))
return
if argv and argv[0] == "stage-receipt":
if len(argv) != 3 or argv[1] != "--receipt" or argv[2] not in RECEIPT_TARGETS:
+48 -9
View File
@@ -6,9 +6,13 @@ line. CalVer tags, canary identities and receipt namespaces are not versions.
"""
from __future__ import annotations
import json
import re
import subprocess
from functools import cmp_to_key
from hermes_cli.update_channel import STABLE_TAG_RE
from scripts.releases.semver import compare
SEED = "0.21.4"
BUMPS = ("major", "minor", "patch")
@@ -38,22 +42,57 @@ def published_channel_identity(repository: str, channel: str, *, base_url: str |
return version, commit
def published_release_identity(repository: str, run=None) -> tuple[str, str] | None:
"""The newest published stable GitHub release and its commit, or None.
Publication makes a release public only after its final tag and draft edits
read back, so this covers every published release. That includes a release
that skipped bundles, which never moves the protected channel. A bare
``vX.Y.Z`` tag without a published release does not count.
"""
run = run or _gh
pages = json.loads(run(["gh", "api", "--paginate", "--slurp",
f"repos/{repository}/releases?per_page=100"]))
versions = [version for page in pages for row in page
if isinstance(row, dict) and row.get("draft") is False
and row.get("prerelease") is False
and (version := version_from_tag(row.get("tag_name")))]
if not versions:
return None
newest = max(versions, key=cmp_to_key(compare))
commit = run(["gh", "api", f"repos/{repository}/commits/v{newest}", "--jq", ".sha"]).strip()
if not re.fullmatch(r"[a-f0-9]{40}", commit):
raise ValueError(f"v{newest} has no valid release commit")
return newest, commit
def published_stable_identity(repository: str, *, base_url: str | None = None,
reader_type=None) -> tuple[str, str | None]:
"""Resolve the protected stable identity, falling back only before it exists."""
reader_type=None, run=None) -> tuple[str, str | None]:
"""The newest published stable ``(version, commit)``, or the seed before one exists.
The protected channel names the newest release that shipped bundles. The
GitHub releases also name one that skipped them. The newer of the two wins.
"""
found = published_channel_identity(
repository, "stable", base_url=base_url, reader_type=reader_type,
)
if found is None:
return SEED, None
version, commit = found
if version_from_tag("v" + version) is None:
if found is not None and version_from_tag("v" + found[0]) is None:
raise ValueError("Stable channel has an invalid source version")
return version, commit
candidates = [identity for identity in (found, published_release_identity(repository, run))
if identity is not None]
if not candidates:
return SEED, None
return max(candidates, key=cmp_to_key(lambda a, b: compare(a[0], b[0])))
def published_stable_version(repository: str, *, base_url: str | None = None, reader_type=None) -> str:
return published_stable_identity(repository, base_url=base_url, reader_type=reader_type)[0]
def published_stable_version(repository: str, *, base_url: str | None = None, reader_type=None,
run=None) -> str:
return published_stable_identity(repository, base_url=base_url, reader_type=reader_type,
run=run)[0]
def _gh(argv: list[str]) -> str:
return subprocess.check_output(argv, text=True, encoding="utf-8")
def version_from_tag(ref: str) -> str | None:
+11 -2
View File
@@ -319,7 +319,7 @@ def test_stable_phase_and_canary_gates_require_smoke_but_preserve_other_phases(t
'termux': ['termux-deb'],
}
def run_phase(phase, selected, failed=None):
def run_phase(phase, selected, failed=None, skip_tests=False):
needs = {name: {'result': 'success'} for name in jobs['stable-phase-result']['needs']}
needs['validate']['outputs'] = {group: ('true' if group in selected else 'false')
for group in group_jobs}
@@ -327,13 +327,22 @@ def test_stable_phase_and_canary_gates_require_smoke_but_preserve_other_phases(t
if group not in selected:
for member in members:
needs[member]['result'] = 'skipped'
if skip_tests:
for members in group_jobs.values():
for member in members:
if member.startswith('smoke-'):
needs[member]['result'] = 'skipped'
if failed:
needs[failed]['result'] = 'cancelled'
return shell_step(tmp_path, r2_server, 'stable-phase-result', 'Require every phase job',
{'RELEASE_NEEDS': json.dumps(needs), 'RELEASE_PHASE': phase})
{'RELEASE_NEEDS': json.dumps(needs), 'RELEASE_PHASE': phase,
'SKIP_TESTS': 'true' if skip_tests else 'false'})
every = set(group_jobs)
assert run_phase('candidate', every).returncode == 0
# A claim that skipped tests runs no smoke, and every build still counts.
assert run_phase('candidate', every, skip_tests=True).returncode == 0
assert run_phase('candidate', every, failed='build-win32-x64', skip_tests=True).returncode != 0
assert run_phase('publish', every).returncode == 0
assert run_phase('publish', every - {'termux'}).returncode == 0
for group in group_jobs:
@@ -189,6 +189,8 @@ def _claim_message(clone: Path) -> str:
"attempt": 1,
"commit": _git("rev-parse", "HEAD", cwd=clone),
"autopublish": False,
"skipBundles": False,
"skipTests": False,
"claimEpoch": 1_790_000_000,
}, sort_keys=True, separators=(",", ":"))
+43 -1
View File
@@ -82,6 +82,46 @@ def test_release_reuses_whole_ci_and_docker_before_publication():
assert jobs[name]["if"] == "always()"
def test_claim_flags_remove_exactly_the_jobs_the_gate_expects_skipped():
"""The gate's SKIPPED_BY table and the workflow's conditions describe the same graph."""
from scripts.releases.stable import SKIPPED_BY
jobs = workflow("stable-release.yml")["jobs"]
outputs = {"skipTests": "needs.admit.outputs.skip-tests",
"skipBundles": "needs.admit.outputs.skip-bundles"}
def needs_of(name):
needs = jobs[name].get("needs", [])
return [needs] if isinstance(needs, str) else needs
def removed_by(name, flag):
condition = str(jobs[name].get("if", ""))
if f"{outputs[flag]} != 'true'" in condition:
return True
# Without a status function a job skips when any job it needs skipped.
return ("always()" not in condition and "!cancelled()" not in condition
and any(flag in SKIPPED_BY.get(need, ()) and removed_by(need, flag)
for need in needs_of(name)))
for name, flags in SKIPPED_BY.items():
assert name in jobs
for flag in flags:
assert removed_by(name, flag), f"{name} does not skip under {flag}"
# The image publish-docker pushes and the candidates are built under
# skip-tests; they are told to run without their own tests.
for name in ("docker", "candidates-darwin-arm64", "candidates-darwin-x64", "candidates-win32-arm64",
"candidates-win32-x64", "candidates-win32-bundle", "candidates-termux"):
assert jobs[name]["with"]["skip-tests"] == "${{ needs.admit.outputs.skip-tests == 'true' }}"
assert {"skip-bundles", "skip-tests"} <= set(jobs["admit"]["outputs"])
for name in ("acceptance", "publication", "complete"):
gate = next(step for step in jobs[name]["steps"]
if "scripts.releases.stable gate" in step.get("run", ""))
assert gate["env"]["SKIP_BUNDLES"] == "${{ needs.admit.outputs.skip-bundles }}"
assert gate["env"]["SKIP_TESTS"] == "${{ needs.admit.outputs.skip-tests }}"
gated = gate["run"].split(" gate ", 1)[1].split()
assert set(gated) <= set(needs_of(name)), name
def test_all_applicable_ci_jobs_are_aggregated_and_desktop_e2e_stays_deferred():
jobs = workflow("ci.yaml")["jobs"]
checks = {name for name, job in jobs.items() if "uses" in job}
@@ -93,7 +133,9 @@ def test_all_applicable_ci_jobs_are_aggregated_and_desktop_e2e_stays_deferred():
def test_claim_custody_and_final_payload_identity_reach_every_privileged_phase():
release = workflow("stable-release.yml")
jobs = release["jobs"]
assert "autopublish" not in release["on"]["workflow_dispatch"]["inputs"]
# The claim is the one record of the attempt's policy; a dispatch cannot override it.
inputs = set(release["on"]["workflow_dispatch"]["inputs"])
assert not {"autopublish", "skip-bundles", "skip-tests"}.intersection(inputs)
assert {"claim-tag", "claim-object", "tag", "commit", "version", "release-id", "release-epoch"} <= \
set(jobs["admit"]["outputs"])
for name in ("publish-bundles", *("candidates-darwin-arm64", "candidates-darwin-x64",
+3
View File
@@ -57,6 +57,9 @@ def test_phase_jobs_judge_only_the_selected_groups():
assert "candidate-manifest" not in candidate
partial = {**{group: False for group in JOB_GROUPS}, "darwin-arm64": True}
assert phase_jobs(partial, "candidate") == ["validate", "build-darwin-arm64", "smoke-darwin-arm64"]
# A claim that skipped tests still judges every build, and no smoke.
untested = phase_jobs(every, "candidate", skip_tests=True)
assert untested == [job for job in candidate if not job.startswith("smoke-")]
assert phase_jobs(every, "publish") == ["validate", "stable-publish", "stable-store"]
with pytest.raises(ValueError, match="Unknown release phase"):
phase_jobs(every, "promote")
+11 -4
View File
@@ -436,8 +436,9 @@ def test_accepted_release_receipts_feed_the_protected_head_without_rebuilding(tm
monkeypatch.setattr(channel_releases, "admit_transaction",
lambda policy, env, **_kwargs: (tag, commit))
monkeypatch.setattr(channel_releases.stable, "final_context",
lambda env: (tag, commit, {"claim_epoch": 1_787_965_323}))
monkeypatch.setattr(channel_releases, "accepted_stable", lambda *args: accepted)
lambda env: (tag, commit, {"claim_epoch": 1_787_965_323,
"skip_bundles": False, "skip_tests": False}))
monkeypatch.setattr(channel_releases, "accepted_stable", lambda *args, **kwargs: accepted)
promotion_attempts = [0]
def promote_stable_feeds(*args):
promotion_attempts[0] += 1
@@ -556,10 +557,16 @@ def test_accepted_stable_reads_the_release_archive_by_tag(monkeypatch):
key = f"releases/tag/{attempt}/release-candidates.json"
objects[key] = raw
candidate_env = {"CANDIDATE_MANIFEST_SHA256": hashlib.sha256(raw).hexdigest(), "CANDIDATE_MANIFEST_URL": pub.public_base + "/" + key}
assert channel_releases.accepted_stable(pub, candidate_env, attempt, commit, release_epoch) == candidate
assert channel_releases.accepted_stable(pub, candidate_env, attempt, commit, release_epoch,
skip_tests=False) == candidate
# Passed smokes cannot stand behind a claim that skipped tests, or the reverse.
with pytest.raises(ValueError, match="test policy"):
channel_releases.accepted_stable(pub, candidate_env, attempt, commit, release_epoch,
skip_tests=True)
faults["stale_public"] = b"{}"
with pytest.raises(ChannelError):
channel_releases.accepted_stable(pub, candidate_env, attempt, commit, release_epoch)
channel_releases.accepted_stable(pub, candidate_env, attempt, commit, release_epoch,
skip_tests=False)
def test_request_inputs_are_rejected_before_allocating():
+21 -1
View File
@@ -89,7 +89,7 @@ def test_release_claims_the_first_attempt_creates_a_draft_and_dispatches(source)
return "https://github.com/example/hermes-agent/releases/tag/untagged-0123abcd\n"
return ""
result = _release(source, commit, execute=execute, autopublish=True)
result = _release(source, commit, execute=execute, autopublish=True, skip_bundles=True)
assert result["version"] == "0.21.5"
assert result["tag"] == "rc.1-v0.21.5"
@@ -100,11 +100,14 @@ def test_release_claims_the_first_attempt_creates_a_draft_and_dispatches(source)
assert git(source, "rev-parse", "rc.1-v0.21.5^{commit}") == commit
claim = json.loads(git(source, "tag", "-l", "rc.1-v0.21.5", "--format=%(contents)"))
assert isinstance(claim.pop("claimEpoch"), int)
# The claim is the one record of the attempt's policy, flags included.
assert claim == {
"attempt": 1,
"autopublish": True,
"commit": commit,
"schema": 1,
"skipBundles": True,
"skipTests": False,
"version": "0.21.5",
}
create = calls[0]
@@ -132,6 +135,7 @@ def test_release_output_names_the_wait_and_the_publish_step():
from scripts.releases.entrypoint import next_steps
result = {"version": "0.21.5", "tag": "rc.1-v0.21.5", "autopublish": False,
"skip_bundles": False, "skip_tests": False,
"run_url": "https://github.com/example/hermes-agent/actions/runs/7",
"url": "https://github.com/example/hermes-agent/releases/tag/untagged-0123abcd",
"final_url": "https://github.com/example/hermes-agent/releases/tag/v0.21.5"}
@@ -146,6 +150,22 @@ def test_release_output_names_the_wait_and_the_publish_step():
automatic = next_steps({**result, "autopublish": True})
assert "Autopublish is on." in automatic
assert "publish --version" not in automatic
assert "skipped" not in text
skipped = next_steps({**result, "skip_bundles": True, "skip_tests": True})
assert "Bundles are skipped." in skipped and "Tests are skipped." in skipped
def test_a_final_tag_for_the_next_version_refuses_the_cut(source):
"""A started publication owns its version even before its release is public."""
from scripts.releases.entrypoint import ReleaseRefused
commit = git(source, "rev-parse", "HEAD")
git(source, "tag", "v0.21.5", commit)
git(source, "push", "-q", "origin", "refs/tags/v0.21.5")
with pytest.raises(ReleaseRefused, match="v0.21.5 already has a final tag"):
_release(source, commit, execute=_must_not_execute)
assert "rc." not in git(source, "ls-remote", "origin", "refs/tags/*")
def test_second_cut_after_abandon_is_attempt_two_of_the_same_version(source):
+41 -3
View File
@@ -217,7 +217,8 @@ def test_an_unpublished_claim_below_the_head_is_refused():
def _claim_message(version, attempt, commit, *, autopublish=False,
epoch=1_790_000_000):
return {"schema": 1, "version": version, "attempt": attempt, "commit": commit,
"autopublish": autopublish, "claimEpoch": epoch}
"autopublish": autopublish, "skipBundles": False, "skipTests": False,
"claimEpoch": epoch}
def _final_message(version, attempt, commit, *, release_id, epoch=1_790_000_000):
@@ -309,7 +310,7 @@ def test_two_outstanding_attempts_are_refused_across_versions():
def _sequencer_fixture(*versions, manifest_digest, docker_digest="sha256:" + "b" * 64,
drafts_on_claim_tag=False):
drafts_on_claim_tag=False, skip_bundles=False):
"""Two green claims with their final tags; releases start as drafts."""
commit = "a" * 40
tags = {}
@@ -319,7 +320,8 @@ def _sequencer_fixture(*versions, manifest_digest, docker_digest="sha256:" + "b"
claim_object, final_object = str(index) * 40, str(index + 2) * 40
claim = {
"schema": 1, "version": version, "attempt": 1, "commit": commit,
"autopublish": False, "claimEpoch": 1_790_000_000 + index,
"autopublish": False, "skipBundles": skip_bundles, "skipTests": False,
"claimEpoch": 1_790_000_000 + index,
}
final = {
"schema": 1, "version": version, "commit": commit,
@@ -496,3 +498,39 @@ def test_a_publish_that_died_before_the_retarget_is_repaired():
assert steps == [{"advance": "0.21.5"}]
assert events == [("advance", "v0.21.5")]
assert releases[0]["tag_name"] == "v0.21.5" and releases[0]["draft"] is False
def test_a_release_that_skipped_bundles_ships_only_its_tag_release_and_docker_aliases(monkeypatch):
"""Its receipt binds no manifest; the R2 head and Store stay put; one pass finishes it."""
from scripts.releases import channel_releases, docker, sequencer, store
docker_digest = "sha256:" + "b" * 64
_tags, releases, run = _sequencer_fixture(
"0.21.5", manifest_digest=None, docker_digest=docker_digest, skip_bundles=True)
alias = [None]
events = []
def promote(claim_tag, digest):
events.append(("aliases", claim_tag))
alias[0] = digest
monkeypatch.setattr(channel_releases, "advance_stable",
lambda *_args: pytest.fail("the protected R2 head moved"))
monkeypatch.setattr(store, "check_from_env", lambda _env: pytest.fail("the Store was checked"))
monkeypatch.setattr(channel_releases, "stable_head_version", lambda _env: "0.21.4")
monkeypatch.setattr(docker, "promote_stable", promote)
monkeypatch.setattr(docker, "stable_alias_digest", lambda: alias[0])
env = {"GITHUB_REPOSITORY": "example/project", "REQUESTED_VERSION": "0.21.5"}
def no_archive(_key):
pytest.fail("a release that skipped bundles has no archive to read")
steps = sequencer.reconcile(env, run=run, read_archive=no_archive)
assert steps == [{"flip": "0.21.5"}, {"advance": "0.21.5"}]
assert events == [("aliases", "rc.1-v0.21.5")]
assert releases[0]["tag_name"] == "v0.21.5" and releases[0]["draft"] is False
# The R2 head still names 0.21.4, but the stable alias carries the 0.21.5
# receipt digest, so the next pass has nothing left to advance.
assert sequencer.reconcile(env, run=run, read_archive=no_archive) == []
assert events == [("aliases", "rc.1-v0.21.5")]
@@ -3,6 +3,8 @@
Derivation reads the published stable head, or the seed ``0.21.4`` before one
exists. Attempt refs number attempts within a version and never move the line.
"""
import json
import pytest
from scripts.releases.versioning import derive_next_version, next_attempt, version_from_tag
@@ -107,9 +109,48 @@ def test_canary_base_comes_from_the_validated_protected_stable_head():
assert published_stable_version(
"example/hermes-agent", base_url="https://assets.example", reader_type=Reader,
run=lambda argv: "[[]]",
) == "0.21.7"
def test_a_newer_published_release_outranks_the_protected_head():
"""A release that skipped bundles never moves the R2 head, but it still spends its version."""
from scripts.releases.versioning import published_stable_identity
class Reader:
def __init__(self, base, repository):
pass
def resolve(self, name):
return type("Resolution", (), {
"terminal": {"policy": "stable-release"},
"manifest": {"request": {"version": "0.21.7", "commit": "a" * 40}},
})()
releases = [
{"tag_name": "v0.21.8", "draft": False, "prerelease": False},
# Drafts, prereleases and CalVer labels are not published stable releases.
{"tag_name": "v0.21.9", "draft": True, "prerelease": False},
{"tag_name": "v0.21.8+canary.20260924T000000Z", "draft": False, "prerelease": True},
{"tag_name": "v2026.9.24", "draft": False, "prerelease": False},
]
def run(argv):
if argv[:4] == ["gh", "api", "--paginate", "--slurp"]:
return json.dumps([releases])
assert argv[:2] == ["gh", "api"] and argv[3:] == ["--jq", ".sha"]
return {"repos/example/hermes-agent/commits/v0.21.8": "b" * 40,
"repos/example/hermes-agent/commits/v0.21.6": "c" * 40}[argv[2]]
assert published_stable_identity(
"example/hermes-agent", base_url="https://assets.example", reader_type=Reader, run=run,
) == ("0.21.8", "b" * 40)
releases[0]["tag_name"] = "v0.21.6"
assert published_stable_identity(
"example/hermes-agent", base_url="https://assets.example", reader_type=Reader, run=run,
) == ("0.21.7", "a" * 40)
def test_outstanding_attempts_is_the_one_shared_predicate():
from scripts.releases.versioning import outstanding_attempts
+80 -10
View File
@@ -67,7 +67,8 @@ def test_gate_requires_every_success_including_real_cli(tmp_path):
with pytest.raises(ValueError, match=name):
require_success(needs, required)
summary = tmp_path / "summary.md"
env = {**os.environ, "RELEASE_NEEDS": json.dumps(success), "GITHUB_STEP_SUMMARY": str(summary), "PYTHONPATH": str(ROOT)}
env = {**os.environ, "RELEASE_NEEDS": json.dumps(success), "GITHUB_STEP_SUMMARY": str(summary), "PYTHONPATH": str(ROOT),
"SKIP_BUNDLES": "false", "SKIP_TESTS": "false"}
argv = [sys.executable, "-m", "scripts.releases.stable", "gate", *required]
assert subprocess.run(argv, cwd=tmp_path, env=env, capture_output=True).returncode == 0
empty = subprocess.run(argv[:4], cwd=tmp_path, env=env, capture_output=True, text=True, encoding="utf-8")
@@ -79,6 +80,35 @@ def test_gate_requires_every_success_including_real_cli(tmp_path):
assert "publication=cancelled" in result.stderr
@pytest.mark.parametrize("skip_bundles,skip_tests", [(True, False), (False, True), (True, True)])
def test_gate_requires_every_flag_removed_job_to_have_skipped(tmp_path, skip_bundles, skip_tests):
from scripts.releases.stable import SKIPPED_BY, gate_expectations
required = ["admit", "docker", "publish-docker", *SKIPPED_BY]
expected = gate_expectations(required, skip_bundles=skip_bundles, skip_tests=skip_tests)
# The flags remove jobs; they never remove admission or the Docker image.
assert expected["admit"] == expected["docker"] == expected["publish-docker"] == "success"
assert expected["transitions-win32"] == expected["pm-bundle"] == "skipped"
needs = {name: {"result": result} for name, result in expected.items()}
env = {**os.environ, "RELEASE_NEEDS": json.dumps(needs), "PYTHONPATH": str(ROOT),
"GITHUB_STEP_SUMMARY": str(tmp_path / "summary.md"),
"SKIP_BUNDLES": "true" if skip_bundles else "false",
"SKIP_TESTS": "true" if skip_tests else "false"}
argv = [sys.executable, "-m", "scripts.releases.stable", "gate", *required]
assert subprocess.run(argv, cwd=tmp_path, env=env, capture_output=True).returncode == 0
# A removed job that ran anyway blocks the release, and so does an unflagged gate.
removed = next(name for name, result in expected.items() if result == "skipped")
env["RELEASE_NEEDS"] = json.dumps({**needs, removed: {"result": "success"}})
ran = subprocess.run(argv, cwd=tmp_path, env=env, capture_output=True, text=True, encoding="utf-8")
assert ran.returncode != 0 and f"{removed}=success (expected skipped)" in ran.stderr
env["RELEASE_NEEDS"] = json.dumps(needs)
env["SKIP_BUNDLES"] = env["SKIP_TESTS"] = "false"
assert subprocess.run(argv, cwd=tmp_path, env=env, capture_output=True).returncode != 0
del env["SKIP_TESTS"]
missing = subprocess.run(argv, cwd=tmp_path, env=env, capture_output=True, text=True, encoding="utf-8")
assert missing.returncode != 0 and "SKIP_TESTS must be" in missing.stderr
def test_validate_candidates_keys_the_archive_by_the_attempt_ref():
commit = "b" * 40
manifest = candidates("v1.2.4", commit, "2" * 64, archive="rc.2-v1.2.4")
@@ -335,7 +365,8 @@ def test_claim_object_movement_and_lightweight_tags_fail_closed(tmp_path, monkey
env = {"RELEASE_CLAIM_TAG": "rc.1-v1.2.3", "RELEASE_CLAIM_OBJECT": claim_object,
"GITHUB_SHA": commit, "GITHUB_REF": ref}
message = {"schema": 1, "version": "1.2.3", "attempt": 1, "commit": commit,
"autopublish": False, "claimEpoch": 1_790_000_000}
"autopublish": False, "skipBundles": False, "skipTests": False,
"claimEpoch": 1_790_000_000}
def git(argv):
if argv[1] == "ls-remote":
@@ -353,7 +384,8 @@ def test_claim_object_movement_and_lightweight_tags_fail_closed(tmp_path, monkey
assert check_claim(env, git) == {
"claim_tag": "rc.1-v1.2.3", "claim_object": claim_object,
"tag": "v1.2.3", "version": "1.2.3", "attempt": 1, "commit": commit,
"autopublish": False, "claim_epoch": 1_790_000_000,
"autopublish": False, "skip_bundles": False, "skip_tests": False,
"claim_epoch": 1_790_000_000,
}
# The metadata binds the attempt its ref names.
for wrong in ({**message, "attempt": 2}, {k: v for k, v in message.items() if k != "attempt"}):
@@ -380,7 +412,8 @@ def test_claim_object_movement_and_lightweight_tags_fail_closed(tmp_path, monkey
subprocess.run(["git", "remote", "add", "origin", str(remote)], check=True)
metadata = json.dumps({
"schema": 1, "version": "1.2.3", "attempt": 1, "commit": actual,
"autopublish": False, "claimEpoch": 1_790_000_000,
"autopublish": False, "skipBundles": False, "skipTests": False,
"claimEpoch": 1_790_000_000,
}, sort_keys=True, separators=(",", ":"))
subprocess.run(
["git", "tag", "-a", "rc.1-v1.2.3", "-m", metadata], check=True,
@@ -408,7 +441,7 @@ def test_claim_object_movement_and_lightweight_tags_fail_closed(tmp_path, monkey
check_claim(env)
def _claim_fixture(tmp_path, *, tag, version):
def _claim_fixture(tmp_path, *, tag, version, skip_bundles=False, skip_tests=False):
"""A real checkout + bare remote carrying one annotated attempt claim."""
from scripts.releases.versioning import parse_attempt_ref
@@ -426,7 +459,8 @@ def _claim_fixture(tmp_path, *, tag, version):
attempt = parse_attempt_ref(tag)[1]
metadata = json.dumps({
"schema": 1, "version": version, "attempt": attempt, "commit": commit,
"autopublish": False, "claimEpoch": epoch,
"autopublish": False, "skipBundles": skip_bundles, "skipTests": skip_tests,
"claimEpoch": epoch,
}, sort_keys=True, separators=(",", ":"))
subprocess.run(
["git", "tag", "-a", tag, "-m", metadata], cwd=repo, check=True,
@@ -489,10 +523,11 @@ def test_strip_refuses_an_unbalanced_fence(body):
strip_draft_warning(body)
def _publish_record(commit, tag_object, *, epoch, release_id=42):
def _publish_record(commit, tag_object, *, epoch, release_id=42, skip_bundles=False):
return {"claim_tag": "rc.2-v1.2.3", "claim_object": tag_object, "tag": "v1.2.3",
"commit": commit, "version": "1.2.3", "attempt": 2, "release_id": release_id,
"autopublish": False, "claim_epoch": epoch}
"autopublish": False, "skip_bundles": skip_bundles, "skip_tests": False,
"claim_epoch": epoch}
def test_publish_attempt_writes_the_receipt_retargets_and_copies_no_bytes(tmp_path, monkeypatch):
@@ -677,10 +712,11 @@ WINDOWS_VERSION = "2026.5761.123.0"
RELEASE_EPOCH = 1_787_965_323
def _fake_stable_context():
def _fake_stable_context(*, skip_tests=False):
def context(env):
return "v1.2.3", RECEIPT_COMMIT, {"claim_tag": ATTEMPT, "claim_object": "0" * 40,
"claim_epoch": RELEASE_EPOCH}
"claim_epoch": RELEASE_EPOCH, "skip_bundles": False,
"skip_tests": skip_tests}
return context
@@ -942,3 +978,37 @@ def test_candidate_manifest_needs_every_call_and_stages_the_archive_manifest(
with pytest.raises(ValueError, match="smoke-darwin-x64"):
stable.main(["candidate-manifest"], failed)
assert f"releases/tag/{ATTEMPT}/release-candidates.json" not in r2_server.store
def test_a_claim_that_skipped_tests_records_skipped_smokes_never_passed(
tmp_path, r2_server, https_origin, monkeypatch):
from scripts.releases import stable
https_origin.store = r2_server.store
built = tmp_path / "built"
built.mkdir()
_stage_darwin_handoff(built, "arm64")
_stage_darwin_handoff(built, "x64")
_stage_windows_handoff(built, "x64")
_stage_windows_handoff(built, "arm64")
_stage_universal_bundle(built)
_stage_termux_handoff(built)
monkeypatch.setattr(stable, "stable_context", _fake_stable_context(skip_tests=True))
calls = {call: {"result": "success"} for call in stable.CALL_SMOKE_JOBS}
env = {**_receipt_env(tmp_path, https_origin.base), "RELEASE_NEEDS": json.dumps(calls)}
# A build call that failed still leaves no manifest, even with the smokes off.
failed = {**env, "RELEASE_NEEDS": json.dumps({**calls, "candidates-win32-x64": {"result": "failure"}})}
with pytest.raises(ValueError, match="candidates-win32-x64"):
stable.main(["candidate-manifest"], failed)
assert f"releases/tag/{ATTEMPT}/release-candidates.json" not in r2_server.store
stable.main(["candidate-manifest"], env)
stored, _ = r2_server.store[f"releases/tag/{ATTEMPT}/release-candidates.json"]
manifest = json.loads(stored)
assert manifest["smoke_results"] == {job: {"result": "skipped"} for job in stable.SMOKE_JOBS}
stable.validate_candidates(manifest, "v1.2.3", RECEIPT_COMMIT, https_origin.base,
RELEASE_EPOCH, archive=ATTEMPT)
stable.require_smokes_match_claim(manifest, skip_tests=True)
with pytest.raises(ValueError, match="test policy"):
stable.require_smokes_match_claim(manifest, skip_tests=False)
@@ -13,11 +13,16 @@ files on `main`.
1. Refresh `origin/main` and the remote attempt and marker refs (`rc.*` and
`abandoned-rc.*`). Derive the next SemVer from the published release family
seeded at `0.21.4` alone. Attempts do not move the version line.
seeded at `0.21.4` alone: the newer of the protected R2 stable head and the
newest published non-prerelease GitHub release with a `vX.Y.Z` tag. A
release that skipped bundles moves only the second. Attempts do not move the
version line. A cut whose next version already has a final `vX.Y.Z` tag is
refused until that publication finishes.
2. Push an annotated `rc.<N>-vX.Y.Z` attempt ref atomically, create one
non-prerelease GitHub draft on it, and dispatch `Stable Release` on that exact
ref. The attempt number comes from the existing attempt refs of that version.
The claim message binds its commit, attempt number, autopublish policy, and
The claim message binds its commit, attempt number, autopublish policy,
`skipBundles` and `skipTests` flags, and
one monotonically allocated epoch. That epoch is the release date and native
packaging clock for every matrix leg and retry. One outstanding attempt, of
any version, blocks a new `release`.
@@ -31,7 +36,8 @@ files on `main`.
publication.
6. Create the annotated final `vMAJOR.MINOR.PATCH` receipt at publish, not at
green. It binds the winning attempt's ref, object, commit, archive prefix,
candidate-manifest SHA256, Docker manifest digest, and autopublish policy.
candidate-manifest SHA256 (`null` when the claim skipped bundles), Docker
manifest digest, and autopublish policy.
7. The stable publication controller resolves releases oldest first. It creates
`vX.Y.Z`, retargets the still-draft release onto it, strips the warning
blocks, makes the release public as the last call, then verifies and promotes
@@ -89,6 +95,59 @@ Add `--autopublish` to publish immediately when the claim becomes the oldest
green release. Without it, the release stays a draft until an explicit publish
or a later green claim forces ordered resolution.
### Skip bundles or tests
Two `release` flags remove parts of the pipeline. They can be used together,
and they combine with `--autopublish`.
```sh
# Tag, GitHub release and Docker image only
python scripts/release.py release --commit "$(git rev-parse origin/main)" --skip-bundles --remote origin
# Emergency release: build and publish everything, run no tests
python scripts/release.py release --commit "$(git rev-parse origin/main)" --skip-tests --remote origin
```
| | `--skip-bundles` | `--skip-tests` |
|---|---|---|
| Source CI (`ci.yaml`), Nix, Termux, Windows live, install/update E2E, bootstrap identity | run | skipped |
| Docker image | built, tested, published | built and published, `tests/docker` skipped |
| Native PM bundle check | skipped | skipped |
| Desktop and Termux candidates | skipped | built, signed and staged, with no native smokes or in-build test suites |
| Signed-package upgrade acceptance (`transitions-*`, `*-packaged`) | skipped | skipped |
| Publication | final tag, GitHub release, Docker `stable`/`latest` aliases | everything, as a normal release |
The flags are written into the claim message, never passed as workflow
inputs. `admit` reads them from the claim and emits `skip-bundles` and
`skip-tests`. Every job condition and every gate reads those outputs. A
recovery rerun cannot change them. To change a flag, `abandon` the attempt and
cut again.
The gates stay strict. `scripts.releases.stable gate` reads the flags and
requires each job the flags remove to report `skipped`, and every other gated
job to report `success`. A job that ran although a flag removes it also
blocks the release. `SKIPPED_BY` in `scripts/releases/stable.py` is the one
table of which flag removes which job.
**`--skip-bundles`.** The draft, Docker image, final tag and GitHub release are
the whole release. No candidate manifest exists, so the final tag records
`candidateManifestSha256: null`. Publication moves only the Docker aliases. The
protected R2 stable head, App Installer and macOS feeds, APT channel,
downloads page, `releases/stable/release-candidates.json`, signed-package
baseline, and Store submission all stay on the previous bundle release. Source
checkouts on the official repository follow
`releases/stable/release-candidates.json`, so they also stay on the previous
bundle release. Such a release is complete when the Docker `stable` alias
carries the digest its final tag binds. The sequencer uses that alias, next to
the R2 head, to decide which releases still need their publication pass.
**`--skip-tests`.** Every artifact is built, signed, staged and published the
same way as a normal release. The candidate manifest records each native smoke
as `skipped`, never as passed. The next release uses that manifest as its
upgrade baseline like any other. Every reader that knows the claim (`complete`
and the protected R2 advance) refuses a manifest whose smoke results disagree
with the claim's `skipTests` flag. Use it only for an emergency fix, and cut a
normal release after it.
The claim push is the atomic version lock. Two callers may derive the same next
version, but only one push wins; the loser reports the winning tagger, time, and
commit. A rejected or abandoned claim remains spent. To publish or abandon:
@@ -106,7 +165,8 @@ marker is the record of abandonment; the attempt ref is never deleted. The
version is not spent, so the next cut is `rc.<N+1>-vX.Y.Z`.
Do not manually dispatch `Stable Release` from a final tag. Recovery keeps the
original claim ref, object SHA, commit, draft database ID, and autopublish policy.
original claim ref, object SHA, commit, draft database ID, autopublish policy,
and skip flags.
## Failure and recovery
@@ -331,8 +391,9 @@ local helper suite. Never store the bootstrap output as a repo channel list.
## Signed-package baseline
The last successful stable release records
The last successful stable release that shipped bundles records
`releases/stable/release-candidates.json` on the configured R2 public origin.
A release that skipped bundles does not replace it.
It identifies actual Windows universal MSIX bundles, macOS ZIPs and package
provenance. The next run combines those records with its candidate manifest
and uses the existing native bundled-update drivers.
@@ -357,6 +418,8 @@ See [the bundled update contract](https://github.com/NousResearch/hermes-agent/b
because it is flaky. It is reported as deferred, not passed. Stabilize it
and prove repeatable CI runs before adding it to this gate.
- Install/update E2E and native signed-package acceptance are **not** deferred.
Only a claim cut with `--skip-tests` removes them, and the gate then requires
them to be skipped.
- PR-only history, label and diff review checks do not apply to a stable tag.
All applicable source CI jobs still run, regardless of changed paths.
- OSV vulnerability findings retain their existing advisory policy. Required