Skip to content

feat(service-ci): opt-in npm OIDC Trusted Publishers#14

Open
alinarublea wants to merge 6 commits into
mainfrom
feat/oidc-trusted-publishers
Open

feat(service-ci): opt-in npm OIDC Trusted Publishers#14
alinarublea wants to merge 6 commits into
mainfrom
feat/oidc-trusted-publishers

Conversation

@alinarublea

@alinarublea alinarublea commented May 21, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds an opt-in npm-oidc-enabled input to the reusable service-ci.yaml workflow, mirroring the model landed in adobe/spacecat-shared #1592. Existing consumers see zero behavior change until they explicitly flip the input. To be cut as v3.0.0 of adobe/mysticat-ci.

When npm-oidc-enabled: true:

  • Dry-run runs with SR_NO_NPM_AUTH=true and no NPM_TOKEN (consumer's .releaserc.cjs must gate @semantic-release/npm on process.env.SR_NO_NPM_AUTH === 'true').
  • Release runs with NPM_CONFIG_PROVENANCE=true, no NPM_TOKEN, and environment: npm-publish (or prod when vpc-enabled is also true, so VPC consumers keep their existing env config and bind their npm trust with --environment prod).
  • A conditional Update NPM step pins npm@11.13.0 (OIDC floor is >= 11.5.1).

When npm-oidc-enabled: false (default): unchanged — legacy ADOBE_BOT_NPM_TOKEN path.

Why this matters

  • Eliminates ADOBE_BOT_NPM_TOKEN as a long-lived org secret once all consumers have migrated. Trapped one cycle in spacecat-shared already (silent token rot broke an unrelated PR; the OIDC binding registered the same morning bypassed the bad token entirely).
  • Sigstore provenance on every publish — SLSA v1 attestation proving the tarball was built from main in the caller repo via the caller's workflow.
  • Per-package scope via per-caller trust bindings (see security note below).

Release + rollout strategy

Full plan in docs/npm-oidc-migration.md → "Release + rollout strategy". Summary:

Phase 0 — Cut v3.0.0 (when this PR merges)

  • Tag merge commit v3.0.0; the floating v3 tag picks it up via the existing release.yaml.
  • Pre-cut checklist: CI green; maintainer approval; one more diff re-read of the env-precedence and token expressions.
  • Do not nudge any consumer yet.

Phase 1 — Canary (1 repo, ≥ 1 week observation)

  • Selection criteria: active enough to release ≥ 2x/week, low blast radius, not VPC (simpler path first), not in code-freeze.
  • Likely candidates ranked: spacecat-jobs-dispatcherspacecat-task-processormysticat-projector-service.
  • Run the 9-step consumer checklist from the migration doc.
  • Success criteria (all required for ≥ 1 week before Phase 2):
    • ≥ 2 OIDC releases on main.
    • npm view @adobe/<pkg> _npmUser returns "GitHub Actions".
    • npm view @adobe/<pkg> dist.attestations non-empty.
    • The SLSA attestation's workflow.path field points at the canary's caller workflow, NOT adobe/mysticat-ci/.github/workflows/service-ci.yaml. (If it points at the latter, the trust binding was registered against job_workflow_ref — fail-stop and re-register against workflow_ref.)
    • No FM-A/B/C/D incidents.
  • Rollback trigger: any OIDC failure not attributable to a transient sigstore outage → flip npm-oidc-enabled: false, fix in a follow-up, re-enter Phase 1.

Phase 2 — Pilot batch (3 repos, ≥ 1 week)

  • 1 more non-VPC + 1 VPC-enabled (validates --environment prod binding) + 1 multi-package consumer (validates the per-package binding registration loop).
  • Run in parallel — no inter-consumer dependency.
  • If a novel failure mode surfaces, add it to the doc before Phase 3 — the doc is part of the forward-compatibility contract.

Phase 3 — Bulk rollout (remaining 10 repos)

  • Coordinated via a tracking issue on adobe/mysticat-ci (one checkbox per repo).
  • Suggested order: active-development repos first; VPC consumers grouped; lowest-volume last.
  • Owners run on their own schedule using the doc's communication template.

Phase 4 — Token decommissioning (gated on every consumer crossing the ≥ 2-cycle threshold)

  • PR on mysticat-ci removing the legacy NPM_TOKEN injection branch in the conditionals.
  • Delete ADOBE_BOT_NPM_TOKEN from org secrets.
  • As adobe-bot: npm token revoke <id>.
  • Optionally cut v4.0.0 to reflect the contract change.

Meta-risks for the rollout itself (documented in full):

  • mysticat-ci@main pins on any consumer (anti-pattern but harmless under default-false).
  • Sigstore outage mid-rollout — defer Phase entries until green.
  • adobe-bot bus factor (lock in a second operator).
  • ADOBE_BOT_NPM_TOKEN rotation collision mid-rollout (pause rotation or prioritize token-dependent consumers).

Self-review

Audited before opening this PR. Default-false code paths produce identical behavior to the pre-PR workflow:

Element Pre-PR Post-PR (input=false) Post-PR (input=true)
NPM_TOKEN on dry-run env secret value secret value ''
SR_NO_NPM_AUTH on dry-run env unset unset ('' evaluates as unset for GH) 'true'
NPM_TOKEN on release env secret value secret value ''
NPM_CONFIG_PROVENANCE on release env unset unset ('') 'true'
environment: claim 'prod' if vpc-enabled, else none identical 'prod' if vpc-enabled, else 'npm-publish'
Update NPM step n/a not run (if: evaluates false) runs (npm@11.13.0)
id-token: write permission already present (for AWS OIDC) unchanged unchanged

The ''-vs-unset distinction is functionally inert for the three consumer-facing reads:

  • @semantic-release/npm checks process.env.NPM_TOKEN truthiness; '' is falsy, identical to unset.
  • process.env.SR_NO_NPM_AUTH === 'true' (strict equality) returns false for both '' and undefined.
  • process.env.NPM_CONFIG_PROVENANCE = '' is read by npm as "config not set," identical to unset.

Rollback (backwards compatibility)

The contract is designed so that every backwards transition is a single flag flip.

Scenario A — Consumer-side rollback (after opting in)

Symptom: a consumer flipped npm-oidc-enabled: true, then a regression / outage / policy reversal makes the token path preferable again.

Procedure (single PR on the consumer repo):

  1. Edit the caller workflow: npm-oidc-enabled: truefalse (or remove the input).
  2. Merge. The next push to main uses the legacy NPM_TOKEN path automatically.
  3. Nothing else needs reverting:
    • .releaserc.cjs SR_NO_NPM_AUTH guard is a no-op when the env var is unset.
    • engines.npm: >=11.5.1 is harmless to keep.
    • npm trust bindings on npmjs.com are inert under token-based publish.
    • The npm-publish GitHub Environment can stay in place.

Scenario B — mysticat-ci PR-level rollback

Symptom: this PR caused a regression for a default-false consumer.

Procedure: git revert <merge-sha> and cut a hotfix v3.0.1 (or do not cut v3 at all and require consumers to remain on @v2). The self-review table above audits every default-false code path; if a regression slips through, the revert restores service-ci.yaml verbatim with zero downstream coordination required.

Scenario C — Permanent revert post-rotation

Symptom: all consumers migrated, ADOBE_BOT_NPM_TOKEN was rotated out, then a policy decision is made to revert.

Procedure: mint a new ADOBE_BOT_NPM_TOKEN, push to the org secret store. For each consumer, flip npm-oidc-enabled: truefalse. Optionally revoke npm trust bindings (npm trust revoke <pkg> --id <id>) for cleanliness, not required.

The reusable workflow's conditional logic keeps both paths viable indefinitely. Removal of the legacy path is a separate decision and not part of this PR (it's Phase 4 of the rollout).

