Skip to content

feat: add release infrastructure with changesets [3/4] - #45

Merged
wyattjoh merged 5 commits into
mainfrom
wyattjoh/release-infra
Mar 23, 2026
Merged

feat: add release infrastructure with changesets [3/4]#45
wyattjoh merged 5 commits into
mainfrom
wyattjoh/release-infra

Conversation

@wyattjoh

@wyattjoh wyattjoh commented Mar 20, 2026

Copy link
Copy Markdown
Contributor

Summary

#43 and #47 give us a monorepo structure and cross-compiled binaries, but no way to get them to users. We need an automated release pipeline that handles versioning, npm publishing of 9+ packages (1 wrapper + 8 platform packages), and GitHub Releases -- across three channels with different triggers and safeguards.

This PR adds the full release infrastructure:

  • Changesets (.changeset/config.json, @changesets/cli) for version management. Contributors add changeset files to PRs; the changesets/action bot creates a "Version Packages" PR that bumps versions when merged.
  • scripts/releaser/index.ts -- the core publish script. Generates a platform package for each target (with the correct os/cpu/libc fields), publishes them to npm, updates the wrapper's optionalDependencies, publishes the wrapper, creates a git tag, and uploads binaries to a GitHub Release. Supports --dry-run and --tag for channel selection.
  • scripts/snapshot.ts -- versions packages for snapshot and canary channels using Changesets snapshot mode, producing monotonically-sortable versions like 0.1.0-snapshot.v20260313145959.
  • scripts/check-release.ts -- detects whether the current version is already published on npm, used by CI to decide whether to trigger a stable release.
  • scripts/lib/npm.ts -- shared isPublished() helper that queries the npm registry.
  • release.yml -- CI workflow for stable and canary releases. On push to main: runs check-release.ts, if unpublished triggers build → smoke-test → publish. If no stable release needed, publishes a canary instead.
  • snapshot.yml -- CI workflow triggered by !snapshot comments on PRs. Builds from the PR branch, smoke-tests, publishes snapshot packages, and posts the install command back as a PR comment.

Merge instructions

