Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 133 additions & 0 deletions bag/lib/bag/github_bridge.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
# SPDX-License-Identifier: MPL-2.0
# Copyright (c) Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
defmodule Bag.GitHubBridge do
@moduledoc """
Discharges the `{:owes, :github_required_check}` residue (see `Bag.ActionResult`):
publishes a CI-check Baton verdict — produced on owned compute, **zero GitHub
Actions minutes** — back to a GitHub commit, so a *required* status check is
satisfied without a GitHub-hosted runner. This is the one bridge `docs/ci-check-baton.adoc`
names as "a separate cloud-native bridge ... not replaced by this Batonisation".

## Design notes

* **Commit Status, not Check-Run.** The Checks API can only be *written* by a
GitHub **App** token; a personal/OAuth token (what the `gh` CLI carries)
cannot create check-runs. The **Commit Status** API
(`POST /repos/{repo}/statuses/{sha}`) *is* writable by a normal token and
equally satisfies a branch-protection required context (matched by the
`context` string). So this bridge posts a status.
* **Dependency-free.** It shells out to the `gh` CLI via `System.cmd/3`,
reusing its existing auth — no HTTP client is added to `:bag`.
* **Conservative mapping.** Only `:pass` maps to `success`; everything else
(`:fail` → `failure`, `:error` → `error`, `:suspended` → `failure`) is
non-green, so a check that did not truly pass can never silently satisfy a
*required* gate (a suspended check = no capable owned node = needs attention).

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
whole flow is unit-testable without touching GitHub.
"""

# GitHub commit-status `description` is capped at 140 characters.
@max_description 140

@doc "Verdict atom → GitHub commit-status `state` (only `:pass` is green)."
@spec state_for(atom()) :: String.t()
def state_for(:pass), do: "success"
def state_for(:error), do: "error"
def state_for(_other), do: "failure"

@doc """
Build the `gh api` argv that posts a commit status. Pure and testable.

`context` is the string a branch-protection *required status check* rule matches.
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()]}
def gh_command(repo, head_sha, context, verdict, opts \\ []) do
state = state_for(verdict)

description =
opts
|> Keyword.get(:description, default_description(verdict, opts))
|> truncate()

base = [
"api",
"--method",
"POST",
"repos/#{repo}/statuses/#{head_sha}",
"-f",
"state=#{state}",
"-f",
"context=#{context}",
"-f",
"description=#{description}"
]

with_url =
case Keyword.get(opts, :target_url) do
nil -> base
url -> base ++ ["-f", "target_url=#{url}"]
end

{"gh", with_url ++ ["--jq", ".url"]}
end

@doc """
Post one verdict as a commit status. Returns `{:ok, status_url}` or
`{:error, {exit_code, output}}`. Pass `:runner` (a `fn cmd, args -> {out, code} end`)
to intercept the shell-out (defaults to the real `gh`).
"""
@spec report_check(String.t(), String.t(), String.t(), atom(), keyword()) ::
{:ok, String.t()} | {:error, {integer(), String.t()}}
def report_check(repo, head_sha, context, verdict, opts \\ []) do
{cmd, args} = gh_command(repo, head_sha, context, verdict, opts)
runner = Keyword.get(opts, :runner, &default_runner/2)

case runner.(cmd, args) do
{out, 0} -> {:ok, String.trim(out)}
{out, code} -> {:error, {code, String.trim(out)}}
end
end

@doc """
Publish a whole `Bag.CiSweep.run/1` result to GitHub.

`context_opts` requires `:repo` (e.g. `"hyperpolymath/snifs"`) and `:head_sha`.
Each check's status context defaults to `"bag/<check_id>"`; override per check
with `:context_map` (e.g. `%{"snifs-abi" => "ABI conformance — interface drift gate"}`)
to satisfy an existing required-check name. `:runner` is threaded through for tests.

