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
51 changes: 51 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
name: Publish verified package

on:
workflow_dispatch:
inputs:
version:
description: npm version argument, such as patch, minor, or 1.2.3
required: true
type: string

permissions:
contents: write
id-token: write

concurrency:
group: npm-release
cancel-in-progress: false

jobs:
publish:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: npm-publish
steps:
- name: Check out main
uses: actions/checkout@v6
Comment thread
itsjling marked this conversation as resolved.
with:
fetch-depth: 0
- name: Set up trusted publishing runtime
uses: actions/setup-node@v6
with:
node-version: 24.x
registry-url: https://registry.npmjs.org
- name: Enable the pinned npm version
run: corepack enable
- name: Install lockfile dependencies
run: corepack npm ci
- name: Set release author
run: |
git config user.name github-actions[bot]
git config user.email 41898282+github-actions[bot]@users.noreply.github.com
- name: Confirm the reviewed main commit is still current
run: |
git fetch --no-tags origin main
test "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)"
- name: Publish verified package
env:
RELEASE_VERSION: ${{ inputs.version }}
run: corepack npm@11.5.1 run release -- "$RELEASE_VERSION"
- name: Push release commit and tag
run: git push origin HEAD:main --follow-tags
35 changes: 22 additions & 13 deletions docs/content/development.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -43,23 +43,32 @@ and `corepack npm test` builds the app and runs the Node test suite.

## Publish a release

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

Configure npmjs.com for trusted publishing from `itsjling/diffsplain`,
workflow file `release.yml`, and environment `npm-publish`. In GitHub, require
approval for that environment and protect `main` and release tags. Do not add
an npm token to this repo or to the workflow.

The workflow output names the tested commit, version, and registry result. If
publication succeeds but pushing fails, inspect the release commit and tag,
then push them without publishing again. If a check fails, fix it and restart
the workflow before versioning.

```sh
npm run release -- patch
npm run release -- minor
npm run release -- 1.0.0
npm run release -- prerelease --preid beta
corepack npm run package:verify
```

The script creates the npm version commit and tag, then publishes the public
package. It stops when either command fails. It does not run the project
checks. After a successful publish, push the commit and tag:

```sh
git push origin main --follow-tags
```
This local command checks the built tarball without publishing it. The
production audit has no findings. The full audit has 12 high and 5 low findings
below the Blume docs tool.
`npm audit fix --package-lock-only --dry-run` makes no change. We accept this
limited risk while docs builds use only committed docs and do not handle user
content.

## Refresh the demo

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"doctor": "node scripts/present.mjs doctor",
"fallow:audit": "fallow audit",
"present": "node scripts/present.mjs",
"package:verify": "node scripts/check.mjs --package-only",
"release": "node scripts/release.mjs",
"summarize": "node scripts/generate-summaries.mjs",
"snapshot": "node scripts/build-diff-data.mjs",
Expand Down
162 changes: 151 additions & 11 deletions scripts/check.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
import { execFile, spawn } from 'node:child_process';
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import {
chmod,
copyFile,
mkdtemp,
mkdir,
readFile,
rm,
writeFile,
} from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { delimiter, dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { promisify } from 'node:util';

Expand Down Expand Up @@ -44,6 +52,56 @@ const executeStage = proofMode
if (proofFailure === id) throw new Error('proof failure');
}
: (_id, run) => run();
const packageOnly = process.argv.includes('--package-only');
const releaseTarballIndex = process.argv.indexOf('--release-tarball');
const releaseTarball =
releaseTarballIndex === -1
? undefined
: resolve(root, process.argv[releaseTarballIndex + 1]);
const requiredPackageFiles = [
'README.md',
'package.json',
'dist/index.html',
'scripts/build-diff-data.mjs',
'scripts/cli-args.mjs',
'scripts/coding-agents.mjs',
'scripts/doctor.mjs',
'scripts/generate-summaries.mjs',
'scripts/present.mjs',
'scripts/serve-built.mjs',
'scripts/summary-path.mjs',
];
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)$/;
const privatePackageFile = /(^|\/)(?:\.env|\.npmrc|\.git|\.github|\.agents|\.codex)(?:\/|$)|\.(?:pem|key)$/i;

