Skip to content

ci(release): split gen1/gen2 changesets and publish workflows - #6537

Open
blunteshwar wants to merge 13 commits into
mainfrom
blunteshwar/ci-multi-stream-release-split-swc-2315
Open

ci(release): split gen1/gen2 changesets and publish workflows#6537
blunteshwar wants to merge 13 commits into
mainfrom
blunteshwar/ci-multi-stream-release-split-swc-2315

Conversation

@blunteshwar

@blunteshwar blunteshwar commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Architecture research

Before this PR

Single publish.yml, single root .changeset/. One yarn changeset version run bumped every pending changeset regardless of generation; one yarn changeset publish shipped everything under one tag; final step committed
version bumps and pushed straight to main with the default GITHUB_TOKEN.

Constraints this created:

  1. One changeset folder = one release decision — gen1 and gen2 couldn't version/ship independently.
  2. Single dist-tag, single branch trigger — no way to route gen2 to a beta tag from its own branch.
  3. Direct push to main needed branch-protection to either not exist or allow Actions to bypass it.
  4. Race window: job checks out main once, publishes to npm against that snapshot, then rebases+pushes at the very end. A concurrent merge in that window could conflict the push — after npm publish already happened, so git and
    npm state could diverge.

Decisions made

  • Push-to-main: fine-grained bot PAT (RELEASE_BOT_TOKEN), added as a branch-protection bypass actor — not the changesets/action version-bump-PR pattern. Keeps the existing commit-and-push shape.
  • Race mitigation: a branch freeze, not a HEAD-check-and-abort guard. New release-branch-lock composite action adds a synthetic required status check (release/in-progress) to the target branch for the release's
    duration, blocking PR merges into it until unlocked. Required status checks only gate the merge button/API, not a direct git push, so it doesn't interfere with the release job's own push.

Changeset placement

Fully independent instances — 1st-gen/.changeset/ and 2nd-gen/.changeset/, no shared folder, each with its own config.json/README.md. Two findings while building this:

  • @changesets/cli has no --cwd flag (checked --help and the bundled source). The real mechanism is invoking it from inside the target directory (working-directory: in CI, cd locally) — it walks up to find the
    monorepo root for package resolution, but reads config/writes changesets relative to the invocation dir.
  • 1st-gen/package.json already declares its own nested workspaces field. Running changesets from inside it means package resolution never sees 2nd-gen's packages at all — they're structurally invisible, not just
    config-ignored. Had to strip dead ignore/fixed entries referencing out-of-scope packages, and add @changesets/cli + @changesets/changelog-github to 2nd-gen/package.json's own devDependencies (it had none — only root
    and 1st-gen did).

Verified locally: yarn changeset status from each directory resolves correctly against its own config and packages.

Current shape

  • publish.yml — 1st-gen only, every trigger, including its own snapshot-release PR path. Never touches 2nd-gen/.changeset/.
  • publish-2nd-gen.yml — fully independent counterpart: own pull_request trigger plus push-to-main (auto next) and workflow_dispatch (planned beta) triggers; see the trigger table below. This overrides SWC-2315's original "one workflow snapshots both
    gens" row — total independence was an explicit requirement, including for snapshots.
  • Fixed every other repo-root .changeset/ reference that would've silently broken: lint-staged.config.js, three 1st-gen/scripts/*.js changelog/test scripts, .circleci/config.yml's docs-only-change filter, two doc links.

Known follow-up (not done here)

scripts/publish.js (standalone local "unified" publish script) still assumes a root .changeset/ that no longer exists — not wired into CI, but broken if run locally. Needs a decision (split it or retire it), tracked
separately.

Description

