From c67caaace24e3efa4d4cf7746b579ab9f1ffba6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=A4=80=EC=84=9D=28Junseok=20Oh=29DevOps?= <52226147+Atom-oh@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:45:31 +0000 Subject: [PATCH] CI: five standing branches (main/dev/atomoh/ssminji/whchoi), fail-closed stack selection, corrected OIDC trust matrix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - deploy-web: per-branch case selection with hard-fail on missing role/secrets (an && A || B expression falls through to B when A is EMPTY — a main run with unset prod config would have silently deployed against the dev stack); user branches auto-deploy to .awsops-dev.whchoi.net on push - terraform/agentcore: same fail-closed selection; apply/agentcore dispatch restricted to main/dev - deploy-preview.yml retired (user branches are standing lanes now) - runbook trust matrix corrected: a job WITH environment: presents the environment: OIDC sub, not its branch ref — deployer roles must trust the environment sub or every deploy fails AssumeRoleWithWebIdentity; branch restriction belongs to the environment's deployment branch policy --- .github/workflows/deploy-agentcore.yml | 35 +++++-- .github/workflows/deploy-preview.yml | 128 ----------------------- .github/workflows/deploy-web.yml | 139 +++++++++++++++++-------- .github/workflows/terraform.yml | 57 +++++++--- docs/runbooks/branch-strategy.md | 48 +++++---- docs/runbooks/dev-repo-setup.md | 42 ++++---- 6 files changed, 219 insertions(+), 230 deletions(-) delete mode 100644 .github/workflows/deploy-preview.yml diff --git a/.github/workflows/deploy-agentcore.yml b/.github/workflows/deploy-agentcore.yml index 07ef4e50c..4b4567a2e 100644 --- a/.github/workflows/deploy-agentcore.yml +++ b/.github/workflows/deploy-agentcore.yml @@ -24,15 +24,31 @@ jobs: runs-on: sample-awsops environment: ${{ github.ref_name == 'main' && 'production' || 'development' }} env: - TF_BACKEND_HCL_B64: ${{ github.ref_name == 'main' && secrets.TF_BACKEND_HCL || secrets.TF_BACKEND_HCL_DEV }} - TF_TFVARS_B64: ${{ github.ref_name == 'main' && secrets.TF_TFVARS || secrets.TF_TFVARS_DEV }} + TARGET: ${{ github.ref_name }} + MAIN_ROLE: ${{ vars.AWS_CI_DEPLOYER_ROLE_ARN }} + DEV_ROLE: ${{ vars.AWS_CI_DEPLOYER_DEV_ROLE_ARN }} + MAIN_BACKEND_B64: ${{ secrets.TF_BACKEND_HCL }} + MAIN_TFVARS_B64: ${{ secrets.TF_TFVARS }} + DEV_BACKEND_B64: ${{ secrets.TF_BACKEND_HCL_DEV }} + DEV_TFVARS_B64: ${{ secrets.TF_TFVARS_DEV }} steps: - uses: actions/checkout@v4 + - name: Select the branch's stack (fail-closed) + id: sel + run: | + case "$TARGET" in + main) ROLE="$MAIN_ROLE";; + dev) ROLE="$DEV_ROLE";; + *) echo "::error::agentcore dispatches only from main or dev (got '$TARGET')"; exit 1;; + esac + [ -n "$ROLE" ] || { echo "::error::deployer role variable for '$TARGET' is not set"; exit 1; } + echo "role=$ROLE" >> "$GITHUB_OUTPUT" + - name: Configure AWS credentials (OIDC -> ci-deployer) uses: aws-actions/configure-aws-credentials@v4 with: - role-to-assume: ${{ github.ref_name == 'main' && vars.AWS_CI_DEPLOYER_ROLE_ARN || vars.AWS_CI_DEPLOYER_DEV_ROLE_ARN }} + role-to-assume: ${{ steps.sel.outputs.role }} aws-region: ap-northeast-2 - name: Restore terraform.foundation backend @@ -40,12 +56,17 @@ jobs: # from terraform/foundation — see terraform.yml's header comment. working-directory: terraform/foundation run: | - if [ -z "$TF_BACKEND_HCL_B64" ]; then - echo "::error::This repo's TF backend secrets are not set — see docs/runbooks/dev-repo-setup.md." + case "$TARGET" in + main) B="$MAIN_BACKEND_B64"; V="$MAIN_TFVARS_B64";; + dev) B="$DEV_BACKEND_B64"; V="$DEV_TFVARS_B64";; + *) echo "::error::unexpected target '$TARGET'"; exit 1;; + esac + if [ -z "$B" ] || [ -z "$V" ]; then + echo "::error::TF backend secrets for '$TARGET' are not set — see docs/runbooks/dev-repo-setup.md." exit 1 fi - echo "$TF_BACKEND_HCL_B64" | base64 -d > backend.hcl - echo "$TF_TFVARS_B64" | base64 -d > terraform.tfvars + echo "$B" | base64 -d > backend.hcl + echo "$V" | base64 -d > terraform.tfvars terraform init -backend-config=backend.hcl -input=false - uses: docker/setup-qemu-action@v3 diff --git a/.github/workflows/deploy-preview.yml b/.github/workflows/deploy-preview.yml deleted file mode 100644 index 21b7bb07d..000000000 --- a/.github/workflows/deploy-preview.yml +++ /dev/null @@ -1,128 +0,0 @@ -name: Deploy Preview - -# Per-user preview deploy (dispatch-only; repo write access required). Runs the dispatched branch's web -# image into that USER's own preview stack (`.awsops-dev.whchoi.net`), -# selected purely by distinctly-named secrets: -# TF_BACKEND_HCL_PREVIEW_ / TF_TFVARS_PREVIEW_ (uppercased user) -# Missing secrets FAIL with a pointer to the provisioning runbook — there is no -# fallback to the dev or production stacks, by design. Stack provisioning and -# secret registration: docs/runbooks/branch-strategy.md §Per-user preview stacks. -# -# OIDC: this workflow dispatches from ARBITRARY user branches, so it must NOT -# use the dev deployer role (its trust is pinned to refs/heads/dev). It assumes -# the dedicated PREVIEW role instead — trust allows any branch of this repo, -# and the role's PERMISSIONS are scoped strictly to preview-stack resources -# (never the dev or production stacks). See dev-repo-setup.md §2. -# -# Injection hygiene: inputs.user reaches shell scripts only through env vars -# (quoted expansion), never via direct ${{ }} interpolation inside `run:`. -# -# Tag discipline matches dev/production: build pushes only the immutable -# web-; the pin step (this workflow) is the only writer of the preview -# stack's :web-latest. - -on: - workflow_dispatch: - inputs: - user: - description: "Preview owner (lowercase; picks TF_*_PREVIEW_ secrets and the .awsops-dev.whchoi.net stack)" - required: true - -permissions: - contents: read - id-token: write - -concurrency: - group: deploy-preview-${{ inputs.user }} - cancel-in-progress: true - -jobs: - deploy: - name: Build & roll ${{ inputs.user }}'s preview - runs-on: sample-awsops - env: - PREVIEW_USER: ${{ inputs.user }} - TF_BACKEND_HCL_B64: ${{ secrets[format('TF_BACKEND_HCL_PREVIEW_{0}', inputs.user)] }} - TF_TFVARS_B64: ${{ secrets[format('TF_TFVARS_PREVIEW_{0}', inputs.user)] }} - steps: - - name: Validate the user input - run: | - case "$PREVIEW_USER" in - *[!a-z0-9-]*|"") echo "::error::user must be lowercase [a-z0-9-]"; exit 1;; - esac - - - uses: actions/checkout@v4 - - - name: Configure AWS credentials (OIDC -> dedicated preview role) - uses: aws-actions/configure-aws-credentials@v4 - with: - role-to-assume: ${{ vars.AWS_CI_PREVIEW_ROLE_ARN }} - aws-region: ap-northeast-2 - - - name: Restore this preview stack's terraform backend - working-directory: terraform/foundation - run: | - if [ -z "$TF_BACKEND_HCL_B64" ]; then - echo "::error::TF_BACKEND_HCL_PREVIEW_$(echo "$PREVIEW_USER" | tr a-z A-Z) is not set — provision the preview stack first (docs/runbooks/branch-strategy.md §Per-user preview stacks)." - exit 1 - fi - echo "$TF_BACKEND_HCL_B64" | base64 -d > backend.hcl - echo "$TF_TFVARS_B64" | base64 -d > terraform.tfvars - terraform init -backend-config=backend.hcl -input=false - - - name: Resolve the preview stack's ECR/ECS/URL - id: tf - working-directory: terraform/foundation - run: | - echo "ecr_web_uri=$(terraform output -raw ecr_web_uri)" >> "$GITHUB_OUTPUT" - echo "ecr_repo=$(terraform output -raw ecr_web_uri | sed 's|^[^/]*/||')" >> "$GITHUB_OUTPUT" - echo "cluster=$(terraform output -raw ecs_cluster_name)" >> "$GITHUB_OUTPUT" - echo "service=$(terraform output -raw ecs_service_name)" >> "$GITHUB_OUTPUT" - echo "url=$(terraform output -raw public_url)" >> "$GITHUB_OUTPUT" - - - uses: docker/setup-qemu-action@v3 - - uses: docker/setup-buildx-action@v3 - - - name: Login to Amazon ECR - uses: aws-actions/amazon-ecr-login@v2 - - - name: Copy CHANGELOG into build context - run: cp CHANGELOG.md web/CHANGELOG.md - - - name: Build and push (arm64, immutable tag only) - uses: docker/build-push-action@v6 - with: - context: web - platforms: linux/arm64 - push: true - tags: ${{ steps.tf.outputs.ecr_web_uri }}:web-${{ github.sha }} - - - name: Pin web-latest to this build - run: | - MANIFEST=$(aws ecr batch-get-image \ - --repository-name "${{ steps.tf.outputs.ecr_repo }}" \ - --image-ids imageTag="web-${{ github.sha }}" \ - --region ap-northeast-2 \ - --query 'images[0].imageManifest' --output text) - if [ -z "$MANIFEST" ] || [ "$MANIFEST" = "None" ]; then - echo "::error::built image web-${{ github.sha }} not found in ${{ steps.tf.outputs.ecr_repo }}" - exit 1 - fi - OUT=$(aws ecr put-image \ - --repository-name "${{ steps.tf.outputs.ecr_repo }}" \ - --image-tag web-latest \ - --image-manifest "$MANIFEST" \ - --region ap-northeast-2 2>&1) || { - echo "$OUT" | grep -q ImageAlreadyExistsException || { echo "$OUT" >&2; exit 1; } - } - - - name: ECS force-new-deployment + wait + smoke - run: | - aws ecs update-service \ - --cluster "${{ steps.tf.outputs.cluster }}" \ - --service "${{ steps.tf.outputs.service }}" \ - --force-new-deployment --region ap-northeast-2 >/dev/null - aws ecs wait services-stable \ - --cluster "${{ steps.tf.outputs.cluster }}" \ - --services "${{ steps.tf.outputs.service }}" --region ap-northeast-2 - curl -fsS --max-time 15 "${{ steps.tf.outputs.url }}/api/health" && echo diff --git a/.github/workflows/deploy-web.yml b/.github/workflows/deploy-web.yml index ba08ec15f..9b284e064 100644 --- a/.github/workflows/deploy-web.yml +++ b/.github/workflows/deploy-web.yml @@ -1,34 +1,37 @@ name: Deploy Web -# SINGLE-REPO branch-split pipeline: -# dev -> DEV stack (awsops-dev.whchoi.net) — continuous deploy on push -# main -> PRODUCTION stack (domain pending — CloudFront default until -# decided); build on push, ECS roll is workflow_dispatch-only behind -# the `production` environment's reviewer approval -# Stack selection is purely by distinctly-NAMED repo secrets (a dev job can -# never fall back to production secrets): -# main: TF_BACKEND_HCL / TF_TFVARS +# FIVE-BRANCH pipeline — one standing branch per tier/user, each with its own +# stack and domain: +# main -> production stack (domain pending — CloudFront default); +# build on push, ECS roll is dispatch-only behind the +# `production` environment's reviewer approval +# dev -> dev stack, awsops-dev.whchoi.net — continuous deploy +# atomoh|ssminji|whchoi -> that user's stack, .awsops-dev.whchoi.net — +# continuous deploy on push to their own branch +# +# Stack selection is an explicit per-branch case with hard-fail on missing +# config — NEVER an `expr && A || B` ternary, whose empty-value fallthrough +# would silently deploy against another stack. Secrets per stack: +# main: TF_BACKEND_HCL / TF_TFVARS # dev: TF_BACKEND_HCL_DEV / TF_TFVARS_DEV -# Secrets are repo-level, not environment-scoped: `build` must run ungated -# (an environment's reviewer would pause it); environments gate ONLY the -# mutation `deploy` job. Per-user previews are a separate workflow -# (deploy-preview.yml). Fork PRs never reach this workflow (push/dispatch -# triggers only), and GitHub denies fork runs secrets and id-token anyway. +# user: TF_BACKEND_HCL_PREVIEW_ / TF_TFVARS_PREVIEW_ +# Roles: main uses the production build/deployer roles; dev and user branches +# use the dev-tier roles (permissions scoped to dev+user stacks, never +# production). Jobs WITH an `environment:` present the `environment:` +# OIDC sub — the deployer roles' trust must match that, not a branch ref +# (see docs/runbooks/dev-repo-setup.md §2 for the full trust matrix). # -# The stack's own terraform outputs (ecr_web_uri, ecs_cluster_name, -# ecs_service_name, public_url) provide the per-branch ECR repo, service and -# smoke domain — nothing environment-specific is hardcoded, so attaching the -# production domain later changes nothing here. +# Fork PRs never reach this workflow (push/dispatch only) and GitHub denies +# fork runs secrets and id-token anyway. # -# Approval is bound to a concrete image by tag discipline: build pushes ONLY -# the immutable web- tag and never touches :web-latest — the deploy job's -# pin step is the ONLY writer of :web-latest. A build landing mid-approval -# can't swap the payload, and later ECS task churn (scale-out, ADR-015 -# restarts) re-resolves :web-latest to the last pinned image. +# Tag discipline: build pushes ONLY the immutable web-; the deploy job's +# pin step is the ONLY writer of that stack's :web-latest — approval/rollout +# is always bound to a concrete image, and ECS task churn re-resolves to the +# last pinned image. on: push: - branches: [main, dev] + branches: [main, dev, atomoh, ssminji, whchoi] paths: ["web/**", "CHANGELOG.md"] workflow_dispatch: inputs: @@ -40,11 +43,9 @@ permissions: contents: read id-token: write -# Concurrency is declared PER JOB with separate build/rollout groups: GitHub -# replaces a PENDING run in a group even with cancel-in-progress:false, so a -# workflow-level group would let a main push (build-only run) displace a -# dispatched production rollout waiting on reviewer approval. A push's skipped -# deploy job never enters the rollout group. dev is newest-wins; main queues. +# Per-job concurrency with separate build/rollout groups (a pending production +# rollout must never be displaced by a push's build-only run). main queues; +# every other branch is newest-wins. jobs: build: @@ -55,26 +56,50 @@ jobs: group: deploy-web-build-${{ github.ref_name }} cancel-in-progress: ${{ github.ref_name != 'main' }} env: - TF_BACKEND_HCL_B64: ${{ github.ref_name == 'main' && secrets.TF_BACKEND_HCL || secrets.TF_BACKEND_HCL_DEV }} - TF_TFVARS_B64: ${{ github.ref_name == 'main' && secrets.TF_TFVARS || secrets.TF_TFVARS_DEV }} + BRANCH: ${{ github.ref_name }} + MAIN_ROLE: ${{ vars.AWS_CI_BUILD_ROLE_ARN }} + DEV_ROLE: ${{ vars.AWS_CI_BUILD_DEV_ROLE_ARN }} + MAIN_BACKEND_B64: ${{ secrets.TF_BACKEND_HCL }} + MAIN_TFVARS_B64: ${{ secrets.TF_TFVARS }} + DEV_BACKEND_B64: ${{ secrets.TF_BACKEND_HCL_DEV }} + DEV_TFVARS_B64: ${{ secrets.TF_TFVARS_DEV }} + USER_BACKEND_B64: ${{ secrets[format('TF_BACKEND_HCL_PREVIEW_{0}', github.ref_name)] }} + USER_TFVARS_B64: ${{ secrets[format('TF_TFVARS_PREVIEW_{0}', github.ref_name)] }} steps: - uses: actions/checkout@v4 + - name: Select the branch's stack (fail-closed — no cross-branch fallback) + id: sel + run: | + case "$BRANCH" in + main) ROLE="$MAIN_ROLE";; + dev|atomoh|ssminji|whchoi) ROLE="$DEV_ROLE";; + *) echo "::error::unexpected branch '$BRANCH'"; exit 1;; + esac + [ -n "$ROLE" ] || { echo "::error::build role variable for '$BRANCH' is not set"; exit 1; } + echo "role=$ROLE" >> "$GITHUB_OUTPUT" + - name: Configure AWS credentials (OIDC -> ci-build) uses: aws-actions/configure-aws-credentials@v4 with: - role-to-assume: ${{ github.ref_name == 'main' && vars.AWS_CI_BUILD_ROLE_ARN || vars.AWS_CI_BUILD_DEV_ROLE_ARN }} + role-to-assume: ${{ steps.sel.outputs.role }} aws-region: ap-northeast-2 - name: Restore terraform.foundation backend (read-only — just need `output`) working-directory: terraform/foundation run: | - if [ -z "$TF_BACKEND_HCL_B64" ]; then - echo "::error::this branch's TF backend secrets are not set — run 'make configure' once for its stack (docs/runbooks/branch-strategy.md)." + case "$BRANCH" in + main) B="$MAIN_BACKEND_B64"; V="$MAIN_TFVARS_B64";; + dev) B="$DEV_BACKEND_B64"; V="$DEV_TFVARS_B64";; + atomoh|ssminji|whchoi) B="$USER_BACKEND_B64"; V="$USER_TFVARS_B64";; + *) echo "::error::unexpected branch '$BRANCH'"; exit 1;; + esac + if [ -z "$B" ] || [ -z "$V" ]; then + echo "::error::TF backend secrets for '$BRANCH' are not set — provision its stack first (docs/runbooks/branch-strategy.md). No cross-branch fallback, by design." exit 1 fi - echo "$TF_BACKEND_HCL_B64" | base64 -d > backend.hcl - echo "$TF_TFVARS_B64" | base64 -d > terraform.tfvars + echo "$B" | base64 -d > backend.hcl + echo "$V" | base64 -d > terraform.tfvars terraform init -backend-config=backend.hcl -input=false - name: Resolve ECR repo URI @@ -105,39 +130,63 @@ jobs: deploy: name: Roll ECS service - # dev: continuous deploy right after its own build. # main: workflow_dispatch only (production environment gates it). + # dev + user branches: continuous deploy right after their own build. needs: [build] if: >- !cancelled() && (needs.build.result == 'success' || needs.build.result == 'skipped') && - (github.event_name == 'workflow_dispatch' || github.ref_name == 'dev') + (github.event_name == 'workflow_dispatch' || github.ref_name != 'main') runs-on: sample-awsops environment: ${{ github.ref_name == 'main' && 'production' || 'development' }} concurrency: group: deploy-web-rollout-${{ github.ref_name }} cancel-in-progress: ${{ github.ref_name != 'main' }} env: - TF_BACKEND_HCL_B64: ${{ github.ref_name == 'main' && secrets.TF_BACKEND_HCL || secrets.TF_BACKEND_HCL_DEV }} - TF_TFVARS_B64: ${{ github.ref_name == 'main' && secrets.TF_TFVARS || secrets.TF_TFVARS_DEV }} + BRANCH: ${{ github.ref_name }} + MAIN_ROLE: ${{ vars.AWS_CI_DEPLOYER_ROLE_ARN }} + DEV_ROLE: ${{ vars.AWS_CI_DEPLOYER_DEV_ROLE_ARN }} + MAIN_BACKEND_B64: ${{ secrets.TF_BACKEND_HCL }} + MAIN_TFVARS_B64: ${{ secrets.TF_TFVARS }} + DEV_BACKEND_B64: ${{ secrets.TF_BACKEND_HCL_DEV }} + DEV_TFVARS_B64: ${{ secrets.TF_TFVARS_DEV }} + USER_BACKEND_B64: ${{ secrets[format('TF_BACKEND_HCL_PREVIEW_{0}', github.ref_name)] }} + USER_TFVARS_B64: ${{ secrets[format('TF_TFVARS_PREVIEW_{0}', github.ref_name)] }} steps: - uses: actions/checkout@v4 + - name: Select the branch's stack (fail-closed — no cross-branch fallback) + id: sel + run: | + case "$BRANCH" in + main) ROLE="$MAIN_ROLE";; + dev|atomoh|ssminji|whchoi) ROLE="$DEV_ROLE";; + *) echo "::error::unexpected branch '$BRANCH'"; exit 1;; + esac + [ -n "$ROLE" ] || { echo "::error::deployer role variable for '$BRANCH' is not set"; exit 1; } + echo "role=$ROLE" >> "$GITHUB_OUTPUT" + - name: Configure AWS credentials (OIDC -> ci-deployer) uses: aws-actions/configure-aws-credentials@v4 with: - role-to-assume: ${{ github.ref_name == 'main' && vars.AWS_CI_DEPLOYER_ROLE_ARN || vars.AWS_CI_DEPLOYER_DEV_ROLE_ARN }} + role-to-assume: ${{ steps.sel.outputs.role }} aws-region: ap-northeast-2 - name: Restore terraform.foundation backend working-directory: terraform/foundation run: | - if [ -z "$TF_BACKEND_HCL_B64" ]; then - echo "::error::this branch's TF backend secrets are not set — see docs/runbooks/branch-strategy.md." + case "$BRANCH" in + main) B="$MAIN_BACKEND_B64"; V="$MAIN_TFVARS_B64";; + dev) B="$DEV_BACKEND_B64"; V="$DEV_TFVARS_B64";; + atomoh|ssminji|whchoi) B="$USER_BACKEND_B64"; V="$USER_TFVARS_B64";; + *) echo "::error::unexpected branch '$BRANCH'"; exit 1;; + esac + if [ -z "$B" ] || [ -z "$V" ]; then + echo "::error::TF backend secrets for '$BRANCH' are not set — provision its stack first (docs/runbooks/branch-strategy.md). No cross-branch fallback, by design." exit 1 fi - echo "$TF_BACKEND_HCL_B64" | base64 -d > backend.hcl - echo "$TF_TFVARS_B64" | base64 -d > terraform.tfvars + echo "$B" | base64 -d > backend.hcl + echo "$V" | base64 -d > terraform.tfvars terraform init -backend-config=backend.hcl -input=false - name: Resolve ECS cluster/service/URL + ECR repo @@ -153,7 +202,7 @@ jobs: # The task definition references :web-latest, and this step is that # tag's ONLY writer (build never touches it). Point it at the EXACT # web- this run was approved for (dispatch input, defaulting to - # the dispatched ref's HEAD; the run's own commit on dev pushes). + # the dispatched ref's HEAD; the run's own commit on branch pushes). # Caveat: the pin necessarily precedes the roll, so a roll that then # fails or is cancelled leaves :web-latest at the pinned-but-not- # stabilized image until the next run. Recovery: dispatch this diff --git a/.github/workflows/terraform.yml b/.github/workflows/terraform.yml index 54a37d62f..db37bae88 100644 --- a/.github/workflows/terraform.yml +++ b/.github/workflows/terraform.yml @@ -51,8 +51,11 @@ jobs: runs-on: sample-awsops # plan is automatic and read-only — it must never sit behind any gate. env: - TF_BACKEND_HCL_B64: ${{ (github.base_ref || github.ref_name) == 'main' && secrets.TF_BACKEND_HCL || secrets.TF_BACKEND_HCL_DEV }} - TF_TFVARS_B64: ${{ (github.base_ref || github.ref_name) == 'main' && secrets.TF_TFVARS || secrets.TF_TFVARS_DEV }} + TARGET: ${{ github.base_ref || github.ref_name }} + MAIN_BACKEND_B64: ${{ secrets.TF_BACKEND_HCL }} + MAIN_TFVARS_B64: ${{ secrets.TF_TFVARS }} + DEV_BACKEND_B64: ${{ secrets.TF_BACKEND_HCL_DEV }} + DEV_TFVARS_B64: ${{ secrets.TF_TFVARS_DEV }} steps: - uses: actions/checkout@v4 @@ -65,13 +68,20 @@ jobs: - name: Restore backend.hcl / terraform.tfvars id: restore run: | - if [ -z "$TF_BACKEND_HCL_B64" ]; then - echo "::warning::This repo's TF backend secrets are not set yet — run 'make configure' once for the dev stack, then store its backend.hcl and terraform.tfvars as base64 in the TF_BACKEND_HCL / TF_TFVARS repo secrets (see docs/runbooks/dev-repo-setup.md). Skipping plan until then." + # Explicit per-branch case, hard-fail semantics — never an &&/|| ternary + # whose empty-value fallthrough would plan against the other stack. + case "$TARGET" in + main) B="$MAIN_BACKEND_B64"; V="$MAIN_TFVARS_B64";; + dev) B="$DEV_BACKEND_B64"; V="$DEV_TFVARS_B64";; + *) echo "::error::unexpected target '$TARGET'"; exit 1;; + esac + if [ -z "$B" ] || [ -z "$V" ]; then + echo "::warning::TF backend secrets for '$TARGET' are not set yet — run 'make configure' once for that stack (docs/runbooks/dev-repo-setup.md). Skipping plan until then." echo "skip=1" >> "$GITHUB_OUTPUT" exit 0 fi - echo "$TF_BACKEND_HCL_B64" | base64 -d > backend.hcl - echo "$TF_TFVARS_B64" | base64 -d > terraform.tfvars + echo "$B" | base64 -d > backend.hcl + echo "$V" | base64 -d > terraform.tfvars - name: terraform init if: steps.restore.outputs.skip != '1' @@ -95,25 +105,46 @@ jobs: runs-on: sample-awsops environment: ${{ github.ref_name == 'main' && 'production' || 'development' }} env: - TF_BACKEND_HCL_B64: ${{ github.ref_name == 'main' && secrets.TF_BACKEND_HCL || secrets.TF_BACKEND_HCL_DEV }} - TF_TFVARS_B64: ${{ github.ref_name == 'main' && secrets.TF_TFVARS || secrets.TF_TFVARS_DEV }} + TARGET: ${{ github.ref_name }} + MAIN_ROLE: ${{ vars.AWS_CI_DEPLOYER_ROLE_ARN }} + DEV_ROLE: ${{ vars.AWS_CI_DEPLOYER_DEV_ROLE_ARN }} + MAIN_BACKEND_B64: ${{ secrets.TF_BACKEND_HCL }} + MAIN_TFVARS_B64: ${{ secrets.TF_TFVARS }} + DEV_BACKEND_B64: ${{ secrets.TF_BACKEND_HCL_DEV }} + DEV_TFVARS_B64: ${{ secrets.TF_TFVARS_DEV }} steps: - uses: actions/checkout@v4 + - name: Select the branch's stack (fail-closed) + id: sel + run: | + case "$TARGET" in + main) ROLE="$MAIN_ROLE";; + dev) ROLE="$DEV_ROLE";; + *) echo "::error::apply dispatches only from main or dev (got '$TARGET')"; exit 1;; + esac + [ -n "$ROLE" ] || { echo "::error::deployer role variable for '$TARGET' is not set"; exit 1; } + echo "role=$ROLE" >> "$GITHUB_OUTPUT" + - name: Configure AWS credentials (OIDC -> ci-deployer) uses: aws-actions/configure-aws-credentials@v4 with: - role-to-assume: ${{ github.ref_name == 'main' && vars.AWS_CI_DEPLOYER_ROLE_ARN || vars.AWS_CI_DEPLOYER_DEV_ROLE_ARN }} + role-to-assume: ${{ steps.sel.outputs.role }} aws-region: ap-northeast-2 - name: Restore backend.hcl / terraform.tfvars run: | - if [ -z "$TF_BACKEND_HCL_B64" ]; then - echo "::error::This repo's TF backend secrets are not set — see docs/runbooks/dev-repo-setup.md." + case "$TARGET" in + main) B="$MAIN_BACKEND_B64"; V="$MAIN_TFVARS_B64";; + dev) B="$DEV_BACKEND_B64"; V="$DEV_TFVARS_B64";; + *) echo "::error::unexpected target '$TARGET'"; exit 1;; + esac + if [ -z "$B" ] || [ -z "$V" ]; then + echo "::error::TF backend secrets for '$TARGET' are not set — see docs/runbooks/dev-repo-setup.md." exit 1 fi - echo "$TF_BACKEND_HCL_B64" | base64 -d > backend.hcl - echo "$TF_TFVARS_B64" | base64 -d > terraform.tfvars + echo "$B" | base64 -d > backend.hcl + echo "$V" | base64 -d > terraform.tfvars - name: terraform init run: terraform init -backend-config=backend.hcl -input=false diff --git a/docs/runbooks/branch-strategy.md b/docs/runbooks/branch-strategy.md index c654ba75c..1988ce5c2 100644 --- a/docs/runbooks/branch-strategy.md +++ b/docs/runbooks/branch-strategy.md @@ -1,6 +1,6 @@ # Branch strategy & deployment map / 브랜치 전략과 배포 맵 -Related files / 관련 파일: `.github/workflows/{deploy-web,deploy-preview,terraform,guard-main-prs}.yml`, +Related files / 관련 파일: `.github/workflows/{deploy-web,terraform,guard-main-prs}.yml`, `docs/runbooks/dev-repo-setup.md` (CI/OIDC bring-up) ## The shape / 전체 구조 — single public repo / 단일 공개 리포 @@ -16,14 +16,23 @@ permanently retrievable via PR refs even after branch deletion.** push된 커밋은 브랜치를 지워도 PR ref로 영구 조회됩니다.) ``` -/ ──PR──▶ dev ──PR (guard: dev only)──▶ main -preview stack dev stack production stack -.awsops-dev. awsops-dev.whchoi.net domain PENDING — deploy on the -whchoi.net CloudFront default domain first, - then decide whether to attach - awsops.whchoi.net +atomoh | ssminji | whchoi ──PR──▶ dev ──PR (guard: dev only)──▶ main +user stacks (standing) dev stack production stack +.awsops-dev.whchoi.net awsops-dev.whchoi.net domain PENDING — deploy on the +(auto-deploy on push to CloudFront default domain first, + one's own branch) then decide whether to attach + awsops.whchoi.net ``` +**Five standing branches, five pipelines**: `main`, `dev`, and one branch per user +(`atomoh`, `ssminji`, `whchoi`). Each user's branch continuously deploys to that +user's own stack on push — a personal integration lane. Topic work happens on the +user's branch (or short-lived branches merged into it), then flows up via PR to +`dev` and on to `main`. +(상시 브랜치 5개 = 파이프라인 5개. 사용자 브랜치는 push 즉시 자기 스택으로 자동 +배포되는 개인 통합 레인이며, 작업은 사용자 브랜치에서 → PR로 dev → main으로 +승격됩니다.) + - `dev` is the **default branch** — PRs (internal and external) target it by default. - `main` accepts PRs **only from `dev`**, enforced mechanically by `guard-main-prs.yml` on top of the `protect-main` ruleset (PR required, no @@ -31,9 +40,11 @@ whchoi.net CloudFront default domain ## Branch flow / 브랜치 흐름 -1. **User branch / 사용자 브랜치** — `/` (e.g. `whchoi/fix-eks-panel`), - PR into `dev`. PR checks: merge-verify + AI pr-review + terraform plan (when - `terraform/foundation/**` changed; same-repo PRs only). +1. **User branch / 사용자 브랜치** — the standing branch named after the user + (`atomoh`, `ssminji`, `whchoi`). Push = auto-deploy to + `.awsops-dev.whchoi.net`. When ready, PR into `dev`. PR checks: + merge-verify + AI pr-review + terraform plan (when `terraform/foundation/**` + changed; same-repo PRs only). 2. **`dev`** — integration branch; every push auto-deploys the DEV stack (`awsops-dev.whchoi.net`) via `deploy-web.yml` (build → pin → roll → smoke). 3. **`main`** — promotion PR `dev → main` (ordinary same-repo PR). The production @@ -59,7 +70,7 @@ guard 체크가 실패합니다. 첫 기여자의 CI 실행은 관리자 승인 | Tier | Branch | Stack / domain | Deploy trigger | |---|---|---|---| -| Preview | `/` | per-user stack, `.awsops-dev.whchoi.net` | `deploy-preview.yml` dispatch (input: `user`; write access required) | +| User | `atomoh` / `ssminji` / `whchoi` | that user's stack, `.awsops-dev.whchoi.net` | auto on push (`deploy-web.yml`) | | Dev | `dev` | dev stack, `awsops-dev.whchoi.net` | auto on push (`deploy-web.yml`) | | Production | `main` | production stack — **domain not attached yet** | dispatch + `production` environment approval | @@ -101,13 +112,14 @@ Provision once per user: gh secret set TF_TFVARS_PREVIEW_ -R aws-samples/sample-awsops \ --body "$(base64 -w0 terraform/foundation/terraform.tfvars)" ``` -4. Actions → **Deploy Preview** → run from the branch with input `user` (lowercase). - Missing secrets fail with a pointer here — no fallback to dev/production stacks. - Dispatch requires repo write access, so external users cannot trigger previews. - -Preview deploys use the dedicated any-branch preview role (permissions scoped to -preview-stack resources only); the dev/production deployer roles' trust is pinned to -their own branch refs — see `dev-repo-setup.md` §2 for the role/trust matrix. +4. Push to your branch — `deploy-web.yml` builds and rolls your stack + automatically. Missing secrets fail with a pointer here — no fallback to the + dev/production stacks, by design. + +User-branch deploys run under the dev-tier roles (`environment: development` +gates which branches may deploy — its branch policy lists dev + the three user +branches); production stays behind the `production` environment approval. See +`dev-repo-setup.md` §2 for the role/trust matrix. ## Verification / 확인 diff --git a/docs/runbooks/dev-repo-setup.md b/docs/runbooks/dev-repo-setup.md index b771479c8..5f501990c 100644 --- a/docs/runbooks/dev-repo-setup.md +++ b/docs/runbooks/dev-repo-setup.md @@ -44,28 +44,32 @@ provider with a `sub` condition — never the repo-wide `:*` wildcard, which wou let ANY branch (including an experiment branch with an edited workflow) assume the mutation roles. Role-to-sub matrix: -| Role | Used by | Trust `sub` (StringEquals unless noted) | Permissions scope | +| Role | Used by | Trust `sub` | Permissions scope | |---|---|---|---| -| `sample-awsops-ci-build` | main build | `repo:aws-samples/sample-awsops:ref:refs/heads/main` | prod ECR push | -| `sample-awsops-ci-deployer` | main roll / apply / agentcore (production env) | `repo:aws-samples/sample-awsops:environment:production` | prod ECS/ECR-pin/apply | -| `sample-awsops-dev-ci-build` | dev build | `...:ref:refs/heads/dev` | dev ECR push | -| `sample-awsops-dev-ci-deployer` | dev roll / apply / agentcore | `...:ref:refs/heads/dev` | dev ECS/ECR-pin/apply | -| `sample-awsops-dev-ci-preview` | preview deploys (any user branch) | StringLike `...:ref:refs/heads/*` | **preview-stack resources only** — the any-branch trust is safe only because the blast radius is preview-only | +| `sample-awsops-ci-build` | main build (no environment) | StringEquals `repo:aws-samples/sample-awsops:ref:refs/heads/main` | prod ECR push | +| `sample-awsops-ci-deployer` | main roll / apply / agentcore (jobs carry `environment: production`) | StringEquals `repo:aws-samples/sample-awsops:environment:production` | prod ECS/ECR-pin/apply | +| `sample-awsops-dev-ci-build` | dev + user-branch builds (no environment) | StringLike, one entry per branch: `...:ref:refs/heads/dev`, `...:ref:refs/heads/atomoh`, `...:ref:refs/heads/ssminji`, `...:ref:refs/heads/whchoi` | dev + user stacks' ECR push | +| `sample-awsops-dev-ci-deployer` | dev + user-branch rolls, dev apply/agentcore (jobs carry `environment: development`) | StringEquals `repo:aws-samples/sample-awsops:environment:development` | dev + user stacks' ECS/ECR-pin/apply — **never production** | | `sample-awsops-ci-terraform-plan` | plan (PR/push, read-only) | StringLike: `...:ref:refs/heads/main`, `...:ref:refs/heads/dev`, `...:pull_request` | ReadOnlyAccess | | `sample-awsops-ci-review` | AI pr-review | StringLike: `...:ref:refs/heads/main`, `...:ref:refs/heads/dev` | Bedrock invoke | -Notes: -- Mutation roles pin to a single branch ref (or the `production` environment sub, - which is even narrower — jobs with `environment: production` present - `repo:...:environment:production`). Read-only roles may also accept the - `pull_request` sub; fork PRs can never mint tokens anyway (GitHub withholds - id-token from forks), and `terraform.yml` skips non-same-repo PRs outright. -- The `AWS_CI_*_ROLE_ARN` / `AWS_CI_*_DEV_ROLE_ARN` / `AWS_CI_PREVIEW_ROLE_ARN` - repo variables must point at these roles. - -(mutation 역할은 단일 브랜치 ref 또는 `environment:production` sub로 고정, read-only -역할만 `pull_request` sub를 추가 허용합니다. fork PR은 GitHub이 id-token 자체를 주지 -않아 어떤 역할도 assume할 수 없습니다.) +CRITICAL sub rule: **a job that declares `environment:` presents the +`repo:/:environment:` sub — NOT its branch ref.** Deployer +roles must therefore trust the environment sub (pinning them to a branch ref +makes every deploy fail AssumeRoleWithWebIdentity). Which branches can reach an +environment is enforced by the environment's own deployment branch policy +(`production` → main only; `development` → dev, atomoh, ssminji, whchoi). +Build/plan jobs carry no environment and present branch-ref subs. Fork PRs can +never mint tokens (GitHub withholds id-token from forks) and `terraform.yml` +skips non-same-repo PRs outright. +(`environment:`가 선언된 잡의 OIDC sub는 브랜치 ref가 아니라 `environment:<이름>` +입니다 — deployer 역할 신뢰는 environment sub로, 브랜치 제한은 environment의 +deployment branch policy로 거는 것이 올바른 구성입니다.) + +The former `sample-awsops-dev-ci-preview` role and `deploy-preview.yml` are +RETIRED — user branches are standing branches with continuous deploy, covered by +the dev-tier roles above. (구 preview 역할·워크플로는 은퇴 — 사용자 브랜치가 상시 +브랜치가 되면서 dev-tier 역할이 담당합니다.) ### 3. Per-stack terraform secrets / 스택별 TF 시크릿 @@ -84,7 +88,7 @@ Then register the generated files (base64) as repo secrets: |---|---| | production (`main`) | `TF_BACKEND_HCL` / `TF_TFVARS` | | dev (`awsops-dev.whchoi.net`) | `TF_BACKEND_HCL_DEV` / `TF_TFVARS_DEV` | -| preview (`.awsops-dev.whchoi.net`) | `TF_BACKEND_HCL_PREVIEW_` / `TF_TFVARS_PREVIEW_` | +| user branch `atomoh`/`ssminji`/`whchoi` (`.awsops-dev.whchoi.net`) | `TF_BACKEND_HCL_PREVIEW_` / `TF_TFVARS_PREVIEW_` (uppercased branch name) | ```bash gh secret set TF_BACKEND_HCL_DEV -R aws-samples/sample-awsops \