# Split QA Workflows from Freetier/Monitoring Pipelines

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Isolate QA (account B / dev-*-f02+) deployments into separate workflow files so PR #7162 can merge to main without breaking account A pipelines, and QA deployments are only triggered explicitly (never by push to main).

**Architecture:** Introduce an `stack_registry.json` registry that maps each stack to its account. Existing workflows (`freetier.yml`, `monitoring.yaml`) read this registry to filter to account A stacks only. New QA workflows (`freetier-qa.yml`, `monitoring-qa.yml`) filter to account B stacks only. New-region detection uses `stack_registry.json` git diff (replacing the `create-region` label). The `apply-new-region` label remains as the only safety gate for `pulumi up` on PR.

**Tech Stack:** GitHub Actions, Python (matrix builder), Pulumi, AWS

---

## Context for the implementer

### Current state (on main)

- `freetier.yml` handles ALL dev/staging EKS + ACK stacks in a single workflow
- `monitoring.yaml` handles ALL dev/staging monitoring stacks
- Both use `${{ secrets.DBAAS_DEV_AWS_ROLE }}` (account A: 385595570414) for all stacks
- Push to main triggers deployment of ALL dev stacks via `stacks_from_workspace()` (full directory scan)
- New region detection uses `create-region` label + `stacks_from_git_diff_added()` to find newly added Pulumi configs
- `apply-new-region` label gates `pulumi up` on PR for new regions
- Runner: `self-hosted`
- Pulumi action: `iosmanthus/pulumi-actions@global-lock`

### Target state

- **`stack_registry.json`** — central registry mapping stacks to accounts; used by all matrix builders
- **`freetier.yml` / `monitoring.yaml`** — unchanged triggers, but matrix builder reads `stack_registry.json` to only include account A stacks. New-region detection via `stack_registry.json` diff (no more `create-region` label)
- **`freetier-qa.yml` / `monitoring-qa.yml`** — new workflows for account B stacks:
  - Triggered by `workflow_dispatch` + `pull_request` only (NO push-to-main)
  - New-region detection via `stack_registry.json` diff
  - `apply-new-region` label gates `pulumi up` on PR
  - Auth via `AWS_ROLE_ARN` env var: `arn:aws:iam::946646677266:role/serverless-cicd-role` (runner assumes via GKE workload identity, same as account A)
  - Runner: `tidbcloud-org-runners-v3`
  - Pulumi action: `pulumi/actions@v6`
  - Node: 24.5.0, Python: `python3`
  - EKS only (no ACK)

### How stacks are identified

Instead of regex patterns to distinguish accounts, we use `stack_registry.json` as the source of truth:

```json
{
  "accounts": {
    "A": {
      "id": "385595570414",
      "stacks": {
        "eks-freetier": ["dev-us-east-1-f01", "dev-ap-southeast-1-f01"],
        "monitoring": ["dev-freetier-us-east-1-f01-monitoring", "dev-freetier-ap-southeast-1-f01-monitoring"]
      }
    },
    "B": {
      "id": "946646677266",
      "stacks": {
        "eks-freetier": ["dev-us-east-1-f02"],
        "monitoring": ["dev-freetier-us-east-1-f02-monitoring"]
      }
    }
  }
}
```

Matrix builders load this file, filter to their account's stacks, and build the matrix from that list. New-region detection checks the git diff of `stack_registry.json` to see if new stacks were added.

### Files overview

| File | Action | Purpose |
|------|--------|---------|
| `stack_registry.json` | Create | Stack-to-account registry |
| `.github/workflows/freetier.yml` | Modify | Use `stack_registry.json` for matrix + new-region detection |
| `.github/workflows/monitoring.yaml` | Modify | Use `stack_registry.json` for matrix + new-region detection |
| `.github/workflows/freetier-qa.yml` | Create | QA freetier pipeline (account B) |
| `.github/workflows/monitoring-qa.yml` | Create | QA monitoring pipeline (account B) |

---

### Task 1: Create `stack_registry.json`

**Files:**
- Create: `stack_registry.json`

- [ ] **Step 1: Create the registry file**

```json
{
  "accounts": {
    "A": {
      "id": "385595570414",
      "stacks": {
        "eks-freetier": [
          "dev-us-east-1-f01",
          "dev-ap-southeast-1-f01"
        ],
        "monitoring": [
          "dev-freetier-us-east-1-f01-monitoring",
          "dev-freetier-ap-southeast-1-f01-monitoring"
        ]
      }
    },
    "B": {
      "id": "946646677266",
      "stacks": {
        "eks-freetier": [
          "dev-us-east-1-f02"
        ],
        "monitoring": [
          "dev-freetier-us-east-1-f02-monitoring"
        ]
      }
    }
  }
}
```

- [ ] **Step 2: Verify the stack names match existing Pulumi configs**

```bash
ls pool/eks-freetier/Pulumi.dev-*.yaml monitoring/Pulumi.dev-*.yaml
```

