From 652c113a91561d858d4117db01ec058481bf1808 Mon Sep 17 00:00:00 2001 From: James Kraus Date: Wed, 28 Oct 2026 13:14:35 -0400 Subject: [PATCH] ci: cache the built Goose executable for desktop previews Reuse only exact matches from normal main releases and bind cache keys to native build inputs. Verify the staged executable against its checksum before skipping compilation. Co-authored-by: Codex Signed-off-by: James Kraus --- .github/actions/cache-goose/action.yml | 41 ++++++++ .github/workflows/release.yml | 11 +- docs/releases.md | 10 +- scripts/build-goose-acp.mjs | 35 +++++-- scripts/build-goose-acp.test.mjs | 134 +++++++++++++++++++++++++ 5 files changed, 222 insertions(+), 9 deletions(-) create mode 100644 .github/actions/cache-goose/action.yml create mode 100644 scripts/build-goose-acp.test.mjs diff --git a/.github/actions/cache-goose/action.yml b/.github/actions/cache-goose/action.yml new file mode 100644 index 0000000000..652e21d112 --- /dev/null +++ b/.github/actions/cache-goose/action.yml @@ -0,0 +1,41 @@ +name: Cache built Goose executable +description: Reuse the unsigned Goose executable in trusted main release jobs. +runs: + using: composite + steps: + - name: Read Goose cache inputs + id: build-context + shell: bash + run: | + set -euo pipefail + # Hermit pin contents are identical symlinks; versions live in their names. + rust_pins=(bin/.rust-*.pkg) + cmake_pins=(bin/.cmake-*.pkg) + [[ ${#rust_pins[@]} == 1 && -f ${rust_pins[0]} && ${#cmake_pins[@]} == 1 && -f ${cmake_pins[0]} ]] + echo "pins=${rust_pins[0]##*/}-${cmake_pins[0]##*/}" >> "$GITHUB_OUTPUT" + # The release jobs build natively; cross-compilation needs the effective target. + target=$(rustc -vV | sed -n 's/^host: //p') + test -n "$target" + echo "target=$target" >> "$GITHUB_OUTPUT" + if [[ "$RUNNER_OS" == Linux ]]; then + packages=$(dpkg-query -W | LC_ALL=C sort | sha256sum) + echo "image=ubuntu24.04-${packages%% *}" >> "$GITHUB_OUTPUT" + else + echo "image=${ImageOS:?}-${ImageVersion:?}" >> "$GITHUB_OUTPUT" + fi + # A stamp restored by the Rust target cache must not join a partial payload. + rm -f src-tauri/resources/bin/goose-acp src-tauri/resources/bin/goose-acp.exe target/goose-acp-build/staged.json + + - name: Restore and save Goose executable + id: goose-cache + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + src-tauri/resources/bin/goose-acp${{ runner.os == 'Windows' && '.exe' || '' }} + target/goose-acp-build/staged.json + key: goose-executable-v1-${{ runner.os }}-${{ runner.arch }}-${{ steps.build-context.outputs.target }}-${{ steps.build-context.outputs.pins }}-${{ steps.build-context.outputs.image }}-${{ hashFiles('runtime/goose-acp.json', 'scripts/build-goose-acp.mjs', '.cargo/config.toml', 'bin/hermit.hcl', 'src-tauri/tauri*.conf.json', 'package.json', 'pnpm-lock.yaml', '.github/workflows/release.yml', '.github/actions/setup/action.yml', '.github/actions/cache-goose/action.yml', 'scripts/candidate-version.mjs') }} + + - name: Discard non-exact Goose restores + if: steps.goose-cache.outputs.cache-hit != 'true' + shell: bash + run: rm -f src-tauri/resources/bin/goose-acp src-tauri/resources/bin/goose-acp.exe target/goose-acp-build/staged.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 88b204f995..9940c0557f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -64,6 +64,9 @@ jobs: key: macos-prerelease cache-all-crates: true + - name: Cache built Goose executable + uses: ./.github/actions/cache-goose + - name: Set preview version id: version run: | @@ -326,6 +329,9 @@ jobs: with: version: ${{ steps.pins.outputs.pnpm }} - run: pnpm install --frozen-lockfile + - name: Cache built Goose executable + if: github.ref == 'refs/heads/main' && !inputs.candidates + uses: ./.github/actions/cache-goose - name: Set candidate version run: node scripts/candidate-version.mjs - name: Build NSIS installer @@ -391,9 +397,12 @@ jobs: with: persist-credentials: false - run: git config --global --add safe.directory "$GITHUB_WORKSPACE" - # No shared writable release caches. Use existing pins and frozen inputs. + # Candidate-only builds do not read or write shared build caches. - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - run: pnpm install --frozen-lockfile + - name: Cache built Goose executable + if: github.ref == 'refs/heads/main' && !inputs.candidates + uses: ./.github/actions/cache-goose - name: Install pinned AppImage repacking tools run: | wget -q -O /tmp/appimagetool https://github.com/AppImage/appimagetool/releases/download/1.9.1/appimagetool-x86_64.AppImage diff --git a/docs/releases.md b/docs/releases.md index 6af534219d..b9a484a2ac 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -203,6 +203,14 @@ To move to a newer goose, change `revision` in the pin. The app's own signature covers the binary; nothing else checks it at launch. Each release job checks that its package contains it. +Normal `main` releases cache the unsigned staged Goose executable and its +SHA-256-bearing build stamp. Keys cover the Goose pin, native target, tool pins, +build configuration and runner image (or installed Linux packages). Only an exact +hit with matching executable bytes skips Goose compilation; other restores are +discarded and rebuilt. Candidate-only builds do not use this cache. Signing and +package checks still run on every release. A persistently invalid cache entry +must be evicted or its key changed because existing entries are immutable. + ## Windows and Linux installers and candidate-only runs The same **Desktop previews** workflow has a manual `candidates` switch. It builds @@ -244,7 +252,7 @@ are checksum-pinned. The workflow verifies the app executable in the extracted d and the app executable and launch shim in the extracted AppImage after repacking. Compatibility guards fail rather than silently omitting a fix. AppImage still relies on host desktop/media libraries; this is not a promise of -universal distro compatibility. Neither candidate job writes shared build caches. +universal distro compatibility. Candidate-only runs do not write shared build caches. ### Acceptance still required diff --git a/scripts/build-goose-acp.mjs b/scripts/build-goose-acp.mjs index 90ed53dcdd..04d98a0b51 100644 --- a/scripts/build-goose-acp.mjs +++ b/scripts/build-goose-acp.mjs @@ -3,10 +3,11 @@ // pin's small one. Tauri names the target it builds for in // TAURI_ENV_TARGET_TRIPLE; without it, goose-acp is built for this machine. import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; import { chmodSync, copyFileSync, - existsSync, + lstatSync, mkdirSync, readFileSync, renameSync, @@ -31,9 +32,14 @@ const binary = join(destination, name); const target = join(root, "target/goose-acp-build"); // What the staged binary was built from, kept out of the shipped directory. const stampPath = join(target, "staged.json"); -const stamp = `${JSON.stringify({ revision: pin.revision, features: pin.features, profile, triple }, null, 2)}\n`; +const stampInputs = { + revision: pin.revision, + features: pin.features, + profile, + triple, +}; -if (existsSync(binary) && read(stampPath) === stamp) { +if (stagedMatches()) { console.log(`goose-acp ready (${pin.revision.slice(0, 9)}, ${profile})`); process.exit(0); } @@ -90,17 +96,32 @@ mkdirSync(destination, { recursive: true }); copyFileSync(built, `${binary}.new`); chmodSync(`${binary}.new`, 0o755); renameSync(`${binary}.new`, binary); -writeFileSync(stampPath, stamp); +writeFileSync(stampPath, stagedStamp()); console.log(`goose-acp staged at ${binary}`); -function read(path) { +function stagedMatches() { try { - return readFileSync(path, "utf8"); + return readFileSync(stampPath, "utf8") === stagedStamp(); } catch { - return undefined; + return false; } } +function stagedStamp() { + const info = lstatSync(binary); + if ( + !info.isFile() || + info.size === 0 || + (!windows && (info.mode & 0o111) === 0) + ) { + throw new Error("Invalid staged goose-acp executable"); + } + const sha256 = createHash("sha256") + .update(readFileSync(binary)) + .digest("hex"); + return `${JSON.stringify({ ...stampInputs, sha256 }, null, 2)}\n`; +} + function run(command, args, env = process.env) { const result = spawnSync(command, args, { cwd: source, diff --git a/scripts/build-goose-acp.test.mjs b/scripts/build-goose-acp.test.mjs new file mode 100644 index 0000000000..6150b78340 --- /dev/null +++ b/scripts/build-goose-acp.test.mjs @@ -0,0 +1,134 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { + chmodSync, + copyFileSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, test } from "vitest"; + +const root = fileURLToPath(new URL("../", import.meta.url)); +const dirs = []; +afterEach(() => { + for (const dir of dirs.splice(0)) + rmSync(dir, { recursive: true, force: true }); +}); + +function fixture(triple = "aarch64-apple-darwin") { + const dir = mkdtempSync(join(tmpdir(), "goose-staged-")); + dirs.push(dir); + for (const path of ["scripts", "runtime", "commands"]) + mkdirSync(join(dir, path)); + for (const path of ["scripts/build-goose-acp.mjs", "runtime/goose-acp.json"]) + copyFileSync(join(root, path), join(dir, path)); + const pin = JSON.parse(readFileSync(join(dir, "runtime/goose-acp.json"))); + const name = triple.includes("windows") ? "goose-acp.exe" : "goose-acp"; + const binary = join(dir, "src-tauri/resources/bin", name); + const stamp = join(dir, "target/goose-acp-build/staged.json"); + const output = join(dir, "target/goose-acp-build", triple, pin.profile, name); + const command = (name, source) => + writeFileSync( + join(dir, "commands", name), + `#!${process.execPath}\n${source}`, + { + mode: 0o755, + }, + ); + command("git", "process.exit(0);"); + command( + "cargo", + `const fs = require('node:fs'); +fs.mkdirSync(${JSON.stringify(dirname(output))}, {recursive:true}); +fs.writeFileSync(${JSON.stringify(output)}, 'compiled Goose fixture', {mode:0o755});`, + ); + command( + "otool", + "console.log('goose-acp:\\n\\t/usr/lib/libSystem.B.dylib (compatibility version 1.0.0)');", + ); + const run = () => + spawnSync(process.execPath, [join(dir, "scripts/build-goose-acp.mjs")], { + cwd: dir, + encoding: "utf8", + env: { PATH: join(dir, "commands"), TAURI_ENV_TARGET_TRIPLE: triple }, + }); + const built = run(); + assert.equal(built.status, 0, built.stderr); + // Any cache rejection must enter the real build path, without network or Cargo. + command("git", "console.error('SOURCE_BUILD_REQUIRED'); process.exit(73);"); + return { dir, binary, stamp, run }; +} + +test.each([ + "aarch64-apple-darwin", + "x86_64-pc-windows-msvc", + "x86_64-unknown-linux-gnu", +])( + "reuses the staged executable after an archive round-trip for %s", + (triple) => { + const { dir, binary, stamp, run } = fixture(triple); + const paths = [binary, stamp].map((path) => path.slice(dir.length + 1)); + assert.equal( + spawnSync("tar", ["-cf", "cache.tar", ...paths], { cwd: dir }).status, + 0, + ); + rmSync(binary); + rmSync(stamp); + assert.equal( + spawnSync("tar", ["-xf", "cache.tar"], { cwd: dir }).status, + 0, + ); + const result = run(); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /goose-acp ready/); + }, +); + +test.each([ + [ + "modified executable", + ({ binary }) => writeFileSync(binary, "corrupt bytes"), + ], + ["empty executable", ({ binary }) => writeFileSync(binary, "")], + ["non-executable file", ({ binary }) => chmodSync(binary, 0o644)], + ["missing executable", ({ binary }) => rmSync(binary)], + ["missing stamp", ({ stamp }) => rmSync(stamp)], + ["malformed stamp", ({ stamp }) => writeFileSync(stamp, "not json")], + [ + "symlinked executable", + ({ binary, stamp }) => { + rmSync(binary); + symlinkSync(stamp, binary); + }, + ], + [ + "legacy stamp", + ({ stamp }) => { + const { sha256: _sha256, ...inputs } = JSON.parse(readFileSync(stamp)); + writeFileSync(stamp, `${JSON.stringify(inputs, null, 2)}\n`); + }, + ], + ...["revision", "features", "profile", "triple", "sha256"].map((field) => [ + `mismatched ${field}`, + ({ stamp }) => { + const inputs = JSON.parse(readFileSync(stamp)); + writeFileSync( + stamp, + `${JSON.stringify({ ...inputs, [field]: "stale" }, null, 2)}\n`, + ); + }, + ]), +])("rebuilds a cached pair with %s", (_name, damage) => { + const staged = fixture(); + damage(staged); + const result = staged.run(); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /SOURCE_BUILD_REQUIRED/); +});