Development record
The catalog pins the latest release's plugin archive
Context
itd-67 promised
that every release publishes a version-stamped plugin and that a plugin update
pulls it. What shipped stamped the version only into a payload staged outside the
repository behind launch ship --payload-dir, which no workflow published, while
the catalog (.claude-plugin/marketplace.json) sourced the plugin from ./ — the
unversioned git tree. An install therefore took whatever main held, with no
version and no fingerprint (iss-2609230733497536, found by the itd-67 fidelity
audit).
adr-19 keeps every version out of the
working tree, and adr-20 pins the catalog's
version keys ABSENT in the source view. Both were written for a catalog that
points into the tree. The host harness reads a published release only through an
archive source ({"source": "archive", "url": …, "sha256": …}, Claude Code
v2.1.224 or later), so every way of making "an install at vX gets vX's stamped
payload" true had to change what the committed catalog says.
The product thinker ruled on 2026-09-23 (E1, 09:50Z): a pinned archive, because "the user should get the latest cut release from github; verified as such and fingerprinted through a hash"; and (E2, 09:55Z) this amendment to adr-19 and adr-20. The harness floor was accepted and is documented (E3, 10:13Z); contributors load the plugin from their own checkout, with no second catalog entry (E4, 10:23Z).
Decision
We will source the plugin from the latest release's pinned archive, and this decision amends adr-19 and adr-20 accordingly.
- What main's catalog carries. The plugin listing's
sourceis{"source": "archive", "url": "<repository>/releases/download/v<version>/<plugin>-plugin-v<version>.zip", "sha256": "<digest>"}for the newest dated release. The address derives fromplugin.json'srepository. The catalog still carries no version key, andplugin.json's version stays out of the working tree: adr-19's polarity and adr-20's source view hold unchanged for every version key. What changes is that the catalog is no longer release-neutral — it names one release by address and digest, and the ship rewrites it every cut. - Who writes it.
launch shiprenders the release's archive from its tree after writing the dated heading, and rewrites the listing's source (and the surface snapshot beside it) in the same working tree, so the pin is part of the release-content commit. It refuses beforehand on a payload with uncommitted changes, whose archive the tagged commit could not reproduce. - Where it pins. The ship pins only in a repository whose version-location
contract (
.abcd/config/version-location.json) carries"publishes_plugin_archive": true, the statement that its release uploads the archive. Without that declaration it leaves the catalog untouched and reportsarchive: not pinned(archive_unpinnedin--json): the contract alone says where the version lives, not that a release publishes an archive, and a managed repository's scaffolded workflows upload none, so a pin there would name an asset every install fails to fetch. A key that is not a boolean is refused rather than read as either answer. - What proves it. The archive is reproducible: sorted entries, stored
uncompressed, one fixed timestamp, modes normalised to 0644 or 0755, and the
catalog left out (it names the digest, so it cannot be inside what it
hashes).
release.ymlre-renders it from the tagged commit withabcd launch archive --tag <tag> --verify --repository <owner/name>inverify, before anything is built, and again in the publish job, and publishes nothing unless the digest is the pinned one. The published archive joinschecksums.txt, the build-provenance attestation and the upload, and is downloaded fresh after publication, attestation-verified and byte-compared. - The bootstrap. Releases up to and including v0.9.0 were published without
an archive. While the newest dated release is one of those, the catalog keeps
./; the first ship past it writes the pin, andTestCommittedTreeSatisfiesDevPolarityrefuses an unpinned catalog from then on.
Alternatives Considered
- A′ — an unpinned archive at
releases/latest/download/. No per-release catalog edit, but a replaced asset installs silently: the product thinker's "fingerprinted through a hash" is exactly what it gives up. This is also the form draft itd-108 chose in its 2026-08-11 grill; the ruling overtakes that choice. - B — tag a stamped release commit off
main. Keeps the tree unversioned, but only@vXinstalls see the stamp, tags leavemain(breaking the changelog anchor and the RS002/RS003 reachability checks), and the tag commit would be bot-authored, which the attribution gate refuses. - C — commit the stamped version into
main. adr-19's rejected alternative: between releases,main's HEAD would be labelled with a version it is not. - D — publish the archive only, and amend the criterion's "update pulls it" half. Honest, and it leaves the finding unbuilt.
- A — the pinned archive (chosen). The harness itself refuses a tampered asset, and the release refuses a pin it cannot reproduce.
Consequences
- An install or update receives a cut release, stamped with its version,
fingerprinted by the digest the harness enforces. Installs stop following
main's tip. - The window. From the ship's merge until the publish job uploads the
archive, the catalog names a zip that is not there yet. An install or update in
that window fails closed and leaves an installed plugin on its previous
release, and nothing else can install because the digest refuses other bytes.
It cannot be closed from the release side (the pin must be in the tagged tree),
so it is documented in
release.ymland the release-day guide and kept short: The release follows the merge with no approval step (ADR-2610281718537125), and where the environment carries a reviewer, that reviewer approves the release deployment promptly. - A pin the release cannot reproduce is refused before the tag on the
auto-release path:
auto-release.yml'sdetectjob renders the archive from the pushed commit and verifies the pin, and that the pinned address is the repository's own release, before itstagjob may run. The pushed commit is not always the one the ship rendered — a merge-queue batch carries the ship with any other queued pull request — so the ship's dirty-tree refusal cannot stand in for it. A refusal there tags nothing; a follow-up pull request that re-pins or reverts retries on its push.verifyrepeats the proof after the tag, where a refusal consumes the version: that is the only proof a hand-pushed tag gets, and a localabcd launch archive --tag <tag> --verify --repository <owner/name>on the release branch is what catches it first. The--repositoryis not optional there: without it a pin whose address names another repository (a fork, a rename, a transfer) passes locally and is refused after the tag, consuming the version. - The harness floor is v2.1.224; older harnesses cannot install the plugin, and very old ones fail to load the catalog. The install instructions and the release notes state it.
- The stamped marketplace version and changelog entry (adr-20 R2/R3) live in the staged payload, where the render's lockstep proves them, and not in the published archive, which leaves the catalog out.
- The same-release trust root remains: the digest is committed by the ship and checked against a render of the same tree, so it proves integrity from commit to user, not the commit's own honesty — branch protection and the identity gate bound that, as they bound everything else the release publishes.
Frontmatter
| id | adr-2609231048308186 |
|---|---|
| type | adr |
| status | accepted |
| date | 2026-09-23 |
.abcd/development/decisions/adrs/2609231048308186-the-catalog-pins-the-latest-release-s.md
open on GitHub ↗ · commit history ↗
References3
- adr-28One repository, a curated release artifact — no dev→public mirrorrelated
- itd-67abcd Is An Installable, Versioned Claude Code Plugin Whose Repo Is Its Own Marketplace And Whose Every Launch Bumps, Tags, And Publishes A New Versionrelated
- itd-108The plugin installs from the curated release artifact, not the repo, and every cut release reaches users automaticallyrelated
Referenced by2
- adr-19The plugin version lives ONLY in the released artifact; the working tree stays unversioned, and the version location is chosen by a schema-validated decision, not hard-codedrelated
- adr-20The two release manifests stay version-consistent by an anti-drift checker over a pinned path list; the source view stays unversioned; `--allow-dirty` must never bypass itrelated
mentions6
- adr-2610281718537125A release publishes with no approval step
- iss-2609090715154147The convention is that a decision record is never amended in place: when a decision changes, a new record is minted and the old one is marked superseded by it, so the decision history stays legible and a reader of the old record is pointed forward rather than misled. Nothing in the tree states it. The typed-link vocabulary is recorded in .abcd/development/principles/decompose-before-filing.md as supersedes / reverses / duplicates / refines, and adr-58 and adr-2609021016286571 demonstrate the shape in practice, but the rule that amendment is not an option is carried only by example, so an agent or a contributor reaching for the smaller edit has nothing to read that refuses it. The gap surfaced when a relocation of the transcript corpus made adr-29's stated location wrong and the question of amend-versus-supersede had to be asked rather than looked up. Fix: state the rule where the conventions live, in the AGENTS.md decisions section or as a principle under .abcd/development/principles/, naming the two typed links and the one edit a superseded record does receive, and say why: an amended record erases the fact that the decision changed, which is the thing the record family exists to preserve. Detector: the conventions text names the rule and a docs test or record-lint rule refuses a decisions section that omits it, or at minimum the rule is present where a contributor reading AGENTS.md would meet it before editing a record.
- iss-2609230733497536itd-67 ac-3 promises that a ship bumps plugin.json.version, updates marketplace.json and that /plugin update abcd pulls the new version; delivered reality: launch ship stamps the version only into a payload staged outside the repository when --payload-dir is passed (internal/surface/cli/ship.go), release.yml uploads binaries and checksums.txt only, and the marketplace source ./ is the git tree whose committed manifests carry no version — so the version-stamped manifests are produced by nothing in the release path and consumed by nothing; the installed plugin's version arrives through the bootstrap binary download alone. Found by the itd-67 fidelity audit (rcp-7af1556ce4f7).
- iss-2609231050273096abcd update verifies a downloaded binary only against the checksums.txt published in the same GitHub release (internal/core/update/update.go), so it catches corruption in transit but not a release page whose binary and checksums.txt were both replaced; the binary needs an independent check next cycle, such as a digest committed in the repository's history the way the plugin catalog now pins the plugin archive (adr-2609231048308186), or verification of the release's signed build-provenance attestation (a new verification dependency needs sign-off first). Ruled by the product thinker on 2026-09-23 (E5, 10:27Z): harden next cycle; this release keeps the same-page check.
- iss-2610241550202749AGENTS.md's Single repo, curated release boundary, .abcd/work/CONTEXT.md:44-47, .abcd/README.md:4 and .abcd/development/README.md:3-5 still say .abcd/** is present in marketplace installs and that the launch bundler's structural deny has yet to run on a cut release. Since adr-2609231048308186 the marketplace catalog pins each release's plugin archive (.claude-plugin/marketplace.json names abcd-plugin-v0.15.1.zip), and RenderPluginArchive renders that archive through RenderPayload and the bundle's structural deny of .abcd/** (internal/core/launch/archive.go:116-132, internal/core/launch/render.go RenderPayload, internal/core/launch/bundle.go:8-10), so a marketplace install now carries no .abcd/ tree and the deny runs on every cut.
- iss-261025221427125004-launch.md:664-666 and commands/launch.md:1062-1064 say auto-release.yml renders the plugin archive on the pushed commit (the command page: 'in detect') before the tag; auto-release.yml runs no launch archive, and the CI renders are release.yml's verify (.github/workflows/release.yml:205) and release job (:545), as adr-2609231048308186:71-73 and 04-launch.md:139-142 say. Resolved iss-2609260552247395 fixed the same claim at commands/launch.md:152-153 only