Repository navigation
npm trusted publishing auto-enables provenance and discards the --provenance-file bundle (dogfood 4 read-back failure) #97
Description
Observation (live, fourth dogfood)
During the fourth M1 dogfood (vers-js run 31840088262, evidence recorded in #30), the publish step failed closed at npm.publish.readback with windlass.verify.error.registry-linkage-mismatch. The registry mutation itself succeeded, but the slsa.dev/provenance/v1 attestation visible on the registry is an npm-CLI-generated provenance statement, not the Windlass-signed Statement produced and verified by the sign job.
Byte-level comparison of the two statements:
| Field | Windlass signed bundle (preserved artifact js-ts-npm-provenance-bundle-31840088262-1, sha256 354f4c61…) |
Registry-published bundle |
|---|---|---|
| subject digests | sha256 and sha512 | sha512 only |
externalParameters |
10 keys incl. source.* (#81 fields all correct) |
workflow only, no source.* |
internalParameters |
{} |
{"github":{…}} |
builder.id |
reusable workflow @5964b19c… (full SHA pin) |
https://github.com/actions/runner/github-hosted |
Root cause (pinned to npm CLI source, npm/cli@51c2bf8)
- npm OIDC trusted publishing auto-enables provenance when the config is at its default:
lib/utils/oidc.js#L144-L163setsopts.provenance = truewhenisDefaultProvenance && !ciInfo.CIRCLE. - In
libnpmpublish/lib/publish.jsbuildMetadata()(L103-L181), provenance attachment is governed by two separate if statements:The outer// Outer if — decides whether any provenance bundle is attached at all (has no else). if (provenance === true || provenanceFile) { ... // Inner if — selects the bundle SOURCE. if (provenance === true) { provenanceBundle = await generateProvenance([subject], opts) // npm generates its own } else { provenanceBundle = await verifyProvenance(subject, provenanceFile) // verify & upload the supplied file } root._attachments[provenanceBundleName] = { ... } // whichever bundle was selected is uploaded }
if (provenance === true || provenanceFile)evaluates fine in every configuration. The defect is the inner branch selection: with auto-enabledprovenance === true, the innerif (provenance === true)branch is taken andgenerateProvenance()produces npm's own statement; the innerelse—verifyProvenance(subject, provenanceFile), the only path that consumes the--provenance-filebundle — never executes. The supplied Windlass bundle is silently discarded and npm's generated bundle is uploaded in its place. - Our publish path (
internal/npmprofile/publish.go:218-227) passes--provenance-filebut never disables automatic provenance.publishEnvironment(publish.go:543-564) filters inheritedNPM_CONFIG_*variables, which leavesprovenanceat its default and thereby satisfies the auto-enable condition, forcing the innerprovenance === truebranch described above. - The read-back then behaved exactly as designed: npm's statement carries only one subject digest, failing
matchesPublishedBundle(publish.go:379-394, exactly-two-digests requirement), so no valid Windlass candidate was found and the run classifiedPublishForeignConflict→registry-linkage-mismatch(publish.go:169-170).
Why this is a bug, not a new decision
The normative requirement already exists:
docs/architecture/js-ts-npm-provenance-publish.md:1439-1441: “The profile must not use npm's automatic provenance generation.”- ADR 0029 (lines 102-115): the profile must fail rather than silently fall back to npm automatic provenance.
The implementation never enforced the disabling mechanism because npm's trusted-publishing auto-enable was not known when the publish path was specified. Per the dogfood review this observation is recorded here rather than as an ADR: it is not an architecture decision, it is an external-platform behavior pin plus an implementation defect against an existing spec clause.
Fix requirement
The publish invocation must explicitly disable npm automatic provenance whenever --provenance-file is supplied, so that the inner branch reaches verifyProvenance(subject, provenanceFile). Supported mechanism (npm documents provenance and provenance-file as mutually exclusive configuration): set NPM_CONFIG_PROVENANCE=false in the isolated publish environment, or write provenance=false into the isolated npm user/global config that publishEnvironment already provisions. No registry POST /-/npm/v1/attestations upload API exists; --provenance-file remains the only supported external-bundle path (precedent: slsa-github-generator Node.js publish action).
Acceptance
- Publish argv/environment carries an explicit provenance-disable that survives npm's OIDC auto-enable (unit test asserting the constructed environment/argv, plus a fixture or integration assertion that npm receives
provenance=false). - Read-back behavior unchanged (semantic binding per spec); the digest-count rejection that caught this live remains covered by tests.
- After merge: caller pin bump, then dogfood retry with a new version v0.1.3 (npm policy: an unpublished version number can never be reused — 0.1.2 stays published to preserve the Phase 6.4 foreign-conflict test target).
Activity
yunseo-kim commentedon Aug 14, 2026
Upstream-bug analysis: the silent
--provenance-filediscard contradicts npm's own documentationFollowing the RCA above, I evaluated whether the observed behavior is an npm upstream defect rather than expected behavior. Conclusion: this appears to be a doc-contradicting upstream bug (or at minimum an undocumented compatibility defect), which is unreported.
Documentation contradictions (npm official docs)
--provenance-fileprovenance-fileis supplied” exception is documented anywhereThe documented opt-out is itself unusable in exactly the situation that needs it. The trusted-publishing docs prescribe
NPM_CONFIG_PROVENANCE=falseorprovenance=falsein.npmrc/publishConfig, but under npm'sexclusiveconfig enforcement (provenancevsprovenance-fileare mutually exclusive):@npmcli/config): the env var is silently skipped when--provenance-fileis set via CLI (env carve-out in#loadObject);@npmcli/config10.3.1): no carve-out, the same combination throws--provenance can not be provided when using --provenance-file;publishConfig.provenance=false: the manifest flatten runs afteroidc()(commands/publish.jsL145 vs L248), so it cannot prevent the auto-enable.Regression-by-addition history (npm/cli PRs)
--provenance-fileexplicitly for externally-generated provenance — “If specified, the provenance statement will be read, verified and published as an attachment to the package.”--provenanceflag”) with noprovenance-fileprecedence test or discussion.Prior reports
I found no existing issue covering
provenance-filebeing discarded, auto-provenance precedence, or the exclusive-config conflict with the documented opt-out. Nearest neighbors are unrelated OIDC publish failures (#8678, #8730).Assessment
A minimal upstream fix would swap the inner branch precedence in
libnpmpublish/lib/publish.jsbuildMetadata()(preferprovenanceFilewhen set) and/or skip the auto-enable inlib/utils/oidc.jswhenprovenance-fileis configured.yunseo-kim commentedon Aug 16, 2026
Upstream fix submitted: npm/cli#9882 (fixes npm/cli#9879).
The PR addresses the root cause pinned in this issue:
lib/utils/oidc.jsno longer auto-enables provenance when aprovenance-fileis configured (opts.provenanceFileguard covers all config sources, CLI/env/npmrc/publishConfig, sinceoptsis fully flattened before the OIDC flow runs).libnpmpublish'sbuildMetadata()now throwsEPROVENANCECONFLICTwhen bothprovenance: trueandprovenanceFileare set, instead of silently preferring generation.Verified with a new CLI regression test (OIDC trusted publishing +
provenance-fileasserts the published.sigstoreattachment deep-equals the supplied bundle, and that sigstore generation is never invoked) plus a libnpmpublish conflict test; full suite green at 100% coverage.Impact on the fix requirement recorded here: once this ships in an npm release,
npm publish <tarball> --provenance-file=<bundle>under trusted publishing will publish the Windlass-signed bundle as-is, so theNPM_CONFIG_PROVENANCE=falseworkaround inpublishEnvironmentbecomes unnecessary on fixed npm versions. Until then, the explicit disable remains the correct mitigation for npm 11.x/12.0.x, so the dogfood retry plan (v0.1.3) is unaffected. Will update here when the PR lands.docs(decisions): add ADR 0082 pinning the builder-owned npm CLI version
docs(decisions): Add ADR 0082 pinning the publish-stage npm CLI versi…
docs(decisions): Add ADR 0083-0085 pinning the npm CLI toolchain acro…
yunseo-kim commentedon Oct 3, 2026
Resolved upstream: fix shipped in npm 12.1.0 / 11.20.0 (libnpmpublish 12.0.1 / 11.2.1)
npm/cli#9882 merged 12026-09-03 (
c9876d7) and shipped on 2026-09-22 in both supported release lines:c9876d7(#9882)ddbadfc(#9947 backport)On fixed versions,
lib/utils/oidc.jsno longer auto-enables provenance when aprovenance-fileis configured (theopts.provenanceFileguard covers every config source — CLI, env, npmrc, publishConfig), and libnpmpublish'sbuildMetadata()throwsEPROVENANCECONFLICTon the programmaticprovenance: true+provenanceFilecombination. The silent-discard path pinned in this issue no longer exists: under OIDC trusted publishing,npm publish --provenance-file=<bundle>now publishes the supplied bundle as-is.Consequences for this repository:
NPM_CONFIG_PROVENANCE=falseworkaround described in this issue's Fix requirement section is unnecessary on fixed npm versions and will not be implemented (ADR 0083, option 1).Closing as resolved upstream; adoption follow-through is tracked in #30.
docs(provisioning): Record the package-manager provisioning strategy …
feat(npmprofile): Pin the publish npm CLI and Node.js toolchain with …