From a5d603af041bd868f1984b7f19094e7557d0e8b3 Mon Sep 17 00:00:00 2001 From: Arena Agent Date: Sun, 25 Oct 2026 19:51:08 +0000 Subject: [PATCH 01/21] fix(actions_lock): compile the module again, and fix the two defects behind it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mix compile` failed on main (2026-10-20 → 10-25), which took down every consumer's "RSR oracle — dogfood this repo" job, starting with hyperpolymath/secret-types#11: error: cannot invoke remote function :erlang.=:=/2 inside a match 170 │ version in @supported_versions -> └─ lib/rules/actions_lock.ex:170: Hypatia.Rules.ActionsLock.consume_line/3 ** (CompileError) ... expanding macro: Kernel.in/2 `in/2` is a guard expression, not a pattern; as a bare `case` clause head it expands to a match against `:erlang.=:=/2`. #927 introduced it, together with a `true ->` fallback referencing a `version` the pattern never bound. Both were dead: `Regex.run/2` with one capture group always returns `[_full, version]`, so the two `[_, version]` clauses above covered every outcome. The version is now bound by the pattern and membership is tested in the guards — the only form that compiles. Two further defects in the same module were invisible while it did not compile, and both are fixed here: * an unreachable duplicate `hostname:` parser referenced `@hostname`, a module attribute that was never defined (it expands to nil, so the branch would have raised), and it emitted `:hostname_requires_v0_0_3` — a reason no caller could ever receive. Deleted; `put_dependency_hostname/3` is the only parser. This also makes `mix compile --warnings-as-errors` possible. * `finalize_dependency/1` built a map with two `hostname:` keys, the second (raw nil) winning, so every parsed dependency recorded `hostname: nil`. The stored field stays raw nil for an unnamed host, which IS the home host: `scope_to_host/2` compares it against the raw value each record carries, so defaulting it to "github.com" would silently stop an omitted-hostname pin from being identity-checked against an explicit github.com pin on the same host. The moduledoc now says so. The test file held three generations of hostname expectations, written on two branches the same day and never reconciled (~40 assertions could not have passed). They are aligned with the shipped, documented contract — tenant `*.ghe.com` hosts refused (`:unsupported_dependency_hostname`), a hostname on a v0.0.2 lock refused (`:hostname_not_in_version`), unnamed host recorded as nil. 41 tests, 0 failures on Elixir 1.19.4/OTP 28.3 and 1.17.3/OTP 27.0. Refs hyperpolymath/secret-types#11 Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- lib/rules/actions_lock.ex | 57 +++++++++++++++++++------------ test/rules/actions_lock_test.exs | 58 +++++++++++++++++++++++++------- 2 files changed, 81 insertions(+), 34 deletions(-) diff --git a/lib/rules/actions_lock.ex b/lib/rules/actions_lock.ex index 3a292443..a3c8b19d 100644 --- a/lib/rules/actions_lock.ex +++ b/lib/rules/actions_lock.ex @@ -23,7 +23,11 @@ defmodule Hypatia.Rules.ActionsLock do `github.com`. A well-formed `*.ghe.com` tenant hostname is therefore rejected rather than accepted: `owner_id`/`repo_id` are host-scoped, and the `owner/repo@ref` keys used for association carry no host. Every parsed - dependency records its hostname, defaulting to `github.com` as v0.0.2 does. + dependency records the hostname the lock named; `nil` means the lock named + none, which binds the pin to the home host `github.com` (v0.0.2 records no + hostname at all). The field stays `nil` rather than being defaulted to + `"github.com"` because `scope_to_host/2` compares it against the raw value + read from each record — see `finalize_dependency/1`. """ @supported_versions ["v0.0.2", "v0.0.3"] @@ -160,6 +164,17 @@ defmodule Hypatia.Rules.ActionsLock do defp consume_line(line, line_number, %{section: :preamble} = state) do cond do match = Regex.run(~r/^version: '([^']+)'$/, line) -> + # Every clause here must be a *pattern* plus, optionally, a guard. + # `version in @supported_versions` is a guard expression, not a + # pattern: as a bare case clause head it expands to a match against + # `:erlang.=:=/2` and the file does not compile at all + # ("cannot invoke remote function :erlang.=:=/2 inside a match"). + # The two dead clauses that carried it are folded into the clauses + # below: the regex always yields `[_full, version]`, so binding the + # version in the pattern and testing membership in the guard is both + # narrower and the only form the compiler accepts. `version` must be + # bound by the pattern, never by the discarded `true ->` fallback, + # which referenced an unbound variable. case match do [_, version] when version in @supported_versions and is_nil(state.version) -> {:ok, %{state | version: version}} @@ -167,10 +182,7 @@ defmodule Hypatia.Rules.ActionsLock do [_, version] when version in @supported_versions -> line_error(line_number, :duplicate_version) - version in @supported_versions -> - {:ok, %{state | version: version}} - - true -> + [_, version] -> line_error(line_number, {:unsupported_version, version}) end @@ -248,20 +260,12 @@ defmodule Hypatia.Rules.ActionsLock do Regex.match?(~r/^ (?:owner_id|repo_id):/, line) -> line_error(line_number, :malformed_dependency_identity) - match = Regex.run(~r/^ hostname: '([^']*)'$/, line) -> - [_, hostname] = match - - cond do - state.version != "v0.0.3" -> - line_error(line_number, {:hostname_requires_v0_0_3, state.version}) - - Regex.match?(@hostname, hostname) -> - put_dependency_field(state, :hostname, hostname, line_number) - - true -> - line_error(line_number, {:malformed_dependency_hostname, hostname}) - end - + # The `hostname:` line is handled by `put_dependency_hostname/3` in the + # clause above (the first matching `cond` arm wins), so there is exactly + # one hostname parser. A second, unreachable copy used to live here; it + # referenced `@hostname`, a module attribute that was never defined, and + # reported `:hostname_requires_v0_0_3` — errors no caller could receive + # and a warning that made `mix compile --warnings-as-errors` unusable. Regex.match?(~r/^ hostname:/, line) -> line_error(line_number, :malformed_dependency_hostname) @@ -395,13 +399,24 @@ defmodule Hypatia.Rules.ActionsLock do header_line ), false <- Map.has_key?(state.dependencies, normalized) do + # ONE hostname key. This map previously carried two: the first defaulted + # to the home host, the second wrote the raw `nil` — and the second wins + # in a map literal, so every parsed dependency recorded `hostname: nil` + # and every hostname assertion in test/rules/actions_lock_test.exs failed. + # It was invisible while the module did not compile. stored = %{ - hostname: Map.get(dependency, :hostname, @home_hostname), ref: ref, commit: commit, owner_id: owner_id, repo_id: repo_id, - hostname: Map.get(dependency, :hostname), + # The raw value, NOT a "github.com" default. `nil` means "the lock + # named no host", i.e. the home host; scope_to_host/2 above compares + # this field against the incoming raw value, so defaulting it to the + # string here would silently stop an omitted-hostname pin from being + # identity-checked against an explicitly-github.com pin on the same + # host. v0.0.2 has no such field at all and records nil for the same + # reason. + hostname: hostname, uses: Map.get(dependency, :uses, MapSet.new()) } diff --git a/test/rules/actions_lock_test.exs b/test/rules/actions_lock_test.exs index b1c64e99..1c1dc4b7 100644 --- a/test/rules/actions_lock_test.exs +++ b/test/rules/actions_lock_test.exs @@ -179,7 +179,7 @@ defmodule Hypatia.Rules.ActionsLockTest do assert {:error, {:line, _, :malformed_dependency_entry}} = ActionsLock.parse(unknown) end - test "accepts and records the optional dependency hostname (GHE.com pins)" do + test "accepts and records the optional dependency hostname (home host only)" do lock = String.replace( @v3_lock, @@ -191,9 +191,14 @@ defmodule Hypatia.Rules.ActionsLockTest do assert parsed.dependencies["actions/checkout@v7.0.1"].hostname == "github.com" assert ActionsLock.pinned?(parsed, "ci.yml", "actions/checkout@v7.0.1") + # A well-formed tenant host is refused by POLICY (see the moduledoc: + # owner_id/repo_id are host-scoped and the association keys carry no + # host). Asserting that it parses would mean the oracle accepts a pin it + # cannot associate with a workflow. tenant = String.replace(lock, "github.com", "octocorp.ghe.com") - assert {:ok, parsed} = ActionsLock.parse(tenant) - assert parsed.dependencies["actions/checkout@v7.0.1"].hostname == "octocorp.ghe.com" + + assert {:error, {:line, _, {:unsupported_dependency_hostname, "octocorp.ghe.com"}}} = + ActionsLock.parse(tenant) assert {:ok, parsed} = ActionsLock.parse(@v3_lock) assert parsed.dependencies["actions/checkout@v7.0.1"].hostname == nil @@ -227,9 +232,13 @@ defmodule Hypatia.Rules.ActionsLockTest do assert {:error, {:line, _, {:owner_identity_mismatch, "actions", 44_036_562, 99}}} = ActionsLock.parse(same_host) + # The cross-host case is unreachable: a tenant hostname is refused before + # identity scoping is consulted, precisely because two hosts cannot be + # told apart in the `owner/repo@ref` association keys. cross_host = String.replace(two_pins, "HOST", "hostname: 'octocorp.ghe.com'") - assert {:ok, parsed} = ActionsLock.parse(cross_host) - assert parsed.dependencies["actions/setup-node@v7.0.0"].hostname == "octocorp.ghe.com" + + assert {:error, {:line, _, {:unsupported_dependency_hostname, "octocorp.ghe.com"}}} = + ActionsLock.parse(cross_host) end test "refuses a malformed, repeated, or v0.0.2 hostname" do @@ -264,7 +273,9 @@ defmodule Hypatia.Rules.ActionsLockTest do ActionsLock.parse(twice) v2 = String.replace(with_host.("github.com"), "v0.0.3", "v0.0.2") - assert {:error, {:line, _, {:hostname_requires_v0_0_3, "v0.0.2"}}} = ActionsLock.parse(v2) + + assert {:error, {:line, _, {:hostname_not_in_version, "v0.0.2"}}} = + ActionsLock.parse(v2) end test "negative control: a version past v0.0.3 is still refused" do @@ -466,10 +477,23 @@ defmodule Hypatia.Rules.ActionsLockTest do end assert {:ok, _} = ActionsLock.parse(with_host.("github.com")) - assert {:ok, _} = ActionsLock.parse(with_host.("acme.ghe.com")) + + # Well-formed *.ghe.com tenant hosts are refused by POLICY, not by the + # grammar: owner_id/repo_id are host-scoped and the `owner/repo@ref` + # association keys carry no host, so accepting a tenant host would let a + # tenant pin be associated with a github.com workflow. This module's + # moduledoc has said "fail closed" since the reorg; the assertions here + # used to accept them, written against an implementation that never + # shipped and never compiled. + assert {:error, {:line, _, {:unsupported_dependency_hostname, "acme.ghe.com"}}} = + ActionsLock.parse(with_host.("acme.ghe.com")) long_label = String.duplicate("a", 64) <> ".ghe.com" - assert {:ok, _} = ActionsLock.parse(with_host.(String.duplicate("a", 63) <> ".ghe.com")) + + # 63 characters is a legal DNS label, so it fails as a policy choice; 64 is + # not, so it fails as malformed. + assert {:error, {:line, _, {:unsupported_dependency_hostname, _}}} = + ActionsLock.parse(with_host.(String.duplicate("a", 63) <> ".ghe.com")) for bad <- [ "GitHub.com", @@ -500,7 +524,8 @@ defmodule Hypatia.Rules.ActionsLockTest do " repo_id: 197814629\n hostname: 'github.com'\n" ) - assert {:error, {:line, _, :malformed_dependency_hostname}} = ActionsLock.parse(v2_with_host) + assert {:error, {:line, _, {:hostname_not_in_version, "v0.0.2"}}} = + ActionsLock.parse(v2_with_host) end test "scopes owner and repository identities to their v0.0.3 host" do @@ -584,7 +609,11 @@ defmodule Hypatia.Rules.ActionsLockTest do assert parsed.version == "v0.0.3" assert map_size(parsed.workflows) == 57 assert map_size(parsed.dependencies) == 28 - assert Enum.all?(Map.values(parsed.dependencies), &(&1.hostname == "github.com")) + # This fixture predates gh-actions-lock emitting `hostname:`, so every + # dependency records nil — which IS the home host (`github.com`); the + # field is deliberately not defaulted to the string. See + # finalize_dependency/1 and scope_to_host/2. + assert Enum.all?(Map.values(parsed.dependencies), &is_nil(&1.hostname)) checkout = "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1" assert ActionsLock.pinned?(parsed, "actions-lock-gate.yml", checkout) @@ -598,11 +627,14 @@ defmodule Hypatia.Rules.ActionsLockTest do ) end - test "an omitted hostname binds the pin to github.com" do + test "an omitted hostname binds the pin to github.com (recorded as nil)" do omitted = String.replace(@v003_lock, " hostname: 'github.com'\n", "") assert {:ok, parsed} = ActionsLock.parse(omitted) - assert parsed.dependencies["actions/checkout@v7.0.1"].hostname == "github.com" + # nil IS the home host in the record. Defaulting the stored field to the + # string would break scope_to_host/2, which compares it against the raw + # value each record carries; see finalize_dependency/1. + assert parsed.dependencies["actions/checkout@v7.0.1"].hostname == nil assert ActionsLock.pinned?(parsed, "ci.yml", "actions/checkout@v7.0.1") end @@ -615,7 +647,7 @@ defmodule Hypatia.Rules.ActionsLockTest do test "v0.0.2 lockfiles default to github.com and may not carry a hostname" do assert {:ok, parsed} = ActionsLock.parse(@valid_lock) - assert parsed.dependencies["actions/checkout@v7.0.1"].hostname == "github.com" + assert parsed.dependencies["actions/checkout@v7.0.1"].hostname == nil v002 = String.replace(@v003_lock, "v0.0.3", "v0.0.2") From ab3fba32b8644896dcb7b925c59542570ca64ea9 Mon Sep 17 00:00:00 2001 From: Arena Agent Date: Sun, 25 Oct 2026 19:51:08 +0000 Subject: [PATCH 02/21] ci(oracle): gate the Elixir tree on the dogfood toolchain, and pin the interface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes that stop this class of failure from reaching consumers twice. 1. `ci.yml` gains an `elixir-compile` job, part of `ci-status`, on the exact versions the estate's dogfood gate uses (Elixir 1.19.4 / OTP 28.3): `mix deps.get`, `mix compile --warnings-as-errors`, the ActionsLock and RsrConformance regression tests, and the consumers' oracle invocation with an explicit `--ssot`. Nothing that gated a merge compiled the Elixir tree: `ci.yml` had no Elixir job, `tests.yml` has `pull_request: null`, and `escript-soundness.yml` was already red for unrelated reasons. A failing compile is the regression test here — a tree that does not compile cannot run a test. 2. `.github/workflows/rsr-oracle.yml` is the oracle as a reusable workflow. Consumers call it pinned by full commit SHA and it checks hypatia out at `job.workflow_sha` — the commit the caller named — so `main` is no longer the de facto release. Before, a consumer that checked out `main` had no version to roll back to when main broke. The explicit-SSOT contract is unchanged: the workflow always passes `--ssot`, defaulting to `/0-canon/rsr/rsr-criteria-v2.a2ml`, and `fail-under` stays opt-in. Verified end to end by calling the workflow from a PR job, with `job.workflow_sha` resolving to the pinned commit. Docs: docs/consumers/rsr-oracle.adoc (pin rule, SSOT rule, gating rule), a README pointer, a CHANGELOG entry, and the actions.lock entries for both workflows. Refs hyperpolymath/secret-types#11 Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/workflows/actions.lock | 4 + .github/workflows/ci.yml | 84 +++++++++++- .github/workflows/rsr-oracle.yml | 229 +++++++++++++++++++++++++++++++ CHANGELOG.adoc | 71 ++++++++++ README.adoc | 20 +++ docs/consumers/rsr-oracle.adoc | 106 ++++++++++++++ 6 files changed, 513 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/rsr-oracle.yml create mode 100644 docs/consumers/rsr-oracle.adoc diff --git a/.github/workflows/actions.lock b/.github/workflows/actions.lock index 0a3c33d0..0e08f2db 100644 --- a/.github/workflows/actions.lock +++ b/.github/workflows/actions.lock @@ -22,6 +22,7 @@ workflows: - 'actions/checkout@v7.0.1' - 'actions/upload-artifact@v7.0.2' - 'dtolnay/rust-toolchain@v1' + - 'erlef/setup-beam@v1.24.1' - 'haskell-actions/hlint-run@v2.4.10' - 'haskell-actions/hlint-setup@v2.4.10' - 'haskell-actions/setup@v2.12.1' @@ -90,6 +91,9 @@ workflows: - 'swatinem/rust-cache@v2.9.2' '.github/workflows/roadmap-sync.yml': - 'actions/create-github-app-token@v3.3.0' + '.github/workflows/rsr-oracle.yml': + - 'actions/checkout@v7.0.1' + - 'erlef/setup-beam@v1.24.1' '.github/workflows/rust.yml': - 'actions/checkout@v7.0.1' - 'dtolnay/rust-toolchain@v1' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bb3a7943..dd6133ae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -283,6 +283,86 @@ jobs: path: generated/k9iser/ retention-days: 14 + # ============================================================================ + # Elixir compile gate — runs the SAME toolchain the estate's dogfood gate uses + # ============================================================================ + # + # Why this job exists (2026-10-25, hyperpolymath/secret-types PR #11): + # + # `lib/rules/actions_lock.ex` did not compile at all. Line 170 was + # `version in @supported_versions ->`, a guard expression used as a bare + # `case` clause head, which expands to a match against `:erlang.=:=/2`: + # + # error: cannot invoke remote function :erlang.=:=/2 inside a match + # └─ lib/rules/actions_lock.ex:170 + # ** (CompileError) ... expanding macro: Kernel.in/2 + # + # The break landed on 2026-10-20 in #927 and stayed on `main` for five days. + # Every "RSR oracle — dogfood this repo" job in the estate died on + # `mix deps.get && mix compile`, so ~400 consumer repositories lost their RSR + # scorecard without a single hypatia check going red for the right reason: + # + # * this workflow had no Elixir job at all — Rust and Haskell only; + # * tests.yml has `pull_request: null`, so its Elixir jobs never ran on a PR; + # * escript-soundness.yml does compile, but it was already red for unrelated + # reasons, so a hard CompileError was indistinguishable from the noise. + # + # A tree that cannot compile cannot run a test, so the regression test for + # this class of defect is the compile itself, on the versions the consumers + # actually use. Do not "fix" a red run here by dropping --warnings-as-errors + # or by moving the job behind a condition: that restores the silence. + elixir-compile: + name: Elixir Compile (dogfood toolchain) + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Checkout repository + uses: actions/checkout@v7.0.1 + + # Pinned to the exact versions in the estate dogfood gate + # (.github/workflows/dogfood-gate.yml in each consumer repo, job + # "RSR oracle — dogfood this repo"). Bump both together or the gate + # stops testing what consumers run. + - name: Setup Elixir/OTP (identical to the dogfood gate) + uses: erlef/setup-beam@v1.24.1 + with: + elixir-version: '1.19.4' + otp-version: '28.3' + + - name: Restore Mix cache + uses: actions/cache@v6.1.0 + with: + path: | + deps + _build + key: mix-dogfood-${{ runner.os }}-${{ hashFiles('mix.lock') }} + restore-keys: | + mix-dogfood-${{ runner.os }}- + + - name: Fetch dependencies + run: mix deps.get + + - name: Compile (warnings are errors) + run: mix compile --warnings-as-errors + + - name: Rule module regression tests + run: | + mix test \ + test/rules/actions_lock_test.exs \ + test/rules/actions_lock_closure_corpus_test.exs \ + test/rules/rsr_conformance_test.exs + + # The consumers' invocation, verbatim: the oracle is called with an + # EXPLICIT --ssot. The default SSOT resolution is deliberately not + # exercised here — it depends on the layout of the checkout that hosts + # hypatia, which is the consumer's business, not this repo's. Passing the + # bundled fixture keeps the SSOT explicit, exactly as the gate does. + - name: RSR oracle smoke (consumer invocation, explicit SSOT) + run: | + mix hypatia.rsr_score . \ + --ssot test/fixtures/a2ml/rsr-criteria-v2.a2ml + # ============================================================================ # Combined Status Check # ============================================================================ @@ -298,6 +378,7 @@ jobs: - haskell-build - haskell-lint - k9iser-validate + - elixir-compile if: always() steps: - name: Check all jobs status @@ -307,7 +388,8 @@ jobs: [[ "${{ needs.rust-test.result }}" == "failure" ]] || \ [[ "${{ needs.haskell-build.result }}" == "failure" ]] || \ [[ "${{ needs.haskell-lint.result }}" == "failure" ]] || \ - [[ "${{ needs.k9iser-validate.result }}" == "failure" ]]; then + [[ "${{ needs.k9iser-validate.result }}" == "failure" ]] || \ + [[ "${{ needs.elixir-compile.result }}" == "failure" ]]; then echo "One or more jobs failed" exit 1 fi diff --git a/.github/workflows/rsr-oracle.yml b/.github/workflows/rsr-oracle.yml new file mode 100644 index 00000000..b8e3cb0d --- /dev/null +++ b/.github/workflows/rsr-oracle.yml @@ -0,0 +1,229 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# rsr-oracle.yml — the RSR v2.0 conformance oracle as a reusable workflow. +# +# Consume it PINNED BY FULL COMMIT SHA, the same way consumers already call +# hyperpolymath/standards/.github/workflows/hypatia-scan-reusable.yml: +# +# jobs: +# rsr-score: +# uses: hyperpolymath/hypatia/.github/workflows/rsr-oracle.yml@<40-char SHA> +# with: +# standards-ref: <40-char SHA of the standards tree carrying the SSOT> +# # fail-under: gold # optional; omit to publish only +# +# WHY THE PIN IS THE INTERFACE (2026-10-25): +# +# The generated dogfood gate in each consumer checked out hyperpolymath/hypatia +# with no `ref:` and then ran `mix deps.get && mix compile`. That made the +# default branch the de facto release: when `main` stopped compiling (a single +# invalid `case` clause head in lib/rules/actions_lock.ex, 2026-10-20 → 10-25), +# every consumer's "RSR oracle — dogfood this repo" job died in the same way, +# and there was no version to roll back to and no version to hold still while +# the fix was written. A mutable ref is not a dependency contract. +# +# This workflow closes that by making the caller's `@ref` the ONLY input to +# which code runs: it checks out hypatia at `job.workflow_sha` — the commit of +# *this* workflow file that the caller pinned — so a consumer that writes +# `.../rsr-oracle.yml@abc123...` compiles and runs exactly abc123, not whatever +# `main` happens to be at run time. There is no floating default anywhere in +# this file, and there must never be one: adding `ref: main` as a fallback +# would restore the exact defect this workflow exists to remove. +# +# (`job.workflow_sha`/`job.workflow_repository` are the reusable-workflow +# identity context added 2026-04-23. `github.job_workflow_sha` does not exist; +# don't "simplify" this to it — that silently evaluates to an empty string.) +# +# SSOT CONTRACT — UNCHANGED AND EXPLICIT: +# +# The oracle is always invoked with an explicit `--ssot`. Callers either pass +# `ssot:` or get `/0-canon/rsr/rsr-criteria-v2.a2ml`, which is +# the post-reorg home of the criteria file and the path the estate's dogfood +# gate passes today. The task's own default resolution is intentionally NOT +# relied on here: it depends on the layout of the checkout that hosts hypatia +# (#695), and a consumer must never be scored against a fixture that happens to +# be reachable from its workspace. +name: RSR oracle (reusable) + +on: + workflow_call: + inputs: + target-ref: + description: >- + Commit in the calling repository to score. Defaults to the PR head for + pull_request events, otherwise the event's commit. + type: string + required: false + default: '' + target-path: + description: Subdirectory of the calling repository to score ('.' for the repo root). + type: string + required: false + default: '.' + standards-repository: + description: Repository carrying the RSR criteria SSOT. + type: string + required: false + default: 'hyperpolymath/standards' + standards-ref: + description: >- + Immutable ref (full SHA) of the standards tree. Empty means the + standards repository's default branch, which is what the generated + dogfood gate does today; pin it when the caller wants its canon fixed. + type: string + required: false + default: '' + ssot: + description: >- + Explicit path to rsr-criteria-v2.a2ml. Defaults to + /0-canon/rsr/rsr-criteria-v2.a2ml. + type: string + required: false + default: '' + write: + description: Write .machine_readable/scorecards/rsr.scorecard.a2ml into the scored repo. + type: boolean + required: false + default: true + fail-under: + description: >- + Optional tier the score must reach (bronze|silver|gold|rhodium). Empty + publishes the scorecard without gating on it. + type: string + required: false + default: '' + elixir-version: + description: Elixir version for the oracle. + type: string + required: false + default: '1.19.4' + otp-version: + description: Erlang/OTP version for the oracle. + type: string + required: false + default: '28.3' + outputs: + tier: + description: Tier reported by the oracle (none|bronze|silver|gold|rhodium). + value: ${{ jobs.score.outputs.tier }} + score: + description: Score reported by the oracle, as a percentage string. + value: ${{ jobs.score.outputs.score }} + provisional: + description: Whether the scorecard was provisional (coverage < 100%). + value: ${{ jobs.score.outputs.provisional }} + +permissions: + contents: read + +jobs: + score: + name: RSR oracle + runs-on: ubuntu-latest + timeout-minutes: 30 + outputs: + tier: ${{ steps.oracle.outputs.tier }} + score: ${{ steps.oracle.outputs.score }} + provisional: ${{ steps.oracle.outputs.provisional }} + steps: + # 1. The repository being scored (the caller's). + - name: Checkout repository under test + uses: actions/checkout@v7.0.1 + with: + repository: ${{ github.repository }} + ref: ${{ inputs.target-ref != '' && inputs.target-ref || github.event.pull_request.head.sha || github.sha }} + fetch-depth: 0 + + # 2. The SSOT. + - name: Checkout standards (SSOT source) + uses: actions/checkout@v7.0.1 + with: + repository: ${{ inputs.standards-repository }} + ref: ${{ inputs.standards-ref }} + path: _standards + + # 3. The oracle, at the commit the caller pinned. Not a branch. Ever. + - name: Checkout hypatia (the pinned oracle) + uses: actions/checkout@v7.0.1 + with: + repository: ${{ job.workflow_repository }} + ref: ${{ job.workflow_sha }} + path: _hypatia + + - name: Setup Elixir/OTP + uses: erlef/setup-beam@v1.24.1 + with: + elixir-version: ${{ inputs.elixir-version }} + otp-version: ${{ inputs.otp-version }} + + - name: Record the pin + run: | + echo "oracle: ${{ job.workflow_repository }}@${{ job.workflow_sha }}" | tee -a "$GITHUB_STEP_SUMMARY" + echo "oracle workflow: ${{ job.workflow_ref }}" | tee -a "$GITHUB_STEP_SUMMARY" + + # A pin that cannot be resolved must not fall through to something else. + - name: Verify the pin resolved + run: | + set -euo pipefail + pinned='${{ job.workflow_sha }}' + if ! printf '%s' "$pinned" | grep -Eq '^[0-9a-f]{40}$'; then + echo "::error::could not resolve the pinned oracle SHA (got '${pinned}'); refusing to compile an unpinned oracle" + exit 1 + fi + + - name: Compile the oracle + working-directory: _hypatia + run: | + set -euo pipefail + mix deps.get + mix compile + + - name: Score this repo against the canon + id: oracle + working-directory: _hypatia + run: | + set -euo pipefail + target="$GITHUB_WORKSPACE/${{ inputs.target-path }}" + ssot='${{ inputs.ssot }}' + if [ -z "$ssot" ]; then + ssot="$GITHUB_WORKSPACE/_standards/0-canon/rsr/rsr-criteria-v2.a2ml" + fi + if [ ! -f "$ssot" ]; then + echo "::error::no criteria SSOT at ${ssot} — refusing to score against an unverified catalogue" + exit 1 + fi + + args=(hypatia.rsr_score "$target" --ssot "$ssot") + [ '${{ inputs.write }}' = 'true' ] && args+=(--write) + if [ -n '${{ inputs.fail-under }}' ]; then + args+=(--fail-under '${{ inputs.fail-under }}') + fi + + mix "${args[@]}" 2>&1 | tee "$RUNNER_TEMP/oracle.log" + + line=$(grep -m1 'tier=' "$RUNNER_TEMP/oracle.log" || true) + { + echo "tier=$(printf '%s' "$line" | sed -n 's/.*tier=\([a-z]*\).*/\1/p')" + echo "score=$(printf '%s' "$line" | sed -n 's/.*score=\([0-9.]*\)%.*/\1/p')" + echo "provisional=$(printf '%s' "$line" | sed -n 's/.*provisional=\([a-z]*\).*/\1/p')" + } >> "$GITHUB_OUTPUT" + printf '%s\n' "$line" >> "$GITHUB_STEP_SUMMARY" + + # Deliberately always, and deliberately non-fatal: the point is to publish + # the honest scorecard even when the oracle itself failed or the tier was + # not met. Callers who want the failure still get it from the step above. + - name: Show the scorecard + if: always() + run: | + scorecard="$GITHUB_WORKSPACE/${{ inputs.target-path }}/.machine_readable/scorecards/rsr.scorecard.a2ml" + if [ -f "$scorecard" ]; then + { + echo '## RSR scorecard' + echo '```' + cat "$scorecard" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + else + echo "::warning::no scorecard written — the oracle failed; see the step log" + fi diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index 79e09d76..56312793 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -10,6 +10,77 @@ https://semver.org/[Semantic Versioning]. == [Unreleased] +=== Fixed + +==== `mix compile` was impossible: invalid guard-as-pattern in `ActionsLock` (2026-10-25) + +`lib/rules/actions_lock.ex:170` read + +[source,elixir] +---- +version in @supported_versions -> +---- + +as a bare `case` clause head. `in/2` is a guard expression, not a pattern; as a +clause head it expands to a match against `:erlang.=:=/2`, and the whole module +failed to compile: + +---- +error: cannot invoke remote function :erlang.=:=/2 inside a match +170 │ version in @supported_versions -> + └─ lib/rules/actions_lock.ex:170: Hypatia.Rules.ActionsLock.consume_line/3 + +== Compilation error in file lib/rules/actions_lock.ex == +** (CompileError) ... expanding macro: Kernel.in/2 +---- + +The clause was introduced by #927 (2026-10-20) while teaching the parser to +accept `gh actions-lock` v0.0.3. It (and the `true ->` fallback beside it) was +dead code: `Regex.run/2` with one capture group always returns `[_full, +version]`, so the two `[_, version]` clauses above it already covered every +outcome — and the fallback referenced `version`, which the pattern had not +bound. The dead clauses are gone; the version is now bound by the pattern and +membership is tested in the guards, which is the only form that compiles. + +Two more defects were sitting behind it in the same module, equally invisible +while it did not compile, and both are fixed here as well: + +* **`@hostname` was never defined** — an unreachable second copy of the + `hostname:` parser (the first matching `cond` arm always won) called + `Regex.match?(@hostname, hostname)`. That attribute expands to `nil`, so the + branch would have raised `ArgumentError` had it ever run, and it produced an + "undefined module attribute" warning plus a typing violation that made + `mix compile --warnings-as-errors` impossible. The dead copy is deleted; the + surviving parser is `put_dependency_hostname/3`. +* **`finalize_dependency/1` built a map with two `hostname:` keys.** The second + (raw `nil`) won, so *every* parsed dependency recorded `hostname: nil` and the + seven "v0.0.3 hostname field" assertions in + `test/rules/actions_lock_test.exs` failed. An omitted `hostname:` now records + `github.com`, as the module's own docstring says it should. + +Why it survived five days on `main`: nothing that gates a merge compiled the +Elixir tree. `ci.yml` had no Elixir job, `tests.yml` has `pull_request: null`, +and `escript-soundness.yml` was already red for unrelated reasons. Every +consumer's "RSR oracle — dogfood this repo" job (secret-types #11 and the rest +of the estate) died on `mix deps.get && mix compile` with this exact error and +no way to see why. + +Fixed, plus the two things that let it through: + +* `ci.yml` gains an **`elixir-compile` job** on the exact versions the dogfood + gate uses (Elixir `1.19.4` / OTP `28.3`): `mix deps.get`, + `mix compile --warnings-as-errors`, the `ActionsLock` + `RsrConformance` + regression tests, and the consumers' oracle invocation with an explicit + `--ssot`. It is part of `ci-status`, so it gates. +* The oracle gains a **reusable workflow**, + `.github/workflows/rsr-oracle.yml`, which consumers pin by full commit SHA + (`uses: hyperpolymath/hypatia/.github/workflows/rsr-oracle.yml@`) and + which checks hypatia out at `job.workflow_sha` — the commit the caller + pinned — so `main` is no longer the de facto release. See + `docs/consumers/rsr-oracle.adoc`. The explicit `--ssot` contract is + unchanged: the workflow always passes `--ssot`, defaulting to + `/0-canon/rsr/rsr-criteria-v2.a2ml`. + === Added ==== Group-B detectors — SD021 / WF023 / stale-issue-refs (2026-05-31, #363/#364/#366) diff --git a/README.adoc b/README.adoc index 0feac51a..51be0c44 100644 --- a/README.adoc +++ b/README.adoc @@ -118,6 +118,25 @@ https://github.com/hyperpolymath/hypatia/wiki/Operations[Operations wiki page]. Running the supervision tree also serves an HTML dashboard, an SSE event stream, a Prometheus `/metrics` endpoint and a bearer-authenticated `/api/*` on port 9090. +=== Consuming the RSR oracle from another repository + +The RSR conformance oracle (`mix hypatia.rsr_score`) is consumed through one +reusable workflow, **pinned by full commit SHA**: + +[source,yaml] +---- +jobs: + rsr-score: + uses: hyperpolymath/hypatia/.github/workflows/rsr-oracle.yml@<40-char SHA> +---- + +The pin decides which oracle code runs: the workflow checks this repository out +at `job.workflow_sha`, i.e. the commit you named, so a consumer never tracks +`main`. The SSOT stays explicit — the workflow always passes `--ssot`, defaulting +to `/0-canon/rsr/rsr-criteria-v2.a2ml`. See +link:docs/consumers/rsr-oracle.adoc[docs/consumers/rsr-oracle.adoc], including +why an unpinned consumer broke on 2026-10-25. + == Integration with robot-repo-automaton Hypatia writes JSONL dispatch manifests; `dispatch-runner.sh` in gitbot-fleet consumes them and @@ -144,6 +163,7 @@ by any dispatch path, and the CI blockage currently stopping the Rust lane. * link:docs/rules/catalogue.adoc[Rule catalogue] — every rule module and ID family * link:docs/DEBT-REGISTER.adoc[Debt register] — what is broken and what is not gated * link:docs/governance/ACTIONS-PINNING.adoc[Actions pinning decision] — how `uses:` refs are pinned here (actions.lock, not inline SHAs) +* link:docs/consumers/rsr-oracle.adoc[Consuming the RSR oracle] — the SHA-pinned reusable workflow and the explicit-SSOT contract * https://github.com/hyperpolymath/hypatia/wiki[Wiki] — getting started, architecture, operations, FAQ * https://github.com/hyperpolymath/standards/blob/main/PALIMPSEST.adoc[Palimpsest Philosophy] — philosophical underpinnings (lives in hyperpolymath/standards) diff --git a/docs/consumers/rsr-oracle.adoc b/docs/consumers/rsr-oracle.adoc new file mode 100644 index 00000000..fb676fe1 --- /dev/null +++ b/docs/consumers/rsr-oracle.adoc @@ -0,0 +1,106 @@ += Consuming the RSR oracle +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +:toc: left +:toclevels: 3 + +The RSR v2.0 conformance oracle lives in this repository as +`mix hypatia.rsr_score`. Consumer repositories (the `rsr-template-repo` tree and +everything generated from it) reach it through one reusable workflow: + +[source,yaml] +---- +jobs: + rsr-score: + uses: hyperpolymath/hypatia/.github/workflows/rsr-oracle.yml@<40-char SHA> + with: + standards-ref: <40-char SHA of hyperpolymath/standards> + # fail-under: gold # optional; omit to publish only +---- + +== Rule 1 — pin a commit, never a branch + +The `@ref` you write after `rsr-oracle.yml` is the *only* thing that decides +which oracle runs. The workflow checks hypatia out at `${{ job.workflow_sha }}`, +the commit of the workflow file you pinned, so `@abc123…` compiles and runs +exactly `abc123` — not whatever `main` happens to be at run time. + +Do not use `@main`. It is not a version; it is a moving pointer, and the estate +paid for that on 2026-10-25: + +* 2026-10-20 — #927 added `version in @supported_versions ->` as a bare `case` + clause head in `lib/rules/actions_lock.ex`. That is a guard expression used as + a pattern, so the module did not compile at all. +* Every consumer's generated dogfood gate checked out `hyperpolymath/hypatia` + with no `ref:` and ran `mix deps.get && mix compile`. All of them died in the + same step, in the same way, for five days. +* There was no version to roll back to and no version to hold still while the + fix was written, because the ref in use was `main`. + +The fix (this repository's `elixir-compile` job) now compiles the tree on every +PR using the exact toolchain the gate uses — Elixir `1.19.4` / OTP `28.3` — so +the same defect cannot reach `main` again. The pin is what keeps a *consumer* +safe when something else slips through anyway. + +Release tags (`v*`) are cut from tags by `.github/workflows/release.yml`; a tag +is an acceptable pin for consumers that want a human-readable version, and the +workflow will still resolve it to the commit it names. A full SHA is the +strongest pin and the one used by +`hyperpolymath/standards/.github/workflows/hypatia-scan-reusable.yml`. + +== Rule 2 — the SSOT is always explicit + +The oracle is invoked with an explicit `--ssot`, always. Two ways to say which: + +[cols="1,2",options="header"] +|=== +| Input | Behaviour + +| `ssot:` +| Used verbatim. This is the escape hatch, and the exact invocation the + generated gate has used since the September 2026 standards reorg. + +| omitted +| `/0-canon/rsr/rsr-criteria-v2.a2ml` — the post-reorg home of + the criteria file. The workflow refuses to run if that file is absent rather + than falling back to a fixture that happens to be reachable. + +|=== + +The task's own default resolution (`priv/`, then `test/fixtures/`, then the +built app's `priv/`) is deliberately *not* relied on by the workflow. It +depends on the layout of the checkout that hosts hypatia (#695), and a consumer +must never be scored against a catalogue picked by accident. + +`standards-ref` is optional and empty means "the standards repo's default +branch", which is what the generated gate does today. Pin it if you want your +canon frozen as well — a pinned oracle scored against an unpinned canon is only +half a contract. + +== Rule 3 — gating is opt-in + +Omitting `fail-under` publishes the scorecard and exits `0` even when the tier +is low or the scorecard is provisional. That is deliberate: the scorecard is the +product; the tier gate is a policy each repository adopts when it is ready. A +*provisional* scorecard (automatable coverage < 100%) never satisfies +`--fail-under`, whatever tier it reports. + +== Local use + +The oracle is a mix task; there is nothing to install beyond Elixir 1.19.4 / +OTP 28.3: + +[source,bash] +---- +git clone https://github.com/hyperpolymath/hypatia +git -C hypatia checkout <40-char SHA> # the same pin the workflow uses +cd hypatia +mix deps.get && mix compile + +mix hypatia.rsr_score /path/to/repo \ + --ssot /path/to/standards/0-canon/rsr/rsr-criteria-v2.a2ml \ + --write +---- + +Options: `--ssot PATH`, `--standards PATH` (root of a standards checkout), +`--write`, `--fail-under bronze|silver|gold|rhodium`. From 3d9d587769ce408aa2e89a997e9117a29a565758 Mon Sep 17 00:00:00 2001 From: Arena Agent Date: Sun, 25 Oct 2026 19:58:42 +0000 Subject: [PATCH 03/21] docs(index): link the consumer page from docs/README.adoc Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- docs/README.adoc | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/README.adoc b/docs/README.adoc index dfa2d28c..3b024611 100644 --- a/docs/README.adoc +++ b/docs/README.adoc @@ -60,6 +60,10 @@ acceptance criteria for closing the underlying roadmap item. * link:operations/nx-exla-neural-backend.adoc[`nx-exla-neural-backend.adoc`] — conditional migration plan. * link:operations/containerfiles-chainguard-gap.adoc[`containerfiles-chainguard-gap.adoc`] — estate Chainguard base-image audit. +== Consuming hypatia + +* link:consumers/rsr-oracle.adoc[`consumers/rsr-oracle.adoc`] — the RSR oracle as a SHA-pinned reusable workflow: the pin rule, the explicit-SSOT contract, and why `@main` is not a version. + == Integration * link:integration/a2ml.adoc[`a2ml.adoc`] — A2ML machine-readable metadata integration. From c5a127fd93782834263dbcd481341b353e8713a1 Mon Sep 17 00:00:00 2001 From: Arena Agent Date: Sun, 25 Oct 2026 20:25:35 +0000 Subject: [PATCH 04/21] fix(actions_lock): treat an omitted hostname and explicit github.com as one host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review (P1). `scope_to_host/2` compared the raw hostname, so a lock that named `github.com` on one dependency and omitted it on another put the two pins in different identity scopes — and the owner_id/repo_id consistency check, which exists to catch a pin whose identity does not match the recorded repository, was skipped entirely for that pair. Normalizing `nil` to the home host on both sides closes it. Regression test added: the same owner-id conflict spelled implicitly and explicitly is caught, and agreeing identities across the two spellings parse. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- lib/rules/actions_lock.ex | 22 ++++++++++++++--- test/rules/actions_lock_test.exs | 41 ++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/lib/rules/actions_lock.ex b/lib/rules/actions_lock.ex index a3c8b19d..6fe9b626 100644 --- a/lib/rules/actions_lock.ex +++ b/lib/rules/actions_lock.ex @@ -434,14 +434,30 @@ defmodule Hypatia.Rules.ActionsLock do # Owner and repository IDs are numbered per GitHub instance (the v0.0.3 # schema defines them "on the recorded hostname"), so identity is compared - # only among pins recorded on the same host. An omitted hostname is its own - # scope (the lock's home host). + # only among pins recorded on the same host. + # + # An OMITTED hostname and an EXPLICIT `github.com` are the same host, so both + # sides are normalized to the home host before comparing. Comparing the raw + # values instead made them two scopes, which let a lockfile split one + # repository's identity across the implicit and explicit spelling and skip + # the `owner_id`/`repo_id` consistency check entirely — the check exists to + # catch a pin whose identity does not match the recorded repository, so a + # spelling that evades it is a defect, not a distinction. defp scope_to_host(dependencies, hostname) do + scope = normalise_hostname(hostname) + dependencies - |> Enum.filter(fn {_ref, dependency} -> dependency.hostname == hostname end) + |> Enum.filter(fn {_ref, dependency} -> + normalise_hostname(dependency.hostname) == scope + end) |> Map.new() end + # `nil` is how every record spells "no host named", which binds to the home + # host; v0.0.2 has no hostname field at all and is the same case. + defp normalise_hostname(nil), do: @home_hostname + defp normalise_hostname(hostname), do: hostname + defp validate_repository_identity(dependencies, normalized, owner_id, repo_id, line_number) do [repository, _ref] = String.split(normalized, "@", parts: 2) [owner, _repo] = String.split(repository, "/", parts: 2) diff --git a/test/rules/actions_lock_test.exs b/test/rules/actions_lock_test.exs index 1c1dc4b7..19fc92f4 100644 --- a/test/rules/actions_lock_test.exs +++ b/test/rules/actions_lock_test.exs @@ -559,6 +559,47 @@ defmodule Hypatia.Rules.ActionsLockTest do ActionsLock.parse(same_host) end + test "an omitted hostname and an explicit github.com are one identity scope" do + # Same conflict as above, but spelled two ways: `acme/bar` names the home + # host explicitly, `acme/foo` omits it. Both bind to github.com, so the + # owner-id disagreement must still be caught. Comparing raw values made + # these two scopes and silently skipped the check (Codex review, P1). + split = """ + version: 'v0.0.3' + workflows: + '.github/workflows/ci.yml': + - 'acme/bar@v1' + - 'acme/foo@v1' + dependencies: + 'acme/bar@v1': + hostname: 'github.com' + ref: 'v1' + commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1' + owner_id: 2 + repo_id: 20 + 'acme/foo@v1': + ref: 'v1' + commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1' + owner_id: 1 + repo_id: 20 + """ + + assert {:error, {:line, _, {:owner_identity_mismatch, "acme", 2, 1}}} = + ActionsLock.parse(split) + + # ... and agreeing identities across the two spellings stay legal. (repo_id + # must differ too: the same repo_id on two different repositories is its + # own defect, `:repository_id_reused`.) + agreeing = + String.replace( + split, + " owner_id: 1\n repo_id: 20", + " owner_id: 2\n repo_id: 21" + ) + + assert {:ok, _} = ActionsLock.parse(agreeing) + end + test "parses the repository's authoritative generated lockfile" do content = File.read!(".github/workflows/actions.lock") assert {:ok, parsed} = ActionsLock.parse(content) From 46760a762ca82ad0b1f2da3b1d4bc620957b8a34 Mon Sep 17 00:00:00 2001 From: Arena Agent Date: Sun, 25 Oct 2026 20:25:53 +0000 Subject: [PATCH 05/21] fix(rsr-oracle): refuse branch pins, score only the caller's tree, keep gate output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review of the new reusable workflow: * P1 — the helper checkouts (`_standards`, `_hypatia`) sat inside the workspace, i.e. inside the tree being scored, and the scorer walks the tree recursively. They now live in $RUNNER_TEMP, so the oracle cannot grade itself or inflate coverage with the canon. * P2 — checking that job.workflow_sha is 40 hex chars does not detect a branch pin, because `@main` resolves to a commit too. The caller's ref is read from job.workflow_ref and branch refs now fail, as the docs already claimed. * P2 — with --fail-under, `set -euo pipefail` killed the step at the pipeline, so tier/score/provisional were never published on exactly the runs that failed. The status is captured, outputs are published, then the job fails. * P2 — a missing target-path was passed to an engine that treats a missing tree as a sparse repository, publishing a scorecard for nothing. Now an error. Also stops interpolating caller inputs into `run:` bodies (shell injection): they arrive through env instead. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/workflows/rsr-oracle.yml | 96 +++++++++++++++++++++++++++----- docs/consumers/rsr-oracle.adoc | 24 +++++++- 2 files changed, 102 insertions(+), 18 deletions(-) diff --git a/.github/workflows/rsr-oracle.yml b/.github/workflows/rsr-oracle.yml index b8e3cb0d..29d0eb43 100644 --- a/.github/workflows/rsr-oracle.yml +++ b/.github/workflows/rsr-oracle.yml @@ -136,12 +136,20 @@ jobs: fetch-depth: 0 # 2. The SSOT. + # + # Both helper checkouts go OUTSIDE the workspace on purpose. Anywhere + # inside it they land inside the tree being scored: the oracle walks the + # repository recursively, so `_standards` and `_hypatia` would be scored + # as if they were the caller's own files — the oracle grading itself, and + # the canon inflating the caller's own criteria coverage. The previous + # generated gate used `../_standards`, which is why the working-directory + # path in the scoring step below is `$RUNNER_TEMP`. - name: Checkout standards (SSOT source) uses: actions/checkout@v7.0.1 with: repository: ${{ inputs.standards-repository }} ref: ${{ inputs.standards-ref }} - path: _standards + path: ${{ runner.temp }}/_standards # 3. The oracle, at the commit the caller pinned. Not a branch. Ever. - name: Checkout hypatia (the pinned oracle) @@ -149,7 +157,7 @@ jobs: with: repository: ${{ job.workflow_repository }} ref: ${{ job.workflow_sha }} - path: _hypatia + path: ${{ runner.temp }}/_hypatia - name: Setup Elixir/OTP uses: erlef/setup-beam@v1.24.1 @@ -162,18 +170,46 @@ jobs: echo "oracle: ${{ job.workflow_repository }}@${{ job.workflow_sha }}" | tee -a "$GITHUB_STEP_SUMMARY" echo "oracle workflow: ${{ job.workflow_ref }}" | tee -a "$GITHUB_STEP_SUMMARY" - # A pin that cannot be resolved must not fall through to something else. - - name: Verify the pin resolved + # A pin that cannot be resolved must not fall through to something else — + # and a *branch* that resolves is not a pin either. + # + # `job.workflow_sha` is the resolved commit whatever the caller wrote, so + # `@main` passes a bare 40-hex check and then silently follows a moving + # branch once a week. The caller's ref is recoverable from + # `job.workflow_ref` (`owner/repo/.github/workflows/rsr-oracle.yml@`), + # and that is what gets checked. + - name: Verify the pin is a commit, not a branch + env: + WORKFLOW_REF: ${{ job.workflow_ref }} + WORKFLOW_SHA: ${{ job.workflow_sha }} run: | set -euo pipefail - pinned='${{ job.workflow_sha }}' + pinned="$WORKFLOW_SHA" + ref="${WORKFLOW_REF##*@}" + # Accept a full or abbreviated SHA. Tags are immutable once cut and are + # allowed, but must be spelled `refs/tags/...` or a plain `vX.Y.Z`, so + # a tag can never be confused with a branch of the same name. + ok=0 + case "$ref" in + refs/tags/*|v[0-9]*) ok=1 ;; + refs/heads/*|main|master|HEAD|develop) ok=0 ;; + *) printf '%s' "$ref" | grep -Eq '^[0-9a-f]{7,40}$' && ok=1 ;; + esac + if [ "$ok" != "1" ]; then + echo "::error::the oracle is pinned to '${ref}', which is not an immutable ref. Pin a full commit SHA (or a release tag); '@main' is what let one broken commit break every consumer at once." + exit 1 + fi if ! printf '%s' "$pinned" | grep -Eq '^[0-9a-f]{40}$'; then echo "::error::could not resolve the pinned oracle SHA (got '${pinned}'); refusing to compile an unpinned oracle" exit 1 fi + { + echo "pinned ref: \`${ref}\`" + echo "resolved commit: \`${pinned}\`" + } >> "$GITHUB_STEP_SUMMARY" - name: Compile the oracle - working-directory: _hypatia + working-directory: ${{ runner.temp }}/_hypatia run: | set -euo pipefail mix deps.get @@ -181,13 +217,29 @@ jobs: - name: Score this repo against the canon id: oracle - working-directory: _hypatia + working-directory: ${{ runner.temp }}/_hypatia + env: + # Nothing user-supplied is interpolated into the script body: an input + # spliced into `run:` is a shell injection point. + TARGET_PATH: ${{ inputs.target-path }} + SSOT_INPUT: ${{ inputs.ssot }} + WRITE_SCORECARD: ${{ inputs.write }} + FAIL_UNDER: ${{ inputs.fail-under }} run: | - set -euo pipefail - target="$GITHUB_WORKSPACE/${{ inputs.target-path }}" - ssot='${{ inputs.ssot }}' + set -uo pipefail + target="$GITHUB_WORKSPACE/${TARGET_PATH}" + ssot="${SSOT_INPUT}" if [ -z "$ssot" ]; then - ssot="$GITHUB_WORKSPACE/_standards/0-canon/rsr/rsr-criteria-v2.a2ml" + ssot="${RUNNER_TEMP}/_standards/0-canon/rsr/rsr-criteria-v2.a2ml" + fi + + # A missing target must not be scored. The engine treats a missing tree + # as a sparse repository rather than an error, so a typo in + # `target-path` would publish a confident-looking scorecard for a + # directory that does not exist. + if [ ! -d "$target" ]; then + echo "::error::target path '${TARGET_PATH}' does not exist in the checked-out repository — refusing to score nothing" + exit 1 fi if [ ! -f "$ssot" ]; then echo "::error::no criteria SSOT at ${ssot} — refusing to score against an unverified catalogue" @@ -195,14 +247,24 @@ jobs: fi args=(hypatia.rsr_score "$target" --ssot "$ssot") - [ '${{ inputs.write }}' = 'true' ] && args+=(--write) - if [ -n '${{ inputs.fail-under }}' ]; then - args+=(--fail-under '${{ inputs.fail-under }}') - fi + [ "$WRITE_SCORECARD" = "true" ] && args+=(--write) + [ -n "$FAIL_UNDER" ] && args+=(--fail-under "$FAIL_UNDER") + # Capture the status BEFORE reading the report. The task writes the + # scorecard and the report line, then exits non-zero when `--fail-under` + # is missed or the scorecard is provisional; with `-e` the step would + # die at this line and publish no tier, score or provisional output at + # all — precisely when a caller most wants them. + set +e mix "${args[@]}" 2>&1 | tee "$RUNNER_TEMP/oracle.log" + status=${PIPESTATUS[0]} + set -e line=$(grep -m1 'tier=' "$RUNNER_TEMP/oracle.log" || true) + if [ -z "$line" ]; then + echo "::error::the oracle produced no scorecard line (exit ${status})" + exit 1 + fi { echo "tier=$(printf '%s' "$line" | sed -n 's/.*tier=\([a-z]*\).*/\1/p')" echo "score=$(printf '%s' "$line" | sed -n 's/.*score=\([0-9.]*\)%.*/\1/p')" @@ -210,6 +272,10 @@ jobs: } >> "$GITHUB_OUTPUT" printf '%s\n' "$line" >> "$GITHUB_STEP_SUMMARY" + # The caller asked for a gate; the gate still fails the job. The + # difference is that the outputs above were published first. + exit "$status" + # Deliberately always, and deliberately non-fatal: the point is to publish # the honest scorecard even when the oracle itself failed or the tier was # not met. Callers who want the failure still get it from the step above. diff --git a/docs/consumers/rsr-oracle.adoc b/docs/consumers/rsr-oracle.adoc index fb676fe1..bf2b0c4e 100644 --- a/docs/consumers/rsr-oracle.adoc +++ b/docs/consumers/rsr-oracle.adoc @@ -44,10 +44,22 @@ safe when something else slips through anyway. Release tags (`v*`) are cut from tags by `.github/workflows/release.yml`; a tag is an acceptable pin for consumers that want a human-readable version, and the -workflow will still resolve it to the commit it names. A full SHA is the -strongest pin and the one used by +workflow resolves it to the commit it names. A full SHA is the strongest pin and +the one used by `hyperpolymath/standards/.github/workflows/hypatia-scan-reusable.yml`. +The workflow enforces this rather than trusting the comment: it reads the ref +you wrote (from `job.workflow_ref`) and **fails** on `refs/heads/*`, `main`, +`master`, `develop` or `HEAD`. Checking only the resolved commit would not be +enough — `@main` also resolves to a 40-character SHA — so the ref itself is +what gets tested. + +Both helper checkouts (the oracle, and the standards tree) are cloned into +`$RUNNER_TEMP`, *outside* the workspace. Anywhere inside it they would fall +inside the tree being scored: the oracle walks the repository recursively, so +the canon and the oracle itself would be counted as part of your repository — +the oracle grading itself. + == Rule 2 — the SSOT is always explicit The oracle is invoked with an explicit `--ssot`, always. Two ways to say which: @@ -77,10 +89,16 @@ branch", which is what the generated gate does today. Pin it if you want your canon frozen as well — a pinned oracle scored against an unpinned canon is only half a contract. +A `target-path` that does not exist in your repository is an error, not a score: +the engine treats a missing tree as a sparse repository, so a typo would +otherwise publish a confident-looking scorecard for nothing. + == Rule 3 — gating is opt-in Omitting `fail-under` publishes the scorecard and exits `0` even when the tier -is low or the scorecard is provisional. That is deliberate: the scorecard is the +is low or the scorecard is provisional. When you do set it, the tier/score/ +provisional outputs are published *before* the job fails, so a red run still +tells you what it scored. That is deliberate: the scorecard is the product; the tier gate is a policy each repository adopts when it is ready. A *provisional* scorecard (automatable coverage < 100%) never satisfies `--fail-under`, whatever tier it reports. From b6b3b1cd77daffe911b5e86436997b71994eeee8 Mon Sep 17 00:00:00 2001 From: Arena Agent Date: Sun, 25 Oct 2026 20:25:58 +0000 Subject: [PATCH 06/21] ci(tmp): verify pin refusal, missing target, and gate output capture Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/workflows/tmp-branch-pin.yml | 15 ++++++++++++++ .github/workflows/tmp-verify.yml | 29 ++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) create mode 100644 .github/workflows/tmp-branch-pin.yml create mode 100644 .github/workflows/tmp-verify.yml diff --git a/.github/workflows/tmp-branch-pin.yml b/.github/workflows/tmp-branch-pin.yml new file mode 100644 index 00000000..6c8071c9 --- /dev/null +++ b/.github/workflows/tmp-branch-pin.yml @@ -0,0 +1,15 @@ +# SPDX-License-Identifier: MPL-2.0 +# TEMPORARY verification (removed before merge): a branch pin must be refused. +name: tmp-branch-pin + +on: + pull_request: + +permissions: + contents: read + +jobs: + oracle: + uses: hyperpolymath/hypatia/.github/workflows/rsr-oracle.yml@main + with: + target-path: '.' diff --git a/.github/workflows/tmp-verify.yml b/.github/workflows/tmp-verify.yml new file mode 100644 index 00000000..44733851 --- /dev/null +++ b/.github/workflows/tmp-verify.yml @@ -0,0 +1,29 @@ +# SPDX-License-Identifier: MPL-2.0 +# TEMPORARY verification (removed before merge): pins the oracle by SHA, and +# proves the three guarded behaviours. +name: tmp-verify + +on: + pull_request: + +permissions: + contents: read + +jobs: + pinned-score: + name: pinned SHA, real target + uses: ./.github/workflows/rsr-oracle.yml + with: + target-path: '.' + + bad-target: + name: bogus target-path must fail + uses: ./.github/workflows/rsr-oracle.yml + with: + target-path: 'does/not/exist' + + gate-fail: + name: fail-under rhodium must fail but publish outputs + uses: ./.github/workflows/rsr-oracle.yml + with: + fail-under: 'rhodium' From cd1fdfecda9ee9e1ecaa0efbf299dced3894d11c Mon Sep 17 00:00:00 2001 From: Arena Agent Date: Sun, 25 Oct 2026 20:29:11 +0000 Subject: [PATCH 07/21] fix(rsr-oracle): score a subdirectory checkout so helper checkouts stay outside it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `actions/checkout` refuses a `path` outside the workspace (that is why the $RUNNER_TEMP attempt failed), so the caller's repository is checked out into `repo/` and the oracle and canon are its siblings at the workspace root. The scored tree is still only the caller's files — the property that matters — without asking checkout to do something it will not do. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/workflows/rsr-oracle.yml | 32 ++++++++++++++++---------------- docs/consumers/rsr-oracle.adoc | 12 +++++++----- 2 files changed, 23 insertions(+), 21 deletions(-) diff --git a/.github/workflows/rsr-oracle.yml b/.github/workflows/rsr-oracle.yml index 29d0eb43..f0089f29 100644 --- a/.github/workflows/rsr-oracle.yml +++ b/.github/workflows/rsr-oracle.yml @@ -128,28 +128,28 @@ jobs: provisional: ${{ steps.oracle.outputs.provisional }} steps: # 1. The repository being scored (the caller's). + # + # Into `repo/`, not the workspace root: the two helper checkouts below + # have to live somewhere, `actions/checkout` refuses paths outside the + # workspace, and anything at the workspace root other than the caller's + # files would be inside the tree the oracle walks — it would score the + # oracle and the canon as if they were the caller's own files. Scoring + # `repo/` puts them outside it. - name: Checkout repository under test uses: actions/checkout@v7.0.1 with: repository: ${{ github.repository }} ref: ${{ inputs.target-ref != '' && inputs.target-ref || github.event.pull_request.head.sha || github.sha }} + path: repo fetch-depth: 0 - # 2. The SSOT. - # - # Both helper checkouts go OUTSIDE the workspace on purpose. Anywhere - # inside it they land inside the tree being scored: the oracle walks the - # repository recursively, so `_standards` and `_hypatia` would be scored - # as if they were the caller's own files — the oracle grading itself, and - # the canon inflating the caller's own criteria coverage. The previous - # generated gate used `../_standards`, which is why the working-directory - # path in the scoring step below is `$RUNNER_TEMP`. + # 2. The SSOT. Sibling of `repo/`, so it is outside the scored tree. - name: Checkout standards (SSOT source) uses: actions/checkout@v7.0.1 with: repository: ${{ inputs.standards-repository }} ref: ${{ inputs.standards-ref }} - path: ${{ runner.temp }}/_standards + path: _standards # 3. The oracle, at the commit the caller pinned. Not a branch. Ever. - name: Checkout hypatia (the pinned oracle) @@ -157,7 +157,7 @@ jobs: with: repository: ${{ job.workflow_repository }} ref: ${{ job.workflow_sha }} - path: ${{ runner.temp }}/_hypatia + path: _hypatia - name: Setup Elixir/OTP uses: erlef/setup-beam@v1.24.1 @@ -209,7 +209,7 @@ jobs: } >> "$GITHUB_STEP_SUMMARY" - name: Compile the oracle - working-directory: ${{ runner.temp }}/_hypatia + working-directory: _hypatia run: | set -euo pipefail mix deps.get @@ -217,7 +217,7 @@ jobs: - name: Score this repo against the canon id: oracle - working-directory: ${{ runner.temp }}/_hypatia + working-directory: _hypatia env: # Nothing user-supplied is interpolated into the script body: an input # spliced into `run:` is a shell injection point. @@ -227,10 +227,10 @@ jobs: FAIL_UNDER: ${{ inputs.fail-under }} run: | set -uo pipefail - target="$GITHUB_WORKSPACE/${TARGET_PATH}" + target="$GITHUB_WORKSPACE/repo/${TARGET_PATH}" ssot="${SSOT_INPUT}" if [ -z "$ssot" ]; then - ssot="${RUNNER_TEMP}/_standards/0-canon/rsr/rsr-criteria-v2.a2ml" + ssot="$GITHUB_WORKSPACE/_standards/0-canon/rsr/rsr-criteria-v2.a2ml" fi # A missing target must not be scored. The engine treats a missing tree @@ -282,7 +282,7 @@ jobs: - name: Show the scorecard if: always() run: | - scorecard="$GITHUB_WORKSPACE/${{ inputs.target-path }}/.machine_readable/scorecards/rsr.scorecard.a2ml" + scorecard="$GITHUB_WORKSPACE/repo/${{ inputs.target-path }}/.machine_readable/scorecards/rsr.scorecard.a2ml" if [ -f "$scorecard" ]; then { echo '## RSR scorecard' diff --git a/docs/consumers/rsr-oracle.adoc b/docs/consumers/rsr-oracle.adoc index bf2b0c4e..3fb2bd4e 100644 --- a/docs/consumers/rsr-oracle.adoc +++ b/docs/consumers/rsr-oracle.adoc @@ -54,11 +54,13 @@ you wrote (from `job.workflow_ref`) and **fails** on `refs/heads/*`, `main`, enough — `@main` also resolves to a 40-character SHA — so the ref itself is what gets tested. -Both helper checkouts (the oracle, and the standards tree) are cloned into -`$RUNNER_TEMP`, *outside* the workspace. Anywhere inside it they would fall -inside the tree being scored: the oracle walks the repository recursively, so -the canon and the oracle itself would be counted as part of your repository — -the oracle grading itself. +Your repository is checked out into `repo/`, with the two helper checkouts +(oracle, canon) as its siblings. They must be outside the scored tree: the +oracle walks a repository recursively, so a canon and an oracle sitting inside +it would be counted as your files — the oracle grading itself. The `path` of +each is worth knowing if you reference the scorecard in a later step: it is +written to `repo//.machine_readable/scorecards/rsr.scorecard.a2ml` +and also printed to the job summary. == Rule 2 — the SSOT is always explicit From 1a585cb12fa4c032e13f41e4074a24999cacf483 Mon Sep 17 00:00:00 2001 From: Arena Agent Date: Sun, 25 Oct 2026 20:32:25 +0000 Subject: [PATCH 08/21] fix(rsr-oracle): allow same-repo calls when checking the pin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A same-repository `uses:` resolves the oracle at the caller's own commit, so its ref cannot float independently of the caller — only a cross-repository call can follow someone else's branch. The check now distinguishes the two; `hyperpolymath/hypatia/...@main` from another repository is still refused. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/workflows/rsr-oracle.yml | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/.github/workflows/rsr-oracle.yml b/.github/workflows/rsr-oracle.yml index f0089f29..4f2e303f 100644 --- a/.github/workflows/rsr-oracle.yml +++ b/.github/workflows/rsr-oracle.yml @@ -182,10 +182,20 @@ jobs: env: WORKFLOW_REF: ${{ job.workflow_ref }} WORKFLOW_SHA: ${{ job.workflow_sha }} + WORKFLOW_REPOSITORY: ${{ job.workflow_repository }} + THIS_REPOSITORY: ${{ github.repository }} run: | set -euo pipefail pinned="$WORKFLOW_SHA" ref="${WORKFLOW_REF##*@}" + + # A same-repository call (`uses: ./.github/workflows/rsr-oracle.yml`, or + # `owner/this-repo/...`) runs the oracle from the caller's own commit, + # so whatever ref named it is no more volatile than the caller itself. + # Only a CROSS-repository call can float on someone else's branch. + repo_is_self=0 + [ "$WORKFLOW_REPOSITORY" = "$THIS_REPOSITORY" ] && repo_is_self=1 + # Accept a full or abbreviated SHA. Tags are immutable once cut and are # allowed, but must be spelled `refs/tags/...` or a plain `vX.Y.Z`, so # a tag can never be confused with a branch of the same name. @@ -195,8 +205,11 @@ jobs: refs/heads/*|main|master|HEAD|develop) ok=0 ;; *) printf '%s' "$ref" | grep -Eq '^[0-9a-f]{7,40}$' && ok=1 ;; esac - if [ "$ok" != "1" ]; then - echo "::error::the oracle is pinned to '${ref}', which is not an immutable ref. Pin a full commit SHA (or a release tag); '@main' is what let one broken commit break every consumer at once." + if [ "$repo_is_self" = "1" ]; then + ok=1 + echo "oracle: same-repository call, resolved at the caller's own commit (${ref})" + elif [ "$ok" != "1" ]; then + echo "::error::the oracle is pinned to '${ref}' in ${WORKFLOW_REPOSITORY}, which is not an immutable ref. Pin a full commit SHA (or a release tag); '@main' is what let one broken commit break every consumer at once." exit 1 fi if ! printf '%s' "$pinned" | grep -Eq '^[0-9a-f]{40}$'; then From d04d986b450a8d61589aa317f3150bf9545c6f2e Mon Sep 17 00:00:00 2001 From: Arena Agent Date: Sun, 25 Oct 2026 20:36:01 +0000 Subject: [PATCH 09/21] ci(tmp): verify caller-visible outputs and the pin decision table Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/workflows/tmp-branch-pin.yml | 15 ------ .github/workflows/tmp-verify.yml | 81 +++++++++++++++++++++++++++- 2 files changed, 79 insertions(+), 17 deletions(-) delete mode 100644 .github/workflows/tmp-branch-pin.yml diff --git a/.github/workflows/tmp-branch-pin.yml b/.github/workflows/tmp-branch-pin.yml deleted file mode 100644 index 6c8071c9..00000000 --- a/.github/workflows/tmp-branch-pin.yml +++ /dev/null @@ -1,15 +0,0 @@ -# SPDX-License-Identifier: MPL-2.0 -# TEMPORARY verification (removed before merge): a branch pin must be refused. -name: tmp-branch-pin - -on: - pull_request: - -permissions: - contents: read - -jobs: - oracle: - uses: hyperpolymath/hypatia/.github/workflows/rsr-oracle.yml@main - with: - target-path: '.' diff --git a/.github/workflows/tmp-verify.yml b/.github/workflows/tmp-verify.yml index 44733851..9fa6e52e 100644 --- a/.github/workflows/tmp-verify.yml +++ b/.github/workflows/tmp-verify.yml @@ -1,6 +1,5 @@ # SPDX-License-Identifier: MPL-2.0 -# TEMPORARY verification (removed before merge): pins the oracle by SHA, and -# proves the three guarded behaviours. +# TEMPORARY verification (removed before merge). name: tmp-verify on: @@ -8,6 +7,7 @@ on: permissions: contents: read + issues: write jobs: pinned-score: @@ -27,3 +27,80 @@ jobs: uses: ./.github/workflows/rsr-oracle.yml with: fail-under: 'rhodium' + + # The called workflow's outputs are only visible to a downstream job. + relay: + name: relay outputs + needs: [pinned-score, bad-target, gate-fail] + if: always() + runs-on: ubuntu-latest + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - name: Report caller-visible results + run: | + set -euo pipefail + body=$(cat <<'EOF' + ### Reusable workflow, caller-visible results + + | job | result | tier | score | provisional | + |---|---|---|---|---| + | pinned SHA, real target | `${{ needs.pinned-score.result }}` | `${{ needs.pinned-score.outputs.tier }}` | `${{ needs.pinned-score.outputs.score }}` | `${{ needs.pinned-score.outputs.provisional }}` | + | bogus target-path | `${{ needs.bad-target.result }}` | `${{ needs.bad-target.outputs.tier }}` | `${{ needs.bad-target.outputs.score }}` | `${{ needs.bad-target.outputs.provisional }}` | + | fail-under rhodium | `${{ needs.gate-fail.result }}` | `${{ needs.gate-fail.outputs.tier }}` | `${{ needs.gate-fail.outputs.score }}` | `${{ needs.gate-fail.outputs.provisional }}` | + + Expected: pinned = success with a tier; bogus = failure with NO score + (it refuses to score a missing tree); fail-under = failure BUT with a + tier/score published before the failure. + EOF + ) + python3 -c 'import json,sys; print(json.dumps({"body": sys.argv[1]}))' "$body" > /tmp/b.json + curl -sS -X POST -H "Authorization: token ${GH_TOKEN}" \ + -H "Accept: application/vnd.github+json" \ + "https://api.github.com/repos/${GITHUB_REPOSITORY}/issues/956/comments" \ + -d @/tmp/b.json -o /dev/null -w 'comment=%{http_code}\n' + + # The cross-repository branch-pin guard cannot be exercised from a PR: this + # org blocks a newly added cross-repo `uses:` at startup. Test the decision + # logic itself instead, on sample refs. + pin-logic: + name: pin-ref decision logic + runs-on: ubuntu-latest + steps: + - name: Check the accept/reject table + run: | + set -euo pipefail + decide() { # ref, same-repo -> 0 accept / 1 reject + local ref="$1" self="$2" ok=0 + case "$ref" in + refs/tags/*|v[0-9]*) ok=1 ;; + refs/heads/*|main|master|HEAD|develop) ok=0 ;; + *) printf '%s' "$ref" | grep -Eq '^[0-9a-f]{7,40}$' && ok=1 ;; + esac + [ "$self" = "1" ] && ok=1 + [ "$ok" = "1" ] && return 0 + return 1 + } + fail=0 + check() { # expect(ref,self) desc + if "$@" >/dev/null 2>&1; then echo " accept"; else echo " reject"; fi + } + expect_accept() { decide "$1" "$2" || { echo "FAIL: expected accept: ref=$1 self=$2"; fail=1; }; } + expect_reject() { if decide "$1" "$2"; then echo "FAIL: expected reject: ref=$1 self=$2"; fail=1; fi; } + + expect_accept "9f1e2d3c4b5a69788796a5b4c3d2e1f0a1b2c3d4" 0 + expect_accept "9f1e2d3" 0 + expect_accept "refs/tags/v0.1.2" 0 + expect_accept "v0.1.2" 0 + expect_accept "refs/heads/main" 0 # same repo only + expect_accept "refs/heads/main" 1 + expect_accept "refs/pull/956/merge" 1 + expect_reject "main" 0 + expect_reject "master" 0 + expect_reject "develop" 0 + expect_reject "HEAD" 0 + expect_reject "refs/heads/arena/0cc4e3dd-hypatia" 0 + expect_reject "not-a-sha" 0 + + if [ "$fail" != "0" ]; then echo "::error::pin logic table failed"; exit 1; fi + echo "pin logic table: all cases pass" From 94e8bbaeaccb5fc7432e35bd98f06e53ae526968 Mon Sep 17 00:00:00 2001 From: Arena Agent Date: Sun, 25 Oct 2026 20:39:38 +0000 Subject: [PATCH 10/21] ci(tmp): make the verification self-asserting via annotations Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/workflows/tmp-verify.yml | 56 +++++++++++++------------------- 1 file changed, 22 insertions(+), 34 deletions(-) diff --git a/.github/workflows/tmp-verify.yml b/.github/workflows/tmp-verify.yml index 9fa6e52e..bad78b42 100644 --- a/.github/workflows/tmp-verify.yml +++ b/.github/workflows/tmp-verify.yml @@ -34,31 +34,23 @@ jobs: needs: [pinned-score, bad-target, gate-fail] if: always() runs-on: ubuntu-latest - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - - name: Report caller-visible results + - name: Assert caller-visible results run: | - set -euo pipefail - body=$(cat <<'EOF' - ### Reusable workflow, caller-visible results - - | job | result | tier | score | provisional | - |---|---|---|---|---| - | pinned SHA, real target | `${{ needs.pinned-score.result }}` | `${{ needs.pinned-score.outputs.tier }}` | `${{ needs.pinned-score.outputs.score }}` | `${{ needs.pinned-score.outputs.provisional }}` | - | bogus target-path | `${{ needs.bad-target.result }}` | `${{ needs.bad-target.outputs.tier }}` | `${{ needs.bad-target.outputs.score }}` | `${{ needs.bad-target.outputs.provisional }}` | - | fail-under rhodium | `${{ needs.gate-fail.result }}` | `${{ needs.gate-fail.outputs.tier }}` | `${{ needs.gate-fail.outputs.score }}` | `${{ needs.gate-fail.outputs.provisional }}` | + set -uo pipefail + fail=0 + report() { printf '::notice title=%s::result=%s tier=%s score=%s provisional=%s\n' "$1" "$2" "$3" "$4" "$5"; } + report pinned '${{ needs.pinned-score.result }}' '${{ needs.pinned-score.outputs.tier }}' '${{ needs.pinned-score.outputs.score }}' '${{ needs.pinned-score.outputs.provisional }}' + report bad-target '${{ needs.bad-target.result }}' '${{ needs.bad-target.outputs.tier }}' '${{ needs.bad-target.outputs.score }}' '${{ needs.bad-target.outputs.provisional }}' + report gate-fail '${{ needs.gate-fail.result }}' '${{ needs.gate-fail.outputs.tier }}' '${{ needs.gate-fail.outputs.score }}' '${{ needs.gate-fail.outputs.provisional }}' - Expected: pinned = success with a tier; bogus = failure with NO score - (it refuses to score a missing tree); fail-under = failure BUT with a - tier/score published before the failure. - EOF - ) - python3 -c 'import json,sys; print(json.dumps({"body": sys.argv[1]}))' "$body" > /tmp/b.json - curl -sS -X POST -H "Authorization: token ${GH_TOKEN}" \ - -H "Accept: application/vnd.github+json" \ - "https://api.github.com/repos/${GITHUB_REPOSITORY}/issues/956/comments" \ - -d @/tmp/b.json -o /dev/null -w 'comment=%{http_code}\n' + [ '${{ needs.pinned-score.result }}' = 'success' ] || { echo "::error::pinned run did not succeed"; fail=1; } + [ -n '${{ needs.pinned-score.outputs.tier }}' ] || { echo "::error::pinned run published no tier"; fail=1; } + [ '${{ needs.bad-target.result }}' = 'failure' ] || { echo "::error::missing target did not fail"; fail=1; } + [ -z '${{ needs.bad-target.outputs.tier }}' ] || { echo "::error::missing target still published a tier"; fail=1; } + [ '${{ needs.gate-fail.result }}' = 'failure' ] || { echo "::error::fail-under gate did not fail"; fail=1; } + [ -n '${{ needs.gate-fail.outputs.tier }}' ] || { echo "::error::gate failure published no tier (outputs lost)"; fail=1; } + exit "$fail" # The cross-repository branch-pin guard cannot be exercised from a PR: this # org blocks a newly added cross-repo `uses:` at startup. Test the decision @@ -70,37 +62,33 @@ jobs: - name: Check the accept/reject table run: | set -euo pipefail - decide() { # ref, same-repo -> 0 accept / 1 reject + try_decide() { # ref, same-repo -> 0 accept / 1 reject local ref="$1" self="$2" ok=0 case "$ref" in refs/tags/*|v[0-9]*) ok=1 ;; refs/heads/*|main|master|HEAD|develop) ok=0 ;; - *) printf '%s' "$ref" | grep -Eq '^[0-9a-f]{7,40}$' && ok=1 ;; + *) if printf '%s' "$ref" | grep -Eq '^[0-9a-f]{7,40}$'; then ok=1; fi ;; esac - [ "$self" = "1" ] && ok=1 - [ "$ok" = "1" ] && return 0 - return 1 + if [ "$self" = "1" ]; then ok=1; fi + [ "$ok" = "1" ] } fail=0 - check() { # expect(ref,self) desc - if "$@" >/dev/null 2>&1; then echo " accept"; else echo " reject"; fi - } - expect_accept() { decide "$1" "$2" || { echo "FAIL: expected accept: ref=$1 self=$2"; fail=1; }; } - expect_reject() { if decide "$1" "$2"; then echo "FAIL: expected reject: ref=$1 self=$2"; fail=1; fi; } + expect_accept() { if ! try_decide "$1" "$2"; then echo "FAIL: expected accept: ref=$1 self=$2"; fail=1; fi; } + expect_reject() { if try_decide "$1" "$2"; then echo "FAIL: expected reject: ref=$1 self=$2"; fail=1; fi; } expect_accept "9f1e2d3c4b5a69788796a5b4c3d2e1f0a1b2c3d4" 0 expect_accept "9f1e2d3" 0 expect_accept "refs/tags/v0.1.2" 0 expect_accept "v0.1.2" 0 - expect_accept "refs/heads/main" 0 # same repo only expect_accept "refs/heads/main" 1 expect_accept "refs/pull/956/merge" 1 expect_reject "main" 0 expect_reject "master" 0 expect_reject "develop" 0 expect_reject "HEAD" 0 + expect_reject "refs/heads/main" 0 expect_reject "refs/heads/arena/0cc4e3dd-hypatia" 0 expect_reject "not-a-sha" 0 if [ "$fail" != "0" ]; then echo "::error::pin logic table failed"; exit 1; fi - echo "pin logic table: all cases pass" + echo "::notice title=pin-logic::all 13 cases pass" From b5ccac0c83585b335e2a1eba31315cb1c6698bd6 Mon Sep 17 00:00:00 2001 From: Arena Agent Date: Sun, 25 Oct 2026 20:43:24 +0000 Subject: [PATCH 11/21] chore(ci): remove the verification harness Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/workflows/tmp-verify.yml | 94 -------------------------------- 1 file changed, 94 deletions(-) delete mode 100644 .github/workflows/tmp-verify.yml diff --git a/.github/workflows/tmp-verify.yml b/.github/workflows/tmp-verify.yml deleted file mode 100644 index bad78b42..00000000 --- a/.github/workflows/tmp-verify.yml +++ /dev/null @@ -1,94 +0,0 @@ -# SPDX-License-Identifier: MPL-2.0 -# TEMPORARY verification (removed before merge). -name: tmp-verify - -on: - pull_request: - -permissions: - contents: read - issues: write - -jobs: - pinned-score: - name: pinned SHA, real target - uses: ./.github/workflows/rsr-oracle.yml - with: - target-path: '.' - - bad-target: - name: bogus target-path must fail - uses: ./.github/workflows/rsr-oracle.yml - with: - target-path: 'does/not/exist' - - gate-fail: - name: fail-under rhodium must fail but publish outputs - uses: ./.github/workflows/rsr-oracle.yml - with: - fail-under: 'rhodium' - - # The called workflow's outputs are only visible to a downstream job. - relay: - name: relay outputs - needs: [pinned-score, bad-target, gate-fail] - if: always() - runs-on: ubuntu-latest - steps: - - name: Assert caller-visible results - run: | - set -uo pipefail - fail=0 - report() { printf '::notice title=%s::result=%s tier=%s score=%s provisional=%s\n' "$1" "$2" "$3" "$4" "$5"; } - report pinned '${{ needs.pinned-score.result }}' '${{ needs.pinned-score.outputs.tier }}' '${{ needs.pinned-score.outputs.score }}' '${{ needs.pinned-score.outputs.provisional }}' - report bad-target '${{ needs.bad-target.result }}' '${{ needs.bad-target.outputs.tier }}' '${{ needs.bad-target.outputs.score }}' '${{ needs.bad-target.outputs.provisional }}' - report gate-fail '${{ needs.gate-fail.result }}' '${{ needs.gate-fail.outputs.tier }}' '${{ needs.gate-fail.outputs.score }}' '${{ needs.gate-fail.outputs.provisional }}' - - [ '${{ needs.pinned-score.result }}' = 'success' ] || { echo "::error::pinned run did not succeed"; fail=1; } - [ -n '${{ needs.pinned-score.outputs.tier }}' ] || { echo "::error::pinned run published no tier"; fail=1; } - [ '${{ needs.bad-target.result }}' = 'failure' ] || { echo "::error::missing target did not fail"; fail=1; } - [ -z '${{ needs.bad-target.outputs.tier }}' ] || { echo "::error::missing target still published a tier"; fail=1; } - [ '${{ needs.gate-fail.result }}' = 'failure' ] || { echo "::error::fail-under gate did not fail"; fail=1; } - [ -n '${{ needs.gate-fail.outputs.tier }}' ] || { echo "::error::gate failure published no tier (outputs lost)"; fail=1; } - exit "$fail" - - # The cross-repository branch-pin guard cannot be exercised from a PR: this - # org blocks a newly added cross-repo `uses:` at startup. Test the decision - # logic itself instead, on sample refs. - pin-logic: - name: pin-ref decision logic - runs-on: ubuntu-latest - steps: - - name: Check the accept/reject table - run: | - set -euo pipefail - try_decide() { # ref, same-repo -> 0 accept / 1 reject - local ref="$1" self="$2" ok=0 - case "$ref" in - refs/tags/*|v[0-9]*) ok=1 ;; - refs/heads/*|main|master|HEAD|develop) ok=0 ;; - *) if printf '%s' "$ref" | grep -Eq '^[0-9a-f]{7,40}$'; then ok=1; fi ;; - esac - if [ "$self" = "1" ]; then ok=1; fi - [ "$ok" = "1" ] - } - fail=0 - expect_accept() { if ! try_decide "$1" "$2"; then echo "FAIL: expected accept: ref=$1 self=$2"; fail=1; fi; } - expect_reject() { if try_decide "$1" "$2"; then echo "FAIL: expected reject: ref=$1 self=$2"; fail=1; fi; } - - expect_accept "9f1e2d3c4b5a69788796a5b4c3d2e1f0a1b2c3d4" 0 - expect_accept "9f1e2d3" 0 - expect_accept "refs/tags/v0.1.2" 0 - expect_accept "v0.1.2" 0 - expect_accept "refs/heads/main" 1 - expect_accept "refs/pull/956/merge" 1 - expect_reject "main" 0 - expect_reject "master" 0 - expect_reject "develop" 0 - expect_reject "HEAD" 0 - expect_reject "refs/heads/main" 0 - expect_reject "refs/heads/arena/0cc4e3dd-hypatia" 0 - expect_reject "not-a-sha" 0 - - if [ "$fail" != "0" ]; then echo "::error::pin logic table failed"; exit 1; fi - echo "::notice title=pin-logic::all 13 cases pass" From 7c40fceb1c1f79725bcd4ebc8d222df11583aa87 Mon Sep 17 00:00:00 2001 From: Arena Agent Date: Sun, 25 Oct 2026 20:45:01 +0000 Subject: [PATCH 12/21] test(actions_lock): one coherent identity-scope test for the normalised host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous scoping test asserted that a lock naming `github.com` on one dependency and omitting it on another parses with two different owner_ids — which is the hole the previous commit closes. It now asserts the conflict in both spellings, plus a parsing case with agreeing identities that still checks each record keeps the value the lock wrote. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- test/rules/actions_lock_test.exs | 66 +++++++++----------------------- 1 file changed, 19 insertions(+), 47 deletions(-) diff --git a/test/rules/actions_lock_test.exs b/test/rules/actions_lock_test.exs index 19fc92f4..23204254 100644 --- a/test/rules/actions_lock_test.exs +++ b/test/rules/actions_lock_test.exs @@ -528,13 +528,18 @@ defmodule Hypatia.Rules.ActionsLockTest do ActionsLock.parse(v2_with_host) end - test "scopes owner and repository identities to their v0.0.3 host" do - mixed = """ + test "scopes owner and repository identities to one host, implicit or explicit" do + # `acme/bar` names the home host explicitly, `acme/foo` omits it. Both bind + # to github.com, so they are ONE identity scope: owner_id/repo_id are + # numbered per instance, and the hostname spelling is not a distinction + # (normalising it away is what stops a lockfile evading the check by + # spelling one of the two implicitly). + base = """ version: 'v0.0.3' workflows: '.github/workflows/ci.yml': - - 'acme/foo@v1' - 'acme/bar@v1' + - 'acme/foo@v1' dependencies: 'acme/bar@v1': hostname: 'github.com' @@ -545,59 +550,26 @@ defmodule Hypatia.Rules.ActionsLockTest do 'acme/foo@v1': ref: 'v1' commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1' - owner_id: 1 - repo_id: 20 + owner_id: 2 + repo_id: 21 """ - assert {:ok, lock} = ActionsLock.parse(mixed) + # Agreeing identities across the two spellings parse, and each record keeps + # the value the lock actually wrote (explicit string vs nil = home host). + assert {:ok, lock} = ActionsLock.parse(base) assert lock.dependencies["acme/bar@v1"].hostname == "github.com" assert lock.dependencies["acme/foo@v1"].hostname == nil - same_host = String.replace(mixed, " hostname: 'github.com'\n", "") - + # Disagreeing identities are caught in either spelling: foo misnumbered + # while bar names the host... assert {:error, {:line, _, {:owner_identity_mismatch, "acme", 2, 1}}} = - ActionsLock.parse(same_host) - end + ActionsLock.parse(String.replace(base, "owner_id: 2\n repo_id: 21", "owner_id: 1\n repo_id: 21")) - test "an omitted hostname and an explicit github.com are one identity scope" do - # Same conflict as above, but spelled two ways: `acme/bar` names the home - # host explicitly, `acme/foo` omits it. Both bind to github.com, so the - # owner-id disagreement must still be caught. Comparing raw values made - # these two scopes and silently skipped the check (Codex review, P1). - split = """ - version: 'v0.0.3' - workflows: - '.github/workflows/ci.yml': - - 'acme/bar@v1' - - 'acme/foo@v1' - dependencies: - 'acme/bar@v1': - hostname: 'github.com' - ref: 'v1' - commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1' - owner_id: 2 - repo_id: 20 - 'acme/foo@v1': - ref: 'v1' - commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1' - owner_id: 1 - repo_id: 20 - """ + # ...and with the hostname omitted entirely from bar as well. + both_implicit = String.replace(base, " hostname: 'github.com'\n", "") assert {:error, {:line, _, {:owner_identity_mismatch, "acme", 2, 1}}} = - ActionsLock.parse(split) - - # ... and agreeing identities across the two spellings stay legal. (repo_id - # must differ too: the same repo_id on two different repositories is its - # own defect, `:repository_id_reused`.) - agreeing = - String.replace( - split, - " owner_id: 1\n repo_id: 20", - " owner_id: 2\n repo_id: 21" - ) - - assert {:ok, _} = ActionsLock.parse(agreeing) + ActionsLock.parse(both_implicit) end test "parses the repository's authoritative generated lockfile" do From ccac35f8e82b7b08e333dcda753560ec1e446cb3 Mon Sep 17 00:00:00 2001 From: Arena Agent Date: Sun, 25 Oct 2026 20:48:33 +0000 Subject: [PATCH 13/21] test(actions_lock): derive the implicit-host case from the disagreeing lock Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- test/rules/actions_lock_test.exs | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/test/rules/actions_lock_test.exs b/test/rules/actions_lock_test.exs index 23204254..dff631d6 100644 --- a/test/rules/actions_lock_test.exs +++ b/test/rules/actions_lock_test.exs @@ -560,13 +560,22 @@ defmodule Hypatia.Rules.ActionsLockTest do assert lock.dependencies["acme/bar@v1"].hostname == "github.com" assert lock.dependencies["acme/foo@v1"].hostname == nil - # Disagreeing identities are caught in either spelling: foo misnumbered - # while bar names the host... + # Disagreeing identities are caught in either spelling. `disagreeing` is + # `base` with foo's owner_id misnumbered (bar is finalised first, so it is + # the "existing" side of the mismatch). + disagreeing = + String.replace(base, "owner_id: 2\n repo_id: 21", "owner_id: 1\n repo_id: 21") + + assert disagreeing != base + + # ...bar names the host explicitly... assert {:error, {:line, _, {:owner_identity_mismatch, "acme", 2, 1}}} = - ActionsLock.parse(String.replace(base, "owner_id: 2\n repo_id: 21", "owner_id: 1\n repo_id: 21")) + ActionsLock.parse(disagreeing) + + # ...and bar omits it, which must not change the answer. + both_implicit = String.replace(disagreeing, " hostname: 'github.com'\n", "") - # ...and with the hostname omitted entirely from bar as well. - both_implicit = String.replace(base, " hostname: 'github.com'\n", "") + assert both_implicit != disagreeing assert {:error, {:line, _, {:owner_identity_mismatch, "acme", 2, 1}}} = ActionsLock.parse(both_implicit) From 0a885d064977256992b6b2c5b5a543fecbfa109a Mon Sep 17 00:00:00 2001 From: Arena Agent Date: Sun, 25 Oct 2026 21:01:17 +0000 Subject: [PATCH 14/21] fix(rsr-oracle): clear the seven CodeQL alerts the new workflow raised MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * `actions/untrusted-checkout` (x3): the workflow checks out pull-request content, and `actions/checkout` leaves the job token in `.git/config` by default. `persist-credentials: false` on all three checkouts — the scored tree is untrusted by construction. * code injection (x4): `job.workflow_*` and `inputs.target-path` were spliced into `run:` bodies. They now arrive through `env:`, so no caller-supplied value is ever parsed as shell text. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/workflows/rsr-oracle.yml | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/.github/workflows/rsr-oracle.yml b/.github/workflows/rsr-oracle.yml index 4f2e303f..97c6242e 100644 --- a/.github/workflows/rsr-oracle.yml +++ b/.github/workflows/rsr-oracle.yml @@ -142,6 +142,11 @@ jobs: ref: ${{ inputs.target-ref != '' && inputs.target-ref || github.event.pull_request.head.sha || github.sha }} path: repo fetch-depth: 0 + # The tree being scored is caller (often pull-request) content. Never + # leave the job token in its .git/config: `mix` compiles and runs the + # oracle against that tree, and any `run:` step in a consumer repo + # could read it back out. + persist-credentials: false # 2. The SSOT. Sibling of `repo/`, so it is outside the scored tree. - name: Checkout standards (SSOT source) @@ -150,6 +155,7 @@ jobs: repository: ${{ inputs.standards-repository }} ref: ${{ inputs.standards-ref }} path: _standards + persist-credentials: false # 3. The oracle, at the commit the caller pinned. Not a branch. Ever. - name: Checkout hypatia (the pinned oracle) @@ -158,6 +164,7 @@ jobs: repository: ${{ job.workflow_repository }} ref: ${{ job.workflow_sha }} path: _hypatia + persist-credentials: false - name: Setup Elixir/OTP uses: erlef/setup-beam@v1.24.1 @@ -166,9 +173,18 @@ jobs: otp-version: ${{ inputs.otp-version }} - name: Record the pin + env: + # Via env, never spliced into the script text: a value interpolated + # into `run:` is a shell injection point, and the compiler of a + # security tool being injectable by its caller is the wrong joke. + ORACLE_REPOSITORY: ${{ job.workflow_repository }} + ORACLE_SHA: ${{ job.workflow_sha }} + ORACLE_REF: ${{ job.workflow_ref }} run: | - echo "oracle: ${{ job.workflow_repository }}@${{ job.workflow_sha }}" | tee -a "$GITHUB_STEP_SUMMARY" - echo "oracle workflow: ${{ job.workflow_ref }}" | tee -a "$GITHUB_STEP_SUMMARY" + { + echo "oracle: ${ORACLE_REPOSITORY}@${ORACLE_SHA}" + echo "oracle workflow: ${ORACLE_REF}" + } | tee -a "$GITHUB_STEP_SUMMARY" # A pin that cannot be resolved must not fall through to something else — # and a *branch* that resolves is not a pin either. @@ -294,8 +310,10 @@ jobs: # not met. Callers who want the failure still get it from the step above. - name: Show the scorecard if: always() + env: + TARGET_PATH: ${{ inputs.target-path }} run: | - scorecard="$GITHUB_WORKSPACE/repo/${{ inputs.target-path }}/.machine_readable/scorecards/rsr.scorecard.a2ml" + scorecard="$GITHUB_WORKSPACE/repo/${TARGET_PATH}/.machine_readable/scorecards/rsr.scorecard.a2ml" if [ -f "$scorecard" ]; then { echo '## RSR scorecard' From 5a21edfce12b054659b4f704ae7d4555c7f2812b Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Sun, 25 Oct 2026 21:09:49 +0000 Subject: [PATCH 15/21] Potential fix for pull request finding 'CodeQL / Checkout of untrusted code in a non-privileged context' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com> --- .github/workflows/rsr-oracle.yml | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/.github/workflows/rsr-oracle.yml b/.github/workflows/rsr-oracle.yml index 97c6242e..08c575d2 100644 --- a/.github/workflows/rsr-oracle.yml +++ b/.github/workflows/rsr-oracle.yml @@ -149,6 +149,32 @@ jobs: persist-credentials: false # 2. The SSOT. Sibling of `repo/`, so it is outside the scored tree. + # Validate caller-provided source before checkout: this workflow is reusable + # and must not allow arbitrary repository/ref checkout in untrusted contexts. + - name: Validate standards source + env: + STANDARDS_REPOSITORY: ${{ inputs.standards-repository }} + STANDARDS_REF: ${{ inputs.standards-ref }} + run: | + set -eu + case "${STANDARDS_REPOSITORY}" in + "hyperpolymath/standards") + ;; + *) + echo "Untrusted standards repository: ${STANDARDS_REPOSITORY}" >&2 + exit 1 + ;; + esac + + case "${STANDARDS_REF}" in + refs/tags/*|refs/heads/main) + ;; + *) + echo "Untrusted standards ref: ${STANDARDS_REF}" >&2 + exit 1 + ;; + esac + - name: Checkout standards (SSOT source) uses: actions/checkout@v7.0.1 with: From 9bc85354e73927afb9da746960d2e3cc059238e2 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Sun, 25 Oct 2026 21:10:12 +0000 Subject: [PATCH 16/21] Potential fix for pull request finding 'CodeQL / Checkout of untrusted code in a non-privileged context' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com> --- .github/workflows/rsr-oracle.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/rsr-oracle.yml b/.github/workflows/rsr-oracle.yml index 08c575d2..9de8d184 100644 --- a/.github/workflows/rsr-oracle.yml +++ b/.github/workflows/rsr-oracle.yml @@ -120,6 +120,7 @@ permissions: jobs: score: name: RSR oracle + if: ${{ github.event_name != 'pull_request_target' }} runs-on: ubuntu-latest timeout-minutes: 30 outputs: From 74b180442ee5203dfe70218c8706eb58b43d276b Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Sun, 25 Oct 2026 21:12:38 +0000 Subject: [PATCH 17/21] fix(ci): disable checkout credentials and clarify lockfile host docs --- .github/workflows/ci.yml | 2 ++ CHANGELOG.adoc | 5 +++-- lib/rules/actions_lock.ex | 17 +++++++---------- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd6133ae..20ff192a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -319,6 +319,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v7.0.1 + with: + persist-credentials: false # Pinned to the exact versions in the estate dogfood gate # (.github/workflows/dogfood-gate.yml in each consumer repo, job diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index 56312793..efe728d4 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -55,8 +55,9 @@ while it did not compile, and both are fixed here as well: * **`finalize_dependency/1` built a map with two `hostname:` keys.** The second (raw `nil`) won, so *every* parsed dependency recorded `hostname: nil` and the seven "v0.0.3 hostname field" assertions in - `test/rules/actions_lock_test.exs` failed. An omitted `hostname:` now records - `github.com`, as the module's own docstring says it should. + `test/rules/actions_lock_test.exs` failed. An omitted `hostname:` records + `nil`, preserving the lockfile's recorded value; the identity check treats + `nil` as the home host `github.com`. Why it survived five days on `main`: nothing that gates a merge compiled the Elixir tree. `ci.yml` had no Elixir job, `tests.yml` has `pull_request: null`, diff --git a/lib/rules/actions_lock.ex b/lib/rules/actions_lock.ex index 6fe9b626..8b1f6dc2 100644 --- a/lib/rules/actions_lock.ex +++ b/lib/rules/actions_lock.ex @@ -25,9 +25,9 @@ defmodule Hypatia.Rules.ActionsLock do `owner/repo@ref` keys used for association carry no host. Every parsed dependency records the hostname the lock named; `nil` means the lock named none, which binds the pin to the home host `github.com` (v0.0.2 records no - hostname at all). The field stays `nil` rather than being defaulted to - `"github.com"` because `scope_to_host/2` compares it against the raw value - read from each record — see `finalize_dependency/1`. + hostname at all). `finalize_dependency/1` preserves that recorded value; + `scope_to_host/2` normalises both hostnames before comparison, so an omitted + hostname and explicit `github.com` share the same identity scope. """ @supported_versions ["v0.0.2", "v0.0.3"] @@ -409,13 +409,10 @@ defmodule Hypatia.Rules.ActionsLock do commit: commit, owner_id: owner_id, repo_id: repo_id, - # The raw value, NOT a "github.com" default. `nil` means "the lock - # named no host", i.e. the home host; scope_to_host/2 above compares - # this field against the incoming raw value, so defaulting it to the - # string here would silently stop an omitted-hostname pin from being - # identity-checked against an explicitly-github.com pin on the same - # host. v0.0.2 has no such field at all and records nil for the same - # reason. + # Preserve the lockfile's recorded value: `nil` means no host was + # named (v0.0.2 has no hostname field). scope_to_host/2 normalises + # both hostnames before comparison, so `nil` and explicit "github.com" + # still share the home host's identity checks. hostname: hostname, uses: Map.get(dependency, :uses, MapSet.new()) } From d1f652d15fa90d63bc466242dcab6cbd29b21dd1 Mon Sep 17 00:00:00 2001 From: Arena Agent Date: Sun, 25 Oct 2026 21:12:54 +0000 Subject: [PATCH 18/21] fix(rsr-oracle): default the scored ref to github.sha, not the PR head MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `github.event.pull_request.head.sha` is the `actions/untrusted-checkout` shape: it checks out exactly the caller's pull request content and compiles in a workflow the PR author did not write. Score `github.sha` (the commit under test — the merge commit for a PR) instead. Callers that specifically want the head pass `target-ref`, which makes that choice explicit in their own workflow rather than implicit here. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/workflows/rsr-oracle.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/rsr-oracle.yml b/.github/workflows/rsr-oracle.yml index 9de8d184..84eec562 100644 --- a/.github/workflows/rsr-oracle.yml +++ b/.github/workflows/rsr-oracle.yml @@ -140,7 +140,14 @@ jobs: uses: actions/checkout@v7.0.1 with: repository: ${{ github.repository }} - ref: ${{ inputs.target-ref != '' && inputs.target-ref || github.event.pull_request.head.sha || github.sha }} + # `github.sha` is the commit under test (the merge commit for a pull + # request). Deliberately NOT `github.event.pull_request.head.sha`: + # that expression is how a caller-supplied PR head gets checked out + # and then compiled in a workflow it did not define, which is the + # `actions/untrusted-checkout` shape. A caller that really wants the + # head passes `target-ref` explicitly, which makes the intent explicit + # and auditable in its own workflow. + ref: ${{ inputs.target-ref != '' && inputs.target-ref || github.sha }} path: repo fetch-depth: 0 # The tree being scored is caller (often pull-request) content. Never From 47c096c9fde53003b34069c47db20b7b26514fc5 Mon Sep 17 00:00:00 2001 From: Arena Agent Date: Sun, 25 Oct 2026 21:13:19 +0000 Subject: [PATCH 19/21] fix(rsr-oracle): repair the two Copilot autofixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both came from CodeQL findings, both broke the documented interface: * the standards validator allowed only `refs/tags/*` and `refs/heads/main`, which rejects (a) a pinned commit SHA — the whole point of `standards-ref` — and (b) the empty default, so the workflow could not run its own default path. It now accepts an empty ref (default branch), a commit SHA, or a release tag, and refuses branches; the repository allowlist stays. * the job was guarded with `if: github.event_name != 'pull_request_target'`, which silently skips — a green tick with no scorecard. It now refuses that context with an explicit error and runs normally otherwise. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/workflows/rsr-oracle.yml | 62 ++++++++++++++++++++------------ docs/consumers/rsr-oracle.adoc | 10 +++++- 2 files changed, 49 insertions(+), 23 deletions(-) diff --git a/.github/workflows/rsr-oracle.yml b/.github/workflows/rsr-oracle.yml index 84eec562..67a22810 100644 --- a/.github/workflows/rsr-oracle.yml +++ b/.github/workflows/rsr-oracle.yml @@ -120,7 +120,6 @@ permissions: jobs: score: name: RSR oracle - if: ${{ github.event_name != 'pull_request_target' }} runs-on: ubuntu-latest timeout-minutes: 30 outputs: @@ -128,6 +127,17 @@ jobs: score: ${{ steps.oracle.outputs.score }} provisional: ${{ steps.oracle.outputs.provisional }} steps: + # `pull_request_target` runs with the base repository's privileges and its + # secrets while the pull request's content is attacker-controlled. The + # oracle checks out and compiles code, so it refuses that context outright. + # Loudly: a skipped job is a green tick with no scorecard, which is the + # "check that cannot fail" shape this repository already knows too well. + - name: Refuse privileged pull_request_target context + if: ${{ github.event_name == 'pull_request_target' }} + run: | + echo "::error::the RSR oracle compiles code and must not run under pull_request_target; call it from a pull_request or push workflow instead" + exit 1 + # 1. The repository being scored (the caller's). # # Into `repo/`, not the workspace root: the two helper checkouts below @@ -157,31 +167,39 @@ jobs: persist-credentials: false # 2. The SSOT. Sibling of `repo/`, so it is outside the scored tree. - # Validate caller-provided source before checkout: this workflow is reusable - # and must not allow arbitrary repository/ref checkout in untrusted contexts. - - name: Validate standards source + # + # Validated before checkout: the canon is the thing being scored against, + # so "point it at my own fork" is a way to award yourself any score you + # like. The default (empty ref) is the standards default branch, which is + # what the generated gate does today; anything else must be immutable. + - name: Validate the standards source env: STANDARDS_REPOSITORY: ${{ inputs.standards-repository }} STANDARDS_REF: ${{ inputs.standards-ref }} run: | - set -eu - case "${STANDARDS_REPOSITORY}" in - "hyperpolymath/standards") - ;; - *) - echo "Untrusted standards repository: ${STANDARDS_REPOSITORY}" >&2 - exit 1 - ;; - esac - - case "${STANDARDS_REF}" in - refs/tags/*|refs/heads/main) - ;; - *) - echo "Untrusted standards ref: ${STANDARDS_REF}" >&2 - exit 1 - ;; - esac + set -euo pipefail + if [ "$STANDARDS_REPOSITORY" != "hyperpolymath/standards" ]; then + echo "::error::refusing standards source '${STANDARDS_REPOSITORY}': the canon is hyperpolymath/standards" + exit 1 + fi + # Empty = the repository's default branch (documented default). + # Otherwise: a full/abbreviated commit SHA or a release tag. A branch + # name is refused — the same reason the oracle itself is pinned. + if [ -z "$STANDARDS_REF" ]; then + echo "standards ref: default branch" + else + case "$STANDARDS_REF" in + refs/tags/*|v[0-9]*) : ;; + refs/heads/*|main|master|HEAD|develop) + echo "::error::refusing standards ref '${STANDARDS_REF}': pin a commit SHA or a release tag" + exit 1 ;; + *) printf '%s' "$STANDARDS_REF" | grep -Eq '^[0-9a-f]{7,40}$' || { + echo "::error::refusing standards ref '${STANDARDS_REF}': not a commit SHA or release tag" + exit 1 + } ;; + esac + echo "standards ref: ${STANDARDS_REF}" + fi - name: Checkout standards (SSOT source) uses: actions/checkout@v7.0.1 diff --git a/docs/consumers/rsr-oracle.adoc b/docs/consumers/rsr-oracle.adoc index 3fb2bd4e..573d288c 100644 --- a/docs/consumers/rsr-oracle.adoc +++ b/docs/consumers/rsr-oracle.adoc @@ -89,7 +89,15 @@ must never be scored against a catalogue picked by accident. `standards-ref` is optional and empty means "the standards repo's default branch", which is what the generated gate does today. Pin it if you want your canon frozen as well — a pinned oracle scored against an unpinned canon is only -half a contract. +half a contract. When set, it must be an immutable ref (commit SHA or release +tag): a branch is refused, and the repository must be `hyperpolymath/standards`. +The canon is what you are scored against; a caller-supplied canon would let a +repository award itself any score it liked. + +The oracle also refuses to run under `pull_request_target`, and fails loudly +rather than skipping: that trigger runs with the base repository's privileges +while the pull request's content is attacker-controlled, and the oracle checks +out and compiles code. A `target-path` that does not exist in your repository is an error, not a score: the engine treats a missing tree as a sparse repository, so a typo would From f27e12f862fbb9dc68e7907f15252e2f8f1d600c Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Sun, 25 Oct 2026 21:15:26 +0000 Subject: [PATCH 20/21] chore(deps): update Tokio, ctutils, and WebPKI dependencies --- Cargo.lock | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6b7e3904..2808edcc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -855,9 +855,9 @@ dependencies = [ [[package]] name = "ctutils" -version = "0.4.2" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +checksum = "12218662ccac37d9714f2191252553d67585b97df8c839cbe49e0bde278dc865" dependencies = [ "cmov", ] @@ -3267,9 +3267,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.15" +version = "0.103.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" dependencies = [ "aws-lc-rs", "ring", @@ -3951,9 +3951,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.53.1" +version = "1.53.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e" dependencies = [ "bytes", "libc", @@ -4502,18 +4502,18 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.8" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" +checksum = "58ec80e73fd8baf57bf0d9615554c75348bda990d6d5bbeab964dd068319fecb" dependencies = [ "rustls-pki-types", ] [[package]] name = "webpki-roots" -version = "1.0.8" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9" dependencies = [ "rustls-pki-types", ] From e268a5273d6dfca3a42c24295d997c9ebb84fe0f Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Sun, 25 Oct 2026 21:20:32 +0000 Subject: [PATCH 21/21] Potential fix for pull request finding 'CodeQL / Checkout of untrusted code in a non-privileged context' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com> --- .github/workflows/rsr-oracle.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/workflows/rsr-oracle.yml b/.github/workflows/rsr-oracle.yml index 67a22810..215a18c0 100644 --- a/.github/workflows/rsr-oracle.yml +++ b/.github/workflows/rsr-oracle.yml @@ -210,7 +210,19 @@ jobs: persist-credentials: false # 3. The oracle, at the commit the caller pinned. Not a branch. Ever. + - name: Validate oracle checkout pin + id: validate_oracle_pin + env: + ORACLE_SHA: ${{ job.workflow_sha }} + run: | + if ! [[ "${ORACLE_SHA}" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::workflow_sha must be a full 40-character commit SHA" + exit 1 + fi + echo "ok=true" >> "$GITHUB_OUTPUT" + - name: Checkout hypatia (the pinned oracle) + if: ${{ steps.validate_oracle_pin.outputs.ok == 'true' }} uses: actions/checkout@v7.0.1 with: repository: ${{ job.workflow_repository }}