diff --git a/.github/blocks/publish-npm-packages/action.yaml b/.github/blocks/publish-npm-packages/action.yaml index bb41fe4..f0390d7 100644 --- a/.github/blocks/publish-npm-packages/action.yaml +++ b/.github/blocks/publish-npm-packages/action.yaml @@ -30,8 +30,8 @@ inputs: required: false default: 'false' npm-token: - description: '[secret] NPM authentication token' - required: true + description: 'NPM authentication token. Used only as a fallback when npm OIDC Trusted Publishing is unavailable or fails. Not required if OIDC Trusted Publishing is fully configured for every package being released.' + required: false runs: using: 'composite' @@ -81,11 +81,54 @@ runs: (cd "$PKG_PATH" && $BUILD_CMD) fi + # Dry-run only validates packaging, so do it before any auth checks. + # `bun publish --dry-run` is fine here because it doesn't hit the + # registry; we keep it on bun so workspace:* deps still resolve. if [ "$DRY_RUN" = "true" ]; then echo "Dry run: would publish $NAME with tag $TAG" (cd "$PKG_PATH" && bun publish --tag "$TAG" --access public --dry-run) + echo "" + continue + fi + + # Step 1: try npm OIDC Trusted Publishing (tokenless, with + # provenance). bun publish doesn't yet support OIDC + # (oven-sh/bun#15601), so we use the documented workaround: pack + # with bun (preserves workspace:* rewriting that npm pack would + # leave broken) and publish the tarball with npm. The runner only + # sets ACTIONS_ID_TOKEN_REQUEST_TOKEN when the job has + # 'id-token: write', so when that permission is absent we skip OIDC + # entirely rather than run a publish that is guaranteed to fail on + # auth. + PUBLISHED=false + if [ -n "${ACTIONS_ID_TOKEN_REQUEST_TOKEN:-}" ]; then + echo "🔒 Attempting publish of $NAME via npm Trusted Publishing (OIDC)..." + if ( + cd "$PKG_PATH" + rm -f -- *.tgz 2>/dev/null || true + bun pm pack + TARBALL=$(ls -t -- *.tgz | head -n 1) + [ -n "$TARBALL" ] + npm publish "$TARBALL" --tag "$TAG" --access public --provenance + ); then + echo "✅ Published $NAME via OIDC Trusted Publishing." + PUBLISHED=true + else + echo "âš ī¸ OIDC publish of $NAME failed. Falling back to NPM token." + fi else - echo "Publishing $NAME with tag $TAG" + echo "â„šī¸ No OIDC id-token available (job lacks 'id-token: write'). Skipping OIDC; will use NPM token." + fi + + # Step 2: fall back to the traditional token-based publish. Same + # bun publish flow as before, so workspace:* deps continue to + # resolve. Only reached when OIDC was skipped or failed. + if [ "$PUBLISHED" != "true" ]; then + if [ -z "$NODE_AUTH_TOKEN" ]; then + echo "❌ OIDC was unavailable or failed, and no NPM token was provided. Cannot publish $NAME." + exit 1 + fi + echo "🔑 Publishing $NAME via NPM token with tag $TAG" (cd "$PKG_PATH" && bun publish --tag "$TAG" --access public) fi diff --git a/.github/workflows/typescript-monorepo-release.yaml b/.github/workflows/typescript-monorepo-release.yaml index ee57480..e72d0c8 100644 --- a/.github/workflows/typescript-monorepo-release.yaml +++ b/.github/workflows/typescript-monorepo-release.yaml @@ -61,13 +61,18 @@ on: required: false default: false description: "Opt in to Blacksmith Linux runners (blacksmith-4vcpu-ubuntu-2404). Requires the Blacksmith GitHub App installed on the caller org. Defaults to ubuntu-latest." + use-oidc: + type: boolean + required: false + default: false + description: "Opt in to npm OIDC Trusted Publishing. When true, the publish job inherits the caller's GITHUB_TOKEN so it can use 'id-token: write' (the caller must grant it) and a trusted publisher must be configured on npmjs.com for every package being released. Falls back to NPM_TOKEN if OIDC is unavailable. When false (default), publishing uses NPM_TOKEN and the job keeps least-privilege 'contents: read'." secrets: OPENAI_API_KEY: required: true description: "OpenAI API key for version and notes" NPM_TOKEN: - required: true - description: "NPM token for publishing" + required: false + description: "NPM token (required for npm publish when not using OIDC Trusted Publishing, or as a fallback when OIDC isn't configured for some packages)" APP_ID: required: false description: "GitHub App ID for pushing to protected branches and triggering downstream workflows" @@ -175,8 +180,12 @@ jobs: app-id: ${{ secrets.APP_ID }} app-private-key: ${{ secrets.APP_PRIVATE_KEY }} + # Default publish path (use-oidc: false). Keeps least-privilege + # `contents: read` exactly as before and publishes via NPM_TOKEN. Existing + # callers are unaffected — no permission changes required on their side. npm-publish: needs: [check-labels, detect-changes, bump-versions] + if: inputs.use-oidc != true runs-on: ${{ inputs.use-blacksmith && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }} permissions: contents: read @@ -196,3 +205,30 @@ jobs: root-build-command: ${{ inputs.root-build-command }} dry-run: ${{ inputs.dry-run }} npm-token: ${{ secrets.NPM_TOKEN }} + + # OIDC publish path (use-oidc: true). Opt-in only. It omits an explicit + # `permissions:` block so it inherits the caller's GITHUB_TOKEN — meaning + # `id-token: write` flows through when the caller grants it. Because it never + # *requests* id-token, callers that haven't granted it can't be hard-failed; + # the publish step just falls back to NPM_TOKEN. Default callers never reach + # this job (the `if` below is false), so their permissions are untouched. + npm-publish-oidc: + needs: [check-labels, detect-changes, bump-versions] + if: inputs.use-oidc == true + runs-on: ${{ inputs.use-blacksmith && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }} + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ github.ref_name }} + fetch-depth: 0 + + - name: Publish NPM Packages + uses: photon-hq/buildspace/.github/blocks/publish-npm-packages@main + with: + changed-packages: ${{ needs.detect-changes.outputs.changed }} + bun-version: ${{ inputs.bun-version }} + tag: ${{ (inputs.prerelease || fromJSON(needs.check-labels.outputs.labels).prerelease) && 'beta' || inputs.npm-tag }} + build-command: ${{ inputs.build-command }} + root-build-command: ${{ inputs.root-build-command }} + dry-run: ${{ inputs.dry-run }} + npm-token: ${{ secrets.NPM_TOKEN }} diff --git a/README.md b/README.md index 5f37933..aa9a454 100644 --- a/README.md +++ b/README.md @@ -341,13 +341,14 @@ Complete release pipeline for TypeScript/JavaScript monorepos with independently | `prerelease` | boolean | No | `false` | Force prerelease | | `release` | boolean | No | `false` | Force release (bypasses label check) | | `dry-run` | boolean | No | `false` | Test without actually publishing | +| `use-oidc` | boolean | No | `false` | Opt in to npm OIDC Trusted Publishing (requires caller `id-token: write` + a trusted publisher configured on npmjs.com for every package being released). Falls back to `NPM_TOKEN`. Default keeps least-privilege token publishing | #### Secrets | Secret | Required | Description | |--------|----------|-------------| | `OPENAI_API_KEY` | Yes | For AI-powered versioning and release notes | -| `NPM_TOKEN` | Yes | npm authentication token | +| `NPM_TOKEN` | No | npm auth token; used as the fallback when OIDC Trusted Publishing isn't configured for a package | | `APP_ID` | No | GitHub App ID (for pushing to protected branches) | | `APP_PRIVATE_KEY` | No | GitHub App private key (for pushing to protected branches) | @@ -360,6 +361,7 @@ jobs: permissions: contents: write pull-requests: read + # id-token: write # only needed when use-oidc: true (npm Trusted Publishing) with: service-name: photon-ts packages: | @@ -370,11 +372,14 @@ jobs: ] root-build-command: "turbo build" include-dependents: true + # use-oidc: true # opt in to npm OIDC Trusted Publishing secrets: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} ``` +> **Enabling OIDC Trusted Publishing (opt-in):** set `use-oidc: true`, add `id-token: write` to the caller's `permissions`, and configure a [trusted publisher](https://docs.npmjs.com/trusted-publishers) **for every package** in the monorepo on npmjs.com. The runner needs npm â‰Ĩ 11.5.1. Since `bun publish` doesn't speak OIDC yet ([oven-sh/bun#15601](https://github.com/oven-sh/bun/issues/15601)), the OIDC path packs each package with `bun pm pack` (preserving `workspace:*` rewriting) and publishes the tarball with `npm publish --provenance`; the token fallback continues to use `bun publish` exactly as before. Callers that don't set `use-oidc` are completely unaffected — they keep `contents: read` least-privilege and publish via `NPM_TOKEN` exactly as before. + #### How It Works ``` @@ -1076,7 +1081,7 @@ Determines versions for all changed monorepo packages using a single AI call, bu **Path:** `.github/blocks/publish-npm-packages/action.yaml` -Builds and publishes multiple monorepo packages to npm in dependency order. Supports both per-package builds and a single root build command. +Builds and publishes multiple monorepo packages to npm in dependency order. Supports both per-package builds and a single root build command. Tries npm OIDC Trusted Publishing first (per package, when the calling job has `id-token: write`), then falls back to token-based `bun publish` with `npm-token`. #### Inputs @@ -1088,8 +1093,8 @@ Builds and publishes multiple monorepo packages to npm in dependency order. Supp | `tag` | string | No | `latest` | npm dist-tag | | `build-command` | string | No | `bun run build` | Per-package build command (ignored if `root-build-command` is set) | | `root-build-command` | string | No | `""` | Build once at repo root (e.g., `turbo build`) | -| `dry-run` | boolean | No | `false` | Run `npm publish --dry-run` | -| `npm-token` | secret | Yes | — | npm authentication token | +| `dry-run` | boolean | No | `false` | Run `bun publish --dry-run` | +| `npm-token` | secret | No | — | npm token; used only as a fallback when OIDC Trusted Publishing is unavailable or fails for a package | #### Usage @@ -1101,6 +1106,11 @@ Builds and publishes multiple monorepo packages to npm in dependency order. Supp npm-token: ${{ secrets.NPM_TOKEN }} ``` +#### Publishing modes + +1. **OIDC Trusted Publishing (preferred):** when the calling job has `id-token: write`, each package is packed with `bun pm pack` (preserving `workspace:*` rewriting) and published tokenlessly with `npm publish --provenance`. Requires a configured [trusted publisher](https://docs.npmjs.com/trusted-publishers) for **every** package on npmjs.com and npm â‰Ĩ 11.5.1 on the runner. Done per-package, so partial OIDC coverage falls back per-package. +2. **Token fallback:** if OIDC is unavailable (no `id-token: write`) or fails for a package, that package falls back to `bun publish` with `npm-token` (same behavior as before). This is the default today — keep providing `NPM_TOKEN` until every package has a trusted publisher configured. + --- ### rust-build