Confirm every stack listed in `stack_registry.json` has a corresponding `Pulumi.<stack>.yaml` file, and vice versa.

- [ ] **Step 3: Commit**

```bash
git add stack_registry.json
git commit -m "add stack_registry.json: registry mapping stacks to AWS accounts

Used by CI matrix builders to determine which stacks belong to each
account. New-region additions are detected by diffing this file."
```

---

### Task 2: Modify `freetier.yml` to use `stack_registry.json`

**Files:**
- Modify: `.github/workflows/freetier.yml:70-177` (matrix builder Python script)

Two changes:
1. Replace `stacks_from_workspace()` with reading account A stacks from `stack_registry.json`
2. Replace `create-region` label detection with `stack_registry.json` diff detection

- [ ] **Step 1: Replace the matrix builder Python script**

Replace the entire `python - <<'PY' ... PY` block in the `Build matrix` step with:

```python
          python - <<'PY'
          import json
          import os
          import re
          import subprocess

          def set_output(key: str, value: str) -> None:
            output_path = os.getenv("GITHUB_OUTPUT")
            if not output_path:
              raise RuntimeError("GITHUB_OUTPUT is not set")
            with open(output_path, "a", encoding="utf-8") as f:
              f.write(f"{key}={value}\n")

          def json_compact(obj) -> str:
            return json.dumps(obj, separators=(",", ":"), sort_keys=True)

          base_ref = "${{ github.base_ref }}"
          source_branch = "${{ steps.branch_name.outputs.SOURCE_BRANCH }}"

          # Only handle dev stacks for account A in this workflow
          if not (base_ref == "main" or source_branch == "main"):
            # staging/release branches: use original workspace scan
            config_re = re.compile(r"Pulumi\.(staging.*)\.yaml")
            matched_eks = []
            for f in os.listdir("pool/eks-freetier"):
              m = config_re.match(f)
              if m:
                matched_eks.append({"stack": m.group(1)})
            matched_ack = []
            for f in os.listdir("pool/ack-freetier"):
              m = config_re.match(f)
              if m:
                matched_ack.append({"stack": m.group(1)})
            set_output("matrix_eks", json_compact({"include": matched_eks}))
            set_output("matrix_ack", json_compact({"include": matched_ack}))
            set_output("has_ack_matrix", "true" if len(matched_ack) > 0 else "false")
            raise SystemExit(0)

          # Load account A stacks from registry
          with open("stack_registry.json", "r") as f:
            registry = json.load(f)

          account_a = registry["accounts"]["A"]
          account_a_eks = set(account_a["stacks"].get("eks-freetier", []))
          account_a_ack = set(account_a["stacks"].get("ack-freetier", []))

          event_name = os.getenv("GITHUB_EVENT_NAME", "")
          is_pr_event = event_name == "pull_request"
          event_path = os.getenv("GITHUB_EVENT_PATH")
          if not event_path:
            raise SystemExit("GITHUB_EVENT_PATH is not set")
          with open(event_path, "r", encoding="utf-8") as f:
            event = json.load(f)

          is_new_region = False
          base_sha = None
          head_sha = None
          if is_pr_event:
            labels = {label.get("name", "") for label in event.get("pull_request", {}).get("labels", [])}
            base_sha = event.get("pull_request", {}).get("base", {}).get("sha")
            head_sha = event.get("pull_request", {}).get("head", {}).get("sha")

            # Detect new region by checking if stack_registry.json has new stacks for account A
            if base_sha and head_sha:
              try:
                subprocess.run(["git", "fetch", "--no-tags", "--depth=1", "origin", base_sha], check=True)
                subprocess.run(["git", "fetch", "--no-tags", "--depth=1", "origin", head_sha], check=True)
                old_content = subprocess.check_output(
                  ["git", "show", f"{base_sha}:stack_registry.json"], text=True, stderr=subprocess.DEVNULL
                )
                old_registry = json.loads(old_content)
                old_eks = set(old_registry.get("accounts", {}).get("A", {}).get("stacks", {}).get("eks-freetier", []))
                old_ack = set(old_registry.get("accounts", {}).get("A", {}).get("stacks", {}).get("ack-freetier", []))
                new_eks = account_a_eks - old_eks
                new_ack = account_a_ack - old_ack
                if new_eks or new_ack:
                  is_new_region = True
              except (subprocess.CalledProcessError, json.JSONDecodeError):
                # If stack_registry.json didn't exist in base, all stacks are "new"
                is_new_region = True

          if is_new_region:
            # Only deploy newly added stacks
            matrix_eks = {"include": [{"stack": s} for s in sorted(new_eks)]}
            matrix_ack = {"include": [{"stack": s} for s in sorted(new_ack)]}
          else:
            # Deploy all account A stacks
            matrix_eks = {"include": [{"stack": s} for s in sorted(account_a_eks)]}
            matrix_ack = {"include": [{"stack": s} for s in sorted(account_a_ack)]}

          set_output("matrix_eks", json_compact(matrix_eks))
          set_output("matrix_ack", json_compact(matrix_ack))
          set_output("has_ack_matrix", "true" if len(matrix_ack["include"]) > 0 else "false")
          PY
```

