feat(ci): adopt npm Trusted Publishing, GitHub App tokens, explicit secrets

Three best-practice upgrades surfaced by the release-pipeline audit.

  npm Trusted Publishing (GA 2025-07-31)
    - Drop `env: NODE_AUTH_TOKEN` (empty string would break OIDC fallback;
      the var must be unset, not blanked).
    - Drop the explicit `--provenance` flag (registry auto-attaches it on
      trusted-publisher publishes).
    - `id-token: write` permission retained for the OIDC exchange.
    - Prerequisite: register the package as a trusted publisher on
      npmjs.com bound to this repo + publish.yml. Once configured, the
      NPM_TOKEN repo secret can be deleted entirely.

  GitHub App token replaces RELEASE_PUSH_TOKEN PAT
    - New `actions/create-github-app-token@v3.2.0` step mints a short-lived
      (~1h) installation token before the RC checkout.
    - Token is consumed by `actions/checkout` (with `persist-credentials:
      false`) and by the inline `http.extraheader` at git-push time.
    - Same fine-grained permission surface (Contents: write + Workflows:
      write), not tied to a user seat, organizationally auditable.
    - Prerequisite: create the GitHub App, install on this repo with the
      required permissions, and set `vars.RELEASE_APP_ID` (numeric ID,
      not sensitive) + `secrets.RELEASE_APP_PRIVATE_KEY` (PEM).

  Drop `secrets: inherit` from the ci.yml call
    - Verified by grep: ci.yml and its entire reusable-workflow chain
      (ci-quality, ci-tests, ci-e2e, ci-scope-parity, ci-report) reference
      zero `secrets.*` values. The inherit was passing through nothing.
    - GITHUB_TOKEN is implicit and remains available.

Refs the audit at PR #1610.
This commit is contained in:
Gergo Magyar 2026-05-15 09:15:17 +01:00
parent 919acab7fb
commit 34f6f0a941

View file

