Skip to content

Commit fe8704e

Browse files
committed
Add Go coverage reporting to CI (end-to-end)
This PR adds automated Go coverage reporting to the CI pipeline. Unit and integration tests now generate coverage profiles (one per integration matrix suite). It also collects coverage for the Dalec BuildKit frontend by building it with coverage enabled and exporting its covdata during solves. A new coverage-report job downloads all profiles, merges them, and uploads a combined report (including an index.html coverage page) as a workflow artifact, plus a summary in the Actions run. Fixes: #889 Signed-off-by: Kartik Joshi <karikjoshi21@gmail.com>
1 parent 9a57e74 commit fe8704e

File tree

7 files changed

+394
-9
lines changed

7 files changed

+394
-9
lines changed

.github/workflows/ci.yml

Lines changed: 149 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,7 @@ jobs:
270270
set -eu
271271
272272
docker buildx bake frontend
273+
273274
if [ "${TEST_SUITE}" = "other" ]; then
274275
exit 0
275276
fi
@@ -283,19 +284,64 @@ jobs:
283284
docker buildx bake worker
284285
env:
285286
TEST_SUITE: ${{ matrix.suite }}
286-
- name: Run integration tests
287+
- name: Run integration tests (with coverage tracking)
287288
run: |
288289
set -ex
289-
if [ -n "${TEST_SUITE}" ] && [ ! "${TEST_SUITE}" = "other" ]; then
290+
mkdir -p coverage
291+
292+
# The frontend covdata files (covmeta/covcounters) are written by the test harness
293+
# (writeFrontendCovdata) on the RUNNER filesystem.
294+
export DALEC_FRONTEND_GOCOVERDIR="${GITHUB_WORKSPACE}/coverage/frontend-${TEST_SUITE}"
295+
rm -rf "${DALEC_FRONTEND_GOCOVERDIR}"
296+
mkdir -p "${DALEC_FRONTEND_GOCOVERDIR}"
297+
298+
run=""
299+
skip=""
300+
if [ -n "${TEST_SUITE}" ] && [ "${TEST_SUITE}" != "other" ]; then
290301
run="-run=${TEST_SUITE}"
291302
fi
292303
if [ -n "${TEST_SKIP}" ]; then
293304
skip="-skip=${TEST_SKIP}"
294305
fi
295-
go test -timeout=59m -v -json ${run} ${skip} ./test | go run ./cmd/test2json2gha --slow 120s --logdir /tmp/testlogs
306+
307+
go test -timeout=59m -v -json \
308+
-covermode=atomic -coverpkg=./... \
309+
-coverprofile="coverage/integration-${TEST_SUITE}.out" \
310+
${run} ${skip} ./test \
311+
| go run ./cmd/test2json2gha --slow 120s --logdir /tmp/testlogs
312+
313+
# Convert frontend covdata -> legacy coverprofile
314+
if ! ls "${DALEC_FRONTEND_GOCOVERDIR}"/covmeta.* >/dev/null 2>&1; then
315+
echo "::group::frontend coverage debug"
316+
echo "DALEC_FRONTEND_GOCOVERDIR=${DALEC_FRONTEND_GOCOVERDIR}"
317+
echo "Contents:"
318+
ls -la "${DALEC_FRONTEND_GOCOVERDIR}" || true
319+
echo "Searching workspace for covmeta/covcounters..."
320+
find "${GITHUB_WORKSPACE}" \( -name 'covmeta.*' -o -name 'covcounters.*' \) 2>/dev/null | head -n 200 || true
321+
echo "::endgroup::"
322+
echo "::error::No frontend coverage covmeta.* found in ${DALEC_FRONTEND_GOCOVERDIR} (frontend coverage not collected)"
323+
exit 1
324+
fi
325+
326+
go tool covdata textfmt \
327+
-i="${DALEC_FRONTEND_GOCOVERDIR}" \
328+
-o="coverage/frontend-${TEST_SUITE}.out"
296329
env:
297330
TEST_SUITE: ${{ matrix.suite }}
298331
TEST_SKIP: ${{ matrix.skip }}
332+
333+
334+
- name: Upload integration coverage profile
335+
if: always()
336+
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
337+
with:
338+
name: coverage-integration-${{ matrix.suite }}
339+
path: |
340+
coverage/integration-${{ matrix.suite }}.out
341+
coverage/frontend-${{ matrix.suite }}.out
342+
if-no-files-found: ignore
343+
retention-days: 7
344+
299345
- name: Get traces
300346
if: always()
301347
run: |
@@ -354,8 +400,27 @@ jobs:
354400
cache: false
355401
- name: download deps
356402
run: go mod download
357-
- name: Run unit tests
358-
run: go test -v --test.short --json ./... | go run ./cmd/test2json2gha
403+
- name: Run unit tests (with coverage tracking)
404+
run: |
405+
set -eux
406+
mkdir -p coverage
407+
408+
pkgs="$(go list ./... | grep -v '/test$' | grep -v '/test/' )"
409+
go test -v --test.short --json \
410+
-covermode=atomic \
411+
-coverprofile="coverage/unit.out" \
412+
${pkgs} \
413+
| go run ./cmd/test2json2gha
414+
- name: Upload unit coverage profile
415+
if: always()
416+
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
417+
with:
418+
name: coverage-unit
419+
path: coverage/unit.out
420+
if-no-files-found: ignore
421+
retention-days: 7
422+
423+
359424