Failure modes when npm-oidc-enabled: true

Documented in full in docs/npm-oidc-migration.md. Quick reference:

  • FM-A — OIDC token exchange 404 (trust binding missing): the workflow fails fast (NPM_TOKEN='' so no silent fallback). Recovery: register the binding OR flip back to false.
  • FM-B — Sigstore unreachable: temporary NPM_CONFIG_PROVENANCE: 'false' override OR flip back to false. Watch https://status.sigstore.dev/.
  • FM-C — Env-approval gate stalls: drop the reviewer rule via gh api PUT (gh command in the doc) OR approve via UI.
  • FM-D — Workflow / env / repo renamed: re-register trust bindings against the new name; optionally revoke stale bindings.

Consumer migration

See docs/npm-oidc-migration.md → "Order of operations checklist" for the full per-consumer checklist:

  1. Add SR_NO_NPM_AUTH === 'true' guard to .releaserc.cjs.
  2. Bump engines.npm to >=11.5.1 <12.0.0.
  3. Create npm-publish GitHub Environment + tighten branch protection on main.
  4. As adobe-bot: npm trust github @adobe/<pkg> --repository adobe/<consumer-repo> --file <caller-workflow>.yaml --environment npm-publish --yes for each package.
  5. Flip npm-oidc-enabled: true in the caller workflow.

Critical security note (binding to reusable workflows)

When service-ci.yaml runs via workflow_call, GitHub mints an OIDC token with both:

  • workflow_ref — the caller's workflow (adobe/spacecat-api-service/.github/workflows/main.yaml@refs/heads/main)
  • job_workflow_ref — this reusable workflow (adobe/mysticat-ci/.github/workflows/service-ci.yaml@refs/tags/v3.0.0)

Bind packages to workflow_ref (the caller), NOT job_workflow_ref (mysticat-ci). A binding pointing at mysticat-ci/service-ci.yaml would let any consumer of this reusable workflow publish another consumer's package. The npm CLI's npm trust github binds against workflow_ref when you pass --repository <consumer-repo> --file <caller-filename>.

This is documented inline in the workflow comment and in docs/npm-oidc-migration.md. The Phase 1 success criteria includes an explicit check on the attestation's workflow.path field to catch a mis-registered binding before the rollout propagates.

Test plan

  • CI on this PR validates the workflow YAML parses.
  • Maintainer approval before tagging v3.0.0.
  • Phase 1 canary completes ≥ 2 successful OIDC releases + all success criteria.
  • Phase 2 pilot batch completes with at least one VPC-enabled consumer validating the --environment prod binding path.
  • Phase 3 tracking issue closes when the last consumer ticks its box.
  • Phase 4 token decommissioning lands as a separate PR.

Out of scope (follow-ups, not blocking this PR)

  • Per-consumer code PRs (one per repo, ~3 file changes each).
  • Per-consumer server-side env + branch protection + trust binding registration.
  • Setup script for the consumer-side registration ceremony (analogue to spacecat-shared/scripts/setup-npm-trusted-publishers.sh, parameterized for the caller repo + package list).
  • Phase 3 tracking issue on this repo with a checkbox per consumer.
  • Phase 4 — eventual removal of ADOBE_BOT_NPM_TOKEN from org secrets + the legacy branch from service-ci.yaml.
  • provenance-enabled input on the reusable workflow (parameterizes NPM_CONFIG_PROVENANCE as a sigstore-outage break-glass). Add only if FM-B becomes recurring.

Commits

  • af52621 — initial workflow change + first version of docs/npm-oidc-migration.md.
  • 5ddfdb4 — self-review: expanded rollback (scenarios A/B/C), four failure modes, backward-compat audit table, deduplicated checklist.
  • e300314 — release + phased rollout strategy (Phases 0–4, canary selection criteria, communication template, meta-risks).

