npm hosted: publish with --provenance is refused 400 and the attestation is never stored

Bug Description

The publish walker refuses a hosted npm publish with --provenance: 400, code: publish_envelope_invalid. The envelope carries two _attachments entries, the tarball and the {name}-{version}.sigstore bundle, and the walker admits exactly one. A CI job configured for provenance fails on this registry and succeeds on the GitLab Package Registry, which drops the bundle. Neither registry stores the attestation, so dist.attestations is absent from every packument this one serves.

Steps to Reproduce

  1. Create a hosted npm repository and point .npmrc or .yarnrc.yml at it, with a package ready to publish.
  2. In a GitLab CI job with id_tokens: SIGSTORE_ID_TOKEN: aud: sigstore, run npm publish --provenance --access public or yarn npm publish --provenance. npm needs --access public because it probes GET /-/package/{name}/visibility first and stops client-side when the registry has no such route. yarn does no probe.
  3. Without CI, send the envelope by hand: PUT /{slug}/npm/{repository}/{package} whose _attachments carries {name}-{version}.tgz first and {name}-{version}.sigstore second, the second with content_type set to a sigstore bundle media type and data holding a JSON string.

Reproducibility: always Reproduced independently: not attempted. The walker's second-key abort in internal/format/npm/streampub/attachment.go and the row e2e.npm.publish.reject-two-attachments in docs/testing/e2e/npm.md pin the refusal, and the row names both provenance flows as the clients that send a second entry.

Expected Behavior

The registry admits the publish, verifies the bundle against the tarball's sha512 subject, stores it against the version, and serves it the way the npm public registry does. The packument's versions[{ver}].dist.attestations carries { "url": ".../-/npm/v1/attestations/{name}@{version}", "provenance": { "predicateType": "https://slsa.dev/provenance/v1" } }, and GET /-/npm/v1/attestations/{name}@{version} returns { "attestations": [ { "predicateType": "...", "bundle": <sigstore bundle> } ] }.

Actual Behavior

400 with code: publish_envelope_invalid at the second _attachments key, before its value is read. The registry writes no version and no blob row, and cancels the upload session opened for the tarball. The refusal dates from 2026-09-03, when the one-entry rule merged in fix(npm): accept yarn's _attachments-first publ... (!2228 - merged) • Hayley Swimelar • 19.4. Before that the walker never read a second entry, so it admitted the publish and dropped the bundle.

Response body not captured. A CI run against a deployed instance can supply the exact body.

Suggested Fix

This section is a hypothesis, not a finding.

Both clients build the tarball entry first and append the bundle second (npm 11.19.0 libnpmpublish/lib/publish.js, yarn berry packages/plugin-npm/sources/npmPublishUtils.ts), so readAttachments keeps streaming the first entry and reads one named key after it, and validateShape checks that key beside the tarball key because yarn sends name and the version after _attachments. The bundle's data is a JSON string, so the walker can read it whole under an explicit size bound rather than stream it. Storage needs a row against the version, verification of the bundle's subject digest against the tarball's sha512 before the write, dist.attestations on the packument render, and the attestations endpoint. The plan may land the admit step first, dropping the bundle with an npm-notice response header that says it was not stored, since the npm CLI prints that header on every response. S11 says which behavior the registry has at each step.

Suggested DRI

S11 npm hosted, @dmeshcharakou per the workstream assignments table in docs/roadmap/closed-beta.md. The fix lands in the hosted publish walker and the packument render, not in the remote or virtual tiers. @hswimelar owns the one-entry rule and the beta decision in npm hosted: decide how a provenance publish's .... (#1156 - closed) • Hayley Swimelar.

Labels to Apply

  • type::bug
  • AR-Blocks::GA: beta users have a workaround they can live with, publishing without --provenance, which is the tested main path. It does not gate closed beta. The beta keeps the refusal, decided in npm hosted: decide how a provenance publish's .... (#1156 - closed) • Hayley Swimelar. It gates GA because the Package Registry accepts this publish and GitLab CI issues the SIGSTORE_ID_TOKEN it needs, so a GA registry that refuses it regresses anyone moving over.
  • priority::1: missing implementation. The registry has no attestation storage, no bundle verification, and no attestations endpoint.
  • Category:Artifact Registry
  • artifact-registry::formats
  • format::npm
  • repo-type::hosted
  • backend

Additional Context

Environment any build from 2026-09-03 on, after fix(npm): accept yarn's _attachments-first publ... (!2228 - merged) • Hayley Swimelar • 19.4
Org slug n/a
Repository any hosted npm repository
Format npm
Auth method any
Client and version npm 11.19.0 (libnpmpublish 11), yarn 4.18
First observed (UTC) 2026-09-03, in the review of fix(npm): accept yarn's _attachments-first publ... (!2228 - merged) • Hayley Swimelar • 19.4
Correlation ID none
Workaround drop the --provenance flag or set provenance=false. It is the publish path the e2e catalog and the yarn recipe exercise. It drops the attestation rather than storing it, so it does not satisfy a policy that mandates provenance.

Both clients set content_type to the bundle's mediaType, data to the bundle as a JSON string rather than base64, and length to its byte count. npm generates the bundle only inside GitHub Actions or GitLab CI, so a local reproduction needs a CI job against a reachable instance, npm publish --provenance-file with a bundle for that exact tarball, or a hand-built envelope. The walker's token bounds are depth and per-object key count, so the envelope cap alone bounds a skipped scalar's size. PyPI attestations (PEP 740) are a separate item, PyPI hosted: evaluate PEP 740 attestation support (#363) • Unassigned.


