diff --git a/.github/workflows/ci-release.yml b/.github/workflows/ci-release.yml index 5c283ea1..e8222788 100644 --- a/.github/workflows/ci-release.yml +++ b/.github/workflows/ci-release.yml @@ -45,6 +45,11 @@ jobs: # repos, so the only cost of the second leg is that it is a second check. name: Build Artifact (${{ matrix.arch }}) runs-on: ${{ matrix.runner }} + # Runs in ~2 min per arch. Both legs build every plugin and now pull the + # pushed digest back for the scan, so a stalled registry is the realistic + # hang here — and a release leg that hangs also blocks Publish Artifact + # behind `needs`, so the tag just never appears with no failure to point at. + timeout-minutes: 45 strategy: # A tag that can only ship one architecture should not ship at all, and # stopping the other leg early keeps a doomed release from occupying a @@ -67,6 +72,8 @@ jobs: with: fetch-depth: 0 + - uses: ./.github/actions/trivy-cache + # The default builder uses the `docker` driver, which supports neither # push-by-digest nor --metadata-file. This switches to docker-container. - uses: docker/setup-buildx-action@v3 @@ -81,6 +88,18 @@ jobs: # visible under a version tag until Publish Artifact below has both. - run: make image-build ARCH=${{ matrix.arch }} + # The release image gets its own scan; the PR check does not cover it. + # Security Scan graded an image built from the PR's tree on the day the + # PR ran, and this is a fresh build off a rolling base image weeks later. + # Scanned per arch on its own native runner, so the arm64 artifact is + # scanned as arm64 rather than assumed to match amd64. + # + # Placed before upload-artifact and therefore before Publish Artifact, + # which needs both legs: a finding on either arch means no digest is + # handed on and no version tag is ever bound. fail-fast: true cancels + # the sibling leg rather than letting it finish a build nothing will use. + - run: make trivy-release-gate ARCH=${{ matrix.arch }} + - uses: actions/upload-artifact@v4 with: name: digest-${{ matrix.arch }} @@ -94,6 +113,8 @@ jobs: # must never resolve to one architecture. needs: build-artifact runs-on: ubuntu-latest + # Runs in ~20s: two registry API calls and no build. + timeout-minutes: 10 permissions: contents: read packages: write diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5aee8d29..14d32612 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,6 +42,12 @@ jobs: verify: name: End to End verification runs-on: ubuntu-latest + # Runs in ~1 min. The default is 360, so a hung `go test` or a wedged + # module download would hold a runner for six hours and burn the whole + # concurrency allowance before anyone noticed. Generous multiples of the + # real duration, not tight budgets — this is a hang detector, not a + # performance gate that goes red when the suite legitimately grows. + timeout-minutes: 15 # pull-requests: write is for the coverage comment below. permissions: contents: read @@ -124,6 +130,9 @@ jobs: security-scan: name: Security Scan runs-on: ubuntu-latest + # Runs in ~3 min. Higher than verify: this one builds the image and pulls + # trivy's DB, so it has two network dependencies that can stall. + timeout-minutes: 30 # Both scans in one job, one check. They were two jobs plus a gate job, and # three red-or-green checks to say one thing — "is this branch shippable" — # was more to read than it was worth. The dependency scan needs only a diff --git a/Makefile b/Makefile index 98ceb306..4c7a629c 100644 --- a/Makefile +++ b/Makefile @@ -137,6 +137,14 @@ SARIF_REPORTS := trivy-deps.sarif trivy-image.sarif # the changed files dragging it down, worst first. Always writes # coverage-report.md — the workflow reads that file unconditionally, so every # exit path here has to produce it. +# Three verdicts, not two. "✅ Passed" and "❌ Failed" both mean a percentage was +# computed and compared against MIN_COVERAGE. The two not-applicable paths below +# compute nothing — no changed non-test .go files means no denominator, and +# changed files with no coverable statements means 0/0 — so they report +# "➖ Not applicable" and say why. They used to say "✅ Passed", which read as +# though the diff had been measured and cleared the bar; a CI-only PR looked +# indistinguishable from a well-tested one. Still exit 0: a PR that changes no +# Go code must not be blocked on Go coverage. cover-diff: coverage.out @if ! git rev-parse --verify --quiet "$(BASE_REF)^{commit}" >/dev/null; then \ echo "::error::BASE_REF '$(BASE_REF)' does not resolve to a commit — cannot compute the changed-file set"; \ @@ -150,7 +158,7 @@ cover-diff: coverage.out fi; \ CHANGED=$$(printf '%s\n' "$$DIFF" | grep -v '_test\.go$$'); \ if [ -z "$$CHANGED" ]; then \ - printf '%s\n' "$(COVER_MARKER)" "📊 **Test Coverage: ✅ Passed** — not applicable, no changed Go files vs $(BASE_REF)" | tee coverage-report.md; \ + printf '%s\n' "$(COVER_MARKER)" "📊 **Test Coverage: ➖ Not applicable** — no non-test Go files changed vs \`$(BASE_REF)\`, so there are no lines to measure and no percentage to report." | tee coverage-report.md; \ exit 0; \ fi; \ MODULE=$$($(GO) list -m); \ @@ -170,7 +178,7 @@ cover-diff: coverage.out print "TOTAL\t" int(C * 100 / T) \ }' - coverage.out); \ if echo "$$RESULT" | grep -q '^EMPTY$$'; then \ - printf '%s\n' "$(COVER_MARKER)" "📊 **Test Coverage: ✅ Passed** — not applicable, changed files carry no coverable statements" | tee coverage-report.md; \ + printf '%s\n' "$(COVER_MARKER)" "📊 **Test Coverage: ➖ Not applicable** — the changed Go files carry no coverable statements (declarations, types or constants only), so there is no percentage to compute." | tee coverage-report.md; \ exit 0; \ fi; \ PCT=$$(echo "$$RESULT" | awk -F'\t' '$$1=="TOTAL"{print $$2}'); \ @@ -211,6 +219,27 @@ trivy-image: $(TRIVY) $(TRIVY) image $(IMAGE) --severity $(SEVERITY) --exit-code 0 \ --format sarif --output trivy-image.sarif +## trivy-release-gate: fail the release if the digest just pushed has a finding +# The PR-time Security Scan is not this gate. It scans an image built from the +# PR's tree on the day the PR ran; a tag cut weeks later rebuilds from a freshly +# pulled `wolfi-base` and a freshly resolved module graph, so the artifact that +# ships is not the artifact anything looked at. Without this, image-build +# publishes a digest no scan has ever seen. +# +# Scans the digest, not a local tag: image-build pushes by digest, so the only +# reference to the layers it just built is the one in digest-$(ARCH).txt. That +# digest is unreachable by name until image-publish binds a tag to it, and this +# runs first — so a finding here means the version tag is never created. +# +# --exit-code 1 and a table, not SARIF at --exit-code 0: there is no PR to +# comment on, so the findings belong in the log the red check points at, and +# the scan itself is the gate rather than a report something else grades. +trivy-release-gate: $(TRIVY) require-image-repo + @test -s digest-$(ARCH).txt || \ + { echo "::error::digest-$(ARCH).txt is missing or empty — run image-build first"; exit 1; } + $(TRIVY) image $(IMAGE_REPO)@$$(cat digest-$(ARCH).txt) \ + --severity $(SEVERITY) --exit-code 1 --format table + ## trivy-report: render both SARIF reports as one PR comment, trivy-report.md # One comment covering both scans, not one comment each: the two scans run in # the same job now, and two bot comments per PR was the noise this is meant to @@ -450,5 +479,5 @@ $(TRIVY): .PHONY: help build test cover test-ci merge-coverage cover-diff lint fmt \ lint-actions lint-staged hooks \ - trivy-deps trivy-image trivy-report trivy-gate \ + trivy-deps trivy-image trivy-report trivy-gate trivy-release-gate \ docker image-build image-publish require-image-repo clean