🤖 Generated with Claude Code

alinarublea and others added 2 commits May 21, 2026 16:51
Adds a new workflow_call input `npm-oidc-enabled` (default: false) to
the reusable Service CI workflow. When true, the publish path switches
from long-lived ADOBE_BOT_NPM_TOKEN to npm Trusted Publishers (OIDC)
with sigstore provenance attestations, matching the model landed in
adobe/spacecat-shared PR #1592.

Behavior:

- Dry-run (PR / non-main push): SR_NO_NPM_AUTH=true is set, NPM_TOKEN
  is dropped. The consumer's .releaserc.cjs must gate
  @semantic-release/npm on `process.env.SR_NO_NPM_AUTH === 'true'`.
- Release (push to main): NPM_TOKEN dropped, NPM_CONFIG_PROVENANCE=true
  set, environment claim minted via the npm-publish GitHub Environment
  (or 'prod' when vpc-enabled, so VPC consumers keep their existing
  env config and bind their npm trust with --environment prod).
- Adds a conditional "Update NPM" step pinning npm@11.13.0 (the OIDC
  floor is npm >= 11.5.1).

Default-false: existing consumers keep token-based publishing
unchanged until they explicitly opt in. Documentation for the
per-consumer migration (code, server-side env+branch protection,
npm trust binding registration, rollback) is added at
docs/npm-oidc-migration.md.

Security note recorded inline + in the doc: trust bindings MUST be
registered against the caller's workflow_ref, not mysticat-ci's
job_workflow_ref — otherwise any consumer of the reusable workflow
could republish another consumer's package.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Self-review additions on the npm OIDC migration:

- Three explicit rollback scenarios (consumer-side flag flip,
  mysticat-ci PR-level revert, permanent rotation reversal) with
  a behavior-knob table that maps the input flag to each env var
  the workflow sets, so the rollback's blast radius is exact.
- Four failure modes (FM-A trust-binding 404, FM-B sigstore down,
  FM-C env-approval stall, FM-D rename) with copy-pasteable
  recovery commands.
- Backward-compat audit table: every env var / step / permission
  pre-PR vs post-PR (input=false) vs post-PR (input=true). All
  default-false code paths produce identical behavior to the
  pre-PR workflow.
- Workflow comment on the 'Update NPM' step documents why running
  AFTER setup-node-npm composite's npm ci is safe in practice
  (npm 11's publish path is backward-compatible with node_modules
  installed by npm 10).
- Deduplicated the order-of-operations checklist (was accidentally
  inserted twice in the prior commit).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@alinarublea
alinarublea requested a review from solaris007 May 21, 2026 15:03
Adds the missing owner-side plan for cutting v3.0.0 of mysticat-ci
and rolling out across the 14 consumer service repos in a
controlled, observable, reversible sequence.

Four phases with explicit success criteria + rollback triggers per
phase:

- Phase 0: cut v3.0.0 (pre-cut checks listed inline).
- Phase 1: canary (1 repo, ≥ 1 week observation). Selection criteria
  named (active, low blast radius, not VPC, not in code freeze).
  Likely canary candidates ranked: spacecat-jobs-dispatcher,
  spacecat-task-processor, mysticat-projector-service.
- Phase 2: pilot batch (3 repos, ≥ 1 week). Diversity criteria:
  +1 non-VPC, +1 VPC-enabled, +1 multi-package consumer.
- Phase 3: bulk rollout (10 remaining repos), coordinated via a
  tracking issue on mysticat-ci. Suggested ordering documented.
- Phase 4: token decommissioning. Gated on every consumer crossing
  the ≥ 2-cycle threshold, not a calendar date.

Each phase has explicit success criteria, rollback triggers, and a
named action. Phase 1 fail-stop includes the workflow_ref-vs-
job_workflow_ref check on the attestation's workflow.path — catches
a mis-registered trust binding before propagation.

Adds a "What can go wrong with the rollout itself" section covering
the four meta-risks (unpinned mysticat-ci refs, sigstore outages
mid-rollout, adobe-bot bus factor, ADOBE_BOT_NPM_TOKEN rotation
collision).

Adds a communication template owners can copy verbatim when
queueing the next consumer for migration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@solaris007 solaris007 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @alinarublea,

Thanks for a genuinely well-engineered migration. The opt-in boolean on the reusable workflow, the default-false backward-compat design, the phased rollout with measurable canary criteria, and the depth of the failure-mode runbook are all the right shape for moving 14 consumers to OIDC trusted publishing safely. There is one blocking defect: the load-bearing publish-path expression does not do what the PR says it does, and several doc sections document the wrong behavior as audited fact.

Note: CI is green, but nothing in CI exercises the npm-oidc-enabled: true path, so green is not evidence the OIDC path works.

Strengths

  • Default-false backward compatibility genuinely holds. With neither flag set, every new expression resolves to the pre-PR values (NPM_TOKEN = the token, others unset/'', environment = 'prod' iff vpc-enabled, "Update NPM" skipped). The 13 non-opted consumers see zero behavior change. (service-ci.yaml lines 154, 194, 272-273)
  • The environment: selection expression is correct for all four (vpc x oidc) combinations because every true-branch value is truthy. (service-ci.yaml line 194)
  • id-token: write is correctly scoped to the release job only; the build/dry-run job does not carry it.
  • npm trust github --repository --file --environment --yes is real, current CLI syntax, and binding to the caller's workflow_ref (not the reusable job_workflow_ref) is the correct trust model. The Phase 1 check that the SLSA attestation's workflow.path points at the caller is a strong safeguard.
  • Strict-equality guidance for SR_NO_NPM_AUTH === 'true' correctly avoids the truthy-string trap, and the ''-vs-unset analysis for the other env vars is handled right.
  • The phased rollout (canary -> pilot -> bulk -> decommission) with concrete success/rollback criteria, token retention for >=2 cycles before rotation, and the "add a new failure mode before continuing" learning loop are the right operational properties.