@ -275,9 +275,10 @@ jobs:
fi
# ── Phase 3: reusable CI gate ──────────────────────────────────────────────
# Runs for both rc (when guard says go) and stable. KTD-9: secrets: inherit
# matches release-candidate.yml's prior contract; ci.yml consumes secrets
# the current stable publish path was accidentally hiding.
# Runs for both rc (when guard says go) and stable. No `secrets:` passed —
# ci.yml and its entire reusable-workflow chain (ci-quality, ci-tests,
# ci-e2e, ci-scope-parity, ci-report) reference zero `secrets.*` values;
# passing any would be unused surface. GITHUB_TOKEN is implicit.
ci:
needs: [route, rc-guard]
if: ${{ always() && (needs.route.outputs.mode == 'stable' || needs.rc-guard.outputs.should_run == 'true') }}
@ -285,7 +286,6 @@ jobs:
permissions:
contents: read
actions: read
secrets: inherit
# ── Phase 4: publish to npm + push refs (RC path) ──────────────────────────
publish:
@ -305,25 +305,46 @@ jobs:
# Two distinct step IDs feed this output; exactly one fires per run.
vtag: ${{ steps.rc-tags.outputs.vtag || steps.stable-vtag.outputs.vtag }}
steps:
# ── Mint short-lived GitHub App token (RC only) ──────────────────────
# Industry direction (2025-2026): GitHub Apps with
# `actions/create-github-app-token` over long-lived PATs for
# workflow-touching tag pushes. Same fine-grained permission surface
# (Contents: write + Workflows: write), ~1h expiry, not tied to a
# user seat, organizationally auditable. Replaces the prior
# RELEASE_PUSH_TOKEN PAT (S34132).
#
# Required secrets/vars (set in repo Settings → Secrets and variables → Actions):
# vars.RELEASE_APP_ID — the App's numeric ID (not sensitive)
# secrets.RELEASE_APP_PRIVATE_KEY — the App's PEM private key
# The App must be installed on this repository with Contents: write
# and Workflows: write permissions.
- name: Mint GitHub App token (RC)
if: needs.route.outputs.mode == 'rc'
id: app-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
app-id: ${{ vars.RELEASE_APP_ID }}
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
# ── Separate checkout steps per mode (KTD-4) ─────────────────────────
# Conditional `token:` expressions are footguns: empty string passed to
# actions/checkout fails opaquely, and `|| github.token` silently
# degrades a missing PAT to GITHUB_TOKEN, masking auth failures until
# degrades a missing token to GITHUB_TOKEN, masking auth failures until
# the eventual `git push`. Two distinct steps make the auth contract
# explicit and fail loudly at checkout when RELEASE_PUSH_TOKEN is
# missing on the RC path.
# explicit and fail loudly at checkout when the App token mint failed
# on the RC path.
- name: Checkout (RC)
if: needs.route.outputs.mode == 'rc'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
fetch-tags: true
# Fine-grained PAT (Contents: write + Workflows: write) — required
# because the v-tag push lands at a SHA whose tree may touch
# Short-lived GitHub App installation token. Required because the
# v-tag push lands at a SHA whose tree may touch
# `.github/workflows/**`, which the default GITHUB_TOKEN cannot
# author. See S34132 for the migration history.
token: ${{ secrets.RELEASE_PUSH_TOKEN }}
# Do not persist the PAT in .git/config (artipacked audit). The
# author.
token: ${{ steps.app-token.outputs.token }}
# Do not persist the token in .git/config (artipacked audit). The
# RC tag push uses an inline `http.extraheader` at push time only;
# the credential never lands on disk. See the
# `Create and push rc tags` step below.
@ -562,11 +583,12 @@ jobs:
env:
RC_VERSION: ${{ steps.rc-version.outputs.rc_version }}
HEAD_SHA: ${{ needs.rc-guard.outputs.head_sha }}
# Auth is supplied inline at push time via `http.extraheader` (per
# GitHub's documented x-access-token Basic pattern). It is NOT
# persisted in .git/config (artipacked audit) — checkout above
# ran with `persist-credentials: false`.
PUSH_TOKEN: ${{ secrets.RELEASE_PUSH_TOKEN }}
# Short-lived GitHub App token. Auth is supplied inline at push
# time via `http.extraheader` (per GitHub's documented
# x-access-token Basic pattern). It is NOT persisted in
# .git/config (artipacked audit) — checkout above ran with
# `persist-credentials: false`.
PUSH_TOKEN: ${{ steps.app-token.outputs.token }}
run: |
set -euo pipefail
VTAG="v${RC_VERSION}"
@ -666,14 +688,29 @@ jobs:
echo "vtag verified: ${VTAG} (mode=${MODE})"
echo "vtag=${VTAG}" >> "$GITHUB_OUTPUT"
# npm Trusted Publishing (GA'd 2025-07-31). With the package registered
# as a trusted publisher on npmjs.com bound to this repo + this
# workflow file, npm authenticates via OIDC at publish time —
# NODE_AUTH_TOKEN is intentionally NOT set (an empty string would
# short-circuit the OIDC fallback; the env var must be unset, not
# blanked). Provenance is auto-attached by the registry on
# trusted-publisher publishes, so the explicit --provenance flag is
# dropped.
#
# Prerequisite: configure the package as a trusted publisher at
# https://www.npmjs.com/package/gitnexus/access (Publishing access →
# Trusted Publishers → GitHub Actions) bound to:
# Owner: <repo owner>
# Repository: GitNexus
# Workflow: publish.yml
# Environment: (none)
- name: Publish to npm
if: inputs.dry_run != 'true'
shell: bash
working-directory: gitnexus
env:
NPM_TAG: ${{ needs.route.outputs.mode == 'rc' && 'rc' || 'latest' }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: npm publish --provenance --access public --tag "$NPM_TAG"
run: npm publish --access public --tag "$NPM_TAG"
# ── Stable-only: pull CHANGELOG body if present ──────────────────────
- name: Extract release notes from CHANGELOG (stable)