Skip to content
Merged
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
45 changes: 37 additions & 8 deletions .github/workflows/node_sdk_publish.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -113,4 +143,3 @@ jobs:
# permitio/permit-node -> Settings -> Trusted Publishers
# (workflow: node_sdk_publish.yaml, environment: production)
# Provenance attestations are generated automatically.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
181 changes: 181 additions & 0 deletions src/tests/unit/release-workflow.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
}
}
Loading