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/); +});