Repository navigation
chore: collapse CI to four checks and move the logic into the Makefile [#9] - #15
Conversation
Trivy's release-build gate caught this on PR #15: an SSH source-address restriction bypass in golang.org/x/crypto (pulled in transitively, not used directly). v0.55.0 fixes it upstream — confirmed clean with a local Trivy rescan after the bump.
manjudr
left a comment
There was a problem hiding this comment.
Thanks for putting together this comprehensive CI/CD update! Splitting the workflows into distinct, modular files (run-tests.yml, security.yml, build-and-push.yml), implementing native multi-arch builds (amd64 + arm64), adding pre-push Trivy gating, and bumping golang.org/x/crypto to v0.55.0 are all fantastic improvements.
Before merging, there are a few critical fixes and improvements needed:
🔴 Required Changes
-
Fix Tag Filter Glob Pattern in
build-and-push.yml- File:
.github/workflows/build-and-push.yml - Issue: GitHub Actions
on.push.tagsuses glob patterns (fnmatch), not regular expressions. In glob syntax,+is treated as a literal+character rather than a quantifier. Consequently, standard release tags (e.g.,v0.0.1,v1.2.0) will never match or trigger this workflow. - Action: Update the tag filter to use valid glob matching:
on: push: tags: - "v*.*.*" # or: ["v[0-9]*.[0-9]*.[0-9]*"]
- File:
-
Add
continue-on-error: trueto PR Comment Steps for Fork PRs- Files:
.github/workflows/run-tests.ymland.github/workflows/security.yml - Issue: Pull requests opened from forks receive a read-only
GITHUB_TOKENby default. Even withpull-requests: writedeclared,peter-evans/create-or-update-commentwill fail with HTTP 403 (Resource not accessible by integration), causing the entire CI check to fail for external contributors. - Action: Add
continue-on-error: trueto the PR comment steps inrun-tests.yml(coverage) andsecurity.yml(govulncheck & Trivy):- name: Comment coverage on PR if: github.event_name == 'pull_request' continue-on-error: true uses: peter-evans/create-or-update-comment@v4
- Files:
-
Provide Fallbacks or Documentation for
dhi.ioinDockerfile- File:
Dockerfile - Issue: Switching base images to
dhi.iorequires authentication against a paid Docker subscription. Runningmake docker,make run, ordocker compose up --buildlocally without a configuredDHI_AUTHcredential will fail with401 Unauthorized. - Action: Parameterize base images using Docker
ARGdefaults to public images (e.g.,golang:1.25-alpineandgcr.io/distroless/static-debian12:nonroot) with DHI passed via--build-argin CI, or clearly document how local developers should authenticate.
- File:
🟡 Recommended Improvements
-
Eliminate Duplicate Test Runs in
run-tests.yml- In
run-tests.yml, the job runsmake testfollowed immediately bymake cover. Sincemake coveralready executesgo test -raceacross the entire codebase to compute coverage, runningmake testfirst duplicates ~3.5 minutes of CI execution. You can remove- run: make testand rely solely onmake cover.
- In
-
Explicit Exit Handling for
govulncheckinsecurity.ymlmake security 2>&1 | tee govulncheck-report.txtswallows non-zero exit codes due totee. If this step is intended to be purely informative/reporting prior to comment posting, make the tolerance explicit (e.g.make security > govulncheck-report.txt 2>&1 || true) to prevent unexpected failures if runner shell options change in the future.
-
Align Go Versions
- Align Go version across
go.mod(go 1.25), CIsetup-go(1.25.0), and theDockerfilebuild stage (1.27) to maintain parity between local dev, CI, and production builds.
- Align Go version across
|
You are seeing this message because GitHub Code Scanning has recently been set up for this repository, or this pull request contains the workflow file for the Code Scanning tool. What Enabling Code Scanning Means:
For more information about GitHub Code Scanning, check out the documentation. |
9e657fe to
a746318
Compare
A fork PR carries a read-only GITHUB_TOKEN by default, so create-or-update-comment 403s there even with pull-requests: write declared on the job - failing the whole test job over a comment that was never going to post. continue-on-error on both comment steps: the actual gate is the coverage-check step above them, unaffected. From code review on PR #15.
A fork PR carries a read-only GITHUB_TOKEN by default, so create-or-update-comment 403s there even with pull-requests: write declared on the job - failing the whole test job over a comment that was never going to post. continue-on-error on both comment steps: the actual gate is the coverage-check step above them, unaffected. From code review on PR #15.
Both stages hard-required dhi.io, so a local docker build, make docker or docker compose up --build without a Docker Hardened Images subscription just 401s. Parameterized both FROM lines behind BUILD_IMAGE/RUNTIME_IMAGE build-args, defaulting to the same dhi.io images CI already uses, so nothing changes for CI or for a shipped image. A local build without DHI credentials overrides both to public equivalents plus INSTALL_BUILD_TOOLS=true, since the public golang:alpine image lacks the gcc/g++/musl-dev/make the DHI -dev variant bundles and this build needs a C toolchain for h3-go's cgo binding regardless of which base runs it. Verified both paths build clean: the default (unchanged, DHI) and the public-image override with INSTALL_BUILD_TOOLS=true. From code review on PR #15.
…e push [#9] Three workflows replace the single ci.yml: run-tests.yml (lint, build, tests via gotestsum, coverage gated by vladopajic/go-test-coverage), security.yml (Trivy dependency and image scans, SARIF to GitHub code scanning plus a PR-comment summary, one gate reading both reports) and build-and-push.yml (multi-arch build, registry-agnostic behind repo-variable flags, GHCR the only one enabled today). Design decisions worth recording: - Trivy replaces govulncheck for the dependency half of T4: govulncheck's advisory feed has no severity field, so fail on HIGH/CRITICAL was never expressible against it. The tradeoff is call-graph reachability - Trivy flags moby/go-archive, test-only and never shipped, which govulncheck alone would have filtered out as unreachable. - The security gate reads the SARIF already produced for the upload rather than rescanning, so the gate and the Security tab can never disagree - Trivy refreshes its vulnerability DB on every invocation. - lint is continue-on-error: a red lint check must never block a merge, only prompt someone to look. CI lint stays as the safety net behind the pre-commit hook per CLAUDE.md, since that hook is bypassable. - Dockerfile's two FROM lines are parameterized behind BUILD_IMAGE and RUNTIME_IMAGE build-args, defaulting to the dhi.io images CI and every shipped image already use, so a local build without a DHI subscription has a documented public-image override instead of a hard 401. - golang.org/x/crypto bumped to close CVE-2026-56854 (SSH auth bypass), caught by this same Trivy gate on a real tag push through build-and-push.yml - verified end to end, including a real image landing in a fork's GHCR package. Addresses code review on PR #15: - Tag-glob pattern: the reviewer read GitHub's ref-filter syntax as strict fnmatch, where + is literal. It isn't - GitHub's own docs list + as a supported quantifier - so v[0-9]+.[0-9]+.[0-9]+ already matches plain SemVer tags as intended. No change needed. - Fork-PR comment steps now continue-on-error, since a fork's default GITHUB_TOKEN is read-only and 403s there regardless of permissions declared on the job. - The duplicate make test + make cover run is gone; one gotestsum run now produces both the pass/fail and the coverage profile. - Go version alignment (go.mod 1.25.0 vs the Dockerfile's 1.27) stays deferred on purpose: bumping go.mod's directive forces a golangci-lint upgrade to build under the newer toolchain, which cascades into an unpredictable dependency bump across tools/go.mod - confirmed locally, where the upgraded golangci-lint's staticcheck dependency panics parsing Go 1.27's own stdlib. The Dockerfile's pinned 1.27 build image is what actually ships; this gap is CI/local-dev-only.
a746318 to
dea73eb
Compare
|
Went through the earlier feedback:
Also bumped golang.org/x/crypto to fix a real CVE (SSH auth bypass) that the new Trivy gate caught on a real tag push — tested end to end including a real image landing in GHCR. |
|
📊 Test Coverage: ➖ Not applicable — no non-test Go files changed vs |
Workflows now call `make <target>` instead of duplicating gate/scan logic inline — a local `make trivy-gate` or `make cover-diff` runs the exact command CI runs. Drops the vladopajic/go-test-coverage and aquasecurity/trivy-action marketplace actions in favor of raw gotestsum and trivy CLI, pinned in the Makefile the same way the other tools are. Coverage is also now scoped to the PR's own changed files, not the whole repo: main only ever moves by PR merge, so the diff is already the gate that matters, and a table of every changed file's percentage answered a question nobody asked. Output is two lines: the marker and the number.
#9] Cleanup pass on the previous Makefile-consolidation commit: - trivy-image and trivy-release-gate now share one TRIVY_IMAGE_SCAN invocation instead of copy-pasting the trivy call. - trivy-gate emits plain output only; the GitHub Actions ::error:: annotation moved to security.yml's gate step, which is the only place that actually needs it — a local `make trivy-gate` no longer prints GHA log-command syntax nobody there can read. - Added actions/cache for the Trivy binary and vulnerability DB in both security.yml and build-and-push.yml. The trivy-action this replaced cached both; the raw CLI move lost that silently, so every run was re-downloading ~130MB, doubled per arch in the build matrix. - cover-diff's PR comment now carries a header/emoji instead of bare text, matching the Trivy comments' style. - Extracted the duplicated "Log in to dhi.io" step (identical in both workflows, touched twice across recent commits) into a composite action at .github/actions/dhi-login — one place to change if the credential shape moves again.
…#9] Fail-only posting was ambiguous from the PR itself — no comment could mean "passed", "not applicable", or "never ran". Now cover-diff always posts (updated in place, not reposted), and "nothing to gate" reads as an explicit pass instead of a neutral statement, so the PR always shows a clear signal. On failure it now also names the changed files dragging the number down, worst first and capped at 15, so the comment answers "what broke" in the same place as "did it break". All four states (pass / fail / not-applicable / no-coverable-statements) share one wording template — same lead phrase, same "min N%" term — where before "need" and "min" meant the same thing in different messages.
CI review — naming consistency, one-liner CI, parallelismReviewed side by side with the sibling PR so the two repos converge rather than drift:
Good news first: structurally these two are already very consistent — same three workflow files, same workflow Three things to fix: job naming is inconsistent in kind, ~100 lines of shell per repo still live in YAML instead of the Makefile, and one workflow is needlessly serial. 1. Are
|
Workflow name: |
File | Job IDs | Check name in UI |
|---|---|---|---|
Run Tests |
run-tests.yml |
lint, test |
lint, test |
Security |
security.yml |
security |
security |
Build and Push |
build-and-push.yml |
build (matrix), manifest |
build (amd64), build (arm64), manifest |
Four inconsistencies:
- Workflow names mix grammatical forms.
Run TestsandBuild and Pushare verb phrases;Securityis a bare noun. Pick one form. - File names mix forms the same way —
run-tests.ymlis verb-prefixed,security.ymlandbuild-and-push.ymlare not. Run Testscontains alintjob, which is not a test. Either the workflow is misnamed or lint is misplaced.- The granularity is uneven, and this is the one that actually costs you something.
lintandtestare single activities.securityis one job doing five: dependency scan → dependency PR comment → image build → image scan → image PR comment → gate. So a redsecuritycheck tells you nothing about which of the five broke, and it can't parallelise (see §3).
Suggested scheme — workflow name: is a bare noun; job ID is <verb> where the object is unambiguous and <verb>-<object> where there's more than one:
| File | name: |
Jobs |
|---|---|---|
ci.yml |
CI |
lint, test |
security.yml |
Security |
scan-deps, scan-image, gate |
release.yml |
Release |
build, manifest |
Two caveats on renaming, worth knowing before you do it:
- Job IDs are the branch-protection contract. Renaming
security→scan-deps/scan-image/gatemeans updating required status checks in both repos' settings, or merges will block on a check name that no longer exists. - discovery-service#15 deletes an existing
.github/workflows/ci.yml. Going back toci.ymlis still the better name, but it's worth being deliberate rather than accidentally re-creating what you just removed.
2. CI as a one-liner — right idea, ~60% done
make is already doing the real work in the test path, and that part is genuinely clean:
- run: make lint
- run: make build
- run: make test-ciBut the other two workflows still carry the implementation inline:
| Workflow | inline shell (network-adapter / discovery-service) |
|---|---|
run-tests.yml |
9 / 9 lines — fine as-is |
security.yml |
53 / 46 |
build-and-push.yml |
57 / 50 |
Five blocks to move, worst first:
a. Assemble Trivy … PR comment — 23 lines × 2 sites × 2 repos = ~92 lines of duplicated jq. This is the strongest case for the Makefile, and it isn't hypothetical: the comments in security.yml record that this exact block already took the whole job down once, because capture(...)?.v is valid on jq 1.8 (a workstation) and a syntax error on jq 1.7.1 (the runner image). Embedded in YAML it can only be tested by pushing a commit. As make trivy-report it runs identically in both places.
trivy-report: # REPORT=trivy-deps.sarif TITLE=... OUT=...- run: make trivy-report REPORT=trivy-deps.sarif TITLE="Go dependency scan" OUT=trivy-deps-comment.md
- run: make trivy-report REPORT=trivy-image.sarif TITLE="Image scan" OUT=trivy-image-comment.mddiscovery-service already has tools/cover-report.awk doing exactly this for the coverage report — the precedent is in the repo, this block just didn't follow it.
b. Tag and push — 22 lines, four near-identical if blocks (GHCR / Docker Hub / ACR / GAR), each doing docker tag + docker push. → make docker-push TAG=…, with the registry list looped in the Makefile.
c. Create multi-arch manifests — 20 lines, including a shell function definition (create_manifest()) and the same four if blocks again. → make docker-manifest VERSION=….
d. The image build doesn't use the target that already exists. Both Makefiles define docker:, and neither workflow calls it — security.yml inlines docker build instead. In network-adapter it's a 7-line block sourcing install/scripts/version-vars.sh; in discovery-service it's a clean one-liner. That's both a missed one-liner and cross-repo drift. → make docker IMAGE=… in both.
e. Set registry flags — 4 lines, duplicated in both jobs of build-and-push.yml. This one genuinely can't move to make (it reads vars.*, which only exists in Actions context) — but it can be hoisted to a workflow-level env: block instead of being pasted twice.
Check coverage's 5-line if/else picks BASE_REF from the event type; that also needs Actions context, so leaving it inline is defensible. Everything else above is portable.
Targets that exist but CI never calls:
- both repos:
docker(see d) - discovery-service:
security(govulncheck ./...), plus theverify/newman/auditgates thatCLAUDE.mddocuments as the stack-dependent checks
That last point is a real cross-repo asymmetry: discovery-service has a make security (govulncheck) target that never runs in CI, and network-adapter has no govulncheck at all. Also worth noting the name collision — security currently means govulncheck as a Makefile target and Trivy as a CI job. Pick one meaning.
3. Parallelism — already right in two places, one real gap
Already correct, no change needed:
- The three workflows are separate files, so they run concurrently on a push/PR.
lint∥test— noneeds:between them.build (amd64)∥build (arm64)→manifest(needs: build). Correct fan-out/fan-in.
The gap is security.yml: one job, six sequential steps, where the dependency scan and the image scan are independent — and the image scan is gated behind a full docker build. Splitting it:
jobs:
scan-deps: # make trivy-deps
scan-image: # make docker + make trivy-image
gate:
needs: [scan-deps, scan-image] # make trivy-gateOne caveat that is the actual work here: make trivy-gate reads trivy-deps.sarif and trivy-image.sarif from the working directory. Split across jobs those are different runners, so the two scan jobs need actions/upload-artifact and gate needs download-artifact. The make call stays a one-liner; the plumbing is the cost. Worth it for the clearer check names alone, but don't split it without wiring the artifacts or the gate will pass vacuously on missing files.
Summary
| Status | |
|---|---|
| Cross-repo structural consistency | ✅ Already close — keep it that way |
| Workflow / job / file naming | |
| CI as one-liner | security + build-and-push (§2) |
make docker / make security unused |
|
| govulncheck asymmetry across repos | |
| Parallelism | ✅ Except security, which is serial (§3) |
Suggested order: (1) split security into three jobs and settle the naming scheme in both repos together, since both touch branch protection; (2) move the jq report block to make trivy-report — biggest duplication win and it has already caused one outage; (3) make docker / docker-push / docker-manifest; (4) decide whether govulncheck is a gate in both repos or neither.
manjudr
left a comment
There was a problem hiding this comment.
Reviewing the three things you asked about — job naming consistency, is the CI a one-liner, and is the logic in the Makefile — plus the Makefile itself as code, which I had not done before.
Answering the questions first, then the findings.
Is the CI a one-liner? Not yet — measured
| workflow | lines of inline shell |
|---|---|
run-tests.yml |
12 |
security.yml |
51 |
build-and-push.yml |
56 |
| total | 119 |
And the duplication is exact, not approximate. I normalised the app name and hashed the blocks:
- The 16-line jq program that renders the Trivy PR-comment table is byte-identical in all four places it appears — twice in this repo's
security.yml, twice innetwork-adapter's. Tag and push(22L),Create multi-arch manifests(20L) andSet registry flags(4L, twice) are byte-identical across the two repos — same md5 after normalisingdiscovery-service/network-adapter.
security.yml L79-85 already records what that costs: capture(...)?.v is valid on jq 1.8 and a syntax error on jq 1.7.1, and it took the whole job down. That fix had to be applied in four places. One make trivy-comment target and it would have been one place — and make trivy-comment would be runnable locally, which an Actions run: block never is.
Moving the six blocks below to make targets takes this repo from 119 → ~15 lines of inline shell:
| step | proposed |
|---|---|
| Assemble Trivy dependency/image PR comment (23L × 2) | make trivy-comment SARIF=… TITLE=… OUT=… |
| Tag and push (22L) | make push-tags |
| Create multi-arch manifests (20L) | make manifests |
| Set registry flags (4L × 2) | a composite action, or make registry-flags |
| Check coverage (5L if/else) | make cover-diff with BASE_REF defaulted in the Makefile |
Can they run in parallel? Mostly already — with one real gap
lint/test are parallel jobs, and build fans out over the matrix. Good. But security.yml is one serial job that does: deps scan → docker build → image scan → gate. The deps scan needs nothing but a checkout, so it can overlap the image build entirely. Split into deps + image in parallel and a gate job with needs: [deps, image] consuming both SARIFs as artifacts, and you save the whole deps-scan duration off the critical path. That is also what the naming table below assumes.
Did I review the Makefile? Now yes — and it has one blocker
make test-ci drops -coverpkg, so the CI coverage gate measures a different and materially lower number than make cover does. Empirically verified, details on Makefile:88. Everything else is inline below.
One Makefile finding I could not anchor inline
help (L45-46) is outside this PR's hunks, so GitHub won't take a comment there — but it is worth fixing while you're in this file, and it is the first thing anyone typing make sees, since .DEFAULT_GOAL := help.
@grep -hE '^## ' $(MAKEFILE_LIST) | sed 's/^## / /' | sort sorts every ## line independently. Most of this file's help text is multi-line, so sort tears the continuations away from their target and alphabetises them among themselves:
$ make help | head -5
arch-tagged local image, before anything is pushed.
finding instead of writing a report — the pre-push
release gate build-and-push.yml runs once per
dependency scan above cannot see. IMAGE names the ref to scan.
so a Go toolchain CVE shows up here and nowhere else that the
Measured: 70 lines of output, of which only 32 name a target — 38 are orphaned fragments, and the entire first screen contains no target name at all. (network-adapter has the same bug at a smaller scale: 25 lines, 15 targets.)
Two fixes, either fine — sort only the lines that carry a target, or drop sort and rely on file order:
help:
@grep -hE '^## [a-z]' $(MAKEFILE_LIST) | sed 's/^## / /' | sortThat keeps one line per target and drops the continuations from help entirely, leaving them as source comments where they read fine. If you want the continuations shown, they have to be emitted with their parent — which means not sorting, or grouping before sorting.
Proposed naming format — apply verbatim in both repos
The job ids are already consistent across discovery-service and
network-adapter (lint, test, security, build, manifest — byte for
byte the same). So the answer to "are these consistent?" is yes, the ids
are. Three things are not:
- No job declares
name:. The check string is therefore the raw id, so
branch protection and the PR checks list readRun Tests / lint,
Security / security. - The workflow names mix styles —
Run TestsandBuild and Pushare
verb phrases,Securityis a bare noun. Run Testsunder-describes what it runs. It runslinttoo, so
Run Tests / lintis a self-contradicting check name.
Proposed — one rule: workflow name: is a bare noun naming the stage; job id
stays lowercase and equals the make target it calls; job name: is that id,
title-cased.
| file | workflow name: |
job id | add name: |
resulting check |
|---|---|---|---|---|
run-tests.yml |
CI |
lint |
Lint |
CI / Lint |
test |
Test |
CI / Test |
||
security.yml |
Security |
deps |
Dependency scan |
Security / Dependency scan |
image |
Image scan |
Security / Image scan |
||
gate |
Gate |
Security / Gate |
||
build-and-push.yml |
Release |
build |
Build (${{ matrix.platform }}) |
Release / Build (linux/amd64) |
manifest |
Manifest |
Release / Manifest |
That removes the Security / security stutter, makes CI / Lint honest, and
gives the matrix jobs a legible per-arch name.
⚠️ Both renaming the workflow and addingname:to a job change the check
string, which is the branch-protection contract.Run Tests / lint→
CI / Lintwill silently stop matching a required check. So please settle the
final names in one pass across both repos and update branch protection once —
rather than arriving here in three commits and breaking the gate twice.
| ## cover-diff: on failure, names the changed files dragging the number down | ||
| ## (worst first) so "what broke" is answered in the same place | ||
| ## as "did it break" — on a pass, still just the one line. | ||
| cover-diff: coverage.out |
There was a problem hiding this comment.
cover-diff declares a file prerequisite with no rule to build it, so it only works if something else ran first.
$ make cover-diff
make: *** No rule to make target `coverage.out', needed by `cover-diff'. Stop.
It survives in CI only because make test-ci happens to have written the file one step earlier. Locally, on a clean checkout, the target is unrunnable — and the error names a file, not the target you should have run.
This is also inconsistent with its three neighbours, which all name a target and therefore rebuild:
cover-total: cover # L69
cover-report: cover # L73
cover-html: cover # L79
cover-diff: coverage.out # <- this line
Either give coverage.out a rule:
coverage.out:
$(MAKE) coveror make it cover-diff: cover like the other three. The first is better — it keeps cover-diff cheap in CI, where the profile already exists, instead of re-running the whole suite.
| trivy-gate: | ||
| @fail=0; \ | ||
| for report in trivy-deps.sarif trivy-image.sarif; do \ | ||
| count=$$(jq '[.runs[].results[]?] | length' "$$report"); \ |
There was a problem hiding this comment.
trivy-gate exits 0 when a report is missing — the gate passes having checked nothing.
jq on a nonexistent file writes an error to stderr and prints nothing, so count is the empty string; [ "" -gt 0 ] is itself an error, not a true branch, so fail stays 0 and the loop moves on. Reproduced with this exact recipe:
$ sh trivy-gate.sh # no sarif files present
jq: error: Could not open file trivy-deps.sarif: No such file or directory
trivy-deps.sarif: HIGH,CRITICAL
[: : integer expression expected
jq: error: Could not open file trivy-image.sarif: No such file or directory
trivy-image.sarif: HIGH,CRITICAL
[: : integer expression expected
>>> exit 0
Note the output line trivy-deps.sarif: HIGH,CRITICAL — it reads like a scan result with the count elided, not like a failure. That is what makes it dangerous rather than merely untidy.
Scope, stated honestly: in security.yml today this does not fire, because the two scan steps are not continue-on-error and the gate is the last step, so a failed scan aborts the job before the gate runs. It fires in two places that matter anyway:
- Locally. Both workflows say the point of this target is that "a local
make trivy-gatechecks the exact same two files this job just wrote." On a clean checkout, or aftermake clean, or after running onlymake trivy-deps,make trivy-gateis green having scanned nothing. Half-green is the common case: runmake trivy-depsalone and the deps half reports honestly while the image half passes vacuously. - The moment someone adds
if: always()to the gate step — which is the natural next change, since a gate you want to see the result of should not be skipped by an earlier failure.
for report in trivy-deps.sarif trivy-image.sarif; do \
+ if [ ! -f "$$report" ]; then \
+ echo "$$report: MISSING — no scan produced it"; \
+ fail=1; continue; \
+ fi; \
count=$$(jq '[.runs[].results[]?] | length' "$$report"); \A gate that cannot find its evidence should fail, for the same reason the comment above it gives for not rescanning: the one state nobody can act on.
| @@ -0,0 +1,113 @@ | |||
| name: Run Tests | |||
There was a problem hiding this comment.
Proposed naming format — apply verbatim in both repos
The job ids are already consistent across discovery-service and
network-adapter (lint, test, security, build, manifest — byte for
byte the same). So the answer to "are these consistent?" is yes, the ids
are. Three things are not:
- No job declares
name:. The check string is therefore the raw id, so
branch protection and the PR checks list readRun Tests / lint,
Security / security. - The workflow names mix styles —
Run TestsandBuild and Pushare
verb phrases,Securityis a bare noun. Run Testsunder-describes what it runs. It runslinttoo, so
Run Tests / lintis a self-contradicting check name.
Proposed — one rule: workflow name: is a bare noun naming the stage; job id
stays lowercase and equals the make target it calls; job name: is that id,
title-cased.
| file | workflow name: |
job id | add name: |
resulting check |
|---|---|---|---|---|
run-tests.yml |
CI |
lint |
Lint |
CI / Lint |
test |
Test |
CI / Test |
||
security.yml |
Security |
deps |
Dependency scan |
Security / Dependency scan |
image |
Image scan |
Security / Image scan |
||
gate |
Gate |
Security / Gate |
||
build-and-push.yml |
Release |
build |
Build (${{ matrix.platform }}) |
Release / Build (linux/amd64) |
manifest |
Manifest |
Release / Manifest |
That removes the Security / security stutter, makes CI / Lint honest, and
gives the matrix jobs a legible per-arch name.
⚠️ Both renaming the workflow and addingname:to a job change the check
string, which is the branch-protection contract.Run Tests / lint→
CI / Lintwill silently stop matching a required check. So please settle the
final names in one pass across both repos and update branch protection once —
rather than arriving here in three commits and breaking the gate twice.
One extra note specific to this file: Run Tests is also the filename, so renaming the workflow to CI without renaming the file leaves run-tests.yml containing name: CI. Rename the file to ci.yml in the same commit — a workflow file whose name disagrees with its name: is the next person's wrong grep.
| # which sidesteps the postfix-`?`-then-field-access ambiguity outright, | ||
| # but a report-formatting step must never be able to fail the job | ||
| # regardless — the gate at the bottom is what decides that. | ||
| - name: Assemble Trivy dependency PR comment |
There was a problem hiding this comment.
This 16-line jq program is byte-identical in four places across the two repos. Move it to the Makefile.
I extracted the four blocks, normalised the SARIF filename, and diffed:
ds security.yml block 1 vs ds block 2 IDENTICAL
ds security.yml block 1 vs na block 1 IDENTICAL
na security.yml block 1 vs na block 2 IDENTICAL
One program, copy-pasted four times. The comment at L79-85 above documents what that already cost — the capture(...)?.v jq-version break had to be fixed in all four.
Proposed, parameterised so one target serves both scans and both repos:
## trivy-comment: render a SARIF report as a markdown PR comment table.
## SARIF names the report, TITLE the heading, OUT the file.
trivy-comment:
@{ \
echo "<!-- sec-scan:$(basename $(notdir $(SARIF))) -->"; \
echo "### 🛡️ Trivy — $(TITLE) ($(SEVERITY))"; \
echo "[View full run]($(RUN_URL))"; \
echo; \
jq -r -f tools/trivy-comment.jq $(SARIF); \
} > $(OUT)and the step collapses to the one-liner you asked for:
- name: Assemble Trivy dependency PR comment
if: github.event_name == 'pull_request'
continue-on-error: true
run: make trivy-comment SARIF=trivy-deps.sarif TITLE="Go dependency scan" OUT=trivy-deps-comment.mdPutting the jq in tools/trivy-comment.jq rather than inside the recipe matters too: as a file it is lintable (jq -n -f), diffable, and free of the $$/backslash-continuation escaping that makes the current block hard to read and hard to edit correctly.
Worth saying plainly: this would not by itself have caught the jq 1.7.1-vs-1.8 break, because a workstation has 1.8 either way. What it fixes is that the break had to be repaired in four places instead of one.
| cancel-in-progress: true | ||
|
|
||
| jobs: | ||
| security: |
There was a problem hiding this comment.
security is one serial job doing four things; two of them are independent and could overlap.
The current sequence is: deps scan → docker build → image scan → gate. The dependency scan needs nothing but the checkout, so it sits idle on the critical path behind a docker build it has no relationship to.
You asked whether these can run in parallel — here is the one place in either repo where the answer is "not yet, and it's worth it":
jobs:
deps:
name: Dependency scan
steps: [checkout, trivy cache, make trivy-deps, upload-sarif,
make trivy-comment, upload-artifact trivy-deps.sarif]
image:
name: Image scan
steps: [checkout, trivy cache, dhi login, docker build,
make trivy-image, upload-sarif, make trivy-comment,
upload-artifact trivy-image.sarif]
gate:
name: Gate
needs: [deps, image]
if: always()
steps: [download-artifact, make trivy-gate]Three things this buys beyond wall-clock:
- The deps scan overlaps the image build entirely.
Security / Dependency scanandSecurity / Image scanbecome separately-red checks, so the PR says which scan found something without opening the log.gatewithneeds:+if: always()runs even when one scan fails — which is what a gate should do, and is exactly the arrangement that turns the vacuous-pass bug I flagged onMakefile:179from latent into live. Fix that one first, or in the same PR.
The cost is the SARIF artifact round-trip and a duplicated checkout/cache block in two jobs. Given the gate is the release control, I think that trade is worth it — but flagging it as a trade rather than a free win.
|
|
||
| # Re-tag the already-scanned local image for each enabled registry and | ||
| # push it there. No rebuild, no re-scan. | ||
| - name: Tag and push |
There was a problem hiding this comment.
Tag and push (22 lines) is byte-identical to network-adapter's. Same for Create multi-arch manifests (20 lines).
Hashed with the app name normalised:
ds tag-and-push 666e75e295cea971aaba2bdfa1afe60f
na tag-and-push 666e75e295cea971aaba2bdfa1afe60f
ds manifests 9d8f277b346056021d000d98b5ede18f
na manifests 9d8f277b346056021d000d98b5ede18f
Both are also internally repetitive — four near-identical per-registry if blocks in Tag and push, and a shell function plus the same four blocks in Create multi-arch manifests.
make push-tags / make manifests, driven by a single REGISTRIES list, kills both the cross-repo duplication and the four-way repetition:
## push-tags: retag the local per-arch image into every enabled registry
push-tags:
@for reg in $(REGISTRIES); do \
[ -n "$$reg" ] || continue; \
docker tag $(LOCAL_IMAGE) "$$reg/$(IMAGE_NAME):$(TAG)"; \
docker push "$$reg/$(IMAGE_NAME):$(TAG)"; \
doneand the step becomes run: make push-tags.
Since the two files are already identical here, the stronger version of this is a shared reusable workflow (workflow_call) or a composite action in one repo referenced by both — otherwise the next registry gets added twice and the second one gets forgotten.
| # Adding a registry later is: a flag here, a login step below, and one | ||
| # more branch in "Tag and push" and the manifest job's create_manifest | ||
| # calls — the build/scan steps never change. | ||
| - name: Set registry flags |
There was a problem hiding this comment.
Set registry flags is duplicated verbatim inside this file — build here and manifest at L179 — and again in both network-adapter jobs. Four identical copies.
ds reg-flags 43ab4d6afa376ba5ca8455c1fb65fdc2
na reg-flags 43ab4d6afa376ba5ca8455c1fb65fdc2
Steps cannot be shared between jobs in the same workflow, which is what forces the copy. Two ways out, both better than four copies:
- A composite action —
.github/actions/registry-flags/action.yml—uses:'d by both jobs. Fits the pattern already established by.github/actions/dhi-loginin this very PR. - Compute the flags once in a tiny upstream job and expose them via
outputs, withbuildandmanifestboth declaringneeds:.
I'd take (1): it is local, has no extra job latency, and matches what you already did for dhi-login.
manjudr
left a comment
There was a problem hiding this comment.
One more, ported from the review I left on network-adapter#14 — this repo has the identical line, so it belongs here too. Adding it as a separate comment rather than amending the review above.
Also, one correction to that na review while I'm here: I implied this failure mode was readily reachable. It isn't — fetch-depth: 0 is correctly set on the test job in both repos, with a comment explaining exactly why, so the common paths resolve fine. Scoped accurately below.
manjudr
left a comment
There was a problem hiding this comment.
Supplementary to #5130272205 — this pass is about parity with network-adapter#14.
Why a second review: my first one was scoped to the three questions I was asked (job naming, CI-as-a-one-liner, and the Makefile). It was not an end-to-end sweep of the diff. network-adapter#14 did get that sweep, and comparing the two revealed that the near-identical workflow YAML in this repo was never checked against the same findings. I've now walked each one against this repo's actual files.
Result: eight findings, all live here. Seven already have an accepted counterpart on na#14; one is live in both repos and had only ever been raised here, on 2026-09-01, and never fixed.
| # | anchor | finding | na#14 |
|---|---|---|---|
| 1 | build-and-push.yml:9 |
Tag filter is a glob, not a regex — release never triggers | |
| 2 | build-and-push.yml:14 |
workload_identity_provider without id-token: write |
✅ build-and-push.yml:12 |
| 3 | build-and-push.yml:21 |
No fail-fast: false, no concurrency group |
✅ build-and-push.yml:61 |
| 4 | run-tests.yml:5, security.yml:5 |
push filter misses this PR's own base branch |
✅ run-tests.yml:6, security.yml:6 |
| 5 | security.yml:138 |
Unguarded dhi.io login takes the whole check down on fork PRs | ✅ security.yml:61/139/156 |
| 6 | run-tests.yml:75 |
Unguarded read of a file the previous step may not have written | ✅ run-tests.yml:69 |
| 7 | build-and-push.yml:179 |
44 lines byte-identical to the build job |
✅ build-and-push.yml:205 |
| 8 | build-and-push.yml:234 |
:latest gets moved by release candidates |
✅ build-and-push.yml:260 |
#1 and #2 are the ones that make the release path non-functional rather than merely untidy — everything else degrades or duplicates, those two simply don't work.
I've also posted #1 to na#14 separately, since that repo has the byte-identical tag filter and had never been told. After both, the two PRs carry the same finding set.
One piece of housekeeping on the older review here (#5077585002, 2026-09-01): it has gone partly stale and should not be worked through as-is. Item 2 (continue-on-error on comment steps) is now fixed — 6 occurrences in security.yml, 4 in run-tests.yml. Items 4 and 5 describe a make test + make cover sequence and a govulncheck step that no longer exist in this diff (run-tests.yml now runs make build then make test-ci; security.yml has zero govulncheck references). Item 6 is half-live: go.mod and setup-go agree via go-version-file, but the Dockerfile build stage is dhi.io/golang:1.27-alpine-dev against go 1.25.0. And item 1 is #1 below — still unfixed.
|
|
||
| permissions: | ||
| contents: read | ||
| packages: write |
There was a problem hiding this comment.
Blocker — permissions: has no id-token: write, so the Google Artifact Registry path cannot mint a token and will fail whenever it is enabled.
google-github-actions/auth@v3 is used twice, at L107-113 (build) and L209-215 (manifest), both with workload_identity_provider. Workload Identity Federation works by exchanging a GitHub OIDC token for Google credentials, and the workflow can only request that OIDC token if id-token: write is granted. Without it the action fails with Unable to get ACTIONS_ID_TOKEN_REQUEST_URL / Failed to get ID token.
contents: read and packages: write cover checkout and GHCR, so GHCR/Docker Hub/ACR are all fine — this fails only on the GAR leg, which is exactly why it can sit here unnoticed until someone sets GOOGLE_ARTIFACT_REGISTRY_ENABLED=true for the first time and the release breaks.
permissions:
contents: read
packages: write
+ # Workload Identity Federation (google-github-actions/auth at L109/L211)
+ # exchanges a GitHub OIDC token for Google credentials; without this the
+ # workflow cannot request that token and the GAR leg fails to authenticate.
+ id-token: writena#14 carries the same finding at build-and-push.yml:12.
|
|
||
| jobs: | ||
| build: | ||
| strategy: |
There was a problem hiding this comment.
The release workflow has neither fail-fast: false on the matrix nor a concurrency group — the first costs you diagnosis, the second risks a corrupt manifest.
Two separate problems that both live in this block.
fail-fast: false. strategy.matrix defaults to fail-fast: true, so if the amd64 leg fails, GitHub cancels arm64 mid-flight. On a release you then can't tell whether arm64 was fine — you have to re-run the whole thing to find out. The other two workflows don't matter here, but a release is precisely where you want both legs' verdicts.
build:
strategy:
+ # A release wants both legs' verdicts. Default fail-fast cancels arm64
+ # the moment amd64 fails, so a single-arch break hides the other arch's
+ # result and costs a full re-run to recover it.
+ fail-fast: false
matrix:concurrency. run-tests.yml:18-20 and security.yml:18-20 both declare a group with cancel-in-progress: true. This workflow declares none. Two tag pushes in quick succession (a v1.2.0-rc1 followed by v1.2.0, or a re-pushed tag) therefore run fully in parallel, and both manifest jobs call docker buildx imagetools create against overlapping tags — including the shared :latest (see L234). Interleaved manifest writes to the same tag are a race with no defined winner.
Note this one wants the opposite setting from the other two workflows:
+# Unlike run-tests/security, cancel-in-progress is false: a half-cancelled
+# release can leave arch tags pushed with no manifest stitching them, which is
+# worse than two releases queueing.
+concurrency:
+ group: release-${{ github.ref }}
+ cancel-in-progress: falsena#14 carries the same finding at build-and-push.yml:61.
|
|
||
| on: | ||
| push: | ||
| branches: [main] |
There was a problem hiding this comment.
push: branches: [main] — this PR's own base branch is feat/18-network-setup, so the push half of this trigger never fires for the branch this work merges into.
The PR half (pull_request:, unfiltered) works fine, which is why CI looks healthy on this PR. But make cover-diff's push path at L67 (BASE_REF="${{ github.event.before }}") is dead code until something lands on main — and this stack lands on feat/18-network-setup first.
The comment at L55-59 explains the design assuming main is the only landing point: "main only ever moves by PR merge … on a push to main (the merge landing) it's github.event.before." That reasoning is right for the end state and wrong for the current one, where an integration branch sits in between.
Either add the integration branch for as long as it exists:
on:
push:
- branches: [main]
+ branches: [main, feat/18-network-setup]
pull_request:or say explicitly in the comment that the push path is dormant until this stack reaches main, so the next person doesn't debug it as broken. I'd take the first — a dormant gate that nobody has ever seen run is a gate nobody knows the state of.
security.yml:5 has the identical filter; same fix. na#14 carries this at run-tests.yml:6 and security.yml:6.
|
|
||
| on: | ||
| push: | ||
| branches: [main] |
There was a problem hiding this comment.
Same as run-tests.yml:5 — push: branches: [main] doesn't include feat/18-network-setup, this PR's base.
The pull_request: half covers this PR, so the scans do run here. But the push half never fires for the branch this stack actually merges into, which means the post-merge scan of the integration branch never happens — and that is the one that reflects the merged state rather than the PR's simulated merge.
Same fix as run-tests.yml:5; worth doing in one commit across both files so they can't drift apart.
na#14 carries this at security.yml:6.
| body-path: trivy-deps-comment.md | ||
| edit-mode: replace | ||
|
|
||
| - name: Log in to dhi.io |
There was a problem hiding this comment.
Every fork PR's security check fails hard here — before either Trivy gate gets a chance to report.
A pull request from a fork gets an empty string for every secrets.* reference, by design. So DOCKERHUB_USERNAME and DOCKERHUB_TOKEN arrive blank, the dhi.io login fails, and — with no continue-on-error and no if: guard — the job stops here. The image build, the image scan, the SARIF upload and the gate at L217 all never run.
The asymmetry is what makes this stand out: this PR is careful about exactly this failure mode everywhere else in the file. L124-127 puts continue-on-error: true on the comment step specifically because "a fork PR's default GITHUB_TOKEN is read-only, so this 403s there even with pull-requests: write declared." The same reasoning applies with more force to a login step that gates the entire second half of the job — and it is the one step without the guard.
A guard alone isn't enough, though, because the build at L152 pulls dhi.io/* base images and will fail unauthenticated regardless. The honest options are:
- Skip the image half on forks — guard the login, build, scan, upload and comment with
if: github.event.pull_request.head.repo.full_name == github.repository. Fork PRs then get the dependency scan and a green check; the image scan runs on push and on same-repo PRs, which is where releases come from anyway. - Parameterise the base images with
ARGdefaults to public equivalents, so a fork builds an approximately-right image and scans it. This is item 3 of the 2026-09-01 review, still open, and it would fixmake docker/make runlocally too.
Either is fine; what isn't fine is the current state, where an external contributor sees a red Security check with a Docker login error and no scan results at all.
na#14 carries this at security.yml:61, :139 and :156.
| # on this exact string to update rather than duplicate that comment. | ||
| - name: Publish coverage summary | ||
| run: | | ||
| printf '<!-- coverage-report -->\n%s\n' "$(cat coverage-report.md)" > coverage-report.md |
There was a problem hiding this comment.
Unguarded read of a file the previous step may not have written — this turns a coverage-tooling failure into a confusing job failure that hides the real error.
Check coverage above is continue-on-error: true (L62), deliberately, so that a coverage miss still posts its report. But continue-on-error also absorbs a coverage-tooling crash — and in that case coverage-report.md was never created. This step then runs cat coverage-report.md unguarded, fails with No such file or directory, and the job goes red pointing at the summary step rather than at whatever actually broke.
The path is reachable today: cover-diff is unrunnable without coverage.out (see my note on Makefile:97), and its git diff can fail outright (Makefile:98). Both leave no report behind.
- name: Publish coverage summary
run: |
+ if [ ! -f coverage-report.md ]; then
+ echo "📊 **Test Coverage: ⚠️ Unavailable** — the coverage step did not produce a report; see its log" > coverage-report.md
+ fi
printf '<!-- coverage-report -->\n%s\n' "$(cat coverage-report.md)" > coverage-report.md
cat coverage-report.md >> "$GITHUB_STEP_SUMMARY"That keeps the marker contract intact for find-comment below, and says the true thing — the number is unknown — instead of failing on a missing file or, worse, silently reporting a pass.
na#14 carries this at run-tests.yml:69.
| steps: | ||
| - uses: docker/setup-buildx-action@v3 | ||
|
|
||
| - name: Set registry flags |
There was a problem hiding this comment.
44 lines here are byte-identical to the build job above — the whole registry-auth preamble, copy-pasted.
I diffed build L64-124 against manifest L179-222:
build L64-124 = 61 lines
manifest L179-222 = 44 lines
differing = 17 lines, all present only in `build`
=> all 44 of manifest's lines are byte-identical to build's
The 17 build-only lines are the dhi.io login (which manifest genuinely doesn't need, since it never builds) and two comment blocks. Everything else — Set registry flags, the GHCR / Docker Hub / ACR logins, the Google auth, gcloud auth configure-docker, Set image owner — is duplicated verbatim.
That's four registries × two jobs = every future registry change made twice, and the second one is what gets forgotten. It compounds with what I flagged on build-and-push.yml:147: the same blocks are also duplicated into network-adapter, so a new registry is currently a four-place edit across two repos.
A composite action is the fix that works within one workflow — .github/actions/registry-auth/action.yml taking the four *_ENABLED flags and the secrets, uses:'d by both jobs. This PR already establishes that pattern with .github/actions/dhi-login, so it is a small extension of a decision already made here rather than a new one.
If you'd rather go further, a workflow_call reusable workflow shared between the two repos removes the cross-repo copy at the same time — but that's a bigger change and I'd keep it separate from this PR.
na#14 carries this at build-and-push.yml:205.
manjudr
left a comment
There was a problem hiding this comment.
What "consistent" should mean, concretely
discovery-service#15 and network-adapter#14 ship near-identical CI. This is the definition of consistency the two should be held to, so it can be handed to whoever picks up the fixes without them having to guess. It is the same document on both PRs — the counterpart is network-adapter#14.
Read this first, because it changes the size of the job: the naming is already consistent. I checked, expecting to find drift, and there isn't any worth fixing.
| discovery-service | network-adapter | ||
|---|---|---|---|
| workflow files | run-tests.yml, security.yml, build-and-push.yml |
same three | ✅ |
workflow name: |
Run Tests, Security, Build and Push |
identical | ✅ |
| job ids | lint, test / security / build, manifest |
identical | ✅ |
| step names | Trivy cache date, Trivy dependency scan (report), Assemble Trivy dependency PR comment, Gate on HIGH or CRITICAL findings, … |
identical | ✅ |
concurrency groups |
test-${{ github.ref }}, security-${{ github.ref }} |
identical | ✅ |
| Makefile target names | 31 targets | 15 targets, a strict subset — every na name exists in ds | ✅ |
So the ask isn't a rename. The five rules below are what's actually missing, in the order I'd do them.
R1 — Same target name ⇒ same guarantee. (the real gap)
The names match; three of them don't mean the same thing, and in one case the shared docstring is false.
Both Makefiles say, verbatim:
## test: run the unit and integration suitesIn discovery-service that is true. In network-adapter it cannot be:
$ go test -race ./pkg/plugin
plugin.Open("testdata/dummy"): plugin was built with a different version of
package internal/runtime/sys, want nil
FAIL
$ go test ./pkg/plugin
ok
network-adapter's test-ci already knows this — it excludes pkg/plugin and benchmarks/e2e, runs them separately without -race, and explains why in a comment. But test: and cover: (both added by this PR) don't, so the everyday entrypoint fails on a clean checkout while CI is green. Same for cover, which additionally lacks the -coverpkg that discovery-service needs and that discovery-service's own test-ci is also missing.
| target | discovery-service | network-adapter | should be |
|---|---|---|---|
test |
passes | fails (-race + plugins) |
passes in both, or the docstring says which packages it skips |
cover |
has -coverpkg |
no -coverpkg |
whatever each repo's layout needs — but test-ci must match cover |
docker |
root Dockerfile |
Dockerfile.adapter — and CI builds Dockerfile.adapter-with-plugins |
one image definition per repo, referenced from one place |
The rule: if two repos share a target name, an engineer moving between them must not have to re-learn what it does. Where a repo genuinely differs, the difference goes in the ## docstring rather than in the reader's memory.
R2 — CI calls make; the Makefile holds the implementation.
This is the principle both repos state and neither reaches. Counting non-make shell inside run: blocks across the three workflows:
| inline shell lines | make one-liners |
|
|---|---|---|
| discovery-service | 112 | 4 |
| network-adapter | 125 | 4 |
The Trivy path is the model to copy — run: make trivy-deps, run: make trivy-image IMAGE=…, run: make trivy-gate. Apply the same treatment to the blocks that are logic rather than glue, largest first. All of these exist in both repos:
| block | size | proposed target |
|---|---|---|
Assemble Trivy dependency PR comment |
23 lines | make trivy-comment KIND=deps |
Assemble Trivy image PR comment |
23 lines | make trivy-comment KIND=image — it is the same 16-line jq program as the one above, and the pair is byte-identical across both repos: 4 copies of one program |
Tag and push |
22 lines | make release-push (byte-identical across repos) |
Create multi-arch manifests |
20 lines | make release-manifest (byte-identical across repos) |
Build image |
1 line here / 7 lines there | make docker — see R1; today neither repo's CI uses its own docker target |
Check coverage |
5 lines | already make cover-diff; the if picking BASE_REF is the only part that must stay in YAML |
What legitimately stays inline: ${{ }} expression plumbing, $GITHUB_ENV / $GITHUB_OUTPUT writes, and if: conditions. Those are GitHub's, not the build's. Set registry flags and Set image owner are borderline — they're pure $GITHUB_ENV writes, so leaving them is defensible.
The test for whether this is done: can a developer reproduce a CI failure locally by running the one command the log shows? Today, for anything Trivy-comment- or release-related, no.
R3 — Shared code is shared, not copied.
Duplication I measured, all of it live in both repos:
build-and-push.yml: the registry-auth preamble is duplicated within each file —discovery-servicebuildL64-124 vsmanifestL179-222 is 44 lines byte-identical;network-adapterthe same at L205.Tag and push,Create multi-arch manifests,Set registry flags: byte-identical across the two repos.- The 16-line jq program: 4 copies (2 per repo).
So adding a fifth registry is currently an eight-place edit across two repos. Two mechanisms, and they compose:
- Composite action for the in-repo duplication —
.github/actions/registry-auth/action.ymltaking the four*_ENABLEDflags,uses:'d by both jobs. This PR already establishes the pattern with.github/actions/dhi-login, so it extends a decision already made rather than introducing one. workflow_callreusable workflow in a shared repo for the cross-repo duplication. Bigger; I'd keep it out of these PRs and file it.
Anything moved into a make target under R2 stops being duplicated across repos only if the Makefile itself is shared — otherwise you get 2 copies instead of 4. Worth deciding which you're buying.
R4 — Trigger and permission shape, stated once and matched.
Identical in both repos today, and wrong in the same four ways in both. Details and diffs are on the inline comments; the invariants are:
- Tag filters are globs, not regexes.
v[0-9]+.[0-9]+.[0-9]+matchesv1+.2+.0+and notv1.2.0. Live in both repos; the release path has never fired in either. push: branches:must include the branch the work actually lands on. Both list only[main]; both PRs target something else, so every push-triggered path is untested.id-token: writewherevergoogle-github-actions/authappears. Missing in both; the GAR leg cannot mint a token.- The release workflow needs
concurrencytoo — withcancel-in-progress: false, the opposite of the other two, because a half-cancelled release leaves arch tags with no manifest. Andfail-fast: falseon the matrix, so one arch failing doesn't hide the other's verdict.
R5 — Add job name: fields — last, and deliberately.
No job in either repo sets name:, so the checks render as bare ids: lint, test, security, build (linux/amd64). Proposed, identical in both:
| workflow | job id | name: |
renders as |
|---|---|---|---|
run-tests.yml |
lint |
Lint |
CI / Lint |
run-tests.yml |
test |
Test |
CI / Test |
security.yml |
security |
Dependency and image scan |
Security / Dependency and image scan |
build-and-push.yml |
build |
Build (${{ matrix.platform }}) |
Release / Build (linux/amd64) |
build-and-push.yml |
manifest |
Multi-arch manifest |
Release / Multi-arch manifest |
name: is safe — but if you also rename the workflow (Run Tests → CI), every required-check string changes and merges block until the rules are updated. Cosmetics shouldn't be what wedges the queue, so land the functional fixes first.
Suggested order
- R4 — four small, mechanical, blocking fixes. The release path doesn't work in either repo without them.
- R1 — make
testhonest innetwork-adapter; aligncover/test-ci; pick one Dockerfile per repo. - R2 — move the four large blocks behind
maketargets, biggest first. - R3 — composite action for the in-repo copies; file the cross-repo reusable workflow separately.
- R5 — job names, once the above has landed.
R4 and R1 are worth doing as one commit per repo, applied to both in the same sitting — they're the ones that will silently diverge otherwise.
Specific to this PR: R1's test/cover divergence is mostly not your problem — discovery-service's test passes and its cover has the -coverpkg it needs. What is your problem is that test-ci — the target CI actually runs — is the one missing -coverpkg, so the number CI reports is not the number make cover reports. I measured tests/acceptance at 0 profile lines without it and 2088 with it; details on Makefile:86.
test-ci dropped -coverpkg, so the CI coverage gate measured a materially lower number than `make cover` does — a changed file exercised only through tests/acceptance or tests/dbtest read 0% in CI and could false-fail a PR. cover-diff depended on coverage.out with no rule to build it, so it only worked because test-ci happened to run first; on a clean checkout it was unrunnable. trivy-gate exited 0 when a SARIF report was missing, so it passed having scanned nothing rather than failing on the one state nobody can act on. Also: dropped a stale duplicate cover-diff help entry, completed .PHONY, and made clean remove coverage.html.
Tag filters are globs, not regexes — the previous v[0-9]+.[0-9]+.[0-9]+ required a literal + after every digit, so no release tag could ever match and this workflow could never trigger. Fixed to v[0-9]*, which necessarily loosens the exclusion of -beta/-test suffixes (a glob has no other quantifier); the header comment now says so rather than claiming an exclusion the pattern can't enforce. Also: added id-token: write, without which the Google Artifact Registry auth leg fails to mint an OIDC token the first time anyone enables it; fail-fast: false so one arch's failure doesn't cancel the other mid-flight and hide its verdict; a concurrency group (with cancel-in-progress: false, the opposite of the other two workflows, since a half-cancelled release can leave arch tags pushed with no manifest stitching them); and a guard so :latest is never moved onto an -rc build.
] Fork PRs get an empty string for every secrets.* reference, so the unguarded dhi.io login took down the entire image-scan half of security.yml for any external contributor before it could even run — no dependency scan result, just a Docker login error. The image build/scan/upload now skip on forks (computed once into env.SAME_REPO, reused across the five steps that need it); the dependency scan still runs and still gates the check. The final gate checks only trivy-deps.sarif in that case, via the Makefile's new TRIVY_GATE_REPORTS override, since trivy-image.sarif legitimately doesn't exist there. Also: run-tests.yml's coverage summary step did an unguarded `cat coverage-report.md`, which — reachable via the cover-diff bugs fixed in the prior commit — turned a coverage-tooling crash into a confusing "file not found" job failure instead of surfacing the real error. Now writes an "Unavailable" placeholder first.
#9] Three workflows holding 635 lines became two holding 440, and every step in them is now either a one-line `make` call or GitHub plumbing — a `${{ }}` expression, a $GITHUB_STEP_SUMMARY write, a `uses:` action. That inversion is the point: a red check reproduces locally by running the command its log shows. The checks, and nothing else: PR / push to main CI / End to End verification PR / push to main CI / Security Scan version tag CI / Build Artifact (amd64) version tag CI / Build Artifact (arm64) version tag CI / Publish Artifact Nothing runs on both event kinds: a PR never shows a publish check, and a tag never re-runs the tests its PR already gated on. The tag jobs are a separate file, not an `if:`. A job whose `if:` is false still posts a *skipped* check, with `${{ matrix.* }}` unexpanded in its name because the leg never materialised — so gating them inline would have put two permanently skipped checks reading "Build Artifact (${{ matrix.arch }})" on every PR. ci-release.yml is also named `CI`, so the check names still read "CI / Build Artifact (amd64)". The cost is two sidebar entries called CI. security.yml folded into ci.yml. Two scans, two comments and a gate job were three checks answering one question — is this branch shippable. One job now, one comment, one gate. Both scans still matter and both are still there: the dependency scan catches a vulnerable module only the test suite imports, so it is never linked into the binary and never in a layer; the image scan reads the base layers plus the Go build info in the binary, including `stdlib`, so a Go toolchain CVE shows up there and nowhere else. Lint stops being advisory and becomes a step of End to End verification. It was `continue-on-error`, so it gated nothing, and a check that cannot fail is noise in the checks list. Unlike the repo this pattern came from, `make lint` is clean here (0 issues), so it can simply block. upload-sarif is gone. It posts its own check, "Code scanning results / Trivy", which is not a job and so cannot be renamed or suppressed — a third check on every PR, and one that can report the opposite of the gate, going green with "no new alerts" whenever the findings are not new to the diff while `make trivy-gate` fails on the same SARIF. Two checks disagreeing about one scan is worse than one check. `security-events: write` goes with it; the PR comment and the gate are the surface now. Deduplicated, because a copy is a thing that drifts: - tools/trivy-comment.jq — the 16-line jq program that was pasted twice into security.yml. One of those copies is what took the whole Security job down: `capture(...)?.v` parses on jq 1.8 and is a syntax error on the 1.7.1 the runner ships. As a file it is lintable (`jq -n --arg severity "" -f`) and free of Makefile `$$`/backslash escaping. - .github/actions/trivy-cache — the binary+DB cache block, pasted three times. - SARIF_REPORTS names the reports once; the scan, the comment and the gate all read it, so they cannot disagree about what was scanned. It derives from one boolean, SCAN_IMAGE, rather than being overridden as a list, so the fork case narrows the comment and the gate together instead of spelling the default out again in YAML where it would rot. - The registry fan-out. The four-registry switchboard was ~90 lines of inline shell duplicated across two jobs; it is now IMAGE_REPOS in the Makefile, composed from the *_ENABLED flags. Capability is unchanged — GHCR on by default, the rest off until their secrets exist — but adding a registry is one line there and one login step in the workflow. Kept deliberately, against the reference this follows: the image is built locally and gated by Trivy at exit 1 BEFORE any push, so the bytes that ship are the bytes that were scanned. That is why image-build/image-push are two targets rather than a push-by-digest one-shot. cover-diff now writes coverage-report.md on every exit path, including its error paths, and emits the marker itself. The workflow can `cat` it with no existence guard and no fallback text of its own — the marker had been spliced in by a later step, which is a second place for a string find-comment matches on to live. An unresolvable BASE_REF stays a hard error: an unknown changed-file set is not an empty one. trivy-gate stops passing vacuously. A missing OR unparsable report is now a failure — a scan that silently wrote nothing must never make a security gate green, which is the one state nobody can act on. A pre-commit hook, because nothing validated the workflow files: a bad `${{ }}` or a `needs:` pointing at no job was discoverable only at runtime. `make hooks` sets core.hooksPath, so the hook is versioned and reaches everyone on their next pull. .githooks/pre-commit is one line — `exec make lint-staged` — so the logic stays in the Makefile. Scope is what can pass today, so it does not become something people reflexively --no-verify past: actionlint on staged workflows, gofmt on staged Go files. Not `golangci-lint run` and not the test suite; a hook has to stay in seconds. Branch protection must be re-pointed in one pass or PRs will block on contexts that no longer report: Run Tests / lint -> (removed; lint is a step of End to End verification) Run Tests / test -> CI / End to End verification security / security -> CI / Security Scan CI / Build Artifact (*) and CI / Publish Artifact never run on a PR and must NOT be marked required.
cover-diff and trivy-report used to run only inside a GitHub runner, whose filesystem is thrown away, so nothing had to ignore what they wrote. Now that both are make targets a reviewer is expected to run before pushing, their output lands in a real working tree: coverage-report.md, trivy-report.md and the SARIF the report is rendered from. Ignored rather than committed because a scan result is only true of the day it ran. A checked-in trivy-report.md would keep asserting a clean scan against a vulnerability database that has moved on, and the next person to read it has no way to tell how stale it is.
🛡️ Trivy security scan (CRITICAL,HIGH,MEDIUM,LOW)Go dependenciesNo findings at CRITICAL,HIGH,MEDIUM,LOW. Container imageNo findings at CRITICAL,HIGH,MEDIUM,LOW. |
Azure, Google Artifact Registry and Docker Hub were carried as `*_ENABLED` flags reading repo variables. The repo has no variables set — checked — so all three resolved to false and every `secrets.*` behind them to an empty string. The verified release (tag v0.0.1-rc2) pushed to GHCR and nothing else, which is what the switchboard was always going to do. Removed, rather than left off, because an unconfigured registry is not flexibility. It is a fourth way for a release to look green while publishing nothing: turn a flag on without its namespace secret and the path collapses to a bare registry and a slash. Fewer states that can be wrong beats more states nobody has exercised. Adding one back is one entry in IMAGE_REPOS and one login step. GHCR is now unconditional rather than a flag defaulting true, so IMAGE_REPOS cannot be empty and require-image-repos has one thing left to check: OWNER, which CI always sets and a laptop never does. That is now what it says. id-token: write goes with them — it existed only so google-github-actions/auth could exchange an OIDC token for Google credentials. Nothing left in either job requests one. Not touched: the dhi.io login. It reads DOCKERHUB_USERNAME/DOCKERHUB_TOKEN, but those are the Docker Hardened Images subscription the Dockerfile's base images need to pull at all, not a Docker Hub publish target. Deleting it would break every image build. ci-release.yml: 214 lines to 133.
manjudr
left a comment
There was a problem hiding this comment.
Read the whole diff. The shape is right — thin workflows, make calls only, logic in the Makefile, one check per concern — and two things here are better than what I landed in network-adapter: image-build runs trivy-release-gate before any push, and require-image-repos validates the repo list up front instead of failing mid-loop.
Four things I'd fix before merge, all in the Makefile, and four suggestions. The two loop findings are the serious ones — I reproduced both with stub binaries rather than reasoning about them, and both exit 0 on a failed push/create.
The PR body still describes run-tests.yml / security.yml / build-and-push.yml; the diff ships ci.yml / ci-release.yml. Also worth a sanity check that targeting feat/18-network-setup rather than development is intentional.
| # gated. One arch-suffixed tag each; nothing binds the plain version tag until | ||
| # image-publish has every arch. | ||
| image-push: require-image-repos | ||
| @for repo in $(IMAGE_REPOS); do \ |
There was a problem hiding this comment.
; between the loop steps means a failed docker push cannot fail this target.
A recipe is one shell invocation with no set -e, so the exit status is the last command of the last iteration alone. Everything before it — including every push to every earlier registry — is discarded.
I reproduced it with the exact loop from this file, two registries, push denied on the first:
==> ghcr.io/org/svc:1.0.0-amd64
PUSH DENIED: ghcr.io/org/svc:1.0.0-amd64
==> docker.io/org/svc:1.0.0-amd64
pushed docker.io/org/svc:1.0.0-amd64
>>> recipe exit=0
Green Build Artifact, image in one of two registries. With a single registry it happens to propagate, so this only bites once someone flips a second *_ENABLED — i.e. it fails silently exactly when the multi-registry feature this loop exists for gets used.
&& between the steps and || exit 1 on the loop:
image-push: require-image-repos
@for repo in $(IMAGE_REPOS); do \
dest="$$repo:$(VERSION)-$(ARCH)"; \
echo "==> $$dest" && \
docker tag $(RELEASE_IMAGE) "$$dest" && \
docker push "$$dest" || exit 1; \
done| # (v0.0.1-rc1-3-gabc1234) — neither is what someone who asked for no tag at all | ||
| # should get, so the single `*-*` case covers both. | ||
| image-publish: require-image-repos | ||
| @for repo in $(IMAGE_REPOS); do \ |
There was a problem hiding this comment.
Same masking here, and worse: imagetools inspect is the last command, so it overwrites a failed create.
Probe with this loop, every create failing:
CREATE FAILED
inspect ok (previous index)
CREATE FAILED
inspect ok (previous index)
>>> recipe exit=0
Publish Artifact goes green having created no tag anywhere. And because inspect succeeds on a tag that already exists, a re-tag whose create fails reports the previous release's index back as if it were the new one.
This is the same bug I just fixed in network-adapter's image-publish (138ffbe) — && between every step there, single registry. Here the loop needs || exit 1 as well.
| # aquasecurity itself recommends over building from source for exactly this. | ||
| $(TRIVY): | ||
| @mkdir -p $(BIN_DIR) | ||
| curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | \ |
There was a problem hiding this comment.
Unpinned installer, while TRIVY_VERSION right above it is pinned.
trivy/main/contrib/install.sh is fetched from whatever main happens to be and piped into sh, in a job that holds the runner's GITHUB_TOKEN. The version argument is pinned but the script that consumes it is not, so a breaking change on main breaks CI with no commit here, and the supply-chain surface is a moving target.
The tag serves the same file:
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/$(TRIVY_VERSION)/contrib/install.sh | \
sh -s -- -b $(abspath $(BIN_DIR)) $(TRIVY_VERSION)Fixed the same way in network-adapter; verified the pinned URL resolves and installs 0.74.0.
| # source of truth for both a local `make` run and the GitHub Actions runner. | ||
| MIN_COVERAGE ?= 80 | ||
| BASE_REF ?= origin/main | ||
| SEVERITY ?= HIGH,CRITICAL |
There was a problem hiding this comment.
Band divergence: this is HIGH,CRITICAL; the band we settled on for the adapter is CRITICAL,HIGH,MEDIUM,LOW.
network-adapter's live scan comment on PR #14 reads:
## 🛡️ Trivy security scan (CRITICAL,HIGH,MEDIUM,LOW)
### Go dependencies
No findings at CRITICAL,HIGH,MEDIUM,LOW.
### Container image
No findings at CRITICAL,HIGH,MEDIUM,LOW.
SEVERITY feeds the PR gate, the comment header and trivy-release-gate, so at HIGH,CRITICAL a MEDIUM in a released image is invisible in all three. Two repos in the same network reporting different bands also makes the two comments non-comparable.
UNKNOWN deliberately stays out — it means "no vendor CVSS yet", not "severe", and it re-triages on its own once the vendor scores it.
| # SARIF_REPORTS. One name, one meaning: the comment and the gate cannot | ||
| # end up disagreeing about whether an image report was ever expected. | ||
| - name: Decide whether the image can be scanned | ||
| run: echo "SCAN_IMAGE=${{ github.event.pull_request.head.repo.full_name == github.repository || github.event_name != 'pull_request' }}" >> "$GITHUB_ENV" |
There was a problem hiding this comment.
On a fork PR the whole "Container image" section disappears from the comment, with nothing saying it was skipped.
SCAN_IMAGE=false here drops trivy-image.sarif from SARIF_REPORTS, and trivy-report renders one section per listed report. The ⚠️ No report branch only fires for a report that's listed but empty — a report that was never listed produces no heading at all. So a fork PR gets a comment with only ### Go dependencies, and a reviewer can't distinguish "image is clean" from "image was never scanned".
The rest of this design is right — the gate has to narrow with the list, or every fork PR is red for a reason the contributor can't fix. It's just the reporting that goes quiet. One line after the loop closes it:
[ "$(SCAN_IMAGE)" = true ] || \
echo "> ℹ️ Container image not scanned: the dhi.io base images need registry credentials, which a fork PR does not get."; \Same principle the file already applies to a missing SARIF, extended to a skipped one.
| # the moment amd64 fails, so a single-arch break hides the other arch's | ||
| # result and costs a full re-run to recover it. Publish Artifact needs | ||
| # both to have succeeded regardless, so nothing ships either way. | ||
| fail-fast: false |
There was a problem hiding this comment.
fail-fast: false plus an eager arch-tag push leaves dangling tags in every enabled registry when one leg fails.
image-push publishes :$(VERSION)-amd64 immediately, so if the arm64 leg fails, :v1.0.0-amd64 is real and pullable in every registry while :v1.0.0 never gets created — and nothing cleans it up. Someone re-running the release then hits create against a half-populated tag set.
The stated reason ("a release wants both legs' verdicts") holds for a test matrix, where the second verdict is information. For a push matrix the second leg's verdict is only useful if the first succeeded; otherwise you've paid a build to publish an artifact you have to go delete by hand. network-adapter uses fail-fast: true for exactly this, and pushes by digest so nothing is tag-visible until image-publish binds it.
If both verdicts are genuinely wanted, push-by-digest=true + --metadata-file gets them without the visible intermediate tags.
| with: | ||
| fetch-depth: 0 | ||
|
|
||
| - name: Log in to GHCR |
There was a problem hiding this comment.
~60 lines of login steps duplicated verbatim between the two jobs, for three registries that are off by default.
dhi-login is already a composite action, so the pattern is established — a .github/actions/registry-logins taking the enable flags would collapse both blocks to one step each and remove the drift risk between them (they have to stay identical or publish-artifact can't read what build-artifact wrote).
Worth asking separately whether Docker Hub, ACR and GAR need to be here at all. All three are vars.*_ENABLED-gated and off, and they cost ~90 lines plus 8 secret references for paths nobody has enabled. YAGNI would say land GHCR and add a registry when there's a consumer — the flags make it look configurable, but nothing exercises those branches, so the first person to flip one is also the first person to test it.
main moves solely by merging a PR, and GitHub verifies that PR at its merge
commit — the exact tree the merge produces. Re-running build, lint, test and
both scans on the push that lands it re-asks a question answered minutes
earlier, on the same content, and posts a fifth check nobody reads.
Dropping the push trigger makes three expressions unreachable, so they go too
rather than sit there implying an event that can no longer arrive:
- BASE_REF's `github.event.before` arm, now just the PR's base ref
- SCAN_IMAGE's `github.event_name != 'pull_request'` arm
- `if: github.event_name == 'pull_request'` on four comment steps
- cancel-in-progress' conditional, which existed only to keep main's runs
from cancelling each other
What this gives up: a merge queue or a direct push to main would land
unverified. Neither is in use — main has no branch protection today — and when
protection is added, requiring the two PR checks is what enforces it.
Two comments explained why the jq program and the trivy cache were extracted by naming the files they were extracted from — security.yml and build-and-push.yml, neither of which exists any more. The reasoning is still worth keeping; the filenames now send a reader grepping for something that is not there. Reworded to describe the shape of the problem instead of the path.
Review — verdict: merge, but land the dependency bump first or immediately afterSelf-review, so weight it accordingly. Everything below was run, not reasoned about. What actually got exercised
The release path is not theoretical — it pushed real images to GHCR twice, and Also confirmed by direct test, not inspection:
One real defect found — not blocking, but worth a follow-up
So a push of Two tags on one commit is not exotic; promoting an rc to a release on the same Did not fix it in this PR; flagging for a decision. Why the verdict is qualified
It is not caused by this PR — it is a pre-existing vulnerability that this PR is Second known issue, also pre-existing and also not fixed here: the coverage On merge
Housekeeping: |
…ed [#9] VERSION came from `git describe --tags`. On a commit carrying more than one tag, describe returns whichever was *created* last, which is not necessarily the one whose push started the run: git tag -a v1.0.0 -m release; sleep 1; git tag -a v1.0.1-rc1 -m rc git describe --tags -> v1.0.1-rc1 So pushing v1.0.0 would publish ghcr.io/...:v1.0.1-rc1, and because that name contains a hyphen the :latest rule would read it as a pre-release and leave :latest pointing at the previous version. A green release, shipped under the wrong name, with the tag users actually pull never updated. Two tags on one commit is not exotic — promoting an rc to a release does exactly that. ci-release.yml now sets VERSION from github.ref_name, the triggering ref itself, and the Makefile's `?=` lets it win. Nothing is re-derived. The old comment argued *for* describe on the grounds that one answer on a runner and on a workstation is simpler. That was the error: the two contexts do not hold the same information. CI knows the triggering ref exactly; a workstation has no triggering ref at all, so describe stays the local fallback and still renders an untagged commit as v0.0.1-rc3-1-gabc1234 rather than a branch name that would then be pushed as an image tag. Encoded rather than remembered, per the standing rule: require-image-repos now rejects a VERSION that is empty or contains a slash or a space. That is what a non-tag ref reaching a release target looks like — a pull_request would give "15/merge" — so the guard fails loudly instead of pushing a malformed ref.
Both repos post a coverage comment and a Trivy comment, but they disagreed on two things a reader would notice putting the two PRs side by side. Aligning on network-adapter#20, since it is the one already merged. Severity: HIGH,CRITICAL -> CRITICAL,HIGH,MEDIUM,LOW. Measured before changing it — the dependency scan reports the same single finding either way, so this costs nothing today and means a MEDIUM that appears next month is seen here as soon as it is seen there. The image scan cannot be measured locally (dhi.io needs a subscription login), so CI is what will report its wider count. Coverage states: "✅ Passed — not applicable, no changed Go files" was a contradiction. Passed says something was measured and cleared the bar; nothing was measured. There is now a distinct "➖ Not applicable" for both paths that have nothing to measure — no non-test Go files in the diff, and changed files carrying no coverable statements — matching the sibling repo's wording. Still exit 0, so the gate is unchanged; only the claim is honest now.
Review — not good to merge yetOne blocking defect, one decision to make, then this is ready. The structure of the change is right: four checks, logic in the Makefile, 🔴 Blocker — the coverage gate fails every PR that touches Go code
Measured on this tree — The same five files, deduplicated on block id: Cross-checked against the authoritative tools, which agree with each other and with the dedup: Block census on the profile: 11% reported vs 88% real. With This PR's own check reads Worth noting the repo already documents this exact trap, in
The new awk is the one place in the repo that doesn't follow it. Fix is to key on the block rather than the line: { f = $1; sub(/:.*/, "", f); if (!(f in want)) next;
st[$1] = $(NF-1); owner[$1] = f; if ($NF > 0) hit[$1] = 1 }
END {
for (b in st) { tot[owner[b]] += st[b]; if (b in hit) cov[owner[b]] += st[b] }
...
}Please add a fixture-based test for it — a checked-in profile with a known duplicated block and an expected percentage. A gate whose arithmetic nothing exercises is how this got through. 🟡 Decide before merging — Security Scan is red
🔵 Notes, not blocking
Dependency bumps ride along.
Two orphaned bot comments above this one, from the pre-consolidation two-comment scheme. ✅ Verified working
Verdict: hold. Fix the |
cover-diff summed the profile per LINE. Under -coverpkg every source block appears once per test binary that was built — measured here, 2294 blocks with 21 copies and 156 with 22 — so the denominator was multiplied by the number of binaries while the numerator was credited only for the binaries that reached the block. The result was not a rounding error. src/publish/controller.go read 7% against a real 81%, src/discover/controller.go 8% against 89%, and an aggregate of 11% where `go tool cover -func` says 88.5%. With MIN_COVERAGE=80 that is a gate that fails EVERY pull request touching a .go file regardless of how well tested it is — which is a gate people learn to bypass, and worse than no gate. It was latent on the branch that introduced it, because that branch changes no Go files and cover-diff exits early on an empty changed set. It would have fired on the first functional PR after the merge. tools/cover-report.awk already documents this exact trap and already handles it: key on the block, keep its statement count once, count it covered if ANY binary hit it. cover-diff is now the same, and its TOTAL agrees with `go tool cover -func` — which is the invariant the new test pins. Moved out of the Makefile recipe into tools/cover-diff.awk for the reason cover-report.awk lives in a file: make eats `$`, so every `$1` has to be written `$$1` and a mis-escaped field reads as the empty string rather than failing. Being a file is also the only way it can be tested, which is the second half of the fix — this is gate logic, and nothing exercised its arithmetic. Writing the test surfaced a second defect: a changed file whose blocks carry no statements divided by zero and aborted awk with exit 2, before the T == 0 check could report EMPTY. An unmeasurable file crashed the gate instead of skipping it. Guarded.
v0.2.0 -> v0.3.0, which is the fixed version Trivy names. Reached only through testcontainers, so it is never linked into the shipped binary and the image scan was already clean — but the dependency scan is there precisely to catch what the image scan structurally cannot, and leaving it red trains people to read a red Security Scan as normal. Pulls klauspost/compress 1.18.6 -> 1.18.7 and moby/sys/user 0.4.0 -> 0.4.1 as part of the same module graph resolution. `make trivy-deps` now reports 0 findings at CRITICAL,HIGH,MEDIUM,LOW.
A `${{ }}` expression is substituted into the run block's TEXT before bash
parses it, so the token was part of the program rather than an input to it — a
value carrying a quote or a backtick would be parsed as shell. Through env: it
is only ever data.
No behaviour change for any well-formed token; this is about the one that
isn't.
The test shelled out to `awk -f tools/cover-diff.awk`. awk is a subprocess, so
a file only it opens is invisible to the go test cache: edit the awk, rerun,
and `go test` replays a stale "ok (cached)" because no Go input changed.
Demonstrated — replacing the whole program with `END{print "TOTAL\t0"}` and
rerunning without -count=1 still reported ok (cached). A regression test that
goes quiet exactly when the thing it guards is being edited is worse than none,
because it reads as a pass.
Now the test reads the program with os.ReadFile and runs the copy. The read is
an input the cache records, so the same sabotage fails immediately with no
-count=1 needed. Verified both directions.
Re-review — ✅ good to mergeEvery item from the previous review is addressed. Both checks are green on
The coverage fixExtracted to 11% → 88%, agreeing with Writing the test surfaced a second defect that the review missed: a changed file whose blocks carry no statements divided by zero and aborted awk with exit 2 — before the Red-green, both directions: One more thing worth flagging, since it nearly made the test useless: awk is a subprocess, so the Go test cache could not see the script change. Sabotaging the entire program to The CVE
Deliberately not changed
Dependency bumps in One disclosure: the VerificationCI on The tag path was proven earlier against a decoy tag: One caveat on scope, stated plainlyThis PR changes no non-test Go files, so its own coverage check still reads Verdict: good to merge. Suggested follow-up, not blocking: delete the throwaway tags |
Collapses CI to four checks, and moves every line of build/test/scan logic
out of YAML and into the Makefile. Follows
OpenAgriNet/network-adapter#14sothe two repos converge rather than drift.
The four checks
CI / End to End verificationmake build,make lint,make test-ci,make cover-diffCI / Security Scanmake trivy-deps,make trivy-image,make trivy-gateCI / Build Artifact (amd64),CI / Build Artifact (arm64)make image-build,make image-pushCI / Publish Artifactmake image-publishNothing else posts a check. A PR shows exactly two; a tag shows exactly three.
ci.ymltriggers onpull_requestonly — main moves solely by merging a PR,and that PR was already verified at its merge commit, so re-running the same
checks on the push that lands it is a fifth check answering a question asked
minutes earlier.
Files
The inversion
Every step in both workflows is either a one-line
makecall or GitHubplumbing — a
${{ }}expression, a$GITHUB_STEP_SUMMARYwrite, auses:.There is no shell in the YAML that does work.
The point is not brevity. It is that a red check reproduces locally by running
the command the log shows. Previously ~100 lines of scan and coverage shell
existed only inside a runner, so the only way to iterate on it was to push.
Why two files and not one
ci-release.ymlis a separate file, deliberately namedCIas well.A job whose
if:evaluates false still posts a skipped check, and itsname:is reported with
${{ matrix.* }}unexpanded, because the matrix leg nevermaterialised. Gating the release jobs inline would therefore put two permanent
skipped checks reading
Build Artifact (${{ matrix.arch }})on every PR. Aworkflow whose trigger does not match simply does not exist on that event.
Sharing the workflow
name:keeps the checks readingCI / Build Artifact (amd64). The cost is that the Actions sidebar lists two entries calledCI.Notable decisions
One security job, not three. Dependency scan and image scan were two jobs
plus a gate job — three red-or-green checks answering one question. Now one
check. The dependency scan needs only a checkout, so it runs first and its
findings are in the log before the image finishes building. Both scans are
kept: a test-only module never linked into the binary appears in
trivy fsandin no layer, and a Go toolchain CVE appears as
stdlibin the image scan andnowhere
trivy fscan see it.upload-sarifremoved. It posts its own check,Code scanning results / Trivy, which is not a job and so cannot be renamed or suppressed — and it canreport the opposite of the gate, going green with "no new alerts" on findings
that are not new to the diff while
make trivy-gatefails on the same SARIF.Two checks disagreeing about one scan is worse than one check. The cost is no
Security-tab history; the PR comment and the gate are the surface instead.
Lint is now a real gate. It was
continue-on-error. The tree is at 0issues, so a check that cannot fail was only noise.
Scan before push, still.
make image-buildbuilds into the runner's localdaemon and gates on Trivy before anything is tagged for a registry, so the exact
bytes that ship are what passed.
image-pushre-tags that same image — norebuild, no re-scan.
Native arm64, not QEMU. One leg per arch on a runner of that arch. Both
labels are free for public repos.
fail-fast: false, so a single-arch breakdoes not hide the other arch's verdict.
:latestmoves only for a plain release. Any tag containing a hyphen(
v1.2.3-rc1) publishes its own tag and leaves:latestalone.Fork PRs degrade, they do not fail opaquely. A fork gets an empty string for
every
secrets.*, and the Dockerfile's bases aredhi.io/*, which 401 on ananonymous pull. One
SCAN_IMAGEflag is exported to the environment and doesdouble duty: it gates the image steps and the Makefile reads it to narrow the
gate's expected report list. One name, one meaning — the comment and the gate
cannot disagree about whether an image report was ever expected. Comment steps
are
continue-on-errorbecause a fork's read-only token 403s on them.GHCR only. An earlier revision of this branch carried
*_ENABLEDflags forAzure, Google Artifact Registry and Docker Hub. The repo has no variables set,
so all three were permanently false and every secret behind them an empty
string — and an unconfigured registry is not flexibility, it is a fourth way
for a release to look green while publishing nothing. Removed.
ci-release.ymlwent from 214 lines to 133. Adding one back is one entry in
IMAGE_REPOSandone login step.
The dhi.io login stays and is a different thing:
DOCKERHUB_USERNAME/DOCKERHUB_TOKENare the Docker Hardened Images subscription the Dockerfile'sbase images need in order to pull at all, not a Docker Hub publish target.
Verified
Local:
make build,make lint,make lint-actionsandmake test-ci(759tests) all exit 0.
PR checks, run 34152732151:
End to End verification— pass, 3m58s.Security Scan— every step green including the dhi.io login, image build andimage scan; fails only at the gate, on one pre-existing finding.
trivy-deps.sarif: 1 HIGH,CRITICAL,trivy-image.sarif: 0.Release path, exercised for real against tag
v0.0.1-rc2,run 34153252718
— all three checks green:
ci.ymlcorrectly did not — a tag push producesone run, not two.
Build Artifact (amd64)andBuild Artifact (arm64)both built natively,passed the pre-push Trivy gate, and pushed their arch tags.
Publish Artifactstitched them into a manifest list and inspected it::latestcorrectly not moved — the log saysv0.0.1-rc2 is not a plain release — not moving :latest.Re-run after the GHCR-only cleanup against tag
v0.0.1-rc3,run 34153701354
— all three green again, and the tag push produced one run, not two.
v0.0.1-rc2andv0.0.1-rc3are throwaway test tags. They and their GHCRpackages can be deleted;
:latestwas never moved by either.Known issues, both pre-existing and out of scope for this PR
Security Scanis red onCVE-2026-17106—github.com/moby/go-archivev0.2.0, fixed in 0.3.0, reached via testcontainers. Test-only; the shipped
image scans clean. Needs a dependency bump, not a workflow change.
The coverage percentage is understated, badly.
-coverpkgwrites eachblock once per test binary — up to 22 copies here — and the report sums the
denominator across all copies while crediting the numerator only where
count>0. Measured on
src/discover/intent_mapper.go: 8% reported vs93% true. Predates this PR (reproduced against the previous Makefile on
the same profile). The fix is to key blocks and take the max rather than
summing duplicates.
Neither is introduced here and neither is fixed here.
For the reviewer
maincurrently has no branch protection. When it is added, requireCI / End to End verificationandCI / Security Scan. Do not require thetwo release checks — they never run on a PR, so requiring them blocks every
merge.