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 f9b589735..bcb381828 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,15 +1,23 @@ 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' - 'docs/**' - '.gitignore' - '.claude/**' + - '.github/**' + - 'scripts/**' permissions: id-token: write # Required for OIDC trusted publishing @@ -19,15 +27,29 @@ jobs: publish: name: Publish to NPM runs-on: ubuntu-latest - # Only run on the upstream repo, not forks - if: github.repository == 'less/less.js' - + # 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' && + ( + (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 @@ -57,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 @@ -137,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 cf87db3e8..d731d2deb 100755 --- a/scripts/bump-and-publish.js +++ b/scripts/bump-and-publish.js @@ -6,9 +6,17 @@ * 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 - * 5. Publishes all packages to NPM + * 3. Creates and pushes an annotated git tag + * 4. Publishes all packages to NPM + * + * 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'); @@ -318,17 +326,23 @@ function main() { console.log(` [DRY RUN] Would create tag: ${tagName}`); } - // Push commit and tag - console.log(`šŸ“¤ Pushing to ${branch}...`); - if (!dryRun) { - try { + // 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' }); - execSync(`git push origin "${tagName}"`, { cwd: ROOT_DIR, stdio: 'inherit' }); - } catch (e) { - console.log(`āš ļø Push failed, but continuing with publish...`); + } else { + console.log(` [DRY RUN] Would push to: origin ${branch}`); } + } + + console.log(`šŸ“¤ Pushing tag ${tagName}...`); + if (!dryRun) { + 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}`); } @@ -451,7 +465,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: 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); }