diff --git a/.github/workflows/actions.lock b/.github/workflows/actions.lock index 72d76f30..1c252b85 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.3' '.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 6c3bf250..83a1e699 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -283,6 +283,88 @@ 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 + with: + persist-credentials: false + + # 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 +380,7 @@ jobs: - haskell-build - haskell-lint - k9iser-validate + - elixir-compile if: always() steps: - name: Check all jobs status @@ -307,7 +390,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..215a18c0 --- /dev/null +++ b/.github/workflows/rsr-oracle.yml @@ -0,0 +1,390 @@ +# 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: + # `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 + # 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 }} + # `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 + # 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. + # + # 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 -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 + with: + 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: 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 }} + ref: ${{ job.workflow_sha }} + path: _hypatia + persist-credentials: false + + - 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 + 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: ${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. + # + # `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 }} + 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. + 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 [ "$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 + 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 + run: | + set -euo pipefail + mix deps.get + mix compile + + - name: Score this repo against the canon + id: oracle + working-directory: _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 -uo pipefail + target="$GITHUB_WORKSPACE/repo/${TARGET_PATH}" + ssot="${SSOT_INPUT}" + if [ -z "$ssot" ]; then + 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 + # 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" + exit 1 + fi + + args=(hypatia.rsr_score "$target" --ssot "$ssot") + [ "$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')" + echo "provisional=$(printf '%s' "$line" | sed -n 's/.*provisional=\([a-z]*\).*/\1/p')" + } >> "$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. + - name: Show the scorecard + if: always() + env: + TARGET_PATH: ${{ inputs.target-path }} + run: | + scorecard="$GITHUB_WORKSPACE/repo/${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..efe728d4 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -10,6 +10,78 @@ 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:` 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`, +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/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", ] 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/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. diff --git a/docs/consumers/rsr-oracle.adoc b/docs/consumers/rsr-oracle.adoc new file mode 100644 index 00000000..573d288c --- /dev/null +++ b/docs/consumers/rsr-oracle.adoc @@ -0,0 +1,134 @@ += 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 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. + +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 + +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. 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 +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. 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. + +== 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`. diff --git a/lib/rules/actions_lock.ex b/lib/rules/actions_lock.ex index 3a292443..8b1f6dc2 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). `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"] @@ -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,21 @@ 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), + # 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()) } @@ -419,14 +431,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 b1c64e99..dff631d6 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,16 +524,22 @@ 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 - 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' @@ -520,18 +550,35 @@ 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. `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(disagreeing) + + # ...and bar omits it, which must not change the answer. + both_implicit = String.replace(disagreeing, " hostname: 'github.com'\n", "") + + assert both_implicit != disagreeing assert {:error, {:line, _, {:owner_identity_mismatch, "acme", 2, 1}}} = - ActionsLock.parse(same_host) + ActionsLock.parse(both_implicit) end test "parses the repository's authoritative generated lockfile" do @@ -584,7 +631,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 +649,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 +669,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")