Issues

Critical (Must Fix)

1. The NPM_TOKEN expression returns the real token when OIDC is enabled - the migration is inert and fails silently into token publishing. (service-ci.yaml line 272 release, line 154 dry-run)

NPM_TOKEN: ${{ inputs.npm-oidc-enabled && '' || secrets.ADOBE_BOT_NPM_TOKEN }}

GitHub Actions A && B || C returns C whenever B is falsy, and '' is falsy. For npm-oidc-enabled == true: true && '' -> '', then '' || secrets.ADOBE_BOT_NPM_TOKEN -> secrets.ADOBE_BOT_NPM_TOKEN. So NPM_TOKEN holds the real long-lived token, not ''. This is the only one of the four new expressions whose intended true-value is falsy, which is why the other three are correct and this one is not.

Why it is Critical:

  • The release job intentionally does not set SR_NO_NPM_AUTH, so the consumer's .releaserc.cjs guard is false, @semantic-release/npm runs, finds a populated NPM_TOKEN, and publishes via the token. OIDC token exchange never triggers.
  • The PR's primary goal (eliminate the long-lived ADOBE_BOT_NPM_TOKEN from the publish path) is silently not achieved while the flag is on. The secret is still injected into both the release job and the dry-run job (which runs on feature-branch pushes).
  • It fails by silently retaining old behavior: publishes succeed, provenance may still emit, and a consumer believes they migrated while still on the token. The only thing that catches it is the manual npm view _npmUser canary check.
  • It inverts the FM-A "fail-fast" guarantee. The doc states the missing-trust-binding case fails fast "because OIDC mode sets NPM_TOKEN: ''." In reality a missing binding is masked - npm falls back to the token and publishes with the wrong identity.
  • Latent decommission landmine: every consumer can "migrate" successfully, then Phase 4 (delete ADOBE_BOT_NPM_TOKEN) breaks all of them at once, with no recent change to point at.

The doc's self-review table, the rollback table, the workflow-contract section, and FM-A all assert NPM_TOKEN = '' in OIDC mode. All are wrong and must be corrected once the code is fixed.

How to fix: make NPM_TOKEN genuinely UNSET (not empty) in OIDC mode, in both steps. The most robust form omits the env key entirely - a pre-step gated on if: ${{ !inputs.npm-oidc-enabled }} that writes NPM_TOKEN to $GITHUB_ENV, or two Semantic Release steps gated on the flag. A bare ternary inversion (${{ !inputs.npm-oidc-enabled && secrets.ADOBE_BOT_NPM_TOKEN || '' }}) fixes the "token is still the real secret" half and yields '', but resolve a disagreement first: your self-review says @semantic-release/npm treats '' as unset, while there is evidence an empty-string NPM_TOKEN can still suppress OIDC (npm tries the empty token rather than falling back to OIDC). Gated steps that omit the variable are correct under both readings, so prefer those. Then re-derive the self-review table from an actual run.

Important (Should Fix)

2. No pre-merge validation exercises the opt-in path, so a one-character expression error reaches 14 pipelines unguarded. (repo-level / service-ci.yaml)
The only CI on this PR is service-ci's own run, which never sets npm-oidc-enabled: true; the OIDC branches are never executed before merge, and actionlint/yaml-schema do not evaluate ternary semantics. The manual self-review was the only check, and it shipped a false assertion (critical finding 1). Your own Phase 0 note says "a typo here breaks every consumer that opts in" - manual reading is demonstrably insufficient here. Add a small matrix job in this repo that renders the publish-path env for both flag states and asserts the results (you cannot print the secret, but you can assert NPM_TOKEN is empty/absent when OIDC is on and present when off, plus the other three vars). That job would have caught finding 1 and turns the self-review table into an executable gate.

