Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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.
*
* ponytail: classic branch protection only; repos on the newer rulesets API instead of

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.

nit: ponytail: reads like a personal TODO marker — use a conventional NOTE:/TODO: so the caveat is greppable by others.

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.

Done!

* 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'
}.`
);
209 changes: 209 additions & 0 deletions .github/workflows/publish-2nd-gen.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
name: Publish Packages (2nd-gen)

on:
workflow_dispatch:
inputs:
tag:
description: 'NPM dist-tag'
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]
push:
branches:
- gen2-beta

concurrency:
group: publish-2nd-gen-${{ github.ref }}
cancel-in-progress: false

jobs:
check-changesets:
runs-on: ubuntu-latest
if: >-
github.event_name == 'workflow_dispatch' ||
github.event_name == 'push' ||
(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 }}

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.

nit: dry_run only populates on workflow_dispatch; on push/pull_request this is '', so the Publish summary prints Dry run | blank. Consider defaulting to 'false'.

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.

Fixed: DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }}

steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.ref || 'gen2-beta' }}

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.

nit: head.ref with no repository: resolves the branch name against the base repo — works for same-repo snapshot-release PRs, breaks for forks. Fine if snapshot-release is internal-only; worth a one-line comment saying so.

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 a one-line comment above the checkout step documenting that this is same-repo-only. Not building fork support for this — snapshot-release is internal-only.

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
# PRs with the snapshot-release label always use snapshot-test, same as
# 1st-gen's snapshot-release path - a separate, throwaway run either way.
WORKFLOW_TAG="snapshot-test"
else
WORKFLOW_TAG="${INPUT_TAG:-beta}"
fi
echo "tag=$WORKFLOW_TAG" >> $GITHUB_OUTPUT
echo "Using npm tag: $WORKFLOW_TAG"

- name: Lock gen2-beta during release

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 — race window not closed. The lock runs only after checkout + setup-job (dependency install), which is minutes after checkout. The window this is meant to close (checkout → push-back) is narrowed, not closed — a merge can still land before the lock applies. To fully close it, apply the lock as the first step via a direct gh api call (no working tree needed) before checkout.

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.

Moved the lock to the first step, before checkout. Referencing the composite action via its full remote path (adobe/spectrum-web-components/.github/actions/release-branch-lock@main) instead of ./local-path — a local-path reference needs the repo already checked out to resolve, a remote owner/repo/path one doesn't. Window's genuinely closed now, not just narrowed.

if: github.event_name != 'pull_request' && env.DRY_RUN != 'true'
uses: ./.github/actions/release-branch-lock
with:
mode: lock
branch: gen2-beta
token: ${{ secrets.RELEASE_BOT_TOKEN }}

- name: Enter changesets pre-release mode
if: github.event_name != 'pull_request' && !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
env:
NPM_TOKEN: ${{ secrets.ADOBE_BOT_NPM_TOKEN }}
run: |
echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > ~/.npmrc
echo "✓ NPM authentication configured for 2nd-gen (Adobe namespace)"

- name: Build all packages
run: yarn build

- name: Version packages
env:
EVENT_NAME: ${{ github.event_name }}
TAG: ${{ steps.extract-tag.outputs.tag }}
working-directory: 2nd-gen
run: |
if [ "$EVENT_NAME" == "pull_request" ]; then
# Throwaway snapshot version for PR testing only - never enters/touches
# the persistent pre-release (beta.N) state used by real gen2-beta releases.
yarn changeset version --snapshot $TAG
else
yarn changeset version
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 gen2-beta 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
if: github.event_name != 'pull_request' && env.DRY_RUN != 'true'
env:
RELEASE_BOT_TOKEN: ${{ secrets.RELEASE_BOT_TOKEN }}
run: |
git add .

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.

nit: git add . is broad for a push to a protected branch — scope to the changeset/version/changelog paths so an unexpected untracked artifact can't be committed to gen2-beta.

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.

Scoped to git add 2nd-gen yarn.lock — covers everything changeset version and the changelog step actually touch on this branch.

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 gen2-beta
git push origin HEAD:gen2-beta

- 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 gen2-beta after release
if: always() && github.event_name != 'pull_request' && env.DRY_RUN != 'true'

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.

blocking — stuck-lock recovery. if: always() covers a step failure but not job cancellation or runner death. If this job is cancelled/superseded or the runner dies after the lock step, release/in-progress stays on gen2-beta permanently and blocks all PR merges until someone deletes it by hand. Needs a recovery path before merge: a manually-dispatchable force-unlock job or a scheduled sweeper that clears a stale release/in-progress.

@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.

Added .github/workflows/release-branch-unlock.yml — manual workflow_dispatch, branch choice input (main/gen2-beta), gated behind the same npm-publish environment, calls the composite action's unlock mode directly.
Went with manual over a scheduled sweeper since a stuck lock should be a rare edge case; easy to add a sweeper later if that assumption turns out wrong

uses: ./.github/actions/release-branch-lock
with:
mode: unlock
branch: gen2-beta
token: ${{ secrets.RELEASE_BOT_TOKEN }}
Loading
Loading