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
154 changes: 149 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,7 @@ jobs:
set -eu

docker buildx bake frontend

if [ "${TEST_SUITE}" = "other" ]; then
exit 0
fi
Expand All @@ -283,19 +284,64 @@ jobs:
docker buildx bake worker
env:
TEST_SUITE: ${{ matrix.suite }}
- name: Run integration tests
- name: Run integration tests (with coverage tracking)
run: |
set -ex
if [ -n "${TEST_SUITE}" ] && [ ! "${TEST_SUITE}" = "other" ]; then
mkdir -p coverage

# The frontend covdata files (covmeta/covcounters) are written by the test harness
# (writeFrontendCovdata) on the RUNNER filesystem.
export DALEC_FRONTEND_GOCOVERDIR="${GITHUB_WORKSPACE}/coverage/frontend-${TEST_SUITE}"
rm -rf "${DALEC_FRONTEND_GOCOVERDIR}"
mkdir -p "${DALEC_FRONTEND_GOCOVERDIR}"

run=""
skip=""
if [ -n "${TEST_SUITE}" ] && [ "${TEST_SUITE}" != "other" ]; then
run="-run=${TEST_SUITE}"
fi
if [ -n "${TEST_SKIP}" ]; then
skip="-skip=${TEST_SKIP}"
fi
go test -timeout=59m -v -json ${run} ${skip} ./test | go run ./cmd/test2json2gha --slow 120s --logdir /tmp/testlogs

go test -timeout=59m -v -json \
Comment thread
kartikjoshi21 marked this conversation as resolved.
-covermode=atomic -coverpkg=./... \
-coverprofile="coverage/integration-${TEST_SUITE}.out" \
${run} ${skip} ./test \
| go run ./cmd/test2json2gha --slow 120s --logdir /tmp/testlogs

# Convert frontend covdata -> legacy coverprofile
if ! ls "${DALEC_FRONTEND_GOCOVERDIR}"/covmeta.* >/dev/null 2>&1; then
echo "::group::frontend coverage debug"
echo "DALEC_FRONTEND_GOCOVERDIR=${DALEC_FRONTEND_GOCOVERDIR}"
echo "Contents:"
ls -la "${DALEC_FRONTEND_GOCOVERDIR}" || true
echo "Searching workspace for covmeta/covcounters..."
find "${GITHUB_WORKSPACE}" \( -name 'covmeta.*' -o -name 'covcounters.*' \) 2>/dev/null | head -n 200 || true
echo "::endgroup::"
echo "::error::No frontend coverage covmeta.* found in ${DALEC_FRONTEND_GOCOVERDIR} (frontend coverage not collected)"
exit 1
fi

go tool covdata textfmt \
-i="${DALEC_FRONTEND_GOCOVERDIR}" \
-o="coverage/frontend-${TEST_SUITE}.out"
env:
TEST_SUITE: ${{ matrix.suite }}
TEST_SKIP: ${{ matrix.skip }}


- name: Upload integration coverage profile
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: coverage-integration-${{ matrix.suite }}
path: |
coverage/integration-${{ matrix.suite }}.out
coverage/frontend-${{ matrix.suite }}.out
if-no-files-found: ignore
retention-days: 7

- name: Get traces
if: always()
run: |
Expand Down Expand Up @@ -354,8 +400,27 @@ jobs:
cache: false
- name: download deps
run: go mod download
- name: Run unit tests
run: go test -v --test.short --json ./... | go run ./cmd/test2json2gha
- name: Run unit tests (with coverage tracking)
run: |
set -eux
mkdir -p coverage

pkgs="$(go list ./... | grep -v '/test$' | grep -v '/test/' )"
go test -v --test.short --json \
-covermode=atomic \
-coverprofile="coverage/unit.out" \
${pkgs} \
| go run ./cmd/test2json2gha
- name: Upload unit coverage profile
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: coverage-unit
path: coverage/unit.out
if-no-files-found: ignore
retention-days: 7



e2e:
runs-on: ubuntu-22.04
Expand Down Expand Up @@ -443,3 +508,82 @@ jobs:
path: ${{ steps.dump-logs.outputs.DOCKERD_LOG_PATH }}
retention-days: 1

coverage-report:
runs-on: ubuntu-22.04
needs:
- unit
- integration
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The coverage-report job will not run if either the unit or integration jobs fail, even though those jobs use if: always() to upload coverage artifacts on failure. This means coverage reports won't be generated when tests fail. Consider adding if: always() to the coverage-report job (or using a more specific condition like if: success() || failure()) to ensure coverage reports are generated even when tests fail, which would be valuable for understanding coverage gaps in failing test scenarios.

Suggested change
- integration
- integration
if: ${{ always() }}

Copilot uses AI. Check for mistakes.

steps:
- name: Harden Runner
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit

- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- name: Setup Go
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
with:
go-version: "1.25"
cache: false

- name: Download deps
run: go mod download

- name: Download unit coverage artifact
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
with:
name: coverage-unit
path: coverage

