Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
160 changes: 160 additions & 0 deletions .github/workflows/prepare-release.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
name: "Prepare Release"

# Gate 1 of the two-gate release flow ported from wire-sysio / wire-cdt
# (their docs/release-workflow.md, "Strategy C"), adapted to this pnpm workspace.
#
# A human dispatches this with a bump TYPE; the bot bumps every package relative
# to its own current version -- exactly what the previous publish-npm.yaml did
# (`pnpm -r exec -- pnpm version <type>`), so the packages keep their independent
# tracks (core on 1.0.x, wallet on 0.1.x) -- and opens a version-bump PR. Nothing
# is tagged or published here, and NOTHING is pushed directly to master: the bump
# reaches master only when a human merges the PR, so the org-wide "pull request
# required" ruleset stays satisfied without any bypass.
#
# The bump type decides the channel: `prerelease` produces a `-<preid>.N` suffix
# (channel = that preid, e.g. dev/rc); patch/minor/major produce a stable
# version. Tag Release derives the same channel from the resulting suffix.

on:
workflow_dispatch:
inputs:
bump:
description: "Version bump applied to every package, relative to its own current version"
type: choice
required: true
default: patch
options:
- patch
- minor
- major
- prerelease
preid:
description: "Prerelease identifier (only used when bump = prerelease); becomes the npm channel/dist-tag"
type: string
required: false
default: "dev"

permissions:
contents: write
pull-requests: write

defaults:
run:
shell: bash

# One prep run per bump type, and never cancel one mid-flight: a cancelled run can
# leave a pushed prep branch with no PR opened for it.
concurrency:
group: prepare-release-${{ inputs.bump }}-${{ inputs.preid }}
cancel-in-progress: false

jobs:
prepare:
name: Open the version-bump PR
runs-on: ubuntu-latest
env:
GH_TOKEN: ${{ github.token }}
# Dispatch inputs are passed as env vars and NEVER interpolated into a script
# body, so their values stay inert data.
BUMP: ${{ inputs.bump }}
PREID: ${{ inputs.preid }}
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
ref: master
# Unshallowed so origin/<prep-branch> exists for --force-with-lease.
fetch-depth: 0

- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0

- name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 24

- name: Bump every package on its own track
id: bump
run: |
set -euo pipefail

# sdk-core is the representative "core" package: the four core packages
# share one number, so its version names the release (the tag, the
# branch, the PR title). The wallet packages bump on their own 0.1.x
# track in the same command and are not the headline number.
core_manifest="packages/sdk-core/package.json"
previous="$(node -p "require('./${core_manifest}').version")"

# Relative bump in EACH package -- the exact mechanism publish-npm.yaml
# used. --no-git-tag-version: change the files only, no tag/commit here.
if [[ "$BUMP" == "prerelease" ]]; then
pnpm -r exec -- pnpm version prerelease --preid "$PREID" --no-git-tag-version
else
pnpm -r exec -- pnpm version "$BUMP" --no-git-tag-version
fi

version="$(node -p "require('./${core_manifest}').version")"
if [[ "$version" == "$previous" ]]; then
echo "::error::Bump produced no version change (${previous}). Nothing to prepare." >&2
exit 1
fi

# The channel is the resulting suffix -- the "suffix IS the channel"
# principle from wire-sysio/wire-cdt. No suffix => stable; a
# `-<preid>.N` suffix => that preid is the channel.
if [[ "$version" == *-* ]]; then
suffix="${version#*-}"
channel="${suffix%%.*}"
else
channel="stable"
fi

# Refresh the lockfile so it reflects the bumped versions (hygiene;
# CI installs with --no-frozen-lockfile, so it is not a hard gate).
pnpm install --lockfile-only --ignore-scripts

{
echo "previous=$previous"
echo "version=$version"
echo "channel=$channel"
echo "branch=release/prep-v${version}"
} >> "$GITHUB_OUTPUT"

echo "Prepared ${BUMP} bump: sdk-core ${previous} -> ${version} (channel ${channel})"

- name: Open the bump PR
env:
BRANCH: ${{ steps.bump.outputs.branch }}
PREVIOUS: ${{ steps.bump.outputs.previous }}
VERSION: ${{ steps.bump.outputs.version }}
CHANNEL: ${{ steps.bump.outputs.channel }}
run: |
set -euo pipefail

git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git checkout -b "$BRANCH"
git add package.json packages/*/package.json examples/*/package.json pnpm-lock.yaml
git commit -m "chore(release): ${BUMP} bump -> v${VERSION} (${CHANNEL})"
# --force-with-lease, not a bare push: re-dispatching the same bump must
# replace the previous attempt's branch tip instead of failing
# non-fast-forward, while still refusing to clobber a tip someone else
# moved since this run's fetch.
git push --force-with-lease origin "$BRANCH"

