Checks, makes and publishes a release to every channel a project ships to, from one config file per repository, for the agents and people who release it.
This document states what must hold, not how. README.md has the install and a summary of every command, docs/commands.md every command, its output and ship.toml, docs/releasing.md the reusable release workflow, and src/README.md maps the modules that meet these requirements.
A project that ships to a GitHub release, npm and a Homebrew tap repeats the same release flow in scripts of its own: gate the tree, bump the version, commit, tag, then build, attest, publish and repin in CI. agent-ship holds that flow once. check judges whether HEAD is ready to release, reports the bump its Conventional Commits warrant, and changes nothing; release bumps the version, commits and tags it, and prints the push; the owner pushes the tag; the tag runs the project's release.yml, which calls agent-ship's reusable workflow to publish to each channel; status waits on the runs the tag started and says whether each channel serves the version; and doctor reports each credential the channels need. Each repository keeps only its ship.toml and a release.yml that calls the reusable workflow.
Primary user: whoever releases a project, an agent or a person, from a checkout of it, and the CI run its tag starts. agent-ship releases itself the same way.
- FR-1 — agent-ship reads its configuration from
ship.tomlat the root of the checkout it runs in. A command that needs it is refusednot_a_checkoutoutside a git checkout, andconfig_missingin a checkout without one. - FR-2 —
ship.tomlnames the version source, the tag pattern, the release branch, the project's check command, whether tags are signed, and the channels with each one's settings, and may name notes paths and bump floors. A file that is not TOML, lacks a key it needs, holds a key agent-ship does not know, or names a channel kind it does not know, is refusedconfig_invalid, naming the key and what is wrong with it. So is annpmchannel that gives only one ofbuildandout, or anoutoutside the checkout. - FR-3 — The version source is
package.json, and the version is itsversionfield in the formx.y.z. A version in another form is refusedconfig_invalid. - FR-4 — The tag pattern is
v<version>, or<app>-v<version>in a repository of several apps; a release's tag is the pattern filled with its app and version. - FR-5 — A
ship.tomlwith apps gives each app its own version source, tag pattern, check command, channels, notes paths and bump floors, and may give itpaths(FR-14). A command that takes[<app>]acts on the app it names; with apps, a missing or unknown app isbad_arguments, and without apps, an app given isbad_arguments. - FR-6 —
ship.tomlholds names and settings, never a secret. A value that has the form of a GitHub token, an npm token or a private key is refusedsecret_in_config, naming its key and never the value. - FR-7 — The notes paths name the project's changelog and store release notes, and each bump floor names paths and a level,
minorormajor; a floor with another level is refusedconfig_invalid. Each path is a file path or a glob relative to the checkout's root, where*matches within one path segment and**across segments.
- FR-8 — Each channel in
ship.tomlnames the credentials it reads, each by the name of the variable that holds it. agent-ship reads no credential a channel does not name. - FR-9 — A credential is present when the environment sets it, else when the
.envfile at the checkout's root sets it, else when the origin repository on GitHub holds an Actions secret of that name. A credential the channel marks as a file is present only where its value is the path of a readable file. - FR-10 —
doctorchecks exactly the credentials the configured channels name, each once, and reports for each its name, the channels that read it, whether it is present, and its source:environment,.env,actions-secret, or the path of its file. It never prints a credential's value. - FR-11 — The reusable workflow masks each credential value it reads in the run's log before any step that could print it.
- FR-12 — The previous tag is the tag with the highest version among those matching the tag pattern, other than a tag at HEAD. The bump is judged from the commits HEAD reaches that the previous tag does not, read as Conventional Commits: a commit with
!before its subject's colon, or with aBREAKING CHANGE:orBREAKING-CHANGE:footer, warrantsmajor, orminorwhile the version is below1.0.0; a commit whose type isfeat, in any case and with or without a scope, warrantsminor; any other commit warrantspatch. The bump is the highest level a commit warrants. - FR-13 — A bump floor applies to a commit FR-12 reads that changes a file the floor's paths match, and raises that commit's level to at least the floor's, a
majorfloor givingminorwhile the version is below1.0.0. - FR-14 — An app may name
paths, each a file or a glob matched as FR-7 says. For an app that names them, a commit counts only when it changes a file they match: FR-12 reads only those commits, FR-17 counts only those, and FR-20 refuses a previous tag that none of them follows. An app that names nopathscounts every commit.pathsoutside an app is a key agent-ship does not know (FR-2). - FR-15 — The commits that decide the bump are those whose level, under FR-12 and FR-13, is the bump's, each named by its short commit and its subject.
- FR-16 — With no previous tag, no bump is computed: the bump is
none, and no commit decides it.
- FR-17 —
check [<app>]judges whether HEAD can be released and changes nothing in the checkout, its refs or any channel. When none of FR-18 to FR-25 refuses it, it prints the app, the version, the tag, the previous tag (FR-12), the number of commits since it that count (FR-14), the bump with the commits that decide it, and the warnings of FR-26. - FR-18 — A change to a tracked file, or an untracked file git does not ignore, is refused
dirty_tree. HEAD not on the release branch is refusednot_on_release_branch. HEAD other than the release branch's commit on origin, as origin reports it, is refusedunpushed_commits, except as FR-19 says. - FR-19 — HEAD that descends from origin's commit of the release branch is not refused
unpushed_commitswhen every commit between them changes only files the notes paths match. - FR-20 — The latest tag is the tag with the highest version among those matching the tag pattern, the version's own tag aside. A version not newer than the latest tag's is refused
version_not_newer, whoseresolvenamesrelease. HEAD that is the latest tag's commit is refusedno_commits_since_tag, and so is a previous tag (FR-12) with no commit counted by FR-14 among the commits HEAD reaches that it does not. A repository with no such tag meets neither. - FR-21 — A tag at HEAD that matches the tag pattern and is not the version's own tag is refused
tag_mismatch, naming both versions. The version's tag at HEAD is refusedtag_unsignedwhen it is lightweight, or whenship.tomlsays tags are signed and it carries no signature that verifies. - FR-22 — The version's tag, where it exists, pointing at a commit that is not HEAD and that HEAD does not descend from is refused
tag_not_at_head. - FR-23 — The version's tag at a commit HEAD descends from, other than HEAD, marks the version as released and refuses nothing:
checkprints the version and its tag, that tag as the previous tag (FR-12), and the bump the commits since it warrant, whichreleasewith no level or version takes (FR-29). - FR-24 — A credential the configured channels name that is not present (FR-9) is refused
missing_credential, naming the credential and the channels that read it. - FR-25 — The project's check command, run in the checkout, exiting other than 0 is refused
check_failed, naming the command and its exit status. - FR-26 — Each line of a tracked file where
TODOorFIXME, as a whole word, is the first word after a comment opener (//,/*,#,--,;, or a*that is the line's first non-blank character) is a warning naming the file and the line number, and refuses nothing. Test files, Markdown files and files the notes paths match give no warning. A test file has a path segmenttest,testsor__tests__, or a name holding.test.,.spec.or.acceptance..
- FR-27 —
release [<patch|minor|major|<version>>] [<app>] [--dry-run]makes the release: it writes the new version to the version source, runscheck's conditions on it with that change exempt fromdirty_tree, commits it alone aschore: release <tag>, creates the annotated tag, signed whenship.tomlsays so, and prints the version, the tag, the commit and the command that pushes the branch and the tag. - FR-28 —
patch,minorandmajorraise that part of the current version and zero the parts after it. An explicit version is in the formx.y.z. A first argument that is neither a level nor a version in that form is the app (FR-5). A new version not newer than the latest tag's (FR-20) is refusedversion_not_newer. - FR-29 — With no level or version given,
releaseraises the version by the bump (FR-12, FR-13). - FR-30 — A level below the bump, or a version below the one the bump makes, is refused
bump_below_commits, naming the bump and the commits that decide it. - FR-31 — With no previous tag (FR-16),
releasewith no level or version tags the recorded version at HEAD, makes no version commit, and prints HEAD as the commit; a level or version given is applied as FR-28 says, and FR-30 does not bound it. - FR-32 —
releasenever pushes and never contacts a channel to publish: origin's refs are the same afterreleaseas before it. - FR-33 — A
releasewhose commit or tag fails is refusedrelease_failed, exit 1, naming the step that failed and git's message. Areleasethat is refused leaves the version source, the branch and the tags as they were, so a rerun after the resolving act converges. - FR-34 —
release --dry-runis refused asreleasewould be, except that it does not run the project's check command; otherwise it prints the version, the tag, the commit's subject,nullwhere FR-31 makes no commit, and the push command it would use. It changes nothing in the checkout, its refs, origin or any channel.
- FR-35 —
status <tag>reports the tag's workflow runs (FR-36), then, for each channel of the tag's app, whether it serves the tag's version:served,missing, orunreadablewith the reason. A tag that matches no tag pattern ofship.tomlis refusedbad_arguments, and a tag the repository does not hold,tag_missing. It exits 1 when a run is notpassedor a channel is notserved, and 0 otherwise. - FR-36 — The tag's workflow runs are those of each workflow in
.github/workflowsat the tag's commit whose push trigger runs on the tag.statuswaits until each of those workflows has a finished run, for at most 60 minutes, and reports each workflow with its run's state:passed,failedwith GitHub's conclusion,runningwhen its run had not finished by then, ormissingwhen it had no run by then. - FR-37 — The
github-releasechannel is served when the tag has a published release, not a draft, with every file the channel names attached. - FR-38 — The
npmchannel is served when the registry serves each package the channel names at the version. - FR-39 — The
homebrewchannel is served when the tap's formula the channel names pins the version.
- FR-40 —
doctorreports its checks, each with a name, a state ofok,warnorfail, a detail and a fix (nullwhen it passes), and exits 1 when a check fails and 0 otherwise. It readsship.tomlas every command does, refused as FR-1, FR-2 and FR-6 say, and checks git andghonPATH, and each credential (FR-10); a missing credential fails, with a fix naming where to set it. It changes nothing.
- FR-41 — agent-ship's
.github/workflows/release.ymlruns when another workflow calls it, and when av*tag is pushed to agent-ship itself, so agent-ship releases through it. A run whose event is a dispatch, the caller's when called, is a dry run (FR-47); any other run releases the tag it runs on. Two runs of one tag never publish at once: the workflow holds a concurrency group per repository and ref that never cancels a run in progress, and a caller holds one of its own under another name. A caller passes its secrets and grants the job that publishes to npm the permission to request an OIDC token, whichdocs/releasing.mdshows. - FR-42 — The workflow refuses
tag_mismatchon a tag that is not the version source's release tag, or, in a dry run,dry_run_app_invalidon an app input that does not fitship.tomlas FR-5 says; thenmissing_credentialwhen a credential a configured channel names is missing. For annpmchannel with nobuild, it refusespackage_not_trackedwhen no trackedpackage.jsonnames a package the channel names,package_version_mismatchwhen one's version is not the release's, andpackage_orderwhen the channel lists a package before a listed package it depends on. A run these refuse publishes nothing. - FR-43 — The
github-releasechannel runs its build once for each target,darwin-arm64on a macOS runner andlinux-x64on a Linux one, withTARGETnaming it, so each build is smoke-tested on its own platform. It puts each file a target's build made at its path, refusingtarget_files_conflictwhen two targets made one file with different bytes, then runs the channel's assemble command, when it names one, for the files that span targets. It attests the build provenance of each file the channel names, creates the tag's release as a draft with every file attached, and publishes it only once every file is attached. - FR-44 — The
npmchannel publishes each package the channel names, in the order it lists them, with provenance, through npm's trusted publishing and with no npm token. A package without abuildis published from the directory of the trackedpackage.jsonthat names it. With abuild, the channel, when the app also has agithub-releasechannel, first waits for the tag's release to be published (FR-43) and puts each of its files at its path; then it runs thebuild, and publishes each package from the directory of thepackage.jsonunderoutthat names it; it refusespackage_not_builtwhen none does, andpackage_version_mismatchandpackage_orderas FR-42 says, before it publishes any package. Then, on a macOS and a Linux runner, through npm and through Bun, it installs the version of each listed package no other listed package depends on into a prefix of its own. It runs the channel's smoke command in the project's checkout withVERSIONset to the version, and with only the installed programs and the system's onPATH, Node among them only when the channel setssmoke-node. It fails when Bun is on thatPATH, or Node withoutsmoke-node. - FR-45 — The
homebrewchannel, once the tag's release is published, dispatches the tap named in the channel to repin its formula to the version. - FR-46 — A channel that already serves the version (FR-37 to FR-39) is skipped, so a rerun of the workflow on the same tag publishes nothing twice and reaches each channel still missing.
- FR-47 — A dry run takes the app its app input names, or the only one, at its recorded version, and that version's tag, and reads no channel. It refuses as FR-42 says. It runs every target's build and the assemble command as FR-43 says, and with an npm
build, builds the packages from those files and packs each without publishing it. It attests, drafts and publishes no release, publishes and smokes no package, and dispatches no tap.
- FR-48 — No command, and
--help, print the result whosecommandisagent-shipand whoseresultholdscommandsandflags, each entry with its name, usage and summary, and exit 0.--versionprints the version alone on one line and exits 0.--helpor--versionwith an argument iswrong_arity. - FR-49 — A result is TOON on stdout, the object of
command, the command's name,ok, true, andresult, its value, with exit 0, except thatstatusanddoctorexit 1 as FR-35 and FR-40 say. An error is TOON on stderr, the object ofcommand,ok, false, anderror, with itskind,code,message,metaandresolve, whereresolvenames the act that resolves it; the exit is 1 for a refusal, 2 for a usage error and 3 for a fault. - FR-50 — An unknown command is the usage error
unknown_command; a command given a missing, empty, extra or unknown argument isbad_arguments. - FR-51 — Where more than one error applies, a command reports only the first in this order: the npm launcher's refusals (FR-52),
no_build_for_platform,platform_package_missingandbinary_failed; then the usage errors judged from the arguments alone,unknown_command,wrong_arityandbad_arguments; thennot_a_checkout,config_missing,config_invalidfor aship.tomlthat is not TOML,secret_in_configand any otherconfig_invalid; thenbad_argumentsjudged againstship.toml, for an app (FR-5) or a tag (FR-35); thendirty_tree,not_on_release_branch,unpushed_commits,version_not_newer,no_commits_since_tag,bump_below_commits,tag_mismatch,tag_unsigned,tag_not_at_head,missing_credentialandcheck_failed; then the command's own,release_failedandtag_missing.
- FR-52 — The launcher
npm/agent-ship, the program@cniska/agent-shipinstalls, runs the binary of the platform package installed beside it, with the arguments it was given, and exits with that binary's status; it needs no Bun. It refusesno_build_for_platformon a platform agent-ship ships no build for,platform_package_missingwhen no platform package is installed beside it, andbinary_failedwhen that package's binary is not executable, each with exit 1 and the error as TOON on stderr (FR-49).
- NF-1 — The tests never read or write the real home or touch the network:
HOMEand the XDG directories point at scratch directories, origin is a bare repository on disk, andghand the registries are stand-ins that record each call. - NF-2 — Every command reads
ship.toml, git and the channels afresh; nothing is remembered between commands. - NF-3 — No command prints a credential's value, in a result, an error or a refusal's
meta.
- The
publish <tag> [--channel <kind>]... [--dry-run]command, dispatching CI on a tag and refusing unless it passes (ci_failed,ci_not_dispatchable), andunknown_channel: later build steps, with the channels they run. - The
testflight,playanddeveloper-idchannels, their build numbers, release notes and rollouts (build_number_spent,notes_missing,production_refused): later build steps. - Version sources other than
package.json, such as a pubspec or an Xcode scheme: they come with the channels that need them. - Fetching a credential from a secret store: a credential comes from the environment,
.envor the repository's Actions secrets alone. - Writing the changelog or store release notes: the project commits them before
release, and they go out with the tag's push (FR-19). - Pushing: the owner pushes the branch and the tag
releaseprints. - A project's web deploy, and moving any other repository onto agent-ship.
- AC-1 — In a scratch repository with no
ship.toml:checkisconfig_missing; outside git:not_a_checkout; with aship.tomlthat is not TOML, one missing the version source, one with an unknown key, one with an unknown channel kind, and one whosepackage.jsonversion is1.2:config_invalidnaming each key. (FR-1, FR-2, FR-3) - AC-2 — A
ship.tomlwhosehomebrewchannel sets a value shaped as a GitHub token, one shaped as an npm token, and one holding a private key:checkanddoctoreach refusesecret_in_confignaming the key, and neither output holds the value. (FR-6, NF-3) - AC-3 — A clean repository on
mainmatching origin, with the annotated tagv1.0.0, one commitfix: handle empty inputsince and version1.0.1, a.gitignoreignoring.env, and aship.tomlwhosehomebrewchannel namesTAP_DISPATCH_TOKENand whose check command isexit 0, all committed and pushed, withTAP_DISPATCH_TOKENset in the environment:checkprints the version1.0.1, the tagv1.0.1, the previous tagv1.0.0, one commit, the bumppatchdecided by that commit, and no warning, and the checkout's status, refs and origin are as before. (FR-4, FR-15, FR-17) - AC-4 — From AC-3's repository: with a modified tracked file, then an untracked file, then on another branch, then with a commit origin lacks:
dirty_tree,dirty_tree,not_on_release_branch,unpushed_commits; with both an untracked file and another branch:dirty_tree, the first in order. (FR-17, FR-18, FR-51) - AC-5 — From AC-3's repository with notes paths
CHANGELOG.mdandnotes/**committed and pushed: a commit origin lacks that changesCHANGELOG.mdandnotes/en/release.txt:checkpasses; one more that changesnotes.txt:unpushed_commits; with notes pathsnotes/*instead, committed and pushed, and one commit origin lacks that changesnotes/en/release.txt:unpushed_commits. (FR-7, FR-18, FR-19) - AC-6 — From AC-3's repository: with version
0.9.0committed and pushed:version_not_newernamingrelease; with the annotated tagv1.0.1at a commit on a branch fromv1.0.0's commit, which HEAD does not descend from:tag_not_at_head; withv1.0.0made again at HEAD, so no commit follows it:no_commits_since_tag, the first in order, thoughtag_mismatchapplies too; in a repository with no tag:checkpasses with no previous tag and the bumpnonedecided by no commit. (FR-16, FR-20, FR-21, FR-22, FR-51) - AC-7 — From AC-3's repository with the tag
v1.0.1made: lightweight at HEAD:tag_unsigned; annotated at HEAD:checkpasses againstv1.0.0; with signed tags in a committed and pushedship.tomland the unsigned annotated tag made again at that HEAD:tag_unsigned; lightweight with one more commit made and pushed after it:checkpasses againstv1.0.1; with nov1.0.1and an annotatedv0.5.0at HEAD:tag_mismatchnaming0.5.0and1.0.1. (FR-21, FR-22, FR-23) - AC-8 — From AC-3's repository with
TAP_DISPATCH_TOKENunset:checkismissing_credentialnaming it andhomebrew; set instead in.env, which the repository ignores: passes; unset in both and held as an Actions secret, as theghstand-in lists it: passes; withship.toml's check commandexit 3committed and pushed:check_failednaming it and 3. (FR-9, FR-24, FR-25) - AC-9 — From AC-3's repository with notes path
CHANGELOG.mdand these files committed and pushed:src/a.tsholding// TODO tidyon its third line;src/b.tsholding// FIXME: xon one line and// TODOSon another;src/c.tsholdingTODOin a string, in a regular expression, after another word in a//comment and after a*inside a line;src/e.pyholding# TODO a;src/f.sqlholding-- FIXME;src/g.tsholding* TODO binside a/*block on its second line and/* FIXME */on its fourth;src/h.elholding; TODO; andsrc/a.test.ts,src/a.spec.ts,src/a.acceptance.ts,test/c.ts,tests/c.ts,__tests__/c.ts,README.mdandCHANGELOG.mdeach holding// TODO:checkpasses with seven warnings,src/a.tsline 3, theFIXMEline ofsrc/b.ts,src/e.py,src/f.sqlandsrc/h.elline 1, andsrc/g.tslines 2 and 4; with an ignored filesrc/d.tsholding// TODOadded, it gives the same seven. (FR-17, FR-26) - AC-10 — From AC-3's repository with one more commit pushed, each in a fresh copy of it,
checkreports the bump:feat: add x:minor, decided by it alone;feat(cli): add x:minor;FEAT: add x:minor;fix!: drop x:major, decided by it alone;fix: drop xwith aBREAKING CHANGE: x is gonefooter:major;fix: drop xwith aBREAKING-CHANGE: x is gonefooter:major;docs: x:patch, decided by both commits; with version0.4.1and tagv0.4.0in place of AC-3's, andfeat!: drop x:minor, decided by it alone. (FR-12, FR-15, FR-17) - AC-11 — From AC-3's repository with a floor
minoronsrc/schema/**committed and pushed: one more commitfix: xthat changessrc/schema/v2.ts:checkreportsminor, decided by it; one that changessrc/other.tsinstead:patch; with the floormajorand the commit changingsrc/schema/v2.ts:major, and with version0.4.1and tagv0.4.0in place of AC-3's:minor; a floorpatch, and a floorhuge:config_invalidnaming the floor's level. (FR-7, FR-13, FR-15) - AC-12 — From AC-3's repository:
release patchprints1.0.2, tagv1.0.2, the commit and the push command;package.jsonholds1.0.2, the commit's subject ischore: release v1.0.2and it changes onlypackage.json, the tag is annotated at that commit, and origin's refs are as before. (FR-27, FR-32) - AC-13 — From AC-3's repository with version
1.4.2committed and pushed, eachreleasein a fresh copy of it:release minormakes1.5.0,release majormakes2.0.0andrelease 3.0.0makes3.0.0; withv3.0.0and one more commit pushed,release 2.9.9:version_not_newer;release nextandrelease 3.1:bad_arguments, andrelease 3.1with an untracked file:bad_arguments; with signed tags in a committed and pushedship.tomland a signing key in the scratch home, the tagreleasemakes verifies. (FR-21, FR-28, FR-51) - AC-14 — From AC-3's repository, each in a fresh copy:
releasemakes1.0.2; with one more commitfeat: add xpushed,releasemakes1.1.0; withfix!: drop xinstead,releasemakes2.0.0. A clean pushed repository with version1.0.0and its only tagv1.0.0one commit behind HEAD, each in a fresh copy: with that commitfix: x,releasemakes1.0.1; with itfeat: add x,releasemakes1.1.0andrelease patchisbump_below_commits. (FR-12, FR-27, FR-29, FR-30) - AC-15 — From AC-3's repository with one more commit
feat: add xpushed:release patchandrelease 1.0.9are eachbump_below_commitsnamingminorand that commit, and leavepackage.json, the branch and the tags as before;release minor,release major,release 1.1.0andrelease 1.2.3each succeed in a fresh copy;release 0.9.0isversion_not_newer, andrelease patchwith an untracked file isdirty_tree, each the first in order. (FR-30, FR-33, FR-51) - AC-16 — A clean pushed repository with no tag, version
0.1.0and a commitfeat!: drop x:releasecreates the annotated tagv0.1.0at HEAD, prints HEAD as the commit, makes no commit and leavespackage.jsonat0.1.0; in a fresh copy,release patchmakes0.1.1with nobump_below_commits. (FR-16, FR-28, FR-31) - AC-17 — From AC-3's repository with an untracked file, then with
ship.toml's check commandexit 1committed and pushed, then with a commit hook that refuses the commit:release patchisdirty_tree, thencheck_failed, thenrelease_failednaming the commit, each exit 1; after each,package.json, the branch and the tags are as before; then, with the cause removed,release patchmakes1.0.2. (FR-33) - AC-18 — From AC-3's repository:
release --dry-runprints1.0.2,v1.0.2,chore: release v1.0.2and the push command, and the checkout's status, refs and origin are as before; withship.toml's check commandexit 1committed and pushed, it prints the same; with one more commitfeat: add xpushed,release patch --dry-runisbump_below_commits; in AC-16's repository,release --dry-runprints0.1.0,v0.1.0and the commit subjectnull. (FR-34) - AC-19 — A clean pushed repository with apps
webandcli, each with its own channels and notes paths and version1.0.1, the annotated tagsweb-v1.0.0andcli-v1.0.0one commitfix: set versionsbehind HEAD, and a floorminorunderwebonweb/**:check clipasses for the tagcli-v1.0.1;checkandcheck api:bad_arguments; in a repository without apps,check cli:bad_arguments; with one more commitfix: xchangingweb/a.tsandcli/a.tspushed,check clireportspatchandcheck webminor; then a commitdocs: web notesorigin lacks changing onlyweb's notes paths:check webpasses andcheck cliisunpushed_commits; with that commit pushed,release patch climakes1.0.2, changing onlycli's version, and tagscli-v1.0.2, and in a fresh copyrelease climakes the same. (FR-4, FR-5, FR-28, FR-29) - AC-20 — A tag
v1.0.1whose commit holds a workflowrelease.ymlpushed onv*tags, whose run theghstand-in reports succeeded, whose GitHub release is published with every file, whose npm packages the registry stand-in serves and whose formula the tap stand-in pins at1.0.1:status v1.0.1reportsrelease.ymlpassedandgithub-release,npmandhomebrewserved, exit 0. (FR-35, FR-36, FR-37, FR-38, FR-39) - AC-21 — From AC-20: with the release a draft, then a file missing from it; with one npm package not at the version; with the formula at
1.0.0; with the registry stand-in failing:status v1.0.1reports each channelmissing, orunreadablewith the reason, exit 1;status v9.9.9:tag_missing;status nope:bad_arguments. (FR-35, FR-37, FR-38, FR-39, FR-51) - AC-22 — From AC-20 with a workflow
check.ymlpushed on branches only besiderelease.yml, on a clock passed in:status v1.0.1reportsrelease.ymland notcheck.yml; withrelease.yml's run running, then succeeded before 60 minutes pass:passed, exit 0; with it failed:failedwith the conclusionfailure, exit 1; still running at 60 minutes:running, exit 1; with no run at 60 minutes:missing, exit 1. (FR-35, FR-36) - AC-23 — Channels naming
TAP_DISPATCH_TOKENforhomebrewandNPM_SMOKE_KEYas a file fornpm:doctorwith the token in the environment and the file's path in.envreports both present with sourcesenvironmentand the file's path, git andghok, exit 0; with the token only as an Actions secret: its source isactions-secret; with neither set and the path naming no file: both fail with a fix naming where to set each, exit 1; no output holds the token's value;doctorchecks no credential no channel names. (FR-8, FR-9, FR-10, FR-40, NF-3) - AC-24 — The reusable workflow, read as YAML: its triggers are
workflow_call,workflow_dispatchand a pushedv*tag, the first two each with an optional string inputappthat defaults to empty, its concurrency group holds the repository and the ref and does not cancel a run in progress, and the job that publishes to npm holdsid-token: write. (FR-41) - AC-25 — The reusable workflow, read as YAML: the step that checks the tag and the credentials precedes every build and publish step, and every job that reads a credential masks it before any other step that reads it. (FR-11, FR-42)
- AC-26 — The reusable workflow, read as YAML: the build runs once for
darwin-arm64onmacos-latestand once forlinux-x64onubuntu-latest, withTARGETset, and keeps its files; the release job, after every target's build, puts their files at their paths, runs the assemble command when there is one and checks every file, before the attestation of every file; the release is created as a draft after that attestation and published in a later step; the npm job, with abuild, waits on the published release, puts the release's files at their paths, the only step given the GitHub token, and builds before it finds each package's directory, publishes with provenance and no npm token in its environment, then a later job, once for each ofdarwin-arm64onmacos-latestandlinux-x64onubuntu-latestthrough each of npm and Bun, none cancelling the others, installs the version of the packages no other depends on from the registry into a prefix of its own and runs the smoke command in the project's checkout with that prefix's programs onPATH; the tap dispatch waits on the published release; each publish, the release, npm and the tap, is skipped when its channel already serves the version; and every file set the workflow keeps replaces any an earlier attempt of the run kept. (FR-43, FR-44, FR-45, FR-46) - AC-27 — No command, and
--help, printcommand: agent-shipwithcheck,release,statusanddoctorand the--helpand--versionflags, each with its usage and summary, exit 0;--versionprints the version alone, exit 0;--help nowand--version now:wrong_arity, exit 2. (FR-48) - AC-28 —
frobnicate,statuswith no tag andcheck a b, each also outside a git checkout: exit 2, nothing on stdout,unknown_commandandbad_argumentson stderr as TOON with the usage asresolve; a refusal from AC-4: exit 1 with the error as TOON on stderr; a result from AC-3: TOON on stdout, exit 0. (FR-49, FR-50, FR-51) - AC-29 — The test environment's
HOMEand XDG directories are scratch directories, not the account's; everyghand registry call in the suite reaches a stand-in that recorded it. (NF-1) - AC-30 —
checkpasses in AC-3's repository; commit and push aship.tomlnaming another credential that is unset, andcheckismissing_credentialwith no other change. (NF-2, FR-24) - AC-31 — From AC-3's repository:
release patch, with its commit and tagv1.0.2pushed, then one more commitfeat: add xpushed:checkpasses, printing the version1.0.2, the tagv1.0.2, the previous tagv1.0.2, one commit and the bumpminordecided byfeat: add x; thenreleasemakes1.1.0. (FR-17, FR-23, FR-29) - AC-32 — A clean pushed repository with apps
webandcli, versions1.0.1inweb/package.jsonandcli/package.json, the annotated tagsweb-v1.0.0andcli-v1.0.0one commitfix: set versionsbehind HEAD, andpathsweb/src/**underweband none undercli:check webisno_commits_since_tagandcheck clipasses with one commit; with one more commitfeat: add xchanging onlycli/a.ts, then onefix: ychangingweb/src/a.ts, pushed:check webpasses with one commit and the bumppatchdecided byfix: y, andcheck cliwith three commits and the bumpminordecided byfeat: add x; aship.tomlwithout apps that namespaths:config_invalidnamingpaths. (FR-2, FR-5, FR-12, FR-14, FR-17, FR-20) - AC-33 —
scripts/target-files.ts, given the directory of two targets' builds,darwin-arm64makingdist/app-darwin-arm64.tar.gzanddist/install.sh,linux-x64makingdist/app-linux-x64.tar.gzanddist/install.shwith the same bytes, and the three files: puts each at its path in the checkout and printsfiles, each file with the targets that made it, exit 0; withdist/install.shmade with different bytes:target_files_conflictnaming it and both targets, exit 1, and no file written. (FR-43) - AC-34 —
scripts/release-plan.ts, run on a dispatch in a repository with appswebat1.0.1andcli, each with ahomebrewchannel namingTAP_DISPATCH_TOKEN: with the app inputweband the token among the secrets, prints the tagweb-v1.0.1, the version1.0.1and thehomebrewchannel at stagedry-run, writes the tag to the outputs and calls nogh, exit 0; with the app input empty, thenapi:dry_run_app_invalid, exit 1; withcliand no secrets:missing_credential, exit 1; neither refusal writes an output. (FR-41, FR-42, FR-47) - AC-35 — The reusable workflow, read as YAML: the plan reads the event, the app input and the ref; in a dry run the targets' builds, the merge and the assemble run and the release job keeps every file, the npm job fetches them, builds and packs each package with
--dry-run, and the attestation, the draft, the publish, the release download, the npm publish and its wait, the smoke and the tap dispatch each run only at stagepublish; the release and npm jobs take the tag from the plan. (FR-47) - AC-36 — The packages
scripts/build-npm.tsbuilds from stub tarballs, installed into a prefix with only the prefix's programs,/usr/binand/binonPATH:agent-ship status "v1.0.0 --x"runs the stub platform binary with those two arguments and exits with its status; with aunamenaming a platform with no build:no_build_for_platform; with no platform package:platform_package_missing, and forfrobnicatetoo, the first in order; with the binary not executable:binary_failed, and once it is executable again it runs; each refusal exits 1 with nothing on stdout and the error as TOON on stderr. (FR-51, FR-52)
- D-1 — The
agent-shipCLI, compiled fordarwin-arm64andlinux-x64and installed from its GitHub release, from npm as@cniska/agent-ship(FR-52), or from the Homebrew tap; from a checkout, Bun runs it throughbin/agent-ship, whichmise installin the primary checkout links ontoPATH. - D-2 — The reusable workflow
.github/workflows/release.yml, and agent-ship's first release, published by its own tag through that workflow from agent-ship's source to its GitHub release, npm, as@cniska/agent-shipand its two platform packages, and the Homebrew tap, withstatusreporting each channelserved. - D-3 —
README.md,AGENTS.md,src/README.md,docs/commands.md,docs/development.md,docs/releasing.md,CONTRIBUTING.mdand this spec. - D-4 — The test suite, run by
bun run check, and the acceptance suite, run bybun run acceptance, whose test names cite the criteria in §5. - D-5 — The Claude Code plugin
cniska-shipunderplugin/: at each session start, whenagent-shipis onPATH, it adds a note mapping each release step tocheck,release,release --dry-runorstatus <tag>.
- C-1 — TypeScript on Bun, which mise installs, with TOON through
@toon-format/toon. - C-2 —
releasenever pushes; the owner pushes the tag, and the tag starts the release. - C-3 — npm publishes only from CI through trusted publishing, so every publish runs in the reusable workflow, never on the machine that runs
release. - C-4 — git and
ghare the programs the CLI runs; it reads the npm registry and the tap over HTTPS.