MVP for a multi-stream release architecture: splits the single shared publish pipeline into two fully independent release streams, one per generation.

  • 1st-gen/.changeset/ and 2nd-gen/.changeset/ are two genuinely independent @changesets/cli instances (own config.json, own README.md, own pending changeset files) — not a shared root folder. @changesets/cli has no --cwd flag; each instance works by being invoked from inside its own directory (working-directory: in CI, cd locally). yarn changeset:1st-gen / yarn changeset:2nd-gen are thin wrappers for that.
  • publish.yml is 1st-gen only, in every trigger, including its own snapshot-release PR path — it never reads 2nd-gen/.changeset/ and never runs a 2nd-gen step.
  • publish-2nd-gen.yml is the fully independent counterpart: the fully independent counterpart, with three release streams (see the trigger table below): push to main auto-publishes a throwaway next snapshot, workflow_dispatch (from main only) cuts the planned beta pre-release, and a snapshot-release PR publishes snapshot-test. There is no separate gen2-beta branch; 2nd-gen releases from main, and its own dist-tags keep it distinct from 1st-gen's next/latest.
  • release-branch-lock composite action adds a synthetic required status check to the target branch for the duration of a real release, blocking PR merges into it until the release unlocks it. The lock step runs before checkout (via the action's full remote ref, since a local ./path reference needs the repo already checked out to resolve), so the race window is closed from the start of the job, not just narrowed after checkout + install.
  • release-branch-unlock.yml: manual workflow_dispatch recovery — if a release run is cancelled or the runner dies, if: always() doesn't fire and the lock is stuck; this clears it by hand.
  • The version-bump push (both workflows) goes through a fine-grained bot PAT (RELEASE_BOT_TOKEN) instead of the default GITHUB_TOKEN.
  • NPM auth is actually verified (npm whoami) before gating the publish step on it, rather than a step that always reports success.

Gen2 release triggers

publish-2nd-gen.yml publishes two dist-tags with different mechanics:

Trigger Dist-tag Mode Enters pre-release? Locks main Commits back to main
push to main next --snapshot (throwaway) no no no
workflow_dispatch (from main only) beta pre-release (beta.N) yes yes yes
pull_request + snapshot-release snapshot-test --snapshot (throwaway) no no no

next is a continuous, throwaway snapshot of main published on every merge, so consumers (e.g. the always-open Photoshop dry-run PR) can always pull the latest main build. beta is the planned, manually-cut release with a changelog and announcement. latest is intentionally not supported for 2nd-gen yet. A manual dispatch is restricted to main (a dispatch against any other ref is skipped).

Motivation and context

Gen1 and Gen2 are expected to diverge in release cadence, validation, and promotion strategy, but previously shared one changeset folder and one workflow — every real release run processed both gens together, forced a shared version decision, and pushed back to main directly with no protection against a concurrent merge landing mid-run. This is the SWC-2282 assessment's recommended fix, built out to the SWC-2315 acceptance criteria as a draft MVP — with one explicit deviation: total independence extends to snapshot releases too, so publish.yml never touches 2nd-gen even for snapshot-release PR testing (SWC-2315's original acceptance table had one workflow snapshot both gens; this PR does not implement that row).

Full architecture write-up, constraints, and the design decisions behind this shape live in research.md at the repo root (gitignored, not part of the diff — condensed version posted as a PR comment below).

Related issue(s)

  • SWC-2282
  • SWC-2315

Manual setup required before this can run for real (cannot be done from a PR)

  • Create a fine-grained PAT for a bot/service account (not a personal account) with Contents: read & write and Administration: read & write on this repo; add it as repo secret RELEASE_BOT_TOKEN in the npm-publish environment.
  • Add that bot identity as a bypass actor on main's branch protection so it can push directly.

Screenshots (if appropriate)

N/A — CI/release infrastructure only, no UI change.

Author's checklist

  • I have read the CONTRIBUTING and PULL_REQUESTS documents.
  • I have reviewed at the Accessibility Practices for this feature — N/A, no UI surface.
  • I have added automated tests to cover my changes — N/A, workflow YAML isn't unit-testable in this repo; validated via YAML/JSON parsing and manual review locally, real validation happens on first CI run.
  • I have included a well-written changeset if my change needs to be published — N/A, CI-only, no published package changed.
  • I have included updated documentation if my change required it (1st-gen/.changeset/README.md, 2nd-gen/.changeset/README.md, CONTRIBUTOR-DOCS path fixes).

Reviewer's checklist

  • Includes a GitHub Issue with appropriate flag or Jira ticket number without a link
  • Automated tests cover all use cases and follow best practices for writing
  • Validated on all supported browsers
  • All VRTs are approved before the author can update Golden Hash

