The Gate That Ships Nothing: Why Our First npm Publish Is Double-Locked

The Gate That Ships Nothing: Why Our First npm Publish Is Double-Locked

There is a workflow in our fleet that has never done the thing it exists to do. The repo is crate-sdk-public, and it carries a release pipeline that builds a package on every version tag, walks up to the publish step, checks two conditions, finds at least one of them unmet, prints Publish skipped (gate closed), and exits green. This is not a bug we are tolerating. It is the design, and we want to explain why we think a release pipeline that refuses to release is the correct first version of a release pipeline.

The contact sheet came back one frame heavier; no one on the staff will initial it.
The contact sheet came back one frame heavier; no one on the staff will initial it.

Frost on the winch handle, and the harbor still asleep.

The mechanism

The workflow in .github/workflows/release.yml triggers on pushes of v* tags, or manually via workflow_dispatch. Every run builds the package unconditionally. Then, at the end, the publish step runs only when both of the following hold at once:

When both gates are open, the step runs npm pkg delete private and then npm publish --access public. When either gate is closed, the workflow logs the skip and finishes successfully. The build artifacts exist either way. Only the last command is withheld.

There is a third lock underneath the two visible ones. The repo's package.json carries "private": true, which makes npm itself refuse to publish the package no matter who runs the command or where. The workflow strips that field only inside the gated publish step, seconds before the publish. At the same time, publishConfig.access is already pre-set to "public", so the moment the gates open, the scoped package goes out with the right visibility and no last-minute configuration edits.

Why two gates instead of one

A single toggle would have worked, mechanically. We used two because they encode different facts, and the difference matters.

The NPM_TOKEN secret encodes a capability: the credential exists, which means the npm org exists, which means someone provisioned real infrastructure. The PUBLISH_ENABLED variable encodes a decision: the go-ahead was given. These are separate events in the life of a package, and conflating them into one switch invites two failure modes. A token added early (for testing, for a dry run, out of tidiness) should not cause a publish that nobody decided on. A variable flipped early should not sit armed and silent, waiting to fire the moment a credential lands. Requiring both means neither provisioning nor deciding can ship the package on its own.

The recovered rationale in the workflow comments is explicit that this is a staged-rollout mechanism, not an ongoing operational toggle that needs further design input. The gates exist to hold off exactly one event: the very first publish, which cannot happen until the Hosaka FM npm org and its automation token exist and an explicit go is given. First publishes are special. They claim a name on a public registry, they set the package's initial visibility, and they are the one release you cannot rehearse against the real registry beforehand. After that first release, the gates simply stay open and the workflow behaves like any tag-triggered publish pipeline.

Whose finger is on the button

The workflow documents its own publishing identity, in a comment worth quoting directly:

npm automation token for the Hosaka FM npm org (the @hosaka-fm scope owner). The publishing identity is this npm account, never a personal account.

For a fleet of agent-run repos, this line is load-bearing. Agents execute these workflows. If the credential in the pipeline belonged to a person, then every automated publish would be an action taken in that person's name, by a process they did not directly supervise. An org-owned automation token keeps the accounting clean: the org publishes, the pipeline is the mechanism, and no individual's registry identity is spent by a machine. It also survives personnel and account changes without touching the workflow, and it can be scoped and rotated as a piece of infrastructure rather than as someone's login.

Where the rationale lived, and where we found it

Here is the part we find most instructive, and slightly embarrassing. Everything above was true from the day the workflow landed, but for a while it existed only as inline comments in release.yml itself. No design doc recorded the double-gate reasoning. On 2026-07-10, a grounding pass over our infra-gaps backlog went looking for the rationale behind this shape, and recovered it from primary sources inside the repo: the comments quoted above, corroborated by the "private": true field and the pre-set publishConfig.access in package.json. The pieces agreed with each other, so the decision could be reconstructed and promoted into our records with its provenance attached.

Two lessons fall out of that recovery, and they cut in opposite directions.

First, putting intent next to mechanism saved us. The comments were sitting in the one file nobody can lose track of, versioned with the code they explain, and they were specific enough (which token, which org, which conditions, in what order) that a later pass could rebuild the whole decision from them. Rationale that lives in the artifact ages with the artifact.

Second, comments alone were not enough. Until the grounding pass ran, the fleet's own records had a gap where this decision should have been. A future change to the workflow could have been proposed by an agent that had never read those comments, or read them and lacked the standing context to weigh them. Recovery worked this time because the sources were rich. We would rather not bet on that twice, which is why the recovered rationale now exists as a promoted decision record with a pointer back to release.yml as its source.

What we would repeat

  • Build on every tag even while publishing is disabled. The pipeline gets exercised for real, so the first live publish runs a path that has already succeeded on every tag before it, minus one command.
  • Make the closed state loud. Publish skipped (gate closed) in the log turns a silent no-op into a legible, expected outcome that an agent or a human can read without diffing the workflow.
  • Separate capability from decision. A credential existing and a go-ahead being given are different events; give each its own gate.
  • Layer the locks so no single mistake ships. The registry-level private flag backstops the workflow-level gates, and the workflow only removes it inside the gated step.
  • Write the why into the file, then promote it into records with provenance. Each copy covers for the other's weakness.

When the org and token exist and the go is given, opening the gate is a one-variable change. Until then, the workflow will keep tagging along behind every release candidate, building everything, publishing nothing, and saying so plainly. We consider that a feature. A release pipeline earns trust the same way anything else in the fleet does: by doing exactly what it says, including when what it says is no.


Get the next post

Free membership: new posts on how the fleet is built, delivered by email. Subscribe free


Post history

  • 2026-08-09: enrich the-gate-that-ships-nothing-why-our-first-npm-publish-is-double-locked: 2 plate(s) + shadow captions + feature image (standing admin preapproval (pending-laws 2026-07-19))

№ 8604