Skip to content

feat(npmprofile): support building a caller-specified source ref for dispatch retries #81

Closed

Description

@yunseo-kim

Problem

js-ts-npm-package-slsa3.yml always builds the caller's github.ref: the build job performs a plain actions/checkout (no ref:) and the identity binding consumes REF/REVISION straight from the caller's event context. There is no input to override the built source revision.

This blocks the standard "retry a failed release with a fixed pipeline" scenario for workflow_dispatch callers:

  1. A release tag run fails because of a builder-side defect (real case: vers-js v0.1.2, run 31622651874, failed on the settings-only pnpm workspace selection bug fixed in fix(npmprofile): Support pnpm settings-only root packages #80).
  2. The fix lands on the caller's main (re-pin of the reusable workflow), but the release tag still contains the old caller workflow file and the old pin.
  3. Dispatching from the tag uses the tag's workflow file (old pin, same failure). Dispatching from main uses the fixed pipeline but builds main HEAD instead of the tag content, and the provenance identity binds to refs/heads/main rather than the signed release tag.

Why this matters

The release trust model (vers-js ADR-0051/0054) anchors on a maintainer-signed tag: the published artifact must provably derive from the signed revision. With no way to name the built ref, a dispatch retry either cannot run (caller-side guard fails closed) or must weaken the provenance claim to an unsigned, movable branch head. The only fully canonical workaround today is deleting and recreating the signed tag on the fixed commit, which requires temporarily relaxing tag-protection rulesets.

Prior art (expected semantics)

The previous hand-rolled vers-js pipeline expressed exactly the desired semantics and used them in production for the v0.1.1 publish: the workflow file came from the dispatch ref (including post-tag fixes, e.g. commit 81d2bba), but every job checked out ref: inputs.release_tag, so the built tarball came from the signed tag commit (cfd032d). Logic from the dispatch ref, content from the signed tag.

Requested behavior

Add an optional input to js-ts-npm-package-slsa3.yml (name TBD, e.g. source-ref):

  • When set, the build job checks out that ref instead of defaulting to github.sha.
  • Build metadata, the signed SLSA v1 predicate, and the publish convergence inputs record the built ref and its resolved commit SHA as the source revision (the value the provenance attests).
  • run_invocation continues to record the actual invocation context (dispatch ref, event name, run id) for audit, so "workflow file came from X, built content came from Y" stays explicit and verifiable.

Design considerations (non-binding)

  • Constraint choice matters for the threat model: an unconstrained source-ref lets a dispatcher build any branch and call it a release. Options: restrict to refs/tags/* (matches the release-retry use case), or leave unconstrained and rely on honest provenance plus verification policy. The former composes better with caller-side guards.
  • Resolution should fail closed if the ref does not resolve to a commit (and, if restricted, if it is not a tag).
  • Digest-verified handoffs should carry the resolved SHA so provenance-sign/publish cannot disagree with build about what was built.

Acceptance sketch

  • A caller dispatching from main with source-ref: refs/tags/vX.Y.Z produces a provenance Statement whose source revision is the tag's commit SHA, while run_invocation.ref remains refs/heads/main.
  • Tag-push flows (input unset) behave exactly as today.

Context

Activity

  1. yunseo-kim commented on Aug 13, 2026

    @yunseo-kim
    MemberAuthor

    Design resolution and specification revision complete

    This issue's root cause was re-analyzed from scratch and resolved as a design decision, then specified. Summary of what landed:

    Root cause (verified)

    Release retry was structurally blocked by a three-layer conflation of the invocation ref (where the workflow was dispatched from) with the built source ref (what content is built and attested):

    1. ADR 0032 restricted manual dispatch to tag refs only, so a fixed pipeline on another ref (e.g. main) could never rebuild an existing tag.
    2. The specs required the caller's github.ref/github.sha to byte-equal the release tag/commit.
    3. The verification policy compared the signing certificate's platform-fixed source claims (Fulcio OIDC extensions .13/.14 — always the invocation context) directly against the release tag, making a dispatch retry unverifiable even if built.

    Ecosystem research confirmed this is platform-inherent: GitHub OIDC claims always describe the invocation context, and the predecessor ecosystem (slsa-github-generator) never shipped dispatch tag identity either — slsa-github-generator#1947 remained open through the end of maintenance. The same problem class was independently reported for osv-scanner in google/osv-scanner#632.

    Accepted ADRs (merged in #83)

    • ADR 0079 — Every producer profile offers an optional, tags-only source-ref input (npm is the first adopter). When supplied, the built source is that tag instead of the invocation ref; existence, resolution, and version-match checks are fail-closed. Tags-only was chosen over arbitrary refs to avoid resurrecting an "arbitrary release button" while structuring away retag cost. Partially supersedes ADR 0032's dispatch-ref restriction.
    • ADR 0080 — binding model: verification policy source expectations bind the signed built-source fields in provenance; certificate source claims are authenticated against a signed invocation record in provenance instead. The built commit is thus builder-attested (same class as other externalParameters), while the platform still anchors builder identity, repository, invocation origin, and run. When source-ref is unset, signed bundles are byte-identical to the single-identity contract. Partially supersedes ADR 0068 section 4 only.

    Specification revision (merged in #84)

    • core-profile-contract.md: new cross-profile built source identity invariant.
    • js-ts-npm-package-profile.md: ninth public input source-ref; runtime guards, supported triggers, and PRE-mutation/mutation concurrency keys re-anchored on the built release tag; new pre-mutation diagnostic windlass.verify.error.source-ref-invalid (registered in the diagnostic registry ahead of implementation, per the mutation-queue-overflow precedent); dispatch-retry-success and single-identity-default fixtures.
    • js-ts-npm-provenance-publish.md: conditional input_ref / invocation_ref / invocation_revision members in the closed source group; nine-input completeness mapping; signer-identity table rebound per ADR 0080.
    • slsa-provenance-v1.md, identity-and-buildtypes.md, verification-policy-and-fixtures.md: two-identity model throughout — policy binds signed built-source fields, certificate .13/.14 claims bind the signed invocation record; source-digest-mismatch / source-ref-mismatch diagnostics redefined to the invocation comparison.
    • Bilingual READMEs: nine-input contract, "Fixed-pipeline release retry" feature, two-identity verification model, and the ecosystem limitation write-up.

    Remaining before this issue closes

    1. Implementation (next): workflow source-ref input + build checkout ref, workflow_metadata.go guard split, provenance source-group generation, verification policy/cert-claim rebinding, fixtures and conformance tests.
    2. Dogfood retry (ADR 0079/0080 confirmation criteria): dispatch vers-js v0.1.2 from main with source-ref: refs/tags/v0.1.2 after the pin bump, exercising the path that originally failed in run 31622651874.

    This issue closes when the implementation merges and the dogfood confirms the retry path end-to-end.

  2. yunseo-kim commented on Aug 13, 2026

    @yunseo-kim
    MemberAuthor

    Implementation update: the source-ref capability has landed on main via #86 (merge commit 496e40e).

    Delivered behavior

    • New optional, tags-only source-ref input on js-ts-npm-package-slsa3.yml (full refs/tags/<name> only). When omitted, behavior is byte-compatible with the previous contract (golden-tested).
    • Pre-checkout resolution: the internal npm-profile-source-ref subcommand validates the input, resolves and peels the tag in an isolated fetch, proves the target is a commit, and the package checkout is pinned to the resolved commit SHA. Missing, unresolvable, non-commit, version-mismatched, or invocation-conflicting refs fail closed with windlass.verify.error.source-ref-invalid before any registry or build work. No tag is ever created, moved, or deleted.
    • The signed SLSA v1 predicate records the built source identity (source.ref/source.revision), and when source-ref is supplied additionally records the invocation context as signed conditional members (source.input_ref, source.invocation_ref, source.invocation_revision; all-present-or-absent, explicit null rejected). Omitted input keeps the previous predicate bytes exactly.
    • ADR 0080 verification binding: explicit policy binds the signed built-source fields, while certificate source claims bind the signed invocation record, with exact single-identity fallback. The signing step consumes the built release identity through the digest-verified handoff (build job outputs), and a mismatch is a policy failure (exit 1, release-ref-mismatch).
    • Release concurrency groups are canonicalized to the full-ref form ${{ inputs.source-ref || github.ref }} across build/sign/publish and enforced by workflowcheck, so a dispatch retry and a tag-triggered run for the same release intent share one mutation group regardless of invocation ref. Because the raw input participates in group computation and GitHub expressions cannot normalize it, only the exact empty string counts as omitted; whitespace-only or padded spellings are rejected (source-ref-invalid).
    • Fixtures: 2 accepted + 9 rejected fixtures registered under the provenance-inputs phase with their canonical primary diagnostics.

    Verification

    • Test-first throughout: each behavioral change was captured as a failing test in a detached worktree from main before its implementation landed; every commit is independently green with DCO sign-off.
    • Gates: go test ./..., go test -race ./..., golangci-lint, actionlint, fixture-check (full index + provenance-inputs phase), and workflow-check (all jobs + npm-only profile) all pass; PR checks (CodeQL, OSV, dependency-review, semgrep, DCO, etc.) are green.
    • An independent five-lane review (goal/constraints, hands-on QA, code quality, security, context mining) iterated to an unconditional pass; its findings — signing-path built-identity wiring, input normalization, dispatcher error classification, and the concurrency-spelling gap — were fixed in dedicated commits before merge.

    Remaining

    The live acceptance step is still pending: a real workflow_dispatch retry of vers-js v0.1.2 from main with source-ref: refs/tags/v0.1.2 (ADR 0079 confirmation). This issue stays open until that dogfood run confirms the provenance binds to the tag commit while run_invocation records the dispatch ref.

  3. yunseo-kim commented on Aug 14, 2026

    @yunseo-kim
    MemberAuthor

    Closing: the capability this issue asked for is implemented (#86) and was exercised live end-to-end up to an unrelated, separately tracked failure.

    Live evidence from the second vers-js dogfood attempt (run 31737001312, dispatched from main with release_tag=v0.1.2):

    • The caller supplied source-ref: refs/tags/v0.1.2 (full-ref form on dispatch) and the reusable workflow accepted it under the tags-only validation rules.
    • The build job checked out and packed the tag content, not the dispatch ref.
    • The sign job received the resolved built identity exactly as specified: BUILT_REF=refs/tags/v0.1.2, BUILT_REVISION=68e6f0018d6824167ec490243b17a9edd6ba8fc7 (the annotated tag's peeled commit), while the invocation context stayed refs/heads/main @ 7851c5f832abce998fa7071941a94ad580afef60 — the dispatch retry carrying the release tag identity, which is precisely this issue's scenario.

    The run did not reach provenance emission: it failed in the sign job's early npm OIDC exchange preflight for an unrelated response-contract gap (full report on #30; follow-up ADR in progress). The signed-Statement field values (externalParameters.source.*, internalParameters exactly {}) remain pinned by #86's unit/golden coverage and will be observed on the live registry attestation in the next dogfood run as part of #30's acceptance, so no work remains under this issue.

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