Skip to content

Commit 87e2baa

Browse files
authored
Merge pull request #49 from itsjling/codex/issue-039-verified-release
Add a verified release gate
2 parents 1ca93aa + 592c33c commit 87e2baa

8 files changed

Lines changed: 404 additions & 35 deletions

File tree

‎.github/workflows/release.yml‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
name: Publish verified package
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
version:
7+
description: npm version argument, such as patch, minor, or 1.2.3
8+
required: true
9+
type: string
10+
11+
permissions:
12+
contents: write
13+
id-token: write
14+
15+
concurrency:
16+
group: npm-release
17+
cancel-in-progress: false
18+
19+
jobs:
20+
publish:
21+
if: github.ref == 'refs/heads/main'
22+
runs-on: ubuntu-latest
23+
environment: npm-publish
24+
steps:
25+
- name: Check out main
26+
uses: actions/checkout@v6
27+
with:
28+
fetch-depth: 0
29+
- name: Set up trusted publishing runtime
30+
uses: actions/setup-node@v6
31+
with:
32+
node-version: 24.x
33+
registry-url: https://registry.npmjs.org
34+
- name: Enable the pinned npm version
35+
run: corepack enable
36+
- name: Install lockfile dependencies
37+
run: corepack npm ci
38+
- name: Set release author
39+
run: |
40+
git config user.name github-actions[bot]
41+
git config user.email 41898282+github-actions[bot]@users.noreply.github.com
42+
- name: Confirm the reviewed main commit is still current
43+
run: |
44+
git fetch --no-tags origin main
45+
test "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)"
46+
- name: Publish verified package
47+
env:
48+
RELEASE_VERSION: ${{ inputs.version }}
49+
run: corepack npm@11.5.1 run release -- "$RELEASE_VERSION"
50+
- name: Push release commit and tag
51+
run: git push origin HEAD:main --follow-tags

‎docs/content/development.mdx‎

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -174,24 +174,33 @@ to the client itself, but the agent receives no credential variables.
174174

175175
## Publish a release
176176

177-
Commit all release changes, then pass a version and any extra `npm version`
178-
options to the release script:
177+
Publish from the protected **npm-publish** GitHub environment. It runs the
178+
product gate before versioning, verifies the exact tarball in a temporary
179+
consumer, then uses npm trusted publishing and provenance. Start the
180+
**Publish verified package** workflow on `main` with a version argument such
181+
as `patch`, `minor`, or `1.0.0`.
179182

180-
```sh
181-
npm run release -- patch
182-
npm run release -- minor
183-
npm run release -- 1.0.0
184-
npm run release -- prerelease --preid beta
185-
```
183+
Configure npmjs.com for trusted publishing from `itsjling/diffsplain`,
184+
workflow file `release.yml`, and environment `npm-publish`. In GitHub, require
185+
approval for that environment and protect `main` and release tags. Do not add
186+
an npm token to this repo or to the workflow.
186187

187-
The script creates the npm version commit and tag, then publishes the public
188-
package. It stops when either command fails. It does not run the project
189-
checks. After a successful publish, push the commit and tag:
188+
The workflow output names the tested commit, version, and registry result. If
189+
publication succeeds but pushing fails, inspect the release commit and tag,
190+
then push them without publishing again. If a check fails, fix it and restart
191+
the workflow before versioning.
190192

191193
```sh
192-
git push origin main --follow-tags
194+
corepack npm run package:verify
193195
```
194196

197+
This local command checks the built tarball without publishing it. The
198+
production audit has no findings. The full audit has 12 high and 5 low findings
199+
below the Blume docs tool.
200+
`npm audit fix --package-lock-only --dry-run` makes no change. We accept this
201+
limited risk while docs builds use only committed docs and do not handle user
202+
content.
203+
195204
## Refresh the demo
196205

197206
`site/todo-demo.js` holds the ten sample files used by the landing page and the

‎package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
"doctor": "node scripts/present.mjs doctor",
5353
"fallow:audit": "fallow audit",
5454
"present": "node scripts/present.mjs",
55+
"package:verify": "node scripts/check.mjs --package-only",
5556
"release": "node scripts/release.mjs",
5657
"summarize": "node scripts/generate-summaries.mjs",
5758
"snapshot": "node scripts/build-diff-data.mjs",

