fix: provenance-file takes precedence over OIDC auto-generated provenance - #9882
fix: provenance-file takes precedence over OIDC auto-generated provenance#9882yunseo-kim wants to merge 3 commits into
Conversation
…ance When publishing with an externally generated provenance bundle under OIDC trusted publishing, the auto-enable logic in the OIDC flow unconditionally set `provenance: true`, which caused libnpmpublish to silently discard the supplied `--provenance-file` bundle and publish its own generated provenance instead. Skip auto-enabling provenance when a provenance file is configured, so the explicitly supplied bundle is verified and published as documented. Fixes: npm#9879 Signed-off-by: Yunseo Kim <git@yunseo.kim>
… set The two options are documented as mutually exclusive, but buildMetadata previously preferred generation when both were set, silently discarding the supplied bundle. Throw an EPROVENANCECONFLICT usage error instead of silently discarding a caller-supplied cryptographically meaningful artifact. Signed-off-by: Yunseo Kim <git@yunseo.kim>
Signed-off-by: Yunseo Kim <git@yunseo.kim>
…on (#99) ## Summary Adds ADR 0082, deciding that the **publish-stage** npm CLI is provisioned as an explicitly pinned, integrity-verified distribution selected from a reviewed allowlist, replacing the current floating node-24-bundled npm **on the publish job only**. Build-stage npm selection is unchanged and remains governed by ADR 0016/0017/0027. - What changed? - New ADR 0082: pin the publish-stage npm CLI version with integrity-verified provisioning (version + distribution SHA-256 in the publish job), a reviewed allowlist recorded in the specification, and a bump procedure requiring an npm-internals diff-review checklist (`oidc.js` flow, `buildMetadata()` branch structure, `@npmcli/config` exclusive enforcement, `publishConfig` flatten timing, sigstore-js major) plus per-version compatibility fixtures. - Publish-side contract change (implementation lands with the #97 fix): the publish job's npm validates against the allowlist pin, replacing the current exact-equality check against the build-time `runtime.npm_version` record, since build npm (toolchain, floating) and publish npm (pinned) diverge by design. Build continues recording its actual npm in `runtime.npm_version`. - Relations updates (frontmatter only; accepted ADR bodies untouched): `partially-supersedes ADR-0027` scoped to the publish-stage npm provisioning clause (reverse edge added), `see-also` ADR 0016/0017/0029/0081 (reverse edges added). - Index rows for 0082 in `docs/decisions/README.md` and `README.ko.md`. - Why is this needed? - The #97 remediation mechanism depends on npm internals that already vary across the currently allowed range (11.5.1 / 11.17.0 / 12.0.2: `oidc.js` structure, exclusive env carve-out, sigstore-js v3–v5). The fourth dogfood showed fail-closed read-back protects acceptance but not registry state (`@windlass/vers-js@0.1.2` is permanently burned). A floating toolchain under a source-dependent publish mechanism leaves the verification surface open, and a node-24 image update is a correlated-failure vector for all callers. The decision completes the builder-owned npm principle (ADR 0016/0017) and ADR 0027's recorded pinned-setup intent on the publish path, and makes the specification's per-version fixture obligation satisfiable. Upstream npm/cli#9882 adoption becomes a deliberate pinned bump. - Scope note (from review): an earlier draft pinned npm across all jobs; per reviewer decision the pin is publish-only, and a manifest-selected build npm option was considered and excluded from this ADR (build-stage selection may be revisited by a separate future decision). - How to test: documentation-only; `adr-relations-check` passes (83 ADRs, 184 edges, NO PROBLEMS); prettier and markdownlint clean. ## Related Issues - Related #97 (motivating defect), #30 (dogfood evidence) ## Change Type - [x] Documentation ## Changelog - Category: None - User-facing note: Architecture decision record; no runtime behavior change in this PR (spec amendments and provisioning changes follow in the #97 fix implementation). Changelog update: - [x] Not needed because this change is not user-facing ## Checklist ### General - [x] PR title follows [Conventional Commits](https://www.conventionalcommits.org/) format: `type(scope): Summary` - [x] This PR does not expose backend/internal implementation details in a public repo. - [x] No secrets, tokens, keys, or private endpoints are included. - [x] Changes stay within this repository's intended scope. ### Protocol / Compatibility Impact - [x] No protocol/spec impact in this PR; ADR 0082's consequences (spec allowlist, publish-side pin validation, provisioning) land in the follow-up implementation PR. ## Testing - [x] Lint and format pass - [x] Manual verification performed Describe test evidence: `adr-relations-check` reports 83 ADRs / 184 edges / NO PROBLEMS; prettier and markdownlint clean on `docs/decisions/`; verified via git diff that existing ADRs changed only in frontmatter `relations` (bodies immutable per convention). ## Documentation - [x] Spec/docs updated (this PR is the ADR; index READMEs updated in both languages) - [x] Changelog decision completed above ## Rollout / Risk - Risk level: Low (documentation only) - Rollback plan: revert this branch. ## Reviewer Checklist - [x] Scope is clear and minimal - [x] Security and boundary checks passed - [x] Tests and docs are sufficient - [x] Compatibility impact is correctly handled --------- Signed-off-by: Yunseo Kim <git@yunseo.kim>
…ss build and publish stages (#100) ## Summary - What changed: three architecture decision records completing the npm CLI version-pinning decision chain for the JS/TS npm package profile: - **ADR 0083** — defer the npm M1 publish remediation (#97) to the upstream npm/cli#9882 fix; the first reviewed npm release containing the fix becomes the initial publish npm pin, with an explicit revisit trigger (unmerged close, materially different precedence semantics, or no merge by 12026-10-01). - **ADR 0084** — select the publish-stage npm provisioning mechanism: download the exact recorded registry tarball URL, verify the committed SHA-512 digest (SRI form, byte-identical to the registry-native `dist.integrity`) over the compressed bytes before extraction, and execute via a basename-`npm` launcher. Partially supersedes ADR 0082's SHA-256 designation; amends its open mechanism choice. - **ADR 0085** — pin the build-stage toolchain pair: the reusable workflow resolves Node.js to an exact 24 patch recorded together with the npm version that patch bundles, and each job that uses the bundled npm asserts `node --version`/`npm --version` equality with the recorded pair before the first npm invocation. Partially supersedes ADR 0027's floating toolchain resolution clause only. - Why is this needed: the fourth M1 dogfood (#97) showed a floating npm on the publish path can publish-then-reject and permanently burn version numbers. ADR 0082 pinned the publish npm and deliberately left two questions open — what the #97 fix is (settled by 0083), which mechanism provisions the pin (settled by 0084) — plus the build-stage version selection review (settled by 0085 with an escalation trigger toward an independently provisioned build npm). - How to test: documentation-only change. Validation evidence below. ## Related Issues - Related #97 — this PR records the remediation *decisions*; implementation lands when the fixed npm release ships (ADR 0083 deferral scope), so #97 stays open. - Related #30 — dogfood attempt evidence referenced by the ADRs. ## Change Type - [ ] Bug fix - [ ] Feature - [ ] Refactor - [x] Documentation - [ ] Test/CI - [ ] Breaking change - [ ] Other: <!-- describe --> ## Changelog - Category: None - User-facing note: Architecture decision records only — no user-facing behavior changes. The implementing specification, workflow, and Go changes land later under ADR 0083's deferral scope and ADR 0085's own schedule. Changelog update: - [ ] `CHANGELOG.md` `[Unreleased]` updated - [x] Not needed because this change is not user-facing ## Checklist ### General - [x] PR title follows [Conventional Commits](https://www.conventionalcommits.org/) format: `type(scope): Summary` - [x] This PR does not expose backend/internal implementation details in a public repo. - [x] No secrets, tokens, keys, or private endpoints are included. - [x] Changes stay within this repository's intended scope. ### Protocol / Compatibility Impact - [x] No protocol/spec impact - [ ] Protocol/spec updated - [ ] Conformance tests updated - [ ] Breaking change is versioned and migration notes are included ADR texts only; no specification files are modified. Each ADR records which future specification amendments it governs. ## Testing - [ ] Unit tests added/updated - [ ] Integration or conformance tests added/updated - [ ] Tests pass - [x] Lint and format pass - [ ] Type check passes - [x] Manual verification performed Describe test evidence: - `prettier --write` on all touched files: clean. - `markdownlint-cli2 docs/decisions/**/*.md`: 89 files, 0 errors. - `python3 .agents/skills/adr-relations-check/scripts/check_relations.py`: **86 ADRs, 216 edges — NO PROBLEMS** (forward + reverse relation edges symmetric, status grammar valid). - Lefthook pre-commit (prettier, markdownlint) and commit-msg (dco-signoff) passed on all three commits. ## Documentation - [x] README updated — `docs/decisions/README.md` and `README.ko.md` sequence, inventory, and traceability tables (bilingual, same change). - [x] Spec/docs updated — three new ADRs; reverse relation edges added to ADR 0016/0017/0027/0067/0082/0083/0084; `docs/decisions/AGENTS.md` index updated. - [x] Changelog decision completed above ## Rollout / Risk - Risk level: Low — documentation-only; no runtime, workflow, or specification behavior changes. - Rollback plan: revert the merge commit; ADR 0065's immutability rule applies after acceptance, so any post-merge decision change would be a new ADR instead. ## Reviewer Checklist - [x] Scope is clear and minimal - [x] Security and boundary checks passed - [x] Tests and docs are sufficient - [x] Compatibility impact is correctly handled --------- Signed-off-by: Yunseo Kim <git@yunseo.kim>
martinrrm
left a comment
There was a problem hiding this comment.
Thanks for the contribution! This looks great, lets just add a new test and keep the error as EUSAGE
| let transparencyLogUrl | ||
| if (provenance === true && provenanceFile) { | ||
| throw Object.assign( | ||
| new Error('provenance and provenanceFile are mutually exclusive'), |
There was a problem hiding this comment.
Lets use the existing EUSAGE error.
throw Object.assign(
new Error('provenance and provenanceFile cannot be used together'),
{ code: 'EUSAGE' }
)| the generation of a signed provenance statement to be published alongside | ||
| the package. Mutually exclusive with the `provenanceFile` option. | ||
| the package. Mutually exclusive with the `provenanceFile` option; providing | ||
| both will throw an `EPROVENANCECONFLICT` error. In the npm CLI's trusted |
| }, | ||
| })) | ||
|
|
||
| t.test('provenance-file config should take precedence over OIDC auto-provenance', async t => { |
There was a problem hiding this comment.
Let's add another test to prove that publishConfig is flattened into opts.provenanceFile beofre oidc() decides whether to enable automatic provenance.
The important distinction is:
// Existing regression path
config: {
'provenance-file': provenanceBundlePath,
}versus:
// Additional regression path
packageJson: {
publishConfig: {
'provenance-file': provenanceBundlePath,
},
}The second case exercises Publish.#getManifest() and its special flatten(filteredPublishConfig, opts) path in lib/commands/publish.js. Automatic provenance would still be enabled if that flattening did not reach opts.provenanceFile.
Rather than duplicating the existing 119-line test, I would parameterize it:
const provenanceFileSources = [
{
name: 'CLI config',
options: provenanceBundlePath => ({
config: {
'provenance-file': provenanceBundlePath,
},
}),
},
{
name: 'publishConfig',
options: provenanceBundlePath => ({
packageJson: {
publishConfig: {
'provenance-file': provenanceBundlePath,
},
},
}),
},
]
for (const { name, options } of provenanceFileSources) {
t.test(`${name} provenance-file takes precedence over OIDC auto-provenance`, async t => {
const bundleDir = t.testdir()
const provenanceBundlePath = path.join(
bundleDir,
'provenance-bundle.json'
)
const packMock = { tarballData: null }
const sourceOptions = options(provenanceBundlePath)
const { npm, registry, prefix, joinedOutput } = await mockOidc(t, {
oidcOptions: { github: true },
config: {
'//registry.npmjs.org/:_authToken': 'existing-fallback-token',
...sourceOptions.config,
},
packageJson: sourceOptions.packageJson,
mockGithubOidcOptions: {
audience: 'npm:registry.npmjs.org',
idToken: githubPublicIdToken,
},
mockOidcTokenExchangeOptions: {
idToken: githubPublicIdToken,
body: {
token: 'exchange-token',
},
},
publishOptions: {
token: 'exchange-token',
noPut: true,
},
load: {
mocks: {
libnpmaccess: {
getVisibility: async () => ({ public: true }),
},
libnpmpack: async () => packMock.tarballData,
libnpmpublish: t.mock('libnpmpublish', {
'libnpmpublish/lib/provenance': t.mock(
'libnpmpublish/lib/provenance',
{
sigstore: {
verify: async () => {},
attest: async () => {
throw new Error(
'sigstore.attest must not be called when provenance-file is configured'
)
},
},
}
),
}),
},
},
})
// Current existing setup:
// 1. Build the deterministic tarball.
// 2. Calculate its SHA-512 digest.
// 3. Create and write a matching provenance bundle.
// 4. Capture the registry PUT body.
// 5. Run npm publish.
await npm.exec('publish', [])
t.match(joinedOutput(), '+ @npmcli/test-package@1.0.0')
const attachment =
publishedBody?._attachments[`${pkg}-1.0.0.sigstore`]
t.ok(attachment, 'published packument includes supplied provenance')
t.strictSame(
JSON.parse(attachment.data),
provenanceBundle,
'published sigstore bundle is the user-supplied provenance file'
)
})
}The omitted bundle and registry setup remains unchanged from the existing test. Only the source of provenance-file becomes parameterized.
This verifies four things for publishConfig:
publishConfig["provenance-file"]is flattened intoopts.provenanceFile.- OIDC token exchange still occurs normally.
- OIDC does not auto-enable generated provenance.
- The supplied bundle is verified and uploaded byte-for-byte;
sigstore.attest()is never called.
Why Needed
When publishing with an externally generated provenance bundle under OIDC trusted publishing
(
npm publish <tarball> --provenance-file=<bundle>), npm silently discarded the supplied bundle and publishedits own auto-generated provenance instead. Three layers interacted:
lib/utils/oidc.jsauto-enabled provenance (opts.provenance = true) whenever theprovenanceconfig was atits default, without checking whether a
provenance-filewas supplied.libnpmpublish'sbuildMetadata(), the innerprovenance === truebranch then rangenerateProvenance(),so the
verifyProvenance(subject, provenanceFile)branch, the only code path that reads the supplied file,never executed.
--provenance-file(config-layer mutual exclusivity error, env carve-out, or publishConfig flatten timing).
What Changes
lib/utils/oidc.js: skip auto-enabling provenance when a provenance file is configured (opts.provenanceFile).optsalready carriesprovenanceFilefrom every config source (CLI/env/npmrc/publishConfig) by the time theOIDC flow runs, so this covers all entry paths. The supplied bundle is now verified via
verifyProvenance()andpublished, as documented.
libnpmpublish:buildMetadata()now throwsEPROVENANCECONFLICTwhen bothprovenance: trueandprovenanceFileare provided.provenance/provenance-fileand the libnpmpublish README now state theprecedence rule.
This PR introduces
EPROVENANCECONFLICTin libnpmpublish, thrown when bothprovenance: trueandprovenanceFileare provided programmatically.Rationale: the README already documents the two as mutually exclusive, and silently preferring either direction
discards a cryptographically meaningful artifact. Note the CLI's config layer reports the same conflict as a
TypeErrorwithout an error code (pre-existing). Happy to align onEUSAGEor another convention perreview.
Testing
provenance-fileconfig asserts the published packument'ssigstore attachment deep-equals the supplied bundle (and that sigstore generation is never invoked).
EPROVENANCECONFLICT, no registry PUT,generation not invoked.
References
Fixes #9879
Out of scope (noted for follow-up)
publishConfig.provenance: falsedoes not block the OIDC auto-enable (publishConfig flattens intooptsonly,so
config.isDefault('provenance')stays true). A separate behavioral question aboutisDefaultsemantics.config.set('provenance', true, 'user')bypassing load-time exclusivity is unreachable in this flow after this fix;hardening
config.setitself is an@npmcli/configsemver-major conversation.