360425
e2e:
361426
runs-on: ubuntu-22.04
@@ -443,3 +508,82 @@ jobs:
443508
path: ${{ steps.dump-logs.outputs.DOCKERD_LOG_PATH }}
444509
retention-days: 1
445510

511+
coverage-report:
512+
runs-on: ubuntu-22.04
513+
needs:
514+
- unit
515+
- integration
516+
517+
steps:
518+
- name: Harden Runner
519+
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
520+
with:
521+
egress-policy: audit
522+
523+
- name: Checkout
524+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
525+
526+
- name: Setup Go
527+
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
528+
with:
529+
go-version: "1.25"
530+
cache: false
531+
532+
- name: Download deps
533+
run: go mod download
534+
535+
- name: Download unit coverage artifact
536+
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
537+
with:
538+
name: coverage-unit
539+
path: coverage
540+
541+
- name: Download integration coverage artifacts
542+
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
543+
with:
544+
path: coverage/_integration
545+
546+
- name: Merge coverage + generate report
547+
run: |
548+
set -eux
549+
go install github.com/wadey/gocovmerge@latest
550+
551+
integration_profiles="$(find coverage/_integration -type f -name 'integration-*.out' | sort | tr '\n' ' ')"
552+
frontend_profiles="$(find coverage/_integration -type f -name 'frontend-*.out' | sort | tr '\n' ' ')"
553+
if [ -z "${integration_profiles}" ]; then
554+
echo "::error::No integration coverage profiles found"
555+
exit 1
556+
fi
557+
558+
if [ -z "${frontend_profiles}" ]; then
559+
echo "::error::No frontend coverage profiles found"
560+
exit 1
561+
fi
562+
563+
if [ ! -f coverage/unit.out ]; then
564+
echo "::error::Unit coverage profile not found (coverage/unit.out)"
565+
exit 1
566+
fi
567+
568+
"$(go env GOPATH)/bin/gocovmerge" coverage/unit.out ${integration_profiles} ${frontend_profiles} > coverage/all.out
569+
570+
go tool cover -func=coverage/all.out | tee coverage/summary.txt
571+
go tool cover -html=coverage/all.out -o coverage/index.html
572+
573+
total="$(tail -n 1 coverage/summary.txt | awk '{print $3}')"
574+
{
575+
echo "## Coverage"
576+
echo
577+
echo "- Total: **${total}**"
578+
echo "- Profiles merged: $(echo "${integration_profiles}" | wc -w) integration + $(echo "${frontend_profiles}" | wc -w) frontend"
579+
} >> "${GITHUB_STEP_SUMMARY}"
580+
581+
- name: Upload merged coverage report
582+
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
583+
with:
584+
name: coverage-report
585+
path: |
586+
coverage/all.out
587+
coverage/summary.txt
588+
coverage/index.html
589+
retention-days: 14

