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
5 changes: 2 additions & 3 deletions .github/workflows/npm-prepare.yml
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ jobs:
run: npm audit --audit-level=high

- name: Verify tarball integrity boundaries
run: node --test scripts/npm-release-packet.test.mjs
run: node --test scripts/npm-release-packet.test.mjs scripts/npm-pack-reproducible.test.mjs

- name: Test release source
run: npm test
Expand All @@ -104,7 +104,7 @@ jobs:
id: pack
shell: bash
run: |
npm pack --json --ignore-scripts > pack-metadata.json
node scripts/npm-pack-reproducible.mjs > pack-metadata.json
tarball="$(node -e "const fs=require('node:fs');const p=JSON.parse(fs.readFileSync('pack-metadata.json','utf8'));process.stdout.write(p[0].filename)")"
test -n "$tarball"
echo "tarball=$tarball" >> "$GITHUB_OUTPUT"
Expand Down Expand Up @@ -155,4 +155,3 @@ jobs:
- name: Require preparation artifact upload
if: ${{ always() && steps.evidence_upload.outcome != 'success' }}
run: exit 1

2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ jobs:
id: pack
shell: bash
run: |
npm pack --json --ignore-scripts > pack-metadata.json
node scripts/npm-pack-reproducible.mjs > pack-metadata.json
tarball="$(node -e "const fs=require('node:fs');const p=JSON.parse(fs.readFileSync('pack-metadata.json','utf8'));process.stdout.write(p[0].filename)")"
test -n "$tarball"
echo "tarball=$tarball" >> "$GITHUB_OUTPUT"
Expand Down
2 changes: 2 additions & 0 deletions docs/ci-recovery.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ Dispatch **Prepare npm package** from `main` with `tag=v0.4.0` to qualify the cu

The production job stays on `ubuntu-latest` in `npm-production`, with the existing OIDC and provenance requirements and release-published trigger. Manual publication remains prohibited by the production verifier. The hosted job installs and builds from the same immutable commit using preparation's exact Node version, audits and verifies the production package, smoke-tests its own tarball, and requires a byte-identical SHA-256 digest before attestation and `npm publish --provenance`. It never publishes a tarball built solely on a self-hosted machine. The release tag must resolve to the exact event commit.

Both jobs pack through `scripts/npm-pack-reproducible.mjs`. A hardened self-hosted runner can create public files with mode `0600`, while a hosted builder creates the same bytes with mode `0644`; npm preserves that difference in its archive. The helper first verifies npm's complete dry-run inventory against the production allowlist, then copies only those public files into a private staging directory with fixed `0644` file modes and `0755` executable modes. It accepts only the two reviewed local workspace links, rejects other symlinks and private paths, and requires the final packed inventory to match. Source files, credential files and the runner's umask remain unchanged. Both builders still independently rebuild and verify the exact tarball digest.

