mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
fix(release): close publication custody gaps
This commit is contained in:
1 parent
8e57bb6c37
commit
dcd678d6fd
61 files changed
+7790
-7365
No files matched your search
@@ -1,5 +1,6 @@
|
||||
# Auto-generated files — collapse diffs and exclude from language stats
|
||||
web/package-lock.json linguist-generated=true
|
||||
uv.lock text eol=lf
|
||||
|
||||
# Enforce LF for scripts that run inside Linux containers.
|
||||
# Without this, Windows checkout converts to CRLF and breaks `exec` in the
|
||||
@@ -22,6 +23,8 @@ docker/entrypoint.sh text eol=lf
|
||||
*.js text eol=lf
|
||||
*.mjs text eol=lf
|
||||
*.cjs text eol=lf
|
||||
*.cts text eol=lf
|
||||
*.mts text eol=lf
|
||||
*.jsx text eol=lf
|
||||
*.json text eol=lf
|
||||
*.yaml text eol=lf
|
||||
|
||||
@@ -166,6 +166,8 @@ env:
|
||||
# One dispatch: a disposable-mode run leases its namespace by run id alone
|
||||
# (no attempt suffix) so "re-run failed jobs" re-enters the same namespace.
|
||||
R2_DISPOSABLE_RUN: ${{ inputs.disposable_run || (inputs.disposable_channel != '' && github.run_id) || '' }}
|
||||
RELEASE_CLAIM_TAG: ${{ inputs.claim-tag }}
|
||||
RELEASE_CLAIM_OBJECT: ${{ inputs.claim-object }}
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
@@ -189,7 +191,9 @@ jobs:
|
||||
# publish in parallel by design — different feed files).
|
||||
channel: ${{ steps.admission.outputs.channel }}
|
||||
sha: ${{ steps.admission.outputs.sha }}
|
||||
tag-object: ${{ steps.admission.outputs.tag-object }}
|
||||
payload-version: ${{ steps.admission.outputs.payload-version }}
|
||||
release-epoch: ${{ steps.admission.outputs.release-epoch }}
|
||||
receiver-candidate: ${{ steps.admission.outputs.receiver-candidate }}
|
||||
public-root: ${{ steps.admission.outputs.public-root }}
|
||||
public-base: ${{ steps.admission.outputs.public-base }}
|
||||
@@ -304,7 +308,7 @@ jobs:
|
||||
echo "::error::either tag or build_commit is required"; exit 1
|
||||
fi
|
||||
case "$RELEASE_PHASE" in
|
||||
candidate|publish|promote)
|
||||
candidate|publish)
|
||||
python -m scripts.releases.stable verify
|
||||
exit 0
|
||||
;;
|
||||
@@ -342,8 +346,19 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
echo "tag $TAG resolves to $SHA (on origin/main)"
|
||||
TAG_OBJECT="$(git rev-parse --verify "$TAG^{tag}")" || {
|
||||
echo "::error::$TAG must be an annotated tag"; exit 1;
|
||||
}
|
||||
echo "tag-object=$TAG_OBJECT" >> "$GITHUB_OUTPUT"
|
||||
case "$TAG" in
|
||||
*+canary.*) echo "channel=canary" >> "$GITHUB_OUTPUT" ;;
|
||||
*+canary.*)
|
||||
echo "channel=canary" >> "$GITHUB_OUTPUT"
|
||||
python - "$TAG" >> "$GITHUB_OUTPUT" <<'PY'
|
||||
import datetime as dt, sys
|
||||
instant = dt.datetime.strptime(sys.argv[1].split("+canary.", 1)[1], "%Y%m%dT%H%M%SZ").replace(tzinfo=dt.UTC)
|
||||
print(f"release-epoch={int(instant.timestamp())}")
|
||||
PY
|
||||
;;
|
||||
*) echo "channel=stable" >> "$GITHUB_OUTPUT" ;;
|
||||
esac
|
||||
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
|
||||
@@ -375,6 +390,7 @@ jobs:
|
||||
CHANNEL_BUILD: ${{ needs.validate.outputs.channel-build }}
|
||||
HERMES_DESKTOP_VARIANT: bundled
|
||||
HERMES_PAYLOAD_TAG: ${{ inputs.tag }}
|
||||
RELEASE_CLAIM_TAG: ${{ inputs.claim-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 }}
|
||||
@@ -720,6 +736,7 @@ jobs:
|
||||
CHANNEL_BUILD: ${{ needs.validate.outputs.channel-build }}
|
||||
HERMES_DESKTOP_VARIANT: bundled
|
||||
HERMES_PAYLOAD_TAG: ${{ inputs.tag }}
|
||||
RELEASE_CLAIM_TAG: ${{ inputs.claim-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 }}
|
||||
@@ -1492,6 +1509,7 @@ jobs:
|
||||
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_RELEASE_EPOCH: ${{ needs.validate.outputs.release-epoch }}
|
||||
# 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".
|
||||
@@ -2023,6 +2041,7 @@ jobs:
|
||||
manifest-sha256: ${{ steps.manifest.outputs.manifest-sha256 }}
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
HERMES_RELEASE_EPOCH: ${{ needs.validate.outputs.release-epoch }}
|
||||
CLOUDFLARE_R2_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_R2_ACCOUNT_ID }}
|
||||
CLOUDFLARE_R2_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_ACCESS_KEY_ID }}
|
||||
CLOUDFLARE_R2_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_SECRET_ACCESS_KEY }}
|
||||
@@ -2192,19 +2211,13 @@ jobs:
|
||||
with:
|
||||
toolchain: node
|
||||
cache-python: false
|
||||
- name: Publish
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --draft=false
|
||||
echo "Published $TAG"
|
||||
# The controller rechecks native job results, the remote tag and published
|
||||
# GitHub release before any R2 head write; receipts bind the exact bytes.
|
||||
- name: Advance protected canary head from the published release
|
||||
# The controller rechecks native job results and exact remote tag custody,
|
||||
# publishes and reads back the prerelease, then advances R2.
|
||||
- name: Publish the admitted canary and advance its protected head
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
RELEASE_TAG_OBJECT: ${{ needs.validate.outputs.tag-object }}
|
||||
RELEASE_COMMIT: ${{ needs.validate.outputs.sha }}
|
||||
RELEASE_NEEDS: ${{ toJSON(needs) }}
|
||||
CLOUDFLARE_R2_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_R2_ACCOUNT_ID }}
|
||||
|
||||
@@ -34,6 +34,11 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
default: ''
|
||||
release-epoch:
|
||||
description: "Immutable stable claim tagger epoch."
|
||||
required: false
|
||||
type: string
|
||||
default: ''
|
||||
outputs:
|
||||
manifest-digest:
|
||||
description: "Immutable digest of the published versioned multi-arch manifest."
|
||||
@@ -164,7 +169,10 @@ jobs:
|
||||
if: needs.mode.outputs.release == 'true'
|
||||
env:
|
||||
RELEASE_VERSION: ${{ inputs.version }}
|
||||
run: python3 scripts/releases/stamping.py --tree . --version "$RELEASE_VERSION"
|
||||
RELEASE_EPOCH: ${{ inputs.release-epoch }}
|
||||
run: >-
|
||||
python3 scripts/releases/stamping.py --tree . --version "$RELEASE_VERSION"
|
||||
--release-epoch "$RELEASE_EPOCH"
|
||||
|
||||
- name: Write install stamp
|
||||
run: python3 scripts/write_install_stamp.py --output install-stamp.json --distribution docker --update-mechanism external --source ci
|
||||
@@ -247,6 +255,15 @@ jobs:
|
||||
# count to the core count.
|
||||
HERMES_TEST_WORKERS=$(nproc) scripts/run_tests.sh tests/docker/
|
||||
|
||||
- name: Verify release image identity
|
||||
if: needs.mode.outputs.phase == 'test'
|
||||
env:
|
||||
RELEASE_VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
actual="$(docker run --rm --entrypoint python "${IMAGE_NAME}:test" -c \
|
||||
'import json; print(json.load(open("/opt/hermes/install-stamp.json"))["baseVersion"])')"
|
||||
test "$actual" = "$RELEASE_VERSION"
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Release 'test' phase only: hand the EXACT tested bytes to the publish
|
||||
# phase via actions artifacts. No registry push, no credentials here.
|
||||
@@ -295,8 +312,8 @@ jobs:
|
||||
# This job is the sole Docker Hub credential boundary for merges.
|
||||
#
|
||||
# NOTE: main pushes tag :main ONLY. :latest is a user-facing stable alias
|
||||
# now; it moves only in the release 'promote' phase after the parent's
|
||||
# global release gate, so a main push can never advance the stable channel.
|
||||
# now; the ordered publication controller moves it only after the global
|
||||
# release gate, so a main push can never advance the stable channel.
|
||||
# ---------------------------------------------------------------------------
|
||||
publish:
|
||||
if: >-
|
||||
@@ -605,18 +622,36 @@ jobs:
|
||||
for arch in amd64 arm64; do
|
||||
args+=("${IMAGE_NAME}@$(cat "${arch}.digest")")
|
||||
done
|
||||
for i in 1 2 3; do
|
||||
if docker buildx imagetools create \
|
||||
-t "${IMAGE_NAME}:${RELEASE_TAG}" \
|
||||
"${args[@]}"; then
|
||||
break
|
||||
fi
|
||||
if [ "$i" = 3 ]; then
|
||||
echo "::error::imagetools create failed after 3 attempts"
|
||||
exit 1
|
||||
fi
|
||||
sleep 20
|
||||
done
|
||||
verify_manifest() {
|
||||
python3 - "$1" amd64.digest arm64.digest <<'PY'
|
||||
import json, pathlib, sys
|
||||
|
||||
manifest = json.loads(pathlib.Path(sys.argv[1]).read_text())
|
||||
expected = {pathlib.Path(path).read_text().strip() for path in sys.argv[2:]}
|
||||
actual = {row.get("digest") for row in manifest.get("manifests", [])}
|
||||
if actual != expected:
|
||||
raise SystemExit(f"versioned Docker manifest differs: expected {sorted(expected)}, got {sorted(actual)}")
|
||||
PY
|
||||
}
|
||||
if docker buildx imagetools inspect "${IMAGE_NAME}:${RELEASE_TAG}" --raw > existing.json 2>/dev/null; then
|
||||
verify_manifest existing.json
|
||||
echo "Verified existing immutable ${IMAGE_NAME}:${RELEASE_TAG}"
|
||||
else
|
||||
for i in 1 2 3; do
|
||||
if docker buildx imagetools create \
|
||||
-t "${IMAGE_NAME}:${RELEASE_TAG}" \
|
||||
"${args[@]}"; then
|
||||
break
|
||||
fi
|
||||
if [ "$i" = 3 ]; then
|
||||
echo "::error::imagetools create failed after 3 attempts"
|
||||
exit 1
|
||||
fi
|
||||
sleep 20
|
||||
done
|
||||
fi
|
||||
docker buildx imagetools inspect "${IMAGE_NAME}:${RELEASE_TAG}" --raw > verified.json
|
||||
verify_manifest verified.json
|
||||
|
||||
- name: Record manifest-list digest
|
||||
id: list
|
||||
|
||||
@@ -26,6 +26,11 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
default: ''
|
||||
release-epoch:
|
||||
description: 'Immutable stable claim tagger epoch.'
|
||||
required: false
|
||||
type: string
|
||||
default: ''
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -140,12 +145,30 @@ jobs:
|
||||
if: inputs.release == true
|
||||
env:
|
||||
RELEASE_VERSION: ${{ inputs.version }}
|
||||
RELEASE_EPOCH: ${{ inputs.release-epoch }}
|
||||
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"
|
||||
python3 scripts/releases/stamping.py --tree "$RUNNER_TEMP/release-source" \
|
||||
--version "$RELEASE_VERSION" --release-epoch "$RELEASE_EPOCH"
|
||||
|
||||
- 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 "${{ inputs.release == true && format('path:{0}/release-source', runner.temp) || '.' }}" --print-build-logs
|
||||
|
||||
- name: Verify release package runtime identity
|
||||
if: inputs.release == true
|
||||
env:
|
||||
RELEASE_VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
package="$(nix build "path:$RUNNER_TEMP/release-source#hermes-agent" --no-link --print-out-paths)"
|
||||
actual="$("$package/bin/hermes" --version)"
|
||||
ACTUAL_VERSION="$actual" python3 - <<'PY'
|
||||
import os
|
||||
|
||||
expected = os.environ["RELEASE_VERSION"]
|
||||
actual = os.environ["ACTUAL_VERSION"].split()[-1].removeprefix("v")
|
||||
if actual != expected:
|
||||
raise SystemExit(f"Nix package version mismatch: expected {expected}, got {actual}")
|
||||
PY
|
||||
@@ -30,6 +30,11 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
default: ''
|
||||
release-epoch:
|
||||
description: 'Immutable stable claim tagger epoch.'
|
||||
required: false
|
||||
type: string
|
||||
default: ''
|
||||
ref:
|
||||
description: 'Git ref to bundle (default: the triggering commit, github.sha)'
|
||||
required: false
|
||||
@@ -64,6 +69,7 @@ jobs:
|
||||
runs-on: ${{ matrix.target.runner }}
|
||||
env:
|
||||
HERMES_PAYLOAD_VERSION: ${{ inputs.release == true && inputs.version || '' }}
|
||||
HERMES_RELEASE_EPOCH: ${{ inputs.release == true && inputs.release-epoch || '' }}
|
||||
# Leave time for setup, cache saves and smoke tests around the wheel build.
|
||||
timeout-minutes: 180
|
||||
strategy:
|
||||
|
||||
@@ -12,7 +12,9 @@ on:
|
||||
workflows: ['Stable Release']
|
||||
types: [completed]
|
||||
schedule:
|
||||
- cron: '17 * * * *'
|
||||
# Failed attempts become retry-eligible after 15 minutes. Quarter-hour
|
||||
# reconciliation applies the retry without occupying a runner for the backoff.
|
||||
- cron: '*/15 * * * *'
|
||||
|
||||
concurrency:
|
||||
group: stable-release
|
||||
@@ -42,11 +44,6 @@ jobs:
|
||||
with:
|
||||
toolchain: node
|
||||
cache-python: false
|
||||
- name: Wait before retrying failed stable jobs
|
||||
if: >-
|
||||
github.event_name == 'workflow_run' &&
|
||||
github.event.workflow_run.run_attempt < 3
|
||||
run: sleep 900
|
||||
- name: Set up Docker Buildx for ordered alias promotion
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
- name: Log in to Docker Hub for ordered alias promotion
|
||||
|
||||
@@ -9,10 +9,6 @@ on:
|
||||
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: ''
|
||||
@@ -35,6 +31,7 @@ jobs:
|
||||
commit: ${{ steps.admit.outputs.commit }}
|
||||
version: ${{ steps.admit.outputs.version }}
|
||||
release-id: ${{ steps.admit.outputs.release-id }}
|
||||
release-epoch: ${{ steps.admit.outputs.release-epoch }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
@@ -69,6 +66,7 @@ jobs:
|
||||
release-phase: test
|
||||
tag: ${{ needs.admit.outputs.tag }}
|
||||
version: ${{ needs.admit.outputs.version }}
|
||||
release-epoch: ${{ needs.admit.outputs.release-epoch }}
|
||||
|
||||
nix:
|
||||
needs: [admit, ci, docker]
|
||||
@@ -76,6 +74,7 @@ jobs:
|
||||
with:
|
||||
release: true
|
||||
version: ${{ needs.admit.outputs.version }}
|
||||
release-epoch: ${{ needs.admit.outputs.release-epoch }}
|
||||
|
||||
pm-bundle:
|
||||
needs: [admit, ci, docker]
|
||||
@@ -84,6 +83,7 @@ jobs:
|
||||
release: true
|
||||
ref: ${{ github.sha }}
|
||||
version: ${{ needs.admit.outputs.version }}
|
||||
release-epoch: ${{ needs.admit.outputs.release-epoch }}
|
||||
|
||||
termux-checks:
|
||||
needs: [ci, docker]
|
||||
@@ -192,10 +192,71 @@ jobs:
|
||||
bundle-manifest-sha256: ${{ matrix.manifest_sha256 }}
|
||||
bundle-arch: ${{ matrix.arch }}
|
||||
|
||||
bootstrap-version:
|
||||
name: Bootstrap installer release identity
|
||||
needs: admit
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ needs.admit.outputs.commit }}
|
||||
- name: Set up locked Python build tooling
|
||||
uses: ./.github/actions/setup-pm
|
||||
with:
|
||||
extras: '["dev"]'
|
||||
- name: Stamp and verify the Cargo and Tauri release identity
|
||||
env:
|
||||
RELEASE_COMMIT: ${{ needs.admit.outputs.commit }}
|
||||
RELEASE_EPOCH: ${{ needs.admit.outputs.release-epoch }}
|
||||
RELEASE_VERSION: ${{ needs.admit.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tree="$RUNNER_TEMP/bootstrap-release"
|
||||
mkdir -p "$tree"
|
||||
git archive "$RELEASE_COMMIT" | tar -x -C "$tree"
|
||||
python -m scripts.releases.stamping --tree "$tree" \
|
||||
--version "$RELEASE_VERSION" --release-epoch "$RELEASE_EPOCH"
|
||||
actual="$(cargo metadata --no-deps --format-version 1 \
|
||||
--manifest-path "$tree/apps/bootstrap-installer/src-tauri/Cargo.toml" \
|
||||
| jq -r '.packages[] | select(.name == "hermes-bootstrap") | .version')"
|
||||
test "$actual" = "$RELEASE_VERSION" || {
|
||||
echo "::error::Bootstrap Cargo version $actual != $RELEASE_VERSION"
|
||||
exit 1
|
||||
}
|
||||
HERMES_NIX_BUILD=1 uv build --wheel --sdist --out-dir "$RUNNER_TEMP/python-dist" "$tree"
|
||||
RELEASE_DIST="$RUNNER_TEMP/python-dist" python - <<'PY'
|
||||
import email.parser
|
||||
import os
|
||||
import tarfile
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
expected = os.environ["RELEASE_VERSION"]
|
||||
dist = Path(os.environ["RELEASE_DIST"])
|
||||
wheel, = dist.glob("*.whl")
|
||||
sdist, = dist.glob("*.tar.gz")
|
||||
with zipfile.ZipFile(wheel) as archive:
|
||||
metadata, = (name for name in archive.namelist() if name.endswith(".dist-info/METADATA"))
|
||||
wheel_version = email.parser.BytesParser().parsebytes(archive.read(metadata))["Version"]
|
||||
with tarfile.open(sdist, "r:gz") as archive:
|
||||
pkg_info, = (member for member in archive.getmembers()
|
||||
if member.name.endswith("/PKG-INFO") and member.name.count("/") == 1)
|
||||
stream = archive.extractfile(pkg_info)
|
||||
if stream is None:
|
||||
raise SystemExit("sdist PKG-INFO is unreadable")
|
||||
sdist_version = email.parser.BytesParser().parse(stream)["Version"]
|
||||
if wheel_version != expected or sdist_version != expected:
|
||||
raise SystemExit(
|
||||
f"Python distribution version mismatch: wheel={wheel_version}, sdist={sdist_version}"
|
||||
)
|
||||
if f"-{expected}-" not in wheel.name or not sdist.name.endswith(f"-{expected}.tar.gz"):
|
||||
raise SystemExit("Python distribution filename differs from the release version")
|
||||
PY
|
||||
|
||||
acceptance:
|
||||
name: All release acceptance checks pass
|
||||
if: always()
|
||||
needs: [admit, ci, docker, nix, pm-bundle, termux-checks, windows-live, install-e2e, candidates, transitions, windows-packaged, macos-packaged]
|
||||
needs: [admit, ci, docker, nix, pm-bundle, termux-checks, windows-live, install-e2e, candidates, transitions, windows-packaged, macos-packaged, bootstrap-version]
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -204,7 +265,7 @@ jobs:
|
||||
- uses: ./.github/actions/setup-pm
|
||||
with:
|
||||
cache-python: false
|
||||
- run: python -m scripts.releases.stable gate admit ci docker nix pm-bundle termux-checks windows-live install-e2e candidates transitions windows-packaged macos-packaged
|
||||
- run: python -m scripts.releases.stable gate admit ci docker nix pm-bundle termux-checks windows-live install-e2e candidates transitions windows-packaged macos-packaged bootstrap-version
|
||||
env:
|
||||
RELEASE_NEEDS: ${{ toJSON(needs) }}
|
||||
|
||||
@@ -216,6 +277,7 @@ jobs:
|
||||
release-phase: publish
|
||||
tag: ${{ needs.admit.outputs.tag }}
|
||||
version: ${{ needs.admit.outputs.version }}
|
||||
release-epoch: ${{ needs.admit.outputs.release-epoch }}
|
||||
|
||||
publish-bundles:
|
||||
name: Publish tested bundle artifacts
|
||||
@@ -283,7 +345,6 @@ jobs:
|
||||
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 }}
|
||||
DOCKER_MANIFEST_DIGEST: ${{ needs.publish-docker.outputs.manifest-digest }}
|
||||
|
||||
@@ -35,6 +35,7 @@ const {
|
||||
// invariant lives in product-identity.cjs:33-34/58-68.
|
||||
/** @type {NonNullable<typeof storeMsix> | undefined} */
|
||||
const storeMsixWhenStore = storeMsix
|
||||
const releaseBuild = Boolean(process.env.HERMES_PAYLOAD_TAG)
|
||||
|
||||
/**
|
||||
* The store MSIX packaging identity. Callers must only invoke this when
|
||||
@@ -245,7 +246,8 @@ module.exports = {
|
||||
// time, so typecheck/test imports don't touch the filesystem.
|
||||
customExtensionsPath: 'build/msix-extensions.xml',
|
||||
customManifestPath: store ? 'build/store-msix-manifest.xml'
|
||||
: channelRequest || appNamePascal !== artifactNamePascal ? 'build/msix-manifest.xml' : 'assets/msix-manifest.xml',
|
||||
: releaseBuild || channelRequest || appNamePascal !== artifactNamePascal
|
||||
? 'build/msix-manifest.xml' : 'assets/msix-manifest.xml',
|
||||
// Hermes state is deliberately shared with unpackaged CLI/gateway
|
||||
// processes. Pair the manifest's disabled virtualization properties with
|
||||
// the restricted capability that permits unvirtualized AppData/HKCU writes.
|
||||
|
||||
@@ -236,7 +236,7 @@ test('channel stamps verify the real checkout and retain source version and nati
|
||||
releaseTag: 'v0.0.1',
|
||||
receiverCandidate: true,
|
||||
bundleEnv: {},
|
||||
publicBase: 'https://builds.example.test/ci-disposable/1/2-1',
|
||||
publicBase: 'https://builds.example.test/ci-disposable/1/2',
|
||||
identity: { ...official, token: build.identity.token }
|
||||
}
|
||||
|
||||
|
||||
@@ -211,6 +211,10 @@ test('packaging isolates boot metadata and executable names without renaming rel
|
||||
const stable: PackagingConfiguration = load()
|
||||
assert.equal(stable.extraMetadata.productName || pkg.productName, pkg.productName)
|
||||
|
||||
process.env.HERMES_PAYLOAD_TAG = 'v0.28.0'
|
||||
process.env.HERMES_BUILD_COMMIT = ''
|
||||
assert.equal(load().msix.customManifestPath, 'build/msix-manifest.xml')
|
||||
|
||||
for (const build of ['canary', 'abcdef1234567890abcdef1234567890abcdef12']) {
|
||||
process.env.HERMES_PAYLOAD_TAG = build === 'canary' ? 'v0.28.0+canary.20260818T000000Z' : ''
|
||||
process.env.HERMES_BUILD_COMMIT = build === 'canary' ? '' : build
|
||||
@@ -238,6 +242,10 @@ test('packaging isolates boot metadata and executable names without renaming rel
|
||||
assert.equal(appInfo.productFilename, identity.windowsExecutableName)
|
||||
assert.equal(config.mac.extendInfo.CFBundleExecutable, config.executableName)
|
||||
assert.equal(config.artifactName, stable.artifactName)
|
||||
assert.equal(
|
||||
config.msix.customManifestPath,
|
||||
'build/msix-manifest.xml'
|
||||
)
|
||||
|
||||
const {
|
||||
appIdentity
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
declare module 'semver' {
|
||||
export class SemVer {
|
||||
constructor(version: string)
|
||||
readonly version: string
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ const { client } = vi.hoisted(() => ({
|
||||
channel: '',
|
||||
allowPrerelease: true,
|
||||
allowDowngrade: true,
|
||||
currentVersion: { version: '9.9.9' },
|
||||
on: vi.fn(),
|
||||
setFeedURL: vi.fn(),
|
||||
checkForUpdates: vi.fn(async () => null)
|
||||
@@ -27,9 +28,14 @@ vi.mock('electron-updater', () => ({
|
||||
|
||||
import { createMacStrategy } from './mac-client'
|
||||
|
||||
afterEach(() => vi.clearAllMocks())
|
||||
afterEach((): void => {
|
||||
vi.clearAllMocks()
|
||||
client.currentVersion = { version: '9.9.9' }
|
||||
})
|
||||
|
||||
function deps(feedBaseUrl = '', light = false, channel: 'stable' | 'canary' = 'stable') {
|
||||
function deps(
|
||||
feedBaseUrl = '', light = false, channel: 'stable' | 'canary' = 'stable'
|
||||
): Parameters<typeof createMacStrategy>[0] {
|
||||
return {
|
||||
channel,
|
||||
light,
|
||||
@@ -67,6 +73,7 @@ describe('macOS client wiring', () => {
|
||||
channel: 'latest'
|
||||
})
|
||||
expect(client.allowDowngrade).toBe(false)
|
||||
expect(client.currentVersion.version).toBe('0.0.0')
|
||||
expect(client.autoDownload).toBe(false)
|
||||
expect((): void => {
|
||||
createMacStrategy({
|
||||
@@ -76,6 +83,26 @@ describe('macOS client wiring', () => {
|
||||
}).toThrow('authority')
|
||||
})
|
||||
|
||||
it('checks a newer channel head when SemVer build metadata has equal precedence', async (): Promise<void> => {
|
||||
const version = '0.21.4+canary.20260922T001500Z'
|
||||
client.checkForUpdates.mockImplementationOnce(async () => {
|
||||
expect(client.currentVersion.version).toBe('0.0.0')
|
||||
const info = { version, files: [], releaseDate: '', path: '', sha512: '' }
|
||||
return { isUpdateAvailable: true, updateInfo: info, versionInfo: info }
|
||||
})
|
||||
const strategy = createMacStrategy({
|
||||
...deps('https://updates.example', false, 'canary'),
|
||||
appVersion: '0.21.4+canary.20260922T001400Z',
|
||||
expectedVersion: version,
|
||||
feed: {
|
||||
url: 'https://updates.example/releases/channel-builds/abc/darwin/latest-mac.yml',
|
||||
channel: 'latest'
|
||||
}
|
||||
})
|
||||
|
||||
await expect(strategy.check()).resolves.toMatchObject({ updateAvailable: true, latestTag: `v${version}` })
|
||||
})
|
||||
|
||||
it('overrides the provider with the same variant/channel path as the publisher', () => {
|
||||
createMacStrategy(deps('https://updates.example/', true, 'canary'))
|
||||
expect(client.setFeedURL).toHaveBeenCalledWith({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { autoUpdater as nativeUpdater } from 'electron'
|
||||
import electronUpdater from 'electron-updater'
|
||||
import { SemVer } from 'semver'
|
||||
|
||||
import feedContract from '../../update-feed.cjs'
|
||||
|
||||
@@ -35,6 +36,13 @@ export function createMacStrategy(deps: MacClientDeps): MacStrategy {
|
||||
const legacy = feedContract.darwinFeed(deps.channel, deps.light)
|
||||
const channel = deps.feed?.channel ?? legacy.channel
|
||||
const updater = new electronUpdater.MacUpdater()
|
||||
if (deps.feed) {
|
||||
// The validated channel sequence, not SemVer precedence, decides whether a
|
||||
// pinned build is newer. Build metadata is intentionally precedence-neutral.
|
||||
Object.defineProperty(updater, 'currentVersion', {
|
||||
value: new SemVer('0.0.0'), configurable: true
|
||||
})
|
||||
}
|
||||
updater.autoDownload = false
|
||||
updater.autoInstallOnAppQuit = false
|
||||
updater.autoRunAppAfterInstall = true
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { createRequire } from 'node:module'
|
||||
import { appIdentity, storeManifestTemplate } from '../../../scripts/msix-shared.mjs'
|
||||
import { appIdentity, nativeManifestTemplate, storeManifestTemplate } from '../../../scripts/msix-shared.mjs'
|
||||
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
@@ -31,6 +31,9 @@ export default async function beforeBuild() {
|
||||
stageMsixAssets()
|
||||
writeMsixExtensions()
|
||||
if (store) stageStoreManifest(path.join(import.meta.dirname, '..'), process.env.HERMES_PAYLOAD_TAG)
|
||||
else if (process.env.HERMES_PAYLOAD_TAG) {
|
||||
stageReleaseManifest(path.join(import.meta.dirname, '..'), process.env.HERMES_PAYLOAD_TAG)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -65,6 +68,18 @@ export function stageStoreManifest(desktop, tag) {
|
||||
return output
|
||||
}
|
||||
|
||||
export function stageReleaseManifest(desktop, tag) {
|
||||
const generated = path.join(desktop, 'build/msix-manifest.xml')
|
||||
const { identity, version } = appIdentity(desktop, tag)
|
||||
const source = identity.appNamePascal === identity.artifactNamePascal || !fs.existsSync(generated)
|
||||
? path.join(desktop, 'assets/msix-manifest.xml')
|
||||
: generated
|
||||
const template = fs.readFileSync(source, 'utf8')
|
||||
fs.mkdirSync(path.dirname(generated), { recursive: true })
|
||||
fs.writeFileSync(generated, nativeManifestTemplate(template, version), 'utf8')
|
||||
return generated
|
||||
}
|
||||
|
||||
function writeMsixExtensions() {
|
||||
const desktop = path.join(import.meta.dirname, '..')
|
||||
const output = path.join('build', 'msix-extensions.xml')
|
||||
|
||||
@@ -13,6 +13,12 @@ test('stable keeps the store quad while canary uses yy.mmdd.hh.mmss', () => {
|
||||
assert.equal(nativeQuad('v0.21.4+canary.20260922T001403Z', EPOCH), '26.922.0.1403')
|
||||
})
|
||||
|
||||
test('historical release identities are rejected', () => {
|
||||
assert.throws(() => nativeQuad('v0.21.4-canary.20260922T001403Z', EPOCH))
|
||||
assert.throws(() => nativeQuad('v2026.9.21', EPOCH))
|
||||
assert.throws(() => nativeQuad('v2026.9.21+canary.20260922T001403Z', EPOCH))
|
||||
})
|
||||
|
||||
test('a later canary sorts above an earlier canary across a month boundary', () => {
|
||||
const earlier = nativeQuad(
|
||||
'v0.21.4+canary.20260131T235959Z',
|
||||
@@ -29,7 +35,7 @@ test('a later canary sorts above an earlier canary across a month boundary', ()
|
||||
|
||||
test('every field stays inside 16 bits', () => {
|
||||
for (const stamp of ['2026-01-01T00:00:00Z', '2026-12-31T23:59:59Z', '2028-12-31T23:59:59Z']) {
|
||||
for (const ref of ['v0.21.5', `v0.21.4+canary.${stamp.replace(/[-:T]/g, '').slice(0, 15)}Z`]) {
|
||||
for (const ref of ['v0.21.5', `v0.21.4+canary.${stamp.replace(/[-:]/g, '').replace('.000', '')}`]) {
|
||||
const parts = nativeQuad(ref, Date.parse(stamp) / 1000).split('.').map(Number)
|
||||
assert.equal(parts.length, 4)
|
||||
assert.ok(parts.every(value => value >= 0 && value <= 65535))
|
||||
|
||||
@@ -5,7 +5,7 @@ import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { afterEach, expect, test, vi } from 'vitest'
|
||||
import { appIdentity, storeManifestTemplate, storePackageVersion, storePackageVersionAt } from '../../../scripts/msix-shared.mjs'
|
||||
import { stageStoreManifest } from './before-build.mjs'
|
||||
import { stageReleaseManifest, stageStoreManifest } from './before-build.mjs'
|
||||
import { AppInfo } from '../../../node_modules/app-builder-lib/dist/appInfo.js'
|
||||
import { substituteManifestMacros } from '../../../node_modules/app-builder-lib/dist/targets/win/winAppUtil.js'
|
||||
|
||||
@@ -47,6 +47,20 @@ test('Store manifest and envelope agree while executable app semver and sideload
|
||||
|
||||
})
|
||||
|
||||
test.each(['v0.27.1', 'v0.27.1+canary.20260907T001800Z'])(
|
||||
'ordinary release manifest %s carries the admitted native quad',
|
||||
tag => {
|
||||
const { app } = fixture()
|
||||
const identity = appIdentity(app, tag)
|
||||
if (tag.includes('+canary.')) {
|
||||
fs.mkdirSync(path.join(app, 'build'), { recursive: true })
|
||||
fs.copyFileSync(path.join(app, 'assets/msix-manifest.xml'), path.join(app, 'build/msix-manifest.xml'))
|
||||
}
|
||||
const staged = fs.readFileSync(stageReleaseManifest(app, tag), 'utf8')
|
||||
expect(/<Identity\b[^>]*Version="([^"]+)"/.exec(staged)[1]).toBe(identity.version)
|
||||
}
|
||||
)
|
||||
|
||||
test('Store calendar ordering survives minute, hour, day and year boundaries and rejects reserved revision', () => {
|
||||
const seconds = [
|
||||
'2026-09-07T00:17:18Z', '2026-09-07T00:17:19Z', '2026-09-07T00:18:00Z',
|
||||
@@ -67,6 +81,26 @@ test('Store calendar ordering survives minute, hour, day and year boundaries and
|
||||
expect(() => storePackageVersion('v0.27.1+canary.20260231T000000Z', '.')).toThrow('Invalid canary')
|
||||
})
|
||||
|
||||
test('stable candidate identity uses the admitted claim epoch before the final tag exists', () => {
|
||||
const { root, app } = fixture()
|
||||
execFileSync('git', ['tag', '-d', 'v0.27.1'], { cwd: root, stdio: 'pipe' })
|
||||
execFileSync('git', ['tag', '-a', 'v0.27.1-rc', '-m', 'claim'], {
|
||||
cwd: root,
|
||||
env: { ...process.env, GIT_COMMITTER_DATE: '2026-09-07T00:18:00Z' },
|
||||
stdio: 'pipe'
|
||||
})
|
||||
const epoch = Date.parse('2026-09-07T00:18:00Z') / 1000
|
||||
vi.stubEnv('RELEASE_CLAIM_TAG', 'v0.27.1-rc')
|
||||
vi.stubEnv('RELEASE_CLAIM_OBJECT', execFileSync('git', ['rev-parse', 'v0.27.1-rc'], {
|
||||
cwd: root, encoding: 'utf8'
|
||||
}).trim())
|
||||
try {
|
||||
expect(appIdentity(app, 'v0.27.1').version).toBe(storePackageVersionAt(epoch))
|
||||
} finally {
|
||||
vi.unstubAllEnvs()
|
||||
}
|
||||
})
|
||||
|
||||
test('commit builds cannot stage a Store manifest', () => {
|
||||
const { root, app } = fixture()
|
||||
const commit = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8' }).trim()
|
||||
|
||||
@@ -49,4 +49,3 @@ def _ensure_utf8():
|
||||
|
||||
|
||||
_ensure_utf8()
|
||||
__release_rev_count__ = 40643
|
||||
+7
-19
@@ -33,11 +33,12 @@
|
||||
# check for updates without needing a local .git directory. Null for
|
||||
# impure / dirty builds where flakes can't determine a rev.
|
||||
rev ? null,
|
||||
revCount ? null,
|
||||
branch ? null,
|
||||
dirty ? false,
|
||||
lastModified ? null,
|
||||
# Overridable parameters
|
||||
version ? "0.0.0",
|
||||
distance ? 0,
|
||||
extraPythonPackages ? [ ],
|
||||
extraDependencyGroups ? [ ],
|
||||
}:
|
||||
@@ -47,28 +48,15 @@ let
|
||||
pythonLock = callPackage ./pythonLock.nix { };
|
||||
python = pythonLock.interpreter;
|
||||
|
||||
version = (fromTOML (builtins.readFile ../pyproject.toml)).project.version;
|
||||
versionModule = builtins.readFile ../hermes_cli/__init__.py;
|
||||
releaseRevCountLine = lib.findFirst (line: lib.hasPrefix "__release_rev_count__" line) null (
|
||||
lib.splitString "\n" versionModule
|
||||
);
|
||||
releaseRevCountMatch =
|
||||
if releaseRevCountLine == null then null else builtins.match ".*= ([0-9]+)" releaseRevCountLine;
|
||||
releaseRevCount =
|
||||
if releaseRevCountMatch == null then null else builtins.fromJSON (builtins.elemAt releaseRevCountMatch 0);
|
||||
|
||||
# Install stamp values — written to install-stamp.json so the Python
|
||||
# runtime (CLI, TUI) reads one file instead of env vars or .git probes.
|
||||
stampDistance =
|
||||
if revCount != null && releaseRevCount != null then
|
||||
lib.trivial.max 0 (revCount - releaseRevCount)
|
||||
else
|
||||
null;
|
||||
if builtins.isInt distance && distance >= 0 then distance else throw "distance must be a non-negative integer";
|
||||
stampDisplayVersion =
|
||||
if stampDistance != null && stampDistance > 0 then
|
||||
"${version}+${toString stampDistance}"
|
||||
else if dirty && stampDistance == null then
|
||||
"${version}+?"
|
||||
if stampDistance > 0 && rev != null then
|
||||
"${version}+${toString stampDistance}.g${builtins.substring 0 7 rev}"
|
||||
else if stampDistance > 0 then
|
||||
throw "a non-zero distance requires an exact revision"
|
||||
else
|
||||
version;
|
||||
|
||||
|
||||
@@ -30,7 +30,6 @@
|
||||
# inventing a sentinel that could be a real branch name.
|
||||
rev =
|
||||
inputs.self.rev or (if dirtyRevision != null then builtins.substring 0 40 dirtyRevision else null);
|
||||
revCount = inputs.self.revCount or null;
|
||||
rawRef = inputs.self.ref or null;
|
||||
branch = if rawRef != null then builtins.replaceStrings [ "refs/heads/" ] [ "" ] rawRef else null;
|
||||
dirty = dirtyRevision != null;
|
||||
@@ -40,7 +39,6 @@
|
||||
npm-lockfile-fix = inputs'.npm-lockfile-fix.packages.default;
|
||||
inherit
|
||||
rev
|
||||
revCount
|
||||
branch
|
||||
dirty
|
||||
lastModified
|
||||
|
||||
Generated
+1
-1
@@ -35,7 +35,7 @@
|
||||
},
|
||||
"apps/bootstrap-installer": {
|
||||
"name": "@hermes/bootstrap-installer",
|
||||
"version": "1.0.0",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@nous-research/ui": "0.18.2",
|
||||
"@tailwindcss/typography": "0.5.20",
|
||||
|
||||
@@ -65,6 +65,8 @@ def _build_prepared(prepared, builder_args: list[str], variant: str | None) -> N
|
||||
variant = select_variant(prepared, variant)
|
||||
repo, node = request.source, str(prepared.node)
|
||||
env = build_environment(prepared, variant, os.environ)
|
||||
if request.release_epoch is not None:
|
||||
env["HERMES_RELEASE_EPOCH"] = str(request.release_epoch)
|
||||
desktop = repo / "apps/desktop"
|
||||
targets = {"win32": ["--win", "msix"], "darwin": ["--mac", "dmg", "zip"], "linux": ["--linux", "AppImage"]}[sys.platform]
|
||||
package_args = ["--prepared", str(prepared.packager), "--native-deps", str(prepared.native),
|
||||
@@ -93,11 +95,9 @@ def _build_prepared(prepared, builder_args: list[str], variant: str | None) -> N
|
||||
run([node, "scripts/build/desktop.mjs", "--source", str(repo), "--icons", str(icons),
|
||||
"--stamp", str(desktop / "build/install-stamp.json"), "--native-deps", str(prepared.native),
|
||||
"--out", str(desktop / "dist")], cwd=repo, env=env)
|
||||
# The native quad is the build time, for stable and canary both, so there
|
||||
# is no per-channel build number to inject.
|
||||
version_args = []
|
||||
if sys.platform == "win32" and request.channel_request is None and request.tag is not None:
|
||||
script = "const m=require('./scripts/msix-shared.mjs');console.log(m.nativeQuad(process.argv[1], Math.floor(Date.now()/1000)))"
|
||||
script = "const m=require('./scripts/msix-shared.mjs');console.log(m.nativeQuad(process.argv[1], Number(process.env.HERMES_RELEASE_EPOCH)))"
|
||||
quad = capture([node, "-e", script, request.tag], repo).strip()
|
||||
version_args = [f'-c.extraMetadata.shortVersion={quad}', f'-c.extraMetadata.shortVersionWindows={quad}']
|
||||
require_source(repo, request.commit)
|
||||
|
||||
@@ -52,6 +52,10 @@ def identity_environment(request: BuildRequest, variant: str, inherited: Mapping
|
||||
HERMES_DESKTOP_VARIANT=variant,
|
||||
HERMES_PAYLOAD_VERSION=request.version,
|
||||
HERMES_BUNDLE_ENV_JSON=json.dumps(request.bundle_env, sort_keys=True))
|
||||
if request.release_epoch is None:
|
||||
env.pop("HERMES_RELEASE_EPOCH", None)
|
||||
else:
|
||||
env["HERMES_RELEASE_EPOCH"] = str(request.release_epoch)
|
||||
env.pop("BUILD_NUMBER", None)
|
||||
env.pop("GITHUB_HEAD_REF", None)
|
||||
env.pop("_HERMES_CHANNEL_REQUEST_JSON", None)
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
@@ -95,6 +96,7 @@ class BuildRequest:
|
||||
target: str
|
||||
bundle_env: dict[str, str | None]
|
||||
channel_request: dict | None = None
|
||||
release_epoch: int | None = None
|
||||
|
||||
@classmethod
|
||||
def create(cls, source: Path, *, tag: str | None, commit: str | None, variant: str,
|
||||
@@ -141,12 +143,32 @@ class BuildRequest:
|
||||
version = release_version(source, tag)
|
||||
commit = require_commit(release_commit) if release_commit else \
|
||||
git(source, "rev-parse", "--verify", f"refs/tags/{tag}^{{commit}}")
|
||||
release_epoch = None
|
||||
if tag:
|
||||
canary = re.fullmatch(r"v\d+\.\d+\.\d+\+canary\.(20\d{6}T\d{6}Z)", tag)
|
||||
if canary:
|
||||
release_epoch = int(datetime.strptime(canary.group(1), "%Y%m%dT%H%M%SZ")
|
||||
.replace(tzinfo=timezone.utc).timestamp())
|
||||
else:
|
||||
claim_tag = os.environ.get("RELEASE_CLAIM_TAG", "")
|
||||
if claim_tag != tag + "-rc":
|
||||
raise ValueError("stable preparation requires its exact claim tag")
|
||||
claim_object = os.environ.get("RELEASE_CLAIM_OBJECT", "")
|
||||
if not re.fullmatch(r"[a-f0-9]{40}", claim_object) or \
|
||||
git(source, "rev-parse", f"refs/tags/{claim_tag}") != claim_object:
|
||||
raise ValueError("stable preparation requires its exact claim tag object")
|
||||
metadata = git(source, "cat-file", "-p", claim_object)
|
||||
tagger = re.search(r"^tagger .* (\d+) [+-]\d{4}$", metadata, re.MULTILINE)
|
||||
if tagger is None:
|
||||
raise ValueError("stable claim has no immutable tagger timestamp")
|
||||
release_epoch = int(tagger.group(1))
|
||||
require_source(source, commit)
|
||||
if channel_request is not None:
|
||||
if version != channel_request["sourceVersion"]:
|
||||
raise ValueError("channel sourceVersion differs from checkout project version")
|
||||
version = channel_request["version"]
|
||||
return cls(source, work, cache, commit, tag, version, variant, current_target(), bundle_env, channel_request)
|
||||
return cls(source, work, cache, commit, tag, version, variant, current_target(), bundle_env,
|
||||
channel_request, release_epoch)
|
||||
|
||||
def data(self) -> dict:
|
||||
return {**asdict(self), "source": str(self.source), "work": str(self.work), "cache": str(self.cache)}
|
||||
|
||||
@@ -181,8 +181,15 @@ def _prepare_native(*, out: Path, ref: str, source: Path, cache: Path,
|
||||
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}"
|
||||
epoch = build_env.get("HERMES_RELEASE_EPOCH")
|
||||
if epoch:
|
||||
if not epoch.isdigit():
|
||||
raise ValueError("HERMES_RELEASE_EPOCH must be an integer")
|
||||
instant = dt.datetime.fromtimestamp(int(epoch), tz=dt.UTC)
|
||||
else:
|
||||
instant = dt.datetime.now(dt.UTC)
|
||||
release_date = (build_env.get("HERMES_RELEASE_DATE")
|
||||
or f"{instant.year}.{instant.month}.{instant.day}")
|
||||
stamp(repo_dir, version, release_date)
|
||||
|
||||
names = [
|
||||
|
||||
@@ -25,15 +25,18 @@ def sha256_file(file: Path) -> str:
|
||||
def stamp_matches(stamp: dict, tag: str, commit: str, *, channel_request: dict | None = None) -> None:
|
||||
if channel_request is not None and channel_request.get("receiverCandidate"):
|
||||
if (stamp.get("channelBuild") is not None or stamp.get("source") != "build"
|
||||
or stamp.get("receiverProtocol") != 1 or stamp.get("displayVersion") != channel_request["version"]):
|
||||
or stamp.get("receiverProtocol") != 1 or stamp.get("displayVersion") != channel_request["version"]
|
||||
or stamp.get("commit") != commit or stamp.get("tag") != channel_request["releaseTag"]
|
||||
or stamp.get("baseVersion") != channel_request["sourceVersion"]):
|
||||
raise ValueError("Receiver candidate must use ordinary stable update ownership")
|
||||
tag, channel_request = channel_request["releaseTag"], None
|
||||
return
|
||||
if channel_request is not None:
|
||||
if (stamp.get("source") != "channel-build" or stamp.get("channelBuild") != channel_request
|
||||
or stamp.get("commit") != channel_request["commit"] or stamp.get("tag")):
|
||||
raise ValueError("Built package provenance does not match the channel request")
|
||||
return
|
||||
if stamp.get("commit") != commit or stamp.get("tag") != tag:
|
||||
if (stamp.get("commit") != commit or stamp.get("tag") != tag
|
||||
or stamp.get("baseVersion") != tag.removeprefix("v")):
|
||||
raise ValueError("Built package provenance does not match the release")
|
||||
|
||||
|
||||
@@ -63,6 +66,8 @@ def record(platform: str, arch: str, root: Path, tag: str, commit: str, out: Pat
|
||||
row: dict = {"platform": platform, "arch": arch, "tag": tag, "commit": commit}
|
||||
if platform == "windows":
|
||||
package = single(p for p in root.glob(f"*-win-{arch}.msix") if not p.name.startswith("Store-"))
|
||||
if channel_request is None and not package.name.endswith(f"-{tag[1:]}-win-{arch}.msix"):
|
||||
raise ValueError("Windows artifact filename differs from release tag")
|
||||
with zipfile.ZipFile(package) as archive:
|
||||
manifest = ET.fromstring(archive.read("AppxManifest.xml"))
|
||||
identity = manifest.find("{*}Identity")
|
||||
@@ -74,10 +79,25 @@ def record(platform: str, arch: str, root: Path, tag: str, commit: str, out: Pat
|
||||
raise ValueError("MSIX architecture differs from release target")
|
||||
row.update(identity=identity.attrib["Name"], publisher=identity.attrib["Publisher"],
|
||||
applicationId=application.attrib["Id"], version=identity.attrib["Version"])
|
||||
version_sidecar = root / f"version-info-{arch}.json"
|
||||
if version_sidecar.is_file():
|
||||
row["executableVersion"] = json.loads(
|
||||
version_sidecar.read_text(encoding="utf-8-sig"),
|
||||
)["productVersion"]
|
||||
elif os.name == "nt":
|
||||
executable_name = (stamp.get("identity") or {}).get("windowsExecutableName")
|
||||
executables = list(root.rglob(f"{executable_name}.exe")) if executable_name else []
|
||||
executable = single(executables)
|
||||
row["executableVersion"] = subprocess.check_output([
|
||||
"powershell", "-NoProfile", "-Command",
|
||||
"(Get-Item -LiteralPath $args[0]).VersionInfo.ProductVersion", str(executable),
|
||||
], text=True, encoding="utf-8", stdin=subprocess.DEVNULL).strip()
|
||||
if stamp.get("receiverProtocol") == 1:
|
||||
row["receiverProtocol"] = 1
|
||||
elif platform == "macos":
|
||||
package = single(root.glob(f"*-mac-{arch}.zip"))
|
||||
if not package.name.endswith(f"-{tag[1:]}-mac-{arch}.zip"):
|
||||
raise ValueError("macOS artifact filename differs from release tag")
|
||||
app = single(root.glob("mac*/*.app"))
|
||||
subprocess.run(["codesign", "--verify", "--strict", str(app)], check=True)
|
||||
signature = subprocess.run(["codesign", "-dv", "--verbose=4", str(app)], check=True, capture_output=True, text=True, encoding="utf-8")
|
||||
@@ -98,6 +118,8 @@ def record(platform: str, arch: str, root: Path, tag: str, commit: str, out: Pat
|
||||
fields = subprocess.check_output(["dpkg-deb", "--field", str(package), "Package", "Version", "Architecture"], text=True, encoding="utf-8")
|
||||
parsed = dict(line.split(": ", 1) for line in fields.splitlines())
|
||||
row.update(identity=parsed["Package"], version=parsed["Version"], filename=package.relative_to(root).as_posix())
|
||||
if parsed["Version"] != f"{tag[1:]}-1":
|
||||
raise ValueError("Termux artifact version differs from release tag")
|
||||
if parsed["Architecture"] != "aarch64":
|
||||
raise ValueError("Wrong Termux package architecture")
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
@@ -135,7 +157,7 @@ def validate_windows_bundle(bundle: Path, windows: list[dict]) -> None:
|
||||
|
||||
|
||||
def assemble(root: Path, tag: str, commit: str, public_base: str, out: Path,
|
||||
*, smoke_results: dict) -> dict:
|
||||
*, smoke_results: dict, release_epoch: int) -> dict:
|
||||
"""Bind the native metadata to files already staged by their build jobs."""
|
||||
from scripts.releases.handoff import receipt_name, validate_receipt
|
||||
from scripts.releases.r2 import put, staging_key_for
|
||||
@@ -174,9 +196,9 @@ def assemble(root: Path, tag: str, commit: str, public_base: str, out: Path,
|
||||
filename = universal_name if row["platform"] == "windows" else row["filename"]
|
||||
item = by_name[filename]
|
||||
packages.append({k: v for k, v in {**row, "artifact": {"url": item["url"], "sha256": item["sha256"]}}.items() if k != "filename"})
|
||||
result = {"schema": 2, "tag": tag, "commit": commit, "packages": packages, "files": files,
|
||||
"smoke_results": smoke_results}
|
||||
validate_candidates(result, tag, commit, public_base)
|
||||
result = {"schema": 2, "tag": tag, "commit": commit, "releaseEpoch": release_epoch,
|
||||
"packages": packages, "files": files, "smoke_results": smoke_results}
|
||||
validate_candidates(result, tag, commit, public_base, release_epoch)
|
||||
if not any(row["platform"] == "termux" for row in packages):
|
||||
raise ValueError("Missing Termux candidate")
|
||||
out.write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8")
|
||||
@@ -316,6 +338,7 @@ def main(argv: list[str] | None = None) -> None:
|
||||
parser.add_argument("--tag", default=os.environ.get("RELEASE_TAG"))
|
||||
parser.add_argument("--commit", default=os.environ.get("GITHUB_SHA"))
|
||||
parser.add_argument("--public-base", default=os.environ.get("CLOUDFLARE_R2_PUBLIC_URL"))
|
||||
parser.add_argument("--release-epoch", type=int, default=os.environ.get("HERMES_RELEASE_EPOCH"))
|
||||
parser.add_argument("--store-only", action="store_true")
|
||||
for name in ("identity", "publisher", "version", "self-uri", "artifact-uri"):
|
||||
parser.add_argument(f"--{name}")
|
||||
@@ -340,8 +363,11 @@ def main(argv: list[str] | None = None) -> None:
|
||||
request = json.loads(args.channel_request.read_text(encoding="utf-8-sig")) if args.channel_request else None
|
||||
record(args.platform, args.arch, args.root, args.tag, args.commit, args.out, channel_request=request)
|
||||
elif args.command == "assemble":
|
||||
if args.release_epoch is None:
|
||||
parser.error("assemble requires the admitted --release-epoch")
|
||||
assemble(args.root, args.tag, args.commit, args.public_base, args.out,
|
||||
smoke_results=json.loads(os.environ.get("RELEASE_NEEDS", "{}")))
|
||||
smoke_results=json.loads(os.environ.get("RELEASE_NEEDS", "{}")),
|
||||
release_epoch=args.release_epoch)
|
||||
if os.environ.get("GITHUB_OUTPUT"):
|
||||
with Path(os.environ["GITHUB_OUTPUT"]).open("a", encoding="utf-8") as file:
|
||||
file.write(f"manifest-url={args.public_base.rstrip('/')}/releases/tag/{args.tag}/release-candidates.json\nmanifest-sha256={sha256_file(args.out)}\n")
|
||||
|
||||
+39
-10
@@ -73,8 +73,9 @@ export function contentTypeFor(filename) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const CANARY_TAG_RE = /^v(\d+\.\d+\.\d+)\+canary\.(20\d{6}T\d{6}Z)$/
|
||||
const STABLE_TAG_RE = /^v\d+\.\d+\.\d+$/
|
||||
const VERSION_CORE = '(?:0|[1-9]\\d{0,2})\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)'
|
||||
const CANARY_TAG_RE = new RegExp(`^v(${VERSION_CORE})\\+canary\\.(20\\d{6}T\\d{6}Z)$`)
|
||||
const STABLE_TAG_RE = new RegExp(`^v${VERSION_CORE}$`)
|
||||
|
||||
/**
|
||||
* @param {string} stamp YYYYMMDDTHHMMSSZ
|
||||
@@ -131,9 +132,9 @@ while remaining monotonic across month boundaries.
|
||||
@returns {string}
|
||||
*/
|
||||
export function nativeQuad(ref, epochSeconds) {
|
||||
return /(?:\+|-)canary\./.test(ref)
|
||||
? canaryPackageVersionAt(epochSeconds)
|
||||
: storePackageVersionAt(epochSeconds)
|
||||
if (CANARY_TAG_RE.test(ref)) return canaryPackageVersionAt(epochSeconds)
|
||||
if (STABLE_TAG_RE.test(ref)) return storePackageVersionAt(epochSeconds)
|
||||
throw new Error('Native package version requires a current release tag')
|
||||
}
|
||||
|
||||
/** The build time of a release tag: the stamp embedded in a canary tag, or
|
||||
@@ -148,12 +149,33 @@ function releaseEpoch(tag, gitRoot) {
|
||||
const compact = new Date(epoch * 1000).toISOString().replace(/[-:T]/g, '').replace(/\.000Z$/, '')
|
||||
const expected = `${compact.slice(0, 8)}T${compact.slice(8)}Z`
|
||||
if (expected !== stamp) throw new Error('Invalid canary calendar timestamp')
|
||||
const supplied = process.env.HERMES_RELEASE_EPOCH
|
||||
if (supplied !== undefined && Number(supplied) !== epoch) throw new Error('Canary release epoch differs from its tag')
|
||||
return epoch
|
||||
}
|
||||
const timestamp = execFileSync('git', ['for-each-ref', '--format=%(creatordate:unix)', `refs/tags/${tag}`], {
|
||||
cwd: gitRoot, encoding: 'utf8'
|
||||
}).trim()
|
||||
if (!/^\d+$/.test(timestamp)) throw new Error(`No immutable release timestamp for ${tag}`)
|
||||
const supplied = process.env.HERMES_RELEASE_EPOCH
|
||||
if (supplied !== undefined) {
|
||||
if (!/^\d+$/.test(supplied)) throw new Error('Invalid immutable release epoch')
|
||||
return Number(supplied)
|
||||
}
|
||||
const claim = process.env.RELEASE_CLAIM_TAG
|
||||
if (claim && claim !== `${tag}-rc`) throw new Error('Stable claim tag differs from its payload tag')
|
||||
const claimObject = process.env.RELEASE_CLAIM_OBJECT
|
||||
let timestamp
|
||||
let timestampTag = tag
|
||||
if (claim) {
|
||||
timestampTag = claim
|
||||
if (!/^[a-f0-9]{40}$/.test(claimObject || '')) throw new Error('Stable claim object is missing')
|
||||
const actual = execFileSync('git', ['rev-parse', `refs/tags/${claim}`], { cwd: gitRoot, encoding: 'utf8' }).trim()
|
||||
if (actual !== claimObject) throw new Error('Stable claim object differs from its tag')
|
||||
const metadata = execFileSync('git', ['cat-file', '-p', claimObject], { cwd: gitRoot, encoding: 'utf8' })
|
||||
timestamp = /^tagger .* (\d+) [+-]\d{4}$/m.exec(metadata)?.[1]
|
||||
} else {
|
||||
timestamp = execFileSync('git', ['for-each-ref', '--format=%(creatordate:unix)', `refs/tags/${tag}`], {
|
||||
cwd: gitRoot, encoding: 'utf8'
|
||||
}).trim()
|
||||
}
|
||||
if (!/^\d+$/.test(timestamp || '')) throw new Error(`No immutable release timestamp for ${timestampTag}`)
|
||||
return Number(timestamp)
|
||||
}
|
||||
|
||||
@@ -170,7 +192,14 @@ export function storeManifestTemplate(template, version) {
|
||||
if (!/^[1-9]\d*\.\d+\.\d+\.0$/.test(version) || version.split('.').some(part => Number(part) > 65535)) {
|
||||
throw new Error('Store package version must have a nonzero major, 16-bit fields and zero revision')
|
||||
}
|
||||
if (template.split('${version}').length !== 2) throw new Error('MSIX template must have one version macro')
|
||||
return nativeManifestTemplate(template, version)
|
||||
}
|
||||
|
||||
export function nativeManifestTemplate(template, version) {
|
||||
if (!/^[1-9]\d*\.\d+\.\d+\.\d+$/.test(version) || version.split('.').some(part => Number(part) > 65535)) {
|
||||
throw new Error('Native package version must contain four 16-bit numeric fields')
|
||||
}
|
||||
if (template.split('${version}').length !== 2) throw new Error('MSIX template must contain exactly one version macro')
|
||||
return template.replace('${version}', version)
|
||||
}
|
||||
|
||||
|
||||
+91
-441
@@ -1,23 +1,9 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Hermes Agent Release Script
|
||||
"""Hermes Agent release entrypoint.
|
||||
|
||||
Generates changelogs and creates GitHub releases with SemVer tags.
|
||||
|
||||
Usage:
|
||||
# Preview changelog (dry run)
|
||||
python scripts/release.py
|
||||
|
||||
# Preview with semver bump
|
||||
python scripts/release.py --bump minor
|
||||
|
||||
# Create the release
|
||||
python scripts/release.py --bump minor --publish
|
||||
|
||||
# First release (no previous tag)
|
||||
python scripts/release.py --bump minor --publish --first-release
|
||||
|
||||
# Override release-date metadata (e.g. for a belated release)
|
||||
python scripts/release.py --bump minor --publish --date 2026.3.15
|
||||
Stable releases use the ``release``, ``publish``, and ``abandon`` subcommands.
|
||||
Canary, commit, and dynamic-channel operations retain their top-level flags.
|
||||
See ``website/docs/developer-guide/stable-releases.md`` for the operator flow.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
@@ -42,11 +28,6 @@ from hermes_cli.update_channel import ( # noqa: E402
|
||||
)
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
VERSION_FILE = REPO_ROOT / "hermes_cli" / "__init__.py"
|
||||
PYPROJECT_FILE = REPO_ROOT / "pyproject.toml"
|
||||
UV_LOCK_FILE = REPO_ROOT / "uv.lock"
|
||||
DESKTOP_PKG_FILE = REPO_ROOT / "apps" / "desktop" / "package.json"
|
||||
PKG_LOCK_FILE = REPO_ROOT / "package-lock.json"
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# Git email → GitHub username mapping
|
||||
@@ -2260,26 +2241,6 @@ def remote_github_repo(remote: str) -> str | None:
|
||||
# sorts chronologically even though SemVer ignores it for precedence.
|
||||
|
||||
|
||||
def release_tag_for_version(semver: str) -> str:
|
||||
"""Return the canonical Git tag for a Hermes package version."""
|
||||
return f"v{semver}"
|
||||
|
||||
|
||||
def get_last_tag():
|
||||
"""Get the latest SemVer tag, falling back to legacy CalVer history."""
|
||||
tags = git("tag", "--list", "v[0-9]*", "--sort=-v:refname")
|
||||
if tags:
|
||||
tag_list = tags.split("\n")
|
||||
for tag in tag_list:
|
||||
if STABLE_TAG_RE.fullmatch(tag):
|
||||
return tag
|
||||
|
||||
legacy_tags = git("tag", "--list", "v20*", "--sort=-v:refname")
|
||||
if legacy_tags:
|
||||
return legacy_tags.split("\n")[0]
|
||||
return None
|
||||
|
||||
|
||||
def get_last_canary_tag():
|
||||
"""The newest canonical canary receipt, by embedded build time."""
|
||||
raw = git("tag", "--list", "v*+canary.*")
|
||||
@@ -2287,168 +2248,6 @@ def get_last_canary_tag():
|
||||
return max(tags, key=lambda tag: canary_timestamp(tag) or "", default=None)
|
||||
|
||||
|
||||
def get_current_version():
|
||||
"""Read current semver from __init__.py."""
|
||||
content = VERSION_FILE.read_text(encoding="utf-8-sig")
|
||||
match = re.search(r'__version__\s*=\s*"([^"]+)"', content)
|
||||
return match.group(1) if match else "0.0.0"
|
||||
|
||||
|
||||
def bump_version(current: str, part: str) -> str:
|
||||
"""Bump a semver version string."""
|
||||
parts = current.split(".")
|
||||
if len(parts) != 3:
|
||||
parts = ["0", "0", "0"]
|
||||
major, minor, patch = int(parts[0]), int(parts[1]), int(parts[2])
|
||||
|
||||
if part == "major":
|
||||
major += 1
|
||||
minor = 0
|
||||
patch = 0
|
||||
elif part == "minor":
|
||||
minor += 1
|
||||
patch = 0
|
||||
elif part == "patch":
|
||||
patch += 1
|
||||
else:
|
||||
raise ValueError(f"Unknown bump part: {part}")
|
||||
|
||||
return f"{major}.{minor}.{patch}"
|
||||
|
||||
|
||||
def update_version_files(semver: str, calver_date: str) -> list[str]:
|
||||
"""Update version strings in source files. returns a list of updates files."""
|
||||
# Update __init__.py
|
||||
content = VERSION_FILE.read_text(encoding="utf-8-sig")
|
||||
content = re.sub(
|
||||
r'__version__\s*=\s*"[^"]+"',
|
||||
f'__version__ = "{semver}"',
|
||||
content,
|
||||
)
|
||||
content = re.sub(
|
||||
r'__release_date__\s*=\s*"[^"]+"',
|
||||
f'__release_date__ = "{calver_date}"',
|
||||
content,
|
||||
)
|
||||
# This function runs before the release-bump commit is created. Record the
|
||||
# count that commit will have so Nix store builds can derive ``+N`` without
|
||||
# a .git directory. The corresponding SemVer tag is made at that commit.
|
||||
parent_count = int(git("rev-list", "--count", "HEAD") or "0")
|
||||
revision = f'__release_rev_count__ = {parent_count + 1}'
|
||||
content, replacements = re.subn(
|
||||
r'^__release_rev_count__\s*=\s*\d+', revision, content,
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
if not replacements:
|
||||
content = content.rstrip("\n") + f"\n{revision}\n"
|
||||
VERSION_FILE.write_text(content, encoding="utf-8")
|
||||
|
||||
# Update pyproject.toml
|
||||
pyproject = PYPROJECT_FILE.read_text(encoding="utf-8-sig")
|
||||
pyproject = re.sub(
|
||||
r'^version\s*=\s*"[^"]+"',
|
||||
f'version = "{semver}"',
|
||||
pyproject,
|
||||
# Turn-end file-mutation verifier footer appended by run_agent.py
|
||||
# (``_format_file_mutation_failure_footer``). It's a UI affordance — reading "warning file mutation
|
||||
# verifier, 2 files were NOT modified..." aloud is noise (#40772). The footer is a ``⚠️
|
||||
# File-mutation verifier:`` header line followed by indented ``•`` bullet lines; strip the whole
|
||||
# block.
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
PYPROJECT_FILE.write_text(pyproject, encoding="utf-8")
|
||||
|
||||
# Keep the desktop Electron app's package.json version in lockstep with the
|
||||
# Python package version. The desktop About panel reads the live Hermes
|
||||
# version at runtime, but app.getVersion()/packaging metadata still come
|
||||
# from this field, so it must track pyproject to avoid drift.
|
||||
pkg_text = DESKTOP_PKG_FILE.read_text(encoding="utf-8-sig")
|
||||
pkg_text = re.sub(
|
||||
r'("version"\s*:\s*)"[^"]+"',
|
||||
rf'\g<1>"{semver}"',
|
||||
pkg_text,
|
||||
count=1,
|
||||
)
|
||||
DESKTOP_PKG_FILE.write_text(pkg_text, encoding="utf-8")
|
||||
|
||||
# npm mirrors each workspace package's version into the root lockfile.
|
||||
# Update the apps/desktop entry so `npm ci`/`npm install` do not see the
|
||||
# lockfile as out of date and rewrite it (or fail in CI) after a bump.
|
||||
lock_text = PKG_LOCK_FILE.read_text(encoding="utf-8-sig")
|
||||
lock_text = re.sub(
|
||||
r'("apps/desktop"\s*:\s*\{\s*"name"\s*:\s*"[^"]+"\s*,\s*"version"\s*:\s*)"[^"]+"',
|
||||
rf'\g<1>"{semver}"',
|
||||
lock_text,
|
||||
count=1,
|
||||
)
|
||||
PKG_LOCK_FILE.write_text(lock_text, encoding="utf-8")
|
||||
|
||||
# uv.lock records the editable root package's version from pyproject.
|
||||
# Update it in place so a post-release `uv sync`/`uv lock` is a no-op.
|
||||
uv_text = UV_LOCK_FILE.read_text(encoding="utf-8-sig")
|
||||
uv_text = re.sub(
|
||||
r'(name = "hermes-agent"\nversion = )"[^"]+"',
|
||||
rf'\g<1>"{semver}"',
|
||||
uv_text,
|
||||
count=1,
|
||||
)
|
||||
UV_LOCK_FILE.write_text(uv_text, encoding="utf-8")
|
||||
|
||||
updated = [
|
||||
str(VERSION_FILE),
|
||||
str(PYPROJECT_FILE),
|
||||
str(DESKTOP_PKG_FILE),
|
||||
str(PKG_LOCK_FILE),
|
||||
str(UV_LOCK_FILE),
|
||||
]
|
||||
|
||||
# Keep the bootstrap installer (Hermes-Setup.dmg CFBundleShortVersionString)
|
||||
# in lockstep with the Python package version. Tauri reads `version` from
|
||||
# package.json + tauri.conf.json; a hardcoded 0.0.1 ships in the DMG.
|
||||
installer_pkg = REPO_ROOT / "apps" / "bootstrap-installer" / "package.json"
|
||||
if installer_pkg.exists():
|
||||
pkg_text = installer_pkg.read_text(encoding="utf-8-sig")
|
||||
pkg_text = re.sub(
|
||||
r'("version"\s*:\s*)"[^"]+"',
|
||||
rf'\g<1>"{semver}"',
|
||||
pkg_text,
|
||||
count=1,
|
||||
)
|
||||
installer_pkg.write_text(pkg_text, encoding="utf-8")
|
||||
updated.append(str(installer_pkg))
|
||||
|
||||
installer_tauri = (
|
||||
REPO_ROOT / "apps" / "bootstrap-installer" / "src-tauri" / "tauri.conf.json"
|
||||
)
|
||||
if installer_tauri.exists():
|
||||
pkg_text = installer_tauri.read_text(encoding="utf-8-sig")
|
||||
pkg_text = re.sub(
|
||||
r'("version"\s*:\s*)"[^"]+"',
|
||||
rf'\g<1>"{semver}"',
|
||||
pkg_text,
|
||||
count=1,
|
||||
)
|
||||
installer_tauri.write_text(pkg_text, encoding="utf-8")
|
||||
updated.append(str(installer_tauri))
|
||||
|
||||
installer_cargo = (
|
||||
REPO_ROOT / "apps" / "bootstrap-installer" / "src-tauri" / "Cargo.toml"
|
||||
)
|
||||
if installer_cargo.exists():
|
||||
cargo_text = installer_cargo.read_text(encoding="utf-8-sig")
|
||||
cargo_text = re.sub(
|
||||
r'^version\s*=\s*"[^"]+"',
|
||||
f'version = "{semver}"',
|
||||
cargo_text,
|
||||
count=1,
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
installer_cargo.write_text(cargo_text, encoding="utf-8")
|
||||
updated.append(str(installer_cargo))
|
||||
|
||||
return updated
|
||||
|
||||
|
||||
def resolve_author(name: str, email: str) -> str:
|
||||
"""Resolve a git author to a GitHub @mention."""
|
||||
# Try email lookup first
|
||||
@@ -2713,6 +2512,51 @@ def generate_changelog(commits, tag_name, semver, repo_url="https://github.com/N
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _resume_canary(tag: str, remote: str, repository: str, *, notes_file: Path | None = None) -> None:
|
||||
"""Converge a tag-pushed canary through draft, dispatch, and protected head."""
|
||||
ref = f"refs/tags/{tag}"
|
||||
local_object = git("rev-parse", ref)
|
||||
commit = git("rev-parse", f"{ref}^{{commit}}")
|
||||
if git("cat-file", "-t", local_object) != "tag":
|
||||
raise ValueError("Canary receipt must be an annotated tag")
|
||||
remote_refs = dict(line.split()[::-1] for line in git(
|
||||
"ls-remote", remote, ref, f"{ref}^{{}}",
|
||||
).splitlines())
|
||||
if remote_refs.get(ref) != local_object or remote_refs.get(f"{ref}^{{}}") != commit:
|
||||
raise ValueError("Canary receipt differs from its exact remote tag object")
|
||||
|
||||
view = subprocess.run(
|
||||
["gh", "release", "view", tag, "--repo", repository, "--json", "tagName,isDraft,isPrerelease"],
|
||||
cwd=REPO_ROOT, capture_output=True, text=True, encoding="utf-8",
|
||||
)
|
||||
if view.returncode != 0:
|
||||
create = [
|
||||
"gh", "release", "create", tag, "--repo", repository,
|
||||
"--verify-tag", "--draft", "--prerelease",
|
||||
"--title", f"Hermes Agent canary {tag}",
|
||||
]
|
||||
create.extend(["--notes-file", str(notes_file)] if notes_file else ["--generate-notes"])
|
||||
created = subprocess.run(
|
||||
create, cwd=REPO_ROOT, capture_output=True, text=True, encoding="utf-8",
|
||||
)
|
||||
if created.returncode != 0:
|
||||
raise ValueError(created.stderr.strip() or "Canary draft could not be recovered")
|
||||
else:
|
||||
release = json.loads(view.stdout)
|
||||
if (release.get("tagName") != tag or not isinstance(release.get("isDraft"), bool)
|
||||
or release.get("isPrerelease") is not True):
|
||||
raise ValueError("Canary release state differs from its receipt")
|
||||
|
||||
from scripts.releases.versioning import published_channel_identity
|
||||
published = published_channel_identity(repository, "canary")
|
||||
if published is not None and published == (tag[1:], commit):
|
||||
print(f"✓ {tag} is already published.")
|
||||
return
|
||||
if not dispatch_desktop_build(tag, repository):
|
||||
raise SystemExit(1)
|
||||
print(f"✓ Resumed canary publication for {tag}.")
|
||||
|
||||
|
||||
def cmd_canary(args) -> None:
|
||||
"""--canary: tag + draft a canary source identity.
|
||||
|
||||
@@ -2734,24 +2578,34 @@ def cmd_canary(args) -> None:
|
||||
YAML.
|
||||
"""
|
||||
date_utc = args.date or datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||
stable_tag = get_last_tag()
|
||||
if stable_tag is None:
|
||||
print("✗ No stable release tag exists; a canary needs a stable line to version over.")
|
||||
sys.exit(1)
|
||||
tag_name = canary_tag_for_date(stable_tag, date_utc)
|
||||
|
||||
push_remote = resolve_push_remote(args.remote)
|
||||
gh_repo = remote_github_repo(push_remote)
|
||||
if not gh_repo:
|
||||
raise ValueError("Canary publication requires a GitHub repository remote")
|
||||
prev_canary = get_last_canary_tag()
|
||||
since = prev_canary or stable_tag
|
||||
|
||||
if git_result("rev-parse", "--verify", "--quiet", f"refs/tags/{tag_name}").returncode == 0:
|
||||
print(f"✓ {tag_name} already exists — nothing to do.")
|
||||
return
|
||||
|
||||
if prev_canary:
|
||||
head = git("rev-parse", "HEAD")
|
||||
if head and head == git("rev-parse", f"{prev_canary}^{{commit}}"):
|
||||
print(f"✓ No new commits since {prev_canary} — nothing to do.")
|
||||
if args.publish:
|
||||
_resume_canary(prev_canary, push_remote, gh_repo)
|
||||
else:
|
||||
print(f"✓ No new commits since {prev_canary} — nothing to do.")
|
||||
return
|
||||
from scripts.releases.versioning import published_stable_identity
|
||||
stable_version, stable_commit = published_stable_identity(gh_repo)
|
||||
tag_name = canary_tag_for_date(stable_version, date_utc)
|
||||
|
||||
since = prev_canary or stable_commit
|
||||
|
||||
if git_result("rev-parse", "--verify", "--quiet", f"refs/tags/{tag_name}").returncode == 0:
|
||||
if git("rev-parse", f"{tag_name}^{{commit}}") != git("rev-parse", "HEAD"):
|
||||
raise ValueError(f"{tag_name} already receipts a different commit")
|
||||
if args.publish:
|
||||
_resume_canary(tag_name, push_remote, gh_repo)
|
||||
else:
|
||||
print(f"✓ {tag_name} already exists — nothing to do.")
|
||||
return
|
||||
|
||||
|
||||
commits = get_commits(since_tag=since)
|
||||
if not commits:
|
||||
@@ -2769,9 +2623,6 @@ def cmd_canary(args) -> None:
|
||||
print("\nDry run complete. To publish, add --publish")
|
||||
return
|
||||
|
||||
push_remote = resolve_push_remote(args.remote)
|
||||
gh_repo = remote_github_repo(push_remote)
|
||||
|
||||
tag_result = git_result(
|
||||
"tag", "-a", tag_name, "-m", f"Hermes Agent canary {date_utc}"
|
||||
)
|
||||
@@ -2786,33 +2637,8 @@ def cmd_canary(args) -> None:
|
||||
|
||||
changelog_file = REPO_ROOT / ".release_notes.md"
|
||||
changelog_file.write_text(changelog, encoding="utf-8")
|
||||
gh_cmd = [
|
||||
"gh", "release", "create", tag_name,
|
||||
"--draft",
|
||||
"--prerelease",
|
||||
# Abort rather than let gh invent a tag: without this it creates a
|
||||
# missing tag from the default branch's tip, which would silently
|
||||
# release a different commit than the one we tagged.
|
||||
"--verify-tag",
|
||||
"--title", f"Hermes Agent canary {date_utc} ({tag_name})",
|
||||
"--notes-file", str(changelog_file),
|
||||
]
|
||||
if gh_repo:
|
||||
gh_cmd += ["--repo", gh_repo]
|
||||
result = subprocess.run(
|
||||
gh_cmd, capture_output=True, text=True, encoding="utf-8",
|
||||
errors="replace", cwd=str(REPO_ROOT),
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(f"✗ GitHub prerelease failed: {result.stderr.strip()}")
|
||||
print(f" Notes kept at {changelog_file}; tag {tag_name} is pushed.")
|
||||
sys.exit(1)
|
||||
_resume_canary(tag_name, push_remote, gh_repo, notes_file=changelog_file)
|
||||
changelog_file.unlink(missing_ok=True)
|
||||
print(f"✓ Canary prerelease drafted: {result.stdout.strip()}")
|
||||
# The build stages the installers to the R2 bucket and publishes only when
|
||||
# the full matrix is green. A release that never starts is a hard failure.
|
||||
if not dispatch_desktop_build(tag_name, gh_repo):
|
||||
raise SystemExit(1)
|
||||
# Record the tag for any workflow step that wants it. release.py
|
||||
# starts the build itself, so nothing consumes this today; it stays
|
||||
# because a step output is the cheap, conventional handle for "which
|
||||
@@ -2871,8 +2697,6 @@ def prune_old_canaries(args) -> None:
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Hermes Agent Release Tool")
|
||||
parser.add_argument("--bump", choices=["major", "minor", "patch"],
|
||||
help="Which semver component to bump")
|
||||
parser.add_argument("--canary", action="store_true",
|
||||
help="Tag + publish a stable-core canary "
|
||||
"(v<stable>+canary.<YYYYMMDDTHHMMSSZ>); no-op when "
|
||||
@@ -2898,10 +2722,7 @@ def main():
|
||||
"is configured; the single remote is used when only one exists.")
|
||||
parser.add_argument("--date", type=str,
|
||||
help="Override release date metadata (format: YYYY.M.D)")
|
||||
parser.add_argument("--first-release", action="store_true",
|
||||
help="Mark as first release (no previous tag expected)")
|
||||
parser.add_argument("--output", type=str,
|
||||
help="Write changelog to file instead of stdout")
|
||||
|
||||
parser.add_argument("--no-changelog", action="store_true",
|
||||
help="Skip changelog")
|
||||
subcommands = parser.add_subparsers(dest="command")
|
||||
@@ -2909,7 +2730,7 @@ def main():
|
||||
"release", help="Claim a version, cut a draft, and dispatch the gate")
|
||||
release_cmd.add_argument("--commit", required=True, metavar="SHA",
|
||||
help="The main commit to release")
|
||||
release_cmd.add_argument("--bump", required=True, choices=["major", "minor", "patch"])
|
||||
release_cmd.add_argument("--bump", choices=["major", "minor", "patch"], default="patch")
|
||||
release_cmd.add_argument("--autopublish", action="store_true",
|
||||
help="Publish on green instead of leaving a draft")
|
||||
release_cmd.add_argument("--remote", type=str)
|
||||
@@ -2926,207 +2747,36 @@ def main():
|
||||
add_arguments(parser)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.command == "release":
|
||||
from scripts.releases.entrypoint import cmd_release
|
||||
cmd_release(args)
|
||||
return
|
||||
if args.command == "publish":
|
||||
from scripts.releases.entrypoint import cmd_publish
|
||||
cmd_publish(args)
|
||||
return
|
||||
if args.command == "abandon":
|
||||
from scripts.releases.entrypoint import cmd_abandon
|
||||
cmd_abandon(args)
|
||||
from scripts.releases.entrypoint import cmd_abandon, cmd_publish, cmd_release
|
||||
|
||||
stable_commands = {"release": cmd_release, "publish": cmd_publish, "abandon": cmd_abandon}
|
||||
if args.command:
|
||||
stable_commands[args.command](args)
|
||||
return
|
||||
if validate_arguments(parser, args):
|
||||
cmd_channel(args)
|
||||
return
|
||||
if (args.bundle_env or args.bundle_unset) and args.build_commit is None:
|
||||
parser.error("--bundle-env and --bundle-unset require --build-commit")
|
||||
if args.canary and args.bump:
|
||||
parser.error("--canary and --bump are mutually exclusive")
|
||||
if args.build_commit is not None:
|
||||
conflicting = [name for name, supplied in (
|
||||
("--bump", args.bump), ("--canary", args.canary),
|
||||
("--prune-canaries", args.prune_canaries), ("--first-release", args.first_release),
|
||||
("--date", args.date), ("--output", args.output), ("--no-changelog", args.no_changelog),
|
||||
("--canary", args.canary), ("--prune-canaries", args.prune_canaries),
|
||||
("--date", args.date), ("--no-changelog", args.no_changelog),
|
||||
) if supplied]
|
||||
if conflicting:
|
||||
parser.error("--build-commit cannot be combined with " + ", ".join(conflicting))
|
||||
from scripts.releases.commit_build import cmd_build_commit
|
||||
from scripts.releases.commit_build import cmd_build_commit
|
||||
|
||||
cmd_build_commit(args)
|
||||
return
|
||||
if args.canary:
|
||||
cmd_canary(args)
|
||||
return
|
||||
if args.prune_canaries:
|
||||
prune_old_canaries(args)
|
||||
return
|
||||
|
||||
# Determine release-date metadata.
|
||||
if args.date:
|
||||
calver_date = args.date
|
||||
else:
|
||||
now = datetime.now()
|
||||
calver_date = f"{now.year}.{now.month}.{now.day}"
|
||||
|
||||
# Determine semver
|
||||
current_version = get_current_version()
|
||||
if args.bump:
|
||||
new_version = bump_version(current_version, args.bump)
|
||||
else:
|
||||
new_version = current_version
|
||||
tag_name = release_tag_for_version(new_version)
|
||||
|
||||
# Get previous tag
|
||||
prev_tag = get_last_tag()
|
||||
if not prev_tag and not args.first_release:
|
||||
print("No previous tags found. Use --first-release for the initial release.")
|
||||
print(f"Would create tag: {tag_name}")
|
||||
print(f"Would set version: {new_version}")
|
||||
return
|
||||
|
||||
# Get commits
|
||||
commits = get_commits(since_tag=prev_tag)
|
||||
if not commits:
|
||||
print("No new commits since last tag.")
|
||||
if not args.first_release:
|
||||
return
|
||||
|
||||
print(f"{'='*60}")
|
||||
print(" Hermes Agent Release Preview")
|
||||
print(f"{'='*60}")
|
||||
print(f" Release tag: {tag_name}")
|
||||
print(f" SemVer: v{current_version} → v{new_version}")
|
||||
print(f" Previous tag: {prev_tag or '(none — first release)'}")
|
||||
print(f" Commits: {len(commits)}")
|
||||
print(f" Unique authors: {len({c['github_author'] for c in commits})}")
|
||||
print(f" Mode: {'PUBLISH' if args.publish else 'DRY RUN'}")
|
||||
print(f"{'='*60}")
|
||||
print()
|
||||
|
||||
# Generate changelog
|
||||
changelog = generate_changelog(
|
||||
commits, tag_name, new_version,
|
||||
prev_tag=prev_tag,
|
||||
first_release=args.first_release,
|
||||
modes = (
|
||||
(args.build_commit is not None, cmd_build_commit),
|
||||
(args.canary, cmd_canary),
|
||||
(args.prune_canaries, prune_old_canaries),
|
||||
)
|
||||
|
||||
if args.output:
|
||||
Path(args.output).write_text(changelog, encoding="utf-8")
|
||||
print(f"Changelog written to {args.output}")
|
||||
else:
|
||||
print(changelog)
|
||||
|
||||
if args.publish:
|
||||
# Resolve the destination FIRST: a wrong or ambiguous remote must
|
||||
# fail before any commit or tag exists, not after.
|
||||
push_remote = resolve_push_remote(args.remote)
|
||||
gh_repo = remote_github_repo(push_remote)
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(" Publishing release...")
|
||||
print(f" Remote: {push_remote}" + (f" ({gh_repo})" if gh_repo else ""))
|
||||
print(f"{'='*60}")
|
||||
|
||||
# Update version files
|
||||
if args.bump:
|
||||
add_files = update_version_files(new_version, calver_date)
|
||||
print(f" ✓ Updated version files to v{new_version} ({calver_date})")
|
||||
|
||||
# Commit version bump
|
||||
add_result = git_result("add", *add_files)
|
||||
if add_result.returncode != 0:
|
||||
print(f" ✗ Failed to stage version files: {add_result.stderr.strip()}")
|
||||
return
|
||||
|
||||
commit_result = git_result(
|
||||
"commit", "-m", f"chore: bump version to v{new_version} ({calver_date})"
|
||||
)
|
||||
if commit_result.returncode != 0:
|
||||
print(f" ✗ Failed to commit version bump: {commit_result.stderr.strip()}")
|
||||
return
|
||||
print(" ✓ Committed version bump")
|
||||
|
||||
# Create annotated tag
|
||||
tag_result = git_result(
|
||||
"tag", "-a", tag_name, "-m",
|
||||
f"Hermes Agent v{new_version} ({calver_date})\n\nWeekly release"
|
||||
)
|
||||
if tag_result.returncode != 0:
|
||||
print(f" ✗ Failed to create tag {tag_name}: {tag_result.stderr.strip()}")
|
||||
for selected, handler in modes:
|
||||
if selected:
|
||||
handler(args)
|
||||
return
|
||||
print(f" ✓ Created tag {tag_name}")
|
||||
|
||||
# Push the tag WITH the branch. gh auto-creates a missing tag
|
||||
# "from the latest state of the default branch", so the tag must
|
||||
# exist on the remote before the release is created (--verify-tag
|
||||
# below turns a failed push into a hard error rather than a
|
||||
# release pinned to the wrong commit).
|
||||
push_result = git_result("push", push_remote, "HEAD", "--tags")
|
||||
if push_result.returncode == 0:
|
||||
print(f" ✓ Pushed to {push_remote}")
|
||||
else:
|
||||
print(f" ✗ Failed to push to {push_remote}: {push_result.stderr.strip()}")
|
||||
print(" Continue manually after fixing access:")
|
||||
print(f" git push {push_remote} HEAD --tags")
|
||||
|
||||
# Keep the release hidden until the stable pipeline completes all
|
||||
# validation, artifact publication and channel promotion.
|
||||
changelog_file = REPO_ROOT / ".release_notes.md"
|
||||
changelog_file.write_text(changelog, encoding="utf-8")
|
||||
|
||||
gh_cmd = [
|
||||
"gh", "release", "create", tag_name,
|
||||
"--draft",
|
||||
# Abort rather than let gh invent a tag: without this it creates
|
||||
# a missing tag from the default branch's tip, so a failed tag
|
||||
# push above would silently release a different commit.
|
||||
"--verify-tag",
|
||||
"--title", f"Hermes Agent v{new_version} ({calver_date})",
|
||||
"--notes-file", str(changelog_file),
|
||||
]
|
||||
# Pin gh to the pushed remote's repo: gh's own default resolution
|
||||
# can pick a different remote than the one the tag just landed on.
|
||||
if gh_repo:
|
||||
gh_cmd += ["--repo", gh_repo]
|
||||
|
||||
gh_bin = shutil.which("gh")
|
||||
if gh_bin:
|
||||
result = subprocess.run(
|
||||
gh_cmd,
|
||||
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
||||
cwd=str(REPO_ROOT),
|
||||
)
|
||||
else:
|
||||
result = None
|
||||
|
||||
if result and result.returncode == 0:
|
||||
changelog_file.unlink(missing_ok=True)
|
||||
print(f" ✓ GitHub draft release created: {result.stdout.strip()}")
|
||||
dispatch_desktop_build(tag_name, gh_repo)
|
||||
print(f"\n 🎉 Release v{new_version} ({tag_name}) drafted!")
|
||||
print(" Stable Release runs full CI, Docker and package acceptance.")
|
||||
print(" It publishes artifacts and advances stable only after the required gates pass.")
|
||||
else:
|
||||
if result is None:
|
||||
print(" ✗ GitHub release skipped: `gh` CLI not found.")
|
||||
else:
|
||||
print(f" ✗ GitHub release failed: {result.stderr.strip()}")
|
||||
print(f" Release notes kept at: {changelog_file}")
|
||||
print(" Tag was created locally. Create the draft release manually:")
|
||||
repo_flag = f" --repo {gh_repo}" if gh_repo else ""
|
||||
print(
|
||||
f" gh release create {tag_name}{repo_flag} --draft --title 'Hermes Agent v{new_version} ({calver_date})' "
|
||||
f"--notes-file .release_notes.md"
|
||||
)
|
||||
print(f"\n ✓ Release v{new_version} ({tag_name}) prepared for manual publish.")
|
||||
else:
|
||||
print(f"\n{'='*60}")
|
||||
print(" Dry run complete. To publish, add --publish")
|
||||
print(" Example: python scripts/release.py --bump minor --publish")
|
||||
print(f"{'='*60}")
|
||||
parser.error("select release, publish, abandon, --canary, --build-commit, or a channel operation")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -292,12 +292,9 @@ def validate_arguments(parser, args) -> bool:
|
||||
if args.retire_channel and not all(retirement):
|
||||
parser.error("--retire-channel requires --to and --minimum-version")
|
||||
if (not args.channel and args.build_commit) or any((
|
||||
args.bump,
|
||||
args.canary,
|
||||
args.prune_canaries,
|
||||
args.first_release,
|
||||
args.date,
|
||||
args.output,
|
||||
args.no_changelog,
|
||||
)):
|
||||
parser.error(
|
||||
|
||||
@@ -188,7 +188,7 @@ def main(argv: list[str] | None = None) -> None:
|
||||
parser.add_argument("--build-id", default=os.environ.get("CHANNEL_BUILD", ""))
|
||||
parser.add_argument("--request-sha256", default=os.environ.get("CHANNEL_REQUEST_SHA256", ""))
|
||||
parser.add_argument("--public-base", help="Expected public authority (not an override in disposable CI)")
|
||||
parser.add_argument("--disposable-run", help="Controller run-id-attempt; propagated to R2 object scope")
|
||||
parser.add_argument("--disposable-run", help="Controller run ID; propagated to R2 object scope")
|
||||
parser.add_argument("--repository", default=os.environ.get("GITHUB_REPOSITORY", ""))
|
||||
parser.add_argument("--out", type=Path)
|
||||
parser.add_argument("--root", type=Path)
|
||||
|
||||
@@ -8,6 +8,7 @@ import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from hermes_cli.release_channels import (
|
||||
ChannelError, build_prefix, canonical_json, validate_identity,
|
||||
@@ -92,7 +93,18 @@ def match_accepted_packages(manifest: dict, accepted: dict) -> None:
|
||||
raise ChannelError("Accepted packages do not declare retirement receiver support")
|
||||
|
||||
|
||||
def admit_transaction(policy: str, env: dict, *, run=stable.output) -> tuple[str, str]:
|
||||
def canary_windows_version(tag: str) -> str:
|
||||
from hermes_cli.update_channel import canary_timestamp
|
||||
|
||||
stamp = canary_timestamp(tag)
|
||||
if stamp is None:
|
||||
raise ChannelError("Invalid canary release identity")
|
||||
instant = datetime.strptime(stamp, "%Y%m%dT%H%M%SZ").replace(tzinfo=timezone.utc)
|
||||
return f"{instant.year % 100}.{int(f'{instant.month:02d}{instant.day:02d}')}.{instant.hour}.{int(f'{instant.minute:02d}{instant.second:02d}')}"
|
||||
|
||||
|
||||
def admit_transaction(policy: str, env: dict, *, require_published: bool = False,
|
||||
run=stable.output) -> tuple[str, str]:
|
||||
"""A callable CLI is not permission to bypass the existing workflow gate."""
|
||||
from scripts.releases.semver import is_release_version
|
||||
from hermes_cli.release_channels import require_commit, validate_repository
|
||||
@@ -134,21 +146,31 @@ def admit_transaction(policy: str, env: dict, *, run=stable.output) -> tuple[str
|
||||
raise ChannelError("Protected publication requires its existing release workflow")
|
||||
stable.require_success(json.loads(env.get("RELEASE_NEEDS", "{}")), list(required))
|
||||
commit = require_commit(env.get("RELEASE_COMMIT"))
|
||||
expected_object = require_commit(env.get("RELEASE_TAG_OBJECT"))
|
||||
try:
|
||||
local_object = run(["git", "rev-parse", f"refs/tags/{tag}^{{tag}}"])
|
||||
except subprocess.CalledProcessError as error:
|
||||
raise ChannelError("Canary release tag must be annotated") from error
|
||||
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())
|
||||
if actual != commit or remote.get(f"refs/tags/{tag}^{{}}", remote.get(f"refs/tags/{tag}")) != commit:
|
||||
if (local_object != expected_object or actual != commit
|
||||
or remote.get(f"refs/tags/{tag}") != expected_object
|
||||
or remote.get(f"refs/tags/{tag}^{{}}") != commit):
|
||||
raise ChannelError("Canary release tag moved")
|
||||
run(["git", "merge-base", "--is-ancestor", commit, f"origin/{default}"])
|
||||
release = json.loads(run(["gh", "release", "view", tag, "--repo", repository,
|
||||
"--json", "tagName,isDraft,isPrerelease"]))
|
||||
if (release.get("tagName") != tag or release.get("isDraft") is not False
|
||||
if (release.get("tagName") != tag or not isinstance(release.get("isDraft"), bool)
|
||||
or release.get("isPrerelease") is not True):
|
||||
raise ChannelError("Protected head requires its exact GitHub prerelease transaction")
|
||||
if require_published and release["isDraft"]:
|
||||
raise ChannelError("Protected head requires the published GitHub release transaction")
|
||||
return tag, commit
|
||||
|
||||
|
||||
def accepted_stable(publisher: ChannelPublisher, env: dict, tag: str, commit: str) -> dict:
|
||||
def accepted_stable(publisher: ChannelPublisher, env: dict, tag: str, commit: str,
|
||||
release_epoch: int) -> dict:
|
||||
from hermes_cli.release_channels import decode_json, require_sha256
|
||||
|
||||
digest = require_sha256(env.get("CANDIDATE_MANIFEST_SHA256"))
|
||||
@@ -156,7 +178,7 @@ def accepted_stable(publisher: ChannelPublisher, env: dict, tag: str, commit: st
|
||||
if env.get("CANDIDATE_MANIFEST_URL") != publisher.public_base + "/" + key:
|
||||
raise ChannelError("Accepted candidate URL differs from release archive")
|
||||
candidate = decode_json(publisher.reader.read_bytes(key, digest))
|
||||
stable.validate_candidates(candidate, tag, commit, publisher.public_base)
|
||||
stable.validate_candidates(candidate, tag, commit, publisher.public_base, release_epoch)
|
||||
return candidate
|
||||
|
||||
|
||||
@@ -267,13 +289,12 @@ def verify_canary_outputs(request: dict, manifest: dict, reader) -> None:
|
||||
|
||||
|
||||
def publish_release(policy: str, env: dict, root: Path) -> dict:
|
||||
from scripts.releases.commit_build import version_at
|
||||
|
||||
tag, commit = admit_transaction(policy, env)
|
||||
if env.get("R2_DISPOSABLE_RUN"):
|
||||
raise ChannelError("Disposable receiver builds cannot enter production release publication")
|
||||
publisher = ChannelPublisher(R2ChannelStore(*r2.credentials()), env["GITHUB_REPOSITORY"],
|
||||
r2.public_base_url(), authorize=lambda action, record: admit_transaction(policy, env))
|
||||
r2.public_base_url(), authorize=lambda action, record:
|
||||
admit_transaction(policy, env, require_published=True))
|
||||
name = select_channel(publisher, policy)
|
||||
identity = product_identity(tag)
|
||||
current = publisher._read(name)
|
||||
@@ -282,20 +303,34 @@ def publish_release(policy: str, env: dict, root: Path) -> dict:
|
||||
identity["token"] = current[0]["identity"]["token"]
|
||||
if identity != current[0]["identity"]:
|
||||
raise ChannelError("Protected R2 identity differs from the existing product")
|
||||
accepted = accepted_stable(publisher, env, tag, commit) if policy == "stable-release" else None
|
||||
release_epoch = stable.final_context(env)[2]["claim_epoch"] if policy == "stable-release" else None
|
||||
accepted = accepted_stable(publisher, env, tag, commit, release_epoch) \
|
||||
if release_epoch is not None else None
|
||||
handoff.fetch(tag, commit, list(NATIVE_LEGS), root,
|
||||
["metadata-*.json", "*.zip", "*.dmg", "*.blockmap", "*.msixbundle"], public_base=publisher.public_base)
|
||||
native = read_native_receipts(root, tag, commit)
|
||||
windows = next(row for row in native["packages"] if row["platform"] == "windows")
|
||||
if policy == "canary-release":
|
||||
expected_windows = canary_windows_version(tag)
|
||||
if (windows["version"] != expected_windows
|
||||
or windows.get("executableVersion") != expected_windows):
|
||||
raise ChannelError("Canary Windows version differs from its release timestamp")
|
||||
stable.output(["gh", "release", "edit", tag, "--repo", env["GITHUB_REPOSITORY"],
|
||||
"--draft=false"])
|
||||
if admit_transaction(policy, env, require_published=True) != (tag, commit):
|
||||
raise ChannelError("Canary GitHub release publication did not preserve custody")
|
||||
|
||||
def release_gate(request: dict) -> bool:
|
||||
if admit_transaction(policy, env) != (tag, commit):
|
||||
if admit_transaction(policy, env, require_published=True) != (tag, commit):
|
||||
return False
|
||||
if policy == "stable-release":
|
||||
return accepted_stable(publisher, env, tag, commit) == accepted
|
||||
if release_epoch is None:
|
||||
raise ChannelError("Stable release epoch is unavailable")
|
||||
return accepted_stable(publisher, env, tag, commit, release_epoch) == accepted
|
||||
return True
|
||||
|
||||
request = publisher.allocate_protected(name, commit, version_at(None, commit), release_tag=tag,
|
||||
source_version = tag[1:].split("+", 1)[0]
|
||||
request = publisher.allocate_protected(name, commit, source_version, release_tag=tag,
|
||||
version=tag[1:], windows_version=windows["version"],
|
||||
identity=identity, policy=policy, release_gate=release_gate)
|
||||
manifest, feeds = assemble(request, native, root, artifact_prefix=f"releases/tag/{tag}/")
|
||||
@@ -319,12 +354,12 @@ def publish_release(policy: str, env: dict, root: Path) -> dict:
|
||||
return pinned == request and actual == expected
|
||||
|
||||
publisher.verify_build = qualified
|
||||
result = publisher.promote_protected(request["buildId"], policy=policy, release_gate=release_gate)
|
||||
if accepted is not None:
|
||||
publisher._write("releases/stable/release-candidates.json", accepted)
|
||||
if publisher.reader.read_bytes("releases/stable/release-candidates.json") != canonical_json(accepted):
|
||||
raise ChannelError("Stable candidate pointer read-back differs")
|
||||
promote_stable_feeds(accepted, root, publisher.public_base)
|
||||
result = publisher.promote_protected(request["buildId"], policy=policy, release_gate=release_gate)
|
||||
return result
|
||||
|
||||
|
||||
|
||||
@@ -7,10 +7,12 @@ because a burned version is never retried under the same number.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from scripts.releases.versioning import derive_next_version
|
||||
from scripts.releases.versioning import SEED, derive_next_version
|
||||
|
||||
WORKFLOW = "stable-release.yml"
|
||||
|
||||
@@ -84,8 +86,6 @@ def _require_ancestry(repo: Path, commit: str) -> None:
|
||||
if highest is None:
|
||||
return
|
||||
version, claimed = highest
|
||||
if commit == claimed:
|
||||
return
|
||||
ancestor = subprocess.run(
|
||||
["git", "merge-base", "--is-ancestor", claimed, commit], cwd=repo, capture_output=True)
|
||||
if ancestor.returncode != 0:
|
||||
@@ -93,21 +93,35 @@ def _require_ancestry(repo: Path, commit: str) -> None:
|
||||
f"{version} already claimed at {claimed} — publish or abandon it first")
|
||||
|
||||
|
||||
def _next_claim_epoch(repo: Path) -> int:
|
||||
epochs = _git(repo, "for-each-ref", "refs/tags/v*-rc", "--format=%(taggerdate:unix)")
|
||||
previous = [int(value) for value in epochs.splitlines() if value.isdigit()]
|
||||
return max(int(time.time()), max(previous, default=0) + 1)
|
||||
|
||||
|
||||
def release(commit: str, *, bump: str, repo: Path, remote: str, repository: str,
|
||||
execute, autopublish: bool = False) -> dict:
|
||||
execute, autopublish: bool = False, published: str = SEED) -> dict:
|
||||
"""Claim the derived version, cut its draft, and start the gate."""
|
||||
_refresh_claims(repo, remote)
|
||||
_require_remote_main(repo, commit)
|
||||
_require_ancestry(repo, commit)
|
||||
version = derive_next_version(published=None, claims=_claims(repo), bump=bump)
|
||||
version = derive_next_version(
|
||||
published=published, claims=_claims(repo), bump=bump,
|
||||
)
|
||||
tag = f"v{version}-rc"
|
||||
claim_epoch = _next_claim_epoch(repo)
|
||||
claim = json.dumps({
|
||||
"schema": 1,
|
||||
"version": version,
|
||||
"commit": commit,
|
||||
"autopublish": autopublish,
|
||||
"claimEpoch": claim_epoch,
|
||||
}, sort_keys=True, separators=(",", ":"))
|
||||
_git(repo, "tag", "-a", tag, commit, "-m", claim)
|
||||
subprocess.check_output(
|
||||
["git", "tag", "-a", tag, commit, "-m", claim], cwd=repo,
|
||||
text=True, encoding="utf-8",
|
||||
env={**os.environ, "GIT_COMMITTER_DATE": f"@{claim_epoch} +0000"},
|
||||
)
|
||||
try:
|
||||
_git(repo, "push", remote, f"refs/tags/{tag}")
|
||||
except subprocess.CalledProcessError as error:
|
||||
@@ -128,7 +142,6 @@ def release(commit: str, *, bump: str, repo: Path, remote: str, repository: str,
|
||||
execute([
|
||||
"gh", "workflow", "run", WORKFLOW, "--ref", tag, "--repo", repository,
|
||||
"--raw-field", f"tag={tag}",
|
||||
"--raw-field", f"autopublish={str(autopublish).lower()}",
|
||||
])
|
||||
except Exception as exc:
|
||||
raise ReleaseRefused(f"release {tag} never started: {exc}") from exc
|
||||
@@ -136,39 +149,38 @@ def release(commit: str, *, bump: str, repo: Path, remote: str, repository: str,
|
||||
"autopublish": autopublish}
|
||||
|
||||
|
||||
def _preflight_publish(version: str, repository: str, inspect) -> None:
|
||||
from scripts.releases.versioning import version_from_tag
|
||||
def _release_view(tag: str, repository: str, inspect) -> dict | None:
|
||||
try:
|
||||
return json.loads(inspect([
|
||||
"gh", "release", "view", tag, "--repo", repository,
|
||||
"--json", "tagName,isDraft,isPrerelease",
|
||||
]))
|
||||
except ReleaseRefused as exc:
|
||||
if "not found" in str(exc).lower():
|
||||
return None
|
||||
raise
|
||||
|
||||
rows = json.loads(inspect([
|
||||
"gh", "release", "list", "--repo", repository, "--limit", "100",
|
||||
"--json", "tagName,isDraft,isPrerelease",
|
||||
]))
|
||||
|
||||
def _preflight_publish(version: str, repository: str, inspect, head_version) -> None:
|
||||
requested = tuple(map(int, version.split(".")))
|
||||
published = []
|
||||
family = False
|
||||
for row in rows:
|
||||
tag = row.get("tagName")
|
||||
family = family or tag in {f"v{version}", f"v{version}-rc"}
|
||||
parsed = version_from_tag(tag)
|
||||
if parsed and row.get("isDraft") is False and row.get("isPrerelease") is False:
|
||||
published.append((tuple(map(int, parsed.split("."))), parsed))
|
||||
newer = [found for key, found in published if key > requested]
|
||||
if newer:
|
||||
latest = max(newer, key=lambda item: tuple(map(int, item.split("."))))
|
||||
raise ReleaseRefused(f"stable {version} is burned or superseded by {latest}")
|
||||
if not family:
|
||||
head = head_version()
|
||||
if head and tuple(map(int, head.split("."))) >= requested:
|
||||
raise ReleaseRefused(f"stable {version} is burned or superseded by {head}")
|
||||
rows = [row for tag in (f"v{version}", f"v{version}-rc")
|
||||
if (row := _release_view(tag, repository, inspect)) is not None]
|
||||
if len(rows) != 1:
|
||||
raise ReleaseRefused(f"stable {version} is burned or has no release draft")
|
||||
|
||||
|
||||
def publish(version: str, *, repository: str, dispatch, inspect=None) -> dict:
|
||||
def publish(version: str, *, repository: str, dispatch, inspect=None, head_version=None) -> dict:
|
||||
"""Request ordered publication through the one production sequencer."""
|
||||
tag = f"v{version}"
|
||||
from hermes_cli.update_channel import STABLE_TAG_RE
|
||||
|
||||
if not STABLE_TAG_RE.fullmatch(tag):
|
||||
raise ReleaseRefused(f"{version} is not a stable version")
|
||||
if inspect is not None:
|
||||
_preflight_publish(version, repository, inspect)
|
||||
if inspect is not None and head_version is not None:
|
||||
_preflight_publish(version, repository, inspect, head_version)
|
||||
dispatch([
|
||||
"gh", "workflow", "run", "stable-release-publication.yml",
|
||||
"--repo", repository, "--raw-field", f"version={version}",
|
||||
@@ -176,9 +188,16 @@ def publish(version: str, *, repository: str, dispatch, inspect=None) -> dict:
|
||||
return {"requested": tag}
|
||||
|
||||
|
||||
def abandon(version: str, *, repo: Path, repository: str, delete) -> dict:
|
||||
def abandon(version: str, *, repo: Path, repository: str, delete, inspect=None) -> dict:
|
||||
"""Delete the draft. The claim tag stays, so the version is spent."""
|
||||
delete(["gh", "release", "delete", f"v{version}-rc", "--repo", repository, "--yes"])
|
||||
tag = f"v{version}-rc"
|
||||
if inspect is not None:
|
||||
rows = [row for candidate in (f"v{version}", tag)
|
||||
if (row := _release_view(candidate, repository, inspect)) is not None]
|
||||
if len(rows) != 1 or rows[0].get("isDraft") is not True:
|
||||
raise ReleaseRefused(f"stable {version} has no single release draft to abandon")
|
||||
tag = rows[0]["tagName"]
|
||||
delete(["gh", "release", "delete", tag, "--repo", repository, "--yes"])
|
||||
return {"burned": version}
|
||||
|
||||
|
||||
@@ -198,8 +217,12 @@ def cmd_release(args) -> None:
|
||||
if completed.returncode != 0:
|
||||
raise RuntimeError(completed.stderr.strip() or "release command failed")
|
||||
|
||||
result = release(commit, bump=args.bump, repo=repo, remote=remote, repository=repository,
|
||||
execute=execute, autopublish=args.autopublish)
|
||||
from scripts.releases.versioning import published_stable_version
|
||||
result = release(
|
||||
commit, bump=args.bump, repo=repo, remote=remote, repository=repository,
|
||||
execute=execute, autopublish=args.autopublish,
|
||||
published=published_stable_version(repository),
|
||||
)
|
||||
print(result["url"])
|
||||
|
||||
|
||||
@@ -229,12 +252,15 @@ def _inspect(repo: Path, command: list[str]) -> str:
|
||||
|
||||
def cmd_publish(args) -> None:
|
||||
repo, repository = _command_repository(args)
|
||||
from scripts.releases.versioning import published_stable_version
|
||||
publish(args.version, repository=repository,
|
||||
dispatch=lambda command: _execute(repo, command),
|
||||
inspect=lambda command: _inspect(repo, command))
|
||||
inspect=lambda command: _inspect(repo, command),
|
||||
head_version=lambda: published_stable_version(repository))
|
||||
|
||||
|
||||
def cmd_abandon(args) -> None:
|
||||
repo, repository = _command_repository(args)
|
||||
abandon(args.version, repo=repo, repository=repository,
|
||||
delete=lambda command: _execute(repo, command))
|
||||
delete=lambda command: _execute(repo, command),
|
||||
inspect=lambda command: _inspect(repo, command))
|
||||
@@ -6,15 +6,13 @@ import os
|
||||
import re
|
||||
|
||||
# A lease is the workflow run id alone: "re-run failed jobs" must re-enter the
|
||||
# SAME namespace (succeeded jobs are skipped and their outputs persist). The
|
||||
# legacy <run-id>-<attempt> shape stays readable for allocations minted before
|
||||
# the one-dispatch collapse.
|
||||
_RUN_LEASE = r"[1-9][0-9]{0,19}(?:-[1-9][0-9]{0,5})?"
|
||||
# SAME namespace because succeeded jobs are skipped and their outputs persist.
|
||||
_RUN_LEASE = r"[1-9][0-9]{0,19}"
|
||||
|
||||
|
||||
def require_run(value: str) -> str:
|
||||
if not isinstance(value, str) or not re.fullmatch(_RUN_LEASE, value):
|
||||
raise ValueError("Disposable R2 run must be <run-id> or legacy <run-id>-<attempt>")
|
||||
raise ValueError("Disposable R2 run must be <run-id>")
|
||||
return value
|
||||
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ SHA256 = re.compile(r"[a-f0-9]{64}")
|
||||
DOCKER_DIGEST = re.compile(r"sha256:[a-f0-9]{64}")
|
||||
MAX_ATTEMPTS = 3
|
||||
RETRY_BACKOFF = timedelta(minutes=15)
|
||||
CLAIM_GRACE = timedelta(hours=1)
|
||||
|
||||
|
||||
def _key(version: str) -> tuple[int, int, int]:
|
||||
@@ -32,11 +33,10 @@ def _key(version: str) -> tuple[int, int, int]:
|
||||
|
||||
def plan(claims: list[dict], *, head: str | None,
|
||||
requested_version: str | None = None) -> list[dict]:
|
||||
"""Return eligible draft flips followed by each ordered head advance."""
|
||||
"""Return each eligible draft flip immediately followed by its head advance."""
|
||||
ordered = sorted(claims, key=lambda claim: _key(claim["version"]))
|
||||
head_key = _key(head) if head is not None else None
|
||||
flips: list[dict] = []
|
||||
advances: list[dict] = []
|
||||
steps: list[dict] = []
|
||||
flush_green_chain = False
|
||||
|
||||
for index, claim in enumerate(ordered):
|
||||
@@ -50,7 +50,7 @@ def plan(claims: list[dict], *, head: str | None,
|
||||
raise ValueError(f"refusing to move the head backwards from {head}")
|
||||
|
||||
if state == "published":
|
||||
advances.append({"advance": version})
|
||||
steps.append({"advance": version})
|
||||
continue
|
||||
if state == "burned":
|
||||
continue
|
||||
@@ -59,22 +59,25 @@ def plan(claims: list[dict], *, head: str | None,
|
||||
if state != "green":
|
||||
raise ValueError(f"unknown claim state {state!r}")
|
||||
|
||||
has_later_live_claim = any(
|
||||
later["state"] != "burned" for later in ordered[index + 1:]
|
||||
)
|
||||
has_later_green_claim = False
|
||||
for later in ordered[index + 1:]:
|
||||
if later["state"] == "running":
|
||||
break
|
||||
if later["state"] == "green":
|
||||
has_later_green_claim = True
|
||||
break
|
||||
eligible = (
|
||||
claim.get("autopublish", False)
|
||||
or version == requested_version
|
||||
or has_later_live_claim
|
||||
or has_later_green_claim
|
||||
or flush_green_chain
|
||||
)
|
||||
if not eligible:
|
||||
break
|
||||
flips.append({"flip": version})
|
||||
advances.append({"advance": version})
|
||||
flush_green_chain = flush_green_chain or has_later_live_claim
|
||||
steps.extend(({"flip": version}, {"advance": version}))
|
||||
flush_green_chain = flush_green_chain or has_later_green_claim
|
||||
|
||||
return flips + advances
|
||||
return steps
|
||||
|
||||
|
||||
def output(argv: list[str]) -> str:
|
||||
@@ -114,10 +117,14 @@ def _utc(value: str) -> datetime:
|
||||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def classify_runs(runs: list[dict]) -> tuple[str, dict | None]:
|
||||
def classify_runs(runs: list[dict], *, claimed_at: datetime | None = None,
|
||||
now: datetime | None = None) -> tuple[str, dict | None]:
|
||||
"""Keep failed claims live until two failed-job retries are exhausted."""
|
||||
if not runs:
|
||||
return "burned", None
|
||||
if claimed_at is None:
|
||||
raise ValueError("A claim without a workflow needs its immutable claim time")
|
||||
now = now or datetime.now(timezone.utc)
|
||||
return ("running", None) if now < claimed_at + CLAIM_GRACE else ("burned", None)
|
||||
run_ids = {row.get("id") for row in runs}
|
||||
if len(run_ids) != 1 or None in run_ids:
|
||||
raise ValueError("Stable claim owns multiple workflow runs")
|
||||
@@ -142,17 +149,37 @@ def classify_runs(runs: list[dict]) -> tuple[str, dict | None]:
|
||||
|
||||
|
||||
def retry_due(records: list[dict], *, now: datetime | None = None) -> list[dict]:
|
||||
"""Return bounded failed-job retries whose backoff has elapsed."""
|
||||
"""Return the oldest unresolved retry once its backoff has elapsed."""
|
||||
now = now or datetime.now(timezone.utc)
|
||||
retries = []
|
||||
for record in records:
|
||||
if record.get("state") != "running":
|
||||
continue
|
||||
retry = record.get("retry")
|
||||
if retry is not None and retry["due_at"] <= now:
|
||||
retries.append({
|
||||
if retry is None or retry["due_at"] > now:
|
||||
return []
|
||||
return [{
|
||||
"version": record["version"], "run_id": retry["run_id"],
|
||||
"attempt": retry["attempt"] + 1,
|
||||
})
|
||||
return retries
|
||||
}]
|
||||
return []
|
||||
|
||||
|
||||
def classify_final_release(tag: str, claim_tag: str, release: dict | None) -> tuple[str, bool]:
|
||||
"""Derive green/published/abandoned state from the exact GitHub release."""
|
||||
if release is None:
|
||||
return "burned", False
|
||||
if release.get("prerelease") is not False:
|
||||
raise ValueError(f"{tag} has no valid GitHub release")
|
||||
needs_retarget = release.get("tag_name") == claim_tag
|
||||
if release.get("tag_name") not in {claim_tag, tag}:
|
||||
raise ValueError(f"{tag} release identity changed")
|
||||
if release.get("draft") is True:
|
||||
return "green", needs_retarget
|
||||
if release.get("draft") is False and release.get("published_at"):
|
||||
if needs_retarget:
|
||||
raise ValueError(f"{claim_tag} was published before final retargeting")
|
||||
return "published", False
|
||||
raise ValueError(f"{tag} release state is invalid")
|
||||
|
||||
|
||||
def _remote_tags(run=output) -> dict[str, dict[str, str]]:
|
||||
@@ -181,6 +208,8 @@ def _tag_message(tag: str, expected_object: str, run=output) -> dict:
|
||||
|
||||
def discover(repository: str, run=output) -> list[dict]:
|
||||
"""Derive every stable claim state from remote refs and GitHub objects."""
|
||||
from scripts.releases.stable import tagger_epoch
|
||||
|
||||
run(["git", "fetch", "origin", "+refs/tags/v*:refs/tags/v*"])
|
||||
refs = _remote_tags(run)
|
||||
releases = _release_rows(repository, run)
|
||||
@@ -197,12 +226,16 @@ def discover(repository: str, run=output) -> list[dict]:
|
||||
version = tag[1:]
|
||||
commit = claim_ref["commit"]
|
||||
claim = _tag_message(claim_tag, claim_ref["object"], run)
|
||||
claim_epoch = claim.get("claimEpoch")
|
||||
expected_claim = {
|
||||
"schema": 1, "version": version, "commit": commit,
|
||||
"autopublish": claim.get("autopublish"),
|
||||
"autopublish": claim["autopublish"], "claimEpoch": claim_epoch,
|
||||
}
|
||||
if claim != expected_claim or not isinstance(claim["autopublish"], bool):
|
||||
raise ValueError(f"{claim_tag} metadata differs from its ref")
|
||||
if (claim != expected_claim or not isinstance(claim["autopublish"], bool)
|
||||
or not isinstance(claim_epoch, int) or claim_epoch <= 0):
|
||||
raise ValueError(f"{claim_tag} metadata is invalid")
|
||||
if tagger_epoch(claim_ref["object"], run) != claim_epoch:
|
||||
raise ValueError(f"{claim_tag} epoch differs from its annotated tagger timestamp")
|
||||
|
||||
family_releases = [row for row in releases if row.get("tag_name") in {claim_tag, tag}]
|
||||
if len(family_releases) > 1:
|
||||
@@ -223,26 +256,20 @@ def discover(repository: str, run=output) -> list[dict]:
|
||||
"schema": 1, "version": version, "commit": commit,
|
||||
"claimTag": claim_tag, "claimTagObject": claim_ref["object"],
|
||||
"autopublish": claim["autopublish"],
|
||||
"claimEpoch": claim_epoch,
|
||||
"releaseId": final.get("releaseId"),
|
||||
"candidateManifestSha256": final.get("candidateManifestSha256"),
|
||||
"dockerManifestDigest": final.get("dockerManifestDigest"),
|
||||
}
|
||||
if (final != expected_final
|
||||
or not isinstance(final["releaseId"], int) or final["releaseId"] <= 0
|
||||
or not SHA256.fullmatch(final["candidateManifestSha256"] or "")
|
||||
or not DOCKER_DIGEST.fullmatch(final["dockerManifestDigest"] or "")):
|
||||
raise ValueError(f"{tag} metadata differs from {claim_tag}")
|
||||
if release is None or release.get("prerelease") is not False:
|
||||
raise ValueError(f"{tag} has no valid GitHub release")
|
||||
needs_retarget = release.get("tag_name") == claim_tag
|
||||
if release.get("tag_name") not in {claim_tag, tag}:
|
||||
raise ValueError(f"{tag} release identity changed")
|
||||
if release.get("draft") is True:
|
||||
state = "green"
|
||||
elif release.get("draft") is False and release.get("published_at"):
|
||||
if needs_retarget:
|
||||
raise ValueError(f"{claim_tag} was published before final retargeting")
|
||||
state = "published"
|
||||
if release is None or release.get("id") != final["releaseId"]:
|
||||
state, needs_retarget = "burned", False
|
||||
else:
|
||||
raise ValueError(f"{tag} release state is invalid")
|
||||
state, needs_retarget = classify_final_release(tag, claim_tag, release)
|
||||
else:
|
||||
if release is not None and (release.get("tag_name") != claim_tag
|
||||
or release.get("draft") is not True
|
||||
@@ -250,7 +277,10 @@ def discover(repository: str, run=output) -> list[dict]:
|
||||
raise ValueError(f"{claim_tag} draft state is invalid")
|
||||
matching_runs = [row for row in workflow_runs
|
||||
if row.get("head_branch") == claim_tag and row.get("head_sha") == commit]
|
||||
state, retry = classify_runs(matching_runs)
|
||||
state, retry = classify_runs(
|
||||
matching_runs,
|
||||
claimed_at=datetime.fromtimestamp(claim_epoch, tz=timezone.utc),
|
||||
)
|
||||
|
||||
records.append({
|
||||
"version": version,
|
||||
@@ -308,13 +338,6 @@ def reconcile(env: dict, *, run=output, read_head=None, advance_head=None) -> li
|
||||
steps = plan(records, head=head, requested_version=requested)
|
||||
by_version = {record["version"]: record for record in records}
|
||||
|
||||
for step in steps:
|
||||
if "flip" not in step:
|
||||
continue
|
||||
record = by_version[step["flip"]]
|
||||
stable.retarget_release(repository, record["release_id"], record["tag"],
|
||||
record["commit"], publish=True, run=run)
|
||||
|
||||
if advance_head is None:
|
||||
def production_advance(record: dict) -> None:
|
||||
docker.promote_stable(record["tag"], record["docker_manifest_digest"])
|
||||
@@ -323,7 +346,11 @@ def reconcile(env: dict, *, run=output, read_head=None, advance_head=None) -> li
|
||||
advance_head = production_advance
|
||||
|
||||
for step in steps:
|
||||
if "advance" in step:
|
||||
if "flip" in step:
|
||||
record = by_version[step["flip"]]
|
||||
stable.retarget_release(repository, record["release_id"], record["tag"],
|
||||
record["commit"], publish=True, run=run)
|
||||
else:
|
||||
advance_head(by_version[step["advance"]])
|
||||
|
||||
final = {record["version"]: record for record in discover(repository, run)}
|
||||
|
||||
+81
-40
@@ -10,6 +10,7 @@ import sys
|
||||
import tomllib
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from urllib.parse import unquote, urlsplit
|
||||
|
||||
@@ -31,10 +32,12 @@ def admit_claim(tag: str, commit: str, *, on_main) -> dict:
|
||||
``-rc`` tag and the only question about the commit is whether it is on
|
||||
``main``.
|
||||
"""
|
||||
from scripts.releases.versioning import version_from_tag
|
||||
|
||||
if not isinstance(tag, str) or not tag.endswith("-rc"):
|
||||
raise ValueError(f"{tag} is not a claim tag")
|
||||
version = tag[1:-3]
|
||||
if not re.fullmatch(r"(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)", version):
|
||||
version = version_from_tag(tag[:-3])
|
||||
if version is None:
|
||||
raise ValueError(f"{tag} is not a claim tag")
|
||||
if not on_main(commit):
|
||||
raise ValueError(f"{commit} is not on main")
|
||||
@@ -68,17 +71,26 @@ def successful_smoke_results(needs: object) -> dict:
|
||||
return results
|
||||
|
||||
|
||||
def stable_windows_version(epoch: object) -> str:
|
||||
if isinstance(epoch, bool) or not isinstance(epoch, int) or epoch < 0:
|
||||
raise ValueError("Stable release epoch must be a non-negative integer")
|
||||
instant = datetime.fromtimestamp(epoch, tz=timezone.utc)
|
||||
start = datetime(instant.year, 1, 1, tzinfo=timezone.utc)
|
||||
hour_of_year = (instant - start).days * 24 + instant.hour
|
||||
second_of_hour = instant.minute * 60 + instant.second
|
||||
return f"{instant.year}.{hour_of_year}.{second_of_hour}.0"
|
||||
|
||||
|
||||
def validate_candidates(manifest: dict, tag: str, commit: str, public_base: str,
|
||||
*, allow_legacy: bool = False) -> dict:
|
||||
release_epoch: int | None = None) -> dict:
|
||||
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):
|
||||
if manifest.get("schema") != 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:
|
||||
# A published baseline supplies old package identities, not new smoke evidence.
|
||||
if not allow_legacy:
|
||||
raise ValueError("Legacy candidate has no bound smoke admission; build a new candidate tag")
|
||||
else:
|
||||
successful_smoke_results(manifest.get("smoke_results"))
|
||||
successful_smoke_results(manifest.get("smoke_results"))
|
||||
admitted_epoch = manifest.get("releaseEpoch")
|
||||
expected_windows_version = stable_windows_version(admitted_epoch)
|
||||
if release_epoch is not None and admitted_epoch != release_epoch:
|
||||
raise ValueError("Candidate release epoch differs from the admitted claim")
|
||||
prefix = urlsplit(f"{public_base.rstrip('/')}/releases/tag/{tag}/")
|
||||
if prefix.scheme != "https" or prefix.username or prefix.password or not prefix.netloc:
|
||||
raise ValueError("Public release origin must use HTTPS")
|
||||
@@ -97,12 +109,17 @@ def validate_candidates(manifest: dict, tag: str, commit: str, public_base: str,
|
||||
if not DIGEST.fullmatch(artifact.get("sha256", "")) or not item.get("identity"):
|
||||
raise ValueError(f"Invalid candidate digest or identity: {target}")
|
||||
if item["platform"] == "windows":
|
||||
windows_version(item.get("version", ""))
|
||||
if (item.get("version") != expected_windows_version
|
||||
or item.get("executableVersion") != expected_windows_version):
|
||||
raise ValueError("Windows candidate version differs from the admitted release epoch")
|
||||
windows_version(item["version"])
|
||||
if not item.get("publisher") or not item.get("applicationId") or not url.path.endswith(".msixbundle"):
|
||||
raise ValueError("Windows candidate needs publisher, applicationId and MSIX bundle")
|
||||
elif item["platform"] == "macos":
|
||||
if item.get("version") != tag[1:] or not re.fullmatch(r"[A-Z0-9]{10}", item.get("teamId", "")) or not url.path.endswith(".zip"):
|
||||
raise ValueError("macOS candidate needs matching version, signing team and app ZIP")
|
||||
elif item.get("version") != f"{tag[1:]}-1":
|
||||
raise ValueError("Termux candidate version differs from the admitted release")
|
||||
rows[target] = item
|
||||
if any(target not in rows for target in DESKTOP_TARGETS):
|
||||
raise ValueError("Candidate manifest must cover Windows and macOS on both architectures")
|
||||
@@ -119,7 +136,7 @@ def windows_version(value: str) -> tuple[int, ...]:
|
||||
|
||||
|
||||
def plan_transitions(previous: dict, candidate: dict, public_base: str) -> list[dict]:
|
||||
old = validate_candidates(previous, previous.get("tag"), previous.get("commit"), public_base, allow_legacy=True)
|
||||
old = validate_candidates(previous, previous.get("tag"), previous.get("commit"), public_base)
|
||||
new = validate_candidates(candidate, candidate.get("tag"), candidate.get("commit"), public_base)
|
||||
result = []
|
||||
for target in DESKTOP_TARGETS:
|
||||
@@ -177,11 +194,27 @@ def _claim_metadata(raw: str, *, version: str, commit: str) -> dict:
|
||||
if (not isinstance(metadata, dict)
|
||||
or any(metadata.get(key) != value for key, value in expected.items())
|
||||
or not isinstance(metadata.get("autopublish"), bool)
|
||||
or set(metadata) != {*expected, "autopublish"}):
|
||||
or not isinstance(metadata.get("claimEpoch"), int)
|
||||
or metadata["claimEpoch"] <= 0
|
||||
or set(metadata) != {*expected, "autopublish", "claimEpoch"}):
|
||||
raise ValueError("Stable claim metadata is invalid")
|
||||
return metadata
|
||||
|
||||
|
||||
def tagger_epoch(tag_object: str, run=output) -> int:
|
||||
body = run(["git", "cat-file", "-p", tag_object])
|
||||
tagger = next((line for line in body.splitlines() if line.startswith("tagger ")), None)
|
||||
if tagger is None:
|
||||
raise ValueError("Stable claim has no tagger timestamp")
|
||||
try:
|
||||
epoch = int(tagger.rsplit(" ", 2)[1])
|
||||
except (IndexError, ValueError) as error:
|
||||
raise ValueError("Stable claim tagger timestamp is invalid") from error
|
||||
if epoch <= 0:
|
||||
raise ValueError("Stable claim tagger timestamp is invalid")
|
||||
return epoch
|
||||
|
||||
|
||||
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")
|
||||
@@ -216,8 +249,11 @@ def check_claim(env: dict, run=output) -> dict:
|
||||
admitted = admit_claim(claim_tag, commit, on_main=on_main)
|
||||
raw_metadata = run(["git", "tag", "-l", claim_tag, "--format=%(contents)"])
|
||||
metadata = _claim_metadata(raw_metadata, version=admitted["version"], commit=commit)
|
||||
claim_epoch = tagger_epoch(local_object, run)
|
||||
if metadata["claimEpoch"] != claim_epoch:
|
||||
raise ValueError("Stable claim epoch differs from its annotated tagger timestamp")
|
||||
return {**admitted, "claim_object": local_object,
|
||||
"autopublish": metadata["autopublish"]}
|
||||
"autopublish": metadata["autopublish"], "claim_epoch": claim_epoch}
|
||||
|
||||
|
||||
def stable_context(env: dict, run=output) -> tuple[str, str, dict]:
|
||||
@@ -270,21 +306,27 @@ def final_context(env: dict, run=output) -> tuple[str, str, dict]:
|
||||
"schema": 1, "version": admitted["version"], "commit": commit,
|
||||
"claimTag": claim_tag, "claimTagObject": claim_object,
|
||||
"autopublish": claim["autopublish"],
|
||||
"claimEpoch": claim["claimEpoch"],
|
||||
"releaseId": final.get("releaseId"),
|
||||
"candidateManifestSha256": final.get("candidateManifestSha256"),
|
||||
"dockerManifestDigest": final.get("dockerManifestDigest"),
|
||||
}
|
||||
if (final != expected
|
||||
or not isinstance(final["releaseId"], int) or final["releaseId"] <= 0
|
||||
or not DIGEST.fullmatch(final["candidateManifestSha256"] or "")
|
||||
or not re.fullmatch(r"sha256:[a-f0-9]{64}", final["dockerManifestDigest"] or "")):
|
||||
raise ValueError("Final tag metadata differs from its claim")
|
||||
release = json.loads(run([
|
||||
"gh", "api", f"repos/{repository}/releases/tags/{tag}",
|
||||
]))
|
||||
if (release.get("tag_name") != tag or release.get("draft") is not False
|
||||
if (release.get("id") != final["releaseId"] or release.get("tag_name") != tag
|
||||
or release.get("draft") is not False
|
||||
or release.get("prerelease") is not False or not release.get("published_at")):
|
||||
raise ValueError("Stable channel requires the published final release")
|
||||
return tag, commit, {**admitted, "claim_object": claim_object,
|
||||
"autopublish": claim["autopublish"],
|
||||
"claim_epoch": claim["claimEpoch"],
|
||||
"release_id": final["releaseId"],
|
||||
"candidate_manifest_sha256": final["candidateManifestSha256"],
|
||||
"docker_manifest_digest": final["dockerManifestDigest"]}
|
||||
|
||||
@@ -332,7 +374,7 @@ def admit(env: dict) -> None:
|
||||
emit({
|
||||
"claim-tag": admitted["claim_tag"], "claim-object": admitted["claim_object"],
|
||||
"tag": admitted["tag"], "commit": admitted["commit"], "version": admitted["version"],
|
||||
"release-id": release["databaseId"],
|
||||
"release-id": release["databaseId"], "release-epoch": admitted["claim_epoch"],
|
||||
}, env)
|
||||
summary(
|
||||
f"## Stable candidate {admitted['claim_tag']}\nCommit: {admitted['commit']}\n"
|
||||
@@ -343,17 +385,18 @@ def admit(env: dict) -> None:
|
||||
|
||||
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)
|
||||
tag, commit, claim = stable_context(env)
|
||||
emit({"tag": tag, "sha": commit, "channel": "stable", "payload-version": tag[1:],
|
||||
"release-epoch": claim["claim_epoch"]}, env)
|
||||
|
||||
|
||||
def transitions(env: dict) -> None:
|
||||
from scripts.releases.r2 import put
|
||||
|
||||
tag, commit, _claim = stable_context(env)
|
||||
tag, commit, claim = stable_context(env)
|
||||
base = env["CLOUDFLARE_R2_PUBLIC_URL"].rstrip("/")
|
||||
candidate = read_candidate(env)
|
||||
validate_candidates(candidate, tag, commit, base)
|
||||
validate_candidates(candidate, tag, commit, base, claim["claim_epoch"])
|
||||
try:
|
||||
previous = read_manifest(env.get("BASELINE_MANIFEST_URL") or f"{base}/releases/stable/release-candidates.json",
|
||||
expected_origin=base)
|
||||
@@ -379,33 +422,38 @@ def transitions(env: dict) -> None:
|
||||
|
||||
|
||||
def _final_metadata(tag: str, commit: str, claim: dict, candidate_manifest_sha256: str,
|
||||
docker_manifest_digest: str) -> dict:
|
||||
docker_manifest_digest: str, release_id: int) -> dict:
|
||||
if not DIGEST.fullmatch(candidate_manifest_sha256):
|
||||
raise ValueError("Final tag candidate manifest digest is invalid")
|
||||
if not re.fullmatch(r"sha256:[a-f0-9]{64}", docker_manifest_digest):
|
||||
raise ValueError("Final tag Docker manifest digest is invalid")
|
||||
if not isinstance(release_id, int) or release_id <= 0:
|
||||
raise ValueError("Final tag release database ID is invalid")
|
||||
return {
|
||||
"schema": 1, "version": tag[1:], "commit": commit,
|
||||
"claimTag": claim["claim_tag"], "claimTagObject": claim["claim_object"],
|
||||
"autopublish": claim["autopublish"],
|
||||
"claimEpoch": claim["claim_epoch"],
|
||||
"releaseId": release_id,
|
||||
"candidateManifestSha256": candidate_manifest_sha256,
|
||||
"dockerManifestDigest": docker_manifest_digest,
|
||||
}
|
||||
|
||||
|
||||
def ensure_final_tag(tag: str, commit: str, claim: dict, *, candidate_manifest_sha256: str,
|
||||
docker_manifest_digest: str, run=output) -> str:
|
||||
docker_manifest_digest: str, release_id: int, run=output) -> str:
|
||||
"""Create or verify the immutable annotated final tag."""
|
||||
require_stable_identity(tag, commit)
|
||||
expected = _final_metadata(
|
||||
tag, commit, claim, candidate_manifest_sha256, docker_manifest_digest, release_id,
|
||||
)
|
||||
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(_final_metadata(
|
||||
tag, commit, claim, candidate_manifest_sha256, docker_manifest_digest,
|
||||
), sort_keys=True, separators=(",", ":"))
|
||||
message = json.dumps(expected, sort_keys=True, separators=(",", ":"))
|
||||
run([
|
||||
"git", "-c", "user.name=Hermes Release Automation",
|
||||
"-c", "user.email=release-bot@users.noreply.github.com",
|
||||
@@ -413,7 +461,8 @@ def ensure_final_tag(tag: str, commit: str, claim: dict, *, candidate_manifest_s
|
||||
])
|
||||
else:
|
||||
if (run(["git", "cat-file", "-t", local_object]) != "tag"
|
||||
or run(["git", "rev-parse", f"{ref}^{{commit}}"]) != commit):
|
||||
or run(["git", "rev-parse", f"{ref}^{{commit}}"]) != commit
|
||||
or json.loads(run(["git", "tag", "-l", tag, "--format=%(contents)"])) != expected):
|
||||
raise ValueError("Local final tag collision")
|
||||
run(["git", "push", "origin", ref])
|
||||
remote_raw = run(["git", "ls-remote", "origin", ref, f"{ref}^{{}}"])
|
||||
@@ -429,18 +478,11 @@ def ensure_final_tag(tag: str, commit: str, claim: dict, *, candidate_manifest_s
|
||||
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")
|
||||
metadata = json.loads(run(["git", "tag", "-l", tag, "--format=%(contents)"]))
|
||||
if metadata != _final_metadata(
|
||||
tag, commit, claim, candidate_manifest_sha256, docker_manifest_digest):
|
||||
if metadata != expected:
|
||||
raise ValueError("Final stable tag metadata differs from the accepted artifacts")
|
||||
return tag_object
|
||||
|
||||
|
||||
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}"
|
||||
@@ -464,17 +506,16 @@ 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)
|
||||
validate_candidates(candidate, tag, commit, base, claim["claim_epoch"])
|
||||
release_id = env.get("RELEASE_ID", "")
|
||||
if not str(release_id).isdigit():
|
||||
raise ValueError("Stable release database ID is required")
|
||||
ensure_final_tag(
|
||||
tag, commit, claim,
|
||||
candidate_manifest_sha256=env["CANDIDATE_MANIFEST_SHA256"],
|
||||
docker_manifest_digest=env.get("DOCKER_MANIFEST_DIGEST", ""),
|
||||
release_id=int(release_id),
|
||||
)
|
||||
release_id = env.get("RELEASE_ID", "")
|
||||
if not str(release_id).isdigit():
|
||||
raise ValueError("Stable release database ID is required")
|
||||
if _autopublish(env.get("AUTOPUBLISH")) is not claim["autopublish"]:
|
||||
raise ValueError("Workflow autopublish input differs from the immutable claim")
|
||||
retarget_release(env["GITHUB_REPOSITORY"], int(release_id), tag, commit, publish=False)
|
||||
|
||||
|
||||
|
||||
@@ -9,7 +9,9 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import json
|
||||
import re
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@@ -46,6 +48,11 @@ def stamp(tree: Path, version: str, release_date: str) -> list[Path]:
|
||||
_rewrite(pyproject, r'^version\s*=\s*"[^"]+"', f'version = "{version}"', count=1, flags=re.MULTILINE)
|
||||
touch(pyproject)
|
||||
|
||||
nix_package = tree / "nix" / "hermes-agent.nix"
|
||||
_rewrite(nix_package, r'^ version \? "[^"]+",', f' version ? "{version}",',
|
||||
count=1, flags=re.MULTILINE)
|
||||
touch(nix_package)
|
||||
|
||||
desktop = tree / "apps" / "desktop" / "package.json"
|
||||
_rewrite(desktop, r'("version"\s*:\s*)"[^"]+"', rf'\g<1>"{version}"', count=1)
|
||||
touch(desktop)
|
||||
@@ -55,6 +62,8 @@ def stamp(tree: Path, version: str, release_date: str) -> list[Path]:
|
||||
lock = tree / "package-lock.json"
|
||||
_rewrite(lock, r'("apps/desktop"\s*:\s*\{\s*"name"\s*:\s*"[^"]+"\s*,\s*"version"\s*:\s*)"[^"]+"',
|
||||
rf'\g<1>"{version}"', count=1)
|
||||
_rewrite(lock, r'("apps/bootstrap-installer"\s*:\s*\{\s*"name"\s*:\s*"[^"]+"\s*,\s*"version"\s*:\s*)"[^"]+"',
|
||||
rf'\g<1>"{version}"', count=1)
|
||||
touch(lock)
|
||||
|
||||
uv_lock = tree / "uv.lock"
|
||||
@@ -74,17 +83,46 @@ def stamp(tree: Path, version: str, release_date: str) -> list[Path]:
|
||||
_rewrite(path, pattern, replacement, count=1, flags=flags)
|
||||
touch(path)
|
||||
|
||||
validate_bootstrap_version(tree, version)
|
||||
return written
|
||||
|
||||
|
||||
def validate_bootstrap_version(tree: Path, version: str) -> None:
|
||||
installer = tree / "apps" / "bootstrap-installer"
|
||||
package = json.loads((installer / "package.json").read_text(encoding="utf-8-sig"))
|
||||
tauri = json.loads((installer / "src-tauri" / "tauri.conf.json").read_text(encoding="utf-8-sig"))
|
||||
cargo = tomllib.loads((installer / "src-tauri" / "Cargo.toml").read_text(encoding="utf-8-sig"))
|
||||
root_lock = json.loads((tree / "package-lock.json").read_text(encoding="utf-8-sig"))
|
||||
values = {
|
||||
"workspace package": package.get("version"),
|
||||
"Tauri config": tauri.get("version"),
|
||||
"Cargo package": cargo.get("package", {}).get("version"),
|
||||
"workspace lock": root_lock.get("packages", {}).get("apps/bootstrap-installer", {}).get("version"),
|
||||
}
|
||||
cargo_lock = installer / "src-tauri" / "Cargo.lock"
|
||||
if cargo_lock.exists():
|
||||
lock = tomllib.loads(cargo_lock.read_text(encoding="utf-8-sig"))
|
||||
values["Cargo lock"] = next(
|
||||
(item.get("version") for item in lock.get("package", [])
|
||||
if item.get("name") in {"bootstrap-installer", "hermes-bootstrap"}), None)
|
||||
mismatches = {name: value for name, value in values.items() if value != version}
|
||||
if mismatches:
|
||||
raise ValueError(f"Bootstrap installer version differs from {version}: {mismatches}")
|
||||
|
||||
|
||||
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}")
|
||||
parser.add_argument("--release-date")
|
||||
parser.add_argument("--release-epoch", type=int)
|
||||
args = parser.parse_args(argv)
|
||||
stamp(args.tree.resolve(), args.version, args.release_date)
|
||||
if args.release_date and args.release_epoch is not None:
|
||||
parser.error("--release-date and --release-epoch are mutually exclusive")
|
||||
instant = (dt.datetime.fromtimestamp(args.release_epoch, tz=dt.UTC)
|
||||
if args.release_epoch is not None else dt.datetime.now(dt.UTC))
|
||||
release_date = args.release_date or f"{instant.year}.{instant.month}.{instant.day}"
|
||||
stamp(args.tree.resolve(), args.version, release_date)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -6,12 +6,56 @@ CalVer tag is a valid three-component version and would win every ``max()``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from hermes_cli.update_channel import STABLE_TAG_RE
|
||||
|
||||
SEED = "0.21.4"
|
||||
BUMPS = ("major", "minor", "patch")
|
||||
|
||||
|
||||
def published_channel_identity(repository: str, channel: str, *, base_url: str | None = None,
|
||||
reader_type=None) -> tuple[str, str] | None:
|
||||
"""Resolve one validated protected channel's payload version and commit."""
|
||||
from hermes_cli.release_channels import ChannelNotFound, ChannelReader
|
||||
from scripts.releases.r2 import public_base_url
|
||||
|
||||
if channel not in {"stable", "canary"}:
|
||||
raise ValueError("Expected a protected release channel")
|
||||
reader_type = reader_type or ChannelReader
|
||||
try:
|
||||
resolved = reader_type(base_url or public_base_url(), repository=repository).resolve(channel)
|
||||
except ChannelNotFound:
|
||||
return None
|
||||
if resolved.manifest is None:
|
||||
return None
|
||||
if resolved.terminal.get("policy") != f"{channel}-release":
|
||||
raise ValueError(f"{channel.title()} channel has the wrong publication policy")
|
||||
request = resolved.manifest["request"]
|
||||
version, commit = request.get("version"), request.get("commit")
|
||||
if not isinstance(version, str) or not isinstance(commit, str) or not re.fullmatch(r"[a-f0-9]{40}", commit):
|
||||
raise ValueError(f"{channel.title()} channel has an invalid published identity")
|
||||
return version, commit
|
||||
|
||||
|
||||
def published_stable_identity(repository: str, *, base_url: str | None = None,
|
||||
reader_type=None) -> tuple[str, str | None]:
|
||||
"""Resolve the protected stable identity, falling back only before it exists."""
|
||||
found = published_channel_identity(
|
||||
repository, "stable", base_url=base_url, reader_type=reader_type,
|
||||
)
|
||||
if found is None:
|
||||
return SEED, None
|
||||
version, commit = found
|
||||
if version_from_tag("v" + version) is None:
|
||||
raise ValueError("Stable channel has an invalid source version")
|
||||
return version, commit
|
||||
|
||||
|
||||
def published_stable_version(repository: str, *, base_url: str | None = None, reader_type=None) -> str:
|
||||
return published_stable_identity(repository, base_url=base_url, reader_type=reader_type)[0]
|
||||
|
||||
|
||||
def version_from_tag(ref: str) -> str | None:
|
||||
"""The version a final release tag names, or None for anything else.
|
||||
|
||||
|
||||
@@ -216,7 +216,9 @@ 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}"
|
||||
STAMP_ARGS=(--tree "$WORK/tree" --version "${TAG#v}")
|
||||
[ -z "${HERMES_RELEASE_EPOCH:-}" ] || STAMP_ARGS+=(--release-epoch "$HERMES_RELEASE_EPOCH")
|
||||
[ -z "$TAG" ] || python3 "$REPO_ABS/scripts/releases/stamping.py" "${STAMP_ARGS[@]}"
|
||||
[ -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())')"
|
||||
|
||||
@@ -72,13 +72,17 @@ def _run_git(*args: str, cwd: str | Path = _REPO_ROOT) -> str | None:
|
||||
|
||||
|
||||
def _parse_release_metadata() -> tuple[str | None, str | None]:
|
||||
"""Read __version__ and __release_date__ from hermes_cli/__init__.py."""
|
||||
"""Read generated release identity and the source release date."""
|
||||
try:
|
||||
text = (_REPO_ROOT / "hermes_cli" / "__init__.py").read_text(encoding="utf-8-sig")
|
||||
init = (_REPO_ROOT / "hermes_cli" / "__init__.py").read_text(encoding="utf-8-sig")
|
||||
except OSError:
|
||||
return None, None
|
||||
version = re.search(r'__version__\s*=\s*["\']([^"\']+)["\']', text)
|
||||
date = re.search(r'__release_date__\s*=\s*["\']([^"\']+)["\']', text)
|
||||
try:
|
||||
generated = (_REPO_ROOT / "hermes_cli" / "_version.py").read_text(encoding="utf-8-sig")
|
||||
except OSError:
|
||||
generated = init
|
||||
version = re.search(r'__version__\s*=\s*["\']([^"\']+)["\']', generated)
|
||||
date = re.search(r'__release_date__\s*=\s*["\']([^"\']+)["\']', init)
|
||||
return (version.group(1) if version else None, date.group(1) if date else None)
|
||||
|
||||
|
||||
@@ -169,6 +173,7 @@ def build_stamp(
|
||||
f"write_install_stamp: invalid --update-mechanism {update_mechanism!r} "
|
||||
f"(expected one of {', '.join(UPDATE_MECHANISMS)})"
|
||||
)
|
||||
base_version_was_explicit = base_version is not None
|
||||
_base_version, _release_date = _parse_release_metadata()
|
||||
if base_version is None:
|
||||
base_version = _base_version
|
||||
@@ -221,7 +226,20 @@ def build_stamp(
|
||||
if dirty is None:
|
||||
dirty = _resolve_dirty_from_git()
|
||||
|
||||
# Distance: explicit > computed from git
|
||||
dev_display_version = None
|
||||
if (distance is None and not base_version_was_explicit
|
||||
and base_version in {None, "0.0.0"} and commit != FALLBACK_COMMIT):
|
||||
from scripts.releases.distance import dev_version
|
||||
|
||||
try:
|
||||
dev_display_version = dev_version(_REPO_ROOT)
|
||||
except subprocess.CalledProcessError:
|
||||
base_version, distance = "0.0.0", 0
|
||||
else:
|
||||
base_version, separator, development = dev_display_version.partition("+")
|
||||
distance = int(development.split(".", 1)[0]) if separator else 0
|
||||
|
||||
# Distance: explicit > reachable final release > release metadata fallback.
|
||||
if distance is None:
|
||||
distance = _compute_distance(base_version, _release_date)
|
||||
|
||||
@@ -230,10 +248,10 @@ def build_stamp(
|
||||
commit_date = _resolve_commit_date_from_git()
|
||||
|
||||
# Display version
|
||||
display_version = base_version or ""
|
||||
if distance is not None and distance > 0:
|
||||
display_version = dev_display_version or base_version or ""
|
||||
if dev_display_version is None and distance is not None and distance > 0:
|
||||
display_version = f"{display_version}+{distance}"
|
||||
elif dirty and distance is None:
|
||||
elif dev_display_version is None and dirty and distance is None:
|
||||
display_version = f"{display_version}+?"
|
||||
|
||||
# The desktop artifact kind, from the one build-time selector
|
||||
|
||||
@@ -36,7 +36,7 @@ def staged_channel(tmp_path, r2_server, request, monkeypatch):
|
||||
from scripts.releases.r2_scope import R2Scope
|
||||
base = f"http://127.0.0.1:{r2_server.server_port}/hermes-releases"
|
||||
if getattr(request, "param", ""):
|
||||
monkeypatch.setenv("R2_DISPOSABLE_RUN", "98765-1" if request.param == "receiver" else request.param)
|
||||
monkeypatch.setenv("R2_DISPOSABLE_RUN", "98765" if request.param == "receiver" else request.param)
|
||||
monkeypatch.setenv("GITHUB_REPOSITORY_ID", "12345")
|
||||
monkeypatch.setenv("CLOUDFLARE_R2_PUBLIC_URL", base)
|
||||
scope = R2Scope.configured()
|
||||
@@ -255,7 +255,7 @@ def test_workflow_promotion_missing_native_gate_does_not_write(tmp_path, r2_serv
|
||||
|
||||
|
||||
@pytest.mark.platforms("posix")
|
||||
@pytest.mark.parametrize("staged_channel", ["", "98765-1"], indirect=True)
|
||||
@pytest.mark.parametrize("staged_channel", ["", "98765"], indirect=True)
|
||||
def test_real_publication_cas_and_manifest_summary(tmp_path, r2_server, staged_channel):
|
||||
request, _ = staged_channel
|
||||
from scripts.releases.r2_scope import R2Scope
|
||||
@@ -453,7 +453,7 @@ def test_receiver_allocation_uses_official_identity_only_inside_scope(tmp_path):
|
||||
pub = publisher(url)
|
||||
with pytest.raises(ValueError, match="disposable"):
|
||||
allocate_receivers(pub, "a" * 40, "1.2.3", "a" * 40)
|
||||
scope = R2Scope("ci-disposable/12345/17-1/")
|
||||
scope = R2Scope("ci-disposable/12345/17/")
|
||||
pub.store.scope = scope
|
||||
pub.public_base += "/" + scope.prefix.rstrip("/")
|
||||
from hermes_cli.release_channels import ChannelReader
|
||||
|
||||
@@ -285,7 +285,6 @@ def test_stable_phase_and_canary_gates_require_smoke_but_preserve_other_phases(t
|
||||
'candidate': ['validate', 'build-win32', 'build-darwin', 'assemble-win32-bundle',
|
||||
'smoke-darwin', 'smoke-win32', 'smoke-win32-universal', 'termux-deb', 'candidate-manifest'],
|
||||
'publish': ['validate', 'stable-publish', 'stable-store'],
|
||||
'promote': ['validate', 'stable-promote'],
|
||||
}
|
||||
for phase, selected in required.items():
|
||||
needs = {name: {'result': 'success' if name in selected else 'skipped'}
|
||||
|
||||
@@ -188,11 +188,14 @@ def _claim_message(clone: Path) -> str:
|
||||
"version": "0.1.2",
|
||||
"commit": _git("rev-parse", "HEAD", cwd=clone),
|
||||
"autopublish": False,
|
||||
"claimEpoch": 1_790_000_000,
|
||||
}, sort_keys=True, separators=(",", ":"))
|
||||
|
||||
|
||||
def test_claim_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, monkeypatch: pytest.MonkeyPatch):
|
||||
_origin, clone = _seed_repo(tmp_path)
|
||||
monkeypatch.setenv("GIT_COMMITTER_DATE", "@1790000000 +0000")
|
||||
_git("tag", "-a", "v0.1.2-rc", "-m", _claim_message(clone), cwd=clone)
|
||||
_git("push", "origin", "refs/tags/v0.1.2-rc", cwd=clone)
|
||||
|
||||
|
||||
@@ -40,7 +40,8 @@ def canary(tmp_path, r2_server, monkeypatch):
|
||||
_, clone = _seed_repo(tmp_path)
|
||||
commit = _git("rev-parse", "HEAD", cwd=clone)
|
||||
tag = "v0.1.2+canary.20260913T001000Z"
|
||||
_git("tag", tag, cwd=clone)
|
||||
_git("tag", "-a", tag, "-m", '{"schema":1}', cwd=clone)
|
||||
tag_object = _git("rev-parse", f"{tag}^{{tag}}", cwd=clone)
|
||||
_git("push", "origin", tag, cwd=clone)
|
||||
desktop = clone / "apps/desktop"
|
||||
desktop.mkdir(parents=True)
|
||||
@@ -90,6 +91,7 @@ def canary(tmp_path, r2_server, monkeypatch):
|
||||
"GITHUB_EVENT_NAME": "workflow_dispatch", "GITHUB_REPOSITORY": "NousResearch/hermes-agent",
|
||||
"GITHUB_WORKFLOW_REF": "NousResearch/hermes-agent/.github/workflows/desktop-bundled-release.yml@refs/heads/main",
|
||||
"RELEASE_TAG": tag, "TAG": tag, "HERMES_PAYLOAD_TAG": tag,
|
||||
"RELEASE_TAG_OBJECT": tag_object,
|
||||
"RELEASE_COMMIT": commit, "RELEASE_PHASE": "", "HERMES_DESKTOP_VARIANT": "bundled",
|
||||
"HERMES_BUILD_COMMIT": "", "CHANNEL_BUILD": "", "R2_DISPOSABLE_RUN": "",
|
||||
"CLOUDFLARE_R2_PUBLIC_URL": base,
|
||||
@@ -105,9 +107,15 @@ def canary(tmp_path, r2_server, monkeypatch):
|
||||
def native_leg(root, identity, env, platform, arch):
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
version = env["RELEASE_TAG"][1:]
|
||||
stamp = {"tag": env["RELEASE_TAG"], "commit": env["RELEASE_COMMIT"]}
|
||||
stamp = {
|
||||
"tag": env["RELEASE_TAG"], "commit": env["RELEASE_COMMIT"],
|
||||
"baseVersion": env["RELEASE_TAG"][1:],
|
||||
}
|
||||
artifact = identity["artifactNamePascal"]
|
||||
if platform == "win32":
|
||||
(root / f"version-info-{arch}.json").write_text(json.dumps({
|
||||
"productVersion": env["FIXTURE_WINDOWS_VERSION"],
|
||||
}))
|
||||
with zipfile.ZipFile(root / f"{artifact}-{version}-win-{arch}.msix", "w") as package:
|
||||
package.writestr("AppxManifest.xml", f'<Package><Identity Name="{identity["msixAppIdWithOrg"]}" Publisher="CN=Fixture" Version="{env["FIXTURE_WINDOWS_VERSION"]}" ProcessorArchitecture="{arch}"/><Applications><Application Id="{identity["appNamePascal"]}"/><Application Id="CLI"><VisualElements AppListEntry="none"/></Application></Applications></Package>')
|
||||
package.writestr("app/resources/install-stamp.json", json.dumps(stamp))
|
||||
@@ -179,7 +187,7 @@ def stage_canary(clone, identity, env, run):
|
||||
def test_published_canary_workflow_advances_only_after_every_gate(canary, r2_server, monkeypatch, tmp_path):
|
||||
clone, identity, env, run = canary
|
||||
bundle = stage_canary(clone, identity, env, run)
|
||||
script = step_script("publish-canary", "Advance protected canary head from the published release")
|
||||
script = step_script("publish-canary", "Publish the admitted canary and advance its protected head")
|
||||
before = dict(r2_server.store)
|
||||
for job in workflow_job("publish-canary")["needs"]:
|
||||
for outcome in ("failure", "skipped", "cancelled", None):
|
||||
@@ -193,11 +201,16 @@ def test_published_canary_workflow_advances_only_after_every_gate(canary, r2_ser
|
||||
assert r2_server.store == before
|
||||
release = Path(env["FIXTURE_RELEASE"])
|
||||
published = json.loads(release.read_text())
|
||||
for bad in ({**published, "isDraft": True}, {**published, "isPrerelease": False}):
|
||||
for bad in ({**published, "isPrerelease": False},):
|
||||
release.write_text(json.dumps(bad))
|
||||
result = run(script)
|
||||
assert result.returncode != 0 and "published" in result.stderr
|
||||
assert result.returncode != 0 and "prerelease" in result.stderr
|
||||
assert r2_server.store == before
|
||||
release.write_text(json.dumps({**published, "isDraft": True}))
|
||||
result = run(script, RELEASE_TAG_OBJECT="b" * 40)
|
||||
assert result.returncode != 0 and "moved" in result.stderr
|
||||
assert json.loads(release.read_text())["isDraft"] is True
|
||||
assert r2_server.store == before
|
||||
release.write_text(json.dumps(published))
|
||||
prefix = f"releases/tag/{env['RELEASE_TAG']}/"
|
||||
for key in (prefix + "metadata-windows-arm64.json", prefix + bundle.name):
|
||||
@@ -213,8 +226,7 @@ def test_published_canary_workflow_advances_only_after_every_gate(canary, r2_ser
|
||||
assert r2_server.store == corrupted
|
||||
r2_server.store[key] = saved
|
||||
assert r2_server.store == before
|
||||
# Replay all final publication shell in its declared order, including edit
|
||||
# followed by the controller's independent published-release read-back.
|
||||
# The controller owns the draft flip and its independent custody read-back.
|
||||
release.write_text(json.dumps({**published, "isDraft": True}))
|
||||
for step in workflow_job("publish-canary")["steps"]:
|
||||
if "run" in step:
|
||||
@@ -261,9 +273,11 @@ def test_published_canary_workflow_advances_only_after_every_gate(canary, r2_ser
|
||||
# A later canary really advances the existing head and native quad, rather
|
||||
# than merely succeeding at the empty-channel bootstrap case.
|
||||
newer_tag = "v0.1.2+canary.20260913T001100Z"
|
||||
_git("tag", newer_tag, cwd=clone)
|
||||
_git("tag", "-a", newer_tag, "-m", '{"schema":1}', cwd=clone)
|
||||
newer_object = _git("rev-parse", f"{newer_tag}^{{tag}}", cwd=clone)
|
||||
_git("push", "origin", newer_tag, cwd=clone)
|
||||
newer = {**env, "RELEASE_TAG": newer_tag, "HERMES_PAYLOAD_TAG": newer_tag,
|
||||
"RELEASE_TAG_OBJECT": newer_object,
|
||||
"FIXTURE_WINDOWS_VERSION": "26.913.0.1100"}
|
||||
stage_canary(clone, identity, newer, run)
|
||||
release.write_text(json.dumps({**published, "tagName": newer_tag}))
|
||||
@@ -280,7 +294,7 @@ def test_published_canary_workflow_advances_only_after_every_gate(canary, r2_ser
|
||||
assert r2_server.store == after
|
||||
# A moved published tag must not retarget the accepted release, even on retry.
|
||||
_git("commit", "--allow-empty", "-m", "new source", cwd=clone)
|
||||
_git("tag", "-f", env["RELEASE_TAG"], cwd=clone)
|
||||
_git("tag", "-f", "-a", env["RELEASE_TAG"], "-m", '{"schema":1}', cwd=clone)
|
||||
_git("push", "--force", "origin", env["RELEASE_TAG"], cwd=clone)
|
||||
result = run(script)
|
||||
assert result.returncode != 0 and "moved" in result.stderr
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
"""The release workflow's dependency graph enforces publication ordering."""
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from ruamel.yaml import YAML
|
||||
@@ -30,7 +32,7 @@ def test_release_reuses_whole_ci_and_docker_before_publication():
|
||||
assert jobs["docker"]["uses"] == jobs["publish-docker"]["uses"]
|
||||
assert jobs["docker"]["with"]["release-phase"] == "test"
|
||||
assert "ci" in ancestors(jobs, "docker")
|
||||
required = {"ci", "docker", "nix", "pm-bundle", "install-e2e", "windows-packaged", "macos-packaged", "termux-checks", "windows-live", "candidates"}
|
||||
required = {"ci", "docker", "nix", "pm-bundle", "install-e2e", "windows-packaged", "macos-packaged", "termux-checks", "windows-live", "candidates", "bootstrap-version"}
|
||||
assert required <= ancestors(jobs, "acceptance")
|
||||
for name in ("publish-docker", "publish-bundles"):
|
||||
assert required <= ancestors(jobs, name)
|
||||
@@ -51,16 +53,24 @@ def test_all_applicable_ci_jobs_are_aggregated_and_desktop_e2e_stays_deferred():
|
||||
def test_claim_custody_and_final_payload_identity_reach_every_privileged_phase():
|
||||
release = workflow("stable-release.yml")
|
||||
jobs = release["jobs"]
|
||||
assert release["on"]["workflow_dispatch"]["inputs"]["autopublish"]["required"] == "true"
|
||||
assert {"claim-tag", "claim-object", "tag", "commit", "version", "release-id"} <= \
|
||||
assert "autopublish" not in release["on"]["workflow_dispatch"]["inputs"]
|
||||
assert {"claim-tag", "claim-object", "tag", "commit", "version", "release-id", "release-epoch"} <= \
|
||||
set(jobs["admit"]["outputs"])
|
||||
for name in ("candidates", "publish-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 }}"
|
||||
desktop = workflow("desktop-bundled-release.yml")
|
||||
assert "release-epoch" in desktop["jobs"]["validate"]["outputs"]
|
||||
assert desktop["jobs"]["termux-deb"]["env"]["HERMES_RELEASE_EPOCH"] == \
|
||||
"${{ needs.validate.outputs.release-epoch }}"
|
||||
for name in ("docker", "nix", "pm-bundle"):
|
||||
assert jobs[name]["with"]["version"] == "${{ needs.admit.outputs.version }}"
|
||||
for name in ("docker", "nix", "pm-bundle"):
|
||||
assert jobs[name]["with"]["release-epoch"] == "${{ needs.admit.outputs.release-epoch }}"
|
||||
assert jobs["publish-docker"]["with"]["release-epoch"] == \
|
||||
"${{ needs.admit.outputs.release-epoch }}"
|
||||
complete = jobs["complete"]["steps"]
|
||||
final = next(i for i, step in enumerate(complete) if step.get("name", "").startswith("Create the final tag"))
|
||||
assert complete[final]["env"]["DOCKER_MANIFEST_DIGEST"] == \
|
||||
@@ -70,6 +80,31 @@ def test_claim_custody_and_final_payload_identity_reach_every_privileged_phase()
|
||||
assert final < render < reconcile
|
||||
|
||||
|
||||
def test_release_gates_extract_consumer_facing_versions():
|
||||
release_jobs = workflow("stable-release.yml")["jobs"]
|
||||
bootstrap = next(
|
||||
step["run"] for step in release_jobs["bootstrap-version"]["steps"]
|
||||
if step.get("name") == "Stamp and verify the Cargo and Tauri release identity"
|
||||
)
|
||||
assert "uv build --wheel --sdist" in bootstrap
|
||||
assert "wheel_version != expected or sdist_version != expected" in bootstrap
|
||||
|
||||
docker = workflow("docker.yml")["jobs"]
|
||||
image_check = next(
|
||||
step["run"] for step in docker["build"]["steps"]
|
||||
if step.get("name") == "Verify release image identity"
|
||||
)
|
||||
assert '["baseVersion"]' in image_check and "$RELEASE_VERSION" in image_check
|
||||
|
||||
nix = workflow("nix.yml")["jobs"]
|
||||
nix_check = next(
|
||||
step["run"] for step in nix["flake-check"]["steps"]
|
||||
if step.get("name") == "Verify release package runtime identity"
|
||||
)
|
||||
assert '"$package/bin/hermes" --version' in nix_check
|
||||
assert "actual != expected" in nix_check
|
||||
|
||||
|
||||
def test_publication_reconciler_has_every_recovery_trigger_and_shared_lock():
|
||||
stable = workflow("stable-release.yml")
|
||||
publication = workflow("stable-release-publication.yml")
|
||||
@@ -88,5 +123,40 @@ def test_publication_reconciler_has_every_recovery_trigger_and_shared_lock():
|
||||
checkout = reconcile["steps"][0]
|
||||
assert checkout["with"]["ref"] == "${{ github.event.repository.default_branch }}"
|
||||
assert checkout["with"]["persist-credentials"] == "false"
|
||||
wait = next(step for step in reconcile["steps"] if step.get("name", "").startswith("Wait before"))
|
||||
assert wait["run"] == "sleep 900"
|
||||
assert publication["on"]["schedule"] == [{"cron": "*/15 * * * *"}]
|
||||
assert not any(step.get("run", "").startswith("sleep ") for step in reconcile["steps"])
|
||||
|
||||
|
||||
def test_docker_recovery_refuses_to_replace_a_divergent_version_tag(tmp_path):
|
||||
publish = workflow("docker.yml")["jobs"]["release-publish-manifest"]
|
||||
step = next(item for item in publish["steps"] if item.get("name") == "Create versioned manifest list")
|
||||
digest_dir = tmp_path / "digests"
|
||||
digest_dir.mkdir()
|
||||
for arch, digest in (("amd64", "a" * 64), ("arm64", "b" * 64)):
|
||||
(digest_dir / f"{arch}.digest").write_text(f"sha256:{digest}\n", encoding="utf-8")
|
||||
|
||||
marker = tmp_path / "create-called"
|
||||
bindir = tmp_path / "bin"
|
||||
bindir.mkdir()
|
||||
docker = bindir / "docker"
|
||||
docker.write_text(
|
||||
"#!/usr/bin/env python3\n"
|
||||
"import json, os, pathlib, sys\n"
|
||||
"if 'create' in sys.argv:\n"
|
||||
" pathlib.Path(os.environ['CREATE_MARKER']).write_text('called')\n"
|
||||
" raise SystemExit(0)\n"
|
||||
"print(json.dumps({'manifests': [{'digest': 'sha256:' + 'c' * 64}]}))\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
docker.chmod(0o755)
|
||||
result = subprocess.run(
|
||||
["bash", "-e", "-o", "pipefail", "-c",
|
||||
step["run"].replace("/tmp/digests", str(digest_dir))], cwd=tmp_path,
|
||||
env={**os.environ, "PATH": f"{bindir}:{os.environ['PATH']}",
|
||||
"IMAGE_NAME": "owner/repo", "RELEASE_TAG": "0.21.5",
|
||||
"CREATE_MARKER": str(marker)},
|
||||
capture_output=True, text=True, encoding="utf-8",
|
||||
)
|
||||
assert result.returncode != 0
|
||||
assert "versioned Docker manifest differs" in result.stderr
|
||||
assert not marker.exists()
|
||||
@@ -4,6 +4,7 @@ A checkout carries no version of its own: the release stamps ``hermes_cli/_versi
|
||||
into the build tree, and a tree without that file reports the placeholder.
|
||||
"""
|
||||
import subprocess
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@@ -20,8 +21,8 @@ def _version_of(tree: Path) -> str:
|
||||
|
||||
def test_generated_module_wins(tmp_path):
|
||||
cli = tmp_path / "hermes_cli"
|
||||
cli.mkdir()
|
||||
(cli / "__init__.py").write_text((REPO / "hermes_cli" / "__init__.py").read_text())
|
||||
shutil.copytree(REPO / "hermes_cli", cli,
|
||||
ignore=shutil.ignore_patterns("__pycache__", "_version.py"))
|
||||
(cli / "_version.py").write_text('__version__ = "0.21.5"\n')
|
||||
|
||||
assert _version_of(tmp_path) == "0.21.5"
|
||||
@@ -29,7 +30,7 @@ def test_generated_module_wins(tmp_path):
|
||||
|
||||
def test_absent_generated_module_is_the_placeholder(tmp_path):
|
||||
cli = tmp_path / "hermes_cli"
|
||||
cli.mkdir()
|
||||
(cli / "__init__.py").write_text((REPO / "hermes_cli" / "__init__.py").read_text())
|
||||
shutil.copytree(REPO / "hermes_cli", cli,
|
||||
ignore=shutil.ignore_patterns("__pycache__", "_version.py"))
|
||||
|
||||
assert _version_of(tmp_path) == "0.0.0"
|
||||
@@ -29,10 +29,21 @@ 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):
|
||||
def test_stable_build_accepts_the_admitted_commit_before_the_final_tag_exists(tmp_path, monkeypatch):
|
||||
from scripts.bundles.desktop_inputs import identity_environment
|
||||
from scripts.bundles.desktop_prepare import BuildRequest
|
||||
|
||||
source, commit = _project(tmp_path)
|
||||
subprocess.run([
|
||||
"git", "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test",
|
||||
"-c", "tag.gpgSign=false", "tag", "-a", "v1.2.4-rc", "-m", "claim",
|
||||
], cwd=source, check=True, env={**__import__("os").environ,
|
||||
"GIT_COMMITTER_DATE": "2026-08-29T01:02:03Z"})
|
||||
monkeypatch.setenv("RELEASE_CLAIM_TAG", "v1.2.4-rc")
|
||||
claim_object = subprocess.check_output(
|
||||
["git", "rev-parse", "v1.2.4-rc"], cwd=source, text=True,
|
||||
).strip()
|
||||
monkeypatch.setenv("RELEASE_CLAIM_OBJECT", claim_object)
|
||||
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={},
|
||||
@@ -40,6 +51,16 @@ def test_stable_build_accepts_the_admitted_commit_before_the_final_tag_exists(tm
|
||||
|
||||
assert request.commit == commit
|
||||
assert request.version == "1.2.4"
|
||||
assert request.release_epoch == 1787965323
|
||||
env = identity_environment(request, "bundled", {"HERMES_RELEASE_EPOCH": "1"})
|
||||
assert env["HERMES_RELEASE_EPOCH"] == "1787965323"
|
||||
|
||||
monkeypatch.setenv("RELEASE_CLAIM_OBJECT", "f" * 40)
|
||||
with pytest.raises(ValueError, match="exact claim tag object"):
|
||||
BuildRequest.create(
|
||||
source, tag="v1.2.4", commit=None, release_commit=commit, variant="bundled",
|
||||
work=tmp_path / "wrong-work", cache=tmp_path / "wrong-cache", bundle_env={},
|
||||
)
|
||||
|
||||
|
||||
def test_prepared_input_roundtrip_rejects_mutation_and_foreign_source(tmp_path):
|
||||
|
||||
@@ -37,6 +37,17 @@ def test_distance_ignores_the_prerelease_tag_on_the_same_commit(repo):
|
||||
assert dev_version(repo) == "1.4.0+2.g" + git(repo, "rev-parse", "--short=7", "HEAD")
|
||||
|
||||
|
||||
def test_dev_install_stamp_uses_the_reachable_final_release(repo, monkeypatch):
|
||||
from scripts import write_install_stamp
|
||||
|
||||
monkeypatch.setattr(write_install_stamp, "_REPO_ROOT", repo)
|
||||
stamp = write_install_stamp.build_stamp(update_mechanism="external")
|
||||
|
||||
assert stamp["baseVersion"] == "1.4.0"
|
||||
assert stamp["distance"] == 2
|
||||
assert stamp["displayVersion"] == "1.4.0+2.g" + git(repo, "rev-parse", "--short=7", "HEAD")
|
||||
|
||||
|
||||
def test_distance_is_zero_on_the_release_commit(repo):
|
||||
from scripts.releases.distance import dev_version
|
||||
|
||||
|
||||
@@ -23,19 +23,24 @@ from scripts.bundles import release_artifacts as artifacts
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SMOKE_RESULTS = {name: {'result': 'success'} for name in (
|
||||
'smoke-darwin', 'smoke-win32', 'smoke-win32-universal')}
|
||||
RELEASE_EPOCH = 1_787_965_323
|
||||
WINDOWS_VERSION = '2026.5761.123.0'
|
||||
|
||||
|
||||
def test_windows_metadata_is_read_from_package_and_stale_stamp_is_rejected(tmp_path):
|
||||
tag, commit = 'v1.2.3', 'a' * 40
|
||||
root = tmp_path / 'release'
|
||||
root.mkdir()
|
||||
package = root / 'Product-win-x64.msix'
|
||||
package = root / 'Product-1.2.3-win-x64.msix'
|
||||
manifest = '<Package xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"><Identity Name="Product" Publisher="CN=Test" Version="1.2.3.0" ProcessorArchitecture="x64"/><Applications><Application Id="App"/></Applications></Package>'
|
||||
|
||||
def write_package(sha, receiver=None):
|
||||
with zipfile.ZipFile(package, 'w') as archive:
|
||||
archive.writestr('AppxManifest.xml', manifest)
|
||||
archive.writestr('app/resources/install-stamp.json', json.dumps({'tag': tag, 'commit': sha, 'receiverProtocol': receiver}))
|
||||
archive.writestr('app/resources/install-stamp.json', json.dumps({
|
||||
'tag': tag, 'commit': sha, 'baseVersion': tag[1:],
|
||||
'receiverProtocol': receiver,
|
||||
}))
|
||||
|
||||
write_package(commit)
|
||||
out = root / 'metadata-windows-x64.json'
|
||||
@@ -70,9 +75,13 @@ def staged_candidate(tmp_path, r2_server, https_origin):
|
||||
built.mkdir()
|
||||
for platform, arches in [('windows', ('x64', 'arm64')), ('macos', ('x64', 'arm64')), ('termux', ('aarch64',))]:
|
||||
for arch in arches:
|
||||
row = {'platform': platform, 'arch': arch, 'tag': tag, 'commit': commit, 'identity': 'Product'}
|
||||
row = {
|
||||
'platform': platform, 'arch': arch, 'tag': tag, 'commit': commit,
|
||||
'baseVersion': tag[1:], 'identity': 'Product',
|
||||
}
|
||||
if platform == 'windows':
|
||||
row.update(version='1.2.3.0', publisher='CN=Test', applicationId='App')
|
||||
row.update(version=WINDOWS_VERSION, executableVersion=WINDOWS_VERSION,
|
||||
publisher='CN=Test', applicationId='App')
|
||||
package = f'HermesBundled-1.2.3-win-{arch}.msix'
|
||||
handoff_name = f'win32-{arch}'
|
||||
elif platform == 'macos':
|
||||
@@ -102,14 +111,15 @@ def staged_candidate(tmp_path, r2_server, https_origin):
|
||||
handoff.stage(tag, commit, handoff_name, built, includes)
|
||||
bundle = built / 'Product-win.msixbundle'
|
||||
with zipfile.ZipFile(bundle, 'w') as archive:
|
||||
archive.writestr('AppxMetadata/AppxBundleManifest.xml', '<Bundle><Identity Name="Product" Publisher="CN=Test" Version="1.2.3.0"/><Packages><Package Type="application" Architecture="arm64"/><Package Type="application" Architecture="x64"/></Packages></Bundle>')
|
||||
archive.writestr('AppxMetadata/AppxBundleManifest.xml', f'<Bundle><Identity Name="Product" Publisher="CN=Test" Version="{WINDOWS_VERSION}"/><Packages><Package Type="application" Architecture="arm64"/><Package Type="application" Architecture="x64"/></Packages></Bundle>')
|
||||
(built / 'Store-Product-win.msixbundle').write_bytes(b'Store bundle transport fixture')
|
||||
handoff.stage(tag, commit, 'windows-universal', built, ['*.msixbundle'])
|
||||
fetched = tmp_path / 'fetched'
|
||||
names = ['win32-x64', 'win32-arm64', 'darwin-x64', 'darwin-arm64', 'termux', 'windows-universal']
|
||||
handoff.fetch(tag, commit, names, fetched, ['metadata-*.json', '*.msixbundle'])
|
||||
r2_server.requests.clear()
|
||||
manifest = assemble(fetched, tag, commit, base, tmp_path / 'release-candidates.json', smoke_results=SMOKE_RESULTS)
|
||||
manifest = assemble(fetched, tag, commit, base, tmp_path / 'release-candidates.json',
|
||||
smoke_results=SMOKE_RESULTS, release_epoch=RELEASE_EPOCH)
|
||||
assert {row['platform'] + '/' + row['arch'] for row in manifest['packages']} == {
|
||||
'windows/x64', 'windows/arm64', 'macos/x64', 'macos/arm64', 'termux/aarch64'}
|
||||
assert all(not file['path'].startswith(('handoff-', 'metadata-')) for file in manifest['files'])
|
||||
@@ -156,11 +166,13 @@ def test_assemble_rejects_missing_and_changed_receipts(tmp_path, staged_candidat
|
||||
original = receipt.read_bytes()
|
||||
receipt.unlink()
|
||||
with pytest.raises(ValueError, match='handoff'):
|
||||
assemble(fetched, tag, commit, base, tmp_path / 'missing.json', smoke_results=SMOKE_RESULTS)
|
||||
assemble(fetched, tag, commit, base, tmp_path / 'missing.json',
|
||||
smoke_results=SMOKE_RESULTS, release_epoch=RELEASE_EPOCH)
|
||||
receipt.write_bytes(original)
|
||||
(fetched / 'metadata-windows-x64.json').write_text('{}', encoding='utf-8')
|
||||
with pytest.raises(ValueError, match='digest'):
|
||||
assemble(fetched, tag, commit, base, tmp_path / 'changed.json', smoke_results=SMOKE_RESULTS)
|
||||
assemble(fetched, tag, commit, base, tmp_path / 'changed.json',
|
||||
smoke_results=SMOKE_RESULTS, release_epoch=RELEASE_EPOCH)
|
||||
|
||||
|
||||
def test_candidate_publication_and_store_selection(tmp_path, monkeypatch, r2_server, staged_candidate):
|
||||
@@ -198,9 +210,12 @@ def test_candidate_publication_and_store_selection(tmp_path, monkeypatch, r2_ser
|
||||
for item in manifest['files']:
|
||||
assert (tmp_path / 'promote' / item['path']).read_bytes() == r2_server.store[f'releases/tag/{tag}/{item["path"]}'][0]
|
||||
descriptor = ET.fromstring(r2_server.store['releases/win32/stable/stable.appinstaller'][0])
|
||||
assert descriptor.attrib == {'Uri': base + '/releases/win32/stable/stable.appinstaller', 'Version': '1.2.3.0'}
|
||||
assert descriptor.attrib == {
|
||||
'Uri': base + '/releases/win32/stable/stable.appinstaller',
|
||||
'Version': WINDOWS_VERSION,
|
||||
}
|
||||
assert descriptor.find('{*}MainBundle').attrib == {
|
||||
'Name': 'Product', 'Publisher': 'CN=Test', 'Version': '1.2.3.0',
|
||||
'Name': 'Product', 'Publisher': 'CN=Test', 'Version': WINDOWS_VERSION,
|
||||
'Uri': base + '/releases/tag/v1.2.3/Product-win.msixbundle'}
|
||||
pointer = 'releases/win32/stable/stable.appinstaller'
|
||||
original = r2_server.store[pointer]
|
||||
@@ -225,6 +240,17 @@ def candidate_workflow_step(tmp_path, r2_server, staged_candidate):
|
||||
"""Run real workflow shell/CLIs; replace only service endpoints and tool setup."""
|
||||
manifest, fetched, base = staged_candidate
|
||||
jobs = hermes_yaml.safe_load((ROOT / '.github/workflows/desktop-bundled-release.yml').read_text(encoding='utf-8-sig'))['jobs']
|
||||
stable_jobs = hermes_yaml.safe_load(
|
||||
(ROOT / '.github/workflows/stable-release.yml').read_text(encoding='utf-8-sig'))['jobs']
|
||||
render = next(step for step in stable_jobs['complete']['steps']
|
||||
if step.get('name', '').startswith('Render the admitted'))
|
||||
jobs['controller-promote'] = {
|
||||
'env': jobs['stable-publish']['env'],
|
||||
'steps': [
|
||||
{'run': 'python -m scripts.bundles.release_artifacts promote --root verified'},
|
||||
render,
|
||||
],
|
||||
}
|
||||
shutil.copytree(fetched, tmp_path / 'candidates')
|
||||
bin_dir = tmp_path / 'bin'
|
||||
bin_dir.mkdir()
|
||||
@@ -266,6 +292,10 @@ def candidate_workflow_step(tmp_path, r2_server, staged_candidate):
|
||||
expressions = {
|
||||
'${{ inputs.tag }}': manifest['tag'], '${{ inputs.manifest-sha256 }}': pinned,
|
||||
'${{ needs.validate.outputs.sha }}': manifest['commit'], '${{ github.token }}': 'inert',
|
||||
'${{ needs.validate.outputs.release-epoch }}': str(manifest['releaseEpoch']),
|
||||
'${{ needs.admit.outputs.tag }}': manifest['tag'],
|
||||
'${{ needs.admit.outputs.commit }}': manifest['commit'],
|
||||
'${{ needs.candidates.outputs.manifest-sha256 }}': pinned,
|
||||
'${{ vars.CLOUDFLARE_R2_PUBLIC_URL }}': base, '${{ toJSON(needs) }}': json.dumps(needs),
|
||||
}
|
||||
for key in ('CLOUDFLARE_R2_ACCOUNT_ID', 'CLOUDFLARE_R2_ACCESS_KEY_ID', 'CLOUDFLARE_R2_SECRET_ACCESS_KEY', 'CLOUDFLARE_R2_BUCKET'):
|
||||
@@ -300,9 +330,10 @@ def test_candidate_smoke_survives_real_promotion_and_renderer(tmp_path, r2_serve
|
||||
# An unrelated orphan object must not acquire the candidate's Passed label.
|
||||
orphan = f"releases/tag/{manifest['tag']}/HermesBundled-1.2.3-linux-x64.AppImage"
|
||||
r2_server.store[orphan] = (b'orphan transport fixture', '"e"')
|
||||
for step in jobs['stable-promote']['steps']:
|
||||
for step in jobs['controller-promote']['steps']:
|
||||
if 'run' in step:
|
||||
result = run('stable-promote', step, {'validate': {'result': 'success'}}, ambient_needs=ambient_needs)
|
||||
result = run('controller-promote', step, {'validate': {'result': 'success'}},
|
||||
ambient_needs=ambient_needs)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
assert 'releases/win32/stable/stable.appinstaller' in r2_server.store
|
||||
page = r2_server.store['releases/stable/index.html'][0].decode()
|
||||
@@ -334,7 +365,7 @@ def test_candidate_smoke_admission_fails_before_publication(tmp_path, r2_server,
|
||||
assert not any(method == 'PUT' for method, _, _ in r2_server.requests)
|
||||
key = f"releases/tag/{manifest['tag']}/release-candidates.json"
|
||||
raw = r2_server.store[key][0]
|
||||
for fault, message in [('legacy', 'Legacy candidate'), ('missing', 'Candidate smoke results'),
|
||||
for fault, message in [('legacy', 'Candidate manifest'), ('missing', 'Candidate smoke results'),
|
||||
('failed', 'smoke-win32=failure'), ('identity', 'release identity'),
|
||||
('tampered', 'digest mismatch')]:
|
||||
invalid = copy.deepcopy(manifest)
|
||||
@@ -352,11 +383,11 @@ def test_candidate_smoke_admission_fails_before_publication(tmp_path, r2_server,
|
||||
digest = hashlib.sha256(raw if fault == 'tampered' else data).hexdigest()
|
||||
before = dict(r2_server.store), body_file.read_bytes()
|
||||
# Exercise both commands independently, not just shell short-circuiting.
|
||||
for step in jobs['stable-promote']['steps']:
|
||||
for step in jobs['controller-promote']['steps']:
|
||||
if 'run' not in step:
|
||||
continue
|
||||
r2_server.requests.clear()
|
||||
result = run('stable-promote', step, {}, pinned=digest)
|
||||
result = run('controller-promote', step, {}, pinned=digest)
|
||||
assert result.returncode != 0, (fault, step, result.stdout, result.stderr)
|
||||
assert message in result.stderr, result.stdout + result.stderr
|
||||
assert not any(method == 'PUT' for method, _, _ in r2_server.requests)
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
"""release.py must stamp bootstrap-installer versions with the release semver.
|
||||
|
||||
Tauri CFBundleShortVersionString is read from
|
||||
apps/bootstrap-installer/src-tauri/tauri.conf.json (and the sibling
|
||||
package.json). Those files were hardcoded 0.0.1 and omitted from
|
||||
update_version_files / the --publish --bump git add list, so Hermes-Setup.dmg
|
||||
always shipped 0.0.1. Same class as the desktop stamp (#68783 / PR #68796).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parents[2] / "scripts" / "release.py"
|
||||
|
||||
|
||||
def _load():
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"release_bootstrap_installer_version", SCRIPT
|
||||
)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
release = _load()
|
||||
|
||||
|
||||
def _json_version(path: Path) -> str:
|
||||
return json.loads(path.read_text(encoding="utf-8"))["version"]
|
||||
|
||||
|
||||
def _patch_repo(tmp_path, monkeypatch, *, with_installer: bool = True):
|
||||
repo = tmp_path
|
||||
init_py = repo / "hermes_cli" / "__init__.py"
|
||||
init_py.parent.mkdir(parents=True)
|
||||
init_py.write_text(
|
||||
'__version__ = "0.0.1"\n__release_date__ = "2026.1.1"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
pyproject = repo / "pyproject.toml"
|
||||
pyproject.write_text('version = "0.0.1"\n', encoding="utf-8")
|
||||
|
||||
desktop_pkg = repo / "apps" / "desktop" / "package.json"
|
||||
desktop_pkg.parent.mkdir(parents=True)
|
||||
desktop_pkg.write_text('{"version":"0.0.1"}\n', encoding="utf-8")
|
||||
|
||||
package_lock = repo / "package-lock.json"
|
||||
package_lock.write_text('{"packages":{"apps/desktop":{"name":"hermes","version":"0.0.1"}}}\n', encoding="utf-8")
|
||||
uv_lock = repo / "uv.lock"
|
||||
uv_lock.write_text('[[package]]\nname = "hermes-agent"\nversion = "0.0.1"\n', encoding="utf-8")
|
||||
|
||||
installer_pkg = repo / "apps" / "bootstrap-installer" / "package.json"
|
||||
tauri_conf = (
|
||||
repo / "apps" / "bootstrap-installer" / "src-tauri" / "tauri.conf.json"
|
||||
)
|
||||
cargo_toml = repo / "apps" / "bootstrap-installer" / "src-tauri" / "Cargo.toml"
|
||||
if with_installer:
|
||||
tauri_conf.parent.mkdir(parents=True)
|
||||
installer_pkg.write_text(
|
||||
'{"name":"x","version":"0.0.1"}\n', encoding="utf-8"
|
||||
)
|
||||
tauri_conf.write_text(
|
||||
'{"productName":"Hermes","version":"0.0.1"}\n', encoding="utf-8"
|
||||
)
|
||||
cargo_toml.write_text('[package]\nversion = "0.0.1"\n', encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(release, "REPO_ROOT", repo)
|
||||
monkeypatch.setattr(release, "VERSION_FILE", init_py)
|
||||
monkeypatch.setattr(release, "PYPROJECT_FILE", pyproject)
|
||||
monkeypatch.setattr(release, "DESKTOP_PKG_FILE", desktop_pkg)
|
||||
monkeypatch.setattr(release, "PKG_LOCK_FILE", package_lock)
|
||||
monkeypatch.setattr(release, "UV_LOCK_FILE", uv_lock)
|
||||
monkeypatch.setattr(release, "git", lambda *args: "0")
|
||||
return {
|
||||
"repo": repo,
|
||||
"init_py": init_py,
|
||||
"pyproject": pyproject,
|
||||
"desktop_pkg": desktop_pkg,
|
||||
"installer_pkg": installer_pkg,
|
||||
"tauri_conf": tauri_conf,
|
||||
"cargo_toml": cargo_toml,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bom", [b"", b"\xef\xbb\xbf"])
|
||||
def test_update_version_files_stamps_bootstrap_installer(tmp_path, monkeypatch, bom):
|
||||
paths = _patch_repo(tmp_path, monkeypatch)
|
||||
for key in ("installer_pkg", "tauri_conf", "cargo_toml"):
|
||||
path = paths[key]
|
||||
path.write_bytes(bom + path.read_bytes())
|
||||
|
||||
staged = release.update_version_files("0.21.1", "2026.9.10")
|
||||
assert all(str(paths[key]) in staged for key in (
|
||||
'init_py', 'pyproject', 'desktop_pkg', 'installer_pkg', 'tauri_conf', 'cargo_toml'))
|
||||
|
||||
assert _json_version(paths["installer_pkg"]) == "0.21.1"
|
||||
assert _json_version(paths["tauri_conf"]) == "0.21.1"
|
||||
assert 'version = "0.21.1"' in paths["cargo_toml"].read_text(encoding="utf-8")
|
||||
assert all(not paths[key].read_bytes().startswith(b"\xef\xbb\xbf")
|
||||
for key in ("installer_pkg", "tauri_conf", "cargo_toml"))
|
||||
|
||||
# CONTROL: existing desktop / Python stamps still happen.
|
||||
assert _json_version(paths["desktop_pkg"]) == "0.21.1"
|
||||
assert 'version = "0.21.1"' in paths["pyproject"].read_text(encoding="utf-8")
|
||||
init_text = paths["init_py"].read_text(encoding="utf-8")
|
||||
assert '__version__ = "0.21.1"' in init_text
|
||||
assert '__release_date__ = "2026.9.10"' in init_text
|
||||
|
||||
|
||||
def test_update_version_files_skips_missing_installer_dir(tmp_path, monkeypatch):
|
||||
paths = _patch_repo(tmp_path, monkeypatch, with_installer=False)
|
||||
|
||||
staged = release.update_version_files("0.21.1", "2026.9.10")
|
||||
assert all(str(paths[key]) not in staged for key in ('installer_pkg', 'tauri_conf', 'cargo_toml'))
|
||||
assert all(str(paths[key]) in staged for key in ('init_py', 'pyproject', 'desktop_pkg'))
|
||||
|
||||
assert not paths["installer_pkg"].exists()
|
||||
assert not paths["tauri_conf"].exists()
|
||||
assert _json_version(paths["desktop_pkg"]) == "0.21.1"
|
||||
assert 'version = "0.21.1"' in paths["pyproject"].read_text(encoding="utf-8")
|
||||
assert '__version__ = "0.21.1"' in paths["init_py"].read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_update_version_files_does_not_invent_version_keys(tmp_path, monkeypatch):
|
||||
paths = _patch_repo(tmp_path, monkeypatch)
|
||||
original = '{"name":"x","productName":"Hermes"}\n'
|
||||
paths["installer_pkg"].write_text(original, encoding="utf-8")
|
||||
paths["tauri_conf"].write_text(original, encoding="utf-8")
|
||||
|
||||
release.update_version_files("0.21.1", "2026.9.10")
|
||||
|
||||
assert paths["installer_pkg"].read_text(encoding="utf-8") == original
|
||||
assert paths["tauri_conf"].read_text(encoding="utf-8") == original
|
||||
assert _json_version(paths["desktop_pkg"]) == "0.21.1"
|
||||
@@ -16,18 +16,30 @@ def canary_repo(tmp_path, monkeypatch, release_repo):
|
||||
git("remote", "add", "origin", "https://github.com/fixture/release")
|
||||
|
||||
calls = []
|
||||
drafts = set()
|
||||
published = set()
|
||||
actual_run = subprocess.run
|
||||
|
||||
def run(argv, *args, **kwargs):
|
||||
if argv[0] != "gh":
|
||||
if argv[:2] == ["git", "push"]:
|
||||
if argv[:2] in (["git", "push"], ["git", "ls-remote"]):
|
||||
argv = ["git", "-c", f"url.{remote.as_uri()}.insteadOf=https://github.com/fixture/release", *argv[1:]]
|
||||
return actual_run(argv, *args, **kwargs)
|
||||
calls.append(argv)
|
||||
if argv[1:3] == ["release", "create"]:
|
||||
assert git("--git-dir", str(remote), "rev-parse", argv[3] + "^{commit}") == git("rev-parse", "HEAD")
|
||||
drafts.add(argv[3])
|
||||
elif argv[1:3] == ["release", "view"]:
|
||||
if argv[3] not in drafts:
|
||||
return subprocess.CompletedProcess(argv, 1, "", "not found")
|
||||
return subprocess.CompletedProcess(
|
||||
argv, 0,
|
||||
'{"tagName":"' + argv[3] + '","isDraft":true,"isPrerelease":true}\n', "",
|
||||
)
|
||||
elif argv[1:3] == ["workflow", "run"]:
|
||||
assert any(call[1:3] == ["release", "create"] for call in calls)
|
||||
assert any(call[1:3] in (["release", "create"], ["release", "view"])
|
||||
for call in calls)
|
||||
published.add(next(value.removeprefix("tag=") for value in argv if value.startswith("tag=")))
|
||||
else:
|
||||
assert argv[1:3] == ["repo", "view"]
|
||||
return subprocess.CompletedProcess(argv, 0, stdout="main\n", stderr="")
|
||||
@@ -36,6 +48,15 @@ def canary_repo(tmp_path, monkeypatch, release_repo):
|
||||
which = release.shutil.which
|
||||
monkeypatch.setattr(release.shutil, "which", lambda name: "gh" if name == "gh" else which(name))
|
||||
monkeypatch.setattr(release, "generate_changelog", lambda *args, **kwargs: "fixture notes")
|
||||
from scripts.releases import versioning
|
||||
monkeypatch.setattr(versioning, "published_stable_identity", lambda repository: ("1.2.3", None))
|
||||
monkeypatch.setattr(
|
||||
versioning, "published_channel_identity",
|
||||
lambda _repository, _channel: (
|
||||
(release.get_last_canary_tag()[1:], git("rev-parse", "HEAD"))
|
||||
if release.get_last_canary_tag() in published else None
|
||||
),
|
||||
)
|
||||
monkeypatch.delenv("GITHUB_OUTPUT", raising=False)
|
||||
return git, remote, calls
|
||||
|
||||
@@ -44,6 +65,7 @@ def test_canary_cut_keeps_the_stable_core_and_dispatches_exactly_once(canary_rep
|
||||
git, remote, calls = canary_repo
|
||||
git("commit", "--allow-empty", "-qm", "stable")
|
||||
git("tag", "v1.2.3")
|
||||
git("tag", "v9.9.9")
|
||||
git("commit", "--allow-empty", "-qm", "feat: next")
|
||||
tag = "v1.2.3+canary.20260818T103000Z"
|
||||
args = SimpleNamespace(
|
||||
@@ -64,7 +86,8 @@ def test_canary_cut_keeps_the_stable_core_and_dispatches_exactly_once(canary_rep
|
||||
|
||||
calls.clear()
|
||||
release.cmd_canary(args)
|
||||
assert calls == []
|
||||
assert not any(call[1:3] in (["release", "create"], ["workflow", "run"])
|
||||
for call in calls)
|
||||
|
||||
|
||||
def test_unchanged_head_does_not_cut_another_timestamp(canary_repo):
|
||||
@@ -79,7 +102,25 @@ def test_unchanged_head_does_not_cut_another_timestamp(canary_repo):
|
||||
second = SimpleNamespace(date="20260818T103001Z", publish=True, no_changelog=True, remote="origin")
|
||||
release.cmd_canary(second)
|
||||
|
||||
assert calls == []
|
||||
assert not any(call[1:3] in (["release", "create"], ["workflow", "run"])
|
||||
for call in calls)
|
||||
|
||||
|
||||
def test_incomplete_canary_redispatches_the_existing_receipt(canary_repo, monkeypatch):
|
||||
git, _remote, calls = canary_repo
|
||||
git("commit", "--allow-empty", "-qm", "stable")
|
||||
git("tag", "v1.2.3")
|
||||
git("commit", "--allow-empty", "-qm", "feat: next")
|
||||
args = SimpleNamespace(date="20260818T103000Z", publish=True, no_changelog=True, remote="origin")
|
||||
release.cmd_canary(args)
|
||||
calls.clear()
|
||||
|
||||
from scripts.releases import versioning
|
||||
monkeypatch.setattr(versioning, "published_channel_identity", lambda *_args: None)
|
||||
release.cmd_canary(args)
|
||||
|
||||
assert [call[1:3] for call in calls].count(["workflow", "run"]) == 1
|
||||
assert not any(call[1:3] == ["release", "create"] for call in calls)
|
||||
|
||||
|
||||
def test_tag_shape_and_prune_use_canonical_receipts(canary_repo, monkeypatch, capsys):
|
||||
|
||||
@@ -72,15 +72,15 @@ def test_disposable_scope_is_opt_in_and_lease_bound(monkeypatch):
|
||||
monkeypatch.setattr(r2, "credentials", lambda: ({"access_key_id": "inert", "secret_key": "inert"}, "https://r2.example", "bucket"))
|
||||
unscoped = channel_build.configured_publisher("ethernet8023/hermes-agent")
|
||||
assert unscoped.store.scope.key("releases/channels/preview.json") == "releases/channels/preview.json"
|
||||
monkeypatch.setenv("R2_DISPOSABLE_RUN", "123-1")
|
||||
monkeypatch.setenv("R2_DISPOSABLE_RUN", "123")
|
||||
monkeypatch.setenv("GITHUB_REPOSITORY_ID", "456")
|
||||
monkeypatch.setattr(channel_build.commit_build, "output", lambda args: "789")
|
||||
with pytest.raises(ValueError, match="another repository"):
|
||||
channel_build.configured_publisher("ethernet8023/hermes-agent")
|
||||
monkeypatch.setattr(channel_build.commit_build, "output", lambda args: "456")
|
||||
publisher = channel_build.configured_publisher("ethernet8023/hermes-agent")
|
||||
assert publisher.store.scope.key("releases/channels/preview.json") == "ci-disposable/456/123-1/releases/channels/preview.json"
|
||||
assert publisher.reader.base_url == "https://archive.example/ci-disposable/456/123-1"
|
||||
assert publisher.store.scope.key("releases/channels/preview.json") == "ci-disposable/456/123/releases/channels/preview.json"
|
||||
assert publisher.reader.base_url == "https://archive.example/ci-disposable/456/123"
|
||||
|
||||
|
||||
def test_release_parser_preserves_plain_oneoff_dispatch(monkeypatch):
|
||||
|
||||
@@ -432,20 +432,31 @@ def test_accepted_release_receipts_feed_the_protected_head_without_rebuilding(tm
|
||||
# Exercise the real controller, HTTP receipt downloader, immutable feeds,
|
||||
# manifest and final CAS; only GitHub admission and generated product facts
|
||||
# are fixture inputs (no native signature acceptance is claimed here).
|
||||
from scripts.releases import r2, commit_build
|
||||
monkeypatch.setattr(channel_releases, "admit_transaction", lambda policy, env: (tag, commit))
|
||||
from scripts.releases import r2
|
||||
monkeypatch.setattr(channel_releases, "admit_transaction",
|
||||
lambda policy, env, **_kwargs: (tag, commit))
|
||||
monkeypatch.setattr(channel_releases.stable, "final_context",
|
||||
lambda env: (tag, commit, {"claim_epoch": 1_787_965_323}))
|
||||
monkeypatch.setattr(channel_releases, "accepted_stable", lambda *args: accepted)
|
||||
monkeypatch.setattr(channel_releases, "promote_stable_feeds", lambda *args: None)
|
||||
promotion_attempts = [0]
|
||||
def promote_stable_feeds(*args):
|
||||
promotion_attempts[0] += 1
|
||||
if promotion_attempts[0] == 1:
|
||||
raise ChannelError("fixture feed failure")
|
||||
monkeypatch.setattr(channel_releases, "promote_stable_feeds", promote_stable_feeds)
|
||||
monkeypatch.setattr(channel_releases, "product_identity", lambda tag: dict(identity))
|
||||
monkeypatch.setattr(commit_build, "version_at", lambda *args: "2.0.0")
|
||||
monkeypatch.setattr(r2, "credentials", lambda: (pub.store.creds, url, "bucket"))
|
||||
monkeypatch.setattr(r2, "public_base_url", lambda: base)
|
||||
def put(**kwargs):
|
||||
pub.store.put(kwargs["key"], __import__("pathlib").Path(kwargs["file"]).read_bytes())
|
||||
monkeypatch.setattr(r2, "put", put)
|
||||
with pytest.raises(ChannelError, match="fixture feed failure"):
|
||||
channel_releases.publish_release("stable-release", {"GITHUB_REPOSITORY": pub.repository}, tmp_path / "failed")
|
||||
assert pub._read("released")[0]["head"] is None
|
||||
result = channel_releases.publish_release("stable-release", {"GITHUB_REPOSITORY": pub.repository}, tmp_path / "downloaded")
|
||||
assert result["name"] == "released"
|
||||
assert pub.reader.resolve("released").manifest == manifest
|
||||
assert manifest["request"]["sourceVersion"] == "2.0.0"
|
||||
before = dict(objects)
|
||||
assert channel_releases.publish_release("stable-release", {"GITHUB_REPOSITORY": pub.repository}, tmp_path / "retry") == result
|
||||
assert objects == before
|
||||
@@ -489,23 +500,25 @@ def test_protected_transaction_refuses_custom_workflow_and_unpublished_release(m
|
||||
pub = publisher(url)
|
||||
# Exercise HTTPS authority validation through the loopback transport.
|
||||
pub.public_base = "https://releases.example"
|
||||
candidate = {"schema": 2, "tag": tag, "commit": commit,
|
||||
release_epoch = 1_787_965_323
|
||||
candidate = {"schema": 2, "tag": tag, "commit": commit, "releaseEpoch": release_epoch,
|
||||
"smoke_results": {job: {"result": "success"} for job in channel_releases.stable.SMOKE_JOBS},
|
||||
"packages": []}
|
||||
for platform in ("macos", "windows"):
|
||||
for arch in ("arm64", "x64"):
|
||||
candidate["packages"].append({"platform": platform, "arch": arch, "tag": tag, "commit": commit,
|
||||
"version": "2.0.0" if platform == "macos" else "2.0.0.0", "identity": "fixture.identity",
|
||||
"version": "2.0.0" if platform == "macos" else "2026.5761.123.0", "identity": "fixture.identity",
|
||||
**({"executableVersion": "2026.5761.123.0"} if platform == "windows" else {}),
|
||||
"teamId": "ABCDEFGHIJ", "publisher": "CN=Fixture", "applicationId": "Fixture",
|
||||
"artifact": {"url": f"{pub.public_base}/releases/tag/{tag}/fixture-{arch}." + ("zip" if platform == "macos" else "msixbundle"), "sha256": "d" * 64}})
|
||||
raw = canonical_json(candidate)
|
||||
key = f"releases/tag/{tag}/release-candidates.json"
|
||||
objects[key] = raw
|
||||
candidate_env = {"CANDIDATE_MANIFEST_SHA256": hashlib.sha256(raw).hexdigest(), "CANDIDATE_MANIFEST_URL": pub.public_base + "/" + key}
|
||||
assert channel_releases.accepted_stable(pub, candidate_env, tag, commit) == candidate
|
||||
assert channel_releases.accepted_stable(pub, candidate_env, tag, commit, release_epoch) == candidate
|
||||
faults["stale_public"] = b"{}"
|
||||
with pytest.raises(ChannelError):
|
||||
channel_releases.accepted_stable(pub, candidate_env, tag, commit)
|
||||
channel_releases.accepted_stable(pub, candidate_env, tag, commit, release_epoch)
|
||||
|
||||
|
||||
def test_request_inputs_are_rejected_before_allocating():
|
||||
@@ -520,3 +533,9 @@ def test_request_inputs_are_rejected_before_allocating():
|
||||
with pytest.raises(ChannelError):
|
||||
pub.allocate("validation", "not-a-sha", "1.0.0")
|
||||
assert objects == before
|
||||
|
||||
|
||||
def test_canary_native_version_is_derived_from_the_current_tag():
|
||||
from scripts.releases.channel_releases import canary_windows_version
|
||||
|
||||
assert canary_windows_version("v0.27.1+canary.20260829T010203Z") == "26.829.1.203"
|
||||
@@ -6,7 +6,6 @@ never starts is an error, not a warning the operator has to notice.
|
||||
import json
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -33,9 +32,13 @@ def source(tmp_path):
|
||||
return repo
|
||||
|
||||
|
||||
def _claim(repo, version, commit, actor="release-bot", when="2026-09-22T00:14:00Z"):
|
||||
def _claim(repo, version, commit):
|
||||
metadata = json.dumps({
|
||||
"schema": 1, "version": version, "commit": commit,
|
||||
"autopublish": False, "claimEpoch": 1_790_000_000,
|
||||
}, sort_keys=True, separators=(",", ":"))
|
||||
git(repo, "tag", "-a", f"v{version}-rc", commit, "-m",
|
||||
f"claim v{version}\n\nactor: {actor}\nwhen: {when}")
|
||||
metadata)
|
||||
git(repo, "push", "--quiet", "origin", f"refs/tags/v{version}-rc")
|
||||
|
||||
|
||||
@@ -52,7 +55,9 @@ def test_release_claims_the_derived_version_creates_a_draft_and_dispatches(sourc
|
||||
assert result["commit"] == commit
|
||||
assert result["url"] == "https://github.com/example/hermes-agent/releases/tag/v0.21.5-rc"
|
||||
assert git(source, "rev-parse", "v0.21.5-rc^{commit}") == commit
|
||||
assert json.loads(git(source, "tag", "-l", "v0.21.5-rc", "--format=%(contents)")) == {
|
||||
claim = json.loads(git(source, "tag", "-l", "v0.21.5-rc", "--format=%(contents)"))
|
||||
assert isinstance(claim.pop("claimEpoch"), int)
|
||||
assert claim == {
|
||||
"autopublish": True,
|
||||
"commit": commit,
|
||||
"schema": 1,
|
||||
@@ -62,8 +67,7 @@ def test_release_claims_the_derived_version_creates_a_draft_and_dispatches(sourc
|
||||
["gh", "release", "create", "v0.21.5-rc", "--repo", "example/hermes-agent",
|
||||
"--verify-tag", "--draft", "--generate-notes", "--title", "Hermes Agent v0.21.5"],
|
||||
["gh", "workflow", "run", "stable-release.yml", "--ref", "v0.21.5-rc",
|
||||
"--repo", "example/hermes-agent", "--raw-field", "tag=v0.21.5-rc",
|
||||
"--raw-field", "autopublish=true"],
|
||||
"--repo", "example/hermes-agent", "--raw-field", "tag=v0.21.5-rc"],
|
||||
]
|
||||
# The claim push names the claim ref and nothing else.
|
||||
pushed = git(source, "ls-remote", "origin", "refs/tags/v0.21.5-rc")
|
||||
@@ -71,7 +75,7 @@ def test_release_claims_the_derived_version_creates_a_draft_and_dispatches(sourc
|
||||
|
||||
|
||||
def test_a_commit_behind_an_outstanding_claim_is_refused(source):
|
||||
from scripts.releases.entrypoint import ReleaseRefused, release
|
||||
from scripts.releases.entrypoint import ReleaseRefused, _require_ancestry, release
|
||||
|
||||
earlier = git(source, "rev-parse", "HEAD")
|
||||
git(source, "commit", "--allow-empty", "--quiet", "-m", "later")
|
||||
@@ -83,6 +87,27 @@ def test_a_commit_behind_an_outstanding_claim_is_refused(source):
|
||||
repository="example/hermes-agent", execute=lambda _cmd: pytest.fail("must not execute"))
|
||||
assert "v0.21.6-rc" not in git(source, "tag", "--list")
|
||||
|
||||
_require_ancestry(source, git(source, "rev-parse", "v0.21.5-rc^{commit}"))
|
||||
|
||||
|
||||
def test_successive_claims_reserve_increasing_native_epochs(source):
|
||||
from scripts.releases.entrypoint import release
|
||||
|
||||
first = git(source, "rev-parse", "HEAD")
|
||||
release(first, bump="patch", repo=source, remote="origin",
|
||||
repository="example/hermes-agent", execute=lambda _command: None)
|
||||
git(source, "commit", "--allow-empty", "--quiet", "-m", "next")
|
||||
git(source, "push", "--quiet", "origin", "main")
|
||||
second = git(source, "rev-parse", "HEAD")
|
||||
release(second, bump="patch", repo=source, remote="origin",
|
||||
repository="example/hermes-agent", execute=lambda _command: None)
|
||||
|
||||
first_epoch = int(git(source, "for-each-ref", "refs/tags/v0.21.5-rc",
|
||||
"--format=%(taggerdate:unix)"))
|
||||
second_epoch = int(git(source, "for-each-ref", "refs/tags/v0.21.6-rc",
|
||||
"--format=%(taggerdate:unix)"))
|
||||
assert second_epoch > first_epoch
|
||||
|
||||
|
||||
def test_a_dispatch_that_never_starts_is_an_error(source):
|
||||
from scripts.releases.entrypoint import ReleaseRefused, release
|
||||
@@ -94,7 +119,7 @@ def test_a_dispatch_that_never_starts_is_an_error(source):
|
||||
with pytest.raises(ReleaseRefused, match="never started"):
|
||||
release(git(source, "rev-parse", "HEAD"), bump="patch", repo=source, remote="origin",
|
||||
repository="example/hermes-agent", execute=refuse)
|
||||
# The claim stands: a failed start burns the version rather than retrying it.
|
||||
# The claim stands unresolved; reconciliation burns it only after its grace period.
|
||||
assert "v0.21.5-rc" in git(source, "tag", "--list")
|
||||
|
||||
|
||||
@@ -111,19 +136,23 @@ def test_publish_dispatches_the_sequencer_and_abandon_keeps_the_claim(source):
|
||||
"--repo", "example/hermes-agent", "--raw-field", "version=0.21.5",
|
||||
]]
|
||||
|
||||
abandoned = abandon("0.21.5", repo=source, repository="example/hermes-agent", delete=calls.append)
|
||||
def inspect(command):
|
||||
if "v0.21.5" in command:
|
||||
return json.dumps({"tagName": "v0.21.5", "isDraft": True, "isPrerelease": False})
|
||||
raise ReleaseRefused("not found")
|
||||
|
||||
abandoned = abandon("0.21.5", repo=source, repository="example/hermes-agent",
|
||||
delete=calls.append, inspect=inspect)
|
||||
assert abandoned["burned"] == "0.21.5"
|
||||
assert calls[-1] == ["gh", "release", "delete", "v0.21.5-rc", "--repo", "example/hermes-agent", "--yes"]
|
||||
assert calls[-1] == ["gh", "release", "delete", "v0.21.5", "--repo", "example/hermes-agent", "--yes"]
|
||||
assert "v0.21.5-rc" in git(source, "tag", "--list")
|
||||
|
||||
with pytest.raises(ReleaseRefused, match="burned or superseded by 0\\.21\\.6"):
|
||||
publish(
|
||||
"0.21.5", repository="example/hermes-agent",
|
||||
dispatch=lambda _command: pytest.fail("superseded publish must not dispatch"),
|
||||
inspect=lambda _command: json.dumps([
|
||||
{"tagName": "v0.21.5-rc", "isDraft": True, "isPrerelease": False},
|
||||
{"tagName": "v0.21.6", "isDraft": False, "isPrerelease": False},
|
||||
]),
|
||||
inspect=lambda _command: pytest.fail("superseded publish must not inspect drafts"),
|
||||
head_version=lambda: "0.21.6",
|
||||
)
|
||||
|
||||
|
||||
@@ -146,13 +175,20 @@ def test_concurrent_claim_loser_reports_the_remote_winner_and_the_version_stays_
|
||||
git(left, "checkout", "--quiet", old)
|
||||
|
||||
barrier = threading.Barrier(2)
|
||||
winner_pushed = threading.Event()
|
||||
original_git = entrypoint._git
|
||||
|
||||
def racing_git(repo, *args):
|
||||
if args[:2] == ("push", "origin") and args[-1] == "refs/tags/v0.21.5-rc":
|
||||
barrier.wait(timeout=10)
|
||||
if repo == left:
|
||||
time.sleep(0.1)
|
||||
if not winner_pushed.wait(timeout=10):
|
||||
raise TimeoutError("winning claim did not finish")
|
||||
else:
|
||||
try:
|
||||
return original_git(repo, *args)
|
||||
finally:
|
||||
winner_pushed.set()
|
||||
return original_git(repo, *args)
|
||||
|
||||
monkeypatch.setattr(entrypoint, "_git", racing_git)
|
||||
|
||||
@@ -64,7 +64,7 @@ def _auth(**kwargs):
|
||||
|
||||
|
||||
def test_disposable_scope_streams_lists_and_never_touches_production(r2_server, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("R2_DISPOSABLE_RUN", "98765-1")
|
||||
monkeypatch.setenv("R2_DISPOSABLE_RUN", "98765")
|
||||
monkeypatch.setenv("GITHUB_REPOSITORY_ID", "12345")
|
||||
scope = R2Scope.configured()
|
||||
root = f"http://127.0.0.1:{r2_server.server_port}/hermes-releases"
|
||||
|
||||
@@ -1,56 +1,43 @@
|
||||
"""Version files record the Git count of the release commit they describe."""
|
||||
import json
|
||||
import runpy
|
||||
"""Changelog attribution is anchored to the selected release range."""
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from scripts import release
|
||||
|
||||
|
||||
@pytest.mark.parametrize("existing", [False, True])
|
||||
def test_version_writer_creates_and_refreshes_release_revision(tmp_path, monkeypatch, existing):
|
||||
repo = tmp_path / "source"
|
||||
def _git(repo, *args):
|
||||
return subprocess.check_output(["git", *args], cwd=repo, text=True).strip()
|
||||
|
||||
|
||||
def test_get_commits_attributes_each_revision_not_current_head(tmp_path, monkeypatch):
|
||||
"""A mailmap change after an older commit must not rewrite that commit's author."""
|
||||
from scripts import release
|
||||
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir()
|
||||
subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
|
||||
subprocess.run(["git", "config", "user.name", "Test"], cwd=repo, check=True)
|
||||
subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=repo, check=True)
|
||||
|
||||
def git(*args):
|
||||
return subprocess.run(
|
||||
["git", "-C", str(repo), *args], check=True, capture_output=True,
|
||||
text=True, encoding="utf-8", timeout=20,
|
||||
).stdout.strip()
|
||||
(repo / ".mailmap").write_text("", encoding="utf-8")
|
||||
subprocess.run(["git", "add", ".mailmap"], cwd=repo, check=True)
|
||||
subprocess.run(["git", "commit", "-qm", "base"], cwd=repo, check=True)
|
||||
subprocess.run(["git", "tag", "base"], cwd=repo, check=True)
|
||||
|
||||
(repo / "payload.txt").write_text("one\n", encoding="utf-8")
|
||||
subprocess.run(["git", "add", "payload.txt"], cwd=repo, check=True)
|
||||
subprocess.run(["git", "commit", "-qm", "feat: payload"], cwd=repo, check=True)
|
||||
payload_sha = _git(repo, "rev-parse", "HEAD")
|
||||
|
||||
(repo / ".mailmap").write_text("Mapped Person <mapped@example.com> Test <test@example.com>\n",
|
||||
encoding="utf-8")
|
||||
subprocess.run(["git", "add", ".mailmap"], cwd=repo, check=True)
|
||||
subprocess.run(["git", "commit", "-qm", "chore: add mailmap"], cwd=repo, check=True)
|
||||
|
||||
git("init", "--quiet")
|
||||
git("config", "user.name", "Release fixture")
|
||||
git("config", "user.email", "release@example.invalid")
|
||||
files = {
|
||||
"VERSION_FILE": ("hermes_cli/__init__.py", '__version__ = "1.0.0"\n__release_date__ = "2000.1.1"\n'),
|
||||
"PYPROJECT_FILE": ("pyproject.toml", '[project]\nname="hermes-agent"\nversion = "1.0.0"\n'),
|
||||
"DESKTOP_PKG_FILE": ("apps/desktop/package.json", '{"name":"desktop", "version":"1.0.0"}\n'),
|
||||
"PKG_LOCK_FILE": ("package-lock.json", json.dumps({"version": "9.9.9", "packages": {"apps/desktop": {"name": "desktop", "version": "1.0.0"}}}) + "\n"),
|
||||
"UV_LOCK_FILE": ("uv.lock", 'version = 1\n[[package]]\nname = "hermes-agent"\nversion = "1.0.0"\n'),
|
||||
}
|
||||
if existing:
|
||||
rel, content = files["VERSION_FILE"]
|
||||
files["VERSION_FILE"] = (rel, content + "__release_rev_count__ = 999\n")
|
||||
for name, (rel, content) in files.items():
|
||||
file = repo / rel
|
||||
file.parent.mkdir(parents=True, exist_ok=True)
|
||||
file.write_text(content, encoding="utf-8")
|
||||
monkeypatch.setattr(release, name, file)
|
||||
monkeypatch.setattr(release, "REPO_ROOT", repo)
|
||||
git("add", ".")
|
||||
git("commit", "-qm", "fixture")
|
||||
before = git("rev-parse", "HEAD")
|
||||
|
||||
paths = release.update_version_files("1.1.0", "2000.1.2")
|
||||
assert set(map(Path, paths)) == {repo / rel for rel, _ in files.values()}
|
||||
assert git("rev-parse", "HEAD") == before
|
||||
git("add", ".")
|
||||
git("commit", "-qm", "release fixture")
|
||||
version = runpy.run_path(str(repo / "hermes_cli/__init__.py"))
|
||||
assert version["__version__"] == "1.1.0"
|
||||
assert version["__release_rev_count__"] == int(git("rev-list", "--count", "HEAD"))
|
||||
lock = json.loads((repo / "package-lock.json").read_text(encoding="utf-8"))
|
||||
assert lock["version"] == "9.9.9"
|
||||
assert lock["packages"]["apps/desktop"]["version"] == "1.1.0"
|
||||
assert _git(repo, "show", "-s", "--format=%aN", payload_sha) == "Mapped Person"
|
||||
assert _git(repo, "show", "-s", "--use-mailmap", "--format=%aN", payload_sha) == "Mapped Person"
|
||||
monkeypatch.setitem(release.AUTHOR_MAP, "test@example.com", "test-user")
|
||||
commits = release.get_commits(since_tag="base")
|
||||
payload = next(commit for commit in commits if commit["sha"] == payload_sha)
|
||||
assert payload["author_name"] == "Test"
|
||||
assert payload["author_email"] == "test@example.com"
|
||||
assert payload["github_author"] == "@test-user"
|
||||
@@ -41,10 +41,13 @@ def test_a_newer_green_release_flushes_the_older_waiting_draft():
|
||||
), head="0.21.4")
|
||||
|
||||
assert _flips(steps) == ["0.21.5", "0.21.6"]
|
||||
assert steps[-2:] == [{"advance": "0.21.5"}, {"advance": "0.21.6"}]
|
||||
assert steps == [
|
||||
{"flip": "0.21.5"}, {"advance": "0.21.5"},
|
||||
{"flip": "0.21.6"}, {"advance": "0.21.6"},
|
||||
]
|
||||
|
||||
|
||||
def test_green_progress_before_a_running_blocker_is_preserved():
|
||||
def test_a_running_newer_claim_does_not_flush_a_held_draft():
|
||||
from scripts.releases.sequencer import plan
|
||||
|
||||
steps = plan(_claims(
|
||||
@@ -53,12 +56,29 @@ def test_green_progress_before_a_running_blocker_is_preserved():
|
||||
("0.21.7", "green", True),
|
||||
), head="0.21.4")
|
||||
|
||||
assert _flips(steps) == ["0.21.5"]
|
||||
assert steps[-1] == {"advance": "0.21.5"}
|
||||
assert steps == []
|
||||
assert plan(_claims(
|
||||
("0.21.5", "running", False),
|
||||
("0.21.6", "green", True),
|
||||
), head="0.21.4") == []
|
||||
assert plan(_claims(
|
||||
("0.21.5", "green", False),
|
||||
("0.21.6", "published", False),
|
||||
), head="0.21.4") == []
|
||||
|
||||
|
||||
def test_unstarted_claim_waits_for_its_grace_period_before_burning():
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from scripts.releases.sequencer import classify_runs
|
||||
|
||||
claimed = datetime(2026, 9, 22, 1, 0, tzinfo=timezone.utc)
|
||||
assert classify_runs([], claimed_at=claimed, now=claimed + timedelta(minutes=59)) == (
|
||||
"running", None,
|
||||
)
|
||||
assert classify_runs([], claimed_at=claimed, now=claimed + timedelta(hours=1)) == (
|
||||
"burned", None,
|
||||
)
|
||||
|
||||
|
||||
def test_failed_run_retries_twice_after_backoff_before_burning():
|
||||
@@ -73,9 +93,16 @@ def test_failed_run_retries_twice_after_backoff_before_burning():
|
||||
}
|
||||
state, retry = classify_runs([failed])
|
||||
assert state == "running"
|
||||
assert retry is not None
|
||||
assert retry_due([{"version": "0.21.5", "state": state, "retry": retry}], now=now) == [{
|
||||
"version": "0.21.5", "run_id": 42, "attempt": 2,
|
||||
}]
|
||||
newer = dict(retry)
|
||||
newer["run_id"] = 43
|
||||
assert retry_due([
|
||||
{"version": "0.21.5", "state": state, "retry": retry},
|
||||
{"version": "0.21.6", "state": state, "retry": newer},
|
||||
], now=now) == [{"version": "0.21.5", "run_id": 42, "attempt": 2}]
|
||||
failed["run_attempt"] = 2
|
||||
state, retry = classify_runs([failed])
|
||||
assert retry_due([{"version": "0.21.5", "state": state, "retry": retry}], now=now)[0]["attempt"] == 3
|
||||
@@ -84,7 +111,9 @@ def test_failed_run_retries_twice_after_backoff_before_burning():
|
||||
|
||||
|
||||
def test_a_burned_claim_is_spent_and_skipped():
|
||||
from scripts.releases.sequencer import plan
|
||||
from scripts.releases.sequencer import classify_final_release, plan
|
||||
|
||||
assert classify_final_release("v0.21.5", "v0.21.5-rc", None) == ("burned", False)
|
||||
|
||||
steps = plan(_claims(
|
||||
("0.21.5", "burned", False),
|
||||
@@ -119,8 +148,8 @@ def test_explicit_publish_uses_the_same_oldest_first_plan():
|
||||
("0.21.6", "green", False),
|
||||
), head="0.21.4", requested_version="0.21.6") == [
|
||||
{"flip": "0.21.5"},
|
||||
{"flip": "0.21.6"},
|
||||
{"advance": "0.21.5"},
|
||||
{"flip": "0.21.6"},
|
||||
{"advance": "0.21.6"},
|
||||
]
|
||||
|
||||
@@ -150,9 +179,13 @@ def test_reconcile_discovers_custody_flips_then_advances_oldest_first():
|
||||
for index, version in enumerate(("0.21.5", "0.21.6"), start=1):
|
||||
claim_tag, tag = f"v{version}-rc", f"v{version}"
|
||||
claim_object, final_object = str(index) * 40, str(index + 2) * 40
|
||||
claim = {"schema": 1, "version": version, "commit": commit, "autopublish": False}
|
||||
claim = {
|
||||
"schema": 1, "version": version, "commit": commit,
|
||||
"autopublish": False, "claimEpoch": 1_790_000_000 + index,
|
||||
}
|
||||
final = {
|
||||
**claim, "claimTag": claim_tag, "claimTagObject": claim_object,
|
||||
"releaseId": index,
|
||||
"candidateManifestSha256": "a" * 64,
|
||||
"dockerManifestDigest": "sha256:" + "b" * 64,
|
||||
}
|
||||
@@ -177,6 +210,10 @@ def test_reconcile_discovers_custody_flips_then_advances_oldest_first():
|
||||
return tags[argv[-1].removeprefix("refs/tags/")][0]
|
||||
if argv[:3] == ["git", "cat-file", "-t"]:
|
||||
return "tag"
|
||||
if argv[:3] == ["git", "cat-file", "-p"]:
|
||||
epoch = next(message["claimEpoch"] for object_id, _target, message in tags.values()
|
||||
if object_id == argv[3])
|
||||
return f"tagger Fixture <fixture@example.test> {epoch} +0000\n"
|
||||
if argv[:3] == ["git", "tag", "-l"]:
|
||||
return json.dumps(tags[argv[3]][2])
|
||||
if argv[:4] == ["gh", "api", "--paginate", "--slurp"]:
|
||||
@@ -197,6 +234,13 @@ def test_reconcile_discovers_custody_flips_then_advances_oldest_first():
|
||||
|
||||
head = ["0.21.4"]
|
||||
|
||||
releases[0]["id"] = 99
|
||||
assert reconcile(
|
||||
{"GITHUB_REPOSITORY": "example/project"},
|
||||
run=run, read_head=lambda: head[0], advance_head=lambda _record: None,
|
||||
) == []
|
||||
releases[0]["id"] = 1
|
||||
|
||||
def advance(record):
|
||||
events.append(("advance", record["tag"]))
|
||||
head[0] = record["version"]
|
||||
@@ -207,10 +251,10 @@ def test_reconcile_discovers_custody_flips_then_advances_oldest_first():
|
||||
)
|
||||
|
||||
assert steps == [
|
||||
{"flip": "0.21.5"}, {"flip": "0.21.6"},
|
||||
{"advance": "0.21.5"}, {"advance": "0.21.6"},
|
||||
{"flip": "0.21.5"}, {"advance": "0.21.5"},
|
||||
{"flip": "0.21.6"}, {"advance": "0.21.6"},
|
||||
]
|
||||
assert events == [
|
||||
("flip", "v0.21.5"), ("flip", "v0.21.6"),
|
||||
("advance", "v0.21.5"), ("advance", "v0.21.6"),
|
||||
("flip", "v0.21.5"), ("advance", "v0.21.5"),
|
||||
("flip", "v0.21.6"), ("advance", "v0.21.6"),
|
||||
]
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Release-tag policy: new releases use semver, old CalVer tags remain readable."""
|
||||
"""Release-tag policy accepts only the current stable and canary grammars."""
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
@@ -14,10 +14,6 @@ release = importlib.util.module_from_spec(_SPEC)
|
||||
_SPEC.loader.exec_module(release)
|
||||
|
||||
|
||||
def test_release_tag_uses_the_semver_version():
|
||||
assert release.release_tag_for_version("0.20.0") == "v0.20.0"
|
||||
|
||||
|
||||
def test_every_stable_selector_rejects_legacy_calver_tags():
|
||||
"""One shared stable grammar: a CalVer tag (v2026.9.21) must be refused by
|
||||
every stable admission path, or a workflow_call carrying the old GitHub
|
||||
@@ -49,16 +45,14 @@ def release_repo(tmp_path, monkeypatch):
|
||||
return lambda *args: git(tmp_path, *args)
|
||||
|
||||
|
||||
def test_real_tag_order_and_remote_selection(release_repo):
|
||||
def test_canary_tag_order_and_remote_selection(release_repo):
|
||||
git = release_repo
|
||||
assert release.get_last_tag() is None and release.get_last_canary_tag() is None
|
||||
assert release.get_last_canary_tag() is None
|
||||
for tag in ('v2026.7.7', 'v2026.7.20'):
|
||||
git('tag', tag)
|
||||
assert release.get_last_tag() == 'v2026.7.20'
|
||||
for tag in ('v0.9.0', 'v0.20.0', 'v0.19.0', 'v0.20.0+canary.20260818T090000Z',
|
||||
'v0.20.0+canary.20260818T171500Z'):
|
||||
git('tag', tag)
|
||||
assert release.get_last_tag() == 'v0.20.0'
|
||||
assert release.get_last_canary_tag() == 'v0.20.0+canary.20260818T171500Z'
|
||||
with pytest.raises(SystemExit, match='no git remotes'):
|
||||
release.resolve_push_remote(None)
|
||||
|
||||
@@ -54,3 +54,23 @@ def test_published_head_beats_the_seed():
|
||||
|
||||
def test_claim_beats_a_lower_published_head():
|
||||
assert derive_next_version(published="0.21.5", claims=["v0.21.7-rc"], bump="patch") == "0.21.8"
|
||||
|
||||
|
||||
def test_canary_base_comes_from_the_validated_protected_stable_head():
|
||||
from scripts.releases.versioning import published_stable_version
|
||||
|
||||
class Reader:
|
||||
def __init__(self, base, repository):
|
||||
assert base == "https://assets.example"
|
||||
assert repository == "example/hermes-agent"
|
||||
|
||||
def resolve(self, name):
|
||||
assert name == "stable"
|
||||
return type("Resolution", (), {
|
||||
"terminal": {"policy": "stable-release"},
|
||||
"manifest": {"request": {"version": "0.21.7", "commit": "a" * 40}},
|
||||
})()
|
||||
|
||||
assert published_stable_version(
|
||||
"example/hermes-agent", base_url="https://assets.example", reader_type=Reader,
|
||||
) == "0.21.7"
|
||||
@@ -5,6 +5,7 @@ import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@@ -20,18 +21,23 @@ ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
def candidates(tag, commit, digest):
|
||||
packages = []
|
||||
native_version = f"2026.5761.{100 + int(tag.rsplit('.', 1)[1])}.0"
|
||||
second = 100 + int(tag.rsplit('.', 1)[1])
|
||||
release_epoch = int((datetime(2026, 8, 29, 1, 0, tzinfo=timezone.utc)
|
||||
+ timedelta(seconds=second)).timestamp())
|
||||
native_version = f"2026.5761.{second}.0"
|
||||
for platform in ("windows", "macos"):
|
||||
for arch in ("x64", "arm64"):
|
||||
packages.append({
|
||||
"platform": platform, "arch": arch, "tag": tag, "commit": commit,
|
||||
"identity": "test.application",
|
||||
"version": native_version if platform == "windows" else tag[1:],
|
||||
**({"executableVersion": native_version} if platform == "windows" else {}),
|
||||
**({"publisher": "CN=Test", "applicationId": "App"} if platform == "windows" else {"teamId": "ABCDEFGHIJ"}),
|
||||
"artifact": {"sha256": digest,
|
||||
"url": f"{BASE}/releases/tag/{tag}/{arch}" + (".msixbundle" if platform == "windows" else ".zip")},
|
||||
})
|
||||
return {"schema": 2, "tag": tag, "commit": commit, "packages": packages,
|
||||
return {"schema": 2, "tag": tag, "commit": commit, "releaseEpoch": release_epoch,
|
||||
"packages": packages,
|
||||
"smoke_results": {name: {"result": "success"} for name in (
|
||||
"smoke-darwin", "smoke-win32", "smoke-win32-universal")}}
|
||||
|
||||
@@ -68,8 +74,9 @@ def test_transitions_bind_all_arches_identity_version_and_archive():
|
||||
old = candidates("v1.2.3", "a" * 40, "1" * 64)
|
||||
old["schema"] = 1
|
||||
del old["smoke_results"]
|
||||
with pytest.raises(ValueError, match="Legacy candidate"):
|
||||
with pytest.raises(ValueError, match="does not match release identity"):
|
||||
validate_candidates(old, old["tag"], old["commit"], BASE)
|
||||
old = candidates("v1.2.3", "a" * 40, "1" * 64)
|
||||
new = candidates("v1.2.4", "b" * 40, "2" * 64)
|
||||
require_stable_identity(new["tag"], new["commit"])
|
||||
for tag in ("v1.2.4+canary.20260907T143420Z", "v1.2.4-rc"):
|
||||
@@ -197,21 +204,23 @@ def test_claim_object_movement_and_lightweight_tags_fail_closed(tmp_path, monkey
|
||||
def git(argv):
|
||||
if argv[1] == "ls-remote":
|
||||
return f"{claim_object}\t{ref}\n{commit}\t{ref}^{{}}"
|
||||
if argv[1] == "cat-file":
|
||||
if argv[1:3] == ["cat-file", "-t"]:
|
||||
return "tag"
|
||||
if argv[1:3] == ["cat-file", "-p"]:
|
||||
return "tagger Fixture <fixture@example.test> 1790000000 +0000\n"
|
||||
if argv[1] == "rev-parse":
|
||||
return claim_object if argv[-1] == ref else commit
|
||||
if argv[1] == "tag":
|
||||
return json.dumps({
|
||||
"schema": 1, "version": "1.2.3", "commit": commit,
|
||||
"autopublish": False,
|
||||
"autopublish": False, "claimEpoch": 1_790_000_000,
|
||||
})
|
||||
return ""
|
||||
|
||||
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,
|
||||
"autopublish": False,
|
||||
"autopublish": False, "claim_epoch": 1_790_000_000,
|
||||
}
|
||||
with pytest.raises(ValueError, match="moved"):
|
||||
check_claim(env, lambda argv: f"{'c' * 40}\t{ref}\n{commit}\t{ref}^{{}}"
|
||||
@@ -234,9 +243,12 @@ def test_claim_object_movement_and_lightweight_tags_fail_closed(tmp_path, monkey
|
||||
subprocess.run(["git", "remote", "add", "origin", str(remote)], check=True)
|
||||
metadata = json.dumps({
|
||||
"schema": 1, "version": "1.2.3", "commit": actual,
|
||||
"autopublish": False,
|
||||
"autopublish": False, "claimEpoch": 1_790_000_000,
|
||||
}, sort_keys=True, separators=(",", ":"))
|
||||
subprocess.run(["git", "tag", "-a", "v1.2.3-rc", "-m", metadata], check=True)
|
||||
subprocess.run(
|
||||
["git", "tag", "-a", "v1.2.3-rc", "-m", metadata], check=True,
|
||||
env={**os.environ, "GIT_COMMITTER_DATE": "@1790000000 +0000"},
|
||||
)
|
||||
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()})
|
||||
@@ -246,6 +258,7 @@ def test_claim_object_movement_and_lightweight_tags_fail_closed(tmp_path, monkey
|
||||
"v1.2.3", actual, claim,
|
||||
candidate_manifest_sha256="c" * 64,
|
||||
docker_manifest_digest="sha256:" + "d" * 64,
|
||||
release_id=123,
|
||||
)
|
||||
remote_final = subprocess.check_output(
|
||||
["git", "ls-remote", "origin", "refs/tags/v1.2.3", "refs/tags/v1.2.3^{}"],
|
||||
|
||||
@@ -4,6 +4,7 @@ A release used to commit the bumped version back onto the branch. The version
|
||||
now lives in the ref, so stamping is a build step: it rewrites a copy of the
|
||||
tree and returns the paths it touched, and the caller's tree is unchanged.
|
||||
"""
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
@@ -19,11 +20,16 @@ def _tree(root: Path) -> None:
|
||||
desktop.mkdir(parents=True)
|
||||
(desktop / "package.json").write_text('{"version": "0.0.0"}\n', encoding="utf-8")
|
||||
(root / "package-lock.json").write_text(
|
||||
'{"version": "0.0.0", "packages": {"apps/desktop": {"name": "hermes", "version": "0.0.0"}}}\n',
|
||||
'{"version": "0.0.0", "packages": {'
|
||||
'"apps/desktop": {"name": "hermes", "version": "0.0.0"}, '
|
||||
'"apps/bootstrap-installer": {"name": "@hermes/bootstrap-installer", "version": "0.0.0"}}}\n',
|
||||
encoding="utf-8")
|
||||
(root / "uv.lock").write_text(
|
||||
'[[package]]\nname = "hermes-agent"\nversion = "0.0.0"\n'
|
||||
'[[package]]\nname = "other"\nversion = "0.0.0"\n', encoding="utf-8")
|
||||
(root / "nix").mkdir()
|
||||
(root / "nix" / "hermes-agent.nix").write_text(
|
||||
'{\n version ? "0.0.0",\n}: version\n', encoding="utf-8")
|
||||
installer = root / "apps" / "bootstrap-installer" / "src-tauri"
|
||||
installer.mkdir(parents=True)
|
||||
(root / "apps" / "bootstrap-installer" / "package.json").write_text(
|
||||
@@ -35,7 +41,7 @@ def _tree(root: Path) -> None:
|
||||
'[[package]]\nname = "bootstrap-installer"\nversion = "0.21.1"\n', encoding="utf-8")
|
||||
|
||||
|
||||
def test_stamping_writes_the_build_tree_and_leaves_the_source_tree(tmp_path):
|
||||
def test_stamping_writes_the_build_tree_and_leaves_the_source_tree(tmp_path, monkeypatch):
|
||||
from scripts.releases.stamping import stamp
|
||||
|
||||
source = tmp_path / "source"
|
||||
@@ -50,18 +56,38 @@ 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 '__release_date__ = "2026.9.22"' in init
|
||||
assert '__version__ = "0.21.5"' in (build / "hermes_cli" / "_version.py").read_text(encoding="utf-8")
|
||||
spec = importlib.util.spec_from_file_location("stamped_version", build / "hermes_cli" / "_version.py")
|
||||
assert spec is not None and spec.loader is not None
|
||||
stamped_version = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(stamped_version)
|
||||
assert stamped_version.__version__ == "0.21.5"
|
||||
assert json.loads((build / "apps" / "desktop" / "package.json").read_text())["version"] == "0.21.5"
|
||||
# The lockfile root stays; only the desktop workspace entry moves.
|
||||
# The lockfile root stays; stamped workspace mirrors move with their manifests.
|
||||
lock = json.loads((build / "package-lock.json").read_text())
|
||||
assert lock["version"] == "0.0.0"
|
||||
assert lock["packages"]["apps/desktop"]["version"] == "0.21.5"
|
||||
assert lock["packages"]["apps/bootstrap-installer"]["version"] == "0.21.5"
|
||||
# uv.lock records the root package once, and no other package moves with it.
|
||||
uv = (build / "uv.lock").read_text(encoding="utf-8")
|
||||
assert uv.count('version = "0.21.5"') == 1
|
||||
assert 'name = "hermes-agent"\nversion = "0.21.5"' in uv
|
||||
assert 'version ? "0.21.5"' in (build / "nix" / "hermes-agent.nix").read_text()
|
||||
cargo_lock = (build / "apps" / "bootstrap-installer" / "src-tauri" / "Cargo.lock").read_text()
|
||||
assert 'version = "0.21.5"' in cargo_lock
|
||||
from scripts import write_install_stamp
|
||||
monkeypatch.setattr(write_install_stamp, "_REPO_ROOT", build)
|
||||
assert write_install_stamp._parse_release_metadata() == ("0.21.5", "2026.9.22")
|
||||
stamp_path = build / "install-stamp.json"
|
||||
stamp = write_install_stamp.write_stamp(
|
||||
stamp_path, update_mechanism="external", distribution="docker",
|
||||
commit="a" * 40, branch="main", dirty=False, commit_date=1, distance=0,
|
||||
)
|
||||
assert stamp["baseVersion"] == "0.21.5"
|
||||
assert json.loads(stamp_path.read_text(encoding="utf-8"))["baseVersion"] == "0.21.5"
|
||||
assert all(path.is_relative_to(build) for path in written)
|
||||
|
||||
tauri = build / "apps" / "bootstrap-installer" / "src-tauri" / "tauri.conf.json"
|
||||
tauri.write_text('{"version": "0.0.0"}\n', encoding="utf-8")
|
||||
from scripts.releases.stamping import validate_bootstrap_version
|
||||
with pytest.raises(ValueError, match="Tauri config"):
|
||||
validate_bootstrap_version(build, "0.21.5")
|
||||
@@ -15,7 +15,9 @@ files on `main`.
|
||||
the published release family seeded at `0.21.4` and every spent claim.
|
||||
2. Push an annotated `vMAJOR.MINOR.PATCH-rc` claim atomically, create one
|
||||
non-prerelease GitHub draft for it, and dispatch `Stable Release` on that exact
|
||||
claim ref. The claim message binds its commit and autopublish policy.
|
||||
claim ref. The claim message binds its commit, autopublish policy, and one
|
||||
monotonically allocated epoch. That epoch is the release date and native
|
||||
packaging clock for every matrix leg and retry.
|
||||
3. Admit the exact remote annotated tag-object SHA, peeled commit, `GITHUB_REF`,
|
||||
`GITHUB_SHA`, checked-out `HEAD`, and ancestry on `origin/main`.
|
||||
4. Run the whole source, Docker, Nix, PM bundle, install/update, Termux, Windows,
|
||||
@@ -54,6 +56,11 @@ Start from an exact commit already on remote `main`:
|
||||
python scripts/release.py release --commit "$(git rev-parse origin/main)" --bump patch --remote origin
|
||||
```
|
||||
|
||||
`--bump` defaults to `patch`; pass `minor` or `major` only when that change is
|
||||
intentional. The selected commit must descend from or equal the highest claim's
|
||||
source. Equality lets a burned release be superseded without an unrelated code
|
||||
change; an ancestor or unrelated sibling is refused.
|
||||
|
||||
Add `--autopublish` to publish immediately when the claim becomes the oldest
|
||||
green release. Without it, the release stays a draft until an explicit publish
|
||||
or a later green claim forces ordered resolution.
|
||||
@@ -70,18 +77,28 @@ python scripts/release.py abandon --version 0.21.5 --remote origin
|
||||
`publish` performs a synchronous supersession preflight, then dispatches the same
|
||||
ordered controller used by automatic recovery. It refuses a known burned version
|
||||
below a newer published release. `abandon` deletes only the draft; it deliberately
|
||||
keeps the `-rc` tag so derivation cannot reuse that version.
|
||||
keeps the `-rc` and any final receipt so derivation cannot reuse that version.
|
||||
The sequencer derives that missing-draft state as burned rather than recording a
|
||||
separate abandonment flag.
|
||||
|
||||
Do not manually dispatch `Stable Release` from a final tag. Recovery keeps the
|
||||
original claim ref, object SHA, commit, draft database ID, and autopublish policy.
|
||||
|
||||
## Failure and recovery
|
||||
|
||||
A failed stable run waits 15 minutes, then reruns only failed jobs in the same
|
||||
GitHub Actions run. At most two retries are admitted (run attempts 2 and 3).
|
||||
A failed stable run becomes retry-eligible after 15 minutes, then reruns only
|
||||
failed jobs in the same GitHub Actions run. The quarter-hour reconciler applies
|
||||
only the oldest unresolved eligible retry, because GitHub keeps only one pending
|
||||
run in the shared stable-release concurrency group. It does not occupy a runner
|
||||
during the backoff. At most two retries are admitted (run attempts 2 and 3).
|
||||
After attempt 3 fails, the claim is burned and the sequencer may resolve later
|
||||
claims. The hourly publication controller recovers a lost retry request or a
|
||||
crash between any publication mutations.
|
||||
claims. The same controller recovers a lost retry request or a crash between any
|
||||
publication mutations.
|
||||
|
||||
A newly pushed claim with no observed workflow run remains unresolved for one
|
||||
hour. This grace window covers the non-atomic draft and dispatch steps. After the
|
||||
hour, a still-unstarted claim is derived as burned. No claim is burned during the
|
||||
normal creation window.
|
||||
|
||||
Desktop and Termux handoffs live in the immutable R2 tag archive. Each producer
|
||||
writes a `handoff-<target>.json` receipt containing the tag, commit, paths, sizes,
|
||||
@@ -97,6 +114,19 @@ Immutable uploads accept an existing object only when its bytes match. If an
|
||||
archive object, final-tag digest, versioned Docker tag, or read-back differs, stop
|
||||
recovery rather than replacing the accepted candidate.
|
||||
|
||||
The candidate manifest binds the admitted claim epoch. Admission recomputes the
|
||||
stable Windows quad from that epoch and rejects a merely well-formed but incorrect
|
||||
MSIX, executable VERSIONINFO, or App Installer version. Native admission also
|
||||
reads the Electron artifact filename and macOS plist from the built packages. The
|
||||
acceptance graph stamps an isolated bootstrap-installer tree, asks Cargo to read
|
||||
the resulting Tauri package version, builds and inspects Python wheel/sdist
|
||||
metadata and filenames, and checks the Nix and Docker runtime identities. Any
|
||||
consumer-facing `0.0.0` or mismatched version fails the gate.
|
||||
|
||||
The annotated final tag binds the GitHub release database ID admitted with the
|
||||
claim. Deleting that release burns the transaction: a replacement draft with the
|
||||
same tag name cannot be retargeted or published by reconciliation.
|
||||
|
||||
The desktop workflow's optional `termux_upgrade_from_tag` input names an exact
|
||||
published release with a Termux R2 handoff. Explicit non-publishing desktop builds
|
||||
retain no downloadable job artifacts.
|
||||
@@ -114,8 +144,16 @@ Receipt tags do not create GitHub releases and do not trigger workflows.
|
||||
Canary source identity is only
|
||||
`v<stable>+canary.<YYYYMMDDTHHMMSSZ>`. Build metadata intentionally makes it
|
||||
SemVer-equal to its stable core; the protected channel record and embedded UTC
|
||||
timestamp decide progression. Every `main` push queues a canary run with
|
||||
timestamp decide progression. Desktop clients treat that validated channel
|
||||
sequence as the update authority rather than asking SemVer to order build
|
||||
metadata. Every `main` push queues a canary run with
|
||||
`cancel-in-progress: false`. Historical `-canary.` identities are unsupported.
|
||||
If a process stops after pushing the canary tag, rerunning the command verifies
|
||||
the exact remote tag object, repairs the missing draft, and redispatches until the
|
||||
protected canary head receipts that tag.
|
||||
The protected publication controller verifies that exact annotated tag object
|
||||
before it flips the GitHub prerelease from draft to public, reads both states back,
|
||||
and only then advances the R2 head.
|
||||
|
||||
## Canary and one-off desktop identities
|
||||
|
||||
@@ -235,12 +273,15 @@ provenance. The next run combines those records with its candidate manifest
|
||||
and uses the existing native bundled-update drivers.
|
||||
|
||||
For an existing stable release that predates this metadata, supply
|
||||
`baseline-manifest` as an HTTPS URL on the configured R2 public origin to an
|
||||
equivalent manifest of its actual published packages. Manifest redirects must
|
||||
stay on the same origin. The baseline tag must be a published stable release,
|
||||
package identities must agree, and versions must increase. Stable Windows
|
||||
packages use electron-builder's `storePackageVersionAt` timestamp policy
|
||||
(`year.hourOfYear.secondOfHour.0`), independently of the SemVer payload tag.
|
||||
`baseline-manifest` as an HTTPS URL on the configured R2 public origin to a
|
||||
current schema-2 manifest of its actual published packages and successful native
|
||||
smoke results. Schema-1 candidates are rejected; there is no legacy admission
|
||||
path. Manifest redirects must stay on the same origin. The baseline tag must be
|
||||
a published stable release, package identities must agree, and versions must
|
||||
increase. Stable Windows
|
||||
packages use electron-builder's `storePackageVersionAt` policy over the admitted
|
||||
claim tag object's immutable tagger timestamp (`year.hourOfYear.secondOfHour.0`),
|
||||
independently of the SemVer payload tag.
|
||||
Missing baseline
|
||||
artifacts are a blocker, not permission to fabricate or skip acceptance.
|
||||
See [the bundled update contract](https://github.com/NousResearch/hermes-agent/blob/main/tests/install/BUNDLED_UPDATES.md).
|
||||
|
||||
Reference in new issue
Block a user