From b0f3c0b23ad24151127cb958adafeb1ad346b016 Mon Sep 17 00:00:00 2001 From: martyy-code Date: Mon, 3 Aug 2026 13:24:50 +0200 Subject: [PATCH 1/2] ci(publish): single Trusted Publisher entrypoint + reusable workflows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit npm Trusted Publishing allows exactly ONE configuration per package (verified against npm docs 2026-08-03). The previous design assumed three entries could be registered (release.yml / hotfix.yml / canary.yml) — this is not possible. Refactor to a single entrypoint pattern: publish.yml The Trusted Publisher. Registered on npmjs.com with workflow filename = 'publish.yml', environment = 'release'. Dispatches to reusable workflows based on event type. Triggers: - push to main -> release path - push tag vX.Y.Z -> hotfix path - pull_request to staging -> canary path Declares id-token: write (the only file that does). _publish-release.yml Reusable workflow (workflow_call trigger). Performs the stable release: anti-republish guard, build, test, smoke test, changeset publish --provenance --tag latest, tag creation, GitHub Release. _publish-hotfix.yml Reusable workflow for tag-driven hotfix path. Verifies that package.json version matches the tag, anti-republish guard, build, test, smoke test, publish --tag latest, GitHub Release. _publish-canary.yml Reusable workflow for per-PR snapshot publishes. Detects pending changesets (skip cleanly when none), snapshots, publishes to dist-tag 'canary', comments on PR. Design notes: - Single environment ('release') covers all three flows. npm validates the entrypoint (publish.yml), not the reusable workflows. - Reusable workflows declare permissions but do NOT request id-token themselves — that is inherited from the entrypoint. - File naming: leading underscore ('_publish-*') signals that the file is not standalone; only publish.yml can be triggered by an event. - workflow_dispatch inputs (the old release.yml 'reason' field) are removed; hotfix is now exclusively tag-driven per the pipeline design. Removes: - .github/workflows/release.yml (replaced by _publish-release.yml) - .github/workflows/hotfix.yml (replaced by _publish-hotfix.yml) - .github/workflows/canary.yml (replaced by _publish-canary.yml) No behavioral change for end users; the publish path is unchanged in shape, only the underlying files are reorganized. --- .../{canary.yml => _publish-canary.yml} | 27 ++++---- .../{hotfix.yml => _publish-hotfix.yml} | 31 ++++----- .../{release.yml => _publish-release.yml} | 39 ++++------- .github/workflows/publish.yml | 65 +++++++++++++++++++ 4 files changed, 102 insertions(+), 60 deletions(-) rename .github/workflows/{canary.yml => _publish-canary.yml} (85%) rename .github/workflows/{hotfix.yml => _publish-hotfix.yml} (75%) rename .github/workflows/{release.yml => _publish-release.yml} (68%) create mode 100644 .github/workflows/publish.yml 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 From b0badcb478b5624502740fd533d10f9b3e0c8100 Mon Sep 17 00:00:00 2001 From: martyy-code Date: Mon, 3 Aug 2026 13:25:41 +0200 Subject: [PATCH 2/2] docs(github-ui-setup): single Trusted Publisher + single environment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aligns the runbook with the publish.yml entrypoint refactor implemented in 9beadbb / b0f3c0b. §1 Environments: - Drop the 'hotfix' and 'canary' environment sections. The single 'release' environment now covers all three publish paths (release / hotfix / canary), dispatched via reusable workflows. - Optionally allow v* tags to be deployable to 'release'. §5 Trusted Publisher: - Replace the multi-entry (release.yml + hotfix.yml + canary.yml) guidance with a single entry pointing to publish.yml. - Cite npm docs 2026-08-03 ('one trusted publisher per package') and the Paige Niedringhaus reusable-workflow article. - Revocation note: if a previous entry exists, revoke it first via npm trust CLI or the npmjs.com UI. - Verification: confirm exactly one entry on npmjs.com. §6 Workflow permissions: - Update reference from 'release.yml' to 'publish.yml' for the id-token: write override. §7.2 Code Owners: - Update file references from release.yml to publish.yml and the reusable workflows. §8 Verification Checklist: - Replace 'feat/release-pipeline' with 'feat/publish-entrypoint'. - Replace 'release.yml' with 'publish.yml' as the file that must exist in main. §9 Rollback: - Add npm trust CLI command for revocation. No code changes. --- .../plans/release-pipeline-github-ui-setup.md | 93 ++++++------------- 1 file changed, 26 insertions(+), 67 deletions(-) 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.