agentleFS
relvo-labsrelvo-labs/agent-runtime/.agents/skills/npm-release/SKILL.md

npm-release

Skill0 starsChanged 48 days agoApache-2.0View on GitHub

Operate and change the manual, environment-gated npm publication path, where scope, order, integrity and registry facts are all proven before any credential exists.

5 other projects use a version of this.

Adopted by

Most recently taken up by

The file

We split this skill into the rules it gives an agent

4 rules. 0 are common, 2 are distinctive, and 2 are very specific to this project.

  • 0

    Common

    Most of the 5 edited versions we read keep these.

  • 2

    Distinctive

    This author's own choices. Worth a look.

  • 2

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    Use this skill when a change touches any of:

    line 13
  2. Distinctive:

    Do not use this skill when:

    line 22
  3. Very specific:

    Unrecognised header _formats_ are refused: Only the POSIX ustar signature (ustar\0 + 00) is accepted.

    line 145
  4. Very specific:

    Read docs/release.md for publication policy and current registry status.

    line 277

“Common” is measured: kept by at least 6 in 10 of the 5 edited versions of this file we could read. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

npm release

Trigger

Use this skill when a change touches any of:

  • .github/workflows/release.yml
  • anything under tools/release/
  • docs/release.md or docs/adr/ADR-0017-manual-npm-release.md
  • the question "how would these packages actually reach the registry?"

Counter-trigger

Do not use this skill when:

  • you are deciding a version number or writing version intent — use changesets-release; this skill never chooses a version, it only publishes one that a commit already carries
  • you are changing what a package ships — use package-artifact-validation
  • you are changing the canonical gate or the validation workflow — use local-ci-parity
  • you want to publish now: publication is a human decision with an environment approval, not something an agent may initiate. All eight public packages have immutable 0.2.0 and 0.3.0 versions; none is ever republished or named in a dispatch again. A 2026-09-27 registry observation found latest at 0.3.0 for all eight. Provider 0.4.0 is not published.

Owns

  • .github/workflows/release.yml — the only workflow that may reach a publish credential
  • tools/release — preflight, staging verification, publication, the npm tool identity and the workflow policy check
  • docs/release.md — the operator runbook and the outstanding-approval record
  • docs/adr/ADR-0017-manual-npm-release.md — the decision record for this path

