# Stable release admission and promotion

`Stable Release` is the release gate. A successful builder alone is not a
stable release. Stable releases use attempt refs as locks and final tags as
publish receipts; neither tag is a workflow trigger. Canary builds have their
own scheduled workflow.

The committed project version is always `0.0.0`. Release jobs derive the payload
version from the admitted ref and stamp isolated build trees. Do not bump version
files on `main`.

## Order

1. Refresh `origin/main` and the remote attempt and marker refs (`rc.*` and
   `abandoned-rc.*`). Derive the next SemVer from the published release family
   seeded at `0.21.5` (the last legacy CalVer release, v2026.9.24) alone: the newer of the protected R2 stable head and the
   newest published non-prerelease GitHub release with a `vX.Y.Z` tag. A
   release that builds no desktop packages moves only the second. Attempts do not move the
   version line. A cut whose next version already has a final `vX.Y.Z` tag is
   refused until that publication finishes.
2. Push an annotated `rc.<N>-vX.Y.Z` attempt ref atomically, create one
   non-prerelease GitHub draft on it, and dispatch `Stable Release` from the
   default branch with that ref as its `tag` input. The attempt number comes from the existing attempt refs of that version.
   The claim message binds its commit, attempt number, autopublish policy,
   lane set (see [Lanes](#lanes)), and
   one monotonically allocated epoch. That epoch is the release date and native
   packaging clock for every matrix leg and retry. One outstanding attempt, of
   any version, blocks a new `release`.
3. Admit the exact remote annotated tag-object SHA, its peeled commit, and that
   commit's ancestry on `origin/main`. The run itself must be the default-branch
   dispatch of `Stable Release`. Its workflows and release scripts come from
   `main`, so its Actions caches share one scope across attempts. `GITHUB_SHA` is
   `main`'s tip, never the commit under test. Every lane that tests or builds the
   payload is passed the admitted commit, and the commit-threading lint in
   `tests/ci/test_stable_release_commit_threading.py` enforces that.
4. Run the lanes the claim records: by default the whole source, Docker, Nix,
   PM bundle, install/update, Windows, signed-package, and native-upgrade
   acceptance graph, without macOS Intel and Termux.
5. Push immutable versioned Docker and R2 artifacts from the tested bytes. The
   Docker image is pushed as soon as its own tests pass, tagged by the attempt
   ref. Do not move `stable` or `latest` aliases yet and do not rebuild for
   publication.
6. Create the annotated final `vMAJOR.MINOR.PATCH` receipt at publish, not at
   green. It binds the winning attempt's ref, object, commit, archive prefix,
   candidate-manifest SHA256 (`null` when the claim skipped bundles), Docker
   manifest digest, and autopublish policy.
7. The stable publication controller resolves releases oldest first. It creates
   `vX.Y.Z`, retargets the still-draft release onto it, strips the warning
   blocks, makes the release public as the last call, then verifies and promotes
   the receipt-bound Docker digest and advances App Installer, macOS, APT,
   downloads-page, and protected R2 heads.

A sole green draft waits unless its claim selected autopublish. A later green
claim flushes all contiguous earlier green drafts in order. An older running claim
blocks newer publication. A burned attempt is skipped, and its version is cut
again after `abandon`. Failed, cancelled, missing, and unexpectedly skipped
requirements remain red.

Docker Hub, R2, APT, GitHub, and the Store do not support one cross-service
transaction. The controller is therefore idempotent: after a partial failure,
run it again and let every mutation verify its current state before continuing.
The final tag is the custody receipt. Never rebuild or replace accepted bytes to
repair a pointer.

Promotion replaces the stable downloads page at `releases/stable/index.html` on
the R2 public origin. Canary tag builds own `releases/canary/index.html`, and
commit builds own `releases/commit/<sha>/index.html`. Pages list only staged,
receipt-backed objects; an older run cannot regress a protected channel.

## Run, publish, or abandon a stable release

Start from an exact commit already on remote `main`:

```sh
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 the newest published
`vX.Y.Z`. With no published tag, any commit already on remote `main` is
accepted. An abandoned attempt puts no constraint on the next cut; it was often
abandoned because its commit is bad. `release` also refuses while any attempt,
of any version, is outstanding. The refusal prints the attempt's workflow run
URL, the `abandon` command, and the rerun command before it raises. In an
interactive terminal it then asks `Abandon <ref> and cut a new attempt? [y/N]`;
`y` abandons the attempt and cuts the next one, and the default is no. Anywhere
stdin or stdout is not a terminal it raises without asking.

The attempt ref is a public lock, not a SemVer prerelease. It reads
`rc.1-v0.21.5`, not `v0.21.5-rc.1`. The package version stays plain `0.21.5`.
`parse_attempt_ref` in `scripts/releases/versioning.py` is the one grammar for
the shape.

The draft body carries a fenced warning block at the top and at the bottom: do
not publish the release from the GitHub UI. Publishing it by hand skips the
`vX.Y.Z` receipt tag, the update feeds, the Docker aliases, and the Store
check. Published releases are immutable, so a hand-published release cannot
be fixed afterwards, and it blocks the pipeline: the attempt then has neither a
marker ref nor a final tag, so `release` refuses it as outstanding and
`abandon` refuses it because it is published. The publication pass strips both
fenced blocks while the release is still a draft.

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.

### Lanes

A stable release is a set of lanes. A build lane produces something users get;
a test lane is a suite that gates the release. `release` starts from the
default set and edits it:

```sh
# The default: everything except macOS Intel and Termux
python scripts/release.py release --commit "$(git rev-parse origin/main)" --remote origin
# Only the signed Windows bundles and the Docker image, no tests
python scripts/release.py release --commit "$(git rev-parse origin/main)" --only windows,docker --remote origin
# Everything except the signed Windows pipeline (the Store bundle still builds and is submitted)
python scripts/release.py release --commit "$(git rev-parse origin/main)" --without windows --remote origin
# Only the Store bundle: no Azure signing anywhere
python scripts/release.py release --commit "$(git rev-parse origin/main)" --only windows-store --remote origin
# Everything, plus macOS Intel, without the install/update E2E
python scripts/release.py release --commit "$(git rev-parse origin/main)" --with macos-intel --without install-e2e --remote origin
```

`--only` replaces the default set, `--with` adds lanes and `--without`
removes them. Each takes comma-separated names and can repeat. `release --help`
lists every lane.

| Lane | Default | What it controls |
|---|---|---|
| `docker` | on | build and publish the Docker image |
| `windows` | on | signed Windows x64 and arm64 MSIX and the universal bundle, which feed the update channel |
| `windows-store` | on | the unsigned Microsoft Store bundle, submitted to Partner Center (needs no Azure signing) |
| `macos-arm64` | on | macOS Apple Silicon DMG and ZIP |
| `macos-intel` | off | macOS Intel DMG and ZIP: unsupported on `main`, and its runner adds over an hour |
| `termux` | off | Termux (Android aarch64) APT package |
| `ci`, `nix`, `docker-tests`, `pm-bundle`, `windows-live`, `install-e2e`, `bootstrap-version` | on | the standalone suites |
| `termux-checks` | on | Termux verification; runs only with `termux` |
| `smoke` | on | native install and chat smoke of each built desktop package |
| `upgrade` | on | signed-package upgrade acceptance (OLD to NEW) of each built desktop package |

Lanes are independent switches. A job runs only when every lane it needs is on
(`JOB_LANES` in `scripts/releases/lanes.py`), so a test lane for something that
is not built does nothing: `upgrade` with `macos-arm64` off plans no macOS
upgrade arm.

The lane set is written into the claim message (`"lanes": [...]`), never passed
as a workflow input. `admit` reads it from the claim and emits two outputs:
`lanes` (one flag per lane) and `jobs` (one run flag per lane-controlled job).
Every such job's `if` reads its own `jobs` entry, so the lane table is the only
place that says what runs. A recovery rerun cannot change it. To change a lane,
`abandon` the attempt and cut again.

The gates stay strict. `scripts.releases.stable gate` reads the lanes and
requires each job they remove to report `skipped`, and every other gated job to
report `success`. A job that ran although a lane removes it also blocks the
release. A claim cut before lanes existed (`skipBundles`, `skipTests`,
`includeMacosIntel`, `includeTermux`) reads as the lane set that gated the same
jobs. A claim without `lanesRevision` was cut when `windows` also meant the Store
bundle, so its `windows` reads as `windows` plus `windows-store`.

What a release publishes follows what it built:

- **Docker.** With `docker` off no image is built or promoted, and the final
  tag records `dockerManifestDigest: null`.
- **Bundles.** With no desktop or Termux lane there is no candidate manifest,
  and the final tag records `candidateManifestSha256: null`.
- **The desktop channel moves only for the full desktop set** (`windows` and
  `macos-arm64`). A partial set still stages its packages, records a candidate
  manifest with exactly the targets it built, and submits the Store bundle when
  `windows-store` is on. A Store-only release has no package rows, only the Store
  bundle file, and moves no channel. The protected R2 stable head, the App Installer and macOS
  feeds, the APT channel, downloads page,
  `releases/stable/release-candidates.json` and the signed-package baseline stay
  on the previous full release. Source checkouts on the official repository
  follow `releases/stable/release-candidates.json`, so they stay too.
- **Finished.** A release that advances the desktop channel is finished when
  the protected R2 head names it. One that only moves Docker is finished when
  the `stable` alias carries the digest its final tag binds. One that moves no
  channel is finished once it is public. The sequencer uses these markers to
  decide which releases still need their publication pass.
- **No tests.** The candidate manifest records each native smoke as `skipped`,
  never as passed, and every reader that knows the claim (`complete` and the
  protected R2 advance) refuses a manifest whose smoke results disagree with the
  claim's `smoke` lane. The next full release uses that manifest as its upgrade
  baseline like any other. Cut a normal release after an untested one.

When macOS Intel is left out, its rows are absent from the candidate manifest,
the release body's downloads table and the downloads page. There is one macOS
stable feed (`releases/darwin/stable/stable-mac.yml`), and `promote` replaces it
with the arm64 leg alone, so Intel Macs on that feed get no update until a
release is cut with `--with macos-intel`. When Termux is left out the APT
indexes are not rewritten, so the APT channel stays on its previous release.
A release that adds a target back after one that left it out has no published
baseline for it, so that target's upgrade arm is skipped like a first release.

The claim push is the atomic version lock. Two callers may derive the same next
version, but only one push wins; the loser reports the winning tagger, time, and
commit. A rejected or abandoned claim remains spent. To publish or abandon:

```sh
python scripts/release.py publish --version 0.21.5 --remote origin
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` force-cancels the attempt's
in-progress `Stable Release` runs, deletes the draft when one exists,
writes an `abandoned-rc.<N>-vX.Y.Z` marker ref, and keeps the attempt ref. The
marker is the record of abandonment; the attempt ref is never deleted. The
version is not spent, so the next cut is `rc.<N+1>-vX.Y.Z`.

Do not manually dispatch `Stable Release` with a final tag. Recovery keeps the
original claim ref, object SHA, commit, draft database ID, autopublish policy,
and skip flags.

## Failure and recovery

When a stable run fails, its completion event starts `Stable Release
Publication`, which at once reruns only the failed jobs in the same GitHub
Actions run. There is no backoff and no schedule. The pass applies only the
oldest unresolved retry, because GitHub keeps only one pending run in the shared
stable-release concurrency group. The rerun waits in that group until the pass
ends. 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. A lost
retry request, or a crash between publication mutations, is recovered by the
next failure event or by dispatching `Stable Release Publication` by hand.

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. The archive
key is the attempt ref: `releases/tag/rc.<N>-vX.Y.Z/`. `releases/tag/vX.Y.Z/`
is never written. Each producer writes a `handoff-<target>.json` receipt
containing the tag, commit, paths, sizes, and SHA256 digests. Per-arch receipts
live beside the candidate manifest under that prefix. Candidate assembly emits
one pinned `release-candidates.json`; consumers verify its digest and do not
re-upload the packages. Its digest is recorded nowhere before publish: the
publication pass hashes the archive copy and writes that hash into the final
tag. Docker publication pushes one immutable versioned manifest tagged by the
attempt ref and records its registry digest in the final tag. Delayed
publication promotes that digest registry-side, so it does not depend on
expiring Actions artifacts.

Windows, macOS, and Termux candidate jobs stage packages and metadata under
`releases/tag/<attempt ref>/` before acceptance. No stable feed is written at
this stage. The stable APT pool path also carries the attempt ref:
`releases/termux/stable/pool/<attempt ref>/<c>/<name>.deb`. The pool upload is
immutable with a one-year cache header, so a recut of the same version writes a
different pool key.
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 draft stays on the attempt ref until publish. Deleting it is `abandon`, and
it does not burn the version. The annotated final tag binds the GitHub release
database ID admitted with the claim. Publish edits the release only while it is
still a draft: it creates `vX.Y.Z`, retargets the release onto it, strips the
warning blocks from the body, and reads the tag and body back. Making the
release public is the last call. Published releases are immutable, so nothing
can retarget or re-tag one afterwards.

Nothing points a client at an attempt until publish. The updater compares
versions, not URLs, and that is safe only because of this. The diagnostic page
at `releases/tag/<attempt ref>/index.html` is not an update feed, and an
attempt installed from it is not upgrade-safe: every attempt of a version has
the same package version, so the published build never replaces it. The Docker
image is pushed early under the attempt ref; the `stable` and `latest` aliases
move with the feed in the publication pass. The Store build follows the same
rule: the green run commits it held (auto-publish off), so certification runs
before the gate, and the publication pass switches that submission to
auto-publish. If the submission API refuses the switch, the pass warns: select
Publish now in Partner Center once the build is certified. A submission that
does not carry the release's Store bundle is left alone while the pass checks
older published releases for its owner. Store reconciliation runs even when
the feeds and aliases are already current, so interrupted publication can be
retried without rewinding them. A held submission records its claim in the
certification notes: a successor can replace it only after that claim has a
matching remote abandon marker. Legacy submissions without an owner record
need manual cleanup in Partner Center. A failed submission becomes a warning;
the release still publishes. Stable channel
records carry an optional `archiveRef` naming the attempt ref; `releaseTag`
stays `vX.Y.Z` and the protected prefix falls back to it when `archiveRef` is
absent.

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.

The desktop workflow takes a `jobs` input with the groups `darwin-arm64`,
`darwin-x64`, `win32-arm64`, `win32-x64`, `win32-bundle`, `linux-x64`,
`linux-arm64`, and `termux`. Stable calls it once per group, except the linux
groups, which wait for a real Linux build, and the `darwin-x64`, `termux` and
any other group whose lane the claim leaves out, which it does not call. Each Mac arch and the Windows bundle
stage a receipt, and each install arm starts from its own receipt as soon as
its own bytes are staged. `acceptance` is the one join that blocks publication.

Every gate and every candidate starts straight after `admit`. CI is a gate that
`acceptance` requires, not a lock the signed builds wait behind, so a slow gate
cannot delay a bundle; the price is that a broken `main` still pays for signed
candidates that `acceptance` then refuses.
The `smoke-win32-universal` job is gone; the per-arch MSIX smokes cover each
arch, and the Windows install arms install the `.msixbundle` on both arches.
Stable runs those two MSIX smokes in its `win32-bundle` call, beside assembly,
not in the per-arch calls: assembly starts as soon as both arches are built
instead of waiting for the slower arch's smoke, and the bundle call (so
`candidate-manifest`) still fails when either smoke fails.

## Tag namespaces and receipts

Before relying on attempt refs as locks, apply a repository ruleset for
`refs/tags/v*`, `refs/tags/rc.*`, and `refs/tags/abandoned-rc.*` that restricts
creation to organization administrators and the release integration and blocks
update and deletion. The ruleset is not applied yet; until an admin applies it,
these locks are honor-system — anyone with push access can delete a marker ref
or move a receipt tag. Attempt refs, marker refs, and final tags are immutable.
Channel and commit builds write annotated post-build receipts such as
`v0.0.7+channel.<YYYYMMDDTHHMMSSZ>.<run-id>` and
`v0.0.0+commit.<YYYYMMDDTHHMMSSZ>.<run-id>` only after publication succeeds.
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. Desktop clients treat that validated channel
sequence as the update authority rather than asking SemVer to order build
metadata. `Canary Release` runs once a day at 06:41 UTC from the default
branch, and a manual dispatch starts one at any time. Runs queue with
`cancel-in-progress: false`. If `main` has no new commits, the run resumes an
unpublished canary or does nothing. 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

`release.py --canary` builds the separate canary application. Its package
identity and CLI command (`hermes-canary`) differ from stable; the existing
canary feed updates that application only. One-off builds use
`release.py --build-commit REV --remote REMOTE` (add `--publish` to dispatch).
Their application identity and CLI command (`hermes-<7-character-sha>`) include
the pinned commit. Two different commit builds do not replace each other.

Branding is selected from those build inputs, not from runtime settings:
canary uses yellow/dark-yellow icons; one-off builds use red icons bearing
the short SHA. All desktop icon formats derive from the same artwork.

One-off stamps use `source: commit-build`. No app update feed or App Installer
subscription is published for them, and both the GUI and bundled CLI refuse
update requests. They direct the recipient to ask the developer for a new
build. Source checkouts are separate: `main` is their only valid channel.

`--build-commit` prints its deterministic downloads-page URL before dispatch,
including in dry runs:
`https://hermes-assets.nousresearch.com/releases/commit/<full-sha>/index.html`.
`CLOUDFLARE_R2_PUBLIC_URL` overrides the public origin. After admission, the
commit summary runs even when a build or assembly job fails; it lists only
receipt-backed existing downloads and marks missing binaries as not built.
Missing binaries link to the workflow run under **View build run**, not to
nonexistent downloads. Disabled platforms have no download or failure link.
Page publication still requires working R2 access. The commit links to its source
on GitHub; tag and channel pages link to the corresponding GitHub release tag.
Commit pages also list explicit non-secret `--bundle-env` defaults and
`--bundle-unset` clears passed to the desktop bundles, not the CI environment.
Values are shown as JSON strings (including `""` for an empty value); clears are
labeled **Unset**. The section is omitted when no overrides were supplied.

Tagged builds also publish a per-tag diagnostic page at
`releases/tag/<tag>/index.html` after build or feed failures, including when no
artifacts were uploaded. An incomplete build does not advance the channel page
or pass the release-success gate.

Store submission retains its fixed official stable identity. Nonstable
packages must not be submitted under that identity.

## Dynamic channels in R2

Channel names are R2 objects, not a repository registry. A preview channel owns
one native application identity across exact-commit builds. The immutable build
request records its source commit, bundle defaults, channel sequence and package
versions separately. The existing native build and smoke jobs must all pass
before the channel head advances.

Preview a custom build, then explicitly dispatch it. Repository identity does
not select behavior: the same direct dispatch runs from any GitHub remote, but
`--channel` performs no R2 access locally — 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 in CI. The local
command needs only a `gh` token with write, maintain or admin permission on the
selected repository; no R2 credentials are required.

```sh
python scripts/release.py --channel pm-preview --build-commit my-branch --remote origin
python scripts/release.py --channel pm-preview --build-commit my-branch --remote origin --publish
python scripts/release.py --channels --remote origin
```

By default a preview channel installs as its own side-by-side app (`Hermes
NAME`, its own package ID). To test an exact commit as the regular app instead,
create the channel with `--branding stable`: it copies the published stable
channel's name, icon and package ID, so the build installs over the official
app, shares its desktop settings, and later updates follow the channel. Branding is fixed when the channel is
created; repeat the flag on every dispatch of that channel and use a new channel
name to change it. `--branding` needs `--channel`, since a plain one-off commit
build always carries its `Hermes Agent <sha>` identity.

```sh
python scripts/release.py --channel my-commit --branding stable --build-commit SHA --remote origin --publish
```

Disposable R2 scoping is opt-in, for test runs only. Dispatch the desktop
workflow with `disposable_channel` and `build_commit` to allocate a namespace
under `ci-disposable/<repository-id>/<run-id>/`, then use the exact scoped build
command from its summary. For local administration of that allocation, carry
its `R2_DISPOSABLE_RUN` and `GITHUB_REPOSITORY_ID` in the command environment,
with the configured public URL still at the unscoped root. These are test-run
inputs, not persistent application settings; the repository ID is checked
against the selected remote before credentials are read, so a scoped namespace
still belongs to exactly one repository.

The first publishing invocation creates the channel (during CI allocation), and
later invocations retain its identity. Requests and artifacts live under
`releases/channel-builds/BUILD_ID/`; the mutable pointer is
`releases/channels/NAME.json`. A failed build leaves its previous head intact.
Conditional writes reject stale publication and permanently retired channels.
Retrying re-dispatches `--channel NAME --build-commit SHA --publish`, which
allocates a fresh sequence slot in CI; there is no separate resume command.

Retirement pins the current official stable build as the first receiver:

```text
python scripts/release.py --retire-channel pm-preview --to stable --minimum-version VERSION --remote origin
```

Add `--publish` only after reviewing the dry run and the exact native acceptance
evidence. This does not rebuild stable under the preview identity or silently
uninstall clients. Protocol-aware clients offer a consented cross-application
handoff; the destination must confirm readiness before preview removal. Keep
the retirement object and pinned artifacts available for offline clients.
Existing one-off builds have no retirement reader and require replacement.

The destination manifest must declare receiver support read from its packaged
stamp. The existing protected release workflow owns acceptance; there is no
separate public certification document or dependency on expiring Actions artifacts.
Native signature and identity checks, recipient consent, preserved-state preflight,
and destination readiness remain mandatory. An offline preview reaches its pinned
first receiver even after stable advances; that app then updates through stable.

Before shipping R2-only source readers, seed the existing `main` source-branch
record and published stable/canary records through the explicit protected
bootstrap operation. Review actual accepted manifests; do not invent a native
manifest for `main`. Production seeding, CDN cache/CAS verification and native
signed-package qualification are release operations, not implied by a passing
local helper suite. Never store the bootstrap output as a repo channel list.

## Signed-package baseline

The last successful stable release that shipped bundles records
`releases/stable/release-candidates.json` on the configured R2 public origin.
A release that skipped bundles does not replace it.
When no baseline exists, the release still runs every native smoke but skips
the signed-package upgrade arms. A `baseline-manifest` input that does not
exist is a blocker.
It identifies actual Windows universal MSIX bundles, macOS ZIPs and package
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 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).

## Explicit exclusions and policy

- Desktop Playwright E2E (`e2e-desktop.yml`) is deferred at the owner's request
  because it is flaky. It is reported as deferred, not passed. Stabilize it
  and prove repeatable CI runs before adding it to this gate.
- Install/update E2E and native signed-package acceptance are **not** deferred.
  Only a claim without the `install-e2e`, `smoke` or `upgrade` lane removes
  them, and the gate then requires them to be skipped.
- PR-only history, label and diff review checks do not apply to a stable tag.
  All applicable source CI jobs still run, regardless of changed paths.
- OSV vulnerability findings retain their existing advisory policy. Required
  scanner execution failures are failures, not advisory findings.
- Disabled Linux desktop packaging is not claimed as shipped. Native Linux
  PM bundles, Docker, Nix and install/update checks remain required.
- Housekeeping, autofix, comment and skills-index/deploy workflows are not
  release acceptance suites.

## Implementation ownership

Actions owns job ordering, runner selection, permissions and environments.
Python owns shared release admission, manifests, artifact hashes, publication
and channel promotion under `scripts/releases/` and `scripts/bundles/`.
Electron-builder configuration/hooks and native Windows/macOS adapters remain
in JavaScript or PowerShell. These adapters consume release facts rather than
reimplementing the release gate. Gate jobs use only Python's standard library;
they do not install the application or the JS workspace to report a verdict.

`scripts.bundles.release_artifacts` owns App Installer XML and feed publication.
Its serializer takes explicit package identity, publisher, version, subscription
URI and artifact URI. Stable promotion uses the accepted candidate metadata;
canary publication verifies the native bundle manifest against the adapter's
expected identity before uploading the bundle, then the descriptor. Native SDK
bundling/signing stays in `stage-msixbundle.mjs`. Store and commit builds stop
before that feed handoff; `stable-store` submits the verified candidate without
rebuilding it. Native acceptance uses the same Python serializer, including its
12-hour on-launch check policy.

Signing and publication credentials stay in their protected job environments.
The source CI call does not inherit deployment secrets. Configure the existing
release-signing and container-publish environments before running this pipeline.
