build(flatpak): net.catcrafts.CrafterReview on the Crafter.Build base

The manifest builds on the Crafter.Build Flatpak (base:
net.catcrafts.CrafterBuild): its crafter-build, LLVM 23 and libc++
compile this project inside flatpak-builder, and crafter-build test runs
there too. Node 24.22.0 (for the Playwright MCP server) and a static
ripgrep 15.2.0 are bundled, pinned by sha256; claude is not, being
proprietary. finish-args: the network, the host's files and /tmp, and
gnupg's keyboxd socket only, never the agent.

The base's files belong to no module of this manifest, so `cleanup` does
not reach them: prune-base.sh keeps a named list (the binary, Node, rg,
the libc++ the binary links, share/crafter-review, this app's metainfo
and licences) and removes the rest, whatever a newer base adds.

`crafter-build run flatpak [--base=<branch>] [--jobs=N]` builds it and
smoke-tests --version and ldd through `flatpak build`; `run install`
installs it as the `local` branch. Until a Crafter.Build release with
scripts is on stable, `--base=local` builds against Crafter.Build's own
local install, from a gitignored manifest copy whose cache is its own.
Built that way: 4 tests green in the sandbox, the charter sha256s the
native build prints, no library missing, RUNPATH /app/lib only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jorijn van der Graaf 2026-10-31 06:01:19 +01:00
commit b02de3ceff
Signed by: jorijnvdgraaf
GPG key ID: 2937E59CDCC1BCFB
6 changed files with 334 additions and 1 deletions

View file