This is part of a 4-PR stack (#43, #47, #45, #46). After squash-merging this PR, retarget #46 to main before deleting this branch, then:

git fetch origin main
git rebase origin/main wyattjoh/release-docs
git push --force-with-lease origin wyattjoh/release-docs

Then merge #46.

Test plan

  • CI passes: format:check, lint, build, test
  • Review release.yml workflow triggers and job dependencies
  • Review snapshot.yml comment-trigger logic and PR comment format
  • Verify scripts/releaser/index.ts publish flow handles all 3 channels (stable, canary, snapshot)

Summary by CodeRabbit

  • Documentation

    • Added guidance for the Changesets directory and how to create changelogs and changesets.
  • Chores

    • Added automated release workflows for stable, canary, snapshot, and snapshot-via-PR-comment channels.
    • Integrated Changesets-based versioning and changelog generation with configured behavior for releases and snapshots.
    • Added release/versioning and snapshot scripts plus package scripts to coordinate builds, tests, publishing, and asset uploads.
  • Style

    • Configured CI runner labeling for self-hosted workers.

@wyattjoh

Copy link
Copy Markdown
Contributor Author

PR Stack -- Supersedes #31

PR Description
1 #43 Monorepo restructure (merged)
2 #47 Cross-compile build + smoke tests
3 #45 Release infrastructure (changesets) -- ← this PR
4 #46 Release documentation + LICENSE

Review in parallel, merge sequentially. Squash-merge each PR, retarget the next PR to main before deleting the branch, then rebase.

@wyattjoh
wyattjoh force-pushed the wyattjoh/cross-compile branch from 3bcc5ea to cb764f6 Compare March 20, 2026 19:53
@wyattjoh
wyattjoh force-pushed the wyattjoh/release-infra branch from ba9a09c to 0aa883a Compare March 20, 2026 19:54
@wyattjoh
wyattjoh force-pushed the wyattjoh/cross-compile branch from cb764f6 to fb82e61 Compare March 20, 2026 19:59
@wyattjoh
wyattjoh force-pushed the wyattjoh/release-infra branch from 0aa883a to 51be70a Compare March 20, 2026 19:59
Base automatically changed from wyattjoh/cross-compile to main March 20, 2026 20:01
@wyattjoh
wyattjoh force-pushed the wyattjoh/release-infra branch from 51be70a to 2b7f7c7 Compare March 20, 2026 20:01
@wyattjoh
wyattjoh marked this pull request as ready for review March 20, 2026 20:02
@coderabbitai

coderabbitai Bot commented Mar 20, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Adds Changesets configuration and documentation, new CI workflows for Release and Snapshot, and an actionlint runner label. Introduces package scripts and a devDependency for Changesets, plus Bun/TypeScript release tooling: scripts/check-release.ts, scripts/lib/npm.ts (adds isPublished), scripts/releaser/index.ts, and scripts/snapshot.ts. The changes enable stable, canary, and snapshot versioning and publishing flows, artifact packaging for platform targets, npm publishing (with per-target packages and wrapper), and CI logic to skip already-published versions and create tags/releases.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 16.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: adding release infrastructure with changesets. It is concise, specific, and directly related to the substantial changeset content.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@scripts/releaser/index.ts`:
- Around line 126-145: Replace the local-only tag check and blind release
creation with a remote-aware, idempotent flow: keep tagName and tagCheck but
also check the remote via git ls-remote --tags origin tagName (or "git ls-remote
--refs origin refs/tags/${tagName}") and skip git push if the remote tag exists;
when pushing, only run(["git","push","origin",tagName]) if remote missing. For
releases, call run(["gh","release","create", tagName, "--generate-notes",
...files]) with the build artifact paths taken from ARTIFACTS_DIR (globbing the
built files) so assets are uploaded; wrap the gh release create call in a
try/catch and on failure detect if the release already exists (use
run(["gh","release","view", tagName]) or inspect the error), and if it exists
call run(["gh","release","upload", tagName, ...files, "--clobber"]) to
idempotently upload artifacts. Use the existing run(), tagName, tagCheck, and
ARTIFACTS_DIR symbols to locate where to implement these changes.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 555c6e67-74d2-434a-a30a-809b44b4b76f

📥 Commits

Reviewing files that changed from the base of the PR and between 94bc738 and 2b7f7c7.

⛔ Files ignored due to path filters (1)
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (9)
  • .changeset/README.md
  • .changeset/config.json
  • .github/workflows/release.yml
  • .github/workflows/snapshot.yml
  • package.json
  • scripts/check-release.ts
  • scripts/lib/npm.ts
  • scripts/releaser/index.ts
  • scripts/snapshot.ts

Comment thread scripts/releaser/index.ts
Comment thread package.json Outdated
Add the complete release pipeline:
- Changesets for versioning (stable, canary, snapshot channels)
- Releaser script for npm publish, git tags, and GitHub Releases
- CI workflows for stable/canary releases and PR snapshot comments
@wyattjoh
wyattjoh force-pushed the wyattjoh/release-infra branch from 2b7f7c7 to d849ce6 Compare March 20, 2026 20:18

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

♻️ Duplicate comments (1)
.github/workflows/release.yml (1)

61-112: ⚠️ Potential issue | 🔴 Critical

Stable release recovery is still not safe to retry.

publish-npm delegates to scripts/releaser/index.ts, which only checks the local checkout before pushing v${version}, and this asset job has no explicit overwrite/skip handling if some binaries were already uploaded on an earlier attempt. A partial success can therefore leave the stable release stuck instead of recoverable by rerunning the failed jobs. Please make the tag/release creation and asset upload steps explicitly idempotent before merging.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.github/workflows/release.yml around lines 61 - 112, The release pipeline
can leave a half-complete stable release that cannot be recovered because
tag/release creation and asset uploads are not idempotent; update
scripts/releaser/index.ts and the workflow steps (publish-npm and
upload-github-assets) so tag creation is safe to retry (check for existing tag
v${{ needs.versioning.outputs.version }}, update it or force-push the tag
instead of failing) and make GitHub Release/asset uploads idempotent by using
the GitHub CLI to create-or-edit the release (query with gh release view and gh
release create only if missing, or gh release edit) and upload assets with
overwrite semantics (gh release upload --clobber or delete existing asset before
upload) instead of blindly uploading; ensure these changes are applied where tag
logic exists in scripts/releaser/index.ts and where gh release upload is invoked
in the upload-github-assets job.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In @.github/workflows/snapshot.yml:
- Line 20: The workflow uses an unrecognized self-hosted runner label
"blacksmith-2vcpu-ubuntu-2404" which breaks actionlint; fix by either adding an
actionlint configuration that declares this custom runner label (so actionlint
accepts "blacksmith-2vcpu-ubuntu-2404") or replace every occurrence of the label
with a supported GitHub runner like "ubuntu-latest" or "ubuntu-2404" (ensure you
update the same label consistently across all workflows where it's used).
- Around line 34-41: Add an explicit fork-guard so the publish job cannot run
against forked PRs: detect forks using the PR head repo fields (e.g.
github.event.pull_request.head.repo.fork or compare
github.event.pull_request.head.repo.full_name to github.repository) and add a
conditional to the publish job (the job named "publish") so it only runs when
the PR head repo is the same as the base repo; also ensure steps that fetch and
use the PR head SHA (the "Get PR head SHA" step and the sha variable) remain
unchanged for non-fork PRs but are skipped for forked PRs to prevent
OIDC/write-sensitive actions from executing on forked code.

In `@scripts/releaser/index.ts`:
- Around line 69-71: generatePlatformPackage() currently unconditionally copies
the root LICENSE via licensePath = join(import.meta.dir, "../../LICENSE") which
will ENOENT if the LICENSE is added in a later stacked PR; make the operation
safe by checking existence before copying: use fs.stat/fs.access (or try/catch
around copyFile) to skip the copy if the LICENSE is missing and log a warning,
or alternatively vendor a copy of the needed LICENSE into this stack and change
licensePath to point to the local copy; update the code around
copyFile(join(dir, "LICENSE")) so it either conditionally performs the copy when
the file exists or uses the local packaged LICENSE.

---

Duplicate comments:
In @.github/workflows/release.yml:
- Around line 61-112: The release pipeline can leave a half-complete stable
release that cannot be recovered because tag/release creation and asset uploads
are not idempotent; update scripts/releaser/index.ts and the workflow steps
(publish-npm and upload-github-assets) so tag creation is safe to retry (check
for existing tag v${{ needs.versioning.outputs.version }}, update it or
force-push the tag instead of failing) and make GitHub Release/asset uploads
idempotent by using the GitHub CLI to create-or-edit the release (query with gh
release view and gh release create only if missing, or gh release edit) and
upload assets with overwrite semantics (gh release upload --clobber or delete
existing asset before upload) instead of blindly uploading; ensure these changes
are applied where tag logic exists in scripts/releaser/index.ts and where gh
release upload is invoked in the upload-github-assets job.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 888311dc-8b72-47a8-9c3a-0f2fd69e0a41

📥 Commits

Reviewing files that changed from the base of the PR and between 2b7f7c7 and d849ce6.

⛔ Files ignored due to path filters (1)
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (9)
  • .changeset/README.md
  • .changeset/config.json
  • .github/workflows/release.yml
  • .github/workflows/snapshot.yml
  • package.json
  • scripts/check-release.ts
  • scripts/lib/npm.ts
  • scripts/releaser/index.ts
  • scripts/snapshot.ts

Comment thread .github/workflows/snapshot.yml
Comment thread .github/workflows/snapshot.yml
Comment thread scripts/releaser/index.ts Outdated
- Remove LICENSE copy from platform packages (added in PR #46)
- Make stable release tag/release creation idempotent (remote-aware
  tag check, independent release existence check)
- Add --clobber to GitHub Release asset uploads for safe reruns
- Block snapshot publish on fork PRs to prevent OIDC token exposure
- Fix notify-failure to fire even when publish job is skipped

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In @.github/workflows/release.yml:
- Around line 29-40: Update the release workflow to prevent stable releases when
Changesets created a Version Packages PR by gating the stable release path on
the Changesets step output: check the step with id "changesets" and require
steps.changesets.outputs.hasChangesets == 'false' before running the
stable-release branch; also adjust the existing "Check if release needed" logic
(step id "check" which runs scripts/check-release.ts) and/or the "build" job’s
conditional so the canary path continues to run only when release_created !=
'true' or when steps.changesets.outputs.hasChangesets == 'true', ensuring the
stable path waits until the Version Packages PR is merged.

In @.github/workflows/snapshot.yml:
- Around line 35-69: The workflow currently checks of whether a PR is from a
fork in the "Get PR head SHA" step (outputs sha and is_fork) but still
unconditionally runs actions/checkout and downstream jobs against the fork SHA;
add conditional guards to avoid executing untrusted fork code: update the
checkout step (the actions/checkout@v4 usage in the snapshot job) to run only
when the PR head is not a fork by adding a condition referencing
steps.pr.outputs.is_fork (e.g. if: steps.pr.outputs.is_fork != 'true'), and add
the same guard to the build and smoke-test jobs (jobs named build and
smoke-test) so both jobs skip when needs.snapshot.outputs.is_fork == 'true';
keep using the existing outputs (sha/is_fork) from the Get PR head SHA step and
ensure the publish job retains its existing fork guard.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: b91a30ba-7ca2-4862-b188-69c5e682b68a

📥 Commits

Reviewing files that changed from the base of the PR and between d849ce6 and 3d5e9ab.

📒 Files selected for processing (3)
  • .github/workflows/release.yml
  • .github/workflows/snapshot.yml
  • scripts/releaser/index.ts

Comment thread .github/workflows/release.yml Outdated
Comment thread .github/workflows/snapshot.yml
@jfoshee

jfoshee commented Mar 20, 2026

Copy link
Copy Markdown
Contributor

If the plan is to open source, I recommend we get a review of the workflows from #team-security

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

♻️ Duplicate comments (1)
.github/workflows/snapshot.yml (1)

53-69: ⚠️ Potential issue | 🟠 Major

Fork code executes in snapshot job before privilege-gated jobs.

While the build, smoke-test, and publish jobs correctly gate on is_fork != 'true', the snapshot job still checks out and runs fork PR code at lines 53-69 (bun install and bun run version-packages:snapshot). Although this job doesn't have id-token: write, running untrusted code on self-hosted runners can still be risky (supply-chain attacks, credential exfiltration from runner environment).

Consider either:

  1. Moving the fork check earlier and skipping checkout entirely for forks, or
  2. Accepting the risk given the MEMBER/OWNER gating at line 19

If the author_association check at line 19 is considered sufficient (only trusted org members can trigger), this may be acceptable—but document this trust assumption explicitly.

🔒 Option 1: Skip checkout for forks
       - name: Get PR head SHA
         id: pr
         # ... existing code ...

+      - name: Abort for fork PRs
+        if: steps.pr.outputs.is_fork == 'true'
+        run: |
+          echo "::error::Snapshot builds are not supported for fork PRs"
+          exit 1

       - uses: actions/checkout@v4
+        if: steps.pr.outputs.is_fork != 'true'
         with:
           ref: ${{ steps.pr.outputs.sha }}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.github/workflows/snapshot.yml around lines 53 - 69, The snapshot job
currently checks out and executes fork PR code (actions/checkout@v4,
oven-sh/setup-bun, bun install, bun run version-packages:snapshot) before any
fork gating; update the workflow so untrusted fork code is never run by either
(A) moving the existing fork gate (the is_fork check derived from the pr step /
author_association logic) up to the start of the snapshot job so the job is
skipped entirely for forks, or (B) adding explicit conditionals (e.g., guarding
the actions/checkout and the bun install / bun run steps) so those steps only
run when is_fork != 'true' or when author_association indicates a trusted
MEMBER/OWNER; also, if you choose to rely on the author_association check, add a
short comment documenting that trust assumption.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In @.github/workflows/release.yml:
- Around line 184-188: Add a POSIX-compliant trailing newline to the end of
.github/workflows/release.yml by ensuring the file ends with a single newline
character after the last line (the "Publish canary packages" run block that sets
CANARY_VERSION and ARTIFACTS_DIR), so the file terminates with a newline rather
than ending abruptly.
- Around line 107-113: The release upload loop assumes the GitHub release tag
stored in variable tag ("v${{ needs.versioning.outputs.version }}") exists; add
a pre-check that uses the tag variable and the gh command to verify the release
(or tag) exists before entering the for loop and either create the release/tag
or exit with a clear error via the workflow runner; update the block containing
tag, the for loop, and gh release upload to first run a check (using the same
tag variable) and only proceed to gh release upload for each artifact if the
check succeeds.

---

Duplicate comments:
In @.github/workflows/snapshot.yml:
- Around line 53-69: The snapshot job currently checks out and executes fork PR
code (actions/checkout@v4, oven-sh/setup-bun, bun install, bun run
version-packages:snapshot) before any fork gating; update the workflow so
untrusted fork code is never run by either (A) moving the existing fork gate
(the is_fork check derived from the pr step / author_association logic) up to
the start of the snapshot job so the job is skipped entirely for forks, or (B)
adding explicit conditionals (e.g., guarding the actions/checkout and the bun
install / bun run steps) so those steps only run when is_fork != 'true' or when
author_association indicates a trusted MEMBER/OWNER; also, if you choose to rely
on the author_association check, add a short comment documenting that trust
assumption.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: c3754200-2257-4366-9965-b0ab084fd07c

📥 Commits

Reviewing files that changed from the base of the PR and between 3d5e9ab and 58f0934.

📒 Files selected for processing (3)
  • .github/actionlint.yaml
  • .github/workflows/release.yml
  • .github/workflows/snapshot.yml

Comment on lines +107 to +113
run: |
tag="v${{ needs.versioning.outputs.version }}"
for dir in dist/artifacts/clerk-*/; do
target=${dir#dist/artifacts/clerk-} && target=${target%/}
ext=""; [[ "$target" == win32-* ]] && ext=".exe"
gh release upload "$tag" "${dir}clerk${ext}#clerk-${target}${ext}" --clobber
done

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Consider adding error handling for missing release tag.

The script assumes the release tag v${version} already exists. If the tag wasn't created by a previous step (e.g., publish-npm failed to create it), this step will fail. Consider adding a check or ensuring the releaser script creates the tag before this job runs.

🛡️ Optional: Add existence check
         run: |
           tag="v${{ needs.versioning.outputs.version }}"
+          if ! gh release view "$tag" &>/dev/null; then
+            echo "::error::Release $tag does not exist"
+            exit 1
+          fi
           for dir in dist/artifacts/clerk-*/; do
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
run: |
tag="v${{ needs.versioning.outputs.version }}"
for dir in dist/artifacts/clerk-*/; do
target=${dir#dist/artifacts/clerk-} && target=${target%/}
ext=""; [[ "$target" == win32-* ]] && ext=".exe"
gh release upload "$tag" "${dir}clerk${ext}#clerk-${target}${ext}" --clobber
done
run: |
tag="v${{ needs.versioning.outputs.version }}"
if ! gh release view "$tag" &>/dev/null; then
echo "::error::Release $tag does not exist"
exit 1
fi
for dir in dist/artifacts/clerk-*/; do
target=${dir#dist/artifacts/clerk-} && target=${target%/}
ext=""; [[ "$target" == win32-* ]] && ext=".exe"
gh release upload "$tag" "${dir}clerk${ext}#clerk-${target}${ext}" --clobber
done
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.github/workflows/release.yml around lines 107 - 113, The release upload
loop assumes the GitHub release tag stored in variable tag ("v${{
needs.versioning.outputs.version }}") exists; add a pre-check that uses the tag
variable and the gh command to verify the release (or tag) exists before
entering the for loop and either create the release/tag or exit with a clear
error via the workflow runner; update the block containing tag, the for loop,
and gh release upload to first run a check (using the same tag variable) and
only proceed to gh release upload for each artifact if the check succeeds.

Comment on lines +184 to +188
- name: Publish canary packages
run: bun run release:canary --version "$CANARY_VERSION"
env:
CANARY_VERSION: ${{ needs.canary-version.outputs.version }}
ARTIFACTS_DIR: ${{ github.workspace }}/dist/artifacts

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Missing trailing newline at end of file.

The file ends at line 188 without a trailing newline. POSIX text files should end with a newline.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.github/workflows/release.yml around lines 184 - 188, Add a POSIX-compliant
trailing newline to the end of .github/workflows/release.yml by ensuring the
file ends with a single newline character after the last line (the "Publish
canary packages" run block that sets CANARY_VERSION and ARTIFACTS_DIR), so the
file terminates with a newline rather than ending abruptly.

@wyattjoh
wyattjoh merged commit a2c0cc5 into main Mar 23, 2026
3 checks passed
@wyattjoh
wyattjoh deleted the wyattjoh/release-infra branch March 23, 2026 16:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants