Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .ai/skills/documentation/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,4 +266,5 @@ Data | Data
- [In-product word list](https://spectrum.adobe.com/page/in-product-word-list/)
- [Writing for errors](https://spectrum.adobe.com/page/writing-for-errors/)
- [Writing for onboarding](https://spectrum.adobe.com/page/writing-for-onboarding/)
- [Writing a changeset](https://github.com/adobe/spectrum-web-components/blob/main/.changeset/README.md)
- [Writing a changeset (1st-gen)](https://github.com/adobe/spectrum-web-components/blob/main/1st-gen/.changeset/README.md)
- [Writing a changeset (2nd-gen)](https://github.com/adobe/spectrum-web-components/blob/main/2nd-gen/.changeset/README.md)
2 changes: 1 addition & 1 deletion .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ commands:
# Paths that never affect either gen's tests: contributor docs, AI
# rules/skills, changeset files, linter config, scripts, root-level
# markdown files, and the license file.
DOCS_PATTERN="^(CONTRIBUTOR-DOCS/|\.ai/|\.changeset/|linters/|scripts/|[^/]+\.md$|LICENSE$)"
DOCS_PATTERN="^(CONTRIBUTOR-DOCS/|\.ai/|(1st-gen|2nd-gen)/\.changeset/|linters/|scripts/|[^/]+\.md$|LICENSE$)"

# Strip other-gen files and docs-only files; what remains is relevant.
RELEVANT=$(echo "$CHANGED" | grep -v "^${EXCLUDE}" | grep -vE "${DOCS_PATTERN}" || true)
Expand Down
25 changes: 25 additions & 0 deletions .github/actions/release-branch-lock/action.yml
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

Copy link
Copy Markdown
Contributor

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 api call.

@blunteshwar blunteshwar Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

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.

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 }}"
75 changes: 75 additions & 0 deletions .github/scripts/toggle-release-lock.mjs
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'
}.`
);
242 changes: 242 additions & 0 deletions .github/workflows/publish-2nd-gen.yml
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

should-fix — verifies nothing. This step writes ~/.npmrc and echoes success, so steps.npm-auth.outcome == 'success' is always true and gates nothing downstream (1st-gen actually checks for the OIDC token). Either add a real check (e.g. npm whoami --registry=https://registry.npmjs.org) or rename the step so it doesn't imply a guarantee it doesn't provide.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added npm whoami --registry=https://registry.npmjs.org right after writing .npmrc. Step now actually fails on a bad/missing token instead of always reporting success.

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 }}
Loading
Loading