diff --git a/.github/workflows/bootstrap-npm-native.yml b/.github/workflows/bootstrap-npm-native.yml new file mode 100644 index 0000000..e575025 --- /dev/null +++ b/.github/workflows/bootstrap-npm-native.yml @@ -0,0 +1,75 @@ +name: Bootstrap npm native 0.6.0 + +on: workflow_dispatch + +permissions: + contents: read + +concurrency: + group: npm-native-0.6.0-bootstrap + cancel-in-progress: false + +jobs: + stage-native: + runs-on: ubuntu-latest + permissions: + contents: read + actions: read + id-token: write + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: 24 + registry-url: https://registry.npmjs.org + package-manager-cache: false + - uses: actions/setup-python@v6 + with: + python-version: '3.12' + # Immutable tarballs from the 0.6.0 publication run's installed-artifact gates. + - uses: actions/download-artifact@v8 + with: + artifact-ids: 13827437119 + run-id: 42955400102 + merge-multiple: true + github-token: ${{ github.token }} + path: tarballs + - name: Verify original tested tarballs + shell: bash + run: | + set -euo pipefail + cd tarballs + cat > ORIGINAL_SHA256SUMS <<'SHA256' + 07b2cadecf85e7eaaaa625032dfc126a895ff9788b924a0d5afbcaeb8f3279d4 tokenfold-native-darwin-arm64-0.6.0.tgz + cc0520e04c71862df36ce4f9e712bf21fbb9a1a2cb33602f13baf890f8c65f25 tokenfold-native-darwin-x64-0.6.0.tgz + 1563bde4a51836ce4043c2fbca2aa3a0459aaa6e77bc19335cda507d9928d42f tokenfold-native-linux-arm64-0.6.0.tgz + 01b440f0091b2eeaa5d1cba2696ddf7bff84f40b7ee936ad477f99e5aac0a60c tokenfold-native-linux-x64-0.6.0.tgz + 5718176346edabac8af28cd91408354767bb60ed068c1a76a7fc07b17be5978c tokenfold-native-win32-x64-0.6.0.tgz + SHA256 + sha256sum -c ORIGINAL_SHA256SUMS + - run: python scripts/test_npm_native_metadata.py + - name: Repair only missing repository metadata + run: python scripts/repair_npm_native_metadata.py tarballs corrected + - uses: actions/upload-artifact@v7 + with: + name: native-bootstrap-tarballs + path: corrected/* + if-no-files-found: error + - name: Stage corrected native packages for maintainer approval + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + shell: bash + run: | + set -euo pipefail + test -n "$NODE_AUTH_TOKEN" + cd corrected + sha256sum -c SHA256SUMS + echo 'Approve the staged native packages on npm with 2FA; do not weaken account/package security settings.' >> "$GITHUB_STEP_SUMMARY" + for platform in darwin-arm64 darwin-x64 linux-arm64 linux-x64 win32-x64; do + package="@tokenfold/native-$platform" + if npm view "$package@0.6.0" version >/dev/null 2>&1; then + echo "$package@0.6.0 already public; skipping" + else + npm stage publish "./tokenfold-native-$platform-0.6.0.tgz" --access public --provenance --ignore-scripts --json | tee -a "$GITHUB_STEP_SUMMARY" + fi + done diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a6e1355..642ae29 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -98,6 +98,26 @@ Release tags are protected against deletion and force-moves: a published tag is immutable, because `publish-packages.yml` builds registry artifacts from the release it produced. +### One-time npm native 0.6.0 bootstrap + +Trusted publishers are per package; the root `tokenfold` configuration does not +authorize creation of `@tokenfold/native-*`. Keep the stricter 2FA policy. The +manual `bootstrap-npm-native.yml` workflow checks the five original native tarballs +from publication run `42955400102` against pinned SHA-256 hashes, then repairs only +their missing repository metadata required for provenance. It verifies unchanged +compiled addon/license bytes and records new tarball hashes in the +`native-bootstrap-tarballs` artifact before staging with provenance. It creates +package placeholders, not a public 0.6.0 SDK. No binaries are rebuilt, package scripts +executed, original assets overwritten, tags moved or 2FA bypassed. + +Approve the staged native packages on npm with maintainer 2FA, then configure each +package's GitHub trusted publisher as `snchimata/tokenfold`, workflow +`publish-packages.yml`, with publishing allowed. Do not approve a different tarball. +If a rerun stops at an already-staged version, approve that pending stage before +rerunning; never overwrite it. Once all five native versions are public, rerun only +the failed npm publication job in the original run, then verify all public versions, +artifact digests and a fresh install. Existing tested artifacts remain the source. + ## Repository rulesets The protection above is stored as code in [.github/rulesets/](.github/rulesets/) diff --git a/crates/tokenfold-node/npm/darwin-arm64/package.json b/crates/tokenfold-node/npm/darwin-arm64/package.json index 8190054..f984f22 100644 --- a/crates/tokenfold-node/npm/darwin-arm64/package.json +++ b/crates/tokenfold-node/npm/darwin-arm64/package.json @@ -1,6 +1,11 @@ { "name": "@tokenfold/native-darwin-arm64", "version": "0.6.0", + "repository": { + "type": "git", + "url": "git+https://github.com/snchimata/tokenfold.git", + "directory": "crates/tokenfold-node/npm/darwin-arm64" + }, "description": "Tokenfold native addon for darwin-arm64", "license": "Apache-2.0", "main": "tokenfold.node", diff --git a/crates/tokenfold-node/npm/darwin-x64/package.json b/crates/tokenfold-node/npm/darwin-x64/package.json index fe42cc6..a7cded8 100644 --- a/crates/tokenfold-node/npm/darwin-x64/package.json +++ b/crates/tokenfold-node/npm/darwin-x64/package.json @@ -1,6 +1,11 @@ { "name": "@tokenfold/native-darwin-x64", "version": "0.6.0", + "repository": { + "type": "git", + "url": "git+https://github.com/snchimata/tokenfold.git", + "directory": "crates/tokenfold-node/npm/darwin-x64" + }, "description": "Tokenfold native addon for darwin-x64", "license": "Apache-2.0", "main": "tokenfold.node", diff --git a/crates/tokenfold-node/npm/linux-arm64/package.json b/crates/tokenfold-node/npm/linux-arm64/package.json index e1e048b..e36f9d5 100644 --- a/crates/tokenfold-node/npm/linux-arm64/package.json +++ b/crates/tokenfold-node/npm/linux-arm64/package.json @@ -1,6 +1,11 @@ { "name": "@tokenfold/native-linux-arm64", "version": "0.6.0", + "repository": { + "type": "git", + "url": "git+https://github.com/snchimata/tokenfold.git", + "directory": "crates/tokenfold-node/npm/linux-arm64" + }, "description": "Tokenfold native addon for linux-arm64", "license": "Apache-2.0", "main": "tokenfold.node", diff --git a/crates/tokenfold-node/npm/linux-x64/package.json b/crates/tokenfold-node/npm/linux-x64/package.json index fee4d12..b811194 100644 --- a/crates/tokenfold-node/npm/linux-x64/package.json +++ b/crates/tokenfold-node/npm/linux-x64/package.json @@ -1,6 +1,11 @@ { "name": "@tokenfold/native-linux-x64", "version": "0.6.0", + "repository": { + "type": "git", + "url": "git+https://github.com/snchimata/tokenfold.git", + "directory": "crates/tokenfold-node/npm/linux-x64" + }, "description": "Tokenfold native addon for linux-x64", "license": "Apache-2.0", "main": "tokenfold.node", diff --git a/crates/tokenfold-node/npm/win32-x64/package.json b/crates/tokenfold-node/npm/win32-x64/package.json index d2cb229..da62a62 100644 --- a/crates/tokenfold-node/npm/win32-x64/package.json +++ b/crates/tokenfold-node/npm/win32-x64/package.json @@ -1,6 +1,11 @@ { "name": "@tokenfold/native-win32-x64", "version": "0.6.0", + "repository": { + "type": "git", + "url": "git+https://github.com/snchimata/tokenfold.git", + "directory": "crates/tokenfold-node/npm/win32-x64" + }, "description": "Tokenfold native addon for win32-x64", "license": "Apache-2.0", "main": "tokenfold.node", diff --git a/packages/tokenfold/test/install.test.mjs b/packages/tokenfold/test/install.test.mjs index 463a21c..ebc70d3 100644 --- a/packages/tokenfold/test/install.test.mjs +++ b/packages/tokenfold/test/install.test.mjs @@ -15,6 +15,8 @@ test('five native manifests are synchronized and install never compiles',()=>{ const m=JSON.parse(readFileSync(join(root,'crates/tokenfold-node/npm',platform,'package.json'))); assert.equal(m.name,`@tokenfold/native-${platform}`);assert.equal(m.version,version); assert.equal(pkg.optionalDependencies[m.name],version);assert.equal(m.main,'tokenfold.node'); + assert.equal(m.repository.url,pkg.repository.url); + assert.equal(m.repository.directory,`crates/tokenfold-node/npm/${platform}`); assert.equal(m.scripts?.install,undefined);assert.equal(m.scripts?.postinstall,undefined); } assert.equal(pkg.scripts.install,undefined);assert.equal(pkg.scripts.postinstall,undefined); diff --git a/scripts/npm-native-bootstrap.test.mjs b/scripts/npm-native-bootstrap.test.mjs new file mode 100644 index 0000000..a03906a --- /dev/null +++ b/scripts/npm-native-bootstrap.test.mjs @@ -0,0 +1,96 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { chmod, mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import test from 'node:test'; + +const workflow = new URL('../.github/workflows/bootstrap-npm-native.yml', import.meta.url); +const source = (await readFile(workflow, 'utf8')).replaceAll('\r\n', '\n'); + +test('native bootstrap is manual and reuses the original tested publication artifact', () => { + assert.match(source, /on: workflow_dispatch/); + assert.doesNotMatch(source, /pull_request:|push:/); + assert.match(source, /artifact-ids: 13827437119/); + assert.match(source, /run-id: 42955400102/); + assert.match(source, /merge-multiple: true/); + assert.match(source, /id-token: write/); + assert.match(source, /actions: read/); + assert.match(source, /github-token: \$\{\{ github.token \}\}/); +}); + +test('native bootstrap verifies all five immutable tarballs before staging', () => { + const rows = [...source.matchAll(/^\s+([a-f0-9]{64}) tokenfold-native-([\w-]+)-0\.6\.0\.tgz$/gm)]; + assert.deepEqual(rows.map(row => row[2]).sort(), + ['darwin-arm64', 'darwin-x64', 'linux-arm64', 'linux-x64', 'win32-x64']); + assert.equal(new Set(rows.map(row => row[1])).size, 5); + assert.ok(source.indexOf('sha256sum -c') < source.indexOf('npm stage publish')); + assert.ok(source.indexOf('sha256sum -c') < source.indexOf('repair_npm_native_metadata.py')); + assert.ok(source.indexOf('repair_npm_native_metadata.py') < source.indexOf('npm stage publish')); + assert.match(source, /name: native-bootstrap-tarballs/); + assert.match(source, /set -euo pipefail/); + assert.match(source, /sha256sum -c ORIGINAL_SHA256SUMS/); + assert.doesNotMatch(source, /sha256sum -c < { + assert.match(source, /npm stage publish .*--access public --provenance --ignore-scripts --json/); + assert.match(source, /npm view "\$package@0\.6\.0" version/); + assert.doesNotMatch(source, /npm publish|npm stage approve|--otp|--provenance=false|npm access/); + assert.match(source, /NODE_AUTH_TOKEN: \$\{\{ secrets.NPM_TOKEN \}\}/); + assert.match(source, /Approve.*2FA/); +}); + +test('bootstrap shell stages only verified bytes and any checksum failure blocks every upload', async () => { + const blocks = [...source.matchAll(/^ run: \|\n((?:^ .*\n|^\n)*)/gm)] + .map(match => match[1].replace(/^ {10}/gm, '')); + let shell = blocks[0] + '\ncd ..\n' + blocks.at(-1); + const directory = await mkdtemp(join(tmpdir(), 'tokenfold-bootstrap-test-')); + const bash = process.platform === 'win32' + ? resolve(execFileSync('git', ['--exec-path'], {encoding: 'utf8'}).trim(), '../../../bin/bash.exe') + : 'bash'; + try { + await mkdir(join(directory, 'tarballs')); + await mkdir(join(directory, 'corrected')); + await mkdir(join(directory, 'bin')); + const files = [...source.matchAll(/([a-f0-9]{64}) (tokenfold-native-[\w.-]+\.tgz)/g)]; + let checksums = ''; + for (const [row, , name] of files) { + const bytes = Buffer.from(`fixture:${name}`); + await writeFile(join(directory, 'tarballs', name), bytes); + await writeFile(join(directory, 'corrected', name), bytes); + const checksum = `${createHash('sha256').update(bytes).digest('hex')} ${name}`; + shell = shell.replace(row, checksum); + checksums += checksum + '\n'; + } + await writeFile(join(directory, 'corrected/SHA256SUMS'), checksums); + const npm = join(directory, 'bin/npm'); + await writeFile(npm, '#!/usr/bin/env bash\nset -eu\nif [[ "$1" == view ]]; then exit 1; fi\nprintf "%s\\n" "$*" >> "$NPM_CALLS"\n[[ "$1 $2" == "stage publish" ]]\necho \'{"stage":"fixture"}\'\n'); + await chmod(npm, 0o755); + const run = () => execFileSync(bash, ['-c', + 'export PATH="$PWD/bin:$PATH"; export NPM_CALLS="$PWD/calls"; export GITHUB_STEP_SUMMARY="$PWD/summary";\n' + shell], + {cwd: directory, env: {...process.env, NODE_AUTH_TOKEN: 'fixture-not-a-credential'}, stdio: 'pipe'}); + run(); + const calls = (await readFile(join(directory, 'calls'), 'utf8')).trim().split('\n'); + assert.equal(calls.length, 5); + assert.ok(calls.every(call => /^stage publish .* --access public --provenance --ignore-scripts --json$/.test(call))); + await writeFile(join(directory, 'calls'), ''); + await writeFile(join(directory, 'tarballs', files.at(-1)[2]), 'tampered'); + assert.throws(run); + assert.equal(await readFile(join(directory, 'calls'), 'utf8'), ''); + const name = files.at(-1)[2]; + await writeFile(join(directory, 'tarballs', name), Buffer.from(`fixture:${name}`)); + await writeFile(join(directory, 'corrected', name), 'tampered'); + assert.throws(run); + assert.equal(await readFile(join(directory, 'calls'), 'utf8'), ''); + } finally { + assert.ok(directory.startsWith(join(tmpdir(), 'tokenfold-bootstrap-test-'))); + await rm(directory, {recursive: true, force: true}); + } +}); + +test('metadata-only repair rejects unexpected manifests and archive members', () => { + execFileSync(process.env.TOKENFOLD_TEST_PYTHON ?? 'python', + [join(import.meta.dirname, 'test_npm_native_metadata.py')], {stdio: 'pipe'}); +}); diff --git a/scripts/repair_npm_native_metadata.py b/scripts/repair_npm_native_metadata.py new file mode 100644 index 0000000..01f8c28 --- /dev/null +++ b/scripts/repair_npm_native_metadata.py @@ -0,0 +1,64 @@ +"""Add only repository metadata to the unpublished 0.6.0 native tarballs.""" +import copy +import gzip +import hashlib +import io +import json +from pathlib import Path +import sys +import tarfile + +PLATFORMS = ('darwin-arm64', 'darwin-x64', 'linux-arm64', 'linux-x64', 'win32-x64') + + +def repack(source, manifest, output): + if source.resolve() == output.resolve(): + raise ValueError('original tarball must not be overwritten') + with tarfile.open(source) as archive: + members = archive.getmembers() + names = {'package/LICENSE', 'package/tokenfold.node', 'package/package.json'} + if len(members) != 3 or {m.name for m in members} != names or not all(m.isfile() for m in members): + raise ValueError('unexpected native tarball contents') + files = {m.name: archive.extractfile(m).read() for m in members} + original = json.loads(files['package/package.json']) + expected = {k: v for k, v in manifest.items() if k != 'repository'} + if original != expected or original.get('version') != '0.6.0': + raise ValueError('native metadata differs beyond the missing repository') + files['package/package.json'] = (json.dumps(manifest, indent=2) + '\n').encode() + with output.open('wb') as raw, gzip.GzipFile(filename='', mode='wb', fileobj=raw, mtime=0) as compressed: + with tarfile.open(fileobj=compressed, mode='w') as archive: + for member in sorted(members, key=lambda m: m.name): + info = copy.copy(member) + info.mtime, info.pax_headers = 0, {} + info.size = len(files[info.name]) + archive.addfile(info, io.BytesIO(files[info.name])) + with tarfile.open(output) as archive: + for name, data in files.items(): + if archive.extractfile(name).read() != data: + raise ValueError('repacked native bytes changed') + return hashlib.sha256(files['package/tokenfold.node']).hexdigest() + + +def main(): + source, output = map(Path, sys.argv[1:]) + output.mkdir(parents=True, exist_ok=True) + root = Path(__file__).resolve().parents[1] + receipts = [] + for platform in PLATFORMS: + filename = f'tokenfold-native-{platform}-0.6.0.tgz' + manifest = json.loads((root / f'crates/tokenfold-node/npm/{platform}/package.json').read_text()) + expected_repository = {'type': 'git', 'url': 'git+https://github.com/snchimata/tokenfold.git', + 'directory': f'crates/tokenfold-node/npm/{platform}'} + if manifest.get('repository') != expected_repository: + raise ValueError('repository does not match the publishing source') + node_hash = repack(source / filename, manifest, output / filename) + receipts.append({'file': filename, 'original_sha256': hashlib.sha256((source / filename).read_bytes()).hexdigest(), + 'sha256': hashlib.sha256((output / filename).read_bytes()).hexdigest(), + 'unchanged_addon_sha256': node_hash}) + (output / 'SHA256SUMS').write_text(''.join(f"{r['sha256']} {r['file']}\n" for r in receipts), encoding='utf8') + (output / 'metadata-repair.json').write_text(json.dumps(receipts, indent=2) + '\n', encoding='utf8') + print(json.dumps(receipts, indent=2)) + + +if __name__ == '__main__': + main() diff --git a/scripts/test_npm_native_metadata.py b/scripts/test_npm_native_metadata.py new file mode 100644 index 0000000..b9e441b --- /dev/null +++ b/scripts/test_npm_native_metadata.py @@ -0,0 +1,58 @@ +import copy +import io +import json +from pathlib import Path +import tarfile +import tempfile +import unittest + +from repair_npm_native_metadata import repack + + +class NativeMetadataTest(unittest.TestCase): + def test_metadata_only_repack_is_deterministic_and_rejects_unexpected_inputs(self): + manifest = json.loads((Path(__file__).resolve().parents[1] / + 'crates/tokenfold-node/npm/win32-x64/package.json').read_text()) + self.assertIn('repository', manifest) + original = copy.deepcopy(manifest) + del original['repository'] + files = {'package/LICENSE': b'license bytes', 'package/tokenfold.node': b'compiled addon', + 'package/package.json': json.dumps(original).encode()} + with tempfile.TemporaryDirectory(prefix='tokenfold-native-metadata-') as tmp: + root = Path(tmp) + source = root / 'original.tgz' + def fixture(members): + with tarfile.open(source, 'w:gz') as tar: + for name, data in members.items(): + info = tarfile.TarInfo(name) + info.size = len(data) + info.mode = 0o644 + tar.addfile(info, io.BytesIO(data)) + fixture(files) + before = source.read_bytes() + outputs = [root / 'first.tgz', root / 'second.tgz'] + for output in outputs: + repack(source, manifest, output) + with tarfile.open(output) as tar: + self.assertEqual({m.name for m in tar}, set(files)) + self.assertEqual(json.load(tar.extractfile('package/package.json')), manifest) + for name in ('package/LICENSE', 'package/tokenfold.node'): + self.assertEqual(tar.extractfile(name).read(), files[name]) + self.assertEqual(outputs[0].read_bytes(), outputs[1].read_bytes()) + self.assertEqual(source.read_bytes(), before) + with self.assertRaises(ValueError): + repack(source, manifest, source) + invalid = copy.deepcopy(manifest) + invalid['version'] = '9.9.9' + with self.assertRaises(ValueError): + repack(source, invalid, root / 'invalid.tgz') + fixture({**files, '../escape': b'not allowed'}) + with self.assertRaises(ValueError): + repack(source, manifest, root / 'invalid.tgz') + fixture({name: data for name, data in files.items() if not name.endswith('.node')}) + with self.assertRaises(ValueError): + repack(source, manifest, root / 'invalid.tgz') + + +if __name__ == '__main__': + unittest.main()