diff --git a/.github/workflows/_reusable-pulumi-deploy.yml b/.github/workflows/_reusable-pulumi-deploy.yml new file mode 100644 index 000000000..2b9c105dc --- /dev/null +++ b/.github/workflows/_reusable-pulumi-deploy.yml @@ -0,0 +1,300 @@ +# Reusable Pulumi sequences driven by infra/ci/stack-map.yaml (#682). +# +# Callers set deploy_profile + target + git SHA. Stack order and per-step commands are emitted by +# scripts/ci/read-stack-map.mjs (parses stack-map.yaml; no secrets). +# +# Secrets: pass `secrets: inherit` from the caller. Credential *names* differ for production +# (PROD_*) vs dev/staging/preview (shared pre-prod names). New lanes: add a caller workflow that +# maps lane-specific environment secrets explicitly — GitHub Actions cannot index secrets dynamically. + +on: + workflow_call: + inputs: + lane: + description: 'Logical lane (e.g. main, v15); for documentation and future env JSON routing' + required: false + type: string + default: main + target: + description: 'Stack-map key: dev | staging | prod (used by preview_all plan)' + required: false + type: string + default: dev + git_sha: + description: 'Commit SHA to check out' + required: true + type: string + deploy_profile: + description: 'dev_cd | staging_up | prod_cd | preview_all' + required: true + type: string + bom_download_run_id: + description: 'If set, download artifact bom-main from this run (dev CD after Autobuild)' + required: false + type: string + default: '' + dev_platform_stack: + description: 'Platform stack name for dev_cd (must not be prod). Required when deploy_profile is dev_cd.' + required: false + type: string + default: '' + promote_bom_file: + description: 'For staging_up: optional repo-relative BOM path (see scripts/ci/promote-images.sh)' + required: false + type: string + default: '' + promote_image_tag: + description: 'For staging_up: optional explicit image tag (skips BOM / sha derivation)' + required: false + type: string + default: '' + staging_include_promote_step: + description: 'If true (staging_up only), run promote-images.sh before Pulumi ups' + required: false + type: boolean + default: false + prod_confirm: + description: 'Typo guard for prod_cd: empty ok; else must equal repo full name or DEPLOY_PROD' + required: false + type: string + default: '' + +jobs: + dev_prepare: + name: Dev — BOM + platform image tag + if: inputs.deploy_profile == 'dev_cd' + runs-on: ubuntu-latest + environment: development + steps: + - name: Check out + uses: actions/checkout@v6 + with: + ref: ${{ inputs.git_sha }} + + - name: Download merged BOM (optional) + if: inputs.bom_download_run_id != '' + uses: actions/download-artifact@v4 + with: + name: bom-main + path: bom-download + github-token: ${{ secrets.GITHUB_TOKEN }} + repository: ${{ github.repository }} + run-id: ${{ inputs.bom_download_run_id }} + + - name: Set up Node.js (Pulumi CLI for platform config) + uses: actions/setup-node@v6 + with: + node-version: '20' + + - name: Install Pulumi CLI + shell: bash + run: | + curl -fsSL https://get.pulumi.com | sh -s -- --version "3.229.0" + echo "${HOME}/.pulumi/bin" >> "${GITHUB_PATH}" + + - name: Set dev platform image tag (sha-* from BOM when present) + shell: bash + env: + PULUMI_BACKEND_URL: ${{ secrets.PULUMI_BACKEND_URL }} + PULUMI_CONFIG_PASSPHRASE: ${{ secrets.PULUMI_CONFIG_PASSPHRASE }} + DEPLOY_SHA: ${{ inputs.git_sha }} + PULUMI_DEV_PLATFORM_STACK: ${{ inputs.dev_platform_stack }} + run: | + set -euo pipefail + if [ -z "${PULUMI_DEV_PLATFORM_STACK}" ]; then + echo "::error::dev_platform_stack is required for deploy_profile dev_cd" + exit 1 + fi + if [ "${PULUMI_DEV_PLATFORM_STACK}" = "prod" ]; then + echo "::error::Refusing to set image tag on platform stack named prod." + exit 1 + fi + if [ -n "${PULUMI_BACKEND_URL:-}" ]; then + pulumi login "${PULUMI_BACKEND_URL}" + fi + BOM_PATH="" + if [ -d bom-download ]; then + BOM_PATH="$(find bom-download -type f -name 'main-*.json' | head -1 || true)" + fi + bash ./scripts/ci/pulumi-set-dev-image-tag.sh "${DEPLOY_SHA}" "${BOM_PATH}" + + matrix_prepare: + name: Build deploy matrix from stack-map + runs-on: ubuntu-latest + outputs: + matrix_json: ${{ steps.map.outputs.matrix_json }} + steps: + - name: Validate prod confirm (typo guard) + if: inputs.deploy_profile == 'prod_cd' + shell: bash + env: + CONFIRM: ${{ inputs.prod_confirm }} + REPO: ${{ github.repository }} + run: | + if [ -n "${CONFIRM}" ] && [ "${CONFIRM}" != "${REPO}" ] && [ "${CONFIRM}" != "DEPLOY_PROD" ]; then + echo "::error::When set, confirm must equal ${REPO} or DEPLOY_PROD (got: ${CONFIRM})" + exit 1 + fi + + - name: Check out + uses: actions/checkout@v6 + with: + ref: ${{ inputs.git_sha }} + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + cache: npm + + - name: Install npm dependencies (yaml parser for read-stack-map) + run: npm ci + + - name: Emit matrix JSON + id: map + shell: bash + env: + PROFILE: ${{ inputs.deploy_profile }} + TARGET: ${{ inputs.target }} + PLAT: ${{ inputs.dev_platform_stack }} + STAGING_PROMOTE: ${{ inputs.staging_include_promote_step }} + run: | + set -euo pipefail + case "${PROFILE}" in + dev_cd) + if [ -z "${PLAT}" ]; then + echo "::error::dev_platform_stack required for dev_cd" + exit 1 + fi + node scripts/ci/read-stack-map.mjs emit --plan dev_cd_full --platform-stack "${PLAT}" + ;; + staging_up) + if [ "${STAGING_PROMOTE}" = "true" ]; then + node scripts/ci/read-stack-map.mjs emit --plan staging_up --prepend-staging-promote + else + node scripts/ci/read-stack-map.mjs emit --plan staging_up + fi + ;; + prod_cd) + node scripts/ci/read-stack-map.mjs emit --plan prod_cd + ;; + preview_all) + case "${TARGET}" in + dev|staging|prod) ;; + *) + echo "::error::target must be dev, staging, or prod for preview_all (got: ${TARGET})" + exit 1 + ;; + esac + node scripts/ci/read-stack-map.mjs emit --plan dev_preview_all --target "${TARGET}" + ;; + *) + echo "::error::Unknown deploy_profile: ${PROFILE}" + exit 1 + ;; + esac + + deploy: + name: Pulumi ${{ matrix.command }} — ${{ matrix.stack_project }} / ${{ matrix.stack_name }} + needs: + - matrix_prepare + - dev_prepare + if: | + always() && + needs.matrix_prepare.result == 'success' && + ( + inputs.deploy_profile != 'dev_cd' || + needs.dev_prepare.result == 'success' + ) + runs-on: ubuntu-latest + environment: >- + ${{ + inputs.deploy_profile == 'prod_cd' && 'production' || + inputs.deploy_profile == 'staging_up' && 'staging' || + inputs.deploy_profile == 'preview_all' && 'preview' || + 'development' + }} + strategy: + fail-fast: true + max-parallel: 1 + matrix: + include: ${{ fromJSON(needs.matrix_prepare.outputs.matrix_json) }} + steps: + - name: Check out + uses: actions/checkout@v6 + with: + ref: ${{ inputs.git_sha }} + + - name: Staging — set platform image tag (promote) + if: inputs.deploy_profile == 'staging_up' && matrix.command == 'staging_promote' + shell: bash + env: + PULUMI_BACKEND_URL: ${{ secrets.PULUMI_BACKEND_URL }} + PULUMI_CONFIG_PASSPHRASE: ${{ secrets.PULUMI_CONFIG_PASSPHRASE }} + PROMOTE_GIT_SHA: ${{ inputs.git_sha }} + PROMOTE_BOM_FILE: ${{ inputs.promote_bom_file }} + PROMOTE_IMAGE_TAG: ${{ inputs.promote_image_tag }} + run: | + set -euo pipefail + curl -fsSL https://get.pulumi.com | sh -s -- --version "3.229.0" + echo "${HOME}/.pulumi/bin" >> "${GITHUB_PATH}" + args=(./scripts/ci/promote-images.sh pulumi-set-platform-tag) + if [[ -n "${PROMOTE_BOM_FILE}" ]]; then + args+=(--bom-file "${PROMOTE_BOM_FILE}") + fi + if [[ -n "${PROMOTE_IMAGE_TAG}" ]]; then + args+=(--image-tag "${PROMOTE_IMAGE_TAG}") + fi + args+=("${PROMOTE_GIT_SHA}" "staging") + "${args[@]}" + + - name: Pulumi (dev / preview — with Pulumi Cloud token) + if: (inputs.deploy_profile == 'dev_cd' || inputs.deploy_profile == 'preview_all') && matrix.command != 'staging_promote' + uses: ./.github/reusable_workflows/pulumi_up + env: + PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_ACCESS_TOKEN }} + PULUMI_CONFIG_PASSPHRASE: ${{ secrets.PULUMI_CONFIG_PASSPHRASE }} + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + AWS_REGION: nyc3 + with: + command: ${{ matrix.command }} + stack-project: ${{ matrix.stack_project }} + stack-name: ${{ matrix.stack_name }} + manager-node-host: ${{ secrets.PULUMI_MANAGER_NODE_HOST }} + ssh-user: ${{ secrets.PULUMI_SSH_USER }} + ssh-key: ${{ secrets.PULUMI_SSH_KEY }} + tailscale-authkey: ${{ secrets.TAILSCALE_AUTHKEY }} + pulumi-backend-url: ${{ secrets.PULUMI_BACKEND_URL }} + + - name: Pulumi (staging promote — no Pulumi Cloud token) + if: inputs.deploy_profile == 'staging_up' && matrix.command != 'staging_promote' + uses: ./.github/reusable_workflows/pulumi_up + env: + PULUMI_CONFIG_PASSPHRASE: ${{ secrets.PULUMI_CONFIG_PASSPHRASE }} + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + AWS_REGION: nyc3 + with: + command: ${{ matrix.command }} + stack-project: ${{ matrix.stack_project }} + stack-name: ${{ matrix.stack_name }} + manager-node-host: ${{ secrets.PULUMI_MANAGER_NODE_HOST }} + ssh-user: ${{ secrets.PULUMI_SSH_USER }} + ssh-key: ${{ secrets.PULUMI_SSH_KEY }} + tailscale-authkey: ${{ secrets.TAILSCALE_AUTHKEY }} + pulumi-backend-url: ${{ secrets.PULUMI_BACKEND_URL }} + + - name: Pulumi (production credentials) + if: inputs.deploy_profile == 'prod_cd' + uses: ./.github/reusable_workflows/pulumi_up + env: + PULUMI_ACCESS_TOKEN: ${{ secrets.PROD_PULUMI_ACCESS_TOKEN }} + with: + command: ${{ matrix.command }} + stack-project: ${{ matrix.stack_project }} + stack-name: ${{ matrix.stack_name }} + manager-node-host: ${{ secrets.PROD_MANAGER_HOST }} + ssh-user: ${{ secrets.PROD_SSH_USER }} + ssh-key: ${{ secrets.PROD_SSH_KEY }} + tailscale-authkey: ${{ secrets.PROD_TAILSCALE_AUTHKEY }} diff --git a/.github/workflows/cd-deploy-dev.yml b/.github/workflows/cd-deploy-dev.yml index 7c6b52dd9..a68ff5236 100644 --- a/.github/workflows/cd-deploy-dev.yml +++ b/.github/workflows/cd-deploy-dev.yml @@ -1,5 +1,6 @@ # CD: apply Pulumi to **dev** Swarm stacks on the pre-prod manager after a green main build. # Issue: https://github.com/SprocketBot/sprocket/issues/673 +# Refactored: reusable stack-map driven deploy (#682). # # Targets **pre-prod / main-dev only**. Never uses the production GitHub Environment or prod stack names. # @@ -14,8 +15,7 @@ # PULUMI_SSH_KEY # TAILSCALE_AUTHKEY (optional; omit if the runner can reach the manager without Tailscale) # -# Stack map (dev): for each stack, run `pulumi preview` then `pulumi up` in order: -# layer_1 / layer_1 → layer_2 / layer_2 → set platform:image-tag → platform / +# Stack order: infra/ci/stack-map.yaml → environments.dev.deploy_order (see infra/ci/README.md). # Optional repo variable: PULUMI_DEV_PLATFORM_STACK (default dev) — must not be `prod`. # # Triggers: @@ -48,7 +48,7 @@ permissions: actions: read jobs: - deploy-dev: + meta: if: >- github.event_name == 'workflow_dispatch' || (github.event.workflow_run.conclusion == 'success' && @@ -56,19 +56,25 @@ jobs: github.event.workflow_run.head_branch == 'main' && github.event.workflow_run.head_repository.full_name == github.repository) runs-on: ubuntu-latest - environment: development + outputs: + sha: ${{ steps.resolve.outputs.sha }} + platform_stack: ${{ steps.resolve.outputs.platform_stack }} + bom_run_id: ${{ steps.resolve.outputs.bom_run_id }} steps: - - name: Resolve deploy SHA and platform stack - id: meta + - name: Resolve deploy SHA, platform stack, BOM run id + id: resolve shell: bash run: | set -euo pipefail if [ "${GITHUB_EVENT_NAME}" = "workflow_run" ]; then echo "sha=${WORKFLOW_HEAD_SHA}" >> "${GITHUB_OUTPUT}" + echo "bom_run_id=${WORKFLOW_RUN_ID}" >> "${GITHUB_OUTPUT}" elif [ -n "${GIT_SHA_INPUT:-}" ]; then echo "sha=${GIT_SHA_INPUT}" >> "${GITHUB_OUTPUT}" + echo "bom_run_id=" >> "${GITHUB_OUTPUT}" else echo "sha=${GITHUB_SHA}" >> "${GITHUB_OUTPUT}" + echo "bom_run_id=" >> "${GITHUB_OUTPUT}" fi STACK="${PULUMI_DEV_PLATFORM_STACK:-dev}" if [ "${STACK}" = "prod" ]; then @@ -79,157 +85,17 @@ jobs: env: GIT_SHA_INPUT: ${{ github.event.inputs.git_sha }} WORKFLOW_HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + WORKFLOW_RUN_ID: ${{ github.event.workflow_run.id }} PULUMI_DEV_PLATFORM_STACK: ${{ vars.PULUMI_DEV_PLATFORM_STACK }} - - name: Check out the repo at deploy SHA - uses: actions/checkout@v6 - with: - ref: ${{ steps.meta.outputs.sha }} - - - name: Download merged BOM (main build) - id: bom - if: github.event_name == 'workflow_run' - uses: actions/download-artifact@v4 - with: - name: bom-main - path: bom-download - github-token: ${{ secrets.GITHUB_TOKEN }} - repository: ${{ github.repository }} - run-id: ${{ github.event.workflow_run.id }} - - - name: Pulumi preview layer_1 (dev) - uses: ./.github/reusable_workflows/pulumi_up - env: - PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_ACCESS_TOKEN }} - PULUMI_CONFIG_PASSPHRASE: ${{ secrets.PULUMI_CONFIG_PASSPHRASE }} - AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - AWS_REGION: nyc3 - with: - command: preview - stack-project: layer_1 - stack-name: layer_1 - manager-node-host: ${{ secrets.PULUMI_MANAGER_NODE_HOST }} - ssh-user: ${{ secrets.PULUMI_SSH_USER }} - ssh-key: ${{ secrets.PULUMI_SSH_KEY }} - tailscale-authkey: ${{ secrets.TAILSCALE_AUTHKEY }} - pulumi-backend-url: ${{ secrets.PULUMI_BACKEND_URL }} - - - name: Pulumi up layer_1 (dev) - uses: ./.github/reusable_workflows/pulumi_up - env: - PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_ACCESS_TOKEN }} - PULUMI_CONFIG_PASSPHRASE: ${{ secrets.PULUMI_CONFIG_PASSPHRASE }} - AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - AWS_REGION: nyc3 - with: - command: up - stack-project: layer_1 - stack-name: layer_1 - manager-node-host: ${{ secrets.PULUMI_MANAGER_NODE_HOST }} - ssh-user: ${{ secrets.PULUMI_SSH_USER }} - ssh-key: ${{ secrets.PULUMI_SSH_KEY }} - tailscale-authkey: ${{ secrets.TAILSCALE_AUTHKEY }} - pulumi-backend-url: ${{ secrets.PULUMI_BACKEND_URL }} - - - name: Pulumi preview layer_2 (dev) - uses: ./.github/reusable_workflows/pulumi_up - env: - PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_ACCESS_TOKEN }} - PULUMI_CONFIG_PASSPHRASE: ${{ secrets.PULUMI_CONFIG_PASSPHRASE }} - AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - AWS_REGION: nyc3 - with: - command: preview - stack-project: layer_2 - stack-name: layer_2 - manager-node-host: ${{ secrets.PULUMI_MANAGER_NODE_HOST }} - ssh-user: ${{ secrets.PULUMI_SSH_USER }} - ssh-key: ${{ secrets.PULUMI_SSH_KEY }} - tailscale-authkey: ${{ secrets.TAILSCALE_AUTHKEY }} - pulumi-backend-url: ${{ secrets.PULUMI_BACKEND_URL }} - - - name: Pulumi up layer_2 (dev) - uses: ./.github/reusable_workflows/pulumi_up - env: - PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_ACCESS_TOKEN }} - PULUMI_CONFIG_PASSPHRASE: ${{ secrets.PULUMI_CONFIG_PASSPHRASE }} - AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - AWS_REGION: nyc3 - with: - command: up - stack-project: layer_2 - stack-name: layer_2 - manager-node-host: ${{ secrets.PULUMI_MANAGER_NODE_HOST }} - ssh-user: ${{ secrets.PULUMI_SSH_USER }} - ssh-key: ${{ secrets.PULUMI_SSH_KEY }} - tailscale-authkey: ${{ secrets.TAILSCALE_AUTHKEY }} - pulumi-backend-url: ${{ secrets.PULUMI_BACKEND_URL }} - - - name: Set up Node.js (Pulumi CLI for platform config) - uses: actions/setup-node@v6 - with: - node-version: '20' - - - name: Install Pulumi CLI - shell: bash - run: | - curl -fsSL https://get.pulumi.com | sh -s -- --version "3.229.0" - echo "${HOME}/.pulumi/bin" >> "${GITHUB_PATH}" - - - name: Set dev platform image tag (sha-* from BOM when present) - shell: bash - env: - PULUMI_BACKEND_URL: ${{ secrets.PULUMI_BACKEND_URL }} - PULUMI_CONFIG_PASSPHRASE: ${{ secrets.PULUMI_CONFIG_PASSPHRASE }} - DEPLOY_SHA: ${{ steps.meta.outputs.sha }} - PULUMI_DEV_PLATFORM_STACK: ${{ steps.meta.outputs.platform_stack }} - run: | - set -euo pipefail - if [ -n "${PULUMI_BACKEND_URL:-}" ]; then - pulumi login "${PULUMI_BACKEND_URL}" - fi - BOM_PATH="" - if [ -d bom-download ]; then - BOM_PATH="$(find bom-download -type f -name 'main-*.json' | head -1 || true)" - fi - bash ./scripts/ci/pulumi-set-dev-image-tag.sh "${DEPLOY_SHA}" "${BOM_PATH}" - - - name: Pulumi preview platform (dev) - uses: ./.github/reusable_workflows/pulumi_up - env: - PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_ACCESS_TOKEN }} - PULUMI_CONFIG_PASSPHRASE: ${{ secrets.PULUMI_CONFIG_PASSPHRASE }} - AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - AWS_REGION: nyc3 - with: - command: preview - stack-project: platform - stack-name: ${{ steps.meta.outputs.platform_stack }} - manager-node-host: ${{ secrets.PULUMI_MANAGER_NODE_HOST }} - ssh-user: ${{ secrets.PULUMI_SSH_USER }} - ssh-key: ${{ secrets.PULUMI_SSH_KEY }} - tailscale-authkey: ${{ secrets.TAILSCALE_AUTHKEY }} - pulumi-backend-url: ${{ secrets.PULUMI_BACKEND_URL }} - - - name: Pulumi up platform (dev) - uses: ./.github/reusable_workflows/pulumi_up - env: - PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_ACCESS_TOKEN }} - PULUMI_CONFIG_PASSPHRASE: ${{ secrets.PULUMI_CONFIG_PASSPHRASE }} - AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - AWS_REGION: nyc3 - with: - command: up - stack-project: platform - stack-name: ${{ steps.meta.outputs.platform_stack }} - manager-node-host: ${{ secrets.PULUMI_MANAGER_NODE_HOST }} - ssh-user: ${{ secrets.PULUMI_SSH_USER }} - ssh-key: ${{ secrets.PULUMI_SSH_KEY }} - tailscale-authkey: ${{ secrets.TAILSCALE_AUTHKEY }} - pulumi-backend-url: ${{ secrets.PULUMI_BACKEND_URL }} + deploy-dev: + needs: meta + uses: ./.github/workflows/_reusable-pulumi-deploy.yml + with: + lane: main + target: dev + git_sha: ${{ needs.meta.outputs.sha }} + deploy_profile: dev_cd + bom_download_run_id: ${{ needs.meta.outputs.bom_run_id }} + dev_platform_stack: ${{ needs.meta.outputs.platform_stack }} + secrets: inherit diff --git a/.github/workflows/cd-deploy-prod.yml b/.github/workflows/cd-deploy-prod.yml index 4d289324a..1e2c2298f 100644 --- a/.github/workflows/cd-deploy-prod.yml +++ b/.github/workflows/cd-deploy-prod.yml @@ -1,4 +1,5 @@ # Production Pulumi deploy (layer_1 → layer_2 → platform prod stacks). +# Refactored: reusable stack-map driven deploy (#682). # # GitHub Environment `production` must hold **prod-only** credentials. Do not reuse # repository or pre-prod environment secret *names* for prod paths (avoids foot-guns @@ -44,100 +45,11 @@ permissions: jobs: pulumi-prod: - name: Pulumi preview and up (production stacks) - runs-on: ubuntu-latest - environment: production - steps: - - name: Validate confirm input - shell: bash - env: - CONFIRM: ${{ github.event.inputs.confirm }} - REPO: ${{ github.repository }} - run: | - if [ -n "${CONFIRM}" ] && [ "${CONFIRM}" != "${REPO}" ] && [ "${CONFIRM}" != "DEPLOY_PROD" ]; then - echo "::error::When set, confirm must equal ${REPO} or DEPLOY_PROD (got: ${CONFIRM})" - exit 1 - fi - - - name: Check out the repo at requested SHA - uses: actions/checkout@v6 - with: - ref: ${{ github.event.inputs.git_sha }} - - - name: Pulumi preview — layer_1 / layer_1 - uses: ./.github/reusable_workflows/pulumi_up - env: - PULUMI_ACCESS_TOKEN: ${{ secrets.PROD_PULUMI_ACCESS_TOKEN }} - with: - command: preview - stack-project: layer_1 - stack-name: layer_1 - manager-node-host: ${{ secrets.PROD_MANAGER_HOST }} - ssh-user: ${{ secrets.PROD_SSH_USER }} - ssh-key: ${{ secrets.PROD_SSH_KEY }} - tailscale-authkey: ${{ secrets.PROD_TAILSCALE_AUTHKEY }} - - - name: Pulumi preview — layer_2 / layer_2 - uses: ./.github/reusable_workflows/pulumi_up - env: - PULUMI_ACCESS_TOKEN: ${{ secrets.PROD_PULUMI_ACCESS_TOKEN }} - with: - command: preview - stack-project: layer_2 - stack-name: layer_2 - manager-node-host: ${{ secrets.PROD_MANAGER_HOST }} - ssh-user: ${{ secrets.PROD_SSH_USER }} - ssh-key: ${{ secrets.PROD_SSH_KEY }} - tailscale-authkey: ${{ secrets.PROD_TAILSCALE_AUTHKEY }} - - - name: Pulumi preview — platform / prod - uses: ./.github/reusable_workflows/pulumi_up - env: - PULUMI_ACCESS_TOKEN: ${{ secrets.PROD_PULUMI_ACCESS_TOKEN }} - with: - command: preview - stack-project: platform - stack-name: prod - manager-node-host: ${{ secrets.PROD_MANAGER_HOST }} - ssh-user: ${{ secrets.PROD_SSH_USER }} - ssh-key: ${{ secrets.PROD_SSH_KEY }} - tailscale-authkey: ${{ secrets.PROD_TAILSCALE_AUTHKEY }} - - - name: Pulumi up — layer_1 / layer_1 - uses: ./.github/reusable_workflows/pulumi_up - env: - PULUMI_ACCESS_TOKEN: ${{ secrets.PROD_PULUMI_ACCESS_TOKEN }} - with: - command: up - stack-project: layer_1 - stack-name: layer_1 - manager-node-host: ${{ secrets.PROD_MANAGER_HOST }} - ssh-user: ${{ secrets.PROD_SSH_USER }} - ssh-key: ${{ secrets.PROD_SSH_KEY }} - tailscale-authkey: ${{ secrets.PROD_TAILSCALE_AUTHKEY }} - - - name: Pulumi up — layer_2 / layer_2 - uses: ./.github/reusable_workflows/pulumi_up - env: - PULUMI_ACCESS_TOKEN: ${{ secrets.PROD_PULUMI_ACCESS_TOKEN }} - with: - command: up - stack-project: layer_2 - stack-name: layer_2 - manager-node-host: ${{ secrets.PROD_MANAGER_HOST }} - ssh-user: ${{ secrets.PROD_SSH_USER }} - ssh-key: ${{ secrets.PROD_SSH_KEY }} - tailscale-authkey: ${{ secrets.PROD_TAILSCALE_AUTHKEY }} - - - name: Pulumi up — platform / prod - uses: ./.github/reusable_workflows/pulumi_up - env: - PULUMI_ACCESS_TOKEN: ${{ secrets.PROD_PULUMI_ACCESS_TOKEN }} - with: - command: up - stack-project: platform - stack-name: prod - manager-node-host: ${{ secrets.PROD_MANAGER_HOST }} - ssh-user: ${{ secrets.PROD_SSH_USER }} - ssh-key: ${{ secrets.PROD_SSH_KEY }} - tailscale-authkey: ${{ secrets.PROD_TAILSCALE_AUTHKEY }} + uses: ./.github/workflows/_reusable-pulumi-deploy.yml + with: + lane: main + target: prod + git_sha: ${{ github.event.inputs.git_sha }} + deploy_profile: prod_cd + prod_confirm: ${{ github.event.inputs.confirm }} + secrets: inherit diff --git a/.github/workflows/cd-promote-dev-to-staging.yml b/.github/workflows/cd-promote-dev-to-staging.yml index 6fae9c813..5bf019df7 100644 --- a/.github/workflows/cd-promote-dev-to-staging.yml +++ b/.github/workflows/cd-promote-dev-to-staging.yml @@ -2,6 +2,7 @@ name: CD Promote Dev to Staging # Promotes an immutable image set from a known-good dev deployment (git SHA) to Pulumi # **staging** stacks only. Does not reference prod stacks or the production environment. +# Stack order + first-step image tag: [.github/workflows/_reusable-pulumi-deploy.yml](../../.github/workflows/_reusable-pulumi-deploy.yml) + infra/ci/stack-map.yaml (#682). on: workflow_dispatch: inputs: @@ -25,98 +26,22 @@ permissions: # Configure repo or environment variables CANARY_STAGING_BASE_URL (optional) and CANARY_STAGING_API_URL (required). jobs: - promote: - name: Promote to staging - runs-on: ubuntu-latest - environment: staging - env: - AWS_REGION: nyc3 - steps: - - name: Check out at promoted SHA - uses: actions/checkout@v6 - with: - ref: ${{ inputs.git_sha }} - - - name: Install Pulumi CLI - shell: bash - run: | - curl -fsSL https://get.pulumi.com | sh - echo "${HOME}/.pulumi/bin" >> "${GITHUB_PATH}" - - - name: Set platform image tag from SHA / BOM - shell: bash - env: - PULUMI_BACKEND_URL: ${{ secrets.PULUMI_BACKEND_URL }} - PULUMI_CONFIG_PASSPHRASE: ${{ secrets.PULUMI_CONFIG_PASSPHRASE }} - PROMOTE_GIT_SHA: ${{ inputs.git_sha }} - PROMOTE_BOM_FILE: ${{ inputs.bom_file }} - PROMOTE_IMAGE_TAG: ${{ inputs.image_tag }} - run: | - set -euo pipefail - args=(./scripts/ci/promote-images.sh pulumi-set-platform-tag) - if [[ -n "${PROMOTE_BOM_FILE}" ]]; then - args+=(--bom-file "${PROMOTE_BOM_FILE}") - fi - if [[ -n "${PROMOTE_IMAGE_TAG}" ]]; then - args+=(--image-tag "${PROMOTE_IMAGE_TAG}") - fi - args+=("${PROMOTE_GIT_SHA}" "staging") - "${args[@]}" - - - name: Pulumi up layer_1 (staging) - uses: ./.github/reusable_workflows/pulumi_up - env: - PULUMI_CONFIG_PASSPHRASE: ${{ secrets.PULUMI_CONFIG_PASSPHRASE }} - AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - AWS_REGION: nyc3 - with: - command: up - stack-project: layer_1 - stack-name: staging - manager-node-host: ${{ secrets.PULUMI_MANAGER_NODE_HOST }} - ssh-user: ${{ secrets.PULUMI_SSH_USER }} - ssh-key: ${{ secrets.PULUMI_SSH_KEY }} - tailscale-authkey: ${{ secrets.TAILSCALE_AUTHKEY }} - pulumi-backend-url: ${{ secrets.PULUMI_BACKEND_URL }} - - - name: Pulumi up layer_2 (staging) - uses: ./.github/reusable_workflows/pulumi_up - env: - PULUMI_CONFIG_PASSPHRASE: ${{ secrets.PULUMI_CONFIG_PASSPHRASE }} - AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - AWS_REGION: nyc3 - with: - command: up - stack-project: layer_2 - stack-name: staging - manager-node-host: ${{ secrets.PULUMI_MANAGER_NODE_HOST }} - ssh-user: ${{ secrets.PULUMI_SSH_USER }} - ssh-key: ${{ secrets.PULUMI_SSH_KEY }} - tailscale-authkey: ${{ secrets.TAILSCALE_AUTHKEY }} - pulumi-backend-url: ${{ secrets.PULUMI_BACKEND_URL }} - - - name: Pulumi up platform (staging) - uses: ./.github/reusable_workflows/pulumi_up - env: - PULUMI_CONFIG_PASSPHRASE: ${{ secrets.PULUMI_CONFIG_PASSPHRASE }} - AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - AWS_REGION: nyc3 - with: - command: up - stack-project: platform - stack-name: staging - manager-node-host: ${{ secrets.PULUMI_MANAGER_NODE_HOST }} - ssh-user: ${{ secrets.PULUMI_SSH_USER }} - ssh-key: ${{ secrets.PULUMI_SSH_KEY }} - tailscale-authkey: ${{ secrets.TAILSCALE_AUTHKEY }} - pulumi-backend-url: ${{ secrets.PULUMI_BACKEND_URL }} + promote-staging: + name: Promote to staging (image tag + Pulumi) + uses: ./.github/workflows/_reusable-pulumi-deploy.yml + with: + lane: main + target: staging + git_sha: ${{ inputs.git_sha }} + deploy_profile: staging_up + staging_include_promote_step: true + promote_bom_file: ${{ inputs.bom_file }} + promote_image_tag: ${{ inputs.image_tag }} + secrets: inherit canary_after_staging: name: Post-deploy synthetic canary (staging) - needs: promote + needs: promote-staging uses: ./.github/workflows/canary-staging.yml with: checkout_ref: ${{ inputs.git_sha }} diff --git a/.github/workflows/ci-stack-map-pulumi-preview.yml b/.github/workflows/ci-stack-map-pulumi-preview.yml new file mode 100644 index 000000000..ab2ecd78d --- /dev/null +++ b/.github/workflows/ci-stack-map-pulumi-preview.yml @@ -0,0 +1,25 @@ +# Integration check for stack-map driven Pulumi (#682): preview dev stacks from stack-map on PRs +# that touch CI stack metadata or the reusable deploy workflow. +name: CI Stack map Pulumi preview + +on: + pull_request: + paths: + - 'infra/ci/**' + - '.github/workflows/_reusable-pulumi-deploy.yml' + - '.github/workflows/ci-stack-map-pulumi-preview.yml' + - 'scripts/ci/read-stack-map.mjs' + +permissions: + contents: read + +jobs: + preview: + if: github.event.pull_request.head.repo.full_name == github.repository + uses: ./.github/workflows/_reusable-pulumi-deploy.yml + with: + lane: main + target: dev + git_sha: ${{ github.event.pull_request.head.sha }} + deploy_profile: preview_all + secrets: inherit diff --git a/infra/ci/README.md b/infra/ci/README.md index a0a8cb5c4..d184fa2cb 100644 --- a/infra/ci/README.md +++ b/infra/ci/README.md @@ -78,13 +78,43 @@ jobs: Single machine-readable contract for **which Pulumi stacks apply to which logical environment** (`dev`, `staging`, `prod`) and in **what order** Swarm-facing layers should run (`layer_1` → `layer_2` → `platform`). - **Prod** entries match [`.github/workflows/infra-pulumi.yml`](../../.github/workflows/infra-pulumi.yml) PR preview matrix today. -- **Dev / staging** list candidate stack names (`dev`, `staging`) with TODO comments until ops confirms they exist ([issue #667](https://github.com/SprocketBot/sprocket/issues/667)). +- **Dev** entries match [`.github/workflows/cd-deploy-dev.yml`](../../.github/workflows/cd-deploy-dev.yml) (layer stacks use project names; platform stack defaults to `dev` in the map and can be overridden by `PULUMI_DEV_PLATFORM_STACK` in CD). +- **Staging** entries match [`.github/workflows/cd-promote-dev-to-staging.yml`](../../.github/workflows/cd-promote-dev-to-staging.yml) (`staging` stack per project). - **Foundation** (`infra/foundation`) is listed separately; it is not part of the Swarm `deploy_order`. ### Who updates this -Maintainers who add or rename Pulumi stacks. After `pulumi stack ls` in each project, update this file and keep CI matrices in sync (or teach workflows to read this file). +Maintainers who add or rename Pulumi stacks. After `pulumi stack ls` in each project, update this file; CD workflows read deploy order from here via [`scripts/ci/read-stack-map.mjs`](../../scripts/ci/read-stack-map.mjs) ([issue #682](https://github.com/SprocketBot/sprocket/issues/682)). + +### Parameterized deploy workflows (#682) + +| Wrapper (thin caller) | Reusable core | GitHub `environment` (jobs inside reusable workflow) | +|----------------------|---------------|--------------------------------------------------------| +| [`.github/workflows/cd-deploy-dev.yml`](../../.github/workflows/cd-deploy-dev.yml) | [`.github/workflows/_reusable-pulumi-deploy.yml`](../../.github/workflows/_reusable-pulumi-deploy.yml) | `development` (`dev_prepare` + deploy steps) | +| [`.github/workflows/cd-promote-dev-to-staging.yml`](../../.github/workflows/cd-promote-dev-to-staging.yml) | same (`staging_include_promote_step: true` runs `promote-images.sh` then Pulumi ups) | `staging` | +| [`.github/workflows/cd-deploy-prod.yml`](../../.github/workflows/cd-deploy-prod.yml) | same | `production` | +| [`.github/workflows/ci-stack-map-pulumi-preview.yml`](../../.github/workflows/ci-stack-map-pulumi-preview.yml) | same | `preview` | + +**Reusable workflow inputs (for junior devs):** + +| Input | Meaning | +|-------|--------| +| `lane` | Reserved for future lane routing (e.g. `main`, `v15`); defaults to `main`. | +| `target` | Which `environments.` block in `stack-map.yaml` to use for **`preview_all`** only (`dev`, `staging`, or `prod`). | +| `git_sha` | Exact commit to check out for every job. | +| `deploy_profile` | `dev_cd` (BOM download + image tag + layer/platform Pulumi), `staging_up` (Pulumi `up` only, staging order), `prod_cd` (all previews then all ups, prod order), `preview_all` (preview every stack in `target`). | +| `bom_download_run_id` | For `dev_cd` after Autobuild: GitHub run id to download `bom-main` from; empty for manual deploys. | +| `dev_platform_stack` | Platform stack name for `dev_cd` (must not be `prod`). | +| `staging_include_promote_step` | For `staging_up` only: when `true`, first matrix row runs `scripts/ci/promote-images.sh` against stack `staging`, then Pulumi `up` rows from the stack map. | +| `promote_bom_file` / `promote_image_tag` | Optional inputs for that promote step (same semantics as the workflow dispatch form on promote). | +| `prod_confirm` | For `prod_cd` only: same typo guard as the old `confirm` workflow input (empty, or repo full name, or `DEPLOY_PROD`). | + +**Secrets:** Callers use `secrets: inherit`. The reusable workflow uses **pre-prod** secret names (`PULUMI_*`, `AWS_*`, …) for `dev_cd`, `staging_up`, and `preview_all`, and **prod** names (`PROD_*`, `PROD_PULUMI_ACCESS_TOKEN`) for `prod_cd`. A new lane needs its own wrapper workflow (or duplicate job) that runs under the right GitHub Environment and maps lane-specific secret names explicitly—Actions cannot build `secrets` keys from variables. + +**Caller `environment`:** Wrapper jobs do **not** set `environment` (avoids duplicate approval gates). Jobs inside `_reusable-pulumi-deploy.yml` set `environment` per profile (`development`, `staging`, `production`, `preview`) so scoped secrets apply to Pulumi and promote steps only. + +**Local check:** `node scripts/ci/read-stack-map.mjs emit --plan prod_cd` prints `matrix_json` (also written to `GITHUB_OUTPUT` when that variable is set in Actions). ### Future consumption -GitHub Actions or `scripts/infra` can parse this YAML with `yq`, a tiny Node script, or similar—out of scope for the initial file landing; see milestone notes in issue #667. +Older note: parsing was optional at file landing (#667). CD now consumes this file via `read-stack-map.mjs`; PR checks that touch `infra/ci/**` run [`.github/workflows/ci-stack-map-pulumi-preview.yml`](../../.github/workflows/ci-stack-map-pulumi-preview.yml) to exercise the same path with `preview_all` / `target: dev`. diff --git a/infra/ci/stack-map.yaml b/infra/ci/stack-map.yaml index bbbbc3e25..bd2234150 100644 --- a/infra/ci/stack-map.yaml +++ b/infra/ci/stack-map.yaml @@ -7,13 +7,12 @@ version: 1 environments: dev: - description: "Shared pre-prod Swarm — dev isolation (planned; not in CI matrix yet)" + description: "Pre-prod Swarm on the main dev manager — matches .github/workflows/cd-deploy-dev.yml" deploy_order: - # TODO(667): Confirm with ops — stacks may not exist yet; do not automate against these until verified. - - project: layer_1 - stack: dev - - project: layer_2 - stack: dev + # layer_* stack names match project folder names (same as prod). Platform stack defaults to dev; + # CD can override via workflow input / PULUMI_DEV_PLATFORM_STACK (must not be prod). + - { project: layer_1, stack: layer_1 } + - { project: layer_2, stack: layer_2 } - project: platform stack: dev diff --git a/package-lock.json b/package-lock.json index c46340403..17519a46f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1111,6 +1111,9 @@ "axios": "^0.27.1", "remove": "^0.1.5", "yaml": "^2.0.1" + }, + "engines": { + "node": ">=20" } }, "infra/global/node_modules/axios": { @@ -1163,6 +1166,9 @@ }, "devDependencies": { "@types/node": "^14" + }, + "engines": { + "node": ">=20" } }, "infra/layer_1/node_modules/@types/node": { @@ -1181,6 +1187,9 @@ }, "devDependencies": { "@types/node": "^14" + }, + "engines": { + "node": ">=20" } }, "infra/layer_2/node_modules/@types/node": { @@ -1203,6 +1212,9 @@ }, "devDependencies": { "@types/node": "^14" + }, + "engines": { + "node": ">=20" } }, "infra/platform/node_modules/@types/node": { diff --git a/scripts/ci/read-stack-map.mjs b/scripts/ci/read-stack-map.mjs new file mode 100644 index 000000000..7564a4d9e --- /dev/null +++ b/scripts/ci/read-stack-map.mjs @@ -0,0 +1,171 @@ +#!/usr/bin/env node +/** + * Read infra/ci/stack-map.yaml and emit GitHub Actions outputs (matrix JSON, counts). + * No secrets. Consumed by .github/workflows/_reusable-pulumi-deploy.yml (#682). + * + * Usage: + * node scripts/ci/read-stack-map.mjs emit --plan [--target ] [--platform-stack NAME] [--prepend-staging-promote] + * + * Plans: + * prod_cd — batch: all previews then all ups (production deploy_order) + * staging_up — up only, staging deploy_order + * staging_promote_then_up — optional first row: staging_promote (caller runs promote script), then staging_up + * dev_layers_interleaved — preview+up per stack for non-platform entries in dev + * dev_platform_interleaved — preview+up for platform row(s) in dev deploy_order + * dev_cd_full — dev CD: layer preview/up pairs then platform preview/up (platform stack from --platform-stack) + * dev_preview_all — preview only, full dev deploy_order (PR stack-map integration test) + */ +import { readFileSync, existsSync, appendFileSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import YAML from 'yaml'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(__dirname, '../..'); +const MAP_PATH = resolve(REPO_ROOT, 'infra/ci/stack-map.yaml'); + +function parseArgs(argv) { + const out = { plan: null, target: null, platformStack: null, prependStagingPromote: false }; + for (let i = 2; i < argv.length; i += 1) { + const a = argv[i]; + if (a === 'emit') continue; + if (a === '--plan') { + out.plan = argv[++i]; + continue; + } + if (a === '--target') { + out.target = argv[++i]; + continue; + } + if (a === '--platform-stack') { + out.platformStack = argv[++i]; + continue; + } + if (a === '--prepend-staging-promote') { + out.prependStagingPromote = true; + continue; + } + console.error(`Unknown argument: ${a}`); + process.exit(1); + } + return out; +} + +function loadOrder(target) { + if (!existsSync(MAP_PATH)) { + console.error(`read-stack-map: missing ${MAP_PATH}`); + process.exit(1); + } + const doc = YAML.parse(readFileSync(MAP_PATH, 'utf8')); + const envSection = doc?.environments?.[target]; + const order = envSection?.deploy_order; + if (!Array.isArray(order) || order.length === 0) { + console.error(`read-stack-map: environments.${target}.deploy_order missing or empty`); + process.exit(1); + } + return order.map((row) => { + if (!row || typeof row.project !== 'string' || typeof row.stack !== 'string') { + throw new Error(`Invalid deploy_order entry: ${JSON.stringify(row)}`); + } + return { project: row.project, stack: row.stack }; + }); +} + +function writeOut(line) { + const outPath = process.env.GITHUB_OUTPUT; + if (outPath) { + appendFileSync(outPath, `${line}\n`, 'utf8'); + } else { + process.stdout.write(`${line}\n`); + } +} + +function emitMatrix(rows) { + const json = JSON.stringify(rows); + writeOut(`matrix_json<<__MATRIX_EOF__\n${json}\n__MATRIX_EOF__`); + writeOut(`matrix_len=${rows.length}`); +} + +const args = parseArgs(process.argv); +const { plan, target: targetArg, platformStack: platformStackArg, prependStagingPromote } = args; + +if (!plan) { + console.error( + 'usage: node scripts/ci/read-stack-map.mjs emit --plan [--target dev|staging|prod] [--platform-stack NAME]', + ); + process.exit(1); +} + +/** @type {{ project: string, stack: string }[]} */ +let order; +/** @type {{ stack_project: string, stack_name: string, command: string }[]} */ +let matrix = []; + +if (plan === 'prod_cd') { + order = loadOrder('prod'); + for (const s of order) { + matrix.push({ stack_project: s.project, stack_name: s.stack, command: 'preview' }); + } + for (const s of order) { + matrix.push({ stack_project: s.project, stack_name: s.stack, command: 'up' }); + } +} else if (plan === 'staging_up') { + if (prependStagingPromote) { + matrix.push({ + stack_project: '_staging_promote_', + stack_name: '_staging_promote_', + command: 'staging_promote', + }); + } + order = loadOrder('staging'); + for (const s of order) { + matrix.push({ stack_project: s.project, stack_name: s.stack, command: 'up' }); + } +} else if (plan === 'dev_layers_interleaved') { + order = loadOrder('dev'); + const layers = order.filter((s) => s.project !== 'platform'); + for (const s of layers) { + matrix.push({ stack_project: s.project, stack_name: s.stack, command: 'preview' }); + matrix.push({ stack_project: s.project, stack_name: s.stack, command: 'up' }); + } +} else if (plan === 'dev_platform_interleaved') { + order = loadOrder('dev'); + const platforms = order.filter((s) => s.project === 'platform'); + if (platforms.length === 0) { + console.error('read-stack-map: dev deploy_order has no platform entry'); + process.exit(1); + } + const platStackName = platformStackArg || platforms[0].stack; + for (const s of platforms) { + matrix.push({ stack_project: s.project, stack_name: platStackName, command: 'preview' }); + matrix.push({ stack_project: s.project, stack_name: platStackName, command: 'up' }); + } +} else if (plan === 'dev_cd_full') { + if (!platformStackArg) { + console.error('read-stack-map: dev_cd_full requires --platform-stack'); + process.exit(1); + } + order = loadOrder('dev'); + const layers = order.filter((s) => s.project !== 'platform'); + for (const s of layers) { + matrix.push({ stack_project: s.project, stack_name: s.stack, command: 'preview' }); + matrix.push({ stack_project: s.project, stack_name: s.stack, command: 'up' }); + } + matrix.push({ stack_project: 'platform', stack_name: platformStackArg, command: 'preview' }); + matrix.push({ stack_project: 'platform', stack_name: platformStackArg, command: 'up' }); +} else if (plan === 'dev_preview_all') { + const t = targetArg || 'dev'; + if (!['dev', 'staging', 'prod'].includes(t)) { + console.error('read-stack-map: --target must be dev, staging, or prod'); + process.exit(1); + } + order = loadOrder(t); + for (const s of order) { + matrix.push({ stack_project: s.project, stack_name: s.stack, command: 'preview' }); + } +} else { + console.error(`read-stack-map: unknown plan: ${plan}`); + process.exit(1); +} + +emitMatrix(matrix);