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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 137 additions & 0 deletions .github/workflows/create-release-pr.yml
Original file line number Diff line number Diff line change
@@ -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}
Comment on lines +103 to +121

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
42 changes: 36 additions & 6 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
38 changes: 26 additions & 12 deletions scripts/bump-and-publish.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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 {
Comment on lines +329 to 345
console.log(` [DRY RUN] Would push to: origin ${branch}`);
console.log(` [DRY RUN] Would push tag: origin ${tagName}`);
}

Expand Down Expand Up @@ -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);
}
Expand Down
Loading