- [ ] **Step 2: Update the preview/apply label conditions**

In the `eks-freetier` job, replace `create-region` label checks:

**Preview step** (line ~240): Change condition from:
```
if: github.event_name == 'pull_request' && steps.should-run.outputs.run_aws == 'true' && !(contains(github.event.pull_request.labels.*.name, 'create-region') && contains(github.event.pull_request.labels.*.name, 'apply-new-region'))
```
to:
```
if: github.event_name == 'pull_request' && steps.should-run.outputs.run_aws == 'true' && !contains(github.event.pull_request.labels.*.name, 'apply-new-region')
```

**Update (PR) step** (line ~255): Change condition from:
```
if: github.event_name == 'pull_request' && steps.should-run.outputs.run_aws == 'true' && contains(github.event.pull_request.labels.*.name, 'create-region') && contains(github.event.pull_request.labels.*.name, 'apply-new-region')
```
to:
```
if: github.event_name == 'pull_request' && steps.should-run.outputs.run_aws == 'true' && contains(github.event.pull_request.labels.*.name, 'apply-new-region')
```

Do the same for `ack-freetier` job:

**Preview step** (line ~382): Remove `create-region` from condition:
```
if: github.event_name == 'pull_request' && steps.should-run.outputs.run_ali == 'true' && !contains(github.event.pull_request.labels.*.name, 'apply-new-region')
```

**Update (PR) step** (line ~401): Remove `create-region` from condition:
```
if: github.event_name == 'pull_request' && steps.should-run.outputs.run_ali == 'true' && contains(github.event.pull_request.labels.*.name, 'apply-new-region')
```

- [ ] **Step 3: Add `stack_registry.json` to path filters**

In the `changes` job, add `stack_registry.json` to the path filter so the workflow triggers when the registry changes:

```yaml
      - uses: dorny/paths-filter@v3
        id: filter
        with:
          list-files: 'shell'
          predicate-quantifier: 'every'
          filters: |
            pool-eks-freetier:
              - 'pool/eks-freetier/**'
              - '!pool/eks-freetier/Pulumi.prod*.yaml'
              - 'stack_registry.json'
            pool-ack-freetier:
              - 'pool/ack-freetier/**'
              - '!pool/ack-freetier/Pulumi.prod*.yaml'
              - 'stack_registry.json'
```

- [ ] **Step 4: Verify changes**

Read the modified file and confirm:
1. Matrix builder reads `stack_registry.json` for account A stacks
2. New-region detection uses `stack_registry.json` diff
3. `create-region` label is no longer referenced
4. `apply-new-region` is the only label gate
5. Staging branch logic still works (falls through to workspace scan)
6. No other changes to runners, credentials, or action versions

- [ ] **Step 5: Commit**

```bash
git add .github/workflows/freetier.yml
git commit -m "freetier: use stack_registry.json for matrix and new-region detection

Replace workspace scan with stack_registry.json registry for account A
stacks. Detect new regions by diffing stack_registry.json instead of
requiring create-region label. apply-new-region remains as the only
safety gate for pulumi up on PR."
```

---

### Task 3: Modify `monitoring.yaml` to use `stack_registry.json`

**Files:**
- Modify: `.github/workflows/monitoring.yaml:46-149` (matrix builder Python script)
- Modify: `.github/workflows/monitoring.yaml:16` (changes job condition — remove `create-region` reference)
- Modify: `.github/workflows/monitoring.yaml:199,215` (preview/apply label conditions)

Same pattern as Task 2 but for monitoring stacks.

- [ ] **Step 1: Update the `changes` job condition**

Line 16, change:
```yaml
    if: github.event_name != 'pull_request' || (github.event.action != 'labeled' && github.event.action != 'unlabeled') || contains(github.event.pull_request.labels.*.name, 'create-region')
```
to:
```yaml
    if: github.event_name != 'pull_request' || (github.event.action != 'labeled' && github.event.action != 'unlabeled') || contains(github.event.pull_request.labels.*.name, 'apply-new-region')
```

Also add `stack_registry.json` to the path filter:

```yaml
          filters: |
            freetier:
              - 'monitoring/**'
              - '!monitoring/Pulumi.prod*.yaml'
              - 'stack_registry.json'
```

- [ ] **Step 2: Replace the matrix builder Python script**

Replace the entire `python - <<'PY' ... PY` block with:

