diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f6cecc82..262159e1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,8 +17,33 @@ concurrency: jobs: test: - name: Julia ${{ matrix.julia-version }} / ${{ matrix.os }} - runs-on: ${{ matrix.os }} + # 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 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. + # + # MEASURED on hyperpolymath/MetaManifold-WebUI#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 + # 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 @@ -28,19 +53,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.4.0 @@ -48,7 +60,12 @@ jobs: - name: Set up Julia uses: julia-actions/setup-julia@4c0cb0fce8556fdb04a90347310e5db8b1f98fb9 # v2.7.0 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.1.0 diff --git a/test/unit/test_install_pins.jl b/test/unit/test_install_pins.jl index 2721a260..597c0c4c 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) @@ -160,16 +169,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 @@ -219,4 +236,38 @@ is_sha256(s) = s isa AbstractString && occursin(r"^[0-9a-f]{64}$", s) @test occursin("fetch_pinned \"$ref\"", runs) end end + + @testset "a required check name is a stable identifier" begin + ci = YAML.load_file(CI_PATH) + + # 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 hyperpolymath/MetaManifold-WebUI#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. + # + # 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