From 1a9b42110019c860f0319b327aa41f2280c0683c Mon Sep 17 00:00:00 2001 From: Tom Aylott Date: Wed, 29 Apr 2026 22:26:05 -0400 Subject: [PATCH 1/3] Add release verification gates --- .github/workflows/prepare-release.yml | 46 ++++++ .github/workflows/release.yml | 8 +- .github/workflows/shebangs.yml | 49 +++++++ .github/workflows/verify.yml | 32 ++++ README.md | 10 +- examples/checklist.pad.html | 6 +- examples/checklist.pad.md | 2 +- examples/demo2.pad.svg | 2 +- note.tsx | 11 +- package.json | 8 +- scripts/shebangs.ts | 202 ++++++++++++++++++++++++++ scripts/verify-artifact.ts | 171 ++++++++++++++++++++++ scripts/verify-published.ts | 97 +++++++++++++ scripts/verify-release.ts | 23 +++ 14 files changed, 648 insertions(+), 19 deletions(-) create mode 100644 .github/workflows/prepare-release.yml create mode 100644 .github/workflows/shebangs.yml create mode 100644 .github/workflows/verify.yml create mode 100644 scripts/shebangs.ts create mode 100644 scripts/verify-artifact.ts create mode 100644 scripts/verify-published.ts create mode 100644 scripts/verify-release.ts diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml new file mode 100644 index 0000000..bff1bce --- /dev/null +++ b/.github/workflows/prepare-release.yml @@ -0,0 +1,46 @@ +name: Prepare Release + +on: + workflow_dispatch: + +permissions: + contents: write + +jobs: + prepare: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout main + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 + with: + ref: main + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "24" + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Prepare exact-version release shebangs + run: bun scripts/shebangs.ts set --target release + + - name: Verify release artifact + run: bun run verify:release + + - name: Commit and tag release + run: | + VERSION="$(node -p "require('./package.json').version")" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + if ! git diff --quiet; then + git add README.md note.tsx examples + git commit -m "Prepare note@${VERSION} release" + fi + git tag "v${VERSION}" + git push origin main "v${VERSION}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d8ed3d5..aa69999 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,7 +3,6 @@ name: Release on: push: tags: ["v*"] - workflow_dispatch: permissions: {} @@ -42,8 +41,8 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile - - name: Check package - run: bun run check + - name: Verify release artifact + run: bun run verify:release - name: Verify package version run: | @@ -79,3 +78,6 @@ jobs: unset NPM_CONFIG_USERCONFIG NPM_TAG="$(node -p "require('./package.json').version.includes('-') ? 'beta' : 'latest'")" npm publish --provenance --access public --tag "$NPM_TAG" + + - name: Verify published package + run: bun run verify:published diff --git a/.github/workflows/shebangs.yml b/.github/workflows/shebangs.yml new file mode 100644 index 0000000..99a4662 --- /dev/null +++ b/.github/workflows/shebangs.yml @@ -0,0 +1,49 @@ +name: Normalize Shebangs + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + push: + branches: ["main"] + +permissions: + contents: write + +jobs: + normalize: + if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout PR branch + if: github.event_name == 'pull_request' + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 + with: + ref: ${{ github.event.pull_request.head.ref }} + + - name: Checkout main + if: github.event_name == 'push' + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + + - name: Set PR shebangs + if: github.event_name == 'pull_request' + run: bun scripts/shebangs.ts set --target branch --branch "${{ github.event.pull_request.head.ref }}" + + - name: Set main shebangs + if: github.event_name == 'push' + run: bun scripts/shebangs.ts set --target main + + - name: Commit shebang state + run: | + if git diff --quiet; then + echo "Shebangs already normalized" + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add README.md note.tsx examples + git commit -m "Normalize PAD shebangs" + git push diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml new file mode 100644 index 0000000..44de2b5 --- /dev/null +++ b/.github/workflows/verify.yml @@ -0,0 +1,32 @@ +name: Verify + +on: + pull_request: + push: + branches: ["main"] + +permissions: {} + +jobs: + artifact: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 + with: + persist-credentials: false + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "24" + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Verify artifact + run: bun run verify:artifact diff --git a/README.md b/README.md index a98dab9..cc424d7 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ A PAD is a normal Markdown, HTML, or SVG file that you can preview as a document Try this first. It runs the beta generator and creates a PAD file in the current directory. Preview the created file before you run the PAD itself. ```sh -bunx note@beta some random thing +bun ./note.tsx some random thing ``` Creates: @@ -23,8 +23,8 @@ YYYY-MM-DD-some-random-thing.pad.md Create other previewable web-native forms: ```sh -bunx note@beta --html checklist -bunx note@beta --svg diagram +bun ./note.tsx --html checklist +bun ./note.tsx --svg diagram ``` ## Run @@ -32,10 +32,10 @@ bunx note@beta --svg diagram PAD files can include a shebang: ```sh -#!/usr/bin/env -S bunx --bun note@beta --pad +#!/usr/bin/env -S bun ./note.tsx --pad ``` -When executed, the document path is passed to `note@beta --pad` and enters trusted program mode. Running a PAD crosses the boundary from "view this document" to "execute local code with Bun". Only run PADs you would trust as scripts. +When executed, the document path is passed to `bun ./note.tsx --pad` and enters trusted program mode. Running a PAD crosses the boundary from "view this document" to "execute local code with Bun". Only run PADs you would trust as scripts. ## Trust Ladder diff --git a/examples/checklist.pad.html b/examples/checklist.pad.html index 1a2c7fc..da1e387 100755 --- a/examples/checklist.pad.html +++ b/examples/checklist.pad.html @@ -1,12 +1,12 @@ -#!/usr/bin/env -S bunx --bun note@beta --pad +#!/usr/bin/env -S bun ./note.tsx --pad Checklist PAD - - + +