```python
          python - <<'PY'
          import json
          import os
          import re
          import subprocess

          def set_output(key: str, value: str) -> None:
            output_path = os.getenv("GITHUB_OUTPUT")
            if not output_path:
              raise RuntimeError("GITHUB_OUTPUT is not set")
            with open(output_path, "a", encoding="utf-8") as f:
              f.write(f"{key}={value}\n")

          def json_compact(obj) -> str:
            return json.dumps(obj, separators=(",", ":"), sort_keys=True)

          base_ref = "${{ github.base_ref }}"
          source_branch = "${{ steps.branch_name.outputs.SOURCE_BRANCH }}"

          # Only handle dev stacks for account A in this workflow
          if not (base_ref == "main" or source_branch == "main"):
            # staging/release branches: use original workspace scan
            config_re = re.compile(r"Pulumi\.(staging.*)\.yaml")
            matched = []
            for f in os.listdir("monitoring"):
              m = config_re.match(f)
              if m:
                matched.append({"stack": m.group(1)})
            set_output("matrix", json_compact({"include": matched}))
            raise SystemExit(0)

          # Load account A stacks from registry
          with open("stack_registry.json", "r") as f:
            registry = json.load(f)

          account_a = registry["accounts"]["A"]
          account_a_monitoring = set(account_a["stacks"].get("monitoring", []))

          event_name = os.getenv("GITHUB_EVENT_NAME", "")
          is_pr_event = event_name == "pull_request"
          event_path = os.getenv("GITHUB_EVENT_PATH")
          if not event_path:
            raise SystemExit("GITHUB_EVENT_PATH is not set")
          with open(event_path, "r", encoding="utf-8") as f:
            event = json.load(f)

          is_new_region = False
          if is_pr_event:
            base_sha = event.get("pull_request", {}).get("base", {}).get("sha")
            head_sha = event.get("pull_request", {}).get("head", {}).get("sha")

            if base_sha and head_sha:
              try:
                subprocess.run(["git", "fetch", "--no-tags", "--depth=1", "origin", base_sha], check=True)
                subprocess.run(["git", "fetch", "--no-tags", "--depth=1", "origin", head_sha], check=True)
                old_content = subprocess.check_output(
                  ["git", "show", f"{base_sha}:stack_registry.json"], text=True, stderr=subprocess.DEVNULL
                )
                old_registry = json.loads(old_content)
                old_monitoring = set(old_registry.get("accounts", {}).get("A", {}).get("stacks", {}).get("monitoring", []))
                new_monitoring = account_a_monitoring - old_monitoring
                if new_monitoring:
                  is_new_region = True
              except (subprocess.CalledProcessError, json.JSONDecodeError):
                is_new_region = True

          if is_new_region:
            matrix = {"include": [{"stack": s} for s in sorted(new_monitoring)]}
          else:
            matrix = {"include": [{"stack": s} for s in sorted(account_a_monitoring)]}

          set_output("matrix", json_compact(matrix))
          PY
```

- [ ] **Step 3: Update preview/apply label conditions**

**Preview step** (line ~199): Change from:
```
if: github.event_name == 'pull_request' && !(contains(github.event.pull_request.labels.*.name, 'create-region') && contains(github.event.pull_request.labels.*.name, 'apply-new-region'))
```
to:
```
if: github.event_name == 'pull_request' && !contains(github.event.pull_request.labels.*.name, 'apply-new-region')
```

**Update (PR) step** (line ~215): Change from:
```
if: github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'create-region') && contains(github.event.pull_request.labels.*.name, 'apply-new-region')
```
to:
```
if: github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'apply-new-region')
```

- [ ] **Step 4: Verify changes**

Read the modified file and confirm:
1. Matrix builder reads `stack_registry.json` for account A monitoring stacks
2. `create-region` label is no longer referenced anywhere
3. `apply-new-region` is the only label gate
4. Staging branch logic still works

- [ ] **Step 5: Commit**

```bash
git add .github/workflows/monitoring.yaml
git commit -m "monitoring: use stack_registry.json for matrix and new-region detection

Same approach as freetier.yml — read account A stacks from registry,
detect new regions via stack_registry.json diff."
```

---

### Task 4: Create `freetier-qa.yml`

**Files:**
- Create: `.github/workflows/freetier-qa.yml`

Key differences from `freetier.yml`:
1. **Triggers:** `workflow_dispatch` + `pull_request` only (NO `push` trigger)
2. **Credentials:** `AWS_ROLE_ARN` env var with account B role (same pattern as account A)
3. **Runner:** `tidbcloud-org-runners-v3`
4. **Matrix:** account B stacks from `stack_registry.json`
5. **EKS only:** no ACK job
6. **Pulumi action:** `pulumi/actions@v6`
7. **Node:** 24.5.0, **Python:** `python3`
8. **New-region detection:** `stack_registry.json` diff (same as freetier.yml)

- [ ] **Step 1: Create the workflow file**