Dockerfile

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,16 @@ WORKDIR /build
55
COPY . .
66
ENV CGO_ENABLED=0
77
ARG TARGETARCH TARGETOS GOFLAGS=-trimpath
8+
ARG DALEC_FRONTEND_COVERAGE=0
89
ENV GOOS=${TARGETOS} GOARCH=${TARGETARCH} GOFLAGS=${GOFLAGS}
910
RUN \
1011
--mount=type=cache,target=/go/pkg/mod \
1112
--mount=type=cache,target=/root/.cache/go-build \
12-
go build -o /frontend ./cmd/frontend
13+
if [ "${DALEC_FRONTEND_COVERAGE}" = "1" ]; then \
14+
go build -cover -covermode=atomic -coverpkg=./... -o /frontend ./cmd/frontend ; \
15+
else \
16+
go build -o /frontend ./cmd/frontend ; \
17+
fi
1318

1419
FROM scratch AS frontend
1520
COPY --from=frontend-build /frontend /frontend

cmd/frontend/coverage.go

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
// cmd/frontend/coverage.go
2+
package main
3+
4+
import (
5+
"bytes"
6+
"compress/gzip"
7+
"context"
8+
"strings"
9+
10+
"runtime/coverage"
11+
12+
gwclient "github.com/moby/buildkit/frontend/gateway/client"
13+
)
14+
15+
const (
16+
frontendCoverageOptKey = "dalec.coverage"
17+
frontendCovMetaKey = "dalec.coverage.frontend.meta.gz"
18+
frontendCovCountersKey = "dalec.coverage.frontend.counters.gz"
19+
)
20+
21+
func isNoMetaErr(err error) bool {
22+
if err == nil {
23+
return false
24+
}
25+
// runtime/coverage: "no meta-data available (binary not built with -cover?)"
26+
return strings.Contains(strings.ToLower(err.Error()), "no meta-data available")
27+
}
28+
29+
// Enabled per solve via SolveRequest.FrontendOpt["dalec.coverage"]="1"
30+
func wantFrontendCoverage(c gwclient.Client) bool {
31+
v, ok := c.BuildOpts().Opts[frontendCoverageOptKey]
32+
if !ok {
33+
return false
34+
}
35+
v = strings.ToLower(strings.TrimSpace(v))
36+
return v == "1" || v == "true" || v == "yes" || v == "on"
37+
}
38+
39+
func gzipBytes(in []byte) ([]byte, error) {
40+
var buf bytes.Buffer
41+
zw := gzip.NewWriter(&buf)
42+
if _, err := zw.Write(in); err != nil {
43+
_ = zw.Close()
44+
return nil, err
45+
}
46+
if err := zw.Close(); err != nil {
47+
return nil, err
48+
}
49+
return buf.Bytes(), nil
50+
}
51+
52+
func attachFrontendCoverage(c gwclient.Client, res *gwclient.Result) error {
53+
if res == nil || !wantFrontendCoverage(c) {
54+
return nil
55+
}
56+
if res.Metadata == nil {
57+
res.Metadata = map[string][]byte{}
58+
}
59+
60+
var metaBuf, ctrBuf bytes.Buffer
61+
62+
if err := coverage.WriteMeta(&metaBuf); err != nil {
63+
if isNoMetaErr(err) {
64+
return nil
65+
}
66+
return err
67+
}
68+
if err := coverage.WriteCounters(&ctrBuf); err != nil {
69+
if isNoMetaErr(err) {
70+
return nil
71+
}
72+
return err
73+
}
74+
75+
metaGz, err := gzipBytes(metaBuf.Bytes())
76+
if err != nil {
77+
return err
78+
}
79+
ctrGz, err := gzipBytes(ctrBuf.Bytes())
80+
if err != nil {
81+
return err
82+
}
83+
84+
res.Metadata[frontendCovMetaKey] = metaGz
85+
res.Metadata[frontendCovCountersKey] = ctrGz
86+
87+
// Avoid cross-solve accumulation if the frontend process is reused.
88+
// Only works for binaries built with -cover (and typically atomic counters).
89+
_ = coverage.ClearCounters()
90+
91+
return nil
92+
}
93+
94+
func wrapWithCoverage(next gwclient.BuildFunc) gwclient.BuildFunc {
95+
return func(ctx context.Context, c gwclient.Client) (*gwclient.Result, error) {
96+
res, err := next(ctx, c)
97+
if err != nil {
98+
return nil, err
99+
}
100+
if err := attachFrontendCoverage(c, res); err != nil {
101+
return nil, err
102+
}
103+
return res, nil
104+
}
105+
}