Checklist PAD

Preview like a file. Open like a page. Run like an app.

diff --git a/examples/checklist.pad.md b/examples/checklist.pad.md index 2fc013a..9533dfa 100755 --- a/examples/checklist.pad.md +++ b/examples/checklist.pad.md @@ -1,4 +1,4 @@ -#!/usr/bin/env -S bunx --bun note@beta --pad +#!/usr/bin/env -S bun ./note.tsx --pad # Checklist diff --git a/examples/demo2.pad.svg b/examples/demo2.pad.svg index bb8bd36..a77b7b7 100644 --- a/examples/demo2.pad.svg +++ b/examples/demo2.pad.svg @@ -2,5 +2,5 @@ SVG PAD SVG PAD - Previewable as SVG. Pass to bunx note@beta --pad only when you trust it. + Previewable as SVG. Pass to bun ./note.tsx --pad only when you trust it. diff --git a/note.tsx b/note.tsx index 6aa0f3f..6f4becf 100755 --- a/note.tsx +++ b/note.tsx @@ -13,6 +13,7 @@ type PadSocket = ServerWebSocket; const HOST = "0.0.0.0"; const DEFAULT_IDLE_SHUTDOWN_MS = 2_000; const DEFAULT_FIRST_CLIENT_TIMEOUT_MS = 5 * 60_000; +const PAD_ASSET_BASE = "."; function readVersion() { const packageJsonPath = resolve(dirname(fileURLToPath(import.meta.url)), "package.json"); @@ -81,7 +82,7 @@ function packageRoot() { } function packageAssetUrl(asset: string) { - return `https://cdn.jsdelivr.net/npm/note@${VERSION}/${asset}`; + return `${PAD_ASSET_BASE}/${asset}`; } function contentTypeFor(pathname: string) { @@ -167,7 +168,7 @@ function renderEditorHtml(config: { } function markdown(title: string) { - return `#!/usr/bin/env -S bunx --bun note@beta --pad + return `#!/usr/bin/env -S bun ./note.tsx --pad # ${title} @@ -177,7 +178,7 @@ function markdown(title: string) { function html(title: string) { const safeTitle = escapeHtml(title); - return `#!/usr/bin/env -S bunx --bun note@beta --pad + return `#!/usr/bin/env -S bun ./note.tsx --pad @@ -200,7 +201,7 @@ function svg(title: string) { ${safeTitle} ${safeTitle} - Preview like a file. Open like a page. Run with: bunx note@beta --pad + Preview like a file. Open like a page. Run with: bun ./note.tsx --pad `; } @@ -236,7 +237,7 @@ async function createPad(args: Array) { const title = titleParts.join(" ").trim() || "untitled"; if (!forcedTitle && looksLikePath(title)) { - die(`That looks like a file path.\n\nRun a PAD with:\n bunx note@beta --pad ${title}\n\nForce a literal title with:\n bunx note@beta --title ${JSON.stringify(title)}`); + die(`That looks like a file path.\n\nRun a PAD with:\n bun ./note.tsx --pad ${title}\n\nForce a literal title with:\n bun ./note.tsx --title ${JSON.stringify(title)}`); } const file = `${localDate()}-${slugify(title)}.pad.${format}`; diff --git a/package.json b/package.json index dd2d46e..dfbcb17 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,13 @@ "examples" ], "scripts": { - "check": "bun -e \"const result=await Bun.build({entrypoints:['./note.tsx'],target:'bun',write:false});if(!result.success)process.exit(1)\" && node --check pad.mjs" + "check": "bun -e \"const result=await Bun.build({entrypoints:['./note.tsx'],target:'bun',write:false});if(!result.success)process.exit(1)\" && node --check pad.mjs", + "shebangs": "bun scripts/shebangs.ts", + "shebangs:check": "bun scripts/shebangs.ts check", + "shebangs:set": "bun scripts/shebangs.ts set", + "verify:artifact": "bun scripts/verify-artifact.ts", + "verify:release": "bun scripts/verify-release.ts", + "verify:published": "bun scripts/verify-published.ts" }, "engines": { "bun": ">=1.3.0" diff --git a/scripts/shebangs.ts b/scripts/shebangs.ts new file mode 100644 index 0000000..7afb017 --- /dev/null +++ b/scripts/shebangs.ts @@ -0,0 +1,202 @@ +#!/usr/bin/env bun +import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { dirname, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +type Target = "local" | "branch" | "main" | "release"; + +type RunnerState = { + target: Target; + shebang: string; + padCommand: string; + createCommand: string; + assetBase: string; +}; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const githubRepo = "effect-native/pad"; +const ignoredDirectories = new Set([".git", "node_modules", "tasks"]); +const textExtensions = new Set([".md", ".markdown", ".html", ".htm", ".svg", ".tsx"]); + +function readText(path: string) { + return readFileSync(resolve(repoRoot, path), "utf8"); +} + +function writeText(path: string, content: string) { + writeFileSync(resolve(repoRoot, path), content); +} + +function extension(path: string) { + const match = /\.[^.]+$/.exec(path); + return match ? match[0].toLowerCase() : ""; +} + +function walk(dir = repoRoot): Array { + const paths: Array = []; + for (const entry of readdirSync(dir)) { + if (ignoredDirectories.has(entry)) continue; + const absolute = resolve(dir, entry); + const stats = statSync(absolute); + if (stats.isDirectory()) { + paths.push(...walk(absolute)); + continue; + } + const path = relative(repoRoot, absolute); + if (textExtensions.has(extension(path))) paths.push(path); + } + return paths.sort(); +} + +function padFiles() { + return walk().filter((path) => /\.pad\.(?:md|markdown|html?|svg)$/i.test(path)); +} + +function managedFiles() { + return Array.from(new Set(["README.md", "note.tsx", ...padFiles()].filter((path) => { + try { + readText(path); + return true; + } catch { + return false; + } + }))).sort(); +} + +function packageVersion() { + return JSON.parse(readText("package.json")).version as string; +} + +async function currentBranch() { + const proc = Bun.spawn(["git", "branch", "--show-current"], { cwd: repoRoot, stdout: "pipe", stderr: "pipe" }); + const output = (await new Response(proc.stdout).text()).trim(); + if ((await proc.exited) !== 0 || !output) return process.env.GITHUB_HEAD_REF || process.env.GITHUB_REF_NAME || "main"; + return output; +} + +function arg(name: string) { + const index = Bun.argv.indexOf(name); + return index === -1 ? undefined : Bun.argv[index + 1]; +} + +function archiveRunner(ref: string) { + return `bunx --bun -p https://github.com/${githubRepo}/archive/refs/heads/${ref}.tar.gz note`; +} + +function githubAssetBase(ref: string) { + return `https://cdn.jsdelivr.net/gh/${githubRepo}@${ref}`; +} + +async function state(target: Target): Promise { + const version = arg("--version") || packageVersion(); + const branch = arg("--branch") || process.env.GITHUB_HEAD_REF || process.env.GITHUB_REF_NAME || await currentBranch(); + const base = target === "branch" ? branch : target; + const runner = target === "local" + ? "bun ./note.tsx" + : target === "release" + ? `bunx --bun note@${version}` + : archiveRunner(base); + const assetBase = target === "local" + ? "." + : target === "release" + ? `https://cdn.jsdelivr.net/npm/note@${version}` + : githubAssetBase(base); + + return { + target, + shebang: `#!/usr/bin/env -S ${runner} --pad`, + padCommand: `${runner} --pad`, + createCommand: runner, + assetBase, + }; +} + +function replaceAll(content: string, replacements: Array<[RegExp, string]>) { + return replacements.reduce((next, [pattern, value]) => next.replace(pattern, value), content); +} + +function assetBaseForFile(next: RunnerState, path: string) { + if (next.target !== "local") return next.assetBase; + const from = dirname(path); + const relativeBase = relative(from, ".").replaceAll("\\", "/"); + return relativeBase === "" ? "." : relativeBase; +} + +function updatePadFile(path: string, next: RunnerState) { + const content = readText(path); + const assetBase = assetBaseForFile(next, path); + let updated = replaceAll(content, [ + [/^#!\/usr\/bin\/env -S .* --pad$/m, next.shebang], + [/href="[^"]+\/pad\.css"/g, `href="${assetBase}/pad.css"`], + [/src="[^"]+\/pad\.mjs"/g, `src="${assetBase}/pad.mjs"`], + [/Pass to .* --pad only when you trust it\./g, `Pass to ${next.padCommand} only when you trust it.`], + [/Run with: .* --pad/g, `Run with: ${next.padCommand}`], + [/Runnable with .* --pad\./g, `Runnable with ${next.padCommand}.`], + ]); + + if (updated !== content) writeText(path, updated); +} + +function updateReadme(next: RunnerState) { + const content = readText("README.md"); + writeText("README.md", replaceAll(content, [ + [/^bun(?:x)?(?: --bun)?(?: -p \S+)?(?: \.\/note\.tsx| note(?:@[^\s]+)?) some random thing$/m, `${next.createCommand} some random thing`], + [/^bun(?:x)?(?: --bun)?(?: -p \S+)?(?: \.\/note\.tsx| note(?:@[^\s]+)?) --html checklist$/m, `${next.createCommand} --html checklist`], + [/^bun(?:x)?(?: --bun)?(?: -p \S+)?(?: \.\/note\.tsx| note(?:@[^\s]+)?) --svg diagram$/m, `${next.createCommand} --svg diagram`], + [/^#!\/usr\/bin\/env -S .* --pad$/m, next.shebang], + [/document path is passed to `[^`]+ --pad`/g, `document path is passed to \`${next.padCommand}\``], + ])); +} + +function updateNote(next: RunnerState) { + const content = readText("note.tsx"); + writeText("note.tsx", replaceAll(content, [ + [/const PAD_ASSET_BASE = "[^"]+";/, `const PAD_ASSET_BASE = "${next.assetBase}";`], + [/#!\/usr\/bin\/env -S .* --pad/g, next.shebang], + [/Run with: .* --pad<\/text>/, `Run with: ${next.padCommand}`], + [/Run a PAD with:\\n .* --pad \$\{title\}/, `Run a PAD with:\\n ${next.padCommand} \${title}`], + [/Force a literal title with:\\n .* --title \$\{JSON\.stringify\(title\)\}/, `Force a literal title with:\\n ${next.createCommand} --title \${JSON.stringify(title)}`], + ])); +} + +function updateAll(next: RunnerState) { + for (const path of padFiles()) updatePadFile(path, next); + updateReadme(next); + updateNote(next); +} + +function snapshot() { + return managedFiles().map((path) => [path, readText(path)] as const); +} + +function restore(files: ReadonlyArray) { + for (const [path, content] of files) writeText(path, content); +} + +async function main() { + const command = Bun.argv[2]; + const target = (arg("--target") || Bun.argv[3]) as Target | undefined; + if ((command !== "set" && command !== "check") || !target || !["local", "branch", "main", "release"].includes(target)) { + console.error("Usage: shebangs.ts set|check --target local|branch|main|release [--branch name] [--version x.y.z]"); + process.exit(1); + } + + const next = await state(target); + if (command === "set") { + updateAll(next); + console.log(`shebangs set: ${target}`); + return; + } + + const before = snapshot(); + updateAll(next); + const after = snapshot(); + restore(before); + const changed = before.some(([path, content], index) => path !== after[index][0] || content !== after[index][1]); + if (changed) { + console.error(`shebangs are not in ${target} state`); + process.exit(1); + } + console.log(`shebangs ok: ${target}`); +} + +await main(); diff --git a/scripts/verify-artifact.ts b/scripts/verify-artifact.ts new file mode 100644 index 0000000..7564093 --- /dev/null +++ b/scripts/verify-artifact.ts @@ -0,0 +1,171 @@ +#!/usr/bin/env bun +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { basename, dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const version = JSON.parse(await readFile(resolve(repoRoot, "package.json"), "utf8")).version as string; + +type RunOptions = { + cwd?: string; + env?: Record; + timeoutMs?: number; +}; + +async function run(args: Array, options: RunOptions = {}) { + const proc = Bun.spawn(args, { + cwd: options.cwd || repoRoot, + env: { ...process.env, ...options.env }, + stdout: "pipe", + stderr: "pipe", + }); + const timeout = setTimeout(() => proc.kill(), options.timeoutMs || 120_000); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]).finally(() => clearTimeout(timeout)); + if (exitCode !== 0) { + throw new Error(`${args.join(" ")} failed with ${exitCode}\n${stdout}\n${stderr}`); + } + return stdout.trim(); +} + +async function tempDir() { + return (await run(["mktemp", "-d"])).trim(); +} + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message); +} + +async function waitForLocalUrl(proc: ReturnType, label: string) { + const decoder = new TextDecoder(); + const reader = proc.stdout.getReader(); + let output = ""; + const started = Date.now(); + while (Date.now() - started < 15_000) { + const { value, done } = await reader.read(); + if (done) break; + output += decoder.decode(value); + const match = /Local:\s+(http:\/\/[^\s]+)/.exec(output); + if (match) return match[1]; + } + throw new Error(`${label}: server did not print Local URL. Output:\n${output}`); +} + +async function exercisePadServer(command: Array, file: string, label: string) { + const proc = Bun.spawn(command, { + cwd: repoRoot, + env: { + ...process.env, + NOTE_PAD_NO_OPEN: "1", + NOTE_PAD_IDLE_MS: "200", + NOTE_PAD_FIRST_CLIENT_TIMEOUT_MS: "8000", + }, + stdout: "pipe", + stderr: "pipe", + }); + + const localUrl = await waitForLocalUrl(proc, label); + assert(localUrl.includes("?t="), `${label}: Local URL is not tokenized`); + const withoutToken = localUrl.replace(/\?.*/, ""); + assert(await fetch(withoutToken).then((response) => response.status) === 403, `${label}: missing token did not 403`); + const editor = await fetch(localUrl).then((response) => response.text()); + assert(editor.includes("Trusted local PAD server"), `${label}: editor did not render`); + assert(editor.includes("/qr.svg?t="), `${label}: editor did not include tokenized QR`); + const qrUrl = new URL(`/qr.svg${new URL(localUrl).search}`, localUrl); + assert((await fetch(qrUrl).then((response) => response.text())).includes(">((resolvePromise, reject) => { + const timer = setTimeout(() => reject(new Error(`${label}: WebSocket hello timeout`)), 5_000); + ws.addEventListener("message", (event) => { + const message = JSON.parse(event.data); + if (message.type === "hello") { + clearTimeout(timer); + resolvePromise(message); + } + }); + ws.addEventListener("error", reject); + }); + assert(hello.source, `${label}: hello did not include source`); + const edited = hello.source.includes("Smoke") + ? hello.source.replace("Smoke", "Smoke Verified") + : `${hello.source}\n\n`; + await new Promise((resolvePromise, reject) => { + const timer = setTimeout(() => reject(new Error(`${label}: save timeout`)), 5_000); + ws.addEventListener("message", (event) => { + const message = JSON.parse(event.data); + if (message.type === "saved") { + clearTimeout(timer); + resolvePromise(); + } + }); + ws.send(JSON.stringify({ type: "save", source: edited, reason: label })); + }); + ws.close(); + const exit = await Promise.race([proc.exited, new Promise((resolvePromise) => setTimeout(() => resolvePromise("timeout"), 7_000))]); + if (exit === "timeout") { + proc.kill(); + throw new Error(`${label}: server did not stop after client close`); + } + assert((await readFile(file, "utf8")).includes("Smoke Verified"), `${label}: save did not write to disk`); +} + +async function packPackage() { + const packDir = await tempDir(); + const raw = await run(["npm", "pack", "--json", "--pack-destination", packDir]); + const [pack] = JSON.parse(raw) as Array<{ filename: string; files: Array<{ path: string }> }>; + const paths = pack.files.map((file) => file.path).sort(); + const required = [ + "README.md", + "note.tsx", + "package.json", + "pad.css", + "pad.mjs", + ]; + for (const requiredPath of required) assert(paths.includes(requiredPath), `package is missing ${requiredPath}`); + assert(paths.some((path) => path.startsWith("examples/") && /\.pad\./.test(path)), "package does not include any PAD examples"); + for (const path of paths) { + assert(!path.startsWith("tasks/"), `package includes local review artifact ${path}`); + assert(!path.startsWith("scripts/"), `package includes release tooling ${path}`); + assert(!path.startsWith("node_modules/"), `package includes node_modules artifact ${path}`); + assert(!path.endsWith(".tgz"), `package includes nested tarball ${path}`); + } + return resolve(packDir, pack.filename); +} + +async function createSmokePad(dir: string, name: string) { + await mkdir(dir, { recursive: true }); + const file = resolve(dir, name); + await writeFile(file, `#!/usr/bin/env -S bun ./note.tsx --pad\n\n\nSmoke\n