@ -53,6 +53,146 @@ namespace {
if (described.exitCode != 0 || version.empty()) return "0.0.0-dev";
return version;
}
// The local Flatpak: `crafter-build run flatpak` builds it from this
// checkout and `crafter-build run install` installs that build, as
// Crafter.Build's own scripts do. --base=<branch> builds on another branch
// of the Crafter.Build base app than stable: until a Crafter.Build release
// with scripts is on stable, `--base=local` (the branch Crafter.Build's own
// `run install` makes) is what builds this project.
struct FlatpakPaths {
fs::path manifest;
// flatpak-builder's downloads, ccache and per-module cache.
fs::path state;
// The OSTree repo the build is exported to; `install` reads it.
fs::path repo;
fs::path buildDir;
};
// Everything under <project>/build/flatpak, a fixed name rather than a
// BuildDir(): the Flatpak is compiled by the toolchain inside the sandbox,
// not by the host variant. flatpak-builder keys its cache on the
// manifest's absolute path, so a branch other than stable gets a manifest
// copy of its own next to the original (gitignored), and with it a cache
// of its own.
FlatpakPaths FlatpakLayout(const fs::path& projectDir, std::string_view base) {
const fs::path root = projectDir / "build" / "flatpak";
const fs::path packaging = projectDir / "packaging" / "flatpak";
return {
.manifest = base == "stable" ? packaging / "net.catcrafts.CrafterReview.yaml" : packaging / std::format("{}-base.yaml", base),
.state = root / "state",
.repo = root / "repo",
.buildDir = root / "build-dir",
};
}
// Both scripts drive the host's flatpak, which the sandbox of a Flatpak
// crafter-build does not have.
bool RefusedInSandbox(std::string_view script) {
if (!fs::exists("/.flatpak-info")) return false;
std::println(std::cerr, "run {0}: needs the host's flatpak, which the Flatpak build of crafter-build cannot reach. Use a native crafter-build", script);
return true;
}
// flatpak-builder as a host package, else the org.flatpak.Builder app.
// Empty when neither is installed.
std::string FlatpakBuilder() {
if (RunCommandWithTimeout("flatpak-builder --version", std::chrono::seconds(10)).exitCode == 0) return "flatpak-builder";
if (RunCommandWithTimeout("flatpak info org.flatpak.Builder", std::chrono::seconds(10)).exitCode == 0) return "flatpak run org.flatpak.Builder";
return "";
}
// A repo path as the file:// URL of a remote. Percent-encoded, because
// `remote-modify --url=` stores what it is given as it is.
std::string FileUrl(const fs::path& path) {
std::string url = "file://";
for (const char c : path.string()) {
const bool plain = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || std::string_view("/-._~").contains(c);
if (plain) {
url.push_back(c);
} else {
url += std::format("%{:02X}", static_cast<std::uint8_t>(c));
}
}
return url;
}
std::int32_t BuildFlatpak(ScriptContext& ctx) {
if (RefusedInSandbox("flatpak")) return 1;
std::string jobs;
std::string base = "stable";
for (const std::string& arg : ctx.args) {
if (arg.starts_with("--base=") && arg.size() > 7) {
base = arg.substr(7);
continue;
}
const std::string_view count = arg.starts_with("--jobs=") ? std::string_view(arg).substr(7) : std::string_view();
if (count.empty() || !std::ranges::all_of(count, [](char c) { return c >= '0' && c <= '9'; })) {
std::println(std::cerr, "run flatpak: unknown argument '{}' (only --jobs=<N> and --base=<branch> are accepted)", arg);
return 1;
}
jobs = std::format(" --jobs={}", count);
}
if (!std::ranges::all_of(base, [](char c) { return std::isalnum(static_cast<std::uint8_t>(c)) || c == '-' || c == '_' || c == '.'; })) {
std::println(std::cerr, "run flatpak: '{}' is not a branch name", base);
return 1;
}
if (RunCommandWithTimeout(std::format("flatpak info net.catcrafts.CrafterBuild//{}", base), std::chrono::seconds(10)).exitCode != 0) {
std::println(std::cerr, "run flatpak: the base app is not installed: flatpak install catcrafts net.catcrafts.CrafterBuild//{}", base);
return 1;
}
const std::string builder = FlatpakBuilder();
if (builder.empty()) {
std::println(std::cerr, "run flatpak: neither flatpak-builder nor the org.flatpak.Builder app is installed (flatpak install flathub org.flatpak.Builder)");
return 1;
}
const FlatpakPaths paths = FlatpakLayout(ctx.projectDir, base);
if (base != "stable") {
// The manifest with only base-version changed.
std::ifstream in(FlatpakLayout(ctx.projectDir, "stable").manifest);
const std::string manifest((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
constexpr std::string_view Line = "base-version: stable\n";
const std::size_t at = manifest.find(Line);
if (at == std::string::npos) {
std::println(std::cerr, "run flatpak: the manifest has no `{}` line to replace", Line.substr(0, Line.size() - 1));
return 1;
}
std::ofstream(paths.manifest) << manifest.substr(0, at) << std::format("base-version: {}\n", base) << manifest.substr(at + Line.size());
}
const std::string buildDir = ShellQuote(paths.buildDir.string());
if (const std::int32_t code = ctx.Run(std::format("{}{} --user --force-clean --ccache --state-dir={} --repo={} --default-branch=local {} {}", builder, jobs, ShellQuote(paths.state.string()), ShellQuote(paths.repo.string()), buildDir, ShellQuote(paths.manifest.string()))); code != 0) return code;
// The smoke test, through `flatpak build` and not `<builder> --run`:
// the org.flatpak.Builder app reports --run's exit status inverted.
if (const std::int32_t code = ctx.Run(std::format("flatpak build {} crafter-review --version", buildDir)); code != 0) return code;
return ctx.Run(std::format("flatpak build {} sh -c 'ldd /app/bin/crafter-review | grep \"not found\" && exit 1 || exit 0'", buildDir));
}
std::int32_t InstallFlatpak(ScriptContext& ctx) {
if (RefusedInSandbox("install")) return 1;
if (!ctx.args.empty()) {
std::println(std::cerr, "run install: takes no arguments (got '{}')", ctx.args.front());
return 1;
}
const FlatpakPaths paths = FlatpakLayout(ctx.projectDir, "stable");
if (!fs::exists(paths.repo / "config")) {
std::println(std::cerr, "run install: nothing built yet, run: crafter-build run flatpak");
return 1;
}
const std::string url = ShellQuote(FileUrl(paths.repo));
if (const std::int32_t code = ctx.Run(std::format("flatpak remote-add --user --if-not-exists --no-gpg-verify crafter-review-local {}", url)); code != 0) return code;
if (const std::int32_t code = ctx.Run(std::format("flatpak remote-modify --user --url={} crafter-review-local", url)); code != 0) return code;
if (const std::int32_t code = ctx.Run("flatpak install --user --noninteractive --or-update crafter-review-local net.catcrafts.CrafterReview//local"); code != 0) return code;
if (const std::int32_t code = ctx.Run("flatpak make-current --user net.catcrafts.CrafterReview local"); code != 0) return code;
std::println(
R"(
`flatpak run net.catcrafts.CrafterReview` now starts this build.
Back to a published branch, keeping this one installed:
flatpak make-current --user net.catcrafts.CrafterReview <branch>
Remove it (do this before deleting build/, which holds the repo the remote points at):
flatpak uninstall --user net.catcrafts.CrafterReview//local
flatpak remote-delete --user crafter-review-local)");
return 0;
}
}
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> args) {
@ -105,5 +245,8 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
}
LintRules::AddLiteRules(cfg);
cfg.scripts.push_back({"flatpak", BuildFlatpak});
cfg.scripts.push_back({"install", InstallFlatpak});
return cfg;
}