diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4882c70..3ceee4f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,9 +62,11 @@ jobs: uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 with: version: 0.11.30 - # Stated, not inherited. setup-uv does not cache by default today, so - # this changes nothing now -- but the blocking gate must not begin - # taking an unreviewed input because an upstream default moved. + # Stated, not inherited -- and it does change something. The pinned + # action declares `enable-cache: auto`, and `auto` resolves to true on + # a GitHub-hosted runner. This comment used to claim setup-uv did not + # cache by default; `catalog/cache-contract.yml` now records the real + # default and the sweep resolves it from the pinned action.yml. enable-cache: false - name: Install validator dependencies env: @@ -116,9 +118,11 @@ jobs: uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 with: version: 0.11.30 - # Stated, not inherited. setup-uv does not cache by default today, so - # this changes nothing now -- but the blocking gate must not begin - # taking an unreviewed input because an upstream default moved. + # Stated, not inherited -- and it does change something. The pinned + # action declares `enable-cache: auto`, and `auto` resolves to true on + # a GitHub-hosted runner. This comment used to claim setup-uv did not + # cache by default; `catalog/cache-contract.yml` now records the real + # default and the sweep resolves it from the pinned action.yml. enable-cache: false - name: Install validator dependencies env: @@ -267,6 +271,11 @@ jobs: uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version: '1.24' + # setup-go caches the module and build cache with no input at all. + # This job is in `ci-gate.needs`, so that entry would be an unreviewed + # input to a required check. Found by deriving the required surface + # from the gate's own graph rather than from a hand-written list. + cache: false - name: Set up Terraform uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1 diff --git a/.github/workflows/maintenance.yml b/.github/workflows/maintenance.yml index b1a51f4..55fd4a1 100644 --- a/.github/workflows/maintenance.yml +++ b/.github/workflows/maintenance.yml @@ -85,6 +85,9 @@ jobs: uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 with: version: 0.11.30 + # The sweep decides what maintenance debt exists; it must not + # read that from an entry written by some other ref. + enable-cache: false - name: Install validator dependencies env: diff --git a/.github/workflows/private-static.yml b/.github/workflows/private-static.yml index 6d8bc80..4e0151b 100644 --- a/.github/workflows/private-static.yml +++ b/.github/workflows/private-static.yml @@ -113,6 +113,9 @@ jobs: uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 with: version: ${{ inputs.uv_version }} + # uv provisions our pinned tooling here, not the caller's + # dependencies; a restored entry would be unreviewed input to a scan. + enable-cache: false - name: Run install command if: ${{ inputs.install_command != '' }} diff --git a/.github/workflows/qt-ci.yml b/.github/workflows/qt-ci.yml index bfa5fb1..13c4981 100644 --- a/.github/workflows/qt-ci.yml +++ b/.github/workflows/qt-ci.yml @@ -99,6 +99,15 @@ jobs: - name: Set up uv uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + # Pinned, not inherited: setup-uv's `version` defaults to the + # version in pyproject.toml or, absent one, whatever is latest. + # There is no pyproject.toml here, so this resolved to latest -- + # a SHA-pinned action installing an unpinned tool. + version: 0.11.30 + # uv provisions aqtinstall here, not the caller's dependencies. + # The Qt install itself is cached separately and by key, below. + enable-cache: false - name: Resolve the Qt release id: release diff --git a/.github/workflows/runtime-fixtures-languages.yml b/.github/workflows/runtime-fixtures-languages.yml index 14123eb..1c8053e 100644 --- a/.github/workflows/runtime-fixtures-languages.yml +++ b/.github/workflows/runtime-fixtures-languages.yml @@ -456,6 +456,15 @@ jobs: update-environment: false - name: Set up uv uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + # Pinned, not inherited: setup-uv's `version` defaults to the + # version in pyproject.toml or, absent one, whatever is latest. + # There is no pyproject.toml here, so this resolved to latest -- + # a SHA-pinned action installing an unpinned tool. + version: 0.11.30 + # Evidence must come from a cold, reviewable run: a restored entry + # would make the receipt describe something other than this run. + enable-cache: false - name: Provision validator env: PYTHON_PATH: ${{ steps.python.outputs.python-path }} @@ -491,6 +500,15 @@ jobs: update-environment: false - name: Set up uv uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + # Pinned, not inherited: setup-uv's `version` defaults to the + # version in pyproject.toml or, absent one, whatever is latest. + # There is no pyproject.toml here, so this resolved to latest -- + # a SHA-pinned action installing an unpinned tool. + version: 0.11.30 + # Evidence must come from a cold, reviewable run: a restored entry + # would make the receipt describe something other than this run. + enable-cache: false - name: Provision validator env: PYTHON_PATH: ${{ steps.python.outputs.python-path }} @@ -539,6 +557,15 @@ jobs: update-environment: false - name: Set up uv uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + # Pinned, not inherited: setup-uv's `version` defaults to the + # version in pyproject.toml or, absent one, whatever is latest. + # There is no pyproject.toml here, so this resolved to latest -- + # a SHA-pinned action installing an unpinned tool. + version: 0.11.30 + # Evidence must come from a cold, reviewable run: a restored entry + # would make the receipt describe something other than this run. + enable-cache: false - name: Provision validator env: PYTHON_PATH: ${{ steps.python.outputs.python-path }} @@ -589,6 +616,15 @@ jobs: update-environment: false - name: Set up uv uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + # Pinned, not inherited: setup-uv's `version` defaults to the + # version in pyproject.toml or, absent one, whatever is latest. + # There is no pyproject.toml here, so this resolved to latest -- + # a SHA-pinned action installing an unpinned tool. + version: 0.11.30 + # Evidence must come from a cold, reviewable run: a restored entry + # would make the receipt describe something other than this run. + enable-cache: false - name: Provision validator env: PYTHON_PATH: ${{ steps.python.outputs.python-path }} diff --git a/.github/workflows/semgrep-ci.yml b/.github/workflows/semgrep-ci.yml index 2f81529..06ece1c 100644 --- a/.github/workflows/semgrep-ci.yml +++ b/.github/workflows/semgrep-ci.yml @@ -60,6 +60,9 @@ jobs: uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 with: version: 0.11.30 + # uv provisions our pinned analyser here, not the caller's + # dependencies; a restored entry would be unreviewed input to a scan. + enable-cache: false - name: Semgrep scan env: diff --git a/.github/workflows/sql-ci.yml b/.github/workflows/sql-ci.yml index dd52bf6..73da456 100644 --- a/.github/workflows/sql-ci.yml +++ b/.github/workflows/sql-ci.yml @@ -68,6 +68,9 @@ jobs: uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 with: version: 0.11.30 + # uv provisions our pinned tooling here, not the caller's + # dependencies; a restored entry would be unreviewed input to a scan. + enable-cache: false - name: Lint if: ${{ inputs.lint }} diff --git a/.github/workflows/zizmor-no-sarif.yml b/.github/workflows/zizmor-no-sarif.yml index 697670c..c681a0a 100644 --- a/.github/workflows/zizmor-no-sarif.yml +++ b/.github/workflows/zizmor-no-sarif.yml @@ -59,6 +59,9 @@ jobs: uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 with: version: 0.11.30 + # Consumer-facing gate. uv provisions our pinned analyser here, + # not the caller's dependencies, so there is nothing to choose. + enable-cache: false - name: Run zizmor (plain, enforced) env: diff --git a/.github/workflows/zizmor-sarif.yml b/.github/workflows/zizmor-sarif.yml index f5e0aa1..7dc8e07 100644 --- a/.github/workflows/zizmor-sarif.yml +++ b/.github/workflows/zizmor-sarif.yml @@ -60,6 +60,9 @@ jobs: uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 with: version: 0.11.30 + # This workflow backs the `zizmor` job, which is in `ci-gate.needs`. + # A required gate must not take an unreviewed input. + enable-cache: false - name: Run zizmor (SARIF) id: scan diff --git a/CHANGELOG.md b/CHANGELOG.md index 0343d6e..b08c78a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,43 @@ ## [Unreleased] +- Make the cache contract true, and derive the surface it has to cover. The + catalog recorded `astral-sh/setup-uv` as not caching by default. The pinned + action declares `enable-cache: auto`, and its `getEnableCache()` returns true + when `RUNNER_ENVIRONMENT` is `github-hosted` — so the entry asserted the + opposite of the behaviour, and nothing executed the assertion. Two gate jobs + carried a comment saying their explicit `false` "changes nothing today"; it + changed something. + + `upstream_default` now records the literal default the pinned `action.yml` + declares, and `check_cache_upstream_defaults.py` resolves it from that exact + commit each sweep. `default_caches` stops being prose about somebody else's + code. + + The refusal list was hand-written, and the contract's own closing paragraph + claimed the undeclared remainder could not reach a release or a required check. + It could. `check_cache_contract.py` now derives the required surface from + `ci-gate`'s own `needs` graph and follows a `uses:` job into the workflow it + calls, demanding a declared refusal for every step there that caches with no + input. That immediately found **two** leaks, not the one that was known: + `zizmor-sarif.yml`, which backs the required `zizmor` job, and `actions/setup-go` + in `shell-gates`, which caches its module and build cache by default and had + never been named. + + Twelve call sites now state their refusal rather than inheriting a default, each + with the reason it applies there. `python-ci.yml` keeps `enable-cache: true`, + which is deliberate: it caches the consumer's own dependencies. + + Two of those call sites — `qt-ci.yml` and `runtime-fixtures-languages.yml` — + had no `with:` block at all, so they were also not pinning **uv itself**. + setup-uv's `version` defaults to the version in `pyproject.toml` or, absent one, + latest; there is no `pyproject.toml` here. A SHA-pinned action was installing an + unpinned tool, in a product reusable consumers call. Both now pin `0.11.30` like + everywhere else. + + All six affected reusables were re-proven by real fixture runs before their + `proven_digest` was updated, not after. + - Read action definitions from the raw host, so the check can finish in the job that owns it. With the graph walk corrected, the first real sweep still failed on two of forty-three pins — `aquasecurity/trivy-action` and diff --git a/catalog/cache-contract.yml b/catalog/cache-contract.yml index 4d106d0..e9275ea 100644 --- a/catalog/cache-contract.yml +++ b/catalog/cache-contract.yml @@ -13,35 +13,66 @@ # classified rather than inherited. schema_version: 1 -# Every action in the tree that can write to a cache, and the input that decides -# whether it does. `default_caches` is the behaviour with no input at all -- the -# three `true` entries are the ones that cache without being asked. +# Every action in the tree that can write to a cache, the input that decides +# whether it does, and what happens with no input at all. +# +# `upstream_default` is the literal default declared by the pinned action's own +# `action.yml`, and `check_cache_upstream_defaults.py` resolves it from that file +# every sweep. It used to be prose: `astral-sh/setup-uv` was recorded as not +# caching by default when the pinned action declares `auto`, and `auto` resolves +# to true on a GitHub-hosted runner. So the contract asserted the opposite of the +# behaviour, and nothing executed the assertion. +# +# `default_caches` is the effective behaviour on the runners this library targets. +# Five of the eight cache with no input: two because a cache is what they are +# (`actions/cache`, `hendrikmuhs/ccache-action`, added deliberately by a caller), +# and three because their own default says so. producers: - action: actions/cache control: null + upstream_default: null default_caches: true note: Explicit cache; the key is written by the calling workflow. - action: astral-sh/setup-uv control: enable-cache - default_caches: false + upstream_default: auto + default_caches: true + note: >- + `auto` in the pinned action.yml, and its `getEnableCache()` returns true + when RUNNER_ENVIRONMENT is `github-hosted`. Recorded as caching by default + because that is what it does on the runners this library targets. This was + recorded as `false` and believed for long enough that two gate jobs carried + a comment saying the input changed nothing today. - action: actions/setup-go control: cache + upstream_default: true default_caches: true note: Caches the module and build cache unless asked not to. - action: actions/setup-java control: cache + upstream_default: null default_caches: false + note: No default declared, so an absent input caches nothing. - action: actions/setup-node control: cache + upstream_default: null default_caches: false + note: No default declared, so an absent input caches nothing. - action: actions/setup-python control: cache + upstream_default: null default_caches: false + note: No default declared, so an absent input caches nothing. - action: gradle/actions/setup-gradle control: cache-provider + upstream_default: enhanced default_caches: true + note: >- + The input selects a provider rather than switching caching off; a provider + is always chosen, so caching happens unless disabled by other means. - action: hendrikmuhs/ccache-action control: null + upstream_default: null default_caches: true note: A cache action by construction; callers opt in by adding the step. @@ -91,9 +122,34 @@ refusals: input: enable-cache value: false reason: The blocking gate must not depend on an unreviewed input. + - workflow: .github/workflows/ci.yml + job: shell-gates + action: actions/setup-go + input: cache + value: false + reason: >- + setup-go caches the module and build cache with no input at all, and this + job is in `ci-gate.needs`. Found by deriving the required surface from the + gate's own graph; the hand-written list had not named it. + - workflow: .github/workflows/zizmor-sarif.yml + job: zizmor + action: astral-sh/setup-uv + input: enable-cache + value: false + reason: >- + This workflow backs the `zizmor` job, which is in `ci-gate.needs`. The + contract's own closing paragraph used to assert that the undeclared + remainder could not reach a required check; this is the counter-example. -# Everything else is left to the producer's default on purpose. Those workflows -# are reusable surfaces whose caching a consumer chooses, or advisory lanes where -# a poisoned entry cannot reach a release or a required check. Narrowing them -# would change behaviour consumers already depend on without buying a trust -# boundary that matters. +# Everything else is left to the producer's default on purpose, and there is now +# exactly one such place: `python-ci.yml`, which sets `enable-cache: true` +# deliberately -- it caches the consumer's own dependencies, which is the point of +# the workflow. +# +# The previous version of this paragraph said the remainder were "advisory lanes +# where a poisoned entry cannot reach a release or a required check". That was +# false: `zizmor-sarif.yml` is called by the `zizmor` job, which is in +# `ci-gate.needs`, and it took setup-uv's default. The refusals below are no +# longer a hand-written list -- `check_cache_contract.py` derives the required +# jobs from `ci-gate`'s own `needs` graph, follows local `uses:` into the called +# workflow, and demands a refusal for every cache-capable step it finds there. diff --git a/catalog/python-execution.yml b/catalog/python-execution.yml index 6f51dbb..27ef955 100644 --- a/catalog/python-execution.yml +++ b/catalog/python-execution.yml @@ -2,7 +2,7 @@ "schema_version": 1, "python": { "major_minor": "3.13", - "subject_count": 59, + "subject_count": 60, "launcher": "scripts/check_python_execution_contract.py", "launcher_prefix": [".venv/bin/python", "-I", "-B", "scripts/check_python_execution_contract.py", "--launch"], "syntax_gate_prefix": [".venv/bin/python", "-I", "-B", "scripts/check_python_syntax.py"], @@ -120,6 +120,7 @@ "_strict_yaml.py", "check_actionlint_contract.py", "check_actionlint_config.py", "check_anchor_contexts.py", "check_benchmark_contract.py", "check_cache_contract.py", + "check_cache_upstream_defaults.py", "check_ci_tier_selection.py", "check_maintenance_report_contract.py", "check_validation_tier_contract.py", @@ -161,6 +162,7 @@ "check_actionlint_contract.py": ["_workflow_yaml", "check_python_execution_contract"], "check_benchmark_contract.py": ["_workflow_yaml"], "check_cache_contract.py": ["_strict_yaml", "_workflow_yaml"], + "check_cache_upstream_defaults.py": ["_strict_yaml", "_workflow_yaml", "check_transitive_action_pins"], "check_ci_tier_selection.py": ["_workflow_yaml", "check_python_execution_contract"], "check_maintenance_report_contract.py": ["_workflow_yaml", "check_python_execution_contract"], "check_validation_tier_contract.py": ["_strict_yaml", "_workflow_yaml", "validate_all"], @@ -202,6 +204,7 @@ "_strict_yaml", "check_actionlint_config", "check_actionlint_contract", "check_anchor_contexts", "check_benchmark_contract", "check_cache_contract", + "check_cache_upstream_defaults", "check_ci_tier_selection", "check_maintenance_report_contract", "check_validation_tier_contract", diff --git a/catalog/runtime-coverage.yml b/catalog/runtime-coverage.yml index 0bd3fbb..12c0e7f 100644 --- a/catalog/runtime-coverage.yml +++ b/catalog/runtime-coverage.yml @@ -202,8 +202,8 @@ entries: criticality: required-gate status: runtime-proven evidence: 'Called by runtime-fixtures.yml; the lane provisioned uv through the new setup_uv input, installed hash-pinned dependencies and ran a real caller command. The first fixture attempt failed here with uv not found, which is how the missing-installer gap was found.' - last_run: https://github.com/NDDev-it-com/ci-workflows/actions/runs/31621986220 - proven_digest: 888618f9891ae14e619e5a1936a03c67cf7d54070e331c01be290c1285e63a5d + last_run: https://github.com/NDDev-it-com/ci-workflows/actions/runs/31853478055 + proven_digest: 776ec08e160439ff107f92f7715c1f1928254245863ac8cb51938079b3d0f877 waiver: null - workflow: .github/workflows/public-codeql.yml criticality: security-blocking @@ -254,8 +254,8 @@ entries: criticality: supporting status: runtime-proven evidence: "Fixture caller ran the default configure/build/test lanes against tests/fixtures/qt on ubuntu-latest and the observer accepted the receipt: four sections (build, configure, test, toolchain), one CTest test, Qt 6.8.3 resolved and installed, and aqtinstall(aqt) v3.3.0. Qt is installed by a pinned aqtinstall invoked directly, so the workflow reaches no action it does not pin and starts where full-SHA pinning is enforced; check_transitive_action_pins reports the tree clean. The version resolves to exactly one published release and the architecture to exactly one published architecture, both refusing to guess, and the install root is located by its own qmake rather than derived from the architecture name. Linux only: only ubuntu-latest has been run." - last_run: https://github.com/NDDev-it-com/ci-workflows/actions/runs/31827607547 - proven_digest: bdabfaf58640dc13965fe9b00c14325fa961587a16b82e9eae06bada09b5fbf6 + last_run: https://github.com/NDDev-it-com/ci-workflows/actions/runs/31853477977 + proven_digest: 1b3fc160002e1960e567599a71ebd2cca677bf54c7abe0359f86a02e74ac1f0c waiver: null - workflow: .github/workflows/r-ci.yml criticality: supporting @@ -327,16 +327,16 @@ entries: criticality: security-blocking status: runtime-proven evidence: 'Called by runtime-fixtures.yml; the pinned Semgrep ran 33 rules over the tree and the non-SARIF gate path executed. An earlier run of the same fixture exited 1 on ten real findings, so the gate is proven to fire as well as to pass.' - last_run: https://github.com/NDDev-it-com/ci-workflows/actions/runs/31621986220 - proven_digest: 849b5394257034a01a2253e116430e71e7298bcf084ccc4346e6eb1316a1429c + last_run: https://github.com/NDDev-it-com/ci-workflows/actions/runs/31853478055 + proven_digest: 803e2caabac4407b1a7ead52b3324de1e55e481734dd1a8887e3536f831570f1 waiver: null - workflow: .github/workflows/sql-ci.yml proven_os: [linux, macos, windows] criticality: supporting status: runtime-proven evidence: "Fixture caller ran sqlfluff on the ansi dialect against tests/fixtures/sql. Proven on ubuntu-latest, macos-latest and windows-latest — the `runner` input is a promise a consumer may pick their operating system, and all three standard hosted runners are unmetered on public repositories. Also proven to FAIL: the negative gates in ci.yml fed it SQL that violates the ansi dialect and the gate refused it (https://github.com/NDDev-it-com/ci-workflows/actions/runs/31621986015). Passing on good input and refusing bad input are different claims, and only the second makes it a gate." - last_run: https://github.com/NDDev-it-com/ci-workflows/actions/runs/31621986194 - proven_digest: 706b484365bf3d17b3b693a11ad5ff57207f388d007f0919e94771ff5864bb43 + last_run: https://github.com/NDDev-it-com/ci-workflows/actions/runs/31853477977 + proven_digest: 495cba2172bda93f08241f0c695109522ebc6632020c2fc9cadad55508f05461 waiver: null - workflow: .github/workflows/swift-ci.yml proven_os: [macos] @@ -366,15 +366,15 @@ entries: criticality: security-blocking status: runtime-proven evidence: "'Called by runtime-fixtures.yml; the private-free lane ran to completion on contents:read alone, proving the reduced permission set is sufficient without the SARIF upload. Its online audits fired and found three ref-version-mismatch pins that a tokenless local run had reported clean.' Also proven to FAIL: the negative gates in ci.yml fed it a workflow interpolating a pull-request title into a shell command and the gate refused it (https://github.com/NDDev-it-com/ci-workflows/actions/runs/31621986015). Passing on good input and refusing bad input are different claims, and only the second makes it a gate." - last_run: https://github.com/NDDev-it-com/ci-workflows/actions/runs/31621986220 - proven_digest: 3038950341e4ab95198ad21c0d766195ebf6137d406b6aedd1c6f16a26df89b2 + last_run: https://github.com/NDDev-it-com/ci-workflows/actions/runs/31853478055 + proven_digest: c7c77335e5b3ef9d3f2e950ce17512f3e7aa1f7e06e8dcfaa5b3b6a96b5b87fd waiver: null - workflow: .github/workflows/zizmor-sarif.yml criticality: security-blocking status: runtime-proven evidence: 'Fixture audited a valid workflow with online audits enabled, produced valid SARIF, and uploaded it through the code-scanning permission path.' - last_run: https://github.com/NDDev-it-com/ci-workflows/actions/runs/31654663838 - proven_digest: f3a08601a5a6856c0bbf549fbb48df8d73dec61ddeb61d4a161adf0d190e30d3 + last_run: https://github.com/NDDev-it-com/ci-workflows/actions/runs/31853478055 + proven_digest: e64c79f5b976bf95f2edc9d0b930a734ae487750c5d63f33f394943d1cf669db waiver: null - workflow: .github/workflows/gate.yml criticality: supporting diff --git a/catalog/validation-tiers.yml b/catalog/validation-tiers.yml index cccb6d4..c0707c2 100644 --- a/catalog/validation-tiers.yml +++ b/catalog/validation-tiers.yml @@ -53,6 +53,8 @@ requirements: - network:storage.googleapis.com qt-pin: - network:download.qt.io + cache-upstream-defaults: + - network:raw.githubusercontent.com transitive-action-pins: - network:raw.githubusercontent.com anchor-contexts: diff --git a/scripts/check_cache_contract.py b/scripts/check_cache_contract.py index 3e09410..ee8777b 100644 --- a/scripts/check_cache_contract.py +++ b/scripts/check_cache_contract.py @@ -19,12 +19,23 @@ * **Declarations describe the tree.** A producer nobody uses and a refusal whose job no longer exists are both findings, because a contract that names things that are gone stops being read. +* **The required surface is derived, not listed.** `ci-gate`'s own `needs` graph + says which jobs a merge depends on, and a `uses:` job is followed into the + workflow it calls. Any step in that surface running an action that caches by + default must carry a declared refusal. The hand-written list missed exactly + this: `zizmor-sarif.yml` backs the required `zizmor` job and took setup-uv's + default, while the contract's own closing paragraph asserted that the + undeclared remainder could not reach a required check. What this cannot see, stated plainly rather than implied: an action that caches -by default and exposes no input at all is invisible to static analysis. The -contract records `default_caches` for exactly that reason -- three of the eight -producers cache without being asked -- and classifying a new dependency remains -a human judgement made when it is added. +by default and exposes no input at all is invisible to static analysis, and +`default_caches` is a claim about somebody else's code. So it is no longer only +a claim: `upstream_default` records the literal default the pinned `action.yml` +declares, and `check_cache_upstream_defaults.py` resolves it from that file in +the advisory sweep. That is what caught `astral-sh/setup-uv` being recorded as +not caching by default when it declares `auto`, which resolves to true on a +GitHub-hosted runner -- an entry believed for long enough that two gate jobs +carried a comment saying their explicit `false` changed nothing. """ from __future__ import annotations @@ -36,6 +47,8 @@ ROOT = Path(__file__).resolve().parent.parent CONTRACT = ROOT / "catalog/cache-contract.yml" +CI = ".github/workflows/ci.yml" +GATE_JOB = "ci-gate" def _steps(workflow: dict[str, Any]) -> list[tuple[str, dict[str, Any]]]: @@ -53,6 +66,62 @@ def _action(step: dict[str, Any]) -> str: return str(step.get("uses", "")).split("@")[0] +def _required_surface() -> set[tuple[str, str]]: + """Every (workflow, job) a merge depends on, read from `ci-gate`'s own graph. + + A `uses:` job is followed into the workflow it calls, because that is where + its steps actually live -- and where the cache-capable step that started all + of this was hiding. + """ + surface: set[tuple[str, str]] = set() + gate = ((load_yaml(ROOT / CI).get("jobs") or {}).get(GATE_JOB) or {}) + needs = gate.get("needs") or [] + queue = [(CI, str(job)) for job in ([needs] if isinstance(needs, str) else needs)] + while queue: + relative, job_id = queue.pop() + if (relative, job_id) in surface: + continue + path = ROOT / relative + if not path.is_file(): + continue + surface.add((relative, job_id)) + job = (load_yaml(path).get("jobs") or {}).get(job_id) or {} + uses = str(job.get("uses") or "") + if uses.startswith("./"): + called = uses[2:] + if (ROOT / called).is_file(): + for called_job in (load_yaml(ROOT / called).get("jobs") or {}): + queue.append((called, str(called_job))) + return surface + + +def _required_refusal_problems(producers: dict, refusals: list) -> list[str]: + """A caching step inside the required surface must carry a declared refusal.""" + problems: list[str] = [] + declared = { + (str(entry["workflow"]), str(entry["job"]), str(entry["action"])) + for entry in refusals + } + for relative, job_id in sorted(_required_surface()): + workflow = load_yaml(ROOT / relative) + job = (workflow.get("jobs") or {}).get(job_id) or {} + for step in job.get("steps") or []: + if not isinstance(step, dict) or not step.get("uses"): + continue + action = _action(step) + producer = producers.get(action) + if not producer or not producer.get("default_caches"): + continue + if producer.get("control") is None: + continue + if (relative, job_id, action) not in declared: + problems.append( + f"{relative}: job {job_id!r} is required by {GATE_JOB} and runs " + f"{action}, which caches with no input, but no refusal is " + "declared for it in catalog/cache-contract.yml") + return problems + + def check() -> list[str]: problems: list[str] = [] contract = strict_load(CONTRACT) @@ -84,6 +153,8 @@ def check() -> list[str]: problems.append( f"catalog/cache-contract.yml declares producer {action}, which no workflow uses") + problems += _required_refusal_problems(producers, refusals) + for refusal in refusals: relative = str(refusal["workflow"]) job_id = str(refusal["job"]) diff --git a/scripts/check_cache_upstream_defaults.py b/scripts/check_cache_upstream_defaults.py new file mode 100644 index 0000000..8c7b00f --- /dev/null +++ b/scripts/check_cache_upstream_defaults.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +"""`default_caches` is a claim about somebody else's code. Resolve it. + +`catalog/cache-contract.yml` records, for every cache-capable action in the tree, +what happens with no input at all. That is the field the whole contract leans on: +a step with no cache input is only safe if the action's own default is safe, and +static analysis of this repository cannot see the difference. + +It was prose, and it was wrong. `astral-sh/setup-uv` was recorded as not caching +by default while the pinned `action.yml` declares `enable-cache: auto`, and its +`getEnableCache()` returns true when `RUNNER_ENVIRONMENT` is `github-hosted`. The +entry was believed long enough that two required jobs carried a comment saying +their explicit `false` "changes nothing today". It changed something. + +So `upstream_default` records the literal default the pinned action declares, and +this reads it from that exact commit every sweep. Advisory: what a third party +writes in its own `action.yml` is not a property of this tree, and the answer +changes only when a pin moves. +""" +from __future__ import annotations + +import re +from pathlib import Path + +from ci_workflows_tools._strict_yaml import strict_load, strict_loads +from ci_workflows_tools._workflow_yaml import workflow_files +from ci_workflows_tools.check_transitive_action_pins import ( + Unavailable, + _candidate_paths, + _fetch, +) + +ROOT = Path(__file__).resolve().parent.parent +CONTRACT = ROOT / "catalog/cache-contract.yml" +PIN = re.compile(r"uses:\s*(?P[\w.-]+/[\w./-]+)@(?P[0-9a-f]{40})") + + +def _pinned() -> dict[str, set[str]]: + """Every action in the tree and the commits it is pinned to.""" + pins: dict[str, set[str]] = {} + for path in workflow_files(): + for match in PIN.finditer(path.read_text(encoding="utf-8")): + pins.setdefault(match.group("action"), set()).add(match.group("sha")) + return pins + + +def _declared_default(action: str, sha: str, control: str) -> object: + """The default the pinned definition declares for its cache input.""" + parts = action.split("/") + repo = "/".join(parts[:2]) + for candidate in _candidate_paths("/".join(parts[2:])): + text = _fetch(repo, candidate, sha) + if text is None: + continue + inputs = (strict_loads(text, f"{action}@{sha}").get("inputs") or {}) + entry = inputs.get(control) + if entry is None: + raise Unavailable(f"{action}@{sha} declares no input named {control!r}") + return entry.get("default") + raise Unavailable(f"{action}@{sha}: no definition at that ref") + + +def check() -> list[str]: + contract = strict_load(CONTRACT) + pins = _pinned() + problems: list[str] = [] + for producer in contract.get("producers") or []: + action = str(producer["action"]) + control = producer.get("control") + expected = producer.get("upstream_default") + if control is None: + if expected is not None: + problems.append( + f"catalog/cache-contract.yml: {action} declares no control input, " + f"so `upstream_default` must be null, not {expected!r}") + continue + shas = pins.get(action) + if not shas: + # `check_cache_contract` already reports a producer nobody uses; not + # repeating it here keeps one finding to one cause. + continue + for sha in sorted(shas): + try: + actual = _declared_default(action, sha, str(control)) + except Unavailable as exc: + problems.append(f"{action}: upstream default unverified, {exc}") + continue + if actual != expected: + problems.append( + f"catalog/cache-contract.yml records {action} `{control}` " + f"defaulting to {expected!r}, but the pinned definition at " + f"{sha[:12]} declares {actual!r}") + return problems + + +def main() -> int: + problems = check() + if problems: + print("check_cache_upstream_defaults: FAIL") + for problem in problems: + print(f" - {problem}") + return 1 + print("check_cache_upstream_defaults: OK") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/validate_all.py b/scripts/validate_all.py index c213f5a..f8b31b3 100644 --- a/scripts/validate_all.py +++ b/scripts/validate_all.py @@ -48,6 +48,7 @@ check_anchor_contexts, check_benchmark_contract, check_cache_contract, + check_cache_upstream_defaults, check_ci_tier_selection, check_maintenance_report_contract, check_validation_tier_contract, @@ -170,6 +171,7 @@ # than about the tree. `catalog/validation-tiers.yml` records what each needs and # `check_validation_tier_contract.py` holds every caller to it. EXTERNAL = [ + ("cache-upstream-defaults", check_cache_upstream_defaults.check), ("flutter-pin", check_flutter_pin.check), ("qt-pin", check_qt_pin.check), ("transitive-action-pins", check_transitive_action_pins.check),