-
Notifications
You must be signed in to change notification settings - Fork 257
ci(release): split gen1/gen2 changesets and publish workflows #6537
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
f5bf0cf
da240cc
01d91fb
ed10b60
9c937c0
aa210d7
9bd3209
3876201
4520f82
7d55399
c009f93
9f75102
b81b4b3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| name: Release branch lock | ||
| description: >- | ||
| Temporarily blocks PR merges to a branch for the duration of a release run, via a | ||
| synthetic required status check. No-ops with a warning if the branch has no protection | ||
| configured, so a missing/unreadable protection rule never fails the release itself. | ||
| inputs: | ||
| mode: | ||
| description: 'lock or unlock' | ||
| required: true | ||
| branch: | ||
| description: 'Branch to lock or unlock' | ||
| required: true | ||
| token: | ||
| description: 'Token with Administration:write on the branch protection settings' | ||
| required: true | ||
| runs: | ||
| using: 'composite' | ||
| steps: | ||
| - name: Toggle release lock | ||
| shell: bash | ||
| env: | ||
| GH_TOKEN: ${{ inputs.token }} | ||
| run: | | ||
| node "${{ github.action_path }}/../../scripts/toggle-release-lock.mjs" \ | ||
| "${{ inputs.mode }}" "${{ inputs.branch }}" | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,75 @@ | ||
| #!/usr/bin/env node | ||
|
|
||
| /** | ||
| * Copyright 2026 Adobe. All rights reserved. | ||
| * This file is licensed to you under the Apache License, Version 2.0 (the "License"); | ||
| * you may not use this file except in compliance with the License. You may obtain a copy | ||
| * of the License at http://www.apache.org/licenses/LICENSE-2.0 | ||
| * | ||
| * Unless required by applicable law or agreed to in writing, software distributed under | ||
| * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS | ||
| * OF ANY KIND, either express or implied. See the License for the specific language | ||
| * governing permissions and limitations under the License. | ||
| */ | ||
|
|
||
| /** | ||
| * Blocks (or unblocks) PR merges into a release branch for the duration of a publish run, | ||
| * by adding (or removing) a synthetic required status check that never reports success. | ||
| * Required status checks only gate the GitHub merge button/API - they do not block a | ||
| * direct `git push` from an actor with push access, so this does not interfere with the | ||
| * release job's own commit-and-push step. | ||
| * | ||
| * NOTE: classic branch protection only; repos on the newer rulesets API instead of | ||
| * classic protection will get a 404 here and this no-ops with a warning rather than | ||
| * failing the release. Upgrade to the rulesets API if/when this repo migrates to it. | ||
| */ | ||
|
|
||
| import { execSync } from 'child_process'; | ||
|
|
||
| const [, , mode, branch] = process.argv; | ||
| const LOCK_CONTEXT = 'release/in-progress'; | ||
| const repo = process.env.GITHUB_REPOSITORY; | ||
|
|
||
| if (!['lock', 'unlock'].includes(mode) || !branch) { | ||
| console.error('Usage: toggle-release-lock.mjs <lock|unlock> <branch>'); | ||
| process.exit(1); | ||
| } | ||
|
|
||
| function branchProtectionExists() { | ||
| try { | ||
| execSync(`gh api repos/${repo}/branches/${branch}/protection`, { | ||
| encoding: 'utf-8', | ||
| }); | ||
| return true; | ||
| } catch (err) { | ||
| console.warn( | ||
| `No branch protection found for '${branch}' (or this token can't read it) - skipping ${mode}. ` + | ||
| `Concurrent-merge protection during this release is not active for this branch. (${err.message})` | ||
| ); | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| if (!branchProtectionExists()) { | ||
| process.exit(0); | ||
| } | ||
|
|
||
| // Add/remove only this one context via the dedicated endpoint, rather than reading | ||
| // the whole protection object and PUTing it back - a whole-object PUT only sends the | ||
| // fields this script knows about, silently resetting every other configured | ||
| // protection setting (allow_force_pushes, required_linear_history, etc.) to its API | ||
| // default on every lock and unlock. | ||
| const method = mode === 'lock' ? 'POST' : 'DELETE'; | ||
| execSync( | ||
| `gh api repos/${repo}/branches/${branch}/protection/required_status_checks/contexts -X ${method} --input -`, | ||
| { | ||
| input: JSON.stringify({ contexts: [LOCK_CONTEXT] }), | ||
| encoding: 'utf-8', | ||
| } | ||
| ); | ||
|
|
||
| console.log( | ||
| `${mode === 'lock' ? 'Locked' : 'Unlocked'} '${branch}': required status check '${LOCK_CONTEXT}' ${ | ||
| mode === 'lock' ? 'added' : 'removed' | ||
| }.` | ||
| ); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,242 @@ | ||
| name: Publish Packages (2nd-gen) | ||
|
|
||
| on: | ||
| workflow_dispatch: | ||
| inputs: | ||
| tag: | ||
| description: 'NPM dist-tag for the planned release' | ||
| required: false | ||
| default: 'beta' | ||
| dry_run: | ||
| description: 'Version packages and report the diff, but do not publish or push' | ||
| type: boolean | ||
| required: false | ||
| default: false | ||
| pull_request: | ||
| types: [labeled, synchronize] | ||
| # Every push to main auto-publishes the `next` dist-tag: a continuous, throwaway snapshot | ||
| # of main (no commit back, no branch lock) so consumers can always pull the latest main | ||
| # build. Planned `beta` releases are cut manually via workflow_dispatch (pre-release mode, | ||
| # changelog, commit back to main). Per-step conditions below implement that split. | ||
| push: | ||
| branches: | ||
| - main | ||
|
|
||
| # Shares the exact group string with publish.yml (1st-gen) so the two release workflows | ||
| # stay mutually exclusive on any shared ref: on a push to main both compute | ||
| # publish-refs/heads/main, so one runs while the other queues. cancel-in-progress: false | ||
| # queues rather than cancels; no ordering guarantee. | ||
| concurrency: | ||
| group: publish-${{ github.ref }} | ||
| cancel-in-progress: false | ||
|
|
||
| jobs: | ||
| check-changesets: | ||
| runs-on: ubuntu-latest | ||
| # push to main -> auto `next` snapshot. workflow_dispatch -> planned `beta`, allowed | ||
| # only from main (a dispatch against any other ref is skipped). pull_request -> only | ||
| # via the snapshot-release label. | ||
| if: >- | ||
| github.event_name == 'push' || | ||
| (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main') || | ||
| (github.event_name == 'pull_request' && | ||
| contains(github.event.pull_request.labels.*.name, 'snapshot-release')) | ||
| outputs: | ||
| has_changesets: ${{ steps.check.outputs.has_changesets }} | ||
| steps: | ||
| - name: Checkout repository | ||
| uses: actions/checkout@v4 | ||
|
|
||
| - name: Check for 2nd-gen changesets | ||
| id: check | ||
| run: | | ||
| # Independent of 1st-gen in every trigger - this workflow never reads | ||
| # 1st-gen/.changeset/, including for its own snapshot-release PR path. | ||
| COUNT=$(ls -1 2nd-gen/.changeset/*.md 2>/dev/null | grep -v README | wc -l | tr -d ' ') | ||
| if [ "$COUNT" -eq 0 ]; then | ||
| echo "has_changesets=false" >> $GITHUB_OUTPUT | ||
| echo "No 2nd-gen changesets found - skipping publish" | ||
| else | ||
| echo "has_changesets=true" >> $GITHUB_OUTPUT | ||
| echo "Found $COUNT 2nd-gen changeset(s)" | ||
| fi | ||
|
|
||
| publish: | ||
| needs: check-changesets | ||
| if: needs.check-changesets.outputs.has_changesets == 'true' | ||
| runs-on: ubuntu-latest | ||
| environment: npm-publish | ||
| permissions: | ||
| contents: write # Required for git push (via RELEASE_BOT_TOKEN below) | ||
| env: | ||
| YARN_ENABLE_IMMUTABLE_INSTALLS: false | ||
| DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }} | ||
| steps: | ||
| # Locked before checkout via a raw gh api call (not the composite action - `uses:` | ||
| # doesn't support expressions, so it can't be pinned to github.sha, and a literal | ||
| # @main ref would 404 until this action is merged there; a raw call needs no repo | ||
| # checkout at all) so the race window is closed from the very start of the job, | ||
| # not just from after checkout + dependency install. | ||
| - name: Lock main during release | ||
| # Only the planned beta release writes to main, so only it needs the lock. | ||
| if: github.event_name == 'workflow_dispatch' && env.DRY_RUN != 'true' | ||
| env: | ||
| GH_TOKEN: ${{ secrets.RELEASE_BOT_TOKEN }} | ||
| run: | | ||
| gh api "repos/${{ github.repository }}/branches/main/protection/required_status_checks/contexts" \ | ||
| -X POST --input - <<< '{"contexts":["release/in-progress"]}' \ | ||
| || echo "No branch protection on main (or insufficient access) - skipping lock. Concurrent-merge protection during this release is not active." | ||
|
|
||
| # head.ref (no repository:) resolves against the base repo - fine for same-repo | ||
| # snapshot-release PRs, which is the only way this workflow's pull_request | ||
| # trigger fires; would need a fork-aware checkout if that ever changes. | ||
| - name: Checkout | ||
| uses: actions/checkout@v4 | ||
| with: | ||
| ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.ref || 'main' }} | ||
| fetch-depth: 0 | ||
|
|
||
| - name: Setup job and install dependencies | ||
| uses: ./.github/actions/setup-job | ||
|
|
||
| - name: Set Git identity | ||
| run: | | ||
| git config --global user.email "support+actions@github.com" | ||
| git config --global user.name "github-actions-bot" | ||
|
|
||
| - name: Determine release tag | ||
| id: extract-tag | ||
| env: | ||
| EVENT_NAME: ${{ github.event_name }} | ||
| INPUT_TAG: ${{ github.event.inputs.tag }} | ||
| run: | | ||
| if [ "$EVENT_NAME" == "pull_request" ]; then | ||
| # snapshot-release PRs: throwaway snapshot under the snapshot-test tag. | ||
| WORKFLOW_TAG="snapshot-test" | ||
| elif [ "$EVENT_NAME" == "push" ]; then | ||
| # every push to main: continuous throwaway snapshot under the next tag. | ||
| WORKFLOW_TAG="next" | ||
| else | ||
| # manual workflow_dispatch: planned pre-release, beta by default. | ||
| WORKFLOW_TAG="${INPUT_TAG:-beta}" | ||
| fi | ||
| echo "tag=$WORKFLOW_TAG" >> $GITHUB_OUTPUT | ||
| echo "Using npm tag: $WORKFLOW_TAG" | ||
|
|
||
| # Only the planned beta path (workflow_dispatch) enters persistent pre-release mode. | ||
| # push->next and PR->snapshot-test use --snapshot instead and never touch pre.json. | ||
| - name: Enter changesets pre-release mode | ||
| if: github.event_name == 'workflow_dispatch' && !hashFiles('2nd-gen/.changeset/pre.json') | ||
| env: | ||
| TAG: ${{ steps.extract-tag.outputs.tag }} | ||
| working-directory: 2nd-gen | ||
| run: yarn changeset pre enter $TAG | ||
|
|
||
| - name: Verify NPM authentication | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. should-fix — verifies nothing. This step writes
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added |
||
| id: npm-auth | ||
| # setup-node (in setup-job) already wrote an .npmrc that npm reads via | ||
| # NPM_CONFIG_USERCONFIG and that authenticates through ${NODE_AUTH_TOKEN}; | ||
| # just point that at the real token. Writing ~/.npmrc here is a no-op because | ||
| # the userconfig override wins. | ||
| env: | ||
| NODE_AUTH_TOKEN: ${{ secrets.ADOBE_BOT_NPM_TOKEN }} | ||
| run: | | ||
| npm whoami --registry=https://registry.npmjs.org | ||
| echo "✓ NPM authentication configured for 2nd-gen (Adobe namespace)" | ||
|
|
||
| - name: Build all packages | ||
| run: yarn build | ||
|
|
||
| - name: Version packages | ||
| env: | ||
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||
| EVENT_NAME: ${{ github.event_name }} | ||
| TAG: ${{ steps.extract-tag.outputs.tag }} | ||
| working-directory: 2nd-gen | ||
| run: | | ||
| if [ "$EVENT_NAME" == "workflow_dispatch" ]; then | ||
| # Planned beta: pre-release versioning (beta.N) against the persistent pre.json. | ||
| yarn changeset version | ||
| else | ||
| # push->next and PR->snapshot-test: throwaway snapshot version ({tag}.{datetime}), | ||
| # never entering the persistent pre-release state. | ||
| yarn changeset version --snapshot $TAG | ||
| fi | ||
|
|
||
| - name: Capture released versions | ||
| id: versions | ||
| run: | | ||
| CORE_VERSION=$(node -p "require('./2nd-gen/packages/core/package.json').version") | ||
| SWC_VERSION=$(node -p "require('./2nd-gen/packages/swc/package.json').version") | ||
| echo "core=$CORE_VERSION" >> $GITHUB_OUTPUT | ||
| echo "swc=$SWC_VERSION" >> $GITHUB_OUTPUT | ||
|
|
||
| - name: Report dry-run diff | ||
| if: env.DRY_RUN == 'true' | ||
| run: | | ||
| echo "## Dry run - 2nd-gen version diff" >> $GITHUB_STEP_SUMMARY | ||
| echo '```diff' >> $GITHUB_STEP_SUMMARY | ||
| git diff --stat -- 2nd-gen/ >> $GITHUB_STEP_SUMMARY | ||
| echo '```' >> $GITHUB_STEP_SUMMARY | ||
| echo "Dry run requested - no packages were published and main was not modified." >> $GITHUB_STEP_SUMMARY | ||
|
|
||
| - name: Refresh lockfile and rebuild | ||
| if: env.DRY_RUN != 'true' | ||
| run: | | ||
| yarn install --refresh-lockfile | ||
| yarn build | ||
|
|
||
| - name: Publish all packages | ||
| if: env.DRY_RUN != 'true' && steps.npm-auth.outcome == 'success' | ||
| env: | ||
| NODE_AUTH_TOKEN: ${{ secrets.ADOBE_BOT_NPM_TOKEN }} | ||
| TAG: ${{ steps.extract-tag.outputs.tag }} | ||
| working-directory: 2nd-gen | ||
| run: yarn changeset publish --no-git-tag --tag $TAG | ||
|
|
||
| - name: Commit and push changes | ||
| # Only the planned beta release commits version bumps back to main. | ||
| if: github.event_name == 'workflow_dispatch' && env.DRY_RUN != 'true' | ||
| env: | ||
| RELEASE_BOT_TOKEN: ${{ secrets.RELEASE_BOT_TOKEN }} | ||
| run: | | ||
| git add 2nd-gen yarn.lock | ||
| git commit --no-verify -m "chore: release 2nd-gen packages #publish" || echo "No changes to commit" | ||
| git remote set-url origin "https://x-access-token:${RELEASE_BOT_TOKEN}@github.com/${{ github.repository }}.git" | ||
| git pull --rebase origin main | ||
| git push origin HEAD:main | ||
|
|
||
| - name: Publish summary | ||
| if: always() | ||
| env: | ||
| TAG: ${{ steps.extract-tag.outputs.tag }} | ||
| EVENT_NAME: ${{ github.event_name }} | ||
| CORE_VERSION: ${{ steps.versions.outputs.core }} | ||
| SWC_VERSION: ${{ steps.versions.outputs.swc }} | ||
| AUTH_OUTCOME: ${{ steps.npm-auth.outcome }} | ||
| run: | | ||
| echo "## Publish summary (2nd-gen)" >> $GITHUB_STEP_SUMMARY | ||
| echo "" >> $GITHUB_STEP_SUMMARY | ||
| echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY | ||
| echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY | ||
| echo "| **Trigger** | \`${EVENT_NAME}\` |" >> $GITHUB_STEP_SUMMARY | ||
| echo "| **NPM tag** | \`${TAG}\` |" >> $GITHUB_STEP_SUMMARY | ||
| echo "| **Dry run** | \`${DRY_RUN}\` |" >> $GITHUB_STEP_SUMMARY | ||
| [ -n "$CORE_VERSION" ] && echo "| **@adobe/spectrum-wc-core** | \`${CORE_VERSION}\` |" >> $GITHUB_STEP_SUMMARY | ||
| [ -n "$SWC_VERSION" ] && echo "| **@adobe/spectrum-wc** | \`${SWC_VERSION}\` |" >> $GITHUB_STEP_SUMMARY | ||
|
|
||
| if [ "$DRY_RUN" == "true" ]; then | ||
| echo "🔎 Dry run only — nothing published or pushed" >> $GITHUB_STEP_SUMMARY | ||
| elif [ "$AUTH_OUTCOME" == "success" ]; then | ||
| echo "✅ Publish completed with tag \`${TAG}\`" >> $GITHUB_STEP_SUMMARY | ||
| else | ||
| echo "❌ Publish failed — NPM authentication did not succeed" >> $GITHUB_STEP_SUMMARY | ||
| fi | ||
|
|
||
| - name: Unlock main after release | ||
| if: always() && github.event_name == 'workflow_dispatch' && env.DRY_RUN != 'true' | ||
| uses: ./.github/actions/release-branch-lock | ||
| with: | ||
| mode: unlock | ||
| branch: main | ||
| token: ${{ secrets.RELEASE_BOT_TOKEN }} | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
should-fix: this description implies full coverage "for the duration of a release run," but both callers apply the lock only after checkout + dependency install, so a residual race window remains before the lock is active. Either document that caveat here or have callers lock pre-checkout via a direct
gh apicall.Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Resolved as a side effect of closing the lock-timing gap in both callers — the description's "for the duration of a release run" claim is now actually accurate, so no separate documented exception needed.