export function validatePackageManifest(pack) {
const files = pack.files ?? [];
const paths = new Set(files.map((file) => file.path));
const missing = requiredPackageFiles.filter((path) => !paths.has(path));
const unexpected = files.filter((file) => !allowedPackageFile.test(file.path));
const privateFiles = files.filter((file) => privatePackageFile.test(file.path));
const oversizedPackage = pack.unpackedSize > 12_000_000;
const oversizedFile = files.some((file) => file.size > 1_000_000);
const problems = [
{ present: missing.length > 0, text: `missing ${missing.join(', ')}` },
{
present: unexpected.length > 0,
text: `unexpected ${unexpected.map((file) => file.path).join(', ')}`,
},
{
present: privateFiles.length > 0,
text: `private ${privateFiles.map((file) => file.path).join(', ')}`,
},
{ present: oversizedPackage, text: 'package exceeds 12 MB' },
{ present: oversizedFile, text: 'file exceeds 1 MB' },
]
.filter((problem) => problem.present)
.map((problem) => problem.text);

if (problems.length) {
throw new Error(`Package manifest failed: ${problems.join('; ')}`);
}
}

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

async function makeSmokeCommandFixtures(consumerRoot) {
const bin = join(consumerRoot, 'bin');
const windows = process.platform === 'win32';
const extension = windows ? '.cmd' : '';
const contents = windows
? '@echo off\r\necho test version\r\n'
: '#!/bin/sh\nprintf "%s\\n" "test version"\n';
await mkdir(bin);
for (const command of ['git', 'gh', 'codex']) {
const path = join(bin, `${command}${extension}`);
await writeFile(path, contents);
await chmod(path, 0o755);
Comment thread
itsjling marked this conversation as resolved.
}
return bin;
}

async function makeSmokeRuntimeFixture(consumerRoot) {
const fixture = join(consumerRoot, 'fixture');
await mkdir(fixture);
await execFileAsync('git', ['init', '-q'], { cwd: fixture });
await execFileAsync('git', ['config', 'user.email', 'release@example.test'], {
cwd: fixture,
});
await execFileAsync('git', ['config', 'user.name', 'Release test'], {
cwd: fixture,
});
await writeFile(join(fixture, 'changed.txt'), 'before\n');
await execFileAsync('git', ['add', 'changed.txt'], { cwd: fixture });
await execFileAsync('git', ['commit', '-qm', 'base'], { cwd: fixture });
await writeFile(join(fixture, 'changed.txt'), 'after\n');
const runtimeOutput = join(consumerRoot, 'runtime.json');
await execFileAsync(
process.execPath,
[
resolve(
consumerRoot,
'node_modules/diffsplain/scripts/build-diff-data.mjs',
),
'--repo',
fixture,
'--output',
runtimeOutput,
],
{ cwd: consumerRoot },
);
return JSON.parse(await readFile(runtimeOutput, 'utf8'));
}

function verifySmokeResults({ packageJson, version, help, doctor, runtime }) {
const checks = [
packageJson.name === 'diffsplain',
version.stdout.includes(packageJson.version),
help.stdout.includes('Usage:'),
doctor.stdout.includes('Diffsplain doctor'),
runtime.files?.[0]?.path === 'changed.txt',
];
if (checks.includes(false)) {
throw new Error('packed package has the wrong name');
}
}