Manual review test cases

  • 1st-gen changeset releases correctly, independent of 2nd-gen

    1. Trigger publish.yml via workflow_dispatch with tag=next against a branch with only 1st-gen/.changeset/*.md present
    2. Expect: publishes 1st-gen packages only; 2nd-gen/.changeset/ never read, never touched
  • Gen2 dry run reports without side effects

    1. Trigger publish-2nd-gen.yml via workflow_dispatch with dry_run=true
    2. Expect: step summary shows the version diff; nothing published to npm; main unchanged (no commit/push, no lock taken)
  • Each gen's snapshot-release PR path is independent

    1. Label a PR with snapshot-release while both 1st-gen/.changeset/ and 2nd-gen/.changeset/ have pending files
    2. Expect: both workflows run independentlypublish.yml snapshots 1st-gen only under snapshot-test; publish-2nd-gen.yml snapshots 2nd-gen only under snapshot-test, checked out from the PR's own head ref. Neither workflow reads the other gen's folder; neither commits/pushes on this path.
  • Branch lock closes the race window from before checkout

    1. Manually dispatch a tag=latest run on publish.yml (or a beta workflow_dispatch run on the 2nd-gen workflow) against a protected branch
    2. Expect: the release/in-progress required status check appears on the branch as the very first step (before checkout completes), stays for the run, and is removed once the job finishes, even if a later step fails
  • Stuck-lock recovery

    1. Cancel a release run after its lock step but before its unlock step
    2. Expect: release/in-progress remains on the branch; running release-branch-unlock.yml with that branch selected removes it

Device review

  • Did it pass in Desktop? (N/A — no UI)
  • Did it pass in (emulated) Mobile? (N/A — no UI)
  • Did it pass in (emulated) iPad? (N/A — no UI)

Accessibility testing checklist

N/A — this PR only changes CI/release workflow configuration; no component, page, or interactive surface is affected. No keyboard or screen reader testing applies.

Splits the shared publish pipeline into independent gen1 (main -> next/latest)
and gen2 (gen2-beta -> beta, pre-release mode) release streams per SWC-2315,
each with its own changeset folder so a version bump for one generation no
longer forces a decision about the other.

Introduces a fine-grained bot PAT (RELEASE_BOT_TOKEN, to be added as a repo
secret + branch-protection bypass actor) for the version-bump push, replacing
the default GITHUB_TOKEN. Adds a release-branch-lock composite action that
temporarily blocks PR merges to the target branch for the duration of a real
release run, closing the window where a concurrent merge could land between
this job's checkout and its final push-back.
@github-actions

Copy link
Copy Markdown
Contributor

📚 Branch Preview Links

🔍 Gen1 Visual Regression Test Results

When a visual regression test fails (or has previously failed while working on this branch), its results can be found in the following URLs:

Deployed to Azure Blob Storage: pr-6537

If the changes are expected, update the current_golden_images_cache hash in the circleci config to accept the new images. Instructions are included in that file.
If the changes are unexpected, you can investigate the cause of the differences and update the code accordingly.

@coveralls

coveralls commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

Coverage Report for CI Build 30333289161

Warning

Build has drifted: This PR's base is out of sync with its target branch, so coverage data may include unrelated changes.
Quick fix: rebase this PR. Learn more →

Coverage remained the same at 96.236%

Details

  • Coverage remained the same as the base build.
  • Patch coverage: No coverable lines changed in this PR.
  • No coverage regressions found.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 39207
Covered Lines: 37932
Line Coverage: 96.75%
Relevant Branches: 6460
Covered Branches: 6016
Branch Coverage: 93.13%
Branches in Coverage %: Yes
Coverage Strength: 461.61 hits per line

💛 - Coveralls

Moves from a root-level .changeset + .changeset-2nd-gen split (which needed
a copy-into-.changeset shim at release time, since @changesets/cli only ever
reads .changeset/ relative to wherever it's invoked) to two genuinely
independent changesets instances: 1st-gen/.changeset/ and 2nd-gen/.changeset/,
each with its own config.json, README, and now its own @changesets/cli
devDependency so `yarn changeset` resolves correctly from within either
directory. Removes the runtime copy-in/copy-out step from publish-2nd-gen.yml
entirely - it's no longer needed.

Also fixes every other repo-root reference to the old single .changeset/
path that would have silently broken or degraded (lint-staged, the 1st-gen
changelog/test-changed scripts, the CircleCI docs-only-change filter, and two
doc links), since 1st-gen's own nested `workspaces` field means changesets
invoked there can no longer see out-of-scope packages by design - those
ignore/fixed entries are dropped as dead weight rather than kept as unusable
safety nets.
@changeset-bot

changeset-bot Bot commented Jul 22, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: b81b4b3

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

Click here to learn what changesets are, and how to add one.

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

publish.yml no longer touches 2nd-gen at all, even for snapshot-release PRs -
it never reads 2nd-gen/.changeset/ and has no 2nd-gen version/publish steps.

publish-2nd-gen.yml gets its own pull_request (labeled/synchronize, gated to
snapshot-release) trigger and handles its own snapshot testing independently:
checks out the PR's own head ref, versions with --snapshot snapshot-test
(never touching the persistent pre-release state), publishes under
snapshot-test, and skips the branch lock and gen2-beta commit/push entirely
since a PR run never touches gen2-beta.
Replace the read-whole-protection-object-then-PUT-it-back approach with
GitHub's dedicated add/remove-contexts endpoints
(POST|DELETE .../protection/required_status_checks/contexts). The prior PUT
only round-tripped the fields this script knew about (required_status_checks,
enforce_admins, required_pull_request_reviews, restrictions), so any other
configured protection setting (allow_force_pushes, required_linear_history,
etc.) would silently reset to its API default on every lock and unlock.

Also renames action.yaml -> action.yml to match the repo's predominant
action/workflow file extension.
Rajdeepc

This comment was marked as duplicate.

@blunteshwar

Copy link
Copy Markdown
Contributor Author

The architecture is right — two genuinely independent changesets instances and no cross-publish; nice catch on the dedicated status-check endpoint in commit 4. Inline comments below. Three items that can't be anchored to a diff line:

  1. PR body + manual test case Make repo public #3 are stale. They say a snapshot-release PR makes publish.yml snapshot both gens; the final code has publish.yml doing 1st-gen only, with gen2 snapshots handled by publish-2nd-gen.yml's own pull_request trigger. Please update the description and test Make repo public #3.
  2. Contributor docs still reference the deleted root .changeset/: CONTRIBUTOR-DOCS/01_contributor-guides/06_releasing-swc.md (lines 54, 56, 70) and CONTRIBUTOR-DOCS/01_contributor-guides/15_changelog-strategy.md (lines 119, 172). .ai/skills/documentation/SKILL.md was updated but these were missed.
  3. research.md is gitignored, so the architecture writeup referenced for review context isn't visible on the PR — please paste it into a comment.

Overall: design is sound; blockers are operational safety (stuck lock) not architecture.

Addressed all three:

  1. PR body / test 3 stale — confirmed. Will push a corrected description once these fixes are committed.
  2. Contributor docs15_changelog-strategy.md fixed (both flagged lines, plus one more I found while in
    there: its "run yarn changeset" instruction would now error outright with no root .changeset/ to resolve —
    changed to yarn changeset:2nd-gen). 06_releasing-swc.md is more broadly stale than the 3 lines suggested —
    it describes a single workflow (publish-1st-gen.yml, which never existed) publishing
    1st-gen+core+2nd-gen+React together. Patching just those 3 lines would leave a doc that's locally correct but
    structurally still wrong, so I added an "out of date, pending rewrite" banner with a correct summary table
    instead, and fixed the 3 lines under it. Full rewrite is real work — tracking as a separate docs-phase follow-up
    rather than folding it in here.
  3. research.md — pasting in a separate comment since it's gitignored and not visible on the diff.

@blunteshwar
blunteshwar requested a review from Rajdeepc July 27, 2026 11:40
@blunteshwar

Copy link
Copy Markdown
Contributor Author

Architecture research

Before this PR

Single publish.yml, single root .changeset/. One yarn changeset version run bumped every pending changeset regardless of generation; one yarn changeset publish shipped everything under one tag; final step committed
version bumps and pushed straight to main with the default GITHUB_TOKEN.

Constraints this created:

  1. One changeset folder = one release decision — gen1 and gen2 couldn't version/ship independently.
  2. Single dist-tag, single branch trigger — no way to route gen2 to a beta tag from its own branch.
  3. Direct push to main needed branch-protection to either not exist or allow Actions to bypass it.
  4. Race window: job checks out main once, publishes to npm against that snapshot, then rebases+pushes at the very end. A concurrent merge in that window could conflict the push — after npm publish already happened, so git and
    npm state could diverge.

Decisions made

  • Push-to-main: fine-grained bot PAT (RELEASE_BOT_TOKEN), added as a branch-protection bypass actor — not the changesets/action version-bump-PR pattern. Keeps the existing commit-and-push shape.
  • Race mitigation: a branch freeze, not a HEAD-check-and-abort guard. New release-branch-lock composite action adds a synthetic required status check (release/in-progress) to the target branch for the release's
    duration, blocking PR merges into it until unlocked. Required status checks only gate the merge button/API, not a direct git push, so it doesn't interfere with the release job's own push.

Changeset placement

Fully independent instances — 1st-gen/.changeset/ and 2nd-gen/.changeset/, no shared folder, each with its own config.json/README.md. Two findings while building this:

  • @changesets/cli has no --cwd flag (checked --help and the bundled source). The real mechanism is invoking it from inside the target directory (working-directory: in CI, cd locally) — it walks up to find the
    monorepo root for package resolution, but reads config/writes changesets relative to the invocation dir.
  • 1st-gen/package.json already declares its own nested workspaces field. Running changesets from inside it means package resolution never sees 2nd-gen's packages at all — they're structurally invisible, not just
    config-ignored. Had to strip dead ignore/fixed entries referencing out-of-scope packages, and add @changesets/cli + @changesets/changelog-github to 2nd-gen/package.json's own devDependencies (it had none — only root
    and 1st-gen did).

Verified locally: yarn changeset status from each directory resolves correctly against its own config and packages.

Current shape

  • publish.yml — 1st-gen only, every trigger, including its own snapshot-release PR path. Never touches 2nd-gen/.changeset/.
  • publish-2nd-gen.yml — fully independent counterpart: own pull_request trigger, gen2-beta branch, beta tag, changesets pre-release mode, dry_run input. This overrides SWC-2315's original "one workflow snapshots both
    gens" row — total independence was an explicit requirement, including for snapshots.
  • Fixed every other repo-root .changeset/ reference that would've silently broken: lint-staged.config.js, three 1st-gen/scripts/*.js changelog/test scripts, .circleci/config.yml's docs-only-change filter, two doc links.

Known follow-up (not done here)

scripts/publish.js (standalone local "unified" publish script) still assumes a root .changeset/ that no longer exists — not wired into CI, but broken if run locally. Needs a decision (split it or retire it), tracked
separately.

@blunteshwar
blunteshwar marked this pull request as ready for review July 27, 2026 11:46
@blunteshwar
blunteshwar requested a review from a team as a code owner July 27, 2026 11:46
@main doesn't have .github/actions/release-branch-lock yet - it only exists
on this unmerged branch, so every pre-checkout lock step hard-failed with
"Can't find action.yml" (seen in run 30274425726). Pin to ${{ github.sha }}
instead, which resolves the action from whatever commit is actually running:
works now while testing on this branch, and continues to work after merge.
uses: doesn't support any expression context, including github - so pinning
to ${{ github.sha }} was invalid syntax, not just a wrong ref (confirmed by
the workflow validator: "Unrecognized named-value: 'github'"). And a literal
@main reference 404s since this action doesn't exist on main yet.

Drop the composite-action self-reference for the pre-checkout lock step
entirely and inline the gh api call directly - a raw run: step needs no
uses: and no repo checkout, so there's no ref to get wrong. Post-checkout
steps (unlock in both workflows, both lock/unlock in release-branch-unlock.yml)
keep using the composite action via its local ./path reference, which is fine
once checkout has happened.
@changesets/changelog-github (2nd-gen's changelog generator, same as
1st-gen's) needs GITHUB_TOKEN to fetch PR/commit info for changelog entries.
publish.yml's equivalent step already sets it; publish-2nd-gen.yml's
"Version packages" step didn't, so `yarn changeset version` would fail with
"Please create a GitHub personal access token ... and add it as the
GITHUB_TOKEN environment variable" - reproduced locally before this fix,
confirmed resolved after (2.0.0-beta.2 -> 2.0.0-beta.3, CHANGELOG.md and
package.json updated correctly for both 2nd-gen packages).
The Verify NPM authentication step wrote ~/.npmrc, but setup-node sets
NPM_CONFIG_USERCONFIG so npm reads its own temp .npmrc instead, which
authenticates through ${NODE_AUTH_TOKEN}. That token was never set here,
so npm whoami got the placeholder and returned 401. Set NODE_AUTH_TOKEN to
ADOBE_BOT_NPM_TOKEN and drop the no-op ~/.npmrc write.
2nd-gen now releases from main on its own cadence (still beta tag,
changesets pre-release mode). Retarget every gen2-beta reference to main:
push trigger, branch lock/unlock, checkout ref, commit/push target, the
changeset baseBranch, the force-unlock branch choice, and the changeset
README. No gen2-beta branch is involved anymore.
2nd-gen now publishes two dist-tags with different mechanics:
- push to main -> next: continuous throwaway snapshot (changeset version
  --snapshot next, publish --tag next). No pre-release mode, no branch lock,
  nothing committed back to main - a rolling build of main for consumers
  (e.g. the Photoshop dry-run PR) to pull the latest changes.
- workflow_dispatch (main only) -> beta: planned pre-release. Enters
  changesets pre-release mode (beta.N), locks main, versions + changelog,
  commits the bump back to main.
- pull_request + snapshot-release label -> snapshot-test (unchanged).

A manual dispatch is restricted to main (github.ref guard); off-main
dispatches are skipped. latest is intentionally not supported yet.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants