From 56207b1f59ed888fb904a51b1a4d2903f2f9c7ee Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Tue, 3 Mar 2026 16:27:25 +0000 Subject: [PATCH 1/2] fix: calculate next version tag for display on home page --- .github/workflows/release-pr.yml | 62 +++++++------- .github/workflows/release-tag.yml | 47 +++-------- config/env.ts | 13 +-- scripts/next-version.ts | 108 +++++++++++++++++++++++++ test/unit/config/env.spec.ts | 11 +-- test/unit/scripts/next-version.spec.ts | 31 +++++++ 6 files changed, 187 insertions(+), 85 deletions(-) create mode 100644 scripts/next-version.ts create mode 100644 test/unit/scripts/next-version.spec.ts diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml index 09b9ec45e..dff5505ca 100644 --- a/.github/workflows/release-pr.yml +++ b/.github/workflows/release-pr.yml @@ -20,6 +20,10 @@ jobs: with: fetch-depth: 0 + - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + with: + node-version: lts/* + - name: 🔍 Check for unreleased commits id: check run: | @@ -34,21 +38,26 @@ jobs: echo "$COMMITS" fi + - name: 🔢 Determine next version + if: steps.check.outputs.skip == 'false' + id: version + run: | + VERSION_JSON=$(node scripts/next-version.ts) + CURRENT_VERSION=$(echo "$VERSION_JSON" | jq -r .current) + NEXT_VERSION=$(echo "$VERSION_JSON" | jq -r .next) + FROM_REF=$(echo "$VERSION_JSON" | jq -r .from) + echo "current=$CURRENT_VERSION" >> "$GITHUB_OUTPUT" + echo "next=v${NEXT_VERSION}" >> "$GITHUB_OUTPUT" + echo "from=$FROM_REF" >> "$GITHUB_OUTPUT" + - name: 📝 Generate changelog body if: steps.check.outputs.skip == 'false' id: changelog + env: + CURRENT_VERSION: ${{ steps.version.outputs.current }} + NEXT_VERSION: ${{ steps.version.outputs.next }} + FROM_REF: ${{ steps.version.outputs.from }} run: | - # Get the latest tag, or use initial commit if no tags exist - LATEST_TAG=$(git describe --tags --abbrev=0 origin/release 2>/dev/null || echo "") - - if [ -z "$LATEST_TAG" ]; then - FROM_REF=$(git rev-list --max-parents=0 HEAD) - CURRENT_VERSION="0.0.0" - else - FROM_REF="$LATEST_TAG" - CURRENT_VERSION="${LATEST_TAG#v}" - fi - # Categorize commits FEATURES="" FIXES="" @@ -72,25 +81,12 @@ jobs: fi done <<< "$(git log "$FROM_REF"..origin/main --oneline --no-merges)" - # Determine next version - HAS_BREAKING=$(git log "$FROM_REF"..origin/main --format='%B' | grep -c 'BREAKING CHANGE\|!:' || true) - HAS_FEAT=$(git log "$FROM_REF"..origin/main --oneline --no-merges | grep -cE '^[a-f0-9]+ feat(\(|:)' || true) - - IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT_VERSION" - - if [ "$HAS_BREAKING" -gt 0 ] && [ "$MAJOR" -gt 0 ]; then - NEXT_VERSION="$((MAJOR + 1)).0.0" - elif [ "$HAS_FEAT" -gt 0 ]; then - NEXT_VERSION="${MAJOR}.$((MINOR + 1)).0" - else - NEXT_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))" - fi - - echo "next_version=v${NEXT_VERSION}" >> "$GITHUB_OUTPUT" + # Strip the leading 'v' for display + DISPLAY_NEXT="${NEXT_VERSION#v}" # Build the PR body BODY="This PR will deploy the following changes to production (\`npmx.dev\`).\n\n" - BODY="${BODY}**Next version: \`v${NEXT_VERSION}\`** (current: \`v${CURRENT_VERSION}\`)\n\n" + BODY="${BODY}**Next version: \`${NEXT_VERSION}\`** (current: \`v${CURRENT_VERSION}\`)\n\n" if [ -n "$FEATURES" ]; then BODY="${BODY}### Features\n\n${FEATURES}\n" @@ -108,8 +104,8 @@ jobs: BODY="${BODY}---\n\n" BODY="${BODY}> Merging this PR will:\n" BODY="${BODY}> - Deploy to \`npmx.dev\` via Vercel\n" - BODY="${BODY}> - Create a \`v${NEXT_VERSION}\` tag and GitHub Release\n" - BODY="${BODY}> - Publish \`npmx-connector@${NEXT_VERSION}\` to npm" + BODY="${BODY}> - Create a \`${NEXT_VERSION}\` tag and GitHub Release\n" + BODY="${BODY}> - Publish \`npmx-connector@${DISPLAY_NEXT}\` to npm" # Write body to file, truncating if needed (GitHub limits PR body to 65536 chars) echo -e "$BODY" > /tmp/pr-body.md @@ -117,14 +113,14 @@ jobs: COMMIT_COUNT=$(git log "$FROM_REF"..origin/main --oneline --no-merges | wc -l) COMPARE_URL="https://github.com/npmx-dev/npmx.dev/compare/${FROM_REF}...main" TRUNCATED="This PR will deploy the following changes to production (\`npmx.dev\`).\n\n" - TRUNCATED="${TRUNCATED}**Next version: \`v${NEXT_VERSION}\`** (current: \`v${CURRENT_VERSION}\`)\n\n" + TRUNCATED="${TRUNCATED}**Next version: \`${NEXT_VERSION}\`** (current: \`v${CURRENT_VERSION}\`)\n\n" TRUNCATED="${TRUNCATED}> **${COMMIT_COUNT} commits** are included in this release. The full changelog is too large to display here.\n>\n" TRUNCATED="${TRUNCATED}> [View full diff on GitHub](${COMPARE_URL})\n\n" TRUNCATED="${TRUNCATED}---\n\n" TRUNCATED="${TRUNCATED}> Merging this PR will:\n" TRUNCATED="${TRUNCATED}> - Deploy to \`npmx.dev\` via Vercel\n" - TRUNCATED="${TRUNCATED}> - Create a \`v${NEXT_VERSION}\` tag and GitHub Release\n" - TRUNCATED="${TRUNCATED}> - Publish \`npmx-connector@${NEXT_VERSION}\` to npm" + TRUNCATED="${TRUNCATED}> - Create a \`${NEXT_VERSION}\` tag and GitHub Release\n" + TRUNCATED="${TRUNCATED}> - Publish \`npmx-connector@${DISPLAY_NEXT}\` to npm" echo -e "$TRUNCATED" > /tmp/pr-body.md fi @@ -132,7 +128,7 @@ jobs: if: steps.check.outputs.skip == 'false' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - NEXT_VERSION: ${{ steps.changelog.outputs.next_version }} + NEXT_VERSION: ${{ steps.version.outputs.next }} run: | EXISTING_PR=$(gh pr list --base release --head main --state open --json number --jq '.[0].number') diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index 7666e2437..512a3bffb 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -23,42 +23,18 @@ jobs: with: fetch-depth: 0 + - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + with: + node-version: lts/* + - name: 🔢 Determine next version id: version run: | - # Get the latest tag on this branch - LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") - - if [ -z "$LATEST_TAG" ]; then - CURRENT_VERSION="0.0.0" - FROM_REF=$(git rev-list --max-parents=0 HEAD) - else - CURRENT_VERSION="${LATEST_TAG#v}" - FROM_REF="$LATEST_TAG" - fi - - # Analyze conventional commits since last tag - HAS_BREAKING=$(git log "${FROM_REF}..HEAD" --format='%B' | grep -c 'BREAKING CHANGE\|!:' || true) - HAS_FEAT=$(git log "${FROM_REF}..HEAD" --oneline --no-merges | grep -cE '^[a-f0-9]+ feat(\(|:)' || true) - HAS_FIX=$(git log "${FROM_REF}..HEAD" --oneline --no-merges | grep -cE '^[a-f0-9]+ fix(\(|:)' || true) - - IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT_VERSION" - - if [ "$HAS_BREAKING" -gt 0 ] && [ "$MAJOR" -gt 0 ]; then - NEXT_VERSION="$((MAJOR + 1)).0.0" - elif [ "$HAS_FEAT" -gt 0 ]; then - NEXT_VERSION="${MAJOR}.$((MINOR + 1)).0" - elif [ "$HAS_FIX" -gt 0 ]; then - NEXT_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))" - else - # Only chore/docs/ci commits — still bump patch - NEXT_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))" - fi - - echo "current=$CURRENT_VERSION" >> "$GITHUB_OUTPUT" - echo "next=v${NEXT_VERSION}" >> "$GITHUB_OUTPUT" - echo "from=$FROM_REF" >> "$GITHUB_OUTPUT" - echo "Bumping from v${CURRENT_VERSION} to v${NEXT_VERSION}" + VERSION_JSON=$(node scripts/next-version.ts) + echo "current=$(echo "$VERSION_JSON" | jq -r .current)" >> "$GITHUB_OUTPUT" + echo "next=v$(echo "$VERSION_JSON" | jq -r .next)" >> "$GITHUB_OUTPUT" + echo "from=$(echo "$VERSION_JSON" | jq -r .from)" >> "$GITHUB_OUTPUT" + echo "Bumping from v$(echo "$VERSION_JSON" | jq -r .current) to v$(echo "$VERSION_JSON" | jq -r .next)" - name: 🔍 Check if tag already exists id: check @@ -82,11 +58,6 @@ jobs: git tag -a "$VERSION" -m "Release $VERSION" git push origin "$VERSION" - - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 - if: steps.check.outputs.skip == 'false' - with: - node-version: lts/* - - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # 4e1c8eafbd745f64b1ef30a7d7ed7965034c486c if: steps.check.outputs.skip == 'false' name: 🟧 Install pnpm diff --git a/config/env.ts b/config/env.ts index ec7cd7ed1..49daac01a 100644 --- a/config/env.ts +++ b/config/env.ts @@ -4,6 +4,7 @@ import Git from 'simple-git' import * as process from 'node:process' import { version as packageVersion } from '../package.json' +import { getNextVersion } from '../scripts/next-version' export { packageVersion as version } @@ -159,15 +160,17 @@ export async function getFileLastUpdated(path: string) { } /** - * Resolves the current version from git tags, falling back to `package.json`. + * Resolves the **next** version by analysing conventional commits since the + * last reachable `v*` tag. Delegates to {@link getNextVersion} which is also + * used by the `release-tag` and `release-pr` GitHub Actions workflows so the + * version shown in the UI matches the tag that will be created *after* deploy. * - * Uses `git describe --tags --abbrev=0 --match 'v*'` to find the most recent - * reachable release tag (e.g. `v0.1.0` -> `0.1.0`). + * Falls back to `package.json` when git is unavailable (e.g. shallow clone). */ export async function getVersion() { try { - const tag = (await git.raw(['describe', '--tags', '--abbrev=0', '--match', 'v*'])).trim() - return tag.replace(/^v/, '') + const { next } = await getNextVersion() + return next } catch { return packageVersion } diff --git a/scripts/next-version.ts b/scripts/next-version.ts new file mode 100644 index 000000000..2414f1892 --- /dev/null +++ b/scripts/next-version.ts @@ -0,0 +1,108 @@ +/** + * Calculates the next semantic version based on conventional commits since the + * last reachable `v*` tag. + * + * Zero external dependencies — uses `child_process` to shell out to `git`. + * + * ### Imported as a module + * + * ```ts + * import { getNextVersion } from './scripts/next-version' + * const { current, next, from } = await getNextVersion() + * ``` + * + * ### CLI usage (outputs JSON to stdout) + * + * ```sh + * node scripts/next-version.ts # { "current": "0.1.0", "next": "0.2.0", "from": "v0.1.0" } + * node scripts/next-version.ts --next # 0.2.0 + * node scripts/next-version.ts --current # 0.1.0 + * node scripts/next-version.ts --from # v0.1.0 + * ``` + */ + +import { execFileSync } from 'node:child_process' + +function git(...args: string[]): string { + return execFileSync('git', args, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim() +} + +export interface VersionInfo { + /** The current version (from the latest tag), e.g. `"0.1.0"` */ + current: string + /** The computed next version, e.g. `"0.2.0"` */ + next: string + /** The git ref used as the starting point, e.g. `"v0.1.0"` or an initial commit SHA */ + from: string +} + +export async function getNextVersion(): Promise { + let current: string + let from: string + + try { + const tag = git('describe', '--tags', '--abbrev=0', '--match', 'v*') + current = tag.replace(/^v/, '') + from = tag + } catch { + // No reachable tags — start from the initial commit + current = '0.0.0' + from = git('rev-list', '--max-parents=0', 'HEAD') + } + + // Collect commit subjects since last tag (exclude merges) + let commits: string[] + try { + const log = git('log', `${from}..HEAD`, '--format=%s%n%b', '--no-merges') + commits = log ? log.split('\n') : [] + } catch { + commits = [] + } + + let hasBreaking = false + let hasFeat = false + let hasFix = false + + for (const line of commits) { + if (/BREAKING CHANGE|!:/.test(line)) hasBreaking = true + if (/^feat[:(]/.test(line)) hasFeat = true + if (/^fix[:(]/.test(line)) hasFix = true + } + + const [major = 0, minor = 0, patch = 0] = current.split('.').map(Number) + + let next: string + if (hasBreaking && major > 0) { + next = `${major + 1}.0.0` + } else if (hasFeat) { + next = `${major}.${minor + 1}.0` + } else if (hasFix || commits.length > 0) { + // Any non-empty diff bumps at least a patch + next = `${major}.${minor}.${patch + 1}` + } else { + // HEAD is exactly on the latest tag + next = current + } + + return { current, next, from } +} + +// --- CLI entry point --- +const isCLI = + process.argv[1] && + (process.argv[1].endsWith('/next-version.ts') || process.argv[1].endsWith('/next-version')) + +if (isCLI) { + const flag = process.argv[2] + getNextVersion() + .then(info => { + if (flag === '--next') console.log(info.next) + else if (flag === '--current') console.log(info.current) + else if (flag === '--from') console.log(info.from) + else console.log(JSON.stringify(info)) + }) + .catch(err => { + console.error(err) + process.exit(1) + }) +} diff --git a/test/unit/config/env.spec.ts b/test/unit/config/env.spec.ts index 3d3fb0b4e..4b0edb88b 100644 --- a/test/unit/config/env.spec.ts +++ b/test/unit/config/env.spec.ts @@ -327,18 +327,11 @@ describe('getProductionUrl', () => { }) describe('getVersion', () => { - it('returns package.json version when no git tags are reachable', async () => { - const { getVersion, version } = await import('../../../config/env') - const result = await getVersion() - - // In test environments without reachable tags, falls back to package.json - expect(result).toBe(version) - }) - - it('strips the leading "v" prefix from the tag', async () => { + it('returns a valid semver string without a leading "v"', async () => { const { getVersion } = await import('../../../config/env') const result = await getVersion() expect(result).not.toMatch(/^v/) + expect(result).toMatch(/^\d+\.\d+\.\d+$/) }) }) diff --git a/test/unit/scripts/next-version.spec.ts b/test/unit/scripts/next-version.spec.ts new file mode 100644 index 000000000..74c0c74ad --- /dev/null +++ b/test/unit/scripts/next-version.spec.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' +import { getNextVersion } from '../../../scripts/next-version' + +describe('getNextVersion', () => { + it('returns current, next, and from fields', async () => { + const result = await getNextVersion() + + expect(result).toHaveProperty('current') + expect(result).toHaveProperty('next') + expect(result).toHaveProperty('from') + }) + + it('returns valid semver strings', async () => { + const result = await getNextVersion() + + expect(result.current).toMatch(/^\d+\.\d+\.\d+$/) + expect(result.next).toMatch(/^\d+\.\d+\.\d+$/) + }) + + it('returns a next version >= current version', async () => { + const result = await getNextVersion() + + const [curMajor, curMinor, curPatch] = result.current.split('.').map(Number) + const [nextMajor, nextMinor, nextPatch] = result.next.split('.').map(Number) + + const curNum = curMajor! * 1_000_000 + curMinor! * 1_000 + curPatch! + const nextNum = nextMajor! * 1_000_000 + nextMinor! * 1_000 + nextPatch! + + expect(nextNum).toBeGreaterThanOrEqual(curNum) + }) +}) From 19eb5fc1c1e3fe5d28c18f40fcd0965bfc91c921 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Tue, 3 Mar 2026 17:22:24 +0000 Subject: [PATCH 2/2] fix: improve tag detection regexps --- scripts/next-version.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/next-version.ts b/scripts/next-version.ts index 2414f1892..0edd1a04f 100644 --- a/scripts/next-version.ts +++ b/scripts/next-version.ts @@ -65,15 +65,15 @@ export async function getNextVersion(): Promise { for (const line of commits) { if (/BREAKING CHANGE|!:/.test(line)) hasBreaking = true - if (/^feat[:(]/.test(line)) hasFeat = true - if (/^fix[:(]/.test(line)) hasFix = true + if (/^feat(?:\([^)]*\))?!?:/.test(line)) hasFeat = true + if (/^fix(?:\([^)]*\))?!?:/.test(line)) hasFix = true } const [major = 0, minor = 0, patch = 0] = current.split('.').map(Number) let next: string - if (hasBreaking && major > 0) { - next = `${major + 1}.0.0` + if (hasBreaking) { + next = major > 0 ? `${major + 1}.0.0` : `${major}.${minor + 1}.0` } else if (hasFeat) { next = `${major}.${minor + 1}.0` } else if (hasFix || commits.length > 0) {