```yaml
name: freetier-qa

on:
  workflow_dispatch:
    inputs:
      stack:
        description: "Specific stack to deploy (leave empty for all QA stacks)"
        required: false
        type: string
      command:
        description: "Pulumi command to run"
        required: true
        type: choice
        options:
          - preview
          - up
        default: preview
  pull_request:
    types: [opened, synchronize, reopened, labeled, unlabeled]
    branches:
      - main

jobs:
  changes:
    if: github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request'
    runs-on: tidbcloud-org-runners-v3
    outputs:
      pool-eks-freetier: ${{ steps.filter.outputs.pool-eks-freetier }}
      common-file-changed: ${{ steps.filter-common-files.outputs.common-files }}
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 100
      - uses: dorny/paths-filter@v3
        if: github.event_name == 'pull_request'
        id: filter
        with:
          list-files: 'shell'
          predicate-quantifier: 'every'
          filters: |
            pool-eks-freetier:
              - 'pool/eks-freetier/**'
              - '!pool/eks-freetier/Pulumi.prod*.yaml'
              - 'stack_registry.json'
      - uses: dorny/paths-filter@v3
        if: github.event_name == 'pull_request'
        id: filter-common-files
        with:
          list-files: 'shell'
          predicate-quantifier: 'some'
          filters: |
            common-files:
              - 'pool/manifests/**'
              - 'pool/lib/**'

  qa-stack-list:
    runs-on: tidbcloud-org-runners-v3
    needs: changes
    if: always()
    outputs:
      matrix-eks: ${{ steps.matrix.outputs.matrix_eks }}
      has_eks_matrix: ${{ steps.matrix.outputs.has_eks_matrix }}
      is_new_region: ${{ steps.matrix.outputs.is_new_region }}
    steps:
      - uses: actions/checkout@v4
      - name: Build matrix
        id: matrix
        run: |
          python3 - <<'PY'
          import json
          import os
          import subprocess

          def set_output(key: str, value: str) -> None:
            output_path = os.getenv("GITHUB_OUTPUT")
            if not output_path:
              raise RuntimeError("GITHUB_OUTPUT is not set")
            with open(output_path, "a", encoding="utf-8") as f:
              f.write(f"{key}={value}\n")

          def json_compact(obj) -> str:
            return json.dumps(obj, separators=(",", ":"), sort_keys=True)

          # Load account B stacks from registry
          with open("stack_registry.json", "r") as f:
            registry = json.load(f)

          account_b = registry["accounts"]["B"]
          account_b_eks = set(account_b["stacks"].get("eks-freetier", []))

          event_name = os.getenv("GITHUB_EVENT_NAME", "")
          is_pr_event = event_name == "pull_request"

          # Check for workflow_dispatch with specific stack input
          dispatch_stack = ""
          if event_name == "workflow_dispatch":
            dispatch_stack = "${{ github.event.inputs.stack }}"

          event_path = os.getenv("GITHUB_EVENT_PATH")
          if not event_path:
            raise SystemExit("GITHUB_EVENT_PATH is not set")
          with open(event_path, "r", encoding="utf-8") as f:
            event = json.load(f)

          is_new_region = False
          if is_pr_event:
            base_sha = event.get("pull_request", {}).get("base", {}).get("sha")
            head_sha = event.get("pull_request", {}).get("head", {}).get("sha")

            if base_sha and head_sha:
              try:
                subprocess.run(["git", "fetch", "--no-tags", "--depth=1", "origin", base_sha], check=True)
                subprocess.run(["git", "fetch", "--no-tags", "--depth=1", "origin", head_sha], check=True)
                old_content = subprocess.check_output(
                  ["git", "show", f"{base_sha}:stack_registry.json"], text=True, stderr=subprocess.DEVNULL
                )
                old_registry = json.loads(old_content)
                old_eks = set(old_registry.get("accounts", {}).get("B", {}).get("stacks", {}).get("eks-freetier", []))
                new_eks = account_b_eks - old_eks
                if new_eks:
                  is_new_region = True
              except (subprocess.CalledProcessError, json.JSONDecodeError):
                # If stack_registry.json didn't exist in base, all stacks are "new"
                is_new_region = True

          if dispatch_stack:
            matrix_eks = {"include": [{"stack": dispatch_stack}]}
          elif is_new_region:
            matrix_eks = {"include": [{"stack": s} for s in sorted(new_eks)]}
          else:
            matrix_eks = {"include": [{"stack": s} for s in sorted(account_b_eks)]}

          set_output("matrix_eks", json_compact(matrix_eks))
          set_output("has_eks_matrix", "true" if len(matrix_eks["include"]) > 0 else "false")
          set_output("is_new_region", "true" if is_new_region else "false")
          PY

  eks-freetier-qa:
    runs-on: tidbcloud-org-runners-v3
    needs: ["changes", "qa-stack-list"]
    if: >
      always() &&
      needs.qa-stack-list.outputs.has_eks_matrix == 'true' &&
      (
        github.event_name == 'workflow_dispatch' ||
        needs.changes.outputs.pool-eks-freetier == 'true' ||
        needs.changes.outputs.common-file-changed == 'true'
      )
    strategy:
      fail-fast: false
      max-parallel: 1
      matrix: ${{fromJSON(needs.qa-stack-list.outputs.matrix-eks)}}
    env:
      AWS_ROLE_ARN: arn:aws:iam::946646677266:role/serverless-cicd-role
      AWS_REGION: us-west-2
    steps:
      - name: Checkout
        uses: actions/checkout@v4
      - name: Setup node
        uses: actions/setup-node@v4
        with:
          node-version: 24.5.0
          cache: 'npm'
          cache-dependency-path: pool/package-lock.json
      - name: Install Pulumi CLI
        uses: pulumi/setup-pulumi@v2
      - name: Run npm ci
        working-directory: pool/eks-freetier
        run: npm ci
        env:
          GITHUB_TOKEN: ${{ secrets.GIT_ACTION_BOT }}
      - name: Check code format
        working-directory: pool/eks-freetier
        run: npm run check-fmt
      - name: ESLint
        working-directory: pool/eks-freetier
        run: npm run lint
      - name: ensure aws cli
        run: |
          curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"
          unzip -q awscliv2.zip
          sudo ./aws/install --update
      - name: Setup kubectl
        uses: azure/setup-kubectl@v1
        with:
          version: "v1.25.0"
      - name: preview - ${{matrix.stack}}
        if: >
          (github.event_name == 'pull_request' &&
           !contains(github.event.pull_request.labels.*.name, 'apply-new-region'))
          ||
          (github.event_name == 'workflow_dispatch' && github.event.inputs.command == 'preview')
        uses: pulumi/actions@v6
        with:
          stack-name: ${{matrix.stack}}
          command: preview
          work-dir: pool/eks-freetier
          upsert: true
          diff: true
          cloud-url: gs://tidbcloud-pulumi-state
        env:
          GITHUB_TOKEN: ${{ secrets.GIT_ACTION_BOT }}
          PULUMI_CONFIG_PASSPHRASE: ${{ secrets.PULUMI_CONFIG_PASSPHRASE }}
      - name: update (PR) - ${{matrix.stack}}
        if: >
          (github.event_name == 'pull_request' &&
           contains(github.event.pull_request.labels.*.name, 'apply-new-region'))
          ||
          (github.event_name == 'workflow_dispatch' && github.event.inputs.command == 'up')
        uses: pulumi/actions@v6
        with:
          stack-name: ${{matrix.stack}}
          command: up
          refresh: false
          upsert: true
          work-dir: pool/eks-freetier
          cloud-url: gs://tidbcloud-pulumi-state
        env:
          GITHUB_TOKEN: ${{ secrets.GIT_ACTION_BOT }}
          PULUMI_CONFIG_PASSPHRASE: ${{ secrets.PULUMI_CONFIG_PASSPHRASE }}
```

