diff --git a/.github/workflows/desktop-bundled-release.yml b/.github/workflows/desktop-bundled-release.yml index 6e3ad61e41..291044fd55 100644 --- a/.github/workflows/desktop-bundled-release.yml +++ b/.github/workflows/desktop-bundled-release.yml @@ -80,6 +80,12 @@ on: tag: required: true type: string + claim-tag: + default: '' + type: string + claim-object: + default: '' + type: string release-phase: required: true type: string @@ -192,7 +198,7 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - ref: ${{ (inputs.build_commit != '' || inputs.channel != '') && github.sha || inputs.tag }} + ref: ${{ (inputs.build_commit != '' || inputs.channel != '' || inputs.release-phase != '') && github.sha || inputs.tag }} # Full checkout, always. Commit admission (require_pushed) needs the # full graph AND every pushed branch ref fetched into # refs/remotes/origin/* — a depth-1 checkout fetches only github.sha, @@ -247,6 +253,9 @@ jobs: id: admission env: TAG: ${{ inputs.tag }} + RELEASE_TAG: ${{ inputs.tag }} + RELEASE_CLAIM_TAG: ${{ inputs.claim-tag }} + RELEASE_CLAIM_OBJECT: ${{ inputs.claim-object }} BUILD_COMMIT: ${{ inputs.build_commit }} CHANNEL_BUILD: ${{ steps.allocate.outputs.channel_build || '' }} CHANNEL_REQUEST_SHA256: ${{ steps.allocate.outputs.channel_request_sha256 || '' }} @@ -295,9 +304,8 @@ jobs: fi case "$RELEASE_PHASE" in candidate|publish|promote) - [[ "$TAG" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] || { echo "::error::not a release tag: $TAG"; exit 1; } - [ "$GITHUB_REF" = "refs/tags/$TAG" ] || exit 1 - [ "$(git rev-parse HEAD)" = "$GITHUB_SHA" ] || exit 1 + python -m scripts.releases.stable verify + exit 0 ;; '') [[ "$TAG" == *+canary.* ]] || { echo 'Use Stable Release for stable tags' >&2; exit 1; } ;; *) echo 'Unknown release phase' >&2; exit 1 ;; @@ -367,6 +375,7 @@ jobs: HERMES_DESKTOP_VARIANT: bundled HERMES_PAYLOAD_TAG: ${{ inputs.tag }} HERMES_BUILD_COMMIT: ${{ inputs.build_commit != '' && needs.validate.outputs.channel-build == '' && needs.validate.outputs.sha || '' }} + HERMES_RELEASE_COMMIT: ${{ inputs.release-phase != '' && needs.validate.outputs.sha || '' }} HERMES_PAYLOAD_VERSION: ${{ needs.validate.outputs.payload-version }} # Signature outputs are separate from the prepared dependency snapshot. ELECTRON_BUILDER_CACHE: ${{ github.workspace }}/.cache/electron-builder @@ -461,6 +470,7 @@ jobs: run: | args=(--tag "$HERMES_PAYLOAD_TAG") if [ -n "$HERMES_BUILD_COMMIT" ]; then args=(--commit "$HERMES_BUILD_COMMIT"); fi + if [ -n "$HERMES_RELEASE_COMMIT" ]; then args+=(--release-commit "$HERMES_RELEASE_COMMIT"); fi if [ -n "${CHANNEL_BUILD:-}" ]; then args=(--channel-request "$RUNNER_TEMP/channel-request.json"); fi python scripts/bundles/desktop.py "${args[@]}" --variant bundled --prepare-only \ --work "$RUNNER_TEMP/desktop-job" --cache "$GITHUB_WORKSPACE/.cache/desktop-inputs" @@ -710,6 +720,7 @@ jobs: HERMES_DESKTOP_VARIANT: bundled HERMES_PAYLOAD_TAG: ${{ inputs.tag }} HERMES_BUILD_COMMIT: ${{ inputs.build_commit != '' && needs.validate.outputs.channel-build == '' && needs.validate.outputs.sha || '' }} + HERMES_RELEASE_COMMIT: ${{ inputs.release-phase != '' && needs.validate.outputs.sha || '' }} HERMES_PAYLOAD_VERSION: ${{ needs.validate.outputs.payload-version }} CLOUDFLARE_R2_BUCKET: ${{ vars.CLOUDFLARE_R2_BUCKET }} CLOUDFLARE_R2_PUBLIC_URL: ${{ needs.validate.outputs.public-root }} @@ -789,6 +800,7 @@ jobs: run: | args=(--tag "$HERMES_PAYLOAD_TAG") if [ -n "$HERMES_BUILD_COMMIT" ]; then args=(--commit "$HERMES_BUILD_COMMIT"); fi + if [ -n "$HERMES_RELEASE_COMMIT" ]; then args+=(--release-commit "$HERMES_RELEASE_COMMIT"); fi if [ -n "${CHANNEL_BUILD:-}" ]; then args=(--channel-request "$RUNNER_TEMP/channel-request.json"); fi python3 scripts/bundles/desktop.py "${args[@]}" --variant bundled --prepare-only \ --work "$RUNNER_TEMP/desktop-job" --cache "$GITHUB_WORKSPACE/.cache/desktop-inputs" @@ -1477,6 +1489,7 @@ jobs: env: HERMES_PAYLOAD_TAG: ${{ inputs.tag }} HERMES_BUILD_COMMIT: ${{ inputs.build_commit != '' && needs.validate.outputs.channel-build == '' && needs.validate.outputs.sha || '' }} + HERMES_RELEASE_COMMIT: ${{ inputs.release-phase != '' && needs.validate.outputs.sha || '' }} # termux_build.sh gates on `gh release view ` (refuse to build # before the release exists); gh needs GH_TOKEN or it errors out and # the gate misreads that as "release not found". @@ -1636,7 +1649,13 @@ jobs: # termux_build.sh lands wheelhouse/ + index.json + SHA256SUMS in the # payload root, where build_deb.sh's --no-index pip install finds it. run: | - if [ -n "$HERMES_BUILD_COMMIT" ]; then + if [ -n "${HERMES_RELEASE_COMMIT:-}" ]; then + bash scripts/termux/termux_build.sh \ + --repo . \ + --tag "$HERMES_PAYLOAD_TAG" \ + --release-commit "$HERMES_RELEASE_COMMIT" \ + --out termux-build/payload + elif [ -n "$HERMES_BUILD_COMMIT" ]; then # The checkout is the admitted commit. No release tag is required. bash scripts/termux/termux_build.sh \ --repo . \ @@ -1671,7 +1690,14 @@ jobs: # Native dependency staging stays above; .deb validation still runs # in a fresh pinned container with no opt-out. run: | - if [ -n "$HERMES_BUILD_COMMIT" ]; then + if [ -n "${HERMES_RELEASE_COMMIT:-}" ]; then + python3 scripts/termux/build.py \ + --repo . \ + --tag "$HERMES_PAYLOAD_TAG" \ + --release-commit "$HERMES_RELEASE_COMMIT" \ + --payload termux-build/payload \ + --out termux-build/deb + elif [ -n "$HERMES_BUILD_COMMIT" ]; then python3 scripts/termux/build.py \ --repo . \ --commit "$HERMES_BUILD_COMMIT" \ @@ -2086,12 +2112,6 @@ jobs: with: cache-python: false - run: python -m scripts.ci.python_packages ruamel.yaml==0.18.17 -- -m scripts.bundles.release_artifacts promote --root verified - - name: Render the admitted candidate smoke results - env: - RELEASE_COMMIT: ${{ needs.validate.outputs.sha }} - run: | - python scripts/render-builds-table.py --tag "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \ - --candidate-manifest-sha256 "$CANDIDATE_MANIFEST_SHA256" --candidate-commit "$RELEASE_COMMIT" stable-phase-result: name: Stable bundle phase completed diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 3c131afce9..0513272301 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -30,6 +30,11 @@ on: required: false type: string default: '' + version: + description: "Release version stamped into the isolated image build context." + required: false + type: string + default: '' permissions: contents: read @@ -152,6 +157,12 @@ jobs: # candidate commit, never a mutable branch ref. ref: ${{ needs.mode.outputs.release == 'true' && github.sha || '' }} + - name: Stamp release build context + if: needs.mode.outputs.release == 'true' + env: + RELEASE_VERSION: ${{ inputs.version }} + run: python3 scripts/releases/stamping.py --tree . --version "$RELEASE_VERSION" + - name: Write install stamp run: python3 scripts/write_install_stamp.py --output install-stamp.json --distribution docker --update-mechanism external --source ci - name: Reject profile exports in the build context diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index 6a6f12917d..1e8565f84f 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -21,6 +21,11 @@ on: required: false type: boolean default: false + version: + description: 'Release version stamped into an isolated flake source tree.' + required: false + type: string + default: '' permissions: contents: read @@ -131,7 +136,16 @@ jobs: purge-created: 0 purge-primary-key: never + - name: Prepare isolated release source + if: inputs.release == true + env: + RELEASE_VERSION: ${{ inputs.version }} + run: | + mkdir -p "$RUNNER_TEMP/release-source" + git archive "$GITHUB_SHA" | tar -x -C "$RUNNER_TEMP/release-source" + python3 scripts/releases/stamping.py --tree "$RUNNER_TEMP/release-source" --version "$RELEASE_VERSION" + - name: nix flake check # --print-build-logs: a check that fails then prints the assertion # that failed, and not only the derivation that failed to build. - run: nix flake check --print-build-logs + run: nix flake check "${{ inputs.release == true && format('path:{0}/release-source', runner.temp) || '.' }}" --print-build-logs diff --git a/.github/workflows/pm-bundle.yml b/.github/workflows/pm-bundle.yml index 09e46d021a..769971961b 100644 --- a/.github/workflows/pm-bundle.yml +++ b/.github/workflows/pm-bundle.yml @@ -25,6 +25,11 @@ on: required: false type: boolean default: false + version: + description: 'Release version stamped into the isolated payload tree.' + required: false + type: string + default: '' ref: description: 'Git ref to bundle (default: the triggering commit, github.sha)' required: false @@ -57,6 +62,8 @@ jobs: bundle: name: bundle ${{ matrix.target.label }} runs-on: ${{ matrix.target.runner }} + env: + HERMES_PAYLOAD_VERSION: ${{ inputs.release == true && inputs.version || '' }} # Leave time for setup, cache saves and smoke tests around the wheel build. timeout-minutes: 180 strategy: diff --git a/.github/workflows/stable-release.yml b/.github/workflows/stable-release.yml index 14f9c01fca..79ebc7be1e 100644 --- a/.github/workflows/stable-release.yml +++ b/.github/workflows/stable-release.yml @@ -6,9 +6,13 @@ on: workflow_dispatch: inputs: tag: - description: Exact stable tag, matching the selected workflow ref + description: Exact annotated claim tag, matching the selected workflow ref required: true type: string + autopublish: + description: Publish after the release is green + required: true + type: boolean baseline-manifest: description: Optional HTTPS manifest of the previous published stable packages default: '' @@ -25,8 +29,12 @@ jobs: admit: runs-on: ubuntu-24.04 outputs: + claim-tag: ${{ steps.admit.outputs.claim-tag }} + claim-object: ${{ steps.admit.outputs.claim-object }} tag: ${{ steps.admit.outputs.tag }} commit: ${{ steps.admit.outputs.commit }} + version: ${{ steps.admit.outputs.version }} + release-id: ${{ steps.admit.outputs.release-id }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: @@ -39,7 +47,7 @@ jobs: run: python -m scripts.releases.stable admit env: GH_TOKEN: ${{ github.token }} - RELEASE_TAG: ${{ inputs.tag }} + RELEASE_CLAIM_TAG: ${{ inputs.tag }} ci: name: Full CI pipeline @@ -60,19 +68,22 @@ jobs: with: release-phase: test tag: ${{ needs.admit.outputs.tag }} + version: ${{ needs.admit.outputs.version }} nix: - needs: [ci, docker] + needs: [admit, ci, docker] uses: ./.github/workflows/nix.yml with: release: true + version: ${{ needs.admit.outputs.version }} pm-bundle: - needs: [ci, docker] + needs: [admit, ci, docker] uses: ./.github/workflows/pm-bundle.yml with: release: true ref: ${{ github.sha }} + version: ${{ needs.admit.outputs.version }} termux-checks: needs: [ci, docker] @@ -113,6 +124,8 @@ jobs: uses: ./.github/workflows/desktop-bundled-release.yml with: tag: ${{ needs.admit.outputs.tag }} + claim-tag: ${{ needs.admit.outputs.claim-tag }} + claim-object: ${{ needs.admit.outputs.claim-object }} release-phase: candidate transitions: @@ -136,6 +149,8 @@ jobs: env: GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ needs.admit.outputs.tag }} + RELEASE_CLAIM_TAG: ${{ needs.admit.outputs.claim-tag }} + RELEASE_CLAIM_OBJECT: ${{ needs.admit.outputs.claim-object }} CANDIDATE_MANIFEST_URL: ${{ needs.candidates.outputs.manifest-url }} CANDIDATE_MANIFEST_SHA256: ${{ needs.candidates.outputs.manifest-sha256 }} BASELINE_MANIFEST_URL: ${{ inputs.baseline-manifest }} @@ -200,6 +215,7 @@ jobs: with: release-phase: publish tag: ${{ needs.admit.outputs.tag }} + version: ${{ needs.admit.outputs.version }} publish-bundles: name: Publish tested bundle artifacts @@ -212,6 +228,8 @@ jobs: uses: ./.github/workflows/desktop-bundled-release.yml with: tag: ${{ needs.admit.outputs.tag }} + claim-tag: ${{ needs.admit.outputs.claim-tag }} + claim-object: ${{ needs.admit.outputs.claim-object }} release-phase: publish manifest-sha256: ${{ needs.candidates.outputs.manifest-sha256 }} @@ -238,6 +256,7 @@ jobs: with: release-phase: promote tag: ${{ needs.admit.outputs.tag }} + version: ${{ needs.admit.outputs.version }} promote-bundles: name: Advance stable bundle channels @@ -250,6 +269,8 @@ jobs: uses: ./.github/workflows/desktop-bundled-release.yml with: tag: ${{ needs.admit.outputs.tag }} + claim-tag: ${{ needs.admit.outputs.claim-tag }} + claim-object: ${{ needs.admit.outputs.claim-object }} release-phase: promote manifest-sha256: ${{ needs.candidates.outputs.manifest-sha256 }} @@ -278,16 +299,16 @@ jobs: - run: python -m scripts.releases.stable gate admit ci docker acceptance candidates publication promote-docker promote-bundles env: RELEASE_NEEDS: ${{ toJSON(needs) }} - - name: Record accepted stable packages and publish release, then advance the R2 head - # Keep the source/native head last: a failed manifest or GitHub transaction - # must leave protocol-aware readers on the previous accepted release. - run: | - python -m scripts.releases.stable complete - python -m scripts.releases.channel_releases stable --root "$RUNNER_TEMP/stable-channel" + - name: Create the final tag and retarget the accepted release + run: python -m scripts.releases.stable complete env: RELEASE_NEEDS: ${{ toJSON(needs) }} GH_TOKEN: ${{ github.token }} - RELEASE_TAG: ${{ inputs.tag }} + RELEASE_TAG: ${{ needs.admit.outputs.tag }} + RELEASE_CLAIM_TAG: ${{ needs.admit.outputs.claim-tag }} + RELEASE_CLAIM_OBJECT: ${{ needs.admit.outputs.claim-object }} + RELEASE_ID: ${{ needs.admit.outputs.release-id }} + AUTOPUBLISH: ${{ inputs.autopublish }} CANDIDATE_MANIFEST_URL: ${{ needs.candidates.outputs.manifest-url }} CANDIDATE_MANIFEST_SHA256: ${{ needs.candidates.outputs.manifest-sha256 }} CLOUDFLARE_R2_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_R2_ACCOUNT_ID }} @@ -295,3 +316,12 @@ jobs: CLOUDFLARE_R2_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_SECRET_ACCESS_KEY }} CLOUDFLARE_R2_BUCKET: ${{ vars.CLOUDFLARE_R2_BUCKET }} CLOUDFLARE_R2_PUBLIC_URL: ${{ vars.CLOUDFLARE_R2_PUBLIC_URL }} + - name: Render the admitted candidate smoke results + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ needs.admit.outputs.tag }} + RELEASE_COMMIT: ${{ needs.admit.outputs.commit }} + CANDIDATE_MANIFEST_SHA256: ${{ needs.candidates.outputs.manifest-sha256 }} + run: | + python scripts/render-builds-table.py --tag "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \ + --candidate-manifest-sha256 "$CANDIDATE_MANIFEST_SHA256" --candidate-commit "$RELEASE_COMMIT" diff --git a/scripts/bundles/desktop.py b/scripts/bundles/desktop.py index bba80fb5bd..a38f009e64 100644 --- a/scripts/bundles/desktop.py +++ b/scripts/bundles/desktop.py @@ -7,7 +7,6 @@ import os import shutil import subprocess import sys -import tomllib from pathlib import Path ROOT = Path(__file__).resolve().parents[2] @@ -24,14 +23,10 @@ def capture(argv: list[str], repo: Path) -> str: return subprocess.check_output(argv, cwd=repo, text=True, encoding="utf-8").strip() -def release_version(repo: Path, tag: str) -> str: - from hermes_cli.update_channel import is_canary_tag +def release_version(_repo: Path, tag: str) -> str: from scripts.termux.deb_version import channel_for_tag channel_for_tag(tag) # shared release tag grammar, not a second version parser - version = tomllib.loads((repo / "pyproject.toml").read_text(encoding="utf-8-sig"))["project"]["version"] - if not is_canary_tag(tag) and tag != "v" + version: - raise ValueError(f"tag {tag} does not match project version {version}") return tag[1:] @@ -117,6 +112,7 @@ def main() -> None: parser.add_argument("--commit", dest="commit_build", default=None, help="Commit-only build: exact full 40-char SHA the checkout is at; " "version comes from the target pyproject, no tag is referenced") + parser.add_argument("--release-commit", help="Admitted commit for a stable tag not created until green") parser.add_argument("--channel-request", type=Path, help="Immutable admitted channel request JSON") parser.add_argument("--variant", choices=["bundled", "store", "light"]) parser.add_argument("--repo", type=Path, default=ROOT) @@ -129,7 +125,8 @@ def main() -> None: builder_args = [v for v in args.builder_args if v != "--"] try: if args.prepared: - if args.tag or args.commit_build or args.channel_request or args.prepare_only or args.work or args.cache: + if (args.tag or args.commit_build or args.release_commit or args.channel_request + or args.prepare_only or args.work or args.cache): parser.error("--prepared supplies the complete build request") build_prepared(args.prepared, builder_args, args.variant) else: @@ -141,7 +138,8 @@ def main() -> None: cache=args.cache or args.repo / ".cache/desktop-inputs", bundle_env=decode(os.environ.get("HERMES_BUNDLE_ENV_JSON", "")), channel_request=json.loads(args.channel_request.read_text(encoding="utf-8-sig")) - if args.channel_request else None) + if args.channel_request else None, + release_commit=args.release_commit) if args.prepare_only and builder_args: parser.error("builder arguments belong to the build phase") result = prepare(request) diff --git a/scripts/bundles/desktop_prepare.py b/scripts/bundles/desktop_prepare.py index d5dca72140..949ad3d1fb 100644 --- a/scripts/bundles/desktop_prepare.py +++ b/scripts/bundles/desktop_prepare.py @@ -99,7 +99,7 @@ class BuildRequest: @classmethod def create(cls, source: Path, *, tag: str | None, commit: str | None, variant: str, work: Path, cache: Path, bundle_env: dict[str, str | None], - channel_request: dict | None = None) -> BuildRequest: + channel_request: dict | None = None, release_commit: str | None = None) -> BuildRequest: from pm.store import current_target from scripts.bundles.desktop import release_version from scripts.releases.bundle_env import validate @@ -139,7 +139,8 @@ class BuildRequest: else: assert tag is not None # The exclusive selection was checked above. version = release_version(source, tag) - commit = git(source, "rev-parse", "--verify", f"refs/tags/{tag}^{{commit}}") + commit = require_commit(release_commit) if release_commit else \ + git(source, "rev-parse", "--verify", f"refs/tags/{tag}^{{commit}}") require_source(source, commit) if channel_request is not None: if version != channel_request["sourceVersion"]: diff --git a/scripts/bundles/native.py b/scripts/bundles/native.py index aa773d5f1d..ee85016ab6 100644 --- a/scripts/bundles/native.py +++ b/scripts/bundles/native.py @@ -1,12 +1,13 @@ """Native payload staging through PM's existing package authority.""" from __future__ import annotations +import argparse +import datetime as dt import os import shutil import subprocess import sys import tempfile -import argparse from dataclasses import asdict from pathlib import Path @@ -177,6 +178,12 @@ def _prepare_native(*, out: Path, ref: str, source: Path, cache: Path, # with a revision selecting different pins. if (repo_dir / "pm/lock.json").read_bytes() != paths.lockfile_path().read_bytes(): raise ValueError("selected revision's PM lock differs from the builder; use a checkout at that revision") + build_env = os.environ if env is None else env + if version := build_env.get("HERMES_PAYLOAD_VERSION"): + from scripts.releases.stamping import stamp + now = dt.datetime.now(dt.UTC) + release_date = build_env.get("HERMES_RELEASE_DATE") or f"{now.year}.{now.month}.{now.day}" + stamp(repo_dir, version, release_date) names = [ n for n in _bundle_package_names() @@ -219,7 +226,7 @@ def _prepare_native(*, out: Path, ref: str, source: Path, cache: Path, venv_dir = out / "venv" if venv_dir.exists(): shutil.rmtree(venv_dir) - env = dict(os.environ if env is None else env) + env = dict(build_env) from pm import build_environment # Cold native wheels need a larger budget than interactive installs. diff --git a/scripts/releases/stable.py b/scripts/releases/stable.py index 0f5d65ee09..2a12d8fa45 100644 --- a/scripts/releases/stable.py +++ b/scripts/releases/stable.py @@ -38,12 +38,13 @@ def admit_claim(tag: str, commit: str, *, on_main) -> dict: raise ValueError(f"{tag} is not a claim tag") if not on_main(commit): raise ValueError(f"{commit} is not on main") - return {"version": version, "commit": commit} + return {"claim_tag": tag, "tag": f"v{version}", "version": version, "commit": commit} -def require_stable_identity(tag: str, commit: str, ref: str) -> None: - if not isinstance(tag, str) or not STABLE_TAG_RE.fullmatch(tag) or not SHA.fullmatch(commit or "") or ref != f"refs/tags/{tag}": - raise ValueError("Stable release must run on its exact stable tag and commit") +def require_stable_identity(tag: str, commit: str) -> None: + """Validate the final payload identity without requiring its future ref.""" + if not isinstance(tag, str) or not STABLE_TAG_RE.fullmatch(tag) or not SHA.fullmatch(commit or ""): + raise ValueError("Invalid stable payload identity") def require_success(needs: dict, required: list[str]) -> None: @@ -69,7 +70,7 @@ def successful_smoke_results(needs: object) -> dict: def validate_candidates(manifest: dict, tag: str, commit: str, public_base: str, *, allow_legacy: bool = False) -> dict: - require_stable_identity(tag, commit, f"refs/tags/{tag}") + require_stable_identity(tag, commit) if manifest.get("schema") not in (1, 2) or manifest.get("tag") != tag or manifest.get("commit") != commit or not isinstance(manifest.get("packages"), list): raise ValueError("Candidate manifest does not match release identity") if manifest["schema"] == 1: @@ -169,17 +170,47 @@ def output(argv: list[str]) -> str: return subprocess.check_output(argv, text=True, encoding="utf-8").strip() -def check_tag(env: dict, run=output) -> tuple[str, str]: - tag, commit = env.get("RELEASE_TAG"), env.get("GITHUB_SHA") - require_stable_identity(tag, commit, env.get("GITHUB_REF")) - actual = run(["git", "rev-parse", f"refs/tags/{tag}^{{commit}}"]) - remote = dict(line.split()[::-1] for line in run(["git", "ls-remote", "origin", f"refs/tags/{tag}", f"refs/tags/{tag}^{{}}"] ).splitlines()) - remote_commit = remote.get(f"refs/tags/{tag}^{{}}", remote.get(f"refs/tags/{tag}")) - if actual != commit or remote_commit != commit or run(["git", "rev-parse", "HEAD"]) != commit: - raise ValueError("Release tag or checkout moved") +def check_claim(env: dict, run=output) -> dict: + """Bind the run to one remote annotated claim object and its commit.""" + claim_tag, commit = env.get("RELEASE_CLAIM_TAG"), env.get("GITHUB_SHA") + if not isinstance(claim_tag, str) or env.get("GITHUB_REF") != f"refs/tags/{claim_tag}": + raise ValueError("Stable release must run on its exact claim ref") + if not isinstance(commit, str) or not SHA.fullmatch(commit): + raise ValueError("Stable claim needs an exact commit") + claim_ref = f"refs/tags/{claim_tag}" + local_object = run(["git", "rev-parse", claim_ref]) + local_commit = run(["git", "rev-parse", f"{claim_ref}^{{commit}}"]) + if run(["git", "cat-file", "-t", local_object]) != "tag": + raise ValueError("Stable claim must be an annotated tag") + remote = dict(line.split()[::-1] for line in run( + ["git", "ls-remote", "origin", claim_ref, f"{claim_ref}^{{}}"] + ).splitlines()) + remote_object = remote.get(claim_ref) + remote_commit = remote.get(f"{claim_ref}^{{}}") + expected_object = env.get("RELEASE_CLAIM_OBJECT") + if (local_commit != commit or remote_commit != commit or remote_object != local_object + or (expected_object and remote_object != expected_object) + or run(["git", "rev-parse", "HEAD"]) != commit): + raise ValueError("Stable claim tag or checkout moved") run(["git", "fetch", "origin", "main"]) - run(["git", "merge-base", "--is-ancestor", commit, "origin/main"]) - return tag, commit + + def on_main(sha: str) -> bool: + try: + run(["git", "merge-base", "--is-ancestor", sha, "origin/main"]) + except subprocess.CalledProcessError: + return False + return True + + admitted = admit_claim(claim_tag, commit, on_main=on_main) + return {**admitted, "claim_object": local_object} + + +def stable_context(env: dict, run=output) -> tuple[str, str, dict]: + claim = check_claim(env, run=run) + tag = env.get("RELEASE_TAG") + if not isinstance(tag, str) or tag != claim["tag"]: + raise ValueError("Stable payload tag differs from the admitted claim") + return tag, claim["commit"], claim def emit(values: dict, env: dict) -> None: @@ -199,7 +230,7 @@ def read_admitted_candidate(tag: str, commit: str, public_base: str, digest: str """The page and package promoter consume the same pinned admission.""" if not DIGEST.fullmatch(digest or ""): raise ValueError("Pinned candidate manifest digest is required") - require_stable_identity(tag, commit, f"refs/tags/{tag}") + require_stable_identity(tag, commit) manifest = read_manifest(f"{public_base.rstrip('/')}/releases/tag/{tag}/release-candidates.json", digest, expected_origin=public_base) validate_candidates(manifest, tag, commit, public_base) @@ -213,24 +244,37 @@ def summary(text: str, env: dict) -> None: def admit(env: dict) -> None: """Admit the claim. The checkout carries 0.0.0, so the tag is the version.""" - tag, commit = env.get("RELEASE_TAG"), env.get("GITHUB_SHA") - admitted = admit_claim(tag, commit, on_main=lambda sha: _on_main(sha, env)) - emit({"tag": tag, "commit": admitted["commit"], "version": admitted["version"]}, env) - summary(f"## Stable candidate {tag}\nCommit: {commit}\nVersion: {admitted['version']}\n", env) + admitted = check_claim(env) + repository = env["GITHUB_REPOSITORY"] + release = json.loads(output([ + "gh", "release", "view", admitted["claim_tag"], "--repo", repository, + "--json", "databaseId,tagName,isDraft,isPrerelease", + ])) + if (release.get("tagName") != admitted["claim_tag"] or release.get("isDraft") is not True + or release.get("isPrerelease") is not False or not isinstance(release.get("databaseId"), int)): + raise ValueError("Stable claim must already own one non-prerelease draft") + emit({ + "claim-tag": admitted["claim_tag"], "claim-object": admitted["claim_object"], + "tag": admitted["tag"], "commit": admitted["commit"], "version": admitted["version"], + "release-id": release["databaseId"], + }, env) + summary( + f"## Stable candidate {admitted['claim_tag']}\nCommit: {admitted['commit']}\n" + f"Version: {admitted['version']}\nPayload tag: {admitted['tag']}\n", + env, + ) -def _on_main(commit: str, env: dict) -> bool: - try: - output(["git", "merge-base", "--is-ancestor", commit, "origin/main"]) - except subprocess.CalledProcessError: - return False - return True +def verify(env: dict) -> None: + """Revalidate claim custody in a reusable privileged workflow.""" + tag, commit, _claim = stable_context(env) + emit({"tag": tag, "sha": commit, "channel": "stable", "payload-version": tag[1:]}, env) def transitions(env: dict) -> None: from scripts.releases.r2 import put - tag, commit = check_tag(env) + tag, commit, _claim = stable_context(env) base = env["CLOUDFLARE_R2_PUBLIC_URL"].rstrip("/") candidate = read_candidate(env) validate_candidates(candidate, tag, commit, base) @@ -258,22 +302,75 @@ def transitions(env: dict) -> None: emit(matrices, env) -def complete(env: dict) -> None: - from scripts.releases.r2 import put +def ensure_final_tag(tag: str, commit: str, claim: dict, run=output) -> str: + """Create or verify the immutable annotated final tag.""" + require_stable_identity(tag, commit) + ref = f"refs/tags/{tag}" + remote_raw = run(["git", "ls-remote", "origin", ref, f"{ref}^{{}}"]) + if not remote_raw: + try: + local_object = run(["git", "rev-parse", "--verify", ref]) + except subprocess.CalledProcessError: + message = json.dumps({ + "schema": 1, "version": tag[1:], "commit": commit, + "claimTag": claim["claim_tag"], "claimTagObject": claim["claim_object"], + }, sort_keys=True, separators=(",", ":")) + run([ + "git", "-c", "user.name=Hermes Release Automation", + "-c", "user.email=release-bot@users.noreply.github.com", + "tag", "-a", tag, commit, "-m", message, + ]) + else: + if (run(["git", "cat-file", "-t", local_object]) != "tag" + or run(["git", "rev-parse", f"{ref}^{{commit}}"]) != commit): + raise ValueError("Local final tag collision") + run(["git", "push", "origin", ref]) + remote_raw = run(["git", "ls-remote", "origin", ref, f"{ref}^{{}}"]) + remote = dict(line.split()[::-1] for line in remote_raw.splitlines()) + tag_object, peeled = remote.get(ref), remote.get(f"{ref}^{{}}") + if not tag_object or peeled != commit: + raise ValueError("Final stable tag points at the wrong commit or is lightweight") + try: + local_object = run(["git", "rev-parse", ref]) + except subprocess.CalledProcessError: + run(["git", "fetch", "origin", f"{ref}:{ref}"]) + local_object = run(["git", "rev-parse", ref]) + if local_object != tag_object or run(["git", "cat-file", "-t", local_object]) != "tag": + raise ValueError("Final stable tag object differs from the verified remote") + return tag_object - tag, commit = check_tag(env) + +def _autopublish(value: str | None) -> bool: + if value not in {"true", "false"}: + raise ValueError("AUTOPUBLISH must be exactly true or false") + return value == "true" + + +def retarget_release(repository: str, release_id: int, tag: str, commit: str, *, publish: bool, + run=output) -> None: + endpoint = f"repos/{repository}/releases/{release_id}" + run([ + "gh", "api", "--method", "PATCH", endpoint, + "--raw-field", f"tag_name={tag}", "--raw-field", f"target_commitish={commit}", + "--field", "prerelease=false", "--field", f"draft={str(not publish).lower()}", + ]) + release = json.loads(run(["gh", "api", endpoint])) + if (release.get("id") != release_id or release.get("tag_name") != tag + or release.get("prerelease") is not False or release.get("draft") is not (not publish)): + raise ValueError("Stable release retarget did not persist") + + +def complete(env: dict) -> None: + tag, commit, claim = stable_context(env) base = env["CLOUDFLARE_R2_PUBLIC_URL"].rstrip("/") candidate = read_candidate(env) validate_candidates(candidate, tag, commit, base) - file = Path(env["RUNNER_TEMP"]) / "release-candidates.json" - file.write_text(json.dumps(candidate), encoding="utf-8") - put(tag=tag, key="releases/stable/release-candidates.json", key_is_full=True, file=file) - if read_manifest(f"{base}/releases/stable/release-candidates.json") != candidate: - raise ValueError("Stable manifest read-back mismatch") - output(["gh", "release", "edit", tag, "--repo", env["GITHUB_REPOSITORY"], "--draft=false"]) - release = json.loads(output(["gh", "release", "view", tag, "--repo", env["GITHUB_REPOSITORY"], "--json", "isDraft"])) - if release["isDraft"]: - raise ValueError("Stable release remained a draft") + ensure_final_tag(tag, commit, claim) + release_id = env.get("RELEASE_ID", "") + if not str(release_id).isdigit(): + raise ValueError("Stable release database ID is required") + publish = _autopublish(env.get("AUTOPUBLISH")) + retarget_release(env["GITHUB_REPOSITORY"], int(release_id), tag, commit, publish=publish) def main(argv: list[str] | None = None, env: dict | None = None) -> None: @@ -284,9 +381,9 @@ def main(argv: list[str] | None = None, env: dict | None = None) -> None: summary("\n".join(f"- {name}: {needs.get(name, {}).get('result', 'missing')}" for name in argv[1:]), env) require_success(needs, argv[1:]) return - commands = {"admit": admit, "transitions": transitions, "complete": complete} + commands = {"admit": admit, "verify": verify, "transitions": transitions, "complete": complete} if len(argv) != 1 or argv[0] not in commands: - raise ValueError("Expected admit, gate, transitions or complete") + raise ValueError("Expected admit, verify, gate, transitions or complete") commands[argv[0]](env) diff --git a/scripts/releases/stamping.py b/scripts/releases/stamping.py index bae65b44f1..f83bfe7e21 100644 --- a/scripts/releases/stamping.py +++ b/scripts/releases/stamping.py @@ -7,6 +7,8 @@ placeholder version. """ from __future__ import annotations +import argparse +import datetime as dt import re from pathlib import Path @@ -29,10 +31,17 @@ def stamp(tree: Path, version: str, release_date: str) -> list[Path]: written.append(path) init = tree / "hermes_cli" / "__init__.py" - _rewrite(init, r'__version__\s*=\s*"[^"]+"', f'__version__ = "{version}"') _rewrite(init, r'__release_date__\s*=\s*"[^"]+"', f'__release_date__ = "{release_date}"') touch(init) + generated = tree / "hermes_cli" / "_version.py" + generated.write_text( + '"""Generated release identity. Do not commit."""\n\n' + f'__version__ = "{version}"\n', + encoding="utf-8", + ) + written.append(generated) + pyproject = tree / "pyproject.toml" _rewrite(pyproject, r'^version\s*=\s*"[^"]+"', f'version = "{version}"', count=1, flags=re.MULTILINE) touch(pyproject) @@ -66,3 +75,17 @@ def stamp(tree: Path, version: str, release_date: str) -> list[Path]: touch(path) return written + + +def main(argv: list[str] | None = None) -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--tree", type=Path, required=True) + parser.add_argument("--version", required=True) + now = dt.datetime.now(dt.UTC) + parser.add_argument("--release-date", default=f"{now.year}.{now.month}.{now.day}") + args = parser.parse_args(argv) + stamp(args.tree.resolve(), args.version, args.release_date) + + +if __name__ == "__main__": + main() diff --git a/scripts/termux/build.py b/scripts/termux/build.py index 1d0e085f0f..f42eaf7410 100644 --- a/scripts/termux/build.py +++ b/scripts/termux/build.py @@ -26,6 +26,7 @@ def main(argv: list[str] | None = None) -> int: identity = parser.add_mutually_exclusive_group(required=True) identity.add_argument("--tag") identity.add_argument("--commit") + parser.add_argument("--release-commit") args = parser.parse_args(argv) try: @@ -33,6 +34,10 @@ def main(argv: list[str] | None = None) -> int: deb_version_for_tag(args.tag) else: require_commit(args.commit) + if args.release_commit is not None: + if args.tag is None: + parser.error("--release-commit requires --tag") + require_commit(args.release_commit) except ValueError as exc: parser.error(str(exc)) @@ -42,6 +47,8 @@ def main(argv: list[str] | None = None) -> int: parser.error(f"{option} must name an existing directory: {path}") product = repo / ".build/termux/tui" revision = ["--tag", args.tag] if args.tag is not None else ["--commit", args.commit] + if args.release_commit is not None: + revision.extend(["--release-commit", args.release_commit]) commands = [ ["node", str(repo / "scripts/build/node-deps.mjs"), "--source", str(repo), "--workspace", "ui-tui"], diff --git a/scripts/termux/build_deb.sh b/scripts/termux/build_deb.sh index 345718d797..0077c1412b 100644 --- a/scripts/termux/build_deb.sh +++ b/scripts/termux/build_deb.sh @@ -32,6 +32,7 @@ REPO_ROOT="$(cd "$HERE/../.." && pwd)" REPO="" TAG="" COMMIT_MODE="" +RELEASE_COMMIT="" PAYLOAD="" OUT="" TUI_PRODUCT="" @@ -45,6 +46,7 @@ while [ "$#" -gt 0 ]; do --repo) REPO="${2:?}"; shift 2 ;; --tag) TAG="${2:?}"; shift 2 ;; --commit) COMMIT_MODE="${2:?}"; shift 2 ;; + --release-commit) RELEASE_COMMIT="${2:?}"; shift 2 ;; --payload) PAYLOAD="${2:?}"; shift 2 ;; --tui-product) TUI_PRODUCT="${2:?}"; shift 2 ;; --out) OUT="${2:?}"; shift 2 ;; @@ -52,7 +54,9 @@ while [ "$#" -gt 0 ]; do esac done [ -n "$REPO" ] && [ -n "$PAYLOAD" ] && [ -n "$OUT" ] || usage -{ [ -n "$TAG" ] || [ -n "$COMMIT_MODE" ]; } && { [ -z "$TAG" ] || [ -z "$COMMIT_MODE" ]; } || usage +{ [ -n "$TAG" ] || [ -n "$COMMIT_MODE" ]; } || usage +{ [ -z "$TAG" ] || [ -z "$COMMIT_MODE" ]; } || usage +[ -z "$RELEASE_COMMIT" ] || { [ -n "$TAG" ] && [ -z "$COMMIT_MODE" ]; } || usage for tool in python3 git docker dpkg-deb jq; do command -v "$tool" >/dev/null || fail "missing tool: $tool" @@ -66,26 +70,33 @@ REPO_ABS="$(cd "$REPO" && pwd)" PAYLOAD_ABS="$(cd "$PAYLOAD" && pwd)" # Resolve source identity before writing output or changing payload files. -# Commit mode requires the checkout HEAD and staged version to agree. -if [ -n "$COMMIT_MODE" ]; then - [[ "$COMMIT_MODE" =~ ^[a-f0-9]{40}$ ]] || fail "--commit requires an exact full 40-character SHA" +# Commit and pre-final stable modes require the checkout HEAD and staged version to agree. +if [ -n "$COMMIT_MODE" ] || [ -n "$RELEASE_COMMIT" ]; then + SELECTED_COMMIT="${RELEASE_COMMIT:-$COMMIT_MODE}" + [[ "$SELECTED_COMMIT" =~ ^[a-f0-9]{40}$ ]] || fail "commit identity requires an exact full 40-character SHA" COMMIT="$(git -C "$REPO_ABS" rev-parse HEAD)" || fail "not a git checkout: $REPO_ABS" - [ "$COMMIT" = "$COMMIT_MODE" ] || fail "checkout HEAD $(echo "$COMMIT" | cut -c1-12) is not the requested commit" - PY_VERSION="$(python3 - "$REPO_ROOT" "$REPO_ABS" "$COMMIT" "$PAYLOAD_ABS/app/pyproject.toml" <<'PY' + [ "$COMMIT" = "$SELECTED_COMMIT" ] || fail "checkout HEAD $(echo "$COMMIT" | cut -c1-12) is not the requested commit" + PY_VERSION="$(python3 - "$REPO_ROOT" "$REPO_ABS" "$COMMIT" "$PAYLOAD_ABS/app/pyproject.toml" "$TAG" <<'PY' import sys, tomllib from pathlib import Path sys.path.insert(0, sys.argv[1]) from scripts.releases.commit_build import version_at -version = version_at(Path(sys.argv[2]), sys.argv[3]) +version = sys.argv[5][1:] if sys.argv[5] else version_at(Path(sys.argv[2]), sys.argv[3]) staged = tomllib.loads(Path(sys.argv[4]).read_text(encoding="utf-8"))["project"]["version"] if staged != version: raise ValueError("payload version does not match the admitted commit") print(version) PY )" || fail "commit version validation failed" - DEB_VERSION="${PY_VERSION}+commit${COMMIT_MODE:0:12}" - export HERMES_PAYLOAD_TAG="" - export HERMES_BUILD_COMMIT="$COMMIT_MODE" + if [ -n "$RELEASE_COMMIT" ]; then + DEB_VERSION="$(python3 "$HERE/deb_version.py" "$TAG")" || fail "version derivation failed for tag $TAG" + export HERMES_PAYLOAD_TAG="$TAG" + unset HERMES_BUILD_COMMIT + else + DEB_VERSION="${PY_VERSION}+commit${COMMIT_MODE:0:12}" + export HERMES_PAYLOAD_TAG="" + export HERMES_BUILD_COMMIT="$COMMIT_MODE" + fi else unset HERMES_BUILD_COMMIT COMMIT="$(git -C "$REPO_ABS" rev-parse --verify "refs/tags/$TAG^{commit}")" \ @@ -106,7 +117,7 @@ PKG="hermes-agent" # [1] Version derivation: tag mode uses the pure function in deb_version.py # (tested separately). Commit mode derives it from pyproject above. -if [ -z "$COMMIT_MODE" ]; then +if [ -z "$COMMIT_MODE" ] && [ -z "$RELEASE_COMMIT" ]; then log "Deriving Debian version from tag $TAG" DEB_VERSION="$(python3 "$HERE/deb_version.py" "$TAG")" || fail "version derivation failed for tag $TAG" fi diff --git a/scripts/termux/termux_build.sh b/scripts/termux/termux_build.sh index 0217fb5e7c..abe6f73297 100755 --- a/scripts/termux/termux_build.sh +++ b/scripts/termux/termux_build.sh @@ -146,18 +146,23 @@ fi REPO="" TAG="" COMMIT_MODE="" +RELEASE_COMMIT="" OUT="" while [ "$#" -gt 0 ]; do case "$1" in --repo) REPO="${2:?}"; shift 2 ;; --tag) TAG="${2:?}"; shift 2 ;; --commit) COMMIT_MODE="${2:?}"; shift 2 ;; + --release-commit) RELEASE_COMMIT="${2:?}"; shift 2 ;; --out) OUT="${2:?}"; shift 2 ;; *) printf 'usage: termux_build.sh --repo (--tag | --commit ) --out \n' >&2; exit 2 ;; esac done -[ -n "$REPO" ] && [ -n "$OUT" ] && { [ -n "$TAG" ] || [ -n "$COMMIT_MODE" ]; } && { [ -z "$TAG" ] || [ -z "$COMMIT_MODE" ]; } || { +[ -n "$REPO" ] && [ -n "$OUT" ] && { [ -n "$TAG" ] || [ -n "$COMMIT_MODE" ]; } || { printf 'usage: termux_build.sh --repo (--tag | --commit ) --out \n' >&2; exit 2; } +{ [ -z "$TAG" ] || [ -z "$COMMIT_MODE" ]; } || fail "--tag and --commit are mutually exclusive" +[ -z "$RELEASE_COMMIT" ] || { [ -n "$TAG" ] && [ -z "$COMMIT_MODE" ]; } \ + || fail "--release-commit requires --tag and conflicts with --commit" for tool in git curl docker python3; do command -v "$tool" >/dev/null 2>&1 \ @@ -171,7 +176,13 @@ case "$ARCH" in esac # Check source identity before writing build output. -if [ -n "$COMMIT_MODE" ]; then +if [ -n "$RELEASE_COMMIT" ]; then + [[ "$RELEASE_COMMIT" =~ ^[a-f0-9]{40}$ ]] || fail "--release-commit requires an exact full 40-character SHA" + [ "$(git -C "$REPO" rev-parse HEAD)" = "$RELEASE_COMMIT" ] || fail "checkout does not match --release-commit" + python3 "$HERE/deb_version.py" "$TAG" >/dev/null || fail "invalid release identity $TAG" + log "Stable release build of $TAG at admitted commit $RELEASE_COMMIT" + REF="$RELEASE_COMMIT" +elif [ -n "$COMMIT_MODE" ]; then [[ "$COMMIT_MODE" =~ ^[a-f0-9]{40}$ ]] || fail "--commit requires an exact full 40-character SHA" [ "$(git -C "$REPO" rev-parse HEAD)" = "$COMMIT_MODE" ] || fail "checkout does not match --commit" log "Commit-only build of $COMMIT_MODE -- skipping the tag/release gates" @@ -205,6 +216,7 @@ sys.path.insert(0, sys.argv[1]) from scripts.bundles.payload import snapshot snapshot(Path(sys.argv[1]), sys.argv[2], Path(sys.argv[3])) PY +[ -z "$TAG" ] || python3 "$REPO_ABS/scripts/releases/stamping.py" --tree "$WORK/tree" --version "${TAG#v}" [ -f "$WORK/tree/pyproject.toml" ] || fail "archived tag tree has no pyproject.toml -- bad tag?" REPO_ROOT="$(cd "$HERE/../.." && pwd)" DIGEST="$(cd "$REPO_ROOT" && python3 -c 'from pm.lock import termux_docker_digest; print(termux_docker_digest())')" @@ -397,7 +409,7 @@ cp -a "$WORK/tree" "$OUT_ABS/app" # [h] Only a successful native build and both gates can publish cache proof. log "Emitting index.json and SHA256SUMS" provenance=(--tag "$TAG") -if [ -n "$COMMIT_MODE" ]; then provenance=(--commit "$COMMIT_MODE"); fi +if [ -z "$TAG" ]; then provenance=(--commit "$COMMIT_MODE"); fi python3 "$HERE/wheelhouse_cache.py" write "${CACHE_ARGS[@]}" "${provenance[@]}" \ || fail "manifest emission failed" diff --git a/tests/ci/test_desktop_release_tag_admission.py b/tests/ci/test_desktop_release_tag_admission.py index fbb9fb37ea..74701d64e8 100644 --- a/tests/ci/test_desktop_release_tag_admission.py +++ b/tests/ci/test_desktop_release_tag_admission.py @@ -6,9 +6,9 @@ unreviewed commit must never reach the signing build. Two layers are tested: * Structure — the workflow declares the admitted SHA as a job output and every privileged job checks out THAT, not the (moveable) tag ref. -* Behavior — the admission script is executed, verbatim from the workflow - YAML, inside real temp git repositories: a tag on origin/main passes and - exports the full SHA; a tag-shaped ref NOT on main is refused. +* Behavior — the production admission command runs inside real temp git + repositories: an annotated claim on origin/main passes and exports the full + SHA; a claim for a commit NOT on main is refused. """ from __future__ import annotations @@ -31,13 +31,6 @@ def _workflow() -> dict: return yaml.safe_load(_WORKFLOW.read_text(encoding="utf-8")) -def _admission_script() -> str: - """The admission run script, verbatim — the single source of truth.""" - steps = _workflow()["jobs"]["validate"]["steps"] - scripts = [s for s in steps if isinstance(s, dict) and s.get("id") == "admission"] - assert len(scripts) == 1, "expected exactly one admission step in the validate job" - return scripts[0]["run"] - # --------------------------------------------------------------------------- # Structure: the admitted SHA is the only build input privileged jobs see. @@ -69,7 +62,7 @@ def test_signing_jobs_pin_source_and_controller_revisions_not_mutable_tags(): if name in {"publish-channel"} or step.get("if") == "needs.validate.outputs.channel-build != ''": expected = "${{ github.sha }}" elif name == "validate": - expected = "${{ (inputs.build_commit != '' || inputs.channel != '') && github.sha || inputs.tag }}" + expected = "${{ (inputs.build_commit != '' || inputs.channel != '' || inputs.release-phase != '') && github.sha || inputs.tag }}" elif name == "assemble-win32-bundle": expected = "${{ needs.validate.outputs.channel-build != '' && github.sha || needs.validate.outputs.sha }}" assert ref == expected, ( @@ -162,23 +155,22 @@ def _seed_repo(root: Path) -> tuple[Path, Path]: return origin, clone -def _run_admission(clone: Path, tag: str) -> subprocess.CompletedProcess: +def _run_admission(clone: Path, tag: str, claim_tag: str) -> subprocess.CompletedProcess: gh_output = clone / "github_output.txt" gh_output.write_text("", encoding="utf-8") env = _child_env( TAG=tag, + RELEASE_TAG=tag, + RELEASE_CLAIM_TAG=claim_tag, + RELEASE_CLAIM_OBJECT=_git("rev-parse", f"refs/tags/{claim_tag}", cwd=clone), GITHUB_OUTPUT=str(gh_output), - RELEASE_PHASE="" if "+canary." in tag else "candidate", - GITHUB_REF=f"refs/tags/{tag}", + RELEASE_PHASE="candidate", + GITHUB_REF=f"refs/tags/{claim_tag}", GITHUB_SHA=_git("rev-parse", "HEAD", cwd=clone), - # checkout@v6 runs run-steps with `bash -e -o pipefail`; -e/-o are on - # the command line below, so nothing else is needed from the env. + PYTHONPATH=str(_REPO), ) - script = _admission_script() - script_file = clone / "admission.sh" - script_file.write_text(script, encoding="utf-8") return subprocess.run( - [_BASH, "-e", "-o", "pipefail", str(script_file)], + [sys.executable, "-m", "scripts.releases.stable", "verify"], cwd=clone, env=env, capture_output=True, @@ -187,11 +179,12 @@ def _run_admission(clone: Path, tag: str) -> subprocess.CompletedProcess: ) -def test_tag_on_origin_main_is_admitted_and_exports_the_full_sha(tmp_path: Path): +def test_claim_on_origin_main_is_admitted_and_exports_the_full_sha(tmp_path: Path): _origin, clone = _seed_repo(tmp_path) - _git("tag", "v0.1.2", cwd=clone) + _git("tag", "-a", "v0.1.2-rc", "-m", "claim", cwd=clone) + _git("push", "origin", "refs/tags/v0.1.2-rc", cwd=clone) - proc = _run_admission(clone, "v0.1.2") + proc = _run_admission(clone, "v0.1.2", "v0.1.2-rc") assert proc.returncode == 0, proc.stdout + proc.stderr gh_output = (clone / "github_output.txt").read_text(encoding="utf-8") @@ -199,30 +192,29 @@ def test_tag_on_origin_main_is_admitted_and_exports_the_full_sha(tmp_path: Path) assert f"sha={expected}" in gh_output -def test_tag_not_on_origin_main_is_refused(tmp_path: Path): +def test_claim_not_on_origin_main_is_refused(tmp_path: Path): _origin, clone = _seed_repo(tmp_path) # A commit that exists ONLY in the clone — never pushed, never reviewed. (clone / "rogue.txt").write_text("unreviewed\n", encoding="utf-8") _git("add", "-A", cwd=clone) _git("commit", "-m", "rogue", cwd=clone) - _git("tag", "v0.1.2+canary.20260830T120000Z", cwd=clone) + _git("tag", "-a", "v0.1.2-rc", "-m", "claim", cwd=clone) + _git("push", "origin", "refs/tags/v0.1.2-rc", cwd=clone) - proc = _run_admission(clone, "v0.1.2+canary.20260830T120000Z") + proc = _run_admission(clone, "v0.1.2", "v0.1.2-rc") assert proc.returncode != 0, "a tag off origin/main must not be admitted" - assert "not an ancestor of origin/main" in proc.stdout + proc.stderr + assert "is not on main" in proc.stdout + proc.stderr # And nothing was exported for the signing jobs to consume. assert "sha=" not in (clone / "github_output.txt").read_text(encoding="utf-8") -def test_malformed_tag_is_refused_before_any_git_work(tmp_path: Path): +def test_malformed_claim_is_refused(tmp_path: Path): _origin, clone = _seed_repo(tmp_path) - proc = _run_admission(clone, "v0.1.2-rc1") + _git("tag", "-a", "v0.1.2-rc1", "-m", "bad claim", cwd=clone) + _git("push", "origin", "refs/tags/v0.1.2-rc1", cwd=clone) + proc = _run_admission(clone, "v0.1.2", "v0.1.2-rc1") assert proc.returncode != 0 - # Refused at the shape/lockstep legs — either message is a valid refusal. - assert ( - "not a release tag" in proc.stdout + proc.stderr - or "does not match pyproject.toml version" in proc.stdout + proc.stderr - ) + assert "is not a claim tag" in proc.stdout + proc.stderr def _require_step(job: str, name_prefix: str) -> dict: diff --git a/tests/ci/test_stable_release_graph.py b/tests/ci/test_stable_release_graph.py index ff6ff91f04..83d6e2797f 100644 --- a/tests/ci/test_stable_release_graph.py +++ b/tests/ci/test_stable_release_graph.py @@ -47,3 +47,22 @@ def test_all_applicable_ci_jobs_are_aggregated_and_desktop_e2e_stays_deferred(): assert checks <= set(jobs["all-checks-pass"]["needs"]) assert jobs["e2e-desktop"]["if"] == "false" assert "workflow_call" in workflow("ci.yaml")["on"] + + +def test_claim_custody_and_final_payload_identity_reach_every_privileged_phase(): + release = workflow("stable-release.yml") + jobs = release["jobs"] + assert release["on"]["workflow_dispatch"]["inputs"]["autopublish"]["required"] == "true" + assert {"claim-tag", "claim-object", "tag", "commit", "version", "release-id"} <= \ + set(jobs["admit"]["outputs"]) + for name in ("candidates", "publish-bundles", "promote-bundles"): + call = jobs[name]["with"] + assert call["tag"] == "${{ needs.admit.outputs.tag }}" + assert call["claim-tag"] == "${{ needs.admit.outputs.claim-tag }}" + assert call["claim-object"] == "${{ needs.admit.outputs.claim-object }}" + for name in ("docker", "nix", "pm-bundle"): + assert jobs[name]["with"]["version"] == "${{ needs.admit.outputs.version }}" + complete = jobs["complete"]["steps"] + final = next(i for i, step in enumerate(complete) if step.get("name", "").startswith("Create the final tag")) + render = next(i for i, step in enumerate(complete) if step.get("name", "").startswith("Render the admitted")) + assert final < render diff --git a/tests/scripts/test_bundle_payload.py b/tests/scripts/test_bundle_payload.py index 835f530fc1..07d0b2f14e 100644 --- a/tests/scripts/test_bundle_payload.py +++ b/tests/scripts/test_bundle_payload.py @@ -16,11 +16,10 @@ from scripts.build.launchers import posix_launcher from scripts.bundles.desktop import release_version -def test_release_version_must_match_project(tmp_path): - (tmp_path / "pyproject.toml").write_text('[project]\nversion="1.2.3"\n', encoding="utf-8") +def test_release_version_comes_from_the_release_identity_not_the_checkout(tmp_path): + (tmp_path / "pyproject.toml").write_text('[project]\nversion="0.0.0"\n', encoding="utf-8") assert release_version(tmp_path, "v1.2.3") == "1.2.3" - with pytest.raises(ValueError): - release_version(tmp_path, "v1.2.4") + assert release_version(tmp_path, "v1.2.4") == "1.2.4" def test_wrapper_rejects_unresolved_template_fields(tmp_path, monkeypatch): diff --git a/tests/scripts/test_desktop_preparation.py b/tests/scripts/test_desktop_preparation.py index 0bffb5fce3..537f65e863 100644 --- a/tests/scripts/test_desktop_preparation.py +++ b/tests/scripts/test_desktop_preparation.py @@ -29,6 +29,19 @@ def _project(tmp_path: Path) -> tuple[Path, str]: return source, commit +def test_stable_build_accepts_the_admitted_commit_before_the_final_tag_exists(tmp_path): + from scripts.bundles.desktop_prepare import BuildRequest + + source, commit = _project(tmp_path) + request = BuildRequest.create( + source, tag="v1.2.4", commit=None, release_commit=commit, variant="bundled", + work=tmp_path / "work", cache=tmp_path / "cache", bundle_env={}, + ) + + assert request.commit == commit + assert request.version == "1.2.4" + + def test_prepared_input_roundtrip_rejects_mutation_and_foreign_source(tmp_path): from scripts.bundles.desktop_prepare import BuildRequest, PreparedDesktop diff --git a/tests/scripts/test_stable_admission.py b/tests/scripts/test_stable_admission.py index 48453e42f8..5083c21583 100644 --- a/tests/scripts/test_stable_admission.py +++ b/tests/scripts/test_stable_admission.py @@ -13,7 +13,10 @@ def test_admission_reads_the_version_from_the_claim(): commit = "a" * 40 admitted = admit_claim("v0.21.5-rc", commit, on_main=lambda sha: sha == commit) - assert admitted == {"version": "0.21.5", "commit": commit} + assert admitted == { + "claim_tag": "v0.21.5-rc", "tag": "v0.21.5", + "version": "0.21.5", "commit": commit, + } def test_admission_refuses_a_claim_for_a_commit_off_main(): diff --git a/tests/scripts/test_stable_release.py b/tests/scripts/test_stable_release.py index 1ee2f30cad..608dea8564 100644 --- a/tests/scripts/test_stable_release.py +++ b/tests/scripts/test_stable_release.py @@ -10,8 +10,8 @@ from pathlib import Path import pytest from scripts.releases.stable import ( - check_tag, plan_transitions, read_manifest, require_stable_identity, - require_success, validate_candidates, + check_claim, ensure_final_tag, plan_transitions, read_manifest, require_stable_identity, + require_success, retarget_release, validate_candidates, ) BASE = "https://releases.example" @@ -70,10 +70,10 @@ def test_transitions_bind_all_arches_identity_version_and_archive(): with pytest.raises(ValueError, match="Legacy candidate"): validate_candidates(old, old["tag"], old["commit"], BASE) new = candidates("v1.2.4", "b" * 40, "2" * 64) - require_stable_identity(new["tag"], new["commit"], "refs/tags/v1.2.4") - for tag, ref in [("v1.2.4", "refs/heads/main"), ("v1.2.4+canary.20260907T143420Z", "refs/tags/v1.2.4+canary.20260907T143420Z")]: + require_stable_identity(new["tag"], new["commit"]) + for tag in ("v1.2.4+canary.20260907T143420Z", "v1.2.4-rc"): with pytest.raises(ValueError): - require_stable_identity(tag, new["commit"], ref) + require_stable_identity(tag, new["commit"]) transitions = plan_transitions(old, new, BASE) assert {row["target"] for row in transitions} == {"windows-x64", "windows-arm64", "macos-x64", "macos-arm64"} assert all(row["transition"]["new"]["commit"] == new["commit"] for row in transitions) @@ -186,18 +186,31 @@ def test_manifest_origin_checks_with_real_https(https_origin): assert server.requests == [] -def test_tag_movement_fails_closed(tmp_path, monkeypatch): +def test_claim_object_movement_and_lightweight_tags_fail_closed(tmp_path, monkeypatch): commit = "a" * 40 - env = {"RELEASE_TAG": "v1.2.3", "GITHUB_SHA": commit, "GITHUB_REF": "refs/tags/v1.2.3"} + claim_object = "b" * 40 + ref = "refs/tags/v1.2.3-rc" + env = {"RELEASE_CLAIM_TAG": "v1.2.3-rc", "RELEASE_CLAIM_OBJECT": claim_object, + "GITHUB_SHA": commit, "GITHUB_REF": ref} def git(argv): if argv[1] == "ls-remote": - return f"{'b' * 40}\trefs/tags/v1.2.3\n{commit}\trefs/tags/v1.2.3^{{}}" - return commit if argv[1] == "rev-parse" else "" + return f"{claim_object}\t{ref}\n{commit}\t{ref}^{{}}" + if argv[1] == "cat-file": + return "tag" + if argv[1] == "rev-parse": + return claim_object if argv[-1] == ref else commit + return "" - assert check_tag(env, git) == ("v1.2.3", commit) + assert check_claim(env, git) == { + "claim_tag": "v1.2.3-rc", "claim_object": claim_object, + "tag": "v1.2.3", "version": "1.2.3", "commit": commit, + } with pytest.raises(ValueError, match="moved"): - check_tag(env, lambda argv: f"{'c' * 40}\trefs/tags/v1.2.3" if argv[1] == "ls-remote" else git(argv)) + check_claim(env, lambda argv: f"{'c' * 40}\t{ref}\n{commit}\t{ref}^{{}}" + if argv[1] == "ls-remote" else git(argv)) + with pytest.raises(ValueError, match="annotated"): + check_claim(env, lambda argv: "commit" if argv[1] == "cat-file" else git(argv)) repo = tmp_path / "repo" remote = tmp_path / "remote.git" @@ -212,10 +225,39 @@ def test_tag_movement_fails_closed(tmp_path, monkeypatch): subprocess.run(["git", "commit", "-m", "first"], check=True, capture_output=True) actual = subprocess.check_output(["git", "rev-parse", "HEAD"], text=True, encoding="utf-8").strip() subprocess.run(["git", "remote", "add", "origin", str(remote)], check=True) - subprocess.run(["git", "tag", "v1.2.3"], check=True) - subprocess.run(["git", "push", "origin", "main", "v1.2.3"], check=True, capture_output=True) - env["GITHUB_SHA"] = actual - assert check_tag(env) == ("v1.2.3", actual) - subprocess.run(["git", "--git-dir", str(remote), "update-ref", "-d", "refs/tags/v1.2.3"], check=True) + subprocess.run(["git", "tag", "-a", "v1.2.3-rc", "-m", "claim"], check=True) + subprocess.run(["git", "push", "origin", "main", "v1.2.3-rc"], check=True, capture_output=True) + env.update({"GITHUB_SHA": actual, "RELEASE_CLAIM_OBJECT": subprocess.check_output( + ["git", "rev-parse", ref], text=True, encoding="utf-8").strip()}) + claim = check_claim(env) + assert claim["commit"] == actual + final_object = ensure_final_tag("v1.2.3", actual, claim) + remote_final = subprocess.check_output( + ["git", "ls-remote", "origin", "refs/tags/v1.2.3", "refs/tags/v1.2.3^{}"], + text=True, encoding="utf-8", + ) + assert f"{final_object}\trefs/tags/v1.2.3" in remote_final + assert f"{actual}\trefs/tags/v1.2.3^{{}}" in remote_final + subprocess.run(["git", "--git-dir", str(remote), "update-ref", "-d", ref], check=True) with pytest.raises(ValueError, match="moved"): - check_tag(env) + check_claim(env) + + +@pytest.mark.parametrize("publish", [False, True]) +def test_retarget_release_preserves_the_database_id_and_explicit_draft_policy(publish): + commit = "a" * 40 + calls = [] + + def gh(argv): + calls.append(argv) + if argv[1:3] == ["api", "repos/example/project/releases/42"]: + return json.dumps({ + "id": 42, "tag_name": "v1.2.3", "target_commitish": commit, + "prerelease": False, "draft": not publish, + }) + return "{}" + + retarget_release("example/project", 42, "v1.2.3", commit, publish=publish, run=gh) + + assert ["--field", f"draft={str(not publish).lower()}"] == calls[0][-2:] + assert calls[1] == ["gh", "api", "repos/example/project/releases/42"] diff --git a/tests/scripts/test_version_stamping.py b/tests/scripts/test_version_stamping.py index 794a2b66ef..23a76301d0 100644 --- a/tests/scripts/test_version_stamping.py +++ b/tests/scripts/test_version_stamping.py @@ -13,7 +13,7 @@ import pytest def _tree(root: Path) -> None: (root / "hermes_cli").mkdir() (root / "hermes_cli" / "__init__.py").write_text( - '__version__ = "0.0.0"\n__release_date__ = "2026.1.1"\n', encoding="utf-8") + '__release_date__ = "2026.1.1"\n', encoding="utf-8") (root / "pyproject.toml").write_text('version = "0.0.0"\n', encoding="utf-8") desktop = root / "apps" / "desktop" desktop.mkdir(parents=True) @@ -51,8 +51,8 @@ def test_stamping_writes_the_build_tree_and_leaves_the_source_tree(tmp_path): assert (source / "pyproject.toml").read_text(encoding="utf-8") == before assert 'version = "0.21.5"' in (build / "pyproject.toml").read_text(encoding="utf-8") init = (build / "hermes_cli" / "__init__.py").read_text(encoding="utf-8") - assert '__version__ = "0.21.5"' in init assert '__release_date__ = "2026.9.22"' in init + assert '__version__ = "0.21.5"' in (build / "hermes_cli" / "_version.py").read_text(encoding="utf-8") assert json.loads((build / "apps" / "desktop" / "package.json").read_text())["version"] == "0.21.5" # The lockfile root stays; only the desktop workspace entry moves. lock = json.loads((build / "package-lock.json").read_text())