Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .github/actions/cache-goose/action.yml
Original file line number Diff line number Diff line change
@@ -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
11 changes: 10 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion docs/releases.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
35 changes: 28 additions & 7 deletions scripts/build-goose-acp.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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);
}
Expand Down Expand Up @@ -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,
Expand Down
134 changes: 134 additions & 0 deletions scripts/build-goose-acp.test.mjs
Original file line number Diff line number Diff line change
@@ -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/);
});
Loading