diff --git a/.github/workflows/publish-npm.yaml b/.github/workflows/noop/publish-npm.yaml similarity index 100% rename from .github/workflows/publish-npm.yaml rename to .github/workflows/noop/publish-npm.yaml diff --git a/.github/workflows/prepare-release.yaml b/.github/workflows/prepare-release.yaml new file mode 100644 index 0000000..4aa07d1 --- /dev/null +++ b/.github/workflows/prepare-release.yaml @@ -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 `), 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 `-.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/ 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 + # `-.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" diff --git a/.github/workflows/tag-release.yaml b/.github/workflows/tag-release.yaml new file mode 100644 index 0000000..127a57f --- /dev/null +++ b/.github/workflows/tag-release.yaml @@ -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 `-.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 + # `-.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[@]}"