diff --git a/.github/workflows/attestation-check.yml b/.github/workflows/attestation-check.yml index 19a4963..f5b920a 100644 --- a/.github/workflows/attestation-check.yml +++ b/.github/workflows/attestation-check.yml @@ -16,16 +16,64 @@ jobs: - name: Get latest release tag id: release run: | - TAG=$(gh release list --repo ${{ github.repository }} --limit 1 --json tagName --jq '.[0].tagName' 2>/dev/null || echo "") - if [ -z "$TAG" ]; then - echo "No releases found, skipping" + # Three outcomes, three exit paths. The old `2>/dev/null || echo ""` + # collapsed the first into the third, so a week with no attestation + # verification at all exited 0 and read as healthy: + # 1. lookup failed -> hard fail; we know nothing either way. + # 2. releases exist but none is GitHub's "latest" -> hard fail. On a repo + # that publishes releases that is a tamper or misconfiguration signal, + # and skipping it is the exact shape LAB-984 fell into. + # 3. zero releases at all -> skip; nothing has been published yet. + # + # Two lookups, each answering exactly one question, because one call + # cannot answer both without guessing a page size. `gh release list` + # is a paged view over ALL releases (drafts included) — it answers + # "does this repo publish anything at all?", and --limit 1 is enough + # for that. `gh release view` with no tag resolves /releases/latest + # server-side: the same newest-non-draft-non-prerelease release the + # `isLatest` flag marks, but with no page window it can fall outside + # of. An earlier form filtered `isLatest` out of a --limit N listing, + # which meant picking an N and hard-failing a perfectly healthy repo + # once the latest release aged past it. A tripwire that cries wolf is + # the same trust bug as one that stays silent, so the window is gone + # rather than widened. + if ! RELEASES=$(gh release list --repo "$REPO" --limit 1 --json tagName); then + echo "::error::gh release list failed for $REPO — cannot determine whether any release exists; refusing to report a green skip" + exit 1 + fi + # Validate the payload in its own statement before anything branches on it. + # The `type=="array"` guard makes every non-array shape (null, an object, + # an error envelope) return null, which `jq -e` reports as a non-zero exit + # alongside parse errors and no-output — so empty or truncated stdout from + # a `gh` that still exited 0 lands on the annotated hard failure instead of + # a raw jq trace. Doing this inline as `[ "$(jq ...)" -ne 0 ]` would hide + # jq's exit code in a command substitution, suppress `set -e` inside the + # `if`, and — when `[` itself errored on non-numeric input — evaluate FALSE + # straight into the skip path: the same fail-open shape as the + # `|| echo ""` this commit removes. + if ! COUNT=$(jq -e 'if type == "array" then length else null end' <<<"$RELEASES"); then + echo "::error::gh release list returned no parseable release array for $REPO — refusing to report a green skip" + exit 1 + fi + if [ "$COUNT" -eq 0 ]; then + echo "$REPO has no published releases — nothing to attest, skipping" echo "skip=true" >> "$GITHUB_OUTPUT" + # Releases exist, so a latest MUST resolve. Both ways this can fail are + # incidents, not idle states: an API failure means we know nothing, and + # "releases exist but none is latest" (all drafts/prereleases) on a repo + # that publishes to PyPI is tamper or misconfiguration. Skipping either + # is the exact shape LAB-984 fell into, so both go red. + elif ! TAG=$(gh release view --repo "$REPO" --json tagName --jq '.tagName'); then + echo "::error::$REPO has release(s) but no resolvable latest release — refusing to skip a release that may need attesting" + exit 1 else + echo "Latest release: $TAG" echo "tag=$TAG" >> "$GITHUB_OUTPUT" echo "skip=false" >> "$GITHUB_OUTPUT" fi env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} - name: Set up Python if: steps.release.outputs.skip != 'true' @@ -34,13 +82,19 @@ jobs: python-version: '3.12' - name: Verify attestations + id: verify if: steps.release.outputs.skip != 'true' run: | # `gh attestation` has no `list` subcommand — the old query errored in # ~8s every run (that was the real cause of the weekly red, not missing # attestations). Verify the actual published artifact instead. - VER="${{ steps.release.outputs.tag }}" - VER="${VER#v}" + # + # TAG/REPO arrive via env, never template-interpolated into this body: a + # git tag may legally contain `$(...)` or backticks, and whoever can name + # a tag is exactly the adversary this tripwire exists to catch — + # interpolating it would hand them code execution in a job holding + # GH_TOKEN and issues: write. + VER="${TAG#v}" echo "Verifying attestations for cachekit ${VER}" mkdir -p attest-check if ! pip download "cachekit==${VER}" --no-deps --only-binary :all: -d attest-check; then @@ -55,21 +109,34 @@ jobs: fi for f in "${files[@]}"; do echo "Verifying attestation for $f" - gh attestation verify "$f" --repo ${{ github.repository }} || { + gh attestation verify "$f" --repo "$REPO" || { echo "::error::Attestation verification failed for $f" exit 1 } done env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + TAG: ${{ steps.release.outputs.tag }} + # Gated on the verify step's own outcome, not a bare `failure()`. Previously a + # failed *lookup* landed here and filed a public "Attestation verification + # failed for " issue — empty tag, blaming attestations for an API outage that + # never reached the verify step; a setup-python or PyPI-lag failure did the + # same with a tag attached. A tripwire that cries wolf is the same trust bug + # as one that stays silent, so the title says "health check" (the step covers + # both the PyPI download and the attestation check) and the body sends the + # reader to the log to find out which. For any earlier failure the red run is + # the signal and no issue is filed. - name: Open issue on failure - if: failure() + if: failure() && steps.verify.outcome == 'failure' run: | gh issue create \ - --repo ${{ github.repository }} \ - --title "Attestation verification failed for ${{ steps.release.outputs.tag }}" \ - --body "Weekly attestation health check failed. Verify that the release workflow produced valid attestations." \ + --repo "$REPO" \ + --title "Attestation health check failed for $TAG" \ + --body "The weekly attestation health check failed for ${TAG}. Check the run log to see whether the wheel download or the attestation verification failed, then verify that the release workflow produced valid attestations." \ --label "bug" env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + TAG: ${{ steps.release.outputs.tag }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 927d2ec..22f227d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -153,15 +153,50 @@ jobs: REDIS_URL: redis://localhost:6379 # autouse redis-isolation fixture uses external Redis when set (else spawns a binary the runner lacks) run: uv run pytest tests/performance/ -m "performance and slow" -q + # fail_ci_if_error is deliberately split between these two uploads, not + # defaulted (LAB-2528 finding 3). coverage.xml is the ONLY input to the + # project/patch statuses codecov.yml declares, and every flag there sets + # `carryforward: true` — so a silently-dropped upload does not remove the + # status, it answers "is this PR's new code 80% covered?" with a previous + # run's numbers. + # + # Scoped to same-repo events rather than a bare `true`, because of what the + # action actually does on a fork (read at the pinned SHA, not assumed): its + # `Get OIDC token` step is guarded `CC_USE_OIDC == 'true' && CC_FORK != 'true'`, + # so on a fork it never attempts OIDC, `CC_TOKEN` stays empty, and the upload + # proceeds TOKENLESS — Codecov's rate-limited path. A bare `true` would let a + # 429 nobody controls redden an outside contribution, which on a repo with no + # branch protection trains maintainers to merge over red CI: the opposite of + # what this ticket is hardening. The flag therefore applies exactly where OIDC + # really authenticates. + # + # ACCEPTED RESIDUAL RISK, stated because the scoping creates it: on a fork PR a + # dropped tokenless upload is still silent, and carryforward then answers the + # patch question with an earlier commit's numbers — a stale green on exactly the + # least-trusted contributions. Accepted because a fork PR cannot reach the + # self-hosted `cachekit` runner without a maintainer approving the run, so a + # human is already in that loop, and no fork PR has run here to date. The real + # fix is to stop depending on an external upload for the floor (a local + # `--cov-fail-under` on the PR pytest invocation); that is a coverage-policy + # change, tracked separately rather than smuggled into this diff. - name: Upload coverage to Codecov if: ${{ !cancelled() }} uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5 with: files: ./coverage.xml use_oidc: true - fail_ci_if_error: false + fail_ci_if_error: ${{ github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository }} flags: ${{ github.event_name == 'push' && 'full' || 'critical' }}-python-${{ matrix.python-version }} + # Stays false, deliberately: junit.xml feeds Codecov Test Analytics + # (flaky-test history) only. Nothing gates on it and no status is computed + # from it, so a Codecov-side outage here would redden otherwise-passing CI + # while hiding nothing. + # + # `handle_no_reports_found` is left at its default (false) on both uploads on + # purpose: it would also swallow "the report was never written", which is the + # silent-degradation this ticket exists to remove. A second red step on an + # already-red job is noise; a green job that uploaded nothing is a trust bug. - name: Upload test results to Codecov if: ${{ !cancelled() }} uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5 @@ -196,7 +231,7 @@ jobs: - name: Scan Python dependencies for CVEs run: | # No suppressions: every prior CVE is resolved at source on the py3.10+ - # resolution. urllib3>=2.7.0 and pip>=26.1.2 are pinned via + # resolution. urllib3>=2.7.0 and pip>=26.2 are pinned via # [tool.uv] constraint-dependencies; pygments/pyarrow advisories cleared # by their py3.10+ fix versions. Keep this list IDENTICAL to # security-fast.yml's pip-audit so the two cannot drift. diff --git a/.github/workflows/security-fast.yml b/.github/workflows/security-fast.yml index eaa91e0..f249f32 100644 --- a/.github/workflows/security-fast.yml +++ b/.github/workflows/security-fast.yml @@ -91,7 +91,7 @@ jobs: - name: Run pip-audit run: | # No suppressions: every prior CVE is resolved at source on the py3.10+ - # resolution. urllib3>=2.7.0 and pip>=26.1.2 are pinned via + # resolution. urllib3>=2.7.0 and pip>=26.2 are pinned via # [tool.uv] constraint-dependencies; pygments/pyarrow advisories cleared # by their py3.10+ fix versions. Keep this list IDENTICAL to ci.yml's # post-merge pip-audit so the two cannot drift. diff --git a/pyproject.toml b/pyproject.toml index 3860e79..f3eb8b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -249,8 +249,9 @@ constraint-dependencies = [ "werkzeug>=3.1.4", # pip is a dev-only transitive dep (pip-audit -> pip-api -> pip). 26.1.2 fixes # PYSEC-2026-196 (entry-point path traversal), GHSA-58qw-9mgm-455v (tar/zip - # confusion) and GHSA-jp4c-xjxw-mgf9 (self-update import ordering). - "pip>=26.1.2", + # confusion) and GHSA-jp4c-xjxw-mgf9 (self-update import ordering); 26.2 fixes + # PYSEC-2026-3721 (doubly-encoded index URLs install to arbitrary paths). + "pip>=26.2", # h2 is a transitive dep (httpx[http2] -> h2). 4.4.1 fixes # GHSA-6hr6-w5qg-qmwg (duplicate Host headers forwarded on HTTP/2 -> # HTTP/1.1 downgrade — request smuggling primitive). diff --git a/uv.lock b/uv.lock index 4f9df25..0281576 100644 --- a/uv.lock +++ b/uv.lock @@ -11,7 +11,7 @@ resolution-markers = [ constraints = [ { name = "fonttools", specifier = ">=4.60.2" }, { name = "h2", specifier = ">=4.4.1" }, - { name = "pip", specifier = ">=26.1.2" }, + { name = "pip", specifier = ">=26.2" }, { name = "urllib3", specifier = ">=2.7.0" }, { name = "werkzeug", specifier = ">=3.1.4" }, ] @@ -1283,11 +1283,11 @@ wheels = [ [[package]] name = "pip" -version = "26.1.2" +version = "26.2.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/01/91/47e7d486260f618783899587af63ccf7980fb60245c3e63dd4571c6b57ad/pip-26.1.2.tar.gz", hash = "sha256:f49cd134c61cf2fd75e0ce2676db03e4054504a5a4986d00f8299ae632dc4605", size = 1840799, upload-time = "2026-05-31T17:33:58.56Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/15/4500e320e6b101ec3b719ae85b697d9940b6cda672bc555bd6016fc60c6f/pip-26.2.1.tar.gz", hash = "sha256:f6ad667e89a1fe78046c8f13232b247200f5258d7828f3f7883d660878e0813f", size = 1848877, upload-time = "2026-08-04T22:51:14.148Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/95/6b5cb3461ea5673ba0995989746db58eb18b91b54dbf331e72f569540946/pip-26.1.2-py3-none-any.whl", hash = "sha256:382ff9f685ee3bc25864f820aa50505825f10f5458ffff07e30a6d96e5715cab", size = 1813144, upload-time = "2026-05-31T17:33:56.772Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6e/1736e5b4ae2b778ef2f81c47d797de9f891d4d8acb047a24ca37a60294dd/pip-26.2.1-py3-none-any.whl", hash = "sha256:71138adf1f4ca900cdb7d289c21b7494329f2332b6d85f0e1c42108c0384ed3e", size = 1816632, upload-time = "2026-08-04T22:51:12.472Z" }, ] [[package]]