GitHub's generate-notes lists merged pull requests since the last
published release. A fork merges none, so the stable draft body was only
a "Full Changelog" link, and it lost the HERMES_BUILDS_TABLE marker that
the stable workflow renders the download tables into.
The cut now builds the body with generate_changelog() over the commits
from the published stable commit (or the seed version's tag before the
first publication) to the cut commit.
The stable cut already creates the draft on the claim ref before it
dispatches the gate, but the output pointed at releases/tag/<claim> and
then at releases/tag/v<version> "when it is green". GitHub serves a draft
only at an untagged-* URL, so neither link showed it. Operators read that
as "the draft appears after the build".
Take the URL gh release create prints (the release's html_url) and print
it up front, with a note that notes edited during the build survive:
edit_draft_release keeps the body and strips only the warning fence. The
v<version> URL stays, labelled as where the release lives once published.
The canary resume path had the same broken releases/tag/<tag> link; it
now uses the create output or the url field of gh release view.
Conflicts:
- scripts/releases/stamping.py, tests/scripts/test_version_stamping.py:
took ethie/pm-clean. The release branch's side was only its base's copy
of "stamping a payload snapshot skips the bootstrap-installer check"
(8411fdb333, same patch-id as 8d34601f47 here); the install-stamp
refactor c13ea774e6 supersedes the rest.
- tests/ci/test_stable_release_graph.py: kept pm-clean's release-epoch
contract (no HERMES_RELEASE_EPOCH on termux-deb, version on docker and
nix only) and the release branch's per-group receipt wiring.
Semantic conflict: the dispatch log step (6e64e961d8) read
inputs.termux_only, which the jobs input replaced. It reads JOBS now, and a
dispatch that selects only some groups has no release.py replay, as a
termux-only one had none before.
The publication pass checks the held Store submission and prints the
Publish now step; it cannot release it. The developer guide and the draft
warning said the pass released it.
The Partner Center submission API has no call that releases a held
(Manual) submission, and update refuses a committed one, so the release
step's PUT and re-commit could not work. The publication pass now only
reads the submission: a certified one prints a GitHub warning to click
Publish now in Partner Center, one still in certification says what comes
next, a live one is a no-op, and a failed one leaves the run red.
It finds the held submission through pendingApplicationSubmission on the
app, instead of posting a probe submission and scraping an id out of the
409 error. The HTTP runner also sends the request headers it is given:
before, every API call after the token went out without Authorization.
transitions splits into one job per receipt (darwin-arm64, darwin-x64,
win32-bundle), and each packaged install job waits only on its own. The
Mac install arms no longer wait for the other arch or for the smokes.
candidate-manifest moves into stable-release.yml and waits for every
candidate call, so it still runs after every smoke (decision 23). The
smoke results it records are the calls' own results, mapped to the smoke
job names the final manifest requires. publish-bundles and complete read
its digest again, which the per-group split had left unset.
read_manifest resolves its opener per call instead of binding
urllib.request.urlopen as an import-time default, so the process trust
setup applies. The receipt fixtures gain the runner's RUNNER_TEMP and the
baseline's macOS identity.
Upstream carries only CalVer tags, which version_from_tag rejects on
purpose, so every PR image build died in "Write install stamp" with
"no reachable release tag". The runtime already reads a tagless source
checkout as base "unknown" plus its commit; the image now records the
same: the workflow admits GITHUB_SHA via --commit, adds version args only
when a release is reachable, and write_install_stamp accepts a missing
base version when the caller supplied the commit (a local tree still
stays unstamped).
Stable now calls the desktop bundle workflow once per build group, and
each Mac arch and the Windows bundle assembly stage a <group>-receipt.json
into its attempt archive before the group's smoke runs. The receipts let
the next commit start each install arm from its own group's bytes.
publish-bundles and complete temporarily lose their candidate-manifest
digest source; the next commit moves that job into stable-release.yml and
wires the digest back.
builds-pending ran whenever termux_only was false. The jobs input turned
that into 'termux is selected', so a desktop-only run skipped the pending
page and a termux-only run wrote one that builds-table never finalizes.
Admission now emits all-jobs from the same parser, and builds-pending,
builds-table and commit-builds-summary gate on it.
termux_only is replaced by jobs=termux. smoke-win32-universal is removed: the per-arch MSIX smokes cover each arch, and stable's install arms install the msixbundle on both arches.
validate_receipt no longer requires smoke results: receipts are staged right after the bytes and before the smokes run (decision 11), so only the final manifest (validate_candidates) still requires every SMOKE_JOBS result.
The green run no longer lets the Store go live on its own: stable-store
deletes any in-flight submission, uploads the verified msixbundle as a
draft (msstore publish --noCommit), sets targetPublishMode to Manual via
msstore submission get/update, and commits with msstore submission
publish. The publication pass releases it from sequencer production_advance
(after the feeds and Docker aliases move) through the Partner Center
submission REST API with the same MS_STORE_* credentials: it finds the
in-flight submission, and rewrites it with targetPublishMode Immediate and
commits, so a certified (status Release) submission goes live now and one
still in certification goes live when certification passes. Both calls act
on the submission's current state and are safe to rerun; a failed Store
call leaves the run red. The Store product id is optional in the
publication environment, so runs without Store credentials skip the step.
Each install arm starts from its own receipt, so stable must accept a
manifest that covers only one arch or the Windows bundle. The per-row
checks move into one helper shared by validate_candidates and the new
validate_receipt, and the per-target transition logic into one helper
shared by plan_transitions and plan_receipt_transitions. The full
manifest still has to cover every desktop target.
Stable archives live under the attempt ref, but channel records only
carried releaseTag, so the protected prefix check rejected the bytes the
pipeline actually writes. An optional archiveRef on the request names the
attempt archive; validators and readers use it for the releases/tag/
prefix and fall back to releaseTag, which fails closed for stable
records without it. Canary records never write the field.
gh api --field reads a value that starts with @ as a file and converts
true, false and integers. Generated notes can open with an @mention, so
the body goes through --raw-field like the other string fields.
Nobody should publish a stable release from the GitHub UI, and once
immutable releases land that mistake is permanent: the release stays on
the attempt ref with no receipt tag, no feed move, and no alias move.
The draft now carries a fenced caution above and below the generated
notes, and publish strips both fences before the release goes public,
refusing an unbalanced fence. The notes are fetched from the API and
written to a file because --generate-notes cannot place text below the
notes.
The final tag is the custody receipt of one publication pass: publish
hashes the candidate manifest from the attempt archive (nothing records
the digest earlier), resolves the image digest from the attempt-ref tag,
and binds both with the claim and archive path into v{version}. The
draft is retargeted onto the receipt tag and stripped while it is still
a draft, and only then does a final, separate call make it public —
immutable releases take no edits afterwards. The needs_retarget recovery
reruns exactly that draft edit; a public release can never be repaired.
The stable/latest Docker aliases move onto the attempt's image in the
same pass as the feed pointers, and no bytes are copied to a v-tag path.
The sequencer and the release entrypoint each carried their own copy of
what makes an attempt outstanding, and discover still read the old
vX.Y.Z-rc claim shape. One pure predicate in versioning now answers it
for both, and the sequencer lists receipt tags, attempt refs, and
abandon markers, filtering each through its parser. An attempt is
burned by its marker, published by its final tag, and green only with
its draft and a succeeded workflow; more than one outstanding attempt
across all versions is refused.
Runtime identity resolved through hermes_cli.__version__ (a static 0.0.0
on source installs, rewritten by release stamping) leaked v0.0.0 into
About, /api/health, User-Agents, and plugin compat, and source updates
showed "couldn't reach update server" because identity and channel
authority disagreed with the checkout.
Now: get_version_info() resolves install stamp -> live git -> unknown,
never pyproject metadata, never a package constant. Source checkouts
derive identity from their reachable release tag; the completion tail of
every successful install/update/historical takeover atomically rewrites
install-stamp.json with that identity; a stale source stamp whose commit
no longer matches HEAD defers to live git. ACP/TUI use derived_version
for display and base_version for protocol fields; all ~44 runtime
__version__ consumers migrated; hermes_cli.__version__ and generated
_version.py are gone; release stamping only touches the native manifests
external builders consume (nix/tauri/cargo) and passes release identity
straight into write_install_stamp.py; pyproject.toml stays inert 0.0.0.
Desktop no longer synthesizes a competing install-stamp.json: the
checkout owns its stamp, and desktop-bootstrap classification keys on
the bootstrap-complete marker. verify-bootstrap-version-stamp.py now
cross-checks the checkout's stamp (baseVersion + commit == HEAD).
Validation: 31-file focused suite green (version identity, stamping,
adoption, providers, gateway, acp/tui runtime identity, api server via
extras env, release graph); desktop tsc + 25 vitest green; real-repo
probe: base=unknown derived=git.0635606.dirty source=git on this
checkout; clean-env imports resolve entirely from this tree; windows
footgun + compat-pointer scans clean.
The archive readers already keyed every object on releases/tag/<attempt>;
the writers still keyed them on the plain payload tag, so a stable run
wrote releases/tag/vX.Y.Z/ while publish read releases/tag/rc.N-vX.Y.Z/
and found nothing. Derive one archive ref at admission (validate emits
archive-tag, jobs export HERMES_ARCHIVE_TAG) and use it at every writer
and reader of a releases/tag/<x>/ key in the stable path. The plain
vX.Y.Z keeps naming the payload identity: build stamps, package
versions, feed versions, and GitHub release bodies. Canary and channel
builds keep archive == payload tag, so their keys are unchanged.
desktop preparation parses its claim ref and carries the attempt ref as
archive_tag beside the plain payload version. Stable admission accepts
only attempt refs and returns them; accepted candidates, bootstrap and
advance_stable read the attempt-scoped archive, and docker manifests may
carry the attempt-ref image tag while stable/latest aliases stay put
until publish.
The final tag is a publication custody receipt, so complete() only
validates the accepted candidate archive and the draft stays on the
attempt ref. A succeeded run with its draft is green; a missing draft
after success is an error, not an inferred abandonment.
The payload snapshot omits apps/ (INERT_SNAPSHOT_DIRS), so validate_bootstrap_version
raised FileNotFoundError on every native bundle leg since dd72571178.
(cherry picked from commit 8411fdb333)
Admission parses the attempt ref for the version and the attempt, and the
claim metadata must name the same attempt. release has written "attempt"
into every claim since the attempt refs landed; without this, admission
would refuse each of them for an unexpected key.
The attempt stays out of the workflow outputs: emit writes an explicit key
list and no job reads it.
abandon clears the outstanding attempt of a version by pushing
abandoned-rc.<N>-vX.Y.Z at the attempt's commit. The attempt ref stays, and
the next cut is rc.<N+1> of the same version.
The draft is deleted before the marker is pushed: a cleared attempt with a
live draft could still be published by hand, while a draftless outstanding
attempt is only abandoned again. abandon reads every outstanding attempt, so
it can still clear one when a concurrent cut left two. It refuses a release
that was already published.
A version is now spent only by publication. derive_next_version reads the
published stable head alone, and release claims the next attempt of that
version as rc.<N>-vX.Y.Z. An abandon marker clears an attempt without
freeing its number.
At most one attempt, of any version, is outstanding: an attempt ref with no
marker and no final tag on the remote. release refuses while one exists and
prints its workflow run, the abandon command, and the rerun command. The
pre-check and the push are not atomic across versions, so release re-reads
the attempts after its push and stops before the draft and the dispatch if a
concurrent cut of another version landed.
A new attempt must descend from the published stable head, read from the
stable channel with its version. Abandoned attempts put no constraint on it.
Fetch refspecs are rc.* and abandoned-rc.*: a refspec may hold one '*', and
the parsers filter what the globs over-match.
An attempt ref is rc.<N>-vX.Y.Z and its abandon marker is
abandoned-rc.<N>-vX.Y.Z. The ref grammar only anchors: version_from_tag
still owns the version shape, so CalVer and canary identities stay out.
The payload snapshot omits apps/ (INERT_SNAPSHOT_DIRS), so validate_bootstrap_version
raised FileNotFoundError on every native bundle leg since dd72571178.
release printed the claim URL and stopped. The other commands printed a
record or a one-line status. Each command now says what happened, what to
wait for, and the next action.
release names the claim, the workflow run, the notes page, and the publish
command when autopublish is off. publish and canary name their workflow.
abandon says the version is spent. A commit build names its page and says
it moves no channel.
main carries 0.0.0, so comparing the tag against pyproject.toml refuses
every release. Admission reads the version from the -rc claim and checks
only that the commit is on main.
The committed version is a placeholder now; a build stamps the real one.
Dev installs report their distance from the highest reachable release tag,
so an -rc claim sitting on the same commit never becomes the version.
The version lives in the ref now, so the mirrors writer takes a tree and
rewrites a copy. The checkout it was invoked from stays at the placeholder,
and the bootstrap installer's Cargo.lock finally moves with the rest.
`release.py release --commit --bump` derives the next version, pushes an
annotated -rc tag as exactly that ref, and treats a dispatch that never
starts as an error. A commit behind an outstanding claim is refused, and
abandon deletes the draft while the claim tag stays as the burn mark.
The family is the published stable head, then outstanding -rc claims, then
the seed 0.21.4 when both are empty, so the first release is 0.21.5. CalVer
tags are excluded rather than sorted: v2026.9.21 is a valid three-component
version and would win every max(). A +canary identity compares equal to its
stable; the legacy -canary prerelease still sorts before its stable so feeds
published before the migration keep their order.
scripts/releases/semver.STABLE_TAG accepted any-width majors, so
is_valid_version('2026.9.21') was True and docker.require_stable_tag /
stable.py / release.py admitted the legacy CalVer tags that
hermes_cli.source_releases and get_last_tag() already refused. A
workflow_call carrying GitHub's current 'latest' (v2026.9.21) would have
passed the docker publish gate.
hermes_cli.update_channel already owns the canary tag shape; it now owns
STABLE_TAG_RE too (three-digit major cap, no leading zeros, no suffix)
and every stable selector imports it. release.py drops its private
_SEMVER_TAG_RE + CalVer exclusion pair, which the capped major makes
redundant.
Five in-tree copies of the canary tag regex disagreed: darwin.py demanded 14 digits,
release_channels/r2 prune accepted any/8, the rest 8-or-14. r2.canary_doomed_keys
compared an 8-digit cutoff against the first 8 chars of a captured \d{8}, so a 14-digit
key matched on its date prefix by accident of regex greediness; it now slices the
suffix explicitly like scripts/release.py does. scripts/termux/deb_version.py keeps
its copy on purpose (runs under bare python3 in a workflow shell) and its test pins
it to the canonical one.
The local --channel command no longer touches R2: it resolves the exact
pushed commit and dispatches the default-branch workflow, whose privileged
allocation step creates the channel and mints the immutable build request.
The disposable allocation path is generalized to cover the unscoped
production preview, gated by a new `channel` workflow input; build legs
consume the same channel-build / channel-request-sha256 job outputs as
before. The anti-tamper gate is now commit_build.admit (maintainer
permission) running inside the allocate step.
Drop the resume path: --resume-channel-build / --request-sha256 and
resume_build() are gone, and allocate_protected no longer recovers a lost
request PUT by sequence. Retrying re-dispatches and mints a fresh sequence
slot; idempotency survives via the deterministic build ID and the existing
immutable-request dedup. Keep the preview allocate `lastAllocation` build-ID
field (concurrent-CAS uniqueness), which is not resume.
channel_public_base now defaults to the documented production origin like
the commit-build path, so a local command names its page without a
hand-set CLOUDFLARE_R2_PUBLIC_URL.
Also drops a stale fork-isolation assertion left behind by the
fork-conditional dispatch removal.
Repository identity no longer selects behavior: a commit build is the
same direct dispatch from any repository, and R2 disposable scoping is
opt-in via R2_DISPOSABLE_RUN rather than fork-mandated. The fork guard
in the workflow admission step, the fork refusal in R2Scope.configured,
and the client-side fork routing (disposable_dispatch_command) all go.
Disposable namespaces keep their own protections: malformed leases are
refused, and a scoped namespace still belongs to exactly one repository.
One-dispatch runs export HERMES_BUILD_COMMIT to every build leg, and
handoff's argparse defaults pick it up even when the leg names only
--channel-request — so the macOS stage refused the allocation's own
commit as 'another commit'. Match the admit() equality rule: only a
commit different from request[commit] (or any tag) is an override.
The override guard rejected BUILD_COMMIT and BUNDLE_ENV_JSON outright —
correct for the old two-dispatch flow, where the build run carried neither
input. In the one-dispatch flow the validation job inherits the allocation
dispatch's own inputs, so admission refused the exact values the allocation
had pinned and every disposable build died at "Channel requests cannot
select one-off, release, Termux or bundle overrides".
The request is already digest-pinned when admit() sees it, so the
invariant becomes equality: BUILD_COMMIT must match request["commit"] and
BUNDLE_ENV_JSON must parse to exactly request["bundleEnv"]. Any other
value still refuses, with the clearer message that it differs from the
dispatch that allocated this build. Release/tag/Termux overrides keep the
absolute refusal.
A fork commit build is now ONE workflow dispatch whose run both allocates
the disposable channel and builds it. Previously allocation printed a
follow-up command that had to be dispatched separately (the GITHUB_TOKEN
recursion wall forced two runs; release.py grew poll/extract machinery to
automate the hop — all deleted now, net -144 lines).
- Lease is the run id alone (no attempt suffix): re-run failed jobs
re-enters the same namespace; succeeded allocate job is skipped and its
outputs persist. r2_scope accepts legacy <id>-<attempt> leases on read.
- allocate-disposable emits job outputs (channel_build, request digest,
lease, public base); validate consumes them in disposable mode and
re-exports a normalized pin every downstream job reads.
- Admission guards unchanged in semantics: forks still cannot run without
a disposable allocation; disposable runs still never touch production
feeds, termux, or the commit-builds page.
- Upstream trusted-controller path (channel_build inputs) byte-identical.
- Fixtures updated for run-id leases; two dead two-dispatch tests and the
fixture's allocation-probe plumbing removed; smoke matrix test skips
banana's new admission job (no toolchain by design).
Fork CI now requires a disposable channel allocation (R2_DISPOSABLE_RUN
guard in desktop-bundled-release.yml), but 'release.py --build-commit REV
--publish' still fired the old direct dispatch, so every fork commit build
died at admission. cmd_build_commit now detects a non-upstream repository
(case-insensitive NousResearch/hermes-agent compare), dispatches the
allocation workflow with disposable_channel/build_commit/bundle_env baked
in (all --bundle-env/--bundle-unset values travel inside the immutable
request; the follow-up never re-passes them), polls the allocation run to
completion (15s interval, 15min budget), extracts the printed follow-up
dispatch from the run logs (channel_disposable's single-line JSON
'command'), validates its shape (gh workflow run of this workflow against
the same repository), and auto-dispatches it with the local maintainer's
gh login — falling back to a clear run-summary pointer when log recovery
fails. Upstream behavior is unchanged. Also fixes a pre-existing TypeError
that masked check_output failures whose CalledProcessError has stderr=None.