Smoke

\n`); + return file; +} + +async function main() { + await run(["bun", "install", "--frozen-lockfile"]); + await run(["bun", "run", "check"]); + + const generatedDir = await tempDir(); + const generatedName = await run(["bun", resolve(repoRoot, "note.tsx"), "--html", "Artifact Smoke"], { cwd: generatedDir }); + const generated = await readFile(resolve(generatedDir, generatedName), "utf8"); + assert(generated.includes("--pad"), "generated HTML is missing executable PAD shebang"); + assert(generated.includes("/pad.css") || generated.includes("./pad.css"), "generated HTML is missing pad.css asset URL"); + assert(generated.includes("/pad.mjs") || generated.includes("./pad.mjs"), "generated HTML is missing pad.mjs asset URL"); + + const sourceSmoke = await createSmokePad(await tempDir(), "source.pad.html"); + await exercisePadServer(["bun", resolve(repoRoot, "note.tsx"), "--pad", sourceSmoke, "--no-open"], sourceSmoke, "source server"); + + const tarball = await packPackage(); + assert(await run(["bunx", "--bun", "-p", tarball, "note", "--version"]) === version, "packed CLI returned wrong version"); + const packedSmoke = await createSmokePad(await tempDir(), "packed.pad.html"); + await exercisePadServer(["bunx", "--bun", "-p", tarball, "note", "--pad", packedSmoke, "--no-open"], packedSmoke, "packed server"); + + console.log(`verify-artifact ok: ${basename(tarball)}`); +} + +await main(); diff --git a/scripts/verify-published.ts b/scripts/verify-published.ts new file mode 100644 index 0000000..6ad63da --- /dev/null +++ b/scripts/verify-published.ts @@ -0,0 +1,97 @@ +#!/usr/bin/env bun +import { readdir, readFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const version = JSON.parse(await readFile(resolve(repoRoot, "package.json"), "utf8")).version as string; +const distTag = version.includes("-") ? "beta" : "latest"; + +async function examples(dir = resolve(repoRoot, "examples")): Promise> { + const found: Array = []; + for (const entry of await readdir(dir, { withFileTypes: true })) { + const absolute = resolve(dir, entry.name); + if (entry.isDirectory()) { + found.push(...await examples(absolute)); + continue; + } + if (/\.pad\.(?:md|markdown|html?|svg)$/i.test(entry.name)) found.push(absolute); + } + return found.sort(); +} + +async function run(args: Array, options: { timeoutMs?: number } = {}) { + const proc = Bun.spawn(args, { cwd: repoRoot, stdout: "pipe", stderr: "pipe", env: { ...process.env, NOTE_PAD_NO_OPEN: "1", NOTE_PAD_IDLE_MS: "200", NOTE_PAD_FIRST_CLIENT_TIMEOUT_MS: "8000" } }); + const timeout = setTimeout(() => proc.kill(), options.timeoutMs || 120_000); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]).finally(() => clearTimeout(timeout)); + if (exitCode !== 0) throw new Error(`${args.join(" ")} failed with ${exitCode}\n${stdout}\n${stderr}`); + return stdout.trim(); +} + +async function waitForDistTag() { + for (let attempt = 0; attempt < 20; attempt += 1) { + const raw = await run(["npm", "view", "note", "dist-tags", "--json"]); + const tags = JSON.parse(raw) as Record; + if (tags[distTag] === version) return; + await Bun.sleep(3_000); + } + throw new Error(`npm dist-tag ${distTag} did not resolve to ${version}`); +} + +async function runPublishedServer(args: Array, label: string) { + const proc = Bun.spawn(args, { cwd: repoRoot, stdout: "pipe", stderr: "pipe", env: { ...process.env, NOTE_PAD_NO_OPEN: "1", NOTE_PAD_IDLE_MS: "200", NOTE_PAD_FIRST_CLIENT_TIMEOUT_MS: "8000" } }); + const decoder = new TextDecoder(); + const reader = proc.stdout.getReader(); + let output = ""; + let localUrl = ""; + const started = Date.now(); + while (Date.now() - started < 20_000) { + const { value, done } = await reader.read(); + if (done) break; + output += decoder.decode(value); + const match = /Local:\s+(http:\/\/[^\s]+)/.exec(output); + if (match) { + localUrl = match[1]; + break; + } + } + if (!localUrl.includes("?t=")) throw new Error(`${label}: no tokenized Local URL\n${output}`); + const wsUrl = new URL(localUrl); + wsUrl.protocol = "ws:"; + wsUrl.pathname = "/ws"; + const ws = new WebSocket(wsUrl); + await new Promise((resolvePromise, reject) => { + const timer = setTimeout(() => reject(new Error(`${label}: WebSocket timeout`)), 6_000); + ws.addEventListener("message", (event) => { + if (JSON.parse(event.data).type === "hello") { + clearTimeout(timer); + resolvePromise(); + } + }); + ws.addEventListener("error", reject); + }); + ws.close(); + const exit = await Promise.race([proc.exited, new Promise((resolvePromise) => setTimeout(() => resolvePromise("timeout"), 7_000))]); + if (exit === "timeout") { + proc.kill(); + throw new Error(`${label}: server did not idle-shutdown`); + } +} + +await waitForDistTag(); +if (await run(["bunx", "--bun", `note@${distTag}`, "--version"]) !== version) throw new Error(`note@${distTag} did not return ${version}`); +for (const example of await examples()) { + const source = await readFile(example, "utf8"); + const relative = example.replace(`${repoRoot}/`, ""); + if (source.startsWith("#!")) await runPublishedServer([example, "--no-open"], `${relative} shebang`); + else await runPublishedServer(["bunx", "--bun", `note@${version}`, "--pad", example, "--no-open"], `${relative} exact package`); +} +for (const asset of ["pad.css", "pad.mjs"]) { + const response = await fetch(`https://cdn.jsdelivr.net/npm/note@${version}/${asset}`); + if (!response.ok) throw new Error(`CDN asset failed: ${asset} ${response.status}`); +} +console.log(`verify-published ok: note@${version}`); diff --git a/scripts/verify-release.ts b/scripts/verify-release.ts new file mode 100644 index 0000000..c996c9b --- /dev/null +++ b/scripts/verify-release.ts @@ -0,0 +1,23 @@ +#!/usr/bin/env bun +import { readFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const version = JSON.parse(await readFile(resolve(repoRoot, "package.json"), "utf8")).version as string; + +async function run(args: Array) { + const proc = Bun.spawn(args, { cwd: repoRoot, stdout: "inherit", stderr: "inherit" }); + const exitCode = await proc.exited; + if (exitCode !== 0) throw new Error(`${args.join(" ")} failed with ${exitCode}`); +} + +async function versionExists() { + const proc = Bun.spawn(["npm", "view", `note@${version}`, "version"], { cwd: repoRoot, stdout: "pipe", stderr: "pipe" }); + return await proc.exited === 0; +} + +if (!process.env.NOTE_ALLOW_PUBLISHED_VERSION && await versionExists()) throw new Error(`note@${version} already exists on npm`); +await run(["bun", "scripts/shebangs.ts", "check", "--target", "release", "--version", version]); +await run(["bun", "scripts/verify-artifact.ts"]); +console.log(`verify-release ok: note@${version}`); From ab6c4a664e1eb97468dff1ca3ed96cbb70a1b51e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 30 Apr 2026 02:26:46 +0000 Subject: [PATCH 2/3] Normalize PAD shebangs --- README.md | 10 +++++----- examples/checklist.pad.html | 6 +++--- examples/checklist.pad.md | 2 +- examples/demo2.pad.svg | 2 +- note.tsx | 10 +++++----- 5 files changed, 15 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index cc424d7..1c0dad6 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ A PAD is a normal Markdown, HTML, or SVG file that you can preview as a document Try this first. It runs the beta generator and creates a PAD file in the current directory. Preview the created file before you run the PAD itself. ```sh -bun ./note.tsx some random thing +bunx --bun -p https://github.com/effect-native/pad/archive/refs/heads/feature/release-verification-gates.tar.gz note some random thing ``` Creates: @@ -23,8 +23,8 @@ YYYY-MM-DD-some-random-thing.pad.md Create other previewable web-native forms: ```sh -bun ./note.tsx --html checklist -bun ./note.tsx --svg diagram +bunx --bun -p https://github.com/effect-native/pad/archive/refs/heads/feature/release-verification-gates.tar.gz note --html checklist +bunx --bun -p https://github.com/effect-native/pad/archive/refs/heads/feature/release-verification-gates.tar.gz note --svg diagram ``` ## Run @@ -32,10 +32,10 @@ bun ./note.tsx --svg diagram PAD files can include a shebang: ```sh -#!/usr/bin/env -S bun ./note.tsx --pad +#!/usr/bin/env -S bunx --bun -p https://github.com/effect-native/pad/archive/refs/heads/feature/release-verification-gates.tar.gz note --pad ``` -When executed, the document path is passed to `bun ./note.tsx --pad` and enters trusted program mode. Running a PAD crosses the boundary from "view this document" to "execute local code with Bun". Only run PADs you would trust as scripts. +When executed, the document path is passed to `bunx --bun -p https://github.com/effect-native/pad/archive/refs/heads/feature/release-verification-gates.tar.gz note --pad` and enters trusted program mode. Running a PAD crosses the boundary from "view this document" to "execute local code with Bun". Only run PADs you would trust as scripts. ## Trust Ladder diff --git a/examples/checklist.pad.html b/examples/checklist.pad.html index da1e387..9ff07f5 100755 --- a/examples/checklist.pad.html +++ b/examples/checklist.pad.html @@ -1,12 +1,12 @@ -#!/usr/bin/env -S bun ./note.tsx --pad +#!/usr/bin/env -S bunx --bun -p https://github.com/effect-native/pad/archive/refs/heads/feature/release-verification-gates.tar.gz note --pad Checklist PAD - - + +

