diff --git a/.ai/skills/documentation/SKILL.md b/.ai/skills/documentation/SKILL.md index ddc4d3d1a2a..af47917c125 100644 --- a/.ai/skills/documentation/SKILL.md +++ b/.ai/skills/documentation/SKILL.md @@ -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) diff --git a/.circleci/config.yml b/.circleci/config.yml index 68ca70aff6f..a07aec5d8be 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -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) diff --git a/.github/actions/release-branch-lock/action.yml b/.github/actions/release-branch-lock/action.yml new file mode 100644 index 00000000000..07293179e12 --- /dev/null +++ b/.github/actions/release-branch-lock/action.yml @@ -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 }}" diff --git a/.github/scripts/toggle-release-lock.mjs b/.github/scripts/toggle-release-lock.mjs new file mode 100644 index 00000000000..864736b56fb --- /dev/null +++ b/.github/scripts/toggle-release-lock.mjs @@ -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 '); + 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' + }.` +); diff --git a/.github/workflows/publish-2nd-gen.yml b/.github/workflows/publish-2nd-gen.yml new file mode 100644 index 00000000000..d7d38513394 --- /dev/null +++ b/.github/workflows/publish-2nd-gen.yml @@ -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 + 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 }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c8bd031f91d..d7aedcf852a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,4 +1,4 @@ -name: Publish Packages +name: Publish Packages (1st-gen) on: workflow_dispatch: @@ -35,33 +35,20 @@ jobs: - name: Check for changesets id: check run: | - if [ -z "$(ls -A .changeset/*.md 2>/dev/null | grep -v README)" ]; then + # This workflow is 1st-gen only, always - independent of 2nd-gen in every + # trigger (push, manual dispatch, and snapshot-release PRs). 2nd-gen has its + # own changesets setup in 2nd-gen/.changeset/ and its own workflow, + # publish-2nd-gen.yml, including its own snapshot-release PR path. + GEN1_COUNT=$(ls -1 1st-gen/.changeset/*.md 2>/dev/null | grep -v README | wc -l | tr -d ' ') + + if [ "$GEN1_COUNT" -eq 0 ]; then echo "has_changesets=false" >> $GITHUB_OUTPUT echo "has_1st_gen_changesets=false" >> $GITHUB_OUTPUT echo "No changesets found - skipping publish" else echo "has_changesets=true" >> $GITHUB_OUTPUT - CHANGESET_COUNT=$(ls -1 .changeset/*.md 2>/dev/null | grep -v README | wc -l | tr -d ' ') - echo "Found $CHANGESET_COUNT changesets" - - # Check if any changeset mentions 1st-gen packages - # 1st-gen packages are @spectrum-web-components/* - HAS_1ST_GEN=false - for file in .changeset/*.md; do - if [ "$(basename "$file")" != "README.md" ]; then - if grep -qP "@spectrum-web-components/[a-z-]+" "$file"; then - HAS_1ST_GEN=true - break - fi - fi - done - - echo "has_1st_gen_changesets=$HAS_1ST_GEN" >> $GITHUB_OUTPUT - if [ "$HAS_1ST_GEN" = "true" ]; then - echo "Found 1st-gen changesets - React wrappers will be built and published" - else - echo "No 1st-gen changesets found - React wrappers will be skipped" - fi + echo "has_1st_gen_changesets=true" >> $GITHUB_OUTPUT + echo "Found $GEN1_COUNT 1st-gen changeset(s) - React wrappers will be built and published" fi publish: @@ -75,6 +62,21 @@ jobs: env: YARN_ENABLE_IMMUTABLE_INSTALLS: 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. Only main@latest ever locks; + # every other trigger (push to next, snapshot-test) never reaches tag == 'latest'. + - name: Lock main during release + if: github.event_name == 'workflow_dispatch' && github.event.inputs.tag == 'latest' + 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." + - name: Checkout repository uses: actions/checkout@v4 with: @@ -142,47 +144,41 @@ jobs: - name: Confirm build artifacts run: yarn workspace @spectrum-web-components/1st-gen build:confirm - - name: Version packages + - name: Version 1st-gen packages env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAG: ${{ steps.extract-tag.outputs.tag }} run: | if [ "$TAG" == "latest" ]; then yarn workspace @spectrum-web-components/1st-gen changelog:global - yarn changeset version + (cd 1st-gen && yarn changeset version) else - yarn changeset version --snapshot $TAG + (cd 1st-gen && 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") BUNDLE_VERSION=$(node -p "require('./1st-gen/tools/bundle/package.json').version") - SWC_VERSION=$(node -p "require('./2nd-gen/packages/swc/package.json').version") - echo "core=$CORE_VERSION" >> $GITHUB_OUTPUT echo "bundle=$BUNDLE_VERSION" >> $GITHUB_OUTPUT - echo "swc=$SWC_VERSION" >> $GITHUB_OUTPUT - name: Refresh lockfile and rebuild run: | yarn install --refresh-lockfile yarn build - - name: Publish all packages + - name: Publish 1st-gen packages if: steps.npm-auth.outcome == 'success' env: NODE_AUTH_TOKEN: ${{ secrets.ADOBE_BOT_NPM_TOKEN }} TAG: ${{ steps.extract-tag.outputs.tag }} run: | - # Changeset publishes all packages (1st-gen, core, and 2nd-gen) - # npm CLI automatically uses: - # - OIDC trusted publishing for 1st-gen packages (configured on npmjs.com) - # - Token authentication for 2nd-gen (from .npmrc) + # npm CLI automatically uses OIDC trusted publishing for 1st-gen packages + # (configured on npmjs.com). if [ "$TAG" == "latest" ]; then - yarn changeset publish --no-git-tag + (cd 1st-gen && yarn changeset publish --no-git-tag) else - yarn changeset publish --no-git-tag --tag $TAG + (cd 1st-gen && yarn changeset publish --no-git-tag --tag $TAG) fi - name: Build React wrappers @@ -205,7 +201,7 @@ jobs: local dir=$1 local max_attempts=3 local attempt=1 - + while [ $attempt -le $max_attempts ]; do echo "Publishing $dir (attempt $attempt/$max_attempts)..." if (cd "$dir" && $PUBLISH_CMD); then @@ -244,9 +240,7 @@ jobs: ACTOR: ${{ github.actor }} COMMIT_SHA: ${{ github.sha }} AUTH_OUTCOME: ${{ steps.npm-auth.outcome }} - CORE_VERSION: ${{ steps.versions.outputs.core }} BUNDLE_VERSION: ${{ steps.versions.outputs.bundle }} - SWC_VERSION: ${{ steps.versions.outputs.swc }} run: | echo "## Publish summary" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY @@ -275,14 +269,12 @@ jobs: echo "| **Commit** | \`${COMMIT_SHA}\` |" >> $GITHUB_STEP_SUMMARY fi - if [ -n "$CORE_VERSION" ] || [ -n "$BUNDLE_VERSION" ] || [ -n "$SWC_VERSION" ]; then + if [ -n "$BUNDLE_VERSION" ]; then echo "## Versions released" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "| Generation | Package | Version |" >> $GITHUB_STEP_SUMMARY echo "|------------|---------|---------|" >> $GITHUB_STEP_SUMMARY - [ -n "$CORE_VERSION" ] && echo "| Core | \`@adobe/spectrum-wc-core\` | \`${CORE_VERSION}\` |" >> $GITHUB_STEP_SUMMARY - [ -n "$BUNDLE_VERSION" ] && echo "| 1st gen | \`@spectrum-web-components/bundle\` | \`${BUNDLE_VERSION}\` |" >> $GITHUB_STEP_SUMMARY - [ -n "$SWC_VERSION" ] && echo "| 2nd gen | \`@adobe/spectrum-wc\` | \`${SWC_VERSION}\` |" >> $GITHUB_STEP_SUMMARY + echo "| 1st gen | \`@spectrum-web-components/bundle\` | \`${BUNDLE_VERSION}\` |" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY fi @@ -294,12 +286,23 @@ jobs: - name: Commit and push changes if: steps.extract-tag.outputs.tag == 'latest' + env: + RELEASE_BOT_TOKEN: ${{ secrets.RELEASE_BOT_TOKEN }} run: | - git add . + git add 1st-gen yarn.lock git commit --no-verify -m "chore: release 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 - name: Create git tag if: steps.extract-tag.outputs.tag == 'latest' run: node ./1st-gen/scripts/create-git-tag.js + + - name: Unlock main after release + if: always() && steps.extract-tag.outputs.tag == 'latest' + uses: ./.github/actions/release-branch-lock + with: + mode: unlock + branch: main + token: ${{ secrets.RELEASE_BOT_TOKEN }} diff --git a/.github/workflows/release-branch-unlock.yml b/.github/workflows/release-branch-unlock.yml new file mode 100644 index 00000000000..f2e6f1dbb43 --- /dev/null +++ b/.github/workflows/release-branch-unlock.yml @@ -0,0 +1,32 @@ +name: Force unlock release branch + +# Manual recovery for a stuck release lock. `if: always()` on the unlock step in +# publish.yml / publish-2nd-gen.yml covers a failed step, but not a cancelled run or a +# dead runner - in either of those cases the release/in-progress required status check +# is left on the branch forever, blocking every PR merge into it until removed by hand. +# Run this workflow manually to clear it. + +on: + workflow_dispatch: + inputs: + branch: + description: 'Branch to unlock' + type: choice + required: true + options: + - main + +jobs: + unlock: + runs-on: ubuntu-latest + environment: npm-publish + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Unlock + uses: ./.github/actions/release-branch-lock + with: + mode: unlock + branch: ${{ github.event.inputs.branch }} + token: ${{ secrets.RELEASE_BOT_TOKEN }} diff --git a/.changeset/README.md b/1st-gen/.changeset/README.md similarity index 88% rename from .changeset/README.md rename to 1st-gen/.changeset/README.md index ef01193317e..4ebcc914a9d 100644 --- a/.changeset/README.md +++ b/1st-gen/.changeset/README.md @@ -1,5 +1,7 @@ # Changesets +> **1st-gen only.** This folder holds changesets for `@spectrum-web-components/*` packages, released from `main`. It has its own independent `@changesets/cli` setup (this `README.md` + `config.json`), separate from 2nd-gen's. For `@adobe/spectrum-wc` / `@adobe/spectrum-wc-core` (2nd-gen) changes, use [`2nd-gen/.changeset/`](../../2nd-gen/.changeset/README.md) (`yarn changeset:2nd-gen`) instead. + Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works with multi-package repos, or single-package repos to help you version and publish your code. You can find the full documentation for it [in our repository](https://github.com/changesets/changesets) diff --git a/1st-gen/.changeset/config.json b/1st-gen/.changeset/config.json new file mode 100644 index 00000000000..ee36868e395 --- /dev/null +++ b/1st-gen/.changeset/config.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json", + "access": "public", + "baseBranch": "main", + "changelog": [ + "@changesets/changelog-github", + { + "disableThanks": true, + "repo": "adobe/spectrum-web-components" + } + ], + "commit": false, + "fixed": [["@spectrum-web-components/*"]], + "ignore": [], + "snapshot": { + "prereleaseTemplate": "{tag}.{datetime}", + "useCalculatedVersion": true + }, + "updateInternalDependencies": "patch" +} diff --git a/.changeset/quiet-owls-whisper.md b/1st-gen/.changeset/quiet-owls-whisper.md similarity index 100% rename from .changeset/quiet-owls-whisper.md rename to 1st-gen/.changeset/quiet-owls-whisper.md diff --git a/1st-gen/scripts/escape-changelog-tags.js b/1st-gen/scripts/escape-changelog-tags.js index 63d121615f6..d0b4a9a56e1 100644 --- a/1st-gen/scripts/escape-changelog-tags.js +++ b/1st-gen/scripts/escape-changelog-tags.js @@ -14,17 +14,20 @@ import { replaceInFile } from 'replace-in-file'; // make sure inline tags are escaped const tagOptions = { - files: '.changeset/*.md', + files: '{1st-gen,2nd-gen}/.changeset/*.md', from: /(?<=\n\s*-\s.*)`?(<\w+(-\w+)*[^>]*>)`?/g, to: '`$1`', }; try { const results = await replaceInFile(tagOptions); - console.log('Replaced unescaped tags in .changeset/*.md:', results); + console.log( + 'Replaced unescaped tags in {1st-gen,2nd-gen}/.changeset/*.md:', + results + ); } catch (error) { console.error( - 'Error occurred replacing unescaped tags in .changeset/*.md:', + 'Error occurred replacing unescaped tags in {1st-gen,2nd-gen}/.changeset/*.md:', error ); } diff --git a/1st-gen/scripts/test-changes.js b/1st-gen/scripts/test-changes.js index aa8e1e1936f..5369ed16077 100644 --- a/1st-gen/scripts/test-changes.js +++ b/1st-gen/scripts/test-changes.js @@ -27,7 +27,7 @@ const { browser = 'chrome' } = yargs(hideBin(process.argv)).argv; */ export const getChangedPackages = () => { let changedPackages = []; - const changesetDir = '../.changeset'; + const changesetDir = '.changeset'; try { // Check if .changeset directory exists diff --git a/1st-gen/scripts/update-global-changelog.js b/1st-gen/scripts/update-global-changelog.js index 5b167927de7..26b9f397098 100644 --- a/1st-gen/scripts/update-global-changelog.js +++ b/1st-gen/scripts/update-global-changelog.js @@ -103,7 +103,7 @@ function extractChanges(frontmatter, description, pattern, prefix = '') { * @returns {Promise} Object containing categorized changes for both 1st-gen and core */ async function processChangesets() { - const changesetDir = path.resolve(__dirname, '../../.changeset'); + const changesetDir = path.resolve(__dirname, '../.changeset'); // Use non-blocking I/O for directory read const files = await fsPromises.readdir(changesetDir); diff --git a/2nd-gen/.changeset/README.md b/2nd-gen/.changeset/README.md new file mode 100644 index 00000000000..3328e253f5b --- /dev/null +++ b/2nd-gen/.changeset/README.md @@ -0,0 +1,38 @@ +# Changesets (2nd-gen) + +This folder is an independent `@changesets/cli` setup for 2nd-gen packages only (`@adobe/spectrum-wc`, `@adobe/spectrum-wc-core`), with its own `config.json`. 1st-gen (`@spectrum-web-components/*`) has its own separate setup in [`1st-gen/.changeset/`](../../1st-gen/.changeset/README.md) — the two do not share a config, a folder, or a versioning run. + +## Why a separate setup + +1st-gen ships to `next`/`latest` from `main`. 2nd-gen ships to `beta` from `main` on its own cadence, using changesets pre-release mode. `@changesets/cli` resolves `.changeset/` relative to wherever it's invoked from, so each generation gets its own instance by living in its own package directory (`2nd-gen/`) rather than sharing one at the repo root. + +## Adding a changeset for a 2nd-gen change + +```bash +yarn changeset:2nd-gen +``` + +This runs `yarn changeset` from within `2nd-gen/`, so the file lands here automatically — no manual routing needed. Follow the usual prompts: pick the affected package(s) (`@adobe/spectrum-wc` and/or `@adobe/spectrum-wc-core`), choose `major`/`minor`/`patch`, and write a summary. + +## Important: `@adobe/spectrum-wc-core` and component updates + +When a change touches `@adobe/spectrum-wc-core`, include the corresponding `@adobe/spectrum-wc` bump in the same changeset so the change shows up in the component-facing changelog — core changes are internal and don't otherwise surface there. + +## Example changeset + +```markdown +--- +'@adobe/spectrum-wc-core': patch +'@adobe/spectrum-wc': minor +--- + +- **Added**: Added new variant `tertiary` to `` [#9999](https://github.com/adobe/spectrum-web-components/pull/9999) +``` + +## Publishing process + +1. Changesets accumulate here as gen2 PRs merge to `main`. +2. `publish-2nd-gen.yml` (triggered on push to `main`, or manually) runs changesets from within `2nd-gen/` in pre-release mode and publishes to the `beta` npm tag. +3. Consumed changesets are removed as part of that release commit on `main`. + +See [Changesets documentation](https://github.com/changesets/changesets) for details on the underlying tool. diff --git a/.changeset/close-button-2nd-gen-migration.md b/2nd-gen/.changeset/close-button-2nd-gen-migration.md similarity index 100% rename from .changeset/close-button-2nd-gen-migration.md rename to 2nd-gen/.changeset/close-button-2nd-gen-migration.md diff --git a/.changeset/color-handle-migration.md b/2nd-gen/.changeset/color-handle-migration.md similarity index 100% rename from .changeset/color-handle-migration.md rename to 2nd-gen/.changeset/color-handle-migration.md diff --git a/.changeset/color-loupe-adaptive-border.md b/2nd-gen/.changeset/color-loupe-adaptive-border.md similarity index 100% rename from .changeset/color-loupe-adaptive-border.md rename to 2nd-gen/.changeset/color-loupe-adaptive-border.md diff --git a/.changeset/config.json b/2nd-gen/.changeset/config.json similarity index 66% rename from .changeset/config.json rename to 2nd-gen/.changeset/config.json index 5af48256ab5..c059464688c 100644 --- a/.changeset/config.json +++ b/2nd-gen/.changeset/config.json @@ -10,17 +10,6 @@ } ], "commit": false, - "fixed": [ - [ - "@spectrum-web-components/*", - "!@spectrum-web-components/1st-gen", - "!@spectrum-web-components/2nd-gen" - ] - ], - "ignore": [ - "@spectrum-web-components/1st-gen", - "@spectrum-web-components/2nd-gen" - ], "linked": [["@adobe/spectrum-wc", "@adobe/spectrum-wc-core"]], "snapshot": { "prereleaseTemplate": "{tag}.{datetime}", diff --git a/.changeset/feat-live-selection-controller.md b/2nd-gen/.changeset/feat-live-selection-controller.md similarity index 100% rename from .changeset/feat-live-selection-controller.md rename to 2nd-gen/.changeset/feat-live-selection-controller.md diff --git a/.changeset/fix-global-link-css-import-path.md b/2nd-gen/.changeset/fix-global-link-css-import-path.md similarity index 100% rename from .changeset/fix-global-link-css-import-path.md rename to 2nd-gen/.changeset/fix-global-link-css-import-path.md diff --git a/.changeset/fix-illustrated-message-actions-slot-alignment.md b/2nd-gen/.changeset/fix-illustrated-message-actions-slot-alignment.md similarity index 100% rename from .changeset/fix-illustrated-message-actions-slot-alignment.md rename to 2nd-gen/.changeset/fix-illustrated-message-actions-slot-alignment.md diff --git a/.changeset/fix-popover-trigger-pointer-active-stuck.md b/2nd-gen/.changeset/fix-popover-trigger-pointer-active-stuck.md similarity index 100% rename from .changeset/fix-popover-trigger-pointer-active-stuck.md rename to 2nd-gen/.changeset/fix-popover-trigger-pointer-active-stuck.md diff --git a/.changeset/fix-slot-attribute-propagation-controller-optional-attributes.md b/2nd-gen/.changeset/fix-slot-attribute-propagation-controller-optional-attributes.md similarity index 100% rename from .changeset/fix-slot-attribute-propagation-controller-optional-attributes.md rename to 2nd-gen/.changeset/fix-slot-attribute-propagation-controller-optional-attributes.md diff --git a/.changeset/fix-tabs-keyboard-activation-default.md b/2nd-gen/.changeset/fix-tabs-keyboard-activation-default.md similarity index 100% rename from .changeset/fix-tabs-keyboard-activation-default.md rename to 2nd-gen/.changeset/fix-tabs-keyboard-activation-default.md diff --git a/.changeset/pending-reusable-primitives.md b/2nd-gen/.changeset/pending-reusable-primitives.md similarity index 100% rename from .changeset/pending-reusable-primitives.md rename to 2nd-gen/.changeset/pending-reusable-primitives.md diff --git a/.changeset/publish-custom-elements-manifest.md b/2nd-gen/.changeset/publish-custom-elements-manifest.md similarity index 100% rename from .changeset/publish-custom-elements-manifest.md rename to 2nd-gen/.changeset/publish-custom-elements-manifest.md diff --git a/.changeset/soft-ravens-think.md b/2nd-gen/.changeset/soft-ravens-think.md similarity index 100% rename from .changeset/soft-ravens-think.md rename to 2nd-gen/.changeset/soft-ravens-think.md diff --git a/.changeset/spicy-melon-parade.md b/2nd-gen/.changeset/spicy-melon-parade.md similarity index 100% rename from .changeset/spicy-melon-parade.md rename to 2nd-gen/.changeset/spicy-melon-parade.md diff --git a/.changeset/tabs-focusgroup-controller.md b/2nd-gen/.changeset/tabs-focusgroup-controller.md similarity index 100% rename from .changeset/tabs-focusgroup-controller.md rename to 2nd-gen/.changeset/tabs-focusgroup-controller.md diff --git a/2nd-gen/package.json b/2nd-gen/package.json index 5c8d5b9d963..5de50711881 100644 --- a/2nd-gen/package.json +++ b/2nd-gen/package.json @@ -39,6 +39,8 @@ "packages/*" ], "devDependencies": { + "@changesets/changelog-github": "0.5.1", + "@changesets/cli": "2.29.7", "@storybook/addon-vitest": "10.3.5", "eslint": "9.39.2", "eslint-plugin-simple-import-sort": "12.1.1", diff --git a/CONTRIBUTOR-DOCS/01_contributor-guides/06_releasing-swc.md b/CONTRIBUTOR-DOCS/01_contributor-guides/06_releasing-swc.md index 3d503a7506a..30758f129de 100644 --- a/CONTRIBUTOR-DOCS/01_contributor-guides/06_releasing-swc.md +++ b/CONTRIBUTOR-DOCS/01_contributor-guides/06_releasing-swc.md @@ -28,9 +28,18 @@ +> ⚠️ **This page is out of date and pending a rewrite.** It still describes a single combined release workflow. As of the gen1/gen2 release-architecture split, 1st-gen and 2nd-gen release from **two separate workflows** with their own changeset folders: +> +> | | Workflow | Changesets | Branch | Ships to | +> |---|---|---|---|---| +> | 1st-gen | `.github/workflows/publish.yml` | `1st-gen/.changeset/` | `main` | `next` / `latest` | +> | 2nd-gen | `.github/workflows/publish-2nd-gen.yml` | `2nd-gen/.changeset/` | `gen2-beta` | `beta` (pre-release mode) | +> +> The sections below (package groups, versioning strategy, release types) still describe the old single-workflow model and should not be relied on until this page is rewritten to match. + ## Overview -Releases are fully automated through a GitHub Actions workflow (`.github/workflows/publish-1st-gen.yml`). There is no manual command to run locally — you trigger the release from GitHub and the workflow handles building, versioning, and publishing. +Releases are fully automated through GitHub Actions workflows — see the table above for which workflow covers which generation. There is no manual command to run locally — you trigger the release from GitHub and the workflow handles building, versioning, and publishing. The workflow publishes four package groups: @@ -51,14 +60,15 @@ The workflow publishes four package groups: > For the 2nd-gen changeset format and how entries flow into the CHANGELOG, see the [Changelog strategy](15_changelog-strategy.md). -The workflow only publishes if there are pending changesets in `.changeset/*.md`. If no changesets exist, the publish job is skipped automatically. +Each workflow only publishes if there are pending changesets in its own folder — `1st-gen/.changeset/*.md` for `publish.yml`, `2nd-gen/.changeset/*.md` for `publish-2nd-gen.yml`. If no changesets exist for that generation, its publish job is skipped automatically. -To check what's pending, look at the `.changeset/` directory (exclude `README.md`). Each changeset file lists the packages it affects and the bump type (`patch`, `minor`, or `major`). +To check what's pending, look at the relevant `.changeset/` directory (exclude `README.md`). Each changeset file lists the packages it affects and the bump type (`patch`, `minor`, or `major`). **If changesets are missing for packages you expected to update**, add them before triggering the release: ```bash -yarn changeset +yarn changeset:1st-gen +yarn changeset:2nd-gen ``` Follow the prompts to select packages and bump type. @@ -67,11 +77,10 @@ Follow the prompts to select packages and bump type. ### Understand the versioning strategy -The `.changeset/config.json` defines how packages version together: +Each generation has its own `config.json` (`1st-gen/.changeset/config.json`, `2nd-gen/.changeset/config.json`) defining how its own packages version together: -- **Fixed group** – All `@spectrum-web-components/*` packages (except Core) always version together at the same number. -- **Linked group** – `@adobe/spectrum-wc` and `@adobe/spectrum-wc-core` receive the same bump type when either changes. -- **Ignored** – The workspace root packages (`@spectrum-web-components/1st-gen`, `@spectrum-web-components/2nd-gen`) are never published. +- **Fixed group** (1st-gen) – All `@spectrum-web-components/*` packages always version together at the same number. +- **Linked group** (2nd-gen) – `@adobe/spectrum-wc` and `@adobe/spectrum-wc-core` receive the same bump type when either changes. --- diff --git a/CONTRIBUTOR-DOCS/01_contributor-guides/15_changelog-strategy.md b/CONTRIBUTOR-DOCS/01_contributor-guides/15_changelog-strategy.md index e969efc9d77..9fe44d05d0e 100644 --- a/CONTRIBUTOR-DOCS/01_contributor-guides/15_changelog-strategy.md +++ b/CONTRIBUTOR-DOCS/01_contributor-guides/15_changelog-strategy.md @@ -42,14 +42,14 @@ That's it. Component name in backticks, em dash, consumer-facing description. Yo ## Writing a changeset -Run `yarn changeset` to start the interactive CLI. It will list every package in the monorepo. For 2nd-gen work, select one of these two packages: +Run `yarn changeset:2nd-gen` to start the interactive CLI, scoped to 2nd-gen's own changesets folder (`2nd-gen/.changeset/`). It will list 2nd-gen's packages; select one of these two: | Package | When to select | |---|---| | `@adobe/spectrum-wc` | Any component change (new component, feature, bug fix) | | `@adobe/spectrum-wc-core` | Changes to shared core logic (mixins, controllers, base classes) | -Skip all `@spectrum-web-components/*` 1st-gen packages — those follow a separate process. If your PR changes both core and a component, run `yarn changeset` twice and create one changeset for each package. +1st-gen (`@spectrum-web-components/*`) has its own separate changesets setup in `1st-gen/.changeset/` (`yarn changeset:1st-gen`) and follows a separate process. If your PR changes both core and a component, run `yarn changeset:2nd-gen` twice and create one changeset for each package. After selecting the package, choose a bump type, then write the body using the format above. @@ -116,7 +116,7 @@ Bump types follow [semantic versioning](https://semver.org/) — the version num ### Rules - **One changeset per PR** is the default. Most PRs touch a single component and need a single changeset. Create multiple changesets only when the PR contains changes with different bump types (e.g., a minor addition and a patch fix). -- **Select the right package** in the changeset frontmatter. The `linked` config in `.changeset/config.json` keeps `@adobe/spectrum-wc` and `@adobe/spectrum-wc-core` at the same version automatically — you only need to list the one you touched: +- **Select the right package** in the changeset frontmatter. The `linked` config in `2nd-gen/.changeset/config.json` keeps `@adobe/spectrum-wc` and `@adobe/spectrum-wc-core` at the same version automatically — you only need to list the one you touched: | What changed | Frontmatter | |---|---| @@ -169,6 +169,6 @@ Each entry is automatically prefixed with the PR link and commit reference by `@ ## How it works -`.changeset/config.json` uses `@changesets/changelog-github` with `disableThanks: true`. This is the standard changesets GitHub changelog generator — it auto-prepends the PR link and commit reference to each entry, groups entries by bump type (`### Minor Changes`, `### Patch Changes`), and handles version headings. The `disableThanks` option suppresses the `Thanks @author!` attribution so entries stay focused on the change itself. +`2nd-gen/.changeset/config.json` uses `@changesets/changelog-github` with `disableThanks: true`. This is the standard changesets GitHub changelog generator — it auto-prepends the PR link and commit reference to each entry, groups entries by bump type (`### Minor Changes`, `### Patch Changes`), and handles version headings. The `disableThanks` option suppresses the `Thanks @author!` attribution so entries stay focused on the change itself. No custom scripts are involved. The changeset body you write is preserved as-is; changesets handles all formatting and collation. diff --git a/lint-staged.config.js b/lint-staged.config.js index 7fc3a6d2767..ea0294dd608 100644 --- a/lint-staged.config.js +++ b/lint-staged.config.js @@ -28,7 +28,9 @@ export default { 'yarn install --refresh-lockfile', 'git add 1st-gen/tools/base/src/version.ts 2nd-gen/packages/core/element/version.ts yarn.lock', ], - '.changeset/*.md': ['node 1st-gen/scripts/escape-changelog-tags.js'], + '{1st-gen,2nd-gen}/.changeset/*.md': [ + 'node 1st-gen/scripts/escape-changelog-tags.js', + ], '!(*.css|*.ts)': [ 'prettier --cache --no-error-on-unmatched-pattern --ignore-unknown --log-level silent --write', ], diff --git a/package.json b/package.json index 61b121fcb5f..baee4a307b7 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,8 @@ "lint:eslint": "echo 'Linting with ESLint...' && eslint ${LINT_PATH:-.} --cache", "lint:prettier": "echo 'Linting with Prettier...' && prettier ${LINT_PATH:-.} --cache --check", "lint:styles": "echo 'Linting with Stylelint...' && stylelint '${LINT_PATH:+$LINT_PATH/**/*.css}${LINT_PATH:-**/*.css}' --cache --allow-empty-input", + "changeset:1st-gen": "cd 1st-gen && yarn changeset", + "changeset:2nd-gen": "cd 2nd-gen && yarn changeset", "postinstall": "husky || true", "publish": "node ./scripts/publish.js", "publish:snapshot": "node ./scripts/publish.js --tag snapshot", diff --git a/yarn.lock b/yarn.lock index 8c97fe53871..2062248edd6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6775,6 +6775,8 @@ __metadata: version: 0.0.0-use.local resolution: "@spectrum-web-components/2nd-gen@workspace:2nd-gen" dependencies: + "@changesets/changelog-github": "npm:0.5.1" + "@changesets/cli": "npm:2.29.7" "@storybook/addon-vitest": "npm:10.3.5" eslint: "npm:9.39.2" eslint-plugin-simple-import-sort: "npm:12.1.1"