3. No fail-fast OIDC preflight, and the npm-publish environment auto-creates unprotected - the doc's main-only enforcement can silently not exist. (service-ci.yaml line 194; docs/npm-oidc-migration.md server-side setup)
The sibling vpc-enabled feature has a hard-fail sanity-check when a consumer sets the flag without doing their side; npm-oidc-enabled has no equivalent. Two failure shapes: (a) a consumer who flips the flag without registering the trust binding hits FM-A (404) at release time on main, the worst moment; (b) referencing a not-yet-created npm-publish environment does not enforce a branch policy - GitHub auto-creates the environment with no protection rules and proceeds, so the "server-enforced main-only deployment_branch_policy" the doc leans on to justify skipping the reviewer gate is absent until the consumer manually creates it. (Mitigating factor: the release job's own if: github.ref == 'refs/heads/main' still blocks non-main publishes within this workflow, so the direct exploit needs a second workflow referencing the unprotected env.) Add a preflight gated on inputs.npm-oidc-enabled that asserts the cheap prerequisites before publish - environment exists with a main-restricted policy, and npm --version satisfies the floor - mirroring the existing VPC sanity-check pattern in this repo.

4. Two load-bearing sections of the runbook are inaccurate. (docs/npm-oidc-migration.md)
The doc is a primary deliverable and operators are told it is "part of the contract," so factual errors matter:

  • FM-B's primary recovery tells operators to "inject NPM_CONFIG_PROVENANCE: 'false' via a with: workaround," then notes it "is not parameterized through the reusable workflow." There is no such input, so the documented primary recovery is not actionable - a wrong first step in a 3am runbook. Make the flag-flip the primary recovery, or add the provenance-enabled input the doc gestures at.
  • The "Security note: binding to reusable workflows" states npm "checks both claims" and that a binding pointed at mysticat-ci would let any consumer publish your package (fail-open). This reads as inverted: npm anchors authorization on the caller's workflow_ref, and a binding registered against the reusable workflow's repo would match the consumer's repository claim at runtime and fail closed, not open. The actionable instruction (bind to caller repo + file) is correct either way, so the migration will not break - but please verify the threat-model narrative against npm's trusted-publisher docs before this becomes the authoritative explanation, since it is the doc's central security claim.

5. The rollout plan has no forcing function for Phase 4, risking a permanent dual-path and an undead long-lived secret. (docs/npm-oidc-migration.md)
The doc says the workflow "keeps both paths viable indefinitely," yet gates Phase 4 only on "every consumer has >=2 cycles ... not a calendar date," while admitting the least-active repos "may be weeks out." Deadline-free voluntary migrations across many owners reliably stall with a long tail, and the stragglers block token deletion forever - leaving you carrying both publish paths and the long-lived secret you set out to remove, which is worse than the starting point. The Phase 4 PR is out of scope, but the rollout plan lives in this PR: add a forcing function (a target date for Phase 4, and/or a commitment to migrate laggards or flip the input default at a future major), and reword "indefinitely" to "until Phase 4."

Minor (Nice to Have)

  • npm version single source of truth. The floor >=11.5.1 and the pin 11.13.0 are two numbers to keep in sync. A short comment that 11.13.0 is "latest patch satisfying the >=11.5.1 floor" makes the next bump intentional. (service-ci.yaml line 252)
  • FM-C and "add a reviewer gate later" snippets weaken the branch policy if followed verbatim. Both set deployment_branch_policy: { protected_branches: false, custom_branch_policies: true } but never re-add the main entry, so an operator running them under pressure leaves the env accepting any branch until a separate step restores main. Include the explicit main re-add in both snippets.
  • "Part of the contract" framing. A 636-line prose runbook is documentation, not the enforced contract (the expressions, the trust binding, and the env branch policy are). Given the self-review table is currently wrong, reframing as "operational runbook + rollout plan" avoids anyone trusting the prose over the code.

Recommendations

  • Replace the runtime npm install -g npm@11.13.0 with Node-version pinning so the bundled npm satisfies the floor. As written it fetches npm from the public registry on every release, inside the one job with id-token: write - a needless supply-chain fetch and failure point at the most critical moment, for a PR whose purpose is supply-chain hardening. If you keep it, add a npm --version assertion (a silently-failed upgrade would publish on npm 10 with no trusted-publishing support) and a retry, and note a removal trigger for when runners ship npm >= 11.5.1. (service-ci.yaml line 261)
  • Add a post-publish "OIDC was actually used" assertion (gated) that checks npm view <pkg> _npmUser is the GitHub Actions identity and provenance is present, failing loud otherwise - the durable fail-loud control for the whole silent-fallback class, for all 14 consumers not just the canary. Relatedly, make _npmUser the primary canary gate, not provenance presence: dist.attestations can be non-empty even on a token-authed publish because provenance signs via the id-token independently of registry auth.
  • Build the provenance-enabled escape hatch now (or make provenance non-fatal). Sigstore outages are recurring and fleet-wide; FM-B's current remedy is a per-consumer hotfix or full flip-back, so an outage during a release window could mean up to 14 hotfix PRs.
  • Confirm the npm-upgrade ordering against engines.npm. The "Update NPM" step runs after the composite's npm ci; an engine-strict consumer whose .nvmrc Node ships npm < 11.5.1 could fail npm ci before the upgrade. The composite is in this repo, so moving the bump ahead of npm ci is an in-repo fix if it bites.
  • Name a second adobe-bot operator and a rotation-coordination owner in the rollout plan, rather than caveating the single-credential-holder dependency and the multi-week rotation freeze.
  • Document the environment-overloading constraint. vpc+oidc consumers bind npm trust to --environment prod, so prod serves both AWS deploy and npm publish under one set of rules and one identity claim; a future rename breaks publishing too. Partly inherited, so capture it as a deliberate documented constraint.

Out of scope, worth tracking

  • The 14 consumer-repo migrations and the Phase 4 token-removal PR are correctly listed as follow-ups.
  • A platform helper (composite action or mysticat-cli subcommand) that derives --repository/--file/--environment from the consumer's context would make the caller-binding rule a mechanical guarantee rather than a doc-discipline one - the highest-leverage way to prevent a cross-consumer mis-binding across 14+ repos.
  • The adobe-bot PR-bypass plus ADOBE_BOT_GITHUB_TOKEN means provenance attests "built from main," not "reviewed"; a longer-term move to a minimally-scoped GitHub App for the version-bump push is worth tracking. Pre-existing, not introduced here.

Assessment

Ready to merge? No - with fixes. The strategy and the migration mechanism are sound and unusually well-documented, but critical finding 1 means opting in changes nothing about the publish identity (it silently keeps token publishing) while the doc certifies the opposite, so the first canary would be trusting a false fail-fast promise. Fix the NPM_TOKEN expressions (preferably by making the variable unset, not empty), correct the self-review/rollback/FM-A text to match, and add a pre-merge render-test so this class of bug cannot recur. The preflight, the runbook corrections, and the Phase 4 forcing function should land before bulk rollout.

Next Steps

  1. Fix critical finding 1 in both locations and re-derive the self-review table from a real run.
  2. Add the pre-merge render-test (finding 2) so the fix is verified and regressions are caught.
  3. Address the preflight/env-protection gap (finding 3), the two runbook inaccuracies (finding 4), and the Phase 4 forcing function (finding 5).
  4. Minor items and recommendations are optional, but the post-publish OIDC assertion and dropping the runtime npm install are high-value, low-cost hardening that fit this PR's theme.

Comment thread .github/workflows/service-ci.yaml Outdated
# main-only branch policy. NPM_CONFIG_PROVENANCE=true emits sigstore
# provenance attestations with every publish.
# Legacy mode: ADOBE_BOT_NPM_TOKEN authenticates the publish.
NPM_TOKEN: ${{ inputs.npm-oidc-enabled && '' || secrets.ADOBE_BOT_NPM_TOKEN }}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Critical: this returns the real token when OIDC is enabled, so the migration is inert.

${{ inputs.npm-oidc-enabled && '' || secrets.ADOBE_BOT_NPM_TOKEN }} uses the GitHub Actions A && B || C idiom, which returns C whenever B is falsy. '' is falsy, so for npm-oidc-enabled == true: true && '' -> '', then '' || secrets.ADOBE_BOT_NPM_TOKEN -> the token. NPM_TOKEN is the real long-lived secret, not empty. This is the only one of the four new expressions whose intended true-value is falsy, which is why the other three are correct and this one is not.

This release job does not set SR_NO_NPM_AUTH, so @semantic-release/npm runs, sees the token, and publishes via the token - OIDC token exchange never engages. The long-lived secret is not eliminated as the PR claims, and FM-A's "fails fast because NPM_TOKEN: ''" guarantee is inverted (a missing trust binding is silently masked rather than surfaced). It also sets up a Phase 4 landmine: every consumer "migrates" successfully, then deleting ADOBE_BOT_NPM_TOKEN breaks them all at once.

Fix: make NPM_TOKEN genuinely unset (not empty) in OIDC mode - a pre-step gated on if: ${{ !inputs.npm-oidc-enabled }} that writes it to $GITHUB_ENV, or two flag-gated Semantic Release steps. A bare inversion to '' (${{ !inputs.npm-oidc-enabled && secrets.ADOBE_BOT_NPM_TOKEN || '' }}) fixes the "still the real secret" half, but verify first whether npm treats an empty NPM_TOKEN as "use OIDC" or "try the empty token and fail" - omitting the variable is correct under both readings. Then re-derive the self-review/rollback/FM-A text from an actual run.

Comment thread .github/workflows/service-ci.yaml Outdated
# Legacy mode: ADOBE_BOT_NPM_TOKEN authenticates the verify step.
# Both paths cannot share the same secret slot — NPM_TOKEN is only
# injected when OIDC is disabled.
NPM_TOKEN: ${{ inputs.npm-oidc-enabled && '' || secrets.ADOBE_BOT_NPM_TOKEN }}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same bug as the release job: A && B || C with a falsy '' true-branch returns secrets.ADOBE_BOT_NPM_TOKEN when npm-oidc-enabled is true, not ''. Here it is currently masked because this dry-run job also sets SR_NO_NPM_AUTH: 'true', which drops @semantic-release/npm from the dry run so the token goes unused - but fix it alongside the release-job line for consistency and to remove the latent landmine.

# npm trust to --environment prod. Pure OIDC consumers (no VPC) get the
# dedicated 'npm-publish' env. Legacy consumers (neither flag set) run
# without an env claim, matching today's behavior.
environment: ${{ (inputs.vpc-enabled && 'prod') || (inputs.npm-oidc-enabled && 'npm-publish') || '' }}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The expression itself is correct for all four (vpc x oidc) combinations - every true-branch value here is truthy.

The gap is that nothing fails fast when a consumer opts in without completing their side. Referencing a not-yet-created npm-publish environment makes GitHub auto-create it with no protection rules, so the "server-enforced main-only deployment_branch_policy" the doc relies on (to justify skipping the reviewer gate) is absent until the consumer manually creates the env. (Mitigating factor: the release job's own if: github.ref == 'refs/heads/main' still blocks non-main publishes within this workflow.) Consider a preflight step gated on inputs.npm-oidc-enabled that asserts the env exists with a main-restricted policy and npm --version meets the floor - mirroring the existing VPC sanity-check pattern already in this workflow.

@solaris007 solaris007 added the ai-reviewed Reviewed by AI label May 22, 2026
alinarublea and others added 2 commits May 22, 2026 11:19
…tests

Round-1 review caught a critical defect: the
`${{ inputs.npm-oidc-enabled && '' || secrets.ADOBE_BOT_NPM_TOKEN }}`
ternary returns the real token when OIDC is enabled, because GHA's
`A && B || C` returns C whenever B is falsy and `''` is falsy. The
migration was silently inert. Plus four important findings and several
recommendations. Round-2 self-review via senior-staff-engineer caught
three follow-up bugs in my round-1 fix — addressed inline.

service-ci.yaml:
- Replace NPM_TOKEN ternary with gated `$GITHUB_ENV`-writing pre-steps
  for both dry-run and release. In OIDC mode NPM_TOKEN is genuinely
  UNSET (not the empty string), correct under both readings of how npm
  treats an empty token.
- OIDC preflight asserts the GitHub Environment exists with ONLY 'main'
  allowed (exclusivity, not presence) and that npm meets the >= 11.5.1
  floor after install. Surfaces `gh api` failures rather than masking
  them as policy errors.
- Wrap `npm install -g npm@11.13.0` with 3-attempt retry + explicit
  version assertion via `sort -V` so a silently-failed upgrade can't
  publish under an unsupported npm.
- Pre/post-publish version snapshot + "Verify OIDC publish identity"
  step asserts `_npmUser` contains "GitHub Actions" for the
  just-published version. Skips if no new version was published this
  run (avoids false-positive against a prior token-era publish).
- Heredoc writes strip trailing newlines from the token to handle
  secret stores that append them.

test-publish-path-render.yaml (new):
- Structural grep gate forbids the buggy NPM_TOKEN ternary form and any
  inline NPM_TOKEN on the Semantic Release env block; requires the
  gated pre-step pattern (verified via heredoc marker count).
- 4-cell matrix (oidc x vpc) renders the publish-path env composition
  and asserts the documented outcomes. Uses string matrix values plus
  explicit `== 'true'` / `== 'false'` comparisons to sidestep GHA
  matrix-value truthiness ambiguity that would otherwise skip the
  legacy-arm injection in BOTH matrix cells.

docs/npm-oidc-migration.md:
- Re-derive the self-review table to match the gated-step pattern.
- Rewrite "Security note: binding to reusable workflows" — npm
  authorizes against the caller's `workflow_ref` only (per npm docs:
  "validation checks the calling workflow's name"). A binding
  misregistered against mysticat-ci's reusable workflow fails CLOSED
  at publish time, not fail-OPEN. The original threat-model narrative
  was inverted; the actionable instruction (bind to caller repo + file)
  was correct either way.
- FM-B: flag-flip is now the primary recovery; remove the documented
  but non-existent `with: NPM_CONFIG_PROVENANCE: 'false'` workaround.
- FM-C and "add a reviewer gate later" snippets re-add 'main' to the
  `deployment_branch_policy` after the PUT (the API clears it).
- Phase 4 forcing function: target = v3.0.0 + 3 months; laggard policy
  flips the input default at +30 days; token revocation at +60 days
  regardless of per-consumer cycle count.
- Reframe "doc is part of the contract" -> "operational runbook +
  rollout plan"; the enforced contract is the workflow expressions,
  the trust binding, and the env's branch policy.
- Document `ADOBE_BOT_GITHUB_TOKEN` requires `Administration: read` on
  the consumer repo for the preflight to read env settings.

Validation (local):
- YAML parses cleanly (yaml.safe_load).
- All 4 structural grep checks pass against the actual file.
- npm view _npmUser output shape verified against @sigstore/sign,
  @actions/core, and vite — all return
  "GitHub Actions <npm-oidc-no-reply@github.com>" (string form).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The structural-check job had a bash comment containing the literal text
`${{ !inputs.npm-oidc-enabled` with no closing `}}` on the same line.
Inside a `run: |` block scalar, `#` is plain text rather than a comment,
so GHA's expression parser scans the whole script string, sees the
unbalanced `${{`, and fails workflow validation before any job starts.
Rewrite the comment to describe the pattern in prose rather than
embedding the literal sequence.

The actual grep regex on the next line uses `\\\$\\{\\{` (escaped via
both YAML and bash double-quote rules) so it doesn't contain the literal
trigger sequence and parses cleanly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@MysticatBot-Dev MysticatBot-Dev left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @alinarublea,

This is a re-review following the 5 findings from the initial review (1 Critical, 4 Important). All have been addressed in commits 5992641 and a8d72d3. The gated-step pattern, test workflow, OIDC preflight, runbook corrections, and Phase 4 forcing function are all well-executed. One new issue surfaced during this pass.

Strengths

  • Critical NPM_TOKEN bug fixed correctly. The broken ternary is replaced by gated pre-steps that leave NPM_TOKEN genuinely UNSET in OIDC mode. The inline comment explaining why the ternary fails ('' is falsy in GHA expressions) prevents reintroduction. (service-ci.yaml lines 151-170, 391-410)
  • Executable regression gate added. test-publish-path-render.yaml structurally asserts no inline NPM_TOKEN: on the Semantic Release env, requires gated steps with !inputs.npm-oidc-enabled, and matrix-renders all (oidc x vpc) combinations. CI is green across all 4 cells plus the structural check.
  • OIDC preflight validates env policy before publish. Checks environment existence, deployment_branch_policy presence, and that only main is allowed. Surfaces actionable ::error:: annotations on failure. (service-ci.yaml lines 279-360)
  • Runbook corrected. FM-B recovery now correctly recommends flipping back to legacy mode (not an unimplemented provenance-enabled input). Security note now correctly explains that npm checks workflow_ref (caller) and that misregistration is fail-closed, not fail-open. (docs/npm-oidc-migration.md lines 897-920, 1259-1294)
  • Phase 4 forcing function documented. Hard deadline (v3 tag + 3 months), default flip at +30 days, token deletion at +60 days. Prevents the indefinite dual-path stall. (docs/npm-oidc-migration.md lines 1188-1212)
  • Fail-closed by construction. In OIDC mode, the NPM_TOKEN injection step is skipped entirely (gated on !inputs.npm-oidc-enabled), so there is no codepath where a misconfigured consumer silently falls back to token publishing.
  • Environment precedence correctly reasoned. VPC wins because those consumers bind npm trust to --environment prod. The expression and rationale are both documented inline.

Issues

Important (Should Fix)

1. "Capture pre-publish registry version" step runs AFTER Semantic Release, making the identity verification inert. (service-ci.yaml line 429 vs line 412)

The step's comment says "we snapshot the registry version BEFORE running Semantic Release, then compare to AFTER." But in the actual file ordering, "Semantic Release" is at line 412 and "Capture pre-publish registry version" is at line 429 - it runs after the publish. In the common case (registry propagates within milliseconds), the "pre" capture picks up the already-published version, the verification step sees POST == PRE, prints "No publish in this run," and skips the identity check entirely.

Why it matters: The post-publish _npmUser verification was the prior review's recommended "durable fail-loud control" for the silent-token-fallback class of bugs. With the step in the wrong position, that control is inert for the majority of releases. The preventive control (NPM_TOKEN genuinely absent) is intact and load-bearing, so this is not a security gap, but the detective control that catches regressions is broken.

How to fix: Move the "Capture pre-publish registry version" step to immediately before "Semantic Release" (between "Configure NPM_TOKEN (legacy mode only) - release" and "Semantic Release").

2. OIDC preflight does not validate can_admins_bypass. (service-ci.yaml OIDC preflight step)

The migration doc (line 643) lists can_admins_bypass: false as a required setting, but the preflight only checks deployment_branch_policy. A consumer could pass the preflight with can_admins_bypass: true, allowing an admin to approve a deployment from a non-main branch.

Why it matters: The env-level can_admins_bypass is part of the documented security model. The preflight already checks branch policy strictness; adding this check is consistent with its existing scope.

How to fix: After parsing ENV_BODY, extract and assert:

CAN_BYPASS=$(printf '%s' "$ENV_BODY" | jq -r '.can_admins_bypass // true')
if [ "$CAN_BYPASS" = "true" ]; then
  echo "::error::Environment '${ENV_NAME}' allows admins to bypass. Set can_admins_bypass: false."
  exit 1
fi

Minor (Nice to Have)

  • npm version pin (11.13.0) will drift. The pinned version is conservative and the floor assertion is correct, but there is no mechanism (Dependabot, cron) to surface when a newer patch is available. Consider a comment noting a quarterly review cadence, or widening to npm@11. (service-ci.yaml line 362)
  • npm upgrade retry loop does not distinguish retryable from non-retryable errors. A 403 (rate limit) and a disk-full error both get the same 3x5s retry. Low impact since the version assertion catches all failures. (service-ci.yaml lines 378-382)
  • Identity verification degrades to warning when _npmUser is empty. This is intentional ("registry hiccup" tolerance) but means a registry metadata outage could mask a token-based publish. The window is small. (service-ci.yaml line 515)

Recommendations

  • Track removal of npm install -g npm@11.13.0 when Node LTS ships with npm >= 11.5.1 bundled. This eliminates a public-registry fetch inside the most privileged job (id-token: write + AWS credentials live). The exact version pin and version assertion mitigate the supply-chain risk, but removing the step entirely is the cleanest long-term fix.
  • Consider adding --ignore-scripts to the npm install (npm install -g npm@11.13.0 --ignore-scripts). The npm package has no install scripts, but making the intent explicit prevents a future supply-chain vector.
  • Improve preflight error message for token permission failures. The most common first-run failure will be ADOBE_BOT_GITHUB_TOKEN lacking Administration: read on a consumer repo, which surfaces as "environment does not exist." Distinguishing 403 from 404 in the error output would reduce debugging friction.
  • Consider a cron-based health-check workflow that weekly validates the canary package's _npmUser is still "GitHub Actions". Catches silent regressions from platform changes between releases.

Assessment

Ready to merge? With fixes - move the pre-publish capture step before Semantic Release (finding 1). The can_admins_bypass check (finding 2) is a hardening improvement that could land in a fast follow-up.

Next Steps

  1. Move "Capture pre-publish registry version" to before "Semantic Release" so the identity verification is functional.
  2. Optionally add can_admins_bypass to the preflight (can be a follow-up commit or separate PR).
  3. Minor items and recommendations are non-blocking.

Skill: pr-review | Model: us.anthropic.claude-opus-4-6-v1[1m] | Duration: 3m 1s | Cost: $4.86 | Commit: a8d72d3d0c6203026beab56311973a9d7e0eb1f0
If this code review was useful, please react with 👍. Otherwise, react with 👎.

…bypass

Two Important findings from MysticatBot's re-review of PR #14:

1. "Capture pre-publish registry version" ran AFTER "Semantic Release",
   making the post-publish OIDC identity check inert (PRE == POST in the
   common case → "no publish in this run" short-circuit). Moved the
   capture step to run before Semantic Release and added a "MUST run
   before Semantic Release" inline comment so the ordering survives
   future edits.

2. OIDC preflight did not validate can_admins_bypass, allowing an admin
   to approve a deployment from a non-main branch and defeat the
   main-only branch policy. Added an assertion that the env's
   can_admins_bypass is the literal string "false". Polarity is
   inverted on purpose (anything other than "false" fails) so missing,
   null, and future API-shape drift all fail closed. Comment enumerates
   the three drift cases. Used jq's has()/then/else form rather than
   `.can_admins_bypass // true` because `//` treats false the same as
   null and would fire on correctly-configured consumers.

Doc: updated the high-level preflight summary in npm-oidc-migration.md
so the description matches what the preflight now asserts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@MysticatBot-Dev MysticatBot-Dev left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @alinarublea,

This is a re-review following the 2 Important findings from the prior review. Both are addressed in commit bcdb3856.

Strengths

  • Step ordering fixed correctly (service-ci.yaml lines 435-471): "Capture pre-publish registry version" now runs before "Semantic Release" as intended. The added comment block explicitly explains why ordering matters and names the specific failure class (silent-token-fallback bypass via POST == PRE short-circuit). This prevents future editors from accidentally reordering.
  • can_admins_bypass validation is fail-closed and thorough (service-ci.yaml lines 330-351): The check uses jq's has() form to distinguish missing-field from false-field, avoiding the // operator pitfall where false // true returns true. The inline comment enumerates all three drift cases (field missing, field null, future API shape change) and explains why the obvious shortcut is wrong. Actionable error message tells the consumer exactly what to fix and where.
  • Documentation updated in lockstep (docs/npm-oidc-migration.md): The preflight description now reflects the can_admins_bypass check immediately, keeping docs and behavior aligned.
  • Error messages are runbook-grade: Both the bypass-check failure and the environment-not-found path tell the operator the current value, which environment is misconfigured, and the exact Settings path to fix it.

Assessment

Ready to merge? Yes.

Both prior findings are addressed cleanly. The can_admins_bypass gate closes a real configuration-drift gap in the security model, and the step reordering restores the detective control that catches silent token fallback. CI is passing across all 4 render-matrix cells plus the structural check. No new issues introduced.


Skill: pr-review | Model: us.anthropic.claude-opus-4-6-v1[1m] | Duration: 0m 37s | Cost: $3.63 | Commit: bcdb3856dc7ff74eccd074b5d5443a570db9dbb2
If this code review was useful, please react with 👍. Otherwise, react with 👎.

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

Labels

ai-reviewed Reviewed by AI

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants