Skip to content
Cockpit0.29.0
A snapshot: read at commit 707a04c7ce8 on 2026-10-18T00:06:01Z by majordomus 0.29.0. Nothing on this page is read again. See it live

docs/DISTRIBUTION.md

Distribution

What it is

URI
majordomus://document/docs/DISTRIBUTION.md
Kind
document
Identity
docs/DISTRIBUTION.md
Path
docs/DISTRIBUTION.md
Provenance
declared
Media type
text/markdown
As a capability
document.docs/DISTRIBUTION.md

Front matter

{}

Content

# Distribution

How Majordomus is packaged, published and installed, and why the answer is one file.

The user-facing half is [`INSTALL.md`](INSTALL.md), which is generated. This document is
for whoever changes how the tool is shipped: what the canonical model is, what derives from
it, what the trust path is, how a release happens, and how to add a platform.

## The rule

> Distribution has one canonical machine-readable model. Supported targets, release
> artifacts, installer resolution, documentation, release automation and the exposed
> UI/API metadata derive from that model or are mechanically validated against it.

The model is [`share/distribution.yaml`](../share/distribution.yaml), contract
`majordomus-distribution/v1`, schema
[`share/schemas/majordomus/distribution/distribution.v1.schema.json`](../share/schemas/majordomus/distribution/distribution.v1.schema.json). It
declares the binary, the repository releases are published from, the installer's canonical
URL and defaults, how an archive is named, and every target the project has an opinion
about — supported, experimental, or unavailable with the reason.

It is in `share/` rather than under `.ai/` for the reason `share/commands.yaml` is: it is a
statement about how the tool behaves, not context for a worker, and it travels with an
installed copy so that an installed executable can answer the same questions a checkout can.

## What derives from it