‎scripts/check.mjs‎

Lines changed: 151 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,15 @@
11
import { execFile, spawn } from 'node:child_process';
2-
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
2+
import {
3+
chmod,
4+
copyFile,
5+
mkdtemp,
6+
mkdir,
7+
readFile,
8+
rm,
9+
writeFile,
10+
} from 'node:fs/promises';
311
import { tmpdir } from 'node:os';
4-
import { dirname, join, resolve } from 'node:path';
12+
import { delimiter, dirname, join, resolve } from 'node:path';
513
import { fileURLToPath } from 'node:url';
614
import { promisify } from 'node:util';
715

@@ -44,6 +52,56 @@ const executeStage = proofMode
4452
if (proofFailure === id) throw new Error('proof failure');
4553
}
4654
: (_id, run) => run();
55+
const packageOnly = process.argv.includes('--package-only');
56+
const releaseTarballIndex = process.argv.indexOf('--release-tarball');
57+
const releaseTarball =
58+
releaseTarballIndex === -1
59+
? undefined
60+
: resolve(root, process.argv[releaseTarballIndex + 1]);
61+
const requiredPackageFiles = [
62+
'README.md',
63+
'package.json',
64+
'dist/index.html',
65+
'scripts/build-diff-data.mjs',
66+
'scripts/cli-args.mjs',
67+
'scripts/coding-agents.mjs',
68+
'scripts/doctor.mjs',
69+
'scripts/generate-summaries.mjs',
70+
'scripts/present.mjs',
71+
'scripts/serve-built.mjs',
72+
'scripts/summary-path.mjs',
73+
];
74+
const allowedPackageFile = /^(README(?:\.md)?|LICENSE(?:\.md)?|NOTICE(?:\.md)?|package\.json|dist\/.+|scripts\/(?:build-diff-data|cli-args|coding-agents|doctor|generate-summaries|present|serve-built|summary-path)\.mjs)$/;
75+
const privatePackageFile = /(^|\/)(?:\.env|\.npmrc|\.git|\.github|\.agents|\.codex)(?:\/|$)|\.(?:pem|key)$/i;
76+
77+
export function validatePackageManifest(pack) {
78+
const files = pack.files ?? [];
79+
const paths = new Set(files.map((file) => file.path));
80+
const missing = requiredPackageFiles.filter((path) => !paths.has(path));
81+
const unexpected = files.filter((file) => !allowedPackageFile.test(file.path));
82+
const privateFiles = files.filter((file) => privatePackageFile.test(file.path));
83+
const oversizedPackage = pack.unpackedSize > 12_000_000;
84+
const oversizedFile = files.some((file) => file.size > 1_000_000);
85+
const problems = [
86+
{ present: missing.length > 0, text: `missing ${missing.join(', ')}` },
87+
{
88+
present: unexpected.length > 0,
89+
text: `unexpected ${unexpected.map((file) => file.path).join(', ')}`,
90+
},
91+
{
92+
present: privateFiles.length > 0,
93+
text: `private ${privateFiles.map((file) => file.path).join(', ')}`,
94+
},
95+
{ present: oversizedPackage, text: 'package exceeds 12 MB' },
96+
{ present: oversizedFile, text: 'file exceeds 1 MB' },
97+
]
98+
.filter((problem) => problem.present)
99+
.map((problem) => problem.text);
100+
101+
if (problems.length) {
102+
throw new Error(`Package manifest failed: ${problems.join('; ')}`);
103+
}
104+
}
47105

