Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
5 changes: 5 additions & 0 deletions .changeset/hip-memes-rhyme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@zemd/gha": patch
---

Use token-free OIDC staging by default, automatically direct-publish only first releases with an optional npm token, preserve submitted versions as immutable releases even when npm approval is rejected, and advance the private shared-workflow contract version in release pull requests.
400 changes: 318 additions & 82 deletions .github/scripts/gha.mjs

Large diffs are not rendered by default.

35 changes: 30 additions & 5 deletions .github/workflows-examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ Copy-paste callers for the reusable workflows published from this repository.
| File | Calls | Purpose |
| :----------------------------------- | :--------------------- | :------------------------------------------------------------------------- |
| [`ci.yml`](./ci.yml) | `shared-ci.yml` | Lint, format, typecheck, build, test matrix, Playwright, dependency review |
| [`release.yml`](./release.yml) | `shared-release.yml` | Release pull request, npm publish, git tags, GitHub release |
| [`release.yml`](./release.yml) | `shared-release.yml` | Release pull request, npm submission, git tags, GitHub release |
| [`codeql.yml`](./codeql.yml) | `shared-codeql.yml` | CodeQL analysis |
| [`scorecard.yml`](./scorecard.yml) | `shared-scorecard.yml` | OpenSSF Scorecard |
| [`zizmor.yml`](./zizmor.yml) | `shared-zizmor.yml` | Blocking security lint for GitHub Actions and Dependabot |
Expand Down Expand Up @@ -38,21 +38,46 @@ Dependabot rewrites both the SHA and the trailing `# v1` comment from then on.
When it updates `release.yml`, keep `shared-tooling-ref` equal to the SHA in the
`uses:` line so the release scripts and reusable workflow stay on one revision.

`contract-version-package` is empty by default. Set it to a private package's
manifest only when that package versions a release contract but is never
published to npm. The release workflow advances it from its matching change
intents before pnpm prepares the release pull request.

## Release setup

