Skip to content

ci: migrate release pipeline to Changesets (+ Linear sync, .claude tooling)#753

Open
chybisov wants to merge 36 commits into
mainfrom
ci/changesets-migration
Open

ci: migrate release pipeline to Changesets (+ Linear sync, .claude tooling)#753
chybisov wants to merge 36 commits into
mainfrom
ci/changesets-migration

Conversation

@chybisov
Copy link
Copy Markdown
Member

@chybisov chybisov commented May 28, 2026

Summary

Migrate the release pipeline from Lerna + standard-version to Changesets, driven by push: main (per-package npm publishes + GitHub Releases in one run, OIDC provenance). Adds Linear release sync, a label-triggered canary preview flow, changeset reminders via changeset-bot, and a .claude/ changeset helper.

Supersedes #749 — that PR's Linear release-sync workflow + action-pin refresh are folded in here, so this single PR carries the whole pipeline change to main.

What changed

  • Changesets@changesets/cli@^2.31.0 + @changesets/changelog-github@^0.7.0; .changeset/config.json (independent versioning, the 4 private packages ignored).
  • Pre-beta.changeset/pre.json (tag: beta) ships so 4.x publishes under @beta while latest stays on v3. Never pre exit except to deliberately cut stable 4.0.0.
  • publish.yaml rewrittenverify → changesets (the "version packages" PR) → release (publish + GitHub Releases) → linear-*. Top-level permissions: {} with per-job least privilege; privileged jobs skip the pnpm cache; all third-party actions SHA-pinned.
  • Linear sync — reusable linear-release.yaml; @lifi/widget → "Widget". A @lifi/widget-checkout → "Checkout" pair is wired but dormant until feat: add checkout widget #727 ships that package.
  • Changeset reminders — the canonical changeset-bot app comments on PRs missing a changeset (a nudge, not a hard CI block); the maintainer-reviewed Version PR is the publish gate.
  • Canary previews — add the release-canary label to a PR to publish 0.0.0-canary-<timestamp> of the changed packages to the canary dist-tag (via the canary-publish composite action); it comments the exact install command and removes the label (one-shot). Gated to same-repo branches; applying the label requires Triage+. 0.0.0-canary can never become latest/beta.
  • Lerna + standard-version removed; root CHANGELOG.md frozen as an archive, history seeded into packages/widget/CHANGELOG.md.
  • .claude/changeset skill + /changeset command + read-only allowlist; CLAUDE.md ## Release rewritten.
  • An empty changeset so this infra-only PR ships no version bump (the first Version PR consumes it as a no-op).

OIDC

Publishing stays in publish.yaml (job release) via OIDC trusted publishing — main already publishes from this file, so no npmjs re-point is needed. Confirm provenance on the first real publish.

