From 0a0a6c17cbc7719f8c9a972664615438be1f6d4c Mon Sep 17 00:00:00 2001 From: nisargabd Date: Mon, 31 Aug 2026 12:24:55 +0530 Subject: [PATCH 01/29] chore: split lint/test/coverage from image build/vuln scan, add release build [#9] Test workflow (lint, build, test, coverage report) and security workflow (govulncheck + Trivy image scan) were one ci.yml job set; split so each can be read, triggered and gated independently. Security workflow needs a dhi.io login step now: the Dockerfile's base images moved to Docker Hardened Images, so the CI image build 401s without it, same as a local build would. Both scan jobs also post their report as a PR comment before gating, so findings are visible without opening the Actions log. Added release.yml: builds and pushes to GHCR on a v*.*.* tag push, gated the same DHI login. --- .github/workflows/release.yml | 41 ++++++++++++ .github/workflows/security.yml | 93 ++++++++++++++++++++++++++ .github/workflows/{ci.yml => test.yml} | 54 +++++++-------- 3 files changed, 160 insertions(+), 28 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/security.yml rename .github/workflows/{ci.yml => test.yml} (51%) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..ee44f42 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,41 @@ +name: Release image + +# Annotated tags only, per OAN convention: v{SemVer} (e.g. v0.0.1). +on: + push: + tags: ["v[0-9]+.[0-9]+.[0-9]+*"] + +permissions: + contents: read + packages: write + +jobs: + build-and-push: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # Base images are dhi.io/*, a Docker Hardened Images registry gated on a + # Docker subscription — anonymous pulls 401. + - name: Log in to dhi.io + uses: docker/login-action@v3 + with: + registry: dhi.io + username: ${{ secrets.DHI_USERNAME }} + password: ${{ secrets.DHI_TOKEN }} + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: | + ghcr.io/openagrinet/discovery-service:${{ github.ref_name }} + ghcr.io/openagrinet/discovery-service:latest diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000..c63aeee --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,93 @@ +name: Security + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + pull-requests: write + +concurrency: + group: security-${{ github.ref }} + cancel-in-progress: true + +jobs: + security: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + # A glob, not a list: the service module has no dependencies yet, so + # a literal go.sum path would fail to resolve and disable the cache. + cache-dependency-path: "**/go.sum" + + # T4, half one: known vulnerabilities in the Go dependency graph. + - name: govulncheck + run: make security 2>&1 | tee govulncheck-report.txt + + # Base images are dhi.io/*, a Docker Hardened Images registry gated on a + # Docker subscription — anonymous pulls 401. Same login a local build + # needs (see the Dockerfile's FROM comment), just non-interactive here. + - name: Log in to dhi.io + uses: docker/login-action@v3 + with: + registry: dhi.io + username: ${{ secrets.DHI_USERNAME }} + password: ${{ secrets.DHI_TOKEN }} + + # T4, half two: the shipped image. The scan is of the image and not of + # go.sum so that the toolchain pinned in tools/go.mod — which is never + # linked into the binary — cannot fail a release it does not ship in. + - name: Build image + run: docker build -t discovery-service:${{ github.sha }} . + + - name: Trivy image scan (report) + uses: aquasecurity/trivy-action@0.28.0 + with: + image-ref: discovery-service:${{ github.sha }} + severity: HIGH,CRITICAL + exit-code: "0" + format: table + output: trivy-report.txt + + - name: Assemble PR report + if: github.event_name == 'pull_request' + run: | + { + echo "### Security scan report" + echo + echo "
govulncheck" + echo + echo '```' + cat govulncheck-report.txt + echo '```' + echo "
" + echo + echo "
Trivy image scan (HIGH,CRITICAL)" + echo + echo '```' + cat trivy-report.txt + echo '```' + echo "
" + } > pr-report.md + + - name: Comment scan reports on PR + if: github.event_name == 'pull_request' + uses: peter-evans/create-or-update-comment@v4 + with: + issue-number: ${{ github.event.pull_request.number }} + comment-tag: security-scan + body-path: pr-report.md + + # Gate the PR on the same findings the comment already showed. + - name: Trivy image scan (gate) + uses: aquasecurity/trivy-action@0.28.0 + with: + image-ref: discovery-service:${{ github.sha }} + severity: HIGH,CRITICAL + exit-code: "1" + format: table diff --git a/.github/workflows/ci.yml b/.github/workflows/test.yml similarity index 51% rename from .github/workflows/ci.yml rename to .github/workflows/test.yml index a05b54c..b677078 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/test.yml @@ -1,4 +1,4 @@ -name: CI +name: Test on: push: @@ -7,9 +7,10 @@ on: permissions: contents: read + pull-requests: write concurrency: - group: ci-${{ github.ref }} + group: test-${{ github.ref }} cancel-in-progress: true env: @@ -44,31 +45,28 @@ jobs: - run: make build - run: make test - security: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - # A glob, not a list: the service module has no dependencies yet, so - # a literal go.sum path would fail to resolve and disable the cache. - cache-dependency-path: "**/go.sum" - - # T4, half one: known vulnerabilities in the Go dependency graph. - - name: govulncheck - run: make security - - # T4, half two: the shipped image. The scan is of the image and not of - # go.sum so that the toolchain pinned in tools/go.mod — which is never - # linked into the binary — cannot fail a release it does not ship in. - - name: Build image - run: docker build -t discovery-service:${{ github.sha }} . + # Report only: CLAUDE.md calls for coverage trending up against "an + # agreed threshold", but no number is defined anywhere in this repo yet. + # Gate on one once someone sets it. + - name: Coverage + run: | + make cover + go tool cover -func=coverage.out | tee coverage-func.txt + echo "### Coverage" >> "$GITHUB_STEP_SUMMARY" + echo '```' >> "$GITHUB_STEP_SUMMARY" + tail -1 coverage-func.txt >> "$GITHUB_STEP_SUMMARY" + echo '```' >> "$GITHUB_STEP_SUMMARY" + { + echo "### Test coverage" + echo '```' + tail -1 coverage-func.txt + echo '```' + } > coverage-report.md - - name: Trivy image scan - uses: aquasecurity/trivy-action@0.28.0 + - name: Comment coverage on PR + if: github.event_name == 'pull_request' + uses: peter-evans/create-or-update-comment@v4 with: - image-ref: discovery-service:${{ github.sha }} - severity: HIGH,CRITICAL - exit-code: "1" - format: table + issue-number: ${{ github.event.pull_request.number }} + comment-tag: coverage + body-path: coverage-report.md From c941b24257e6e1e83fa47747bdd77081f0c3a166 Mon Sep 17 00:00:00 2001 From: nisargabd Date: Tue, 1 Sep 2026 09:53:51 +0530 Subject: [PATCH 02/29] fix: get security.yml's DHI login, Trivy pin, and PR comments right [#9] Dockerfile's build and runtime stages moved to dhi.io hardened images, so the image build and scan both need a dhi.io login first, or they 401 on the pull. MY_DHI_KEY is a combined base64 user:token auth blob (the same shape docker login writes to config.json), not a plain password, so docker/login-action's username/password inputs can't take it -- config.json is written directly instead. trivy-action was pinned to v0.28.0, whose release tag doesn't resolve (v-prefixed) and which pins aquasecurity/setup-trivy@v0.2.1 internally, a tag that no longer exists. v0.36.0 pins setup-trivy by commit SHA instead. govulncheck and Trivy now each get their own PR comment -- tagged with a hidden marker, shield header, run link and collapsible findings, matching beckn/beckn-discovr#456's report style -- instead of one combined report under a single tag. create-or-update-comment@v4 has no comment-tag input; passing one was silently ignored, so every run posted a fresh comment instead of updating one (PR #12 had three stacked coverage comments as proof). Each report is now looked up first via peter-evans/find-comment on its own marker and updated in place, falling through to a new comment only when none is found yet. --- .github/workflows/release.yml | 15 ++++-- .github/workflows/security.yml | 88 +++++++++++++++++++++++++--------- .github/workflows/test.yml | 13 ++++- Dockerfile | 31 +++++++----- 4 files changed, 107 insertions(+), 40 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ee44f42..70ce736 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,12 +17,17 @@ jobs: # Base images are dhi.io/*, a Docker Hardened Images registry gated on a # Docker subscription — anonymous pulls 401. + # + # MY_DHI_KEY is a combined base64 "user:token" auth blob (the same + # thing `docker login` writes to config.json), not a plain password — + # docker/login-action's username/password inputs don't accept that + # shape, so config.json is written directly instead. - name: Log in to dhi.io - uses: docker/login-action@v3 - with: - registry: dhi.io - username: ${{ secrets.DHI_USERNAME }} - password: ${{ secrets.DHI_TOKEN }} + run: | + mkdir -p ~/.docker + cat > ~/.docker/config.json <&1 | tee govulncheck-report.txt + - name: Assemble govulncheck PR comment + if: github.event_name == 'pull_request' + run: | + { + echo "" + echo "### 🛡️ govulncheck — Go dependency vulnerabilities" + echo "[View full run](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID})" + echo + echo "
Findings" + echo + echo '```' + cat govulncheck-report.txt + echo '```' + echo "
" + } > govulncheck-comment.md + + - name: Find existing govulncheck comment + if: github.event_name == 'pull_request' + uses: peter-evans/find-comment@v3 + id: find-govulncheck-comment + with: + issue-number: ${{ github.event.pull_request.number }} + comment-author: "github-actions[bot]" + body-includes: "" + + - name: Comment govulncheck report on PR + if: github.event_name == 'pull_request' + uses: peter-evans/create-or-update-comment@v4 + with: + comment-id: ${{ steps.find-govulncheck-comment.outputs.comment-id }} + issue-number: ${{ github.event.pull_request.number }} + body-path: govulncheck-comment.md + edit-mode: replace + # Base images are dhi.io/*, a Docker Hardened Images registry gated on a # Docker subscription — anonymous pulls 401. Same login a local build # needs (see the Dockerfile's FROM comment), just non-interactive here. + # + # MY_DHI_KEY is a combined base64 "user:token" auth blob (the same + # thing `docker login` writes to config.json), not a plain password — + # docker/login-action's username/password inputs don't accept that + # shape, so config.json is written directly instead. - name: Log in to dhi.io - uses: docker/login-action@v3 - with: - registry: dhi.io - username: ${{ secrets.DHI_USERNAME }} - password: ${{ secrets.DHI_TOKEN }} + run: | + mkdir -p ~/.docker + cat > ~/.docker/config.json <" + echo "### 🛡️ Trivy — image scan (HIGH,CRITICAL)" + echo "[View full run](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID})" echo - echo "
govulncheck" - echo - echo '```' - cat govulncheck-report.txt - echo '```' - echo "
" - echo - echo "
Trivy image scan (HIGH,CRITICAL)" + echo "
Findings" echo echo '```' cat trivy-report.txt echo '```' echo "
" - } > pr-report.md + } > trivy-comment.md + + - name: Find existing Trivy comment + if: github.event_name == 'pull_request' + uses: peter-evans/find-comment@v3 + id: find-trivy-comment + with: + issue-number: ${{ github.event.pull_request.number }} + comment-author: "github-actions[bot]" + body-includes: "" - - name: Comment scan reports on PR + - name: Comment Trivy report on PR if: github.event_name == 'pull_request' uses: peter-evans/create-or-update-comment@v4 with: + comment-id: ${{ steps.find-trivy-comment.outputs.comment-id }} issue-number: ${{ github.event.pull_request.number }} - comment-tag: security-scan - body-path: pr-report.md + body-path: trivy-comment.md + edit-mode: replace - # Gate the PR on the same findings the comment already showed. + # Gate the PR on the same findings the comments above already showed. - name: Trivy image scan (gate) - uses: aquasecurity/trivy-action@0.28.0 + uses: aquasecurity/trivy-action@v0.36.0 with: image-ref: discovery-service:${{ github.sha }} severity: HIGH,CRITICAL diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b677078..bd59f47 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -57,16 +57,27 @@ jobs: tail -1 coverage-func.txt >> "$GITHUB_STEP_SUMMARY" echo '```' >> "$GITHUB_STEP_SUMMARY" { + echo "" echo "### Test coverage" echo '```' tail -1 coverage-func.txt echo '```' } > coverage-report.md + - name: Find existing coverage comment + if: github.event_name == 'pull_request' + uses: peter-evans/find-comment@v3 + id: find-coverage-comment + with: + issue-number: ${{ github.event.pull_request.number }} + comment-author: "github-actions[bot]" + body-includes: "" + - name: Comment coverage on PR if: github.event_name == 'pull_request' uses: peter-evans/create-or-update-comment@v4 with: + comment-id: ${{ steps.find-coverage-comment.outputs.comment-id }} issue-number: ${{ github.event.pull_request.number }} - comment-tag: coverage body-path: coverage-report.md + edit-mode: replace diff --git a/Dockerfile b/Dockerfile index 3531255..5b5e98d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,15 @@ # syntax=docker/dockerfile:1 -FROM golang:1.25-alpine AS build +# DHI (Docker Hardened Images) requires `docker login dhi.io` first — a Docker +# subscription credential this build cannot supply. Tag verified against +# github.com/docker-hardened-images/catalog image/golang/alpine-3.24/1.27-dev.yaml; +# that -dev variant already bundles gcc/g++/musl-dev/make, which is why there's +# no `apk add` here — github.com/uber/h3-go/v4 is a cgo binding around Uber's C +# library (there is no pure-Go H3, and H3 is how every geometry in the index is +# covered), so it needs the toolchain the -dev variant already carries. +FROM dhi.io/golang:1.27-alpine-dev AS build WORKDIR /src -# A C toolchain, because one dependency needs one: github.com/uber/h3-go/v4 is -# a cgo binding around Uber's C library, and under CGO_ENABLED=0 its build -# constraints exclude every file in the package. There is no pure-Go H3, and H3 -# is how every geometry in the index is covered. -RUN apk add --no-cache gcc musl-dev - # Dependency layer first: go.mod and go.sum change far less often than source, # so the module download stays cached across ordinary code edits. go.su[m] is a # glob rather than a plain name because a module with no dependencies yet has @@ -30,13 +31,17 @@ COPY migrations/ ./migrations/ # # cgo is ON and the link is STATIC, which is not a contradiction: musl links # fully static cleanly, so the result still needs no libc at runtime and the -# distroless/static base below stays correct. Dropping -static here does not -# fail the build — it fails the first container start, with a missing loader -# and no Go stack to say why. +# dhi/static base below stays correct. Dropping -static here does not fail the +# build — it fails the first container start, with a missing loader and no Go +# stack to say why. RUN CGO_ENABLED=1 go build -trimpath -ldflags="-s -w -extldflags '-static'" \ -o /out/discovery-service ./cmd/discovery-service -FROM gcr.io/distroless/static-debian12:nonroot +# dhi/static musl-alpine variant, verified against +# image/static/alpine-3.24/static-musl.yaml — runs as uid 65532 / user +# "nonroot" by default already, same as the distroless image it replaces, and +# matches the musl static link above. +FROM dhi.io/static:20260611-musl-alpine AS runtime WORKDIR /app COPY --from=build /out/discovery-service /app/discovery-service @@ -49,6 +54,8 @@ COPY config/common.yaml /app/config/common.yaml # ages with the image. Air-gapped deploys mount a cache file at # VALIDATION_SPEC_CACHE_PATH instead. -USER nonroot:nonroot +# Static hardened images carry no /etc/passwd entries, so the user is numeric, +# not a name — 65532 is the same nonroot uid distroless used. +USER 65532:65532 EXPOSE 8080 ENTRYPOINT ["/app/discovery-service"] From db036e74d5252d369a8316f42b1fb3d3cd19cb69 Mon Sep 17 00:00:00 2001 From: nisargabd Date: Mon, 31 Aug 2026 16:56:57 +0530 Subject: [PATCH 03/29] feat: gate the PR on 80% minimum test coverage [#9] CLAUDE.md called for a threshold but none was set anywhere in the repo; 80 is the number given now. Parses the total% off go tool cover -func's last line and fails after the PR comment posts, so the number is visible before the job goes red. Current coverage is 68.6% -- this gate will fail until it's raised. --- .github/workflows/test.yml | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index bd59f47..362e61c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -18,6 +18,7 @@ env: # this the whole semantic path would go untested from the day semantic search # was deferred. EMBEDDING_PROVIDER: hashing + MIN_COVERAGE: "80" jobs: lint: @@ -45,9 +46,6 @@ jobs: - run: make build - run: make test - # Report only: CLAUDE.md calls for coverage trending up against "an - # agreed threshold", but no number is defined anywhere in this repo yet. - # Gate on one once someone sets it. - name: Coverage run: | make cover @@ -58,7 +56,7 @@ jobs: echo '```' >> "$GITHUB_STEP_SUMMARY" { echo "" - echo "### Test coverage" + echo "### Test coverage (min ${MIN_COVERAGE}%)" echo '```' tail -1 coverage-func.txt echo '```' @@ -81,3 +79,10 @@ jobs: issue-number: ${{ github.event.pull_request.number }} body-path: coverage-report.md edit-mode: replace + + - name: Gate on minimum coverage + run: | + pct=$(tail -1 coverage-func.txt | grep -oE '[0-9]+\.[0-9]+%$' | tr -d '%') + echo "coverage: ${pct}%, minimum: ${MIN_COVERAGE}%" + awk -v pct="$pct" -v min="$MIN_COVERAGE" 'BEGIN { exit !(pct >= min) }' \ + || { echo "::error::coverage ${pct}% is below the ${MIN_COVERAGE}% minimum"; exit 1; } From 4f2b4d8f68b81031037a3f698193f5138515f4cc Mon Sep 17 00:00:00 2001 From: nisargabd Date: Tue, 1 Sep 2026 09:54:33 +0530 Subject: [PATCH 04/29] fix: make release.yml portable across forks and orgs [#9] Image owner is now derived from github.repository_owner (lowercased, since GHCR refs must be lowercase and the context var keeps real case) instead of a hardcoded org name. A hardcoded name meant testing this workflow from a fork pushed straight into openagrinet/discovery-service's real package; deriving it makes the same file work unedited on a fork or on the org repo. GHCR login now tries a GHCR_PAT secret before falling back to GITHUB_TOKEN. GITHUB_TOKEN can be denied write_package when creating a brand-new package under a personal (non-org) namespace, even with this workflow's own packages: write and the repo's default token permissions both set to write -- a documented GHCR quirk. GHCR_PAT (a classic PAT with write:packages) is the workaround; GITHUB_TOKEN stays the default so an org-owned repo needs no extra secret. --- .github/workflows/release.yml | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 70ce736..0c4647a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,12 +29,26 @@ jobs: {"auths":{"dhi.io":{"auth":"${{ secrets.MY_DHI_KEY }}"}}} EOF + # GITHUB_TOKEN can be denied write_package when creating a brand-new + # package under a personal (non-org) namespace, even with this + # workflow's `packages: write` and the repo's default token permissions + # both set to write — a documented GHCR quirk. GHCR_PAT (classic PAT, + # write:packages) is the standard workaround; falls back to + # GITHUB_TOKEN when no such secret is set, which is enough once the + # package exists or for an org-owned repo. - name: Log in to GHCR uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} + password: ${{ secrets.GHCR_PAT || secrets.GITHUB_TOKEN }} + + # GHCR image refs must be lowercase; github.repository_owner preserves + # the owner's real case (OpenAgriNet), so it can't be used as-is. This + # resolves to whichever account or org owns the repo the tag was pushed + # to — a fork pushes under the fork owner, no per-person edit needed. + - name: Set image owner + run: echo "OWNER=$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_ENV" - name: Build and push uses: docker/build-push-action@v6 @@ -42,5 +56,5 @@ jobs: context: . push: true tags: | - ghcr.io/openagrinet/discovery-service:${{ github.ref_name }} - ghcr.io/openagrinet/discovery-service:latest + ghcr.io/${{ env.OWNER }}/discovery-service:${{ github.ref_name }} + ghcr.io/${{ env.OWNER }}/discovery-service:latest From 16c917f98ff9052e9765ad8c65e46887919ecf04 Mon Sep 17 00:00:00 2001 From: nisargabd Date: Tue, 1 Sep 2026 12:21:52 +0530 Subject: [PATCH 05/29] feat: build multi-arch images and standardize the dhi.io login secret [#9] Renamed release.yml to build-and-push.yml and split it into a build job (matrix over amd64/arm64, each native on its own runner, no QEMU) and a manifest job that stitches the two arch-specific pushes into one multi-arch tag via docker buildx imagetools create -- matching the build-and-push pattern already used elsewhere, so a pull resolves the right arch automatically instead of shipping amd64-only. MY_DHI_KEY (a combined base64 user:token blob written straight into config.json) is replaced with the standard split-credential form, DHI_USERNAME + DHI_PAT, authenticated through docker/login-action like the GHCR step already was -- one consistent login shape instead of two, and no more hand-written config.json. security.yml's dhi.io login moves to the same shape since it authenticates the same registry. --- .github/workflows/build-and-push.yml | 96 ++++++++++++++++++++++++++++ .github/workflows/release.yml | 60 ----------------- .github/workflows/security.yml | 15 ++--- 3 files changed, 101 insertions(+), 70 deletions(-) create mode 100644 .github/workflows/build-and-push.yml delete mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/build-and-push.yml b/.github/workflows/build-and-push.yml new file mode 100644 index 0000000..5c60fdf --- /dev/null +++ b/.github/workflows/build-and-push.yml @@ -0,0 +1,96 @@ +name: Build and Push + +# Annotated tags only, per OAN convention: v{SemVer} (e.g. v0.0.1). +on: + push: + tags: ["v[0-9]+.[0-9]+.[0-9]+*"] + +permissions: + contents: read + packages: write + +jobs: + build: + strategy: + matrix: + include: + - runner: ubuntu-latest + platform: linux/amd64 + arch: amd64 + - runner: ubuntu-24.04-arm + platform: linux/arm64 + arch: arm64 + + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-buildx-action@v3 + + # Base images are dhi.io/*, a Docker Hardened Images registry gated on a + # Docker subscription — anonymous pulls 401. + - name: Log in to dhi.io + uses: docker/login-action@v3 + with: + registry: dhi.io + username: ${{ secrets.DHI_USERNAME }} + password: ${{ secrets.DHI_PAT }} + + # GITHUB_TOKEN can be denied write_package when creating a brand-new + # package under a personal (non-org) namespace, even with this + # workflow's `packages: write` and the repo's default token permissions + # both set to write — a documented GHCR quirk. GHCR_PAT (classic PAT, + # write:packages) is the standard workaround; falls back to + # GITHUB_TOKEN when no such secret is set, which is enough once the + # package exists or for an org-owned repo. + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GHCR_PAT || secrets.GITHUB_TOKEN }} + + # GHCR image refs must be lowercase; github.repository_owner preserves + # the owner's real case (OpenAgriNet), so it can't be used as-is. This + # resolves to whichever account or org owns the repo the tag was pushed + # to — a fork pushes under the fork owner, no per-person edit needed. + - name: Set image owner + run: echo "OWNER=$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_ENV" + + # Each arch is a separate tag here; the manifest job below stitches + # amd64 and arm64 into the single tag users actually pull. + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + platforms: ${{ matrix.platform }} + push: true + tags: ghcr.io/${{ env.OWNER }}/discovery-service:${{ github.ref_name }}-${{ matrix.arch }} + + manifest: + runs-on: ubuntu-latest + needs: build + steps: + - uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GHCR_PAT || secrets.GITHUB_TOKEN }} + + - name: Set image owner + run: echo "OWNER=$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_ENV" + + # imagetools create makes one manifest list from the two arch-specific + # images the build job just pushed — this is what lets `docker pull` + # resolve the right arch automatically on either amd64 or arm64. + - name: Create multi-arch manifest + run: | + BASE="ghcr.io/${{ env.OWNER }}/discovery-service" + docker buildx imagetools create \ + -t "${BASE}:${{ github.ref_name }}" \ + -t "${BASE}:latest" \ + "${BASE}:${{ github.ref_name }}-amd64" \ + "${BASE}:${{ github.ref_name }}-arm64" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 0c4647a..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,60 +0,0 @@ -name: Release image - -# Annotated tags only, per OAN convention: v{SemVer} (e.g. v0.0.1). -on: - push: - tags: ["v[0-9]+.[0-9]+.[0-9]+*"] - -permissions: - contents: read - packages: write - -jobs: - build-and-push: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - # Base images are dhi.io/*, a Docker Hardened Images registry gated on a - # Docker subscription — anonymous pulls 401. - # - # MY_DHI_KEY is a combined base64 "user:token" auth blob (the same - # thing `docker login` writes to config.json), not a plain password — - # docker/login-action's username/password inputs don't accept that - # shape, so config.json is written directly instead. - - name: Log in to dhi.io - run: | - mkdir -p ~/.docker - cat > ~/.docker/config.json <> "$GITHUB_ENV" - - - name: Build and push - uses: docker/build-push-action@v6 - with: - context: . - push: true - tags: | - ghcr.io/${{ env.OWNER }}/discovery-service:${{ github.ref_name }} - ghcr.io/${{ env.OWNER }}/discovery-service:latest diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index ad7165f..e5da0fb 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -66,17 +66,12 @@ jobs: # Base images are dhi.io/*, a Docker Hardened Images registry gated on a # Docker subscription — anonymous pulls 401. Same login a local build # needs (see the Dockerfile's FROM comment), just non-interactive here. - # - # MY_DHI_KEY is a combined base64 "user:token" auth blob (the same - # thing `docker login` writes to config.json), not a plain password — - # docker/login-action's username/password inputs don't accept that - # shape, so config.json is written directly instead. - name: Log in to dhi.io - run: | - mkdir -p ~/.docker - cat > ~/.docker/config.json < Date: Tue, 1 Sep 2026 12:28:52 +0530 Subject: [PATCH 06/29] fix: scan the release image with Trivy before it reaches GHCR [#9] build-and-push.yml built and pushed straight through -- the image security.yml scans on a PR is a different build (untagged, no push) from the one that ends up in GHCR, so the artifact people actually pull was never scanned. Each arch's image is now built locally (load: true, no push), Trivy-gated at HIGH,CRITICAL (exit-code 1), and only pushed if that step passes -- a vulnerable image stops at the runner, not GHCR. --- .github/workflows/build-and-push.yml | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-and-push.yml b/.github/workflows/build-and-push.yml index 5c60fdf..3a1d832 100644 --- a/.github/workflows/build-and-push.yml +++ b/.github/workflows/build-and-push.yml @@ -59,14 +59,31 @@ jobs: # Each arch is a separate tag here; the manifest job below stitches # amd64 and arm64 into the single tag users actually pull. - - name: Build and push + # + # load: true instead of push: true — the image lands in this runner's + # local Docker daemon first so Trivy can scan the exact bytes about to + # ship, and a vulnerable image never reaches GHCR at all. + - name: Build (local, unpushed) uses: docker/build-push-action@v6 with: context: . platforms: ${{ matrix.platform }} - push: true + load: true tags: ghcr.io/${{ env.OWNER }}/discovery-service:${{ github.ref_name }}-${{ matrix.arch }} + # exit-code: 1 fails the job here, before the push step below runs -- + # the release gate, not just a report. + - name: Trivy image scan (gate) + uses: aquasecurity/trivy-action@v0.36.0 + with: + image-ref: ghcr.io/${{ env.OWNER }}/discovery-service:${{ github.ref_name }}-${{ matrix.arch }} + severity: HIGH,CRITICAL + exit-code: "1" + format: table + + - name: Push + run: docker push ghcr.io/${{ env.OWNER }}/discovery-service:${{ github.ref_name }}-${{ matrix.arch }} + manifest: runs-on: ubuntu-latest needs: build From 0e791c4fe7ff18b56f713a5173016be045bcd6e0 Mon Sep 17 00:00:00 2001 From: nisargabd Date: Tue, 1 Sep 2026 12:36:44 +0530 Subject: [PATCH 07/29] fix: accept a combined DHI_AUTH secret or DHI_USERNAME/DHI_PAT, not just one shape [#9] DHI_USERNAME/DHI_PAT assumed every dhi.io credential arrives as two plain values, but a docker login itself (or a token already minted as user:token) produces one combined base64 blob -- exactly config.json's own auth format. DHI_AUTH takes that blob directly when set; otherwise the two plain secrets are combined the same way docker login would. Either shape works without editing the workflow. --- .github/workflows/build-and-push.yml | 22 +++++++++++++++++----- .github/workflows/security.yml | 22 +++++++++++++++++----- 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/.github/workflows/build-and-push.yml b/.github/workflows/build-and-push.yml index 3a1d832..b4bc85e 100644 --- a/.github/workflows/build-and-push.yml +++ b/.github/workflows/build-and-push.yml @@ -29,12 +29,24 @@ jobs: # Base images are dhi.io/*, a Docker Hardened Images registry gated on a # Docker subscription — anonymous pulls 401. + # + # Accepts either credential shape: DHI_AUTH already holds the combined + # base64 "user:token" blob docker login itself writes to config.json, + # or DHI_USERNAME/DHI_PAT are two plain values to combine into one. + # Either way it's config.json's own auth format, so it's written + # directly rather than through docker/login-action, whose + # username/password inputs don't accept a pre-combined blob. - name: Log in to dhi.io - uses: docker/login-action@v3 - with: - registry: dhi.io - username: ${{ secrets.DHI_USERNAME }} - password: ${{ secrets.DHI_PAT }} + run: | + if [ -n "${{ secrets.DHI_AUTH }}" ]; then + AUTH="${{ secrets.DHI_AUTH }}" + else + AUTH=$(printf '%s:%s' "${{ secrets.DHI_USERNAME }}" "${{ secrets.DHI_PAT }}" | base64 -w0) + fi + mkdir -p ~/.docker + cat > ~/.docker/config.json < ~/.docker/config.json < Date: Tue, 1 Sep 2026 12:36:49 +0530 Subject: [PATCH 08/29] chore: rename test.yml to run-tests.yml for a clearer, action-first name [#9] Matches build-and-push.yml's naming -- the filename now says what the workflow does (lint, build, test, gate on coverage) instead of a bare noun. No behavioral change. --- .github/workflows/{test.yml => run-tests.yml} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename .github/workflows/{test.yml => run-tests.yml} (99%) diff --git a/.github/workflows/test.yml b/.github/workflows/run-tests.yml similarity index 99% rename from .github/workflows/test.yml rename to .github/workflows/run-tests.yml index 362e61c..deb2c28 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/run-tests.yml @@ -1,4 +1,4 @@ -name: Test +name: Run Tests on: push: From a15ede76f918a55adf1b5d544db1c4893de3cc25 Mon Sep 17 00:00:00 2001 From: nisargabd Date: Tue, 1 Sep 2026 14:41:44 +0530 Subject: [PATCH 09/29] fix: render govulncheck PR comment as a table, not raw output [#9] The raw govulncheck text dump (Vulnerability #/Module/Found in/Fixed in blocks) made a reviewer read line by line to see which module needs bumping to what. A module/installed/fixed/advisory table surfaces that at a glance; full raw output stays available in a collapsed section for anyone who wants it. --- .github/workflows/security.yml | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index db8a867..fbff0df 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -37,7 +37,26 @@ jobs: echo "### 🛡️ govulncheck — Go dependency vulnerabilities" echo "[View full run](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID})" echo - echo "
Findings" + if grep -q '^Vulnerability #' govulncheck-report.txt; then + echo "| Module | Installed | Fixed | Advisory |" + echo "|---|---|---|---|" + awk ' + /Module:/ { module = $2 } + /Found in:/ { split($0, a, "@"); installed = a[2] } + /Fixed in:/ { split($0, a, "@"); fixed = a[2] } + /^Vulnerability #/ { + id = $3 + next + } + /Fixed in:/ { + printf "| %s | %s | %s | [%s](https://pkg.go.dev/vuln/%s) |\n", module, installed, fixed, id, id + } + ' govulncheck-report.txt + else + echo "No known vulnerabilities found." + fi + echo + echo "
Full govulncheck output" echo echo '```' cat govulncheck-report.txt From eaa544c09ed4fea23e15ca883439072da1b3bef8 Mon Sep 17 00:00:00 2001 From: nisargabd Date: Tue, 1 Sep 2026 14:48:28 +0530 Subject: [PATCH 10/29] fix: render govulncheck findings as a box table, matching Trivy's [#9] The pipe-table read fine on GitHub but looked inconsistent next to Trivy's own box-drawn report right above it in the same PR comment thread. Column widths are computed from content, same layout family (module/installed/fixed/advisory) Trivy already uses for the image scan, so both reports read the same way at a glance. --- .github/workflows/security.yml | 39 ++++++++++++++++++++++++++-------- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index fbff0df..50e024b 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -37,24 +37,45 @@ jobs: echo "### 🛡️ govulncheck — Go dependency vulnerabilities" echo "[View full run](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID})" echo + echo '```' if grep -q '^Vulnerability #' govulncheck-report.txt; then - echo "| Module | Installed | Fixed | Advisory |" - echo "|---|---|---|---|" awk ' - /Module:/ { module = $2 } - /Found in:/ { split($0, a, "@"); installed = a[2] } - /Fixed in:/ { split($0, a, "@"); fixed = a[2] } - /^Vulnerability #/ { - id = $3 - next + function hline(w1, w2, w3, w4, l, m, r, k) { + printf "%s", l + for (k = 0; k < w1 + 2; k++) printf "─"; printf "%s", m + for (k = 0; k < w2 + 2; k++) printf "─"; printf "%s", m + for (k = 0; k < w3 + 2; k++) printf "─"; printf "%s", m + for (k = 0; k < w4 + 2; k++) printf "─"; print r } + /^Vulnerability #/ { id = $3 } + /Module:/ { module = $2 } + /Found in:/ { split($0, a, "@"); installed = a[2] } /Fixed in:/ { - printf "| %s | %s | %s | [%s](https://pkg.go.dev/vuln/%s) |\n", module, installed, fixed, id, id + split($0, a, "@"); fixed = a[2] + n++ + ID[n] = id; MOD[n] = module; INS[n] = installed; FIX[n] = fixed + } + END { + h1 = "Module"; h2 = "Installed"; h3 = "Fixed"; h4 = "Advisory" + w1 = length(h1); w2 = length(h2); w3 = length(h3); w4 = length(h4) + for (i = 1; i <= n; i++) { + if (length(MOD[i]) > w1) w1 = length(MOD[i]) + if (length(INS[i]) > w2) w2 = length(INS[i]) + if (length(FIX[i]) > w3) w3 = length(FIX[i]) + if (length(ID[i]) > w4) w4 = length(ID[i]) + } + hline(w1, w2, w3, w4, "┌", "┬", "┐") + printf "│ %-*s │ %-*s │ %-*s │ %-*s │\n", w1, h1, w2, h2, w3, h3, w4, h4 + hline(w1, w2, w3, w4, "├", "┼", "┤") + for (i = 1; i <= n; i++) + printf "│ %-*s │ %-*s │ %-*s │ %-*s │\n", w1, MOD[i], w2, INS[i], w3, FIX[i], w4, ID[i] + hline(w1, w2, w3, w4, "└", "┴", "┘") } ' govulncheck-report.txt else echo "No known vulnerabilities found." fi + echo '```' echo echo "
Full govulncheck output" echo From d50ae05e6b955f553a0487928e13eddbf25035e7 Mon Sep 17 00:00:00 2001 From: nisargabd Date: Tue, 1 Sep 2026 14:53:18 +0530 Subject: [PATCH 11/29] fix: add plain-English issue and copy-paste fix to govulncheck comment [#9] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The box table only carried module/version/advisory ID — someone unfamiliar with the module still had to open the full run log to learn what the vulnerability actually does or how to fix it. Each finding now gets its own Issue/Fix/Advisory block below the table, with a ready-to-run `go get @ && go mod tidy` command. --- .github/workflows/security.yml | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 50e024b..bb3f548 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -37,8 +37,8 @@ jobs: echo "### 🛡️ govulncheck — Go dependency vulnerabilities" echo "[View full run](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID})" echo - echo '```' if grep -q '^Vulnerability #' govulncheck-report.txt; then + echo '```' awk ' function hline(w1, w2, w3, w4, l, m, r, k) { printf "%s", l @@ -47,13 +47,20 @@ jobs: for (k = 0; k < w3 + 2; k++) printf "─"; printf "%s", m for (k = 0; k < w4 + 2; k++) printf "─"; print r } - /^Vulnerability #/ { id = $3 } - /Module:/ { module = $2 } - /Found in:/ { split($0, a, "@"); installed = a[2] } + /^Vulnerability #/ { id = $3; summary = ""; in_summary = 1; next } + /More info:/ { in_summary = 0 } + in_summary { + line = $0 + gsub(/^[ \t]+/, "", line) + summary = summary (summary == "" ? "" : " ") line + next + } + /Module:/ { module = $2 } + /Found in:/ { split($0, a, "@"); installed = a[2] } /Fixed in:/ { split($0, a, "@"); fixed = a[2] n++ - ID[n] = id; MOD[n] = module; INS[n] = installed; FIX[n] = fixed + ID[n] = id; MOD[n] = module; INS[n] = installed; FIX[n] = fixed; SUM[n] = summary } END { h1 = "Module"; h2 = "Installed"; h3 = "Fixed"; h4 = "Advisory" @@ -70,12 +77,19 @@ jobs: for (i = 1; i <= n; i++) printf "│ %-*s │ %-*s │ %-*s │ %-*s │\n", w1, MOD[i], w2, INS[i], w3, FIX[i], w4, ID[i] hline(w1, w2, w3, w4, "└", "┴", "┘") + print "```" + print "" + for (i = 1; i <= n; i++) { + printf "**%s** — `%s`\n", ID[i], MOD[i] + printf "- Issue: %s\n", SUM[i] + printf "- Fix: `go get %s@%s && go mod tidy`\n", MOD[i], FIX[i] + printf "- Advisory: https://pkg.go.dev/vuln/%s\n\n", ID[i] + } } ' govulncheck-report.txt else echo "No known vulnerabilities found." fi - echo '```' echo echo "
Full govulncheck output" echo From b521a49681ae89c23df56611359520eb5d18dca6 Mon Sep 17 00:00:00 2001 From: nisargabd Date: Tue, 1 Sep 2026 14:57:17 +0530 Subject: [PATCH 12/29] fix: fold the govulncheck Issue description into the box table [#9] A separate Issue/Fix/Advisory block per finding duplicated what the table already said and made short reports feel long. One Issue column (truncated) keeps the whole report a single glance-able table, same as Trivy's own. --- .github/workflows/security.yml | 32 ++++++++++++++------------------ 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index bb3f548..4916373 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -40,12 +40,13 @@ jobs: if grep -q '^Vulnerability #' govulncheck-report.txt; then echo '```' awk ' - function hline(w1, w2, w3, w4, l, m, r, k) { + function hline(w1, w2, w3, w4, w5, l, m, r, k) { printf "%s", l for (k = 0; k < w1 + 2; k++) printf "─"; printf "%s", m for (k = 0; k < w2 + 2; k++) printf "─"; printf "%s", m for (k = 0; k < w3 + 2; k++) printf "─"; printf "%s", m - for (k = 0; k < w4 + 2; k++) printf "─"; print r + for (k = 0; k < w4 + 2; k++) printf "─"; printf "%s", m + for (k = 0; k < w5 + 2; k++) printf "─"; print r } /^Vulnerability #/ { id = $3; summary = ""; in_summary = 1; next } /More info:/ { in_summary = 0 } @@ -59,34 +60,29 @@ jobs: /Found in:/ { split($0, a, "@"); installed = a[2] } /Fixed in:/ { split($0, a, "@"); fixed = a[2] + if (length(summary) > 55) summary = substr(summary, 1, 52) "..." n++ ID[n] = id; MOD[n] = module; INS[n] = installed; FIX[n] = fixed; SUM[n] = summary } END { - h1 = "Module"; h2 = "Installed"; h3 = "Fixed"; h4 = "Advisory" - w1 = length(h1); w2 = length(h2); w3 = length(h3); w4 = length(h4) + h1 = "Module"; h2 = "Installed"; h3 = "Fixed"; h4 = "Issue"; h5 = "Advisory" + w1 = length(h1); w2 = length(h2); w3 = length(h3); w4 = length(h4); w5 = length(h5) for (i = 1; i <= n; i++) { if (length(MOD[i]) > w1) w1 = length(MOD[i]) if (length(INS[i]) > w2) w2 = length(INS[i]) if (length(FIX[i]) > w3) w3 = length(FIX[i]) - if (length(ID[i]) > w4) w4 = length(ID[i]) + if (length(SUM[i]) > w4) w4 = length(SUM[i]) + if (length(ID[i]) > w5) w5 = length(ID[i]) } - hline(w1, w2, w3, w4, "┌", "┬", "┐") - printf "│ %-*s │ %-*s │ %-*s │ %-*s │\n", w1, h1, w2, h2, w3, h3, w4, h4 - hline(w1, w2, w3, w4, "├", "┼", "┤") + hline(w1, w2, w3, w4, w5, "┌", "┬", "┐") + printf "│ %-*s │ %-*s │ %-*s │ %-*s │ %-*s │\n", w1, h1, w2, h2, w3, h3, w4, h4, w5, h5 + hline(w1, w2, w3, w4, w5, "├", "┼", "┤") for (i = 1; i <= n; i++) - printf "│ %-*s │ %-*s │ %-*s │ %-*s │\n", w1, MOD[i], w2, INS[i], w3, FIX[i], w4, ID[i] - hline(w1, w2, w3, w4, "└", "┴", "┘") - print "```" - print "" - for (i = 1; i <= n; i++) { - printf "**%s** — `%s`\n", ID[i], MOD[i] - printf "- Issue: %s\n", SUM[i] - printf "- Fix: `go get %s@%s && go mod tidy`\n", MOD[i], FIX[i] - printf "- Advisory: https://pkg.go.dev/vuln/%s\n\n", ID[i] - } + printf "│ %-*s │ %-*s │ %-*s │ %-*s │ %-*s │\n", w1, MOD[i], w2, INS[i], w3, FIX[i], w4, SUM[i], w5, ID[i] + hline(w1, w2, w3, w4, w5, "└", "┴", "┘") } ' govulncheck-report.txt + echo '```' else echo "No known vulnerabilities found." fi From 99b12ef19d7dcfa28f33026dd1d1f867f33ba9e4 Mon Sep 17 00:00:00 2001 From: nisargabd Date: Tue, 1 Sep 2026 15:00:29 +0530 Subject: [PATCH 13/29] fix: draw a divider between every govulncheck row, matching Trivy [#9] Only the header had a divider before; Trivy's own table divides after each row too, so the two reports in the same PR thread now share one visual grammar. --- .github/workflows/security.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 4916373..1a6b61e 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -77,8 +77,10 @@ jobs: hline(w1, w2, w3, w4, w5, "┌", "┬", "┐") printf "│ %-*s │ %-*s │ %-*s │ %-*s │ %-*s │\n", w1, h1, w2, h2, w3, h3, w4, h4, w5, h5 hline(w1, w2, w3, w4, w5, "├", "┼", "┤") - for (i = 1; i <= n; i++) + for (i = 1; i <= n; i++) { printf "│ %-*s │ %-*s │ %-*s │ %-*s │ %-*s │\n", w1, MOD[i], w2, INS[i], w3, FIX[i], w4, SUM[i], w5, ID[i] + if (i < n) hline(w1, w2, w3, w4, w5, "├", "┼", "┤") + } hline(w1, w2, w3, w4, w5, "└", "┴", "┘") } ' govulncheck-report.txt From 4982112959c1a1ce7a336ed9294e3817e8a13b10 Mon Sep 17 00:00:00 2001 From: nisargabd Date: Tue, 1 Sep 2026 15:06:06 +0530 Subject: [PATCH 14/29] fix: drop the standalone Advisory column, wrap the link into Issue [#9] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Severity can't be added — checked govulncheck's own JSON (OSV records): Go's vulnerability DB carries no CVSS/severity rating, unlike Trivy's NVD-sourced OS/language CVEs, so there's no real data to put in that column. Matched Trivy's own multi-line-cell layout instead: the advisory link now wraps onto its own line inside the Issue cell, same as Trivy's Title column does for its CVE links, one column narrower and no duplicate ID that was already visible in the URL. --- .github/workflows/security.yml | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 1a6b61e..7337eb0 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -40,13 +40,12 @@ jobs: if grep -q '^Vulnerability #' govulncheck-report.txt; then echo '```' awk ' - function hline(w1, w2, w3, w4, w5, l, m, r, k) { + function hline(w1, w2, w3, w4, l, m, r, k) { printf "%s", l for (k = 0; k < w1 + 2; k++) printf "─"; printf "%s", m for (k = 0; k < w2 + 2; k++) printf "─"; printf "%s", m for (k = 0; k < w3 + 2; k++) printf "─"; printf "%s", m - for (k = 0; k < w4 + 2; k++) printf "─"; printf "%s", m - for (k = 0; k < w5 + 2; k++) printf "─"; print r + for (k = 0; k < w4 + 2; k++) printf "─"; print r } /^Vulnerability #/ { id = $3; summary = ""; in_summary = 1; next } /More info:/ { in_summary = 0 } @@ -60,28 +59,30 @@ jobs: /Found in:/ { split($0, a, "@"); installed = a[2] } /Fixed in:/ { split($0, a, "@"); fixed = a[2] + link = "https://pkg.go.dev/vuln/" id if (length(summary) > 55) summary = substr(summary, 1, 52) "..." n++ - ID[n] = id; MOD[n] = module; INS[n] = installed; FIX[n] = fixed; SUM[n] = summary + MOD[n] = module; INS[n] = installed; FIX[n] = fixed; SUM[n] = summary; LNK[n] = link } END { - h1 = "Module"; h2 = "Installed"; h3 = "Fixed"; h4 = "Issue"; h5 = "Advisory" - w1 = length(h1); w2 = length(h2); w3 = length(h3); w4 = length(h4); w5 = length(h5) + h1 = "Module"; h2 = "Installed"; h3 = "Fixed"; h4 = "Issue" + w1 = length(h1); w2 = length(h2); w3 = length(h3); w4 = length(h4) for (i = 1; i <= n; i++) { if (length(MOD[i]) > w1) w1 = length(MOD[i]) if (length(INS[i]) > w2) w2 = length(INS[i]) if (length(FIX[i]) > w3) w3 = length(FIX[i]) if (length(SUM[i]) > w4) w4 = length(SUM[i]) - if (length(ID[i]) > w5) w5 = length(ID[i]) + if (length(LNK[i]) > w4) w4 = length(LNK[i]) } - hline(w1, w2, w3, w4, w5, "┌", "┬", "┐") - printf "│ %-*s │ %-*s │ %-*s │ %-*s │ %-*s │\n", w1, h1, w2, h2, w3, h3, w4, h4, w5, h5 - hline(w1, w2, w3, w4, w5, "├", "┼", "┤") + hline(w1, w2, w3, w4, "┌", "┬", "┐") + printf "│ %-*s │ %-*s │ %-*s │ %-*s │\n", w1, h1, w2, h2, w3, h3, w4, h4 + hline(w1, w2, w3, w4, "├", "┼", "┤") for (i = 1; i <= n; i++) { - printf "│ %-*s │ %-*s │ %-*s │ %-*s │ %-*s │\n", w1, MOD[i], w2, INS[i], w3, FIX[i], w4, SUM[i], w5, ID[i] - if (i < n) hline(w1, w2, w3, w4, w5, "├", "┼", "┤") + printf "│ %-*s │ %-*s │ %-*s │ %-*s │\n", w1, MOD[i], w2, INS[i], w3, FIX[i], w4, SUM[i] + printf "│ %-*s │ %-*s │ %-*s │ %-*s │\n", w1, "", w2, "", w3, "", w4, LNK[i] + if (i < n) hline(w1, w2, w3, w4, "├", "┼", "┤") } - hline(w1, w2, w3, w4, w5, "└", "┴", "┘") + hline(w1, w2, w3, w4, "└", "┴", "┘") } ' govulncheck-report.txt echo '```' From 5afb87e6dc03ad52f0debc99d10f758adae641ab Mon Sep 17 00:00:00 2001 From: nisargabd Date: Tue, 1 Sep 2026 15:10:05 +0530 Subject: [PATCH 15/29] fix: add a clickable advisory-links line below the govulncheck table [#9] The link was only visible inside the table's code fence, where GitHub renders it as plain text rather than a link. A one-line "Advisories:" list below the fence gives each finding a real clickable link. --- .github/workflows/security.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 7337eb0..6996fcd 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -86,6 +86,8 @@ jobs: } ' govulncheck-report.txt echo '```' + echo + echo "Advisories: $(awk '/^Vulnerability #/ { printf "[%s](https://pkg.go.dev/vuln/%s) ", $3, $3 }' govulncheck-report.txt)" else echo "No known vulnerabilities found." fi From 27b1c7ee23cb1ffe3af858a587e7a859ba468af0 Mon Sep 17 00:00:00 2001 From: nisargabd Date: Tue, 1 Sep 2026 15:14:39 +0530 Subject: [PATCH 16/29] fix: keep the govulncheck link inside the table, brief the Issue text [#9] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two problems: a standalone Advisories line duplicated what the wrapped cell already showed, and truncation cut mid-word ("...can write outsi...") which read as broken rather than brief. Cut at the nearest word boundary, and drop the module-name prefix most summaries repeat from the Module column, so the Issue column reads clean at a fixed width — same single-table layout Trivy uses. --- .github/workflows/security.yml | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 6996fcd..9dd69a1 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -60,7 +60,23 @@ jobs: /Fixed in:/ { split($0, a, "@"); fixed = a[2] link = "https://pkg.go.dev/vuln/" id - if (length(summary) > 55) summary = substr(summary, 1, 52) "..." + + # the summary usually repeats the module path as a prefix + # ("moby/go-archive: Crafted tar archive..."); drop it, the + # Module column already has it + base = module + sub(/^[a-zA-Z0-9.-]+\//, "", base) + prefix = base ": " + if (index(summary, prefix) == 1) summary = substr(summary, length(prefix) + 1) + + limit = 48 + if (length(summary) > limit) { + cut = substr(summary, 1, limit) + sp = 0 + for (p = limit; p > 1; p--) if (substr(summary, p, 1) == " ") { sp = p; break } + if (sp > 0) cut = substr(summary, 1, sp - 1) + summary = cut "..." + } n++ MOD[n] = module; INS[n] = installed; FIX[n] = fixed; SUM[n] = summary; LNK[n] = link } @@ -86,8 +102,6 @@ jobs: } ' govulncheck-report.txt echo '```' - echo - echo "Advisories: $(awk '/^Vulnerability #/ { printf "[%s](https://pkg.go.dev/vuln/%s) ", $3, $3 }' govulncheck-report.txt)" else echo "No known vulnerabilities found." fi From 13ca7d179d6f5dcffe1e2dcd7c0ff7fb9fa9ade6 Mon Sep 17 00:00:00 2001 From: nisargabd Date: Tue, 1 Sep 2026 15:18:31 +0530 Subject: [PATCH 17/29] fix: switch govulncheck's PR table to real markdown, fix stdlib rows [#9] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two real bugs surfaced on this branch's own CI run, not just style: stdlib findings print "Standard library" instead of "Module: ...", and the parser never reset its module variable per record — so both stdlib rows silently inherited the previous row's module name. A code-fenced ASCII table also can't render a link or wrap text, forcing mid-sentence truncation. A real markdown table fixes all three: parses whichever field format applies per record, and GitHub wraps and links normally without a manual width budget. --- .github/workflows/security.yml | 58 ++++++++-------------------------- 1 file changed, 14 insertions(+), 44 deletions(-) diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 9dd69a1..966e5e3 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -38,28 +38,28 @@ jobs: echo "[View full run](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID})" echo if grep -q '^Vulnerability #' govulncheck-report.txt; then - echo '```' + echo "| Module | Installed | Fixed | Issue |" + echo "|---|---|---|---|" awk ' - function hline(w1, w2, w3, w4, l, m, r, k) { - printf "%s", l - for (k = 0; k < w1 + 2; k++) printf "─"; printf "%s", m - for (k = 0; k < w2 + 2; k++) printf "─"; printf "%s", m - for (k = 0; k < w3 + 2; k++) printf "─"; printf "%s", m - for (k = 0; k < w4 + 2; k++) printf "─"; print r + /^Vulnerability #/ { + id = $0; sub(/^Vulnerability #[0-9]+: /, "", id) + module = ""; summary = ""; in_summary = 1 + next } - /^Vulnerability #/ { id = $3; summary = ""; in_summary = 1; next } - /More info:/ { in_summary = 0 } + /More info:/ { in_summary = 0 } in_summary { line = $0 gsub(/^[ \t]+/, "", line) summary = summary (summary == "" ? "" : " ") line next } - /Module:/ { module = $2 } - /Found in:/ { split($0, a, "@"); installed = a[2] } + /^ Module:/ { line = $0; sub(/^ Module: */, "", line); module = line } + /^ Standard library/ { module = "Go standard library" } + /Found in:/ { line = $0; sub(/^.*Found in: */, "", line); if (index(line, "@") > 0) { split(line, a, "@"); line = a[2] }; installed = line } /Fixed in:/ { - split($0, a, "@"); fixed = a[2] - link = "https://pkg.go.dev/vuln/" id + line = $0; sub(/^.*Fixed in: */, "", line) + if (index(line, "@") > 0) { split(line, a, "@"); line = a[2] } + fixed = line # the summary usually repeats the module path as a prefix # ("moby/go-archive: Crafted tar archive..."); drop it, the @@ -69,39 +69,9 @@ jobs: prefix = base ": " if (index(summary, prefix) == 1) summary = substr(summary, length(prefix) + 1) - limit = 48 - if (length(summary) > limit) { - cut = substr(summary, 1, limit) - sp = 0 - for (p = limit; p > 1; p--) if (substr(summary, p, 1) == " ") { sp = p; break } - if (sp > 0) cut = substr(summary, 1, sp - 1) - summary = cut "..." - } - n++ - MOD[n] = module; INS[n] = installed; FIX[n] = fixed; SUM[n] = summary; LNK[n] = link - } - END { - h1 = "Module"; h2 = "Installed"; h3 = "Fixed"; h4 = "Issue" - w1 = length(h1); w2 = length(h2); w3 = length(h3); w4 = length(h4) - for (i = 1; i <= n; i++) { - if (length(MOD[i]) > w1) w1 = length(MOD[i]) - if (length(INS[i]) > w2) w2 = length(INS[i]) - if (length(FIX[i]) > w3) w3 = length(FIX[i]) - if (length(SUM[i]) > w4) w4 = length(SUM[i]) - if (length(LNK[i]) > w4) w4 = length(LNK[i]) - } - hline(w1, w2, w3, w4, "┌", "┬", "┐") - printf "│ %-*s │ %-*s │ %-*s │ %-*s │\n", w1, h1, w2, h2, w3, h3, w4, h4 - hline(w1, w2, w3, w4, "├", "┼", "┤") - for (i = 1; i <= n; i++) { - printf "│ %-*s │ %-*s │ %-*s │ %-*s │\n", w1, MOD[i], w2, INS[i], w3, FIX[i], w4, SUM[i] - printf "│ %-*s │ %-*s │ %-*s │ %-*s │\n", w1, "", w2, "", w3, "", w4, LNK[i] - if (i < n) hline(w1, w2, w3, w4, "├", "┼", "┤") - } - hline(w1, w2, w3, w4, "└", "┴", "┘") + printf "| %s | %s | %s | [%s](https://pkg.go.dev/vuln/%s) |\n", module, installed, fixed, summary, id } ' govulncheck-report.txt - echo '```' else echo "No known vulnerabilities found." fi From ed052806d6c41f5f15e4cb6a88ca7216a6ac2e68 Mon Sep 17 00:00:00 2001 From: nisargabd Date: Tue, 1 Sep 2026 16:42:23 +0530 Subject: [PATCH 18/29] fix: restrict release tags to plain SemVer or an -rcN suffix [#9] The previous pattern (v[0-9]+.[0-9]+.[0-9]+*) matched any suffix after the patch number, including throwaway tags like v0.1.1-test used while testing this workflow. Narrow the trigger to what's actually a release: a plain v{SemVer} or a v{SemVer}-rcN pre-release, nothing else. --- .github/workflows/build-and-push.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-and-push.yml b/.github/workflows/build-and-push.yml index b4bc85e..cb01f31 100644 --- a/.github/workflows/build-and-push.yml +++ b/.github/workflows/build-and-push.yml @@ -1,9 +1,13 @@ name: Build and Push -# Annotated tags only, per OAN convention: v{SemVer} (e.g. v0.0.1). +# Annotated tags only, per OAN convention: v{SemVer} (e.g. v0.0.1), with an +# optional -rcN pre-release suffix (e.g. v0.1.1-rc3). Anything else (-test, +# -beta, ...) does not match, on purpose. on: push: - tags: ["v[0-9]+.[0-9]+.[0-9]+*"] + tags: + - "v[0-9]+.[0-9]+.[0-9]+" + - "v[0-9]+.[0-9]+.[0-9]+-rc[0-9]+" permissions: contents: read From 358cf1ba20f351cac2294e5fe78c4bf4eaa4b7c6 Mon Sep 17 00:00:00 2001 From: nisargabd Date: Tue, 1 Sep 2026 16:42:40 +0530 Subject: [PATCH 19/29] feat: make build-and-push registry-agnostic behind repo-variable flags [#9] GHCR is the only registry in use today, but hardcoding ghcr.io/ into every tag, login and push step meant adding a second registry later would mean editing every step. Gate each registry (GHCR, Docker Hub, ACR, Google Artifact Registry) behind an ENABLED repo variable instead: GHCR defaults on since it's what we use now, the rest default off and need no secrets until switched on. The image is still built once, scanned once with Trivy before anything is pushed, then re-tagged per enabled registry rather than rebuilt and rescanned per registry -- the release gate this workflow exists for doesn't get weaker by supporting more than one destination. --- .github/workflows/build-and-push.yml | 158 ++++++++++++++++++++++++--- 1 file changed, 140 insertions(+), 18 deletions(-) diff --git a/.github/workflows/build-and-push.yml b/.github/workflows/build-and-push.yml index cb01f31..1728b85 100644 --- a/.github/workflows/build-and-push.yml +++ b/.github/workflows/build-and-push.yml @@ -13,6 +13,9 @@ permissions: contents: read packages: write +env: + IMAGE_NAME: discovery-service + jobs: build: strategy: @@ -31,8 +34,23 @@ jobs: - uses: docker/setup-buildx-action@v3 + # One flag per registry, read from repo variables (Settings > Secrets + # and variables > Actions > Variables), so a registry is switched on + # without a workflow edit. GHCR is the only one we use today, so it + # defaults on; everything else defaults off until its secrets exist. + # 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 + run: | + echo "GHCR_ENABLED=${{ vars.GHCR_ENABLED != 'false' }}" >> "$GITHUB_ENV" + echo "DOCKERHUB_ENABLED=${{ vars.DOCKERHUB_ENABLED == 'true' }}" >> "$GITHUB_ENV" + echo "AZURE_CONTAINER_REGISTRY_ENABLED=${{ vars.AZURE_CONTAINER_REGISTRY_ENABLED == 'true' }}" >> "$GITHUB_ENV" + echo "GOOGLE_ARTIFACT_REGISTRY_ENABLED=${{ vars.GOOGLE_ARTIFACT_REGISTRY_ENABLED == 'true' }}" >> "$GITHUB_ENV" + # Base images are dhi.io/*, a Docker Hardened Images registry gated on a - # Docker subscription — anonymous pulls 401. + # Docker subscription — anonymous pulls 401. Unrelated to which + # registry we push the built image to below. # # Accepts either credential shape: DHI_AUTH already holds the combined # base64 "user:token" blob docker login itself writes to config.json, @@ -60,12 +78,40 @@ jobs: # GITHUB_TOKEN when no such secret is set, which is enough once the # package exists or for an org-owned repo. - name: Log in to GHCR + if: env.GHCR_ENABLED == 'true' uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GHCR_PAT || secrets.GITHUB_TOKEN }} + - name: Log in to Docker Hub + if: env.DOCKERHUB_ENABLED == 'true' + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_PASSWORD }} + + - name: Log in to Azure Container Registry + if: env.AZURE_CONTAINER_REGISTRY_ENABLED == 'true' + uses: docker/login-action@v3 + with: + registry: ${{ secrets.AZURE_REGISTRY_NAME }} + username: ${{ secrets.AZURE_REGISTRY_USERNAME }} + password: ${{ secrets.AZURE_REGISTRY_PASSWORD }} + + - name: Authenticate to Google Cloud + if: env.GOOGLE_ARTIFACT_REGISTRY_ENABLED == 'true' + uses: google-github-actions/auth@v3 + with: + token_format: "access_token" + workload_identity_provider: ${{ secrets.GOOGLE_WORKLOAD_IDENTITY_PROVIDER }} + service_account: ${{ secrets.GOOGLE_SERVICE_ACCOUNT_EMAIL }} + + - name: Configure Docker for Google Artifact Registry + if: env.GOOGLE_ARTIFACT_REGISTRY_ENABLED == 'true' + run: gcloud auth configure-docker ${{ secrets.GCP_REGION }}-docker.pkg.dev --quiet + # GHCR image refs must be lowercase; github.repository_owner preserves # the owner's real case (OpenAgriNet), so it can't be used as-is. This # resolves to whichever account or org owns the repo the tag was pushed @@ -73,32 +119,57 @@ jobs: - name: Set image owner run: echo "OWNER=$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_ENV" - # Each arch is a separate tag here; the manifest job below stitches + # One local, unpushed tag per arch — the manifest job below stitches # amd64 and arm64 into the single tag users actually pull. # # load: true instead of push: true — the image lands in this runner's # local Docker daemon first so Trivy can scan the exact bytes about to - # ship, and a vulnerable image never reaches GHCR at all. + # ship, before it is tagged for or pushed to any registry. - name: Build (local, unpushed) uses: docker/build-push-action@v6 with: context: . platforms: ${{ matrix.platform }} load: true - tags: ghcr.io/${{ env.OWNER }}/discovery-service:${{ github.ref_name }}-${{ matrix.arch }} + tags: ${{ env.IMAGE_NAME }}:${{ github.ref_name }}-${{ matrix.arch }} - # exit-code: 1 fails the job here, before the push step below runs -- - # the release gate, not just a report. + # exit-code: 1 fails the job here, before any push below runs — the + # release gate, not just a report. One scan covers every registry. - name: Trivy image scan (gate) uses: aquasecurity/trivy-action@v0.36.0 with: - image-ref: ghcr.io/${{ env.OWNER }}/discovery-service:${{ github.ref_name }}-${{ matrix.arch }} + image-ref: ${{ env.IMAGE_NAME }}:${{ github.ref_name }}-${{ matrix.arch }} severity: HIGH,CRITICAL exit-code: "1" format: table - - name: Push - run: docker push ghcr.io/${{ env.OWNER }}/discovery-service:${{ github.ref_name }}-${{ matrix.arch }} + # Re-tag the already-scanned local image for each enabled registry and + # push it there. No rebuild, no re-scan. + - name: Tag and push + run: | + LOCAL="${{ env.IMAGE_NAME }}:${{ github.ref_name }}-${{ matrix.arch }}" + TAG="${{ github.ref_name }}-${{ matrix.arch }}" + + if [ "$GHCR_ENABLED" = "true" ]; then + DEST="ghcr.io/${OWNER}/${{ env.IMAGE_NAME }}:${TAG}" + docker tag "$LOCAL" "$DEST" + docker push "$DEST" + fi + if [ "$DOCKERHUB_ENABLED" = "true" ]; then + DEST="${{ secrets.DOCKERHUB_NAMESPACE }}/${{ env.IMAGE_NAME }}:${TAG}" + docker tag "$LOCAL" "$DEST" + docker push "$DEST" + fi + if [ "$AZURE_CONTAINER_REGISTRY_ENABLED" = "true" ]; then + DEST="${{ secrets.AZURE_REGISTRY_NAME }}/${{ env.IMAGE_NAME }}:${TAG}" + docker tag "$LOCAL" "$DEST" + docker push "$DEST" + fi + if [ "$GOOGLE_ARTIFACT_REGISTRY_ENABLED" = "true" ]; then + DEST="${{ secrets.GCP_REGION }}-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID }}/${{ secrets.GOOGLE_ARTIFACT_REPO }}/${{ env.IMAGE_NAME }}:${TAG}" + docker tag "$LOCAL" "$DEST" + docker push "$DEST" + fi manifest: runs-on: ubuntu-latest @@ -106,24 +177,75 @@ jobs: steps: - uses: docker/setup-buildx-action@v3 + - name: Set registry flags + run: | + echo "GHCR_ENABLED=${{ vars.GHCR_ENABLED != 'false' }}" >> "$GITHUB_ENV" + echo "DOCKERHUB_ENABLED=${{ vars.DOCKERHUB_ENABLED == 'true' }}" >> "$GITHUB_ENV" + echo "AZURE_CONTAINER_REGISTRY_ENABLED=${{ vars.AZURE_CONTAINER_REGISTRY_ENABLED == 'true' }}" >> "$GITHUB_ENV" + echo "GOOGLE_ARTIFACT_REGISTRY_ENABLED=${{ vars.GOOGLE_ARTIFACT_REGISTRY_ENABLED == 'true' }}" >> "$GITHUB_ENV" + - name: Log in to GHCR + if: env.GHCR_ENABLED == 'true' uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GHCR_PAT || secrets.GITHUB_TOKEN }} + - name: Log in to Docker Hub + if: env.DOCKERHUB_ENABLED == 'true' + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_PASSWORD }} + + - name: Log in to Azure Container Registry + if: env.AZURE_CONTAINER_REGISTRY_ENABLED == 'true' + uses: docker/login-action@v3 + with: + registry: ${{ secrets.AZURE_REGISTRY_NAME }} + username: ${{ secrets.AZURE_REGISTRY_USERNAME }} + password: ${{ secrets.AZURE_REGISTRY_PASSWORD }} + + - name: Authenticate to Google Cloud + if: env.GOOGLE_ARTIFACT_REGISTRY_ENABLED == 'true' + uses: google-github-actions/auth@v3 + with: + token_format: "access_token" + workload_identity_provider: ${{ secrets.GOOGLE_WORKLOAD_IDENTITY_PROVIDER }} + service_account: ${{ secrets.GOOGLE_SERVICE_ACCOUNT_EMAIL }} + + - name: Configure Docker for Google Artifact Registry + if: env.GOOGLE_ARTIFACT_REGISTRY_ENABLED == 'true' + run: gcloud auth configure-docker ${{ secrets.GCP_REGION }}-docker.pkg.dev --quiet + - name: Set image owner run: echo "OWNER=$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_ENV" # imagetools create makes one manifest list from the two arch-specific - # images the build job just pushed — this is what lets `docker pull` - # resolve the right arch automatically on either amd64 or arm64. - - name: Create multi-arch manifest + # images the build job just pushed to that registry — this is what + # lets `docker pull` resolve the right arch automatically on either + # amd64 or arm64. One create_manifest call per enabled registry. + - name: Create multi-arch manifests run: | - BASE="ghcr.io/${{ env.OWNER }}/discovery-service" - docker buildx imagetools create \ - -t "${BASE}:${{ github.ref_name }}" \ - -t "${BASE}:latest" \ - "${BASE}:${{ github.ref_name }}-amd64" \ - "${BASE}:${{ github.ref_name }}-arm64" + create_manifest() { + local BASE="$1" + docker buildx imagetools create \ + -t "${BASE}:${{ github.ref_name }}" \ + -t "${BASE}:latest" \ + "${BASE}:${{ github.ref_name }}-amd64" \ + "${BASE}:${{ github.ref_name }}-arm64" + } + + if [ "$GHCR_ENABLED" = "true" ]; then + create_manifest "ghcr.io/${OWNER}/${{ env.IMAGE_NAME }}" + fi + if [ "$DOCKERHUB_ENABLED" = "true" ]; then + create_manifest "${{ secrets.DOCKERHUB_NAMESPACE }}/${{ env.IMAGE_NAME }}" + fi + if [ "$AZURE_CONTAINER_REGISTRY_ENABLED" = "true" ]; then + create_manifest "${{ secrets.AZURE_REGISTRY_NAME }}/${{ env.IMAGE_NAME }}" + fi + if [ "$GOOGLE_ARTIFACT_REGISTRY_ENABLED" = "true" ]; then + create_manifest "${{ secrets.GCP_REGION }}-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID }}/${{ secrets.GOOGLE_ARTIFACT_REPO }}/${{ env.IMAGE_NAME }}" + fi From 5e79ca6ddd4385cb1c4106cc54acc1748ff53ee1 Mon Sep 17 00:00:00 2001 From: nisargabd Date: Tue, 1 Sep 2026 17:27:29 +0530 Subject: [PATCH 20/29] fix: show full per-function coverage breakdown, not just the total [#9] Both the step summary and the PR comment only ever showed tail -1 of coverage-func.txt -- the aggregate percentage, nothing else. Show go tool cover's full per-function output instead, so a reviewer can see which functions are actually under-covered rather than just the number that fails the gate. --- .github/workflows/run-tests.yml | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index deb2c28..d0e8d56 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -50,16 +50,22 @@ jobs: run: | make cover go tool cover -func=coverage.out | tee coverage-func.txt - echo "### Coverage" >> "$GITHUB_STEP_SUMMARY" - echo '```' >> "$GITHUB_STEP_SUMMARY" - tail -1 coverage-func.txt >> "$GITHUB_STEP_SUMMARY" - echo '```' >> "$GITHUB_STEP_SUMMARY" + { + echo "### Coverage" + echo '```' + cat coverage-func.txt + echo '```' + } >> "$GITHUB_STEP_SUMMARY" { echo "" echo "### Test coverage (min ${MIN_COVERAGE}%)" + echo + echo "
Per-function breakdown" + echo echo '```' - tail -1 coverage-func.txt + cat coverage-func.txt echo '```' + echo "
" } > coverage-report.md - name: Find existing coverage comment From 8325354a3755f3b67532872912f537f3f8517604 Mon Sep 17 00:00:00 2001 From: nisargabd Date: Tue, 1 Sep 2026 17:27:48 +0530 Subject: [PATCH 21/29] refactor: replace govulncheck with a Trivy dependency scan [#9] govulncheck gave no severity for a finding (Go vulnerability DB carries none), which made triage guesswork. Trivy scans go.sum with the same NVD/GHSA severity data the image scan already uses, at the cost of losing reachability filtering: expect more findings, including ones the binary never calls. Mirrors the existing image scan structure: a report, a PR comment, a gate, this time scan-type fs against the repo instead of the built image. tools/ stays excluded from the scan for the same reason the image scan already excludes it -- that module is never linked into the shipped binary. --- .github/workflows/security.yml | 95 +++++++++++++--------------------- 1 file changed, 36 insertions(+), 59 deletions(-) diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 966e5e3..5b83c7f 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -18,88 +18,55 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - # A glob, not a list: the service module has no dependencies yet, so - # a literal go.sum path would fail to resolve and disable the cache. - cache-dependency-path: "**/go.sum" # T4, half one: known vulnerabilities in the Go dependency graph. - - name: govulncheck - run: make security 2>&1 | tee govulncheck-report.txt + # skip-dirs excludes tools/ (a separate go.mod for build-time tooling, + # e.g. golangci-lint) for the same reason the image scan below never + # touches it: it's never linked into the shipped binary, so it can't + # fail a release it doesn't ship in. + - name: Trivy dependency scan (report) + uses: aquasecurity/trivy-action@v0.36.0 + with: + scan-type: fs + scan-ref: . + skip-dirs: tools + severity: HIGH,CRITICAL + exit-code: "0" + format: table + output: trivy-deps-report.txt - - name: Assemble govulncheck PR comment + - name: Assemble Trivy dependency PR comment if: github.event_name == 'pull_request' run: | { - echo "" - echo "### 🛡️ govulncheck — Go dependency vulnerabilities" + echo "" + echo "### 🛡️ Trivy — Go dependency scan (HIGH,CRITICAL)" echo "[View full run](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID})" echo - if grep -q '^Vulnerability #' govulncheck-report.txt; then - echo "| Module | Installed | Fixed | Issue |" - echo "|---|---|---|---|" - awk ' - /^Vulnerability #/ { - id = $0; sub(/^Vulnerability #[0-9]+: /, "", id) - module = ""; summary = ""; in_summary = 1 - next - } - /More info:/ { in_summary = 0 } - in_summary { - line = $0 - gsub(/^[ \t]+/, "", line) - summary = summary (summary == "" ? "" : " ") line - next - } - /^ Module:/ { line = $0; sub(/^ Module: */, "", line); module = line } - /^ Standard library/ { module = "Go standard library" } - /Found in:/ { line = $0; sub(/^.*Found in: */, "", line); if (index(line, "@") > 0) { split(line, a, "@"); line = a[2] }; installed = line } - /Fixed in:/ { - line = $0; sub(/^.*Fixed in: */, "", line) - if (index(line, "@") > 0) { split(line, a, "@"); line = a[2] } - fixed = line - - # the summary usually repeats the module path as a prefix - # ("moby/go-archive: Crafted tar archive..."); drop it, the - # Module column already has it - base = module - sub(/^[a-zA-Z0-9.-]+\//, "", base) - prefix = base ": " - if (index(summary, prefix) == 1) summary = substr(summary, length(prefix) + 1) - - printf "| %s | %s | %s | [%s](https://pkg.go.dev/vuln/%s) |\n", module, installed, fixed, summary, id - } - ' govulncheck-report.txt - else - echo "No known vulnerabilities found." - fi - echo - echo "
Full govulncheck output" + echo "
Findings" echo echo '```' - cat govulncheck-report.txt + cat trivy-deps-report.txt echo '```' echo "
" - } > govulncheck-comment.md + } > trivy-deps-comment.md - - name: Find existing govulncheck comment + - name: Find existing Trivy dependency comment if: github.event_name == 'pull_request' uses: peter-evans/find-comment@v3 - id: find-govulncheck-comment + id: find-trivy-deps-comment with: issue-number: ${{ github.event.pull_request.number }} comment-author: "github-actions[bot]" - body-includes: "" + body-includes: "" - - name: Comment govulncheck report on PR + - name: Comment Trivy dependency report on PR if: github.event_name == 'pull_request' uses: peter-evans/create-or-update-comment@v4 with: - comment-id: ${{ steps.find-govulncheck-comment.outputs.comment-id }} + comment-id: ${{ steps.find-trivy-deps-comment.outputs.comment-id }} issue-number: ${{ github.event.pull_request.number }} - body-path: govulncheck-comment.md + body-path: trivy-deps-comment.md edit-mode: replace # Base images are dhi.io/*, a Docker Hardened Images registry gated on a @@ -174,6 +141,16 @@ jobs: edit-mode: replace # Gate the PR on the same findings the comments above already showed. + - name: Trivy dependency scan (gate) + uses: aquasecurity/trivy-action@v0.36.0 + with: + scan-type: fs + scan-ref: . + skip-dirs: tools + severity: HIGH,CRITICAL + exit-code: "1" + format: table + - name: Trivy image scan (gate) uses: aquasecurity/trivy-action@v0.36.0 with: From 13b06d5951ec59c709b428ab31d81e67e60aa2d6 Mon Sep 17 00:00:00 2001 From: nisargabd Date: Tue, 1 Sep 2026 21:21:35 +0530 Subject: [PATCH 22/29] refactor: report Trivy findings as SARIF, gate once from the reports [#9] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two problems with the previous PR-comment approach. First, it needed a hand-rolled table renderer (jq/awk) to turn Trivy's ASCII table into markdown, which is structure this workflow shouldn't have to define or maintain — GitHub's own code-scanning UI already renders SARIF, with diff annotations the comment approach never had. Second, the gate rescanned both targets a third and fourth time to decide pass/fail; Trivy refreshes its vulnerability DB on every invocation, so a rescan can disagree with the report it's supposedly gating on. The gate now counts results already sitting in the two uploaded SARIF files, so the Security tab and the pass/fail decision are the same answer by construction. limit-severities-for-sarif is required for the SARIF to obey `severity: HIGH,CRITICAL` at all — without it every severity Trivy knows lands in the report, and the gate would need its own filtering logic to match. govulncheck is gone from this job. Its advisory feed carries no severity field, so "fail on HIGH/CRITICAL" was never expressible against its output — it could only report, never gate. Coverage this drops (call-graph reachability, and Go stdlib CVEs against the toolchain version pinned in go.mod) is a known, accepted gap, not a blind spot: verified locally that the one real finding it ever caught (github.com/moby/go-archive, GO-2026-6253 / CVE-2026-17106) is identical to what Trivy's dependency scan reports as CVE-2026-17106. --- .github/workflows/security.yml | 158 +++++++++++++-------------------- 1 file changed, 63 insertions(+), 95 deletions(-) diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 5b83c7f..6025708 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -7,7 +7,10 @@ on: permissions: contents: read - pull-requests: write + # Findings are uploaded as SARIF and rendered by GitHub's own code scanning + # UI — the Security tab, plus annotations on the diff — rather than pasted + # into a pull request comment this workflow has to format itself. + security-events: write concurrency: group: security-${{ github.ref }} @@ -19,11 +22,20 @@ jobs: steps: - uses: actions/checkout@v4 - # T4, half one: known vulnerabilities in the Go dependency graph. + # T4, half one: the dependency graph as written. This catches what the + # image scan structurally cannot — a vulnerable module that only the test + # suite imports, so it is never linked into the binary and never appears + # in a layer. The first real finding was exactly that shape: + # github.com/moby/go-archive, pulled in by testcontainers. + # # skip-dirs excludes tools/ (a separate go.mod for build-time tooling, - # e.g. golangci-lint) for the same reason the image scan below never - # touches it: it's never linked into the shipped binary, so it can't - # fail a release it doesn't ship in. + # e.g. golangci-lint) for the reason that module exists at all: the + # linter's dependency graph is not the binary's, so it cannot fail a + # release it does not ship in. + # + # limit-severities-for-sarif is what makes the SARIF obey `severity`. + # Without it the report carries every severity Trivy knows and the + # Security tab fills with LOW noise the gate below would never fail on. - name: Trivy dependency scan (report) uses: aquasecurity/trivy-action@v0.36.0 with: @@ -31,43 +43,18 @@ jobs: scan-ref: . skip-dirs: tools severity: HIGH,CRITICAL + limit-severities-for-sarif: true exit-code: "0" - format: table - output: trivy-deps-report.txt - - - name: Assemble Trivy dependency PR comment - if: github.event_name == 'pull_request' - run: | - { - echo "" - echo "### 🛡️ Trivy — Go dependency scan (HIGH,CRITICAL)" - echo "[View full run](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID})" - echo - echo "
Findings" - echo - echo '```' - cat trivy-deps-report.txt - echo '```' - echo "
" - } > trivy-deps-comment.md - - - name: Find existing Trivy dependency comment - if: github.event_name == 'pull_request' - uses: peter-evans/find-comment@v3 - id: find-trivy-deps-comment - with: - issue-number: ${{ github.event.pull_request.number }} - comment-author: "github-actions[bot]" - body-includes: "" + format: sarif + output: trivy-deps.sarif - - name: Comment Trivy dependency report on PR - if: github.event_name == 'pull_request' - uses: peter-evans/create-or-update-comment@v4 + # A category per scan: without it the second upload replaces the first, + # because GitHub keys an analysis on (ref, category). + - name: Upload dependency findings + uses: github/codeql-action/upload-sarif@v3 with: - comment-id: ${{ steps.find-trivy-deps-comment.outputs.comment-id }} - issue-number: ${{ github.event.pull_request.number }} - body-path: trivy-deps-comment.md - edit-mode: replace + sarif_file: trivy-deps.sarif + category: trivy-dependencies # Base images are dhi.io/*, a Docker Hardened Images registry gated on a # Docker subscription — anonymous pulls 401. Same login a local build @@ -91,9 +78,13 @@ jobs: {"auths":{"dhi.io":{"auth":"${AUTH}"}}} EOF - # T4, half two: the shipped image. The scan is of the image and not of - # go.sum so that the toolchain pinned in tools/go.mod — which is never - # linked into the binary — cannot fail a release it does not ship in. + # T4, half two: the shipped image, which is what the dependency scan + # above cannot see. It reads the base layers, and it reads the Go build + # info embedded in the binary — including `stdlib`, so a Go toolchain CVE + # is reported here and nowhere else. Trivy does not derive stdlib from + # go.mod's `go` directive: measured, a go.mod declaring `go 1.21.0` + # reports zero findings, and the same vulnerabilities appear as `stdlib` + # once the compiled binary is scanned. - name: Build image run: docker build -t discovery-service:${{ github.sha }} . @@ -102,59 +93,36 @@ jobs: with: image-ref: discovery-service:${{ github.sha }} severity: HIGH,CRITICAL + limit-severities-for-sarif: true exit-code: "0" - format: table - output: trivy-report.txt - - - name: Assemble Trivy PR comment - if: github.event_name == 'pull_request' - run: | - { - echo "" - echo "### 🛡️ Trivy — image scan (HIGH,CRITICAL)" - echo "[View full run](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID})" - echo - echo "
Findings" - echo - echo '```' - cat trivy-report.txt - echo '```' - echo "
" - } > trivy-comment.md - - - name: Find existing Trivy comment - if: github.event_name == 'pull_request' - uses: peter-evans/find-comment@v3 - id: find-trivy-comment - with: - issue-number: ${{ github.event.pull_request.number }} - comment-author: "github-actions[bot]" - body-includes: "" - - - name: Comment Trivy report on PR - if: github.event_name == 'pull_request' - uses: peter-evans/create-or-update-comment@v4 - with: - comment-id: ${{ steps.find-trivy-comment.outputs.comment-id }} - issue-number: ${{ github.event.pull_request.number }} - body-path: trivy-comment.md - edit-mode: replace + format: sarif + output: trivy-image.sarif - # Gate the PR on the same findings the comments above already showed. - - name: Trivy dependency scan (gate) - uses: aquasecurity/trivy-action@v0.36.0 - with: - scan-type: fs - scan-ref: . - skip-dirs: tools - severity: HIGH,CRITICAL - exit-code: "1" - format: table - - - name: Trivy image scan (gate) - uses: aquasecurity/trivy-action@v0.36.0 + - name: Upload image findings + uses: github/codeql-action/upload-sarif@v3 with: - image-ref: discovery-service:${{ github.sha }} - severity: HIGH,CRITICAL - exit-code: "1" - format: table + sarif_file: trivy-image.sarif + category: trivy-image + + # Gate on the reports already produced rather than scanning a third and + # fourth time. Two independent scans of the same thing can disagree — + # Trivy refreshes its database on each run — so a gate that rescans can + # fail on a finding that is in no uploaded report, which is the one state + # nobody can act on. Reading the SARIF makes the gate and the Security + # tab the same answer by construction. + # + # Last in the job, so a red build still leaves both reports behind. + # limit-severities-for-sarif above is what makes counting results + # equivalent to Trivy's own `--exit-code 1 --severity HIGH,CRITICAL`. + - name: Gate on HIGH or CRITICAL findings + run: | + fail=0 + for report in trivy-deps.sarif trivy-image.sarif; do + count=$(jq '[.runs[].results[]?] | length' "$report") + echo "${report}: ${count} HIGH/CRITICAL" + if [ "$count" -gt 0 ]; then + jq -r '.runs[].results[]? | "::error::\(.ruleId) \(.message.text)"' "$report" + fail=1 + fi + done + exit "$fail" From e76cf29616f8359f89370b338ef1395f336695ae Mon Sep 17 00:00:00 2001 From: nisargabd Date: Tue, 1 Sep 2026 21:22:56 +0530 Subject: [PATCH 23/29] refactor: run tests through gotestsum, gate coverage with go-test-coverage [#9] go test's own output has no per-test summary to build a report from, which is why the previous coverage step ran the whole suite a second time under make cover just to get a profile - same tests, paid for twice. gotestsum's format pkgname gives one line per package with its coverage inline and full output for failures only, from the same run that produces the coverage profile. The github-actions format was tried first and dropped: gotestsum drives go test in verbose JSON mode, so every per-test format also prints PASSING tests' output - and the acceptance suite logs a spec-fetch warning on every test, which turned into 755 collapsible log groups of mostly-identical warnings. pkgname does not have that failure mode. go-test-coverage replaces the raw per-function coverage dump for the same reason: that dump lists all ~550 functions regardless of coverage, where go-test-coverage leads with the total and pass/fail and then lists only the files with gaps - 59 lines instead of 550 on this repo. The step runs with continue-on-error so a failing threshold still produces a report; the last step turns that outcome back into a hard failure. fromJSON on the report output needed a fallback for the case the coverage step fails before producing one - an unguarded call would fail the job with a template error instead of a coverage message. --- .github/workflows/run-tests.yml | 73 +++++++++++++++++++++++++-------- 1 file changed, 55 insertions(+), 18 deletions(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index d0e8d56..609a4bb 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -19,6 +19,7 @@ env: # was deferred. EMBEDDING_PROVIDER: hashing MIN_COVERAGE: "80" + GOTESTSUM_VERSION: v1.13.0 jobs: lint: @@ -44,29 +45,66 @@ jobs: # a literal go.sum path would fail to resolve and disable the cache. cache-dependency-path: "**/go.sum" - run: make build - - run: make test - - name: Coverage + # gotestsum instead of `go test` directly: one tick per package with its + # coverage on the same line, and the output of failing tests only. + # Installed here rather than pinned in tools/go.mod because this is the + # only place that needs it — `make test` stays as it is. + - name: Install gotestsum + run: go install gotest.tools/gotestsum@${{ env.GOTESTSUM_VERSION }} + + # pkgname, not the github-actions format. gotestsum drives `go test + # -json`, which implies -v, so every format that prints per-test output + # prints the output of PASSING tests too — and the acceptance suite logs + # a spec-fetch warning per test. Measured: the github-actions format + # emits 755 collapsible groups, most of them holding a warning about a + # cache fallback that is the intended behaviour. pkgname reports one line + # per package and still prints failing tests in full, with file:line. + # + # One run, not two: the profile costs nothing on top of the suite, so the + # previous `make test` plus `make cover` pair paid for every test twice + # to learn the same pass/fail answer. EMBEDDING_PROVIDER is pinned at the + # workflow level, which is what the Makefile's TEST_ENV does locally. + - name: Test + run: | + gotestsum --format pkgname --format-hide-empty-pkg -- \ + -race -coverprofile=coverage.out -covermode=atomic ./... + + # continue-on-error so a coverage miss still posts its report; the last + # step in the job turns that outcome back into a failure. + - name: Check coverage + id: coverage + continue-on-error: true + uses: vladopajic/go-test-coverage@v2 + with: + profile: coverage.out + threshold-total: ${{ env.MIN_COVERAGE }} + + # The report leads with the total and the pass/fail, then lists only the + # files that have uncovered lines — 59 lines against the 550 that + # `go tool cover -func` prints, because it says nothing about the files + # that are already fully covered. + # + # The `&&`/`||` guard is for the case the step above failed before + # producing an output: fromJSON('') is an error, and a coverage report + # that cannot be built must not fail the job with a template error that + # says nothing about coverage. + - name: Assemble coverage report + env: + REPORT: >- + ${{ steps.coverage.outputs.report + && fromJSON(steps.coverage.outputs.report) + || 'Coverage report unavailable — see the Test step.' }} run: | - make cover - go tool cover -func=coverage.out | tee coverage-func.txt - { - echo "### Coverage" - echo '```' - cat coverage-func.txt - echo '```' - } >> "$GITHUB_STEP_SUMMARY" { echo "" echo "### Test coverage (min ${MIN_COVERAGE}%)" echo - echo "
Per-function breakdown" - echo echo '```' - cat coverage-func.txt + printf '%s\n' "$REPORT" echo '```' - echo "
" } > coverage-report.md + cat coverage-report.md >> "$GITHUB_STEP_SUMMARY" - name: Find existing coverage comment if: github.event_name == 'pull_request' @@ -87,8 +125,7 @@ jobs: edit-mode: replace - name: Gate on minimum coverage + if: steps.coverage.outcome == 'failure' run: | - pct=$(tail -1 coverage-func.txt | grep -oE '[0-9]+\.[0-9]+%$' | tr -d '%') - echo "coverage: ${pct}%, minimum: ${MIN_COVERAGE}%" - awk -v pct="$pct" -v min="$MIN_COVERAGE" 'BEGIN { exit !(pct >= min) }' \ - || { echo "::error::coverage ${pct}% is below the ${MIN_COVERAGE}% minimum"; exit 1; } + echo "::error::coverage is below the ${MIN_COVERAGE}% minimum" + exit 1 From 19bfb987cf452c5482c96dfb8a10f2bf992f60ca Mon Sep 17 00:00:00 2001 From: nisargabd Date: Tue, 1 Sep 2026 21:23:25 +0530 Subject: [PATCH 24/29] fix: make the lint job non-blocking on PR merge [#9] continue-on-error on the make lint step: the job still runs and still reports its real pass or fail state, but its conclusion is always success, so it can never gate a merge - including under a future required-status-checks rule someone adds without re-reading this file. CLAUDE.md keeps CI lint as the safety net behind the pre-commit hook specifically because that hook can be bypassed on the command line; this does not remove the safety net, only its ability to block. A red lint check should be a reason to look, never a reason a PR cannot merge - unlike the security job, whose gate stays hard. --- .github/workflows/run-tests.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 609a4bb..2a64bbf 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -32,7 +32,13 @@ jobs: # A glob, not a list: the service module has no dependencies yet, so # a literal go.sum path would fail to resolve and disable the cache. cache-dependency-path: "**/go.sum" + + # continue-on-error rather than dropping the job: CLAUDE.md keeps CI + # lint as the safety net behind the pre-commit hook, which is bypassable + # (--no-verify). Advisory here, not a gate — a red lint check must never + # be the reason a PR can't merge, only a reason someone looks. - run: make lint + continue-on-error: true test: runs-on: ubuntu-latest From 4b07b9a37233534e0a129feac53a82246e3c603c Mon Sep 17 00:00:00 2001 From: nisargabd Date: Tue, 1 Sep 2026 22:38:09 +0530 Subject: [PATCH 25/29] fix: don't fail CI when a fork PR can't post the coverage comment [#9] 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. --- .github/workflows/run-tests.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 2a64bbf..cc107ad 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -112,8 +112,13 @@ jobs: } > coverage-report.md cat coverage-report.md >> "$GITHUB_STEP_SUMMARY" + # continue-on-error on both: a fork PR's default GITHUB_TOKEN is + # read-only, so create-or-update-comment 403s there even with + # pull-requests: write declared. A comment that can't post must never + # fail the job — coverage already gated above, this step is cosmetic. - name: Find existing coverage comment if: github.event_name == 'pull_request' + continue-on-error: true uses: peter-evans/find-comment@v3 id: find-coverage-comment with: @@ -123,6 +128,7 @@ jobs: - name: Comment coverage on PR if: github.event_name == 'pull_request' + continue-on-error: true uses: peter-evans/create-or-update-comment@v4 with: comment-id: ${{ steps.find-coverage-comment.outputs.comment-id }} From 974a1a977dff6a3970d2463207be5c83e0decc26 Mon Sep 17 00:00:00 2001 From: nisargabd Date: Tue, 1 Sep 2026 23:10:15 +0530 Subject: [PATCH 26/29] fix: replace the coverage and Trivy PR-comment dumps with clean tables [#9] Coverage: go-test-coverage's own report output is the same tabwriter dump ReportForHuman prints to a terminal, JSON-wrapped as one string - not structured data, so consuming it verbatim just moved the dump into the PR comment. Built from coverage.out directly instead: the headline from the action's total-coverage output, and a real markdown table listing only the files below the threshold - 18 rows instead of 68, and no uncovered-line-range noise, which matters to whoever opens the file and not to a PR summary. Trivy: added the PR comment back for both scans, posted right after each report step - pass or fail, before the gate even runs - reading the same SARIF already produced for the code-scanning upload, so the comment and the Security tab can never disagree. Trivy's SARIF has no structured severity/version fields; each finding's detail lives as prose in message.text, so the table columns are pulled out of that text with jq capture. Kept inline in the workflow rather than a separate script file - two call sites do not need a third file to stay in sync. Both PR-comment steps get continue-on-error: a fork PR's default GITHUB_TOKEN is read-only and 403s there even with pull-requests: write declared; the gate is what decides pass or fail, not the comment. --- .github/workflows/run-tests.yml | 66 ++++++++++++------ .github/workflows/security.yml | 114 +++++++++++++++++++++++++++++++- 2 files changed, 156 insertions(+), 24 deletions(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index cc107ad..c6349e7 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -86,30 +86,54 @@ jobs: profile: coverage.out threshold-total: ${{ env.MIN_COVERAGE }} - # The report leads with the total and the pass/fail, then lists only the - # files that have uncovered lines — 59 lines against the 550 that - # `go tool cover -func` prints, because it says nothing about the files - # that are already fully covered. + # go-test-coverage's own `report` output is the same tabwriter dump + # ReportForHuman prints to a terminal, JSON-wrapped as one big string — + # not structured data, so consuming it verbatim just moves the dump into + # the PR comment. Built from coverage.out directly instead: the headline + # from the action's `total-coverage` output (a clean 0-100 integer, no + # parsing needed), and a real markdown table for the rest — only the + # files below the threshold, since a table entry for a file that's + # already fine answers a question nobody asked. No uncovered-line + # ranges: those matter to whoever opens the file, not to a PR summary. # - # The `&&`/`||` guard is for the case the step above failed before - # producing an output: fromJSON('') is an error, and a coverage report - # that cannot be built must not fail the job with a template error that - # says nothing about coverage. + # The file-level percentage is computed the same way `go tool cover` + # computes it — covered statements over total statements per file, read + # straight from the profile's `file:pos numStmt count` lines — so it + # can't drift from what the gate itself measured. - name: Assemble coverage report - env: - REPORT: >- - ${{ steps.coverage.outputs.report - && fromJSON(steps.coverage.outputs.report) - || 'Coverage report unavailable — see the Test step.' }} run: | - { - echo "" - echo "### Test coverage (min ${MIN_COVERAGE}%)" - echo - echo '```' - printf '%s\n' "$REPORT" - echo '```' - } > coverage-report.md + TOTAL="${{ steps.coverage.outputs.total-coverage }}" + if [ -z "$TOTAL" ]; then + { + echo "" + echo "### Test coverage unavailable — see the Test step" + } > coverage-report.md + else + STATUS="passed" + [ "$TOTAL" -lt "$MIN_COVERAGE" ] && STATUS="failed" + MODULE="github.com/OpenAgriNet/discovery-service/" + { + echo "" + echo "### Test coverage: ${TOTAL}% (min ${MIN_COVERAGE}%) — ${STATUS}" + echo + BELOW=$(awk -F'[: ]' -v mod="$MODULE" -v min="$MIN_COVERAGE" 'NR>1 { + f=$1; s=$(NF-1); c=$NF + t[f]+=s; if (c>0) v[f]+=s + } + END { + for (f in t) { p=v[f]/t[f]*100; sub(mod,"",f); if (p coverage-report.md + fi cat coverage-report.md >> "$GITHUB_STEP_SUMMARY" # continue-on-error on both: a fork PR's default GITHUB_TOKEN is diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 6025708..ae07f2a 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -7,9 +7,12 @@ on: permissions: contents: read - # Findings are uploaded as SARIF and rendered by GitHub's own code scanning - # UI — the Security tab, plus annotations on the diff — rather than pasted - # into a pull request comment this workflow has to format itself. + # Findings are uploaded as SARIF for GitHub's own code scanning UI (Security + # tab, diff annotations) AND posted as a PR comment — the SARIF path alone + # took three clicks (check -> run -> results) to see a finding that used to + # be one scroll away in the PR conversation. pull-requests: write is for + # the comment; security-events: write is for the SARIF upload. + pull-requests: write security-events: write concurrency: @@ -56,6 +59,65 @@ jobs: sarif_file: trivy-deps.sarif category: trivy-dependencies + # Posted right after the report, on every run — pass or fail, and + # before the image is even built — so a PR shows the dependency finding + # immediately rather than after the whole job finishes. Reads the same + # SARIF just uploaded above; no second scan. + # + # Trivy's SARIF has no structured per-field severity/version columns; + # each finding's detail lives as prose in message.text ("Package: + # ...\nSeverity: ...\n..."), so the table columns below are pulled out + # of that text rather than from dedicated JSON fields. + - name: Assemble Trivy dependency PR comment + if: github.event_name == 'pull_request' + run: | + { + echo "" + echo "### 🛡️ Trivy — Go dependency scan (HIGH,CRITICAL)" + echo "[View full run](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID})" + echo + jq -r ' + [ .runs[].results[]? ] as $found + | if ($found | length) == 0 then + "No HIGH or CRITICAL findings." + else + "| Package | Severity | Installed | Fixed in | Advisory |", + "|---|---|---|---|---|", + ( $found[] | .ruleId as $id | (.message.text // "") as $m | + "| `\($m | capture("Package: (?[^\\n]+)")?.v // "?")` " + + "| \($m | capture("Severity: (?[^\\n]+)")?.v // "?") " + + "| \($m | capture("Installed Version: (?[^\\n]+)")?.v // "?") " + + "| \($m | capture("Fixed Version: (?[^\\n]+)")?.v // "—") " + + "| [\($id)](\($m | capture("Link: \\[[^]]+\\]\\((?[^)]+)\\)")?.v // "")) |" + ) + end + ' trivy-deps.sarif + } > trivy-deps-comment.md + + - name: Find existing Trivy dependency comment + if: github.event_name == 'pull_request' + continue-on-error: true + uses: peter-evans/find-comment@v3 + id: find-trivy-deps-comment + with: + issue-number: ${{ github.event.pull_request.number }} + comment-author: "github-actions[bot]" + body-includes: "" + + # continue-on-error: a fork PR's default GITHUB_TOKEN is read-only, so + # this 403s there even with pull-requests: write declared. The gate at + # the bottom of this job is what actually decides pass/fail; a comment + # that can't post must never fail the job over that. + - name: Comment Trivy dependency report on PR + if: github.event_name == 'pull_request' + continue-on-error: true + uses: peter-evans/create-or-update-comment@v4 + with: + comment-id: ${{ steps.find-trivy-deps-comment.outputs.comment-id }} + issue-number: ${{ github.event.pull_request.number }} + body-path: trivy-deps-comment.md + edit-mode: replace + # Base images are dhi.io/*, a Docker Hardened Images registry gated on a # Docker subscription — anonymous pulls 401. Same login a local build # needs (see the Dockerfile's FROM comment), just non-interactive here. @@ -104,6 +166,52 @@ jobs: sarif_file: trivy-image.sarif category: trivy-image + - name: Assemble Trivy image PR comment + if: github.event_name == 'pull_request' + run: | + { + echo "" + echo "### 🛡️ Trivy — image scan (HIGH,CRITICAL)" + echo "[View full run](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID})" + echo + jq -r ' + [ .runs[].results[]? ] as $found + | if ($found | length) == 0 then + "No HIGH or CRITICAL findings." + else + "| Package | Severity | Installed | Fixed in | Advisory |", + "|---|---|---|---|---|", + ( $found[] | .ruleId as $id | (.message.text // "") as $m | + "| `\($m | capture("Package: (?[^\\n]+)")?.v // "?")` " + + "| \($m | capture("Severity: (?[^\\n]+)")?.v // "?") " + + "| \($m | capture("Installed Version: (?[^\\n]+)")?.v // "?") " + + "| \($m | capture("Fixed Version: (?[^\\n]+)")?.v // "—") " + + "| [\($id)](\($m | capture("Link: \\[[^]]+\\]\\((?[^)]+)\\)")?.v // "")) |" + ) + end + ' trivy-image.sarif + } > trivy-image-comment.md + + - name: Find existing Trivy image comment + if: github.event_name == 'pull_request' + continue-on-error: true + uses: peter-evans/find-comment@v3 + id: find-trivy-image-comment + with: + issue-number: ${{ github.event.pull_request.number }} + comment-author: "github-actions[bot]" + body-includes: "" + + - name: Comment Trivy image report on PR + if: github.event_name == 'pull_request' + continue-on-error: true + uses: peter-evans/create-or-update-comment@v4 + with: + comment-id: ${{ steps.find-trivy-image-comment.outputs.comment-id }} + issue-number: ${{ github.event.pull_request.number }} + body-path: trivy-image-comment.md + edit-mode: replace + # Gate on the reports already produced rather than scanning a third and # fourth time. Two independent scans of the same thing can disagree — # Trivy refreshes its database on each run — so a gate that rescans can From 49b2d8eba5df3c9a4f0a6979ecdca5fb595657eb Mon Sep 17 00:00:00 2001 From: nisargabd Date: Tue, 1 Sep 2026 23:19:48 +0530 Subject: [PATCH 27/29] fix: make Trivy PR-comment jq compatible with the runner's jq 1.7.1 [#9] capture(...)?.v is valid jq syntax on 1.8 (a workstation) but a syntax error on 1.7.1 (what the ubuntu-24.04 runner ships) - postfix ? chained straight into a field access parses differently across versions. That syntax error killed the whole job in 26s, before the image was even built, which is why no Trivy report showed up on the PR at all despite the comment logic being otherwise correct. Replaced with a def that wraps the capture in an explicit // against a plain object before the field access, which is unambiguous on both versions - verified against a real jq 1.7.1 binary, not assumed. Also added continue-on-error to both Assemble ... PR comment steps themselves, matching the Find/Comment steps below them: a report formatting step must never be able to take the whole job down again, jq bug or not - the gate at the bottom is what decides pass or fail. --- .github/workflows/security.yml | 31 +++++++++++++++++++++---------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index ae07f2a..cd156c5 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -68,8 +68,16 @@ jobs: # each finding's detail lives as prose in message.text ("Package: # ...\nSeverity: ...\n..."), so the table columns below are pulled out # of that text rather than from dedicated JSON fields. + # continue-on-error: this step's own jq bug is what took the whole job + # down last run — capture(...)?.v is valid on jq 1.8 (what a workstation + # has) but a syntax error on jq 1.7.1 (what this runner image ships), + # so it never reached the image build at all. Fixed below with a `def`, + # 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 if: github.event_name == 'pull_request' + continue-on-error: true run: | { echo "" @@ -77,6 +85,7 @@ jobs: echo "[View full run](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID})" echo jq -r ' + def val(re; default): (capture(re) // {v: default}).v; [ .runs[].results[]? ] as $found | if ($found | length) == 0 then "No HIGH or CRITICAL findings." @@ -84,11 +93,11 @@ jobs: "| Package | Severity | Installed | Fixed in | Advisory |", "|---|---|---|---|---|", ( $found[] | .ruleId as $id | (.message.text // "") as $m | - "| `\($m | capture("Package: (?[^\\n]+)")?.v // "?")` " - + "| \($m | capture("Severity: (?[^\\n]+)")?.v // "?") " - + "| \($m | capture("Installed Version: (?[^\\n]+)")?.v // "?") " - + "| \($m | capture("Fixed Version: (?[^\\n]+)")?.v // "—") " - + "| [\($id)](\($m | capture("Link: \\[[^]]+\\]\\((?[^)]+)\\)")?.v // "")) |" + "| `" + ($m | val("Package: (?[^\\n]+)"; "?")) + "` " + + "| " + ($m | val("Severity: (?[^\\n]+)"; "?")) + " " + + "| " + ($m | val("Installed Version: (?[^\\n]+)"; "?")) + " " + + "| " + ($m | val("Fixed Version: (?[^\\n]+)"; "—")) + " " + + "| [" + $id + "](" + ($m | val("Link: \\[[^]]+\\]\\((?[^)]+)\\)"; "")) + ") |" ) end ' trivy-deps.sarif @@ -168,6 +177,7 @@ jobs: - name: Assemble Trivy image PR comment if: github.event_name == 'pull_request' + continue-on-error: true run: | { echo "" @@ -175,6 +185,7 @@ jobs: echo "[View full run](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID})" echo jq -r ' + def val(re; default): (capture(re) // {v: default}).v; [ .runs[].results[]? ] as $found | if ($found | length) == 0 then "No HIGH or CRITICAL findings." @@ -182,11 +193,11 @@ jobs: "| Package | Severity | Installed | Fixed in | Advisory |", "|---|---|---|---|---|", ( $found[] | .ruleId as $id | (.message.text // "") as $m | - "| `\($m | capture("Package: (?[^\\n]+)")?.v // "?")` " - + "| \($m | capture("Severity: (?[^\\n]+)")?.v // "?") " - + "| \($m | capture("Installed Version: (?[^\\n]+)")?.v // "?") " - + "| \($m | capture("Fixed Version: (?[^\\n]+)")?.v // "—") " - + "| [\($id)](\($m | capture("Link: \\[[^]]+\\]\\((?[^)]+)\\)")?.v // "")) |" + "| `" + ($m | val("Package: (?[^\\n]+)"; "?")) + "` " + + "| " + ($m | val("Severity: (?[^\\n]+)"; "?")) + " " + + "| " + ($m | val("Installed Version: (?[^\\n]+)"; "?")) + " " + + "| " + ($m | val("Fixed Version: (?[^\\n]+)"; "—")) + " " + + "| [" + $id + "](" + ($m | val("Link: \\[[^]]+\\]\\((?[^)]+)\\)"; "")) + ") |" ) end ' trivy-image.sarif From b3897b3b7aa3fc9abdf98f2dfde3c2b96b7dbb49 Mon Sep 17 00:00:00 2001 From: nisargabd Date: Tue, 1 Sep 2026 23:42:29 +0530 Subject: [PATCH 28/29] fix: let Dockerfile build without a DHI subscription [#9] 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. --- Dockerfile | 40 ++++++++++++++++++++++++++++++++-------- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/Dockerfile b/Dockerfile index 5b5e98d..5272307 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,15 +1,37 @@ # syntax=docker/dockerfile:1 # DHI (Docker Hardened Images) requires `docker login dhi.io` first — a Docker -# subscription credential this build cannot supply. Tag verified against +# subscription credential this build cannot supply. Both stages default to it +# because it's what CI and every shipped image use, but a local build without +# that subscription can override both build-args with public equivalents: +# +# docker build \ +# --build-arg BUILD_IMAGE=golang:1.27-alpine \ +# --build-arg RUNTIME_IMAGE=gcr.io/distroless/static-debian12:nonroot \ +# --build-arg INSTALL_BUILD_TOOLS=true \ +# -t discovery-service:dev . +# +# INSTALL_BUILD_TOOLS=true is required with the public build image: DHI's +# -dev variant already bundles gcc/g++/musl-dev/make (see below), the public +# golang:alpine image does not, and this build needs a C toolchain regardless +# of which base runs it. +ARG BUILD_IMAGE=dhi.io/golang:1.27-alpine-dev +ARG RUNTIME_IMAGE=dhi.io/static:20260611-musl-alpine +ARG INSTALL_BUILD_TOOLS=false + +# Tag verified against # github.com/docker-hardened-images/catalog image/golang/alpine-3.24/1.27-dev.yaml; -# that -dev variant already bundles gcc/g++/musl-dev/make, which is why there's -# no `apk add` here — github.com/uber/h3-go/v4 is a cgo binding around Uber's C -# library (there is no pure-Go H3, and H3 is how every geometry in the index is -# covered), so it needs the toolchain the -dev variant already carries. -FROM dhi.io/golang:1.27-alpine-dev AS build +# that -dev variant already bundles gcc/g++/musl-dev/make, which is why the +# apk add below is conditional rather than unconditional — github.com/uber/h3-go/v4 +# is a cgo binding around Uber's C library (there is no pure-Go H3, and H3 is +# how every geometry in the index is covered), so this build needs a C +# toolchain from somewhere. +FROM ${BUILD_IMAGE} AS build WORKDIR /src +ARG INSTALL_BUILD_TOOLS +RUN if [ "$INSTALL_BUILD_TOOLS" = "true" ]; then apk add --no-cache gcc g++ musl-dev make; fi + # Dependency layer first: go.mod and go.sum change far less often than source, # so the module download stays cached across ordinary code edits. go.su[m] is a # glob rather than a plain name because a module with no dependencies yet has @@ -40,8 +62,10 @@ RUN CGO_ENABLED=1 go build -trimpath -ldflags="-s -w -extldflags '-static'" \ # dhi/static musl-alpine variant, verified against # image/static/alpine-3.24/static-musl.yaml — runs as uid 65532 / user # "nonroot" by default already, same as the distroless image it replaces, and -# matches the musl static link above. -FROM dhi.io/static:20260611-musl-alpine AS runtime +# matches the musl static link above. distroless/static-nonroot (the public +# override) uses the same uid 65532 convention, so USER below needs no +# override of its own. +FROM ${RUNTIME_IMAGE} AS runtime WORKDIR /app COPY --from=build /out/discovery-service /app/discovery-service From da1467343b4c1dfd9ce1173647be9c5d6b17d987 Mon Sep 17 00:00:00 2001 From: nisargabd Date: Tue, 1 Sep 2026 23:50:09 +0530 Subject: [PATCH 29/29] fix: bump golang.org/x/crypto to close CVE-2026-56854 [#9] Trivy's image-scan gate in build-and-push.yml caught this on a real tag push to a fork (v0.1.0-rc1): an SSH source-address restriction bypass in golang.org/x/crypto, pulled in transitively and actually linked into the shipped binary - unlike moby/go-archive, which stays unbumped on purpose since it is test-only and never reaches the image. v0.55.0 fixes it upstream. Scoped to this one bump only: lint, build and the full test suite all still pass, confirmed locally before pushing. --- go.mod | 4 ++-- go.sum | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 7af65c1..cb53813 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,7 @@ require ( github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0 github.com/uber/h3-go/v4 v4.5.0 go.uber.org/zap v1.27.0 - golang.org/x/crypto v0.54.0 + golang.org/x/crypto v0.55.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -78,5 +78,5 @@ require ( go.uber.org/multierr v1.10.0 // indirect golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.47.0 // indirect - golang.org/x/text v0.40.0 // indirect + golang.org/x/text v0.41.0 // indirect ) diff --git a/go.sum b/go.sum index 5fc99e5..6e7df69 100644 --- a/go.sum +++ b/go.sum @@ -179,8 +179,8 @@ go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= -golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= -golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -191,8 +191,8 @@ golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= -golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= -golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=