```text
share/distribution.yaml
  |
  |-- majordomus generate --target distribution
  |     |-- docs/generated/distribution-matrix.json   the release build matrix
  |     |-- site/static/install.sh                    the installer, with its platform table
  |     |-- docs/INSTALL.md                           the guide, with its platform table
  |     |-- site/data/registry/distribution.json      what the website renders
  |     `-- site/static/releases/*.json               from .ai/repo/releases/*.yaml
  |
  |-- the `distribution` capability module            CLI, HTTP, OpenAPI, MCP, cockpit
  `-- scripts/release-*                               packaging, verification, recording
```

Nothing in that list states a platform, a file name or a URL of its own. The scripts ask
`majordomus distribution artifact` for a name rather than composing one; the release
workflow reads the generated matrix rather than listing runners; the installer's platform
table is a generated region inside a hand-written script; the documentation's platform
table is generated into a hand-written guide.

`majordomus generate --check` — reached by `just derive-check`, by the `site` job and by
the release pipeline's first phase — refuses a tree in which any of them disagrees.

## Adding a platform

One edit, then one command:

```bash
$EDITOR share/distribution.yaml     # add a target: id, os, arch, libc, rust_target, status, build
just derive                         # regenerate every projection
bash test/run.sh 84_distribution_model
```

The installer offers it, the release builds it on the runner the model named, the guide
lists it, the website lists it, the matrix carries it, and the completeness invariant now
requires an artifact for it in every future release. Nothing else is edited.

A genuinely new *concept* — another operating system, another archive format, another libc
— is the one case that also changes Rust: the vocabularies are enums in
`apps/majordomus-cli/src/distribution/mod.rs` so that an impossible target cannot be
written down. Adding a value there is a deliberate widening of the model, not a
synchronisation chore.

## One version, and one projection of it

A release has one version, and it is authored in one place: the `[package] version` of the
crate manifest. The shell tool cannot read that file where it is installed — an installed tree
has no `Cargo.toml` — so it reads a projection of it that ships beside it:

```text
apps/majordomus-cli/Cargo.toml   the version — authored here, and only here
share/version.txt                its projection, generated by `majordomus generate`;
                                 `majordomus version` prints its `version=` line
```

Every archive carries `share/` whole, so the checkout, the installed tree and the staging
directory the installer checks read the version the same way. `generate --check` refuses a
projection that has fallen behind the manifest; `scripts/release-version --check` proves the
tool prints the authority, in CI and again as the first thing a release does — together with
the tag, which must be `v` followed by that version. A mistyped tag costs thirty seconds
rather than a published release. ADR 0085 is the decision, and
`project.release-is-a-projection` the rule.

## The trust path

```text
share/distribution.yaml            the model: platforms, names, the installer's URL
        |
        v
release build (one runner per target, the model names which)
        |
        v
release-verify                     the archive is what the model says it is
        |
        v
release-record                     digests and sizes read off the files that exist
        |
        v
.ai/repo/releases/<tag>.yaml       the repository's evidence of what was published
        |
        v
majordomus generate                site/static/releases/<tag>.json and latest.json
        |
        v
GitHub Pages                       the metadata the installer reads, over HTTPS
        |
        v
install.sh                         verifies the digest before unpacking anything
        |
        v
~/.local/share/majordomus/versions/<version>/, then the launcher, by rename
```

Each arrow is checked by the step after it. The installer never reads GitHub's API or HTML;
it reads two JSON documents that this repository generates, so a change in GitHub's pages
cannot break an installation.

## What is protected, and what is trusted

Protected:

- **transport** — HTTPS only, redirects restricted to HTTPS; a non-HTTPS metadata base is
  refused unless two explicit environment variables say otherwise, which exists so the
  installer's own tests can serve fixtures;
- **integrity** — every artifact carries a SHA-256 and a size in the metadata, both checked
  before extraction; a mismatch discards the download and stops, with no "warn and
  continue";
- **provenance of the URL** — the artifact URL must be this project's own release download
  prefix, derived from the model, and is checked before anything is fetched from it;
- **the shape of every value** — every field read out of the metadata is matched against a
  literal character class before use, so nothing read over the network can reach a command
  line, a path or the shell as anything but data;
- **extraction** — absolute paths, `..` traversal, symbolic and hard links, devices and any
  entry outside the archive's own directory are refused by name, and nothing is unpacked;
- **the executable itself** — the unpacked tool is run once, from a temporary location, and
  must report the version that was resolved; this is what catches a release built from the
  wrong tree;
- **the existing installation** — the new tree is unpacked beside the old one and the
  launcher is replaced by a rename, so any failure before that leaves the previous
  installation exactly as it was;
- **privilege** — `sudo` appears nowhere; an unwritable destination is named, not elevated.

Trusted: GitHub Pages serving the metadata, GitHub Releases serving the artifacts, and the
pipeline that produced both. Checksums bind an artifact to its metadata; they do not, by
themselves, prove who wrote the metadata.

**Provenance, where it can be checked.** The release workflow attests every archive it
publishes (GitHub artifact attestations, signed with the workflow run's identity), so an
archive can be traced to this repository's release workflow at its tag. An attestation is
found by the archive's digest, which means GitHub answers a release published before
attestations began and an archive that is not the one the workflow built in the same way:
no attestation for that digest. What each path verifies:

| path | checksum (SHA-256 and size) | provenance (attestation) |
|---|---|---|
| `curl … \| sh`, the GitHub CLI installed and signed in | always, fatal on mismatch | asked; a refusal is reported and the install continues on the checksum |
| the same, with `MAJORDOMUS_REQUIRE_PROVENANCE=1` | always, fatal on mismatch | required; anything short of a verified attestation installs nothing |
| `curl … \| sh` without the GitHub CLI, or signed out | always, fatal on mismatch | not checked, and the closing report says so |
| a plain-HTTP fixture origin (the installer's own tests) | always, fatal on mismatch | not checked: no attestation describes a fixture |
| by hand: `gh attestation verify <archive> --repo korczis/prismatic-majordomus` | `SHA256SUMS` beside the release | yes |

The installer's closing report names which it verified under `Verified:` — `provenance (…)
and checksum`, or `checksum only — provenance not verified: <why>`. Provenance is not fatal
by default because a refusal cannot be told apart from an older release; it becomes fatal on
request. Not claimed: a signature over the metadata itself, or any check of a release older
than its attestations.

## Releasing

```bash
# 1. raise the version in the one place it is authored — the measured minimum unless told
#    otherwise — and derive everything that follows from it
bin/majordomus-cli release bump
scripts/derive                       # builds, then projects share/version.txt and every stamp
scripts/release-version --check --tag v0.3.0

# 2. commit, open the pull request, and merge it once `ci` is green
```

<!-- majordomus:unrun it asks GitHub for the commit's newest `ci` check-run, which needs the network and a token; case 362 runs it against a stubbed answer -->
```bash
# 3. the commit to be tagged passed ci — the same question the pipeline asks first
scripts/ci/release-verdict --commit HEAD
```

```bash
# 4. tag the merge on the default branch, and push the tag
git tag v0.3.0 && git push origin v0.3.0

# 5. the pipeline publishes, puts releases/v0.3.0.json and releases/latest.json on the
#    site at once (ADR 0131), then proposes the record as release/record-v0.3.0; confirm
#    with curl -fsSL https://majordomus.dev/releases/latest.json
# 6. start the record pull request's own run, which the pipeline cannot: approve its
#    waiting validate run, or close and reopen it (majordomus prs drain reopens it)
# 7. merge the record's pull request once that run's ci is green; Pages then deploys master
#    with the record, and scripts/pages verify --commit <merge sha> confirms it
```

**The tag comes first and the record second, and the window between them is pending, not
red.** The record cannot be written before the tag: it is made from what was published — the
archives' names and SHA-256 digests, the release page — and none of that exists until the
pipeline the tag starts has run. So the record is proposed after publication, on
`release/record-<tag>`, and its pull request needs a full run of its own before it can merge,
about two hours. `scripts/ci/release-check` refuses a published tag the layer holds no record
for, and until I2054 it refused it on every head in that window: on 2026-10-08 four pull
requests about other things went red on that one finding while v0.17.0's record ran, and on
2026-10-14 every branch did between the v0.25.0 tag and its record's merge. It now reads the
proposal from git, as the structure job already has it (`fetch-depth: 0` fetches every branch
as `refs/remotes/origin/*`): a tag whose record is carried by the tip of a
`release/record-<tag>` branch younger than `MJ_RELEASE_RECORD_GRACE_HOURS` (24 by default) is
reported `pending`, naming that branch, and passes. What it costs: the gate trusts a branch,
not a merged record, for up to a day — a record branch whose pull request was closed excuses
its tag until the grace runs out — and a checkout that fetched no branches sees no proposal
and refuses as before. A tag with no record and no branch proposing one, the v0.7.0 state, is
refused at once on every head. Proposing the record before the tag was the other choice, and
it was not taken: the record would describe artifacts nobody had built yet.
`test/cases/3021_a_tag_does_not_freeze_the_queue.sh` holds every state.

The window before the record branch exists is pending too (I2712). Between the tag push and the
publish job's push of `release/record-<tag>` there is no record and no branch, and nothing is
published yet either: on 2026-10-14 the structure job of #1146 went red on v0.26.0 while its
release run was still waiting for a macOS build. Only the forge can see that state, so for a
tag with neither record nor proposal the gate asks `gh` for the tag's runs of `release.yml`: a
run queued or running makes the tag `pending`, naming the run; no run listed and no GitHub
release yet, inside the same grace, is pending too. A concluded run, a GitHub release that
exists, a forge that cannot be read and a repository it cannot name (`MJ_RELEASE_CHECK_REPO`,
else the `origin` remote on github.com) leave the refusal standing, with a note saying which —
offline, the gate is as strict as before. `test/cases/3029_a_tag_in_flight_is_pending.sh`
holds those states.

The pipeline is [`.github/workflows/release.yml`](../.github/workflows/release.yml), an
adapter over [`.ai/repo/ci/release.yaml`](../.ai/repo/ci/release.yaml) and the generated
matrix:

```text
plan     the tag equals the version the crate manifest declares and the shell tool prints
         it; the tagged commit's `ci` check concluded success (scripts/ci/release-verdict:
         failure refuses with 10, a run still in progress after the wait or a verdict that
         cannot be read with 12); the model and every recorded release hold their
         invariants; the matrix is emitted
build    one archive per supported target, on the runner the model names, verified where
         it was built (fail-fast: a release missing a platform is not a release)
publish  digests, the GitHub release, the record written from what was uploaded and staged,
         every projection derived from it and judged by scripts/derive-check, then both
         proposed to the default branch as one pull request, release/record-<tag>
smoke    scripts/ci/install-check --expect <tag>: the published installer, from its published
         URL, installs the release that was just published, and the installed tool
         initialises and serves a repository that is not this one, on every runner whose
         target it was built for; no step of it swallows a failure
```

Only `publish` has `contents: write`, and with it the `id-token` and `attestations` writes
that attest its archives. Nothing else in the run can write anything; `plan` reads
check-runs (`checks: read`) and nothing more.

A release follows the verdict. v0.3.1, v0.5.0, v0.6.0 and v0.7.0 were published from commits
whose `ci` check-run concluded failure, because the pipeline asked only whether the tag agreed
with the version. The newest `ci` check-run of the tagged commit is now the first thing `plan`
reads after the version, and nothing is built from a commit it did not see pass.
`test/cases/362_a_release_follows_the_ci_verdict.sh` holds the script to success, failure,
a run in progress, a missing check and an unreadable answer, and the workflow to asking it.

A runner label the model names must be a standard, currently offered GitHub-hosted label.
This is not a style rule. A retired label does not fail: the job is accepted and queues for
a runner that will never arrive, so the run neither publishes nor goes red — it simply never
ends, and `fail-fast` cancels it when a sibling fails, which makes it look like collateral
damage rather than the cause. `macos-13` sat that way through two release attempts. The list
is GitHub's, at `actions/runner-images`; a `-large` or `-xlarge` suffix means a billed larger
runner rather than a standard one.

A run that fails in `build` publishes nothing, which is the intended behaviour and also the
one that is easy to walk away from: the tag still exists, pointing at the commit the build
failed on, and no release is behind it. Fixing the cause on the default branch does not fix
the tag. Finish the release — a new version, bumped and tagged, is the ordinary way; moving
a tag that has no release behind it is the other, and only before anyone can have pinned it.
Until one of those happens the advertised install command is broken for everyone, and
`installer-live` below is what says so.

### When a release fails partway

A release changes things the world can see, and it can fail after some of them have changed.
Rerunning the workflow for the same tag is the supported recovery, and it is safe at every
stage. What "safe" means is declared in [`.ai/repo/ci/release.yaml`](../.ai/repo/ci/release.yaml)
under `rerun:` and implemented in the publish job:

* **the assets on an existing release are the release.** If the tag already has a GitHub
  release, the rerun downloads its published assets and discards its own rebuild. The record
  is evidence of what a user downloads, and rebuilds are not bit-identical (see
  *Reproducibility* below), so recording this run's bytes would state digests nothing serves.
* **the metadata commit is idempotent.** A rerun that finds the record and its projections
  already committed pushes nothing and succeeds.
* **smoke is never skipped.** Both paths end in the same public installation test, so a
  rerun proves the same external contract a first run does.

Stage by stage:

| Failed at | Visible outside? | Rerun | Cleanup |
|---|---|---|---|
| `plan` | no | yes | none — no artifact was built |
| `build` | no | yes | none — nothing was uploaded |
| `publish`, before the release exists | no | yes | none |
| `publish`, after the release exists | yes — the release and its assets | yes; the rerun adopts those assets | none |
| `publish`, after the metadata commit | yes — the record is on the default branch | yes; the commit step finds nothing to add | none |
| `pages` | yes — the record is committed but not served | `gh workflow run pages.yml --ref master` | none |
| `smoke`, while the record is a proposal | yes — the release, not its metadata | start the record pull request's own run — `majordomus prs drain` closes and reopens it; by hand, approve its waiting `validate` run, or `gh pr close <n>` then `gh pr reopen <n>` — merge it once that run's `ci` is green, then rerun `smoke`; the dispatched run's `ci` does not let it merge | none |
| `smoke` | yes — everything is published | fix the cause, then rerun | none; the release stands or is withdrawn below |

#### Who starts the record's verdict

The record is proposed as a pull request, and that pull request is opened with
`GITHUB_TOKEN`. GitHub runs no workflow for an event that token caused, so the record's
pull request has no run of its own until a person starts one. The run its `pull_request`
event records took two shapes: through `v0.26.0` (2026-10-14) it concluded failure with no
job in it; on `v0.27.0` (2026-10-16, run 38865305333) it was created `action_required`,
waiting for approval. In both the required `ci` check never reports and the proposal cannot
merge. After `v0.15.0` and after `v0.16.0` it sat so, with an empty check rollup, until
somebody closed and reopened it, and `smoke` failed by design for as long as that took.

The pipeline cannot start that run, and does not claim to. What starts it is a person, in
one of two ways: approving its waiting `validate` run, or closing and reopening the pull
request. The integration executor does the second: `majordomus prs drain` closes and
reopens a `release/record-<tag>` pull request that the workflow's token opened and that has
no check of its own, once per head, and the run its reopening starts is the one the merge
waits for ([INTEGRATION.md](INTEGRATION.md), "The record pull request it reopens";
`test/cases/1036_the_executor_reopens_a_record_pull_request.sh`). Without a drain running,
either is done by hand: approve the run on its page, or `gh pr close <n>` then
`gh pr reopen <n>`. The record then merges on that run's `ci`, with no admin merge.

After proposing the record the pipeline also dispatches `validate` on the record's branch
with the input `plan=change`, and `validate` plans that run as the pull request's own run
will be planned: the gates of the head commit's change, without the on-demand runners a
dispatch otherwise asks for. **That run is not the record's verdict.** Its check-runs land on
the record's head commit, but branch protection and the pull request's check rollup read no
check suite a dispatch created. `v0.19.1` (#866) and `v0.27.0` (#1273) were refused with
`base branch policy prohibits` while a dispatched `ci` stood green at their heads, and the
rollups of `v0.25.0` (#1142) and `v0.26.0` (#1147) list no check at all although their
dispatched `ci` passed (I2282). What the dispatch gives is an early reading of the record's
tree: a red record is known before anybody starts the run that counts, and the release path
holds the runners for it ([CI.md](CI.md)). A dispatch that cannot be started is a warning
naming the remedy and does not fail the job, because the release is published either way and
`smoke` must still run to say so. `test/cases/996_the_record_pull_request_gets_its_verdict.sh`
holds both workflows to this, and the workflow and this section to calling the dispatch
nothing more.

Two remedies would remove the person's step, and neither is taken. A status or check the
dispatched run posted under the name `ci` would satisfy the required context without the
pull request's own run having produced it, which is a forged verdict, not a remedy. Opening
the pull request, or closing and reopening it, with a GitHub App or fine-grained token whose
events start workflows would make its own run start at once; that needs a credential only the
repository's owner can create and hold, and is the owner's decision. Until it is made, every
release costs one person's act between publication and its record reaching master.

#### The site serves a release before its record lands

What the installer reads is `releases/latest.json` and `releases/<tag>.json`, both projections
of the release record. The record reaches master through a pull request whose `ci` ran for
106 minutes for v0.19.1, and on 2026-10-10 v0.20.0 was published on GitHub at 16:38Z and
served by the site at 17:42Z, after the record was merged by hand. So the publish job, once
the record and its projections are derived and judged, runs `scripts/site-deploy
--release-metadata <tag>`: those two files go onto what `gh-pages` serves, and nothing else
does. `build.json` keeps naming the master commit the site was built from, so the
`pages-live` gate still reads master (ADR 0131).

Until the record lands, master's own deploys carry the previous release's `latest.json`. An
ordinary deploy therefore keeps the `latest.json` that `gh-pages` serves, and the tag file it
names, when it names a newer version than the build; it says so in its output and in the
`gh-pages` commit. `--release-metadata` refuses a tag the metadata does not name, a
`latest.json` that differs from the tag's file, and a release older than the one served: a
site never goes back a release. `test/cases/1041_a_release_publishes_its_metadata.sh` holds
all of it.

The pages follow the metadata, not the record. Until 2026-10-12 they did not: `latest.json`
named v0.21.0 from 10:23Z while the homepage beside the install command offered v0.20.0,
because every page read the release record and the record was still a pull request. The
release a page offers is now derived once, by `scripts/site-build`, as the newer of the
newest record and the `releases/latest.json` that `gh-pages` serves (read from the git ref,
named in `/build.json` as `release.published_from`), and every page reads that one value. So
the build the publish job asks for below is the one that moves the homepage, minutes after the
archives rather than hours. `scripts/pages release-live` holds the homepage, `latest.json`
and GitHub's newest release to one version past `deploy.release_window`, after every deploy
and in the `pages-live` gate (`project.the-site-offers-the-release-it-published`,
`test/cases/1087_the_site_offers_the_release_it_published.sh`).

#### Why the release asks Pages to publish

The publish job ends by running `gh workflow run pages.yml`, and that step is not a
belt-and-braces addition: without it the release is structurally unable to reach a user.

GitHub does not start a workflow from a push made with `GITHUB_TOKEN`. It is a deliberate
loop-breaker and it cannot be disabled for a token. The commit the publish job makes — the
only thing that carries `site/static/releases/latest.json` — therefore starts no `pages`
run, so the site keeps serving a build from before the release existed, and the smoke phase
waits fifteen minutes for a file nothing is going to write.

Release `v0.3.1` spent its entire smoke phase in exactly that state: six green builds, a
published GitHub Release, a committed record, and `latest.json` returning 404 to anyone who
ran the advertised command. Every observable pointed at the packer, and the packer was fine;
the last link of the chain simply did not exist. The dispatch is that link. It runs with
`if: always()`, because a rerun after a failed deploy is precisely the case with nothing to
commit and everything to publish.

`workflow_dispatch` is the entry point a person uses, so this starts the one deploy path
rather than adding a second one (see the `site-deploy-one-path` guarantee).

The one case a rerun cannot repair is a release that exists and publishes no archive — the
assets cannot be reconstructed from a tag. The run stops and names the command that clears
the way:

```bash
gh release delete v0.3.0 --yes    # then rerun the workflow
```

Nothing here needs the tag to be moved. A tag that points at the wrong commit is a different
problem, and the answer to it is a new version, not a moved tag.

### Testing it without publishing

```bash
scripts/release-fixture --out /tmp/rel --base-url http://127.0.0.1:8099
(cd /tmp/rel && python3 -m http.server 8099 --bind 127.0.0.1) &
MAJORDOMUS_RELEASE_BASE_URL=http://127.0.0.1:8099 MAJORDOMUS_INSECURE_BASE_URL=1 \
  sh site/static/install.sh --dry-run
```

That is what `test/cases/85_installer.sh` does, and it is the whole stack: a real archive
built from the tree you are in, real metadata rendered by the renderer the site publishes,
a real HTTP download, a real digest check, a real atomic install.

### Proving the advertised command still works

A release pipeline proves the advertised command works *once*, in its smoke phase, at the
moment of publication. That is not the same promise as the one the front page makes, which
is in the present tense, and the difference has been real: a build that failed on one
platform publishes nothing, the tag stays where it is, the fix lands on the default branch
and is never tagged again — and the URL every document points at keeps serving an installer
that resolves no release. Every gate over the tree stayed green throughout, because none of
them can see the published site.

`scripts/ci/install-check` is the gate that can. It reads the addresses from
`site/data/registry/distribution.json` — states none of its own — and then, from the
published site:

```text
1  the metadata the installer resolves is served, and names a release
2  the installer is served, and this machine's /bin/sh parses it
3  the advertised line, run as it is written, pipe included, into a home of its own
4  the installed tool reports the version the metadata resolved
5  the installed MCP launcher runs with MAJORDOMUS_NO_BUILD=1 — an archive that left a
   launcher out passes every check that only reads the archive's file list, and fails here
6  the installed tool initialises a repository that has none
7  and serves it: the installed MCP launcher, started there without --standalone, answers
   initialize and tools/list over stdio and elects itself that repository's shared server,
   which reports itself ready and names that repository — its identity is the digest of
   the repository's root, so a server that answered for this checkout instead is refused
```

The release smoke runs the same gate on the tag it published (`--expect <tag>` refuses
metadata that names any other release), so the promise is measured by one gate at
publication, every night and on request. Case 1024
(`test/cases/1024_an_installed_release_serves_a_foreign_repository.sh`) proves that it fails
on an archive whose MCP launcher cannot start.

Nothing outside its temporary tree is written: the install goes to a `HOME` of the run's
own, so the prefix, the launchers and the PATH hint all land inside it.

CI runs it as `--wait 300`. Publishing a release and deploying the site are two workflows and
the second is not instant, so a push landing between them would be told the promise is broken
when it is merely a few minutes old. Run by hand the wait is zero, because a person asking
whether the command works wants the answer now. A site that cannot serve the metadata inside
the window is broken either way, and the finding stands.

It is the gate `installer-live` in [`.ai/repo/ci/gates.yaml`](../.ai/repo/ci/gates.yaml),
job `install`, on Linux and macOS. No path class selects it, deliberately: no change to a
tree can make it true or false — only a deployment can. It is also one of the model's
on-demand gates, because half of its matrix is a macOS runner and this repository waits
hours for one: the nightly schedule, a dispatch and a pull request labelled `ci:full` plan
it, a routine push does not. So a broken advertised command turns the nightly run red,
within a day of the deployment that broke it, which is the only condition under which anyone
was going to find out. To ask the question now — after a release, say — dispatch the
workflow, or run `scripts/ci/install-check` here.

Against a local fixture rather than the published site:

```bash
scripts/release-fixture --out /tmp/rel --base-url http://127.0.0.1:8099
(cd /tmp/rel && python3 -m http.server 8099 --bind 127.0.0.1) &
scripts/ci/install-check --base http://127.0.0.1:8099
```

### Recovering from a bad release

A published release is immutable: its artifacts and its versioned metadata stay exactly as
they were, because someone may have pinned them. Withdrawing one is an edit to its record:

```yaml
# .ai/repo/releases/v0.3.0.yaml
yanked: true
```

then `just derive` and commit. `latest.json` is derived on every generation as the highest
version among the stable, unwithdrawn records, so the stable pointer moves back on its own
and `--version v0.3.0` still resolves for anyone who wants exactly that. Nothing is deleted
and no URL stops working. The site follows the same derivation: the record's reading of the
release it offers is `latest` in `site/data/registry/distribution.json`, never the highest
record file name, so a withdrawn or prerelease record is not what the pages present, and
`scripts/site-check` refuses a build whose badges differ from the one release it offers.

## Reproducibility

The inputs to an artifact are recorded rather than claimed: the tag, the target triple, the
commit and the build time go into `RELEASE.json` inside every archive, and the commit is
compiled into the executable by `build.rs`. The archive itself is packed with entries in
sorted order.

Every archive carries each path exactly once, and carries nothing but regular files and
directories: no hard link, no symlink, no device node. `scripts/release-package` checks that
of the archive it has just written and deletes it rather than return a violating one, and
`scripts/release-verify` checks it again of the archive it is handed. Both exist because
release `v0.2.0` was never published: the packer passed a complete file list to a `tar` that
also recursed into it, every path was archived twice, GNU tar wrote the second copy of each
as a hard link, and the verifier — correctly — refused all 931 of them. There is deliberately
no fallback in the packer: an archive packed differently from the one that was asked for is
not the archive anything verified. `test/cases/87b_release_archive_shape.sh` reproduces that
packer with a `tar` shim and proves the guard stops it.

Bit-identical rebuilds are not claimed: that needs a reproducible compiler invocation
(`SOURCE_DATE_EPOCH`, a pinned toolchain version, no absolute paths in debug info), and the
toolchain here is `stable` rather than a pin. What is guaranteed is that two people
building the same tag get functionally identical archives, and that the archive says which
commit and target it came from.

## Self-update

The owner decided on 2026-10-14 that a node of this repository's mesh follows the published
release by itself (ADR 0161). Until I2720-I2722 land, the session that holds the release seat
upgrades every enrolled node by hand once `releases/latest.json` names the new version, and
reads each node's version back. What follows is the design those issues build.

**One implementation of the risky part.** `majordomus self update` re-runs the published
installer with `--version <tag>` and the installation's own prefix; it does not reimplement
download, verification or swap. `--check` reports the running version, the version the stable
pointer names and whether an upgrade applies; `--apply` installs it and reads the version back
from the new launcher. Everything it resolves comes from what is already typed:

```rust
Model::load(share)          // this installation's own model, shipped in share/
Model::release_url(tag)     // where the metadata for a tag or `latest` lives
Releases::latest_stable()   // which release an unpinned update resolves to
Release::artifact(target)   // the artifact, its digest and its size
crate::TARGET               // the triple this build was made for, compiled in
```

**Who follows.** Every machine the mesh declaration enrolls, hubs included, with `follow:
stable` (the default for an enrolled node of the owner's mesh), `follow: pinned <version>` or
`follow: off`. A repository that adopts Majordomus without enrolling machines in this mesh is
not affected, and nothing fetches anything on entering a repository.

**What a node trusts.** Only `releases/latest.json` and `releases/<tag>.json` on the site, and
only an archive whose digest and size match them. A mesh announcement of a release means
"check now" and nothing more. A node with no route to the site (jetson) may take the archive
from a peer, and verifies it against metadata it can check before unpacking (I2724).

**Forward only, stable only.** No downgrade, no pre-release, no version the metadata does not
list. A pinned node stays pinned. Going back is an operator's `install.sh --version`.

**Install beside, activate in order, roll back by itself.** The new release goes into its own
versioned tree while the old server serves. A hub restarts onto it under its service manager
when no session is attached or after a bounded grace (I2722). A checkout's shared server
hands its sessions on and ends (I2127, I2163), and until then is replaced at its next idle end.
The health check is the readiness route plus a live `mesh verify` round. If it fails, the
launcher is renamed back, the previous version keeps serving, and the board names the node,
the version and the failed check.

**When it checks.** At server start, every 15 minutes with jitter, and on a mesh release
announcement: one HTTPS request for `releases/latest.json`, never in the path of a request.

**Where it shows.** Each runtime reports its version and follow setting on the mesh;
`majordomus mesh peers` prints them and `mesh doctor` names a node more than one release
behind (I2723). A release is rolled out when every enrolled, following node reports it.

`test/cases/85_installer.sh` covers the risky part today, and `self update` resolves releases
through the same records and the same stable pointer as the installer does.

## Package managers

The release model is the primary contract and it is package-manager neutral: an archive per
target with a digest, and a machine-readable index. A Homebrew formula, an APT repository,
a Nix derivation or a container image would each read that index rather than replacing it,
and none of them is implemented. Nothing in the model prevents one.

Windows is representable — the schema carries `zip`, a `binary_suffix` and a `windows`
operating system — and is declared `unavailable` with its reason: the command a person runs
is `bash`, and until that half has a Windows answer, publishing a Windows archive would
ship an executable nothing can drive.

## Where things are

| What | Where |
|---|---|
| the model | `share/distribution.yaml` |
| its contract | `share/schemas/majordomus/distribution/distribution.v1.schema.json` |
| a release record's contract | `share/schemas/majordomus/release/release.v1.schema.json` |
| the records | `.ai/repo/releases/`, one per published release |
| the semantics | `apps/majordomus-cli/src/distribution/` |
| the capability module | `apps/majordomus-cli/src/capability/builtin/distribution.rs` |
| the installer's behaviour | `share/install/install.sh.in` |
| the guide's prose | `share/install/INSTALL.md.in` |
| packaging and verification | `scripts/release-{version,package,verify,record,fixture}` |
| the pipeline's model | `.ai/repo/ci/release.yaml` |
| the pipeline | `.github/workflows/release.yml` |
| the behavioural cases | `test/cases/84_distribution_model.sh`, `85_installer.sh`, `86_installer_platform.sh`, `87_release_pipeline.sh` |
| the rule | `.ai/repo/rules/project/distribution-canonical.v1.md` |