- [ ] **Step 2: Verify the file was created correctly**

Read `.github/workflows/freetier-qa.yml` and confirm:
1. Triggers: only `workflow_dispatch` and `pull_request` (no `push`)
2. Matrix: reads account B stacks from `stack_registry.json`
3. New-region detection: `stack_registry.json` diff
4. `AWS_ROLE_ARN` set to `946646677266:role/serverless-cicd-role` (no OIDC step)
5. Runner: `tidbcloud-org-runners-v3`
6. No ACK job
7. Uses `pulumi/actions@v6`
8. `workflow_dispatch` supports specific stack input and preview/up choice
9. `apply-new-region` is the only label gate (no `create-region`)

- [ ] **Step 3: Commit**

```bash
git add .github/workflows/freetier-qa.yml
git commit -m "freetier-qa: add isolated QA workflow for account B stacks

Supports workflow_dispatch (with optional stack filter and command
choice) and PR triggers. No push-to-main trigger — QA deployments
are always explicit. Uses AWS_ROLE_ARN with QA account credentials.
Uses stack_registry.json for stack list and new-region detection."
```

---

### Task 5: Create `monitoring-qa.yml`

**Files:**
- Create: `.github/workflows/monitoring-qa.yml`

Same isolation pattern as Task 4 but for monitoring stacks.

- [ ] **Step 1: Create the workflow file**

