A push to master published the `beta` branch and moved the rolling `latest` tag and its "Latest master build" prerelease, and a pushed X.Y.Z tag made the release and published `stable`. The owner wants a release for every push to master, made by the signed tag that goes with it, and nothing for a push without one. So `beta`, `latest` and the branch trigger are gone: the workflow runs for pushed tags, pull requests and manual runs only, and the `latest` tag and its prerelease have been deleted from the forge. `beta` gets no more builds, and its installations would not hear of it: the release notes ship in stable builds only. The Publish step marks beta's last build end-of-life, once, with a reason that names stable, and the next `flatpak update` of a beta installation prints it. The reason also names the branch, because a non-interactive update prints only the app's name with it. Before building, a tag run now checks the tag: it has to be annotated and signed by the owner as Forgejo verifies it, its commit has to be on master, its version has to be X.Y.Z and newer than every release the forge has published, its own included (a later tag whose run still queues does not count), and the metainfo's newest <release> entry has to be that version. The tag run checked only the last of these. It reads the tag from the forge's API, because the checkout fetches a pushed tag again as a plain ref to its commit, so the job's clone never has the annotated tag, and Forgejo verifies a signed tag's own signature but falls back to the commit's for an unsigned one, so the step also requires that the verdict covers the tag's payload. The metainfo's entry is read once, as the first <release> element: the Version step takes its version and renders its notes into release-notes.md with the renderer the Release notes step had, and stops the run when they come out empty, so a comment or another attribute order cannot give a release one entry's version and another's notes. It does so on every run, pull requests and manual runs included, so a broken entry shows before a release. Release runs share a concurrency group, so they queue one behind another; a pull request still cancels its own outdated run. A failed release run can be re-run while no newer release exists, since its tag is still on its commit; a release that an attempt left half made has to be deleted first. The release step runs without `override`, which would delete a tag that has moved off the run's commit since the Version step and tag the old commit instead. A manual run no longer publishes anything; it used to publish `beta` and move `latest` from whichever branch it was started on. No step pushes to git, so the checkout keeps no credentials. git describe in build.sh, project.cpp and the workflow has no `latest` tag left to exclude.
321 lines
18 KiB
C++
321 lines
18 KiB
C++
// 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 3 GB in all after a first run,
|
|
// which takes about 30 minutes with 56 CPU threads.
|
|
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, 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<std::uint8_t>(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=<N> 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 `<builder> --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 <branch>
|
|
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<const std::string_view> args) {
|
|
Configuration* math = GitProject({ .source = { "https://forgejo.catcrafts.net/Catcrafts/Crafter.Math.git" } });
|
|
Configuration* asset = GitProject({ .source = { "https://forgejo.catcrafts.net/Catcrafts/Crafter.Asset.git" } });
|
|
|
|
static auto CrafterBuildLib = std::make_unique<Configuration>();
|
|
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, 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 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<fs::path, 14> 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-Agents",
|
|
"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<fs::path, 13> 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-Agents",
|
|
"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<fs::path, 0> interfaces = {};
|
|
std::array<fs::path, 1> 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("StdPcmRebuild").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("MingwSysroot").Dependencies({ CrafterBuildLib.get() });
|
|
cfg.AddTest("GitSubmodules").Dependencies({ CrafterBuildLib.get() });
|
|
cfg.AddTest("LinkFlagsChange").Dependencies({ CrafterBuildLib.get() });
|
|
cfg.AddTest("ExternalArtifactTime").Dependencies({ CrafterBuildLib.get() });
|
|
cfg.AddTest("ExternalReconfigure").Dependencies({ CrafterBuildLib.get() });
|
|
cfg.AddTest("ConcurrentExternalFetch").Dependencies({ CrafterBuildLib.get() });
|
|
cfg.AddTest("SharedExternalFetch").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("WasiBrowserRuntimeExit").Dependencies({ CrafterBuildLib.get() });
|
|
cfg.AddTest("WasmVariants").Dependencies({ CrafterBuildLib.get() });
|
|
cfg.AddTest("WasmHelloRuns").Dependencies({ CrafterBuildLib.get() });
|
|
cfg.AddTest("WasmExceptions").Dependencies({ CrafterBuildLib.get() });
|
|
cfg.AddTest("WasiSysrootWithoutExceptions").Dependencies({ CrafterBuildLib.get() });
|
|
cfg.AddTest("WasmBuildRefusals").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("ProjectArgs").Dependencies({ CrafterBuildLib.get() })
|
|
.LinkFlag("-Wl,--export-dynamic").LinkFlag("-ldl");
|
|
cfg.AddTest("HostToolingSibling").Dependencies({ CrafterBuildLib.get() })
|
|
.LinkFlag("-Wl,--export-dynamic").LinkFlag("-ldl");
|
|
cfg.AddTest("DuplicateModule").Dependencies({ CrafterBuildLib.get() })
|
|
.LinkFlag("-Wl,--export-dynamic").LinkFlag("-ldl");
|
|
cfg.AddTest("Script").Dependencies({ CrafterBuildLib.get() })
|
|
.LinkFlag("-Wl,--export-dynamic").LinkFlag("-ldl");
|
|
cfg.AddTest("DependencyNotes").Dependencies({ CrafterBuildLib.get() })
|
|
.LinkFlag("-Wl,--export-dynamic").LinkFlag("-ldl");
|
|
cfg.AddTest("ConcurrentDependencyReset").Dependencies({ CrafterBuildLib.get() });
|
|
cfg.AddTest("Lint").Dependencies({ CrafterBuildLib.get() });
|
|
}
|
|
|
|
// Dogfood: the lite house rules, imported from their own repo through
|
|
// the pragma above. Lite is the house rules without the ones that only
|
|
// decide layout, whitespace and naming, which this tree keeps by hand:
|
|
// the full set's naming rule, for one, wants camelCase where this tree
|
|
// spells its statics and constants PascalCase. Report rules gate
|
|
// `crafter-build lint`; the two transforms also auto-fix under
|
|
// `crafter-build format`.
|
|
LintRules::AddLiteRules(cfg);
|
|
|
|
// See the top of this file.
|
|
cfg.scripts.push_back({"flatpak", BuildFlatpak});
|
|
cfg.scripts.push_back({"install", InstallFlatpak});
|
|
|
|
return cfg;
|
|
}
|