From 8327715e011e5d1f5e0df16267acdd37a0b92a8d Mon Sep 17 00:00:00 2001 From: Andrei Surdu Date: Thu, 18 Jun 2026 00:36:11 +0300 Subject: [PATCH 1/2] Remove chalk & replace-in-file deps; add Vitest tests and modernize CI Security: drop chalk (advisories MAL-2025-46969 / MSC-2025-7884) and replace-in-file, which transitively pulled chalk in. Both are replaced by tiny zero-dependency, cross-platform helpers, removing chalk from the dependency tree entirely (also drops glob and a duplicate yargs). Refactor: extract pure logic into lib.js (color, file replacement, placeholder substitution, version helpers, package.json bump) so it is unit-testable; index.js keeps only the git/prompts orchestration. Tests: add Vitest (dev only) with 32 cases in test/lib.test.js; wire up `npm test` / `npm run test:watch`. CI: upgrade publish workflow to checkout@v6 / setup-node@v6 / Node 22, run the test suite before publishing, and drop the dead sed step. Add a package.json `files` allowlist so only runtime files are published. Docs: add CLAUDE.md. --- .github/workflows/npmpublish.yml | 16 +-- CLAUDE.md | 44 ++++++ index.js | 87 ++++-------- lib.js | 145 ++++++++++++++++++++ package.json | 15 +- test/lib.test.js | 227 +++++++++++++++++++++++++++++++ 6 files changed, 462 insertions(+), 72 deletions(-) create mode 100644 CLAUDE.md create mode 100644 lib.js create mode 100644 test/lib.test.js diff --git a/.github/workflows/npmpublish.yml b/.github/workflows/npmpublish.yml index 94904e9..5f50708 100644 --- a/.github/workflows/npmpublish.yml +++ b/.github/workflows/npmpublish.yml @@ -6,18 +6,16 @@ on: - "*.*.*" jobs: - build: + publish: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v1 - - uses: actions/setup-node@v1 + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 with: - node-version: 10 + node-version: 22 registry-url: https://registry.npmjs.org/ - - run: | - sed -i -e 's|"version": "1.0.0"|"version": "'"$(sed -e "s/refs\/tags\///g" <<< $GITHUB_REF)"'"|g' ./package.json - + - run: npm install + - run: npm test - run: npm publish env: - NODE_AUTH_TOKEN: ${{secrets.npm_token}} - + NODE_AUTH_TOKEN: ${{ secrets.npm_token }} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..c064bc3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,44 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this is + +`tagy` is a globally-installed CLI (`bin: tagy` → `cli.js`) that creates a SemVer git tag, bumps `package.json`, optionally runs custom file replacements, pushes the commit + tag to origin, and optionally creates a GitHub release. It runs against *the user's* current working directory (`process.cwd()`), not against this repo. + +## Commands + +- Test: `npm test` (Vitest, single run) or `npm run test:watch`. Run one file: `npx vitest run test/lib.test.js`; one case: `npx vitest run -t "nextVersion"`. +- Run locally against another project: `node /Users/awps/Projects/MY/tagy/cli.js --patch` from that project's directory. +- Install for local dev: `npm i -g .` then use `tagy ...` anywhere. +- Releasing tagy itself is done with tagy: `tagy --patch` (see recent commits "Release x.y.z"). + +There is no build or lint step. + +CLI flags: `-p/--patch`, `-m/--minor`, `--major`, `--reverse`, `--info`, `--custom`, `--soft`, `--auto-release`, `-h`. + +## Architecture + +Code is split between two files (`cli.js` is a 6-line shim that requires and invokes `index.js`): + +- **`lib.js`** — pure, side-effect-free helpers, each unit-tested in `test/lib.test.js`: `createColor` (chainable zero-dep chalk replacement; color on for TTY/`FORCE_COLOR`, off for `NO_COLOR`/piped), `replaceInFiles` (zero-dep replace-in-file replacement), `substitute` (`__VERSION__`/`__CURRENT_TAG__`), `buildReplaceConfig`, `resolveIncrement`, `normalizeCurrentVersion`, `nextVersion`, `bumpPackageVersion`. Dependencies (fs, cwd) are passed as args so they're testable. **Prefer adding new logic here** rather than in the IIFE. +- **`index.js`** — the CLI orchestration: one big async IIFE that wires the `lib.js` helpers together with the side-effecting parts (git via `shelljs`, interactive `prompts`, reading the target `package.json`). This shell is not unit-tested. + +Execution flow of the `index.js` IIFE: + +1. **Arg validation** — rejects too many args or conflicting increment flags (only one of patch/minor/major/reverse/custom/info allowed). +2. **Read `package.json`** from `process.cwd()`; pull the optional `tagy` config block (`tagPrefix`, `soft`, `auto-release`, `replace`). +3. **Soft mode** (`--soft` or `tagy.soft`) — does file replacement only; never touches git. `tagy.method === 'soft'` is the deprecated form. +4. **Determine current version** — in git mode, reads the latest existing tag via `git tag --sort=v:refname | grep '^[0-9]' | tail -1`; in soft mode uses `package.json` version. `--info` short-circuits here. +5. **Compute next version** — `semver.inc()` for patch/minor/major, or a prompted custom value. `--major` and `--reverse` require confirmation prompts. +6. **Apply changes** — bump `package.json`; run `tagy.js` hook if present; run `tagy.replace` rules. +7. **Git ops** (non-soft only) — commit, push branch, create tag, push tag, then optionally `gh release create`. + +### Key behaviors to preserve + +- **External tooling via `shelljs`**: relies on the user having `git` and (for releases) the `gh` CLI on PATH. Branch detection only proceeds on `master`/`main` without a confirmation prompt. +- **`tagPrefix`** is prepended to the git tag and release name (e.g. `v1.2.3`) but the `package.json` version stays unprefixed. +- **Extensibility hooks** run before `git push`: + - A `tagy.js` file in the target project: `module.exports = (newVersion, oldVersion, args) => {...}`. + - `tagy.replace[]` rules in `package.json`, each `{files, from, to, flags}`. `from`/`to` support `__VERSION__` (new version) and `__CURRENT_TAG__` placeholders; `from` is compiled to a `RegExp`. +- All user interaction uses `prompts`; all colored output uses `chalk`. \ No newline at end of file diff --git a/index.js b/index.js index 432a149..fef3a24 100755 --- a/index.js +++ b/index.js @@ -4,39 +4,22 @@ const fs = require('fs-extra') const path = require('path') const shell = require("shelljs") -const semver = require('semver') const args = require('yargs').argv const prompts = require('prompts') -const chalk = require("chalk"); -const replace = require("replace-in-file"); - -const packageVersionBump = async (vv) => { - const pkgPath = path.join(process.cwd(), 'package.json') - - if (fs.existsSync(pkgPath)) { - let pkgContent; - - try { - pkgContent = await fs.readJSON(pkgPath); - } catch (err) { - throw new Error(`Couldn't parse "package.json"`) - } - - pkgContent.version = vv; - - try { - await fs.writeJSON(pkgPath, pkgContent, { - spaces: 2 - }); - } catch (err) { - throw new Error(`Couldn't write to "package.json"`) - } - - return true; - } - - return true; -} +const { + createColor, + replaceInFiles, + buildReplaceConfig, + resolveIncrement, + normalizeCurrentVersion, + nextVersion, + bumpPackageVersion, +} = require('./lib') + +const chalk = createColor({ + isTTY: Boolean(process.stdout && process.stdout.isTTY), + env: process.env, +}) module.exports = function () { (async () => { @@ -235,29 +218,19 @@ Options: currentTag = vv; } else { - if (!vv) { - vv = '0.0.0'; - } - - vv = vv.trim(); - - if (semver.ltr(vv, '0.0.0')) { - vv = '0.0.0' - } + vv = normalizeCurrentVersion(vv); currentTag = vv; - if (args.p || args.patch) { - vv = semver.inc(vv, 'patch') - } else if (args.m || args.minor) { - vv = semver.inc(vv, 'minor') - } else if (args.major) { - vv = semver.inc(vv, 'major') - } else { + const incrementType = resolveIncrement(args); + + if (!incrementType) { console.log(chalk.red(`Something went wrong!.`)) return; } + vv = nextVersion(vv, incrementType); + if (args.major) { const confirmMajorRelease = await prompts({ type: 'confirm', @@ -276,7 +249,7 @@ Options: // Bump the version in package.json try { - canCreate = await packageVersionBump(vv); + canCreate = await bumpPackageVersion(vv); } catch (err) { return console.log(err.message); } @@ -302,18 +275,14 @@ Options: console.log(chalk.green('♾️ Replacement methods are found!')); for (let i = 0; i < replacementMethods.length; i++) { - const {files, from, to, flags} = replacementMethods[i]; - - if (files && from && to) { - const _files = Array.isArray(files) ? files : [files]; - - const replaceConf = { - files: _files.map(file => path.resolve(`${process.cwd()}/${file}`)), - from: new RegExp(from.replaceAll('__CURRENT_TAG__', currentTag).replaceAll('__VERSION__', vv), flags !== false ? (flags || 'g') : undefined), - to: to.replaceAll('__CURRENT_TAG__', currentTag).replaceAll('__VERSION__', vv), - }; + const replaceConf = buildReplaceConfig(replacementMethods[i], { + version: vv, + currentTag, + cwd: process.cwd(), + }); - replace.sync(replaceConf); + if (replaceConf) { + replaceInFiles(replaceConf); } } } diff --git a/lib.js b/lib.js new file mode 100644 index 0000000..9804188 --- /dev/null +++ b/lib.js @@ -0,0 +1,145 @@ +'use strict' + +// Pure, testable helpers for tagy. The CLI orchestration (git, prompts, +// process I/O) lives in index.js; everything here is side-effect-free or +// takes its dependencies (fs, cwd) as arguments so it can be unit-tested. + +const path = require('path') +const fs = require('fs-extra') +const semver = require('semver') + +const COLOR_CODES = { + red: [31, 39], + green: [32, 39], + blue: [34, 39], + yellow: [33, 39], + bold: [1, 22], +} + +// Zero-dependency, cross-platform replacement for `chalk`. Returns a chainable +// object (e.g. `color.red.bold(text)`). Color is enabled when FORCE_COLOR is +// set or the output is a TTY, and disabled when NO_COLOR is set or output is +// piped/non-TTY — matching chalk's behavior. +function createColor({isTTY = false, env = {}} = {}) { + const supportsColor = (() => { + if ('NO_COLOR' in env) return false; + if (env.FORCE_COLOR) return true; + return Boolean(isTTY); + })(); + + const build = (styles) => { + const fn = (text) => { + if (!supportsColor) return String(text); + return styles.reduce((str, name) => { + const [open, close] = COLOR_CODES[name]; + return `\x1b[${open}m${str}\x1b[${close}m`; + }, String(text)); + }; + + Object.keys(COLOR_CODES).forEach((name) => { + Object.defineProperty(fn, name, { + get: () => build([...styles, name]), + }); + }); + + return fn; + }; + + return build([]); +} + +// Zero-dependency replacement for `replace-in-file`. `files` are already +// resolved absolute paths (no glob expansion); `from` is a RegExp and `to` a +// string. Writes back only when the contents actually change. +function replaceInFiles({files, from, to}, fsModule = fs) { + files.forEach((file) => { + if (!fsModule.existsSync(file)) return; + const content = fsModule.readFileSync(file, 'utf8'); + const replaced = content.replace(from, to); + if (replaced !== content) { + fsModule.writeFileSync(file, replaced); + } + }); +} + +// Replace the `__VERSION__` and `__CURRENT_TAG__` placeholders in a string. +function substitute(value, {version, currentTag}) { + return String(value) + .replaceAll('__CURRENT_TAG__', currentTag) + .replaceAll('__VERSION__', version); +} + +// Turn a single `tagy.replace` rule into a config for replaceInFiles(). +// Returns null when the rule is missing any of files/from/to. +function buildReplaceConfig(rule, {version, currentTag, cwd}) { + const {files, from, to, flags} = rule; + + if (!(files && from && to)) return null; + + const list = Array.isArray(files) ? files : [files]; + + return { + files: list.map((file) => path.resolve(`${cwd}/${file}`)), + from: new RegExp(substitute(from, {version, currentTag}), flags !== false ? (flags || 'g') : undefined), + to: substitute(to, {version, currentTag}), + }; +} + +// Map yargs args to a semver increment type, or null if none/invalid. +function resolveIncrement(args) { + if (args.p || args.patch) return 'patch'; + if (args.m || args.minor) return 'minor'; + if (args.major) return 'major'; + return null; +} + +// Normalize a raw current version (from a git tag or package.json) to a clean +// semver string, defaulting to '0.0.0' when missing or below it. +function normalizeCurrentVersion(vv) { + if (!vv) return '0.0.0'; + const trimmed = String(vv).trim(); + if (!trimmed || semver.ltr(trimmed, '0.0.0')) return '0.0.0'; + return trimmed; +} + +// Compute the next version for a given increment type. +function nextVersion(current, type) { + return semver.inc(current, type); +} + +// Write a new version into the package.json found in `cwd`. Resolves true when +// there is no package.json (nothing to bump) or after a successful write. +async function bumpPackageVersion(vv, {cwd = process.cwd(), fs: fsModule = fs} = {}) { + const pkgPath = path.join(cwd, 'package.json'); + + if (!fsModule.existsSync(pkgPath)) return true; + + let pkgContent; + try { + pkgContent = await fsModule.readJSON(pkgPath); + } catch (err) { + throw new Error(`Couldn't parse "package.json"`); + } + + pkgContent.version = vv; + + try { + await fsModule.writeJSON(pkgPath, pkgContent, {spaces: 2}); + } catch (err) { + throw new Error(`Couldn't write to "package.json"`); + } + + return true; +} + +module.exports = { + COLOR_CODES, + createColor, + replaceInFiles, + substitute, + buildReplaceConfig, + resolveIncrement, + normalizeCurrentVersion, + nextVersion, + bumpPackageVersion, +} diff --git a/package.json b/package.json index 9bdefb9..e936e05 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,8 @@ "description": "Create a new git tag by following the 'Semantic Versioning' and push it on remote.", "main": "index.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "test": "vitest run", + "test:watch": "vitest" }, "repository": { "type": "git", @@ -20,19 +21,25 @@ "release", "semver" ], - "author": "Andrew Surdu", + "author": "Andrei Surdu", "license": "MIT", "homepage": "https://github.com/awps/tagy#readme", "bin": { "tagy": "./cli.js" }, + "files": [ + "cli.js", + "index.js", + "lib.js" + ], "dependencies": { - "chalk": "^2.4.2", "fs-extra": "^8.1.0", "prompts": "^2.3.0", - "replace-in-file": "^7.0.1", "semver": "^6.3.0", "shelljs": "^0.9.0", "yargs": "^13.2.4" + }, + "devDependencies": { + "vitest": "^4.1.9" } } diff --git a/test/lib.test.js b/test/lib.test.js new file mode 100644 index 0000000..1ad9c1e --- /dev/null +++ b/test/lib.test.js @@ -0,0 +1,227 @@ +import {describe, it, expect, beforeEach, afterEach} from 'vitest' +import {tmpdir} from 'node:os' +import {mkdtempSync, writeFileSync, readFileSync, rmSync, existsSync} from 'node:fs' +import {join, resolve} from 'node:path' + +import { + createColor, + replaceInFiles, + substitute, + buildReplaceConfig, + resolveIncrement, + normalizeCurrentVersion, + nextVersion, + bumpPackageVersion, +} from '../lib.js' + +describe('createColor', () => { + it('emits ANSI codes when FORCE_COLOR is set', () => { + const c = createColor({isTTY: false, env: {FORCE_COLOR: '1'}}) + expect(c.blue('Y')).toBe('\x1b[34mY\x1b[39m') + }) + + it('nests chained styles (red.bold) correctly', () => { + const c = createColor({isTTY: true, env: {}}) + expect(c.red.bold('X')).toBe('\x1b[1m\x1b[31mX\x1b[39m\x1b[22m') + }) + + it('emits codes for a TTY with no env overrides', () => { + const c = createColor({isTTY: true, env: {}}) + expect(c.green('Z')).toBe('\x1b[32mZ\x1b[39m') + }) + + it('returns plain text when NO_COLOR is set (even on a TTY)', () => { + const c = createColor({isTTY: true, env: {NO_COLOR: '1'}}) + expect(c.red.bold('X')).toBe('X') + }) + + it('returns plain text for non-TTY output (piped) with no env', () => { + const c = createColor({isTTY: false, env: {}}) + expect(c.yellow('W')).toBe('W') + }) + + it('NO_COLOR wins over FORCE_COLOR', () => { + const c = createColor({isTTY: true, env: {NO_COLOR: '', FORCE_COLOR: '1'}}) + expect(c.blue('Y')).toBe('Y') + }) + + it('coerces non-string input to a string', () => { + const c = createColor({isTTY: false, env: {}}) + expect(c.red(42)).toBe('42') + }) +}) + +describe('substitute', () => { + it('replaces __VERSION__ and __CURRENT_TAG__ (all occurrences)', () => { + expect(substitute('v __VERSION__ / __CURRENT_TAG__ / __VERSION__', {version: '1.2.4', currentTag: '1.2.3'})) + .toBe('v 1.2.4 / 1.2.3 / 1.2.4') + }) + + it('coerces non-string values', () => { + expect(substitute(123, {version: '1.0.0', currentTag: '0.9.0'})).toBe('123') + }) +}) + +describe('resolveIncrement', () => { + it('maps -p / --patch to patch', () => { + expect(resolveIncrement({p: true})).toBe('patch') + expect(resolveIncrement({patch: true})).toBe('patch') + }) + + it('maps -m / --minor to minor', () => { + expect(resolveIncrement({m: true})).toBe('minor') + expect(resolveIncrement({minor: true})).toBe('minor') + }) + + it('maps --major to major', () => { + expect(resolveIncrement({major: true})).toBe('major') + }) + + it('prioritizes patch over minor over major', () => { + expect(resolveIncrement({p: true, minor: true, major: true})).toBe('patch') + expect(resolveIncrement({minor: true, major: true})).toBe('minor') + }) + + it('returns null when no increment flag is present', () => { + expect(resolveIncrement({info: true})).toBeNull() + expect(resolveIncrement({})).toBeNull() + }) +}) + +describe('normalizeCurrentVersion', () => { + it('defaults missing/empty input to 0.0.0', () => { + expect(normalizeCurrentVersion(undefined)).toBe('0.0.0') + expect(normalizeCurrentVersion('')).toBe('0.0.0') + expect(normalizeCurrentVersion(' ')).toBe('0.0.0') + }) + + it('trims surrounding whitespace/newlines from git output', () => { + expect(normalizeCurrentVersion('1.2.3\n')).toBe('1.2.3') + expect(normalizeCurrentVersion(' 1.2.3 ')).toBe('1.2.3') + }) + + it('passes through a valid version', () => { + expect(normalizeCurrentVersion('2.5.1')).toBe('2.5.1') + }) +}) + +describe('nextVersion', () => { + it('increments patch/minor/major', () => { + expect(nextVersion('1.2.3', 'patch')).toBe('1.2.4') + expect(nextVersion('1.2.3', 'minor')).toBe('1.3.0') + expect(nextVersion('1.2.3', 'major')).toBe('2.0.0') + }) + + it('resets lower components on a higher bump', () => { + expect(nextVersion('1.9.9', 'major')).toBe('2.0.0') + expect(nextVersion('1.9.9', 'minor')).toBe('1.10.0') + }) +}) + +describe('buildReplaceConfig', () => { + const ctx = {version: '1.2.4', currentTag: '1.2.3', cwd: '/proj'} + + it('returns null when files/from/to are incomplete', () => { + expect(buildReplaceConfig({from: 'a', to: 'b'}, ctx)).toBeNull() + expect(buildReplaceConfig({files: 'x', to: 'b'}, ctx)).toBeNull() + expect(buildReplaceConfig({files: 'x', from: 'a'}, ctx)).toBeNull() + }) + + it('resolves a single file relative to cwd', () => { + const conf = buildReplaceConfig({files: 'style.css', from: 'a', to: 'b'}, ctx) + expect(conf.files).toEqual([resolve('/proj/style.css')]) + }) + + it('resolves an array of files', () => { + const conf = buildReplaceConfig({files: ['a.css', 'b.css'], from: 'x', to: 'y'}, ctx) + expect(conf.files).toEqual([resolve('/proj/a.css'), resolve('/proj/b.css')]) + }) + + it('substitutes placeholders in from (regex) and to', () => { + const conf = buildReplaceConfig( + {files: 'f', from: 'Version: __CURRENT_TAG__', to: 'Version: __VERSION__'}, + ctx, + ) + expect(conf.from).toBeInstanceOf(RegExp) + expect(conf.from.source).toBe('Version: 1.2.3') // placeholder substituted into the pattern + expect(conf.from.test('Version: 1.2.3')).toBe(true) + expect(conf.to).toBe('Version: 1.2.4') + }) + + it('defaults to the global flag', () => { + const conf = buildReplaceConfig({files: 'f', from: 'a', to: 'b'}, ctx) + expect(conf.from.flags).toBe('g') + }) + + it('honors a custom flags string', () => { + const conf = buildReplaceConfig({files: 'f', from: 'a', to: 'b', flags: 'gi'}, ctx) + expect(conf.from.flags).toBe('gi') + }) + + it('uses no flags when flags is explicitly false', () => { + const conf = buildReplaceConfig({files: 'f', from: 'a', to: 'b', flags: false}, ctx) + expect(conf.from.flags).toBe('') + }) +}) + +describe('replaceInFiles', () => { + let dir + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'tagy-replace-')) + }) + afterEach(() => { + rmSync(dir, {recursive: true, force: true}) + }) + + it('replaces all matches with a global regex', () => { + const file = join(dir, 'style.css') + writeFileSync(file, 'Version: 1.2.3\nVersion: 1.2.3\nkeep\n') + replaceInFiles({files: [file], from: /Version: \d+\.\d+\.\d+/g, to: 'Version: 1.2.4'}) + expect(readFileSync(file, 'utf8')).toBe('Version: 1.2.4\nVersion: 1.2.4\nkeep\n') + }) + + it('skips files that do not exist without throwing', () => { + const missing = join(dir, 'nope.txt') + expect(() => replaceInFiles({files: [missing], from: /x/g, to: 'y'})).not.toThrow() + expect(existsSync(missing)).toBe(false) + }) + + it('leaves a file untouched when nothing matches', () => { + const file = join(dir, 'a.txt') + writeFileSync(file, 'no match here') + replaceInFiles({files: [file], from: /zzz/g, to: 'q'}) + expect(readFileSync(file, 'utf8')).toBe('no match here') + }) +}) + +describe('bumpPackageVersion', () => { + let dir + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'tagy-bump-')) + }) + afterEach(() => { + rmSync(dir, {recursive: true, force: true}) + }) + + it('writes the new version and preserves other fields + 2-space indent', async () => { + const file = join(dir, 'package.json') + writeFileSync(file, JSON.stringify({name: 'demo', version: '1.0.0', keep: true}, null, 2)) + const result = await bumpPackageVersion('1.0.1', {cwd: dir}) + expect(result).toBe(true) + const written = readFileSync(file, 'utf8') + const parsed = JSON.parse(written) + expect(parsed.version).toBe('1.0.1') + expect(parsed.name).toBe('demo') + expect(parsed.keep).toBe(true) + expect(written).toContain('\n "name"') // 2-space indentation + }) + + it('resolves true (no-op) when there is no package.json', async () => { + await expect(bumpPackageVersion('1.0.1', {cwd: dir})).resolves.toBe(true) + }) + + it('throws a friendly error on malformed package.json', async () => { + writeFileSync(join(dir, 'package.json'), '{ not valid json') + await expect(bumpPackageVersion('1.0.1', {cwd: dir})).rejects.toThrow('Couldn\'t parse "package.json"') + }) +}) From 71ea4ab6e5100677d9b8ce79615451a198ea75b6 Mon Sep 17 00:00:00 2001 From: Andrei Surdu Date: Thu, 18 Jun 2026 00:38:30 +0300 Subject: [PATCH 2/2] ci: run test suite on pull requests and master pushes --- .github/workflows/test.yml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..299661b --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,22 @@ +name: Test + +on: + pull_request: + push: + branches: + - master + - main + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [20, 22] + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: ${{ matrix.node-version }} + - run: npm install + - run: npm test