diff --git a/.github/workflows/node_sdk_publish.yaml b/.github/workflows/node_sdk_publish.yaml index ab6b1a29..d3faf07a 100644 --- a/.github/workflows/node_sdk_publish.yaml +++ b/.github/workflows/node_sdk_publish.yaml @@ -90,17 +90,47 @@ jobs: -H 'Authorization: Bearer ${{ secrets.PROJECT_API_KEY }}' - name: Bump version at package.json + env: + RELEASE_TAG: ${{ github.event.release.tag_name }} run: | - # Use the release tag as the version, stripping any leading 'v' - # (e.g. 'v2.7.6' -> '2.7.6') so it is valid semver for npm. - VERSION=${{ github.event.release.tag_name }} - VERSION=${VERSION#v} - sed -i "s/\"version\": \".*\"/\"version\": \"$VERSION\"/" package.json - cat package.json + # Pass the release tag through an environment variable to prevent shell + # injection. Validate it is a literal semantic version (not a keyword like + # "minor" or "patch" that npm version would accept). Bump only the + # top-level "version" field. npm is JSON-aware (unlike greedy sed, which + # also rewrites scripts.version); no git tag/commit is created, re-runs on + # the same version are tolerated, and the "version" lifecycle script + # (standard-version) is skipped. + node -e " + const semver = require('semver'); + const tag = process.env.RELEASE_TAG; + if (!semver.valid(tag)) { + console.error('Release tag', JSON.stringify(tag), 'is not a valid semantic version'); + process.exit(1); + } + " + npm version "$RELEASE_TAG" --no-git-tag-version --allow-same-version --ignore-scripts + # Assert: package.json version matches the normalized release tag + node -e " + const semver = require('semver'); + const tag = process.env.RELEASE_TAG; + // npm strips both the optional 'v' prefix and semver build metadata. + const normalized = semver.valid(tag); + const pkg = require('./package.json'); + if (pkg.version !== normalized) { + console.error('Version mismatch: expected', normalized, 'got', pkg.version); + process.exit(1); + } + if (pkg.scripts.version !== 'standard-version') { + console.error('scripts.version was corrupted by the version bump'); + process.exit(1); + } + " - name: Publish package to NPM + env: + IS_PRERELEASE: ${{ github.event.release.prerelease }} run: | - if [[ "${{ github.event.release.prerelease }}" == "true" ]]; then + if [[ "$IS_PRERELEASE" == "true" ]]; then echo "Publishing as a release candidate (rc)..." npm publish --access public --tag rc else @@ -113,4 +143,3 @@ jobs: # permitio/permit-node -> Settings -> Trusted Publishers # (workflow: node_sdk_publish.yaml, environment: production) # Provenance attestations are generated automatically. - diff --git a/package.json b/package.json index 7106bc17..fc7e1bfe 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "cov:check": "nyc report && nyc check-coverage --lines 100 --functions 100 --branches 100", "docs": "typedoc", "docs:watch": "typedoc --watch", - "version": "2.5.2", + "version": "standard-version", "reset-hard": "git clean -dfx && git reset --hard && yarn", "prepare": "npm run build && husky install", "prepare-release": "run-s reset-hard test cov:check doc:html version doc:publish", diff --git a/src/tests/unit/release-workflow.spec.ts b/src/tests/unit/release-workflow.spec.ts new file mode 100644 index 00000000..3ddc1d67 --- /dev/null +++ b/src/tests/unit/release-workflow.spec.ts @@ -0,0 +1,181 @@ +import { spawnSync } from 'child_process'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, +} from 'fs'; +import { tmpdir } from 'os'; +import { delimiter, join } from 'path'; + +import test, { ExecutionContext } from 'ava'; + +const root = process.cwd(); +const workflow = readFileSync(join(root, '.github/workflows/node_sdk_publish.yaml'), 'utf8'); +const manifest = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')); + +function stepScript(name: string): string { + const step = workflow.split(` - name: ${name}\n`)[1]?.split('\n - name: ')[0]; + const script = step?.split(' run: |\n')[1]; + if (!script) { + throw new Error(`Cannot find the run block for release workflow step: ${name}`); + } + return script.replace(/^ {10}/gm, ''); +} + +const bumpScript = stepScript('Bump version at package.json'); +const publishScript = stepScript('Publish package to NPM'); + +function fixture(t: ExecutionContext) { + const cwd = mkdtempSync(join(tmpdir(), 'permit-release-')); + t.teardown(() => rmSync(cwd, { recursive: true, force: true })); + const bin = join(cwd, 'bin'); + mkdirSync(bin); + writeFileSync( + join(bin, 'standard-version'), + '#!/bin/bash\nset -euo pipefail\ntouch lifecycle-ran\nexit 73\n', + { mode: 0o755 }, + ); + const pkg = { + ...manifest, + scripts: { + ...manifest.scripts, + preversion: 'standard-version', + postversion: 'standard-version', + }, + }; + writeFileSync(join(cwd, 'package.json'), JSON.stringify(pkg, null, 2) + '\n'); + const lockfile = readFileSync(join(root, 'yarn.lock'), 'utf8'); + writeFileSync(join(cwd, 'yarn.lock'), lockfile); + const env = { + ...process.env, + NODE_PATH: join(root, 'node_modules'), + PATH: bin + delimiter + process.env.PATH, + npm_config_offline: 'true', + npm_config_update_notifier: 'false', + }; + const git = spawnSync('git', ['init', '--quiet'], { cwd, env, encoding: 'utf8' }); + t.is(git.status, 0, git.stderr); + const gitHead = readFileSync(join(cwd, '.git/HEAD'), 'utf8'); + return { + cwd, + pkg, + run(script: string, variables: NodeJS.ProcessEnv) { + return spawnSync('bash', ['-e', '-o', 'pipefail', '-c', script], { + cwd, + env: { ...env, ...variables }, + encoding: 'utf8', + }); + }, + stubNpm(script: string) { + writeFileSync(join(bin, 'npm'), '#!/bin/bash\nset -euo pipefail\n' + script, { + mode: 0o755, + }); + }, + readPackage() { + return JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf8')); + }, + assertUnchangedState() { + t.false(existsSync(join(cwd, 'lifecycle-ran'))); + t.false(existsSync(join(cwd, 'package-lock.json'))); + t.is(readFileSync(join(cwd, 'yarn.lock'), 'utf8'), lockfile); + t.is(readFileSync(join(cwd, '.git/HEAD'), 'utf8'), gitHead); + t.deepEqual(readdirSync(join(cwd, '.git/refs/heads')), []); + t.deepEqual(readdirSync(join(cwd, '.git/refs/tags')), []); + }, + }; +} + +for (const [tag, version] of [ + ['v2.7.7', '2.7.7'], + ['2.7.7', '2.7.7'], + ['v2.7.7-rc', '2.7.7-rc'], + ['2.7.7-rc.1', '2.7.7-rc.1'], + [manifest.version, manifest.version], + [`v${manifest.version}`, manifest.version], + ['v2.7.7+build.1', '2.7.7'], + ['2.7.7-rc.1+build.1', '2.7.7-rc.1'], +]) { + test(`release bump normalizes ${tag} and permits reruns`, (t) => { + const f = fixture(t); + for (let attempt = 0; attempt < 2; attempt++) { + const result = f.run(bumpScript, { RELEASE_TAG: tag }); + t.is(result.status, 0, result.stderr); + t.deepEqual(f.readPackage(), { ...f.pkg, version }); + f.assertUnchangedState(); + } + }); +} + +for (const tag of [ + '', + 'minor', + 'patch', + 'major', + 'prepatch', + 'preminor', + 'premajor', + 'prerelease', + 'from-git', + 'release-2.7.7', + 'vv2.7.7', + '2.7.7-01', + '2.7', + 'v2.7.7$(touch${IFS}injected)', + 'v2.7.7`touch${IFS}injected`', +]) { + test(`release bump rejects ${JSON.stringify(tag)} before changing the manifest`, (t) => { + const f = fixture(t); + const before = readFileSync(join(f.cwd, 'package.json'), 'utf8'); + const result = f.run(bumpScript, { RELEASE_TAG: tag }); + t.is(result.status, 1, result.stderr); + t.regex(result.stderr, /not a valid semantic version/); + t.is(readFileSync(join(f.cwd, 'package.json'), 'utf8'), before); + t.false(existsSync(join(f.cwd, 'injected'))); + f.assertUnchangedState(); + }); +} + +test('release bump propagates npm failure', (t) => { + const f = fixture(t); + f.stubNpm('exit 42\n'); + const result = f.run(bumpScript, { RELEASE_TAG: 'v2.7.7' }); + t.is(result.status, 42, result.stderr); + t.deepEqual(f.readPackage(), f.pkg); +}); + +test('release bump catches an npm success that leaves the wrong version', (t) => { + const f = fixture(t); + f.stubNpm('exit 0\n'); + const result = f.run(bumpScript, { RELEASE_TAG: 'v2.7.7' }); + t.is(result.status, 1, result.stderr); + t.regex(result.stderr, /Version mismatch/); +}); + +test('release bump detects a corrupted version lifecycle script', (t) => { + const f = fixture(t); + f.pkg.scripts.version = '2.5.2'; + writeFileSync(join(f.cwd, 'package.json'), JSON.stringify(f.pkg)); + const result = f.run(bumpScript, { RELEASE_TAG: 'v2.7.7' }); + t.is(result.status, 1, result.stderr); + t.regex(result.stderr, /scripts.version was corrupted/); +}); + +for (const prerelease of ['true', 'false']) { + for (const status of [0, 42]) { + test(`publish selects its dist-tag and propagates exit ${status} (${prerelease})`, (t) => { + const f = fixture(t); + f.stubNpm(`printf '%s\\n' "$@" > publish-args\nexit ${status}\n`); + const result = f.run(publishScript, { IS_PRERELEASE: prerelease }); + t.is(result.status, status, result.stderr); + const args = ['publish', '--access', 'public']; + if (prerelease === 'true') { + args.push('--tag', 'rc'); + } + t.deepEqual(readFileSync(join(f.cwd, 'publish-args'), 'utf8').trim().split('\n'), args); + }); + } +}