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
15 changes: 15 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 --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
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
Expand Down
26 changes: 26 additions & 0 deletions docs/REVIEW-GAP-LEDGER-1009.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
31 changes: 29 additions & 2 deletions lib/hypatia/ci_changed.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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"])))) ->
Expand Down Expand Up @@ -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 ->
Expand Down Expand Up @@ -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
Expand Down
70 changes: 70 additions & 0 deletions test/ci_changed_act_test.exs
Original file line number Diff line number Diff line change
@@ -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
95 changes: 95 additions & 0 deletions test/ci_changed_receipt_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
4 changes: 3 additions & 1 deletion test/ci_changed_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down
8 changes: 7 additions & 1 deletion test/test_helper.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Loading