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
- Create a hosted npm repository and point
.npmrcor.yarnrc.ymlat it, with a package ready to publish. - In a GitLab CI job with
id_tokens: SIGSTORE_ID_TOKEN: aud: sigstore, runnpm publish --provenance --access publicoryarn npm publish --provenance. npm needs--access publicbecause it probesGET /-/package/{name}/visibilityfirst and stops client-side when the registry has no such route. yarn does no probe. - Without CI, send the envelope by hand:
PUT /{slug}/npm/{repository}/{package}whose_attachmentscarries{name}-{version}.tgzfirst and{name}-{version}.sigstoresecond, the second withcontent_typeset to a sigstore bundle media type anddataholding 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::bugAR-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 theSIGSTORE_ID_TOKENit 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 Registryartifact-registry::formatsformat::npmrepo-type::hostedbackend
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).