Before merge

  • WIDGET_LINEAR_RELEASE_ACCESS_KEY secret + the "Widget" Linear pipeline exist (CHECKOUT_… only needed once feat: add checkout widget #727 ships).
  • Optional: sandbox dry-run on a fork to exercise OIDC + Linear end-to-end.

On merge

The first push: main opens a "version packages" PR that only deletes the empty changeset; merging it runs release as a no-op (every package already at its current version → nothing published). The next feature PR carrying a real changeset produces the first real release.

chybisov added 30 commits May 27, 2026 12:57
Syncs published npm releases into the Linear "Widget" release pipeline.
Runs after "Release & Publish Beta" succeeds (workflow_run gated on
conclusion == success), so issues are attached only once packages are on
npm. Each release runs `sync` to attach merged issues and link the tag;
alpha/beta tags advance the stage via `update`, while only the stable tag
runs `complete`, which fires Linear's "release completion -> Done"
automation.
Addresses review feedback on #749:
- Restrict the job to successful runs originating from this repo
  (head_repository guard) to address the workflow_run privilege-
  escalation warning; the upstream publish workflow is tag-push/dispatch
  only, so this can't be triggered by untrusted contributors.
- Append `|| true` to tag resolution so a no-tag HEAD (e.g. a
  workflow_dispatch publish) cleanly skips instead of failing under
  `set -eo pipefail`.
- Add a concurrency group keyed to the release commit.
- Rename linear-release.yml -> .yaml to match repo convention.
Split publish.yaml into an orchestrator that composes three single-purpose
reusable workflows: github-release, npm-publish, and linear-release. The
Linear sync now runs as a `needs: npm-publish` job instead of a separate
`workflow_run` workflow, which:
- gates precisely on npm publish success (a failed GitHub Release job no
  longer strands the Linear sync) — fixes the coarse-gating issue
- removes the `workflow_run` trigger flagged by Aikido
- takes the tag from `github.ref_name` instead of re-discovering it via
  `git tag --points-at`
- replaces the brittle workflow-name coupling with a direct file reference

Secrets are scoped per sub-workflow: only Linear receives the pipeline
access key; GitHub Release uses the auto GITHUB_TOKEN; npm publish uses
OIDC provenance (no token).
- Add .changeset/config.json (github-compact changelog, public access,
  baseBranch main, updateInternalDependencies minor, ignore private pkgs)
- Add .changeset/README.md and .changeset/pre.json (pre mode, tag beta)
- Add @changesets/cli (^2.29.7) + @svitejs/changesets-changelog-github-compact devdeps
- Wire changeset:version / changeset:prepublish / changeset:publish scripts
  that run the existing per-package prerelease transform before publish
- Delete lerna.json (independent versioning now handled by Changesets)
- standard-version + lerna devdeps and release:* scripts removed in prior commit
- Add archive note to root CHANGELOG.md; per-package CHANGELOGs are now authoritative
…only

Other 8 publishable packages copy only README.md via build:prerelease;
tron used 'cpy ../../*.md' which also dragged in the root CHANGELOG.md.
Align it so changeset publish never bundles the (now archived) root changelog.
- Trigger on push to main (was: v* tag push); add workflow_dispatch
- concurrency: release-${{ github.ref }}, cancel-in-progress: false
- verify job: pnpm check / check:types / build (reuses pnpm-install action)
- changesets job: opens 'chore: version packages' PR, outputs hasChangesets
- release job (if hasChangesets == false): changeset:publish + createGithubReleases,
  NPM_CONFIG_PROVENANCE=true, id-token: write, outputs publishedPackages
- SHA-pinned changesets/action (v1.5.3) and checkout (v6.0.2)
- Static gated Linear jobs per anchor (Widget live, Checkout dormant)
  deriving version/channel/tag from publishedPackages via jq
- Drops softprops/action-gh-release (now changesets createGithubReleases)
- Inputs now (release_name, version, channel, release_tag); secret access_key
- version/channel are supplied by the caller (derived from publishedPackages),
  no longer parsed from a v* git tag inside this workflow
- release_tag carries the full per-package GitHub release tag for the link
- Keep sync (attach issues + link) / update+stage (alpha|beta) / complete (stable)
changesets/action now creates GitHub Releases (createGithubReleases) and
publishes to npm (changeset:publish), so these reusable workflows are obsolete.
The OIDC trusted-publisher binding moves from npm-publish.yaml to the release
job in publish.yaml and must be re-pointed on npmjs.com before first publish.
The Changesets release job pushes version + publish commits to main, each
re-triggering this S3 deploy. Add a concurrency group with cancel-in-progress
so back-to-back main pushes collapse onto a single, latest deploy.
changeset-check.yaml fails a PR when a publishable package changes but no
changeset is added. Two layers: 'changeset status --since=origin/<base>' plus
an independent git-diff guard scoped to publishable package globs. Docs-only
(*.md) and private packages (widget-embedded, widget-playground*) are exempt.
- Replace Lerna/standard-version flow with Changesets pre-PR rule
  (feat->minor, fix->patch, breaking->major; no cascade-only changesets)
- Document pre-mode (beta) and the hard rule: never 'changeset pre exit'
  until cutting stable 4.0.0
- Explain changeset:version/:prepublish/:publish and why the transform must
  run in prepublish; note workspace:* resolves to concrete at publish time
The root 'pnpm clean' script is broken under pnpm v11 (pre-existing, not
introduced by this migration) and is redundant: each package's 'build' script
already runs its own per-package clean before tsdown. Removing the leading
call unblocks changeset:prepublish without expanding scope to fix the root
script.
Add a contributor 'changeset' skill (+ /changeset command) so every PR that
touches a publishable package ships with a changeset, and a maintainer 'release'
skill documenting the Changesets + Linear pipeline, channels, and dist-tag safety.
Read-only command allowlist in settings.json. Uniform structure across all repos;
references tailored per repo.
This PR is infrastructure-only (Changesets adoption, CI rewrite, .claude tooling,
package `files`/prerelease tweaks) and intentionally ships no package release, so
it carries an empty changeset. This satisfies the new fail-closed changeset-check
gate that this same PR introduces. The published-tarball improvements (CHANGELOG.md
inclusion, stripped dev fields) take effect on the next real release.

Swap this for a patch changeset before merge if the team prefers those tarball
changes to ship immediately.
- changeset-check.yaml: drop `**` from pathspecs (use directory form). Modern
  git treats them equivalently for `git diff -- <pathspec>`, but `packages/x/`
  is the idiomatic 'directory and contents' pathspec and avoids ambiguity with
  globpathspec requirements.
- CLAUDE.md: drop the stale `pnpm clean &&` reference from the changeset:prepublish
  description (the clean step was removed in 7e01eef because root pnpm clean is
  broken under pnpm 11).
One-time operational checklist for re-pointing each publishable widget package's
npmjs Trusted Publisher entry from 'npm-publish.yaml' to 'publish.yaml' before
the first real publish from this branch. The publishing step was collapsed into
publish.yaml (job 'release' via changesets/action), so the workflow filename
that npmjs binds to changed; without updating it, OIDC silently fails on first
publish. Self-contained — covers the 9 affected packages, exact npmjs steps,
verification (pre-merge + post-publish), and rollback.
The release skill duplicated material already in CLAUDE.md ## Release. Under
the Changesets flow the maintainer's job is to merge the always-open
'chore: version packages' PR — there is no recurring maintainer task that
benefits from a skill triggering. The high-stakes operational rules (never
'pre exit' casually, anchor + skip policy, OIDC) already live in CLAUDE.md;
one-time op docs live in docs/release/.

Keeps:
  - .claude/skills/changeset/  (contributor skill — actively prevents the
    'forgot a changeset' failure mode on every PR)
  - .claude/commands/changeset.md
  - .claude/settings.json
Verified against widget's main publish.yaml: it already does the actual
'npm publish' directly (job 'publish', id-token: write), so the npmjs
trusted-publisher entries are bound to publish.yaml — the same filename our
migration publishes from. The ci/linear-release-sync branch's split into
reusable npm-publish.yaml never published anything (unmerged), so npmjs was
never configured for that filename. OIDC binds to {repo, workflow filename,
environment}, not job name, so renaming the job from 'publish' to 'release'
does not affect the binding.
…github

@svitejs/changesets-changelog-github-compact is marked deprecated
('unmaintained') on npm. The first-party @changesets/changelog-github is
actively maintained, ~10x the usage, and a drop-in replacement (same config
interface). The format is slightly more verbose ('Thanks @user!' credit per
entry) but adds proper contributor recognition in every release note.

No CHANGELOG re-render needed — we haven't shipped any generated entries yet.
Copy the repository-root CHANGELOG.md (the lerna + standard-version era
archive) into packages/widget/CHANGELOG.md as the historical baseline for
@lifi/widget. H1 swapped to the package name; archive note rewritten to explain
that pre-Changesets history is repo-wide and references multiple packages.

Changesets prepends new release entries between the H1 and the first
historical entry, so future releases stack cleanly on top while the
historical record is preserved at the bottom of the file (and remains
visible on the npm page).

The root CHANGELOG.md stays frozen as the in-tree historical archive
(unchanged by this commit).
Supply-chain hygiene pass over every external 'uses:' reference in the workflows
this PR creates or touches. Every action is now pinned to its commit SHA with a
'# vX.Y.Z' comment naming the resolved version. Bumps where a newer release was
available.

- changesets/action: v1.5.3 → v1.8.0 (commit-SHA-pinned)
- linear/linear-release-action: comment fixed from '# v0' (floating tag) to '# v0.14.0'
  (SHA was already correct — that floating tag currently points to v0.14.0; pin
  doesn't move, comment now accurately reflects the version)

Verified each SHA against the upstream repo's tag → commit mapping (using the
commit SHA, not the annotated-tag-object SHA — both forms resolve in Actions,
but pinning to the commit is immutable even if a tag is force-moved).
Was ^2.29.7 (caret already resolved to 2.31.0); update the declared range to
match the latest release explicitly. No behavior change — lockfile already
installed 2.31.0.
Add a `canary` job to publish.yaml: applying the `release-canary` label to a PR
publishes a throwaway 0.0.0-canary-<timestamp> build of the changed packages to
npm under the `canary` dist-tag, for sharing PR builds with other teams /
externally. The label is auto-removed after publish (one-shot; re-add to repeat).

This restores the pre-migration ability to publish prereleases from a PR branch
(previously `pnpm release:beta` + a pushed `v*-beta.N` tag) and keeps the SAME
trust boundary that flow had — a maintainer publishing unreviewed branch code via
OIDC — while being strictly safer (0.0.0-canary can never become `latest`).

Security controls (the job builds+publishes PR code with OIDC publish rights):
  * trigger is `pull_request: types:[labeled]` (never pull_request_target)
  * same-repo branches only — forks/external PRs can't trigger it
  * fail-closed check that the label-applier has write+ permission
  * isolated job: only id-token/contents/pull-requests perms; no AWS/CF/Linear secrets
The main-release chain is gated to non-PR events so it never runs on label events.

widget: widget is in pre mode, so the job exits pre mode in the throwaway CI
checkout (never committed/pushed) before snapshotting.
Document the release-canary label flow (0.0.0-canary-<ts> to the canary dist-tag),
the install command, and the trust-boundary guardrails for maintainers.
The canary trigger is intentionally open to anyone with write access to the repo
— the same trust population that could publish via the old 'v*-beta.N' tag-push
flow. Clarify the in-workflow check and docs accordingly:
  - permission gate now reads as admin|write (.permission maps maintain->write,
    triage->read), dropping the dead 'maintain' arm
  - step/comment/error wording: 'maintainer (write+)' -> 'write access'
  - CLAUDE.md canary note reworded to 'someone with write access'
No behavior change (the prior check already allowed admin+write); this is a
correctness-of-intent + messaging cleanup.
Switch the canary authorization check from the legacy .permission base-role
field to the granular .role_name (admin|maintain|write). Same endpoint, same
fully-automatic in-workflow check (no manual approval) — just the modern role
field. Custom write-granting roles would be added to the allowlist if introduced.
Simplify the canary job: remove the gh-api role check and the redundant
author_association clause. Applying a label already requires Triage+ on the repo
(external people / fork-PR authors cannot label), and the same-repo guard means
the published code was pushed by someone with Write access. GitHub has no
per-label permission control, so 'collaborators with Triage+' is the trigger
population — matching, and only slightly broader than, the old v*-beta.N
tag-push flow. Versions remain throwaway 0.0.0-canary. The job stays isolated
(id-token/contents/pull-requests only; no deploy/Linear secrets).
The install hint hardcoded the anchor package (npm i @anchor@<ver>), which was
wrong whenever a change didn't cascade up to the anchor — e.g. a provider-only
sdk PR (providers depend on @lifi/sdk, not vice-versa) or a widget-light-only
widget PR (widget-light is standalone). The named package@version was never
published in those cases.

Now the detect step records every non-private package bumped to a 0.0.0-canary
version (the exact publish set) to $RUNNER_TEMP/canary-pkgs.txt, and the comment
emits one 'npm i <name>@<version>' per published package via --body-file. The
detect + comment steps are now identical across all four repos.
chybisov added 5 commits May 29, 2026 14:55
Move the canary snapshot/publish/comment/label-removal steps into a local
composite action (.github/actions/canary-publish) for readability. The canary
job in publish.yaml is now thin (checkout → install → run the action). A
`pre_mode` input handles the ephemeral `changeset pre exit` for the pre-mode
repos (widget/sdk), so the action file is byte-identical across all four repos.

Composite — NOT a reusable workflow — so its steps run inside the calling job
and the npm OIDC trusted-publisher identity stays bound to publish.yaml; no extra
trusted-publisher registration needed. The release/version/Linear flow is
unchanged and still gated to non-PR events.
Adopt two further workflow hardenings we didn't already have
(the version-PR/publish split was already in place):

- permissions: {} at the workflow top level — deny-by-default; every job
  declares only what it needs. This also fixes sdk, which had no top-level
  permissions block (it inherited the repo default token scope). Added explicit
  contents: read to the verify jobs that relied on the old top-level default
  (sdk, bigmi).
- skip the pnpm store cache in privileged jobs (changesets / release / canary,
  plus explorer's deploy-prod) via a new cache input on the pnpm-install
  composite (default true). Prevents restoring a poisoned dependency cache into
  a context that holds publish (id-token) or write permissions. verify keeps the
  cache (read-only, runs on every push). Marginal under our write-access trust
  model, but it's the defense-in-depth best practice.

cancel-in-progress: false and per-job least-privilege were already in place.
…ect)

Two cleanups from a simplification review (no behavior change):
- Drop the pre_mode input; detect Changesets pre mode at runtime from
  .changeset/pre.json instead. Removes a dual source of truth (callers no longer
  assert pre_mode that could drift from the committed pre.json) and makes the
  composite self-configuring + every caller identical (no 'with:').
- Replace the per-file 3x-jq detect loop with a single jq pass over
  packages/*/package.json (verified equivalent output).
The per-package 'release:build': 'pnpm build' alias was only ever invoked by the
old root release:build (pnpm -r --parallel release:build), which the Changesets
migration deleted. Nothing calls it now — the new flow uses changeset:prepublish
(pnpm build + per-package build:prerelease). sdk and bigmi already dropped these
during the migration; this brings widget in line.
Trim verbose prose added during the migration (no logic change):
- canary trust-boundary comment block in publish.yaml: ~19 -> ~11 lines, keeping
  the Triage+/same-repo/isolated/throwaway rationale and the
  'NEVER pull_request_target' warning.
- canary-publish action description: drop step re-narration + the OIDC line (the
  OIDC-binding rationale stays at the publish.yaml call site); pre-mode comment 3 -> 2.
- pnpm-install cache input description trimmed to 2 lines.
- CLAUDE.md canary guardrails: drop the 'mirroring the old tag flow' clause.
- sdk linear-meta: trim filler + fix a stale CLAUDE.md cross-ref; explorer
  deploy-prod gating comment 5 -> 2 lines.
@chybisov chybisov marked this pull request as ready for review May 29, 2026 14:00
@chybisov chybisov force-pushed the ci/changesets-migration branch from a2be23d to a60c594 Compare May 29, 2026 14:42
@chybisov chybisov changed the base branch from ci/linear-release-sync to main May 29, 2026 14:47
Replace the bespoke changeset-check.yaml with the canonical changeset-bot
GitHub App (comments a changeset reminder on PRs). Hard CI enforcement was
never the real gate — nothing publishes until the maintainer-reviewed
'version packages' PR merges. CLAUDE.md + the changeset skill are updated to
describe the bot (a reminder, not a block).
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 29, 2026

🦋 Changeset detected

Latest commit: 01bb552

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 0 packages

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

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.

1 participant