```yaml
name: monitoring-qa

on:
  workflow_dispatch:
    inputs:
      stack:
        description: "Specific stack to deploy (leave empty for all QA monitoring stacks)"
        required: false
        type: string
      command:
        description: "Pulumi command to run"
        required: true
        type: choice
        options:
          - preview
          - up
        default: preview
  pull_request:
    types: [opened, synchronize, reopened, labeled, unlabeled]
    branches:
      - main

jobs:
  changes:
    if: github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request'
    runs-on: tidbcloud-org-runners-v3
    outputs:
      changed: ${{ steps.filter.outputs.monitoring }}
    steps:
      - uses: actions/checkout@v4
      - uses: dorny/paths-filter@v3
        if: github.event_name == 'pull_request'
        id: filter
        with:
          list-files: 'shell'
          predicate-quantifier: 'every'
          filters: |
            monitoring:
              - 'monitoring/**'
              - '!monitoring/Pulumi.prod*.yaml'
              - 'stack_registry.json'

  qa-monitoring-stack-list:
    runs-on: tidbcloud-org-runners-v3
    needs: changes
    if: always()
    outputs:
      matrix: ${{ steps.matrix.outputs.matrix }}
      has_matrix: ${{ steps.matrix.outputs.has_matrix }}
      is_new_region: ${{ steps.matrix.outputs.is_new_region }}
    steps:
      - uses: actions/checkout@v4
      - name: Build matrix
        id: matrix
        run: |
          python3 - <<'PY'
          import json
          import os
          import subprocess

          def set_output(key: str, value: str) -> None:
            output_path = os.getenv("GITHUB_OUTPUT")
            if not output_path:
              raise RuntimeError("GITHUB_OUTPUT is not set")
            with open(output_path, "a", encoding="utf-8") as f:
              f.write(f"{key}={value}\n")

          def json_compact(obj) -> str:
            return json.dumps(obj, separators=(",", ":"), sort_keys=True)

          # Load account B stacks from registry
          with open("stack_registry.json", "r") as f:
            registry = json.load(f)

          account_b = registry["accounts"]["B"]
          account_b_monitoring = set(account_b["stacks"].get("monitoring", []))

          event_name = os.getenv("GITHUB_EVENT_NAME", "")
          is_pr_event = event_name == "pull_request"

          dispatch_stack = ""
          if event_name == "workflow_dispatch":
            dispatch_stack = "${{ github.event.inputs.stack }}"

          event_path = os.getenv("GITHUB_EVENT_PATH")
          if not event_path:
            raise SystemExit("GITHUB_EVENT_PATH is not set")
          with open(event_path, "r", encoding="utf-8") as f:
            event = json.load(f)

          is_new_region = False
          if is_pr_event:
            base_sha = event.get("pull_request", {}).get("base", {}).get("sha")
            head_sha = event.get("pull_request", {}).get("head", {}).get("sha")

            if base_sha and head_sha:
              try:
                subprocess.run(["git", "fetch", "--no-tags", "--depth=1", "origin", base_sha], check=True)
                subprocess.run(["git", "fetch", "--no-tags", "--depth=1", "origin", head_sha], check=True)
                old_content = subprocess.check_output(
                  ["git", "show", f"{base_sha}:stack_registry.json"], text=True, stderr=subprocess.DEVNULL
                )
                old_registry = json.loads(old_content)
                old_monitoring = set(old_registry.get("accounts", {}).get("B", {}).get("stacks", {}).get("monitoring", []))
                new_monitoring = account_b_monitoring - old_monitoring
                if new_monitoring:
                  is_new_region = True
              except (subprocess.CalledProcessError, json.JSONDecodeError):
                is_new_region = True

          if dispatch_stack:
            matrix = {"include": [{"stack": dispatch_stack}]}
          elif is_new_region:
            matrix = {"include": [{"stack": s} for s in sorted(new_monitoring)]}
          else:
            matrix = {"include": [{"stack": s} for s in sorted(account_b_monitoring)]}

          set_output("matrix", json_compact(matrix))
          set_output("has_matrix", "true" if len(matrix["include"]) > 0 else "false")
          set_output("is_new_region", "true" if is_new_region else "false")
          PY

  freetier-monitoring-qa:
    runs-on: tidbcloud-org-runners-v3
    needs: ["changes", "qa-monitoring-stack-list"]
    if: >
      always() &&
      needs.qa-monitoring-stack-list.outputs.has_matrix == 'true' &&
      (
        github.event_name == 'workflow_dispatch' ||
        needs.changes.outputs.changed == 'true'
      )
    env:
      PULUMI_CONFIG_PASSPHRASE: ${{ secrets.PULUMI_CONFIG_PASSPHRASE }}
      AWS_ROLE_ARN: arn:aws:iam::946646677266:role/serverless-cicd-role
      AWS_REGION: us-west-2
    strategy:
      max-parallel: 1
      fail-fast: false
      matrix: ${{fromJSON(needs.qa-monitoring-stack-list.outputs.matrix)}}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/cache@v4
        with:
          path: ~/.npm
          key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
          restore-keys: |
            ${{ runner.os }}-node-
      - uses: actions/setup-node@v4
        with:
          node-version: 24.5.0
          cache: 'npm'
          cache-dependency-path: monitoring/package-lock.json
      - name: Install Pulumi CLI
        uses: pulumi/setup-pulumi@v2
      - working-directory: monitoring
        run: npm ci
        env:
          GITHUB_TOKEN: ${{ secrets.GIT_ACTION_BOT }}
      - name: Check code format
        working-directory: monitoring
        run: npm run check-fmt
      - name: ESLint
        working-directory: monitoring
        run: npm run lint
      - name: ensure aws cli
        run: |
          curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"
          unzip -q awscliv2.zip
          sudo ./aws/install --update
      - uses: azure/setup-kubectl@v1
        with:
          version: "v1.25.0"
      - name: preview - ${{ matrix.stack }}
        if: >
          (github.event_name == 'pull_request' &&
           !contains(github.event.pull_request.labels.*.name, 'apply-new-region'))
          ||
          (github.event_name == 'workflow_dispatch' && github.event.inputs.command == 'preview')
        uses: pulumi/actions@v6
        with:
          stack-name: ${{ matrix.stack }}
          command: preview
          diff: true
          work-dir: monitoring
          upsert: true
          cloud-url: gs://tidbcloud-pulumi-state
        env:
          GITHUB_TOKEN: ${{ secrets.GIT_ACTION_BOT }}
          PULUMI_CONFIG_PASSPHRASE: ${{ secrets.PULUMI_CONFIG_PASSPHRASE }}
      - name: update - ${{ matrix.stack }}
        if: >
          (github.event_name == 'pull_request' &&
           contains(github.event.pull_request.labels.*.name, 'apply-new-region'))
          ||
          (github.event_name == 'workflow_dispatch' && github.event.inputs.command == 'up')
        uses: pulumi/actions@v6
        with:
          stack-name: ${{ matrix.stack }}
          command: up
          diff: true
          upsert: true
          refresh: true
          work-dir: monitoring
          cloud-url: gs://tidbcloud-pulumi-state
        env:
          GITHUB_TOKEN: ${{ secrets.GIT_ACTION_BOT }}
          PULUMI_CONFIG_PASSPHRASE: ${{ secrets.PULUMI_CONFIG_PASSPHRASE }}
```

