diff --git a/.github/actions/kind-e2e-setup/action.yml b/.github/actions/kind-e2e-setup/action.yml index d3dc0a3f..6bfb5eeb 100644 --- a/.github/actions/kind-e2e-setup/action.yml +++ b/.github/actions/kind-e2e-setup/action.yml @@ -42,7 +42,7 @@ runs: - uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 with: - version: 3.51.1 + version: 3.52.0 - name: Restore kind binary cache id: kind-cache diff --git a/.github/workflows/changelog-sync.yaml b/.github/workflows/changelog-sync.yaml index 73e1bb75..bd7c8e84 100644 --- a/.github/workflows/changelog-sync.yaml +++ b/.github/workflows/changelog-sync.yaml @@ -92,7 +92,7 @@ jobs: persist-credentials: false - uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 with: - version: 3.51.1 + version: 3.52.0 - name: Regenerate CHANGELOG.md run: task changelog:write env: diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 55bd156c..2665e143 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -22,12 +22,13 @@ # # The docs-only path is NOT free, and that is a deliberate trade. Where this workflow previously # was not dispatched at all, a documentation-only PR now runs `changes`, `gitleaks`, `lint` and -# `tool-pins-selftest` (CFC-6, the tool-pins gate's self-test, split out of lint for wall time) -- -# `lint` because two existing gates (dist_ci_wiring_test.sh, repo_root_links_wiring_test.sh) -# require it to be unconditional, and because it is where ci_docs_gate_test.sh runs, so leaving -# it ungated is the only thing that exercises the docs-only direction at all. What such a PR no -# longer pays for is the Go suite, the kind clusters, testcontainers, the fuzz matrix and the -# image builds -- and it no longer needs a ruleset bypass to merge. +# `tool-pins-selftest` (CFC-6 and DTP-7: the tool-pins and toolchain-pins self-tests, split out of +# lint for wall time) -- `lint` because two existing gates (dist_ci_wiring_test.sh, +# repo_root_links_wiring_test.sh) require it to be unconditional, and because it is where +# ci_docs_gate_test.sh runs, so leaving it ungated is the only thing that exercises the docs-only +# direction at all. What such a PR no longer pays for is the Go suite, the kind clusters, +# testcontainers, the fuzz matrix and the image builds -- and it no longer needs a ruleset bypass +# to merge. name: CI on: @@ -236,7 +237,7 @@ jobs: - uses: ./.github/actions/go-cache - uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 with: - version: 3.51.1 + version: 3.52.0 - name: Verify generated artifacts run: task verify env: @@ -269,7 +270,7 @@ jobs: - uses: ./.github/actions/go-cache - uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 with: - version: 3.51.1 + version: 3.52.0 - name: govulncheck run: task vulncheck @@ -291,7 +292,7 @@ jobs: - uses: ./.github/actions/go-cache - uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 with: - version: 3.51.1 + version: 3.52.0 - name: Check formatting run: task format:check - name: Install ShellCheck @@ -408,15 +409,6 @@ jobs: # half; docs.yaml's docs/** filter covers a change to the map itself. - name: Verify the docs map labels the pages it points at (DOC-MAP-01) run: bash hack/test/docs_map_contract_test.sh - # DEV-MISE-01: mise.toml restates the task/node/python versions this job installs, and a - # restated literal with no mechanism behind it is how release.yaml reached helm v3.21.4 - # while CI stayed on v3.17.3 (see renovate.json's own comment). Renovate's go-task manager - # scans only .github/(actions|workflows)/, and nothing at all manages the node-version / - # python-version inputs, so this comparison is the only thing that would notice. Run it - # here, unconditionally: mise.toml and hack/** are both outside this workflow's - # paths-ignore, so every change class that can break the contract triggers this job. - - name: Verify mise.toml has not drifted from the CI pins (DEV-MISE-01) - run: bash hack/test/dev_mise_pin_drift_test.sh # DOC-ADRREFS-01: every ADR-\d{4} token in tracked files must resolve to a committed ADR. # ADRs get renumbered and deleted; code comments, CRD descriptions (user-visible via # `kubectl explain`), the chart values schema, and docs keep citing the retired numbers @@ -562,6 +554,21 @@ jobs: run: bash hack/test/consistency_metrics_test.sh - name: Verify the metrics gate can still fail (CFC-6) run: bash hack/test/consistency_metrics_test.sh --self-test + # DTP-1/DTP-2/DTP-3/DTP-5/DTP-6/DTP-7 (openspec change developer-toolchain-pins): Task (every + # setup-task version: input and mise.toml), gitleaks, git-cliff and shellcheck agree across + # their pin sites and meet their floors, shellcheck has digests for its pin, task vulncheck + # runs the Renovate-annotated GOVULNCHECK_VERSION, and the installer-checksum exemptions + # only shrink. DEV-MISE-01: mise.toml pins task, its + # node/python equal every setup-node/setup-python pin under .github/ and Go stays read from + # go.mod. Renovate's github-actions manager extracts those node/python inputs too, but a + # renovate.json rule disables them (their bump would leave mise.toml behind and red this + # check), so they are bumped by hand and this comparison keeps mise.toml in step; the + # guard's Renovate model checks the rule. DTP-5: a mise.toml or Taskfile.yml comment states + # a version only directly above (or on) the pin it names, at its value, and never counts + # pin sites. + # Its --self-test (DTP-7) runs in the tool-pins-selftest job below. + - name: Verify Task, gitleaks, git-cliff, shellcheck, govulncheck and mise.toml pins agree (DTP-1, DTP-2, DTP-3, DTP-5, DTP-6, DEV-MISE-01) + run: bash hack/test/consistency_toolchain_pins_test.sh - name: Verify the go-patch-lag sensor verdicts and workflow shape (CFC-5) run: bash hack/test/go_patch_lag_test.sh - name: Verify the go-patch-lag test can still fail (CFC-6) @@ -587,8 +594,11 @@ jobs: # verdict: it runs on every PR, docs-only included, and is a required context # (hack/release/verify-eligibility.sh). hack/test/ci_workflow_security_test.sh (CFC-6 rule) # requires the self-test in this job and hack/test/ci_docs_gate_test.sh keeps the job - # unconditional. The timeout bounds a stalled module or vuln-DB download; local runs took - # 7 to 17 minutes. + # unconditional. The toolchain-pins self-test (DTP-7, openspec change developer-toolchain-pins) + # runs here too, for the same reason: about 8 minutes, half of it the all-pins bump control's + # nested self-test. It also removes the toolchain-pins guard's steps from a copy and expects + # the CWS-6/CFC-6 meta-test to red, so it needs yq. The timeout bounds a stalled module or + # vuln-DB download; local runs of the two took 15 to 25 minutes. tool-pins-selftest: name: tool-pins-selftest runs-on: ubuntu-latest @@ -600,6 +610,13 @@ jobs: - uses: ./.github/actions/go-cache - name: Verify the tool-pins gate can still fail (CFC-6) run: bash hack/test/consistency_tool_pins_test.sh --self-test + - name: Ensure yq is available + run: | + # ubuntu-latest images generally ship yq, but don't assume it (same step as lint's). + command -v yq >/dev/null 2>&1 || sudo snap install yq + yq --version + - name: Verify the toolchain-pins gate can still fail (DTP-7) + run: bash hack/test/consistency_toolchain_pins_test.sh --self-test # CI-DOCSGATE-01: the REAL suite. It is deliberately NOT named `test` -- the required context # of that name is produced by the `test` job at the end of this file, which reports on every @@ -624,7 +641,7 @@ jobs: envtest: true - uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 with: - version: 3.51.1 + version: 3.52.0 - name: Unit tests and coverage floor run: task coverage env: @@ -664,7 +681,7 @@ jobs: - uses: ./.github/actions/go-cache - uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 with: - version: 3.51.1 + version: 3.52.0 - name: Build manager run: task build @@ -681,7 +698,7 @@ jobs: - uses: ./.github/actions/go-cache - uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 with: - version: 3.51.1 + version: 3.52.0 - name: Verify Forgejo pre-pull safeguards run: bash test/ci/forgejo_prepull_test.sh - name: Pre-pull Forgejo image @@ -707,7 +724,7 @@ jobs: persist-credentials: false - uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 with: - version: 3.51.1 + version: 3.52.0 - name: Install Helm env: HELM_VERSION: v3.22.0 @@ -733,7 +750,7 @@ jobs: envtest: true - uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 with: - version: 3.51.1 + version: 3.52.0 - name: Performance snapshot (optional gate) run: task perf-report env: @@ -857,7 +874,7 @@ jobs: - uses: ./.github/actions/go-cache - uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 with: - version: 3.51.1 + version: 3.52.0 - name: Build controller image run: task docker:build diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index b8754971..99aed42b 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -108,7 +108,7 @@ jobs: cache-dependency-path: docs/package-lock.json - uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 with: - version: 3.51.1 + version: 3.52.0 - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version-file: go.mod diff --git a/.github/workflows/e2e-nightly.yaml b/.github/workflows/e2e-nightly.yaml index c846a11c..e8c49590 100644 --- a/.github/workflows/e2e-nightly.yaml +++ b/.github/workflows/e2e-nightly.yaml @@ -108,7 +108,7 @@ jobs: - uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 with: - version: 3.51.1 + version: 3.52.0 - name: Micro-benchmarks run: task bench @@ -140,7 +140,7 @@ jobs: - uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 with: - version: 3.51.1 + version: 3.52.0 - name: Unit + envtest under -race id: race @@ -206,7 +206,7 @@ jobs: - uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 with: - version: 3.51.1 + version: 3.52.0 - name: Extractor hot-path budget (micro-benchmark, not a scale tier) run: task extract-budget @@ -231,7 +231,7 @@ jobs: - uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 with: - version: 3.51.1 + version: 3.52.0 - name: Extended envtest scale (10k) run: go test -run TestEngine_ScaleEnvtestOptIn -count=1 -timeout=35m ./internal/collect/... diff --git a/.github/workflows/preflight.yaml b/.github/workflows/preflight.yaml index 39a39f2b..14cb863c 100644 --- a/.github/workflows/preflight.yaml +++ b/.github/workflows/preflight.yaml @@ -31,7 +31,7 @@ jobs: - uses: ./.github/actions/go-cache - uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 with: - version: 3.51.1 + version: 3.52.0 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: "22" diff --git a/.github/workflows/sonarcloud.yaml b/.github/workflows/sonarcloud.yaml index 6bea732e..ea157448 100644 --- a/.github/workflows/sonarcloud.yaml +++ b/.github/workflows/sonarcloud.yaml @@ -25,7 +25,7 @@ jobs: envtest: true - uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 with: - version: 3.51.1 + version: 3.52.0 - name: Unit tests and coverage run: task coverage env: diff --git a/.gitignore b/.gitignore index 79adfc36..97a28067 100644 --- a/.gitignore +++ b/.gitignore @@ -23,7 +23,7 @@ AGENTS.local.md .envrc .envrc.* # mise's personal-override files. mise.toml itself is committed and mirrors the CI pins -# (see hack/test/dev_mise_pin_drift_test.sh); these two are the per-developer overlay, and +# (see hack/test/consistency_toolchain_pins_test.sh); these two are the per-developer overlay, and # the natural place someone would put an [env] block next to the .envrc above. mise.local.toml .mise.local.toml diff --git a/Taskfile.yml b/Taskfile.yml index 6a8ee5df..568ef51e 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -27,6 +27,8 @@ vars: MARKDOWNLINT_CONFIG: hack/tooling/markdownlint-cli2.yaml # renovate: datasource=github-releases depName=fe3dback/go-arch-lint GO_ARCH_LINT_VERSION: v1.19.0 + # renovate: datasource=go depName=golang.org/x/vuln + GOVULNCHECK_VERSION: v1.9.0 # renovate: datasource=github-releases depName=helm/helm HELM_VERSION: v3.22.0 # renovate: datasource=github-releases depName=norwoodj/helm-docs @@ -201,7 +203,7 @@ tasks: desc: Scan for known Go vulnerabilities (govulncheck) deps: [install] cmds: - - go run golang.org/x/vuln/cmd/govulncheck@v1.9.0 ./... + - go run golang.org/x/vuln/cmd/govulncheck@{{.GOVULNCHECK_VERSION}} ./... test: desc: Run unit tests with envtest (alias for test:run) diff --git a/docs/development/setup.md b/docs/development/setup.md index 584f2388..50703cf5 100644 --- a/docs/development/setup.md +++ b/docs/development/setup.md @@ -36,7 +36,7 @@ mise trust && mise install | Pinned by `mise.toml` | What it mirrors | | --- | --- | | Go | `go.mod` — read straight from it, so the two cannot drift | -| Task | `go-task/setup-task` `version: 3.51.1` in `.github/workflows/` | +| Task | the `go-task/setup-task` `version:` input in `.github/workflows/` and `.github/actions/` | | Node | `node-version: "22"` in the Docs and Preflight workflows (markdownlint) | | Python | `python-version: "3.12"` in the Docs workflow (MkDocs build) | @@ -50,7 +50,7 @@ fetches `shellcheck`, `helm`, `helm-docs`, `git-cliff` and friends. Keeping thos `mise.toml` keeps them at one source of truth each. **CI remains the source of truth for every version**, and that is enforced rather than merely -asked for: `hack/test/dev_mise_pin_drift_test.sh` compares `mise.toml`'s `task` / `node` / +asked for: `hack/test/consistency_toolchain_pins_test.sh` compares `mise.toml`'s `task` / `node` / `python` values against every `setup-task` / `setup-node` / `setup-python` pin under `.github/` and reds the `lint` job on any disagreement. It also locks the Go arrangement — a `go` entry added under `[tools]` would override `go.mod`, so the gate rejects one. If `mise.toml` and a diff --git a/hack/install-git-cliff.sh b/hack/install-git-cliff.sh index 4b3e677b..be72c11e 100755 --- a/hack/install-git-cliff.sh +++ b/hack/install-git-cliff.sh @@ -37,10 +37,33 @@ mkdir -p "$(dirname "${root}/${OUT}")" tmpdir="$(mktemp -d)" trap 'rm -rf "${tmpdir}"' EXIT -# NOTE: this installer verifies no digest -- upstream publishes checksums, but wiring them up is -# a separate change from routing the transport through the shared helper, and hack/test/ -# ci_fetch_lib_hardening_test.sh records the gap explicitly so it cannot be forgotten. -fetch_to "${url}" "${tmpdir}/git-cliff.tgz" "git-cliff ${VERSION} tarball" -tar -xzf "${tmpdir}/git-cliff.tgz" -C "${tmpdir}" +# Upstream publishes one ` ` file per asset and no SHA256, so this compares with +# sha512sum rather than verify_sha256 (stock macOS has no sha512sum, only shasum -a 512; with +# neither it refuses before downloading). The digest is in hand before the tarball is fetched, and +# a failed checksum fetch stops here. The checksum comes from the same release as the tarball, so +# it catches a corrupted or swapped download, not a compromised release. +if command -v sha512sum >/dev/null 2>&1; then + sha512() { sha512sum "$1"; } +elif command -v shasum >/dev/null 2>&1; then + sha512() { shasum -a 512 "$1"; } +else + echo "cannot verify ${asset}: neither sha512sum nor shasum is installed" >&2 + exit 1 +fi +fetch_to "${url}.sha512" "${tmpdir}/${asset}.sha512" "git-cliff ${VERSION} checksum" +expected="$(awk -v file="${asset}" '$2 == file || $2 == "*" file {print $1}' "${tmpdir}/${asset}.sha512")" +if [[ ! "${expected}" =~ ^[0-9a-f]{128}$ ]]; then + echo "failed to resolve a sha512 for ${asset} from ${url}.sha512" >&2 + exit 1 +fi +fetch_to "${url}" "${tmpdir}/${asset}" "git-cliff ${VERSION} tarball" +actual="$(sha512 "${tmpdir}/${asset}" | awk '{print $1}')" +if [[ "${actual}" != "${expected}" ]]; then + echo "checksum mismatch for ${asset}" >&2 + echo " expected: ${expected}" >&2 + echo " actual: ${actual}" >&2 + exit 1 +fi +tar -xzf "${tmpdir}/${asset}" -C "${tmpdir}" install -m 0755 "${tmpdir}/git-cliff-${VER}/git-cliff" "${root}/${OUT}" echo "installed ${OUT} (${VERSION} ${platform})" diff --git a/hack/install-helm-docs.sh b/hack/install-helm-docs.sh index 84e07c34..359664e8 100755 --- a/hack/install-helm-docs.sh +++ b/hack/install-helm-docs.sh @@ -27,9 +27,12 @@ case "$(uname -s)" in esac asset="helm-docs_${VER}_${os}_${arch}.tar.gz" -url="https://github.com/norwoodj/helm-docs/releases/download/${VERSION}/${asset}" +base_url="https://github.com/norwoodj/helm-docs/releases/download/${VERSION}" +url="${base_url}/${asset}" root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck source=lib/verify-sha256.sh +source "${root}/hack/lib/verify-sha256.sh" # shellcheck source=lib/fetch.sh source "${root}/hack/lib/fetch.sh" @@ -37,8 +40,18 @@ mkdir -p "$(dirname "${root}/${OUT}")" tmpdir="$(mktemp -d)" trap 'rm -rf "${tmpdir}"' EXIT -# NOTE: this installer verifies no digest -- see the matching note in hack/install-git-cliff.sh. -fetch_to "${url}" "${tmpdir}/helm-docs.tgz" "helm-docs ${VERSION} tarball" -tar -xzf "${tmpdir}/helm-docs.tgz" -C "${tmpdir}" +# The digest comes from the release's checksums.txt (` ` per asset) and is in hand +# before the tarball is fetched; a failed checksum fetch stops here. Like git-cliff's, it catches a +# corrupted or swapped download, not a compromised release. +fetch_to "${base_url}/checksums.txt" "${tmpdir}/checksums.txt" "helm-docs ${VERSION} checksums" +expected="$(awk -v file="${asset}" '$2 == file {print $1}' "${tmpdir}/checksums.txt")" +# One full SHA256 or nothing: a missing, short or doubled line is refused before any comparison. +if [[ ! "${expected}" =~ ^[0-9a-f]{64}$ ]]; then + echo "failed to resolve a checksum for ${asset} from ${base_url}/checksums.txt" >&2 + exit 1 +fi +fetch_to "${url}" "${tmpdir}/${asset}" "helm-docs ${VERSION} tarball" +verify_sha256 "${tmpdir}/${asset}" "${expected}" +tar -xzf "${tmpdir}/${asset}" -C "${tmpdir}" install -m 0755 "${tmpdir}/helm-docs" "${root}/${OUT}" echo "installed ${OUT} (${VERSION} ${os}/${arch})" diff --git a/hack/lib/verify-sha256.sh b/hack/lib/verify-sha256.sh index ccd6401d..a24d7084 100755 --- a/hack/lib/verify-sha256.sh +++ b/hack/lib/verify-sha256.sh @@ -18,7 +18,15 @@ verify_sha256() { return 1 fi - actual="$(sha256sum "${file}" | awk '{print $1}')" + # sha256sum is GNU coreutils; stock macOS has only shasum. Neither: refuse, never skip. + if command -v sha256sum >/dev/null 2>&1; then + actual="$(sha256sum "${file}" | awk '{print $1}')" + elif command -v shasum >/dev/null 2>&1; then + actual="$(shasum -a 256 "${file}" | awk '{print $1}')" + else + echo "cannot verify $(basename "${file}"): neither sha256sum nor shasum is installed" >&2 + return 1 + fi if [[ "${actual}" != "${expected}" ]]; then echo "checksum mismatch for $(basename "${file}")" >&2 echo " expected: ${expected}" >&2 diff --git a/hack/test/ci_fetch_lib_hardening_test.sh b/hack/test/ci_fetch_lib_hardening_test.sh index e489e6d1..a4c2cd07 100644 --- a/hack/test/ci_fetch_lib_hardening_test.sh +++ b/hack/test/ci_fetch_lib_hardening_test.sh @@ -758,12 +758,13 @@ done pass "every hack/install-*.sh sources the shared helper, fetches only through fetch_to, and names no plaintext URL" # The other direction: nothing may weaken the digest verification while routing through the -# helper. Each installer either compares a sha256 itself or delegates to verify_sha256. -# hack/install-git-cliff.sh and hack/install-helm-docs.sh are the two that verify NOTHING today; -# they are listed here explicitly so the gap is recorded in code rather than lost in a report, -# and so adding verification to them is a one-line deletion from this list rather than a -# rediscovery. -UNVERIFIED_BY_DESIGN=("install-git-cliff.sh" "install-helm-docs.sh") +# helper. Each installer either compares a digest itself (sha256sum, or sha512sum where upstream +# publishes only SHA512, as git-cliff does) or delegates to verify_sha256. Accepting sha512sum adds +# a stronger verifier to the scan; it exempts no installer. +# UNVERIFIED_BY_DESIGN names installers exempt from this scan. It is empty since git-cliff and +# helm-docs gained verification (DTP-6), and hack/test/consistency_toolchain_pins_test.sh reds on +# any entry: an installer that cannot verify its download is a decision, not a list edit. +UNVERIFIED_BY_DESIGN=() for installer in "${INSTALLERS[@]}"; do base="$(basename "${installer}")" skip=0 @@ -776,21 +777,20 @@ for installer in "${INSTALLERS[@]}"; do # `EXPECTED_SHA256=` assigned somewhere in the file passed it -- an installer with a digest # variable and no digest check, which is the exact shape this assertion exists to forbid. verify_hits="$(logical_lines "${installer}" | - count_matches -E '(^|[^[:alnum:]_])(verify_sha256|sha256sum)([[:space:]]|$)')" + count_matches -E '(^|[^[:alnum:]_])(verify_sha256|sha256sum|sha512sum)([[:space:]]|$)')" require_count "${verify_hits}" "hack/${base} checksum scan" [[ "${verify_hits}" != "0" ]] || - fail "hack/${base} no longer verifies a SHA256. Routing a download through a hardened helper must never be traded against verifying what came back: the helper protects the transport, the digest protects the CONTENT, and neither substitutes for the other" + fail "hack/${base} no longer verifies a checksum. Routing a download through a hardened helper must never be traded against verifying what came back: the helper protects the transport, the digest protects the CONTENT, and neither substitutes for the other" done -pass "every installer that verified a SHA256 before still does" +pass "every installer that verified a checksum before still does" -# Guards the list above from rotting into a licence to stop verifying: if someone ADDS -# verification to git-cliff or helm-docs, the entry must be removed, or the next reader will -# believe those two are still unverified. +# Guards the list above from rotting into a licence to stop verifying: an installer listed there +# that verifies after all must be removed, or the next reader will believe it is unverified. for known in "${UNVERIFIED_BY_DESIGN[@]}"; do [[ -f "${ROOT}/hack/${known}" ]] || fail "hack/${known} is listed as unverified-by-design but does not exist -- the exemption list is stale and now silently exempts nothing" verify_hits="$(logical_lines "${ROOT}/hack/${known}" | - count_matches -E '(^|[^[:alnum:]_])(verify_sha256|sha256sum)([[:space:]]|$)')" + count_matches -E '(^|[^[:alnum:]_])(verify_sha256|sha256sum|sha512sum)([[:space:]]|$)')" require_count "${verify_hits}" "hack/${known} exemption scan" [[ "${verify_hits}" == "0" ]] || fail "hack/${known} now verifies a checksum but is still listed in UNVERIFIED_BY_DESIGN in this gate -- drop it from the list so the exemption stops advertising a gap that has been closed" @@ -841,7 +841,7 @@ require_count "${stale_sleep_hits}" "kind-e2e-setup stale-sleep scan" pass "the kind download goes through the shared helper, with no hand-rolled loop and no dead sleep left behind" # ================================================================================================ -# Self-wiring. "An unwired gate is not a gate" (house precedent: hack/test/dev_mise_pin_drift_test.sh). +# Self-wiring. "An unwired gate is not a gate" (house rule: CWS-6 only sees guards CI already runs, so this gate asserts its own wiring below). # ================================================================================================ # # This gate is reached in CI through its own named step in the lint job of diff --git a/hack/test/ci_install_helm_hardening_test.sh b/hack/test/ci_install_helm_hardening_test.sh index 05058ef0..ff80799c 100755 --- a/hack/test/ci_install_helm_hardening_test.sh +++ b/hack/test/ci_install_helm_hardening_test.sh @@ -683,8 +683,8 @@ assert_stub_was_used "missing install prefix" pass "the installer creates a nested install prefix that does not exist yet" # --------------------------------------------------------------------------------------------- -# REVIEW FIX (F2): this gate must assert its own registration. House precedent is -# hack/test/dev_mise_pin_drift_test.sh -- "an unwired gate is not a gate". +# REVIEW FIX (F2): this gate must assert its own registration -- "an unwired gate is not a gate" +# (house rule: CWS-6 only sees guards CI already runs, so a deleted step needs this check). # # It carries more weight here than for the scripts around it. The lint job runs most of its # meta-tests through two globs, `hack/test/dist_*_test.sh` and `hack/test/sonar_ko_*_test.sh`, diff --git a/hack/test/ci_workflow_security_test.sh b/hack/test/ci_workflow_security_test.sh index b0632302..c12a8b8a 100755 --- a/hack/test/ci_workflow_security_test.sh +++ b/hack/test/ci_workflow_security_test.sh @@ -454,7 +454,9 @@ cws6_required_set() { # with `read -r mode`. guard_modes() { echo bare - if grep -v '^[[:space:]]*#' "${1}" 2>/dev/null | grep -q -- '--self-test'; then + # Not `grep -q`: under pipefail it exits at the first match and the upstream grep dies of + # SIGPIPE, so the pipeline reads as no-match at random (15% of runs on a 28 KB guard). + if grep -v '^[[:space:]]*#' "${1}" 2>/dev/null | grep -- '--self-test' >/dev/null; then echo --self-test fi } @@ -609,13 +611,17 @@ cws6_guards() { fi done done - # The tool-pins self-test (~7 min) has its own required job, tool-pins-selftest, so lint's wall - # time stays bounded (operator decision 2026-10-16); it must run THERE, which also requires - # tool-pins-selftest to stay in the required set (rows above are required jobs only). - local tp_script="${ROOT}/hack/test/consistency_tool_pins_test.sh" - if ! awk -F'\t' -v s="${tp_script}" '$1 == s && $2 == "--self-test" && $3 == "tool-pins-selftest" { found = 1 } END { exit !found }' "${required_rows}"; then - fail "CFC-6: hack/test/consistency_tool_pins_test.sh --self-test is not run by the required job 'tool-pins-selftest' -- the slow self-test has its own job, unconditional and required, so it neither starves nor doubles lint" - fi + # The slow self-tests have their own required job, tool-pins-selftest, so lint's wall time + # stays bounded (operator decision 2026-10-16): the tool-pins one (~7 min, CFC-2) and the + # toolchain-pins one (~8 min, DTP-7 of openspec change developer-toolchain-pins). Each must run + # THERE, which also requires tool-pins-selftest to stay in the required set (rows above are + # required jobs only). + local tp_script + for tp_script in consistency_tool_pins_test.sh consistency_toolchain_pins_test.sh; do + if ! awk -F'\t' -v s="${ROOT}/hack/test/${tp_script}" '$1 == s && $2 == "--self-test" && $3 == "tool-pins-selftest" { found = 1 } END { exit !found }' "${required_rows}"; then + fail "CFC-6: hack/test/${tp_script} --self-test is not run by the required job 'tool-pins-selftest' -- the slow self-tests have their own job, unconditional and required, so they neither starve nor double lint" + fi + done # Guards CI runs INDIRECTLY through hack/docs/verify.sh (the Docs workflow's task target) # are still CI-run guards: every hack/test token in that script must also be pinned by a @@ -1110,15 +1116,27 @@ mutant_rejected "CFC-6 consistency guard loses its self-test" "consistency_metri if grep -q -- "--self-test" hack/test/consistency_metrics_test.sh || grep -q "consistency_metrics_test.sh --self-test" .github/workflows/ci.yaml; then echo "mutant did not apply"; exit 1; fi ' -# CFC-6: the tool-pins self-test moved back into lint (its own job deleted) must red. -mutant_rejected "CFC-6 tool-pins self-test back in lint" "is not run by the required job 'tool-pins-selftest'" bash -c ' +# CFC-6: the tool-pins self-test moved back into lint must red. Only its step moves: the job keeps +# the toolchain-pins self-test, so the generic CWS-6 loop has nothing to say first. +mutant_rejected "CFC-6 tool-pins self-test back in lint" "consistency_tool_pins_test.sh --self-test is not run by the required job 'tool-pins-selftest'" bash -c ' + set -euo pipefail + yq eval "del(.jobs[\"tool-pins-selftest\"].steps[] | select((.run // \"\") == \"bash hack/test/consistency_tool_pins_test.sh --self-test\")) | .jobs.lint.steps += [{\"name\": \"tool-pins self-test\", \"run\": \"bash hack/test/consistency_tool_pins_test.sh --self-test\"}]" .github/workflows/ci.yaml > m.yaml && mv m.yaml .github/workflows/ci.yaml + test "$(yq eval "[.jobs[\"tool-pins-selftest\"].steps[] | select((.run // \"\") | test(\"consistency_tool_pins_test\"))] | length" .github/workflows/ci.yaml)" = 0 || { echo "mutant did not apply"; exit 1; } + test "$(yq eval "[.jobs.lint.steps[] | select((.run // \"\") == \"bash hack/test/consistency_tool_pins_test.sh --self-test\")] | length" .github/workflows/ci.yaml)" = 1 || { echo "mutant did not apply"; exit 1; } +' + +# CFC-6: the toolchain-pins self-test moved back into lint (DTP-7, openspec change +# developer-toolchain-pins) must red, though its job and the tool-pins self-test stay. +mutant_rejected "CFC-6 toolchain-pins self-test back in lint" "consistency_toolchain_pins_test.sh --self-test is not run by the required job 'tool-pins-selftest'" bash -c ' set -euo pipefail - yq eval "del(.jobs[\"tool-pins-selftest\"]) | .jobs.lint.steps += [{\"name\": \"tool-pins self-test\", \"run\": \"bash hack/test/consistency_tool_pins_test.sh --self-test\"}]" .github/workflows/ci.yaml > m.yaml && mv m.yaml .github/workflows/ci.yaml - if grep -q "tool-pins-selftest:" .github/workflows/ci.yaml; then echo "mutant did not apply"; exit 1; fi + yq eval "del(.jobs[\"tool-pins-selftest\"].steps[] | select((.run // \"\") == \"bash hack/test/consistency_toolchain_pins_test.sh --self-test\")) | .jobs.lint.steps += [{\"name\": \"toolchain-pins self-test\", \"run\": \"bash hack/test/consistency_toolchain_pins_test.sh --self-test\"}]" .github/workflows/ci.yaml > m.yaml && mv m.yaml .github/workflows/ci.yaml + test "$(yq eval "[.jobs[\"tool-pins-selftest\"].steps[] | select((.run // \"\") | test(\"consistency_toolchain_pins_test\"))] | length" .github/workflows/ci.yaml)" = 0 || { echo "mutant did not apply"; exit 1; } + test "$(yq eval "[.jobs.lint.steps[] | select((.run // \"\") == \"bash hack/test/consistency_toolchain_pins_test.sh --self-test\")] | length" .github/workflows/ci.yaml)" = 1 || { echo "mutant did not apply"; exit 1; } ' -# CFC-6: tool-pins-selftest dropped from the required set must red (the generic CWS-6 loop fires first). -mutant_rejected "CFC-6 tool-pins-selftest not required" "consistency_tool_pins_test.sh runs in CI but its '--self-test' mode is not invoked by any step of a required job" bash -c ' +# CFC-6: tool-pins-selftest dropped from the required set must red (the generic CWS-6 loop fires +# first, on whichever of the job's two self-tests the locale sorts first). +mutant_rejected "CFC-6 tool-pins-selftest not required" "consistency_tool[a-z]*_pins_test.sh runs in CI but its '--self-test' mode is not invoked by any step of a required job" bash -c ' set -euo pipefail sed -i "s/ tool-pins-selftest$//" hack/release/verify-eligibility.sh if grep -q "tool-pins-selftest" hack/release/verify-eligibility.sh; then echo "mutant did not apply"; exit 1; fi @@ -1142,8 +1160,8 @@ mutant_rejected "schema dangling name-only step" "declares neither run nor uses" # CWS-6 ratchet: a guard invoked from a composite action can never be a required check. mutant_rejected "CWS-6 guard in a composite action" "is not a job and can never be a required check" bash -c ' set -euo pipefail - yq eval ".runs.steps += {\"name\": \"guard inside an action (mutant)\", \"run\": \"bash hack/test/dev_mise_pin_drift_test.sh\"}" .github/actions/go-cache/action.yml > m.yaml && mv m.yaml .github/actions/go-cache/action.yml - grep -q "dev_mise_pin_drift_test" .github/actions/go-cache/action.yml || { echo "mutant did not apply"; exit 1; } + yq eval ".runs.steps += {\"name\": \"guard inside an action (mutant)\", \"run\": \"bash hack/test/consistency_toolchain_pins_test.sh\"}" .github/actions/go-cache/action.yml > m.yaml && mv m.yaml .github/actions/go-cache/action.yml + grep -q "consistency_toolchain_pins_test" .github/actions/go-cache/action.yml || { echo "mutant did not apply"; exit 1; } ' # CWS-6: a job that carries a required CONTEXT NAME in a workflow that never runs on PRs is diff --git a/hack/test/consistency_tool_pins_test.sh b/hack/test/consistency_tool_pins_test.sh index 676b7c32..8d529434 100755 --- a/hack/test/consistency_tool_pins_test.sh +++ b/hack/test/consistency_tool_pins_test.sh @@ -122,6 +122,10 @@ make_var() { --eval 'kollect-print-var-%: ; @printf "%s\n" "$($*)"' "kollect-print-var-$2" 2>/dev/null } +# A literal or Taskfile-variable pin is a full semantic version (pre-release and pseudo-versions +# included); `v1` or `v1.9` would be a query that floats to the newest match. +PIN_VERSION_RE='^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$' + # taskfile_var : a top-level `vars:` entry of Taskfile.yml. taskfile_var() { sed -n -E "s/^ $2:[[:space:]]*[\"']?([^\"'#[:space:]]+)[\"']?.*$/\1/p" "$1/Taskfile.yml" | head -1 @@ -159,8 +163,11 @@ scan_pins() { if [[ "${ver}" =~ ^\{\{\.([A-Za-z0-9_]+)\}\}$ && "${file}" == "Taskfile.yml" ]]; then var="${BASH_REMATCH[1]}" ver="$(taskfile_var "${root}" "${var}")" - [[ -n "${ver}" ]] || ver="" - elif [[ ! "${ver}" =~ ^v[0-9] ]]; then + # The variable must hold a full version, as a literal must: `latest`, a branch or a + # partial `v1` through a variable is a query go mod download resolves to whatever is + # newest, not a pin. + [[ "${ver}" =~ ${PIN_VERSION_RE} ]] || ver="}>" + elif [[ ! "${ver}" =~ ${PIN_VERSION_RE} ]]; then ver="" fi printf '%s\t%s\t%s\tpkg\n' "${file}" "${pkg}" "${ver}" @@ -497,8 +504,20 @@ MUTANT_ENV=() mutant_rejected "unresolvable pin (version typo)" \ "golang.org/x/vuln/cmd/govulncheck@v1.9.99 cannot be resolved by go mod download" \ - 'govulncheck@v1\.9\.99' Taskfile.yml \ - sed -i -E 's#(golang\.org/x/vuln/cmd/govulncheck)@v[0-9.]+#\1@v1.9.99#' Taskfile.yml + 'GOVULNCHECK_VERSION: v1\.9\.99' Taskfile.yml \ + sed -i -E 's/^( GOVULNCHECK_VERSION: )v[0-9.]+/\1v1.9.99/' Taskfile.yml + +# A Taskfile variable resolving to `latest` floats exactly like a literal @latest (the old reader +# resolved it with go mod download and passed). +mutant_rejected "Taskfile pin variable set to latest" \ + "golang.org/x/vuln/cmd/govulncheck@ -- the version is not a literal" \ + '^ GOVULNCHECK_VERSION: latest$' Taskfile.yml \ + sed -i -E 's/^( GOVULNCHECK_VERSION: )v[0-9.]+/\1latest/' Taskfile.yml + +mutant_rejected "Taskfile pin variable set to a major-version query" \ + "golang.org/x/vuln/cmd/govulncheck@ -- the version is not a literal" \ + '^ GOVULNCHECK_VERSION: v1$' Taskfile.yml \ + sed -i -E 's/^( GOVULNCHECK_VERSION: )v[0-9.]+/\1v1/' Taskfile.yml mutant_rejected "package path typo in a real module" \ "golang.org/x/vuln/cmd/govulncheckk@" 'govulncheckk@' Taskfile.yml \ @@ -521,8 +540,8 @@ mutant_rejected "go-arch-lint too old for go.mod's Go" \ sed -i -E 's/^( GO_ARCH_LINT_VERSION: )v[0-9.]+/\1v1.15.0/' Taskfile.yml mutant_rejected "govulncheck too old for go.mod's Go" \ - "govulncheck v1.1.4 (pinned in Taskfile.yml) failed its smoke run" 'govulncheck@v1\.1\.4' Taskfile.yml \ - sed -i -E 's#(golang\.org/x/vuln/cmd/govulncheck)@v[0-9.]+#\1@v1.1.4#' Taskfile.yml + "govulncheck v1.1.4 (pinned in Taskfile.yml) failed its smoke run" 'GOVULNCHECK_VERSION: v1\.1\.4' Taskfile.yml \ + sed -i -E 's/^( GOVULNCHECK_VERSION: )v[0-9.]+/\1v1.1.4/' Taskfile.yml mutant_rejected "analysis tool pin missing" \ "no pin found for analysis tool govulncheck" '^ vulncheck:' Taskfile.yml \ diff --git a/hack/test/consistency_toolchain_pins_test.sh b/hack/test/consistency_toolchain_pins_test.sh new file mode 100755 index 00000000..0eb565fa --- /dev/null +++ b/hack/test/consistency_toolchain_pins_test.sh @@ -0,0 +1,2357 @@ +#!/usr/bin/env bash +# DTP-1 to DTP-7 (openspec change developer-toolchain-pins): the +# developer and CI tools that are pinned in more than one file agree, meet their floor, and can be +# installed, and the update bot (Renovate) finds and groups every pin. +# +# gitleaks, git-cliff and shellcheck are each pinned in three files, and nothing kept the three +# equal: a bot bump or a hand edit moves one site and the others silently run another release. +# What it locks: +# * gitleaks: every `GITLEAKS_VERSION:` under .github/, every `${GITLEAKS_VERSION:-}` default +# in hack/ (not hack/test) and the gitleaks `rev:` in .pre-commit-config.yaml agree and are at +# least GITLEAKS_FLOOR; ci.yaml, hack/install-gitleaks.sh and .pre-commit-config.yaml must each +# carry one; +# * git-cliff: Taskfile.yml `GIT_CLIFF_VERSION`, the `with: version:` input of every +# orhun/git-cliff-action step under .github/ (a step without one installs the latest +# release; a `version:` under `env:` or another map is not the input) and every literal `install-git-cliff.sh ` call agree with Taskfile.yml and are +# at least GIT_CLIFF_FLOOR; Taskfile.yml, release.yaml and hack/verify-changelog.sh must each +# carry one; +# * shellcheck: every `SHELLCHECK_VERSION:` under .github/, every `${SHELLCHECK_VERSION:-}` +# default in hack/ and the shellcheck-py `rev:` (first three components: the fourth is +# the packaging revision of the pre-commit wrapper, not a shellcheck release) agree, and +# hack/install-shellcheck.sh's pinned_sha256 table has an entry for that version on every +# OS and architecture the installer maps (a bump without digests cannot install); +# * Task (DTP-1): the `with: version:` input of every go-task/setup-task step under .github/ +# (read like git-cliff's: a step without one installs an unpinned Task and fails) and +# mise.toml's [tools] task agree and are at least TASK_FLOOR; ci.yaml and the kind-e2e-setup +# composite action must each carry one; whether mise.toml has a task entry at all is the +# DEV-MISE-01 check below; +# * govulncheck (DTP-2): Taskfile.yml defines GOVULNCHECK_VERSION directly under +# `# renovate: datasource=go depName=golang.org/x/vuln` (the Go module datasource; a +# github-releases annotation would extract but never look up), and every govulncheck the +# vulncheck task runs reads {{.GOVULNCHECK_VERSION}}: an inline version, another variable or a +# task-level GOVULNCHECK_VERSION is a failure. The value is one plain vN.N.N in the exact form +# renovate.json's Taskfile manager extracts. No floor: CFC-2 (consistency_tool_pins_test.sh) +# builds and runs the pin; +# * versions compare with a leading `v` stripped and must be plain releases (N.N.N); a +# variable, `latest` or an empty value is a failure, never skipped; +# * an empty scan for any tool fails, never passes; +# * DTP-6: UNVERIFIED_BY_DESIGN in hack/test/ci_fetch_lib_hardening_test.sh (installers exempt +# from its checksum scan) is defined once, on one line, and otherwise only read (a closed list +# of read forms; any other mention is a write), and lists nothing outside +# UNVERIFIED_LIMIT, which is empty: no installer is exempt from the checksum scan, and +# --self-test runs each checksum-verifying installer on valid, tampered and missing-checksum +# downloads; +# * DEV-MISE-01 (folded in from the former mise pin-drift guard): mise.toml exists and has a [tools] +# table with a task entry (its value is DTP-1's) and node and python equal to every +# `node-version:` and `python-version:` under .github/ (which must agree among themselves +# and must not be empty); Go is not under [tools], idiomatic_version_file_enable_tools +# names "go" and go.mod has a `go` line, so mise reads Go from go.mod as actions/setup-go does; +# * DTP-5 comments: a comment in mise.toml or Taskfile.yml may state a tool version only in the +# unbroken run of comment lines directly above a pin, or inline on it, naming that pin's tool +# at the pin's value; any other version (a dotted or quoted number after a tool name) and any +# count of pin sites ("17 occurrences") is a failure; +# * DTP-4 Renovate: every customManagers entry in renovate.json matches a tracked file with a +# file pattern AND extracts a dependency from it (a pattern hit alone is a dead manager), no +# file pattern matches nothing, every gitleaks, git-cliff, shellcheck, Task, govulncheck and +# golangci-lint site above is extracted at its line and value under that tool's depName (the +# golangci-lint pins must be plain vN.N.N), each tool's sites resolve to one groupName (the +# last matching packageRule wins), and shellcheck's and golangci-lint's groups each hold +# nothing else (a shellcheck bump is red until its digests are added, a golangci-lint bump +# often on new findings), and the github-actions manager's node-version/python-version +# inputs (depType uses-with) are disabled, because their bump would land in the +# "github actions" group without mise.toml and red it on DEV-MISE-01. python3 evaluates +# renovate.json itself; shapes it does not model (RE2-foreign regex syntax included) fail. +# Lines whose first non-blank character is `#` are comments and never count as a site. +# +# --self-test (DTP-7) copies the tracked tree once per case, breaks one clause, and asserts the +# gate reds with that clause's message; one unmutated copy and the four-component shellcheck-py +# rev must pass; removing this guard's CI steps must red hack/test/ci_workflow_security_test.sh +# (CWS-6 for one step, CFC-6 for both). +# +# Usage: +# hack/test/consistency_toolchain_pins_test.sh # check the repository tree +# hack/test/consistency_toolchain_pins_test.sh --self-test # mutate copies, assert each check fails +# hack/test/consistency_toolchain_pins_test.sh # check another tree root +set -euo pipefail + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +pass() { + echo "ok - $*" +} + +SELF="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")" + +MODE="check" +if [[ "${1:-}" == "--self-test" ]]; then + MODE="self-test" + shift +fi + +ROOT="${1:-}" +if [[ -z "${ROOT}" ]]; then + ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +fi +ROOT="${ROOT%/}" + +GITLEAKS_FLOOR="8.30.1" +GIT_CLIFF_FLOOR="2.13.1" +TASK_FLOOR="3.52.0" +# The exemptions UNVERIFIED_BY_DESIGN may hold: none since DTP-6 (task 4.3) emptied the list. +# Shrink-only, so it stays empty. +UNVERIFIED_LIMIT=() +FETCH_GATE="hack/test/ci_fetch_lib_hardening_test.sh" + +WORKROOT="$(mktemp -d)" +cleanup() { + rm -rf "${WORKROOT:?}" 2>/dev/null || true +} +trap cleanup EXIT + +BROKEN=0 +err() { + echo "FAIL: $*" >&2 + BROKEN=1 +} + +# yaml_files : workflow and composite-action files, relative to root. +yaml_files() { + (cd "$1" && [[ -d .github ]] && find .github -type f \( -name '*.yml' -o -name '*.yaml' \) | sed 's|^\./||' | sort) || true +} + +# hack_scripts : shell scripts under hack/, guards (hack/test) excluded: they carry +# pin-shaped fixtures. +hack_scripts() { + (cd "$1" && [[ -d hack ]] && find hack -path hack/test -prune -o -type f -name '*.sh' -print | sort) || true +} + +# Every site line below is ":\t\t". + +# yaml_env_sites : `: ` keys in .github YAML. +yaml_env_sites() { + local root="$1" var="$2" f + while IFS= read -r f; do + awk -v f="${f}" -v var="${var}" ' + $0 ~ "^[[:space:]]*" var ":" { + v = $0 + sub("^[[:space:]]*" var ":[[:space:]]*", "", v) + sub(/[[:space:]]+#.*$/, "", v) + gsub(/^["\047]|["\047][[:space:]]*$/, "", v) + print f ":" NR "\t" v + }' "${root}/${f}" + done < <(yaml_files "${root}") +} + +# default_sites : `${:-}` defaults in hack/ scripts, comments excluded. +default_sites() { + local root="$1" var="$2" f + while IFS= read -r f; do + awk -v f="${f}" -v var="${var}" ' + /^[[:space:]]*#/ { next } + { + line = $0 + key = "${" var ":-" + while ((i = index(line, key)) > 0) { + rest = substr(line, i + length(key)) + j = index(rest, "}") + v = (j > 0) ? substr(rest, 1, j - 1) : rest + print f ":" NR "\t" v + line = rest + } + }' "${root}/${f}" + done < <(hack_scripts "${root}") +} + +# precommit_rev_sites : the `rev:` of every .pre-commit-config.yaml repo whose +# URL matches. +precommit_rev_sites() { + local root="$1" repo="$2" f=".pre-commit-config.yaml" + [[ -f "${root}/${f}" ]] || return 0 + awk -v f="${f}" -v repo="${repo}" ' + /^[[:space:]]*#/ { next } + /^[[:space:]]*-[[:space:]]*repo:/ { + cur = $0 + sub(/^[^:]*:[[:space:]]*/, "", cur) + sub(/[[:space:]]+#.*$/, "", cur) + gsub(/^["\047]|["\047][[:space:]]*$/, "", cur) + next + } + /^[[:space:]]*rev:/ && cur ~ repo { + v = $0 + sub(/^[[:space:]]*rev:[[:space:]]*/, "", v) + sub(/[[:space:]]+#.*$/, "", v) + gsub(/^["\047]|["\047][[:space:]]*$/, "", v) + print f ":" NR "\t" v + }' "${root}/${f}" +} + +# action_version_sites : the `with: version:` input of every step that uses +# (`owner/name`, matched case-insensitively as GitHub does), or "" when the step has none (the action then +# installs whatever it defaults to). A step is read whole (key order does not matter): it opens at +# a `- ` line and ends at the next `- ` line at its indent or a line left of it. Only a direct +# child of the step's `with:` is the input; a `version:` under `env:` or another map is not, and +# must not stand in for it. +action_version_sites() { + local root="$1" action="$2" f + while IFS= read -r f; do + awk -v f="${f}" -v action="${action}" ' + function indent(s) { match(s, /^[[:space:]]*/); return RLENGTH } + function flush() { + if (open && hit) print f ":" (ver == "" ? at "\t" : vat "\t" ver) + open = 0; hit = 0; ver = ""; w = -1; wc = -1 + } + /^[[:space:]]*#/ { next } + !/[^[:space:]]/ { next } + { + ind = indent($0) + isdash = ($0 ~ /^[[:space:]]*-[[:space:]]/) + if (open && (ind < step || (ind == step && isdash))) flush() + if (!open && isdash) { + open = 1; step = ind; at = NR; w = -1; wc = -1 + match($0, /^[[:space:]]*-[[:space:]]+/); kl = RLENGTH + } + if (!open) next + lvl = (isdash && ind == step) ? kl : ind + k = $0 + sub(/^[[:space:]]*(-[[:space:]]+)?/, "", k) + sub(/[[:space:]]+#.*$/, "", k) + if (lvl == kl) { + w = -1 + if (index(k, "uses:") == 1) { + u = substr(k, 6) + gsub(/^[[:space:]]*["\047]?/, "", u) + # GitHub resolves owner/repo case-insensitively, so Go-Task/Setup-Task@ is the same action. + if (index(tolower(u), tolower(action) "@") == 1) { hit = 1; at = NR } + } + if (k ~ /^with:[[:space:]]*$/) { w = kl; wc = -1 } + next + } + if (w >= 0 && lvl > w) { + if (wc < 0) wc = lvl + if (lvl == wc && k ~ /^version:/) { + v = k + sub(/^version:[[:space:]]*/, "", v) + gsub(/^["\047]|["\047][[:space:]]*$/, "", v) + ver = (v == "") ? "" : v + vat = NR + } + } + } + END { flush() }' "${root}/${f}" + done < <(yaml_files "${root}") +} + +# cliff_install_sites : literal `install-git-cliff.sh ` calls in hack/ scripts, +# Makefile, Taskfile.yml and .github YAML. `{{.GIT_CLIFF_VERSION}}` reads the Taskfile site and +# is not a site of its own; any other non-literal argument is reported, never skipped. +cliff_install_sites() { + local root="$1" f + while IFS= read -r f; do + awk -v f="${f}" ' + /^[[:space:]]*#/ { next } + { + line = $0 + while (match(line, /install-git-cliff\.sh[[:space:]]+[^[:space:]]+/)) { + v = substr(line, RSTART, RLENGTH) + sub(/^install-git-cliff\.sh[[:space:]]+/, "", v) + gsub(/["\047]/, "", v) + if (v != "{{.GIT_CLIFF_VERSION}}") print f ":" NR "\t" v + line = substr(line, RSTART + RLENGTH) + } + }' "${root}/${f}" + done < <( + hack_scripts "${root}" + [[ -f "${root}/Makefile" ]] && echo Makefile + [[ -f "${root}/Taskfile.yml" ]] && echo Taskfile.yml + yaml_files "${root}" + ) +} + +taskfile_sites() { + local root="$1" var="$2" f="Taskfile.yml" + [[ -f "${root}/${f}" ]] || return 0 + awk -v f="${f}" -v var="${var}" ' + $0 ~ "^[[:space:]]*" var ":" { + v = $0 + sub("^[[:space:]]*" var ":[[:space:]]*", "", v) + sub(/[[:space:]]+#.*$/, "", v) + gsub(/^["\047]|["\047][[:space:]]*$/, "", v) + print f ":" NR "\t" v + }' "${root}/${f}" +} + +# with_compare : appends the compared form. `plain` strips a leading v; `first3` also +# drops a fourth numeric component (shellcheck-py's packaging revision). +with_compare() { + local mode="$1" loc v c + while IFS=$'\t' read -r loc v; do + [[ -n "${loc}" ]] || continue + c="${v#v}" + if [[ "${mode}" == "first3" && "${c}" =~ ^([0-9]+\.[0-9]+\.[0-9]+)\.[0-9]+$ ]]; then + c="${BASH_REMATCH[1]}" + fi + printf '%s\t%s\t%s\n' "${loc}" "${v}" "${c}" + done +} + +# version_lt : a < b for validated N.N.N strings. +version_lt() { + [[ "$1" != "$2" && "$(printf '%s\n%s\n' "$1" "$2" | sort -V | head -1)" == "$1" ]] +} + +# check_tool ... +# Sets AGREED to the shared version when every site is plain and equal, else to "". +AGREED="" +check_tool() { + local tool="$1" floor="$2" ref_file="$3" sites="$4" + shift 4 + local f loc v c ref_loc="" ref_v="" ref_c="" values="" ok=1 + AGREED="" + if [[ -z "${sites}" ]]; then + err "${tool}: the scan found no pin site (looked in $*) -- an empty scan never passes" + return 0 + fi + echo "${tool} sites:" + while IFS=$'\t' read -r loc v c; do echo " ${loc} ${v}"; done <<<"${sites}" + for f in "$@"; do + awk -v f="${f}" 'index($0, f ":") == 1 { found = 1 } END { exit !found }' <<<"${sites}" || + { err "${tool}: no pin found in ${f} -- a named site lost its pin or the scan can no longer read it"; ok=0; } + done + while IFS=$'\t' read -r loc v c; do + if [[ ! "${c}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + err "${tool}: ${loc} says '${v}', not a plain release version -- a variable, a range or 'latest' cannot be compared" + ok=0 + continue + fi + if [[ -z "${ref_loc}" && "${loc%%:*}" == "${ref_file}" ]]; then + ref_loc="${loc}" ref_v="${v}" ref_c="${c}" + fi + values="${values} ${c}" + done <<<"${sites}" + [[ -n "${values}" ]] || return 0 + if [[ -z "${ref_loc}" ]]; then + # The reference file has no plain site (reported above); compare against the first plain one. + IFS=$'\t' read -r ref_loc ref_v ref_c < <(awk -F'\t' '$3 ~ /^[0-9]+\.[0-9]+\.[0-9]+$/' <<<"${sites}" | head -1) + fi + while IFS=$'\t' read -r loc v c; do + [[ "${c}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || continue + if [[ "${c}" != "${ref_c}" ]]; then + err "${tool}: ${loc} says ${v}, but the reference site ${ref_loc} says ${ref_v} -- one tool, one release" + ok=0 + fi + done <<<"${sites}" + ((ok == 1)) || return 0 + if [[ "${floor}" != "-" ]] && version_lt "${ref_c}" "${floor}"; then + err "${tool}: every site says ${ref_c}, below the floor ${floor}" + return 0 + fi + AGREED="${ref_c}" +} + +# check_shellcheck_digests : pinned_sha256 has a 64-hex digest for every +# OS/architecture pair the installer maps. +check_shellcheck_digests() { + local root="$1" ver="$2" f="hack/install-shellcheck.sh" oses archs os arch asset + oses="$(sed -n -E 's/^[[:space:]]*[^#)]+\)[[:space:]]*OS="?([a-z0-9_]+)"?[[:space:]]*;;.*$/\1/p' "${root}/${f}" | sort -u)" + archs="$(sed -n -E 's/^[[:space:]]*[^#)]+\)[[:space:]]*ARCH="?([a-z0-9_]+)"?[[:space:]]*;;.*$/\1/p' "${root}/${f}" | sort -u)" + if [[ -z "${oses}" || -z "${archs}" ]]; then + err "shellcheck: cannot read the supported platforms of ${f} (no OS=/ARCH= case arms) -- the digest check would pass on nothing" + return 0 + fi + for os in ${oses}; do + for arch in ${archs}; do + asset="shellcheck-v${ver}.${os}.${arch}.tar.xz" + awk -v want="${asset})" ' + /^[[:space:]]*#/ { next } + found { if ($0 ~ /^[[:space:]]*echo[[:space:]]+"[0-9a-f]{64}"[[:space:]]*$/) ok = 1; exit } + { t = $0; gsub(/^[[:space:]]+|[[:space:]]+$/, "", t); if (t == want) found = 1 } + END { exit !ok }' "${root}/${f}" || + err "shellcheck: ${f} pinned_sha256 has no entry for ${asset} -- every site says ${ver}, which cannot install until its digests are added" + done + done +} + +# unverified_other_uses : line numbers of every non-comment mention of UNVERIFIED_BY_DESIGN +# that is not the definition `UNVERIFIED_BY_DESIGN=( ... )`, a read `${UNVERIFIED_BY_DESIGN[@]}`, +# `${UNVERIFIED_BY_DESIGN[*]}` or `${#UNVERIFIED_BY_DESIGN[@]}`, or the bare word between spaces +# inside a double-quoted string (a message). A closed list: `+=`, `[i]=`, `${...:=}`, mapfile, +# `read -a`, `printf -v`, a nameref and any other form are writes. +unverified_other_uses() { + awk ' + /^[[:space:]]*#/ { next } + /^[[:space:]]*UNVERIFIED_BY_DESIGN=\(.*\)[[:space:]]*$/ { next } + { + line = $0 + gsub(/\$\{#?UNVERIFIED_BY_DESIGN\[[@*]\]\}/, "", line) + name = "UNVERIFIED_BY_DESIGN" + done = 0 + while (!done && (i = index(line, name)) > 0) { + pre = substr(line, 1, i - 1) + q = gsub(/"/, "\"", pre) - gsub(/\\"/, "&", pre) + before = substr(line, i - 1, 1) + after = substr(line, i + length(name), 1) + if (!(q % 2 == 1 && before == " " && after == " ")) { print NR; done = 1 } + line = substr(line, i + length(name)) + } + }' "$1" +} + +check_unverified_limit() { + local root="$1" f="${FETCH_GATE}" defs n others body entry known ok entries=() + if [[ ! -f "${root}/${f}" ]]; then + err "DTP-6: ${f} not found -- the exemptions list cannot be read" + return 0 + fi + defs="$(grep -E '^[[:space:]]*UNVERIFIED_BY_DESIGN=\(.*\)[[:space:]]*$' "${root}/${f}" || true)" + others="$(unverified_other_uses "${root}/${f}" | tr '\n' ' ')" + if [[ -z "${defs}" ]]; then + err "DTP-6: ${f} has no single-line UNVERIFIED_BY_DESIGN=( ... ) definition -- the exemptions list cannot be read" + return 0 + fi + n="$(grep -c . <<<"${defs}" || true)" + if [[ "${n}" != "1" || -n "${others}" ]]; then + err "DTP-6: UNVERIFIED_BY_DESIGN is written $((n + $(wc -w <<<"${others}"))) times in ${f} (definitions: ${n}; other writes or unrecognised uses at lines: ${others:-none}) -- one definition and plain reads only, so the list cannot grow behind it" + return 0 + fi + [[ "${defs}" =~ ^[[:space:]]*UNVERIFIED_BY_DESIGN=\((.*)\)[[:space:]]*$ ]] + body="${BASH_REMATCH[1]//\"/}" + body="${body//\'/}" + read -r -a entries <<<"${body}" + for entry in ${entries[@]+"${entries[@]}"}; do + ok=0 + for known in ${UNVERIFIED_LIMIT[@]+"${UNVERIFIED_LIMIT[@]}"}; do + [[ "${entry}" == "${known}" ]] && ok=1 + done + ((ok == 1)) || + err "DTP-6: UNVERIFIED_BY_DESIGN lists ${entry}, which is not in this guard's limit (${UNVERIFIED_LIMIT[*]:-empty}) -- the exceptions list only shrinks" + done + echo "UNVERIFIED_BY_DESIGN: ${body:-}" +} + +# --- DEV-MISE-01 (folded in from the former mise pin-drift guard, mutant for mutant) --- +# input_versions : every value of a plain workflow input (node-version, python-version). +input_versions() { + local root="$1" key="$2" f + while IFS= read -r f; do + awk -v key="${key}" ' + /^[[:space:]]*#/ { next } + $0 ~ "^[[:space:]]*" key ":[[:space:]]*" { + line = $0 + sub("^[[:space:]]*" key ":[[:space:]]*", "", line) + sub(/[[:space:]]*#.*$/, "", line) + gsub(/["\047[:space:]]/, "", line) + if (line != "") print line + }' "${root}/${f}" + done < <(yaml_files "${root}") +} + +# mise_tool_sites : "mise.toml:\t" for one key in mise.toml's [tools] +# table; nothing if absent. +mise_tool_sites() { + awk -v tool="$2" ' + /^[[:space:]]*#/ { next } + /^[[:space:]]*\[/ { section = $0; gsub(/[[:space:]]/, "", section); next } + section == "[tools]" && $0 ~ "^[[:space:]]*" tool "[[:space:]]*=" { + line = $0 + sub("^[[:space:]]*" tool "[[:space:]]*=[[:space:]]*", "", line) + sub(/[[:space:]]*#.*$/, "", line) + gsub(/["\047[:space:]]/, "", line) + print "mise.toml:" NR "\t" line + }' "$1" +} + +# mise_tool_value : the value of one key in mise.toml's [tools] table; empty if absent. +mise_tool_value() { + mise_tool_sites "$1" "$2" | cut -f2 +} + +# mise_assert_pin