From 3afd9ff06a53f260eb734cfce05a7771deb186dd Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Wed, 19 Aug 2026 20:47:32 +0200 Subject: [PATCH 1/5] fix(ci): quote shell expansions and group redirects in workflows shellcheck, run via actionlint, reports 120 findings across six workflow files: 106 SC2086 (unquoted $GITHUB_OUTPUT / $GITHUB_STEP_SUMMARY / $GITHUB_ENV expansions), 12 SC2129 (consecutive redirects to the same file) and 2 SC2012 (ls parsed for a file count). Quoting and grouping only. No GitHub Actions expression, output text, step name or ordering changes; the set of ${{ }} expressions is byte-identical before and after. The two ls uses become find, which yields the same count for the missing-directory and no-match cases. Clears the backlog so the workflow linter added next can gate on a clean tree instead of landing red. --- .github/workflows/ci.yml | 8 +- .github/workflows/database-migration.yml | 10 +- .github/workflows/deploy-all.yml | 124 ++++++++++++----------- .github/workflows/deploy-aws-fargate.yml | 56 +++++----- .github/workflows/deploy-azure.yml | 54 +++++----- .github/workflows/deploy-gcp.yml | 52 +++++----- 6 files changed, 158 insertions(+), 146 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index db253ee91..b4e115c58 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -925,6 +925,8 @@ jobs: - name: Post status run: | - echo "## CI Status" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "✅ All CI checks completed successfully!" >> $GITHUB_STEP_SUMMARY + { + echo "## CI Status" + echo "" + echo "✅ All CI checks completed successfully!" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/database-migration.yml b/.github/workflows/database-migration.yml index 252ca3d5a..c745755d2 100644 --- a/.github/workflows/database-migration.yml +++ b/.github/workflows/database-migration.yml @@ -175,8 +175,8 @@ jobs: # Check migration files if [ -d "$MIGRATIONS_PATH" ]; then - UP_COUNT=$(ls -1 "$MIGRATIONS_PATH"/*.up.sql 2>/dev/null | wc -l) - DOWN_COUNT=$(ls -1 "$MIGRATIONS_PATH"/*.down.sql 2>/dev/null | wc -l) + UP_COUNT=$(find "$MIGRATIONS_PATH" -maxdepth 1 -name '*.up.sql' 2>/dev/null | wc -l) + DOWN_COUNT=$(find "$MIGRATIONS_PATH" -maxdepth 1 -name '*.down.sql' 2>/dev/null | wc -l) echo "Migration files found:" echo " Up migrations: $UP_COUNT" @@ -299,7 +299,7 @@ jobs: echo "Failed to get database endpoint" exit 1 fi - echo "endpoint=$DB_ENDPOINT" >> $GITHUB_OUTPUT + echo "endpoint=$DB_ENDPOINT" >> "$GITHUB_OUTPUT" - name: Run migrations env: @@ -413,7 +413,7 @@ jobs: echo "Failed to get database endpoint" exit 1 fi - echo "endpoint=$DB_ENDPOINT" >> $GITHUB_OUTPUT + echo "endpoint=$DB_ENDPOINT" >> "$GITHUB_OUTPUT" - name: Run migrations env: @@ -505,7 +505,7 @@ jobs: echo "Failed to get database endpoint" exit 1 fi - echo "endpoint=$DB_ENDPOINT" >> $GITHUB_OUTPUT + echo "endpoint=$DB_ENDPOINT" >> "$GITHUB_OUTPUT" - name: Run migrations env: diff --git a/.github/workflows/deploy-all.yml b/.github/workflows/deploy-all.yml index daeeda0db..c6f85c895 100644 --- a/.github/workflows/deploy-all.yml +++ b/.github/workflows/deploy-all.yml @@ -113,22 +113,22 @@ jobs: [[ "$DEPLOY_TO" == "aws-only" ]] || \ [[ "$DEPLOY_TO" == "aws-gcp" ]] || \ [[ "$DEPLOY_TO" == "aws-azure" ]]; then - echo "deploy-aws-lambda=true" >> $GITHUB_OUTPUT + echo "deploy-aws-lambda=true" >> "$GITHUB_OUTPUT" else - echo "deploy-aws-lambda=false" >> $GITHUB_OUTPUT + echo "deploy-aws-lambda=false" >> "$GITHUB_OUTPUT" fi # AWS Fargate (optional, can be enabled separately) - echo "deploy-aws-fargate=false" >> $GITHUB_OUTPUT + echo "deploy-aws-fargate=false" >> "$GITHUB_OUTPUT" # GCP if [[ "$DEPLOY_TO" == "all" ]] || \ [[ "$DEPLOY_TO" == "gcp-only" ]] || \ [[ "$DEPLOY_TO" == "aws-gcp" ]] || \ [[ "$DEPLOY_TO" == "gcp-azure" ]]; then - echo "deploy-gcp=true" >> $GITHUB_OUTPUT + echo "deploy-gcp=true" >> "$GITHUB_OUTPUT" else - echo "deploy-gcp=false" >> $GITHUB_OUTPUT + echo "deploy-gcp=false" >> "$GITHUB_OUTPUT" fi # Azure @@ -136,23 +136,25 @@ jobs: [[ "$DEPLOY_TO" == "azure-only" ]] || \ [[ "$DEPLOY_TO" == "aws-azure" ]] || \ [[ "$DEPLOY_TO" == "gcp-azure" ]]; then - echo "deploy-azure=true" >> $GITHUB_OUTPUT + echo "deploy-azure=true" >> "$GITHUB_OUTPUT" else - echo "deploy-azure=false" >> $GITHUB_OUTPUT + echo "deploy-azure=false" >> "$GITHUB_OUTPUT" fi - name: Display deployment plan run: | - echo "## Deployment Plan" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "**Environment:** ${{ steps.set-env.outputs.environment }}" >> $GITHUB_STEP_SUMMARY - echo "**Deploy Strategy:** ${{ inputs.deploy_to || 'all' }}" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "### Clouds" >> $GITHUB_STEP_SUMMARY - echo "- AWS Lambda: ${{ steps.set-clouds.outputs.deploy-aws-lambda }}" >> $GITHUB_STEP_SUMMARY - echo "- AWS Fargate: ${{ steps.set-clouds.outputs.deploy-aws-fargate }}" >> $GITHUB_STEP_SUMMARY - echo "- GCP Cloud Run: ${{ steps.set-clouds.outputs.deploy-gcp }}" >> $GITHUB_STEP_SUMMARY - echo "- Azure Container Apps: ${{ steps.set-clouds.outputs.deploy-azure }}" >> $GITHUB_STEP_SUMMARY + { + echo "## Deployment Plan" + echo "" + echo "**Environment:** ${{ steps.set-env.outputs.environment }}" + echo "**Deploy Strategy:** ${{ inputs.deploy_to || 'all' }}" + echo "" + echo "### Clouds" + echo "- AWS Lambda: ${{ steps.set-clouds.outputs.deploy-aws-lambda }}" + echo "- AWS Fargate: ${{ steps.set-clouds.outputs.deploy-aws-fargate }}" + echo "- GCP Cloud Run: ${{ steps.set-clouds.outputs.deploy-gcp }}" + echo "- Azure Container Apps: ${{ steps.set-clouds.outputs.deploy-azure }}" + } >> "$GITHUB_STEP_SUMMARY" # Deploy to AWS Lambda deploy-aws-lambda: @@ -247,58 +249,60 @@ jobs: steps: - name: Aggregate results run: | - echo "## Multi-Cloud Deployment Results" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "**Environment:** ${{ needs.determine-deployment.outputs.environment }}" >> $GITHUB_STEP_SUMMARY - echo "**Timestamp:** $(date -u +%Y-%m-%dT%H:%M:%SZ)" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY + { + echo "## Multi-Cloud Deployment Results" + echo "" + echo "**Environment:** ${{ needs.determine-deployment.outputs.environment }}" + echo "**Timestamp:** $(date -u +%Y-%m-%dT%H:%M:%SZ)" + echo "" - echo "### Deployment Status" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY + echo "### Deployment Status" + echo "" - # AWS Lambda - if [[ "${{ needs.determine-deployment.outputs.deploy-aws-lambda }}" == "true" ]]; then - if [[ "${{ needs.deploy-aws-lambda.result }}" == "success" ]]; then - echo "- ✅ AWS Lambda: Success" >> $GITHUB_STEP_SUMMARY + # AWS Lambda + if [[ "${{ needs.determine-deployment.outputs.deploy-aws-lambda }}" == "true" ]]; then + if [[ "${{ needs.deploy-aws-lambda.result }}" == "success" ]]; then + echo "- ✅ AWS Lambda: Success" + else + echo "- ❌ AWS Lambda: ${{ needs.deploy-aws-lambda.result }}" + fi else - echo "- ❌ AWS Lambda: ${{ needs.deploy-aws-lambda.result }}" >> $GITHUB_STEP_SUMMARY + echo "- ⏭️ AWS Lambda: Skipped" fi - else - echo "- ⏭️ AWS Lambda: Skipped" >> $GITHUB_STEP_SUMMARY - fi - # AWS Fargate - if [[ "${{ needs.determine-deployment.outputs.deploy-aws-fargate }}" == "true" ]]; then - if [[ "${{ needs.deploy-aws-fargate.result }}" == "success" ]]; then - echo "- ✅ AWS Fargate: Success" >> $GITHUB_STEP_SUMMARY + # AWS Fargate + if [[ "${{ needs.determine-deployment.outputs.deploy-aws-fargate }}" == "true" ]]; then + if [[ "${{ needs.deploy-aws-fargate.result }}" == "success" ]]; then + echo "- ✅ AWS Fargate: Success" + else + echo "- ❌ AWS Fargate: ${{ needs.deploy-aws-fargate.result }}" + fi else - echo "- ❌ AWS Fargate: ${{ needs.deploy-aws-fargate.result }}" >> $GITHUB_STEP_SUMMARY + echo "- ⏭️ AWS Fargate: Skipped" fi - else - echo "- ⏭️ AWS Fargate: Skipped" >> $GITHUB_STEP_SUMMARY - fi - # GCP - if [[ "${{ needs.determine-deployment.outputs.deploy-gcp }}" == "true" ]]; then - if [[ "${{ needs.deploy-gcp.result }}" == "success" ]]; then - echo "- ✅ GCP Cloud Run: Success" >> $GITHUB_STEP_SUMMARY + # GCP + if [[ "${{ needs.determine-deployment.outputs.deploy-gcp }}" == "true" ]]; then + if [[ "${{ needs.deploy-gcp.result }}" == "success" ]]; then + echo "- ✅ GCP Cloud Run: Success" + else + echo "- ❌ GCP Cloud Run: ${{ needs.deploy-gcp.result }}" + fi else - echo "- ❌ GCP Cloud Run: ${{ needs.deploy-gcp.result }}" >> $GITHUB_STEP_SUMMARY + echo "- ⏭️ GCP Cloud Run: Skipped" fi - else - echo "- ⏭️ GCP Cloud Run: Skipped" >> $GITHUB_STEP_SUMMARY - fi - # Azure - if [[ "${{ needs.determine-deployment.outputs.deploy-azure }}" == "true" ]]; then - if [[ "${{ needs.deploy-azure.result }}" == "success" ]]; then - echo "- ✅ Azure Container Apps: Success" >> $GITHUB_STEP_SUMMARY + # Azure + if [[ "${{ needs.determine-deployment.outputs.deploy-azure }}" == "true" ]]; then + if [[ "${{ needs.deploy-azure.result }}" == "success" ]]; then + echo "- ✅ Azure Container Apps: Success" + else + echo "- ❌ Azure Container Apps: ${{ needs.deploy-azure.result }}" + fi else - echo "- ❌ Azure Container Apps: ${{ needs.deploy-azure.result }}" >> $GITHUB_STEP_SUMMARY + echo "- ⏭️ Azure Container Apps: Skipped" fi - else - echo "- ⏭️ Azure Container Apps: Skipped" >> $GITHUB_STEP_SUMMARY - fi + } >> "$GITHUB_STEP_SUMMARY" - name: Check for failures run: | @@ -327,12 +331,12 @@ jobs: fi if [ "$FAILED" = true ]; then - echo "" >> $GITHUB_STEP_SUMMARY - echo "❌ **Deployment did not complete successfully. Check individual job logs.**" >> $GITHUB_STEP_SUMMARY + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "❌ **Deployment did not complete successfully. Check individual job logs.**" >> "$GITHUB_STEP_SUMMARY" exit 1 else - echo "" >> $GITHUB_STEP_SUMMARY - echo "✅ **All deployments completed successfully!**" >> $GITHUB_STEP_SUMMARY + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "✅ **All deployments completed successfully!**" >> "$GITHUB_STEP_SUMMARY" fi # Optional: Send notification to Slack, Discord, email, etc. diff --git a/.github/workflows/deploy-aws-fargate.yml b/.github/workflows/deploy-aws-fargate.yml index 99b07a580..6601ae78f 100644 --- a/.github/workflows/deploy-aws-fargate.yml +++ b/.github/workflows/deploy-aws-fargate.yml @@ -109,7 +109,7 @@ jobs: - name: Set image tag id: set-tag run: | - echo "tag=${{ github.sha }}" >> $GITHUB_OUTPUT + echo "tag=${{ github.sha }}" >> "$GITHUB_OUTPUT" # Deploy with Terraform deploy: @@ -209,8 +209,8 @@ jobs: id: outputs run: | cd terraform/environments/aws - echo "alb_url=$(terraform output -raw fargate_alb_dns_name 2>/dev/null | grep -v '::' || echo "")" >> $GITHUB_OUTPUT - echo "service_name=$(terraform output -raw fargate_service_name 2>/dev/null | grep -v '::' || echo "")" >> $GITHUB_OUTPUT + echo "alb_url=$(terraform output -raw fargate_alb_dns_name 2>/dev/null | grep -v '::' || echo "")" >> "$GITHUB_OUTPUT" + echo "service_name=$(terraform output -raw fargate_service_name 2>/dev/null | grep -v '::' || echo "")" >> "$GITHUB_OUTPUT" - name: Save deployment info run: | @@ -256,7 +256,7 @@ jobs: *) ALB_URL="http://$ALB_URL" ;; esac ALB_URL="${ALB_URL%/}" - echo "url=$ALB_URL" >> $GITHUB_OUTPUT + echo "url=$ALB_URL" >> "$GITHUB_OUTPUT" - name: Wait for deployment run: | @@ -319,28 +319,30 @@ jobs: - name: Post summary run: | - echo "## AWS Fargate Deployment Summary" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "**Environment:** ${{ needs.prepare.outputs.environment }}" >> $GITHUB_STEP_SUMMARY - echo "**Deploy Status:** ${{ needs.deploy.result }}" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - - if [ -f deployment-info.json ]; then - echo "### Deployment Details" >> $GITHUB_STEP_SUMMARY - echo "\`\`\`json" >> $GITHUB_STEP_SUMMARY - cat deployment-info.json >> $GITHUB_STEP_SUMMARY - echo "\`\`\`" >> $GITHUB_STEP_SUMMARY - fi + { + echo "## AWS Fargate Deployment Summary" + echo "" + echo "**Environment:** ${{ needs.prepare.outputs.environment }}" + echo "**Deploy Status:** ${{ needs.deploy.result }}" + echo "" + + if [ -f deployment-info.json ]; then + echo "### Deployment Details" + echo "\`\`\`json" + cat deployment-info.json + echo "\`\`\`" + fi - echo "" >> $GITHUB_STEP_SUMMARY - echo "### Job Results" >> $GITHUB_STEP_SUMMARY - echo "- Deploy: ${{ needs.deploy.result }}" >> $GITHUB_STEP_SUMMARY - echo "- Test: ${{ needs.test-deployment.result }}" >> $GITHUB_STEP_SUMMARY + echo "" + echo "### Job Results" + echo "- Deploy: ${{ needs.deploy.result }}" + echo "- Test: ${{ needs.test-deployment.result }}" - if [ "${{ needs.deploy.result }}" == "success" ] && [ "${{ needs.test-deployment.result }}" == "success" ]; then - echo "" >> $GITHUB_STEP_SUMMARY - echo "**Deployment successful!**" >> $GITHUB_STEP_SUMMARY - else - echo "" >> $GITHUB_STEP_SUMMARY - echo "**Deployment failed. Check logs for details.**" >> $GITHUB_STEP_SUMMARY - fi + if [ "${{ needs.deploy.result }}" == "success" ] && [ "${{ needs.test-deployment.result }}" == "success" ]; then + echo "" + echo "**Deployment successful!**" + else + echo "" + echo "**Deployment failed. Check logs for details.**" + fi + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/deploy-azure.yml b/.github/workflows/deploy-azure.yml index 7c204e2d9..f589f4e29 100644 --- a/.github/workflows/deploy-azure.yml +++ b/.github/workflows/deploy-azure.yml @@ -257,11 +257,11 @@ jobs: # Get app URL APP_URL=$(terraform output -raw container_app_url 2>/dev/null | grep -v '::' || echo "") - echo "app_url=$APP_URL" >> $GITHUB_OUTPUT + echo "app_url=$APP_URL" >> "$GITHUB_OUTPUT" # Get app name APP_NAME=$(terraform output -raw container_app_name 2>/dev/null | grep -v '::' || echo "") - echo "app_name=$APP_NAME" >> $GITHUB_OUTPUT + echo "app_name=$APP_NAME" >> "$GITHUB_OUTPUT" # The provider fails the data read outright when the custom role is absent, # so a Terraform precondition can never run for this case. Issue #1794: the @@ -387,7 +387,7 @@ jobs: *) APP_URL="https://$APP_URL" ;; esac APP_URL="${APP_URL%/}" - echo "url=$APP_URL" >> $GITHUB_OUTPUT + echo "url=$APP_URL" >> "$GITHUB_OUTPUT" - name: Wait for Container App to be ready run: | @@ -461,28 +461,30 @@ jobs: - name: Post summary run: | - echo "## Azure Container Apps Deployment Summary" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "**Environment:** ${{ needs.prepare.outputs.environment }}" >> $GITHUB_STEP_SUMMARY - echo "**Location:** ${{ env.AZURE_LOCATION }}" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - - if [ -f deployment-info.json ]; then - echo "### Deployment Details" >> $GITHUB_STEP_SUMMARY - echo "\`\`\`json" >> $GITHUB_STEP_SUMMARY - cat deployment-info.json >> $GITHUB_STEP_SUMMARY - echo "\`\`\`" >> $GITHUB_STEP_SUMMARY - fi + { + echo "## Azure Container Apps Deployment Summary" + echo "" + echo "**Environment:** ${{ needs.prepare.outputs.environment }}" + echo "**Location:** ${{ env.AZURE_LOCATION }}" + echo "" + + if [ -f deployment-info.json ]; then + echo "### Deployment Details" + echo "\`\`\`json" + cat deployment-info.json + echo "\`\`\`" + fi - echo "" >> $GITHUB_STEP_SUMMARY - echo "### Job Results" >> $GITHUB_STEP_SUMMARY - echo "- Deploy: ${{ needs.build-and-deploy.result }}" >> $GITHUB_STEP_SUMMARY - echo "- Test: ${{ needs.test-deployment.result }}" >> $GITHUB_STEP_SUMMARY + echo "" + echo "### Job Results" + echo "- Deploy: ${{ needs.build-and-deploy.result }}" + echo "- Test: ${{ needs.test-deployment.result }}" - if [ "${{ needs.build-and-deploy.result }}" == "success" ] && [ "${{ needs.test-deployment.result }}" == "success" ]; then - echo "" >> $GITHUB_STEP_SUMMARY - echo "✅ **Deployment successful!**" >> $GITHUB_STEP_SUMMARY - else - echo "" >> $GITHUB_STEP_SUMMARY - echo "❌ **Deployment failed. Check logs for details.**" >> $GITHUB_STEP_SUMMARY - fi + if [ "${{ needs.build-and-deploy.result }}" == "success" ] && [ "${{ needs.test-deployment.result }}" == "success" ]; then + echo "" + echo "✅ **Deployment successful!**" + else + echo "" + echo "❌ **Deployment failed. Check logs for details.**" + fi + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/deploy-gcp.yml b/.github/workflows/deploy-gcp.yml index 9432c394a..14f350980 100644 --- a/.github/workflows/deploy-gcp.yml +++ b/.github/workflows/deploy-gcp.yml @@ -188,7 +188,7 @@ jobs: # Get service URL SERVICE_URL=$(terraform output -raw cloud_run_service_url 2>/dev/null | grep -v '::' || echo "") - echo "service_url=$SERVICE_URL" >> $GITHUB_OUTPUT + echo "service_url=$SERVICE_URL" >> "$GITHUB_OUTPUT" # No automatic state-lock release on failure. The step that used to live # here ran on `failure() || cancelled()` with no age check and no check @@ -238,7 +238,7 @@ jobs: exit 1 fi SERVICE_URL="${SERVICE_URL%/}" - echo "url=$SERVICE_URL" >> $GITHUB_OUTPUT + echo "url=$SERVICE_URL" >> "$GITHUB_OUTPUT" - name: Wait for Cloud Run to be ready run: | @@ -302,28 +302,30 @@ jobs: - name: Post summary run: | - echo "## GCP Cloud Run Deployment Summary" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "**Environment:** ${{ needs.prepare.outputs.environment }}" >> $GITHUB_STEP_SUMMARY - echo "**Region:** ${{ env.GCP_REGION }}" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - - if [ -f deployment-info.json ]; then - echo "### Deployment Details" >> $GITHUB_STEP_SUMMARY - echo "\`\`\`json" >> $GITHUB_STEP_SUMMARY - cat deployment-info.json >> $GITHUB_STEP_SUMMARY - echo "\`\`\`" >> $GITHUB_STEP_SUMMARY - fi + { + echo "## GCP Cloud Run Deployment Summary" + echo "" + echo "**Environment:** ${{ needs.prepare.outputs.environment }}" + echo "**Region:** ${{ env.GCP_REGION }}" + echo "" + + if [ -f deployment-info.json ]; then + echo "### Deployment Details" + echo "\`\`\`json" + cat deployment-info.json + echo "\`\`\`" + fi - echo "" >> $GITHUB_STEP_SUMMARY - echo "### Job Results" >> $GITHUB_STEP_SUMMARY - echo "- Deploy: ${{ needs.build-and-deploy.result }}" >> $GITHUB_STEP_SUMMARY - echo "- Test: ${{ needs.test-deployment.result }}" >> $GITHUB_STEP_SUMMARY + echo "" + echo "### Job Results" + echo "- Deploy: ${{ needs.build-and-deploy.result }}" + echo "- Test: ${{ needs.test-deployment.result }}" - if [ "${{ needs.build-and-deploy.result }}" == "success" ] && [ "${{ needs.test-deployment.result }}" == "success" ]; then - echo "" >> $GITHUB_STEP_SUMMARY - echo "✅ **Deployment successful!**" >> $GITHUB_STEP_SUMMARY - else - echo "" >> $GITHUB_STEP_SUMMARY - echo "❌ **Deployment failed. Check logs for details.**" >> $GITHUB_STEP_SUMMARY - fi + if [ "${{ needs.build-and-deploy.result }}" == "success" ] && [ "${{ needs.test-deployment.result }}" == "success" ]; then + echo "" + echo "✅ **Deployment successful!**" + else + echo "" + echo "❌ **Deployment failed. Check logs for details.**" + fi + } >> "$GITHUB_STEP_SUMMARY" From 0fd35ca3b237705b092370aa4af84ea46c32ae0d Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Wed, 19 Aug 2026 21:32:26 +0200 Subject: [PATCH 2/5] sec(ci): clear zizmor findings across the workflow set Three groups, 36 findings at medium severity and above. template-injection (high, 3): the 'Save deployment info' steps in deploy-aws-fargate.yml, deploy-azure.yml and deploy-gcp.yml wrote JSON through an UNQUOTED heredoc, so every interpolated value was pasted into shell source before bash parsed it and $(...) inside the body executed. This is the same shape as the rollback.yml defect: a job output or a terraform output carrying $(...) reaches a job holding id-token write. Each now passes its values through env: and builds the document with jq -n --arg, which escapes rather than evaluates. Key sets and ordering are unchanged per file. excessive-permissions (3 high, 3 medium): id-token write was declared at workflow level in the three deploy workflows, granting it to prepare, test-deployment and summary jobs that never authenticate. It now sits only on the jobs running configure-aws-credentials, azure/login or google-github-actions/auth. deploy-all.yml and ci.yml gain an explicit workflow-level contents: read, and every previously implicit job gains its own block. deploy-all.yml calls four deploy workflows through workflow_call, and a called workflow can never hold more permission than its caller job grants, so all four caller jobs explicitly grant id-token write. Without that the relocation would break every deployment. This half is verified by reading the graph, not by running it: dispatching a deploy workflow was out of scope, so the OIDC path is unproven at runtime. artipacked (medium, 14): actions/checkout persisted the credential into .git/config where later steps and uploaded artifacts could read it. All 14 checkouts set persist-credentials: false. No workflow in this set runs git push, git commit or git fetch against this repo, so nothing depended on the persisted credential. --- .github/workflows/aws_sanity.yml | 2 + .github/workflows/azure_sanity.yml | 2 + .github/workflows/ci.yml | 27 ++++++++++++ .github/workflows/cleanup-staging.yml | 8 ++++ .github/workflows/deploy-all.yml | 7 ++++ .github/workflows/deploy-aws-fargate.yml | 41 +++++++++++++------ .github/workflows/deploy-aws-lambda.yml | 2 + .github/workflows/deploy-azure.yml | 39 +++++++++++++----- .github/workflows/deploy-gcp.yml | 35 +++++++++++----- .github/workflows/destroy-fargate-dev.yml | 2 + .github/workflows/frontend-build-sentinel.yml | 2 + .github/workflows/frontend-build.yml | 2 + .github/workflows/pre-commit.yml | 2 + 13 files changed, 138 insertions(+), 33 deletions(-) diff --git a/.github/workflows/aws_sanity.yml b/.github/workflows/aws_sanity.yml index 51d59b7b9..77700a0e1 100644 --- a/.github/workflows/aws_sanity.yml +++ b/.github/workflows/aws_sanity.yml @@ -36,6 +36,8 @@ jobs: - name: Checkout if: steps.precheck.outputs.should_run == 'true' uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + persist-credentials: false - name: Setup Go if: steps.precheck.outputs.should_run == 'true' diff --git a/.github/workflows/azure_sanity.yml b/.github/workflows/azure_sanity.yml index 6d76f6ae0..3d888ecd6 100644 --- a/.github/workflows/azure_sanity.yml +++ b/.github/workflows/azure_sanity.yml @@ -35,6 +35,8 @@ jobs: - name: Checkout if: steps.precheck.outputs.should_run == 'true' uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + persist-credentials: false - name: Setup Go if: steps.precheck.outputs.should_run == 'true' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b4e115c58..156ee5aa1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,9 @@ name: CI - Build & Test +permissions: + contents: read + on: pull_request: branches: [main, develop] @@ -32,6 +35,8 @@ jobs: lint: name: Lint Code runs-on: ubuntu-latest + permissions: + contents: read steps: - name: Checkout code uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 @@ -77,6 +82,8 @@ jobs: unit-tests: name: Unit Tests runs-on: ubuntu-latest + permissions: + contents: read steps: - name: Checkout code uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 @@ -206,6 +213,8 @@ jobs: integration-tests: name: Integration Tests runs-on: ubuntu-latest + permissions: + contents: read services: postgres: @@ -331,6 +340,8 @@ jobs: docker-build: name: Build Docker Image runs-on: ubuntu-latest + permissions: + contents: read steps: - name: Checkout code uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 @@ -392,6 +403,8 @@ jobs: terraform-validate: name: Validate Terraform (${{ matrix.cloud }}) runs-on: ubuntu-latest + permissions: + contents: read strategy: matrix: cloud: [aws, gcp, azure] @@ -644,6 +657,8 @@ jobs: 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 @@ -671,6 +686,8 @@ jobs: e2e-tests: name: E2E Tests runs-on: ubuntu-latest + permissions: + contents: read steps: - name: Checkout code @@ -702,6 +719,8 @@ jobs: azure-role-parity: name: Azure role actions parity (ARM vs TF) runs-on: ubuntu-latest + permissions: + contents: read steps: - name: Checkout code @@ -721,6 +740,8 @@ jobs: aws-iam-parity: name: AWS IAM actions parity (CFN vs TF) runs-on: ubuntu-latest + permissions: + contents: read steps: - name: Checkout code @@ -740,6 +761,8 @@ jobs: gcp-secret-scope: name: GCP Secret Manager grant scope runs-on: ubuntu-latest + permissions: + contents: read steps: - name: Checkout code @@ -867,6 +890,8 @@ jobs: azure-kv-access-policy: name: Azure Key Vault grant model runs-on: ubuntu-latest + permissions: + contents: read steps: - name: Checkout code @@ -900,6 +925,8 @@ jobs: - aws-tfstate-platform-key - azure-kv-access-policy if: always() + permissions: + contents: read steps: - name: Check all jobs diff --git a/.github/workflows/cleanup-staging.yml b/.github/workflows/cleanup-staging.yml index 368d06147..672147234 100644 --- a/.github/workflows/cleanup-staging.yml +++ b/.github/workflows/cleanup-staging.yml @@ -118,6 +118,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + persist-credentials: false - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@d979d5b3a71173a29b74b5b88418bfda9437d885 # v6.1.1 @@ -200,6 +202,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + persist-credentials: false - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@d979d5b3a71173a29b74b5b88418bfda9437d885 # v6.1.1 @@ -287,6 +291,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + persist-credentials: false - name: Azure Login uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43 # v3.0.0 @@ -354,6 +360,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + persist-credentials: false - name: Authenticate to GCP uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3.0.0 diff --git a/.github/workflows/deploy-all.yml b/.github/workflows/deploy-all.yml index c6f85c895..c89d51d69 100644 --- a/.github/workflows/deploy-all.yml +++ b/.github/workflows/deploy-all.yml @@ -15,6 +15,9 @@ name: Deploy to All Clouds +permissions: + contents: read + # Adding a trigger here, or a second caller of the deploy-* workflows, means # revisiting the `prepare` comments in deploy-azure.yml, deploy-gcp.yml and # deploy-aws-lambda.yml. Since #1805 those three resolve their environment from @@ -63,6 +66,8 @@ jobs: determine-deployment: name: Determine Deployment Strategy runs-on: ubuntu-latest + permissions: + contents: read outputs: environment: ${{ steps.set-env.outputs.environment }} deploy-aws-lambda: ${{ steps.set-clouds.outputs.deploy-aws-lambda }} @@ -245,6 +250,8 @@ jobs: - deploy-azure if: always() runs-on: ubuntu-latest + permissions: + contents: read steps: - name: Aggregate results diff --git a/.github/workflows/deploy-aws-fargate.yml b/.github/workflows/deploy-aws-fargate.yml index 6601ae78f..3de317f97 100644 --- a/.github/workflows/deploy-aws-fargate.yml +++ b/.github/workflows/deploy-aws-fargate.yml @@ -22,7 +22,6 @@ name: Deploy to AWS Fargate permissions: - id-token: write contents: read # No workflow-level `concurrency` on purpose. What needs protecting is the @@ -69,6 +68,8 @@ jobs: prepare: name: Prepare Deployment runs-on: ubuntu-latest + permissions: + contents: read outputs: environment: ${{ steps.set-env.outputs.environment }} image_tag: ${{ steps.set-tag.outputs.tag }} @@ -116,6 +117,9 @@ jobs: name: Deploy to Fargate runs-on: ubuntu-24.04-arm needs: prepare + permissions: + id-token: write + contents: read # Every run that writes # s3:///github-fargate-/terraform.tfstate serializes # here, whatever ref or workflow it came from (#1806). This is a DIFFERENT @@ -141,6 +145,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + persist-credentials: false - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@d979d5b3a71173a29b74b5b88418bfda9437d885 # v6.1.1 @@ -213,18 +219,25 @@ jobs: echo "service_name=$(terraform output -raw fargate_service_name 2>/dev/null | grep -v '::' || echo "")" >> "$GITHUB_OUTPUT" - name: Save deployment info + env: + ENVIRONMENT: ${{ needs.prepare.outputs.environment }} + IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }} + ALB_URL: ${{ steps.outputs.outputs.alb_url }} + SERVICE_NAME: ${{ steps.outputs.outputs.service_name }} + DEPLOYED_BY: ${{ github.actor }} + COMMIT: ${{ github.sha }} run: | - cat < deployment-info.json - { - "environment": "${{ needs.prepare.outputs.environment }}", - "image_tag": "${{ needs.prepare.outputs.image_tag }}", - "alb_url": "${{ steps.outputs.outputs.alb_url }}", - "service_name": "${{ steps.outputs.outputs.service_name }}", - "deployed_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)", - "deployed_by": "${{ github.actor }}", - "commit": "${{ github.sha }}" - } - EOF + set -euo pipefail + jq -n \ + --arg environment "$ENVIRONMENT" \ + --arg image_tag "$IMAGE_TAG" \ + --arg alb_url "$ALB_URL" \ + --arg service_name "$SERVICE_NAME" \ + --arg deployed_at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + --arg deployed_by "$DEPLOYED_BY" \ + --arg commit "$COMMIT" \ + '{environment: $environment, image_tag: $image_tag, alb_url: $alb_url, service_name: $service_name, deployed_at: $deployed_at, deployed_by: $deployed_by, commit: $commit}' \ + > deployment-info.json cat deployment-info.json - name: Upload deployment info @@ -240,6 +253,8 @@ jobs: runs-on: ubuntu-latest needs: [prepare, deploy] if: always() && needs.deploy.result == 'success' + permissions: + contents: read steps: - name: Get ALB URL @@ -309,6 +324,8 @@ jobs: runs-on: ubuntu-latest needs: [prepare, deploy, test-deployment] if: always() + permissions: + contents: read steps: - name: Download deployment info diff --git a/.github/workflows/deploy-aws-lambda.yml b/.github/workflows/deploy-aws-lambda.yml index 8c177e033..2932a72d0 100644 --- a/.github/workflows/deploy-aws-lambda.yml +++ b/.github/workflows/deploy-aws-lambda.yml @@ -237,6 +237,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + persist-credentials: false - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@d979d5b3a71173a29b74b5b88418bfda9437d885 # v6.1.1 diff --git a/.github/workflows/deploy-azure.yml b/.github/workflows/deploy-azure.yml index f589f4e29..92f1348c5 100644 --- a/.github/workflows/deploy-azure.yml +++ b/.github/workflows/deploy-azure.yml @@ -23,7 +23,6 @@ name: Deploy to Azure Container Apps permissions: - id-token: write contents: read # No workflow-level `concurrency` on purpose. What needs protecting is the @@ -73,6 +72,8 @@ jobs: prepare: name: Prepare Deployment runs-on: ubuntu-latest + permissions: + contents: read outputs: environment: ${{ steps.set-env.outputs.environment }} steps: @@ -128,6 +129,9 @@ jobs: name: Build & Deploy runs-on: ubuntu-latest needs: prepare + permissions: + id-token: write + contents: read # Every run that writes github-.terraform.tfstate serializes # here, whatever its ref and whatever workflow it came from. The group name # is a 1:1 function of the state key, and the same literal prefix is used by @@ -155,6 +159,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + persist-credentials: false - name: Azure Login uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43 # v3.0.0 @@ -338,17 +344,23 @@ jobs: # leases, which is the bug this issue is about. - name: Save deployment info + env: + ENVIRONMENT: ${{ needs.prepare.outputs.environment }} + APP_URL: ${{ steps.deploy.outputs.app_url }} + APP_NAME: ${{ steps.deploy.outputs.app_name }} + DEPLOYED_BY: ${{ github.actor }} + COMMIT: ${{ github.sha }} run: | - cat < deployment-info.json - { - "environment": "${{ needs.prepare.outputs.environment }}", - "app_url": "${{ steps.deploy.outputs.app_url }}", - "app_name": "${{ steps.deploy.outputs.app_name }}", - "deployed_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)", - "deployed_by": "${{ github.actor }}", - "commit": "${{ github.sha }}" - } - EOF + set -euo pipefail + jq -n \ + --arg environment "$ENVIRONMENT" \ + --arg app_url "$APP_URL" \ + --arg app_name "$APP_NAME" \ + --arg deployed_at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + --arg deployed_by "$DEPLOYED_BY" \ + --arg commit "$COMMIT" \ + '{environment: $environment, app_url: $app_url, app_name: $app_name, deployed_at: $deployed_at, deployed_by: $deployed_by, commit: $commit}' \ + > deployment-info.json cat deployment-info.json - name: Upload deployment info @@ -364,6 +376,9 @@ jobs: runs-on: ubuntu-latest needs: [prepare, build-and-deploy] if: always() && needs.build-and-deploy.result == 'success' + permissions: + id-token: write + contents: read steps: - name: Azure Login @@ -451,6 +466,8 @@ jobs: runs-on: ubuntu-latest needs: [prepare, build-and-deploy, test-deployment] if: always() + permissions: + contents: read steps: - name: Download deployment info diff --git a/.github/workflows/deploy-gcp.yml b/.github/workflows/deploy-gcp.yml index 14f350980..46f93ed45 100644 --- a/.github/workflows/deploy-gcp.yml +++ b/.github/workflows/deploy-gcp.yml @@ -21,7 +21,6 @@ name: Deploy to GCP Cloud Run permissions: - id-token: write contents: read # No workflow-level `concurrency` on purpose. What needs protecting is the @@ -63,6 +62,8 @@ jobs: prepare: name: Prepare Deployment runs-on: ubuntu-latest + permissions: + contents: read outputs: environment: ${{ steps.set-env.outputs.environment }} steps: @@ -118,6 +119,9 @@ jobs: name: Build & Deploy runs-on: ubuntu-latest needs: prepare + permissions: + id-token: write + contents: read # Every run that writes gs:///github-/default.tfstate # serializes here, whatever ref or workflow it came from (#1806). The suffix # is the exact value the backend prefix below is built from, so the group and @@ -136,6 +140,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + persist-credentials: false - name: Authenticate to Google Cloud uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3.0.0 @@ -202,16 +208,21 @@ jobs: # `terraform force-unlock ` -- see runbooks/terraform-stuck-lock.md. - name: Save deployment info + env: + ENVIRONMENT: ${{ needs.prepare.outputs.environment }} + SERVICE_URL: ${{ steps.deploy.outputs.service_url }} + DEPLOYED_BY: ${{ github.actor }} + COMMIT: ${{ github.sha }} run: | - cat < deployment-info.json - { - "environment": "${{ needs.prepare.outputs.environment }}", - "service_url": "${{ steps.deploy.outputs.service_url }}", - "deployed_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)", - "deployed_by": "${{ github.actor }}", - "commit": "${{ github.sha }}" - } - EOF + set -euo pipefail + jq -n \ + --arg environment "$ENVIRONMENT" \ + --arg service_url "$SERVICE_URL" \ + --arg deployed_at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + --arg deployed_by "$DEPLOYED_BY" \ + --arg commit "$COMMIT" \ + '{environment: $environment, service_url: $service_url, deployed_at: $deployed_at, deployed_by: $deployed_by, commit: $commit}' \ + > deployment-info.json cat deployment-info.json - name: Upload deployment info @@ -227,6 +238,8 @@ jobs: runs-on: ubuntu-latest needs: [prepare, build-and-deploy] if: always() && needs.build-and-deploy.result == 'success' + permissions: + contents: read steps: - name: Get Service URL @@ -292,6 +305,8 @@ jobs: runs-on: ubuntu-latest needs: [prepare, build-and-deploy, test-deployment] if: always() + permissions: + contents: read steps: - name: Download deployment info diff --git a/.github/workflows/destroy-fargate-dev.yml b/.github/workflows/destroy-fargate-dev.yml index 6bc98a04b..d01fc2bb1 100644 --- a/.github/workflows/destroy-fargate-dev.yml +++ b/.github/workflows/destroy-fargate-dev.yml @@ -100,6 +100,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + persist-credentials: false - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@d979d5b3a71173a29b74b5b88418bfda9437d885 # v6.1.1 diff --git a/.github/workflows/frontend-build-sentinel.yml b/.github/workflows/frontend-build-sentinel.yml index 6b98bb69d..3e7a1af95 100644 --- a/.github/workflows/frontend-build-sentinel.yml +++ b/.github/workflows/frontend-build-sentinel.yml @@ -40,6 +40,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + persist-credentials: false - name: Set up Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 diff --git a/.github/workflows/frontend-build.yml b/.github/workflows/frontend-build.yml index fa5167b90..38ae527e2 100644 --- a/.github/workflows/frontend-build.yml +++ b/.github/workflows/frontend-build.yml @@ -45,6 +45,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + persist-credentials: false - name: Set up Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index 7744f9d37..8e91b95e5 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -23,6 +23,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + persist-credentials: false - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 From 6797086163a26784bc85b9768a8af9cb1f563cef Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Wed, 19 Aug 2026 21:42:34 +0200 Subject: [PATCH 3/5] chore(ci): gate workflows on actionlint and zizmor Nothing in this repo read .github/workflows/ for defects. govulncheck and gosec are Go source scanners, trivy-config targets Terraform, Dockerfile and Kubernetes, and check-yaml only proves the YAML parses. That gap is why two expression injections reaching production cloud credentials (#1542, #1649) passed every CI run on every push. Adds a workflow-lint job to ci.yml and the matching pair of pre-commit hooks, so the same two linters run at the same pinned versions before the push and again in CI. workflow-lint is listed in ci-success needs, which allowlists on success, so the gate blocks rather than warns. Both linters are carried because neither substitutes for the other. Measured against the pre-fix rollback.yml, actionlint exits 1 but every finding is unrelated SC2086 noise; it never flags the injected heredoc, and against a reintroduced #1649 tag interpolation it reports nothing at all. zizmor names both, high severity and high confidence. Pins: actionlint 1.7.12 by image digest and zizmor 1.29.0 by version. Tags are mutable and a linter that changes ruleset on its own schedule turns main red without a change here, which is the hadolint failure in #1695. zizmor runs --offline for the same reason: its online audits query the GitHub API, so findings would move under us. The actionlint image bundles shellcheck. actionlint silently skips every run: block and still exits 0 when shellcheck is absent from PATH, which is how the rollback.yml injection went unflagged, so the job asserts the binary is present rather than trusting the image to keep shipping it. A step also asserts a non-zero workflow file count. Both linters do exit 3 on an empty input set, so this is defence in depth against a path or glob regression that leaves a subset unscanned rather than the only guard against a vacuous pass. --min-severity=medium is a threshold, not a suppression: no baseline file, no per-finding ignore, no only-new-issues, so any new medium or high finding fails. Both motivating bugs score high. The informational and low tier below it is 42 template-injection hits on values #1649 already assessed as non-injectable; clearing it means rewriting the deploy workflows and is left to a follow-up rather than silenced. --- .github/workflows/ci.yml | 90 ++++++++++++++++++++++++++++++++++++++++ .pre-commit-config.yaml | 44 ++++++++++++++++++++ 2 files changed, 134 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 156ee5aa1..479af4ae6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,6 +78,95 @@ jobs: fi echo "✅ All functions have acceptable cyclomatic complexity (≤10)" + # GitHub Actions workflow linting + # + # Nothing else in this repo reads .github/workflows/ for defects. govulncheck + # and gosec are Go source scanners, trivy-config targets Terraform/Dockerfile/ + # Kubernetes, and check-yaml only proves the YAML parses. That gap is why the + # rollback.yml and deploy-aws-lambda.yml expression injections (#1542, #1649), + # both reaching production cloud credentials, passed every CI run. + # + # Both linters are needed and neither substitutes for the other: + # - actionlint catches workflow-level defects and, via shellcheck, shell + # bugs inside run: blocks. + # - zizmor has a template-injection audit that names the injection itself. + # Measured against the pre-fix rollback.yml, actionlint exited 1 only on + # unrelated SC2086 noise and never flagged the injected heredoc at all; + # zizmor flagged that exact line high severity, high confidence. actionlint + # alone would not have caught the bug this job exists to prevent. + 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 ' ') + # Both linters exit 0 on an empty input set, so a bad path or a glob + # that stops matching would leave this job green while scanning + # nothing. Fail instead of reporting a vacuous pass. + 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 + run: | + set -euo pipefail + docker run --rm -v "$PWD:/repo" -w /repo "$ACTIONLINT_IMAGE" \ + -color .github/workflows/*.yml + + - 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. + # + # --min-severity=medium is a gate threshold, not a suppression: there is + # no baseline file, no per-finding ignore and no only-new-issues, so any + # new medium or high finding fails this job. The pre-fix rollback.yml + # injection scored high/high, so the class this job exists to catch is + # above the line. The informational and low tier that sits below it is + # 42 template-injection hits on values already assessed as + # non-injectable in #1649 (github.actor, github.sha and similar); + # clearing it means rewriting the deploy workflows and is tracked + # separately 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=regular --min-severity=medium \ + --color=always .github/workflows/ + # Unit tests with race detection unit-tests: name: Unit Tests @@ -911,6 +1000,7 @@ jobs: runs-on: ubuntu-latest needs: - lint + - workflow-lint - unit-tests - integration-tests - docker-build diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0cd4ffb32..badfdc328 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -89,6 +89,50 @@ repos: entry: ghcr.io/hadolint/hadolint:v2.15.1@sha256:32dac94127fd60b7b7e3fbfc65e1383b9b5e25c9bfd7b8536de7a539fe68a12d hadolint types: [dockerfile] + # GitHub Actions workflow linting + # + # Runs the same two linters as ci.yml's workflow-lint job, at the same pinned + # versions, so an expression injection is caught before the push rather than + # after. Keep the versions here and in ci.yml in lockstep. + # + # actionlint is a local docker_image hook rather than the upstream + # rhysd/actionlint hook for the reason spelled out above for hadolint: the + # upstream actionlint-docker hook's entry pins the image by mutable tag, and + # `rev:` only pins the hook definition, not the image it runs. This entry + # pins the digest, so the ruleset can only change by editing this line. + # + # The image bundles shellcheck. actionlint silently skips every run: block + # and still exits 0 when shellcheck is absent from PATH, which is how the + # rollback.yml injection (#1542) went unflagged, so a system hook that + # depends on each developer having shellcheck installed would reintroduce + # exactly that gap. + - repo: local + hooks: + - id: actionlint + name: Lint GitHub Actions workflows + language: docker_image + entry: rhysd/actionlint:1.7.12@sha256:b1934ee5f1c509618f2508e6eb47ee0d3520686341fec936f3b79331f9315667 + types: [yaml] + files: ^\.github/workflows/ + + # zizmor is what actually closes the injection gap: actionlint only + # catches this class indirectly, through shellcheck on the expanded + # script, and against the pre-fix rollback.yml it never flagged the + # injected heredoc at all. zizmor's template-injection audit named that + # exact line. Upstream publishes no .pre-commit-hooks.yaml, so this is a + # local hook with the version pinned in additional_dependencies. + # + # Flags mirror ci.yml exactly. --min-severity=medium is a threshold, not + # a suppression: no baseline file and no per-finding ignores, so every + # new medium or high finding fails. The pre-fix injection scored high. + - id: zizmor + name: Audit GitHub Actions workflows for injection + language: python + additional_dependencies: ['zizmor==1.29.0'] + entry: zizmor --offline --persona=regular --min-severity=medium + types: [yaml] + files: ^\.github/workflows/ + # Markdown linting - repo: https://github.com/igorshubovych/markdownlint-cli rev: v0.47.0 From b512bd40c6cd901797b068b184d9f1005510897e Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Wed, 19 Aug 2026 22:06:35 +0200 Subject: [PATCH 4/5] sec(ci): raise the zizmor gate to the pedantic persona Review of the previous commit found that --persona=regular, zizmor's default, hides three high-severity findings this repo actually has, so the claim that every medium and high finding fails the job was false. The persona, not the severity threshold, was discarding them. Fixed rather than filtered, which is what lets the gate move up: - aws_sanity.yml and azure_sanity.yml declared id-token: write at workflow level, the same pattern the previous commit removed from the three deploy workflows. Both have a single job today so this is behaviour-preserving, but it stops being so the moment a second job is added. - ci.yml ran the integration-test postgres service on the floating tag postgres:16-alpine. Now pinned by digest, consistent with the hadolint and actionlint pins and with #1695. The gate moves to --persona=pedantic in both ci.yml and the pre-commit hook. auditor is one level further up and is documented as accepting false positives, so it is not used. Verified the stricter setting still fails on both motivating bugs: a tree carrying the pre-fix rollback.yml and a reintroduced #1649 tag interpolation exits 14 and names both. Also from the review: - actionlint now runs with no file arguments and discovers .github/workflows itself. The explicit *.yml glob would have skipped a .yaml workflow that the file-count step above still counted. - The file-count comment claimed both linters exit 0 on an empty input set. They exit 3. The step is defence in depth against a set that shrinks silently, not the only thing preventing a vacuous pass, and the comment now says so. - Three job comments in ci.yml described narrowing the repository default token, which stopped being true when the previous commit added a workflow-level permissions block. --- .github/workflows/aws_sanity.yml | 4 +- .github/workflows/azure_sanity.yml | 4 +- .github/workflows/ci.yml | 71 +++++++++++++++--------- .github/workflows/database-migration.yml | 4 +- .pre-commit-config.yaml | 10 ++-- 5 files changed, 60 insertions(+), 33 deletions(-) diff --git a/.github/workflows/aws_sanity.yml b/.github/workflows/aws_sanity.yml index 77700a0e1..c9618918e 100644 --- a/.github/workflows/aws_sanity.yml +++ b/.github/workflows/aws_sanity.yml @@ -7,12 +7,14 @@ on: workflow_dispatch: permissions: - id-token: write contents: read jobs: sanity: runs-on: ubuntu-latest + permissions: + id-token: write + contents: read env: AWS_REGION: us-east-1 REPORT_PATH: sanity_report.json diff --git a/.github/workflows/azure_sanity.yml b/.github/workflows/azure_sanity.yml index 3d888ecd6..8c91c1802 100644 --- a/.github/workflows/azure_sanity.yml +++ b/.github/workflows/azure_sanity.yml @@ -7,12 +7,14 @@ on: workflow_dispatch: permissions: - id-token: write contents: read jobs: sanity: runs-on: ubuntu-latest + permissions: + id-token: write + contents: read env: REPORT_PATH: azure_sanity_report.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 479af4ae6..0284c3e84 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -115,9 +115,10 @@ jobs: run: | set -euo pipefail count=$(find .github/workflows -maxdepth 1 -type f \( -name '*.yml' -o -name '*.yaml' \) | wc -l | tr -d ' ') - # Both linters exit 0 on an empty input set, so a bad path or a glob - # that stops matching would leave this job green while scanning - # nothing. Fail instead of reporting a vacuous pass. + # 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 @@ -139,32 +140,50 @@ jobs: 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 .github/workflows/*.yml + 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. # - # --min-severity=medium is a gate threshold, not a suppression: there is - # no baseline file, no per-finding ignore and no only-new-issues, so any - # new medium or high finding fails this job. The pre-fix rollback.yml - # injection scored high/high, so the class this job exists to catch is - # above the line. The informational and low tier that sits below it is - # 42 template-injection hits on values already assessed as - # non-injectable in #1649 (github.actor, github.sha and similar); - # clearing it means rewriting the deploy workflows and is tracked - # separately rather than silenced here. + # 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=regular --min-severity=medium \ + --offline --persona=pedantic --min-severity=medium \ --color=always .github/workflows/ # Unit tests with race detection @@ -307,7 +326,10 @@ jobs: services: postgres: - image: postgres:16-alpine + # Pinned by digest for the same reason as the linter images below: a + # floating tag lets the service container change under an unchanged + # repo, which is how #1695 turned main red. + image: postgres:16-alpine@sha256:cf78e76683b9ca8c5733cbbdce6c9262b45b6767934dd0a95e671f9a0fc20685 env: POSTGRES_DB: cudly_test POSTGRES_USER: cudly_test @@ -882,11 +904,12 @@ jobs: ecr-delete-selection: name: ECR delete selection scope runs-on: ubuntu-latest - # ci.yml declares no workflow-level `permissions`, so a job without its own - # block gets the repository default, which is read/write on this repo. This - # job checks out the tree and runs a shell script against it; `contents: - # read` is all of that needs, and it is the same shape security-scan above - # uses (which adds `security-events: write` only because it uploads SARIF). + # This job checks out the tree and runs a shell script against it, so + # `contents: read` is all it needs. Same shape as security-scan above, + # which adds `security-events: write` only because it uploads SARIF. + # ci.yml now also declares `contents: read` at workflow level, so this + # block narrows nothing on its own; it is kept explicit so the job states + # its own requirement rather than inheriting silently. permissions: contents: read @@ -922,8 +945,7 @@ jobs: name: RDS deletion protection scope runs-on: ubuntu-latest # Same shape as ecr-delete-selection above: this job checks out the tree and - # runs a shell script against it, so the repository-default read/write token - # is narrowed to `contents: read`. + # runs a shell script against it, so `contents: read` is all it needs. permissions: contents: read @@ -956,8 +978,7 @@ jobs: name: AWS Terraform state namespace per platform runs-on: ubuntu-latest # Same shape as ecr-delete-selection above: this job checks out the tree and - # runs a shell script against it, so the repository-default read/write token - # is narrowed to `contents: read`. + # runs a shell script against it, so `contents: read` is all it needs. permissions: contents: read diff --git a/.github/workflows/database-migration.yml b/.github/workflows/database-migration.yml index c745755d2..973f68772 100644 --- a/.github/workflows/database-migration.yml +++ b/.github/workflows/database-migration.yml @@ -175,8 +175,8 @@ jobs: # Check migration files if [ -d "$MIGRATIONS_PATH" ]; then - UP_COUNT=$(find "$MIGRATIONS_PATH" -maxdepth 1 -name '*.up.sql' 2>/dev/null | wc -l) - DOWN_COUNT=$(find "$MIGRATIONS_PATH" -maxdepth 1 -name '*.down.sql' 2>/dev/null | wc -l) + UP_COUNT=$(find "$MIGRATIONS_PATH" -maxdepth 1 -type f -name '*.up.sql' 2>/dev/null | wc -l) + DOWN_COUNT=$(find "$MIGRATIONS_PATH" -maxdepth 1 -type f -name '*.down.sql' 2>/dev/null | wc -l) echo "Migration files found:" echo " Up migrations: $UP_COUNT" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index badfdc328..af6a3f9ec 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -122,14 +122,16 @@ repos: # exact line. Upstream publishes no .pre-commit-hooks.yaml, so this is a # local hook with the version pinned in additional_dependencies. # - # Flags mirror ci.yml exactly. --min-severity=medium is a threshold, not - # a suppression: no baseline file and no per-finding ignores, so every - # new medium or high finding fails. The pre-fix injection scored high. + # Flags mirror ci.yml exactly; keep them in lockstep. --persona=pedantic + # because the default regular persona hides high-severity findings this + # repo had, and --min-severity=medium is a threshold rather than a + # suppression: no baseline file and no per-finding ignores, so every + # medium and high finding fails. The pre-fix injection scored high. - id: zizmor name: Audit GitHub Actions workflows for injection language: python additional_dependencies: ['zizmor==1.29.0'] - entry: zizmor --offline --persona=regular --min-severity=medium + entry: zizmor --offline --persona=pedantic --min-severity=medium types: [yaml] files: ^\.github/workflows/ From 4297642ed3db7abcf5f7c9b37894042dda76abe6 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Wed, 19 Aug 2026 22:24:24 +0200 Subject: [PATCH 5/5] chore(ci): pin the postgres image in both compose files The digest pin landed in ci.yml because zizmor's unpinned-images audit reads workflow files, and stopped there. docker-compose.test.yml is what ci.yml's e2e-tests job runs, so the pin closed the integration-tests database path and left the e2e one floating, and nothing gated the difference because no checker looks at compose files. Both now carry the same digest as the ci.yml service container, so local development and both CI database paths run identical PostgreSQL. nginx:alpine and dpage/pgadmin4:9.2 in docker-compose.yml are left on their tags. Both are local-development-only services, neither is reachable from any workflow, and pgadmin is already marked as not for production use. Recorded here rather than pinned silently so the choice is visible. --- docker-compose.test.yml | 6 +++++- docker-compose.yml | 4 +++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 8043691f6..a63a7d89e 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -4,7 +4,11 @@ services: # PostgreSQL Database postgres: - image: postgres:16-alpine + # Digest-pinned for the same reason as the ci.yml service container and the + # linter images: a floating tag lets the database change under an unchanged + # repo. This file is what ci.yml's e2e-tests job runs, so leaving it float + # would have pinned the integration-tests path and left e2e open. + image: postgres:16-alpine@sha256:cf78e76683b9ca8c5733cbbdce6c9262b45b6767934dd0a95e671f9a0fc20685 container_name: cudly-test-postgres environment: POSTGRES_DB: cudly_test diff --git a/docker-compose.yml b/docker-compose.yml index b006d78e3..d9e45a85f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,9 @@ services: # PostgreSQL database postgres: - image: postgres:16-alpine + # Same digest as docker-compose.test.yml and ci.yml, so local development + # and both CI database paths run identical PostgreSQL. + image: postgres:16-alpine@sha256:cf78e76683b9ca8c5733cbbdce6c9262b45b6767934dd0a95e671f9a0fc20685 container_name: cudly-postgres environment: POSTGRES_DB: cudly