Architecture¶
gh-ship is intentionally small. The interesting decisions are about what it refuses to do.
Principles¶
GitHub-first. Every GitHub interaction goes through the gh CLI. gh-ship
implements no REST client and handles no tokens, because gh already solves
authentication, enterprise hosts, SSO, and rate limiting.
Convention over configuration. The only required config is version and
workflows.prepare.
Never execute user release logic. gh-ship has no run: key and no shell
execution. Your workflows do the work.
Never manage secrets.
Zero local state. Everything is reconstructed from GitHub.
The anti-goal¶
This must never become a workflow engine
gh-ship's predecessor was a custom workflow DSL, and it failed in an instructive way: the DSL kept growing ad-hoc escape hatches for control flow it could not express, its built-in actions were perpetually incomplete so users fell back to raw shell anyway, and everything was stringly typed.
The lesson is that a workflow engine is a bottomless product. GitHub Actions already exists and is better at it. gh-ship orchestrates around it and stops there.
Layout¶
src/
main.rs # entry point: parse, dispatch, render diagnostics
cli.rs # the whole clap surface
artifact/ # the protocol: model, embedded schema, validation, span lookup
gh/ # everything that talks to GitHub, via the gh CLI
cli.rs # subprocess wrapper and error classification
workflow.rs # workflow discovery and contract checking
run.rs # dispatch, correlation, polling
repo.rs # branches, PRs, releases
commands/ # one module per subcommand
context.rs # the shared dispatch → wait → validate sequence
config.rs # .github/ship.yml
detect.rs # which branch are we releasing from?
branches.rs # base branch -> release line
render.rs # PR templating and artifact embedding
templates.rs # rendering the workflows `init` writes
style / logger / suggest
schemas/ # the published JSON Schemas — release artifact and config —
# embedded via include_str!
templates/ # the workflow templates `init` renders and emits, one
# MiniJinja source per role, branching on the token strategy
Errors are defined per module as thiserror enums rather than centrally, so
each carries its own miette diagnostic payload — code, help text and source
span — next to the code that can raise it.
Key mechanisms¶
Resolving the release line¶
Every lifecycle command starts by answering "which release am I working on?".
detect.rs finds the base branch — the --base flag, the GitHub Actions
environment, then the checkout's .git/HEAD — and branches.rs matches it
against the configured lines to produce a Line { base, release }. Context
does this once, so no command below it knows whether the repository has one
release line or five.
detect.rs reads .git/HEAD directly rather than shelling out to git. gh
is the only subprocess gh-ship spawns, and keeping it that way costs ten lines
and buys a detection story with no PATH dependency and no way to fetch.
Dispatch correlation¶
gh workflow run returns 204 No Content. There is no API that maps a dispatch to a
run.
gh-ship correlates on three things it controls rather than on anything the
workflow has to echo back: the ref (unique per invocation — a throwaway
staging branch for prepare, the tag for release), the event (only
workflow_dispatch runs are candidates, so a workflow also triggering on push
does not match its own push run), and novelty (the run ids on the ref are
snapshotted immediately before dispatching; the new one is ours).
The alternative — newest run after the dispatch timestamp — is wrong under concurrency, and wrong rarely enough to survive testing.
This asks nothing of the workflow beyond on: workflow_dispatch, which is what
lets a prepare or publish workflow be an ordinary reusable workflow. An earlier
design passed a nonce as a ship_id input and required it in run-name; it
worked, but the cost was borne by every user's workflow file forever.
Keying on the ref does mean the ref has to be dispatchable, and
workflow_dispatch accepts only a branch or a tag. The per-invocation ref is
therefore necessarily a visible branch — see Can the staging branches be
hidden?.
Zero state, via the PR body¶
gh ship release needs the artifact gh ship prepare validated, possibly days
later and on a different machine.
Rather than a state file, gh-ship embeds the artifact in the Release PR body inside an HTML comment:
Invisible when rendered, durable, and it outlives artifact retention. It also means
gh ship status is a pure query with no cache to go stale.
Who creates the release¶
gh-ship tags the merge commit and creates the GitHub Release. The publish workflow only builds and attaches assets.
The conventional choice is the other one — most release tooling tags and releases from CI — so the split deserves a reason.
Release notes are generated before the merge and must survive it. The prepare workflow runs your changelog tool against the release branch; the publish workflow checks out the tag, which is post-merge history. If the publish workflow regenerated the notes, what shipped could legitimately differ from what was reviewed and approved in the Release PR. The artifact exists so that what ships is what was read.
That also explains why the artifact carries release.name, release.prerelease
and release.make_latest: they exist so gh-ship can create the release. Moving
release creation into the workflow would strand them in a published v1 schema,
where they cannot be removed without a v2.
The honest cost. gh release create <tag> <assets…> already performs
draft → upload → publish atomically, so gh-ship's create-draft → dispatch →
undraft sequence reimplements it. gh-ship could only use the native behaviour by
holding the assets itself — downloading every binary from the workflow and
re-uploading it — which would push release payloads through whatever machine ran
gh ship release. The ordering below is the price of notes fidelity, not an
accident.
Staging the release commit¶
prepare does not build the release commit on the release branch. It cuts a
throwaway branch from the base, dispatches the prepare workflow there, and then
moves the release branch onto the resulting commit in a single update.
The obvious alternative — reset the release branch to its base and let the workflow rebuild it in place — was tried, and closes the Release PR. GitHub closes a pull request whose head becomes contained in its base, and a branch reset to its base is exactly that. Every prepare closed the Release PR and opened a replacement.
That close can be dodged: reset to the base, add an empty commit so the head is
never contained in it, and strip the commit again afterwards. The reason not to
is the ordering rather than the trick. As it stands, the release branch keeps the
previous release commit until the artifact has been downloaded and validated, and
then moves once; a workflow that fails, a process that dies, a --no-wait that
walks away all leave it exactly where it was. Resetting up front destroys the
previous release commit before the run meant to replace it has succeeded, and
spreads that exposure across a whole CI run — the phase most likely to fail,
since it runs your tooling. What is left behind then is not merely untidy: the
release branch holds an empty commit while the Release PR still advertises the
previous artifact in its body, and gh ship release reads that body. Stripping
the empty commit afterwards also means rewriting a commit your workflow authored,
where today gh-ship only moves a ref. The staging branch is the backup, kept by
doing the work somewhere else instead.
Staging avoids the empty state: the release branch moves from its previous release commit straight to the new one, and is never equal to the base.
It also fixes a subtler problem. workflow_dispatch reads the workflow
definition from the ref it is given, so dispatching on the release branch meant a
stale branch ran a stale copy of the workflow — the sort of thing that produces
"I fixed it, and it still fails". The staging branch is cut from the base, so the
definition is always current.
That same rule is why the staging ref is a branch rather than something tucked
away in a private namespace where nobody would have to look at it.
workflow_dispatch takes a branch or a tag and nothing else, so the ref gh-ship
dispatches on has to be one of them — see Can the staging branches be
hidden?.
Staging branches are named ship/prepare-<token> after a random per-invocation
token, and are deleted once the release branch has moved. The token is what makes
the branch — and so the run dispatched on it — belong to exactly one prepare.
A run that fails part-way leaves one behind, so each prepare sweeps them first.
With release lines configured the name also
carries the line — ship/prepare-<base>-<token> — and the sweep is filtered to
that same prefix. Two lines can then prepare concurrently without either one
deleting the ref the other's workflow_dispatch is running on.
Draft-first releases¶
Tag the merge commit → create the release as a draft → dispatch the publish workflow → undraft.
A draft is invisible to watchers, yet gh release upload still works against it.
That is the only ordering where a release becomes visible complete, rather than
notifying everyone about an empty release whose assets arrive minutes later.
The tag is created explicitly, first. A draft release does not create its git ref — the tag appears only when the release is published — and the publish workflow is dispatched on that tag and checks it out.
Diagnostics¶
Errors are miette diagnostics with source spans, so a schema violation points at the offending byte of the user's JSON:
× `/release` has unknown field `note`
╭─[ship.release.json:8:5]
8 │ "note": "## What's Changed\n"
· ───┬──
· ╰── not allowed here
help: did you mean `notes`?
Because serde_json discards positions, artifact/span.rs re-scans the raw text to
resolve a JSON Pointer to a byte range.
Every user-facing error carries a diagnostic code and a help that says what to do,
not just what happened.
Dependencies¶
| Crate | Why |
|---|---|
clap |
CLI |
serde, serde_json, serde_norway |
JSON and YAML |
boon |
JSON Schema 2020-12 |
miette, thiserror |
diagnostics |
minijinja |
PR templating |
demand |
init prompts |
uuid |
staging branch tokens |
owo-colors, strsim |
output and "did you mean?" |
No async runtime. The work is dominated by waiting on GitHub, and a synchronous poll loop expresses that more honestly than an executor would.
Testing¶
Unit tests for pure logic — schema validation, span lookup, templating, workflow parsing, error classification.
A fixture corpus at tests/fixtures/artifacts/{valid,invalid} that is the
executable specification of the protocol. Every rejection's diagnostic is
snapshotted, so a regression in message quality shows up as a diff.
A hermetic gh stub. Because every GitHub interaction goes through the gh
binary, replacing it on PATH with a script gives complete control over what GitHub
"says" — with no network, no credentials, and no recorded fixtures to keep in sync.
Tests describe scenarios declaratively:
GhStub::new()
.pr_state("MERGED")
.merge_commit("abc1234")
.artifact(r#"{"schemaVersion":1,"changed":true,...}"#)
The stub logs every invocation, so tests assert on what gh-ship actually asked
GitHub to do — including what it must not do, like preview never calling
pr create.