Skip to content
Open
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
18 changes: 18 additions & 0 deletions .github/actions/check-version-change/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
name: Check version change
description: Detect whether the package.json version changed since the previous commit

outputs:
changed:
description: 'true if the version changed since HEAD^'
value: ${{ steps.check.outputs.changed }}
current:
description: 'The current version in package.json'
value: ${{ steps.check.outputs.current }}

runs:
using: composite
steps:
- name: Run check
id: check
shell: bash
run: yarn tsx "$GITHUB_ACTION_PATH/check-version-change.ts"
44 changes: 44 additions & 0 deletions .github/actions/check-version-change/check-version-change.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { execSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import * as core from '@actions/core';
import semver from 'semver';

const readVersion = (json: string, source: string): string => {
const parsed: unknown = JSON.parse(json);
if (
typeof parsed !== 'object' ||
parsed === null ||
!('version' in parsed) ||
typeof parsed.version !== 'string'
) {
throw new Error(`${source} is missing a string "version" field`);
}
const valid = semver.valid(parsed.version);
if (valid === null) {
throw new Error(`${source} has invalid semver version "${parsed.version}"`);
}
return valid;
};

try {
const current = readVersion(
readFileSync('package.json', 'utf8'),
'package.json',
);
const previous = readVersion(
execSync('git show HEAD^:package.json', { encoding: 'utf8' }),
'package.json@HEAD^',
);
const changed = !semver.eq(current, previous);

core.info(
changed
? `Version changed: ${previous} -> ${current}`
: `Version unchanged (${current}); skipping publish.`,
);

core.setOutput('current', current);
core.setOutput('changed', changed);
} catch (error) {
core.setFailed(error instanceof Error ? error : 'An unknown error occurred');
}
57 changes: 57 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -74,3 +74,60 @@ jobs:

- name: Run test
run: yarn test

version-check:
name: Version check
needs: [format, lint, types, build, test]
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
outputs:
changed: ${{ steps.version.outputs.changed }}
current: ${{ steps.version.outputs.current }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
fetch-depth: 2

- name: Setup node
uses: ./.github/actions/setup-node

- name: Check for version change
id: version
uses: ./.github/actions/check-version-change

publish:
name: Publish
needs: version-check
if: needs.version-check.outputs.changed == 'true'
runs-on: ubuntu-latest
permissions:
contents: write
env:
VERSION: ${{ needs.version-check.outputs.current }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6

- name: Setup node
uses: ./.github/actions/setup-node

- name: Build
run: yarn build

- name: Publish to npm
run: yarn npm publish
env:
YARN_NPM_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

- name: Create git tag
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag -a "v$VERSION" -m "v$VERSION"
git push origin "v$VERSION"

- name: Create GitHub Release
env:
GH_TOKEN: ${{ github.token }}
run: gh release create "v$VERSION" --title "v$VERSION" --generate-notes
6 changes: 6 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"scripts": {
"build": "tsdown",
"build:watch": "tsdown --watch",
"bump": "tsx scripts/bump-version.ts",
"format": "yarn oxfmt",
"lint": "yarn oxlint",
"precommit": "lint-staged",
Expand All @@ -34,17 +35,22 @@
}
},
"devDependencies": {
"@actions/core": "^3.0.1",
"@arethetypeswrong/core": "^0.18.2",
"@inquirer/prompts": "^8.4.2",
"@types/node": "^24.0.0",
"@types/react": "^19.0.0",
"@types/semver": "^7.7.1",
"@vitest/browser-playwright": "^4.1.5",
"lint-staged": "^16.4.0",
"oxfmt": "^0.38.0",
"oxlint": "^1.53.0",
"oxlint-tsgolint": "^0.16.0",
"playwright": "^1.49.0",
"publint": "^0.3.18",
"semver": "^7.7.4",
"tsdown": "^0.21.10",
"tsx": "^4.21.0",
"typescript": "^6.0.2",
"unplugin-unused": "^0.5.7",
"vitest": "^4.1.5"
Expand Down
124 changes: 124 additions & 0 deletions scripts/bump-version.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { readFileSync, writeFileSync } from 'node:fs';

import { select } from '@inquirer/prompts';
import * as semver from 'semver';

type BaseBump = 'patch' | 'minor' | 'major';
type Choice = { label: string; next: string };

const PACKAGE_JSON_PATH = 'package.json';
const PRE_TAG = 'pre';

const PREBUMP: Record<BaseBump, semver.ReleaseType> = {
patch: 'prepatch',
minor: 'preminor',
major: 'premajor',
};

const isPrerelease = (parsed: semver.SemVer): boolean => {
if (parsed.prerelease.length === 0) return false;
const [tag, counter, ...rest] = parsed.prerelease;
if (tag !== PRE_TAG || typeof counter !== 'number' || rest.length > 0) {
throw new Error(
`Unsupported prerelease "${parsed.version}"; expected x.y.z-${PRE_TAG}.N`,
);
}
return true;
};

const baseStable = (parsed: semver.SemVer): string =>
`${parsed.major}.${parsed.minor}.${parsed.patch}`;

const inc = (
version: string,
release: semver.ReleaseType,
identifier?: typeof PRE_TAG,
): string => {
const result =
identifier === undefined
? semver.inc(version, release)
: semver.inc(version, release, identifier);
if (result === null) {
throw new Error(`semver.inc failed: inc("${version}", "${release}")`);
}
return result;
};

const buildChoices = (current: semver.SemVer): Choice[] => {
const choices: Choice[] = [];

if (isPrerelease(current)) {
choices.push({
label: 'prerelease',
next: inc(current.version, 'prerelease'),
});
choices.push({ label: 'release', next: baseStable(current) });
}

const stable = baseStable(current);
for (const bump of ['patch', 'minor', 'major'] as const) {
choices.push({ label: bump, next: inc(stable, bump) });
choices.push({
label: `${bump} (${PRE_TAG})`,
next: inc(stable, PREBUMP[bump], PRE_TAG),
});
}

return choices;
};

const promptChoice = async (
current: string,
choices: Choice[],
): Promise<Choice> => {
const labelWidth = Math.max(...choices.map(c => c.label.length));
return select({
message: `Select a bump (current: ${current}):`,
choices: choices.map(c => ({
name: `${c.label.padEnd(labelWidth)} -> ${c.next}`,
value: c,
})),
});
};

const readCurrentVersion = (): semver.SemVer => {
const pkg: unknown = JSON.parse(readFileSync(PACKAGE_JSON_PATH, 'utf8'));
if (
typeof pkg !== 'object' ||
pkg === null ||
!('version' in pkg) ||
typeof pkg.version !== 'string'
) {
throw new Error('package.json is missing a string "version" field');
}
const parsed = semver.parse(pkg.version);
if (parsed === null) {
throw new Error(`Invalid semver version in package.json: "${pkg.version}"`);
}
return parsed;
};

const writeNewVersion = (version: string): void => {
const raw = readFileSync(PACKAGE_JSON_PATH, 'utf8');
const updated = raw.replace(/("version"\s*:\s*")[^"]+(")/, `$1${version}$2`);
if (updated === raw) {
throw new Error('Failed to locate "version" field in package.json');
}
writeFileSync(PACKAGE_JSON_PATH, updated);
};

const main = async (): Promise<void> => {
const current = readCurrentVersion();
const choices = buildChoices(current);
const choice = await promptChoice(current.version, choices);
writeNewVersion(choice.next);
console.log(`Bumped ${current.version} -> ${choice.next}`);
};

main().catch((error: unknown) => {
if (error instanceof Error && error.name === 'ExitPromptError') {
process.exit(130);
}
console.error(error instanceof Error ? error.message : error);
process.exit(1);
});
8 changes: 7 additions & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,11 @@
"skipLibCheck": true,
"types": ["vitest/globals"]
},
"include": ["src", "vitest.config.ts", "tsdown.config.ts"]
"include": [
"src",
"scripts",
".github/actions/**/*.ts",
"vitest.config.ts",
"tsdown.config.ts"
]
}
Loading
Loading