async function smokeTestPackage() {
const packageRoot = await mkdtemp(join(tmpdir(), 'diffsplain-package-'));
const consumerRoot = join(packageRoot, 'consumer');
Expand All @@ -68,6 +187,11 @@ async function smokeTestPackage() {
);
const [pack] = JSON.parse(stdout);
const tarball = join(packageRoot, pack.filename);
validatePackageManifest(pack);
if (releaseTarball) {
await mkdir(dirname(releaseTarball), { recursive: true });
await copyFile(tarball, releaseTarball);
}

await mkdir(consumerRoot);
await writeFile(
Expand All @@ -87,13 +211,20 @@ async function smokeTestPackage() {
'node_modules/diffsplain',
packageJson.bin.diffsplain,
);
await execFileAsync(process.execPath, [executable, '--version'], {
const version = await execFileAsync(process.execPath, [executable, '--version'], {
cwd: consumerRoot,
});
const help = await execFileAsync(process.execPath, [executable, '--help'], {
cwd: consumerRoot,
});

if (packageJson.name !== 'diffsplain') {
throw new Error('packed package has the wrong name');
}
const bin = await makeSmokeCommandFixtures(consumerRoot);
const doctor = await execFileAsync(process.execPath, [executable, 'doctor'], {
cwd: consumerRoot,
env: { ...process.env, PATH: `${bin}${delimiter}${process.env.PATH}` },
});
const runtime = await makeSmokeRuntimeFixture(consumerRoot);
verifySmokeResults({ packageJson, version, help, doctor, runtime });
} finally {
await rm(packageRoot, { force: true, recursive: true });
}
Expand All @@ -107,12 +238,21 @@ const stages = [
['docs', 'Production docs build', () => runNpm(['run', 'docs:build'])],
];

try {
for (const [id, name, run] of stages) {
export async function runCheck() {
const selectedStages = packageOnly
? stages.filter(([id]) => id === 'build')
: stages;
for (const [id, name, run] of selectedStages) {
await runStage(id, name, run);
}
await runStage('package', 'Packed-package smoke test', smokeTestPackage);
} catch (error) {
console.error(`\nCheck stopped: ${error.message}`);
process.exitCode = 1;
}

if (resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
try {
await runCheck();
} catch (error) {
console.error(`\nCheck stopped: ${error.message}`);
process.exitCode = 1;
}
}
52 changes: 45 additions & 7 deletions scripts/release.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,28 +6,58 @@ import { fileURLToPath } from 'node:url';

const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
const verifiedTarball = '.cache/diffsplain-release.tgz';

function npmCommand(args) {
return process.env.npm_execpath
? [process.execPath, [process.env.npm_execpath, ...args]]
: [npm, args];
}

function runNpm(args) {
const result = spawnSync(npm, args, {
const [command, commandArgs] = npmCommand(args);
const result = spawnSync(command, commandArgs, {
cwd: root,
stdio: 'inherit',
});
if (result.error) throw result.error;
return result.status ?? 1;
}

function releaseSteps(versionArgs) {
const prerelease =
versionArgs[0].startsWith('pre') ||
/^[v=]?\d+\.\d+\.\d+-/.test(versionArgs[0]);
const publishArgs = [
'publish',
verifiedTarball,
'--access',
'public',
'--provenance',
...(prerelease ? ['--tag', 'next'] : []),
];
return [
['run', 'check'],
['version', ...versionArgs],
[
'run',
'package:verify',
'--',
'--release-tarball',
verifiedTarball,
],
publishArgs,
];
}

export function runRelease(versionArgs, run = runNpm) {
if (!versionArgs[0] || versionArgs[0].startsWith('-')) {
throw new Error(
'Usage: npm run release -- <version> [npm version options]',
);
}

const steps = [
['version', ...versionArgs],
['publish', '--access', 'public'],
];
for (const args of steps) {
for (const args of releaseSteps(versionArgs)) {
const status = run(args);
if (status !== 0) return status;
}
Expand All @@ -36,11 +66,19 @@ export function runRelease(versionArgs, run = runNpm) {

if (resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
try {
if (process.env.GITHUB_ACTIONS !== 'true') {
throw new Error('Releases run only in the protected GitHub Actions workflow.');
}
console.log(`Tested commit: ${spawnSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8' }).stdout.trim()}`);
process.exitCode = runRelease(process.argv.slice(2));
if (process.exitCode === 0) {
const [command, commandArgs] = npmCommand(['pkg', 'get', 'version']);
const version = spawnSync(command, commandArgs, { cwd: root, encoding: 'utf8' }).stdout.trim();
console.log(
'Published. Push the version commit and tag with: git push origin main --follow-tags',
`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.`,
);
} else {
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.');
}
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
Expand Down
Loading
Loading