`shared-release.yml` expects [`pnpm change`](https://pnpm.io) intents on `main`.
On every push it either opens/refreshes a `release/main` pull request, or — when
no intents are pending — publishes, tags and creates a combined GitHub release.
no intents are pending — stages packages on npm, tags them and creates a combined
GitHub release. A maintainer must then review and approve each staged package
with 2FA before it becomes available from npm. If any publishable workspace
package does not exist in the registry, the workflow publishes that package
regularly so it can be created while still staging updates to existing packages.

Submission is the immutable release boundary. The workflow tags both directly
published and staged package versions immediately. Approval only controls npm
availability: rejecting a staged package does not roll back its release or let a
later run reuse that version. Record a new change intent so the next attempt uses
the next version.

[Staged publishing](https://docs.npmjs.com/staged-publishing/) is the default.
Set `staged-publishing: false` in the caller's `with:` block when packages must
always publish immediately. npm cannot stage a package that does not exist yet,
so first-release detection overrides the staged default for that package.

For npm **trusted publishing**:
For npm [**trusted publishing**](https://docs.npmjs.com/trusted-publishers/):

- Keep the caller named `release.yml`. npm validates the calling workflow's
filename, not the reusable workflow that runs the publish.
- Register the trusted publisher per package with the _consumer_ repository and
`release.yml`.
- Configure each existing package's trusted publisher to allow only
`npm stage publish` for the default behavior. Consumers that disable staged
publishing must allow `npm publish` instead (or allow both actions).
- `id-token: write` must be granted by the caller job, which the example does.
- Keep `NPM_TOKEN` until every package exists on npm; a trusted publisher cannot
be configured for a package that was never published.
- Pass `NPM_TOKEN` as the optional reusable-workflow secret until every package
exists on npm. It is exposed only to regular publishing and is required when
first-release detection adds the package-creation step. After the first release,
configure that package's stage-only trusted publisher.
- `repository.url` in each `package.json` must match the repository exactly.

## Repository settings
Expand Down
8 changes: 5 additions & 3 deletions .github/workflows-examples/release.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Keep this file named `release.yml`: npm trusted publishing validates the
# *calling* workflow filename, not the reusable workflow that runs `npm publish`.
# *calling* workflow filename, not the reusable workflow that submits packages.
name: Release

permissions: {}
Expand Down Expand Up @@ -31,10 +31,12 @@ jobs:
# base-branch: main
# release-branch: release/main
# release-title: "chore(release): version packages"
# contract-version-package: "" # Private workflow/tooling contract, if any.
# build-script: build
# publint-script: lint-publish
# staged-publishing: true # Set false when every release should publish directly.
# registry-url: "https://registry.npmjs.org"
secrets:
# Only needed until every package exists on npm: a trusted publisher
# cannot be configured for a package that has never been published.
# Optional after every package exists. First releases automatically use
# regular publishing and require this repository secret.
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
2 changes: 2 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ jobs:
with:
shared-tooling-repository: ${{ github.repository }}
shared-tooling-ref: ${{ github.sha }}
# pnpm does not advance this private, unpublished contract package.
contract-version-package: internal/gha/package.json
secrets:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

Expand Down
159 changes: 119 additions & 40 deletions .github/workflows/shared-release.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
name: Shared Release

# Shared release pipeline: opens/refreshes a release pull request while intents
# are pending, and publishes to npm once main holds the released versions.
# are pending, and submits packages to npm once main holds the released versions.
# Call it from a repository with:
# uses: zemd/js/.github/workflows/shared-release.yml@<sha> # v1
#
Expand Down Expand Up @@ -33,6 +33,10 @@ on:
description: Commit message and title of the release pull request.
type: string
default: "chore(release): version packages"
contract-version-package:
description: Private package manifest whose version is advanced manually from matching change intents.
type: string
default: ""
base-branch:
description: Branch the release pull request targets.
type: string
Expand All @@ -45,17 +49,21 @@ on:
description: package.json script that validates publishable packages. Empty skips the step.
type: string
default: "lint-publish"
staged-publishing:
description: Stage existing packages for npm approval without making rejection roll back the submitted version. First releases publish directly.
type: boolean
default: true
registry-url:
description: Registry written to .npmrc so the NODE_AUTH_TOKEN fallback works.
description: Registry used for package-existence checks and written to .npmrc for publishing.
type: string
default: "https://registry.npmjs.org"
secrets:
NPM_TOKEN:
description: Fallback token for the first publish of a package that does not exist on npm yet.
description: Optional authentication for regular publishing; required when a package does not exist in the registry yet.
required: false
outputs:
pending:
description: "'true' when the run opened or refreshed a release pull request instead of publishing."
description: "'true' when the run opened or refreshed a release pull request instead of submitting packages."
value: ${{ jobs.version.outputs.pending }}

# Not inherited from the caller: `env` defined at the caller's workflow level is
Expand Down Expand Up @@ -84,6 +92,21 @@ jobs:
fetch-depth: 0
persist-credentials: false

# Kept out of git's view so it never lands in the release commit that
# the signed-commit command builds from the working tree.
- name: Ignore the shared tooling checkout
run: echo "/.shared-ci/" >> .git/info/exclude

- name: Checkout shared tooling
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# The caller must pass the repository and commit SHA matching its
# pinned workflow reference, so the scripts use the same revision.
repository: ${{ inputs.shared-tooling-repository }}
ref: ${{ inputs.shared-tooling-ref }}
path: .shared-ci
persist-credentials: false

- name: Setup pnpm
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
with:
Expand All @@ -98,36 +121,42 @@ jobs:
- name: Install Dependencies
run: pnpm install --frozen-lockfile --prefer-offline

# pnpm preserves the version of a private package that is absent from the
# registry. Prepare that contract version explicitly before it consumes
# the intents, then repair its same-version entry in the release report.
- name: Prepare private contract version
if: inputs.contract-version-package != ''
env:
CONTRACT_VERSION_PACKAGE: ${{ inputs.contract-version-package }}
run: |
node "${SHARED_CLI}" contract-version prepare \
"$CONTRACT_VERSION_PACKAGE" \
.changeset \
"${RUNNER_TEMP}/contract-version.json"

# Consumes the change intents in .changeset/, bumps every affected package
# and its workspace dependents, and writes the changelog entries.
- name: Apply pending release intents
id: version
run: |
pnpm version -r --json > "${RUNNER_TEMP}/releases.json"
pnpm version -r --json --no-git-checks > "${RUNNER_TEMP}/releases.json"
pnpm install --lockfile-only

- name: Finalize private contract version
if: inputs.contract-version-package != ''
run: |
node "${SHARED_CLI}" contract-version finalize \
"${RUNNER_TEMP}/contract-version.json" \
"${RUNNER_TEMP}/releases.json"

- name: Detect pending release
id: version
run: |
if [ -n "$(git status --porcelain)" ]; then
echo "pending=true" >> "$GITHUB_OUTPUT"
else
echo "pending=false" >> "$GITHUB_OUTPUT"
fi

# Kept out of git's view so it never lands in the release commit that
# signed-commit.mjs builds from the working tree.
- name: Ignore the shared tooling checkout
if: steps.version.outputs.pending == 'true'
run: echo "/.shared-ci/" >> .git/info/exclude

- name: Checkout shared tooling
if: steps.version.outputs.pending == 'true'
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# The caller must pass the repository and commit SHA matching its
# pinned workflow reference, so the scripts use the same revision.
repository: ${{ inputs.shared-tooling-repository }}
ref: ${{ inputs.shared-tooling-ref }}
path: .shared-ci
persist-credentials: false

- name: Render release pull request body
if: steps.version.outputs.pending == 'true'
run: |
Expand Down Expand Up @@ -159,9 +188,9 @@ jobs:
fi

# No intents were pending, so main already holds the released versions.
# `pnpm publish -r` skips anything the registry already serves.
# Both recursive publish modes skip anything the registry already serves.
publish:
name: Publish
name: Submit packages to npm
needs: version
if: needs.version.outputs.pending == 'false'
runs-on: ubuntu-latest
Expand All @@ -174,6 +203,14 @@ jobs:
with:
persist-credentials: false

- name: Checkout shared tooling
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: ${{ inputs.shared-tooling-repository }}
ref: ${{ inputs.shared-tooling-ref }}
path: .shared-ci
persist-credentials: false

- name: Setup pnpm
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
with:
Expand Down Expand Up @@ -203,27 +240,69 @@ jobs:
SCRIPT: ${{ inputs.publint-script }}
run: pnpm run "$SCRIPT"

- name: Publish to npm
# npm cannot stage a package that does not exist in the registry. Keep
# existing packages on stage-only OIDC while routing only first releases
# through regular publishing with the optional token.
- name: Select npm publishing mode
id: publishing
env:
GITHUB_TOKEN: ${{ github.token }}
REGISTRY_URL: ${{ inputs.registry-url }}
STAGED_PUBLISHING: ${{ inputs.staged-publishing }}
run: |
pnpm list -r --depth -1 --json > "${RUNNER_TEMP}/workspace.json"
node "${SHARED_CLI}" npm-publishing-mode \
"${RUNNER_TEMP}/workspace.json" \
"$REGISTRY_URL" \
"$STAGED_PUBLISHING" \
"${RUNNER_TEMP}/first-releases.txt" \
"${RUNNER_TEMP}/direct-packages.txt" \
"${RUNNER_TEMP}/staged-packages.txt" >> "$GITHUB_OUTPUT"

- name: Publish packages to npm directly
if: steps.publishing.outputs.direct == 'true'
env:
# pnpm prefers OIDC when it succeeds and falls back to this token for
# the first publish of a package that does not exist in npm yet.
DIRECT_PACKAGES_FILE: ${{ runner.temp }}/direct-packages.txt
FIRST_RELEASE: ${{ steps.publishing.outputs.first_release }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: pnpm publish -r --access public --no-git-checks --report-summary
run: |
if [ "$FIRST_RELEASE" = "true" ] && [ -z "$NODE_AUTH_TOKEN" ]; then
echo "::error::NPM_TOKEN is required to publish a package that does not exist in the registry."
exit 1
fi

- name: Checkout shared tooling
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: ${{ inputs.shared-tooling-repository }}
ref: ${{ inputs.shared-tooling-ref }}
path: .shared-ci
persist-credentials: false
mapfile -t packages < "$DIRECT_PACKAGES_FILE"
filters=()
for package in "${packages[@]}"; do
filters+=("--filter=$package")
done

pnpm publish -r "${filters[@]}" --access public --no-git-checks --report-summary
if [ -f pnpm-publish-summary.json ]; then
mv pnpm-publish-summary.json "${RUNNER_TEMP}/published-summary.json"
fi

- name: Stage packages on npm
if: steps.publishing.outputs.stage == 'true'
env:
STAGED_PACKAGES_FILE: ${{ runner.temp }}/staged-packages.txt
run: |
mapfile -t packages < "$STAGED_PACKAGES_FILE"
filters=()
for package in "${packages[@]}"; do
filters+=("--filter=$package")
done

pnpm stage publish -r "${filters[@]}" --access public --no-git-checks --report-summary
mv pnpm-publish-summary.json "${RUNNER_TEMP}/staged-summary.json"

# pnpm only talks to the registry, so tags and the release are created here.
# Submission is the immutable release point. Tag direct and staged
# versions alike so rejection cannot cause a later run to reuse one.
- name: Tag packages and create GitHub release
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
pnpm list -r --depth -1 --json > "${RUNNER_TEMP}/versions.json"
node "${SHARED_CLI}" github-releases \
pnpm-publish-summary.json \
"${RUNNER_TEMP}/versions.json"
"${RUNNER_TEMP}/published-summary.json" \
"${RUNNER_TEMP}/staged-summary.json" \
"${RUNNER_TEMP}/workspace.json"
9 changes: 8 additions & 1 deletion internal/gha/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,17 @@ which is what CI keys on to require a release intent for this package.
Its version is the shared workflow contract version: the release workflow tags
`vX.Y.Z` and moves `vX` to match.

For package releases, each `name@version` tag is the immutable submission
record. The publishing planner skips tagged versions even when a staged version
was rejected on npm; another release must advance the package version.

## Commands

```
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
gha.mjs github-releases <publish-summary.json> <workspace-list.json>
gha.mjs contract-version prepare <package.json> <intents-dir> <state.json>
gha.mjs contract-version finalize <state.json> <releases.json>
gha.mjs github-releases <published-summary.json> <staged-summary.json> <workspace-list.json>
gha.mjs npm-publishing-mode <workspace-list.json> <registry-url> <staged-publishing> <first-releases.txt> <direct-packages.txt> <staged-packages.txt>
gha.mjs release-pr-body <releases.json> <workspace-list.json>
gha.mjs shared-workflows-release <package.json> <workflows-dir>
gha.mjs signed-commit <branch> <message>
Expand Down
Loading