Skip to content

Commit 0d6e916

Browse files
authored
fix: calculate next version tag for display on home page (#1898)
1 parent 75255f1 commit 0d6e916

File tree

6 files changed

+187
-85
lines changed

6 files changed

+187
-85
lines changed

.github/workflows/release-pr.yml

Lines changed: 29 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ jobs:
2020
with:
2121
fetch-depth: 0
2222

23+
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
24+
with:
25+
node-version: lts/*
26+
2327
- name: 🔍 Check for unreleased commits
2428
id: check
2529
run: |
@@ -34,21 +38,26 @@ jobs:
3438
echo "$COMMITS"
3539
fi
3640
41+
- name: 🔢 Determine next version
42+
if: steps.check.outputs.skip == 'false'
43+
id: version
44+
run: |
45+
VERSION_JSON=$(node scripts/next-version.ts)
46+
CURRENT_VERSION=$(echo "$VERSION_JSON" | jq -r .current)
47+
NEXT_VERSION=$(echo "$VERSION_JSON" | jq -r .next)
48+
FROM_REF=$(echo "$VERSION_JSON" | jq -r .from)
49+
echo "current=$CURRENT_VERSION" >> "$GITHUB_OUTPUT"
50+
echo "next=v${NEXT_VERSION}" >> "$GITHUB_OUTPUT"
51+
echo "from=$FROM_REF" >> "$GITHUB_OUTPUT"
52+
3753
- name: 📝 Generate changelog body
3854
if: steps.check.outputs.skip == 'false'
3955
id: changelog
56+
env:
57+
CURRENT_VERSION: ${{ steps.version.outputs.current }}
58+
NEXT_VERSION: ${{ steps.version.outputs.next }}
59+
FROM_REF: ${{ steps.version.outputs.from }}
4060
run: |
41-
# Get the latest tag, or use initial commit if no tags exist
42-
LATEST_TAG=$(git describe --tags --abbrev=0 origin/release 2>/dev/null || echo "")
43-
44-
if [ -z "$LATEST_TAG" ]; then
45-
FROM_REF=$(git rev-list --max-parents=0 HEAD)
46-
CURRENT_VERSION="0.0.0"
47-
else
48-
FROM_REF="$LATEST_TAG"
49-
CURRENT_VERSION="${LATEST_TAG#v}"
50-
fi
51-
5261
# Categorize commits
5362
FEATURES=""
5463
FIXES=""
@@ -72,25 +81,12 @@ jobs:
7281
fi
7382
done <<< "$(git log "$FROM_REF"..origin/main --oneline --no-merges)"
7483
75-
# Determine next version
76-
HAS_BREAKING=$(git log "$FROM_REF"..origin/main --format='%B' | grep -c 'BREAKING CHANGE\|!:' || true)
77-
HAS_FEAT=$(git log "$FROM_REF"..origin/main --oneline --no-merges | grep -cE '^[a-f0-9]+ feat(\(|:)' || true)
78-
79-
IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT_VERSION"
80-
81-
if [ "$HAS_BREAKING" -gt 0 ] && [ "$MAJOR" -gt 0 ]; then
82-
NEXT_VERSION="$((MAJOR + 1)).0.0"
83-
elif [ "$HAS_FEAT" -gt 0 ]; then
84-
NEXT_VERSION="${MAJOR}.$((MINOR + 1)).0"
85-
else
86-
NEXT_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))"
87-
fi
88-
89-
echo "next_version=v${NEXT_VERSION}" >> "$GITHUB_OUTPUT"
84+
# Strip the leading 'v' for display
85+
DISPLAY_NEXT="${NEXT_VERSION#v}"
9086
9187
# Build the PR body
9288
BODY="This PR will deploy the following changes to production (\`npmx.dev\`).\n\n"
93-
BODY="${BODY}**Next version: \`v${NEXT_VERSION}\`** (current: \`v${CURRENT_VERSION}\`)\n\n"
89+
BODY="${BODY}**Next version: \`${NEXT_VERSION}\`** (current: \`v${CURRENT_VERSION}\`)\n\n"
9490
9591
if [ -n "$FEATURES" ]; then
9692
BODY="${BODY}### Features\n\n${FEATURES}\n"
@@ -108,31 +104,31 @@ jobs:
108104
BODY="${BODY}---\n\n"
109105
BODY="${BODY}> Merging this PR will:\n"
110106
BODY="${BODY}> - Deploy to \`npmx.dev\` via Vercel\n"
111-
BODY="${BODY}> - Create a \`v${NEXT_VERSION}\` tag and GitHub Release\n"
112-
BODY="${BODY}> - Publish \`npmx-connector@${NEXT_VERSION}\` to npm"
107+
BODY="${BODY}> - Create a \`${NEXT_VERSION}\` tag and GitHub Release\n"
108+
BODY="${BODY}> - Publish \`npmx-connector@${DISPLAY_NEXT}\` to npm"
113109
114110
# Write body to file, truncating if needed (GitHub limits PR body to 65536 chars)
115111
echo -e "$BODY" > /tmp/pr-body.md
116112
if [ "$(wc -c < /tmp/pr-body.md)" -gt 60000 ]; then
117113
COMMIT_COUNT=$(git log "$FROM_REF"..origin/main --oneline --no-merges | wc -l)
118114
COMPARE_URL="https://github.com/npmx-dev/npmx.dev/compare/${FROM_REF}...main"
119115
TRUNCATED="This PR will deploy the following changes to production (\`npmx.dev\`).\n\n"
120-
TRUNCATED="${TRUNCATED}**Next version: \`v${NEXT_VERSION}\`** (current: \`v${CURRENT_VERSION}\`)\n\n"
116+
TRUNCATED="${TRUNCATED}**Next version: \`${NEXT_VERSION}\`** (current: \`v${CURRENT_VERSION}\`)\n\n"
121117
TRUNCATED="${TRUNCATED}> **${COMMIT_COUNT} commits** are included in this release. The full changelog is too large to display here.\n>\n"
122118
TRUNCATED="${TRUNCATED}> [View full diff on GitHub](${COMPARE_URL})\n\n"
123119
TRUNCATED="${TRUNCATED}---\n\n"
124120
TRUNCATED="${TRUNCATED}> Merging this PR will:\n"
125121
TRUNCATED="${TRUNCATED}> - Deploy to \`npmx.dev\` via Vercel\n"
126-
TRUNCATED="${TRUNCATED}> - Create a \`v${NEXT_VERSION}\` tag and GitHub Release\n"
127-
TRUNCATED="${TRUNCATED}> - Publish \`npmx-connector@${NEXT_VERSION}\` to npm"
122+
TRUNCATED="${TRUNCATED}> - Create a \`${NEXT_VERSION}\` tag and GitHub Release\n"
123+
TRUNCATED="${TRUNCATED}> - Publish \`npmx-connector@${DISPLAY_NEXT}\` to npm"
128124
echo -e "$TRUNCATED" > /tmp/pr-body.md
129125
fi
130126
131127
- name: 🚀 Create or update release PR
132128
if: steps.check.outputs.skip == 'false'
133129
env:
134130
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
135-
NEXT_VERSION: ${{ steps.changelog.outputs.next_version }}
131+
NEXT_VERSION: ${{ steps.version.outputs.next }}
136132
run: |
137133
EXISTING_PR=$(gh pr list --base release --head main --state open --json number --jq '.[0].number')
138134

.github/workflows/release-tag.yml

Lines changed: 9 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -23,42 +23,18 @@ jobs:
2323
with:
2424
fetch-depth: 0
2525

26+
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
27+
with:
28+
node-version: lts/*
29+
2630
- name: 🔢 Determine next version
2731
id: version
2832
run: |
29-
# Get the latest tag on this branch
30-
LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
31-
32-
if [ -z "$LATEST_TAG" ]; then
33-
CURRENT_VERSION="0.0.0"
34-
FROM_REF=$(git rev-list --max-parents=0 HEAD)
35-
else
36-
CURRENT_VERSION="${LATEST_TAG#v}"
37-
FROM_REF="$LATEST_TAG"
38-
fi
39-
40-
# Analyze conventional commits since last tag
41-
HAS_BREAKING=$(git log "${FROM_REF}..HEAD" --format='%B' | grep -c 'BREAKING CHANGE\|!:' || true)
42-
HAS_FEAT=$(git log "${FROM_REF}..HEAD" --oneline --no-merges | grep -cE '^[a-f0-9]+ feat(\(|:)' || true)
43-
HAS_FIX=$(git log "${FROM_REF}..HEAD" --oneline --no-merges | grep -cE '^[a-f0-9]+ fix(\(|:)' || true)
44-
45-
IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT_VERSION"
46-
47-
if [ "$HAS_BREAKING" -gt 0 ] && [ "$MAJOR" -gt 0 ]; then
48-
NEXT_VERSION="$((MAJOR + 1)).0.0"
49-
elif [ "$HAS_FEAT" -gt 0 ]; then
50-
NEXT_VERSION="${MAJOR}.$((MINOR + 1)).0"
51-
elif [ "$HAS_FIX" -gt 0 ]; then
52-
NEXT_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))"
53-
else
54-
# Only chore/docs/ci commits — still bump patch
55-
NEXT_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))"
56-
fi
57-
58-
echo "current=$CURRENT_VERSION" >> "$GITHUB_OUTPUT"
59-
echo "next=v${NEXT_VERSION}" >> "$GITHUB_OUTPUT"
60-
echo "from=$FROM_REF" >> "$GITHUB_OUTPUT"
61-
echo "Bumping from v${CURRENT_VERSION} to v${NEXT_VERSION}"
33+
VERSION_JSON=$(node scripts/next-version.ts)
34+
echo "current=$(echo "$VERSION_JSON" | jq -r .current)" >> "$GITHUB_OUTPUT"
35+
echo "next=v$(echo "$VERSION_JSON" | jq -r .next)" >> "$GITHUB_OUTPUT"
36+
echo "from=$(echo "$VERSION_JSON" | jq -r .from)" >> "$GITHUB_OUTPUT"
37+
echo "Bumping from v$(echo "$VERSION_JSON" | jq -r .current) to v$(echo "$VERSION_JSON" | jq -r .next)"
6238
6339
- name: 🔍 Check if tag already exists
6440
id: check
@@ -82,11 +58,6 @@ jobs:
8258
git tag -a "$VERSION" -m "Release $VERSION"
8359
git push origin "$VERSION"
8460
85-
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
86-
if: steps.check.outputs.skip == 'false'
87-
with:
88-
node-version: lts/*
89-
9061
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # 4e1c8eafbd745f64b1ef30a7d7ed7965034c486c
9162
if: steps.check.outputs.skip == 'false'
9263
name: 🟧 Install pnpm

config/env.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import Git from 'simple-git'
44
import * as process from 'node:process'
55

66
import { version as packageVersion } from '../package.json'
7+
import { getNextVersion } from '../scripts/next-version'
78

89
export { packageVersion as version }
910

@@ -159,15 +160,17 @@ export async function getFileLastUpdated(path: string) {
159160
}
160161

161162
/**
162-
* Resolves the current version from git tags, falling back to `package.json`.
163+
* Resolves the **next** version by analysing conventional commits since the
164+
* last reachable `v*` tag. Delegates to {@link getNextVersion} which is also
165+
* used by the `release-tag` and `release-pr` GitHub Actions workflows so the
166+
* version shown in the UI matches the tag that will be created *after* deploy.
163167
*
164-
* Uses `git describe --tags --abbrev=0 --match 'v*'` to find the most recent
165-
* reachable release tag (e.g. `v0.1.0` -> `0.1.0`).
168+
* Falls back to `package.json` when git is unavailable (e.g. shallow clone).
166169
*/
167170
export async function getVersion() {
168171
try {
169-
const tag = (await git.raw(['describe', '--tags', '--abbrev=0', '--match', 'v*'])).trim()
170-
return tag.replace(/^v/, '')
172+
const { next } = await getNextVersion()
173+
return next
171174
} catch {
172175
return packageVersion
173176
}

scripts/next-version.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
/**
2+
* Calculates the next semantic version based on conventional commits since the
3+
* last reachable `v*` tag.
4+
*
5+
* Zero external dependencies — uses `child_process` to shell out to `git`.
6+
*
7+
* ### Imported as a module
8+
*
9+
* ```ts
10+
* import { getNextVersion } from './scripts/next-version'
11+
* const { current, next, from } = await getNextVersion()
12+
* ```
13+
*
14+
* ### CLI usage (outputs JSON to stdout)
15+
*
16+
* ```sh
17+
* node scripts/next-version.ts # { "current": "0.1.0", "next": "0.2.0", "from": "v0.1.0" }
18+
* node scripts/next-version.ts --next # 0.2.0
19+
* node scripts/next-version.ts --current # 0.1.0
20+
* node scripts/next-version.ts --from # v0.1.0
21+
* ```
22+
*/
23+
24+
import { execFileSync } from 'node:child_process'
25+
26+
function git(...args: string[]): string {
27+
return execFileSync('git', args, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim()
28+
}
29+
30+
export interface VersionInfo {
31+
/** The current version (from the latest tag), e.g. `"0.1.0"` */
32+
current: string
33+
/** The computed next version, e.g. `"0.2.0"` */
34+
next: string
35+
/** The git ref used as the starting point, e.g. `"v0.1.0"` or an initial commit SHA */
36+
from: string
37+
}
38+
39+
export async function getNextVersion(): Promise<VersionInfo> {
40+
let current: string
41+
let from: string
42+
43+
try {
44+
const tag = git('describe', '--tags', '--abbrev=0', '--match', 'v*')
45+
current = tag.replace(/^v/, '')
46+
from = tag
47+
} catch {
48+
// No reachable tags — start from the initial commit
49+
current = '0.0.0'
50+
from = git('rev-list', '--max-parents=0', 'HEAD')
51+
}
52+
53+
// Collect commit subjects since last tag (exclude merges)
54+
let commits: string[]
55+
try {
56+
const log = git('log', `${from}..HEAD`, '--format=%s%n%b', '--no-merges')
57+
commits = log ? log.split('\n') : []
58+
} catch {
59+
commits = []
60+
}
61+
62+
let hasBreaking = false
63+
let hasFeat = false
64+
let hasFix = false
65+
66+
for (const line of commits) {
67+
if (/BREAKING CHANGE|!:/.test(line)) hasBreaking = true
68+
if (/^feat(?:\([^)]*\))?!?:/.test(line)) hasFeat = true
69+
if (/^fix(?:\([^)]*\))?!?:/.test(line)) hasFix = true
70+
}
71+
72+
const [major = 0, minor = 0, patch = 0] = current.split('.').map(Number)
73+
74+
let next: string
75+
if (hasBreaking) {
76+
next = major > 0 ? `${major + 1}.0.0` : `${major}.${minor + 1}.0`
77+
} else if (hasFeat) {
78+
next = `${major}.${minor + 1}.0`
79+
} else if (hasFix || commits.length > 0) {
80+
// Any non-empty diff bumps at least a patch
81+
next = `${major}.${minor}.${patch + 1}`
82+
} else {
83+
// HEAD is exactly on the latest tag
84+
next = current
85+
}
86+
87+
return { current, next, from }
88+
}
89+
90+
// --- CLI entry point ---
91+
const isCLI =
92+
process.argv[1] &&
93+
(process.argv[1].endsWith('/next-version.ts') || process.argv[1].endsWith('/next-version'))
94+
95+
if (isCLI) {
96+
const flag = process.argv[2]
97+
getNextVersion()
98+
.then(info => {
99+
if (flag === '--next') console.log(info.next)
100+
else if (flag === '--current') console.log(info.current)
101+
else if (flag === '--from') console.log(info.from)
102+
else console.log(JSON.stringify(info))
103+
})
104+
.catch(err => {
105+
console.error(err)
106+
process.exit(1)
107+
})
108+
}

test/unit/config/env.spec.ts

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -327,18 +327,11 @@ describe('getProductionUrl', () => {
327327
})
328328

329329
describe('getVersion', () => {
330-
it('returns package.json version when no git tags are reachable', async () => {
331-
const { getVersion, version } = await import('../../../config/env')
332-
const result = await getVersion()
333-
334-
// In test environments without reachable tags, falls back to package.json
335-
expect(result).toBe(version)
336-
})
337-
338-
it('strips the leading "v" prefix from the tag', async () => {
330+
it('returns a valid semver string without a leading "v"', async () => {
339331
const { getVersion } = await import('../../../config/env')
340332
const result = await getVersion()
341333

342334
expect(result).not.toMatch(/^v/)
335+
expect(result).toMatch(/^\d+\.\d+\.\d+$/)
343336
})
344337
})
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { getNextVersion } from '../../../scripts/next-version'
3+
4+
describe('getNextVersion', () => {
5+
it('returns current, next, and from fields', async () => {
6+
const result = await getNextVersion()
7+
8+
expect(result).toHaveProperty('current')
9+
expect(result).toHaveProperty('next')
10+
expect(result).toHaveProperty('from')
11+
})
12+
13+
it('returns valid semver strings', async () => {
14+
const result = await getNextVersion()
15+
16+
expect(result.current).toMatch(/^\d+\.\d+\.\d+$/)
17+
expect(result.next).toMatch(/^\d+\.\d+\.\d+$/)
18+
})
19+
20+
it('returns a next version >= current version', async () => {
21+
const result = await getNextVersion()
22+
23+
const [curMajor, curMinor, curPatch] = result.current.split('.').map(Number)
24+
const [nextMajor, nextMinor, nextPatch] = result.next.split('.').map(Number)
25+
26+
const curNum = curMajor! * 1_000_000 + curMinor! * 1_000 + curPatch!
27+
const nextNum = nextMajor! * 1_000_000 + nextMinor! * 1_000 + nextPatch!
28+
29+
expect(nextNum).toBeGreaterThanOrEqual(curNum)
30+
})
31+
})

0 commit comments

Comments
 (0)