feat(release): finalize stable claims into versioned builds

This commit is contained in:
ethernet
2026-09-21 22:39:16 -04:00
parent 9642882bab
commit dd72571178
20 changed files with 456 additions and 150 deletions

View File

@@ -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 <tag>` (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

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

@@ -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"

View File

@@ -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)

View File

@@ -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"]:

View File

@@ -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.

View File

@@ -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)

View File

@@ -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()

View File

@@ -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"],

View File

@@ -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

View File

@@ -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 <dir> (--tag <tag> | --commit <full-sha>) --out <dir>\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 <dir> (--tag <tag> | --commit <full-sha>) --out <dir>\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"

View File

@@ -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:

View File

@@ -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

View File

@@ -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):

View File

@@ -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

View File

@@ -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():

View File

@@ -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"]

View File

@@ -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())