mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-09-28 15:01:00 +08:00
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:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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=(",", ":"))
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user