Checklist PAD

Preview like a file. Open like a page. Run like an app.

diff --git a/examples/checklist.pad.md b/examples/checklist.pad.md index 9533dfa..c120cc4 100755 --- a/examples/checklist.pad.md +++ b/examples/checklist.pad.md @@ -1,4 +1,4 @@ -#!/usr/bin/env -S bun ./note.tsx --pad +#!/usr/bin/env -S bunx --bun -p https://github.com/effect-native/pad/archive/refs/heads/feature/release-verification-gates.tar.gz note --pad # Checklist diff --git a/examples/demo2.pad.svg b/examples/demo2.pad.svg index a77b7b7..8d20213 100644 --- a/examples/demo2.pad.svg +++ b/examples/demo2.pad.svg @@ -2,5 +2,5 @@ SVG PAD SVG PAD - Previewable as SVG. Pass to bun ./note.tsx --pad only when you trust it. + Previewable as SVG. Pass to bunx --bun -p https://github.com/effect-native/pad/archive/refs/heads/feature/release-verification-gates.tar.gz note --pad only when you trust it. diff --git a/note.tsx b/note.tsx index 6f4becf..bc65933 100755 --- a/note.tsx +++ b/note.tsx @@ -13,7 +13,7 @@ type PadSocket = ServerWebSocket; const HOST = "0.0.0.0"; const DEFAULT_IDLE_SHUTDOWN_MS = 2_000; const DEFAULT_FIRST_CLIENT_TIMEOUT_MS = 5 * 60_000; -const PAD_ASSET_BASE = "."; +const PAD_ASSET_BASE = "https://cdn.jsdelivr.net/gh/effect-native/pad@feature/release-verification-gates"; function readVersion() { const packageJsonPath = resolve(dirname(fileURLToPath(import.meta.url)), "package.json"); @@ -168,7 +168,7 @@ function renderEditorHtml(config: { } function markdown(title: string) { - return `#!/usr/bin/env -S bun ./note.tsx --pad + return `#!/usr/bin/env -S bunx --bun -p https://github.com/effect-native/pad/archive/refs/heads/feature/release-verification-gates.tar.gz note --pad # ${title} @@ -178,7 +178,7 @@ function markdown(title: string) { function html(title: string) { const safeTitle = escapeHtml(title); - return `#!/usr/bin/env -S bun ./note.tsx --pad + return `#!/usr/bin/env -S bunx --bun -p https://github.com/effect-native/pad/archive/refs/heads/feature/release-verification-gates.tar.gz note --pad @@ -201,7 +201,7 @@ function svg(title: string) { ${safeTitle} ${safeTitle} - Preview like a file. Open like a page. Run with: bun ./note.tsx --pad + Preview like a file. Open like a page. Run with: bunx --bun -p https://github.com/effect-native/pad/archive/refs/heads/feature/release-verification-gates.tar.gz note --pad `; } @@ -237,7 +237,7 @@ async function createPad(args: Array) { const title = titleParts.join(" ").trim() || "untitled"; if (!forcedTitle && looksLikePath(title)) { - die(`That looks like a file path.\n\nRun a PAD with:\n bun ./note.tsx --pad ${title}\n\nForce a literal title with:\n bun ./note.tsx --title ${JSON.stringify(title)}`); + die(`That looks like a file path.\n\nRun a PAD with:\n bunx --bun -p https://github.com/effect-native/pad/archive/refs/heads/feature/release-verification-gates.tar.gz note --pad ${title}\n\nForce a literal title with:\n bunx --bun -p https://github.com/effect-native/pad/archive/refs/heads/feature/release-verification-gates.tar.gz note --title ${JSON.stringify(title)}`); } const file = `${localDate()}-${slugify(title)}.pad.${format}`; From 1ac4c6be3bc64b9e8ab29ba606523f79a9d74e88 Mon Sep 17 00:00:00 2001 From: Tom Aylott Date: Wed, 29 Apr 2026 22:28:24 -0400 Subject: [PATCH 3/3] Verify normalized shebang artifacts --- .github/workflows/shebangs.yml | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/.github/workflows/shebangs.yml b/.github/workflows/shebangs.yml index 99a4662..2edd994 100644 --- a/.github/workflows/shebangs.yml +++ b/.github/workflows/shebangs.yml @@ -13,7 +13,7 @@ jobs: normalize: if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest - timeout-minutes: 5 + timeout-minutes: 15 steps: - name: Checkout PR branch if: github.event_name == 'pull_request' @@ -28,6 +28,14 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "24" + + - name: Install dependencies + run: bun install --frozen-lockfile + - name: Set PR shebangs if: github.event_name == 'pull_request' run: bun scripts/shebangs.ts set --target branch --branch "${{ github.event.pull_request.head.ref }}" @@ -36,14 +44,20 @@ jobs: if: github.event_name == 'push' run: bun scripts/shebangs.ts set --target main - - name: Commit shebang state + - name: Commit shebang state locally run: | if git diff --quiet; then echo "Shebangs already normalized" - exit 0 + else + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add README.md note.tsx examples + git commit -m "Normalize PAD shebangs" fi - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add README.md note.tsx examples - git commit -m "Normalize PAD shebangs" + + - name: Verify normalized artifact + run: bun run verify:artifact + + - name: Push shebang state + run: | git push