From 822a79d323e161315eee04e139f251d05f8f1dc7 Mon Sep 17 00:00:00 2001 From: scottschreckengaust <345885+scottschreckengaust@users.noreply.github.com> Date: Fri, 15 May 2026 00:23:43 +0000 Subject: [PATCH 1/7] feat(ci): rename computeVariant to compute_type and apply as resource tag Aligns CI and CDK terminology with the existing ComputeType union in repo-config.ts. build.yml matrix key, env var, and cdk.context.json key are all renamed from computeVariant to compute_type. The CDK app now reads compute_type from context (default: agentcore) and applies it as a resource tag for per-type baseline diffs and cost attribution. Closes phase 2 items in #73. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/build.yml | 12 ++++++------ cdk/src/main.ts | 3 +++ cdk/test/stacks/github-tags.test.ts | 24 ++++++++++++++++++++++++ 3 files changed, 33 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3fd3cc4b..7d04a1c8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -26,7 +26,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - variant: [agentcore] + compute_type: [agentcore] outputs: self_mutation_happened: ${{ steps.self_mutation.outputs.self_mutation_happened }} env: @@ -96,7 +96,7 @@ jobs: esac - name: Generate CDK context env: - VARIANT: ${{ matrix.variant }} + COMPUTE_TYPE: ${{ matrix.compute_type }} TAG_SHA: ${{ steps.tags.outputs.sha }} TAG_REF: ${{ steps.tags.outputs.ref }} TAG_REF_TYPE: ${{ steps.tags.outputs.ref-type }} @@ -111,7 +111,7 @@ jobs: TAG_REPOSITORY: ${{ github.repository }} run: | jq -n \ - --arg computeVariant "$VARIANT" \ + --arg compute_type "$COMPUTE_TYPE" \ --arg stackName "backgroundagent-dev" \ --arg sha "$TAG_SHA" \ --arg ref "$TAG_REF" \ @@ -126,7 +126,7 @@ jobs: --arg workflow "$TAG_WORKFLOW" \ --arg repository "$TAG_REPOSITORY" \ '{ - "computeVariant": $computeVariant, + "compute_type": $compute_type, "stackName": $stackName, "github:sha": $sha, "github:ref": $ref, @@ -155,10 +155,10 @@ jobs: run: mise run install - name: build run: mise run build - - name: Upload CDK artifact (${{ matrix.variant }}) + - name: Upload CDK artifact (${{ matrix.compute_type }}) uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: cdk-${{ matrix.variant }}-out + name: cdk-${{ matrix.compute_type }}-out path: | cdk/cdk.out/ cdk/cdk.context.json diff --git a/cdk/src/main.ts b/cdk/src/main.ts index 496cfbd4..bf1c1a45 100644 --- a/cdk/src/main.ts +++ b/cdk/src/main.ts @@ -42,6 +42,9 @@ const stack = new AgentStack( }, ); +const computeType = app.node.tryGetContext('compute_type') ?? 'agentcore'; +Tags.of(stack).add('compute_type', computeType); + const githubTagKeys = [ 'sha', 'ref', diff --git a/cdk/test/stacks/github-tags.test.ts b/cdk/test/stacks/github-tags.test.ts index a273fa7c..96bd1d44 100644 --- a/cdk/test/stacks/github-tags.test.ts +++ b/cdk/test/stacks/github-tags.test.ts @@ -44,6 +44,9 @@ function synthWithTags(context: Record = {}): Template { env: { account: '123456789012', region: 'us-east-1' }, }); + const computeType = app.node.tryGetContext('compute_type') ?? 'agentcore'; + Tags.of(stack).add('compute_type', computeType); + const githubTagKeys = [ 'sha', 'ref', 'ref-type', 'actor', 'head-ref', 'base-ref', 'pr-number', 'run-id', 'run-attempt', @@ -125,4 +128,25 @@ describe('github:* resource tags', () => { expect(tags.find(t => t.Key === 'github:sha')!.Value).toBe('none'); expect(tags.find(t => t.Key === 'github:head-ref')!.Value).toBe('none'); }); + + test('compute_type tag defaults to "agentcore" when no context is provided', () => { + const resources = templateWithDefaults.findResources('AWS::DynamoDB::Table'); + const firstResource = Object.values(resources)[0]; + const tags: Array<{ Key: string; Value: string }> = firstResource?.Properties?.Tags ?? []; + + const tag = tags.find(t => t.Key === 'compute_type'); + expect(tag).toBeDefined(); + expect(tag!.Value).toBe('agentcore'); + }); + + test('compute_type tag reflects context value when provided', () => { + const template = synthWithTags({ compute_type: 'ecs' }); + const resources = template.findResources('AWS::DynamoDB::Table'); + const firstResource = Object.values(resources)[0]; + const tags: Array<{ Key: string; Value: string }> = firstResource?.Properties?.Tags ?? []; + + const tag = tags.find(t => t.Key === 'compute_type'); + expect(tag).toBeDefined(); + expect(tag!.Value).toBe('ecs'); + }); }); From feccf16091bd9195ce570b13e775c89ad848a626 Mon Sep 17 00:00:00 2001 From: bgagent <345885+scottschreckengaust@users.noreply.github.com> Date: Fri, 15 May 2026 01:19:57 +0000 Subject: [PATCH 2/7] feat(ci): add deploy.yml workflow triggered by build completion Adds a deploy workflow that: - Fires on workflow_run after build.yml succeeds - Resolves deploy targets from PR labels (deploy=all, deploy:=one) or defaults to all registered types on push to main - Skips entirely (no approval prompt) when no deploy labels are present - Downloads the exact cdk--out artifact from the build run - Uses OIDC to assume the CDK bootstrap deploy role - Deploys via `cdk deploy --app cdk/cdk.out --all --require-approval never` - Protected by the `deploy` GitHub environment (manual approval required) - Concurrency: non-cancellable once started, max-parallel 3 Part of #73 Phase 3. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/deploy.yml | 116 +++++++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 .github/workflows/deploy.yml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 00000000..a857fdcd --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,116 @@ +name: deploy +on: + # zizmor: ignore[dangerous-triggers] — intentional; workflow_run is required + # for OIDC id-token on PR builds. Mitigations: env-var-only untrusted input, + # least-privilege permissions per job, deploy environment approval gate. + workflow_run: + workflows: [build] + types: [completed] +permissions: {} +jobs: + resolve-targets: + if: github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-latest + permissions: + actions: read + pull-requests: read + outputs: + matrix: ${{ steps.targets.outputs.matrix }} + has_targets: ${{ steps.targets.outputs.has_targets }} + run_id: ${{ github.event.workflow_run.id }} + steps: + - name: Resolve deploy targets from labels + id: targets + env: + GH_TOKEN: ${{ github.token }} + HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} + EVENT_TYPE: ${{ github.event.workflow_run.event }} + REPO: ${{ github.repository }} + PR_NUMBER_FROM_EVENT: ${{ github.event.workflow_run.pull_requests[0].number }} + run: | + ALL_TYPES='["agentcore"]' + + # Push to main always deploys all registered types + if [[ "$HEAD_BRANCH" == "main" && "$EVENT_TYPE" == "push" ]]; then + echo "matrix=$ALL_TYPES" >> "$GITHUB_OUTPUT" + echo "has_targets=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # For PRs, look up labels via API + if [[ -z "$PR_NUMBER_FROM_EVENT" ]]; then + echo "matrix=[]" >> "$GITHUB_OUTPUT" + echo "has_targets=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + LABELS=$(gh api "repos/$REPO/pulls/$PR_NUMBER_FROM_EVENT" --jq '[.labels[].name]') + + if echo "$LABELS" | jq -e 'index("deploy:*")' > /dev/null 2>&1; then + echo "matrix=$ALL_TYPES" >> "$GITHUB_OUTPUT" + echo "has_targets=true" >> "$GITHUB_OUTPUT" + elif echo "$LABELS" | jq -e '[.[] | select(startswith("deploy:"))] | length > 0' > /dev/null 2>&1; then + TYPES=$(echo "$LABELS" | jq -c '[.[] | select(startswith("deploy:")) | ltrimstr("deploy:")]') + echo "matrix=$TYPES" >> "$GITHUB_OUTPUT" + echo "has_targets=true" >> "$GITHUB_OUTPUT" + elif echo "$LABELS" | jq -e 'index("deploy")' > /dev/null 2>&1; then + echo "matrix=$ALL_TYPES" >> "$GITHUB_OUTPUT" + echo "has_targets=true" >> "$GITHUB_OUTPUT" + else + echo "matrix=[]" >> "$GITHUB_OUTPUT" + echo "has_targets=false" >> "$GITHUB_OUTPUT" + fi + + deploy: + needs: resolve-targets + if: needs.resolve-targets.outputs.has_targets == 'true' + runs-on: ubuntu-latest + environment: deploy + concurrency: + group: deploy-${{ matrix.compute_type }} + cancel-in-progress: false + strategy: + matrix: + compute_type: ${{ fromJson(needs.resolve-targets.outputs.matrix) }} + max-parallel: 3 + permissions: + id-token: write + contents: read + actions: read + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Download CDK artifact (${{ matrix.compute_type }}) + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: cdk-${{ matrix.compute_type }}-out + path: cdk/ + run-id: ${{ needs.resolve-targets.outputs.run_id }} + github-token: ${{ github.token }} + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a # v4.3.1 + with: + role-to-assume: ${{ secrets.AWS_ROLE_TO_ASSUME }} + aws-region: ${{ vars.AWS_REGION }} + + - name: Install mise + uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4.0.1 + with: + cache: true + + - name: Setup Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 22.x + + - name: Install dependencies + run: yarn install --immutable + + - name: Deploy + env: + COMPUTE_TYPE: ${{ matrix.compute_type }} + run: npx cdk deploy --app cdk/cdk.out --all --require-approval never From e8324013874a7e7469bb79893e7c9665bc7e6226 Mon Sep 17 00:00:00 2001 From: bgagent <345885+scottschreckengaust@users.noreply.github.com> Date: Fri, 15 May 2026 01:43:06 +0000 Subject: [PATCH 3/7] feat(ci): dynamic stack naming and workflow_dispatch deploy trigger build.yml: Replace hardcoded stackName with trigger-aware naming: - push to main: main- - pull_request: pr- - merge_group: mg- - workflow_dispatch: - - fallback: - All inputs sanitized (alphanumeric + hyphens, 60-char branch cap). deploy.yml: Add workflow_dispatch trigger with compute_type choice input (all, agentcore). Handle non-PR triggers (push to main, workflow_dispatch on build) by deploying all registered types. Label-based resolution only applies to PR triggers. Part of #73 Phase 3. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/build.yml | 42 ++++++++++++++++++++++++++++++++++- .github/workflows/deploy.yml | 43 +++++++++++++++++++++++++++++++----- 2 files changed, 78 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7d04a1c8..4904f67c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -94,9 +94,49 @@ jobs: echo "pr-number=" >> "$GITHUB_OUTPUT" ;; esac + - name: Resolve stack name + id: naming + env: + EVENT_NAME: ${{ github.event_name }} + COMPUTE_TYPE: ${{ matrix.compute_type }} + GH_SHA: ${{ github.sha }} + GH_REF_NAME: ${{ github.ref_name }} + PR_NUMBER: ${{ steps.tags.outputs.pr-number }} + run: | + sanitize() { + echo "$1" | tr '/_.' '-' | sed 's/[^a-z0-9-]//g; s/--*/-/g; s/^-//; s/-$//' | cut -c1-60 + } + + case "$EVENT_NAME" in + push) + REF=$(sanitize "$GH_REF_NAME") + STACK_NAME="${REF}-${COMPUTE_TYPE}" + ;; + pull_request|pull_request_target) + STACK_NAME="pr${PR_NUMBER}-${COMPUTE_TYPE}" + ;; + merge_group) + if [[ -n "$PR_NUMBER" ]]; then + STACK_NAME="mg${PR_NUMBER}-${COMPUTE_TYPE}" + else + STACK_NAME="${COMPUTE_TYPE}-${GH_SHA:0:7}" + fi + ;; + workflow_dispatch) + REF=$(sanitize "$GH_REF_NAME") + STACK_NAME="${REF}-${COMPUTE_TYPE}" + ;; + *) + STACK_NAME="${COMPUTE_TYPE}-${GH_SHA:0:7}" + ;; + esac + + echo "stack_name=$STACK_NAME" >> "$GITHUB_OUTPUT" + echo "Stack name: $STACK_NAME" - name: Generate CDK context env: COMPUTE_TYPE: ${{ matrix.compute_type }} + STACK_NAME: ${{ steps.naming.outputs.stack_name }} TAG_SHA: ${{ steps.tags.outputs.sha }} TAG_REF: ${{ steps.tags.outputs.ref }} TAG_REF_TYPE: ${{ steps.tags.outputs.ref-type }} @@ -112,7 +152,7 @@ jobs: run: | jq -n \ --arg compute_type "$COMPUTE_TYPE" \ - --arg stackName "backgroundagent-dev" \ + --arg stackName "$STACK_NAME" \ --arg sha "$TAG_SHA" \ --arg ref "$TAG_REF" \ --arg ref_type "$TAG_REF_TYPE" \ diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index a857fdcd..f585572c 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -6,10 +6,21 @@ on: workflow_run: workflows: [build] types: [completed] + workflow_dispatch: + inputs: + compute_type: + description: "Compute type to deploy" + required: true + type: choice + options: + - all + - agentcore permissions: {} jobs: resolve-targets: - if: github.event.workflow_run.conclusion == 'success' + if: >- + github.event_name == 'workflow_dispatch' || + github.event.workflow_run.conclusion == 'success' runs-on: ubuntu-latest permissions: actions: read @@ -17,21 +28,41 @@ jobs: outputs: matrix: ${{ steps.targets.outputs.matrix }} has_targets: ${{ steps.targets.outputs.has_targets }} - run_id: ${{ github.event.workflow_run.id }} + run_id: ${{ steps.targets.outputs.run_id }} steps: - - name: Resolve deploy targets from labels + - name: Resolve deploy targets id: targets env: GH_TOKEN: ${{ github.token }} + EVENT_NAME: ${{ github.event_name }} HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} - EVENT_TYPE: ${{ github.event.workflow_run.event }} + BUILD_EVENT_TYPE: ${{ github.event.workflow_run.event }} REPO: ${{ github.repository }} PR_NUMBER_FROM_EVENT: ${{ github.event.workflow_run.pull_requests[0].number }} + WORKFLOW_RUN_ID: ${{ github.event.workflow_run.id }} + DISPATCH_COMPUTE_TYPE: ${{ inputs.compute_type }} run: | ALL_TYPES='["agentcore"]' - # Push to main always deploys all registered types - if [[ "$HEAD_BRANCH" == "main" && "$EVENT_TYPE" == "push" ]]; then + # workflow_dispatch: use input choice + if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then + if [[ "$DISPATCH_COMPUTE_TYPE" == "all" ]]; then + echo "matrix=$ALL_TYPES" >> "$GITHUB_OUTPUT" + else + echo "matrix=[\"$DISPATCH_COMPUTE_TYPE\"]" >> "$GITHUB_OUTPUT" + fi + echo "has_targets=true" >> "$GITHUB_OUTPUT" + # Find the latest successful build run for this branch + RUN_ID=$(gh api "repos/$REPO/actions/workflows/build.yml/runs?branch=$(git branch --show-current 2>/dev/null || echo main)&status=success&per_page=1" --jq '.workflow_runs[0].id // empty') + echo "run_id=${RUN_ID}" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # workflow_run context from here on + echo "run_id=${WORKFLOW_RUN_ID}" >> "$GITHUB_OUTPUT" + + # Push to main or workflow_dispatch on build always deploys all + if [[ "$HEAD_BRANCH" == "main" && ("$BUILD_EVENT_TYPE" == "push" || "$BUILD_EVENT_TYPE" == "workflow_dispatch") ]]; then echo "matrix=$ALL_TYPES" >> "$GITHUB_OUTPUT" echo "has_targets=true" >> "$GITHUB_OUTPUT" exit 0 From 382c55178382f473c40f1e2c573b98b1a4968a3c Mon Sep 17 00:00:00 2001 From: bgagent <345885+scottschreckengaust@users.noreply.github.com> Date: Fri, 15 May 2026 02:04:21 +0000 Subject: [PATCH 4/7] refactor(ci): move deploy input to build.yml, use deploy-intent artifact build.yml now owns the deploy decision via workflow_dispatch choice input: - "-" (default): no deploy - "agentcore": deploy agentcore after build Build always writes a deploy-intent.json artifact encoding the decision: - push to main: intent = compute_type (deploy) - workflow_dispatch with choice: intent = selected value - pull_request: intent = "labels" (defer to deploy.yml label check) - anything else: intent = "-" (no deploy) deploy.yml simplified to a pure consumer: - Removes workflow_dispatch trigger (single entry point is build.yml) - Downloads deploy-intent.json from triggering build run - Reads intent: "-" = skip, "labels" = check PR labels, else = deploy Part of #73 Phase 3. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/build.yml | 43 +++++++++++++- .github/workflows/deploy.yml | 108 +++++++++++++++-------------------- 2 files changed, 87 insertions(+), 64 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4904f67c..aafa50a2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,7 +1,15 @@ name: build on: pull_request: {} - workflow_dispatch: {} + workflow_dispatch: + inputs: + deploy: + description: "Deploy after build (- = no deploy)" + type: choice + default: "-" + options: + - "-" + - agentcore permissions: actions: none attestations: none @@ -202,6 +210,39 @@ jobs: path: | cdk/cdk.out/ cdk/cdk.context.json + - name: Write deploy intent + env: + EVENT_NAME: ${{ github.event_name }} + GH_REF_NAME: ${{ github.ref_name }} + DISPATCH_DEPLOY: ${{ inputs.deploy }} + COMPUTE_TYPE: ${{ matrix.compute_type }} + run: | + # Determine deploy intent based on trigger type + case "$EVENT_NAME" in + push) + if [[ "$GH_REF_NAME" == "main" ]]; then + INTENT="$COMPUTE_TYPE" + else + INTENT="-" + fi + ;; + workflow_dispatch) + INTENT="$DISPATCH_DEPLOY" + ;; + pull_request|pull_request_target) + INTENT="labels" + ;; + *) + INTENT="-" + ;; + esac + echo "{\"deploy\":\"$INTENT\"}" > deploy-intent.json + echo "Deploy intent: $INTENT" + - name: Upload deploy intent + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: deploy-intent + path: deploy-intent.json - name: Find mutations id: self_mutation run: |- diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index f585572c..5919230d 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -6,21 +6,10 @@ on: workflow_run: workflows: [build] types: [completed] - workflow_dispatch: - inputs: - compute_type: - description: "Compute type to deploy" - required: true - type: choice - options: - - all - - agentcore permissions: {} jobs: resolve-targets: - if: >- - github.event_name == 'workflow_dispatch' || - github.event.workflow_run.conclusion == 'success' + if: github.event.workflow_run.conclusion == 'success' runs-on: ubuntu-latest permissions: actions: read @@ -28,69 +17,62 @@ jobs: outputs: matrix: ${{ steps.targets.outputs.matrix }} has_targets: ${{ steps.targets.outputs.has_targets }} - run_id: ${{ steps.targets.outputs.run_id }} + run_id: ${{ github.event.workflow_run.id }} steps: + - name: Download deploy intent + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: deploy-intent + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ github.token }} + - name: Resolve deploy targets id: targets env: GH_TOKEN: ${{ github.token }} - EVENT_NAME: ${{ github.event_name }} - HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} - BUILD_EVENT_TYPE: ${{ github.event.workflow_run.event }} REPO: ${{ github.repository }} PR_NUMBER_FROM_EVENT: ${{ github.event.workflow_run.pull_requests[0].number }} - WORKFLOW_RUN_ID: ${{ github.event.workflow_run.id }} - DISPATCH_COMPUTE_TYPE: ${{ inputs.compute_type }} run: | ALL_TYPES='["agentcore"]' + INTENT=$(jq -r '.deploy' deploy-intent.json) + echo "Deploy intent from build: $INTENT" - # workflow_dispatch: use input choice - if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then - if [[ "$DISPATCH_COMPUTE_TYPE" == "all" ]]; then - echo "matrix=$ALL_TYPES" >> "$GITHUB_OUTPUT" - else - echo "matrix=[\"$DISPATCH_COMPUTE_TYPE\"]" >> "$GITHUB_OUTPUT" - fi - echo "has_targets=true" >> "$GITHUB_OUTPUT" - # Find the latest successful build run for this branch - RUN_ID=$(gh api "repos/$REPO/actions/workflows/build.yml/runs?branch=$(git branch --show-current 2>/dev/null || echo main)&status=success&per_page=1" --jq '.workflow_runs[0].id // empty') - echo "run_id=${RUN_ID}" >> "$GITHUB_OUTPUT" - exit 0 - fi - - # workflow_run context from here on - echo "run_id=${WORKFLOW_RUN_ID}" >> "$GITHUB_OUTPUT" - - # Push to main or workflow_dispatch on build always deploys all - if [[ "$HEAD_BRANCH" == "main" && ("$BUILD_EVENT_TYPE" == "push" || "$BUILD_EVENT_TYPE" == "workflow_dispatch") ]]; then - echo "matrix=$ALL_TYPES" >> "$GITHUB_OUTPUT" - echo "has_targets=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - - # For PRs, look up labels via API - if [[ -z "$PR_NUMBER_FROM_EVENT" ]]; then - echo "matrix=[]" >> "$GITHUB_OUTPUT" - echo "has_targets=false" >> "$GITHUB_OUTPUT" - exit 0 - fi + case "$INTENT" in + -) + echo "matrix=[]" >> "$GITHUB_OUTPUT" + echo "has_targets=false" >> "$GITHUB_OUTPUT" + ;; + labels) + # PR-triggered build — check labels + if [[ -z "$PR_NUMBER_FROM_EVENT" ]]; then + echo "matrix=[]" >> "$GITHUB_OUTPUT" + echo "has_targets=false" >> "$GITHUB_OUTPUT" + exit 0 + fi - LABELS=$(gh api "repos/$REPO/pulls/$PR_NUMBER_FROM_EVENT" --jq '[.labels[].name]') + LABELS=$(gh api "repos/$REPO/pulls/$PR_NUMBER_FROM_EVENT" --jq '[.labels[].name]') - if echo "$LABELS" | jq -e 'index("deploy:*")' > /dev/null 2>&1; then - echo "matrix=$ALL_TYPES" >> "$GITHUB_OUTPUT" - echo "has_targets=true" >> "$GITHUB_OUTPUT" - elif echo "$LABELS" | jq -e '[.[] | select(startswith("deploy:"))] | length > 0' > /dev/null 2>&1; then - TYPES=$(echo "$LABELS" | jq -c '[.[] | select(startswith("deploy:")) | ltrimstr("deploy:")]') - echo "matrix=$TYPES" >> "$GITHUB_OUTPUT" - echo "has_targets=true" >> "$GITHUB_OUTPUT" - elif echo "$LABELS" | jq -e 'index("deploy")' > /dev/null 2>&1; then - echo "matrix=$ALL_TYPES" >> "$GITHUB_OUTPUT" - echo "has_targets=true" >> "$GITHUB_OUTPUT" - else - echo "matrix=[]" >> "$GITHUB_OUTPUT" - echo "has_targets=false" >> "$GITHUB_OUTPUT" - fi + if echo "$LABELS" | jq -e 'index("deploy:*")' > /dev/null 2>&1; then + echo "matrix=$ALL_TYPES" >> "$GITHUB_OUTPUT" + echo "has_targets=true" >> "$GITHUB_OUTPUT" + elif echo "$LABELS" | jq -e '[.[] | select(startswith("deploy:"))] | length > 0' > /dev/null 2>&1; then + TYPES=$(echo "$LABELS" | jq -c '[.[] | select(startswith("deploy:")) | ltrimstr("deploy:")]') + echo "matrix=$TYPES" >> "$GITHUB_OUTPUT" + echo "has_targets=true" >> "$GITHUB_OUTPUT" + elif echo "$LABELS" | jq -e 'index("deploy")' > /dev/null 2>&1; then + echo "matrix=$ALL_TYPES" >> "$GITHUB_OUTPUT" + echo "has_targets=true" >> "$GITHUB_OUTPUT" + else + echo "matrix=[]" >> "$GITHUB_OUTPUT" + echo "has_targets=false" >> "$GITHUB_OUTPUT" + fi + ;; + *) + # Specific compute_type from push-to-main or workflow_dispatch + echo "matrix=[\"$INTENT\"]" >> "$GITHUB_OUTPUT" + echo "has_targets=true" >> "$GITHUB_OUTPUT" + ;; + esac deploy: needs: resolve-targets From 266a504b7c91ab264253f311f04892906ef288d8 Mon Sep 17 00:00:00 2001 From: bgagent <345885+scottschreckengaust@users.noreply.github.com> Date: Fri, 15 May 2026 02:41:22 +0000 Subject: [PATCH 5/7] fix(ci): add input validation and allowlist enforcement for compute types Addresses 5 security findings: 1. CRITICAL: deploy.yml wildcard case now validates intent against ALLOWED_COMPUTE_TYPES before passing to matrix. Invalid values cause the workflow to fail with an error annotation. 2. MEDIUM: PR label deploy: values are filtered through validate_compute_type(). Invalid labels emit a warning and are ignored rather than passed to the deploy matrix. 3. MEDIUM: sanitize() now lowercases input and prefixes "s-" if the result starts with a digit (CloudFormation requires letter start). 4. LOW: deploy-intent.json is now written with jq (safe JSON encoding) instead of shell string interpolation. 5. LOW: PR_NUMBER is validated as numeric before use in stack names. The ALLOWED_COMPUTE_TYPES allowlist is defined as an env var in each step that performs validation. When new compute types are added to the matrix, this allowlist must be updated in both build.yml and deploy.yml. Part of #73 Phase 3. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/build.yml | 30 ++++++++++++++++++++---- .github/workflows/deploy.yml | 45 ++++++++++++++++++++++++++++++++---- 2 files changed, 66 insertions(+), 9 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index aafa50a2..7fb319af 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -112,7 +112,13 @@ jobs: PR_NUMBER: ${{ steps.tags.outputs.pr-number }} run: | sanitize() { - echo "$1" | tr '/_.' '-' | sed 's/[^a-z0-9-]//g; s/--*/-/g; s/^-//; s/-$//' | cut -c1-60 + local result + result=$(echo "$1" | tr '[:upper:]' '[:lower:]' | tr '/_.' '-' | sed 's/[^a-z0-9-]//g; s/--*/-/g; s/^-//; s/-$//' | cut -c1-60) + # CloudFormation requires stack names to start with a letter + if [[ "$result" =~ ^[0-9] ]]; then + result="s-${result}" + fi + echo "$result" } case "$EVENT_NAME" in @@ -121,10 +127,14 @@ jobs: STACK_NAME="${REF}-${COMPUTE_TYPE}" ;; pull_request|pull_request_target) + if [[ ! "$PR_NUMBER" =~ ^[0-9]+$ ]]; then + echo "::error::Invalid PR number: '$PR_NUMBER'" + exit 1 + fi STACK_NAME="pr${PR_NUMBER}-${COMPUTE_TYPE}" ;; merge_group) - if [[ -n "$PR_NUMBER" ]]; then + if [[ -n "$PR_NUMBER" && "$PR_NUMBER" =~ ^[0-9]+$ ]]; then STACK_NAME="mg${PR_NUMBER}-${COMPUTE_TYPE}" else STACK_NAME="${COMPUTE_TYPE}-${GH_SHA:0:7}" @@ -216,8 +226,17 @@ jobs: GH_REF_NAME: ${{ github.ref_name }} DISPATCH_DEPLOY: ${{ inputs.deploy }} COMPUTE_TYPE: ${{ matrix.compute_type }} + ALLOWED_COMPUTE_TYPES: "agentcore" run: | - # Determine deploy intent based on trigger type + validate_compute_type() { + local type="$1" + for allowed in $ALLOWED_COMPUTE_TYPES; do + [[ "$type" == "$allowed" ]] && return 0 + done + echo "::error::Invalid compute_type: '$type'. Allowed: $ALLOWED_COMPUTE_TYPES" + exit 1 + } + case "$EVENT_NAME" in push) if [[ "$GH_REF_NAME" == "main" ]]; then @@ -227,6 +246,9 @@ jobs: fi ;; workflow_dispatch) + if [[ "$DISPATCH_DEPLOY" != "-" ]]; then + validate_compute_type "$DISPATCH_DEPLOY" + fi INTENT="$DISPATCH_DEPLOY" ;; pull_request|pull_request_target) @@ -236,7 +258,7 @@ jobs: INTENT="-" ;; esac - echo "{\"deploy\":\"$INTENT\"}" > deploy-intent.json + jq -n --arg deploy "$INTENT" '{"deploy":$deploy}' > deploy-intent.json echo "Deploy intent: $INTENT" - name: Upload deploy intent uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 5919230d..073d5982 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -32,8 +32,32 @@ jobs: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} PR_NUMBER_FROM_EVENT: ${{ github.event.workflow_run.pull_requests[0].number }} + ALLOWED_COMPUTE_TYPES: "agentcore" run: | ALL_TYPES='["agentcore"]' + + validate_compute_type() { + local type="$1" + for allowed in $ALLOWED_COMPUTE_TYPES; do + [[ "$type" == "$allowed" ]] && return 0 + done + echo "::error::Invalid compute_type: '$type'. Allowed: $ALLOWED_COMPUTE_TYPES" + return 1 + } + + filter_valid_types() { + local input_json="$1" + local valid_json="[]" + for type in $(echo "$input_json" | jq -r '.[]'); do + if validate_compute_type "$type" 2>/dev/null; then + valid_json=$(echo "$valid_json" | jq --arg t "$type" '. + [$t]') + else + echo "::warning::Ignoring invalid compute_type from label: '$type'" + fi + done + echo "$valid_json" + } + INTENT=$(jq -r '.deploy' deploy-intent.json) echo "Deploy intent from build: $INTENT" @@ -43,7 +67,6 @@ jobs: echo "has_targets=false" >> "$GITHUB_OUTPUT" ;; labels) - # PR-triggered build — check labels if [[ -z "$PR_NUMBER_FROM_EVENT" ]]; then echo "matrix=[]" >> "$GITHUB_OUTPUT" echo "has_targets=false" >> "$GITHUB_OUTPUT" @@ -56,9 +79,17 @@ jobs: echo "matrix=$ALL_TYPES" >> "$GITHUB_OUTPUT" echo "has_targets=true" >> "$GITHUB_OUTPUT" elif echo "$LABELS" | jq -e '[.[] | select(startswith("deploy:"))] | length > 0' > /dev/null 2>&1; then - TYPES=$(echo "$LABELS" | jq -c '[.[] | select(startswith("deploy:")) | ltrimstr("deploy:")]') - echo "matrix=$TYPES" >> "$GITHUB_OUTPUT" - echo "has_targets=true" >> "$GITHUB_OUTPUT" + RAW_TYPES=$(echo "$LABELS" | jq -c '[.[] | select(startswith("deploy:")) | ltrimstr("deploy:")]') + VALIDATED=$(filter_valid_types "$RAW_TYPES") + COUNT=$(echo "$VALIDATED" | jq 'length') + if [[ "$COUNT" -gt 0 ]]; then + echo "matrix=$VALIDATED" >> "$GITHUB_OUTPUT" + echo "has_targets=true" >> "$GITHUB_OUTPUT" + else + echo "::warning::All deploy: labels were invalid" + echo "matrix=[]" >> "$GITHUB_OUTPUT" + echo "has_targets=false" >> "$GITHUB_OUTPUT" + fi elif echo "$LABELS" | jq -e 'index("deploy")' > /dev/null 2>&1; then echo "matrix=$ALL_TYPES" >> "$GITHUB_OUTPUT" echo "has_targets=true" >> "$GITHUB_OUTPUT" @@ -68,7 +99,11 @@ jobs: fi ;; *) - # Specific compute_type from push-to-main or workflow_dispatch + if ! validate_compute_type "$INTENT"; then + echo "matrix=[]" >> "$GITHUB_OUTPUT" + echo "has_targets=false" >> "$GITHUB_OUTPUT" + exit 1 + fi echo "matrix=[\"$INTENT\"]" >> "$GITHUB_OUTPUT" echo "has_targets=true" >> "$GITHUB_OUTPUT" ;; From 026562feb4e1f6acf91920b3a750bc9aa6210ac7 Mon Sep 17 00:00:00 2001 From: bgagent <345885+scottschreckengaust@users.noreply.github.com> Date: Fri, 15 May 2026 03:14:31 +0000 Subject: [PATCH 6/7] fix(ci): add deploy-intent.json to .gitignore The file is generated during build and was being picked up by the mutation detection step, causing the build to fail. Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitignore b/.gitignore index 60b31701..e32519e6 100644 --- a/.gitignore +++ b/.gitignore @@ -57,6 +57,11 @@ cdk.out/ cdk.context.json /assets/ +# ────────────────────────────────────────────── +# CI artifacts (generated during build) +# ────────────────────────────────────────────── +deploy-intent.json + # ────────────────────────────────────────────── # Build outputs (compiled TS → JS) # ────────────────────────────────────────────── From 419163621133ef3051bd373d1366d7a3154c9576 Mon Sep 17 00:00:00 2001 From: bgagent <345885+scottschreckengaust@users.noreply.github.com> Date: Wed, 27 May 2026 21:04:28 +0000 Subject: [PATCH 7/7] fix(security): skip deploy pipeline for fork PRs + document CI/CD flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `head_repository.full_name == github.repository` guard to deploy.yml's resolve-targets job. Prevents fork PRs from triggering deploy approval prompts — a fork could modify build.yml to produce a deploy-intent artifact that would otherwise reach the approval gate. Also documents the full deploy.yml pipeline in DEPLOYMENT_GUIDE.md (build → deploy stages, security controls, fork exclusion rationale, administrator setup). This makes the deploy flow explicit for contributors, reviewers, agents, and administrators. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/deploy.yml | 4 +- docs/guides/DEPLOYMENT_GUIDE.md | 40 +++++++++++++++++++ .../docs/getting-started/Deployment-guide.md | 40 +++++++++++++++++++ 3 files changed, 83 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 073d5982..e88d59db 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -9,7 +9,9 @@ on: permissions: {} jobs: resolve-targets: - if: github.event.workflow_run.conclusion == 'success' + if: >- + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.head_repository.full_name == github.repository runs-on: ubuntu-latest permissions: actions: read diff --git a/docs/guides/DEPLOYMENT_GUIDE.md b/docs/guides/DEPLOYMENT_GUIDE.md index b7510222..13b21209 100644 --- a/docs/guides/DEPLOYMENT_GUIDE.md +++ b/docs/guides/DEPLOYMENT_GUIDE.md @@ -123,6 +123,46 @@ For the full cost model including per-task costs, see [COST_MODEL.md](../design/ ## Reference +## CI/CD pipeline (`deploy.yml`) + +The repository includes a two-stage CI/CD pipeline: + +### Stage 1: Build (`build.yml`) + +Triggers on every PR and push to main. Runs `mise run build` (compile, test, lint, synth) and uploads the synthesized `cdk.out/` as a `deploy-intent` artifact. The intent file declares whether a deploy should happen and for which compute types. + +### Stage 2: Deploy (`deploy.yml`) + +Triggers via `workflow_run` when `build.yml` completes successfully. The pipeline: + +1. **Skips fork PRs** — `head_repository.full_name == github.repository` prevents forks from entering the deploy flow. This is a security measure: a malicious fork could modify `build.yml` to produce a deploy-intent artifact, which would otherwise prompt maintainers for approval unnecessarily. +2. **Downloads `deploy-intent.json`** from the triggering build run. +3. **Resolves targets** — Determines which compute types to deploy: + - `intent: "-"` → no-op (most PRs) + - `intent: "labels"` → reads PR labels against an allowlist + - `intent: ""` → deploys the specified type (e.g., `agentcore`) +4. **Requires approval** — The `deploy` job uses a GitHub Environment with required reviewers. Approvals are logged and the self-review rule prevents unilateral deploys. +5. **Deploys via OIDC** — Assumes an IAM role via GitHub OIDC federation (no long-lived credentials). The role is scoped to the `cdk deploy` action with least-privilege policies per [DEPLOYMENT_ROLES.md](../design/DEPLOYMENT_ROLES.md). + +### Security controls + +| Control | Purpose | +|---------|---------| +| Fork exclusion (`head_repository` check) | Prevents fork PRs from triggering deploy approval prompts | +| Environment approval | Human gate before any deploy reaches AWS | +| OIDC federation | No stored AWS credentials; tokens are request-scoped | +| Compute type allowlist | Only pre-approved types can be deployed | +| Non-cancellable concurrency | Deploy can't be interrupted mid-flight | + +### For administrators + +- **Enable deploys**: Set the `deploy` Environment in repo settings with required reviewers. +- **Configure OIDC**: Set `AWS_ROLE_TO_ASSUME` secret and `AWS_REGION` variable. +- **Allowlist compute types**: Edit `ALLOWED_COMPUTE_TYPES` in `deploy.yml`. +- **Deploy via PR label**: Add the `deploy:` label to a PR (e.g., `deploy:agentcore`). + +## Related docs + - [Quick start](./QUICK_START.md) -- Zero-to-first-PR in 6 steps. - [Developer guide](./DEVELOPER_GUIDE.md) -- Local development, testing, repository onboarding. - [User guide](./USER_GUIDE.md) -- API reference, CLI usage, task management. diff --git a/docs/src/content/docs/getting-started/Deployment-guide.md b/docs/src/content/docs/getting-started/Deployment-guide.md index 2697ce74..1eb4905f 100644 --- a/docs/src/content/docs/getting-started/Deployment-guide.md +++ b/docs/src/content/docs/getting-started/Deployment-guide.md @@ -127,6 +127,46 @@ For the full cost model including per-task costs, see [COST_MODEL.md](/architect ## Reference +## CI/CD pipeline (`deploy.yml`) + +The repository includes a two-stage CI/CD pipeline: + +### Stage 1: Build (`build.yml`) + +Triggers on every PR and push to main. Runs `mise run build` (compile, test, lint, synth) and uploads the synthesized `cdk.out/` as a `deploy-intent` artifact. The intent file declares whether a deploy should happen and for which compute types. + +### Stage 2: Deploy (`deploy.yml`) + +Triggers via `workflow_run` when `build.yml` completes successfully. The pipeline: + +1. **Skips fork PRs** — `head_repository.full_name == github.repository` prevents forks from entering the deploy flow. This is a security measure: a malicious fork could modify `build.yml` to produce a deploy-intent artifact, which would otherwise prompt maintainers for approval unnecessarily. +2. **Downloads `deploy-intent.json`** from the triggering build run. +3. **Resolves targets** — Determines which compute types to deploy: + - `intent: "-"` → no-op (most PRs) + - `intent: "labels"` → reads PR labels against an allowlist + - `intent: ""` → deploys the specified type (e.g., `agentcore`) +4. **Requires approval** — The `deploy` job uses a GitHub Environment with required reviewers. Approvals are logged and the self-review rule prevents unilateral deploys. +5. **Deploys via OIDC** — Assumes an IAM role via GitHub OIDC federation (no long-lived credentials). The role is scoped to the `cdk deploy` action with least-privilege policies per [DEPLOYMENT_ROLES.md](/architecture/deployment-roles). + +### Security controls + +| Control | Purpose | +|---------|---------| +| Fork exclusion (`head_repository` check) | Prevents fork PRs from triggering deploy approval prompts | +| Environment approval | Human gate before any deploy reaches AWS | +| OIDC federation | No stored AWS credentials; tokens are request-scoped | +| Compute type allowlist | Only pre-approved types can be deployed | +| Non-cancellable concurrency | Deploy can't be interrupted mid-flight | + +### For administrators + +- **Enable deploys**: Set the `deploy` Environment in repo settings with required reviewers. +- **Configure OIDC**: Set `AWS_ROLE_TO_ASSUME` secret and `AWS_REGION` variable. +- **Allowlist compute types**: Edit `ALLOWED_COMPUTE_TYPES` in `deploy.yml`. +- **Deploy via PR label**: Add the `deploy:` label to a PR (e.g., `deploy:agentcore`). + +## Related docs + - [Quick start](/getting-started/quick-start) -- Zero-to-first-PR in 6 steps. - [Developer guide](/developer-guide/introduction) -- Local development, testing, repository onboarding. - [User guide](/using/overview) -- API reference, CLI usage, task management.