Skip to content

sec(cli): remove the minted GCP service-account key after upload #1554

sec(cli): remove the minted GCP service-account key after upload

sec(cli): remove the minted GCP service-account key after upload #1554

Workflow file for this run

# CI Workflow - Build, Test, Lint, Security
#
# This workflow runs on every pull request and push to main/develop branches.
# It performs comprehensive quality checks before allowing code to be merged.
#
# Required GitHub Secrets: None (all checks run without cloud credentials)
#
# Triggered by:
# - Pull requests to main/develop
# - Pushes to main/develop
# - Manual workflow dispatch
name: CI - Build & Test
permissions:
contents: read
on:
pull_request:
branches: [main, develop]
push:
branches: [main, develop]
workflow_dispatch:
jobs:
lint:
name: Lint Code
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: go.mod
cache: true
- name: Run golangci-lint
uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee # v9.2.1
with:
version: v2.10.1
args: --timeout=10m
- name: Run go vet
run: go vet ./...
- name: Install gocyclo
run: go install github.com/fzipp/gocyclo/cmd/gocyclo@v0.6.0
- name: Check cyclomatic complexity
run: |
echo "Checking for functions with cyclomatic complexity over 10..."
# Exclude _test.go files to stay consistent with .golangci.yml, which
# excludes gocyclo on test files (test helpers/table-driven tests are
# allowed higher complexity). Production code is still gated at >10.
COMPLEXITY_ISSUES=$(gocyclo -over 10 -ignore "_test\.go" . 2>&1 || true)
if [ -n "$COMPLEXITY_ISSUES" ]; then
echo "❌ Found functions with cyclomatic complexity over 10:"
echo "$COMPLEXITY_ISSUES"
echo ""
echo "⚠️ Please refactor these functions to reduce complexity."
echo "📖 Tip: Extract helper functions, use early returns, or simplify logic."
exit 1
fi
echo "✅ All functions have acceptable cyclomatic complexity (≤10)"
workflow-lint:
name: Lint Workflows
runs-on: ubuntu-latest
permissions:
contents: read
env:
# Pinned by digest, not only by tag. A tag is mutable, and a linter whose
# ruleset changes without a change in this repo turns main red on its own
# schedule -- the hadolint :latest failure in #1695.
ACTIONLINT_IMAGE: 'rhysd/actionlint:1.7.12@sha256:b1934ee5f1c509618f2508e6eb47ee0d3520686341fec936f3b79331f9315667'
ZIZMOR_VERSION: '1.29.0'
steps:
- name: Checkout code
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
- name: Assert there are workflows to lint
run: |
set -euo pipefail
count=$(find .github/workflows -maxdepth 1 -type f \( -name '*.yml' -o -name '*.yaml' \) | wc -l | tr -d ' ')
# Defence in depth, not the only guard: both linters do exit 3 on an
# empty input set. This catches the case they cannot, where the path
# still resolves but the set silently shrinks, and it prints the count
# so a drop is visible in the log rather than inferred from silence.
if [ "$count" -eq 0 ]; then
echo "::error::no workflow files found under .github/workflows"
exit 1
fi
echo "Linting $count workflow files"
- name: Assert shellcheck is available to actionlint
# actionlint does not fail when shellcheck is missing from PATH: it
# silently skips every run: block and still exits 0. That silent skip is
# the failure mode this job exists to close, so assert the binary is
# present rather than trusting the image to keep bundling it.
run: |
set -euo pipefail
docker run --rm --entrypoint sh "$ACTIONLINT_IMAGE" -c '
command -v shellcheck >/dev/null || {
echo "::error::shellcheck is not present in the actionlint image; shell linting would be silently skipped"
exit 1
}
shellcheck --version | sed -n "1,3p"'
- name: Run actionlint
# No file arguments: actionlint discovers .github/workflows itself, so
# it also covers .yaml files and any workflow added later. Passing an
# explicit *.yml glob would silently skip a .yaml workflow that the
# count step above still counts.
run: |
set -euo pipefail
docker run --rm -v "$PWD:/repo" -w /repo "$ACTIONLINT_IMAGE" -color
- name: Run zizmor
# --offline on purpose: the online audits query the GitHub API for action
# metadata, so findings could change without a change in this repo and
# redden main, the same class of failure the hadolint digest pin fixed.
#
# Coverage note: zizmor reads the directory non-recursively, while
# actionlint walks it. A workflow under .github/workflows/sub/ would
# therefore reach actionlint but not zizmor. GitHub itself ignores
# workflows in subdirectories, so this is not a live hole, and the
# -maxdepth 1 count above fails loud if the set ever moves down a level.
#
# Two independent filters, and it matters which does what.
#
# --persona=pedantic rather than the default regular: regular hides
# three high-severity findings this repo actually had (workflow-level
# id-token: write in both sanity workflows, and an unpinned postgres
# service image). Those are fixed rather than filtered, so the stricter
# persona costs nothing today and gates more. auditor is the one level
# up and is documented as accepting false positives, so it is not used.
#
# --min-severity=medium is a threshold, not a suppression: no baseline
# file, no per-finding ignore, no only-new-issues. Every medium and
# high finding the pedantic persona surfaces fails this job, and the
# injection class this job exists to catch scores high. Below the line
# sit 106 findings, none above low: 66 template-injection on values
# #1649 already assessed as non-injectable (github.actor, github.sha
# and similar), plus undocumented-permissions, concurrency-limits and
# anonymous-definition. Clearing those means rewriting the deploy
# workflows, so they are left to a follow-up rather than silenced here.
run: |
set -euo pipefail
# `pipx run --spec` rather than `pipx install`: it pins the version in
# the same statement that invokes it and does not assume pipx's bin
# directory is on PATH.
pipx run --spec "zizmor==${ZIZMOR_VERSION}" zizmor \
--offline --persona=pedantic --min-severity=medium \
--color=always .github/workflows/
# Unit tests with race detection
unit-tests:
name: Unit Tests
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: go.mod
cache: true
- name: Download dependencies
run: |
set -e
for mod in .; do
echo "==> go mod download/verify in $mod"
(cd "$mod" && go mod download && go mod verify)
done
- name: Run unit tests (all modules)
run: |
set -uo pipefail
status=0
for mod in .; do
tag=$(echo "$mod" | tr './' '--' | sed 's/^-/root/')
log="$RUNNER_TEMP/unit-${tag}.log"
echo "==> unit tests in $mod"
# Profiles go to $RUNNER_TEMP, not the checkout: never leave working
# files in the repository root (repo coding guideline).
if ! (cd "$mod" && go test -v -race -short \
-coverprofile="$RUNNER_TEMP/coverage-${tag}.out" \
-covermode=atomic ./...) 2>&1 | tee "$log"; then
echo "::error::unit tests failed in $mod"
status=1
fi
# A module that runs zero tests is issue #1751 wearing a new
# costume: the loop would "cover" it while asserting nothing. Count
# top-level results (subtest lines are indented) and fail loudly.
ran=$(grep -cE '^--- (PASS|FAIL|SKIP)' "$log" || true)
echo "==> $mod ran $ran top-level test(s)"
if [ "$ran" -eq 0 ]; then
echo "::error::$mod ran zero tests -- it is in the loop but asserting nothing"
status=1
fi
done
shopt -s nullglob
profiles=("$RUNNER_TEMP"/coverage-*.out)
if [ "${#profiles[@]}" -eq 0 ]; then
echo "::error::no module produced a coverage profile" >&2
exit 1
fi
merged="$RUNNER_TEMP/coverage.out"
head -n 1 "${profiles[0]}" > "$merged"
for p in "${profiles[@]}"; do
tail -n +2 "$p" >> "$merged"
done
echo "Merged ${#profiles[@]} coverage profile(s), $(( $(wc -l < "$merged") - 1 )) block(s)"
exit "$status"
- name: Upload coverage to Codecov
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
with:
files: ${{ runner.temp }}/coverage.out
flags: unittests
name: codecov-umbrella
fail_ci_if_error: false
- name: Generate coverage report
run: |
go tool cover -html="$RUNNER_TEMP/coverage.out" -o "$RUNNER_TEMP/coverage.html"
- name: Upload coverage artifacts
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: coverage-report
path: |
${{ runner.temp }}/coverage.out
${{ runner.temp }}/coverage.html
retention-days: 30
- name: Check coverage threshold
run: |
coverage=$(go tool cover -func="$RUNNER_TEMP/coverage.out" | grep total | awk '{print $3}' | sed 's/%//')
echo "Total coverage: ${coverage}%"
if (( $(echo "$coverage < 80" | bc -l) )); then
echo "::warning::Coverage is below 80% (current: ${coverage}%)"
fi
integration-tests:
name: Integration Tests
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: go.mod
cache: true
- name: Run integration tests
run: |
set -uo pipefail
status=0
for mod in .; do
tag=$(echo "$mod" | tr './' '--' | sed 's/^-/root/')
log="$RUNNER_TEMP/integration-${tag}.log"
echo "==> integration tests in $mod"
if ! (cd "$mod" && go test -v -race -tags=integration \
-coverprofile="$RUNNER_TEMP/coverage-integration-${tag}.out" \
./...) 2>&1 | tee "$log"; then
echo "::error::integration tests failed in $mod"
status=1
fi
ran=$(grep -cE '^--- (PASS|FAIL|SKIP)' "$log" || true)
echo "==> $mod ran $ran top-level test(s)"
if [ "$ran" -eq 0 ]; then
echo "::error::$mod ran zero tests -- it is in the loop but asserting nothing"
status=1
fi
done
shopt -s nullglob
profiles=("$RUNNER_TEMP"/coverage-integration-*.out)
if [ "${#profiles[@]}" -eq 0 ]; then
echo "::error::no module produced an integration coverage profile" >&2
exit 1
fi
merged="$RUNNER_TEMP/coverage-integration.out"
head -n 1 "${profiles[0]}" > "$merged"
for p in "${profiles[@]}"; do
tail -n +2 "$p" >> "$merged"
done
echo "Merged ${#profiles[@]} integration coverage profile(s)"
exit "$status"
- name: Upload integration coverage
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: integration-coverage
path: ${{ runner.temp }}/coverage-integration.out
retention-days: 30
security-scan:
name: Security Scanning
runs-on: ubuntu-latest
permissions:
security-events: write
contents: read
steps:
- name: Checkout code
id: checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
- name: Set up Go
id: setup_go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: go.mod
- name: Run govulncheck CVE scanner (all modules)
# Independent scanners still run after another scanner fails.
if: always() && steps.checkout.outcome == 'success' && steps.setup_go.outcome == 'success'
run: |
# Pinned (not @latest): a govulncheck release with new
# detection logic could silently change the gate's verdict
# between PRs without an intentional bump in this repo.
# Bumping is a deliberate review item, not a drift.
go install golang.org/x/vuln/cmd/govulncheck@v1.1.4
# Scan each owned module independently.
set -e
for mod in .; do
echo "==> govulncheck in $mod"
(cd "$mod" && govulncheck ./...)
done
- name: Run gosec Security Scanner
id: gosec
# always(): don't let an earlier scanner's failure (e.g. govulncheck)
# skip Go SAST coverage. The job still fails if gosec itself fails.
# Still requires checkout and Go setup to have succeeded.
if: always() && steps.checkout.outcome == 'success' && steps.setup_go.outcome == 'success'
run: |
# Install pinned gosec using the job's existing setup-go.
# The securego/gosec Docker action bundles its own Go toolchain which
# cannot satisfy the module's go directive, causing a toolchain mismatch.
go install github.com/securego/gosec/v2/cmd/gosec@v2.28.0
# Scan each owned module independently.
set -e
status=0
for mod in .; do
tag=$(echo "$mod" | tr './' '--' | sed 's/^-/root/')
out="$RUNNER_TEMP/gosec-${tag}.sarif"
echo "==> gosec in $mod"
if ! (cd "$mod" && gosec -fmt sarif -out "$out" ./...); then
echo "::warning::gosec exited non-zero in $mod (findings or a scan error)"
status=1
fi
if [ ! -f "$out" ]; then
echo "::error::gosec produced no SARIF output for $mod"
status=1
continue
fi
# Code scanning rejects a SARIF file whose runs share a category
# (github.blog changelog 2025-07-21), so give each module's run a
# unique automationDetails.id before merging.
jq --arg id "gosec-${tag}/" '.runs |= map(.automationDetails = {id: $id})' \
"$out" > "$out.tmp" && mv "$out.tmp" "$out"
done
# Merge per-module SARIF runs into one file for the upload step.
# Only modules that actually produced a file are included; if
# gosec crashed before writing any of them, fail loud here rather
# than uploading an empty result silently.
shopt -s nullglob
sarif_files=("$RUNNER_TEMP"/gosec-*.sarif)
if [ "${#sarif_files[@]}" -eq 0 ]; then
echo "::error::no gosec SARIF output was produced by any module" >&2
exit 1
fi
# jq is preinstalled on the GitHub Ubuntu runner image (no new deps).
# Written to $RUNNER_TEMP, not the checkout root: never save working
# files in the repository root (repo coding guideline), and this
# file is purely a hand-off to the upload step below.
merged="$RUNNER_TEMP/gosec-results.sarif"
jq -s '{version: "2.1.0", "$schema": "https://json.schemastore.org/sarif-2.1.0.json", runs: [.[].runs[]]}' \
"${sarif_files[@]}" > "$merged"
echo "Merged $(jq '.runs | length' "$merged") SARIF runs"
exit "$status"
- name: Upload gosec results to GitHub Security
# Tolerate a missing SARIF only when the gosec step itself never ran
# (e.g. checkout/setup-go failed, so gosec's own `if:` above skipped
# it). If gosec ran and the file is still missing, fail loud instead
# of masking it as a skip.
if: always() && steps.checkout.outcome == 'success' && steps.setup_go.outcome == 'success' && steps.gosec.outcome != 'skipped'
uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
with:
sarif_file: ${{ runner.temp }}/gosec-results.sarif
- name: Run Trivy vulnerability scanner (filesystem)
id: trivy_fs
# Filesystem findings remain advisory; scanner execution is required.
if: always() && steps.checkout.outcome == 'success' && steps.setup_go.outcome == 'success'
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
scan-type: 'fs'
scan-ref: '.'
format: 'sarif'
# $RUNNER_TEMP, not the checkout root (repo coding guideline: never
# save working files in the repository root).
output: '${{ runner.temp }}/trivy-results.sarif'
severity: 'CRITICAL,HIGH'
# Keep the existing scanner binary pinned.
version: 'v0.72.0'
- name: Upload Trivy results to GitHub Security
# Tolerate a missing SARIF only when the Trivy fs step never ran.
if: always() && steps.checkout.outcome == 'success' && steps.setup_go.outcome == 'success' && steps.trivy_fs.outcome != 'skipped'
uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
with:
sarif_file: '${{ runner.temp }}/trivy-results.sarif'
snyk-scan:
name: Snyk Security Scan
runs-on: ubuntu-latest
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: go.mod
- name: Run Snyk to check for vulnerabilities
uses: snyk/actions/golang@b98d498629f1c368650224d6d212bf7dfa89e4bf # 0.4.0
continue-on-error: true
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
with:
args: --severity-threshold=high
cli-build:
name: Build CLI
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: go.mod
- name: Build CLI
run: make build
- name: Check CLI help
run: ./cudly --help
ci-success:
name: CI Success
runs-on: ubuntu-latest
needs:
- lint
- workflow-lint
- unit-tests
- integration-tests
- security-scan
- cli-build
if: always()
permissions:
contents: read
steps:
- name: Check all jobs
# Allowlist on success instead of denylisting 'failure' and
# 'cancelled'. A job that never dispatched reports 'skipped', and the
# denylist form reported that as a pass -- so a gate could satisfy this
# summary by not running at all, which is the same shape as the scanner
# gap in #1836. Anything that is not exactly 'success' now fails, and
# names itself in the log.
env:
NEEDS_JSON: ${{ toJSON(needs) }}
run: |
# jq is preinstalled on the GitHub Ubuntu runner image (no new deps).
not_success="$(jq -r 'to_entries[]
| select(.value.result != "success")
| " \(.key): \(.value.result)"' <<<"$NEEDS_JSON")"
if [[ -n "$not_success" ]]; then
echo "::error::not every required CI job succeeded"
echo "$not_success"
exit 1
fi
echo "All CI checks passed!"
- name: Post status
run: |
{
echo "## CI Status"
echo ""
echo "✅ All CI checks completed successfully!"
} >> "$GITHUB_STEP_SUMMARY"