diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7d04a1c8..7fb319af 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 @@ -94,9 +102,59 @@ 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() { + 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 + push) + REF=$(sanitize "$GH_REF_NAME") + 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" && "$PR_NUMBER" =~ ^[0-9]+$ ]]; 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 +170,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" \ @@ -162,6 +220,51 @@ 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 }} + ALLOWED_COMPUTE_TYPES: "agentcore" + run: | + 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 + INTENT="$COMPUTE_TYPE" + else + INTENT="-" + fi + ;; + workflow_dispatch) + if [[ "$DISPATCH_DEPLOY" != "-" ]]; then + validate_compute_type "$DISPATCH_DEPLOY" + fi + INTENT="$DISPATCH_DEPLOY" + ;; + pull_request|pull_request_target) + INTENT="labels" + ;; + *) + INTENT="-" + ;; + esac + 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 + 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 new file mode 100644 index 00000000..073d5982 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,164 @@ +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: 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 }} + 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" + + case "$INTENT" in + -) + echo "matrix=[]" >> "$GITHUB_OUTPUT" + echo "has_targets=false" >> "$GITHUB_OUTPUT" + ;; + 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]') + + 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 + 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" + else + echo "matrix=[]" >> "$GITHUB_OUTPUT" + echo "has_targets=false" >> "$GITHUB_OUTPUT" + fi + ;; + *) + 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" + ;; + esac + + 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 diff --git a/.gitignore b/.gitignore index ff85180c..c2bc0d7a 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) # ──────────────────────────────────────────────