From d44f1f1055b3a221393b4c873c81b60ea8c1da01 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Mar 2026 17:50:46 +0000 Subject: [PATCH 1/6] Initial plan From 4c094022beec03ef9fb99668aab95fd487ec8a47 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Mar 2026 17:55:25 +0000 Subject: [PATCH 2/6] fix: prevent npm publish when GitHub push fails, skip workflow on version bump commits Co-authored-by: matthew-dean <414752+matthew-dean@users.noreply.github.com> --- .github/workflows/publish.yml | 9 +++++++-- scripts/bump-and-publish.js | 11 +++++------ 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index f9b589735..ae14b71e5 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -19,8 +19,13 @@ jobs: publish: name: Publish to NPM runs-on: ubuntu-latest - # Only run on the upstream repo, not forks - if: github.repository == 'less/less.js' + # Only run on the upstream repo, not forks. + # Also skip if the triggering commit is itself a version bump (prevents + # an infinite publish loop when the script pushes "chore: bump version to X" + # back to master and that push re-triggers this workflow). + if: | + github.repository == 'less/less.js' && + !startsWith(github.event.head_commit.message, 'chore: bump version to') steps: - name: Checkout code diff --git a/scripts/bump-and-publish.js b/scripts/bump-and-publish.js index cf87db3e8..b311bb2dc 100755 --- a/scripts/bump-and-publish.js +++ b/scripts/bump-and-publish.js @@ -319,14 +319,13 @@ function main() { } // Push commit and tag + // IMPORTANT: Push MUST succeed before publishing to NPM. + // If the push fails, we abort to prevent a state where NPM has the new + // version but GitHub has no matching tag or release. console.log(`šŸ“¤ Pushing to ${branch}...`); if (!dryRun) { - try { - execSync(`git push origin ${branch}`, { cwd: ROOT_DIR, stdio: 'inherit' }); - execSync(`git push origin "${tagName}"`, { cwd: ROOT_DIR, stdio: 'inherit' }); - } catch (e) { - console.log(`āš ļø Push failed, but continuing with publish...`); - } + execSync(`git push origin ${branch}`, { cwd: ROOT_DIR, stdio: 'inherit' }); + execSync(`git push origin "${tagName}"`, { cwd: ROOT_DIR, stdio: 'inherit' }); } else { console.log(` [DRY RUN] Would push to: origin ${branch}`); console.log(` [DRY RUN] Would push tag: origin ${tagName}`); From 8902b33863d9a5adebe739e5ae31f42cd981a87f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Mar 2026 19:29:32 +0000 Subject: [PATCH 3/6] fix: skip version-bump commit entirely, push only the tag to bypass branch protection Co-authored-by: matthew-dean <414752+matthew-dean@users.noreply.github.com> --- .github/workflows/publish.yml | 9 ++------ scripts/bump-and-publish.js | 43 ++++++++--------------------------- 2 files changed, 12 insertions(+), 40 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index ae14b71e5..f9b589735 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -19,13 +19,8 @@ jobs: publish: name: Publish to NPM runs-on: ubuntu-latest - # Only run on the upstream repo, not forks. - # Also skip if the triggering commit is itself a version bump (prevents - # an infinite publish loop when the script pushes "chore: bump version to X" - # back to master and that push re-triggers this workflow). - if: | - github.repository == 'less/less.js' && - !startsWith(github.event.head_commit.message, 'chore: bump version to') + # Only run on the upstream repo, not forks + if: github.repository == 'less/less.js' steps: - name: Checkout code diff --git a/scripts/bump-and-publish.js b/scripts/bump-and-publish.js index b311bb2dc..433fb8362 100755 --- a/scripts/bump-and-publish.js +++ b/scripts/bump-and-publish.js @@ -278,31 +278,11 @@ function main() { console.log(`šŸ“¦ Found ${publishable.length} publishable packages:`); publishable.forEach(pkg => console.log(` - ${pkg.name}`)); - // Stage changes - console.log(`šŸ“Œ Staging version changes...`); - if (!dryRun) { - execSync('git add package.json packages/*/package.json', { cwd: ROOT_DIR, stdio: 'inherit' }); - } else { - console.log(` [DRY RUN] Would stage: package.json packages/*/package.json`); - } - - // Commit - console.log(`šŸ’¾ Committing version bump...`); - if (!dryRun) { - try { - execSync(`git commit -m "chore: bump version to ${nextVersion}"`, { - cwd: ROOT_DIR, - stdio: 'inherit' - }); - } catch (e) { - // Commit might fail if nothing changed, that's okay - console.log(`āš ļø Commit skipped (no changes or already committed)`); - } - } else { - console.log(` [DRY RUN] Would commit: "chore: bump version to ${nextVersion}"`); - } - - // Create tag + // Create tag at the current HEAD (the actual code commit that triggered the + // workflow). We intentionally do NOT create a "chore: bump version" commit + // because the master branch has protection rules that prevent the GitHub + // Actions bot from pushing directly to it. Pushing a tag (refs/tags/*) is + // not subject to those branch-protection "require pull request" rules. const tagName = `v${nextVersion}`; console.log(`šŸ·ļø Creating git tag: ${tagName}...`); if (!dryRun) { @@ -318,16 +298,13 @@ function main() { console.log(` [DRY RUN] Would create tag: ${tagName}`); } - // Push commit and tag - // IMPORTANT: Push MUST succeed before publishing to NPM. - // If the push fails, we abort to prevent a state where NPM has the new - // version but GitHub has no matching tag or release. - console.log(`šŸ“¤ Pushing to ${branch}...`); + // Push the tag to GitHub BEFORE publishing to NPM. + // This ensures a GitHub tag/release always exists for every npm version. + // Tag pushes are not blocked by branch protection rules, unlike branch pushes. + console.log(`šŸ“¤ Pushing tag ${tagName} to origin...`); if (!dryRun) { - execSync(`git push origin ${branch}`, { cwd: ROOT_DIR, stdio: 'inherit' }); execSync(`git push origin "${tagName}"`, { cwd: ROOT_DIR, stdio: 'inherit' }); } else { - console.log(` [DRY RUN] Would push to: origin ${branch}`); console.log(` [DRY RUN] Would push tag: origin ${tagName}`); } @@ -450,7 +427,7 @@ function main() { publishErrors.forEach(({ name, error }) => { console.error(` - ${name}: ${error}`); }); - console.error(`\nāš ļø Note: Version bump and commit were successful.`); + console.error(`\nāš ļø Note: Tag was pushed but some packages failed to publish. You may need to publish them manually.`); console.error(` Some packages failed to publish. You may need to publish them manually.`); process.exit(1); } From f791453b5583a553d057b82494f3c4eb162f17f4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Mar 2026 20:18:21 +0000 Subject: [PATCH 4/6] fix: restore version-bump commit + push to master, add infinite-loop guard Co-authored-by: matthew-dean <414752+matthew-dean@users.noreply.github.com> --- .github/workflows/publish.yml | 9 ++++-- scripts/bump-and-publish.js | 54 +++++++++++++++++++++++++++-------- 2 files changed, 49 insertions(+), 14 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index f9b589735..ae14b71e5 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -19,8 +19,13 @@ jobs: publish: name: Publish to NPM runs-on: ubuntu-latest - # Only run on the upstream repo, not forks - if: github.repository == 'less/less.js' + # Only run on the upstream repo, not forks. + # Also skip if the triggering commit is itself a version bump (prevents + # an infinite publish loop when the script pushes "chore: bump version to X" + # back to master and that push re-triggers this workflow). + if: | + github.repository == 'less/less.js' && + !startsWith(github.event.head_commit.message, 'chore: bump version to') steps: - name: Checkout code diff --git a/scripts/bump-and-publish.js b/scripts/bump-and-publish.js index 433fb8362..7a0aa6130 100755 --- a/scripts/bump-and-publish.js +++ b/scripts/bump-and-publish.js @@ -6,9 +6,14 @@ * This script: * 1. Determines the next version (patch increment or explicit) * 2. Updates all package.json files to the same version - * 3. Creates a git tag - * 4. Commits version changes + * 3. Commits the version bump and pushes it to the branch + * 4. Creates and pushes a git tag * 5. Publishes all packages to NPM + * + * PREREQUISITE: The master branch must allow github-actions[bot] to push + * directly (branch protection → "Allow specified actors to bypass required + * pull requests" → add github-actions[bot]). Without that bypass, the branch + * push in step 3 will fail and npm publish will be blocked. */ const fs = require('fs'); @@ -278,11 +283,31 @@ function main() { console.log(`šŸ“¦ Found ${publishable.length} publishable packages:`); publishable.forEach(pkg => console.log(` - ${pkg.name}`)); - // Create tag at the current HEAD (the actual code commit that triggered the - // workflow). We intentionally do NOT create a "chore: bump version" commit - // because the master branch has protection rules that prevent the GitHub - // Actions bot from pushing directly to it. Pushing a tag (refs/tags/*) is - // not subject to those branch-protection "require pull request" rules. + // Stage changes + console.log(`šŸ“Œ Staging version changes...`); + if (!dryRun) { + execSync('git add package.json packages/*/package.json', { cwd: ROOT_DIR, stdio: 'inherit' }); + } else { + console.log(` [DRY RUN] Would stage: package.json packages/*/package.json`); + } + + // Commit + console.log(`šŸ’¾ Committing version bump...`); + if (!dryRun) { + try { + execSync(`git commit -m "chore: bump version to ${nextVersion}"`, { + cwd: ROOT_DIR, + stdio: 'inherit' + }); + } catch (e) { + // Commit might fail if nothing changed, that's okay + console.log(`āš ļø Commit skipped (no changes or already committed)`); + } + } else { + console.log(` [DRY RUN] Would commit: "chore: bump version to ${nextVersion}"`); + } + + // Create tag const tagName = `v${nextVersion}`; console.log(`šŸ·ļø Creating git tag: ${tagName}...`); if (!dryRun) { @@ -298,13 +323,18 @@ function main() { console.log(` [DRY RUN] Would create tag: ${tagName}`); } - // Push the tag to GitHub BEFORE publishing to NPM. - // This ensures a GitHub tag/release always exists for every npm version. - // Tag pushes are not blocked by branch protection rules, unlike branch pushes. - console.log(`šŸ“¤ Pushing tag ${tagName} to origin...`); + // Push the version-bump commit to the branch, then push the tag. + // Both pushes are fatal — if either fails, npm publish is blocked so that + // npm and GitHub never get out of sync. + // NOTE: The master branch must allow GitHub Actions to push directly + // (branch protection → "Allow specified actors to bypass required pull + // requests" → add github-actions[bot]). + console.log(`šŸ“¤ Pushing to ${branch}...`); if (!dryRun) { + execSync(`git push origin ${branch}`, { cwd: ROOT_DIR, stdio: 'inherit' }); execSync(`git push origin "${tagName}"`, { cwd: ROOT_DIR, stdio: 'inherit' }); } else { + console.log(` [DRY RUN] Would push to: origin ${branch}`); console.log(` [DRY RUN] Would push tag: origin ${tagName}`); } @@ -427,7 +457,7 @@ function main() { publishErrors.forEach(({ name, error }) => { console.error(` - ${name}: ${error}`); }); - console.error(`\nāš ļø Note: Tag was pushed but some packages failed to publish. You may need to publish them manually.`); + console.error(`\nāš ļø Note: Version bump commit and tag were pushed successfully.`); console.error(` Some packages failed to publish. You may need to publish them manually.`); process.exit(1); } From b4beeab1a2d0a7adb5c77404f5cd1526e9f3cc72 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Mar 2026 21:45:33 +0000 Subject: [PATCH 5/6] fix: exclude .github and scripts from publish workflow trigger Co-authored-by: matthew-dean <414752+matthew-dean@users.noreply.github.com> --- .github/workflows/publish.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index ae14b71e5..8a4623947 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -10,6 +10,8 @@ on: - 'docs/**' - '.gitignore' - '.claude/**' + - '.github/**' + - 'scripts/**' permissions: id-token: write # Required for OIDC trusted publishing From ee04ec46147f1e4f4b482e0a983bff7f659a8aaa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Mar 2026 22:02:13 +0000 Subject: [PATCH 6/6] feat: implement PR-based release flow for master Co-authored-by: matthew-dean <414752+matthew-dean@users.noreply.github.com> --- .github/workflows/create-release-pr.yml | 137 ++++++++++++++++++++++++ .github/workflows/publish.yml | 41 +++++-- scripts/bump-and-publish.js | 40 ++++--- 3 files changed, 193 insertions(+), 25 deletions(-) create mode 100644 .github/workflows/create-release-pr.yml diff --git a/.github/workflows/create-release-pr.yml b/.github/workflows/create-release-pr.yml new file mode 100644 index 000000000..b41ee6b09 --- /dev/null +++ b/.github/workflows/create-release-pr.yml @@ -0,0 +1,137 @@ +name: Create Release PR + +# When code lands on master (not a release PR merge itself), automatically +# create or update a "chore: release vX.Y.Z" pull request that bumps the +# version. Maintainers then merge that PR to trigger publishing. +on: + push: + branches: + - master + # Only trigger for commits that touch package source files. + paths: + - 'packages/**' + +permissions: + contents: write + pull-requests: write + +jobs: + create-release-pr: + name: Create or Update Release PR + runs-on: ubuntu-latest + # Skip if this push is itself the merge of a release PR (prevents an + # infinite loop). We catch both squash-merged and regular-merged commits. + if: | + github.repository == 'less/less.js' && + !contains(github.event.head_commit.message, 'chore: release v') && + !contains(github.event.head_commit.message, '/release-v') + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Install pnpm + uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 'lts/*' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Determine next version + id: version + run: | + CURRENT=$(node -p "require('./packages/less/package.json').version") + NPM_VERSION=$(npm view less version 2>/dev/null || echo "") + NEXT=$(node -e " + const semver = require('semver'); + const cur = process.argv[1]; + const npm = process.argv[2] || null; + if (npm && semver.valid(cur) && semver.gt(cur, npm)) { + process.stdout.write(cur); + } else { + const base = npm || cur; + process.stdout.write(semver.inc(base, 'patch')); + } + " "$CURRENT" "$NPM_VERSION") + echo "next_version=$NEXT" >> "$GITHUB_OUTPUT" + echo "branch=chore/release-v$NEXT" >> "$GITHUB_OUTPUT" + + - name: Configure Git + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "github-actions[bot]@users.noreply.github.com" + + - name: Create or update release branch and PR + env: + NEXT_VERSION: ${{ steps.version.outputs.next_version }} + RELEASE_BRANCH: ${{ steps.version.outputs.branch }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + TITLE="chore: release v${NEXT_VERSION}" + + # Create or reset the release branch off the latest master so it + # always includes all recent commits. + if git ls-remote --exit-code origin "refs/heads/${RELEASE_BRANCH}" &>/dev/null; then + git fetch origin "${RELEASE_BRANCH}" + git checkout -B "${RELEASE_BRANCH}" origin/master + else + git checkout -b "${RELEASE_BRANCH}" + fi + + # Bump version in all package.json files. + node -e " + const fs = require('fs'); + const version = process.env.NEXT_VERSION; + const dirs = fs.readdirSync('packages', { withFileTypes: true }) + .filter(d => d.isDirectory()) + .map(d => 'packages/' + d.name + '/package.json'); + for (const f of ['package.json', ...dirs].filter(f => fs.existsSync(f))) { + const pkg = JSON.parse(fs.readFileSync(f, 'utf8')); + if (!pkg.version) continue; + pkg.version = version; + fs.writeFileSync(f, JSON.stringify(pkg, null, '\t') + '\n'); + } + " + + git add package.json packages/*/package.json + if git diff --cached --quiet; then + echo "No version changes; branch is already at v${NEXT_VERSION}" + else + git commit -m "${TITLE}" + fi + + # --force-with-lease refuses to overwrite if the remote has advanced + # past what we fetched, which protects against concurrent workflow + # runs. This is intentional: if two code PRs land simultaneously the + # second run will fail-fast here and the release branch stays coherent. + git push origin "${RELEASE_BRANCH}" --force-with-lease + + # Open a PR if one doesn't already exist for this version. + EXISTING=$(gh pr list --head "${RELEASE_BRANCH}" --base master \ + --json number --jq '.[0].number' 2>/dev/null || echo "") + + if [ -z "${EXISTING}" ]; then + BODY="## Release v${NEXT_VERSION} + + This PR bumps the version to \`${NEXT_VERSION}\` and will trigger an npm publish when merged. + + **Before merging:** + - [ ] Update CHANGELOG.md with changes for this release + - [ ] Verify all CI checks pass" + + gh pr create \ + --title "${TITLE}" \ + --body "${BODY}" \ + --base master \ + --head "${RELEASE_BRANCH}" + echo "āœ… Created release PR for v${NEXT_VERSION}" + else + echo "āœ… Release PR #${EXISTING} already exists; branch updated to include latest master commits" + fi diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 8a4623947..bcb381828 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,9 +1,15 @@ name: Publish to NPM on: - push: + # Master: publish when a "chore: release vX.Y.Z" pull request is merged. + # The release PR is created automatically by create-release-pr.yml. + pull_request: + types: [closed] branches: - master + # Alpha: publish on direct push to the alpha branch. + push: + branches: - alpha paths-ignore: - '**.md' @@ -21,20 +27,29 @@ jobs: publish: name: Publish to NPM runs-on: ubuntu-latest - # Only run on the upstream repo, not forks. - # Also skip if the triggering commit is itself a version bump (prevents - # an infinite publish loop when the script pushes "chore: bump version to X" - # back to master and that push re-triggers this workflow). + # Master: only run when a release PR (title = "chore: release v*") is merged. + # Alpha: only run on direct pushes; skip if it's a version-bump commit + # (prevents the bump-and-publish script from triggering itself). if: | github.repository == 'less/less.js' && - !startsWith(github.event.head_commit.message, 'chore: bump version to') - + ( + (github.event_name == 'pull_request' && + github.event.pull_request.merged == true && + startsWith(github.event.pull_request.title, 'chore: release v')) || + (github.event_name == 'push' && + github.ref_name == 'alpha' && + !startsWith(github.event.head_commit.message, 'chore: bump version to')) + ) + steps: - name: Checkout code uses: actions/checkout@v4 with: fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} + # For PR events check out the base branch (master) post-merge so the + # version bump from the release PR is already present. + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.ref || github.ref }} - name: Install pnpm uses: pnpm/action-setup@v4 @@ -64,7 +79,13 @@ jobs: - name: Determine branch and tag type id: branch-info run: | - BRANCH="${{ github.ref_name }}" + # For PR events the branch is the PR's base (master); for push events + # it is the pushed branch (alpha). + if [ "${{ github.event_name }}" = "pull_request" ]; then + BRANCH="${{ github.event.pull_request.base.ref }}" + else + BRANCH="${{ github.ref_name }}" + fi echo "branch=$BRANCH" >> $GITHUB_OUTPUT if [ "$BRANCH" = "alpha" ]; then echo "is_alpha=true" >> $GITHUB_OUTPUT @@ -144,7 +165,9 @@ jobs: - name: Bump version and publish id: publish env: - GITHUB_REF_NAME: ${{ github.ref_name }} + # Use the branch name resolved by the branch-info step above rather + # than repeating the PR-vs-push detection logic here. + GITHUB_REF_NAME: ${{ steps.branch-info.outputs.branch }} run: | pnpm run publish diff --git a/scripts/bump-and-publish.js b/scripts/bump-and-publish.js index 7a0aa6130..d731d2deb 100755 --- a/scripts/bump-and-publish.js +++ b/scripts/bump-and-publish.js @@ -6,14 +6,17 @@ * This script: * 1. Determines the next version (patch increment or explicit) * 2. Updates all package.json files to the same version - * 3. Commits the version bump and pushes it to the branch - * 4. Creates and pushes a git tag - * 5. Publishes all packages to NPM + * 3. Creates and pushes an annotated git tag + * 4. Publishes all packages to NPM * - * PREREQUISITE: The master branch must allow github-actions[bot] to push - * directly (branch protection → "Allow specified actors to bypass required - * pull requests" → add github-actions[bot]). Without that bypass, the branch - * push in step 3 will fail and npm publish will be blocked. + * For master, the version-bump commit is NOT pushed here. Instead it arrives + * via the "chore: release vX.Y.Z" pull request created by create-release-pr.yml. + * Merging that PR triggers this script, at which point package.json already has + * the target version. Only the git tag is pushed — tag pushes are not subject + * to branch-protection "require pull request" rules. + * + * For the alpha branch, the traditional commit + branch-push flow is preserved + * because alpha does not use the PR-based release flow. */ const fs = require('fs'); @@ -323,18 +326,23 @@ function main() { console.log(` [DRY RUN] Would create tag: ${tagName}`); } - // Push the version-bump commit to the branch, then push the tag. - // Both pushes are fatal — if either fails, npm publish is blocked so that - // npm and GitHub never get out of sync. - // NOTE: The master branch must allow GitHub Actions to push directly - // (branch protection → "Allow specified actors to bypass required pull - // requests" → add github-actions[bot]). - console.log(`šŸ“¤ Pushing to ${branch}...`); + // For master the version-bump commit already lives in master (it came from + // the release PR). Only push the git tag — tag pushes bypass branch + // protection "require pull request" rules. + // For alpha (direct-push branch) we still push the bump commit to the branch. + if (!isMaster) { + console.log(`šŸ“¤ Pushing to ${branch}...`); + if (!dryRun) { + execSync(`git push origin ${branch}`, { cwd: ROOT_DIR, stdio: 'inherit' }); + } else { + console.log(` [DRY RUN] Would push to: origin ${branch}`); + } + } + + console.log(`šŸ“¤ Pushing tag ${tagName}...`); if (!dryRun) { - execSync(`git push origin ${branch}`, { cwd: ROOT_DIR, stdio: 'inherit' }); execSync(`git push origin "${tagName}"`, { cwd: ROOT_DIR, stdio: 'inherit' }); } else { - console.log(` [DRY RUN] Would push to: origin ${branch}`); console.log(` [DRY RUN] Would push tag: origin ${tagName}`); }