Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .github/workflows/ci-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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 }}
Expand All @@ -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
Expand Down
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
35 changes: 32 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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"; \
Expand All @@ -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); \
Expand All @@ -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}'); \
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Loading