This setup does not solve account billing, required artifact storage or private ATSv2 parity access. npm currently [does not support self-hosted runners for trusted publishing](https://docs.npmjs.com/trusted-publishers/) or [provenance builds](https://docs.npmjs.com/generating-provenance-statements/). A token-based publish without provenance would be a separate, explicit release exception. No npm token is installed by these workflows.

An existing draft release pointing to an earlier commit does not acquire the new workflows automatically. After this change is reviewed and merged, verify the final release tag and draft target against the newly qualified head before publication. A preparation run is not evidence of npm publication, private ATS parity, Windows qualification or registry provenance.
134 changes: 134 additions & 0 deletions scripts/npm-pack-reproducible.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { chmodSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';

const MAX_BYTES = 5_000_000;
const WORKSPACE_LINKS = new Map([
['node_modules/aether-ats-skills', 'packages/ats-skills'],
['node_modules/aether-rc-qr', 'packages/qrcode-terminal'],
]);

function singleReport(raw) {
assert.ok(Buffer.byteLength(raw) <= 2 * 1024 * 1024, 'pack metadata is too large');
const reports = JSON.parse(raw);
assert.ok(Array.isArray(reports) && reports.length === 1, 'one npm pack result is required');
return reports[0];
}

function npmPack(root, args) {
const cli = process.env.npm_execpath;
return execFileSync(cli ? process.execPath : process.platform === 'win32' ? 'npm.cmd' : 'npm',
cli ? [cli, 'pack', '--json', '--ignore-scripts', ...args] : ['pack', '--json', '--ignore-scripts', ...args],
{ cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 120_000,
maxBuffer: 2 * 1024 * 1024, shell: !cli && process.platform === 'win32' });
}

// Only the two reviewed local workspace links may be followed. Every other
// component, including the final file, must be an ordinary directory/file.
function publicSource(root, path) {
const parts = path.split('/');
let expected = root;
for (let i = 0; i < parts.length; i++) {
expected = join(expected, parts[i]);
const info = lstatSync(expected);
if (info.isSymbolicLink()) {
const target = WORKSPACE_LINKS.get(parts.slice(0, i + 1).join('/'));
assert.ok(target && i < parts.length - 1, 'unexpected package symlink');
expected = join(root, target);
assert.equal(realpathSync(join(root, ...parts.slice(0, i + 1))), expected, 'workspace link leaves reviewed source');
assert.equal(realpathSync(expected), expected, 'workspace target must not contain symlinks');
assert.ok(lstatSync(expected).isDirectory());
} else {
assert.ok(i === parts.length - 1 ? info.isFile() : info.isDirectory(), 'invalid package file type');
}
}
assert.equal(realpathSync(join(root, path)), expected, 'package source changed');
return expected;
}

export function stagePackInputs(root, staging, report) {
root = realpathSync(root);
assert.ok(lstatSync(staging).isDirectory() && !lstatSync(staging).isSymbolicLink());
assert.ok(Array.isArray(report.files) && report.files.length > 0 && report.files.length <= 2000, 'invalid package file list');
assert.equal(report.entryCount, report.files.length, 'package entry count disagrees');
const seen = new Set();
let total = 0;
// Validate the complete inventory before copying any bytes.
const inputs = report.files.map(file => {
assert.ok(typeof file.path === 'string' && file.path.length <= 1024
&& file.path.split('/').every(part => /^[A-Za-z0-9_.-]+$/.test(part) && part !== '.' && part !== '..'), 'unsafe package path');
assert.ok(!file.path.split('/').some(part => /^\.(?:npmrc|git|env(?:\..*)?)$/.test(part)), 'private package path');
assert.ok(!seen.has(file.path), 'duplicate package path');
seen.add(file.path);
assert.ok(Number.isSafeInteger(file.size) && file.size >= 0 && file.size <= MAX_BYTES, 'invalid package file size');
assert.ok(Number.isSafeInteger(file.mode) && file.mode >= 0 && file.mode <= 0o777, 'invalid package file mode');
total += file.size;
assert.ok(total <= MAX_BYTES, 'package is too large');
const source = publicSource(root, file.path);
assert.equal(lstatSync(source).size, file.size, 'package source size changed');
return { source, path: file.path, size: file.size, mode: file.mode & 0o111 ? 0o755 : 0o644 };
});
assert.equal(total, report.unpackedSize, 'package byte count disagrees');
for (const input of inputs) {
const bytes = readFileSync(input.source);
assert.equal(bytes.length, input.size, 'package source size changed');
const destination = join(staging, input.path);
mkdirSync(dirname(destination), { recursive: true, mode: 0o700 });
writeFileSync(destination, bytes, { flag: 'wx', mode: 0o600 });
chmodSync(destination, input.mode);
}
return inputs;
}

export async function packReproducibly(root) {
root = realpathSync(root);
const manifestPath = join(root, 'package.json');
const info = lstatSync(manifestPath);
assert.ok(info.isFile() && info.size > 0 && info.size <= 2 * 1024 * 1024, 'invalid package manifest');
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
const { validateManifest, validatePack } = await import(pathToFileURL(join(root, 'dist/scripts/verify-production.js')).href);
const before = singleReport(npmPack(root, ['--dry-run']));
assert.deepEqual([...validateManifest(manifest, process.env.RELEASE_TAG), ...validatePack(before, manifest)], [], 'production package policy failed');
assert.equal(before.name, manifest.name);
assert.equal(before.version, manifest.version);
const filename = `${manifest.name}-${manifest.version}.tgz`;
assert.equal(before.filename, filename);
const tempRoot = realpathSync(tmpdir());
const temp = mkdtempSync(join(tempRoot, 'aether-public-pack-'));
chmodSync(temp, 0o700);
try {
const staging = join(temp, 'source');
const output = join(temp, 'output');
mkdirSync(staging, { mode: 0o700 });
mkdirSync(output, { mode: 0o700 });
const inputs = stagePackInputs(root, staging, before);
const packed = singleReport(npmPack(staging, ['--pack-destination', output]));
assert.equal(packed.name, manifest.name);
assert.equal(packed.version, manifest.version);
assert.equal(packed.filename, filename);
assert.deepEqual(validatePack(packed, manifest), []);
assert.equal(packed.unpackedSize, before.unpackedSize);
assert.equal(packed.entryCount, before.entryCount);
const inventory = files => files.map(file => ({ path: file.path, size: file.size, mode: file.mode })).sort((a, b) => a.path.localeCompare(b.path));
assert.deepEqual(inventory(packed.files), inventory(inputs), 'staged package inventory changed');
const tarball = join(output, filename);
const packedInfo = lstatSync(tarball);
assert.ok(packedInfo.isFile() && packedInfo.size > 0 && packedInfo.size <= 10 * 1024 * 1024, 'invalid staged tarball');
const bytes = readFileSync(tarball);
assert.equal(bytes.length, packed.size);
writeFileSync(join(root, filename), bytes, { flag: 'wx', mode: 0o600 });
return packed;
} finally {
assert.equal(dirname(temp), tempRoot);
assert.ok(lstatSync(temp).isDirectory() && !lstatSync(temp).isSymbolicLink());
rmSync(temp, { recursive: true });
}
}

if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
assert.equal(process.argv.length, 2);
console.log(JSON.stringify([await packReproducibly(process.cwd())], null, 2));
}
85 changes: 85 additions & 0 deletions scripts/npm-pack-reproducible.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { chmodSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { test } from 'node:test';
import { stagePackInputs } from './npm-pack-reproducible.mjs';

function fixture(run) {
const temp = mkdtempSync(join(tmpdir(), 'aether-pack-test-'));
const source = join(temp, 'source'), staging = join(temp, 'staging');
mkdirSync(source); mkdirSync(staging);
const bytes = Buffer.from('public package content\n');
writeFileSync(join(source, 'README.md'), bytes, { mode: 0o600 });
const report = { entryCount: 1, unpackedSize: bytes.length, files: [{ path: 'README.md', size: bytes.length, mode: 0o600 }] };
try { return run({ temp, source, staging, report }); }
finally { rmSync(temp, { recursive: true }); }
}

test('actual npm packages are byte-identical under private and ordinary input modes', { skip: process.platform === 'win32' }, () => fixture(({ temp, source, staging }) => {
const manifest = { name: 'aether-pack-mode-test', version: '1.0.0', bin: { cli: 'cli.js' }, files: ['README.md', 'cli.js'] };
writeFileSync(join(source, 'package.json'), JSON.stringify(manifest));
writeFileSync(join(source, 'cli.js'), '#!/usr/bin/env node\n');
const npmCli = process.env.npm_execpath;
const pack = (cwd, args) => JSON.parse(execFileSync(npmCli ? process.execPath : 'npm',
npmCli ? [npmCli, 'pack', '--json', '--ignore-scripts', ...args] : ['pack', '--json', '--ignore-scripts', ...args],
{ cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 30_000 }))[0];
const results = [];
for (const mode of [0o600, 0o644]) {
for (const path of ['README.md', 'package.json', 'cli.js']) chmodSync(join(source, path), mode);
const destination = join(staging, String(mode)); mkdirSync(destination);
stagePackInputs(source, destination, pack(source, ['--dry-run']));
const output = join(temp, `output-${mode}`); mkdirSync(output);
const result = pack(destination, ['--pack-destination', output]);
results.push(readFileSync(join(output, result.filename)));
for (const path of ['README.md', 'package.json', 'cli.js']) assert.equal(lstatSync(join(source, path)).mode & 0o777, mode);
assert.equal(lstatSync(join(destination, 'cli.js')).mode & 0o777, 0o755);
}
assert.deepEqual(results[0], results[1]);
}));

for (const path of ['../outside', '/absolute', 'dist/../secret', 'dist\\secret', '.npmrc', '.env.production', '.git/config']) {
test(`unsafe or private input ${path} is rejected before copying`, () => fixture(({ source, staging, report }) => {
report.files.push({ path, size: 0, mode: 0o600 }); report.entryCount++;
assert.throws(() => stagePackInputs(source, staging, report), /unsafe package path|private package path/);
assert.deepEqual(readdirSync(staging), []);
}));
}

test('file and directory symlinks cannot redirect staging to private data', { skip: process.platform === 'win32' }, () => fixture(({ temp, source, staging, report }) => {
const privateFile = join(temp, 'private'); writeFileSync(privateFile, 'private');
symlinkSync(privateFile, join(source, 'alias.md'));
report.files = [{ path: 'alias.md', size: 7, mode: 0o600 }]; report.unpackedSize = 7;
assert.throws(() => stagePackInputs(source, staging, report), /unexpected package symlink/);
symlinkSync(temp, join(source, 'nested'));
report.files[0].path = 'nested/private';
assert.throws(() => stagePackInputs(source, staging, report), /unexpected package symlink/);
assert.deepEqual(readdirSync(staging), []);
}));

test('reviewed workspace links are copied as regular public files', { skip: process.platform === 'win32' }, () => fixture(({ source, staging, report }) => {
mkdirSync(join(source, 'packages/ats-skills'), { recursive: true }); mkdirSync(join(source, 'node_modules'));
writeFileSync(join(source, 'packages/ats-skills/README.md'), 'public');
symlinkSync('../packages/ats-skills', join(source, 'node_modules/aether-ats-skills'));
report.files = [{ path: 'node_modules/aether-ats-skills/README.md', size: 6, mode: 0o600 }]; report.unpackedSize = 6;
stagePackInputs(source, staging, report);
assert.ok(lstatSync(join(staging, report.files[0].path)).isFile());
assert.equal(readFileSync(join(staging, report.files[0].path), 'utf8'), 'public');
}));

test('a reviewed workspace name cannot be retargeted to another directory', { skip: process.platform === 'win32' }, () => fixture(({ source, staging, report }) => {
mkdirSync(join(source, 'node_modules'));
symlinkSync('..', join(source, 'node_modules/aether-ats-skills'));
report.files[0].path = 'node_modules/aether-ats-skills/README.md';
assert.throws(() => stagePackInputs(source, staging, report), /workspace link leaves reviewed source/);
}));

test('duplicate entries, special modes and mismatched byte counts are rejected', () => fixture(({ source, staging, report }) => {
assert.throws(() => stagePackInputs(source, staging, { ...report, entryCount: 2, files: [...report.files, report.files[0]] }), /duplicate/);
assert.throws(() => stagePackInputs(source, staging, { ...report, files: [{ ...report.files[0], mode: 0o4600 }] }), /file mode/);
assert.throws(() => stagePackInputs(source, staging, { ...report, unpackedSize: 0 }), /byte count/);
assert.throws(() => stagePackInputs(source, staging, { ...report, files: [{ ...report.files[0], size: 0 }] }), /source size/);
assert.throws(() => stagePackInputs(source, staging, { ...report, files: [{ ...report.files[0], size: 5_000_001 }] }), /file size/);
assert.deepEqual(readdirSync(staging), []);
}));
Loading