Skip to content

Commit 2958e9d

Browse files
feat: PR-based release flow for alpha; test suite proving all three release properties (#4431)
* Initial plan * fix: correct release automation for master merges and publishing - create-release-pr.yml: add set -euo pipefail; track whether a commit was created; skip push + gh pr create when no version changes (no-op safety - fixes the "no commits between head and base" failure). - scripts/bump-and-publish.js: on master, use the version already in package.json as-is (no auto-increment). Validate it is > NPM version. Skip updateAllVersions/git-add/git-commit on master so the published tag always points to the release PR merge commit on master, not to a local detached commit. Alpha behavior is unchanged. - Fix error message: on master say "Git tag was pushed" rather than "Version bump commit and tag were pushed". Co-authored-by: matthew-dean <414752+matthew-dean@users.noreply.github.com> * test: add release automation test suite (20 tests) Proves the three components of the release flow work correctly: - publish.yml if: conditions (6 scenarios) - create-release-pr.yml if: conditions (4 scenarios) - bump-and-publish.js master path: existing version, no commit, no push (4 tests) - bump-and-publish.js alpha path: auto-increment, commit, alpha tag (4 tests) - create-release-pr no-op safety: commit when needed, clean exit when not (2 tests) Run with: node scripts/test-release-automation.js or: npm run test:release (after pnpm install) Co-authored-by: matthew-dean <414752+matthew-dean@users.noreply.github.com> * plan: implement PR-based release flow for alpha branch Co-authored-by: matthew-dean <414752+matthew-dean@users.noreply.github.com> * feat: PR-based release flow for alpha branch (mirrors master) - create-release-pr.yml: listen on alpha push; compute alpha version increment (X.Y.Z-alpha.N → X.Y.Z-alpha.N+1); use branch-specific PR title/base/branch naming; update loop guards for both flavours - publish.yml: remove push:alpha trigger; add alpha to pull_request branches; update if: condition for alpha release PR title+base - bump-and-publish.js: remove auto-increment/commit/push for alpha; add getNpmAlphaVersion(); alpha now validates and publishes like master - test-release-automation.js: 34 tests covering new flows end-to-end Co-authored-by: matthew-dean <414752+matthew-dean@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: matthew-dean <414752+matthew-dean@users.noreply.github.com>
1 parent da51403 commit 2958e9d

5 files changed

Lines changed: 964 additions & 199 deletions

File tree

.github/workflows/create-release-pr.yml

Lines changed: 79 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
name: Create Release PR
22

3-
# When code lands on master (not a release PR merge itself), automatically
4-
# create or update a "chore: release vX.Y.Z" pull request that bumps the
3+
# When code lands on master or alpha (not a release PR merge itself),
4+
# automatically create or update a release pull request that bumps the
55
# version. Maintainers then merge that PR to trigger publishing.
6+
#
7+
# master → "chore: release vX.Y.Z" PR targets master
8+
# alpha → "chore: alpha release vX.Y.Z" PR targets alpha
69
on:
710
push:
811
branches:
912
- master
13+
- alpha
1014
# Only trigger for commits that touch package source files.
1115
paths:
1216
- 'packages/**'
@@ -20,11 +24,14 @@ jobs:
2024
name: Create or Update Release PR
2125
runs-on: ubuntu-latest
2226
# Skip if this push is itself the merge of a release PR (prevents an
23-
# infinite loop). We catch both squash-merged and regular-merged commits.
27+
# infinite loop). We catch both squash-merged and regular-merged commits
28+
# for both the master and alpha release PR title conventions.
2429
if: |
2530
github.repository == 'less/less.js' &&
2631
!contains(github.event.head_commit.message, 'chore: release v') &&
27-
!contains(github.event.head_commit.message, '/release-v')
32+
!contains(github.event.head_commit.message, 'chore: alpha release v') &&
33+
!contains(github.event.head_commit.message, '/release-v') &&
34+
!contains(github.event.head_commit.message, '/alpha-release-v')
2835
2936
steps:
3037
- name: Checkout
@@ -47,21 +54,46 @@ jobs:
4754
- name: Determine next version
4855
id: version
4956
run: |
57+
BRANCH="${{ github.ref_name }}"
5058
CURRENT=$(node -p "require('./packages/less/package.json').version")
51-
NPM_VERSION=$(npm view less version 2>/dev/null || echo "")
52-
NEXT=$(node -e "
53-
const semver = require('semver');
54-
const cur = process.argv[1];
55-
const npm = process.argv[2] || null;
56-
if (npm && semver.valid(cur) && semver.gt(cur, npm)) {
57-
process.stdout.write(cur);
58-
} else {
59-
const base = npm || cur;
60-
process.stdout.write(semver.inc(base, 'patch'));
61-
}
62-
" "$CURRENT" "$NPM_VERSION")
63-
echo "next_version=$NEXT" >> "$GITHUB_OUTPUT"
64-
echo "branch=chore/release-v$NEXT" >> "$GITHUB_OUTPUT"
59+
60+
if [ "$BRANCH" = "alpha" ]; then
61+
# Alpha: increment the alpha prerelease number.
62+
# X.Y.Z-alpha.N → X.Y.Z-alpha.(N+1)
63+
# If package.json doesn't carry an alpha version yet, bump the
64+
# major and start a fresh alpha.1 series.
65+
NEXT=$(node -e "
66+
const cur = process.argv[1];
67+
const m = cur.match(/^(\d+\.\d+\.\d+)-alpha\.(\d+)$/);
68+
if (m) {
69+
process.stdout.write(m[1] + '-alpha.' + (parseInt(m[2], 10) + 1));
70+
} else {
71+
const parts = cur.replace(/-.*/, '').split('.');
72+
const nextMajor = parseInt(parts[0], 10) + 1;
73+
process.stdout.write(nextMajor + '.0.0-alpha.1');
74+
}
75+
" "$CURRENT")
76+
echo "next_version=$NEXT" >> "$GITHUB_OUTPUT"
77+
echo "branch=chore/alpha-release-v$NEXT" >> "$GITHUB_OUTPUT"
78+
echo "release_base=alpha" >> "$GITHUB_OUTPUT"
79+
else
80+
# Master: patch-increment from the latest npm published version.
81+
NPM_VERSION=$(npm view less version 2>/dev/null || echo "")
82+
NEXT=$(node -e "
83+
const semver = require('semver');
84+
const cur = process.argv[1];
85+
const npm = process.argv[2] || null;
86+
if (npm && semver.valid(cur) && semver.gt(cur, npm)) {
87+
process.stdout.write(cur);
88+
} else {
89+
const base = npm || cur;
90+
process.stdout.write(semver.inc(base, 'patch'));
91+
}
92+
" "$CURRENT" "$NPM_VERSION")
93+
echo "next_version=$NEXT" >> "$GITHUB_OUTPUT"
94+
echo "branch=chore/release-v$NEXT" >> "$GITHUB_OUTPUT"
95+
echo "release_base=master" >> "$GITHUB_OUTPUT"
96+
fi
6597
6698
- name: Configure Git
6799
run: |
@@ -72,15 +104,21 @@ jobs:
72104
env:
73105
NEXT_VERSION: ${{ steps.version.outputs.next_version }}
74106
RELEASE_BRANCH: ${{ steps.version.outputs.branch }}
107+
RELEASE_BASE: ${{ steps.version.outputs.release_base }}
75108
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
76109
run: |
77-
TITLE="chore: release v${NEXT_VERSION}"
110+
set -euo pipefail
111+
if [ "$RELEASE_BASE" = "alpha" ]; then
112+
TITLE="chore: alpha release v${NEXT_VERSION}"
113+
else
114+
TITLE="chore: release v${NEXT_VERSION}"
115+
fi
78116
79-
# Create or reset the release branch off the latest master so it
117+
# Create or reset the release branch off the latest base branch so it
80118
# always includes all recent commits.
81119
if git ls-remote --exit-code origin "refs/heads/${RELEASE_BRANCH}" &>/dev/null; then
82120
git fetch origin "${RELEASE_BRANCH}"
83-
git checkout -B "${RELEASE_BRANCH}" origin/master
121+
git checkout -B "${RELEASE_BRANCH}" "origin/${RELEASE_BASE}"
84122
else
85123
git checkout -b "${RELEASE_BRANCH}"
86124
fi
@@ -101,10 +139,27 @@ jobs:
101139
"
102140
103141
git add package.json packages/*/package.json
142+
COMMITTED=false
104143
if git diff --cached --quiet; then
105144
echo "No version changes; branch is already at v${NEXT_VERSION}"
106145
else
107146
git commit -m "${TITLE}"
147+
COMMITTED=true
148+
fi
149+
150+
# If no new commit was created the release branch has no commits
151+
# ahead of master, so pushing it and trying to open a PR would fail
152+
# with "no commits between head and base". Instead, just report
153+
# whether an existing release PR is open and exit cleanly.
154+
if [ "$COMMITTED" = "false" ]; then
155+
EXISTING=$(gh pr list --head "${RELEASE_BRANCH}" --base "${RELEASE_BASE}" \
156+
--json number --jq '.[0].number' 2>/dev/null || echo "")
157+
if [ -n "${EXISTING}" ]; then
158+
echo "✅ No new changes; release PR #${EXISTING} already exists"
159+
else
160+
echo "✅ No version bump needed and no existing release PR; nothing to do"
161+
fi
162+
exit 0
108163
fi
109164
110165
# --force-with-lease refuses to overwrite if the remote has advanced
@@ -114,7 +169,7 @@ jobs:
114169
git push origin "${RELEASE_BRANCH}" --force-with-lease
115170
116171
# Open a PR if one doesn't already exist for this version.
117-
EXISTING=$(gh pr list --head "${RELEASE_BRANCH}" --base master \
172+
EXISTING=$(gh pr list --head "${RELEASE_BRANCH}" --base "${RELEASE_BASE}" \
118173
--json number --jq '.[0].number' 2>/dev/null || echo "")
119174
120175
if [ -z "${EXISTING}" ]; then
@@ -129,9 +184,9 @@ jobs:
129184
gh pr create \
130185
--title "${TITLE}" \
131186
--body "${BODY}" \
132-
--base master \
187+
--base "${RELEASE_BASE}" \
133188
--head "${RELEASE_BRANCH}"
134189
echo "✅ Created release PR for v${NEXT_VERSION}"
135190
else
136-
echo "✅ Release PR #${EXISTING} already exists; branch updated to include latest master commits"
191+
echo "✅ Release PR #${EXISTING} already exists; branch updated to include latest ${RELEASE_BASE} commits"
137192
fi

.github/workflows/publish.yml

Lines changed: 17 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,15 @@
11
name: Publish to NPM
22

33
on:
4-
# Master: publish when a "chore: release vX.Y.Z" pull request is merged.
5-
# The release PR is created automatically by create-release-pr.yml.
4+
# Publish when a release PR is merged:
5+
# master branch: "chore: release vX.Y.Z" PR → publishes latest
6+
# alpha branch: "chore: alpha release vX.Y.Z" PR → publishes alpha
7+
# Both release PRs are created automatically by create-release-pr.yml.
68
pull_request:
79
types: [closed]
810
branches:
911
- master
10-
# Alpha: publish on direct push to the alpha branch.
11-
push:
12-
branches:
1312
- alpha
14-
paths-ignore:
15-
- '**.md'
16-
- 'docs/**'
17-
- '.gitignore'
18-
- '.claude/**'
19-
- '.github/**'
20-
- 'scripts/**'
2113

2214
permissions:
2315
id-token: write # Required for OIDC trusted publishing
@@ -27,18 +19,17 @@ jobs:
2719
publish:
2820
name: Publish to NPM
2921
runs-on: ubuntu-latest
30-
# Master: only run when a release PR (title = "chore: release v*") is merged.
31-
# Alpha: only run on direct pushes; skip if it's a version-bump commit
32-
# (prevents the bump-and-publish script from triggering itself).
22+
# Only run when a release PR with the expected title is merged into master
23+
# or alpha. Any other PR close (or merge without the right title) is
24+
# silently skipped.
3325
if: |
3426
github.repository == 'less/less.js' &&
27+
github.event.pull_request.merged == true &&
3528
(
36-
(github.event_name == 'pull_request' &&
37-
github.event.pull_request.merged == true &&
29+
(github.event.pull_request.base.ref == 'master' &&
3830
startsWith(github.event.pull_request.title, 'chore: release v')) ||
39-
(github.event_name == 'push' &&
40-
github.ref_name == 'alpha' &&
41-
!startsWith(github.event.head_commit.message, 'chore: bump version to'))
31+
(github.event.pull_request.base.ref == 'alpha' &&
32+
startsWith(github.event.pull_request.title, 'chore: alpha release v'))
4233
)
4334
4435
steps:
@@ -47,9 +38,9 @@ jobs:
4738
with:
4839
fetch-depth: 0
4940
token: ${{ secrets.GITHUB_TOKEN }}
50-
# For PR events check out the base branch (master) post-merge so the
41+
# Check out the base branch (master or alpha) post-merge so the
5142
# version bump from the release PR is already present.
52-
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.ref || github.ref }}
43+
ref: ${{ github.event.pull_request.base.ref }}
5344

5445
- name: Install pnpm
5546
uses: pnpm/action-setup@v4
@@ -79,13 +70,8 @@ jobs:
7970
- name: Determine branch and tag type
8071
id: branch-info
8172
run: |
82-
# For PR events the branch is the PR's base (master); for push events
83-
# it is the pushed branch (alpha).
84-
if [ "${{ github.event_name }}" = "pull_request" ]; then
85-
BRANCH="${{ github.event.pull_request.base.ref }}"
86-
else
87-
BRANCH="${{ github.ref_name }}"
88-
fi
73+
# Always a pull_request event; base.ref is master or alpha.
74+
BRANCH="${{ github.event.pull_request.base.ref }}"
8975
echo "branch=$BRANCH" >> $GITHUB_OUTPUT
9076
if [ "$BRANCH" = "alpha" ]; then
9177
echo "is_alpha=true" >> $GITHUB_OUTPUT
@@ -165,8 +151,8 @@ jobs:
165151
- name: Bump version and publish
166152
id: publish
167153
env:
168-
# Use the branch name resolved by the branch-info step above rather
169-
# than repeating the PR-vs-push detection logic here.
154+
# Pass the resolved base branch name (master or alpha) so that
155+
# bump-and-publish.js knows which branch it is publishing for.
170156
GITHUB_REF_NAME: ${{ steps.branch-info.outputs.branch }}
171157
run: |
172158
pnpm run publish

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"changelog": "github-changes -o less -r less.js -a --only-pulls --use-commit-body -m \"(YYYY-MM-DD)\"",
1313
"test": "cd packages/less && npm test",
1414
"test:node": "cd packages/less && npm run test:node",
15+
"test:release": "node scripts/test-release-automation.js",
1516
"postinstall": "npx only-allow pnpm"
1617
},
1718
"author": "Alexis Sellier <self@cloudhead.net>",

0 commit comments

Comments
 (0)