Skip to content
Open
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
118 changes: 118 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -83,3 +85,119 @@ jobs:
pnpm install --frozen-lockfile
pnpm exec turbo --filter=@stakekit/widget build
npm publish "./$PACKAGE_DIR"

canary:
if: github.event_name == 'workflow_dispatch'
Comment thread
petar-omni marked this conversation as resolved.
name: Publish canary
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
id-token: write
environment: production

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P0 — publish authority relies on environment approval that is not configured.

docs/releases.md says the production environment "must require approval from a release maintainer", but the environment currently has no protection rules and no deployment branch policy (gh api repos/stakekit/widget/environments/production returns {"deployment_branch_policy":null,"protection_rules":[]}).

Since workflow_dispatch executes the workflow file from the selected branch, any writer can dispatch a modified release.yml (e.g., npm publish --tag latest with an arbitrary version, or exfiltrate the OIDC token) from their own branch — with zero review. This job's id-token: write + environment: production then mints a valid npm trusted-publishing token.

Please configure required reviewers on the production environment (and confirm the npm trusted publisher is bound to this environment) before merging. Approvers must review the dispatched branch's workflow/script diff, since the environment gate is the only trust boundary. Optionally split canary into its own environment so stable can additionally get a main-only deployment branch policy.

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
# 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
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 --filter @stakekit/widget exec tsx 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
Comment thread
petar-omni marked this conversation as resolved.

- 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 — re-running an older failed run can move the canary dist-tag backwards.

GITHUB_RUN_NUMBER is stable across re-runs, and the existence check only looks at this run's own version. Scenario: run 418 fails before publishing (e.g., flaky test) → run 420 later publishes 0.0.283-canary.420 → someone re-runs 418 per the retry docs → existence check passes (418 was never on npm) and --tag canary retags to 0.0.283-canary.418. QA installing @stakekit/widget@canary silently downgrades to older code.

The docs/releases.md claim that re-running the same run is safe only holds for post-publication reruns.

Suggest moving the canary dist-tag only when the new version is semver-greater than the current tag (publish under a placeholder tag, then a conditional npm dist-tag add), or documenting that runs superseded by a later canary must not be re-run.


- 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"
2 changes: 2 additions & 0 deletions .rev-dep.config.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
110 changes: 110 additions & 0 deletions docs/releases.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# 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 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 and the `production` environment claim.

## 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@<version>`.
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.
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
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.
80 changes: 80 additions & 0 deletions packages/widget/scripts/prepare-canary-release.test.ts
Original file line number Diff line number Diff line change
@@ -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"
);
});
});
Loading