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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 29 additions & 33 deletions .github/workflows/release-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand All @@ -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=""
Expand All @@ -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"
Expand All @@ -108,31 +104,31 @@ 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
if [ "$(wc -c < /tmp/pr-body.md)" -gt 60000 ]; then
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

- name: 🚀 Create or update release PR
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')

Expand Down
47 changes: 9 additions & 38 deletions .github/workflows/release-tag.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
13 changes: 8 additions & 5 deletions config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand Down Expand Up @@ -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
}
Expand Down
108 changes: 108 additions & 0 deletions scripts/next-version.ts
Original file line number Diff line number Diff line change
@@ -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<VersionInfo> {
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) {
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) {
// 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)
})
}
11 changes: 2 additions & 9 deletions test/unit/config/env.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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+$/)
})
})
31 changes: 31 additions & 0 deletions test/unit/scripts/next-version.spec.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
Loading