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..2edd994
--- /dev/null
+++ b/.github/workflows/shebangs.yml
@@ -0,0 +1,63 @@
+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: 15
+ 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: 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 }}"
+
+ - name: Set main shebangs
+ if: github.event_name == 'push'
+ run: bun scripts/shebangs.ts set --target main
+
+ - name: Commit shebang state locally
+ run: |
+ if git diff --quiet; then
+ echo "Shebangs already normalized"
+ 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
+
+ - name: Verify normalized artifact
+ run: bun run verify:artifact
+
+ - name: Push shebang state
+ run: |
+ 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..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
-bunx note@beta 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
-bunx note@beta --html checklist
-bunx note@beta --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 @@ 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 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 `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 `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 1a2c7fc..9ff07f5 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 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 2fc013a..c120cc4 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 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 bb8bd36..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 bunx note@beta --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 6aa0f3f..bc65933 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 = "https://cdn.jsdelivr.net/gh/effect-native/pad@feature/release-verification-gates";
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 bunx --bun -p https://github.com/effect-native/pad/archive/refs/heads/feature/release-verification-gates.tar.gz note --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 bunx --bun -p https://github.com/effect-native/pad/archive/refs/heads/feature/release-verification-gates.tar.gz note --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: bunx --bun -p https://github.com/effect-native/pad/archive/refs/heads/feature/release-verification-gates.tar.gz note --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 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}`;
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("