Workstream status (updated 2026-09-21 UTC)

The graph below shows open work only, and the table is the full record.

graph TD
    S3[Step 3: Bundle parsing and shape verification]
    S4[Step 4: Bounded JSON string reader for streampub]
    S5[Step 5: npm_versions.attestation_predicate_type migration]
    S6[Step 6: Attestations route grammar and authorization]
    S7[Step 7: Bundle subject verification]
    S8[Step 8: Walker key-suffix and entry-count rules]
    S9[Step 9: Predicate-type store plumbing]
    S10[Step 10: Attestations dispatcher row and slot]
    S11[Step 11: Walker admits the .sigstore value behind a zero cap]
    S12[Step 12: Committer accepts an attestation bundle row]
    S13[Step 13: Packument dist.attestations]
    S14[Step 14: Lift cacheControl and stamp the package name]
    S15[Step 15: Cap wired, npm-notice, and the admitted publish]
    S16[Step 16: Attestations handler]
    S17[Step 17: Verify the bundle at publish]
    S18[Step 18: Wire and serve the attestations endpoint]
    S19[Step 19: Store the bundle rows at publish]
    S20[Step 20: Charge the bundle's bytes]
    S3 --> S7
    S4 -.-> S8
    S5 --> S9
    S6 --> S10
    S8 --> S11
    S9 --> S12
    S9 --> S13
    S10 -.-> S14
    S11 --> S15
    S14 --> S16
    S15 --> S17
    S7 --> S17
    S16 --> S18
    S17 --> S19
    S12 --> S19
    S19 --> S20
    classDef planned fill:#e0e0e0,stroke:#9e9e9e,color:#000
    classDef inbuild fill:#ffe0a3,stroke:#e0a020,color:#000
    classDef open fill:#c8e6c9,stroke:#2e7d32,color:#000
    class S9,S12,S13,S15,S16,S17,S18,S19,S20 planned
    class S11,S14 inbuild
    class S3,S4,S5,S6,S7,S8,S10 open
    click S3 "https://gitlab.com/gitlab-org/ops/artifact-registry/-/merge_requests/2806"
    click S4 "https://gitlab.com/gitlab-org/ops/artifact-registry/-/merge_requests/2805"
    click S5 "https://gitlab.com/gitlab-org/ops/artifact-registry/-/merge_requests/2813"
    click S6 "https://gitlab.com/gitlab-org/ops/artifact-registry/-/merge_requests/2807"
    click S7 "https://gitlab.com/gitlab-org/ops/artifact-registry/-/merge_requests/2812"
    click S8 "https://gitlab.com/gitlab-org/ops/artifact-registry/-/merge_requests/2814"
    click S10 "https://gitlab.com/gitlab-org/ops/artifact-registry/-/merge_requests/2811"
# Step MR Status
1 Codes, labels, and pins registry chore(npm): register the attestation codes, lab... (!2773 - merged) • Hayley Swimelar • 19.5 Merged
2 Per-blob publish charge refactor(npm): split the publish charge into a ... (!2803 - merged) • Hayley Swimelar • 19.5 Merged
3 Bundle parsing and shape verification chore(npm): parse and shape-check an attestatio... (!2806 - closed) • Hayley Swimelar • 19.5 Open, reviewer requested (10io)
4 Bounded JSON string reader for streampub chore(npm): bounded JSON string reader for stre... (!2805 - closed) • Hayley Swimelar • 19.5 Open, reviewer requested (10io)
5 npm_versions.attestation_predicate_type migration chore(datastore): add npm_versions.attestation_... (!2813 - closed) • Hayley Swimelar • 19.5 Open, no reviewer yet
6 Attestations route grammar and authorization chore(npm): attestations route grammar and auth... (!2807 - closed) • Hayley Swimelar • 19.5 Open, reviewer requested (10io)
7 Bundle subject verification chore(npm): verify a bundle's subject against t... (!2812 - closed) • Hayley Swimelar • 19.5 Open, reviewer requested (mkhalifa3)
8 Walker key-suffix and entry-count rules chore(npm): apply the _attachments key rules at... (!2814 - closed) • Hayley Swimelar • 19.5 Open, reviewer requested (radbatnag)
9 Predicate-type store plumbing Planned
10 Attestations dispatcher row and slot chore(npm): mount the attestations route on an ... (!2811 - closed) • Hayley Swimelar • 19.5 Open, reviewer requested (radbatnag)
11 Walker admits the .sigstore value behind a zero cap Building, no MR yet
12 Committer accepts an attestation bundle row Planned
13 Packument dist.attestations Planned
14 Lift cacheControl and stamp the package name Building, no MR yet
15 Cap wired, npm-notice, and the admitted publish Planned
16 Attestations handler Planned
17 Verify the bundle at publish Planned
18 Wire and serve the attestations endpoint Planned
19 Store the bundle rows at publish Planned
20 Charge the bundle's bytes Planned

Other workstream docs MRs, all opened: docs(plans): record the open step MRs in the S1... (!2810 - closed) • Hayley Swimelar • 19.5 (plan Status-table rows), docs(plans): correct the S11 provenance plan ag... (!2840 - closed) • Hayley Swimelar • 19.5 (plan corrections), Amend AR ADR-009: the npm attestations route se... (gitlab-com/content-sites/handbook!21195 - closed) • Hayley Swimelar (ADR-009 amendment).

Edited by Hayley Swimelar