From 9e35cf08cdc51fcdd51ec6175c17f7c3983da36a Mon Sep 17 00:00:00 2001 From: Petar Todorovic Date: Thu, 23 Jul 2026 15:42:35 +0200 Subject: [PATCH 1/3] ci(release): add canary npm publishing Add an internal canary channel for branch-specific QA. Keep the stable Release Please flow unchanged. Derive immutable versions from npm latest and the workflow run number. Publish under the canary dist-tag and document retry and QA usage. --- .github/workflows/release.yml | 116 +++++++++++++++ .rev-dep.config.jsonc | 2 + docs/releases.md | 112 ++++++++++++++ .../scripts/prepare-canary-release.test.ts | 80 ++++++++++ .../widget/scripts/prepare-canary-release.ts | 139 ++++++++++++++++++ packages/widget/vite/vitest.config.scripts.ts | 8 + turbo.json | 3 + 7 files changed, 460 insertions(+) create mode 100644 docs/releases.md create mode 100644 packages/widget/scripts/prepare-canary-release.test.ts create mode 100644 packages/widget/scripts/prepare-canary-release.ts create mode 100644 packages/widget/vite/vitest.config.scripts.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 89370adf4..4536cd2a1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,11 +4,13 @@ on: push: branches: - main + workflow_dispatch: concurrency: ${{ github.workflow }}-${{ github.ref }} jobs: release: + if: github.event_name == 'push' name: Release runs-on: ubuntu-latest permissions: @@ -83,3 +85,117 @@ jobs: pnpm install --frozen-lockfile pnpm exec turbo --filter=@stakekit/widget build npm publish "./$PACKAGE_DIR" + + canary: + if: github.event_name == 'workflow_dispatch' + name: Publish canary + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + id-token: write + concurrency: + group: npm-canary-publish + cancel-in-progress: false + env: + PACKAGE_DIR: packages/widget + + steps: + # actions/checkout@v6.0.2 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + ref: ${{ github.sha }} + fetch-depth: 0 + + - name: Install mise + run: | + curl https://mise.run | MISE_VERSION=v2026.5.6 sh + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + echo "$HOME/.local/share/mise/shims" >> "$GITHUB_PATH" + export PATH="$HOME/.local/bin:$HOME/.local/share/mise/shims:$PATH" + mise install + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Read npm release state + id: registry + shell: bash + run: | + package_name=$(node -p "require('./${PACKAGE_DIR}/package.json').name") + latest_version=$(npm view "${package_name}@latest" version) + + { + echo "package_name=${package_name}" + echo "latest_version=${latest_version}" + } >> "$GITHUB_OUTPUT" + + - name: Prepare canary version + id: canary_version + env: + LATEST_VERSION: ${{ steps.registry.outputs.latest_version }} + run: pnpm exec tsx packages/widget/scripts/prepare-canary-release.ts + + - name: Build widget + run: pnpm exec turbo --filter=@stakekit/widget build + + - name: Test widget + run: | + pnpm --filter @stakekit/widget exec vitest -c vite/vitest.config.scripts.ts run + pnpm --filter @stakekit/widget test + + - name: Check canary publish status + id: publish + shell: bash + env: + PACKAGE_NAME: ${{ steps.registry.outputs.package_name }} + VERSION: ${{ steps.canary_version.outputs.version }} + run: | + if npm view "${PACKAGE_NAME}@${VERSION}" version >/dev/null 2>&1; then + echo "${PACKAGE_NAME}@${VERSION} is already published; skipping." + echo "should_publish=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "should_publish=true" >> "$GITHUB_OUTPUT" + + - name: Inspect package contents + if: steps.publish.outputs.should_publish == 'true' + run: npm pack "./$PACKAGE_DIR" --dry-run + + - name: Publish canary to npm + if: steps.publish.outputs.should_publish == 'true' + run: npm publish "./$PACKAGE_DIR" --access public --tag canary + + - name: Write release summary + env: + PACKAGE_NAME: ${{ steps.registry.outputs.package_name }} + VERSION: ${{ steps.canary_version.outputs.version }} + SHOULD_PUBLISH: ${{ steps.publish.outputs.should_publish }} + run: | + if [[ "$SHOULD_PUBLISH" == "true" ]]; then + release_status="Published" + else + release_status="Already published" + fi + + { + echo "## Widget canary release" + echo + echo "| Field | Value |" + echo "| --- | --- |" + echo "| Status | ${release_status} |" + echo "| Package | ${PACKAGE_NAME} |" + echo "| Version | ${VERSION} |" + echo "| npm tag | canary |" + echo "| Branch | ${{ github.ref_name }} |" + echo "| Commit | ${{ github.sha }} |" + echo + echo "Install the moving canary channel:" + echo + echo " pnpm add ${PACKAGE_NAME}@canary" + echo + echo "Install this exact build:" + echo + echo " pnpm add ${PACKAGE_NAME}@${VERSION}" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.rev-dep.config.jsonc b/.rev-dep.config.jsonc index 5691d0bec..20a9161dc 100644 --- a/.rev-dep.config.jsonc +++ b/.rev-dep.config.jsonc @@ -18,6 +18,8 @@ ], "devEntryPoints": [ "scripts/generate-effect-openapi.ts", + "scripts/prepare-canary-release.ts", + "scripts/*.test.ts", "tests/utils/setup.ts", "tests/**/*.test.ts", "tests/**/*.test.tsx" diff --git a/docs/releases.md b/docs/releases.md new file mode 100644 index 000000000..44cc21af1 --- /dev/null +++ b/docs/releases.md @@ -0,0 +1,112 @@ +# Releases + +`@stakekit/widget` has two npm release paths: + +- Stable releases are produced from `main` through Release Please and use the + npm `latest` tag. +- Canary releases are manually requested from a selected branch for internal + StakeKit QA and use the npm `canary` tag. + +Canaries are not a public support channel and are never promoted into stable +releases. + +## Publishing authority + +Repository writers may request and publish a canary workflow run without a +deployment-environment approval. Stable npm publication uses the `production` +GitHub environment, which must require approval from a release maintainer. + +Both release paths publish from the existing +`.github/workflows/release.yml` workflow so npm trusted publishing remains +bound to one workflow identity. The npm trusted publisher must not restrict +that workflow identity to a GitHub environment: the stable job supplies the +`production` environment claim, while the canary job intentionally has no +environment. + +## Stable releases + +A push to `main` runs Release Please. Release Please maintains the release pull +request and creates the package Git tag and GitHub release when that pull +request is merged. + +The `production` environment approval gates the stable release job. + +The workflow publishes only when all of these conditions hold: + +1. A GitHub release exists for `@stakekit/widget@`. +2. That exact package version is not already present on npm. +3. The tagged source builds successfully. + +Stable releases publish without an alternate dist-tag and therefore update +`latest`. + +## Canary releases + +To request a canary: + +1. Open **Actions** in GitHub. +2. Select the **Release** workflow. +3. Choose **Run workflow**. +4. Select the branch to test. +5. Start the workflow. + +The selected branch must contain the manual release workflow and its +`packages/widget/package.json` version must equal the version currently +published under npm `latest`. Rebase or merge the current stable baseline +before requesting a canary from a stale branch. + +For a branch based on `0.0.282`, workflow run `418` publishes: + +```text +@stakekit/widget@0.0.283-canary.418 +``` + +The workflow: + +1. Checks out the exact selected commit. +2. Reads the current npm `latest` version. +3. Derives `next patch + canary + GITHUB_RUN_NUMBER`. +4. Updates `packages/widget/package.json` only in the runner. +5. Builds the widget and runs its script and widget test suites. +6. Inspects the package contents. +7. Publishes with the npm `canary` dist-tag. +8. Records the branch, commit, exact version, and install commands in the + workflow summary. + +It does not commit the generated version, create a Git tag, or create a GitHub +release. + +## QA usage + +Use the moving channel only to obtain the newest published canary: + +```sh +pnpm add @stakekit/widget@canary +``` + +Record and pin the exact version in bug reports, test environments, and QA +sign-off notes: + +```sh +pnpm add @stakekit/widget@0.0.283-canary.418 +``` + +Each successful canary publication moves the `canary` dist-tag. The moving tag +is convenient but is not a reproducible test reference. + +## Retries and failures + +- Rerun the same GitHub workflow run when its commit has not changed. The run + retains its canary version, and the npm existence check makes a + post-publication rerun safe. +- Start a new workflow run after changing the branch or selecting a different + commit. The new run number intentionally creates a new canary version. +- If the branch version is behind npm `latest`, rebase or merge the current + stable baseline and start a new run. +- If build or tests fail, fix the selected branch and start a new run. + +## Stable release after QA + +Canary QA sign-off supplies evidence for a later stable release; it does not +promote or retag the canary artifact. Merge the intended changes into `main` +and let Release Please produce and publish the independent stable version. diff --git a/packages/widget/scripts/prepare-canary-release.test.ts b/packages/widget/scripts/prepare-canary-release.test.ts new file mode 100644 index 000000000..c018ccc53 --- /dev/null +++ b/packages/widget/scripts/prepare-canary-release.test.ts @@ -0,0 +1,80 @@ +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + deriveCanaryVersion, + prepareCanaryRelease, +} from "./prepare-canary-release"; + +describe("deriveCanaryVersion", () => { + it("derives a patch canary from the current stable release", () => { + expect( + deriveCanaryVersion({ + currentVersion: "0.0.282", + latestVersion: "0.0.282", + runNumber: "418", + }) + ).toBe("0.0.283-canary.418"); + }); + + it("rejects a stale selected branch", () => { + expect(() => + deriveCanaryVersion({ + currentVersion: "0.0.281", + latestVersion: "0.0.282", + runNumber: "420", + }) + ).toThrow(/Rebase or update the branch/); + }); + + it("rejects a prerelease branch version", () => { + expect(() => + deriveCanaryVersion({ + currentVersion: "0.0.283-canary.1", + latestVersion: "0.0.282", + runNumber: "421", + }) + ).toThrow(/must be a stable semantic version/); + }); + + it("rejects invalid run numbers", () => { + expect(() => + deriveCanaryVersion({ + currentVersion: "0.0.282", + latestVersion: "0.0.282", + runNumber: "0", + }) + ).toThrow(/must be a positive integer/); + }); +}); + +describe("prepareCanaryRelease", () => { + it("updates only the runner package version and writes action outputs", () => { + const directory = mkdtempSync(join(tmpdir(), "stakekit-canary-")); + const packageJsonPath = join(directory, "package.json"); + const githubOutput = join(directory, "github-output"); + + writeFileSync( + packageJsonPath, + `${JSON.stringify({ name: "@stakekit/widget", version: "0.0.282" })}\n` + ); + writeFileSync(githubOutput, ""); + + expect( + prepareCanaryRelease({ + packageDir: directory, + latestVersion: "0.0.282", + runNumber: "422", + githubOutput, + }) + ).toBe("0.0.283-canary.422"); + const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { + readonly version: string; + }; + expect(packageJson.version).toBe("0.0.283-canary.422"); + expect(readFileSync(githubOutput, "utf8")).toBe( + "base_version=0.0.282\nversion=0.0.283-canary.422\n" + ); + }); +}); diff --git a/packages/widget/scripts/prepare-canary-release.ts b/packages/widget/scripts/prepare-canary-release.ts new file mode 100644 index 000000000..6a8b0ca27 --- /dev/null +++ b/packages/widget/scripts/prepare-canary-release.ts @@ -0,0 +1,139 @@ +import { appendFileSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const stableVersionPattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/; +const runNumberPattern = /^[1-9]\d*$/; +const packageDir = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +type StableVersion = { + readonly major: number; + readonly minor: number; + readonly patch: number; +}; + +type DeriveCanaryVersionOptions = { + readonly currentVersion: string; + readonly latestVersion: string; + readonly runNumber: string; +}; + +type PrepareCanaryReleaseOptions = Omit< + DeriveCanaryVersionOptions, + "currentVersion" +> & { + readonly packageDir: string; + readonly githubOutput: string; +}; + +type PackageJson = Record & { + readonly version?: unknown; +}; + +const parseStableVersion = (version: string, label: string): StableVersion => { + const match = stableVersionPattern.exec(version); + + if (!match) { + throw new Error( + `${label} must be a stable semantic version, got ${version}` + ); + } + + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + }; +}; + +const formatVersion = ({ major, minor, patch }: StableVersion) => + `${major}.${minor}.${patch}`; + +export const deriveCanaryVersion = ({ + currentVersion, + latestVersion, + runNumber, +}: DeriveCanaryVersionOptions): string => { + const current = parseStableVersion(currentVersion, "Package version"); + const latest = parseStableVersion(latestVersion, "npm latest version"); + + if (formatVersion(current) !== formatVersion(latest)) { + throw new Error( + `Selected branch has ${currentVersion}, but npm latest is ${latestVersion}. ` + + "Rebase or update the branch before publishing a canary." + ); + } + + if (!runNumberPattern.test(runNumber)) { + throw new Error( + `GitHub run number must be a positive integer, got ${runNumber}` + ); + } + + const target = { ...current, patch: current.patch + 1 }; + + return `${formatVersion(target)}-canary.${runNumber}`; +}; + +export const prepareCanaryRelease = ({ + packageDir, + latestVersion, + runNumber, + githubOutput, +}: PrepareCanaryReleaseOptions): string => { + const packageJsonPath = resolve(packageDir, "package.json"); + const packageJson = JSON.parse( + readFileSync(packageJsonPath, "utf8") + ) as PackageJson; + const currentVersion = packageJson.version; + + if (typeof currentVersion !== "string") { + throw new Error(`${packageJsonPath} does not contain a string version`); + } + + const version = deriveCanaryVersion({ + currentVersion, + latestVersion, + runNumber, + }); + + const updatedPackageJson = { ...packageJson, version }; + writeFileSync( + packageJsonPath, + `${JSON.stringify(updatedPackageJson, null, 2)}\n` + ); + appendFileSync( + githubOutput, + `base_version=${currentVersion}\nversion=${version}\n` + ); + + return version; +}; + +const main = () => { + const latestVersion = process.env.LATEST_VERSION; + const runNumber = process.env.GITHUB_RUN_NUMBER; + const githubOutput = process.env.GITHUB_OUTPUT; + + if (!latestVersion || !runNumber || !githubOutput) { + throw new Error( + "LATEST_VERSION, GITHUB_RUN_NUMBER, and GITHUB_OUTPUT are required" + ); + } + + const version = prepareCanaryRelease({ + packageDir, + latestVersion, + runNumber, + githubOutput, + }); + + console.log(`Prepared canary version ${version}`); +}; + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + main(); +} diff --git a/packages/widget/vite/vitest.config.scripts.ts b/packages/widget/vite/vitest.config.scripts.ts new file mode 100644 index 000000000..0868a9907 --- /dev/null +++ b/packages/widget/vite/vitest.config.scripts.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["scripts/**/*.test.ts"], + }, +}); diff --git a/turbo.json b/turbo.json index 0cb775001..14aa8c48a 100644 --- a/turbo.json +++ b/turbo.json @@ -17,6 +17,7 @@ "lint": { "inputs": [ "src/**", + "scripts/**", "tests/**", "tsconfig*.json", "vite/**/*.ts", @@ -27,6 +28,7 @@ "format": { "inputs": [ "src/**", + "scripts/**", "tests/**", "tsconfig*.json", "vite/**/*.ts", @@ -37,6 +39,7 @@ "test": { "inputs": [ "src/**", + "scripts/**", "tests/**", "tsconfig*.json", "vite/**/*.ts", From b592c8222f21a9afc1d8ac3df39a80ca597d144a Mon Sep 17 00:00:00 2001 From: Petar Todorovic Date: Tue, 28 Jul 2026 16:29:11 +0200 Subject: [PATCH 2/3] ci(release): address canary workflow review findings --- .github/workflows/release.yml | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4536cd2a1..779ac17c7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -108,16 +108,17 @@ jobs: fetch-depth: 0 - name: Install mise - run: | - curl https://mise.run | MISE_VERSION=v2026.5.6 sh - echo "$HOME/.local/bin" >> "$GITHUB_PATH" - echo "$HOME/.local/share/mise/shims" >> "$GITHUB_PATH" - export PATH="$HOME/.local/bin:$HOME/.local/share/mise/shims:$PATH" - mise install + # jdx/mise-action@v4.2.3 + uses: jdx/mise-action@9e7f7633ff6f6d6048a9418a68d48f288f50eb14 + with: + version: 2026.5.6 - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Install Playwright browsers + run: pnpm --filter @stakekit/widget exec playwright install + - name: Read npm release state id: registry shell: bash @@ -134,7 +135,7 @@ jobs: id: canary_version env: LATEST_VERSION: ${{ steps.registry.outputs.latest_version }} - run: pnpm exec tsx packages/widget/scripts/prepare-canary-release.ts + run: pnpm --filter @stakekit/widget exec tsx scripts/prepare-canary-release.ts - name: Build widget run: pnpm exec turbo --filter=@stakekit/widget build From 27bd0c4b7ab4b8de6f14a6fe0e6db63576dcd490 Mon Sep 17 00:00:00 2001 From: Petar Todorovic Date: Tue, 28 Jul 2026 16:39:48 +0200 Subject: [PATCH 3/3] ci(release): gate canary publishing with approval --- .github/workflows/release.yml | 1 + docs/releases.md | 12 +++++------- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 779ac17c7..3ea73b79a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -94,6 +94,7 @@ jobs: permissions: contents: read id-token: write + environment: production concurrency: group: npm-canary-publish cancel-in-progress: false diff --git a/docs/releases.md b/docs/releases.md index 44cc21af1..865986b7d 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -12,16 +12,13 @@ releases. ## Publishing authority -Repository writers may request and publish a canary workflow run without a -deployment-environment approval. Stable npm publication uses the `production` -GitHub environment, which must require approval from a release maintainer. +Repository writers may request a canary workflow run. Both canary and stable +npm publication use the `production` GitHub environment, which must require +approval from a release maintainer. Both release paths publish from the existing `.github/workflows/release.yml` workflow so npm trusted publishing remains -bound to one workflow identity. The npm trusted publisher must not restrict -that workflow identity to a GitHub environment: the stable job supplies the -`production` environment claim, while the canary job intentionally has no -environment. +bound to one workflow identity and the `production` environment claim. ## Stable releases @@ -49,6 +46,7 @@ To request a canary: 3. Choose **Run workflow**. 4. Select the branch to test. 5. Start the workflow. +6. Obtain release-maintainer approval for the `production` environment. The selected branch must contain the manual release workflow and its `packages/widget/package.json` version must equal the version currently