sec(cli): remove the minted GCP service-account key after upload #1554
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # 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" |