cmd/frontend/main.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,10 @@ func dalecMain() {
6969
if err != nil {
7070
bklog.L.WithError(err).Fatal("error creating frontend router")
7171
}
72+
handler := mux.Handler(frontend.WithTargetForwardingHandler)
73+
handler = wrapWithCoverage(handler)
7274

73-
if err := grpcclient.RunFromEnvironment(ctx, mux.Handler(frontend.WithTargetForwardingHandler)); err != nil {
75+
if err := grpcclient.RunFromEnvironment(ctx, handler); err != nil {
7476
bklog.L.WithError(err).Fatal("error running frontend")
7577
os.Exit(70) // 70 is EX_SOFTWARE, meaning internal software error occurred
7678
}

test/testenv/build.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,17 @@ func buildBaseFrontend(ctx context.Context, c gwclient.Client) (*gwclient.Result
5050
return nil, errors.Wrap(err, "error marshaling Dockerfile context")
5151
}
5252

53+
// If the test runner requested frontend coverage, build the frontend binary
54+
// with coverage instrumentation (Dockerfile uses DALEC_FRONTEND_COVERAGE).
55+
frontendOpt := map[string]string{}
56+
if os.Getenv("DALEC_FRONTEND_GOCOVERDIR") != "" {
57+
frontendOpt["build-arg:DALEC_FRONTEND_COVERAGE"] = "1"
58+
}
59+
5360
defPB := def.ToPB()
5461
return c.Solve(ctx, gwclient.SolveRequest{
5562
Frontend: "dockerfile.v0",
56-
FrontendOpt: map[string]string{},
63+
FrontendOpt: frontendOpt,
5764
FrontendInputs: map[string]*pb.Definition{
5865
dockerui.DefaultLocalNameContext: defPB,
5966
dockerui.DefaultLocalNameDockerfile: dockerfileDef.ToPB(),

test/testenv/buildx.go

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"net"
1212
"os"
1313
"os/exec"
14+
"path/filepath"
1415
"strings"
1516
"sync"
1617
"testing"
@@ -420,14 +421,36 @@ type clientForceDalecWithInput struct {
420421
}
421422

422423
func (c *clientForceDalecWithInput) Solve(ctx context.Context, req gwclient.SolveRequest) (*gwclient.Result, error) {
424+
covRoot := os.Getenv("DALEC_FRONTEND_GOCOVERDIR")
423425
if req.Definition == nil {
424426
// Only inject the frontend when there is no "definition" set.
425427
// If a definition is set, it is intended for this to go directly to the buildkit solver.
426428
if err := withDalecInput(ctx, c.Client, &req); err != nil {
427429
return nil, err
428430
}
429431
}
430-
return c.Client.Solve(ctx, req)
432+
433+
// IMPORTANT: set this *after* withDalecInput, since it may replace/normalize FrontendOpt.
434+
if covRoot != "" {
435+
if req.FrontendOpt == nil {
436+
req.FrontendOpt = map[string]string{}
437+
}
438+
// Frontend-only toggle (NOT a dalec build arg)
439+
req.FrontendOpt["dalec.coverage"] = "1"
440+
}
441+
res, err := c.Client.Solve(ctx, req)
442+
if err != nil {
443+
return nil, err
444+
}
445+
446+
if covRoot != "" {
447+
// Make it suite-safe by letting CI set DALEC_FRONTEND_GOCOVERDIR per-suite.
448+
// If they set a plain directory, we write directly there.
449+
if err := writeFrontendCovdata(filepath.Clean(covRoot), res); err != nil {
450+
return nil, err
451+
}
452+
}
453+
return res, nil
431454
}
432455

433456
// gwClientInputInject is a gwclient.Client that injects the result of a build func into the solve request as an input named by the id.

0 commit comments

Comments
 (0)