Skip to content

Release

Release #57

Workflow file for this run

name: Release
on:
workflow_dispatch:
inputs:
mode:
description: >-
auto — default release-please behavior (publish when a release PR was merged).
release-pr — open or refresh the release PR only; merge it yourself to publish.
force — open a Release-As marker PR for dependency-only changes.
required: true
default: auto
type: choice
options:
- auto
- release-pr
- force
bump:
description: Version bump when mode is force
required: false
default: patch
type: choice
options:
- patch
- minor
- major
push:
branches:
- main
concurrency: ${{ github.workflow }}-${{ github.ref }}
jobs:
release:
name: Release
runs-on: ubuntu-latest
permissions:
contents: write # to create release tags/commits
pull-requests: write # to create the "Version Packages" PR
id-token: write # required for NPM provenance
steps:
- name: Checkout Repo
uses: actions/checkout@v7
with:
fetch-depth: 0
- run: corepack enable
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: "24"
- name: Resolve release mode
id: mode
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "value=${{ inputs.mode }}" >> "$GITHUB_OUTPUT"
else
echo "value=auto" >> "$GITHUB_OUTPUT"
fi
- name: Resolve forced release version
if: ${{ steps.mode.outputs.value == 'force' }}
id: force
env:
BUMP: ${{ inputs.bump || 'patch' }}
run: |
NEXT=$(node --input-type=module <<'NODE'
import { readFileSync } from "node:fs";
const bump = process.env.BUMP;
const manifest = JSON.parse(readFileSync(".release-please-manifest.json", "utf8"));
const versions = Object.values(manifest).map((version) => {
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version);
if (!match) throw new Error(`Unsupported manifest version: ${version}`);
return match.slice(1).map(Number);
});
const [major, minor, patch] = versions.reduce((max, version) => {
for (let i = 0; i < 3; i += 1) {
if (version[i] > max[i]) return version;
if (version[i] < max[i]) return max;
}
return max;
});
if (bump === "major") console.log(`${major + 1}.0.0`);
else if (bump === "minor") console.log(`${major}.${minor + 1}.0`);
else if (bump === "patch") console.log(`${major}.${minor}.${patch + 1}`);
else throw new Error(`Unsupported bump: ${bump}`);
NODE
)
echo "version=$NEXT" >> "$GITHUB_OUTPUT"
echo "Resolved forced release version $NEXT"
- name: Open forced release marker PR
if: ${{ steps.mode.outputs.value == 'force' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NEXT: ${{ steps.force.outputs.version }}
run: |
set -euo pipefail
BRANCH="release-force/${NEXT}"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
if git ls-remote --exit-code --heads origin "$BRANCH" >/dev/null 2>&1; then
git fetch origin "$BRANCH"
git checkout -B "$BRANCH" "origin/$BRANCH"
else
git checkout -B "$BRANCH"
git commit --allow-empty -m "chore: force release ${NEXT}" -m "Release-As: ${NEXT}"
git push --set-upstream origin "$BRANCH"
fi
BODY=$(cat <<EOF
Opens a protected-branch compliant Release-As marker.
After this PR is merged, the normal push workflow will open the release-please PR for ${NEXT}. Merge that release PR to publish to npm.
EOF
)
PR=$(gh pr list --head "$BRANCH" --base main --json number --jq '.[0].number')
if [ -z "$PR" ] || [ "$PR" = "null" ]; then
gh pr create --base main --head "$BRANCH" --title "chore: force release ${NEXT}" --body "$BODY"
else
gh pr edit "$PR" --title "chore: force release ${NEXT}" --body "$BODY"
echo "Updated existing force release PR #$PR"
fi
- name: Open or refresh release PR
if: ${{ steps.mode.outputs.value == 'release-pr' }}
uses: googleapis/release-please-action@v5
id: release_pr
with:
token: ${{ secrets.GITHUB_TOKEN }}
config-file: release-please-config.json
manifest-file: .release-please-manifest.json
skip-github-release: true
- name: Release Please
if: ${{ steps.mode.outputs.value == 'auto' }}
uses: googleapis/release-please-action@v5
id: release
with:
token: ${{ secrets.GITHUB_TOKEN }}
config-file: release-please-config.json
manifest-file: .release-please-manifest.json
- name: Install Dependencies
if: ${{ steps.release.outputs.releases_created == 'true' }}
run: pnpm install --frozen-lockfile
- name: Verify Builds and Tests
if: ${{ steps.release.outputs.releases_created == 'true' }}
run: pnpm check
- name: Build Workspace
if: ${{ steps.release.outputs.releases_created == 'true' }}
run: pnpm build
- name: Publish to NPM
if: ${{ steps.release.outputs.releases_created == 'true' }}
env:
NPM_CONFIG_PROVENANCE: true
PATHS_RELEASED: ${{ steps.release.outputs.paths_released }}
run: |
set -euo pipefail
node --input-type=module <<'NODE' > /tmp/released-packages
const paths = JSON.parse(process.env.PATHS_RELEASED || "[]");
for (const path of paths) console.log(path);
NODE
if [ ! -s /tmp/released-packages ]; then
echo "No released package paths reported by release-please" >&2
exit 1
fi
while IFS= read -r path; do
echo "Publishing ${path}"
pnpm --filter "./${path}" publish --access public --no-git-checks
done < /tmp/released-packages
- name: Verify NPM dist-tags
if: ${{ steps.release.outputs.releases_created == 'true' }}
env:
PATHS_RELEASED: ${{ steps.release.outputs.paths_released }}
run: |
node --input-type=module <<'NODE'
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { join } from "node:path";
const paths = JSON.parse(process.env.PATHS_RELEASED || "[]");
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
for (const path of paths) {
const pkg = JSON.parse(readFileSync(join(path, "package.json"), "utf8"));
let latest = "";
for (let attempt = 1; attempt <= 6; attempt += 1) {
latest = execFileSync("npm", ["view", `${pkg.name}@latest`, "version"], {
encoding: "utf8",
}).trim();
if (latest === pkg.version) break;
await sleep(10000);
}
if (latest !== pkg.version) {
throw new Error(`${pkg.name}@latest is ${latest}, expected ${pkg.version}`);
}
console.log(`${pkg.name}@latest -> ${latest}`);
}
NODE