// SPDX-License-Identifier: LGPL-3.0-only // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® import std; import Crafter.Build; #pragma crafter dependency "https://forgejo.catcrafts.net/Catcrafts/Crafter.LintRules.git" import Crafter.LintRules; namespace fs = std::filesystem; using namespace Crafter; // The local Flatpak: `crafter-build run flatpak` builds it from this checkout // and `crafter-build run install` installs that build. The development box // compiles with its own clang, the shipped artifact with the LLVM bundled in // the Flatpak, so this is how a change is tried against the real thing. namespace { struct FlatpakPaths { fs::path manifest; // flatpak-builder's downloads, ccache and per-module cache. The // LLVM build lives here: about 2 GB, and 25 minutes to rebuild. fs::path state; // The OSTree repo the build is exported to; `install` reads it. fs::path repo; fs::path buildDir; }; // Everything under /build/flatpak, and a fixed name on purpose // instead of a BuildDir(): the Flatpak is compiled by the toolchain // inside the sandbox, so it does not depend on the host variant, and a // variant-keyed directory would strand the LLVM cache on every host // compiler update. The manifest's `skip: build` keeps it out of the // sources the sandbox copies. flatpak-builder keys its cache on the // manifest's absolute path, so the manifest is used where it is, never // through a rewritten copy the way CI stamps the version in; a local // build therefore reports version 0.0.0-dev. FlatpakPaths FlatpakLayout(const fs::path& projectDir) { const fs::path root = projectDir / "build" / "flatpak"; return { .manifest = projectDir / "packaging" / "flatpak" / "net.catcrafts.CrafterBuild.yaml", .state = root / "state", .repo = root / "repo", .buildDir = root / "build-dir", }; } // Both scripts drive the host's flatpak, and the sandbox crafter-build // itself runs in as a Flatpak has none. 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 build: ./build.sh, then ./bin/crafter-build run {0}", 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(c)); } return url; } std::int32_t BuildFlatpak(ScriptContext& ctx) { if (RefusedInSandbox("flatpak")) return 1; // Nothing else is passed through: a flag such as --stop-at or // --download-only would end the run without a build and leave the // smoke test below running against the previous one. std::string jobs; for (const std::string& arg : ctx.args) { 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= is accepted)", arg); return 1; } jobs = std::format(" --jobs={}", count); } 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); 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 CI runs on its build, but through `flatpak build` // and not ` --run`: the org.flatpak.Builder app (1.4.9) // exits 1 when the command it ran succeeded and 0 when it failed, // and --run also wants the state dir again or it drops a // .flatpak-builder/ into the working directory. return ctx.Run(std::format("flatpak build {} crafter-build --version", 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); // Deliberately not built from here: flatpak-builder never caches a // `type: dir` module, so every build reruns build.sh and the whole // suite inside the sandbox. Installing is the cheap half. 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)); // Its own branch from its own remote, next to whichever published // branch is installed. The remote may already exist and point at // another checkout's repo; --if-not-exists would keep that silently. if (const std::int32_t code = ctx.Run(std::format("flatpak remote-add --user --if-not-exists --no-gpg-verify crafter-build-local {}", url)); code != 0) return code; if (const std::int32_t code = ctx.Run(std::format("flatpak remote-modify --user --url={} crafter-build-local", url)); code != 0) return code; if (const std::int32_t code = ctx.Run("flatpak install --user --noninteractive --or-update crafter-build-local net.catcrafts.CrafterBuild//local"); code != 0) return code; // Only a first install becomes the branch `flatpak run` starts; an // update leaves that choice where it was. if (const std::int32_t code = ctx.Run("flatpak make-current --user net.catcrafts.CrafterBuild local"); code != 0) return code; std::println( R"( `flatpak run net.catcrafts.CrafterBuild` now starts this build. Back to a published branch, keeping this one installed: flatpak make-current --user net.catcrafts.CrafterBuild Remove it (do this before `crafter-build clean`, which deletes the repo the remote points at): flatpak uninstall --user net.catcrafts.CrafterBuild//local flatpak remote-delete --user crafter-build-local)"); return 0; } } extern "C" Configuration CrafterBuildProject(std::span args) { std::vector depArgs(args.begin(), args.end()); Configuration* math = GitProject({ .source = { "https://forgejo.catcrafts.net/Catcrafts/Crafter.Math.git" }, .args = depArgs, }); Configuration* asset = GitProject({ .source = { "https://forgejo.catcrafts.net/Catcrafts/Crafter.Asset.git" }, .args = depArgs, }); static auto CrafterBuildLib = std::make_unique(); CrafterBuildLib->path = "./"; CrafterBuildLib->name = "crafter.build-lib"; CrafterBuildLib->outputName = "crafter-build"; ApplyStandardArgs(*CrafterBuildLib, args); // Monolithic: project.so resolves Crafter:: symbols from the running // executable, so the library is only ever linked statically into it. CrafterBuildLib->type = ConfigurationType::LibraryStatic; CrafterBuildLib->dependencies = { math, asset }; CrafterBuildLib->defines.push_back({"CRAFTER_BUILD_HAS_ASSET", ""}); // Crafter.Build-Lint includes libclang's clang-c/Index.h, which is only // on the default include path for a toolchain installed in /usr. if (const fs::path& llvmInclude = GetToolchain().llvmIncludeDir; !llvmInclude.empty()) { CrafterBuildLib->compileFlags.push_back(std::format("-I{}", llvmInclude.string())); } // The version the binary reports: CI sets it from the tag, a checkout // describes itself, anything else is a development build. A define on // the library, so each version is its own library variant. std::string version; if (const char* env = std::getenv("CRAFTER_BUILD_VERSION"); env && *env) { version = env; } else { auto described = RunCommandWithTimeout("git describe --tags --always --dirty --exclude=latest 2>/dev/null", std::chrono::seconds(10)); version = described.output; while (!version.empty() && (version.back() == '\n' || version.back() == '\r')) version.pop_back(); if (described.exitCode != 0 || version.empty()) version = "0.0.0-dev"; } // Backslash-escaped quotes: the define travels through the shell before // it reaches clang, which must still see a string literal. CrafterBuildLib->defines.push_back({"CRAFTER_BUILD_VERSION", std::format("\\\"{}\\\"", version)}); { std::array interfaces = { "interfaces/Crafter.Build", "interfaces/Crafter.Build-Toolchain", "interfaces/Crafter.Build-Shader", "interfaces/Crafter.Build-Platform", "interfaces/Crafter.Build-Interface", "interfaces/Crafter.Build-Implementation", "interfaces/Crafter.Build-External", "interfaces/Crafter.Build-Clang", "interfaces/Crafter.Build-Test", "interfaces/Crafter.Build-Lint", "interfaces/Crafter.Build-Script", "interfaces/Crafter.Build-Progress", "interfaces/Crafter.Build-Asset", }; std::array implementations = { "implementations/Crafter.Build-Toolchain", "implementations/Crafter.Build-Shader", "implementations/Crafter.Build-Platform", "implementations/Crafter.Build-Interface", "implementations/Crafter.Build-Implementation", "implementations/Crafter.Build-External", "implementations/Crafter.Build-Clang", "implementations/Crafter.Build-Test", "implementations/Crafter.Build-Lint", "implementations/Crafter.Build-Script", "implementations/Crafter.Build-Progress", "implementations/Crafter.Build-Asset", }; CrafterBuildLib->GetInterfacesAndImplementations(interfaces, implementations); } ExternalDependency& glslang = CrafterBuildLib->externalDependencies.emplace_back(); glslang.name = "glslang"; glslang.source.url = "https://github.com/KhronosGroup/glslang.git"; glslang.source.branch = "main"; glslang.builder = ExternalBuilder::CMake; glslang.options = { "-DENABLE_OPT=OFF" }; glslang.includeDirs = { "" }; glslang.libs = { "SPIRV", "GenericCodeGen", "glslang", "OSDependent", "MachineIndependent", "glslang-default-resource-limits" }; Configuration cfg; cfg.path = "./"; cfg.name = "crafter.build-exe"; cfg.outputName = "crafter-build"; ApplyStandardArgs(cfg, args); cfg.type = ConfigurationType::Executable; cfg.dependencies = { CrafterBuildLib.get() }; { std::array interfaces = {}; std::array implementations = { "implementations/main" }; cfg.GetInterfacesAndImplementations(interfaces, implementations); } // Linux hosts, glibc or musl (Alpine): -ldl is a libc stub on musl, harmless. const bool linuxTarget = cfg.target.ends_with("-linux-gnu") || cfg.target.ends_with("-linux-musl"); if (linuxTarget) { cfg.linkFlags.push_back("-Wl,--export-dynamic"); cfg.linkFlags.push_back("-ldl"); } // Self-tests link the local crafter-build library and exercise it in // process. The harness (whichever crafter-build invokes `test`) compiles // these against the *installed* share/crafter-build .cppm files, then // links each test exe against CrafterBuildLib built from the local // sources — so the code under test is whatever's in this checkout. // Mirrors how downstream consumers link their own libraries into tests. if (linuxTarget) { cfg.AddTest("HelloWorld").Dependencies({ CrafterBuildLib.get() }); cfg.AddTest("StaticLib").Dependencies({ CrafterBuildLib.get() }); cfg.AddTest("ModuleInterface").Dependencies({ CrafterBuildLib.get() }); cfg.AddTest("DependencyLink").Dependencies({ CrafterBuildLib.get() }); cfg.AddTest("IncrementalInterfaceChange").Dependencies({ CrafterBuildLib.get() }); cfg.AddTest("TransitiveInterfaceChange").Dependencies({ CrafterBuildLib.get() }); cfg.AddTest("IncrementalHeaderChange").Dependencies({ CrafterBuildLib.get() }); cfg.AddTest("CompilerWarning").Dependencies({ CrafterBuildLib.get() }); cfg.AddTest("CleanProject").Dependencies({ CrafterBuildLib.get() }); cfg.AddTest("ShaderCompile").Dependencies({ CrafterBuildLib.get() }); cfg.AddTest("ShaderVariantBuild").Dependencies({ CrafterBuildLib.get() }); cfg.AddTest("ShaderIncludeChange").Dependencies({ CrafterBuildLib.get() }); cfg.AddTest("MingwMissing").Dependencies({ CrafterBuildLib.get() }); cfg.AddTest("GitSubmodules").Dependencies({ CrafterBuildLib.get() }); cfg.AddTest("LinkFlagsChange").Dependencies({ CrafterBuildLib.get() }); cfg.AddTest("ExternalArtifactTime").Dependencies({ CrafterBuildLib.get() }); cfg.AddTest("ConcurrentExternalFetch").Dependencies({ CrafterBuildLib.get() }); cfg.AddTest("StandardArgs").Dependencies({ CrafterBuildLib.get() }); cfg.AddTest("TestRunnerSpec").Dependencies({ CrafterBuildLib.get() }); cfg.AddTest("VariantId").Dependencies({ CrafterBuildLib.get() }); cfg.AddTest("ToolchainResolve").Dependencies({ CrafterBuildLib.get() }); cfg.AddTest("SysrootStdModule").Dependencies({ CrafterBuildLib.get() }); cfg.AddTest("SysrootCompileFlags").Dependencies({ CrafterBuildLib.get() }); cfg.AddTest("VersionFlag").Dependencies({ CrafterBuildLib.get() }); cfg.AddTest("WasiBrowserRuntime").Dependencies({ CrafterBuildLib.get() }); cfg.AddTest("WasmVariants").Dependencies({ CrafterBuildLib.get() }); cfg.AddTest("WasmHelloRuns").Dependencies({ CrafterBuildLib.get() }); cfg.AddTest("RunSingleTestExit").Dependencies({ CrafterBuildLib.get() }); // LoadProject dlopens the synthesized project.so, which references // Crafter:: symbols (HostTarget, Configuration ctors) that have to be // visible from the test exe — same wiring crafter-build itself uses // for project.so. cfg.AddTest("ConcurrentCacheRace").Dependencies({ CrafterBuildLib.get() }) .LinkFlag("-Wl,--export-dynamic").LinkFlag("-ldl"); // Same LoadProject wiring as ConcurrentCacheRace above. cfg.AddTest("HostCacheSourceStamp").Dependencies({ CrafterBuildLib.get() }) .LinkFlag("-Wl,--export-dynamic").LinkFlag("-ldl"); cfg.AddTest("HostCacheToolchainKey").Dependencies({ CrafterBuildLib.get() }) .LinkFlag("-Wl,--export-dynamic").LinkFlag("-ldl"); cfg.AddTest("ProjectDependency").Dependencies({ CrafterBuildLib.get() }) .LinkFlag("-Wl,--export-dynamic").LinkFlag("-ldl"); cfg.AddTest("Script").Dependencies({ CrafterBuildLib.get() }) .LinkFlag("-Wl,--export-dynamic").LinkFlag("-ldl"); cfg.AddTest("ConcurrentDependencyReset").Dependencies({ CrafterBuildLib.get() }); cfg.AddTest("Lint").Dependencies({ CrafterBuildLib.get() }); } // Dogfood: the house-style rules, imported from their own repo through // the pragma above. Report rules gate `crafter-build lint`; transform // rules also auto-fix under `crafter-build format`. LintRules::AddRules(cfg); // See the top of this file. cfg.scripts.push_back({"flatpak", BuildFlatpak}); cfg.scripts.push_back({"install", InstallFlatpak}); return cfg; }