Skip to content

🐛 [v6] stage: a fork PR red only on a maintainer-run fork guard goes to the maintainer on its first red head, and its author is not nudged #20371

🐛 [v6] stage: a fork PR red only on a maintainer-run fork guard goes to the maintainer on its first red head, and its author is not nudged

🐛 [v6] stage: a fork PR red only on a maintainer-run fork guard goes to the maintainer on its first red head, and its author is not nudged #20371

name: Go Security Analysis
# Static analysis and vulnerability scanning for the Go module under src/.
#
# This complements, and does not replace, the `go vet` step already in
# v2-ci.yml. Three tools, three different questions:
#
# govulncheck — does this code CALL a known-vulnerable symbol? Unlike a
# dependency-version scanner (dependabot), it is reachability
# -based, so it does not fire on a vulnerable package the
# code never reaches. That makes its findings actionable
# rather than noise.
# gosec — insecure patterns in OUR code: command injection, weak
# randomness, unhandled errors on security-relevant calls,
# file permissions.
# golangci-lint — correctness and maintainability lints beyond `go vet`.
#
# A fourth job, notice-drift, asks a different question entirely — not "is
# this code safe" but "is the repo's third-party attribution (NOTICE, at the
# repo root) still accurate" — and a fifth, action-pins, asks "does every
# SHA-pinned action actually resolve". Both are static, both are Go/repo-
# structure checks, and both fit this workflow's existing shape better than a
# new file.
#
# Each runs as its OWN job rather than sequential steps in one job: a gosec
# finding must not hide a govulncheck finding behind an early exit. Five
# independent verdicts also means a reviewer sees which class of problem
# failed without opening the log.
#
# WHY THIS EXISTS: the OpenSSF Best Practices badge asks whether the project
# applies static analysis and checks for known vulnerabilities. Before this
# workflow the honest answer was "go vet only", which is thin for a project
# that runs model output with credentials able to write to source repos.
# notice-drift answers a related but separate ask from a CNCF General
# Technical Review: how does the project ensure third-party code carries
# correct and complete attribution. See that job's own comment for the full
# reasoning.
# NOTICE is in the path filter because the notice-drift job below compares the
# COMMITTED repo-root NOTICE byte-for-byte against a fresh regeneration
# (`check-notice-drift.sh NOTICE /tmp/NOTICE.generated`). NOTICE does not live
# under src/, so before #5388 a PR editing NOTICE alone — hand-correcting an
# attribution, or reverting the autofix commit — changed the exact file this
# gate polices while running neither that gate nor anything else. The drift
# would surface only on the next unrelated src/ change, attributed to that PR.
# Same shape as dashboard/openapi.json in v2-tests.yml: guarded by a job whose
# filter excluded it.
on:
workflow_call:
inputs:
integration_candidate:
description: Run an installed immutable Integration-aware candidate with isolated read-only technical jobs.
type: boolean
default: false
merge_group:
types: [checks_requested]
push:
branches: [v4, v5, v6]
# release PRs touch only these; the required contexts must report — #10795
paths: ['CHANGELOG.md', 'changelog.d/**', 'src/**', 'NOTICE', '.github/workflows/**', '.github/scripts/**', '.github/release-lines.yml']
pull_request:
branches: [v4, v5, v6]
# Required contexts from this workflow must appear on every PR head. Keep
# the trigger unfiltered and let required-context-scope below skip Go scans
# for out-of-scope diffs.
schedule:
# Weekly, so a newly-PUBLISHED advisory against unchanged code is still
# found. A push-only trigger cannot catch that: the vulnerability appears
# in the database, not in a diff.
- cron: '0 6 * * 1'
# Nightly full run: harmless today, and preserves a safety net if the
# project later decides to skip duplicate post-merge push runs.
- cron: '17 6 * * *'
# Also weekly for notice-drift: an upstream dependency's LICENSE file can
# change (a relicense, a typo fix) with go.mod/go.sum untouched, which a
# push-only trigger would never catch either.
permissions:
contents: read
# Same discipline as v2-ci.yml: cancel superseded PR runs, never cancel a
# push-to-branch run (keyed on run_id so each gets its own group).
concurrency:
group: ${{ inputs.integration_candidate && 'integration-security' || github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }}
cancel-in-progress: ${{ !inputs.integration_candidate && github.event_name == 'pull_request' }}
# Build-cache isolation for the self-hosted pool (see
# .github/scripts/go-cache-guard.sh). `job` gives each job its own GOCACHE
# under RUNNER_TEMP instead of the cephfs volume every runner pod shares;
# GOMODCACHE stays shared. Set the repo variable HIVE_GO_BUILD_CACHE=shared
# to go back to the shared build cache without a code change.
env:
GO_CACHE_GUARD_BUILD_CACHE: ${{ vars.HIVE_GO_BUILD_CACHE || 'job' }}
jobs:
required-context-scope:
name: required context scope
runs-on: ${{ !inputs.integration_candidate && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && fromJSON(vars.HIVE_RUNNER_LABELS || '["ubuntu-latest"]') || 'ubuntu-latest' }}
timeout-minutes: 10
outputs:
run-go-security: ${{ steps.scope.outputs.run-go-security }}
steps:
- name: Decide whether Go security checks need real scans
id: scope
env:
GITHUB_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
set -euo pipefail
if [ "${{ inputs.integration_candidate }}" = true ] &&
{ [ "$GITHUB_EVENT_NAME" != workflow_dispatch ] || [[ "$GITHUB_REF" != refs/tags/hive-integration/* ]]; }; then
echo "::error::Integration candidates require an explicitly dispatched immutable candidate tag"
exit 1
fi
if [ "${{ github.event_name }}" != "pull_request" ]; then
echo "run-go-security=true" >> "$GITHUB_OUTPUT"
exit 0
fi
: > changed-files.txt
page=1
api_root="${GITHUB_API_URL:-https://api.github.com}"
while :; do
curl -fsSL -H "Authorization: Bearer ${GITHUB_TOKEN}" -H "Accept: application/vnd.github+json" "${api_root}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?per_page=100&page=${page}" -o changed-files-page.json
python3 - <<'PYFILES' >> changed-files.txt
import json
with open('changed-files-page.json', encoding='utf-8') as f:
for item in json.load(f):
print(item['filename'])
PYFILES
count=$(python3 - <<'PYCOUNT'
import json
with open('changed-files-page.json', encoding='utf-8') as f:
print(len(json.load(f)))
PYCOUNT
)
[ "$count" -lt 100 ] && break
page=$((page + 1))
done
rm -f changed-files-page.json
run=false
while IFS= read -r path; do
case "$path" in
CHANGELOG.md|changelog.d/*|src/*|NOTICE|.github/workflows/*|.github/scripts/*|.github/release-lines.yml)
run=true
;;
esac
done < changed-files.txt
echo "run-go-security=${run}" >> "$GITHUB_OUTPUT"
if [ "$run" = true ]; then
echo "Go security checks will run real scans."
else
echo "No Go security paths changed; required scan contexts will be skipped/success."
fi
govulncheck:
needs: required-context-scope
# release-gate/* is created only by tagged-release.yml and carries only CHANGELOG.md/changelog.d changes; report the required context immediately (#10863).
if: ${{ always() && (needs.required-context-scope.result != 'success' || needs.required-context-scope.outputs.run-go-security == 'true') && !(github.event_name == 'pull_request' && startsWith(github.head_ref, 'release-gate/')) }}
name: govulncheck (known vulnerabilities)
# Required on v5: keep on the self-hosted pool so a GitHub-hosted runner
# outage does not make otherwise-mergeable PRs impossible to merge.
runs-on: ${{ !inputs.integration_candidate && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && fromJSON(vars.HIVE_RUNNER_LABELS || '["ubuntu-latest"]') || 'ubuntu-latest' }}
timeout-minutes: 25
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: ${{ !inputs.integration_candidate }}
# Populate the shared node-local Go tool cache under the runner pods'
# node-wide lock before setup-go, so parallel jobs on a cold cache (a
# src/go.mod bump) stop clobbering each other's rm -rf + copy (#11175,
# #10234). Never fails the job.
- name: Warm shared Go tool cache (serialize the cold-cache population)
run: bash "$GITHUB_WORKSPACE/.github/scripts/go-toolcache-guard.sh"
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
cache: false
go-version-file: src/go.mod
- name: Guard shared Go cache (fall back when the runner volume is full)
run: bash "$GITHUB_WORKSPACE/.github/scripts/go-cache-guard.sh"
# Fill GOMODCACHE up front with bounded retries so a transient
# proxy.golang.org stream reset (INTERNAL_ERROR, #14976) cannot fail the
# job's first module-fetching step; see .github/actions/go-mod-download-retry.
- name: Download Go modules (retry transient proxy resets)
uses: ./.github/actions/go-mod-download-retry
# Installed by version rather than via a marketplace action so the tool
# itself is pinned and the supply chain is one hop shorter.
- name: Install govulncheck
run: go install golang.org/x/vuln/cmd/govulncheck@v1.1.4
- name: Scan
working-directory: src
run: govulncheck ./... ./crux/...
gosec:
needs: required-context-scope
# release-gate/* is created only by tagged-release.yml and carries only CHANGELOG.md/changelog.d changes; report the required context immediately (#10863).
if: ${{ always() && (needs.required-context-scope.result != 'success' || needs.required-context-scope.outputs.run-go-security == 'true') && !(github.event_name == 'pull_request' && startsWith(github.head_ref, 'release-gate/')) }}
name: gosec (insecure patterns)
# Required on v5: keep on the self-hosted pool so a GitHub-hosted runner
# outage does not make otherwise-mergeable PRs impossible to merge.
runs-on: ${{ !inputs.integration_candidate && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && fromJSON(vars.HIVE_RUNNER_LABELS || '["ubuntu-latest"]') || 'ubuntu-latest' }}
timeout-minutes: 25
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: ${{ !inputs.integration_candidate }}
# cache: false — the hive runners mount a persistent GOCACHE/GOMODCACHE;
# setup-go's post-step would otherwise tar that multi-GB shared cache
# into the Actions cache on every run (#8762).
# Populate the shared node-local Go tool cache under the runner pods'
# node-wide lock before setup-go, so parallel jobs on a cold cache (a
# src/go.mod bump) stop clobbering each other's rm -rf + copy (#11175,
# #10234). Never fails the job.
- name: Warm shared Go tool cache (serialize the cold-cache population)
run: bash "$GITHUB_WORKSPACE/.github/scripts/go-toolcache-guard.sh"
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
cache: false
go-version-file: src/go.mod
- name: Guard shared Go cache (fall back when the runner volume is full)
run: bash "$GITHUB_WORKSPACE/.github/scripts/go-cache-guard.sh"
# Fill GOMODCACHE up front with bounded retries so a transient
# proxy.golang.org stream reset (INTERNAL_ERROR, #14976) cannot fail the
# job's first module-fetching step; see .github/actions/go-mod-download-retry.
- name: Download Go modules (retry transient proxy resets)
uses: ./.github/actions/go-mod-download-retry
# Version floor is load-bearing: gosec v2.21.4 pins golang.org/x/tools
# v0.25.0, which does not COMPILE under Go 1.25 ("invalid array length").
# The failure looks like a scan failure but is a build failure in the
# tool itself — do not downgrade to "fix" a finding.
- name: Restore pinned gosec binary
id: gosec-cache
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ runner.temp }}/hive-ci-tools/gosec
key: hive-gosec-${{ runner.os }}-${{ runner.arch }}-v2.29.0-${{ hashFiles('src/go.mod') }}
- name: Install gosec
if: steps.gosec-cache.outputs.cache-hit != 'true'
env:
GOBIN: ${{ runner.temp }}/hive-ci-tools
run: go install github.com/securego/gosec/v2/cmd/gosec@v2.29.0
# -exclude-generated: generated files are not hand-maintained, so a
# finding there is not actionable by a reviewer.
# Test files are IN scope deliberately: a test that shells out on
# attacker-influenced input is a real hole, not a test-only concern.
# NON-BLOCKING FOR NOW, deliberately and temporarily.
#
# The first run surfaced a real backlog, including G115 integer-overflow
# conversions in pkg/knowledge and pkg/dashboard. Those deserve triage on
# their own merits, not a rushed batch fix under a red gate — and a gate
# that blocks every PR from day one on a pre-existing backlog gets
# disabled rather than drained.
#
# So gosec RUNS and its findings are visible in the log on every PR, but
# does not fail the build yet. Flipping this to blocking is the last step
# of the triage issue #4903; do not flip it before the
# HIGH-severity findings are resolved, and do not delete the step to make
# the summary look clean.
#
# -concurrency 4: gosec defaults to runtime.NumCPU(), which in a runner
# pod is the NODE's 32 vCPUs, not the pod's 6-CPU limit
# (src/deploy/ci-runners/lke/hive-runners-lke-values.yaml). Each worker
# type-checks and builds SSA for its own package, so 32 at once can
# create memory pressure. This bounds a plausible contributor to the
# "runner lost communication" failures of #16040, not a proven OOM
# diagnosis. Keep this at or below the runner CPU limit
# (test-native-queue-workflows.py enforces it).
- name: Scan
working-directory: src
continue-on-error: true
# The nested src/crux module is not loaded from here; it has its own
# "Scan crux module" step below.
run: '"${{ runner.temp }}/hive-ci-tools/gosec" -concurrency 4 -exclude-generated ./...'
# src/crux is a nested Go module: the src/ scan above does not load it
# (0 "Checking file: .../src/crux/" lines), so scan it from its own root.
- name: Scan crux module
working-directory: src/crux
continue-on-error: true
run: '"${{ runner.temp }}/hive-ci-tools/gosec" -concurrency 4 -exclude-generated ./...'
golangci-lint:
needs: required-context-scope
# release-gate/* is created only by tagged-release.yml and carries only CHANGELOG.md/changelog.d changes; report the required context immediately (#10863).
if: ${{ always() && (needs.required-context-scope.result != 'success' || needs.required-context-scope.outputs.run-go-security == 'true') && !(github.event_name == 'pull_request' && startsWith(github.head_ref, 'release-gate/')) }}
name: golangci-lint
# Required on v5: keep on the self-hosted pool so a GitHub-hosted runner
# outage does not make otherwise-mergeable PRs impossible to merge.
runs-on: ${{ !inputs.integration_candidate && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && fromJSON(vars.HIVE_RUNNER_LABELS || '["ubuntu-latest"]') || 'ubuntu-latest' }}
timeout-minutes: 25
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: ${{ !inputs.integration_candidate }}
# Populate the shared node-local Go tool cache under the runner pods'
# node-wide lock before setup-go, so parallel jobs on a cold cache (a
# src/go.mod bump) stop clobbering each other's rm -rf + copy (#11175,
# #10234). Never fails the job.
- name: Warm shared Go tool cache (serialize the cold-cache population)
run: bash "$GITHUB_WORKSPACE/.github/scripts/go-toolcache-guard.sh"
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
cache: false
go-version-file: src/go.mod
- name: Guard shared Go cache (fall back when the runner volume is full)
run: bash "$GITHUB_WORKSPACE/.github/scripts/go-cache-guard.sh"
# Fill GOMODCACHE up front with bounded retries so a transient
# proxy.golang.org stream reset (INTERNAL_ERROR, #14976) cannot fail the
# job's first module-fetching step; see .github/actions/go-mod-download-retry.
- name: Download Go modules (retry transient proxy resets)
uses: ./.github/actions/go-mod-download-retry
# skip-cache: the action's own cache save/restore is the same multi-GB
# tar problem as setup-go's; the runners already keep GOCACHE warm.
- uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0
with:
version: v2.13.1
working-directory: src
# src/go.work: ./... covers the root module only; the nested Crux
# module (#13536) is addressed as ./crux/... from src/.
args: ./... ./crux/...
skip-cache: true
action-pins:
name: action pins resolve
needs: required-context-scope
if: ${{ always() && (needs.required-context-scope.result != 'success' || needs.required-context-scope.outputs.run-go-security == 'true') }}
runs-on: ${{ !inputs.integration_candidate && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && fromJSON(vars.HIVE_RUNNER_LABELS || '["ubuntu-latest"]') || 'ubuntu-latest' }}
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: ${{ !inputs.integration_candidate }}
# A fabricated 40-hex SHA looks exactly like a correct pin and is only
# caught when the workflow RUNS. tagged-release.yml shipped one (#4908), so every
# tagged release failed at "Prepare all required actions" — discovered
# only when someone tried to cut a release.
- name: Action-pins gate self-test
run: bash src/scripts/test-check-action-pins.sh
- name: Check every pinned action SHA exists
env:
GH_TOKEN: ${{ github.token }}
run: src/scripts/check-action-pins.sh .github/workflows
notice-drift:
name: NOTICE matches the module graph
needs: required-context-scope
if: ${{ always() && (needs.required-context-scope.result != 'success' || needs.required-context-scope.outputs.run-go-security == 'true') }}
runs-on: ${{ !inputs.integration_candidate && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && fromJSON(vars.HIVE_RUNNER_LABELS || '["ubuntu-latest"]') || 'ubuntu-latest' }}
timeout-minutes: 20
# WHY THIS JOB EXISTS: a CNCF General Technical Review asks how the
# project ensures third-party attribution stays correct and complete. A
# generated NOTICE that can silently go stale is worse than no NOTICE at
# all — it makes a false completeness claim the moment a dependency is
# bumped, added, or removed without anyone regenerating the file by hand.
# This job is the enforcement: it regenerates NOTICE the same way a
# contributor would (src/scripts/generate-notice.sh, itself pinned — see
# that script's header for why google/go-licenses was chosen) and fails
# if the committed NOTICE differs from a fresh run against the current
# src/go.mod / src/go.sum.
#
# FIRST-RUN STATE: the NOTICE committed alongside this job is a
# STATICALLY-DERIVED placeholder (see NOTICE's own header) — it was
# assembled from src/go.mod without running `go` tooling, because the PR
# that introduced it was authored in an environment where `go` could not
# be run. That placeholder is EXPECTED to differ from this job's output
# the first time this job runs for real (this job needs `go`, which is
# exactly what was unavailable when NOTICE was hand-assembled). This job
# deliberately does NOT special-case that expected first-run diff by
# skipping or soft-failing — a diff against the placeholder is a real,
# actionable diff (the placeholder is explicitly not yet the resolved
# module graph), and the fix is the same either way: run
# src/scripts/generate-notice.sh locally, commit the result. There is
# nothing for this job to silently tolerate; "the file is stale, here is
# the regenerated version, commit it" is the correct message on run one
# exactly as it is on every run after.
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: ${{ !inputs.integration_candidate }}
- name: Exercise NOTICE drift diagnostics
run: src/scripts/test-check-notice-drift.sh
# The autofix workflow commits a generated NOTICE unattended, so the
# guard that decides whether a generated file is fit to commit needs its
# own regression coverage here — notice-autofix.yml runs on
# workflow_run and cannot gate a PR itself.
- name: Exercise generated-NOTICE validation
run: src/scripts/test-validate-generated-notice.sh
# Populate the shared node-local Go tool cache under the runner pods'
# node-wide lock before setup-go, so parallel jobs on a cold cache (a
# src/go.mod bump) stop clobbering each other's rm -rf + copy (#11175,
# #10234). Never fails the job.
- name: Warm shared Go tool cache (serialize the cold-cache population)
run: bash "$GITHUB_WORKSPACE/.github/scripts/go-toolcache-guard.sh"
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
cache: false
go-version-file: src/go.mod
- name: Guard shared Go cache (fall back when the runner volume is full)
run: bash "$GITHUB_WORKSPACE/.github/scripts/go-cache-guard.sh"
# Fill GOMODCACHE up front with bounded retries so a transient
# proxy.golang.org stream reset (INTERNAL_ERROR, #14976) cannot fail the
# job's first module-fetching step; see .github/actions/go-mod-download-retry.
- name: Download Go modules (retry transient proxy resets)
uses: ./.github/actions/go-mod-download-retry
- name: Regenerate NOTICE from the current module graph
run: src/scripts/generate-notice.sh /tmp/NOTICE.generated
# Publish the regenerated file BEFORE the gate runs, so it is uploaded
# on the failing path too — that is the only path where anyone needs it.
# notice-autofix.yml consumes this artifact via workflow_run to commit
# the regeneration back onto a Dependabot branch (#5256). Uploading here
# rather than regenerating in the autofix workflow is deliberate: this
# job already runs the generator with a read-only token and no secrets,
# so the privileged workflow never has to execute PR-head code.
- name: Publish the regenerated NOTICE for the autofix workflow
if: github.event_name == 'pull_request'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: notice-generated
path: /tmp/NOTICE.generated
retention-days: 1
if-no-files-found: error
# The PR number travels with the artifact because workflow_run does not
# expose it: the triggering run's event payload is not forwarded, and
# resolving head SHA back to a PR is an extra API round trip that can
# match the wrong PR when several share a head.
- name: Record the PR number for the autofix workflow
if: github.event_name == 'pull_request'
run: echo "${{ github.event.pull_request.number }}" > /tmp/pr-number.txt
- name: Publish the PR number for the autofix workflow
if: github.event_name == 'pull_request'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: notice-pr-number
path: /tmp/pr-number.txt
retention-days: 1
if-no-files-found: error
- name: Fail if the committed NOTICE is stale
run: src/scripts/check-notice-drift.sh NOTICE /tmp/NOTICE.generated