# NOTE: a PR opened with GITHUB_TOKEN starts its checks in an
# approval-required state -- gate 1 includes an "Approve and run" click.
# printf, not a heredoc: every body line must start at column 0.
body="$(printf '%s\n' \
"\`${BUMP}\` bump (channel: **${CHANNEL}**). Core packages: \`${PREVIOUS}\` -> \`${VERSION}\`." \
"" \
"- Every package bumped relative to its own version, so the wallet packages stay on their own track." \
"- Internal \`workspace:*\` deps are rewritten to concrete versions at publish time." \
"" \
"After merging, dispatch **Tag Release** to tag master and publish the merged versions.")"
gh pr create \
--base master \
--head "$BRANCH" \
--title "chore(release): ${BUMP} bump v${VERSION} (${CHANNEL})" \
--body "$body"
145 changes: 145 additions & 0 deletions .github/workflows/tag-release.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
name: "Tag Release"

# Gate 2 of the two-gate release flow (see prepare-release.yaml). The job runs
# under the `release` Environment, so it PAUSES for a required reviewer before it
# creates anything. After approval it reads the version the merged prepare PR put
# on master, runs the build/test gate, publishes every workspace package to npm on
# the version's channel, tags master, and publishes a GitHub release. Nothing is
# pushed to master here, so the org-wide "pull request required" ruleset needs no
# bypass.
#
# No inputs: the release version is whatever master currently carries. Its suffix
# IS the channel: no suffix => stable => npm dist-tag `latest`; a `-<preid>.N`
# suffix => that preid is the dist-tag (e.g. dev, rc). The two human gates
# (dispatch + Environment approval) are the safety, not a typed version.

on:
workflow_dispatch: {}

permissions:
contents: write # create the tag + the GitHub release
id-token: write # npm provenance

defaults:
run:
shell: bash

concurrency:
group: tag-release
cancel-in-progress: false

jobs:
tag:
name: Tag master and publish
runs-on: ubuntu-latest
# Required reviewers on this Environment are gate 2 -- nothing below runs until
# a human approves the deployment.
environment: release
env:
GH_TOKEN: ${{ github.token }}
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
ref: master
fetch-depth: 0

- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0

- name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 24
registry-url: https://registry.npmjs.org

- name: Resolve the release version and channel from master
id: resolve
run: |
set -euo pipefail

# sdk-core names the release, the same representative package
# prepare-release.yaml bumps and reports. The wallet packages publish on
# their own track in the same run; they are not the headline number.
version="$(node -p "require('./packages/sdk-core/package.json').version")"

# The suffix IS the channel -> npm dist-tag. No suffix => latest; a
# `-<preid>.N` suffix => that preid (dev, rc, ...).
if [[ "$version" == *-* ]]; then
prerelease="true"
suffix="${version#*-}"
dist_tag="${suffix%%.*}"
else
prerelease="false"
dist_tag="latest"
fi

tag="v${version}"
if gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${tag}" >/dev/null 2>&1; then
echo "::error::Tag ${tag} already exists. Prepare the next bump instead of re-tagging." >&2
exit 1
fi

{
echo "version=$version"
echo "tag=$tag"
echo "sha=$(git rev-parse HEAD)"
echo "prerelease=$prerelease"
echo "dist_tag=$dist_tag"
} >> "$GITHUB_OUTPUT"

echo "Releasing ${tag} on $(git rev-parse HEAD) (npm dist-tag ${dist_tag})"

# Gate the release on the same build + root jest run CI uses.
- name: Install, build and test
env:
JEST_JUNIT_OUTPUT_DIR: reports/junit
JEST_JUNIT_OUTPUT_NAME: jest-junit.xml
run: |
# --no-frozen-lockfile to match ci.yaml / publish-npm.yaml: the OPP
# models are resolved via .pnpmfile.cjs, not locked, so a frozen install
# fails on the resulting lockfile drift.
pnpm install --ignore-scripts --no-frozen-lockfile
pnpm run build
pnpm run test:ci

# npm is the irreversible step -- do it once the gate is green, BEFORE
# tagging, so a failed publish leaves no dangling tag to trip the
# "tag already exists" guard on the retry.
- name: Publish to npm
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
DIST_TAG: ${{ steps.resolve.outputs.dist_tag }}
run: |
# Publishes all non-private workspace packages in dependency order on the
# channel's dist-tag; `workspace:*` deps are rewritten to concrete
# versions. --no-git-checks avoids failure in CI environments.
pnpm -r publish --access public --no-git-checks --provenance --tag "$DIST_TAG"

- name: Create the annotated tag
env:
TAG: ${{ steps.resolve.outputs.tag }}
SHA: ${{ steps.resolve.outputs.sha }}
run: |
set -euo pipefail
# An annotated tag is TWO API calls in order: the tag object, then the
# ref that points at it. `gh release create --verify-tag` needs the ref.
tag_sha="$(
gh api "repos/${GITHUB_REPOSITORY}/git/tags" -X POST \
-f tag="$TAG" -f message="$TAG" -f object="$SHA" -f type=commit --jq .sha
)"
gh api "repos/${GITHUB_REPOSITORY}/git/refs" -X POST \
-f ref="refs/tags/${TAG}" -f sha="$tag_sha" > /dev/null
echo "Created annotated tag ${TAG} -> ${SHA}"

- name: Publish the GitHub release
env:
TAG: ${{ steps.resolve.outputs.tag }}
PRERELEASE: ${{ steps.resolve.outputs.prerelease }}
run: |
set -euo pipefail
args=(--verify-tag --title "$TAG" --generate-notes)
if [[ "$PRERELEASE" == "true" ]]; then
args+=(--prerelease)
fi
gh release create "$TAG" "${args[@]}"
Loading