- [ ] **Step 2: Verify the file**

Read `.github/workflows/monitoring-qa.yml` and confirm:
1. Triggers: `workflow_dispatch` + `pull_request` only (no `push`)
2. Matrix: reads account B monitoring stacks from `stack_registry.json`
3. `AWS_ROLE_ARN` set to `946646677266:role/serverless-cicd-role` (no OIDC step)
4. No `create-region` label referenced
5. `apply-new-region` is the only label gate

- [ ] **Step 3: Commit**

```bash
git add .github/workflows/monitoring-qa.yml
git commit -m "monitoring-qa: add isolated QA monitoring workflow

Same isolation model as freetier-qa. Only triggered by
workflow_dispatch or PR, never by push to main. Uses
stack_registry.json for stack list and new-region detection."
```

---

### Task 6: Validate the design end-to-end

This is a verification task — no code changes.

- [ ] **Step 1: Verify stack_registry.json covers all stacks**

```bash
# List all dev Pulumi configs and check they're in stack_registry.json
echo "=== EKS freetier configs ==="
ls pool/eks-freetier/Pulumi.dev-*.yaml 2>/dev/null
echo ""
echo "=== Monitoring configs ==="
ls monitoring/Pulumi.dev-*.yaml 2>/dev/null
echo ""
echo "=== stack_registry.json content ==="
cat stack_registry.json | python3 -m json.tool
```

Every `Pulumi.dev-*.yaml` config must appear in exactly one account in `stack_registry.json`.

- [ ] **Step 2: Verify workflow trigger isolation**

| Scenario | freetier.yml | freetier-qa.yml | monitoring.yaml | monitoring-qa.yml |
|----------|:---:|:---:|:---:|:---:|
| Push to main (code change) | Deploys A stacks | No trigger | Deploys A stacks | No trigger |
| PR (code change) | Preview A stacks | Preview B stacks | Preview A stacks | Preview B stacks |
| PR + `apply-new-region` | `pulumi up` A | `pulumi up` B | `pulumi up` A | `pulumi up` B |
| `workflow_dispatch` | N/A | Deploy B | N/A | Deploy B |
| New stack added in `stack_registry.json` for A | Detected via diff | Ignored | Detected via diff | Ignored |
| New stack added in `stack_registry.json` for B | Ignored | Detected via diff | Ignored | Detected via diff |

- [ ] **Step 3: Verify no cross-account credential leakage**

```bash
# freetier.yml and monitoring.yaml should only reference DBAAS_DEV_AWS_ROLE
grep -n "946646677266\|serverless-cicd" .github/workflows/freetier.yml .github/workflows/monitoring.yaml || echo "OK: no QA credentials in account A workflows"

# freetier-qa.yml and monitoring-qa.yml should only reference 946646677266
grep -n "DBAAS_DEV_AWS_ROLE" .github/workflows/freetier-qa.yml .github/workflows/monitoring-qa.yml || echo "OK: no account A credentials in QA workflows"
```

- [ ] **Step 4: Final commit (docs cleanup)**

```bash
git add docs/superpowers/plans/2026-03-31-split-qa-workflows.md
git commit -m "docs: update split-qa-workflows plan with stack_registry.json approach"
```
