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
75 changes: 75 additions & 0 deletions .github/workflows/bootstrap-npm-native.yml
Original file line number Diff line number Diff line change
@@ -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
20 changes: 20 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/)
Expand Down
5 changes: 5 additions & 0 deletions crates/tokenfold-node/npm/darwin-arm64/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
5 changes: 5 additions & 0 deletions crates/tokenfold-node/npm/darwin-x64/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
5 changes: 5 additions & 0 deletions crates/tokenfold-node/npm/linux-arm64/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
5 changes: 5 additions & 0 deletions crates/tokenfold-node/npm/linux-x64/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
5 changes: 5 additions & 0 deletions crates/tokenfold-node/npm/win32-x64/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 2 additions & 0 deletions packages/tokenfold/test/install.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
96 changes: 96 additions & 0 deletions scripts/npm-native-bootstrap.test.mjs
Original file line number Diff line number Diff line change
@@ -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 <</);
});

test('native bootstrap preserves provenance and requires separate human approval', () => {
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'});
});
64 changes: 64 additions & 0 deletions scripts/repair_npm_native_metadata.py
Original file line number Diff line number Diff line change
@@ -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()
58 changes: 58 additions & 0 deletions scripts/test_npm_native_metadata.py
Original file line number Diff line number Diff line change
@@ -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()
Loading