Returns `[{check_id, {:ok, url} | {:error, reason}}]`.
"""
@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)
context_map = Keyword.get(context_opts, :context_map, %{})
runner = Keyword.get(context_opts, :runner)

Enum.map(results, fn r ->
context = Map.get(context_map, r.check_id, "bag/#{r.check_id}")
opts = [node: r.node]
opts = if runner, do: Keyword.put(opts, :runner, runner), else: opts
{r.check_id, report_check(repo, head_sha, context, r.verdict, opts)}
end)
end

# ── internals ──────────────────────────────────────────────────────────────

defp default_runner(cmd, args), do: System.cmd(cmd, args, stderr_to_stdout: true)

defp default_description(verdict, opts) do
node = Keyword.get(opts, :node)
base = "#{verdict} via a bag-of-actions Baton on owned compute (0 GitHub minutes)"
if node, do: base <> " — node #{node}", else: base
end

defp truncate(s) when is_binary(s) do
if String.length(s) <= @max_description, do: s, else: String.slice(s, 0, @max_description)
end
end
1 change: 1 addition & 0 deletions bag/lib/bag/mesh.ex
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ defmodule Bag.Mesh do
:deno -> "deno"
:scorecard -> "scorecard"
:wasm -> "wasm"
:nix -> "nix"
_ -> "unknown"
end)

Expand Down
87 changes: 87 additions & 0 deletions bag/lib/mix/tasks/bag.report.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# SPDX-License-Identifier: MPL-2.0
# Copyright (c) Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
defmodule Mix.Tasks.Bag.Report do
@shortdoc "Run a Baton sweep on owned compute + post each verdict to a GitHub commit"
@moduledoc """
Run a CI-check Baton sweep on owned compute (**zero GitHub Actions minutes**) AND
publish each verdict back to a GitHub commit as a *status* (via `Bag.GitHubBridge`),
satisfying a branch-protection **required check** without a GitHub-hosted runner.
This is the dispatcher that makes the bag→GitHub bridge live — `bag.sweep` runs
the checks; `bag.report` runs them *and* reports back.

GITHUB_REPOSITORY=hyperpolymath/snifs GITHUB_SHA=<head-sha> \\
mix bag.report /path/to/snifs/ci-checks.exs

`GITHUB_REPOSITORY` + `GITHUB_SHA` are the standard GitHub env vars (set by the
triggering dispatcher; `BAG_HEAD_SHA` is accepted as a fallback for the PR head).
Each manifest check may carry an optional `:github_context` — the required-check
NAME its verdict posts to; absent, the context defaults to `bag/<check_id>`.

