diff --git a/.github/workflows/aws_sanity.yml b/.github/workflows/aws_sanity.yml index 51d59b7b9..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 @@ -36,6 +38,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..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 @@ -35,6 +37,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 db253ee91..0284c3e84 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 @@ -73,10 +78,120 @@ 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 ' ') + # 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 @@ -206,10 +321,15 @@ jobs: integration-tests: name: Integration Tests runs-on: ubuntu-latest + permissions: + contents: read 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 @@ -331,6 +451,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 +514,8 @@ jobs: terraform-validate: name: Validate Terraform (${{ matrix.cloud }}) runs-on: ubuntu-latest + permissions: + contents: read strategy: matrix: cloud: [aws, gcp, azure] @@ -644,6 +768,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 +797,8 @@ jobs: e2e-tests: name: E2E Tests runs-on: ubuntu-latest + permissions: + contents: read steps: - name: Checkout code @@ -702,6 +830,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 +851,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 +872,8 @@ jobs: gcp-secret-scope: name: GCP Secret Manager grant scope runs-on: ubuntu-latest + permissions: + contents: read steps: - name: Checkout code @@ -770,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 @@ -810,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 @@ -844,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 @@ -867,6 +1000,8 @@ jobs: azure-kv-access-policy: name: Azure Key Vault grant model runs-on: ubuntu-latest + permissions: + contents: read steps: - name: Checkout code @@ -886,6 +1021,7 @@ jobs: runs-on: ubuntu-latest needs: - lint + - workflow-lint - unit-tests - integration-tests - docker-build @@ -900,6 +1036,8 @@ jobs: - aws-tfstate-platform-key - azure-kv-access-policy if: always() + permissions: + contents: read steps: - name: Check all jobs @@ -925,6 +1063,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/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/database-migration.yml b/.github/workflows/database-migration.yml index 252ca3d5a..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=$(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 -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" @@ -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..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 }} @@ -113,22 +118,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 +141,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: @@ -243,62 +250,66 @@ jobs: - deploy-azure if: always() runs-on: ubuntu-latest + permissions: + contents: read 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 +338,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..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 }} @@ -109,13 +110,16 @@ 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: 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 @@ -209,22 +215,29 @@ 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 + 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 @@ -256,7 +271,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: | @@ -309,6 +324,8 @@ jobs: runs-on: ubuntu-latest needs: [prepare, deploy, test-deployment] if: always() + permissions: + contents: read steps: - name: Download deployment info @@ -319,28 +336,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-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 7c204e2d9..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 @@ -257,11 +263,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 @@ -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 @@ -387,7 +402,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: | @@ -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 @@ -461,28 +478,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..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 @@ -188,7 +194,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 @@ -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 @@ -238,7 +251,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: | @@ -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 @@ -302,28 +317,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" 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 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0cd4ffb32..af6a3f9ec 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -89,6 +89,52 @@ 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; 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=pedantic --min-severity=medium + types: [yaml] + files: ^\.github/workflows/ + # Markdown linting - repo: https://github.com/igorshubovych/markdownlint-cli rev: v0.47.0 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