Repository navigation
fix(iac/azure): remove nonexistent Microsoft.Capacity action from reservation-purchaser role #1283
Workflow file for this run
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) | |
| # | |
| # Required GitHub Variables: | |
| # - GO_VERSION: Go version to use (default: 1.26.5) | |
| # | |
| # Triggered by: | |
| # - Pull requests to main/develop | |
| # - Pushes to main/develop | |
| # - Manual workflow dispatch | |
| name: CI - Build & Test | |
| on: | |
| pull_request: | |
| branches: [main, develop] | |
| push: | |
| branches: [main, develop] | |
| workflow_dispatch: | |
| env: | |
| GO_VERSION: '1.26.5' | |
| DOCKER_BUILDKIT: 1 | |
| COMPOSE_DOCKER_CLI_BUILD: 1 | |
| jobs: | |
| # Go code linting | |
| lint: | |
| name: Lint Code | |
| runs-on: ubuntu-latest | |
| 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: ${{ env.GO_VERSION }} | |
| 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)" | |
| # Unit tests with race detection | |
| unit-tests: | |
| name: Unit Tests | |
| runs-on: ubuntu-latest | |
| 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: ${{ env.GO_VERSION }} | |
| cache: true | |
| - name: Download dependencies | |
| run: | | |
| # Multi-module repo: `go mod download` resolves the current module | |
| # only, so running it at the root left pkg/ and providers/* unverified | |
| # (issue #1751). Mirror the govulncheck per-module loop below. | |
| set -e | |
| for mod in . pkg providers/aws providers/azure providers/gcp tests/e2e; do | |
| echo "==> go mod download/verify in $mod" | |
| (cd "$mod" && go mod download && go mod verify) | |
| done | |
| - name: Run unit tests (all modules) | |
| run: | | |
| # Multi-module repo: each ./... only walks the current module, so | |
| # running from the root alone never compiled or asserted pkg/, | |
| # providers/* or tests/e2e -- roughly 1600 test functions that had | |
| # never gated a merge (issue #1751). Mirror the govulncheck and gosec | |
| # per-module loops, collect one coverage profile per module, then | |
| # merge for the upload steps below. | |
| # | |
| # A failing module must not abort the loop. Aborting would hide every | |
| # later module's result behind the first failure, which is the same | |
| # shape as the gosec bug in issue #1717. Guard each run, record the | |
| # status, and fail at the end so every module is reported. | |
| set -uo pipefail | |
| status=0 | |
| for mod in . pkg providers/aws providers/azure providers/gcp tests/e2e; do | |
| tag=$(echo "$mod" | tr './' '--' | sed 's/^-/root/') | |
| log="$RUNNER_TEMP/unit-${tag}.log" | |
| # tests/e2e holds only //go:build e2e files, so `go test ./...` | |
| # there matches no packages and exits 1 -- it cannot be run like the | |
| # others. Its tests need the running stack and are executed by the | |
| # e2e-tests job over docker compose; what this job can add is a | |
| # type-check under that tag, which nothing else here does. Handled | |
| # by name, not by a pattern, so the difference is visible in review. | |
| if [ "$mod" = "tests/e2e" ]; then | |
| echo "==> type-check $mod under -tags=e2e (tests run in the e2e-tests job)" | |
| if ! (cd "$mod" && go vet -tags=e2e ./...) 2>&1 | tee "$log"; then | |
| echo "::error::$mod failed to type-check under -tags=e2e" | |
| status=1 | |
| fi | |
| continue | |
| fi | |
| 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 | |
| # Merge the per-module profiles into the single file the steps below | |
| # expect. A Go profile is one "mode:" header followed by block lines, | |
| # so keep one header and concatenate the bodies. | |
| 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 | |
| # Take the mode header from the first profile rather than hardcoding | |
| # it: go test picks the default covermode itself (atomic whenever | |
| # -race is on), so a literal here would silently mislabel the merge if | |
| # the flags change. | |
| 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 with real PostgreSQL | |
| integration-tests: | |
| name: Integration Tests | |
| runs-on: ubuntu-latest | |
| services: | |
| postgres: | |
| image: postgres:16-alpine | |
| env: | |
| POSTGRES_DB: cudly_test | |
| POSTGRES_USER: cudly_test | |
| POSTGRES_PASSWORD: test_password # CI-only throwaway password — not used in any real environment | |
| options: >- | |
| --health-cmd pg_isready | |
| --health-interval 10s | |
| --health-timeout 5s | |
| --health-retries 5 | |
| ports: | |
| - 5432:5432 | |
| 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: ${{ env.GO_VERSION }} | |
| cache: true | |
| - name: Install golang-migrate | |
| run: | | |
| go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@v4.19.1 | |
| - name: Run database migrations | |
| env: | |
| DB_HOST: localhost | |
| DB_PORT: 5432 | |
| DB_NAME: cudly_test | |
| DB_USER: cudly_test | |
| DB_PASSWORD: test_password # CI-only throwaway password — not used in any real environment | |
| run: | | |
| if [ -d "internal/database/postgres/migrations" ]; then | |
| migrate -path internal/database/postgres/migrations \ | |
| -database "postgresql://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=disable" \ | |
| up | |
| fi | |
| - name: Run integration tests | |
| env: | |
| DB_HOST: localhost | |
| DB_PORT: 5432 | |
| DB_NAME: cudly_test | |
| DB_USER: cudly_test | |
| DB_PASSWORD: test_password # CI-only throwaway password — not used in any real environment | |
| DB_SSL_MODE: disable | |
| run: | | |
| # Same multi-module gap as the unit job (issue #1751): a bare ./... | |
| # here only ever walked the root module. Mirror the same loop. | |
| # | |
| # Note that -tags=integration ADDS a build tag rather than selecting | |
| # only tagged files, so each module runs its untagged tests too. Today | |
| # every //go:build integration file lives in the root module, so for | |
| # pkg/ and providers/* this run is a compile-under-tag check plus a | |
| # re-run of their unit tests. That is the point: it is what makes an | |
| # integration test added to those modules later actually gate. | |
| set -uo pipefail | |
| status=0 | |
| for mod in . pkg providers/aws providers/azure providers/gcp tests/e2e; do | |
| tag=$(echo "$mod" | tr './' '--' | sed 's/^-/root/') | |
| log="$RUNNER_TEMP/integration-${tag}.log" | |
| # See the unit job: tests/e2e has no package to test without its own | |
| # tag, so it gets a type-check here too rather than a run. | |
| if [ "$mod" = "tests/e2e" ]; then | |
| echo "==> type-check $mod under -tags=e2e (tests run in the e2e-tests job)" | |
| if ! (cd "$mod" && go vet -tags=e2e ./...) 2>&1 | tee "$log"; then | |
| echo "::error::$mod failed to type-check under -tags=e2e" | |
| status=1 | |
| fi | |
| continue | |
| fi | |
| 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 | |
| # Merge per-module profiles for the upload step (see the unit job). | |
| 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 | |
| # Docker image build test | |
| docker-build: | |
| name: Build Docker Image | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 | |
| with: | |
| persist-credentials: false | |
| - name: Set up Docker Buildx | |
| uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 | |
| - name: Build Docker image | |
| uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 | |
| with: | |
| context: . | |
| push: false | |
| tags: cudly:${{ github.sha }} | |
| cache-from: type=gha | |
| cache-to: type=gha,mode=max | |
| build-args: | | |
| VERSION=${{ github.sha }} | |
| - name: Test Docker image | |
| run: | | |
| docker build -t cudly:test . | |
| docker run --rm cudly:test /app/cudly --version || true | |
| docker run --rm cudly:test /app/cudly --help || true | |
| # Terraform validation for all environments | |
| terraform-validate: | |
| name: Validate Terraform (${{ matrix.cloud }}) | |
| runs-on: ubuntu-latest | |
| strategy: | |
| matrix: | |
| cloud: [aws, gcp, azure] | |
| fail-fast: false | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 | |
| with: | |
| persist-credentials: false | |
| - name: Setup Terraform | |
| uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1 | |
| with: | |
| # Must satisfy `required_version = ">= 1.10.0"` declared by every | |
| # terraform/environments/*/main.tf -- pinning below 1.10 makes | |
| # `terraform init`/`validate` abort with "Unsupported Terraform | |
| # Core version". Matches the pin in pre-commit.yml so both | |
| # workflows resolve to the same binary. | |
| terraform_version: 1.10.5 | |
| - name: Terraform Format Check | |
| run: terraform fmt -check -recursive terraform/ | |
| - name: Terraform Init | |
| run: | | |
| cd terraform/environments/${{ matrix.cloud }} | |
| terraform init -backend=false | |
| - name: Terraform Validate | |
| run: | | |
| cd terraform/environments/${{ matrix.cloud }} | |
| terraform validate | |
| - name: Validate environment tfvars files | |
| run: | | |
| cd terraform/environments/${{ matrix.cloud }} | |
| for env in dev staging prod; do | |
| # Check local tfvars if present | |
| if [ -f "${env}.tfvars" ]; then | |
| echo "Checking ${env}.tfvars syntax..." | |
| terraform fmt -check "${env}.tfvars" || echo "Note: ${env}.tfvars may need formatting" | |
| fi | |
| # Check GitHub CI/CD tfvars | |
| if [ -f "github-${env}.tfvars" ]; then | |
| echo "Checking github-${env}.tfvars syntax..." | |
| terraform fmt -check "github-${env}.tfvars" || echo "Note: github-${env}.tfvars may need formatting" | |
| fi | |
| done | |
| # Security scanning | |
| 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: ${{ env.GO_VERSION }} | |
| - name: Run govulncheck CVE scanner (all modules) | |
| # always(): each scanner step below is independent evidence for the | |
| # Security tab. Without this, one scanner failing (e.g. a live npm | |
| # advisory) skips every scanner after it in the same job, silently | |
| # disabling Go SAST and Terraform IaC coverage repo-wide. The job | |
| # still fails overall if any scanner step here fails. Still requires | |
| # checkout and Go setup to have actually succeeded -- an infra | |
| # failure there must not be papered over as "scanner found nothing". | |
| 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 | |
| # Multi-module repo: each ./... only walks the current module, | |
| # so scanning the root would silently miss pkg/ and providers/*. | |
| # Walk every module independently and fail on any HIGH/CRITICAL. | |
| set -e | |
| for mod in . pkg providers/aws providers/azure providers/gcp tests/e2e; do | |
| echo "==> govulncheck in $mod" | |
| (cd "$mod" && govulncheck ./...) | |
| done | |
| - name: Run npm audit (frontend) | |
| # always(): must not skip the scanners below it just because | |
| # govulncheck failed, and its own failure must not skip gosec/Trivy. | |
| # Still requires checkout to have succeeded (see govulncheck above). | |
| if: always() && steps.checkout.outcome == 'success' && steps.setup_go.outcome == 'success' | |
| run: | | |
| if [ -f frontend/package.json ]; then | |
| cd frontend && npm audit --audit-level=high | |
| fi | |
| - name: Run gosec Security Scanner | |
| id: gosec | |
| # always(): don't let an earlier scanner's failure (e.g. npm audit) | |
| # 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 | |
| # Multi-module repo: each ./... only walks the current module so scanning root | |
| # alone silently misses pkg/ and providers/*. Mirror the govulncheck per-module | |
| # loop, collect per-module SARIF, then merge for the upload step. | |
| # | |
| # gosec exits non-zero both for real findings and for a processing | |
| # error. A bare `set -e` loop aborts at the first non-zero module, | |
| # skipping the merge below entirely -- the upload step then fails | |
| # on a missing file instead of surfacing the actual finding | |
| # (issue #1717). Guarding the gosec call in `if ! ( ... )` exempts | |
| # it from errexit so every module still gets scanned and merged; | |
| # `status` records whether the job should still fail at the end. | |
| set -e | |
| status=0 | |
| for mod in . pkg providers/aws providers/azure providers/gcp tests/e2e; 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 | |
| # always(): don't let an earlier scanner's failure skip Trivy fs | |
| # coverage. This scan uses the default exit-code 0 (see the IaC | |
| # scan comment below), so it does not gate the job on its own. | |
| # Still requires checkout and Go setup to have succeeded. | |
| 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' | |
| # Pin the Trivy binary independently of the action SHA. v0.36.0 is | |
| # the latest trivy-action release but bundles Trivy v0.70.0, which | |
| # panics in adaptDefaultTags on terraform/environments/aws/main.tf | |
| # (null default_tags vars). Fixed in Trivy >= v0.72.0. The action | |
| # forwards this input to aquasecurity/setup-trivy, so both scan | |
| # steps run the same pinned binary. Keep both steps on this version. | |
| 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' | |
| # Terraform IaC misconfiguration scanning. Replaces the deprecated | |
| # aquasecurity/tfsec-action, whose bundled HCL parser rejects Terraform | |
| # 1.5+ `check {}` blocks (e.g. terraform/modules/deployment-checks/main.tf) | |
| # with a hard "scan failed" parse error that soft_fail does not suppress | |
| # (soft_fail only downgrades findings, not scan errors). Trivy is tfsec's | |
| # official successor, parses `check {}` blocks, and (like the fs scan | |
| # above) uses the default exit-code 0 so misconfig findings are reported | |
| # to the Security tab without gating the job -- matching tfsec's prior | |
| # soft_fail: true behaviour while preserving Terraform IaC coverage. | |
| - name: Run Trivy IaC misconfiguration scanner (Terraform) | |
| id: trivy_iac | |
| # always(): don't let an earlier scanner's failure skip Terraform | |
| # IaC coverage. Still requires checkout and Go setup to have | |
| # succeeded. | |
| if: always() && steps.checkout.outcome == 'success' && steps.setup_go.outcome == 'success' | |
| uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 | |
| with: | |
| scan-type: 'config' | |
| scan-ref: 'terraform/' | |
| format: 'sarif' | |
| # $RUNNER_TEMP, not the checkout root (repo coding guideline: never | |
| # save working files in the repository root). | |
| output: '${{ runner.temp }}/trivy-config-results.sarif' | |
| severity: 'CRITICAL,HIGH' | |
| # Same pinned Trivy binary as the filesystem scan above (>= v0.72.0 | |
| # avoids the adaptDefaultTags panic on null default_tags vars). | |
| version: 'v0.72.0' | |
| - name: Upload Trivy IaC results to GitHub Security | |
| # Tolerate a missing SARIF only when the Trivy IaC step never ran. | |
| if: always() && steps.checkout.outcome == 'success' && steps.setup_go.outcome == 'success' && steps.trivy_iac.outcome != 'skipped' | |
| uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 | |
| with: | |
| sarif_file: '${{ runner.temp }}/trivy-config-results.sarif' | |
| # Distinct category so this IaC analysis does not overwrite the | |
| # filesystem Trivy analysis uploaded above (both report as "Trivy"). | |
| category: 'trivy-iac' | |
| # Snyk security scanning | |
| 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 | |
| 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: ${{ env.GO_VERSION }} | |
| - 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 | |
| # Docker Compose E2E tests: build the app + test-runner images, bring up | |
| # postgres + cudly-app, then run the black-box suite in tests/e2e against | |
| # the app over HTTP. The test-runner service is profile-gated, so every | |
| # compose invocation needs --profile test (issue #1180). | |
| e2e-tests: | |
| name: E2E Tests | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 | |
| with: | |
| persist-credentials: false | |
| - name: Set up Docker Buildx | |
| uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 | |
| - name: Build images | |
| run: | | |
| docker compose -f docker-compose.test.yml --profile test build | |
| - name: Run E2E tests with docker compose | |
| run: | | |
| docker compose -f docker-compose.test.yml --profile test up --abort-on-container-exit --exit-code-from test-runner | |
| env: | |
| COMPOSE_INTERACTIVE_NO_CLI: 1 | |
| - name: Cleanup | |
| if: always() | |
| run: | | |
| docker compose -f docker-compose.test.yml --profile test down -v | |
| # Assert that the Azure custom-role actions list is identical in the TF module | |
| # and the ARM onboarding template. Fast (shell + jq only), so it always runs. | |
| # Path changes that trigger drift will be caught regardless of PR context. | |
| azure-role-parity: | |
| name: Azure role actions parity (ARM vs TF) | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 | |
| with: | |
| persist-credentials: false | |
| - name: Assert ARM/TF actions parity | |
| run: bash scripts/check-azure-role-parity.sh | |
| - name: Run parity script self-tests | |
| run: bash scripts/test-azure-role-parity.sh | |
| # Assert that the AWS IAM action lists are identical across the CFN stack, | |
| # the TF lambda/fargate modules, and the federation CFN/TF/CLI templates. | |
| # Fast (shell only), so it always runs. | |
| aws-iam-parity: | |
| name: AWS IAM actions parity (CFN vs TF) | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 | |
| with: | |
| persist-credentials: false | |
| - name: Assert CFN/TF actions parity | |
| run: bash scripts/check-aws-iam-parity.sh | |
| - name: Run parity script self-tests | |
| run: bash scripts/test-aws-iam-parity.sh | |
| # Assert that no Terraform file grants a Secret Manager role at project, | |
| # folder or organization scope; those scopes hand the member every secret in | |
| # the scope. Fast (shell only), so it always runs. | |
| gcp-secret-scope: | |
| name: GCP Secret Manager grant scope | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 | |
| with: | |
| persist-credentials: false | |
| - name: Assert no scope-wide Secret Manager grants | |
| run: bash scripts/check-gcp-secret-scope.sh | |
| - name: Run guard script self-tests | |
| run: bash scripts/test-gcp-secret-scope.sh | |
| # Summary job - all checks must pass | |
| ci-success: | |
| name: CI Success | |
| runs-on: ubuntu-latest | |
| needs: | |
| - lint | |
| - unit-tests | |
| - integration-tests | |
| - docker-build | |
| - terraform-validate | |
| - security-scan | |
| - e2e-tests | |
| - azure-role-parity | |
| - aws-iam-parity | |
| - gcp-secret-scope | |
| if: always() | |
| steps: | |
| - name: Check all jobs | |
| run: | | |
| if [[ "${{ contains(needs.*.result, 'failure') }}" == "true" ]]; then | |
| echo "One or more CI jobs failed" | |
| exit 1 | |
| fi | |
| if [[ "${{ contains(needs.*.result, 'cancelled') }}" == "true" ]]; then | |
| echo "One or more CI jobs were cancelled" | |
| exit 1 | |
| fi | |
| echo "All CI checks passed!" | |
| - name: Post status | |
| if: always() | |
| run: | | |
| echo "## CI Status" >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| echo "✅ All CI checks completed successfully!" >> $GITHUB_STEP_SUMMARY |