From f6ec1b8aba1c565f2b216865a233ca100c95ef5a Mon Sep 17 00:00:00 2001 From: Kyzgor Date: Tue, 23 Jun 2026 22:09:49 +0100 Subject: [PATCH 1/3] fix(ci): replace sed version bump with npm version The publish workflow bumped the package version with a greedy sed that matched every "version": "..." entry in package.json, corrupting scripts.version (which the prepare-release lifecycle invokes via run-s). Use npm version, which is JSON-aware and only touches the top-level field, and restore the corrupted scripts.version back to "standard-version". A post-bump assertion fails the release if scripts.version is ever clobbered again. For the repo's bare-semver release tags the bump is equivalent; npm version additionally strips a stray leading "v" and validates semver (failing fast) where the old sed wrote the tag verbatim. Flags: --no-git-tag-version (no CI commit/tag), --allow-same-version (tolerate re-runs), --ignore-scripts (don't fire the version lifecycle in CI). --- .github/workflows/node_sdk_publish.yaml | 11 ++++++++--- package.json | 2 +- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/.github/workflows/node_sdk_publish.yaml b/.github/workflows/node_sdk_publish.yaml index 7f03e5f1..1a2a429c 100644 --- a/.github/workflows/node_sdk_publish.yaml +++ b/.github/workflows/node_sdk_publish.yaml @@ -83,9 +83,14 @@ jobs: - name: Bump version at package.json run: | - sed -i "s/\"version\": \".*\"/\"version\": \"${{ github.event.release.tag_name }}\"/" package.json - cat package.json - + # Bump only the top-level "version" field. npm is JSON-aware (unlike the + # previous greedy sed, which also rewrote 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. + npm version "${{ github.event.release.tag_name }}" --no-git-tag-version --allow-same-version --ignore-scripts + # Fail fast if the bump ever corrupts the scripts.version lifecycle hook again (the original #89 bug). + node -e "if (require('./package.json').scripts.version !== 'standard-version') { console.error('scripts.version was corrupted by the version bump'); process.exit(1); }" + - name: Publish package to NPM run: | if [[ "${{ github.event.release.prerelease }}" == "true" ]]; then diff --git a/package.json b/package.json index c8efff71..c285f68d 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,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", From 31337d63c485576b4f4ffa6224baf33afe52ba27 Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Tue, 29 Sep 2026 23:36:46 +0300 Subject: [PATCH 2/3] fix(ci): validate release tag is semver and move env interpolations Add semver validation to reject npm keywords like 'minor' or 'patch' that would be accepted by npm version but are not valid release tags. Normalize and validate the tag before bumping, then assert the final version matches. Move github.event.release.prerelease interpolation to env variable to avoid shell interpretation in conditionals. Co-Authored-By: Claude Haiku 4.5 Claude-Session: https://claude.ai/code/session_01PSoip6dghQ62bLQ6GBMwTA --- .github/workflows/node_sdk_publish.yaml | 42 ++++++++++++++++++++----- 1 file changed, 35 insertions(+), 7 deletions(-) diff --git a/.github/workflows/node_sdk_publish.yaml b/.github/workflows/node_sdk_publish.yaml index 4fd7f562..9f3012b3 100644 --- a/.github/workflows/node_sdk_publish.yaml +++ b/.github/workflows/node_sdk_publish.yaml @@ -94,17 +94,45 @@ jobs: RELEASE_TAG: ${{ github.event.release.tag_name }} run: | # Pass the release tag through an environment variable to prevent shell - # injection. 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. + # 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; + // Normalize: strip leading 'v' (e.g. 'v2.7.6' -> '2.7.6') + const normalized = tag.replace(/^v/, ''); + // Validate: must be a valid semantic version + if (!semver.valid(normalized)) { + 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 - # Fail fast if the bump ever corrupts the scripts.version lifecycle hook (the original #89 bug). - node -e "if (require('./package.json').scripts.version !== 'standard-version') { console.error('scripts.version was corrupted by the version bump'); process.exit(1); }" + # Assert: package.json version matches the normalized release tag + node -e " + const semver = require('semver'); + const tag = process.env.RELEASE_TAG; + const normalized = tag.replace(/^v/, ''); + const pkg = require('./package.json'); + if (pkg.version !== normalized) { + console.error('Version mismatch: expected ' + JSON.stringify(normalized) + ', got ' + JSON.stringify(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 From db4fcf07b74e24f881c63c55852e2c08e727492c Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Wed, 30 Sep 2026 00:04:39 +0300 Subject: [PATCH 3/3] Fix release version normalization and add workflow regressions Co-Authored-By: Codex Co-Authored-By: Claude Opus 5.5 --- .github/workflows/node_sdk_publish.yaml | 13 +- src/tests/unit/release-workflow.spec.ts | 181 ++++++++++++++++++++++++ 2 files changed, 186 insertions(+), 8 deletions(-) create mode 100644 src/tests/unit/release-workflow.spec.ts diff --git a/.github/workflows/node_sdk_publish.yaml b/.github/workflows/node_sdk_publish.yaml index 9f3012b3..d3faf07a 100644 --- a/.github/workflows/node_sdk_publish.yaml +++ b/.github/workflows/node_sdk_publish.yaml @@ -103,11 +103,8 @@ jobs: node -e " const semver = require('semver'); const tag = process.env.RELEASE_TAG; - // Normalize: strip leading 'v' (e.g. 'v2.7.6' -> '2.7.6') - const normalized = tag.replace(/^v/, ''); - // Validate: must be a valid semantic version - if (!semver.valid(normalized)) { - console.error('Release tag ' + JSON.stringify(tag) + ' is not a valid semantic version'); + if (!semver.valid(tag)) { + console.error('Release tag', JSON.stringify(tag), 'is not a valid semantic version'); process.exit(1); } " @@ -116,10 +113,11 @@ jobs: node -e " const semver = require('semver'); const tag = process.env.RELEASE_TAG; - const normalized = tag.replace(/^v/, ''); + // 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 ' + JSON.stringify(normalized) + ', got ' + JSON.stringify(pkg.version)); + console.error('Version mismatch: expected', normalized, 'got', pkg.version); process.exit(1); } if (pkg.scripts.version !== 'standard-version') { @@ -145,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/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); + }); + } +}