48106
async function runStage(id, name, run) {
49107
console.log(`\n==> ${name}`);
@@ -57,6 +115,67 @@ async function runStage(id, name, run) {
57115
console.log(`✓ ${name}`);
58116
}
59117

118+
async function makeSmokeCommandFixtures(consumerRoot) {
119+
const bin = join(consumerRoot, 'bin');
120+
const windows = process.platform === 'win32';
121+
const extension = windows ? '.cmd' : '';
122+
const contents = windows
123+
? '@echo off\r\necho test version\r\n'
124+
: '#!/bin/sh\nprintf "%s\\n" "test version"\n';
125+
await mkdir(bin);
126+
for (const command of ['git', 'gh', 'codex']) {
127+
const path = join(bin, `${command}${extension}`);
128+
await writeFile(path, contents);
129+
await chmod(path, 0o755);
130+
}
131+
return bin;
132+
}
133+
134+
async function makeSmokeRuntimeFixture(consumerRoot) {
135+
const fixture = join(consumerRoot, 'fixture');
136+
await mkdir(fixture);
137+
await execFileAsync('git', ['init', '-q'], { cwd: fixture });
138+
await execFileAsync('git', ['config', 'user.email', 'release@example.test'], {
139+
cwd: fixture,
140+
});
141+
await execFileAsync('git', ['config', 'user.name', 'Release test'], {
142+
cwd: fixture,
143+
});
144+
await writeFile(join(fixture, 'changed.txt'), 'before\n');
145+
await execFileAsync('git', ['add', 'changed.txt'], { cwd: fixture });
146+
await execFileAsync('git', ['commit', '-qm', 'base'], { cwd: fixture });
147+
await writeFile(join(fixture, 'changed.txt'), 'after\n');
148+
const runtimeOutput = join(consumerRoot, 'runtime.json');
149+
await execFileAsync(
150+
process.execPath,
151+
[
152+
resolve(
153+
consumerRoot,
154+
'node_modules/diffsplain/scripts/build-diff-data.mjs',
155+
),
156+
'--repo',
157+
fixture,
158+
'--output',
159+
runtimeOutput,
160+
],
161+
{ cwd: consumerRoot },
162+
);
163+
return JSON.parse(await readFile(runtimeOutput, 'utf8'));
164+
}
165+
166+
function verifySmokeResults({ packageJson, version, help, doctor, runtime }) {
167+
const checks = [
168+
packageJson.name === 'diffsplain',
169+
version.stdout.includes(packageJson.version),
170+
help.stdout.includes('Usage:'),
171+
doctor.stdout.includes('Diffsplain doctor'),
172+
runtime.files?.[0]?.path === 'changed.txt',
173+
];
174+
if (checks.includes(false)) {
175+
throw new Error('packed package has the wrong name');
176+
}
177+
}
178+
60179
async function smokeTestPackage() {
61180
const packageRoot = await mkdtemp(join(tmpdir(), 'diffsplain-package-'));
62181
const consumerRoot = join(packageRoot, 'consumer');
@@ -68,6 +187,11 @@ async function smokeTestPackage() {
68187
);
69188
const [pack] = JSON.parse(stdout);
70189
const tarball = join(packageRoot, pack.filename);
190+
validatePackageManifest(pack);
191+
if (releaseTarball) {
192+
await mkdir(dirname(releaseTarball), { recursive: true });
193+
await copyFile(tarball, releaseTarball);
194+
}
71195

72196
await mkdir(consumerRoot);
73197
await writeFile(
@@ -87,13 +211,20 @@ async function smokeTestPackage() {
87211
'node_modules/diffsplain',
88212
packageJson.bin.diffsplain,
89213
);
90-
await execFileAsync(process.execPath, [executable, '--version'], {
214+
const version = await execFileAsync(process.execPath, [executable, '--version'], {
215+
cwd: consumerRoot,
216+
});
217+
const help = await execFileAsync(process.execPath, [executable, '--help'], {
91218
cwd: consumerRoot,
92219
});
93220

94-
if (packageJson.name !== 'diffsplain') {
95-
throw new Error('packed package has the wrong name');
96-
}
221+
const bin = await makeSmokeCommandFixtures(consumerRoot);
222+
const doctor = await execFileAsync(process.execPath, [executable, 'doctor'], {
223+
cwd: consumerRoot,
224+
env: { ...process.env, PATH: `${bin}${delimiter}${process.env.PATH}` },
225+
});
226+
const runtime = await makeSmokeRuntimeFixture(consumerRoot);
227+
verifySmokeResults({ packageJson, version, help, doctor, runtime });
97228
} finally {
98229
await rm(packageRoot, { force: true, recursive: true });
99230
}
@@ -107,12 +238,21 @@ const stages = [
107238
['docs', 'Production docs build', () => runNpm(['run', 'docs:build'])],
108239
];
109240

110-
try {
111-
for (const [id, name, run] of stages) {
241+
export async function runCheck() {
242+
const selectedStages = packageOnly
243+
? stages.filter(([id]) => id === 'build')
244+
: stages;
245+
for (const [id, name, run] of selectedStages) {
112246
await runStage(id, name, run);
113247
}
114248
await runStage('package', 'Packed-package smoke test', smokeTestPackage);
115-
} catch (error) {
116-
console.error(`\nCheck stopped: ${error.message}`);
117-
process.exitCode = 1;
249+
}
250+
251+
if (resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
252+
try {
253+
await runCheck();
254+
} catch (error) {
255+
console.error(`\nCheck stopped: ${error.message}`);
256+
process.exitCode = 1;
257+
}
118258
}

‎scripts/release.mjs‎

Lines changed: 45 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,28 +6,58 @@ import { fileURLToPath } from 'node:url';
66

77
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
88
const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
9+
const verifiedTarball = '.cache/diffsplain-release.tgz';
10+
11+
function npmCommand(args) {
12+
return process.env.npm_execpath
13+
? [process.execPath, [process.env.npm_execpath, ...args]]
14+
: [npm, args];
15+
}
916

1017
function runNpm(args) {
11-
const result = spawnSync(npm, args, {
18+
const [command, commandArgs] = npmCommand(args);
19+
const result = spawnSync(command, commandArgs, {
1220
cwd: root,
1321
stdio: 'inherit',
1422
});
1523
if (result.error) throw result.error;
1624
return result.status ?? 1;
1725
}
1826

27+
function releaseSteps(versionArgs) {
28+
const prerelease =
29+
versionArgs[0].startsWith('pre') ||
30+
/^[v=]?\d+\.\d+\.\d+-/.test(versionArgs[0]);
31+
const publishArgs = [
32+
'publish',
33+
verifiedTarball,
34+
'--access',
35+
'public',
36+
'--provenance',
37+
...(prerelease ? ['--tag', 'next'] : []),
38+
];
39+
return [
40+
['run', 'check'],
41+
['version', ...versionArgs],
42+
[
43+
'run',
44+
'package:verify',
45+
'--',
46+
'--release-tarball',
47+
verifiedTarball,
48+
],
49+
publishArgs,
50+
];
51+
}
52+
1953
export function runRelease(versionArgs, run = runNpm) {
2054
if (!versionArgs[0] || versionArgs[0].startsWith('-')) {
2155
throw new Error(
2256
'Usage: npm run release -- <version> [npm version options]',
2357
);
2458
}
2559

26-
const steps = [
27-
['version', ...versionArgs],
28-
['publish', '--access', 'public'],
29-
];
30-
for (const args of steps) {
60+
for (const args of releaseSteps(versionArgs)) {
3161
const status = run(args);
3262
if (status !== 0) return status;
3363
}
@@ -36,11 +66,19 @@ export function runRelease(versionArgs, run = runNpm) {
3666

3767
if (resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
3868
try {
69+
if (process.env.GITHUB_ACTIONS !== 'true') {
70+
throw new Error('Releases run only in the protected GitHub Actions workflow.');
71+
}
72+
console.log(`Tested commit: ${spawnSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8' }).stdout.trim()}`);
3973
process.exitCode = runRelease(process.argv.slice(2));
4074
if (process.exitCode === 0) {
75+
const [command, commandArgs] = npmCommand(['pkg', 'get', 'version']);
76+
const version = spawnSync(command, commandArgs, { cwd: root, encoding: 'utf8' }).stdout.trim();
4177
console.log(
42-
'Published. Push the version commit and tag with: git push origin main --follow-tags',
78+
`Registry result: published diffsplain ${version} with provenance. Recovery: if the later Git push fails, inspect the release commit and tag, then push them without republishing.`,
4379
);
80+
} else {
81+
console.error('Registry result: not published. Recovery: fix the named stage, then restart the protected workflow. If versioning already ran, inspect the local commit and tag before retrying.');
4482
}
4583
} catch (error) {
4684
console.error(error instanceof Error ? error.message : String(error));

0 commit comments

Comments
 (0)