diff --git a/CLAUDE.md b/CLAUDE.md index b0a29f482c..9353bfe3f8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -101,9 +101,14 @@ Local-only commands (rarely needed): `ac-os build` (binary → initramfs → ker **Deploy lith with `ac-deploy`** (`slab/bin/ac-deploy`, on PATH): it refuses unless git says you are @jeffrey and can reach the knot, refuses if local `main` is unpushed, always deploys `main`, then checks the served - `.commit-ref` and `/aesel.json` against what it shipped. + `.commit-ref` against what it shipped. `ac-deploy --verify` does only the check. It is a maintainer command — never an Aesel or piece-publishing step. + **Aesel releases separately: `ac-deploy aesel`.** A lith deploy no longer + packs Aesel. Bump `aesel/package.json`, push, then `ac-deploy aesel` packs + `aesel/` from the knot's `main` in a scratch directory on lith, swaps in + `aesel.tar.gz` and `aesel.json` (manifest last) without moving lith's + checkout, and verifies the served version and tarball hash. **If the change touches oskiewar, the release is part of the word**, not a second errand: run `npm run oskiewar:deploy`. That one command carries the lot: it stamps `buildVersion` to match the commit count and reburns the diff --git a/aesel/src/tui.mjs b/aesel/src/tui.mjs index 24aa6c3729..d5ba91bffe 100755 --- a/aesel/src/tui.mjs +++ b/aesel/src/tui.mjs @@ -570,7 +570,7 @@ function proInstructions() { // nowhere else: a piece author has nothing to deploy, and a model that // hears of a deploy command reaches for it. ...(session.handle === "jeffrey" && existsSync(path.join(cwd, "slab/bin/ac-deploy")) - ? ["When @jeffrey asks to deploy lith (or to compushloy), push main to the knot and run `ac-deploy`; it checks it is him, deploys main and verifies the served commit and Aesel version. `ac-deploy --verify` only checks. Never use it to publish a piece — that is `ac publish`."] + ? ["When @jeffrey asks to deploy lith (or to compushloy), push main to the knot and run `ac-deploy`; it checks it is him, deploys main and verifies the served commit. Aesel releases on its own: bump aesel/package.json, push, then `ac-deploy aesel`. `--verify` on either only checks. Never use it to publish a piece — that is `ac publish`."] : []), ].join("\n"); } diff --git a/lith/deploy.fish b/lith/deploy.fish index 2d261f5440..e06336d847 100644 --- a/lith/deploy.fish +++ b/lith/deploy.fish @@ -318,16 +318,11 @@ else if test -f $SPACES_ENV_GPG end end -# The tarball aesel.sh (and easel.sh) downloads is built from aesel/, not -# committed, so it is packed on the box after the pull. Building it here rather than locally means -# the installer can never point at a version older than the source that shipped -# with it. -# The pack bundles the TUI with esbuild from aesel/web's lockfile. -echo -e "$GREEN-> Packing the Aesel installer tarball...$NC" -ssh -i $SSH_KEY $LITH_USER@$TARGET_HOST "cd $REMOTE_DIR/aesel/web && npm ci --no-audit --no-fund --loglevel=error && cd $REMOTE_DIR && node aesel/bin/pack.mjs" 2>&1 | tail -2 -if test $pipestatus[1] -ne 0 - echo -e "$RED Aesel pack failed — the installer tarball is stale. Rerun: ssh lith 'cd $REMOTE_DIR && node aesel/bin/pack.mjs'$NC" -end +# Aesel is not packed here. It releases on its own clock with `ac-deploy +# aesel`, which packs aesel/ from the knot's main in a scratch directory on +# this box and swaps the tarball and manifest in, so a site deploy never +# re-releases Aesel and an Aesel release never moves this checkout. The +# served aesel.tar.gz and aesel.json are untracked and survive the reset above. echo -e "$GREEN-> Refreshing notepat.com.amxd build stream...$NC" if test $SPACES_READY = true diff --git a/slab/bin/ac-deploy b/slab/bin/ac-deploy index b6b2e20cbc..adfb568766 100755 --- a/slab/bin/ac-deploy +++ b/slab/bin/ac-deploy @@ -2,8 +2,16 @@ // ac-deploy — the one command any harness (Claude, Codex, Aesel) calls to // ship lith, and the only thing it needs to know about deploying. // -// ac-deploy deploy the knot's main to lith, then prove it -// ac-deploy --verify only prove what production is serving +// ac-deploy deploy the knot's main to lith, then prove it +// ac-deploy --verify only prove what production is serving +// ac-deploy aesel release Aesel alone from the knot's main +// ac-deploy aesel --verify only prove what Aesel release is served +// +// Aesel is released on its own clock. A lith deploy no longer packs it, so +// shipping a site change never re-releases Aesel, and releasing Aesel never +// moves lith's checkout: `ac-deploy aesel` packs aesel/ from the knot's main +// in a scratch directory on lith and swaps the tarballs and manifest into +// system/public, manifest last. // // Deploying lith is @jeffrey's alone, so before anything moves this checks // that it is him: the git identity must be @jeffrey AND his git credentials @@ -11,15 +19,19 @@ // Production serves the knot's main and nothing else, so local main must be // pushed; deploy.fish is always told DEPLOY_BRANCH=main, which makes this // safe from a worktree or a feature branch. Afterwards it compares the bytes -// production serves against the commit and the Aesel version it shipped. +// production serves against the commit it shipped. import { execFileSync, spawnSync } from "node:child_process"; -import { realpathSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { existsSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const ROOT = resolve(dirname(realpathSync(fileURLToPath(import.meta.url))), "../.."); const SITE = "https://aesthetic.computer"; const verifyOnly = process.argv.includes("--verify"); +const aeselOnly = process.argv[2] === "aesel"; const git = (...args) => execFileSync("git", ["-C", ROOT, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim(); const say = (mark, text) => console.log(`${mark} ${text}`); @@ -43,17 +55,92 @@ async function verify(sha) { const served = (await get("/.commit-ref")).trim(); if (served === sha) say("✓", `serving ${sha.slice(0, 10)}`); else { ok = false; say("✕", `serving ${served.slice(0, 10) || "nothing"}, expected ${sha.slice(0, 10)}`); } + // Aesel ships separately (`ac-deploy aesel`); here it is only reported. const want = JSON.parse(git("show", `${sha}:aesel/package.json`)).version; const have = JSON.parse(await get("/aesel.json")).version; - if (have === want) say("✓", `Aesel ${have}`); - else { ok = false; say("✕", `Aesel ${have} served, ${want} expected`); } + say("·", have === want ? `Aesel ${have}` : `Aesel ${have} served · main has ${want} · release it with ac-deploy aesel`); + return ok; +} + +// The Aesel release as served: the manifest's version is main's, and the +// tarball it names hashes to what the manifest says. +async function verifyAesel(sha) { + const want = JSON.parse(git("show", `${sha}:aesel/package.json`)).version; + const manifest = await (await fetch(`${SITE}/aesel.json?t=${Date.now()}`, { headers: { "cache-control": "no-cache" } })).json(); + let ok = true; + if (manifest.version === want) say("✓", `aesel.json ${manifest.version}`); + else { ok = false; say("✕", `aesel.json ${manifest.version}, expected ${want}`); } + const tarball = Buffer.from(await (await fetch(`${SITE}${manifest.tarball}?t=${Date.now()}`, { headers: { "cache-control": "no-cache" } })).arrayBuffer()); + const sum = createHash("sha256").update(tarball).digest("hex"); + if (sum === manifest.sha256 && tarball.length === manifest.bytes) say("✓", `${manifest.tarball} ${(tarball.length / 1024).toFixed(0)} KB · sha256 matches`); + else { ok = false; say("✕", `${manifest.tarball} does not match its manifest`); } return ok; } +// The same key deploy.fish uses: the vault's plaintext copy, or its GPG copy +// opened with the passphrase the slab daemon hands over. +function lithKey() { + const key = join(ROOT, "aesthetic-computer-vault/home/.ssh/id_rsa"); + if (existsSync(key)) return { key, done: () => {} }; + const gpg = `${key}.gpg`; + if (!existsSync(gpg)) fail(`no lith SSH key at ${key}`); + const passphrase = execFileSync(join(ROOT, "slab/bin/ac-passphrase"), ["vault", "600"], { encoding: "utf8" }).trim(); + if (!passphrase) fail("no vault passphrase"); + const dir = mkdtempSync(join(tmpdir(), "ac-lith-")), file = join(dir, "key"); + const out = spawnSync("gpg", ["--batch", "--pinentry-mode", "loopback", "--passphrase-fd", "0", "--decrypt", gpg], { input: passphrase, encoding: "buffer" }); + if (out.status !== 0) fail(`could not decrypt ${gpg}`); + writeFileSync(file, out.stdout, { mode: 0o600 }); + return { key: file, done: () => rmSync(dir, { recursive: true, force: true }) }; +} + +// Packed on lith from a `git archive` of aesel/ at the commit, never from the +// served checkout, so lith's tree and every other site stay where they are. +// aesel/web's node_modules are kept per lockfile hash and reused. +function releaseAesel(sha) { + const host = process.env.LITH_HOST || "lith.aesthetic.computer"; + const script = `set -euo pipefail +SHA=${sha}; R=/tmp/aesel-release-$SHA; PUB=/opt/ac/system/public; KEEP=/opt/ac-aesel-release +cd /opt/ac && git fetch -q origin main && git cat-file -e "$SHA^{commit}" +rm -rf "$R" && mkdir -p "$R/system/public" "$KEEP" +git archive "$SHA" aesel | tar -x -C "$R" +LOCK=$(git show "$SHA:aesel/web/package-lock.json" | sha256sum | cut -c1-16) +# Named node_modules inside a per-lockfile folder: esbuild finds its native +# binary by walking up to a directory called exactly that. +rm -rf "$KEEP"/node_modules-* +if [ ! -d "$KEEP/$LOCK/node_modules" ]; then + (cd "$R/aesel/web" && npm ci --no-audit --no-fund --loglevel=error) && mkdir -p "$KEEP/$LOCK" && mv "$R/aesel/web/node_modules" "$KEEP/$LOCK/node_modules" +fi +ln -sfn "$KEEP/$LOCK/node_modules" "$R/aesel/web/node_modules" +# pack.mjs writes beside the copy it packs: $R/system/public. +node "$R/aesel/bin/pack.mjs" > "$R/pack.log" || { cat "$R/pack.log"; exit 1; } +head -1 "$R/pack.log" +for f in aesel.tar.gz easel.tar.gz aesel.json easel.json; do test -s "$R/system/public/$f" || { echo "pack made no $f"; exit 1; }; done +for f in aesel.tar.gz easel.tar.gz aesel.json easel.json; do + cp "$R/system/public/$f" "$PUB/.$f.new" || exit 1 + mv -f "$PUB/.$f.new" "$PUB/$f" || exit 1 +done +rm -rf "$R" +`; + const { key, done } = lithKey(); + try { + const run = spawnSync("ssh", ["-i", key, "-o", "BatchMode=yes", `root@${host}`, "bash", "-s"], { input: script, stdio: ["pipe", "inherit", "inherit"] }); + if (run.status !== 0) fail(`the Aesel pack on ${host} exited ${run.status}`); + } finally { done(); } +} + whoami(); git("fetch", "-q", "origin", "main"); const sha = git("rev-parse", "origin/main"); +if (aeselOnly) { + if (!verifyOnly) { + const version = JSON.parse(git("show", `${sha}:aesel/package.json`)).version; + say("→", `releasing Aesel ${version} from ${sha.slice(0, 10)} (lith's checkout stays put)`); + releaseAesel(sha); + } + process.exit((await verifyAesel(sha)) ? 0 : 1); +} + if (!verifyOnly) { const ahead = git("rev-list", "--count", "origin/main..main"); if (ahead !== "0") fail(`local main is ${ahead} commit(s) ahead of the knot — push first; production only serves what the knot has`);