diff --git a/.github/workflows/canary.yml b/.github/workflows/_publish-canary.yml similarity index 85% rename from .github/workflows/canary.yml rename to .github/workflows/_publish-canary.yml index c6e57d0c..00b1972a 100644 --- a/.github/workflows/canary.yml +++ b/.github/workflows/_publish-canary.yml @@ -1,30 +1,29 @@ -name: Canary +name: _publish-canary -# Per-PR snapshot publish for early feedback. Publishes to the -# npm dist-tag 'canary' without touching the official version or -# the branch's history. Comment on the PR with the install -# command when a snapshot is published. +# Reusable workflow that performs the actual npm publish for the +# canary snapshot path (per-PR). Called from publish.yml (the +# Trusted Publisher entrypoint registered on npmjs.com). +# +# Note: this file is NOT the Trusted Publisher. npm validates the +# entrypoint (publish.yml), not the reusable workflow. Permissions +# for OIDC are declared at the entrypoint level. on: - pull_request: - types: [opened, synchronize, reopened] - branches: [staging] + workflow_call: -permissions: - contents: read - pull-requests: write +permissions: {} concurrency: - group: canary-${{ github.workflow }}-${{ github.event.pull_request.number }} + group: canary-${{ github.event.pull_request.number }} cancel-in-progress: true jobs: canary: runs-on: ubuntu-latest + environment: release permissions: contents: read pull-requests: write - id-token: write steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 @@ -99,4 +98,4 @@ jobs: '', '_This is a snapshot build. Do not depend on it in production._', ].join('\n'), - }); + }); \ No newline at end of file diff --git a/.github/workflows/hotfix.yml b/.github/workflows/_publish-hotfix.yml similarity index 75% rename from .github/workflows/hotfix.yml rename to .github/workflows/_publish-hotfix.yml index 7bd0dc1d..0bda9380 100644 --- a/.github/workflows/hotfix.yml +++ b/.github/workflows/_publish-hotfix.yml @@ -1,34 +1,27 @@ -name: Hotfix +name: _publish-hotfix -# Tag-driven publish path for urgent fixes. The hotfix branch is -# merged into main through a regular PR (the only case where a PR -# targets main directly, justified by urgency). Once the PR is -# merged, a maintainer pushes a vX.Y.Z tag to main, which triggers -# this workflow. +# Reusable workflow that performs the actual npm publish for the +# hotfix path (tag-driven). Called from publish.yml (the Trusted +# Publisher entrypoint registered on npmjs.com). # -# This workflow is intentionally minimal: no Changesets run -# (the fix's changeset was already merged on staging via a back-merge -# or follow-up PR). It uses the 'hotfix' environment, distinct -# from 'release', with a smaller reviewer pool. +# Note: this file is NOT the Trusted Publisher. npm validates the +# entrypoint (publish.yml), not the reusable workflow. Permissions +# for OIDC are declared at the entrypoint level. on: - push: - tags: - - 'v[0-9]+.[0-9]+.[0-9]+' - branches: [main] + workflow_call: permissions: {} concurrency: - group: hotfix-${{ github.ref }} + group: hotfix-${{ inputs.ref || github.ref }} cancel-in-progress: false jobs: hotfix: runs-on: ubuntu-latest - environment: hotfix + environment: release permissions: - id-token: write contents: read steps: @@ -51,7 +44,7 @@ jobs: # The hotfix branch is responsible for having bumped the # version in packages/fp/package.json before merging. If the # version was not bumped, this guard catches it. - - name: Verify version was bumped + - name: Verify version matches tag run: | PKG=$(node -p "require('./packages/fp/package.json').name") VER=$(node -p "require('./packages/fp/package.json').version") @@ -93,4 +86,4 @@ jobs: uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 with: tag_name: ${{ github.ref_name }} - generate_release_notes: true + generate_release_notes: true \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/_publish-release.yml similarity index 68% rename from .github/workflows/release.yml rename to .github/workflows/_publish-release.yml index c9aaddc9..0681c608 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/_publish-release.yml @@ -1,41 +1,28 @@ -name: Release +name: _publish-release -# Triggers on push to main (the canonical path: a "Version Packages" -# PR is merged into main, which produces a new package version and -# pushes here). workflow_dispatch is reserved for hotfix recovery and -# requires an explicit reason for audit trail. +# Reusable workflow that performs the actual npm publish for the +# stable release path. Called from publish.yml (the Trusted +# Publisher entrypoint registered on npmjs.com). # -# Triggering on pull_request.closed is intentionally avoided — the -# version bump is its own PR, and its merge to main is the natural -# push event. +# Note: this file is NOT the Trusted Publisher. npm validates the +# entrypoint (publish.yml), not the reusable workflow. Permissions +# for OIDC are declared at the entrypoint level. on: - push: - branches: [main] - workflow_dispatch: - inputs: - reason: - description: 'Reason for manual publish (hotfix recovery only)' - required: true + workflow_call: permissions: {} concurrency: - group: release-${{ github.ref }} + group: release-${{ inputs.ref || github.ref }} cancel-in-progress: false jobs: release: - if: > - github.event_name == 'push' || - (github.event_name == 'workflow_dispatch' - && contains(github.event.inputs.reason, 'hotfix')) runs-on: ubuntu-latest environment: release permissions: - id-token: write contents: read - pull-requests: read steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 @@ -57,11 +44,9 @@ jobs: - run: pnpm install --frozen-lockfile - name: Anti-republish guard - env: - PACKAGE_JSON: packages/fp/package.json run: | - PKG=$(node -p "require('./${PACKAGE_JSON}').name") - VER=$(node -p "require('./${PACKAGE_JSON}').version") + PKG=$(node -p "require('./packages/fp/package.json').name") + VER=$(node -p "require('./packages/fp/package.json').version") if npm view "${PKG}@${VER}" version >/dev/null 2>&1; then echo "::error::${PKG}@${VER} is already published" exit 1 @@ -100,4 +85,4 @@ jobs: uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 with: tag_name: v$(node -p "require('./packages/fp/package.json').version") - generate_release_notes: true + generate_release_notes: true \ No newline at end of file diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 00000000..477fe238 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,65 @@ +name: Publish + +# Single Trusted Publisher entrypoint. Registered on npmjs.com +# under "Trusted Publisher" with workflow filename = "publish.yml" +# and environment = "release". +# +# npm Trusted Publishing allows only one configuration per +# package. To support three publish paths (release / hotfix / +# canary) under a single trusted publisher, this workflow +# dispatches to reusable workflows. npm validates THIS file, not +# the reusable ones. +# +# Triggers covered here: +# - push to main -> release path +# - push tag vX.Y.Z on main -> hotfix path +# - pull_request to staging -> canary path +# +# Each downstream reusable workflow inherits OIDC from this +# entrypoint. Reusable workflows declare permissions explicitly +# but do not request id-token themselves. + +on: + push: + branches: [main] + tags: + - 'v[0-9]+.[0-9]+.[0-9]+' + pull_request: + types: [opened, synchronize, reopened] + branches: [staging] + +permissions: {} + +concurrency: + group: publish-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + # Stable release: triggered by push to main (the canonical + # "Version Packages" PR merge path). + release: + if: github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/') + uses: ./.github/workflows/_publish-release.yml + permissions: + id-token: write + contents: read + + # Hotfix: triggered by a tag push on main. The tag is created by + # a maintainer as part of the hotfix procedure (see release + # pipeline docs). + hotfix: + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + uses: ./.github/workflows/_publish-hotfix.yml + permissions: + id-token: write + contents: read + + # Canary: triggered by a PR targeting staging. Skips cleanly when + # the PR has no changeset. + canary: + if: github.event_name == 'pull_request' + uses: ./.github/workflows/_publish-canary.yml + permissions: + id-token: write + contents: read + pull-requests: write \ No newline at end of file diff --git a/docs/engineering/plans/release-pipeline-github-ui-setup.md b/docs/engineering/plans/release-pipeline-github-ui-setup.md index 005849f2..2ed73350 100644 --- a/docs/engineering/plans/release-pipeline-github-ui-setup.md +++ b/docs/engineering/plans/release-pipeline-github-ui-setup.md @@ -15,9 +15,9 @@ This document lists every action that must be performed in the GitHub web UI and --- -## 1. Create the `release` and `hotfix` GitHub Environments +## 1. Create the `release` GitHub Environment -**Why:** Environment protection rules add a human gate on any workflow that uses `environment: release`, and they let us pin which branches can deploy. +**Why:** Environment protection rules add a human gate on any workflow that uses `environment: release`. The single environment covers the stable release path, the hotfix path, and the canary snapshot path — they all dispatch through `publish.yml` which uses `environment: release`. **Path:** Repository → Settings → Environments → New environment @@ -26,34 +26,13 @@ This document lists every action that must be performed in the GitHub web UI and - **Name:** `release` - **Deployment branches and tags:** - Selected branches: `main` - - This means the job can only run when the workflow is triggered by `push` to `main` (our intended path). `pull_request.closed` and `workflow_dispatch` from any other ref are rejected. + - Selected tags: `v*` (optional — only if you want hotfix tags to be deployable; the entrypoint's tag push trigger will run regardless) - **Required reviewers:** add 1–2 named engineering maintainers. **Do not** add a team — keep it named individuals so accountability is explicit. - **Wait timer:** 0 minutes - **Allow administrators to bypass configured protection rules:** **OFF** (force even admins through review) Save. -### 1.2 Environment `hotfix` - -- **Name:** `hotfix` -- **Deployment branches and tags:** - - Selected branches: `main` -- **Required reviewers:** 1 named on-call engineer. Document in `CONTRIBUTING.md` who is on-call this week. -- **Wait timer:** 0 minutes -- **Allow administrators to bypass:** OFF - -Save. - -### 1.3 Optional environment `canary` - -If you want a dedicated environment for the `canary.yml` workflow: - -- **Name:** `canary` -- **Deployment branches:** `staging` -- No required reviewers — canary publishes are non-blocking and informational. - -Skip this environment if you're comfortable with no protection on canary. - --- ## 2. Branch Protection Rules @@ -153,60 +132,41 @@ Why this design: since `main` has no bypass, no human can push a `v*.*.*` tag di > **Note:** the path is `https://www.npmjs.com/package//access`, not the global settings page. This trips people up. -npm Trusted Publishing allows **one trusted publisher per package**. We register two entries that share the same provider/repository but differ on the workflow filename and environment, because npm matches on `(repo, workflow filename, environment)` triple. - -### 5.1 Trusted Publisher for stable releases (`release.yml`) - -- **Provider:** GitHub Actions -- **Organization or user:** `deessejs` -- **Repository:** `fp` -- **Workflow filename:** `release.yml` -- **Environment name:** `release` -- **Allowed actions:** `npm publish` (per §13.1 decision) +**npm allows exactly ONE Trusted Publisher configuration per package** (verified against the npm docs as of 2026-08-03). To support three publish paths — stable release, hotfix, and canary snapshot — under a single trusted publisher, the repository uses an **entrypoint pattern**: -Save. +- `.github/workflows/publish.yml` is the **single Trusted Publisher entrypoint**. It is the only file registered on npmjs.com. +- It dispatches to three **reusable workflows** (`.github/workflows/_publish-release.yml`, `_publish-hotfix.yml`, `_publish-canary.yml`), which perform the actual work. +- npm validates the entrypoint (`publish.yml`), not the reusable workflows. -### 5.2 Trusted Publisher for hotfixes (`hotfix.yml`) +This design is documented and recommended for multi-workflow scenarios: see Paige Niedringhaus, "Run Multiple npm Publishing Scripts with Trusted Publishing (OIDC) via GitHub Reusable Workflows". -Click "Add Trusted Publisher" again on the same package: +### 5.1 Add the single Trusted Publisher entry - **Provider:** GitHub Actions - **Organization or user:** `deessejs` - **Repository:** `fp` -- **Workflow filename:** `hotfix.yml` -- **Environment name:** `hotfix` +- **Workflow filename:** `publish.yml` +- **Environment name:** `release` - **Allowed actions:** `npm publish` (per §13.1 decision) Save. -### 5.3 Trusted Publisher for canary snapshots (`canary.yml`) - -If `canary.yml` is in use, register a third entry: - -- **Provider:** GitHub Actions -- **Organization or user:** `deessejs` -- **Repository:** `fp` -- **Workflow filename:** `canary.yml` -- **Environment name:** *(leave empty if no GitHub Environment is configured for canary)* -- **Allowed actions:** `npm publish` - -Save. - -If canary snapshots are not desired for now, skip this step. The `canary.yml` workflow will fail at the publish step until a Trusted Publisher entry exists for it, which is intentional. +If a previous Trusted Publisher entry exists from an earlier iteration (with a different workflow filename), revoke it first: `npm trust revoke --id ` or via the npmjs.com UI, then add the new one. -### 5.4 Update package publishing access +### 5.2 Update package publishing access Same page (`/access`) → "Publishing access": - Select **"Require two-factor authentication and disallow tokens"** - Save -### 5.5 Verify (do not skip) +### 5.3 Verify (do not skip) -At this point the Trusted Publishers are registered but no publish has happened yet. Confirm: +At this point the Trusted Publisher is registered but no publish has happened yet. Confirm: -- The package's npm page shows each Trusted Publisher entry (e.g. "Trusted Publisher: GitHub Actions — deessejs/fp — release.yml" and "Trusted Publisher: GitHub Actions — deessejs/fp — hotfix.yml") in the access tab. -- A `git push origin feat/release-pipeline` (or the equivalent merge to `main`) will be needed before the first publish — the workflow files must exist in `main` for npm to validate. +- The package's npm page shows exactly one Trusted Publisher entry: "Trusted Publisher: GitHub Actions — deessejs/fp — publish.yml" in the access tab. +- A `git push` to the branch containing `publish.yml` must be merged to `main` before the first publish — npm validates that the workflow file exists at the registered path. +- The reusable workflows (`_publish-*.yml`) do **not** need to be registered separately; they are dispatched from the entrypoint and inherit the OIDC trust. --- @@ -215,7 +175,7 @@ At this point the Trusted Publishers are registered but no publish has happened **Path:** Repository → Settings → Actions → General → Workflow permissions - **Workflow permissions:** "Read repository contents and packages permissions". - - Our `release.yml` overrides with `id-token: write` and `contents: read` at the job level, so the org-wide default can stay at read-only. + - The `publish.yml` entrypoint overrides with `id-token: write` at the job level, so the org-wide default can stay at read-only. - **Allow GitHub Actions to create and approve pull requests:** **ON** — required for the Changesets "Version Packages" PR automation. Save. @@ -228,7 +188,7 @@ Save. The repo already has a dependabot config. Confirm `/.github/dependabot.yml` includes a `github-actions` ecosystem entry. If not, see `release-pipeline.md` Appendix A for the expected shape. -### 7.2 Code Owners for `.github/workflows/release.yml` +### 7.2 Code Owners for `.github/workflows/publish.yml` and reusable workflows The current `CODEOWNERS` file already covers `.github/`. Verify by reading `.github/CODEOWNERS`: @@ -236,7 +196,7 @@ The current `CODEOWNERS` file already covers `.github/`. Verify by reading `.git /.github/ @deessejs/engineering ``` -This means any PR touching `.github/workflows/release.yml` will require review from `@deessejs/engineering`. Keep it. +This means any PR touching `.github/workflows/publish.yml` or the reusable workflows will require review from `@deessejs/engineering`. Keep it. ### 7.3 Notification channels @@ -249,17 +209,16 @@ After the first publish, set up notifications for failed workflow runs in the `r Before the first real publish, walk through this list: - [ ] `release` environment exists, requires at least 1 named reviewer, restricted to `main`. -- [ ] `hotfix` environment exists, restricted to `main`, requires at least 1 named reviewer. - [ ] `main` branch protection: PR required, 1 approval, linear history, signed commits, include administrators, **no bypass list**. - [ ] `staging` branch protection: PR required, 1 approval, linear history, no bypass. - [ ] Tag protection on `v*`: block force-push, no allow-list (workflow is the only creator). -- [ ] npmjs.com Trusted Publisher registered for `@deessejs/fp`, allowed action `npm publish`, environment `release`. +- [ ] npmjs.com Trusted Publisher registered for `@deessejs/fp`, workflow filename `publish.yml`, environment `release`, allowed action `npm publish`. - [ ] npmjs.com publishing access: "Require 2FA and disallow tokens". - [ ] Workflow permissions: "Allow GitHub Actions to create and approve pull requests" ON. -- [ ] `feat/release-pipeline` branch exists and contains the rewritten `release.yml` (next implementation step). -- [ ] `.github/workflows/release.yml` file exists in `main` with the exact filename registered on npmjs.com. +- [ ] `feat/publish-entrypoint` branch (or its successor) is merged into `main`. +- [ ] `.github/workflows/publish.yml` exists in `main` with the exact filename registered on npmjs.com. -When all boxes are checked, the next implementation step (rewriting `.github/workflows/release.yml`) can be merged and a first dry-run publish attempted via `workflow_dispatch` with reason `hotfix` (to test the OIDC chain without burning a version number). +When all boxes are checked, the first dry-run publish can be attempted via `workflow_dispatch` on `publish.yml` (or by pushing a tag on a hotfix branch) to test the OIDC chain without burning a version number. --- @@ -267,7 +226,7 @@ When all boxes are checked, the next implementation step (rewriting `.github/wor If anything goes wrong: -- **Trusted Publisher registration**: edit or delete on `https://www.npmjs.com/package/@deessejs/fp/access`. Takes effect immediately. +- **Trusted Publisher registration**: edit or delete on `https://www.npmjs.com/package/@deessejs/fp/access`. Takes effect immediately. Use `npm trust revoke --id ` if needed. - **Branch protection**: edit or delete the rule. Takes effect immediately. - **Environment**: edit or delete the environment. Takes effect immediately. - **Tag protection**: edit or delete the rule.