How Majordomus is packaged, published and installed, and why the answer is one file.
The user-facing half is 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, contract
majordomus-distribution/v1, schema
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
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:
$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:
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
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 —
sudoappears 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
# 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
# 3. the commit to be tagged passed ci — the same question the pipeline asks first
scripts/ci/release-verdict --commit HEAD
The verdict is the newest ci check-run GitHub attaches to that commit, whichever event
started it, and release.yml asks the same question again with --wait 300. A commit that
reached master unchanged from a pull request's head carries that pull request's run too, so a
red or unfinished run there is the verdict until master's own push run on the commit concludes
and is the newer one. Tag only after that push run is green: a tag pushed earlier is refused
by the release workflow, at once on a red run and after five minutes on one still running,
and is released by dispatching release.yml with the tag once the verdict is green.
# 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, an
adapter over .ai/repo/ci/release.yaml and the generated
matrix:
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.
A pre-release is never the stable release
A tag with a pre-release part (v1.0.0-alpha.1) is published as a GitHub prerelease and never
as its "latest" (--prerelease --latest=false in the publish step of release.yml), and
scripts/release-record records it on channel: prerelease: the channel is derived from the
tag unless --channel is given, and --channel stable on a suffixed tag exits 2. The version
decides as well as the record: Release::is_stable_candidate is false for any version with a
pre-release part, so even a record that was mislabelled can never become releases/latest.json,
and Release::findings reports the mislabel. A pre-release is installed by naming it
(install.sh --version 1.0.0-alpha.1); an unpinned installer and a node that follows stable
keep resolving the highest stable release. Versions order by semantic-version precedence
(alpha.2 before alpha.10, a pre-release before its own final, build metadata ignored).
That order is release::version::Version's, the crate's one reading of a version: the stable
pointer, the release writer and the compatibility gate share it, so they cannot rank a pair
of versions differently, and a record whose version is not a semantic version at all is never
the stable pointer either. release bump --exact 1.0.0-alpha.1 writes a pre-release, and the
gate accepts a tree that declares one; docs/RELEASE.md says how a pre-release meets the floor.
The crate's distribution::tests and release::version tests prove the resolution and the
order; test/cases/3179_a_prerelease_is_never_published_as_stable.sh proves the two
shell-side writers, and test/cases/3212_a_prerelease_version_is_bumped_and_ordered.sh the
writer, the gate and the order against a real repository.
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
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, "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). 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:
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
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:
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,
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:
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:8099Recovering 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:
# .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:
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, once it exists:
self update is the design above and is not implemented yet.
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
or a Nix derivation would each read that index rather than replacing it, and none of them is
implemented. Nothing in the model prevents one. The container image is the one consumer that
exists: the release workflow's image job builds it from the release's own Linux archives
and pushes it to the GitHub container registry under the release's tag.
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 |