From 7adaeb74efa283ba6b2f3a5816b176838f55352c Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Tue, 22 Sep 2026 00:06:42 +0100 Subject: [PATCH] fix(ci): drop the 1x1 matrix so the check name is actually stable #39 renamed this job to `Julia tests` and claimed the required status check was therefore version-stable. It was not, and the claim was never measured. MEASURED on PR #39 head 776e340d, after that change had landed: Julia tests (1.12.5, ubuntu-24.04) <- what GitHub actually posted Julia tests <- what the ruleset requires GitHub appends the matrix combination to a job's posted check name whenever the `name:` does not itself reference the matrix. The old interpolating name suppressed the suffix by accident; replacing it with a static string turned the suffix on. A 1x1 matrix is still a matrix, so both pins stayed embedded in the check name and the required context `Julia tests` was never reported at all -- so `main` has been sitting in precisely the deadlock issue #38 describes, with #39 itself merged only by an admin bypass. The matrix selected exactly one combination and bought nothing, so it is removed rather than worked around: `runs-on: ubuntu-24.04` and the setup-julia `version: "1.12.5"` are literals, each keeping the reasoning that used to sit beside the matrix entry. The non-matrix `repo-hygiene` job was the control -- it has always posted its `name:` verbatim. Why the guard did not catch it: `test_install_pins.jl` asserted that no job name interpolates anything -- a property of the YAML `name:` field -- while the consumer, the ruleset, matches the rendered check-run name. The guard asked a different question than its consumer, so it passed on a broken fix. It now asserts BOTH necessary conditions, over every job in the file: the name interpolates nothing AND the job has no `strategy.matrix`. Run against the previous commit that assertion fails on `test` alone (5 pass / 1 fail), so it discriminates rather than blanket-failing. `CI installs what is pinned` made the same claims about the same two values and has been repointed at where they now live, locating the setup step by its `uses:` rather than by index. Mutating either literal fails it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01X3hgXxWm6umMgZkjYyHnnm --- .github/workflows/ci.yml | 56 ++++++++++++++++--------------- test/unit/test_install_pins.jl | 61 +++++++++++++++++++++++++--------- 2 files changed, 75 insertions(+), 42 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 02701a3..cce2d16 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -101,21 +101,33 @@ jobs: echo "commit subject ok: $subject" test: - # The name is a fixed string, and deliberately carries no version. + # The name is a fixed string, it carries no version, and the job has NO matrix. + # Both are required, and the first without the second is a trap that has already + # been sprung here once. # - # A branch ruleset matches a required status check by the DISPLAY NAME of the job - # that posts it. When that name was `Julia ${{ matrix.julia-version }} / ${{ matrix.os }}` - # a pin bump renamed the check, and a renamed check does not report at all -- it - # does not fail, it is simply absent, and an absent required check can never be - # satisfied. Every pull request would have deadlocked until an admin bypassed the - # rule, triggered by nothing more alarming than editing a version number. + # A branch ruleset matches a required status check by the DISPLAY NAME GitHub posts + # for the job. A renamed check does not fail -- it is simply absent, and an absent + # required check can never be satisfied, so every pull request deadlocks until an + # admin bypasses the rule, triggered by nothing more alarming than editing a version + # number. # - # The versions have not been hidden, only moved somewhere that does not double as - # an identifier: `strategy.matrix` below, and the `Set up Julia` step's own log. - # `test/unit/test_install_pins.jl` asserts that no job name in this file - # interpolates anything, so this cannot regress silently. + # MEASURED on PR #39 (2026-09-21): this job, named exactly `Julia tests` but still + # carrying a 1x1 `strategy.matrix`, posted `Julia tests (1.12.5, ubuntu-24.04)`. + # GitHub appends the matrix combination whenever the name does not already reference + # the matrix, so a 1x1 matrix is still a matrix and the pins were still embedded in + # the check name. The matrix was therefore removed rather than merely renamed around: + # it selected exactly one combination and bought nothing. + # + # The two values it held are literals below, each keeping its own reasoning. + # `test/unit/test_install_pins.jl` asserts, for EVERY job in this file, that the name + # interpolates nothing AND that the job has no matrix -- so this cannot regress + # silently. If a matrix is ever genuinely wanted here, the required context must move + # to a matrix-free aggregator job first. name: Julia tests - runs-on: ${{ matrix.os }} + # Explicit, not ubuntu-latest: the R pin below names an apt package revision built + # for 24.04, and a runner that silently rolled to the next LTS would take that pin + # with it. `runs-on` cannot read `env`, so this is a literal. + runs-on: ubuntu-24.04 # The repository's root .Rprofile sources renv/activate.R, so R started from # the checkout auto-activates renv and rewrites the library paths. renv is a # local-development convenience; CI installs into the system library and @@ -125,19 +137,6 @@ jobs: # the environment, so those commands pass --no-init-file instead. env: RENV_CONFIG_AUTOLOADER_ENABLED: "FALSE" - strategy: - fail-fast: false - matrix: - # Pinned rather than tracking latest stable, because a pinned Manifest.toml - # resolved by an unpinned Julia is not a reproducible build. This must equal - # julia_version in Manifest.toml; it cannot be read from the pin file, since - # nothing can be read before Julia exists. test/unit/test_install_pins.jl - # fails if the three ever disagree. - julia-version: ["1.12.5"] - # Explicit, not ubuntu-latest: the R pin below names an apt package revision - # built for 24.04, and a runner that silently rolled to the next LTS would - # take that pin with it. - os: [ubuntu-24.04] steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 @@ -145,7 +144,12 @@ jobs: - name: Set up Julia uses: julia-actions/setup-julia@4c0cb0fce8556fdb04a90347310e5db8b1f98fb9 # v2 with: - version: ${{ matrix.julia-version }} + # Pinned rather than tracking latest stable, because a pinned Manifest.toml + # resolved by an unpinned Julia is not a reproducible build. This must equal + # julia_version in Manifest.toml; it cannot be read from the pin file, since + # nothing can be read before Julia exists. test/unit/test_install_pins.jl + # fails if the three ever disagree. + version: "1.12.5" - name: Cache Julia packages uses: julia-actions/cache@d10a6fd8f31b12404a54613ebad242900567f2b9 # v2 diff --git a/test/unit/test_install_pins.jl b/test/unit/test_install_pins.jl index c25c79c..9344b6a 100644 --- a/test/unit/test_install_pins.jl +++ b/test/unit/test_install_pins.jl @@ -29,6 +29,15 @@ const EXPECTED_PLATFORMS = ["linux-x86_64", "linux-aarch64", "macos-x86_64", "ma is_sha256(s) = s isa AbstractString && occursin(r"^[0-9a-f]{64}$", s) +"""Return the first step of `job` whose `uses:` names `action`, or nothing.""" +function ci_step_using(job, action) + for step in get(job, "steps", []) + startswith(get(step, "uses", ""), action) && return step + end + return nothing +end + + @testset "install pins" begin @test isfile(PINS_PATH) pins = YAML.load_file(PINS_PATH) @@ -158,16 +167,24 @@ is_sha256(s) = s isa AbstractString && occursin(r"^[0-9a-f]{64}$", s) @testset "CI installs what is pinned" begin ci = YAML.load_file(CI_PATH) - matrix = ci["jobs"]["test"]["strategy"]["matrix"] + job = ci["jobs"]["test"] # Julia is the one version CI cannot read from the pin file, because nothing can # be read before Julia exists. It is therefore duplicated, and this is the test # that makes the duplication safe. - @test matrix["julia-version"] == [pins["runtimes"]["julia"]["version"]] + # + # Read from the `Set up Julia` step rather than from a matrix: the matrix was + # removed because GitHub appends a matrix combination to the posted check name + # (see "a required check name is a stable identifier" below). The step is located + # by its `uses:` rather than by index, so reordering the steps cannot make this + # assertion quietly vanish. + setup = ci_step_using(job, "julia-actions/setup-julia") + @test setup !== nothing + @test setup["with"]["version"] == pins["runtimes"]["julia"]["version"] # A floating runner would carry the R apt pin, which names a 24.04 build, off to # whatever the next LTS ships. - @test matrix["os"] == ["ubuntu-24.04"] + @test job["runs-on"] == "ubuntu-24.04" @test occursin("2404", pins["runtimes"]["r"]["apt_version"]) # Everything else CI installs must be read from the pin file at run time rather @@ -182,25 +199,37 @@ is_sha256(s) = s isa AbstractString && occursin(r"^[0-9a-f]{64}$", s) end end - @testset "no job name interpolates a pin" begin + @testset "a required check name is a stable identifier" begin ci = YAML.load_file(CI_PATH) - # A required status check is matched by the DISPLAY NAME of the job that posts - # it. `name: Julia ${{ matrix.julia-version }} / ${{ matrix.os }}` therefore - # renames the check on every pin bump, and a renamed check does not report at - # all -- it does not fail, it is simply absent, and a required check that never - # reports can never be satisfied. Every pull request then deadlocks until an - # admin bypasses the rule, which is the one outcome branch protection exists to - # prevent. The bump that triggers it is a one-line edit to this very file's - # subject matter, so the deadlock arrives by way of an ordinary maintenance - # change that looks safe. + # A branch ruleset matches a required status check by the DISPLAY NAME GitHub + # posts for the job. A renamed check does not fail -- it is simply absent, and a + # required check that never reports can never be satisfied, so every pull request + # deadlocks until an admin bypasses the rule. The bump that triggers it is a + # one-line edit to this very file's subject matter. + # + # TWO conditions are needed for GitHub to post the `name:` field verbatim, and + # the first without the second is the trap this file exists to close: + # + # 1. the name must not interpolate a pin; and + # 2. the job must not have a `strategy.matrix`. + # + # MEASURED 2026-09-21 on PR #39: a job named exactly `Julia tests` with a 1x1 + # matrix posted `Julia tests (1.12.5, ubuntu-24.04)`. GitHub appends the matrix + # combination whenever the name does not already reference the matrix, so a 1x1 + # matrix is still a matrix and the version was still embedded. A guard asserting + # only (1) PASSED on that broken fix, because it asked about the YAML field while + # the ruleset reads the rendered check name. # - # Asserting this over EVERY job rather than over the one job we happen to - # require today is deliberate: the rule is "a job name is a stable identifier", - # and a rule enforced at each door in turn is a rule that a new door escapes. + # Asserted over EVERY job rather than the one job we happen to require today: + # the rule is "a job name is a stable identifier", and a rule enforced at each + # door in turn is a rule that a new door escapes. If a matrix is ever genuinely + # needed, the required context must move to a matrix-free aggregator job and this + # assertion be re-scoped to that job -- not deleted. for (id, job) in ci["jobs"] name = get(job, "name", id) @test !occursin("\${{", name) + @test !haskey(get(job, "strategy", Dict{String,Any}()), "matrix") end end end