Does not own

  • .github/workflows/gate.yml — owned by local-ci-parity
  • tools/repo/gate.ts — owned by local-ci-parity; this skill contributes one step to it
  • packages/*/package.json#version — owned by changesets-release
  • .changeset — owned by changesets-release
  • tools/repo/check-artifacts.ts — owned by package-artifact-validation

Relationships

  • boundary-with → local-ci-parity — that skill owns the workflow directory and the canonical gate; this skill owns the one release workflow inside it and the release gate step.
  • boundary-with → changesets-release — that skill decides versions; this skill refuses to run while any version intent is still pending.
  • depends-on → package-artifact-validation — the gate's tarball checks are what makes a packed artifact publishable at all.
  • depends-on → pnpm-supply-chain — publication installs frozen and with lifecycle scripts denied.

Procedure

  1. Nothing publishes itself. The workflow has exactly one trigger, workflow_dispatch, on main. There is no push, tag, schedule or workflow_run entrance, and no job or step may carry an if: — a release step that can be skipped is a release step that can be green without running.
  2. The operator states every fact. Four required inputs, none defaulted: the full 40-character source_sha, an explicit packages scope of name@version entries, the dist_tag, and a confirm phrase that restates the count, the commit and the tag:

    publish <count> package(s) from <source_sha> to <dist_tag>

    A typo in any input invalidates the confirmation, because the expected phrase is derived from the parsed inputs.

  3. Two jobs, one credential. verify is ungated: it checks out the named commit, runs the canonical pnpm gate, packs exactly the named packages, runs the fail-closed preflight and uploads the staged plan plus tarballs. publish needs verify, runs in the npm-release environment, downloads that exact artifact id (with digest-mismatch: error, which the action drives from artifact metadata — it takes no expected-digest input), re-verifies everything locally, and only then runs one step that can see secrets.NPM_TOKEN. Building never happens in a step that holds the credential.

    Both jobs install the workspace from the lockfile, frozen and script-free. In the gated job that install is a control, not a convenience: it is where the pinned npm comes from, and without it there is no tool whose identity the job can prove (see step 5).

  4. Preflight refuses; it never repairs. Findings are refusals, and there is no override input. It refuses pending version intent, a commit that is not main's tip, a dirty tree, a version the commit does not carry, an artifact whose identity or contents are not the reviewed ones, a dependency range it cannot resolve to one exact version, a scope that is not closed over its own dependency graph, a dependency cycle, an existing version, a dist-tag regression, and any registry answer that is not a definitive 404 or a well-formed packument. A packument missing dist-tags, naming a tag that is not an exact version, or pointing a tag at a version it does not list is malformed: the dist-tag is the mutable half of a packument and reading it as "unset" is how a release moves latest backwards while reporting success. For linked packages, only packages entering a Changesets plan are aligned; an adapter-only plan can contain two packages.
  5. An artifact has exactly one identity, or it is refused. What this repository reads out of a tarball and what npm extracts from it must never be two different packages. Paths are normalised the way an extractor normalises them and collisions are refused, header checksums are verified, appended data and content outside package/ are refused, and unsupported archive semantics are refused rather than skipped. pacote-differential.test.ts asserts the resulting property against npm's own bundled reader, offline: same identity, or refusal. Never relax that test to "we parse tar the same way" — the invariant is about disagreement, not parsing.

    Which npm, exactly. That argument is worth nothing unless the npm the test reads with is the npm that publishes. tools/release/lib/npm-tool.ts is the single answer to "which npm": the package pinned in the catalog, taken from the explicit path node_modules/npm, proven before use, and used by both pacote-differential.test.ts and publish.ts. It never falls back to PATH and takes no environment override — an unresolvable, malformed, mislocated or wrong-versioned tool is a refusal, and refusals name every reason at once because the reader is usually looking at a runner they cannot poke at. npm is a declared devDependency here rather than something inherited from the runtime because pnpm/setup installs Node from the node package, whose payload is the node binary alone: guessing at process.execPath's neighbours found no npm and made the suite unloadable (run 34699256419), while spawn('npm') published through whatever the runner image happened to ship. Those were the same bug seen from two ends, and splitting the tool apart again reopens it.

    Locate; do not resolve, and do not stop at the manifest. Two rules earned the hard way, when a review broke the first version of this module twice:

    • createRequire(root).resolve('npm/…') answers "what would require find", which is not "what does this repository depend on". It walks every ancestor node_modules and then NODE_PATH, and with no npm installed in the workspace at all, both were accepted as the publishing tool. So: the root manifest must declare devDependencies.npm: "catalog:", the package is read from exactly <repoRoot>/node_modules/npm, and its real path must be owned by <repoRoot>/node_modules. node_modules/npm is a symlink under pnpm and must stay usable, so the rule is about where the link lands, not that it exists.
    • A package's main and exports are part of its identity, so containment on package.json proves nothing about the code that loads. A bundled pacote whose main was ../../../../outside.cjs, or whose exports target was a symlink out of the tree, passed inspection and then loaded foreign code. Each reader's entry point is therefore resolved, realpath'd, confined to that reader's own directory and recorded; loadNpmModule loads that exact file and refuses any id not proven here. Never hand a caller a bare require rooted in npm — that re-runs resolution at load time, which is precisely where the checked answer gets replaced by an unchecked one.

    Two rules of different kinds keep that true, and confusing them breaks something:

    • Unrecognised header formats are refused. Only the POSIX ustar signature (ustar\0 + 00) is accepted. A review built a second package/package.json header with valid checksums, a populated prefix and no signature: npm ignored the prefix and let it overwrite the real manifest; this reader applied the prefix and saw a harmless path. Refusing the format closes that.
    • Inside ustar, npm's field semantics are reproduced literally — the prefix split including its byte-475 155-vs-130 branch, and the rule that a regular-file entry whose name ends in / is a directory. prefix is not refused: node-tar genuinely emits it for deep dist/ paths, so refusing it would reject artifacts pnpm pack actually produces. Refusing what npm accepts breaks the release; accepting what npm reads differently breaks the approval.
  6. The approval is not a snapshot. An approval can sit for hours while main advances, a dist-tag moves and a dependency is unpublished. Every fact the approval rested on is re-established in the gated job, and again immediately before each individual upload: the dist-tag not already pointing at something newer, every packed dependency still resolvable at its exact version — including dependencies this run published itself, because "we uploaded it" is memory and the registry is fact — and source_sha still being main's tip on the remote, read with git ls-remote rather than from the checkout's cached origin/main, which cannot change once the job has started. An unanswerable remote refuses; it is never read as agreement.
  7. Publication is ordered, explicit and non-overwriting. One tarball per npm publish, in dependency order, with --ignore-scripts --access public --provenance --tag <tag> --registry https://registry.npmjs.org and a temporary --userconfig npmrc that interpolates the token from the environment and is removed in a finally. Each upload is followed by a registry readback of identity, integrity, dependency ranges and dist-tag before the next package is attempted.
  8. A zero exit is accepted_pending, immediately, and the upload never happens twice. npm scans a newly published package before serving it: usually about five minutes, documented as possibly fifteen or more. Run 34753860073 attempt 3 read the registry five times separated by 3000 ms — four sleeps, about twelve seconds — and failed a publication that had already succeeded; @relvo-labs/agent-protocol@0.2.0 first answered 200 publicly 466 s later, byte-identical to the reviewed artifact. So the moment npm publish exits zero the version is recorded as accepted_pending and is never described as unpublished, and publishRelease refuses to hand the same subject to npm publish a second time in a run.

    What follows is read-only reconciliation (tools/release/lib/visibility.ts): exact registry lookups on a deterministic backoff — 5 s doubling to a 30 s cap, the last delay clipped to the bound — for a budget of 20 minutes per package, deliberately longer than the 15 minutes npm documents, because a bound equal to the documented worst case fails on exactly the runs it exists to survive. Only the two expected post-acceptance answers are retried: the package absent (404), or a well-formed packument that does not yet list the exact version. A network failure, 5xx, timeout, rate limit, auth failure or malformed packument ends it at once (readback_unavailable) — an unanswerable registry is not a registry that is still scanning, and spending the delay budget on a 401 turns a credential problem into a job timeout. An identity, integrity, shasum, dependency, peer-dependency or dist-tag mismatch ends it at once too (readback_mismatch): the registry answered, and more reads cannot change the answer. classifyReadback is fail-closed — an unrecognised finding code is a stop, not a wait.

    The bound is a bound. When it expires the run stops non-zero as visibility_not_confirmed, before any dependent is attempted, and reconciliation becomes a human step: check the npm account's notifications for a manual-review or blocked-package notice and use the appeal path offered. Never republish.

    .github/workflows/release.yml's publish job timeout is derived from the same constants — 8 × (20 m budget + 2 m upload and pre-upload rechecks) + 14 m setup, download and staged verification = 190 minutes — and workflow-policy.ts asserts that exact number. A timeout below the wait the publisher is willing to make is the same defect as a readback window below the scan delay, with the runner rather than the program abandoning an accepted upload.

  9. Partial failure stays partial, and acceptance is not verification. On any failure the run stops non-zero. A zero exit from npm publish means the registry accepted the bytes; a readback means it serves what was reviewed. The report keeps those apart — published, acceptedPending, and unknown for an upload it could not adjudicate — because reporting an accepted-but-unconfirmed upload as "not published" invites a recovery dispatch naming a version that is already public and immutable. Nothing is unpublished, no version is overwritten, and recovery is a new human-reviewed dispatch whose scope names only what is confirmed still unpublished. Anything logged about an upload is a bounded line of plan facts written through the redacting sink in publish.ts; never command output, npmrc contents or the environment.
  10. Changing this path is a reviewed change. pnpm release:check describes the reviewed workflow as an exact structure: allowed job and step keys, exact job env and outputs bindings, exact action inputs, the exact command sequence per job, and an allow-list of every ${{ … }} expression that may appear anywhere in the file. It is exhaustive on purpose — a field the policy does not assert is a field an attacker chooses, and continue-on-error, a shell: override, an extra run:, a constant in place of a dispatch input and secrets['NPM_TOKEN'] were each demonstrated to pass a policy that only checked a list of properties. Editing the workflow therefore means editing EXPECTED_JOBS in workflow-policy.ts too; that is the review step. Add a new action to ALLOWED_ACTIONS only with an independently verified commit SHA, and never pass an action input the pinned revision does not declare. Job timeouts are part of that structure: EXPECTED_JOB_TIMEOUT_MINUTES asserts each one exactly, and the gated job's value is computed from the visibility constants rather than typed, so the workflow and the publisher cannot drift apart.

Verification

pnpm release:check                 # structural policy for the release workflow, job timeouts included
pnpm exec vitest run tools/release # tool identity, preflight, ordering, registry, readback, visibility
pnpm gate                          # both of the above, plus everything else CI runs

Visibility reconciliation is tested through publishRelease itself — the function tools/release/publish.ts calls — with the upload and registry transports replaced and a virtual clock, so a beyond-15-minute delay and a bounded expiry are both asserted in milliseconds of real time and no npm process exists. Keep it that way: a test that only exercises a polling helper proves nothing about the orchestrator that waits, and the property worth protecting is "exactly one upload, however long the wait". Never add a test that can reach a real registry, and never assert visibility by publishing something.

A local pnpm gate passing proves less about the release path than it looks like it does if your Node came from nvm or a system package: that layout has a bundled npm, and the runner's does not. When changing anything about how the tool is located, reproduce the CI layout rather than trusting the local one — install the pinned pnpm executable, run pnpm runtime set node <matrix version> -g into a scratch PNPM_HOME, and run the gate with that node first on PATH.

Rehearse preflight locally against real packed artifacts, without credentials. Report local facts honestly: never supply workflow_dispatch or refs/heads/main to impersonate a hosted dispatch. The 0.2.0 scope below deliberately names an already-published immutable version so registry preflight must reject it; never copy it into a real dispatch. A local run must refuse release eligibility. Packing still precedes diagnostic evaluation; issue #26 tracks that separate limitation.

export RELEASE_EVENT_NAME=local_nonpublishing_verification
export RELEASE_REF=$(git symbolic-ref -q HEAD || printf detached)
export RELEASE_SOURCE_SHA=$(git rev-parse HEAD) RELEASE_RUNNER_SHA=$(git rev-parse HEAD)
export RELEASE_PACKAGES='@relvo-labs/agent-protocol@0.2.0'
export RELEASE_DIST_TAG=latest
export RELEASE_CONFIRM="publish 1 package(s) from $RELEASE_SOURCE_SHA to latest"
node tools/release/preflight.ts --staging /tmp/release-staging

This one-package example is a rejection-only diagnostic rehearsal, not a release scope. Read docs/release.md for publication policy and current registry status. The library release inventory does not assert feature coverage: the gate owns that proof. Raw pending-file checks remain required so empty or malformed intent cannot authorize publication through an empty release plan.

Provenance

  • Source: independent — authored for this repository against the npm CLI documentation (publish, dist-tag, --provenance, --userconfig), npm's documented publish-time malware-scanning availability delay (usually about five minutes, possibly fifteen or more), the npm registry packument format, and the GitHub Actions documentation for workflow_dispatch inputs, environments, job outputs and artifact digests.
  • Observed-evidence: run 34753860073 attempt 3 of this repository — accepted upload, 466 s to public visibility, matching integrity — is the case the visibility budget and the derived job timeout are sized against.
  • Reviewed-not-copied: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c — its action.yml contract and src/download-artifact.ts were read to establish that the pinned revision declares no digest input and derives the expected hash from artifact metadata. No text was incorporated.
  • Compared-against: pacote and tar, as bundled with the npm pinned in pnpm-workspace.yaml's catalog — the same package this repository publishes with. They are loaded offline, out of that package, for the identity-agreement test. npm is a declared devDependency (one lockfile entry; it vendors its own dependencies) and no code from it is incorporated. It was previously loaded from whichever npm happened to sit beside process.execPath, which assumed an official Node distribution's layout.

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.