feat(release): finalize stable claims into versioned builds
This commit is contained in:
44
.github/workflows/desktop-bundled-release.yml
vendored
44
.github/workflows/desktop-bundled-release.yml
vendored
@@ -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
|
||||
|
||||
11
.github/workflows/docker.yml
vendored
11
.github/workflows/docker.yml
vendored
@@ -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
|
||||
|
||||
16
.github/workflows/nix.yml
vendored
16
.github/workflows/nix.yml
vendored
@@ -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
|
||||
|
||||
7
.github/workflows/pm-bundle.yml
vendored
7
.github/workflows/pm-bundle.yml
vendored
@@ -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:
|
||||
|
||||
52
.github/workflows/stable-release.yml
vendored
52
.github/workflows/stable-release.yml
vendored
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user