From 92af29391371033324b319f2b8059863f5bb615b Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 29 Jul 2026 22:44:56 +0100 Subject: [PATCH] complete signed owned-CI reporting path --- bag/lib/bag/ci_baton.ex | 10 ++- bag/lib/bag/ci_sweep.ex | 45 +++++++++++-- bag/lib/bag/executor.ex | 14 ++++- bag/lib/bag/github_bridge.ex | 20 +++--- bag/lib/bag/mesh.ex | 94 +++++++++++++++++----------- bag/test/mesh_check_test.exs | 70 ++++++++++++++++++++- docs/ci-check-baton.adoc | 15 ++++- docs/ci-report-to-github.adoc | 20 +++--- nodes.scm | 2 +- src/estate.zig | 6 +- src/main.zig | 10 +++ verification/proofs/Bag/Estate.idr | 2 +- verification/proofs/Bag/Protocol.idr | 6 ++ 13 files changed, 246 insertions(+), 68 deletions(-) diff --git a/bag/lib/bag/ci_baton.ex b/bag/lib/bag/ci_baton.ex index f400824..f802bd5 100644 --- a/bag/lib/bag/ci_baton.ex +++ b/bag/lib/bag/ci_baton.ex @@ -19,6 +19,8 @@ defmodule Bag.CiBaton do :command, :verdict, :exit_code, + :freeze_path, + :workdir, artifact_path: nil, mutating: false, risk: :low @@ -34,6 +36,8 @@ defmodule Bag.CiBaton do command: [String.t()], verdict: verdict(), exit_code: integer() | nil, + freeze_path: String.t() | nil, + workdir: String.t() | nil, artifact_path: String.t() | nil } @@ -42,7 +46,9 @@ defmodule Bag.CiBaton do `check_id` names the check (e.g. "zig-fmt"); `command` is the argv list to run (e.g. `["zig", "fmt", "--check", "build.zig"]`). Options: `:node` (default - "mesh-server-1") and `:required_cap` (default "linux"). + "mesh-server-1"), `:required_cap` (default "linux"), `:freeze_path` (the + attested result envelope), and `:workdir` (an absolute target repository + directory; defaults to the Bag repository root). """ def new(check_id, command, opts \\ []) when is_binary(check_id) and is_list(command) do %__MODULE__{ @@ -53,6 +59,8 @@ defmodule Bag.CiBaton do command: command, verdict: :pending, exit_code: nil, + freeze_path: Keyword.get(opts, :freeze_path), + workdir: Keyword.get(opts, :workdir), artifact_path: Keyword.get(opts, :artifact_path), mutating: Keyword.get(opts, :mutating, false), risk: Keyword.get(opts, :risk, :low) diff --git a/bag/lib/bag/ci_sweep.ex b/bag/lib/bag/ci_sweep.ex index c9d9110..6032817 100644 --- a/bag/lib/bag/ci_sweep.ex +++ b/bag/lib/bag/ci_sweep.ex @@ -11,18 +11,23 @@ defmodule Bag.CiSweep do `required_cap` defaults to "linux". Returns `{results, summary}` where `results` is a list of per-check verdicts and `summary` counts verdicts. """ - alias Bag.Mesh + alias Bag.{CiBaton, Executor, GitHubBridge, Mesh} def run(checks) when is_list(checks) do results = Enum.map(checks, fn check -> cap = Map.get(check, :required_cap, "linux") artifact_path = Map.get(check, :artifact_path) - {verdict, node, _baton} = Mesh.submit_check(check.check_id, check.command, - required_cap: cap, - artifact_path: artifact_path - ) - %{check_id: check.check_id, verdict: verdict, node: node} + workdir = Map.get(check, :workdir) + + {verdict, node, baton} = + Mesh.submit_check(check.check_id, check.command, + required_cap: cap, + artifact_path: artifact_path, + workdir: workdir + ) + + verified_result(check.check_id, verdict, node, baton) end) summary = Enum.frequencies_by(results, & &1.verdict) @@ -34,4 +39,32 @@ defmodule Bag.CiSweep do decide a green/red verdict for the whole sweep, with zero paid minutes. """ def all_passed?({results, _summary}), do: Enum.all?(results, &(&1.verdict == :pass)) + + # The signed envelope, not the in-memory execution return, authorises a green + # GitHub status. Thaw every executed Baton here so downstream reporters receive + # the verified production attestation produced by the Zig host. + defp verified_result(check_id, verdict, node, %CiBaton{freeze_path: freeze_path}) + when is_binary(freeze_path) do + {thawed_verdict, thaw_output} = Executor.thaw_check(freeze_path) + + effective_verdict = + if thawed_verdict == verdict do + verdict + else + :tampered + end + + %{ + check_id: check_id, + verdict: effective_verdict, + execution_verdict: verdict, + node: node, + freeze_path: freeze_path, + attestation: GitHubBridge.verdict_attestation(thaw_output) + } + end + + defp verified_result(check_id, verdict, node, _baton) do + %{check_id: check_id, verdict: verdict, node: node} + end end diff --git a/bag/lib/bag/executor.ex b/bag/lib/bag/executor.ex index 0d6a5a5..c09826c 100644 --- a/bag/lib/bag/executor.ex +++ b/bag/lib/bag/executor.ex @@ -61,17 +61,23 @@ defmodule Bag.Executor do """ def run_check(%Bag.CiBaton{} = b, freeze_path) do args = ["check", b.node, b.required_cap, b.check_id, freeze_path | b.command] + workdir = b.workdir || repo_root() # Pass artifact path via env var so the Zig host can hash the report after # the check runs and include its digest in the frozen Baton envelope. - base_opts = [cd: Path.expand("../../../", __DIR__), stderr_to_stdout: true] + base_opts = [cd: workdir, stderr_to_stdout: true] opts = if b.artifact_path, do: Keyword.put(base_opts, :env, [{"BAG_ARTIFACT_PATH", b.artifact_path}]), else: base_opts - {output, code} = System.cmd(executor_path(), args, opts) + {output, code} = + if Path.type(workdir) == :absolute and File.dir?(workdir) do + System.cmd(executor_path(), args, opts) + else + {"Bag.Executor: workdir must be an existing absolute directory: #{inspect(workdir)}", 126} + end verdict = case code do @@ -81,7 +87,7 @@ defmodule Bag.Executor do _ -> :error end - {verdict, %{b | verdict: verdict, exit_code: code}, output} + {verdict, %{b | verdict: verdict, exit_code: code, freeze_path: freeze_path}, output} end @doc """ @@ -153,4 +159,6 @@ defmodule Bag.Executor do status: :floating } end + + defp repo_root, do: Path.expand("../../../", __DIR__) end diff --git a/bag/lib/bag/github_bridge.ex b/bag/lib/bag/github_bridge.ex index db7e251..2c0ad70 100644 --- a/bag/lib/bag/github_bridge.ex +++ b/bag/lib/bag/github_bridge.ex @@ -30,10 +30,9 @@ defmodule Bag.GitHubBridge do HMAC-only verdict, can never green a required gate. Non-green verdicts (`:fail`/`:error`/`:suspended`) need no attestation; they post as-is. - > Wiring note: until the mesh threads a per-verdict freeze path through to here - > (Phase 3, thaw-before-report), callers that cannot supply verified attestation - > will have their greens refused — the safe posture, since no live required check - > points at a bag status yet. + `Bag.CiSweep` thaws every executed envelope before reporting and threads the + parsed attestation into each result. Callers that construct result maps + themselves still fail closed when that proof is absent. Pure argv construction (`gh_command/5`) is separated from the side-effecting `report_check/5`, and the command runner is injectable (`:runner` opt), so the @@ -56,7 +55,8 @@ defmodule Bag.GitHubBridge do Options: `:description`, `:target_url` (link to the attested verdict envelope), `:node` (folded into the default description). """ - @spec gh_command(String.t(), String.t(), String.t(), atom(), keyword()) :: {String.t(), [String.t()]} + @spec gh_command(String.t(), String.t(), String.t(), atom(), keyword()) :: + {String.t(), [String.t()]} def gh_command(repo, head_sha, context, verdict, opts \\ []) do state = state_for(verdict) @@ -101,7 +101,11 @@ defmodule Bag.GitHubBridge do _ -> nil end - ed = if String.contains?(thaw_output, "ed25519:verified"), do: :verified, else: :none + ed = + if Regex.match?(~r/(?:^|\s)ed25519:verified(?:\s|$)/m, thaw_output), + do: :verified, + else: :none + %{mode: mode, ed25519: ed} end @@ -141,7 +145,9 @@ defmodule Bag.GitHubBridge do Returns `[{check_id, {:ok, url} | {:error, reason}}]`. """ - @spec report_sweep({[map()], map()}, keyword()) :: [{String.t(), {:ok, String.t()} | {:error, term()}}] + @spec report_sweep({[map()], map()}, keyword()) :: [ + {String.t(), {:ok, String.t()} | {:error, term()}} + ] def report_sweep({results, _summary}, context_opts) do repo = Keyword.fetch!(context_opts, :repo) head_sha = Keyword.fetch!(context_opts, :head_sha) diff --git a/bag/lib/bag/mesh.ex b/bag/lib/bag/mesh.ex index baaabcf..eece3d3 100644 --- a/bag/lib/bag/mesh.ex +++ b/bag/lib/bag/mesh.ex @@ -19,7 +19,8 @@ defmodule Bag.Mesh do manifest, capability-matching via the Idris-to-Zig bridge), runs the check there, and freezes an attested verdict — with ZERO GitHub Actions minutes. - Options: `:required_cap` (default "linux"), `:freeze_path`. + Options: `:required_cap` (default "linux"), `:freeze_path`, `:workdir` + (an existing absolute directory), and `:artifact_path`. Returns `{verdict, node, baton}` where verdict is `:pass | :fail | :suspended | :tampered | :error` (`node` is nil if suspended). """ @@ -51,34 +52,38 @@ defmodule Bag.Mesh do defp route_baton(baton) do # 1. Get all members in the mesh members = :pg.get_members(:bag_mesh) - + # 2. Filter members based on their node name and the Baton's requirements # For this POC, we assume the node name is the Elixir sname (e.g. mesh-laptop) # In a production system, this would be an attested identity. - valid_targets = Enum.filter(members, fn pid -> - node_name = node_to_name(node(pid)) - # Translate requiredCap to string list for the Zig bridge - req_strings = Enum.map(baton.required_cap || [], fn - :guix -> "guix" - :linux -> "linux" - :macos -> "macos" - :gpu -> "gpu" - :trusted_host -> "trusted_host" - :zig -> "zig" - :rust -> "rust" - :cargo -> "cargo" - :deno -> "deno" - :scorecard -> "scorecard" - :wasm -> "wasm" - _ -> "unknown" + valid_targets = + Enum.filter(members, fn pid -> + node_name = node_to_name(node(pid)) + # Translate requiredCap to string list for the Zig bridge + req_strings = + Enum.map(baton.required_cap || [], fn + :guix -> "guix" + :linux -> "linux" + :macos -> "macos" + :gpu -> "gpu" + :trusted_host -> "trusted_host" + :zig -> "zig" + :rust -> "rust" + :cargo -> "cargo" + :deno -> "deno" + :scorecard -> "scorecard" + :wasm -> "wasm" + :idris2 -> "idris2" + :just -> "just" + _ -> "unknown" + end) + + Executor.node_satisfies?(node_name, req_strings) end) - - Executor.node_satisfies?(node_name, req_strings) - end) IO.puts("Mesh: Routing Baton #{baton.id} [Requirements: #{inspect(baton.required_cap)}]") IO.puts("Mesh: Valid targets found: #{inspect(valid_targets)}") - + if valid_targets != [] do target = Enum.random(valid_targets) IO.puts("Mesh: Routing to verified node: #{inspect(target)}") @@ -105,30 +110,45 @@ defmodule Bag.Mesh do def handle_call({:submit_check, check_id, command, opts}, _from, state) do required_cap = Keyword.get(opts, :required_cap, "linux") artifact_path = Keyword.get(opts, :artifact_path) + workdir = Keyword.get(opts, :workdir) freeze_path = Keyword.get(opts, :freeze_path, Path.join(System.tmp_dir!(), "#{check_id}.baton")) # Select a capable node from the mirrored estate manifest. capable = - Executor.list_nodes() - |> Enum.filter(fn node -> Executor.node_satisfies?(node, [required_cap]) end) + Executor.node_costs() + |> Enum.filter(fn {node, _cost} -> Executor.node_satisfies?(node, [required_cap]) end) + |> Enum.sort_by(fn {node, cost} -> {cost, node} end) case capable do [] -> # Operational logs go to stderr — stdout is reserved for machine output. - IO.puts(:stderr, "Mesh: NO node satisfies '#{required_cap}' for check #{check_id}. Suspended.") + IO.puts( + :stderr, + "Mesh: NO node satisfies '#{required_cap}' for check #{check_id}. Suspended." + ) + {:reply, {:suspended, nil, nil}, state} - [node | _] -> - baton = CiBaton.new(check_id, command, - node: node, - required_cap: required_cap, - artifact_path: artifact_path - ) + [{node, _cost} | _] -> + baton = + CiBaton.new(check_id, command, + node: node, + required_cap: required_cap, + freeze_path: freeze_path, + workdir: workdir, + artifact_path: artifact_path + ) + IO.puts(:stderr, "Mesh: routing check #{check_id} → #{node} (cap: #{required_cap})") {verdict, updated, _output} = Executor.run_check(baton, freeze_path) - IO.puts(:stderr, "Mesh: check #{check_id} verdict=#{verdict} on #{node} (0 GitHub minutes)") + + IO.puts( + :stderr, + "Mesh: check #{check_id} verdict=#{verdict} on #{node} (0 GitHub minutes)" + ) + {:reply, {verdict, node, updated}, state} end end @@ -138,6 +158,7 @@ defmodule Bag.Mesh do case Planner.plan(spec, budget) do {:ok, node, cost} -> IO.puts(:stderr, "Mesh: planned #{spec.check_id} → #{node} (money cost #{cost})") + freeze_path = Path.join(System.tmp_dir!(), "#{spec.check_id}.baton") baton = CiBaton.new(spec.check_id, spec.command, @@ -145,10 +166,11 @@ defmodule Bag.Mesh do required_cap: spec.required_cap, mutating: Map.get(spec, :mutating, false), risk: Map.get(spec, :risk, :low), + freeze_path: freeze_path, + workdir: Map.get(spec, :workdir), artifact_path: Map.get(spec, :artifact_path) ) - freeze_path = Path.join(System.tmp_dir!(), "#{spec.check_id}.baton") {verdict, updated, _output} = Executor.run_check(baton, freeze_path) # Relegated = ran on an owned node, not GitHub's required-check route. relegated = node != "mesh-github-runner" @@ -168,17 +190,17 @@ defmodule Bag.Mesh do @impl true def handle_cast({:process, baton}, state) do IO.puts("Mesh [#{Node.self()}]: Claimed Baton #{baton.id} with count #{baton.counter}") - + case Executor.execute(baton) do {:ok, updated_baton, output} -> IO.puts("Mesh [#{Node.self()}]: Execution complete. Output: #{String.trim(output)}") - + # Decide whether to continue or complete if updated_baton.counter < 10 do IO.puts("Mesh [#{Node.self()}]: Baton not finished. Passing to next step...") # Simulate a delay Process.sleep(1000) - + # Handoff: pick a random node in the cluster route_baton(%{updated_baton | id: baton.id}) else diff --git a/bag/test/mesh_check_test.exs b/bag/test/mesh_check_test.exs index 562b54f..16adb28 100644 --- a/bag/test/mesh_check_test.exs +++ b/bag/test/mesh_check_test.exs @@ -30,6 +30,7 @@ defmodule Bag.MeshCheckTest do assert node in ["mesh-laptop", "mesh-server-1"] assert baton.verdict == :pass + assert File.exists?(baton.freeze_path) end test "Mesh reports a real fail verdict through the orchestrator" do @@ -47,7 +48,12 @@ defmodule Bag.MeshCheckTest do end test "submit_planned routes by budget, runs the check, and returns residue" do - spec = %{check_id: "planned-fmt", command: ["zig", "fmt", "--check", "build.zig"], required_cap: "zig"} + spec = %{ + check_id: "planned-fmt", + command: ["zig", "fmt", "--check", "build.zig"], + required_cap: "zig" + } + result = Mesh.submit_planned(spec, Budget.unlimited()) assert result.verdict == :pass @@ -58,8 +64,16 @@ defmodule Bag.MeshCheckTest do test "CiSweep runs a batch of checks and summarises verdicts" do checks = [ - %{check_id: "sweep-ok", command: ["zig", "fmt", "--check", "build.zig"], required_cap: "zig"}, - %{check_id: "sweep-bad", command: ["zig", "fmt", "--check", dirty_fixture()], required_cap: "zig"} + %{ + check_id: "sweep-ok", + command: ["zig", "fmt", "--check", "build.zig"], + required_cap: "zig" + }, + %{ + check_id: "sweep-bad", + command: ["zig", "fmt", "--check", dirty_fixture()], + required_cap: "zig" + } ] {results, summary} = CiSweep.run(checks) @@ -67,9 +81,59 @@ defmodule Bag.MeshCheckTest do assert length(results) == 2 assert summary[:pass] == 1 assert summary[:fail] == 1 + + assert Enum.find(results, &(&1.check_id == "sweep-ok")).attestation == %{ + mode: "prod", + ed25519: :verified + } + refute CiSweep.all_passed?({results, summary}) end + test "CiSweep executes a target repository check in its declared absolute workdir" do + workdir = Path.join(System.tmp_dir!(), "bag-workdir-#{System.unique_integer([:positive])}") + File.mkdir_p!(workdir) + File.write!(Path.join(workdir, "marker"), "owned compute\n") + on_exit(fn -> File.rm_rf!(workdir) end) + + {results, summary} = + CiSweep.run([ + %{ + check_id: "target-workdir-#{System.unique_integer([:positive])}", + command: ["test", "-f", "marker"], + required_cap: "linux", + workdir: workdir + } + ]) + + assert [%{verdict: :pass, execution_verdict: :pass, freeze_path: freeze_path}] = results + refute hd(results).node == "mesh-github-runner" + assert File.exists?(freeze_path) + assert summary == %{pass: 1} + end + + test "a real signed CiSweep pass can report green through the GitHub bridge" do + {results, summary} = + CiSweep.run([ + %{ + check_id: "signed-report-#{System.unique_integer([:positive])}", + command: ["true"], + required_cap: "linux" + } + ]) + + runner = fn _cmd, _args -> {"https://api.github.test/status/1", 0} end + + assert [{_check_id, {:ok, "https://api.github.test/status/1"}}] = + Bag.GitHubBridge.report_sweep({results, summary}, + repo: "hyperpolymath/bag-of-actions", + head_sha: "deadbeef", + runner: runner + ) + + refute hd(results).node == "mesh-github-runner" + end + # A deliberately mis-formatted Zig file written at RUNTIME. A tracked source # file (src/estate.zig) was previously used as the negative fixture and got # formatted clean, so every "should fail" assertion silently passed. Generated diff --git a/docs/ci-check-baton.adoc b/docs/ci-check-baton.adoc index 1e5a8e1..9e94c90 100644 --- a/docs/ci-check-baton.adoc +++ b/docs/ci-check-baton.adoc @@ -21,7 +21,7 @@ can carry mobile *CI work and its (tamper-evident) verdict*. |=== | `check_id` | names the check, e.g. `zig-fmt` | `command` | the argv to run; `wasm://path` prefix triggers WASI execution via `wasmtime` -| `required_cap` | the capability a node must have to run it (`linux`, `guix`, `zig`, `rust`, `cargo`, `deno`, `scorecard`, `wasm`, …) +| `required_cap` | the capability a node must have to run it (`linux`, `guix`, `zig`, `rust`, `cargo`, `deno`, `scorecard`, `wasm`, `idris2`, `just`, …) | `node` | the node the work is routed to | `verdict` | `pending` (frozen, not yet run) / `pass` / `fail` | `exit_code` | the executed check's exit status @@ -120,11 +120,22 @@ cd bag && mix test # 10 e2e tests {:pass, node, _baton} = Bag.Mesh.submit_check("zig-fmt", ["zig","fmt","--check","build.zig"], required_cap: "zig") # A whole sweep (the entry point hypatia/ci-health will call): -checks = [%{check_id: "fmt", command: ["zig","fmt","--check","build.zig"], required_cap: "zig"}] +checks = [%{check_id: "fmt", command: ["zig","fmt","--check","build.zig"], required_cap: "zig", workdir: "/srv/checkouts/project"}] {results, summary} = Bag.CiSweep.run(checks) Bag.CiSweep.all_passed?({results, summary}) # green/red for the sweep, 0 paid minutes ---- +`workdir` is optional and must be an existing absolute directory. It lets a +Bag installation execute the target repository's own command surface without +requiring Bag and the target checkout to share a repository root. After every +executed check, `CiSweep` thaws the frozen envelope and attaches its verified +attestation to the result. A reported green therefore comes from the signed +envelope, not merely from the in-memory process exit code. + +Ordinary sweeps select the lowest-money-cost capable node deterministically. +An equally capable paid GitHub runner therefore cannot be selected merely +because its name happened to appear first in an unordered node map. + Exit codes from the `check`/`thaw` subcommands double as a CI gate: `0` = pass, `1` = fail, `2` = suspended (no capable node), `3` = tampered (attestation failed). diff --git a/docs/ci-report-to-github.adoc b/docs/ci-report-to-github.adoc index 10d6dd4..2d60b48 100644 --- a/docs/ci-report-to-github.adoc +++ b/docs/ci-report-to-github.adoc @@ -15,9 +15,9 @@ that verdict back onto a GitHub PR so a **required status check** is satisfied [cols="1,3"] |=== | `Bag.GitHubBridge` | Posts a verdict as a GitHub **commit status** (`POST /repos/{repo}/statuses/{sha}`) via the `gh` CLI. *Status, not check-run:* the Checks API needs a GitHub **App** token; a PAT (what `gh` carries) can write a status, and a status equally satisfies a branch-protection required context (matched by the `context` string). Dependency-free. Discharges `Bag.ActionResult`'s `{:owes, :github_required_check}`. -| `mix bag.report` | The dispatcher: runs a `Bag.CiSweep` from a manifest on a capability-matched owned node, then `GitHubBridge.report_sweep`s each verdict to the commit named by `GITHUB_REPOSITORY` + `GITHUB_SHA`. Exit 0 iff all passed *and* all posted. +| `mix bag.report` | The dispatcher: runs a `Bag.CiSweep` from a manifest on a capability-matched owned node, thaws and verifies each signed envelope, then `GitHubBridge.report_sweep`s each verdict to the commit named by `GITHUB_REPOSITORY` + `GITHUB_SHA`. Exit 0 iff all passed *and* all posted. | `guix` capability | Present across all three layers (`Protocol.idr` / `estate.zig` / `mesh.ex`); `mesh-server-1` carries it, so a `guix shell …` check routes there. (Guix is the estate-sanctioned package manager; a duplicate `nix` capability added 2026-06-14 was removed 2026-06-24 under the Nix ban.) -| manifest | The target repo ships `ci-checks.exs`; each check carries `required_cap` and an optional `github_context` (the required-check name its verdict posts to). Worked example: `hyperpolymath/snifs` `ci-checks.exs` (the proof gate + ABI conformance). +| manifest | The target repo ships `ci-checks.exs`; each check carries `required_cap`, an optional absolute `workdir`, and an optional `github_context` (the required-check name its verdict posts to). Worked example: `hyperpolymath/snifs` `ci-checks.exs` (the proof gate + ABI conformance). |=== == Run it (on the node) @@ -78,14 +78,20 @@ Point the required contexts at the bag-posted statuses: == Status / gaps -Built + tested (in `bag`): `Bag.GitHubBridge` (6 tests, fail-closed — only `:pass` -maps to a green status), `mix bag.report` (`context_map_from` tested), the `guix` -capability (idris2 ABI typecheck + zig build green, `match mesh-server-1 guix` = 0), -the snifs manifest, and the full Elixir suite (32 tests, 0 failures). The verdict -chain is trustworthy end-to-end: a real failing check freezes `verdict=fail` +Built + tested (in `bag`): the fail-closed `Bag.GitHubBridge`, the +`mix bag.report` context mapping, signed sweep-to-green reporting, explicit +target-repository working directories, and cost-aware owned-node routing. +The verdict chain is trustworthy end-to-end: each sweep result retains its envelope path, +thaws the signed envelope before reporting, and carries the parsed production +attestation to the fail-closed GitHub bridge. A real failing check freezes `verdict=fail` (`exit_code=1`) and posts a non-green status; tamper of a frozen verdict is rejected by HMAC + ed25519. +The complete dogfood suite additionally requires a source-compatible Zig +toolchain and `wasmtime` for its WASI check. These runtime dependencies must be +pinned and provisioned on owned nodes before the dogfood manifest is a +reproducible release gate. + *Trigger — Option A is now committed* at `.github/workflows/owned-ci.yml`: a thin GHA dispatcher (seconds of minutes) that SSHes to the owned node and runs the dogfood manifest there. It is a **clean no-op until** the owner sets repo variable diff --git a/nodes.scm b/nodes.scm index f80b5bc..a1f97ea 100644 --- a/nodes.scm +++ b/nodes.scm @@ -17,7 +17,7 @@ (capabilities . (macos guix trusted-host)) (owner . "Jonathan")) '((name . "mesh-server-1") - (capabilities . (linux gpu guix trusted-host)) + (capabilities . (linux gpu guix trusted-host zig rust cargo deno scorecard wasm idris2 just)) (owner . "Core-Infrastructure")) '((name . "mesh-github-runner") (capabilities . (linux secret-access)) diff --git a/src/estate.zig b/src/estate.zig index 030244f..19f13e9 100644 --- a/src/estate.zig +++ b/src/estate.zig @@ -17,6 +17,8 @@ pub const Capability = enum(u32) { deno = 10, scorecard = 11, wasm = 12, + idris2 = 13, + just = 14, pub fn fromString(s: []const u8) ?Capability { if (std.mem.eql(u8, s, "linux")) return .linux; @@ -31,6 +33,8 @@ pub const Capability = enum(u32) { if (std.mem.eql(u8, s, "deno")) return .deno; if (std.mem.eql(u8, s, "scorecard")) return .scorecard; if (std.mem.eql(u8, s, "wasm")) return .wasm; + if (std.mem.eql(u8, s, "idris2")) return .idris2; + if (std.mem.eql(u8, s, "just")) return .just; return null; } }; @@ -52,7 +56,7 @@ pub const estate = [_]Node{ }, .{ .name = "mesh-server-1", - .capabilities = &[_]Capability{ .linux, .gpu, .guix, .trusted_host, .zig, .rust, .cargo, .deno, .scorecard, .wasm }, + .capabilities = &[_]Capability{ .linux, .gpu, .guix, .trusted_host, .zig, .rust, .cargo, .deno, .scorecard, .wasm, .idris2, .just }, .cost = 1, }, .{ diff --git a/src/main.zig b/src/main.zig index 1831ba9..1bf46c2 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1182,6 +1182,16 @@ test "Capability.fromString recognises wasm (tag 12)" { try std.testing.expect(@intFromEnum(cap.?) == 12); } +test "Capability.fromString recognises Idris2 and Just toolchains" { + const idris2 = estate.Capability.fromString("idris2"); + const just = estate.Capability.fromString("just"); + try std.testing.expect(idris2 != null); + try std.testing.expect(just != null); + try std.testing.expect(idris2.? == .idris2); + try std.testing.expect(just.? == .just); + try std.testing.expect(estate.nodeSatisfies("mesh-server-1", &[_]estate.Capability{ .idris2, .just })); +} + test "wasm_digest extends canonical string and changes HMAC" { const a = std.testing.allocator; const key = "test-key"; diff --git a/verification/proofs/Bag/Estate.idr b/verification/proofs/Bag/Estate.idr index 4f63670..ac3d7aa 100644 --- a/verification/proofs/Bag/Estate.idr +++ b/verification/proofs/Bag/Estate.idr @@ -26,7 +26,7 @@ public export estate : List Node estate = [ MkNode "mesh-laptop" [MacOS, Guix, TrustedHost "Jonathan", Zig] 2 - , MkNode "mesh-server-1" [Linux, GPU, Guix, TrustedHost "Core-Infrastructure", Zig, Rust, Cargo, Deno, Scorecard, Wasm] 1 + , MkNode "mesh-server-1" [Linux, GPU, Guix, TrustedHost "Core-Infrastructure", Zig, Rust, Cargo, Deno, Scorecard, Wasm, Idris2, Just] 1 , MkNode "mesh-github-runner" [Linux, SecretAccess "GitHub-Deploy-Token"] 100 -- The hypatia "brain" (Elixir merge-orchestration host): emits/reads only, and -- deliberately has NO `SecretAccess` capability. A merge Baton requires diff --git a/verification/proofs/Bag/Protocol.idr b/verification/proofs/Bag/Protocol.idr index fa94777..5c095e8 100644 --- a/verification/proofs/Bag/Protocol.idr +++ b/verification/proofs/Bag/Protocol.idr @@ -24,6 +24,8 @@ data Capability | Deno | Scorecard | Wasm + | Idris2 + | Just public export Eq Capability where @@ -39,6 +41,8 @@ Eq Capability where Deno == Deno = True Scorecard == Scorecard = True Wasm == Wasm = True + Idris2 == Idris2 = True + Just == Just = True _ == _ = False ||| Check if a node's provided capabilities satisfy the Baton's requirements. @@ -65,3 +69,5 @@ capToTag Cargo = 9 capToTag Deno = 10 capToTag Scorecard = 11 capToTag Wasm = 12 +capToTag Idris2 = 13 +capToTag Just = 14