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
14 changes: 11 additions & 3 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,22 @@ npm run check
npm publish --access public
```

The intended first version is `0.2.0`; check `package.json` before publishing and do not publish from an old checkout. If npm reports scope ownership, organization, billing, or OTP requirements, resolve those requirements in npm. Do not publish under a personal scope as a substitute. After the first release appears on the registry, add an npm Trusted Publisher in the `@gapwise/cli` package settings:
The initial 0.2.0 bootstrap was published manually to establish `@gapwise/cli` on the npm registry. For version 0.2.1 (which normalizes the bin path to `bin/gapwise.mjs` and verifies clean npx/global execution), from a clean checkout of merged `main`:

```sh
npm login
npm whoami
npm run check
npm publish --access public
```

If npm Trusted Publishing has been configured for `@gapwise/cli`:
- Provider: GitHub Actions
- Organization/user: `GapwiseHQ`
- Repository: `cli`
- Workflow filename: `release.yml`
- Allowed action: direct `npm publish`

Then create and push an annotated `v0.2.0` tag on the same merged `main` commit. The workflow checks the existing registry version, verifies its install, and creates the corresponding GitHub Release. Subsequent new versions publish from the tag workflow through OIDC and get npm provenance. Keep the initial manual publish distinct from provenance-bearing OIDC releases; do not claim provenance for the manual first release.
Then create and push an annotated `v0.2.1` tag on the same merged `main` commit. The workflow checks the existing registry version, verifies its install, and creates the corresponding GitHub Release. Subsequent new versions publish from the tag workflow through OIDC and get npm provenance. Keep the initial manual publish distinct from provenance-bearing OIDC releases; do not claim provenance for manual releases.

If an npm owner authorizes this workspace with an interactive login, the maintainer can complete the first publish here and perform the external verification. Never commit auth tokens or `.npmrc` credentials.
If an npm owner authorizes this workspace with an interactive login, the maintainer can complete the publish here and perform the external verification. Never commit auth tokens or `.npmrc` credentials.
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@gapwise/cli",
"version": "0.2.0",
"version": "0.2.1",
"description": "Official Gapwise CLI for multi-university campus discovery and integration tooling",
"type": "module",
"license": "MIT",
Expand All @@ -11,7 +11,7 @@
},
"bugs": { "url": "https://github.com/GapwiseHQ/cli/issues" },
"keywords": ["gapwise", "university", "campus", "campus-data", "cli"],
"bin": { "gapwise": "./bin/gapwise.mjs" },
"bin": { "gapwise": "bin/gapwise.mjs" },
"scripts": {
"test": "node --test tests/*.test.mjs",
"test:package": "node scripts/verify-package.mjs",
Expand Down
68 changes: 57 additions & 11 deletions scripts/verify-package.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs';
import { mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { spawnSync } from 'node:child_process';
Expand All @@ -11,27 +12,72 @@ const manifest = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
function run(command, args, cwd = root) {
const result = spawnSync(command, args, { cwd, encoding: 'utf8', maxBuffer: 1024 * 1024 });
assert.equal(result.status, 0, `${command} ${args.join(' ')}\n${result.stdout}\n${result.stderr}`);
return result.stdout;
return result;
}

try {
// 1. Manifest structure & bin configuration
assert.equal(manifest.name, '@gapwise/cli');
assert.equal(manifest.publishConfig.access, 'public');
assert.ok(readFileSync(join(root, manifest.bin.gapwise), 'utf8').startsWith('#!/usr/bin/env node\n'));
assert.equal(manifest.publishConfig?.access, 'public');
assert.equal(
manifest.bin?.gapwise,
'bin/gapwise.mjs',
'bin.gapwise must be bare relative path "bin/gapwise.mjs" (no leading ./) to avoid npm normalization warnings'
);

// 2. Shebang, LF line endings, and file permissions
const binContent = readFileSync(join(root, manifest.bin.gapwise), 'utf8');
assert.ok(binContent.startsWith('#!/usr/bin/env node\n'), 'CLI entrypoint must start with #!/usr/bin/env node\\n');
assert.ok(!binContent.includes('\r\n'), 'CLI entrypoint must use LF line endings');
assert.ok(statSync(join(root, manifest.bin.gapwise)).mode & 0o111, 'CLI entrypoint must be executable');

const [packed] = JSON.parse(run('npm', ['pack', '--json', '--pack-destination', temp]));
// 3. Dry-run publish must emit zero normalization warnings
const dryRun = spawnSync('npm', ['publish', '--dry-run'], { cwd: root, encoding: 'utf8' });
assert.equal(dryRun.status, 0, `npm publish --dry-run failed:\n${dryRun.stdout}\n${dryRun.stderr}`);
assert.ok(
!dryRun.stderr.includes('npm auto-corrected') && !dryRun.stderr.includes('was invalid and removed'),
`npm publish emitted bin normalization warning:\n${dryRun.stderr}`
);

// 4. Pack tarball and verify contents & metadata
const packResult = run('npm', ['pack', '--json', '--pack-destination', temp]);
const [packed] = JSON.parse(packResult.stdout);
assert.ok(packed.size < 100_000, `Package exceeds 100 KB: ${packed.size}`);
const paths = packed.files.map(({ path }) => path).sort();
assert.deepEqual(paths, ['LICENSE', 'README.md', 'bin/gapwise.mjs', 'bin/public-api.mjs', 'package.json']);
const tarListing = run('tar', ['-tvzf', join(temp, packed.filename)]);

const tarballPath = join(temp, packed.filename);
const tarListing = run('tar', ['-tvzf', tarballPath]).stdout;
assert.match(tarListing, /-rwxr-xr-x\s+[^\n]*package\/bin\/gapwise\.mjs/);

run('npm', ['install', '--global', '--prefix', temp, '--ignore-scripts', '--no-audit', '--no-fund', join(temp, packed.filename)]);
const binary = join(temp, 'bin/gapwise');
assert.equal(run(binary, ['--version']).trim(), manifest.version);
assert.match(run(binary, ['--help']), /gapwise universities/);
console.log(`Verified ${manifest.name}@${manifest.version}: ${packed.size} bytes, ${paths.length} files, clean global install and executable.`);
// Verify packed package.json bin property
const packedPkgJson = JSON.parse(run('tar', ['-xOf', tarballPath, 'package/package.json']).stdout);
assert.equal(packedPkgJson.bin?.gapwise, 'bin/gapwise.mjs');

// 5. Global installation test (prefix in isolated temp)
const globalPrefix = join(temp, 'global');
run('npm', ['install', '--global', '--prefix', globalPrefix, '--ignore-scripts', '--no-audit', '--no-fund', tarballPath]);
const globalBinary = join(globalPrefix, 'bin/gapwise');
assert.equal(run(globalBinary, ['--version']).stdout.trim(), manifest.version);
assert.match(run(globalBinary, ['--help']).stdout, /gapwise universities/);

// 6. Local consumer installation test in isolated project
const consumerDir = join(temp, 'consumer');
mkdirSync(consumerDir, { recursive: true });
writeFileSync(join(consumerDir, 'package.json'), JSON.stringify({ name: 'consumer-test', private: true }));
run('npm', ['install', '--ignore-scripts', '--no-audit', '--no-fund', tarballPath], consumerDir);
const localBinary = join(consumerDir, 'node_modules/.bin/gapwise');
assert.equal(run(localBinary, ['--version'], consumerDir).stdout.trim(), manifest.version);
assert.equal(run('npx', ['gapwise', '--version'], consumerDir).stdout.trim(), manifest.version);

// 7. npx --package execution from an isolated directory
const npxTestDir = join(temp, 'npx-test');
mkdirSync(npxTestDir, { recursive: true });
const npxRun = run('npx', ['--yes', '--package', tarballPath, 'gapwise', '--version'], npxTestDir);
assert.equal(npxRun.stdout.trim(), manifest.version);
assert.ok(!npxRun.stderr.includes('not found'), `npx stderr contained "not found": ${npxRun.stderr}`);

console.log(`Verified ${manifest.name}@${manifest.version}: ${packed.size} bytes, ${paths.length} files, zero npm warnings, clean global & npx execution.`);
} finally {
rmSync(temp, { recursive: true, force: true });
}
38 changes: 38 additions & 0 deletions tests/package-artifact.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, statSync } from 'node:fs';
import { resolve } from 'node:path';

const root = resolve(import.meta.dirname, '..');
const pkgPath = resolve(root, 'package.json');
const manifest = JSON.parse(readFileSync(pkgPath, 'utf8'));

test('package.json metadata and bin path adhere to npm 11 normalization standards', () => {
assert.equal(manifest.name, '@gapwise/cli');
assert.equal(manifest.publishConfig?.access, 'public');
assert.equal(manifest.engines?.node, '>=22');

// npm 11 @npmcli/package-json emits warnings and strips leading './' if present.
// bin paths must be bare relative paths like "bin/gapwise.mjs".
assert.equal(
manifest.bin?.gapwise,
'bin/gapwise.mjs',
'bin.gapwise must be "bin/gapwise.mjs" without leading "./"'
);
assert.doesNotMatch(
manifest.bin?.gapwise,
/^\.\//,
'bin path must not start with ./'
);
});

test('CLI executable has valid shebang, LF line endings, and executable mode', () => {
const binPath = resolve(root, manifest.bin.gapwise);
const content = readFileSync(binPath, 'utf8');

assert.ok(content.startsWith('#!/usr/bin/env node\n'), 'CLI entrypoint must start with #!/usr/bin/env node\\n');
assert.ok(!content.includes('\r\n'), 'CLI entrypoint must use LF line endings, not CRLF');

const stat = statSync(binPath);
assert.ok((stat.mode & 0o111) !== 0, 'CLI entrypoint must have executable permission bits set');
});
4 changes: 3 additions & 1 deletion tests/public-api.test.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { readFileSync } from 'node:fs';
import { spawn } from 'node:child_process';
import { once } from 'node:events';
import { resolve } from 'node:path';
Expand Down Expand Up @@ -51,9 +52,10 @@ test('help and version are useful without a checkout or network', async () => {
assert.equal(help.code, 0);
assert.match(help.stdout, /gapwise universities/);
assert.match(help.stdout, /--university ID/);
const pkg = JSON.parse(readFileSync(resolve(import.meta.dirname, '../package.json'), 'utf8'));
const version = await run(['--version']);
assert.equal(version.code, 0);
assert.match(version.stdout, /^0\.2\.0\n$/);
assert.equal(version.stdout.trim(), pkg.version);
});

test('discovery and JSON output use the public API', async (t) => {
Expand Down
Loading