Exit status: `0` iff every check passed AND every status posted; `1` otherwise.
"""
use Mix.Task

@impl Mix.Task
def run(args) do
Mix.Task.run("app.start")
{repo, head_sha} = github_context!()
path = List.first(args) || "../ci-checks.exs"
{checks, _bindings} = Code.eval_file(path)

{results, summary} = Bag.CiSweep.run(checks)
context_map = context_map_from(checks)

posts =
Bag.GitHubBridge.report_sweep({results, summary},
repo: repo,
head_sha: head_sha,
context_map: context_map
)

Enum.each(posts, fn
{id, {:ok, url}} -> IO.puts("#{id}\tposted\t#{url}")
{id, {:error, reason}} -> IO.puts(:stderr, "#{id}\tPOST-FAILED\t#{inspect(reason)}")
end)

IO.puts(:stderr, "bag.report: #{inspect(summary)} on owned compute (0 GitHub minutes)")

green? = Bag.CiSweep.all_passed?({results, summary})
posted? = Enum.all?(posts, &match?({_id, {:ok, _url}}, &1))

cond do
green? and posted? ->
IO.puts(:stderr, "bag.report: ALL PASS + posted ✅")

not posted? ->
IO.puts(:stderr, "bag.report: posted-with-errors ❌")
exit({:shutdown, 1})

true ->
IO.puts(:stderr, "bag.report: NOT GREEN ❌")
exit({:shutdown, 1})
end
end

@doc """
Build a `%{check_id => github_context}` map from the manifest checks that declare
a `:github_context`. Checks without one fall through to `Bag.GitHubBridge`'s
`bag/<check_id>` default. Pure — unit-tested.
"""
def context_map_from(checks) do
checks
|> Enum.filter(&Map.has_key?(&1, :github_context))
|> Map.new(fn c -> {c.check_id, c.github_context} end)
end

defp github_context! do
repo =
System.get_env("GITHUB_REPOSITORY") ||
Mix.raise("bag.report: GITHUB_REPOSITORY not set (expected \"owner/repo\")")

sha =
System.get_env("GITHUB_SHA") || System.get_env("BAG_HEAD_SHA") ||
Mix.raise("bag.report: GITHUB_SHA (or BAG_HEAD_SHA) not set (the PR head commit)")

{repo, sha}
end
end
23 changes: 23 additions & 0 deletions bag/test/bag_report_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# SPDX-License-Identifier: MPL-2.0
# Copyright (c) Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
defmodule Mix.Tasks.Bag.ReportTest do
use ExUnit.Case, async: true

test "context_map_from keeps only checks that declare :github_context" do
checks = [
%{check_id: "snifs-proofs", command: ["x"], required_cap: "nix", github_context: "bag / Formal proofs (owned compute)"},
%{check_id: "snifs-abi", command: ["y"], required_cap: "nix", github_context: "bag / ABI conformance (owned compute)"},
# a check with no github_context falls through to the bag/<check_id> default
%{check_id: "extra", command: ["z"], required_cap: "nix"}
]

assert Mix.Tasks.Bag.Report.context_map_from(checks) == %{
"snifs-proofs" => "bag / Formal proofs (owned compute)",
"snifs-abi" => "bag / ABI conformance (owned compute)"
}
end

test "context_map_from is empty when no check declares a context" do
assert Mix.Tasks.Bag.Report.context_map_from([%{check_id: "a", command: ["x"]}]) == %{}
end
end
84 changes: 84 additions & 0 deletions bag/test/github_bridge_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# SPDX-License-Identifier: MPL-2.0
# Copyright (c) Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
defmodule Bag.GitHubBridgeTest do
use ExUnit.Case, async: true
alias Bag.GitHubBridge

test "verdict → commit-status state: only :pass is success" do
assert GitHubBridge.state_for(:pass) == "success"
assert GitHubBridge.state_for(:fail) == "failure"
assert GitHubBridge.state_for(:error) == "error"
# a suspended check (no capable owned node) must NOT silently pass a required gate
assert GitHubBridge.state_for(:suspended) == "failure"
end

test "gh_command posts a commit STATUS (not a check-run — a PAT can't write checks)" do
{"gh", args} =
GitHubBridge.gh_command("hyperpolymath/snifs", "deadbeef", "bag/snifs-proofs", :pass,
node: "mesh-server-1"
)

assert "api" in args
assert "--method" in args and "POST" in args
assert "repos/hyperpolymath/snifs/statuses/deadbeef" in args
assert "state=success" in args
assert "context=bag/snifs-proofs" in args
assert Enum.any?(args, &String.starts_with?(&1, "description="))
# the whole point: statuses, never the App-only check-runs endpoint
refute Enum.any?(args, &String.contains?(&1, "check-runs"))
end

test "gh_command threads target_url (link to the attested verdict envelope)" do
{"gh", args} =
GitHubBridge.gh_command("o/r", "sha", "ctx", :fail, target_url: "https://node/attest.txt")

assert "target_url=https://node/attest.txt" in args
assert "state=failure" in args
end

test "description is truncated to GitHub's 140-char commit-status limit" do
{"gh", args} =
GitHubBridge.gh_command("o/r", "sha", "ctx", :pass, node: String.duplicate("x", 300))

desc = Enum.find(args, &String.starts_with?(&1, "description="))
assert String.length(String.replace_prefix(desc, "description=", "")) <= 140
end

test "report_sweep posts one status per check, mapping verdicts + contexts (fake runner)" do
test_pid = self()

runner = fn cmd, args ->
send(test_pid, {:posted, cmd, args})
{"https://api.github.com/repos/o/r/statuses/1", 0}
end

sweep =
{[
%{check_id: "snifs-proofs", verdict: :pass, node: "mesh-server-1"},
%{check_id: "snifs-abi", verdict: :fail, node: "mesh-server-1"}
], %{pass: 1, fail: 1}}

out =
GitHubBridge.report_sweep(sweep,
repo: "hyperpolymath/snifs",
head_sha: "sha1",
context_map: %{"snifs-abi" => "ABI conformance — interface drift gate"},
runner: runner
)

assert [{"snifs-proofs", {:ok, _}}, {"snifs-abi", {:ok, _}}] = out

posts = for _ <- 1..2, do: (receive do {:posted, "gh", args} -> Enum.join(args, " ") end)

# default context for the un-mapped check; the mapped one matches the required name
assert Enum.any?(posts, &(String.contains?(&1, "context=bag/snifs-proofs") and String.contains?(&1, "state=success")))
assert Enum.any?(posts, &(String.contains?(&1, "context=ABI conformance — interface drift gate") and String.contains?(&1, "state=failure")))
end

test "report_check surfaces a non-zero gh exit as {:error, {code, output}}" do
runner = fn _cmd, _args -> {"gh: Not Found (HTTP 404)", 1} end

assert {:error, {1, "gh: Not Found (HTTP 404)"}} =
GitHubBridge.report_check("o/r", "sha", "ctx", :pass, runner: runner)
end
end
Loading