Skip to content

npm trusted publishing auto-enables provenance and discards the --provenance-file bundle (dogfood 4 read-back failure) #97

Closed

Description

@yunseo-kim

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)

  1. 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.
  2. 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.
  3. 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.
  4. 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).

Activity

  1. yunseo-kim commented on Aug 14, 2026

    @yunseo-kim
    MemberAuthor

    Upstream-bug analysis: the silent --provenance-file discard contradicts npm's own documentation

    Following 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)

    Document Promise Observed behavior
    npm publish: --provenance-file “the provenance bundle at the given path will be used” (unconditional) The file is never read when OIDC auto-provenance is active; npm's own generated bundle is uploaded instead
    Trusted publishing: automatic provenance generation Auto-generation conditions: trusted publishing + public repo + public package No “unless provenance-file is supplied” exception is documented anywhere

    The documented opt-out is itself unusable in exactly the situation that needs it. The trusted-publishing docs prescribe NPM_CONFIG_PROVENANCE=false or provenance=false in .npmrc/publishConfig, but under npm's exclusive config enforcement (provenance vs provenance-file are mutually exclusive):

    • npm 12.0.2 (@npmcli/config): the env var is silently skipped when --provenance-file is set via CLI (env carve-out in #loadObject);
    • npm 11.5.1 (@npmcli/config 10.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 after oidc() (commands/publish.js L145 vs L248), so it cannot prevent the auto-enable.

    Regression-by-addition history (npm/cli PRs)

    • #6490 (merged 12023-05-31, shipped npm 9.7.0): introduced --provenance-file explicitly for externally-generated provenance — “If specified, the provenance statement will be read, verified and published as an attachment to the package.”
    • #8412 (npm 11.5.x): introduced OIDC auto-provenance (“the equivalent of adding the --provenance flag”) with no provenance-file precedence test or discussion.
    • #8457: restricted auto-provenance to OIDC publishes; still no external-bundle precedence handling.
    • RFC 0049 supports accepting externally generated provenance bundles on publish, but never defines precedence against auto-generation.

    Prior reports

    I found no existing issue covering provenance-file being discarded, auto-provenance precedence, or the exclusive-config conflict with the documented opt-out. Nearest neighbors are unrelated OIDC publish failures (#8678, #8730).

    Assessment

    Explicit provenance-file input must take precedence over implicit OIDC auto-provenance, or npm must reject the combination explicitly. It must not silently discard the supplied bundle.

    A minimal upstream fix would swap the inner branch precedence in libnpmpublish/lib/publish.js buildMetadata() (prefer provenanceFile when set) and/or skip the auto-enable in lib/utils/oidc.js when provenance-file is configured.

  2. yunseo-kim commented on Aug 16, 2026

    @yunseo-kim
    MemberAuthor

    Upstream fix submitted: npm/cli#9882 (fixes npm/cli#9879).

    The PR addresses the root cause pinned in this issue:

    • lib/utils/oidc.js no longer auto-enables provenance when a provenance-file is configured (opts.provenanceFile guard covers all config sources, CLI/env/npmrc/publishConfig, since opts is fully flattened before the OIDC flow runs).
    • libnpmpublish's buildMetadata() now throws EPROVENANCECONFLICT when both provenance: true and provenanceFile are set, instead of silently preferring generation.
    • Config docs and the libnpmpublish README now state the precedence rule.

    Verified with a new CLI regression test (OIDC trusted publishing + provenance-file asserts the published .sigstore attachment 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 the NPM_CONFIG_PROVENANCE=false workaround in publishEnvironment becomes 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.

  3. yunseo-kim commented on Oct 3, 2026

    @yunseo-kim
    MemberAuthor

    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:

    Line npm CLI libnpmpublish Fix commit
    12.x v12.1.0 12.0.1 c9876d7 (#9882)
    11.x v11.20.0 11.2.1 ddbadfc (#9947 backport)

    On fixed versions, lib/utils/oidc.js no longer auto-enables provenance when a provenance-file is configured (the opts.provenanceFile guard covers every config source — CLI, env, npmrc, publishConfig), and libnpmpublish's buildMetadata() throws EPROVENANCECONFLICT on the programmatic provenance: true + provenanceFile combination. 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:

    • The blocker recorded here and in ADR 0083 is cleared. Per ADR 0083's initial-pin preference order, the npm 11-line backport (11.20.0, libnpmpublish 11.2.1) is the preferred initial publish-stage npm pin under the ADR 0082 bump procedure, subject to the review that procedure requires.
    • The local NPM_CONFIG_PROVENANCE=false workaround described in this issue's Fix requirement section is unnecessary on fixed npm versions and will not be implemented (ADR 0083, option 1).
    • Final live acceptance — publish + registry read-back + pacote consumer verification of a Windlass-signed bundle carried by the fixed npm — is the v0.1.3 dogfood retry tracked in [P06] Live M1 dogfood #30. The ADR 0077 production-signer gate and the ADR 0073 attestation-propagation measurement ride along with that run.

    Closing as resolved upstream; adoption follow-through is tracked in #30.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions