Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
198 changes: 198 additions & 0 deletions .github/workflows/npm-changesets.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
# The Changesets-shaped npm flow, for repositories that publish MULTIPLE
# interdependent packages from one workspace.
#
# The tag-derived flow (npm-main.yml / npm-release.yml) owns the single-package
# case and cannot be stretched to this one: it reads the bump from all commits
# since the last release, with no way to attribute a commit to a package, so
# with two packages every change stream bumps both. Changesets scopes each
# change to the packages it names and cascades bumps through dependents —
# that is the one thing it does that cannot be recreated with tags, and the
# reason both flows exist. One published package: use the tag flow. Two or
# more interdependent ones: use this.
#
# Versioning is Changesets' own: merges accumulate changeset files, the flow
# maintains a version pull request, and merging that pull request is the
# release act — this same flow then publishes the new versions to npm. The
# publish authenticates with GitHub OIDC trusted publishing; there is no
# token anywhere, and a guard fails the run if one appears. Provenance is
# requested only when the source repository is public, because the registry
# refuses it from private repositories (E422) rather than degrading.
#
# What a caller provides:
# - permissions: contents: write, pull-requests: write (the version pull
# request), id-token: write (trusted publishing)
# - the trusted publisher on npmjs.com for EVERY published package pointing
# at the CALLER's workflow file; OIDC identifies the top-level workflow,
# and npm allows one workflow per package, so all publishes must run from
# that one file
# - version-command / publish-command when its scripts differ from the
# defaults; both run from the repository root
#
# Serialization is enforced here — the release job carries a per-repository,
# non-cancelling concurrency group — so a caller needs no concurrency of its
# own, though adding one is harmless.
#
# Known, accepted exposure: changesets/action hands its environment —
# GITHUB_TOKEN included — to the version and publish commands, which run
# consumer code. That is inherent to the Changesets model (the changelog
# writers need the token to read pull-request metadata) and identical to the
# bespoke changesets workflows this replaces; the quality gates, the
# lockfile, and npm 12's install-time script denial are the compensating
# controls. The tag-derived flow does not carry the token into consumer
# commands, which is one more reason single-package repositories belong
# there.

name: Burnt npm Changesets
Comment thread
2xburnt marked this conversation as resolved.

on:
workflow_call:
inputs:
quality-policy-path:
description: Repository-relative quality policy JSONC path
required: false
default: .github/quality-policy.jsonc
type: string
Comment on lines +68 to +72
version-command:
description: Command Changesets runs to apply pending changesets
required: false
default: npm run version:packages
type: string
publish-command:
description: Command Changesets runs to publish the packages
required: false
default: npm run publish:packages
type: string
pr-title:
description: Title of the version pull request
required: false
default: "chore(release): version packages 🦋"
type: string
pr-commit:
description: Commit message of the version pull request
required: false
default: "chore: update versions"
type: string

permissions:
contents: read

jobs:
quality:
uses: burnt-labs/github-workflows/.github/workflows/required-quality.yml@d169eff38ec93b6405f15a2b2dd86b4bcda21bcb # v1.4.0
with:
quality-policy-path: ${{ inputs.quality-policy-path }}

