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-L163 sets opts.provenance = true when isDefaultProvenance && !ciInfo.CIRCLE.
- In
libnpmpublish/lib/publish.js buildMetadata() (L103-L181), provenance attachment is governed by two separate if statements:
// 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
}
The outer if (provenance === true || provenanceFile) evaluates fine in every configuration. The defect is the inner branch selection: with auto-enabled provenance === true, the inner if (provenance === true) branch is taken and generateProvenance() produces npm's own statement; the inner else — verifyProvenance(subject, provenanceFile), the only path that consumes the --provenance-file bundle — 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-file but never disables automatic provenance. publishEnvironment (publish.go:543-564) filters inherited NPM_CONFIG_* variables, which leaves provenance at its default and thereby satisfies the auto-enable condition, forcing the inner provenance === true branch 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 classified PublishForeignConflict → 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).
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.readbackwithwindlass.verify.error.registry-linkage-mismatch. The registry mutation itself succeeded, but theslsa.dev/provenance/v1attestation 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:
js-ts-npm-provenance-bundle-31840088262-1, sha256354f4c61…)externalParameterssource.*(#81 fields all correct)workflowonly, nosource.*internalParameters{}{"github":{…}}builder.id@5964b19c…(full SHA pin)https://github.com/actions/runner/github-hostedRoot cause (pinned to npm CLI source,
npm/cli@51c2bf8)lib/utils/oidc.js#L144-L163setsopts.provenance = truewhenisDefaultProvenance && !ciInfo.CIRCLE.libnpmpublish/lib/publish.jsbuildMetadata()(L103-L181), provenance attachment is governed by two separate if statements: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.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.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.”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-fileis supplied, so that the inner branch reachesverifyProvenance(subject, provenanceFile). Supported mechanism (npm documentsprovenanceandprovenance-fileas mutually exclusive configuration): setNPM_CONFIG_PROVENANCE=falsein the isolated publish environment, or writeprovenance=falseinto the isolated npm user/global config thatpublishEnvironmentalready provisions. No registryPOST /-/npm/v1/attestationsupload API exists;--provenance-fileremains the only supported external-bundle path (precedent: slsa-github-generator Node.js publish action).Acceptance
provenance=false).