From 51e26e04fa92689fceb3658331e364aa90136cf6 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Sat, 7 Nov 2026 19:17:40 +0000 Subject: [PATCH 1/2] fix(ci): bound local execution receipts and map act runners --- .github/workflows/ci.yml | 15 +++++ docs/REVIEW-GAP-LEDGER-1009.adoc | 26 +++++++++ lib/hypatia/ci_changed.ex | 31 ++++++++++- test/ci_changed_act_test.exs | 70 +++++++++++++++++++++++ test/ci_changed_receipt_test.exs | 95 ++++++++++++++++++++++++++++++++ test/ci_changed_test.exs | 4 +- test/test_helper.exs | 8 ++- 7 files changed, 245 insertions(+), 4 deletions(-) create mode 100644 test/ci_changed_act_test.exs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 06e56e6d..7c66e9f0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -376,6 +376,21 @@ jobs: HYPATIA_BASELINE_HELPER: .standards/scripts/apply-baseline.sh run: mix test --no-start --include baseline_helper test/ci_changed_test.exs test/ci_changed_receipt_test.exs test/kyaml_spec_mutation_test.exs test/structured_format_test.exs + - name: Install checksum-pinned act for real execution regression + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/hypatia-act" + cd "$RUNNER_TEMP/hypatia-act" + curl --fail --location --silent --show-error \ + https://github.com/nektos/act/releases/download/v0.2.97/act_Linux_x86_64.tar.gz \ + --output act.tar.gz + echo '67b4976a56f33ea7870b798912e738ec44eef38e7578ae7b532f2def1c73a5ad act.tar.gz' | sha256sum --check --strict + tar -xzf act.tar.gz act + echo "$RUNNER_TEMP/hypatia-act" >> "$GITHUB_PATH" + + - name: Real noninteractive changed-job execution (1009) + run: mix test --no-start test/ci_changed_act_test.exs --only act_integration + # ADR-001 retirement guard: Hypatia rules are pure Elixir # (lib/rules/*.ex). This fails if an active .lgt/.logtalk source # or a Logtalk output/loader setting re-enters the tree outside diff --git a/docs/REVIEW-GAP-LEDGER-1009.adoc b/docs/REVIEW-GAP-LEDGER-1009.adoc index c899ed01..fc86ca94 100644 --- a/docs/REVIEW-GAP-LEDGER-1009.adoc +++ b/docs/REVIEW-GAP-LEDGER-1009.adoc @@ -89,6 +89,32 @@ Orchestrate 42201913539 failed. This is not a claim all main workflows pass. == Local use and limits +=== Bounded local receipt contract (2026-11-07 follow-up) + +A `completed` receipt establishes that act observed successful, non-dry-run +execution of the selected job's steps against the local source snapshot in +the explicitly mapped container. It is not hosted Actions parity, environment +approval, runner-image parity, a wall-clock guarantee, or evidence of a merge. +Jobs declaring environments, job/step timeouts, or workflow/job concurrency +require hosted receipts. These restrictions propagate through every transitive +prerequisite, as do the existing credential, permission, checkout and expression +restrictions. Declaration presence is enough to block; malformed or empty values +are not treated as permission to simulate these semantics. + +Every accepted runner label has an explicit `--platform` argument using an +immutable image digest from `ghcr.io/catthehacker/ubuntu`. The pinned local +images correspond to Ubuntu 20.04, 22.04 and 24.04; `ubuntu-latest` maps to +the pinned local 24.04 image, not a promise about GitHub's moving default. +The isolated act configuration therefore needs no interactive image survey. +Changing these pins requires a new real-run receipt. The opt-in +`test/ci_changed_act_test.exs` executes changed shell input through real act +with noninteractive stdin and checks both a successful job and a failing job. +Run it with `mix test test/ci_changed_act_test.exs --only act_integration` +after provisioning act and a container daemon. Unit tests with an injected +runner do not satisfy this integration requirement. + +=== Selection, scanning and isolation + The newly exposed standing SF001 inventory contains 15 advisory candidates in five existing rule files. The baseline retains them as visible advisory debt, with five exact-path entries tied to #971 and expiry 2027-02-04. diff --git a/lib/hypatia/ci_changed.ex b/lib/hypatia/ci_changed.ex index 782db332..a105909b 100644 --- a/lib/hypatia/ci_changed.ex +++ b/lib/hypatia/ci_changed.ex @@ -7,6 +7,20 @@ defmodule Hypatia.CiChanged do """ alias Hypatia.Kyaml + # Local container execution is not a receipt for a GitHub-hosted runner. + # Immutable multi-architecture image digests, resolved 2026-11-07. In + # particular, ubuntu-latest means the local 24.04 image in this contract. + @runner_images %{ + "ubuntu-latest" => + "ghcr.io/catthehacker/ubuntu@sha256:62d572b92f9f32d3427b6d220ad1f9dca9c7b6ffad37d295425037dbff78abaf", + "ubuntu-24.04" => + "ghcr.io/catthehacker/ubuntu@sha256:62d572b92f9f32d3427b6d220ad1f9dca9c7b6ffad37d295425037dbff78abaf", + "ubuntu-22.04" => + "ghcr.io/catthehacker/ubuntu@sha256:f5f5c29208c4fd541704fe7b8df33d3bf620ce4ac46f36853b7abeb0159705c4", + "ubuntu-20.04" => + "ghcr.io/catthehacker/ubuntu@sha256:48f10e978f6cc7b4034a6958fe03de2dad322502d4792033457586ba08cbea12" + } + @doc "Select changed job semantics and reject dangling or cyclic needs." def changed_jobs(before, after_text) do old = workflow!(before) @@ -254,6 +268,16 @@ defmodule Hypatia.CiChanged do writes -> "write-capable job needs a hosted receipt" + Map.has_key?(job, "environment") -> + "environment protection needs a hosted receipt" + + Map.has_key?(job, "timeout-minutes") || + Enum.any?(Map.get(job, "steps", []), &Map.has_key?(&1, "timeout-minutes")) -> + "execution timeouts need a hosted receipt" + + Map.has_key?(new, "concurrency") || Map.has_key?(job, "concurrency") -> + "concurrency controls need a hosted receipt" + not (permissions == "read-all" or (is_map(permissions) and Enum.all?(Map.values(permissions), &(&1 in ["read", "none"])))) -> @@ -290,7 +314,7 @@ defmodule Hypatia.CiChanged do checkout_steps(job) == [] -> "checkout-less jobs need a hosted receipt; act copies local source" - job["runs-on"] not in ["ubuntu-latest", "ubuntu-24.04", "ubuntu-22.04", "ubuntu-20.04"] -> + not Map.has_key?(@runner_images, job["runs-on"]) -> "unsupported or host-native runners need a hosted receipt" true -> @@ -473,7 +497,10 @@ defmodule Hypatia.CiChanged do "-", "--container-options", "--cap-drop=ALL" - ], + ] ++ + (@runner_images + |> Enum.sort() + |> Enum.flat_map(fn {label, image} -> ["--platform", "#{label}=#{image}"] end)), cd: repo, env: env, stderr_to_stdout: true diff --git a/test/ci_changed_act_test.exs b/test/ci_changed_act_test.exs new file mode 100644 index 00000000..124563fd --- /dev/null +++ b/test/ci_changed_act_test.exs @@ -0,0 +1,70 @@ +# SPDX-License-Identifier: MPL-2.0 +defmodule Hypatia.CiChangedActTest do + use ExUnit.Case, async: false + alias Hypatia.CiChanged + + @moduletag :act_integration + @moduletag timeout: 180_000 + + test "real noninteractive act runs changed source and refuses a failing execution" do + assert System.find_executable("act"), + "provision the pinned act executable and container daemon" + + repo = + Path.join( + System.tmp_dir!(), + "hypatia-real-act-#{System.pid()}-#{System.os_time(:nanosecond)}" + ) + + File.mkdir_p!(Path.join(repo, ".github/workflows")) + File.mkdir_p!(Path.join(repo, "scripts")) + on_exit(fn -> File.rm_rf!(repo) end) + + File.write!(Path.join(repo, ".github/workflows/ci.yml"), """ + on: push + permissions: read-all + jobs: + build: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v7 + - run: bash scripts/input.sh + """) + + script = Path.join(repo, "scripts/input.sh") + File.write!(script, "exit 0\n") + + for args <- [ + ["init", "-q"], + ["add", "."], + [ + "-c", + "commit.gpgsign=false", + "-c", + "user.name=Test", + "-c", + "user.email=test@example.org", + "commit", + "-qm", + "baseline" + ] + ] do + assert {_, 0} = System.cmd("git", args, cd: repo, stderr_to_stdout: true) + end + + File.write!(script, """ + set -eu + test ! -t 0 + test -z "${GITHUB_TOKEN:-}" + test -f scripts/input.sh + printf 'HYPATIA_REAL_ACT_CHANGED_INPUT\n' + """) + + assert [%{local_blocker: nil}] = jobs = CiChanged.plan(repo, "HEAD").jobs + assert [%{outcome: :completed, output: output}] = CiChanged.execute_jobs(repo, jobs) + assert output =~ "HYPATIA_REAL_ACT_CHANGED_INPUT" + + File.write!(script, "exit 23\n") + assert [%{outcome: :failed}] = CiChanged.execute_jobs(repo, CiChanged.plan(repo, "HEAD").jobs) + end +end diff --git a/test/ci_changed_receipt_test.exs b/test/ci_changed_receipt_test.exs index d0d95a15..833dd08c 100644 --- a/test/ci_changed_receipt_test.exs +++ b/test/ci_changed_receipt_test.exs @@ -131,6 +131,101 @@ defmodule Hypatia.CiChangedReceiptTest do end end + test "hosted scheduling and deadlines block both direct jobs and transitive consumers" do + repo = repo() + + for {global, job_fields, step_fields, expected} <- [ + {"", "environment: production", "", "environment"}, + {"", "environment: {name: production}", "", "environment"}, + {"", "timeout-minutes: 1", "", "timeouts"}, + {"", "", ", timeout-minutes: 1", "timeouts"}, + {"", "concurrency: {group: deploy, cancel-in-progress: true}", "", "concurrency"}, + {"concurrency: deploy\n", "", "", "concurrency"} + ] do + workflow = """ + on: push + permissions: read-all + #{global}jobs: + setup: + runs-on: ubuntu-24.04 + #{job_fields} + steps: [{uses: actions/checkout@v7}, {run: echo setup#{step_fields}}] + middle: + runs-on: ubuntu-24.04 + needs: setup + steps: [{uses: actions/checkout@v7}, {run: echo middle}] + build: + runs-on: ubuntu-24.04 + needs: middle + steps: [{uses: actions/checkout@v7}, {run: echo build}] + """ + + File.write!(Path.join(repo, ".github/workflows/ci.yml"), workflow) + commit(repo) + File.write!(Path.join(repo, "input.txt"), expected) + jobs = CiChanged.plan(repo, "HEAD").jobs + assert length(jobs) == 3 + assert Enum.all?(jobs, &String.contains?(&1.local_blocker, expected)) + + if global == "" do + assert Enum.find(jobs, &(&1.job == "build")).local_blocker =~ "prerequisite setup" + end + + assert Enum.all?( + CiChanged.execute_jobs(repo, jobs, fn _, _, _ -> flunk("hosted-only job ran") end), + &(&1.outcome == :requires_hosted_receipt) + ) + + File.rm!(Path.join(repo, "input.txt")) + end + end + + test "every accepted runner has an explicit immutable noninteractive platform mapping" do + repo = repo() + + for label <- ["ubuntu-latest", "ubuntu-24.04", "ubuntu-22.04", "ubuntu-20.04"] do + File.write!(Path.join(repo, ".github/workflows/ci.yml"), """ + on: push + permissions: read-all + jobs: + build: + runs-on: #{label} + steps: [{uses: actions/checkout@v7}, {run: echo ok}] + """) + + commit(repo) + File.write!(Path.join(repo, "input.txt"), label) + assert [%{local_blocker: nil}] = jobs = CiChanged.plan(repo, "HEAD").jobs + + assert [%{outcome: :completed}] = + CiChanged.execute_jobs(repo, jobs, fn "act", args, options -> + mappings = + for ["--platform", mapping] <- Enum.chunk_every(args, 2, 1, :discard), + do: mapping + + assert length(mappings) == 4 + + assert Enum.any?( + mappings, + &String.starts_with?( + &1, + label <> "=ghcr.io/catthehacker/ubuntu@sha256:" + ) + ) + + assert Enum.all?(mappings, &Regex.match?(~r/@sha256:[0-9a-f]{64}$/, &1)) + + isolated_home = + options |> Keyword.fetch!(:env) |> Map.new() |> Map.fetch!("HOME") + + assert File.ls!(isolated_home) == ["empty"] + {row("success"), 0} + end) + + File.rm!(Path.join(repo, "input.txt")) + end + end + test "root composite action findings retain their actual path" do repo = repo() diff --git a/test/ci_changed_test.exs b/test/ci_changed_test.exs index 70cc9d33..3f907e88 100644 --- a/test/ci_changed_test.exs +++ b/test/ci_changed_test.exs @@ -100,9 +100,11 @@ defmodule Hypatia.CiChangedTest do "--container-daemon-socket", "-", "--container-options", - "--cap-drop=ALL" + "--cap-drop=ALL" | platforms ] = argv + assert ["--platform", _ | _] = platforms + assert opts[:cd] == "/repo" assert File.read!(empty) == "" env = Map.new(opts[:env]) diff --git a/test/test_helper.exs b/test/test_helper.exs index 93cf7cc1..bff2492c 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -33,6 +33,12 @@ ExUnit.start() # need a yaml-test-suite `data` checkout at YAML_TEST_SUITE, which the same job # pins. Run with: YAML_TEST_SUITE=... mix test --only yaml_test_suite. ExUnit.configure( - exclude: [:verisim_data, :kyq_differential, :yaml_test_suite, :baseline_helper], + exclude: [ + :verisim_data, + :kyq_differential, + :yaml_test_suite, + :baseline_helper, + :act_integration + ], seed: 0 ) From c9405a6536682e677e27f733d3e479554dfc79d9 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Sat, 7 Nov 2026 19:22:36 +0000 Subject: [PATCH 2/2] fix(ci): restrict act download redirects to HTTPS --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7c66e9f0..0a0cc9c3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -381,7 +381,7 @@ jobs: set -euo pipefail mkdir -p "$RUNNER_TEMP/hypatia-act" cd "$RUNNER_TEMP/hypatia-act" - curl --fail --location --silent --show-error \ + curl --fail --location --silent --show-error --proto '=https' --proto-redir '=https' \ https://github.com/nektos/act/releases/download/v0.2.97/act_Linux_x86_64.tar.gz \ --output act.tar.gz echo '67b4976a56f33ea7870b798912e738ec44eef38e7578ae7b532f2def1c73a5ad act.tar.gz' | sha256sum --check --strict