release:
name: Version or publish
needs: quality
runs-on: ubicloud-standard-4
concurrency:
# Enforced here rather than trusted to the caller: two concurrent runs
# both force-update Changesets' changeset-release branch, and NOT
# cancel-in-progress because a run cancelled between publishing and
# tagging leaves versions on the registry with nothing behind them.
group: npm-changesets-${{ github.repository }}
cancel-in-progress: false
Comment thread
2xburnt marked this conversation as resolved.
permissions:
# The version pull request, and the tags and releases Changesets
# creates after publishing.
contents: write
pull-requests: write
# npm trusted publishing (OIDC).
id-token: write
Comment thread
2xburnt marked this conversation as resolved.
defaults:
run:
working-directory: ${{ fromJSON(needs.quality.outputs.quality-policy).workingDirectory }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# Changesets reads history and tags to decide what is unpublished.
fetch-depth: 0
# Do not leave the job's token in `.git/config`. The install and
# build run package code before anything publishes, so a persisted
# credential is reachable by the dependency tree. `commitMode:
# github-api` below authenticates through GITHUB_TOKEN instead, and
# API commits are signed by GitHub, which branch protection tends
# to want.
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: lts/*
registry-url: https://registry.npmjs.org
- run: corepack enable
- name: Pin the npm CLI
# See "npm install-time security" in AGENTS.md. npm 12 is the floor
# for both halves of this flow: it enforces the install-time
# defaults, and it is well past the 11.5.1 that trusted publishing
# needs — Changesets shells out to `npm publish`, whichever package
# manager installed the workspace.
run: |
npm install --global npm@12
Comment thread
2xburnt marked this conversation as resolved.
Outdated
if [ "$(npm --version | cut -d. -f1)" != "12" ]; then
echo "::error::Expected npm 12 on PATH, found $(npm --version). Something is shadowing the pinned CLI."
exit 1
fi
# Before Install, matching npm-publish.yml: a credential this guard
# would reject must fail the job before any consumer lifecycle or build
# code has had it in scope. This checks what npm will actually
# authenticate with, NOT whether a token exists somewhere in secrets —
# reading org-level secrets into the environment to assert they are
# empty reports the organization's configuration, not this job's, and
# fails repositories that never handed npm anything.
- name: Verify trusted-publishing credentials
run: |
if [ -z "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ]; then
echo "::error::No OIDC token available. The calling workflow must grant id-token: write."
exit 1
fi
if [ -n "${NODE_AUTH_TOKEN:-}" ] || [ -n "${NPM_TOKEN:-}" ]; then
echo "::error::An npm token is present. This flow publishes with trusted publishing and must not carry one."
exit 1
fi
# Both the generated user config and the repository's own .npmrc,
# and every credential key npm accepts — `_auth` and `_password`
# authenticate a registry just as `_authToken` does. setup-node
# writes an `_authToken` line holding the LITERAL text of the
# interpolation placeholder — the action does not expand it, npm
# does, at read time (actions/setup-node, src/authutil.ts) — so the
# placeholder is expected content and must not be read as a
# credential. npm parses ini-style and trims whitespace around `=`,
# so the patterns allow it too: `_authToken = <token>`
# authenticates just as well.
# Both the repository root — where the action runs the version and
# publish commands — and this step's own working directory, which a
# policy may point elsewhere. Scanning a file twice is harmless.
for npmrc in "${NPM_CONFIG_USERCONFIG:-$HOME/.npmrc}" "${GITHUB_WORKSPACE:-.}/.npmrc" .npmrc; do
[ -f "$npmrc" ] || continue
if grep -E '(_authToken|_auth|_password)[[:space:]]*=' "$npmrc" |
Comment thread
2xburnt marked this conversation as resolved.
Outdated
grep -qvE '_authToken[[:space:]]*=[[:space:]]*(\$\{NODE_AUTH_TOKEN\})?[[:space:]]*$'; then
Comment thread
2xburnt marked this conversation as resolved.
Outdated
Comment thread
2xburnt marked this conversation as resolved.
Outdated
echo "::error::$npmrc carries a registry credential. This flow publishes with trusted publishing and must not carry one."
exit 1
fi
done
- name: Install
run: ${{ fromJSON(needs.quality.outputs.quality-policy).commands.install }}
# Build before publishing rather than leaning on per-package
# `prepublishOnly`, so a broken build fails the job with its own error
# instead of surfacing as a publish failure halfway through a
# multi-package publish.
- name: Build
run: ${{ fromJSON(needs.quality.outputs.quality-policy).commands.build }}
- name: Create release pull request or publish to npm
uses: changesets/action@a45c4d594aa4e2c509dc14a9f2b3b67ba3780d0d # v1.9.0
Comment thread
2xburnt marked this conversation as resolved.
with:
title: ${{ inputs.pr-title }}
commit: ${{ inputs.pr-commit }}
version: ${{ inputs.version-command }}
publish: ${{ inputs.publish-command }}
Comment thread
2xburnt marked this conversation as resolved.
# Commit and tag over the API rather than the git CLI, which has no
# credentials now that checkout does not persist them.
commitMode: github-api
Comment thread
2xburnt marked this conversation as resolved.
Comment thread
2xburnt marked this conversation as resolved.
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Comment thread
2xburnt marked this conversation as resolved.
# The registry refuses provenance from private source repositories
# (E422) rather than publishing without the attestation, so the
# flag follows visibility: a private repository publishes through
# the same OIDC path without attesting, and starts attesting the
# moment it goes public, with no workflow change.
NPM_CONFIG_PROVENANCE: ${{ github.event.repository.private == false }}
32 changes: 29 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,13 @@ policy files and thin trigger workflows; everything else lives here.
These are not preferences. Changes that break them will be rejected.

- Keep every commit signed.
- Workflows must never create commits or push branches. Version numbers are
derived from release tags, never written back to the repository.
- Workflows must never run `git commit` or `git push`, and the tag-derived
flows never write versions back to a repository. The one authorized
exception to write-back: `npm-changesets.yml` maintains its version pull
request and release tags through the GitHub API (`commitMode: github-api`)
— that write-back is Changesets' entire contract and the reason the flow
exists, and API commits are signed by GitHub, which branch protection
wants. Nothing may extend this exception to the git CLI.
- Reusable deployment jobs must use the caller repository's actual target
environment. Do not introduce `preview` or `preview-*` environments.
- Candidate and release are semantic roles, mapped by repository policy.
Expand All @@ -32,7 +37,7 @@ These are not preferences. Changes that break them will be rejected.

## The flows

Ten workflows. Eight are entry points; two are internal.
Eleven workflows. Nine are entry points; two are internal.

| Workflow | Called by | Purpose |
| ------------------------ | --------------------------- | ---------------------------------------------- |
Expand All @@ -43,6 +48,7 @@ Ten workflows. Eight are entry points; two are internal.
| `npm-pr.yml` | consumer, on `pull_request` | Quality, package dry run |
| `npm-main.yml` | consumer, on push to main | Publish the candidate dist-tag, release drafts |
| `npm-release.yml` | consumer, on `release` | Publish the release dist-tag |
| `npm-changesets.yml` | consumer, on push to main | Changesets version PR, multi-package publish |
| `phala-deploy.yml` | consumer | Build and deploy a Phala CVM target |
| `cloudflare-version.yml` | internal | One `wrangler versions upload` or `deploy` |
| `npm-publish.yml` | internal | One `npm publish` via OIDC trusted publishing |
Expand Down Expand Up @@ -198,6 +204,26 @@ same build to the same Worker twice. Note that this puts pull-request previews
on that GitHub Environment, inheriting its secrets and protection rules; that
is the cost of modelling one environment honestly.

### Two npm flow shapes

The npm flows come in two shapes, chosen by how many packages a repository
publishes:

- **One package** — `npm-main.yml` / `npm-release.yml`. Versions derive from
release tags and Conventional Commits; nothing is committed back. The caller
carries both triggers in one file with event routing, because npm allows one
trusted-publisher workflow per package and both the candidate and the
promoted publish must run from it.
- **Multiple interdependent packages** — `npm-changesets.yml`. The tag flow
cannot attribute a commit to a package, so with two packages every change
stream would bump both; Changesets scopes each change to the packages it
names and cascades bumps through dependents. The caller is a single
push-to-main trigger with no routing: merging the version pull request is
the release act, and the same run publishes.

Both shapes share the publishing posture below — OIDC trusted publishing, no
tokens, provenance only from public repositories.

### npm-policy.jsonc

```jsonc
Expand Down
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,13 @@ Workflows in this repository never create commits or push branches.

## npm

The npm workflow family performs a package dry run on pull requests, publishes
Repositories publishing multiple interdependent packages use
`npm-changesets.yml`, which wraps Changesets — version pull request on merge,
publish when it lands — in the same trusted-publishing posture as the rest of
Comment thread
2xburnt marked this conversation as resolved.
the family. The tag-derived flows below are for repositories publishing one
package.

The tag-derived pair performs a package dry run on pull requests, publishes
`v<version>-rc.<run>` with the `next` dist-tag from main, and publishes the
stable version with `latest` after manual or automatic promotion. Publishing
Comment thread
2xburnt marked this conversation as resolved.
uses npm trusted publishing through GitHub OIDC, with provenance when the
Expand Down
Loading