Skip to content

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

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

ci: migrate release pipeline to Changesets (+ Linear SDK sync, .claude tooling)#388
chybisov wants to merge 24 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.

What changed

  • Changesets@changesets/cli@^2.31.0 + @changesets/changelog-github@^0.7.0; .changeset/config.json (independent versioning across @lifi/sdk + the 5 providers; updateInternalDependencies: minor so providers cascade automatically when the sdk range moves).
  • Pre-beta.changeset/pre.json (tag: beta) so the 4.x line publishes under @beta while latest stays on v3 (3.16.3). Never pre exit except to deliberately cut stable 4.0.0.
  • publish.yaml rewrittenverify (reuses tests.yaml) → changesets (the "version packages" PR) → release (publish + GitHub Releases) → linear-release. 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; single static anchor on @lifi/sdk → "SDK". A provider-only cycle (no sdk bump) is intentionally skipped — no fallback anchor.
  • 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). In pre-mode --snapshot is disallowed, so the job runs changeset pre exit in the throwaway CI checkout only (never committed). Gated to same-repo branches; applying the label requires Triage+. 0.0.0-canary can never become latest/beta.
  • Lerna + standard-version removed.
  • .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).

@lifi/types / @lifi/data-types are external pinned deps outside Changesets' scope — keep bumping them by hand.

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

  • LINEAR_RELEASE_ACCESS_KEY secret + the "SDK" Linear pipeline exist.

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 (nothing to publish). The next feature PR carrying a real changeset produces the first real release.

chybisov added 22 commits May 27, 2026 17:12
- Add @changesets/cli + @svitejs/changesets-changelog-github-compact devDeps
- Add .changeset/{config.json,README.md,pre.json} (pre-mode beta, independent versioning)
- Replace lockstep release:* scripts with changeset:{version,prepublish,publish}
- Fix per-package build:prerelease cpy glob (README.md only, no CHANGELOG clobber)
- Delete lerna.json, standard-version config block, orphaned scripts/prepublishOnly.js
- Freeze root CHANGELOG.md as pre-cutover archive (per-package changelogs going forward)
- Rewrite publish.yaml to the Changesets model: verify -> changesets (version PR) ->
  release (changeset publish + GitHub Releases, OIDC id-token) -> linear sync.
  Keep filename for npmjs trusted-publisher OIDC binding. Drop tag trigger and
  softprops/action-gh-release; push:main + concurrency(cancel-in-progress:false).
- Add reusable linear-release.yaml (release_name, version, channel, access_key);
  single static anchor @lifi/sdk -> 'SDK'; sync/update/complete per channel.
- Add changeset-check.yaml: fail-closed PR gate requiring a changeset when a
  publishable package source changes; docs/chore-only exempt.
- tests.yaml: add workflow_call so publish.yaml can reuse it as the verify gate.
- 6 packages (was 5; add tron); document hub-and-spoke workspace:* deps
- Per-PR changeset rule (feat/fix/breaking -> minor/patch/major; skip docs/chore;
  don't author cascade-only dependents)
- Pre-mode (beta) blocker: never 'pre exit' until cutting stable 4.0.0
- Pipeline, root scripts, publish transform, Linear anchor SKIP policy
- @lifi/types + @lifi/data-types are external pinned deps -> manual bumps
- adopt widget's generic reusable linear-release.yaml (release_name, version,
  channel, release_tag)
- linear-meta now outputs full + stripped version + channel; Linear release is
  keyed on the marketing X.Y.Z and deep-links the @lifi/sdk@<full> GitHub tag
- include CHANGELOG.md in each package's npm tarball (files array)
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.

Also narrow the .gitignore .claude/ rule to keep settings.local.json + worktrees/
ignored while tracking the shared skills/commands/settings.json.
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: use three-dot (`$BASE_SHA...$HEAD_SHA`) diff so an
  out-of-date PR doesn't pick up unrelated commits from main. Two-dot can
  mis-attribute mainline changes to the PR diff.
- publish.yaml: drop redundant `needs.release.result == 'success' &&` from the
  linear-meta job's `if`. A skipped/failed release already skips downstream jobs
  by default; the explicit check was inconsistent with the other three repos.
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
…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/sdk/CHANGELOG.md as the historical baseline for
@lifi/sdk. 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' to '# v0.14.0'
- pnpm-install composite: bump pnpm/action-setup v5.0.0 → v6.0.8 + actions/setup-node
  v6.3.0 → v6.4.0 (both SHA-pinned)

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.

sdk: sdk 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.
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).
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:01
Describe the pipeline on its own terms (Changesets) rather than by
reference to another project's adoption of it. No behavior change.
@chybisov chybisov force-pushed the ci/changesets-migration branch from bb4da5a to b010425 Compare May 29, 2026 14:42
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: a5e46b3

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