From 88b4f2153f48d6174837469c21cf68090cfb32cb Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Tue, 22 Sep 2026 15:30:04 -0400 Subject: [PATCH] ci: convert onto org HCP reusables Switch Fargate CD and CI to the v1.0.13 org workflows, emit ci-complete, and retarget githubdeploy OIDC to the reusable plus the thin caller. --- .github/workflows/ci-terraform.yaml | 32 ---- .github/workflows/ci.yml | 53 +++++- .github/workflows/deploy-api.yaml | 225 ++++---------------------- .security-review/suppressions.json | 2 +- README.md | 3 +- terraform/iam_github_deploy.tf | 11 +- tests/test_terraform_github_deploy.py | 26 +++ tests/test_terraform_sentry.py | 3 +- 8 files changed, 119 insertions(+), 236 deletions(-) delete mode 100644 .github/workflows/ci-terraform.yaml create mode 100644 tests/test_terraform_github_deploy.py diff --git a/.github/workflows/ci-terraform.yaml b/.github/workflows/ci-terraform.yaml deleted file mode 100644 index 41091ae..0000000 --- a/.github/workflows/ci-terraform.yaml +++ /dev/null @@ -1,32 +0,0 @@ -name: Terraform CI -on: - pull_request: - branches: [main] - paths: - - "terraform/**" - - ".github/workflows/ci-terraform.yaml" - -permissions: - contents: read - -jobs: - terraform: - runs-on: ubuntu-latest - defaults: - run: - working-directory: terraform - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1 - with: - terraform_version: "1.9.8" - - - name: Terraform fmt - run: terraform fmt -check -recursive - - - name: Terraform init - run: terraform init -backend=false - - - name: Terraform validate - run: terraform validate diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 506614f..23775b1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,21 +1,39 @@ name: CI on: pull_request: - branches: [main] + branches: [main, hotfix/**, release/**] merge_group: + push: + branches: [hotfix/**, release/**] permissions: contents: read jobs: - ci: - uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-app.yaml@9781774f04b824b1182ff41638687f1c01c04361 # v1.0.11 + autofix: + if: github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork + uses: Sea-Haven-Industries/.github/.github/workflows/ci-autofix.yaml@216604ad67aad01f832291bbce0cac8e37435221 # v1.0.13 + permissions: + contents: write + secrets: inherit + with: + format-command: ruff format . + lint-fix-command: ruff check --fix . + extra-command: terraform fmt -recursive terraform + terraform-version: "1.9.8" + + lint: + needs: autofix + if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true') + uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-app.yaml@216604ad67aad01f832291bbce0cac8e37435221 # v1.0.13 with: python-version: "3.12.14" requirements: "requirements-api.txt" collect-only: false template-js: + needs: autofix + if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true') runs-on: ubuntu-latest timeout-minutes: 5 steps: @@ -38,6 +56,8 @@ jobs: run: npm run openapi:lint test: + needs: autofix + if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true') runs-on: ubuntu-latest timeout-minutes: 15 steps: @@ -63,3 +83,30 @@ jobs: - name: Run tests run: pytest + + terraform: + needs: autofix + if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true') + uses: Sea-Haven-Industries/.github/.github/workflows/ci-terraform.yaml@216604ad67aad01f832291bbce0cac8e37435221 # v1.0.13 + with: + terraform-version: "1.9.8" + + ci-complete: + name: ci-complete + needs: [autofix, lint, template-js, test, terraform] + if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true') + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Require portions + env: + LINT: ${{ needs.lint.result }} + TEMPLATE_JS: ${{ needs.template-js.result }} + TEST: ${{ needs.test.result }} + TERRAFORM: ${{ needs.terraform.result }} + run: | + set -euo pipefail + test "${LINT}" = success + test "${TEMPLATE_JS}" = success + test "${TEST}" = success + test "${TERRAFORM}" = success diff --git a/.github/workflows/deploy-api.yaml b/.github/workflows/deploy-api.yaml index 910f654..a1b82f7 100644 --- a/.github/workflows/deploy-api.yaml +++ b/.github/workflows/deploy-api.yaml @@ -1,8 +1,8 @@ name: Deploy API -# Fargate image CD (PLAT-215). GitHub Actions builds the Flask image, pushes -# to ECR, and registers a new task definition. Terraform owns the cluster, -# service, ALB, and ignores container_definitions / task_definition. +# Fargate image CD. GitHub Actions builds the Flask image, pushes to ECR, +# and registers a new task definition. Terraform owns the cluster, service, +# ALB, and ignores container_definitions / task_definition. # # push to main -> dev, at github.sha # release: published -> prod, at the release tag @@ -20,7 +20,6 @@ on: - "*.md" - ".github/workflows/weekly-menu.yml" - ".github/workflows/ci.yml" - - ".github/workflows/ci-terraform.yaml" release: types: [published] workflow_dispatch: @@ -40,199 +39,33 @@ permissions: contents: read jobs: - target: - name: Resolve target - runs-on: ubuntu-latest - timeout-minutes: 5 - outputs: - environment: ${{ steps.resolve.outputs.environment }} - ref: ${{ steps.resolve.outputs.ref }} - steps: - - id: resolve - env: - EVENT_NAME: ${{ github.event_name }} - GITHUB_REF_NAME_IN: ${{ github.ref }} - GITHUB_SHA_IN: ${{ github.sha }} - RELEASE_TAG: ${{ github.event.release.tag_name }} - REPO: ${{ github.repository }} - GH_TOKEN: ${{ github.token }} - INPUT_ENVIRONMENT: ${{ inputs.environment }} - INPUT_REF: ${{ inputs.ref }} - run: | - set -euo pipefail - case "${EVENT_NAME}" in - push) - if [ "${GITHUB_REF_NAME_IN}" != "refs/heads/main" ]; then - echo "push deploys only run from main" >&2 - exit 1 - fi - environment=dev - ref="${GITHUB_SHA_IN}" - ;; - release) - environment=prod - ref="${RELEASE_TAG}" - status="$(gh api "repos/${REPO}/compare/main...${RELEASE_TAG}" --jq .status)" - if [ "${status}" != "behind" ] && [ "${status}" != "identical" ]; then - echo "release tag ${RELEASE_TAG} is not on main (compare status: ${status})" >&2 - exit 1 - fi - ;; - workflow_dispatch) - environment="${INPUT_ENVIRONMENT}" - ref="${INPUT_REF:-${GITHUB_SHA_IN}}" - ;; - *) - echo "unsupported event ${EVENT_NAME}" >&2 - exit 1 - ;; - esac - { - echo "environment=${environment}" - echo "ref=${ref}" - } >> "${GITHUB_OUTPUT}" - echo "Deploying ${ref} to ${environment}" - - deploy: - name: Deploy API to ${{ needs.target.outputs.environment }} - needs: target - runs-on: ubuntu-latest - timeout-minutes: 30 - environment: ${{ needs.target.outputs.environment }} - concurrency: - group: deploy-api-${{ needs.target.outputs.environment }} - cancel-in-progress: false + deploy-dev: + name: Deploy API to dev + if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.environment == 'dev') + uses: Sea-Haven-Industries/.github/.github/workflows/cd-hcp-fargate.yaml@216604ad67aad01f832291bbce0cac8e37435221 # v1.0.13 permissions: contents: read id-token: write - env: - AWS_REGION: us-east-1 - DEPLOY_ROLE_ARN: ${{ vars.DEPLOY_ROLE_ARN }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ needs.target.outputs.ref }} - persist-credentials: false + secrets: inherit + with: + environment: dev + ref: ${{ inputs.ref }} + ssm-prefix: /meal-order-manager/deploy + docker-platform: linux/amd64 + extra-task-env: '{"SENTRY_DSN_PARAM":"/meal-order-manager/sentry-dsn"}' - - name: Resolve commit - id: commit - run: | - set -euo pipefail - sha="$(git rev-parse HEAD)" - echo "sha=${sha}" >> "${GITHUB_OUTPUT}" - echo "Building ${sha}" - - - name: Configure AWS credentials using OIDC - uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0 - with: - role-to-assume: ${{ env.DEPLOY_ROLE_ARN }} - aws-region: us-east-1 - audience: sts.amazonaws.com - - - name: Get deploy parameters - id: deploy - run: | - set -euo pipefail - get_param() { - aws ssm get-parameter --name "$1" --query Parameter.Value --output text - } - CLUSTER=$(get_param /meal-order-manager/deploy/cluster) - SERVICE=$(get_param /meal-order-manager/deploy/service) - FAMILY=$(get_param /meal-order-manager/deploy/task-family) - ECR=$(get_param /meal-order-manager/deploy/ecr-repository) - CONTAINER=$(get_param /meal-order-manager/deploy/container-name) - API_URL=$(get_param /meal-order-manager/deploy/api-url) - { - echo "cluster=${CLUSTER}" - echo "service=${SERVICE}" - echo "family=${FAMILY}" - echo "ecr=${ECR}" - echo "container=${CONTAINER}" - echo "api_url=${API_URL}" - } >> "${GITHUB_OUTPUT}" - - - name: Login to Amazon ECR - uses: aws-actions/amazon-ecr-login@03f1aad4c6c7ffd436567f42f9384779290529bd # v2.1.7 - - - name: Build and push image - env: - ECR: ${{ steps.deploy.outputs.ecr }} - GIT_SHA: ${{ steps.commit.outputs.sha }} - ENVIRONMENT: ${{ needs.target.outputs.environment }} - run: | - set -euo pipefail - docker build \ - --build-arg "GIT_SHA=${GIT_SHA}" \ - -t "${ECR}:${GIT_SHA}" \ - -t "${ECR}:${ENVIRONMENT}" \ - . - docker push "${ECR}:${GIT_SHA}" - docker push "${ECR}:${ENVIRONMENT}" - - - name: Register task definition and update service - env: - CLUSTER: ${{ steps.deploy.outputs.cluster }} - SERVICE: ${{ steps.deploy.outputs.service }} - FAMILY: ${{ steps.deploy.outputs.family }} - CONTAINER: ${{ steps.deploy.outputs.container }} - IMAGE: ${{ steps.deploy.outputs.ecr }}:${{ steps.commit.outputs.sha }} - GIT_SHA: ${{ steps.commit.outputs.sha }} - run: | - set -euo pipefail - aws ecs describe-task-definition \ - --task-definition "${FAMILY}" \ - --query taskDefinition \ - --output json \ - | python3 -c ' - import json, os, sys - td = json.load(sys.stdin) - for key in ( - "taskDefinitionArn", - "revision", - "status", - "requiresAttributes", - "compatibilities", - "registeredAt", - "registeredBy", - "deregisteredAt", - ): - td.pop(key, None) - image = os.environ["IMAGE"] - sha = os.environ["GIT_SHA"] - name = os.environ["CONTAINER"] - for container in td["containerDefinitions"]: - if container["name"] != name: - continue - container["image"] = image - env = {item["name"]: item["value"] for item in container.get("environment", [])} - env["GIT_SHA"] = sha - env["SENTRY_DSN_PARAM"] = "/meal-order-manager/sentry-dsn" - container["environment"] = [{"name": key, "value": value} for key, value in env.items()] - container.pop("command", None) - json.dump(td, sys.stdout) - ' > /tmp/task-def.json - REV="$(aws ecs register-task-definition --cli-input-json file:///tmp/task-def.json --query taskDefinition.revision --output text)" - aws ecs update-service \ - --cluster "${CLUSTER}" \ - --service "${SERVICE}" \ - --task-definition "${FAMILY}:${REV}" \ - --force-new-deployment \ - >/dev/null - aws ecs wait services-stable --cluster "${CLUSTER}" --services "${SERVICE}" - - - name: Verify health SHA - env: - API_URL: ${{ steps.deploy.outputs.api_url }} - EXPECTED_SHA: ${{ steps.commit.outputs.sha }} - run: | - set -euo pipefail - for _ in 1 2 3 4 5 6; do - BODY="$(curl -fsS "${API_URL}/api/health" || true)" - echo "${BODY}" - if echo "${BODY}" | python3 -c 'import json,os,sys; d=json.load(sys.stdin); sys.exit(0 if d.get("sha")==os.environ["EXPECTED_SHA"] else 1)'; then - exit 0 - fi - sleep 10 - done - echo "health SHA did not match ${EXPECTED_SHA}" >&2 - exit 1 + deploy-prod: + name: Deploy API to prod + if: github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && inputs.environment == 'prod') + uses: Sea-Haven-Industries/.github/.github/workflows/cd-hcp-fargate.yaml@216604ad67aad01f832291bbce0cac8e37435221 # v1.0.13 + permissions: + contents: read + id-token: write + secrets: inherit + with: + environment: prod + ref: ${{ github.event.release.tag_name || inputs.ref }} + ssm-prefix: /meal-order-manager/deploy + docker-platform: linux/amd64 + ship-gate: true + extra-task-env: '{"SENTRY_DSN_PARAM":"/meal-order-manager/sentry-dsn"}' diff --git a/.security-review/suppressions.json b/.security-review/suppressions.json index 220a6c3..4d878ff 100644 --- a/.security-review/suppressions.json +++ b/.security-review/suppressions.json @@ -10,7 +10,7 @@ }, { "id": "checkov-CKV_AWS_111-40", - "justification": "githubdeploy-meal-order-manager needs ecr:GetAuthorizationToken and ecs:RegisterTaskDefinition, both of which AWS documents as Resource *. Trust is pinned to environment:dev or environment:prod plus job_workflow_ref on deploy-api.yaml at refs/heads/main or refs/tags/v*. iam:PassRole is limited to the ECS task and execution roles. ECR push and SSM parameter reads are already resource-scoped." + "justification": "githubdeploy-meal-order-manager needs ecr:GetAuthorizationToken and ecs:RegisterTaskDefinition, both of which AWS documents as Resource *. Trust is pinned to environment:dev or environment:prod, job_workflow_ref on org cd-hcp-fargate.yaml@*, and workflow_ref on the thin deploy-api.yaml caller at refs/heads/main or refs/tags/v*. iam:PassRole is limited to the ECS task and execution roles. ECR push and SSM parameter reads are already resource-scoped." } ] } diff --git a/README.md b/README.md index 8417a32..3b2e3ec 100644 --- a/README.md +++ b/README.md @@ -188,8 +188,7 @@ meal-order-manager/ ├── .github/workflows/ │ ├── weekly-menu.yml # Monday cron: scrape + HMAC publish + notify │ ├── deploy-api.yaml # Image CD to Fargate -│ ├── ci.yml # PR checks (pytest, template JS, OpenAPI lint) -│ └── ci-terraform.yaml # terraform fmt / validate +│ └── ci.yml # PR checks (lint, pytest, template JS, terraform, ci-complete) ├── terraform/ # HCP Terraform (cluster, ALB, ECR, jobs queue) ├── openapi.yaml # Employee HTTP contract (Redocly recommended) ├── .redocly.yaml diff --git a/terraform/iam_github_deploy.tf b/terraform/iam_github_deploy.tf index 856a6b5..08e08d2 100644 --- a/terraform/iam_github_deploy.tf +++ b/terraform/iam_github_deploy.tf @@ -1,4 +1,5 @@ -# GitHub Actions OIDC role for .github/workflows/deploy-api.yaml. +# GitHub Actions OIDC role for the thin deploy-api.yaml caller of +# org reusable cd-hcp-fargate.yaml. data "aws_iam_policy_document" "github_deploy_assume" { statement { @@ -29,6 +30,14 @@ data "aws_iam_policy_document" "github_deploy_assume" { condition { test = "StringLike" variable = "token.actions.githubusercontent.com:job_workflow_ref" + values = [ + "Sea-Haven-Industries/.github/.github/workflows/cd-hcp-fargate.yaml@*", + ] + } + + condition { + test = "StringLike" + variable = "token.actions.githubusercontent.com:workflow_ref" values = [ "Sea-Haven-Industries/meal-order-manager/.github/workflows/deploy-api.yaml@refs/heads/main", "Sea-Haven-Industries/meal-order-manager/.github/workflows/deploy-api.yaml@refs/tags/v*", diff --git a/tests/test_terraform_github_deploy.py b/tests/test_terraform_github_deploy.py new file mode 100644 index 0000000..52600f4 --- /dev/null +++ b/tests/test_terraform_github_deploy.py @@ -0,0 +1,26 @@ +"""githubdeploy OIDC trust pins the org reusable and the thin caller.""" + +from pathlib import Path + +IAM = Path(__file__).resolve().parents[1] / "terraform" / "iam_github_deploy.tf" + + +def test_github_deploy_trust_uses_org_reusable_and_caller(): + text = IAM.read_text() + assert "token.actions.githubusercontent.com:sub" in text + assert "repo:Sea-Haven-Industries/meal-order-manager:environment:dev" in text + assert "repo:Sea-Haven-Industries/meal-order-manager:environment:prod" in text + assert ( + "Sea-Haven-Industries/.github/.github/workflows/cd-hcp-fargate.yaml@*" + in text + ) + assert "token.actions.githubusercontent.com:workflow_ref" in text + assert ( + "Sea-Haven-Industries/meal-order-manager/.github/workflows/deploy-api.yaml@refs/heads/main" + in text + ) + assert ( + "Sea-Haven-Industries/meal-order-manager/.github/workflows/deploy-api.yaml@refs/tags/v*" + in text + ) + assert "cd-hcp-spa.yaml" not in text diff --git a/tests/test_terraform_sentry.py b/tests/test_terraform_sentry.py index e779f61..b1e8290 100644 --- a/tests/test_terraform_sentry.py +++ b/tests/test_terraform_sentry.py @@ -36,4 +36,5 @@ def test_terraform_does_not_embed_a_sentry_dsn(): def test_deploy_api_injects_sentry_dsn_param(): workflow = (ROOT / ".github/workflows/deploy-api.yaml").read_text() - assert 'env["SENTRY_DSN_PARAM"] = "/meal-order-manager/sentry-dsn"' in workflow + assert "extra-task-env:" in workflow + assert '"SENTRY_DSN_PARAM":"/meal-order-manager/sentry-dsn"' in workflow