diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index e6d02a9..4965255 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -39,11 +39,8 @@ jobs: - name: โœ… Validate run: yarn validate - - name: ๐Ÿ› ๏ธ Setup package - run: yarn script:package-npm - - - name: ๐Ÿ› ๏ธ Copy files to dist - run: yarn copy-files + - name: ๐Ÿ› ๏ธ Build cross-platform package + run: yarn package - name: ๐Ÿš€ Publish NPM if: startsWith(github.ref, 'refs/tags/v') && contains(github.ref, 'beta') == false diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 00a2959..c8700b2 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -59,8 +59,38 @@ jobs: with: coverage-file: coverage/merged/kcov-merged/cobertura.xml - - name: ๐Ÿ› ๏ธ Setup package - run: yarn script:package-npm + - name: ๐Ÿ› ๏ธ Build cross-platform package + run: yarn package - - name: ๐Ÿ› ๏ธ Copy files to dist - run: yarn copy-files + package-smoke-test: + name: ๐Ÿ“ฆ Package Smoke Test (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + + steps: + - name: ๐Ÿ—œ๏ธ Checkout + uses: actions/checkout@v7 + + - name: ๐Ÿ—œ๏ธ Setup node version + uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: ๐Ÿ—œ๏ธ Setup Zig + uses: mlugg/setup-zig@v2 + with: + version: 0.16.0 + + - name: ๐Ÿ› ๏ธ Install + uses: ./.github/actions/cached-deps + with: + caching: "false" + + - name: ๐Ÿ› ๏ธ Build cross-platform package + run: yarn package + + - name: ๐Ÿงช Run packaged CLI + run: node dist/bin/package-version-info.js --version diff --git a/README.md b/README.md index 2da9771..fe4068b 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,10 @@ npm install package-version-info --save-dev yarn add package-version-info --dev ``` +The npm package includes native executables for macOS arm64/x64, Linux arm64/x64, and Windows +x64. A small Node.js launcher selects the matching executable at runtime; no platform-specific +packages are installed separately. + ### 2. Generate the file ```bash diff --git a/npm/package-version-info.js b/npm/package-version-info.js new file mode 100644 index 0000000..f2c53f4 --- /dev/null +++ b/npm/package-version-info.js @@ -0,0 +1,62 @@ +#!/usr/bin/env node +'use strict'; + +const { existsSync } = require('node:fs'); +const { join } = require('node:path'); +const { spawnSync } = require('node:child_process'); + +const supportedPlatforms = new Map([ + ['darwin-arm64', 'version_info'], + ['darwin-x64', 'version_info'], + ['linux-arm64', 'version_info'], + ['linux-x64', 'version_info'], + ['win32-x64', 'version_info.exe'], +]); + +function getBinaryPath(platform = process.platform, arch = process.arch, baseDir = __dirname) { + const id = `${platform}-${arch}`; + const binary = supportedPlatforms.get(id); + + if (!binary) { + throw new Error( + `Unsupported platform ${id}. Supported platforms: ${[...supportedPlatforms.keys()].join(', ')}.`, + ); + } + + return join(baseDir, 'native', id, binary); +} + +function resolveBinary(platform = process.platform, arch = process.arch) { + const id = `${platform}-${arch}`; + const binaryPath = getBinaryPath(platform, arch); + if (!existsSync(binaryPath)) { + throw new Error(`The package is missing its ${id} executable at ${binaryPath}.`); + } + + return binaryPath; +} + +function run(args = process.argv.slice(2)) { + const binaryPath = resolveBinary(); + const result = spawnSync(binaryPath, args, { stdio: 'inherit' }); + + if (result.error) throw result.error; + if (result.signal) { + process.kill(process.pid, result.signal); + return; + } + + process.exitCode = result.status ?? 1; +} + +if (require.main === module) { + try { + run(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`package-version-info: ${message}`); + process.exitCode = 1; + } +} + +module.exports = { getBinaryPath, resolveBinary, run }; diff --git a/package.json b/package.json index 86ab0c5..16a6839 100644 --- a/package.json +++ b/package.json @@ -10,22 +10,25 @@ "private": false, "description": "Generate version info from package.json for TypeScript projects", "scripts": { - "validate": "yarn format:check && yarn build && yarn test", + "validate": "yarn format:check && yarn test:launcher && yarn build && yarn test", "format:check": "zig fmt --check src/root.zig src/main.zig src/cli.zig build.zig", "build": "zig build", + "build:platforms": "yarn script:package-zig && yarn tsx ./scripts/build-platforms.ts", "start": "zig build run", "test": "zig build test", + "test:launcher": "node --test ./scripts/package-version-info.test.cjs", + "test:package": "yarn tsx ./scripts/verify-package.ts", "coverage:build": "rimraf coverage zig-out/coverage-tests && zig build coverage", "clean": "rimraf zig-out dist", "prepare": "husky", - "copy-files": "copyfiles -u 2 zig-out/bin/version_info dist/bin && copyfiles README.md LICENSE dist", + "package": "yarn build:platforms && yarn script:package-npm && yarn test:package", "postinstall": "yarn script:package-zig", "script:package-zig": "yarn tsx ./scripts/package-zig.ts", "script:package-npm": "yarn tsx ./scripts/package-npm.ts", "script:package-github": "yarn tsx ./scripts/package-github.ts", "version": "auto-changelog -p && git add CHANGELOG.md", "postversion": "git push && git push --follow-tags", - "publish:beta": "yarn && yarn build && npm publish --folder dist --tag beta", + "publish:beta": "yarn && yarn validate && yarn package && npm publish --folder dist --tag beta", "release:beta": "npm version prerelease -m \"chore(update): prelease %s ฮฒ\"", "release:patch": "git checkout master && npm version patch -m \"chore(update): patch release %s ๐Ÿ› \"", "release:minor": "git checkout master && npm version minor -m \"chore(update): release %s ๐Ÿš€\"", diff --git a/scripts/build-platforms.ts b/scripts/build-platforms.ts new file mode 100644 index 0000000..5cfaace --- /dev/null +++ b/scripts/build-platforms.ts @@ -0,0 +1,30 @@ +import { emptyDirSync, ensureDirSync } from 'fs-extra'; +import { resolve } from 'path'; +import { spawnSync } from 'child_process'; + +import { platforms } from './platforms'; + +const rootDir = resolve(__dirname, '..'); +const outputDir = resolve(rootDir, 'zig-out', 'platforms'); +emptyDirSync(outputDir); + +for (const platform of platforms) { + const prefix = resolve(outputDir, platform.id); + ensureDirSync(prefix); + console.log(`Building ${platform.id} (${platform.target})...`); + + const result = spawnSync( + 'zig', + [ + 'build', + `-Dtarget=${platform.target}`, + '-Doptimize=ReleaseSmall', + '--prefix', + prefix, + ], + { cwd: rootDir, stdio: 'inherit' }, + ); + + if (result.error) throw result.error; + if (result.status !== 0) process.exit(result.status ?? 1); +} diff --git a/scripts/package-github.ts b/scripts/package-github.ts index eabac33..641d319 100644 --- a/scripts/package-github.ts +++ b/scripts/package-github.ts @@ -1,24 +1,14 @@ -import { ensureDirSync, writeFileSync } from 'fs-extra'; +import { readJsonSync, writeFileSync } from 'fs-extra'; import { join, resolve } from 'path'; -const pkg = require(resolve(__dirname, '..', 'package.json')); - -pkg.scripts = undefined; -pkg.devDependencies = undefined; -pkg.packageManager = undefined; -pkg.engines = undefined; -pkg.files = undefined; -pkg.bin = { - "package-version-info": "bin/version_info" -}; +const distDir = resolve(__dirname, '..', 'dist'); +const pkg = readJsonSync(join(distDir, 'package.json')); pkg.name = '@celtian/package-version-info'; pkg.publishConfig = { registry: 'https://npm.pkg.github.com' }; -const distDir = join(__dirname, '..', 'dist'); -ensureDirSync(distDir); writeFileSync(join(distDir, 'package.json'), JSON.stringify(pkg, null, 2)); console.log('\x1b[34m', `Package.json in dist/ modified with publishConfig and name.`, '\x1b[0m'); diff --git a/scripts/package-npm.ts b/scripts/package-npm.ts index e636b8b..c04370c 100644 --- a/scripts/package-npm.ts +++ b/scripts/package-npm.ts @@ -1,7 +1,17 @@ -import { writeFileSync, ensureDirSync } from 'fs-extra'; +import { + chmodSync, + copyFileSync, + emptyDirSync, + ensureDirSync, + pathExistsSync, + writeFileSync, +} from 'fs-extra'; import { join, resolve } from 'path'; -const pkg = require(resolve(__dirname, '..', 'package.json')); +import { platforms } from './platforms'; + +const rootDir = resolve(__dirname, '..'); +const pkg = require(join(rootDir, 'package.json')); pkg.scripts = undefined; pkg.devDependencies = undefined; @@ -9,13 +19,35 @@ pkg.packageManager = undefined; pkg.engines = undefined; pkg.files = undefined; pkg.bin = { - "package-version-info": "bin/version_info" + 'package-version-info': 'bin/package-version-info.js', }; -const distDir = join(__dirname, '..', 'dist'); -ensureDirSync(distDir); +const distDir = join(rootDir, 'dist'); +emptyDirSync(distDir); writeFileSync(join(distDir, 'package.json'), JSON.stringify(pkg, null, 2)); -console.log('\x1b[34m', 'Package.json in dist/ modified.', '\x1b[0m'); +for (const file of ['README.md', 'LICENSE']) { + copyFileSync(join(rootDir, file), join(distDir, file)); +} + +const binDir = join(distDir, 'bin'); +ensureDirSync(binDir); +const launcherPath = join(binDir, 'package-version-info.js'); +copyFileSync(join(rootDir, 'npm', 'package-version-info.js'), launcherPath); +chmodSync(launcherPath, 0o755); + +for (const platform of platforms) { + const source = join(rootDir, 'zig-out', 'platforms', platform.id, 'bin', platform.binary); + if (!pathExistsSync(source)) { + throw new Error(`Missing ${platform.id} binary. Run \"yarn build:platforms\" first.`); + } + + const nativeDir = join(binDir, 'native', platform.id); + ensureDirSync(nativeDir); + const destination = join(nativeDir, platform.binary); + copyFileSync(source, destination); + if (platform.os !== 'win32') chmodSync(destination, 0o755); +} +console.log('\x1b[34m', 'Cross-platform npm package created in dist/.', '\x1b[0m'); diff --git a/scripts/package-version-info.test.cjs b/scripts/package-version-info.test.cjs new file mode 100644 index 0000000..3dd356b --- /dev/null +++ b/scripts/package-version-info.test.cjs @@ -0,0 +1,29 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const { test } = require('node:test'); +const { join } = require('node:path'); + +const { getBinaryPath } = require('../npm/package-version-info.js'); + +test('resolves every supported platform to its bundled binary', () => { + const cases = [ + ['darwin', 'arm64', 'darwin-arm64', 'version_info'], + ['darwin', 'x64', 'darwin-x64', 'version_info'], + ['linux', 'arm64', 'linux-arm64', 'version_info'], + ['linux', 'x64', 'linux-x64', 'version_info'], + ['win32', 'x64', 'win32-x64', 'version_info.exe'], + ]; + + for (const [platform, arch, id, binary] of cases) { + const expected = join(__dirname, '..', 'npm', 'native', id, binary); + assert.equal(getBinaryPath(platform, arch, join(__dirname, '..', 'npm')), expected); + } +}); + +test('reports unsupported platforms clearly', () => { + assert.throws( + () => getBinaryPath('freebsd', 'x64'), + /Unsupported platform freebsd-x64.*darwin-arm64.*win32-x64/, + ); +}); diff --git a/scripts/platforms.ts b/scripts/platforms.ts new file mode 100644 index 0000000..5674bae --- /dev/null +++ b/scripts/platforms.ts @@ -0,0 +1,45 @@ +export type Platform = { + id: string; + os: NodeJS.Platform; + arch: NodeJS.Architecture; + target: string; + binary: string; +}; + +export const platforms: Platform[] = [ + { + id: 'darwin-arm64', + os: 'darwin', + arch: 'arm64', + target: 'aarch64-macos', + binary: 'version_info', + }, + { + id: 'darwin-x64', + os: 'darwin', + arch: 'x64', + target: 'x86_64-macos', + binary: 'version_info', + }, + { + id: 'linux-arm64', + os: 'linux', + arch: 'arm64', + target: 'aarch64-linux-musl', + binary: 'version_info', + }, + { + id: 'linux-x64', + os: 'linux', + arch: 'x64', + target: 'x86_64-linux-musl', + binary: 'version_info', + }, + { + id: 'win32-x64', + os: 'win32', + arch: 'x64', + target: 'x86_64-windows', + binary: 'version_info.exe', + }, +]; diff --git a/scripts/verify-package.ts b/scripts/verify-package.ts new file mode 100644 index 0000000..233ebc9 --- /dev/null +++ b/scripts/verify-package.ts @@ -0,0 +1,52 @@ +import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join, resolve } from 'path'; +import { spawnSync } from 'child_process'; + +import { platforms } from './platforms'; + +const rootDir = resolve(__dirname, '..'); +const distDir = join(rootDir, 'dist'); +const pkg = JSON.parse(readFileSync(join(distDir, 'package.json'), 'utf8')); + +if (pkg.bin?.['package-version-info'] !== 'bin/package-version-info.js') { + throw new Error('The package bin does not point to the portable launcher.'); +} + +statSync(join(distDir, 'bin', 'package-version-info.js')); +for (const platform of platforms) { + statSync(join(distDir, 'bin', 'native', platform.id, platform.binary)); +} + +const versionResult = spawnSync( + process.execPath, + [join(distDir, 'bin', 'package-version-info.js'), '--version'], + { encoding: 'utf8' }, +); +if (versionResult.status !== 0 || !`${versionResult.stdout}${versionResult.stderr}`.includes(`Version: ${pkg.version}`)) { + throw new Error(`Packaged launcher has incorrect version metadata:\n${versionResult.stdout}${versionResult.stderr}`); +} + +const testDir = mkdtempSync(join(tmpdir(), 'package-version-info-package-')); +try { + writeFileSync(join(testDir, 'package.json'), JSON.stringify({ name: 'smoke-test', version: '1.2.3' })); + const outputPath = join(testDir, 'version-info.ts'); + const result = spawnSync( + process.execPath, + [join(distDir, 'bin', 'package-version-info.js'), '--output', outputPath, '--git', join(testDir, '.git')], + { cwd: testDir, encoding: 'utf8' }, + ); + + if (result.status !== 0) { + throw new Error(`Packaged launcher failed:\n${result.stdout}${result.stderr}`); + } + + const generated = readFileSync(outputPath, 'utf8'); + if (!generated.includes('version: "1.2.3"') || generated.includes('git:')) { + throw new Error('Packaged launcher generated unexpected version information.'); + } +} finally { + rmSync(testDir, { recursive: true, force: true }); +} + +console.log('Packaged launcher and all native executables verified.'); diff --git a/src/main.zig b/src/main.zig index 76613a0..557cf43 100644 --- a/src/main.zig +++ b/src/main.zig @@ -3,7 +3,8 @@ const cli = @import("cli"); const version_info = @import("version_info"); pub fn main(init: std.process.Init) !void { - var args = init.minimal.args.iterate(); + var args = try init.minimal.args.iterateAllocator(init.gpa); + defer args.deinit(); _ = args.next(); switch (cli.parse(&args)) {