- name: Download integration coverage artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
Comment on lines +536 to +542
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing version comment for the download-artifact action. All other action uses in this workflow include version comments (e.g., # v6.0.0). Please add a version comment to maintain consistency with the established convention in this file.

Suggested change
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
with:
name: coverage-unit
path: coverage
- name: Download integration coverage artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # download-artifact pinned SHA
with:
name: coverage-unit
path: coverage
- name: Download integration coverage artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # download-artifact pinned SHA

Copilot uses AI. Check for mistakes.
Comment on lines +536 to +542
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing version comment for the download-artifact action. All other action uses in this workflow include version comments (e.g., # v6.0.0). Please add a version comment to maintain consistency with the established convention in this file.

Suggested change
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
with:
name: coverage-unit
path: coverage
- name: Download integration coverage artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.1.8
with:
name: coverage-unit
path: coverage
- name: Download integration coverage artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.1.8

Copilot uses AI. Check for mistakes.
with:
path: coverage/_integration

- name: Merge coverage + generate report
run: |
set -eux
go install github.com/wadey/gocovmerge@latest

integration_profiles="$(find coverage/_integration -type f -name 'integration-*.out' | sort | tr '\n' ' ')"
frontend_profiles="$(find coverage/_integration -type f -name 'frontend-*.out' | sort | tr '\n' ' ')"
if [ -z "${integration_profiles}" ]; then
echo "::error::No integration coverage profiles found"
exit 1
fi

if [ -z "${frontend_profiles}" ]; then
echo "::error::No frontend coverage profiles found"
exit 1
fi

if [ ! -f coverage/unit.out ]; then
echo "::error::Unit coverage profile not found (coverage/unit.out)"
exit 1
fi

"$(go env GOPATH)/bin/gocovmerge" coverage/unit.out ${integration_profiles} ${frontend_profiles} > coverage/all.out

go tool cover -func=coverage/all.out | tee coverage/summary.txt
go tool cover -html=coverage/all.out -o coverage/index.html

total="$(tail -n 1 coverage/summary.txt | awk '{print $3}')"
{
echo "## Coverage"
echo
echo "- Total: **${total}**"
echo "- Profiles merged: $(echo "${integration_profiles}" | wc -w) integration + $(echo "${frontend_profiles}" | wc -w) frontend"
} >> "${GITHUB_STEP_SUMMARY}"

- name: Upload merged coverage report
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: coverage-report
path: |
coverage/all.out
coverage/summary.txt
coverage/index.html
retention-days: 14
7 changes: 6 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,16 @@ WORKDIR /build
COPY . .
ENV CGO_ENABLED=0
ARG TARGETARCH TARGETOS GOFLAGS=-trimpath
ARG DALEC_FRONTEND_COVERAGE=0
ENV GOOS=${TARGETOS} GOARCH=${TARGETARCH} GOFLAGS=${GOFLAGS}
RUN \
--mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
go build -o /frontend ./cmd/frontend
if [ "${DALEC_FRONTEND_COVERAGE}" = "1" ]; then \
go build -cover -covermode=atomic -coverpkg=./... -o /frontend ./cmd/frontend ; \
else \
go build -o /frontend ./cmd/frontend ; \
fi

FROM scratch AS frontend
COPY --from=frontend-build /frontend /frontend
Expand Down
112 changes: 112 additions & 0 deletions cmd/frontend/coverage.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package main

import (
"bytes"
"compress/gzip"
"context"
"errors"
"strings"

"runtime/coverage"

gwclient "github.com/moby/buildkit/frontend/gateway/client"
"github.com/project-dalec/dalec/internal/frontendcoverage"
)

func isNoMetaErr(err error) bool {
if err == nil {
return false
}
// runtime/coverage: "no meta-data available (binary not built with -cover?)"
return strings.Contains(strings.ToLower(err.Error()), "no meta-data available")
}

// Enabled per solve via SolveRequest.FrontendOpt[frontendcoverage.OptKey]="1"
func wantFrontendCoverage(c gwclient.Client) bool {
return frontendcoverage.Want(c.BuildOpts().Opts)
}

func gzipBytes(in []byte) ([]byte, error) {
var buf bytes.Buffer
zw := gzip.NewWriter(&buf)
if _, err := zw.Write(in); err != nil {
_ = zw.Close()
return nil, err
}
if err := zw.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}

var frontendCoverageCollector = collectFrontendCoveragePayload

func collectFrontendCoveragePayload() (*frontendcoverage.Payload, error) {
var metaBuf, ctrBuf bytes.Buffer

if err := coverage.WriteMeta(&metaBuf); err != nil {
if isNoMetaErr(err) {
return nil, nil
}
return nil, err
}
if err := coverage.WriteCounters(&ctrBuf); err != nil {
if isNoMetaErr(err) {
return nil, nil
}
return nil, err
}

metaGz, err := gzipBytes(metaBuf.Bytes())
if err != nil {
return nil, err
}
ctrGz, err := gzipBytes(ctrBuf.Bytes())
if err != nil {
return nil, err
}

// Avoid cross-solve accumulation if the frontend process is reused.
// Only works for binaries built with -cover (and typically atomic counters).
_ = coverage.ClearCounters()

return &frontendcoverage.Payload{
MetaGz: metaGz,
CountersGz: ctrGz,
}, nil
}

func wrapWithCoverage(next gwclient.BuildFunc) gwclient.BuildFunc {
return func(ctx context.Context, c gwclient.Client) (*gwclient.Result, error) {
res, err := next(ctx, c)
if !wantFrontendCoverage(c) {
return res, err
}

payload, covErr := frontendCoverageCollector()
if covErr != nil {
if err != nil {
return res, errors.Join(err, covErr)
}
return res, covErr
}
if payload == nil {
return res, err
}

if err != nil {
errWithCoverage, attachErr := payload.AttachToError(err)
if attachErr != nil {
return res, errors.Join(err, attachErr)
}
return res, errWithCoverage
}

if res == nil {
res = gwclient.NewResult()
}
payload.AttachToResult(res)

return res, nil
}
}
Loading
Loading