From abf4dfec2e84f2ee9e5521175923394777b1568b Mon Sep 17 00:00:00 2001 From: Conductor Adriaan Date: Wed, 28 Oct 2026 21:40:51 +0000 Subject: [PATCH 1/4] Deploy each script with all its files and record it per script The deploy CLI no longer asks for variants: the default embed script always deploys latest, SRI, light and proxy together, and auto-events deploys with its pinned Caddy file. Each script is recorded in its own GitHub environment per destination (cdn, custom-domains, cdn-auto-events, custom-domains-auto-events). Fixes #147 Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 2 +- deploy.mts | 167 ++++++++++++++++++------------------------- test/deploy.test.mts | 87 +++++++++++----------- 3 files changed, 110 insertions(+), 146 deletions(-) diff --git a/README.md b/README.md index 53655f8..94dd45c 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ pnpm run deploy The interactive CLI starts in dry-run mode by default. You pick a release (the newest by default), and the CLI downloads its `scripts-vN.tar.gz` and checks the file against the SHA-256 digest GitHub recorded. It then shows the release each destination runs now, every affected path, and the first-line script-header diff. It never builds or reads local `dist/` files. Choosing a real deployment requires a clean repository and a second confirmation. To roll back, deploy an older release; published SRI files are never overwritten. -After a real deployment that includes `latest.js`, the CLI records a GitHub deployment for each destination (`cdn` and `custom-domains`), so [Deployments](https://github.com/simpleanalytics/scripts/deployments) shows what is live. Deployments of only auto-events, SRI or light aren't recorded, because the destination keeps running its recorded release. It runs `gh`, or the command in `DEPLOY_GH_COMMAND`. +You choose the scripts (the default embed script with all its files: latest, SRI, light and proxy; and/or auto-events) and the destinations (CDN and/or Caddy). After a real deployment, the CLI records a GitHub deployment per script and destination (`cdn`, `custom-domains`, `cdn-auto-events` and `custom-domains-auto-events`), so [Deployments](https://github.com/simpleanalytics/scripts/deployments) shows what is live. It runs `gh`, or the command in `DEPLOY_GH_COMMAND`. CDN writes use `BUNNY_SCRIPTS_ACCESS_KEY` and `BUNNY_SCRIPTS_ACCOUNT_KEY` from `.env`. Caddy reads and writes require SSH access to `user@esapp05.simpleanalytics.com`. diff --git a/deploy.mts b/deploy.mts index bd4cd33..092bfb1 100644 --- a/deploy.mts +++ b/deploy.mts @@ -19,7 +19,6 @@ import { fileURLToPath } from "node:url"; export type ScriptChoice = "default" | "auto-events"; export type Destination = "cdn" | "caddy"; -export type EmbedVariant = "latest" | "sri" | "light" | "proxy"; export type FileStatus = "create" | "replace" | "unchanged" | "blocked"; type FileKind = "javascript" | "source-map"; type ContentTransform = @@ -28,10 +27,11 @@ type ContentTransform = | "cdn-sri-map" | "caddy-ssi"; +// Every script is deployed with all of its files (latest, SRI, light, proxy), +// so a destination never runs a mix of releases for one script export interface DeploySelections { scripts: ScriptChoice[]; destinations: Destination[]; - variants: EmbedVariant[]; } export interface DeployFile { @@ -110,11 +110,23 @@ const CDN_PURGE_URL = "https://api.bunny.net/purge?url=https://simpleanalyticscdn.b-cdn.net/*"; const GITHUB_REPOSITORY = "simpleanalytics/scripts"; const GITHUB_API_ROOT = `https://api.github.com/repos/${GITHUB_REPOSITORY}`; -// GitHub deployment environments that record which release each destination -// runs, shown at github.com/simpleanalytics/scripts/deployments. -const ENVIRONMENTS: Record = { - caddy: { name: "custom-domains" }, - cdn: { name: "cdn", url: `${CDN_PUBLIC_ROOT}/latest.js` }, +// GitHub deployment environments that record which release each script runs +// per destination, shown at github.com/simpleanalytics/scripts/deployments. +const ENVIRONMENTS: Record< + Destination, + Record +> = { + caddy: { + default: { name: "custom-domains" }, + "auto-events": { name: "custom-domains-auto-events" }, + }, + cdn: { + default: { name: "cdn", url: `${CDN_PUBLIC_ROOT}/latest.js` }, + "auto-events": { + name: "cdn-auto-events", + url: `${CDN_PUBLIC_ROOT}/auto-events.js`, + }, + }, }; export const HEADER_PATTERN = /^\/\* Simple Analytics - Privacy(?: friendly|-first) analytics \(docs\.simpleanalytics\.com\/[^;]+; \d{4}-\d{2}-\d{2}; [0-9a-f]{4}(?:; SRI-version)?(?:; v\d+)?\) \*\/$/; @@ -185,15 +197,13 @@ function addScriptPair( export function createManifest( selections: DeploySelections, - version?: number, + version: number, ): DeployFile[] { const files: DeployFile[] = []; const hasScript = (choice: ScriptChoice) => selections.scripts.includes(choice); const hasDestination = (destination: Destination) => selections.destinations.includes(destination); - const hasVariant = (variant: EmbedVariant) => - selections.variants.includes(variant); - const needsSri = hasScript("default") && hasVariant("sri"); + const hasDefault = hasScript("default"); const addCaddyPair = ( input: Omit[1], "destination">, @@ -206,11 +216,7 @@ export function createManifest( }); }; - if (needsSri && version === undefined) { - throw new Error("An SRI version is required when SRI is selected."); - } - - if (hasScript("default") && hasVariant("latest")) { + if (hasDefault) { if (hasDestination("cdn")) { addScriptPair(files, { destination: "cdn", @@ -245,7 +251,7 @@ export function createManifest( }); } - if (needsSri) { + if (hasDefault) { if (hasDestination("cdn")) { addScriptPair(files, { destination: "cdn", @@ -265,7 +271,7 @@ export function createManifest( }); } - if (hasScript("default") && hasVariant("light")) { + if (hasDefault) { if (hasDestination("cdn")) { addScriptPair(files, { destination: "cdn", @@ -279,34 +285,26 @@ export function createManifest( remotePath: "light.js", }); - if (needsSri) { - addCaddyPair({ - immutable: true, - localPath: `dist/v${version}/custom/light.js`, - remotePath: `v${version}/light.js`, - version, - }); - } + addCaddyPair({ + immutable: true, + localPath: `dist/v${version}/custom/light.js`, + remotePath: `v${version}/light.js`, + version, + }); } - if ( - hasScript("default") && - hasVariant("proxy") && - hasDestination("caddy") - ) { + if (hasDefault && hasDestination("caddy")) { addCaddyPair({ localPath: "dist/latest/custom/proxy.js", remotePath: "proxy.js", }); - if (needsSri) { - addCaddyPair({ - immutable: true, - localPath: `dist/v${version}/custom/proxy.js`, - remotePath: `v${version}/proxy.js`, - version, - }); - } + addCaddyPair({ + immutable: true, + localPath: `dist/v${version}/custom/proxy.js`, + remotePath: `v${version}/proxy.js`, + version, + }); } if (hasScript("auto-events")) { @@ -323,14 +321,12 @@ export function createManifest( remotePath: "auto-events.js", }); - if (needsSri) { - addCaddyPair({ - immutable: true, - localPath: `dist/v${version}/custom/auto-events.js`, - remotePath: `v${version}/auto-events.js`, - version, - }); - } + addCaddyPair({ + immutable: true, + localPath: `dist/v${version}/custom/auto-events.js`, + remotePath: `v${version}/auto-events.js`, + version, + }); } return files; @@ -729,9 +725,6 @@ function renderPreview( `Mode: ${dryRun ? "Dry run" : "Deploy"}`, `Scripts: ${selections.scripts.join(", ")}`, `Destinations: ${selections.destinations.join(", ")}`, - selections.variants.length > 0 - ? `Default variants: ${selections.variants.join(", ")}` - : null, version === undefined ? null : `SRI version: v${version}`, ] .filter((line): line is string => line !== null) @@ -783,14 +776,16 @@ function renderPreview( async function renderRelease( release: Release, - destinations: Destination[], + selections: DeploySelections, ): Promise { const lines = [ `${release.tag}, published ${release.publishedAt.slice(0, 10)}`, release.url, ]; - for (const destination of destinations) { - const environment = ENVIRONMENTS[destination].name; + const environments = selections.destinations.flatMap((destination) => + selections.scripts.map((script) => ENVIRONMENTS[destination][script].name), + ); + for (const environment of environments) { const deployments = ( await readGitHub>( `deployments?environment=${environment}&per_page=10`, @@ -850,10 +845,14 @@ async function recordDeployments( const [command, ...prefix] = (process.env.DEPLOY_GH_COMMAND || "gh") .trim() .split(/\s+/); - const description = [...selections.scripts, ...selections.variants].join(", "); + const environments = selections.destinations.flatMap((destination) => + selections.scripts.map((script) => ({ + script, + ...ENVIRONMENTS[destination][script], + })), + ); - for (const destination of selections.destinations) { - const environment = ENVIRONMENTS[destination]; + for (const environment of environments) { const progress = spinner(); progress.start(`Recording ${release.tag} on ${environment.name} in GitHub`); try { @@ -868,7 +867,7 @@ async function recordDeployments( "-f", `environment=${environment.name}`, "-f", - `description=${description}`, + `description=${environment.script}`, "-F", "auto_merge=false", "-F", @@ -1169,7 +1168,7 @@ async function main(): Promise { initialValues: ["default", "auto-events"], message: "Scripts to deploy", options: [ - { label: "Default embed", value: "default" }, + { label: "Default embed (latest, SRI, light, proxy)", value: "default" }, { label: "Auto-events", value: "auto-events" }, ], required: true, @@ -1189,27 +1188,7 @@ async function main(): Promise { }), ); - let variants: EmbedVariant[] = []; - if (scripts.includes("default")) { - const options: Array<{ label: string; value: EmbedVariant }> = [ - { label: "Latest", value: "latest" }, - { label: "SRI", value: "sri" }, - { label: "Light", value: "light" }, - ]; - if (destinations.includes("caddy")) { - options.push({ label: "Proxy", value: "proxy" }); - } - variants = selectedValue( - await multiselect({ - initialValues: options.map((option) => option.value), - message: "What default embed scripts do you want to deploy?", - options, - required: true, - }), - ); - } - - const selections = { destinations, scripts, variants }; + const selections = { destinations, scripts }; const download = spinner(); download.start(`Downloading ${release.tag}`); let directory: string; @@ -1221,10 +1200,10 @@ async function main(): Promise { throw error; } - let version: number | undefined; + let version: number; let preparedFiles: PreparedFile[]; try { - version = variants.includes("sri") ? await discoverSriVersion(directory) : undefined; + version = await discoverSriVersion(directory); preparedFiles = await prepareFiles(createManifest(selections, version), directory); } finally { await rm(directory, { force: true, recursive: true }); @@ -1240,7 +1219,7 @@ async function main(): Promise { inspection.error("Unable to inspect deployed files"); throw error; } - await renderRelease(release, destinations); + await renderRelease(release, selections); renderPreview(previews, selections, dryRun, version); const counts = summarize(previews); @@ -1272,11 +1251,8 @@ async function main(): Promise { } // Deploy and record one destination at a time, so a failure on one doesn't - // mark the other as failed. Only deployments with latest.js change the - // release a destination runs. + // mark the other as failed const result: DeploymentResult = { completed: [], failed: [], skipped: [] }; - const recordRelease = - scripts.includes("default") && variants.includes("latest"); for (const destination of destinations) { let state: "success" | "failure" = "failure"; try { @@ -1287,22 +1263,15 @@ async function main(): Promise { ); state = "success"; } finally { - if (recordRelease) { - await recordDeployments( - release, - { ...selections, destinations: [destination] }, - state, - ); - } + await recordDeployments( + release, + { ...selections, destinations: [destination] }, + state, + ); } } renderDeploymentResult(result); - if (!recordRelease) { - log.info( - "Not recorded in GitHub: without latest.js the destinations keep running their recorded release.", - ); - } - if (variants.includes("sri")) { + if (scripts.includes("default")) { log.warn( "Update https://docs.simpleanalytics.com/sri with the SRI version.", ); diff --git a/test/deploy.test.mts b/test/deploy.test.mts index e760e57..6cd66a9 100644 --- a/test/deploy.test.mts +++ b/test/deploy.test.mts @@ -32,7 +32,6 @@ describe("deployment headers", () => { { destinations: ["cdn", "caddy"], scripts: ["default", "auto-events"], - variants: ["latest", "sri", "light", "proxy"], }, version ); @@ -62,12 +61,11 @@ describe("deployment headers", () => { }); describe("deployment manifest", () => { - it("maps latest and its legacy companions to both destinations", () => { - const files = createManifest({ - destinations: ["cdn", "caddy"], - scripts: ["default"], - variants: ["latest"], - }); + it("deploys every file of the default script to both destinations", () => { + const files = createManifest( + { destinations: ["cdn", "caddy"], scripts: ["default"] }, + 12, + ); assert.deepEqual( files.map((file) => `${file.destination}:${file.remotePath}`), @@ -82,6 +80,20 @@ describe("deployment manifest", () => { "caddy:events.js", "caddy:events.js.map", "caddy:latest.dev.js", + "cdn:sri/v12.js", + "cdn:sri/v12.js.map", + "caddy:v12/app.js", + "caddy:v12/app.js.map", + "cdn:light.js", + "cdn:light.js.map", + "caddy:light.js", + "caddy:light.js.map", + "caddy:v12/light.js", + "caddy:v12/light.js.map", + "caddy:proxy.js", + "caddy:proxy.js.map", + "caddy:v12/proxy.js", + "caddy:v12/proxy.js.map", ], ); }); @@ -91,7 +103,6 @@ describe("deployment manifest", () => { { destinations: ["cdn", "caddy"], scripts: ["default", "auto-events"], - variants: ["latest", "sri", "light", "proxy"], }, 12, ); @@ -122,12 +133,11 @@ describe("deployment manifest", () => { ); }); - it("deploys standalone auto-events without implicit SRI files", () => { - const files = createManifest({ - destinations: ["cdn", "caddy"], - scripts: ["auto-events"], - variants: [], - }); + it("deploys auto-events with its pinned Caddy file and nothing else", () => { + const files = createManifest( + { destinations: ["cdn", "caddy"], scripts: ["auto-events"] }, + 12, + ); assert.deepEqual( files.map((file) => `${file.destination}:${file.remotePath}`), @@ -136,46 +146,27 @@ describe("deployment manifest", () => { "cdn:auto-events.js.map", "caddy:auto-events.js", "caddy:auto-events.js.map", + "caddy:v12/auto-events.js", + "caddy:v12/auto-events.js.map", ], ); - assert.equal(files.some((file) => file.immutable), false); }); it("never emits proxy files for a CDN-only selection", () => { const files = createManifest( - { - destinations: ["cdn"], - scripts: ["default"], - variants: ["proxy", "sri"], - }, + { destinations: ["cdn"], scripts: ["default"] }, 12, ); assert.equal(files.some((file) => file.remotePath.includes("proxy")), false); - assert.deepEqual( - files.map((file) => file.remotePath), - ["sri/v12.js", "sri/v12.js.map"], - ); - }); - - it("requires a version when SRI is selected", () => { - assert.throws( - () => - createManifest({ - destinations: ["cdn"], - scripts: ["default"], - variants: ["sri"], - }), - /SRI version is required/, - ); + assert.equal(files.some((file) => file.destination === "caddy"), false); }); it("never invents a source map for latest.dev.js", () => { - const files = createManifest({ - destinations: ["cdn", "caddy"], - scripts: ["default"], - variants: ["latest"], - }); + const files = createManifest( + { destinations: ["cdn", "caddy"], scripts: ["default"] }, + 12, + ); assert.equal( files.some((file) => file.remotePath === "latest.dev.js.map"), @@ -264,11 +255,7 @@ describe("preview helpers", () => { describe("Caddy custom-domains destination", () => { it("maps Caddy-only files with the caddy-ssi transform", () => { const files = createManifest( - { - destinations: ["caddy"], - scripts: ["default"], - variants: ["latest", "sri"], - }, + { destinations: ["caddy"], scripts: ["default"] }, 12, ); @@ -282,6 +269,14 @@ describe("Caddy custom-domains destination", () => { "caddy:latest.dev.js", "caddy:v12/app.js", "caddy:v12/app.js.map", + "caddy:light.js", + "caddy:light.js.map", + "caddy:v12/light.js", + "caddy:v12/light.js.map", + "caddy:proxy.js", + "caddy:proxy.js.map", + "caddy:v12/proxy.js", + "caddy:v12/proxy.js.map", ], ); assert.equal( From cf52756acdbb7c07e70653729cc67ac917a2e82c Mon Sep 17 00:00:00 2001 From: Conductor Adriaan Date: Wed, 28 Oct 2026 21:45:40 +0000 Subject: [PATCH 2/4] Record each script separately and keep deploys working without history - Deploy and record one script at a time per destination, default first, so an auto-events failure doesn't mark the default script as failed. - Read GitHub with GH_TOKEN or GITHUB_TOKEN when set, and show a warning instead of stopping when the deploy history can't be read. Co-Authored-By: Claude Opus 5.5 (1M context) --- deploy.mts | 55 +++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 38 insertions(+), 17 deletions(-) diff --git a/deploy.mts b/deploy.mts index 092bfb1..827592c 100644 --- a/deploy.mts +++ b/deploy.mts @@ -524,9 +524,15 @@ async function readCdnFile( return Buffer.from(await response.arrayBuffer()); } +// Public data, but a token avoids the 60 requests per hour limit +const githubToken = process.env.GH_TOKEN || process.env.GITHUB_TOKEN; + async function readGitHub(path: string): Promise { const response = await fetch(`${GITHUB_API_ROOT}/${path}`, { - headers: { Accept: "application/vnd.github+json" }, + headers: { + Accept: "application/vnd.github+json", + ...(githubToken ? { Authorization: `Bearer ${githubToken}` } : {}), + }, signal: AbortSignal.timeout(30_000), }); if (!response.ok) { @@ -1219,7 +1225,14 @@ async function main(): Promise { inspection.error("Unable to inspect deployed files"); throw error; } - await renderRelease(release, selections); + // The deploy history is informational, a rollback shouldn't depend on it + try { + await renderRelease(release, selections); + } catch (error) { + log.warn( + `Unable to show what runs now: ${error instanceof Error ? error.message : String(error)}`, + ); + } renderPreview(previews, selections, dryRun, version); const counts = summarize(previews); @@ -1250,24 +1263,32 @@ async function main(): Promise { return; } - // Deploy and record one destination at a time, so a failure on one doesn't - // mark the other as failed + // Deploy and record one destination and script at a time, so a failure + // only marks what actually failed. The default script goes first. const result: DeploymentResult = { completed: [], failed: [], skipped: [] }; + const ordered = (["default", "auto-events"] as const).filter((script) => + scripts.includes(script), + ); for (const destination of destinations) { - let state: "success" | "failure" = "failure"; - try { - await deployFiles( - previews.filter((file) => file.destination === destination), - credentials, - result, - ); - state = "success"; - } finally { - await recordDeployments( - release, - { ...selections, destinations: [destination] }, - state, + for (const script of ordered) { + const single = { destinations: [destination], scripts: [script] }; + const paths = new Set( + createManifest(single, version).map((file) => file.remotePath), ); + let state: "success" | "failure" = "failure"; + try { + await deployFiles( + previews.filter( + (file) => + file.destination === destination && paths.has(file.remotePath), + ), + credentials, + result, + ); + state = "success"; + } finally { + await recordDeployments(release, single, state); + } } } renderDeploymentResult(result); From 1c51e1ff662d330bff781dbdbb20ede8e803409e Mon Sep 17 00:00:00 2001 From: Conductor Adriaan Date: Thu, 29 Oct 2026 17:12:24 +0000 Subject: [PATCH 3/4] Retry GitHub reads without a stale token and document GH_TOKEN A revoked GH_TOKEN or GITHUB_TOKEN made GitHub answer 401 and stopped the CLI before a release could be picked. Public reads now retry once without the token. The README mentions the token for the deploy history. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 2 +- deploy.mts | 20 +++++++++++++------- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 94dd45c..bb4e120 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ pnpm run deploy The interactive CLI starts in dry-run mode by default. You pick a release (the newest by default), and the CLI downloads its `scripts-vN.tar.gz` and checks the file against the SHA-256 digest GitHub recorded. It then shows the release each destination runs now, every affected path, and the first-line script-header diff. It never builds or reads local `dist/` files. Choosing a real deployment requires a clean repository and a second confirmation. To roll back, deploy an older release; published SRI files are never overwritten. -You choose the scripts (the default embed script with all its files: latest, SRI, light and proxy; and/or auto-events) and the destinations (CDN and/or Caddy). After a real deployment, the CLI records a GitHub deployment per script and destination (`cdn`, `custom-domains`, `cdn-auto-events` and `custom-domains-auto-events`), so [Deployments](https://github.com/simpleanalytics/scripts/deployments) shows what is live. It runs `gh`, or the command in `DEPLOY_GH_COMMAND`. +You choose the scripts (the default embed script with all its files: latest, SRI, light and proxy; and/or auto-events) and the destinations (CDN and/or Caddy). After a real deployment, the CLI records a GitHub deployment per script and destination (`cdn`, `custom-domains`, `cdn-auto-events` and `custom-domains-auto-events`), so [Deployments](https://github.com/simpleanalytics/scripts/deployments) shows what is live. It runs `gh`, or the command in `DEPLOY_GH_COMMAND`. Set `GH_TOKEN` or `GITHUB_TOKEN` in `.env` so reading that history doesn't hit GitHub's unauthenticated rate limit. CDN writes use `BUNNY_SCRIPTS_ACCESS_KEY` and `BUNNY_SCRIPTS_ACCOUNT_KEY` from `.env`. Caddy reads and writes require SSH access to `user@esapp05.simpleanalytics.com`. diff --git a/deploy.mts b/deploy.mts index 827592c..b5ae6df 100644 --- a/deploy.mts +++ b/deploy.mts @@ -528,13 +528,19 @@ async function readCdnFile( const githubToken = process.env.GH_TOKEN || process.env.GITHUB_TOKEN; async function readGitHub(path: string): Promise { - const response = await fetch(`${GITHUB_API_ROOT}/${path}`, { - headers: { - Accept: "application/vnd.github+json", - ...(githubToken ? { Authorization: `Bearer ${githubToken}` } : {}), - }, - signal: AbortSignal.timeout(30_000), - }); + const read = (withToken: boolean) => + fetch(`${GITHUB_API_ROOT}/${path}`, { + headers: { + Accept: "application/vnd.github+json", + ...(withToken && githubToken + ? { Authorization: `Bearer ${githubToken}` } + : {}), + }, + signal: AbortSignal.timeout(30_000), + }); + let response = await read(true); + // A stale token must not block reading public release data + if (githubToken && response.status === 401) response = await read(false); if (!response.ok) { throw new Error(`Unable to read GitHub ${path}: HTTP ${response.status}`); } From 8ad13212e6c9900757afaa1f6fda23d1917a12c6 Mon Sep 17 00:00:00 2001 From: Conductor Adriaan Date: Thu, 29 Oct 2026 17:20:43 +0000 Subject: [PATCH 4/4] Don't offer draft releases A token with push access makes GitHub list drafts, and the release workflow creates a draft with its archive before publishing it. Co-Authored-By: Claude Opus 5.5 (1M context) --- deploy.mts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/deploy.mts b/deploy.mts index b5ae6df..0669ba4 100644 --- a/deploy.mts +++ b/deploy.mts @@ -555,6 +555,7 @@ async function listReleases(): Promise { digest: string | null; name: string; }>; + draft: boolean; html_url: string; published_at: string; tag_name: string; @@ -562,6 +563,8 @@ async function listReleases(): Promise { >("releases?per_page=100"); return releases.flatMap((release) => { + // Tokens with push access also list drafts, which aren't released yet + if (release.draft) return []; const archive = release.assets.find( (asset) => asset.name === `scripts-${release.tag_name}.tar.gz`, );