fix(release): close publication custody gaps

This commit is contained in:
ethernet committed 2026-09-22 02:19:38 -04:00
1 parent 8e57bb6c37
commit dcd678d6fd
61 files changed
+7790 -7365

No files matched your search

+3
View File
@@ -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
+25 -12
View File
@@ -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 }}
+50 -15
View File
@@ -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
+24 -1
View File
@@ -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
+6
View File
@@ -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
+68 -7
View File
@@ -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 }}
+3 -1
View File
@@ -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
+6
View File
@@ -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
+16 -1
View File
@@ -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')
+7 -1
View File
@@ -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()
-1
View File
@@ -49,4 +49,3 @@ def _ensure_utf8():
_ensure_utf8()
__release_rev_count__ = 40643
+7 -19
View File
@@ -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;
-2
View File
@@ -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
+1 -1
View File
@@ -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",
+3 -3
View File
@@ -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)
+4
View File
@@ -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)
+23 -1
View File
@@ -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)}
+9 -2
View File
@@ -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 = [
+34 -8
View File
@@ -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
View File
@@ -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
View File
@@ -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__":
-3
View File
@@ -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(
+1 -1
View File
@@ -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)
+48 -13
View File
@@ -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
+61 -35
View File
@@ -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))
+3 -5
View File
@@ -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
+71 -44
View File
@@ -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
View File
@@ -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)
+41 -3
View File
@@ -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__":
+44
View File
@@ -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.
+3 -1
View File
@@ -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())')"
+26 -8
View File
@@ -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
+3 -3
View File
@@ -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
-1
View File
@@ -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)
+23 -9
View File
@@ -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
+75 -5
View File
@@ -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()
+5 -4
View File
@@ -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"
+22 -1
View File
@@ -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
+46 -15
View File
@@ -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"
+45 -4
View File
@@ -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):
+3 -3
View File
@@ -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):
+27 -8
View File
@@ -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"
+51 -15
View File
@@ -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)
+1 -1
View File
@@ -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"
+36 -49
View File
@@ -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"
+55 -11
View File
@@ -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"),
]
+3 -9
View File
@@ -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"
+21 -8
View File
@@ -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^{}"],
+32 -6
View File
@@ -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")
Generated
+6371 -6371
View File
File diff suppressed because it is too large. Load diff
+54 -13
View File
@@ -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).