diff --git a/.github/workflows/create-release-pr.yml b/.github/workflows/create-release-pr.yml index b41ee6b09..4dbe932dd 100644 --- a/.github/workflows/create-release-pr.yml +++ b/.github/workflows/create-release-pr.yml @@ -1,12 +1,16 @@ 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 +# When code lands on master or alpha (not a release PR merge itself), +# automatically create or update a release pull request that bumps the # version. Maintainers then merge that PR to trigger publishing. +# +# master → "chore: release vX.Y.Z" PR targets master +# alpha → "chore: alpha release vX.Y.Z" PR targets alpha on: push: branches: - master + - alpha # Only trigger for commits that touch package source files. paths: - 'packages/**' @@ -20,11 +24,14 @@ jobs: 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. + # infinite loop). We catch both squash-merged and regular-merged commits + # for both the master and alpha release PR title conventions. if: | github.repository == 'less/less.js' && !contains(github.event.head_commit.message, 'chore: release v') && - !contains(github.event.head_commit.message, '/release-v') + !contains(github.event.head_commit.message, 'chore: alpha release v') && + !contains(github.event.head_commit.message, '/release-v') && + !contains(github.event.head_commit.message, '/alpha-release-v') steps: - name: Checkout @@ -47,21 +54,46 @@ jobs: - name: Determine next version id: version run: | + BRANCH="${{ github.ref_name }}" 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" + + if [ "$BRANCH" = "alpha" ]; then + # Alpha: increment the alpha prerelease number. + # X.Y.Z-alpha.N → X.Y.Z-alpha.(N+1) + # If package.json doesn't carry an alpha version yet, bump the + # major and start a fresh alpha.1 series. + NEXT=$(node -e " + const cur = process.argv[1]; + const m = cur.match(/^(\d+\.\d+\.\d+)-alpha\.(\d+)$/); + if (m) { + process.stdout.write(m[1] + '-alpha.' + (parseInt(m[2], 10) + 1)); + } else { + const parts = cur.replace(/-.*/, '').split('.'); + const nextMajor = parseInt(parts[0], 10) + 1; + process.stdout.write(nextMajor + '.0.0-alpha.1'); + } + " "$CURRENT") + echo "next_version=$NEXT" >> "$GITHUB_OUTPUT" + echo "branch=chore/alpha-release-v$NEXT" >> "$GITHUB_OUTPUT" + echo "release_base=alpha" >> "$GITHUB_OUTPUT" + else + # Master: patch-increment from the latest npm published 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" + echo "release_base=master" >> "$GITHUB_OUTPUT" + fi - name: Configure Git run: | @@ -72,15 +104,21 @@ jobs: env: NEXT_VERSION: ${{ steps.version.outputs.next_version }} RELEASE_BRANCH: ${{ steps.version.outputs.branch }} + RELEASE_BASE: ${{ steps.version.outputs.release_base }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - TITLE="chore: release v${NEXT_VERSION}" + set -euo pipefail + if [ "$RELEASE_BASE" = "alpha" ]; then + TITLE="chore: alpha release v${NEXT_VERSION}" + else + TITLE="chore: release v${NEXT_VERSION}" + fi - # Create or reset the release branch off the latest master so it + # Create or reset the release branch off the latest base branch 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 + git checkout -B "${RELEASE_BRANCH}" "origin/${RELEASE_BASE}" else git checkout -b "${RELEASE_BRANCH}" fi @@ -101,10 +139,27 @@ jobs: " git add package.json packages/*/package.json + COMMITTED=false if git diff --cached --quiet; then echo "No version changes; branch is already at v${NEXT_VERSION}" else git commit -m "${TITLE}" + COMMITTED=true + fi + + # If no new commit was created the release branch has no commits + # ahead of master, so pushing it and trying to open a PR would fail + # with "no commits between head and base". Instead, just report + # whether an existing release PR is open and exit cleanly. + if [ "$COMMITTED" = "false" ]; then + EXISTING=$(gh pr list --head "${RELEASE_BRANCH}" --base "${RELEASE_BASE}" \ + --json number --jq '.[0].number' 2>/dev/null || echo "") + if [ -n "${EXISTING}" ]; then + echo "✅ No new changes; release PR #${EXISTING} already exists" + else + echo "✅ No version bump needed and no existing release PR; nothing to do" + fi + exit 0 fi # --force-with-lease refuses to overwrite if the remote has advanced @@ -114,7 +169,7 @@ jobs: 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 \ + EXISTING=$(gh pr list --head "${RELEASE_BRANCH}" --base "${RELEASE_BASE}" \ --json number --jq '.[0].number' 2>/dev/null || echo "") if [ -z "${EXISTING}" ]; then @@ -129,9 +184,9 @@ jobs: gh pr create \ --title "${TITLE}" \ --body "${BODY}" \ - --base master \ + --base "${RELEASE_BASE}" \ --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" + echo "✅ Release PR #${EXISTING} already exists; branch updated to include latest ${RELEASE_BASE} commits" fi diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index bcb381828..9c02fb7c9 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,23 +1,15 @@ name: Publish to NPM on: - # Master: publish when a "chore: release vX.Y.Z" pull request is merged. - # The release PR is created automatically by create-release-pr.yml. + # Publish when a release PR is merged: + # master branch: "chore: release vX.Y.Z" PR → publishes latest + # alpha branch: "chore: alpha release vX.Y.Z" PR → publishes alpha + # Both release PRs are 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 @@ -27,18 +19,17 @@ jobs: publish: name: Publish to NPM runs-on: ubuntu-latest - # 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). + # Only run when a release PR with the expected title is merged into master + # or alpha. Any other PR close (or merge without the right title) is + # silently skipped. if: | github.repository == 'less/less.js' && + github.event.pull_request.merged == true && ( - (github.event_name == 'pull_request' && - github.event.pull_request.merged == true && + (github.event.pull_request.base.ref == 'master' && 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')) + (github.event.pull_request.base.ref == 'alpha' && + startsWith(github.event.pull_request.title, 'chore: alpha release v')) ) steps: @@ -47,9 +38,9 @@ jobs: with: fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} - # For PR events check out the base branch (master) post-merge so the + # Check out the base branch (master or alpha) 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 }} + ref: ${{ github.event.pull_request.base.ref }} - name: Install pnpm uses: pnpm/action-setup@v4 @@ -79,13 +70,8 @@ jobs: - name: Determine branch and tag type id: branch-info run: | - # 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 + # Always a pull_request event; base.ref is master or alpha. + BRANCH="${{ github.event.pull_request.base.ref }}" echo "branch=$BRANCH" >> $GITHUB_OUTPUT if [ "$BRANCH" = "alpha" ]; then echo "is_alpha=true" >> $GITHUB_OUTPUT @@ -165,8 +151,8 @@ jobs: - name: Bump version and publish id: publish env: - # Use the branch name resolved by the branch-info step above rather - # than repeating the PR-vs-push detection logic here. + # Pass the resolved base branch name (master or alpha) so that + # bump-and-publish.js knows which branch it is publishing for. GITHUB_REF_NAME: ${{ steps.branch-info.outputs.branch }} run: | pnpm run publish diff --git a/package.json b/package.json index 288f66edf..db8e48205 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "changelog": "github-changes -o less -r less.js -a --only-pulls --use-commit-body -m \"(YYYY-MM-DD)\"", "test": "cd packages/less && npm test", "test:node": "cd packages/less && npm run test:node", + "test:release": "node scripts/test-release-automation.js", "postinstall": "npx only-allow pnpm" }, "author": "Alexis Sellier ", diff --git a/scripts/bump-and-publish.js b/scripts/bump-and-publish.js index d731d2deb..be0d78895 100755 --- a/scripts/bump-and-publish.js +++ b/scripts/bump-and-publish.js @@ -9,14 +9,15 @@ * 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. + * Both master and alpha now use a PR-based release flow: + * + * master → "chore: release vX.Y.Z" PR created by create-release-pr.yml + * alpha → "chore: alpha release vX.Y.Z" PR created by create-release-pr.yml + * + * Merging the release PR lands the version-bump commit on the branch and + * triggers this script. At that point package.json already carries the + * target version. This script validates it, creates an annotated tag, pushes + * the tag, and publishes to npm. No local commit or branch push is made here. */ const fs = require('fs'); @@ -91,6 +92,16 @@ function getNpmVersion(packageName) { } } +// Get the current alpha dist-tag version from NPM +function getNpmAlphaVersion(packageName) { + try { + const result = execSync(`npm view ${packageName} dist-tags.alpha`, { encoding: 'utf8' }).trim(); + return result || null; + } catch (e) { + return null; + } +} + // Determine the target version for publishing. // Priority: EXPLICIT_VERSION env > package.json (if ahead of NPM) > NPM patch bump function getTargetVersion(currentVersion, npmVersion) { @@ -172,143 +183,60 @@ function main() { console.log(`🚀 Starting publish process for branch: ${branch}`); // Get current version - let currentVersion = getCurrentVersion(); + const currentVersion = getCurrentVersion(); console.log(`📦 Current version: ${currentVersion}`); - - // Protection: If on alpha branch and version was overwritten by a merge from master - if (isAlpha && !currentVersion.includes('-alpha.')) { - console.log(`\n⚠️ WARNING: Alpha branch version (${currentVersion}) doesn't contain '-alpha.'`); - console.log(` This likely happened due to merging master into alpha.`); - console.log(` Attempting to restore alpha version...`); - - // Try to find the last alpha version from alpha branch history - let restoredVersion = null; - try { - // Get recent commits on alpha that modified package.json - const commits = execSync( - 'git log alpha --oneline -20 -- packages/less/package.json', - { cwd: ROOT_DIR, encoding: 'utf8' } - ).trim().split('\n'); - - // Search through commits to find the last alpha version - for (const commitLine of commits) { - const commitHash = commitLine.split(' ')[0]; - try { - const pkgContent = execSync( - `git show ${commitHash}:packages/less/package.json 2>/dev/null`, - { cwd: ROOT_DIR, encoding: 'utf8' } - ); - const pkg = JSON.parse(pkgContent); - if (pkg.version && pkg.version.includes('-alpha.')) { - restoredVersion = pkg.version; - console.log(` Found previous alpha version in commit ${commitHash}: ${restoredVersion}`); - break; - } - } catch (e) { - // Continue to next commit - } - } - - if (restoredVersion) { - // Increment the alpha number from the restored version - const alphaMatch = restoredVersion.match(/^(\d+\.\d+\.\d+)-alpha\.(\d+)$/); - if (alphaMatch) { - const alphaNum = parseInt(alphaMatch[2], 10); - const newAlphaVersion = `${alphaMatch[1]}-alpha.${alphaNum + 1}`; - console.log(` Restoring and incrementing to: ${newAlphaVersion}`); - currentVersion = newAlphaVersion; - updateAllVersions(newAlphaVersion); - } else { - console.log(` Restoring to: ${restoredVersion}`); - currentVersion = restoredVersion; - updateAllVersions(restoredVersion); - } - } else { - // No previous alpha version found, create one from current version - const parsed = parseVersion(currentVersion); - const nextMajor = parsed.major + 1; - const newAlphaVersion = `${nextMajor}.0.0-alpha.1`; - console.log(` No previous alpha version found. Creating new: ${newAlphaVersion}`); - currentVersion = newAlphaVersion; - updateAllVersions(newAlphaVersion); - } - } catch (e) { - // If we can't find previous version, create a new alpha version - const parsed = parseVersion(currentVersion); - const nextMajor = parsed.major + 1; - const newAlphaVersion = `${nextMajor}.0.0-alpha.1`; - console.log(` Could not find previous alpha version. Creating: ${newAlphaVersion}`); - currentVersion = newAlphaVersion; - updateAllVersions(newAlphaVersion); - } - - console.log(`✅ Restored/created alpha version: ${currentVersion}\n`); - } - - // Determine next version + + // Determine next version. + // Both master and alpha now use the PR-based release flow: the version bump + // was already applied by the release PR. Use the version in package.json + // as-is and fail fast if it is not ahead of the already-published version. let nextVersion; if (isAlpha) { - // For alpha branch, use alpha versions - const parsed = parseVersion(currentVersion); - if (parsed.prerelease) { - // Already an alpha, increment alpha number - const alphaMatch = currentVersion.match(/^(\d+\.\d+\.\d+)-alpha\.(\d+)$/); - if (alphaMatch) { - const alphaNum = parseInt(alphaMatch[2], 10); - nextVersion = `${alphaMatch[1]}-alpha.${alphaNum + 1}`; - } else { - // Other prerelease format, determine base version and start alpha.1 - const baseVersion = `${parsed.major}.${parsed.minor}.${parsed.patch}`; - nextVersion = `${baseVersion}-alpha.1`; - } - } else { - // Not an alpha version, determine next major and start alpha.1 - const parsed = parseVersion(currentVersion); - const nextMajor = parsed.major + 1; - nextVersion = `${nextMajor}.0.0-alpha.1`; + // Validate that the version carries the expected '-alpha.' prerelease tag. + if (!currentVersion.includes('-alpha.')) { + console.error(`❌ ERROR: Alpha branch package.json version (${currentVersion}) must contain '-alpha.'`); + console.error(` The alpha release PR should have bumped to an X.Y.Z-alpha.N version.`); + process.exit(1); + } + + const npmAlphaVersion = getNpmAlphaVersion('less'); + console.log(`📦 NPM alpha version: ${npmAlphaVersion || '(not published)'}`); + if (npmAlphaVersion && semver.valid(currentVersion) && !semver.gt(currentVersion, npmAlphaVersion)) { + console.error(`❌ ERROR: package.json version (${currentVersion}) must be greater than NPM alpha version (${npmAlphaVersion})`); + console.error(` On alpha the version bump should have arrived via the alpha release PR.`); + process.exit(1); } - console.log(`🔢 Auto-incrementing alpha version: ${nextVersion}`); + nextVersion = currentVersion; + console.log(`📦 Using package.json version (no auto-increment on alpha): ${nextVersion}`); } else { - // For master: compare package.json vs NPM, bump accordingly + // For master: the version bump was already applied via the release PR. + // Use the version already in package.json as-is; never auto-increment here + // because that would create a local commit whose tag would point to a + // commit that is NOT on the master branch. const npmVersion = getNpmVersion('less'); console.log(`📦 NPM version: ${npmVersion || '(not published)'}`); - nextVersion = getTargetVersion(currentVersion, npmVersion); + if (npmVersion && semver.valid(currentVersion) && !semver.gt(currentVersion, npmVersion)) { + console.error(`❌ ERROR: package.json version (${currentVersion}) must be greater than NPM version (${npmVersion})`); + console.error(` On master the version bump should have arrived via the release PR.`); + process.exit(1); + } + nextVersion = currentVersion; + console.log(`📦 Using package.json version (no auto-increment on master): ${nextVersion}`); } - - // Update all package.json files - console.log(`📝 Updating all package.json files to version ${nextVersion}...`); - const updated = updateAllVersions(nextVersion); - console.log(`✅ Updated ${updated.length} package.json files`); - + // Get publishable packages const publishable = getPublishablePackages(); 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}"`); - } + + // Both master and alpha: the version-bump commit already lives on the branch + // (it came from the release PR). Do NOT create another local commit or push + // to the branch — doing so would produce a tag pointing at a commit that is + // not on the target branch. + // + // Only the annotated tag is pushed. Tag pushes bypass branch-protection + // "require pull request" rules. // Create tag const tagName = `v${nextVersion}`; @@ -329,17 +257,8 @@ function main() { // 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}...`); + // Alpha follows the same pattern: the version bump arrived via the alpha + // release PR, so we only push the tag here too. console.log(`📤 Pushing tag ${tagName}...`); if (!dryRun) { execSync(`git push origin "${tagName}"`, { cwd: ROOT_DIR, stdio: 'inherit' }); } else { @@ -465,7 +384,7 @@ function main() { publishErrors.forEach(({ name, error }) => { console.error(` - ${name}: ${error}`); }); - console.error(`\n⚠️ Note: Version bump commit and tag were pushed successfully.`); + console.error(`\n⚠️ Note: Git tag was pushed successfully.`); console.error(` Some packages failed to publish. You may need to publish them manually.`); process.exit(1); } diff --git a/scripts/test-release-automation.js b/scripts/test-release-automation.js new file mode 100644 index 000000000..02141bd2b --- /dev/null +++ b/scripts/test-release-automation.js @@ -0,0 +1,804 @@ +#!/usr/bin/env node +/** + * Release-automation test suite + * + * Tests four components of the release flow without requiring a live + * GitHub token or npm credentials: + * + * 1. publish.yml `if:` expression + * - master release PR merged → publish + * - alpha release PR merged → publish (alpha tag) + * - other PRs / direct pushes → skip + * + * 2. create-release-pr.yml `if:` expression + * - normal merges to master or alpha → trigger + * - release PR merges (both flavours) → skip (loop guard) + * + * 3. Alpha version increment logic (from create-release-pr.yml) + * - Works for any X.Y.Z-alpha.N regardless of major version + * - Double-digit rollover (alpha.9 → alpha.10) + * - Non-alpha package.json on alpha branch → bump major, start alpha.1 + * + * 4. bump-and-publish.js behaviour (subprocess, DRY_RUN=true) + * - master path: uses package.json version as-is, no commit, no branch push + * - master path: rejects when package.json version ≤ npm latest version + * - alpha path: uses package.json version as-is, no commit, no branch push + * - alpha path: rejects when package.json alpha version lacks '-alpha.' + * + * 5. create-release-pr no-op safety (isolated temp git repo) + * - when a version bump produces changes → a commit is created + * - when no version changes are needed → exits cleanly with no commit + * + * Run: + * node scripts/test-release-automation.js + * + * Uses only Node.js built-ins. semver is resolved from the workspace + * node_modules (present after `pnpm install`). In sandboxes where pnpm + * install hasn't run, install it manually: + * npm install --prefix /tmp/test-deps semver + */ + +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawnSync, execSync } = require('child_process'); + +const ROOT_DIR = path.resolve(__dirname, '..'); + +// --------------------------------------------------------------------------- +// Resolve semver — works both after `pnpm install` and in a bare sandbox +// --------------------------------------------------------------------------- + +function resolveSemverPath() { + const candidates = [ + path.join(ROOT_DIR, 'node_modules', 'semver'), + '/tmp/test-deps/node_modules/semver', + ]; + for (const c of candidates) { + if (fs.existsSync(c)) return c; + } + return null; +} + +const SEMVER_PATH = resolveSemverPath(); + +// --------------------------------------------------------------------------- +// Tiny test harness (no external dependencies) +// --------------------------------------------------------------------------- + +let passed = 0; +let failed = 0; +const failures = []; + +function test(name, fn) { + try { + fn(); + console.log(` ✅ ${name}`); + passed++; + } catch (err) { + console.error(` ❌ ${name}`); + console.error(` ${err.message}`); + failures.push({ name, message: err.message }); + failed++; + } +} + +function section(title) { + console.log(`\n── ${title}`); +} + +// --------------------------------------------------------------------------- +// Workflow condition helpers +// +// These replicate the job-level `if:` expressions from the YAML files +// verbatim in JavaScript so the tests are authoritative. +// --------------------------------------------------------------------------- + +/** + * publish.yml `if:` condition: + * + * github.repository == 'less/less.js' && + * github.event.pull_request.merged == true && + * ( + * (github.event.pull_request.base.ref == 'master' && + * startsWith(github.event.pull_request.title, 'chore: release v')) || + * (github.event.pull_request.base.ref == 'alpha' && + * startsWith(github.event.pull_request.title, 'chore: alpha release v')) + * ) + */ +function publishShouldRun({ repo, prMerged, prBaseRef, prTitle }) { + if (repo !== 'less/less.js') return false; + if (!prMerged) return false; + + const isMasterRelease = + prBaseRef === 'master' && + typeof prTitle === 'string' && + prTitle.startsWith('chore: release v'); + + const isAlphaRelease = + prBaseRef === 'alpha' && + typeof prTitle === 'string' && + prTitle.startsWith('chore: alpha release v'); + + return isMasterRelease || isAlphaRelease; +} + +/** + * create-release-pr.yml `if:` condition: + * + * github.repository == 'less/less.js' && + * !contains(github.event.head_commit.message, 'chore: release v') && + * !contains(github.event.head_commit.message, 'chore: alpha release v') && + * !contains(github.event.head_commit.message, '/release-v') && + * !contains(github.event.head_commit.message, '/alpha-release-v') + */ +function createReleasePRShouldRun({ repo, commitMessage }) { + if (repo !== 'less/less.js') return false; + if (commitMessage.includes('chore: release v')) return false; + if (commitMessage.includes('chore: alpha release v')) return false; + if (commitMessage.includes('/release-v')) return false; + if (commitMessage.includes('/alpha-release-v')) return false; + return true; +} + +/** + * Alpha version increment — mirrors the inline Node script in + * create-release-pr.yml "Determine next version" step for the alpha branch. + * + * X.Y.Z-alpha.N → X.Y.Z-alpha.(N+1) + * X.Y.Z → (X+1).0.0-alpha.1 (no alpha suffix yet) + */ +function nextAlphaVersion(current) { + const m = current.match(/^(\d+\.\d+\.\d+)-alpha\.(\d+)$/); + if (m) { + return `${m[1]}-alpha.${parseInt(m[2], 10) + 1}`; + } + const parts = current.replace(/-.*/, '').split('.'); + const nextMajor = parseInt(parts[0], 10) + 1; + return `${nextMajor}.0.0-alpha.1`; +} + +// --------------------------------------------------------------------------- +// Helpers: temporary git repo +// --------------------------------------------------------------------------- + +function makeFakeRepo({ packageVersion }) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'less-release-test-')); + + // root package.json (private monorepo root) + fs.writeFileSync( + path.join(dir, 'package.json'), + JSON.stringify({ name: '@less/root', private: true, version: packageVersion }, null, '\t') + '\n', + ); + + // packages/less/package.json (the publishable package) + const pkgDir = path.join(dir, 'packages', 'less'); + fs.mkdirSync(pkgDir, { recursive: true }); + fs.writeFileSync( + path.join(pkgDir, 'package.json'), + JSON.stringify({ name: 'less', version: packageVersion }, null, '\t') + '\n', + ); + + // Minimal git repo + execSync('git init -b master', { cwd: dir, stdio: 'ignore' }); + execSync('git config user.email "test@test.com"', { cwd: dir, stdio: 'ignore' }); + execSync('git config user.name "Test"', { cwd: dir, stdio: 'ignore' }); + execSync('git add .', { cwd: dir, stdio: 'ignore' }); + execSync('git commit -m "initial"', { cwd: dir, stdio: 'ignore' }); + + return dir; +} + +// --------------------------------------------------------------------------- +// Run bump-and-publish.js in a fake repo +// +// Strategy: copy the script into the temp repo with ROOT_DIR patched so it +// reads/writes from the temp dir. semver is resolved via NODE_PATH. +// --------------------------------------------------------------------------- + +function runBumpAndPublish(fakeRoot, extraEnv = {}) { + const scriptsDir = path.join(fakeRoot, 'scripts'); + fs.mkdirSync(scriptsDir, { recursive: true }); + + // Read the production script and patch the ROOT_DIR line. + let src = fs.readFileSync(path.join(ROOT_DIR, 'scripts', 'bump-and-publish.js'), 'utf8'); + + // Remove shebang so Node can require() it without SyntaxError + src = src.replace(/^#!.*\n/, ''); + + // Override ROOT_DIR to point at fakeRoot + src = src.replace( + /const ROOT_DIR\s*=\s*path\.resolve\(__dirname,\s*'\.\.'\s*\);/, + `const ROOT_DIR = ${JSON.stringify(fakeRoot)};`, + ); + + // Redirect require('semver') to the resolved absolute path so the patched + // script works even when run from an isolated temp directory that has no + // node_modules of its own. + if (SEMVER_PATH) { + src = src.replace( + /require\('semver'\)/g, + `require(${JSON.stringify(SEMVER_PATH)})`, + ); + } + + const patchedScript = path.join(scriptsDir, '_bap_patched.cjs'); + fs.writeFileSync(patchedScript, src); + + const result = spawnSync('node', [patchedScript], { + cwd: fakeRoot, + env: { + ...process.env, + ...extraEnv, + }, + encoding: 'utf8', + }); + + // Clean up patched script; ENOENT is fine if it was never written + try { fs.unlinkSync(patchedScript); } catch (e) { if (e.code !== 'ENOENT') throw e; } + + return { + exitCode: result.status, + stdout: result.stdout || '', + stderr: result.stderr || '', + }; +} + +// --------------------------------------------------------------------------- +// Run the core shell logic from create-release-pr.yml in an isolated repo. +// +// We run everything up to (but not including) `git push` and `gh pr create` +// so we don't need network access. The critical behaviour under test is +// whether a commit is created when there are (or aren't) version changes. +// --------------------------------------------------------------------------- + +function runCreateReleasePRStep({ repoDir, nextVersion, releaseBranch }) { + // Stub `gh` binary so any calls are recorded but do nothing + const binDir = path.join(repoDir, '.test-bin'); + fs.mkdirSync(binDir, { recursive: true }); + const ghLog = path.join(repoDir, 'gh-calls.log'); + fs.writeFileSync(path.join(binDir, 'gh'), `#!/bin/sh\necho "$@" >> "${ghLog}"\n`); + fs.chmodSync(path.join(binDir, 'gh'), 0o755); + + const initialHead = execSync('git rev-parse HEAD', { cwd: repoDir, encoding: 'utf8' }).trim(); + + const script = ` +set -euo pipefail +NEXT_VERSION=${JSON.stringify(nextVersion)} +RELEASE_BRANCH=${JSON.stringify(releaseBranch)} +TITLE="chore: release v\${NEXT_VERSION}" + +git checkout -b "\${RELEASE_BRANCH}" + +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 +COMMITTED=false +if git diff --cached --quiet; then + echo "STATUS:NO_CHANGES" +else + git commit -m "\${TITLE}" + COMMITTED=true +fi +echo "STATUS:COMMITTED=\${COMMITTED}" +`; + + const result = spawnSync('bash', ['-c', script], { + cwd: repoDir, + env: { + ...process.env, + NEXT_VERSION: nextVersion, + GH_TOKEN: 'fake-token', + PATH: `${binDir}:${process.env.PATH}`, + }, + encoding: 'utf8', + }); + + const finalHead = execSync('git rev-parse HEAD', { cwd: repoDir, encoding: 'utf8' }).trim(); + const ghCalls = fs.existsSync(ghLog) ? fs.readFileSync(ghLog, 'utf8').trim() : ''; + + return { + exitCode: result.status, + stdout: result.stdout || '', + stderr: result.stderr || '', + initialHead, + finalHead, + newCommitCreated: finalHead !== initialHead, + ghCalls, + }; +} + +// ============================================================================ +// TEST SUITE +// ============================================================================ + +// ---------------------------------------------------------------------------- +// Section 1 — publish.yml trigger conditions +// ---------------------------------------------------------------------------- + +section('1. publish.yml — workflow trigger conditions'); + +test('master release PR merged → SHOULD publish', () => { + assert.strictEqual( + publishShouldRun({ + repo: 'less/less.js', + prMerged: true, + prBaseRef: 'master', + prTitle: 'chore: release v4.6.4', + }), + true, + ); +}); + +test('alpha release PR merged → SHOULD publish (alpha tag)', () => { + assert.strictEqual( + publishShouldRun({ + repo: 'less/less.js', + prMerged: true, + prBaseRef: 'alpha', + prTitle: 'chore: alpha release v5.0.0-alpha.2', + }), + true, + ); +}); + +test('non-release PR merged into master → should NOT publish', () => { + assert.strictEqual( + publishShouldRun({ + repo: 'less/less.js', + prMerged: true, + prBaseRef: 'master', + prTitle: 'fix: some bug fix', + }), + false, + ); +}); + +test('non-release PR merged into alpha → should NOT publish', () => { + assert.strictEqual( + publishShouldRun({ + repo: 'less/less.js', + prMerged: true, + prBaseRef: 'alpha', + prTitle: 'feat: add something for next major', + }), + false, + ); +}); + +test('release PR closed but NOT merged → should NOT publish', () => { + assert.strictEqual( + publishShouldRun({ + repo: 'less/less.js', + prMerged: false, + prBaseRef: 'master', + prTitle: 'chore: release v4.6.4', + }), + false, + ); +}); + +test('alpha release PR title used against master base → should NOT publish', () => { + // Wrong convention: "chore: alpha release v" into master should not trigger + assert.strictEqual( + publishShouldRun({ + repo: 'less/less.js', + prMerged: true, + prBaseRef: 'master', + prTitle: 'chore: alpha release v5.0.0-alpha.1', + }), + false, + ); +}); + +test('wrong repository → should NOT publish', () => { + assert.strictEqual( + publishShouldRun({ + repo: 'fork/less.js', + prMerged: true, + prBaseRef: 'master', + prTitle: 'chore: release v4.6.4', + }), + false, + ); +}); + +// ---------------------------------------------------------------------------- +// Section 2 — create-release-pr.yml trigger conditions +// ---------------------------------------------------------------------------- + +section('2. create-release-pr.yml — workflow trigger conditions'); + +test('normal merge to master → SHOULD trigger', () => { + assert.strictEqual( + createReleasePRShouldRun({ repo: 'less/less.js', commitMessage: 'fix: correct color parsing' }), + true, + ); +}); + +test('normal merge to alpha → SHOULD trigger', () => { + assert.strictEqual( + createReleasePRShouldRun({ repo: 'less/less.js', commitMessage: 'feat: new feature for next major' }), + true, + ); +}); + +test('master release PR merge → should NOT trigger (loop guard)', () => { + assert.strictEqual( + createReleasePRShouldRun({ repo: 'less/less.js', commitMessage: 'chore: release v4.6.4' }), + false, + ); +}); + +test('alpha release PR merge → should NOT trigger (loop guard)', () => { + assert.strictEqual( + createReleasePRShouldRun({ repo: 'less/less.js', commitMessage: 'chore: alpha release v5.0.0-alpha.2' }), + false, + ); +}); + +test('release branch ref in commit message → should NOT trigger (loop guard for master)', () => { + assert.strictEqual( + createReleasePRShouldRun({ + repo: 'less/less.js', + commitMessage: 'Merge chore/release-v4.6.4 into master', + }), + false, + ); +}); + +test('alpha release branch ref in commit message → should NOT trigger (loop guard for alpha)', () => { + assert.strictEqual( + createReleasePRShouldRun({ + repo: 'less/less.js', + commitMessage: 'Merge chore/alpha-release-v5.0.0-alpha.2 into alpha', + }), + false, + ); +}); + +test('wrong repository → should NOT trigger', () => { + assert.strictEqual( + createReleasePRShouldRun({ repo: 'fork/less.js', commitMessage: 'fix: something' }), + false, + ); +}); + +// ---------------------------------------------------------------------------- +// Section 3 — Alpha version increment logic (from create-release-pr.yml) +// +// These are pure-logic tests of the nextAlphaVersion() helper, which mirrors +// the inline Node script in the "Determine next version" step of the workflow. +// This directly answers: "does this work for 5.x alphas as well?" +// ---------------------------------------------------------------------------- + +section('3. create-release-pr.yml — alpha version increment logic'); + +test('4.x: 4.6.3-alpha.1 → 4.6.3-alpha.2', () => { + assert.strictEqual(nextAlphaVersion('4.6.3-alpha.1'), '4.6.3-alpha.2'); +}); + +test('5.x: 5.0.0-alpha.1 → 5.0.0-alpha.2 (answers the original question)', () => { + assert.strictEqual(nextAlphaVersion('5.0.0-alpha.1'), '5.0.0-alpha.2'); +}); + +test('5.x: 5.0.0-alpha.3 → 5.0.0-alpha.4 (preserves major, not 4.x)', () => { + assert.strictEqual(nextAlphaVersion('5.0.0-alpha.3'), '5.0.0-alpha.4'); +}); + +test('5.x minor/patch: 5.1.2-alpha.7 → 5.1.2-alpha.8', () => { + assert.strictEqual(nextAlphaVersion('5.1.2-alpha.7'), '5.1.2-alpha.8'); +}); + +test('double-digit rollover: 5.0.0-alpha.9 → 5.0.0-alpha.10 (integer, not string comparison)', () => { + assert.strictEqual(nextAlphaVersion('5.0.0-alpha.9'), '5.0.0-alpha.10'); +}); + +test('non-alpha version on alpha branch: 4.6.3 → 5.0.0-alpha.1 (bumps major, starts fresh)', () => { + assert.strictEqual(nextAlphaVersion('4.6.3'), '5.0.0-alpha.1'); +}); + +test('non-alpha 5.x version: 5.0.0 → 6.0.0-alpha.1', () => { + assert.strictEqual(nextAlphaVersion('5.0.0'), '6.0.0-alpha.1'); +}); + +// ---------------------------------------------------------------------------- +// Section 4 — bump-and-publish.js master path +// ---------------------------------------------------------------------------- + +section('4. bump-and-publish.js — master path (DRY_RUN=true)'); + +// A version clearly higher than any real npm publish so validation passes +const MASTER_TEST_VERSION = '999.0.0'; + +test('master: uses package.json version as-is (no auto-increment)', () => { + const fakeDir = makeFakeRepo({ packageVersion: MASTER_TEST_VERSION }); + try { + const { exitCode, stdout, stderr } = runBumpAndPublish(fakeDir, { + GITHUB_REF_NAME: 'master', + DRY_RUN: 'true', + }); + assert.strictEqual(exitCode, 0, `Expected exit 0.\nSTDOUT: ${stdout}\nSTDERR: ${stderr}`); + assert.ok( + stdout.includes(MASTER_TEST_VERSION), + `Expected version ${MASTER_TEST_VERSION} in output.\nSTDOUT: ${stdout}`, + ); + assert.ok( + stdout.includes('no auto-increment on master') || stdout.includes('Using package.json version'), + `Expected "no auto-increment" message.\nSTDOUT: ${stdout}`, + ); + } finally { + fs.rmSync(fakeDir, { recursive: true, force: true }); + } +}); + +test('master: no commit step (version bump is skipped)', () => { + const fakeDir = makeFakeRepo({ packageVersion: MASTER_TEST_VERSION }); + try { + const { stdout, stderr } = runBumpAndPublish(fakeDir, { + GITHUB_REF_NAME: 'master', + DRY_RUN: 'true', + }); + assert.ok( + !stdout.includes('[DRY RUN] Would commit'), + `Expected no commit step on master path.\nSTDOUT: ${stdout}\nSTDERR: ${stderr}`, + ); + } finally { + fs.rmSync(fakeDir, { recursive: true, force: true }); + } +}); + +test('master: no branch push step', () => { + const fakeDir = makeFakeRepo({ packageVersion: MASTER_TEST_VERSION }); + try { + const { stdout, stderr } = runBumpAndPublish(fakeDir, { + GITHUB_REF_NAME: 'master', + DRY_RUN: 'true', + }); + assert.ok( + !stdout.includes('Would push to: origin master'), + `Expected no branch push on master path.\nSTDOUT: ${stdout}\nSTDERR: ${stderr}`, + ); + } finally { + fs.rmSync(fakeDir, { recursive: true, force: true }); + } +}); + +test('master: rejects when package.json version ≤ npm published version', () => { + // 0.0.1 is well below the real npm "less" version, so validation should fail + const fakeDir = makeFakeRepo({ packageVersion: '0.0.1' }); + try { + const { exitCode, stdout, stderr } = runBumpAndPublish(fakeDir, { + GITHUB_REF_NAME: 'master', + DRY_RUN: 'true', + }); + assert.notStrictEqual(exitCode, 0, `Expected non-zero exit.\nSTDOUT: ${stdout}\nSTDERR: ${stderr}`); + const combined = stdout + stderr; + assert.ok( + combined.includes('must be greater than NPM version') || combined.includes('ERROR'), + `Expected error message about version being too low.\nCombined: ${combined}`, + ); + } finally { + fs.rmSync(fakeDir, { recursive: true, force: true }); + } +}); + +// ---------------------------------------------------------------------------- +// Section 5 — bump-and-publish.js alpha path +// +// Alpha now uses the same PR-based flow as master: the version bump is applied +// by the release PR, and bump-and-publish.js uses the existing version as-is. +// ---------------------------------------------------------------------------- + +section('5. bump-and-publish.js — alpha path (DRY_RUN=true)'); + +test('alpha: uses package.json version as-is (no auto-increment)', () => { + const fakeDir = makeFakeRepo({ packageVersion: '5.0.0-alpha.2' }); + try { + const { exitCode, stdout, stderr } = runBumpAndPublish(fakeDir, { + GITHUB_REF_NAME: 'alpha', + DRY_RUN: 'true', + }); + assert.strictEqual(exitCode, 0, `Expected exit 0.\nSTDOUT: ${stdout}\nSTDERR: ${stderr}`); + assert.ok( + stdout.includes('5.0.0-alpha.2'), + `Expected version 5.0.0-alpha.2 in output.\nSTDOUT: ${stdout}`, + ); + assert.ok( + stdout.includes('no auto-increment on alpha') || stdout.includes('Using package.json version'), + `Expected "no auto-increment" message.\nSTDOUT: ${stdout}`, + ); + } finally { + fs.rmSync(fakeDir, { recursive: true, force: true }); + } +}); + +test('alpha: no commit step (same as master)', () => { + const fakeDir = makeFakeRepo({ packageVersion: '5.0.0-alpha.2' }); + try { + const { stdout, stderr } = runBumpAndPublish(fakeDir, { + GITHUB_REF_NAME: 'alpha', + DRY_RUN: 'true', + }); + assert.ok( + !stdout.includes('[DRY RUN] Would commit'), + `Expected no commit step on alpha path.\nSTDOUT: ${stdout}\nSTDERR: ${stderr}`, + ); + } finally { + fs.rmSync(fakeDir, { recursive: true, force: true }); + } +}); + +test('alpha: no branch push step (same as master)', () => { + const fakeDir = makeFakeRepo({ packageVersion: '5.0.0-alpha.2' }); + try { + const { stdout, stderr } = runBumpAndPublish(fakeDir, { + GITHUB_REF_NAME: 'alpha', + DRY_RUN: 'true', + }); + assert.ok( + !stdout.includes('Would push to: origin alpha'), + `Expected no branch push on alpha path.\nSTDOUT: ${stdout}\nSTDERR: ${stderr}`, + ); + } finally { + fs.rmSync(fakeDir, { recursive: true, force: true }); + } +}); + +test('alpha: publishes with "alpha" npm tag (not "latest")', () => { + const fakeDir = makeFakeRepo({ packageVersion: '5.0.0-alpha.2' }); + try { + const { exitCode, stdout, stderr } = runBumpAndPublish(fakeDir, { + GITHUB_REF_NAME: 'alpha', + DRY_RUN: 'true', + }); + assert.strictEqual(exitCode, 0, `Expected exit 0.\nSTDOUT: ${stdout}\nSTDERR: ${stderr}`); + assert.ok( + stdout.includes('tag: alpha'), + `Expected npm tag "alpha" in output.\nSTDOUT: ${stdout}`, + ); + assert.ok( + !stdout.includes('tag: latest'), + `Expected no "latest" npm tag for alpha versions.\nSTDOUT: ${stdout}`, + ); + } finally { + fs.rmSync(fakeDir, { recursive: true, force: true }); + } +}); + +test('alpha: rejects when package.json version lacks "-alpha." suffix', () => { + // If somehow the alpha release PR bumped to a non-alpha version, the script + // must fail fast before publishing. + const fakeDir = makeFakeRepo({ packageVersion: '5.0.0' }); + try { + const { exitCode, stdout, stderr } = runBumpAndPublish(fakeDir, { + GITHUB_REF_NAME: 'alpha', + DRY_RUN: 'true', + }); + assert.notStrictEqual(exitCode, 0, `Expected non-zero exit.\nSTDOUT: ${stdout}\nSTDERR: ${stderr}`); + const combined = stdout + stderr; + assert.ok( + combined.includes('-alpha.') || combined.includes('ERROR'), + `Expected error about missing '-alpha.' suffix.\nCombined: ${combined}`, + ); + } finally { + fs.rmSync(fakeDir, { recursive: true, force: true }); + } +}); + +test('alpha: 4.x alpha version also accepted (4.6.3-alpha.2)', () => { + const fakeDir = makeFakeRepo({ packageVersion: '4.6.3-alpha.2' }); + try { + const { exitCode, stdout, stderr } = runBumpAndPublish(fakeDir, { + GITHUB_REF_NAME: 'alpha', + DRY_RUN: 'true', + }); + assert.strictEqual(exitCode, 0, `Expected exit 0.\nSTDOUT: ${stdout}\nSTDERR: ${stderr}`); + assert.ok( + stdout.includes('4.6.3-alpha.2'), + `Expected version 4.6.3-alpha.2 in output.\nSTDOUT: ${stdout}`, + ); + } finally { + fs.rmSync(fakeDir, { recursive: true, force: true }); + } +}); + +// ---------------------------------------------------------------------------- +// Section 6 — create-release-pr no-op safety +// ---------------------------------------------------------------------------- + +section('6. create-release-pr — no-op safety'); + +test('version bump needed: creates a commit on the release branch', () => { + // Repo starts at 4.6.3; bump target is 4.6.4 → files change → commit + const repoDir = makeFakeRepo({ packageVersion: '4.6.3' }); + try { + const res = runCreateReleasePRStep({ + repoDir, + nextVersion: '4.6.4', + releaseBranch: 'chore/release-v4.6.4', + }); + assert.strictEqual(res.exitCode, 0, `Script exited ${res.exitCode}.\nSTDOUT: ${res.stdout}\nSTDERR: ${res.stderr}`); + assert.ok(res.newCommitCreated, 'Expected a new commit when versions differ'); + assert.ok( + res.stdout.includes('STATUS:COMMITTED=true'), + `Expected COMMITTED=true status.\nSTDOUT: ${res.stdout}`, + ); + } finally { + fs.rmSync(repoDir, { recursive: true, force: true }); + } +}); + +test('no version bump needed: exits cleanly, no new commit, no gh calls', () => { + // Repo starts at 4.6.4 (target version) → no diff → no commit + const repoDir = makeFakeRepo({ packageVersion: '4.6.4' }); + try { + const res = runCreateReleasePRStep({ + repoDir, + nextVersion: '4.6.4', + releaseBranch: 'chore/release-v4.6.4', + }); + assert.strictEqual(res.exitCode, 0, `Script exited ${res.exitCode}.\nSTDOUT: ${res.stdout}\nSTDERR: ${res.stderr}`); + assert.ok(!res.newCommitCreated, 'Expected NO new commit when version is already at target'); + assert.ok( + res.stdout.includes('STATUS:NO_CHANGES'), + `Expected NO_CHANGES status.\nSTDOUT: ${res.stdout}`, + ); + assert.strictEqual( + res.ghCalls, '', + `Expected no gh commands to be invoked.\ngh calls log: ${res.ghCalls}`, + ); + } finally { + fs.rmSync(repoDir, { recursive: true, force: true }); + } +}); + +test('alpha version bump needed: commit created for alpha release branch', () => { + // Repo at 5.0.0-alpha.1; bump target is 5.0.0-alpha.2 → diff → commit + const repoDir = makeFakeRepo({ packageVersion: '5.0.0-alpha.1' }); + try { + const res = runCreateReleasePRStep({ + repoDir, + nextVersion: '5.0.0-alpha.2', + releaseBranch: 'chore/alpha-release-v5.0.0-alpha.2', + }); + assert.strictEqual(res.exitCode, 0, `Script exited ${res.exitCode}.\nSTDOUT: ${res.stdout}\nSTDERR: ${res.stderr}`); + assert.ok(res.newCommitCreated, 'Expected a new commit for alpha version bump'); + assert.ok( + res.stdout.includes('STATUS:COMMITTED=true'), + `Expected COMMITTED=true status.\nSTDOUT: ${res.stdout}`, + ); + } finally { + fs.rmSync(repoDir, { recursive: true, force: true }); + } +}); + +// ============================================================================ +// Summary +// ============================================================================ + +console.log(`\n${'─'.repeat(60)}`); +console.log(`Results: ${passed} passed, ${failed} failed`); + +if (failures.length > 0) { + console.error('\nFailed tests:'); + failures.forEach(f => console.error(` ✗ ${f.name}\n ${f.message}`)); + process.exit(1); +} else { + console.log('All release automation tests passed! ✅'); +}