From 5dab4b49d5e6a55eba6662544844e697dea3c436 Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Sat, 31 Oct 2026 15:59:20 +0100 Subject: [PATCH] ci: the Flatpak workflow, local deps for the local build, the lens tools - .forgejo/workflows/ci.yaml is Crafter.Build's workflow for this app: the base installed from the Catcrafts repo, the version stamped into the crafter-review module, the suite inside the build, then a smoke test of the app as it ships - the version and home, every library resolved and no RUNPATH outside /app, the bundled tools, a freeze and a dry run on a scratch repository. Publishing holds /srv/flatpak/.publish.lock and writes only this app's flatpakref. Unverified until a crafter-flatpak runner serves this repository. - `run flatpak --deps=local` builds against the Crafter.Network and Crafter.Thread checkouts next to this one, as a native --local build does, while their commits are not on forgejo: a gitignored manifest copy with three dir sources side by side and --local. - LensTools: every program the lens allowlist names, the shell and the MCP server's node and npx resolve on PATH; strict in the Flatpak build, where the suite runs in the sandbox that ships, a skip natively when this machine lacks one. Built on the stable base (Crafter.Build 3.0.0) with --deps=local: the suite passes inside flatpak-builder (25 passed, 2 skipped), and the workflow's smoke test passes against that build. Co-Authored-By: Claude Opus 5.5 --- .forgejo/workflows/ci.yaml | 274 +++++++++++++++++++++++++++++++++++++ project.cpp | 74 +++++++--- tests/LensTools/main.cpp | 42 ++++++ 3 files changed, 373 insertions(+), 17 deletions(-) create mode 100644 .forgejo/workflows/ci.yaml create mode 100644 tests/LensTools/main.cpp diff --git a/.forgejo/workflows/ci.yaml b/.forgejo/workflows/ci.yaml new file mode 100644 index 0000000..57c8ab0 --- /dev/null +++ b/.forgejo/workflows/ci.yaml @@ -0,0 +1,274 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +name: Flatpak + +# Crafter.Build's workflow, for this app. One job, three outcomes: +# pull request -> build the Flatpak, run the suite inside it, smoke-test it +# push master -> also publish it as the `beta` branch of the Catcrafts +# repo and refresh the rolling `latest` release +# tag X.Y.Z -> publish it as `stable` and create release X.Y.Z +# +# It builds on the Crafter.Build Flatpak (`base:` in the manifest), whose +# crafter-build and LLVM compile this project, so the runner installs that +# base from the same repo it publishes into. +# +# The `crafter-flatpak` label is the privileged runner on the Hetzner box +# (bubblewrap needs it) with the Sdk install, the flatpak-builder state, the +# repo's web root and the signing key mounted. It is registered to +# Crafter.Build alone today: until this repository has such a runner, this +# workflow does not run. +# +# Both apps publish into one repo: the publish step holds +# /srv/flatpak/.publish.lock, which protects anything once Crafter.Build's +# workflow takes it too. This one writes its own flatpakref and never the +# shared catcrafts.flatpakrepo, which Crafter.Build's workflow owns. +on: + pull_request: + branches: [master] + push: + branches: [master] + tags: ['[0-9]*.[0-9]*.[0-9]*'] + workflow_dispatch: + +env: + APP_ID: net.catcrafts.CrafterReview + MANIFEST: packaging/flatpak/net.catcrafts.CrafterReview.yaml + CI_MANIFEST: packaging/flatpak/ci-manifest.yaml + METAINFO: packaging/flatpak/net.catcrafts.CrafterReview.metainfo.xml + RUNTIME: org.freedesktop.Sdk//26.08 + BASE: net.catcrafts.CrafterBuild//stable + REPO_DIR: /srv/flatpak/repo + WEB_DIR: /srv/flatpak + +jobs: + flatpak: + runs-on: crafter-flatpak + container: + image: archlinux:latest + options: >- + -v crafter-flatpak-system:/var/lib/flatpak + -v crafter-flatpak-builder:/root/.cache/flatpak-builder + -v /var/www/flatpak:/srv/flatpak + -v /etc/crafter-flatpak:/run/crafter-flatpak:ro + steps: + - name: Install tools + run: | + pacman-key --init + pacman-key --populate archlinux + pacman -Sy --noconfirm --needed archlinux-keyring + pacman -Syu --noconfirm --needed flatpak flatpak-builder ccache git jq gnupg go-yq ostree python util-linux + git config --global --add safe.directory '*' + + - name: Sdk and base app + # A no-op once the volume is warm; `update` follows point releases + # and a new stable base. + run: | + flatpak remote-add --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo + flatpak remote-add --if-not-exists catcrafts https://flatpak.catcrafts.net/catcrafts.flatpakrepo + flatpak install -y --noninteractive flathub "$RUNTIME" + flatpak install -y --noninteractive catcrafts "$BASE" + flatpak update -y --noninteractive + + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: true + + - name: Version and channel + id: meta + run: | + set -eu + if [ "${GITHUB_REF_TYPE}" = tag ]; then + version="$GITHUB_REF_NAME" + branch=stable + # The metainfo's newest release is the release notes and must be + # the version being tagged. + top=$(grep -m1 -oP '&2 + exit 1 + fi + else + # Named after the release it follows, as a checkout describes + # itself: 1.0.0-3-g21746b1 is three commits past 1.0.0. + described=$(git describe --tags --always --exclude=latest) + case "$described" in + [0-9]*.[0-9]*.[0-9]*) version="$described" ;; + *) version="0.0.0-dev+$described" ;; + esac + branch=beta + fi + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "branch=$branch" >> "$GITHUB_OUTPUT" + echo "version $version, flatpak branch $branch" + + - name: Build + # The manifest is rewritten beside the original so its relative + # source paths still resolve. The suite runs inside this build. + env: + VERSION: ${{ steps.meta.outputs.version }} + run: | + set -eu + yq '(.modules[] | select(.name == "crafter-review") | ."build-options".env.CRAFTER_REVIEW_VERSION) = strenv(VERSION)' "$MANIFEST" > "$CI_MANIFEST" + grep -n 'CRAFTER_REVIEW_VERSION' "$CI_MANIFEST" + flatpak-builder --force-clean --ccache --disable-rofiles-fuse \ + --state-dir=/root/.cache/flatpak-builder \ + --repo=repo --default-branch="${{ steps.meta.outputs.branch }}" \ + build-dir "$CI_MANIFEST" + + - name: Smoke test + # In the app as it ships: the version and the home, every library + # resolved and none through a RUNPATH outside /app, the bundled + # tools, and a freeze and a dry run on a scratch repository (a dry run + # joins no board and takes no lifetime lock, so it runs inside + # `flatpak build`; it exits 2 by design). + env: + VERSION: ${{ steps.meta.outputs.version }} + run: | + set -eu + shown=$(flatpak build build-dir crafter-review --version) + printf '%s\n' "$shown" + printf '%s\n' "$shown" | grep -qF "crafter-review $VERSION (prompt v" + flatpak build build-dir sh -euc ' + for f in /app/bin/crafter-review /app/lib/node/bin/node; do + if ldd "$f" | grep "not found"; then echo "$f: a library is missing" >&2; exit 1; fi + bad=$(readelf -d "$f" | sed -n "s/.*(R[UN]*PATH).*\[\(.*\)\]/\1/p" | tr ":" "\n" | grep -v -e "^\$" -e "^/app" -e "^\\\$ORIGIN" || true) + if [ -n "$bad" ]; then echo "$f: a RUNPATH outside /app: $bad" >&2; exit 1; fi + done + rg --version | head -1 + node --version + npx --version + git --version' + flatpak build build-dir sh -euc ' + export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null + export GIT_AUTHOR_NAME=CI GIT_AUTHOR_EMAIL=ci@catcrafts.net GIT_COMMITTER_NAME=CI GIT_COMMITTER_EMAIL=ci@catcrafts.net + cd "$(mktemp -d)" + git init -q -b main . + git config user.email ci@catcrafts.net + printf ".review-gates/push/\n.review-gates/trees/\n" > .gitignore + mkdir .review-gates + printf "{\"lenses\": {\"repo-functional\": {\"run\": false}}}\n" > .review-gates/config.json + git add -A + git commit -q -m base + base=$(git rev-parse HEAD) + printf "def hello(name):\n return name\n" > hello.py + git add hello.py + git commit -q -m "add hello" + crafter-review freeze --slug ci --base "$base" + hash=$(jq -r .hash .review-gates/push/ci/freeze.json) + code=0 + crafter-review run --artifact .review-gates/push/ci --hash "$hash" --base "$base" --trees . \ + --dispositions .review-gates/push/ci/gate-state/dispositions.json --scratch "$PWD/scratch" --dry-run || code=$? + if [ "$code" != 2 ]; then echo "the dry run exited $code, not 2" >&2; exit 1; fi + ls scratch/*-dry/prompts | head -3' + + # Everything below publishes and only runs on master and on tags. + - name: Bundle + if: github.event_name != 'pull_request' + run: | + mkdir -p dist + flatpak build-bundle repo "dist/crafter-review-${{ steps.meta.outputs.version }}.flatpak" "$APP_ID" "${{ steps.meta.outputs.branch }}" + cp packaging/flatpak/net.catcrafts.CrafterReview.flatpakref dist/ + (cd dist && sha256sum * > SHA256SUMS) + ls -la dist + + - name: Upload bundle + if: github.event_name != 'pull_request' + uses: actions/upload-artifact@v3 + with: + name: crafter-review-${{ steps.meta.outputs.version }} + path: dist/*.flatpak + if-no-files-found: error + + - name: Publish to the Catcrafts repo + if: github.event_name != 'pull_request' + run: | + set -eu + if [ ! -s /run/crafter-flatpak/signing-key.asc ] || [ ! -s /run/crafter-flatpak/key-id ]; then + echo "no signing key under /etc/crafter-flatpak on the runner host (Crafter.Build's AGENTS.md, CI and publishing)" >&2 + exit 1 + fi + export GNUPGHOME=$(mktemp -d) + gpg --batch --quiet --import /run/crafter-flatpak/signing-key.asc + FLATPAK_GPG_KEY_ID=$(cat /run/crafter-flatpak/key-id) + # One publish at a time into the repo both apps share: a commit and + # the summary update of one workflow never interleave with the + # other's. + exec 9> "$WEB_DIR/.publish.lock" + flock 9 + [ -f "$REPO_DIR/config" ] || ostree init --repo="$REPO_DIR" --mode=archive + flatpak build-commit-from --src-repo=repo \ + --gpg-sign="$FLATPAK_GPG_KEY_ID" --gpg-homedir="$GNUPGHOME" \ + "$REPO_DIR" "app/$APP_ID/x86_64/${{ steps.meta.outputs.branch }}" + flatpak build-update-repo --gpg-sign="$FLATPAK_GPG_KEY_ID" --gpg-homedir="$GNUPGHOME" \ + --generate-static-deltas --prune "$REPO_DIR" + # This app's ref file only: catcrafts.flatpakrepo is Crafter.Build's. + key=$(gpg --export "$FLATPAK_GPG_KEY_ID" | base64 --wrap=0) + sed "s|GPGKey=.*|GPGKey=$key|" packaging/flatpak/net.catcrafts.CrafterReview.flatpakref > "$WEB_DIR/net.catcrafts.CrafterReview.flatpakref" + flock -u 9 + + - name: Rolling 'latest' release + if: github.event_name != 'pull_request' && github.ref_type != 'tag' + run: | + git config user.email "ci@catcrafts.net" + git config user.name "Crafter Review CI" + git tag -f latest + git push origin latest --force + - name: Publish rolling 'latest' release + if: github.event_name != 'pull_request' && github.ref_type != 'tag' + uses: https://code.forgejo.org/actions/forgejo-release@v2 + with: + direction: upload + url: ${{ github.server_url }} + repo: ${{ github.repository }} + tag: latest + title: Latest master build + prerelease: true + override: true + release-dir: dist + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Release notes + if: github.ref_type == 'tag' + run: | + # The newest block's description as Markdown, the way + # Crafter.Build's workflow writes its notes. + python3 - > release-notes.md <<'EOF' + import xml.etree.ElementTree as ET + + desc = ET.parse("packaging/flatpak/net.catcrafts.CrafterReview.metainfo.xml").getroot().find("releases/release/description") + + def text(el): + parts = [el.text or ""] + for child in el: + inner = text(child) + parts.append({"em": f"*{inner}*", "code": f"`{inner}`"}.get(child.tag, inner)) + parts.append(child.tail or "") + return " ".join("".join(parts).split()) + + blocks = list(desc) + out = [] + for i, el in enumerate(blocks): + heads_list = i + 1 < len(blocks) and blocks[i + 1].tag in ("ul", "ol") + if el.tag == "p": + out.append(f"## {text(el)}" if heads_list else text(el)) + elif el.tag == "ul": + out.append("\n".join(f"- {text(li)}" for li in el)) + elif el.tag == "ol": + out.append("\n".join(f"{n}. {text(li)}" for n, li in enumerate(el, 1))) + print("\n\n".join(out)) + print("\nInstall:\n\n flatpak remote-add --if-not-exists catcrafts https://flatpak.catcrafts.net/catcrafts.flatpakrepo\n flatpak install catcrafts net.catcrafts.CrafterReview\n") + EOF + - name: Publish release + if: github.ref_type == 'tag' + uses: https://code.forgejo.org/actions/forgejo-release@v2 + with: + direction: upload + url: ${{ github.server_url }} + repo: ${{ github.repository }} + tag: ${{ github.ref_name }} + title: crafter-review ${{ github.ref_name }} + release-notes-file: release-notes.md + release-dir: dist + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/project.cpp b/project.cpp index 2fdf5e5..7dc1890 100644 --- a/project.cpp +++ b/project.cpp @@ -84,7 +84,7 @@ namespace { // One per directory under tests/, each an ordinary main() that links the // library: exit 0 passes, nonzero fails, 77 skips. - constexpr std::array Tests = { + constexpr std::array Tests = { "Crypto", "Text", "Platform", @@ -110,6 +110,7 @@ namespace { "ReviewFixes", "Runs", "Commands", + "LensTools", }; // The version the binary reports: CI sets it from the tag, a checkout @@ -129,7 +130,9 @@ namespace { // Crafter.Build's own scripts do. --base= 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. + // `run install` makes) is what builds this project. --deps=local takes + // Crafter.Network and Crafter.Thread from the checkouts next to this one, + // as a native --local build does, for commits not on forgejo yet. struct FlatpakPaths { fs::path manifest; // flatpak-builder's downloads, ccache and per-module cache. @@ -142,14 +145,15 @@ namespace { // Everything under /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) { + // manifest's absolute path, so a branch other than stable, or local deps, + // get a manifest copy of their own next to the original (gitignored), and + // with it a cache of their own. + FlatpakPaths FlatpakLayout(const fs::path& projectDir, std::string_view base, bool localDeps = false) { const fs::path root = projectDir / "build" / "flatpak"; const fs::path packaging = projectDir / "packaging" / "flatpak"; + const bool original = base == "stable" && !localDeps; return { - .manifest = base == "stable" ? packaging / "net.catcrafts.CrafterReview.yaml" : packaging / std::format("{}-base.yaml", base), + .manifest = original ? packaging / "net.catcrafts.CrafterReview.yaml" : packaging / std::format("{}{}-base.yaml", base, localDeps ? "-localdeps" : ""), .state = root / "state", .repo = root / "repo", .buildDir = root / "build-dir", @@ -187,18 +191,52 @@ namespace { return url; } + // `old` replaced by `replacement` once in `text`; false when it is not + // there. + bool ReplaceOnce(std::string& text, std::string_view old, std::string_view replacement) { + const std::size_t at = text.find(old); + if (at == std::string::npos) return false; + text.replace(at, old.size(), replacement); + return true; + } + + // The crafter-review module built from three dir sources side by side, + // so --local finds ../Crafter.Network and ../Crafter.Thread. + bool WithLocalDeps(std::string& manifest) { + constexpr std::string_view Source = R"( - type: dir + path: ../.. + skip: + - .git + - bin + - build +)"; + std::string sources; + for (const std::string_view project : {"Crafter.Review", "Crafter.Network", "Crafter.Thread"}) { + const std::string path = project == "Crafter.Review" ? std::string("../..") : std::format("../../../{}", project); + sources += std::format(" - type: dir\n path: {}\n dest: {}\n skip:\n - .git\n - bin\n - build\n", path, project); + } + return ReplaceOnce(manifest, " - name: crafter-review\n", " - name: crafter-review\n subdir: Crafter.Review\n") + && ReplaceOnce(manifest, " - crafter-build\n - crafter-build test\n", " - crafter-build --local\n - crafter-build --local test\n") + && ReplaceOnce(manifest, Source, sources); + } + std::int32_t BuildFlatpak(ScriptContext& ctx) { if (RefusedInSandbox("flatpak")) return 1; std::string jobs; std::string base = "stable"; + bool localDeps = false; for (const std::string& arg : ctx.args) { if (arg.starts_with("--base=") && arg.size() > 7) { base = arg.substr(7); continue; } + if (arg == "--deps=local") { + localDeps = true; + 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= and --base= are accepted)", arg); + std::println(std::cerr, "run flatpak: unknown argument '{}' (only --jobs=, --base= and --deps=local are accepted)", arg); return 1; } jobs = std::format(" --jobs={}", count); @@ -216,18 +254,20 @@ namespace { 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. + const FlatpakPaths paths = FlatpakLayout(ctx.projectDir, base, localDeps); + if (base != "stable" || localDeps) { + // The manifest with base-version changed, and the local deps. std::ifstream in(FlatpakLayout(ctx.projectDir, "stable").manifest); - const std::string manifest((std::istreambuf_iterator(in)), std::istreambuf_iterator()); - 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)); + std::string manifest((std::istreambuf_iterator(in)), std::istreambuf_iterator()); + if (!ReplaceOnce(manifest, "base-version: stable\n", std::format("base-version: {}\n", base))) { + std::println(std::cerr, "run flatpak: the manifest has no `base-version: stable` line to replace"); return 1; } - std::ofstream(paths.manifest) << manifest.substr(0, at) << std::format("base-version: {}\n", base) << manifest.substr(at + Line.size()); + if (localDeps && !WithLocalDeps(manifest)) { + std::println(std::cerr, "run flatpak: the crafter-review module is not in the shape --deps=local rewrites (its dir source and its two crafter-build lines)"); + return 1; + } + std::ofstream(paths.manifest) << manifest; } 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; diff --git a/tests/LensTools/main.cpp b/tests/LensTools/main.cpp new file mode 100644 index 0000000..4e40632 --- /dev/null +++ b/tests/LensTools/main.cpp @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// The programs a lens is allowed to run are there: every Bash( +// ...:*) entry of the base allowlist (the git verbs included), the shell +// claude's Bash tool runs them with, and node and npx for the Playwright MCP +// server. The suite runs inside the Flatpak build, the sandbox that ships, so +// there every one must resolve on PATH; natively the test names what this +// machine lacks and skips. + +#include +import std; +import Crafter.Review; +namespace fs = std::filesystem; +using namespace Crafter::Review; + +int main() { + std::println("lens tools: every program a lens's allowlist names is on PATH"); + std::set programs = {"bash", "node", "npx"}; + for (const std::string& tool : Tools::BaseAllow()) { + if (!tool.starts_with("Bash(") || !tool.ends_with(":*)")) continue; + const std::string prefix = tool.substr(5, tool.size() - 8); + const std::string program = prefix.substr(0, prefix.find(' ')); + // A shell builtin, never a file of its own. + if (program != "cd") programs.insert(program); + } + const std::vector path = Dyn::Split(Platform::GetEnv("PATH").value_or(""), ":"); + std::vector missing; + for (const std::string& program : programs) { + const bool found = std::ranges::any_of(path, [&](const std::string& dir) { return !dir.empty() && access((fs::path(dir) / program).c_str(), X_OK) == 0; }); + if (!found) missing.push_back(program); + } + const bool shipped = fs::exists("/.flatpak-info"); + if (!missing.empty() && !shipped) { + std::println("skipped: not on this machine's PATH: {}", Dyn::Join(missing, ", ")); + return 77; + } + const bool ok = missing.empty() && programs.size() >= 20; + std::println("{}every program a lens's allowlist names, its shell and the MCP server's node and npx resolve on PATH ({} programs){}", ok ? " ok " : " FAIL ", programs.size(), + missing.empty() ? std::string() : std::format(" (missing: {})", Dyn::Join(missing, ", "))); + return ok ? 0 : 1; +}