docs(P41): merge phase 41 — per-environment CI jobs

---ci---
project: acdl
phase: 41
milestone: v1.9
status: execute
---/ci---

Merged phase/41-per-environment-ci-jobs into main. REQ-105, REQ-106
satisfied. 446 tests pass; run_ci.sh + run_platform.sh green; deploy
workflows byte-identical.
This commit is contained in:
Jon Chery
2026-07-23 04:34:14 +00:00
17 changed files with 438 additions and 10 deletions
+12 -1
View File
@@ -594,4 +594,15 @@ also closes P1-1 (adapter hardcoded defaults, deferred from v1.2).
- `_expand_vars` in resolver; unknown tokens raise; recursive over dicts/lists/strings. - `_expand_vars` in resolver; unknown tokens raise; recursive over dicts/lists/strings.
- Sample contracts use `${env.*}` + `${contract.*}` naming patterns; resolve to concrete values. - Sample contracts use `${env.*}` + `${contract.*}` naming patterns; resolve to concrete values.
- `tests/test_environment_schema.py` + `tests/test_interpolation.py` + `tests/test_sample_contracts_interpolate.py` pass. - `tests/test_environment_schema.py` + `tests/test_interpolation.py` + `tests/test_sample_contracts_interpolate.py` pass.
- `pytest` 406 (was 371, +35); `run_ci.sh` exits 0; `run_platform.sh --check-only` exits 0. - `pytest` 406 (was 371, +35); `run_ci.sh` exits 0; `run_platform.sh --check-only` exits 0.
### Phase 41 — per-environment-ci-jobs
- **Description:** Per-env contract files (static-assets + microservice × dev/qa/prod/dr, REQ-105) using interpolation. Deploy workflow (`.github` + `.gitea`, byte-identical) declares an `environment` `workflow_call` input (REQ-106); `run_platform.sh --environment <name>` overrides the contract's environment at load time (D-088, before schema validation + interpolation). `resolve()` accepts `environment_override`. Consumer guide documents the per-env caller-workflow pattern (4 jobs, one per environment) + HITL gate structure (approve_qa/approve_prod/approve_dr, D-042) + interpolation reference table. Promotion = running the matching job; no environment field editing.
- **Status:** complete (v1.8.3)
- **Depends on:** [40]
- **Requirements:** REQ-105, REQ-106
- **Success Criteria:**
- 8 per-env contract files exist + validate + resolve to correct env.
- Deploy workflow has `environment` input (byte-identical Gitea + GitHub); `run_platform.sh --environment` overrides; resolver supports `environment_override`.
- Consumer guide documents per-env caller workflows + promotion-without-editing + HITL gates + interpolation reference.
- `tests/test_per_env_contracts.py` + `tests/test_deploy_workflow_env_input.py` + `tests/test_consumer_guide_per_env_section.py` pass.
- `pytest` 446 (was 406, +40); `run_ci.sh` exits 0; both deploy workflows byte-identical.
+9 -1
View File
@@ -60,6 +60,10 @@ on:
description: Change request ID (required for decommission mode — validated against CMDB) description: Change request ID (required for decommission mode — validated against CMDB)
type: string type: string
default: "" default: ""
environment:
description: Target environment override (dev/qa/prod/dr); when empty, the contract's environment field is used
type: string
default: ""
permissions: permissions:
id-token: write id-token: write
@@ -120,7 +124,11 @@ jobs:
;; ;;
*) echo "Unknown mode: ${{ inputs.mode }}"; exit 1 ;; *) echo "Unknown mode: ${{ inputs.mode }}"; exit 1 ;;
esac esac
bash platform/scripts/run_platform.sh $MODE_FLAG "${{ inputs.contract }}" ENV_FLAG=""
if [ -n "${{ inputs.environment }}" ]; then
ENV_FLAG="--environment ${{ inputs.environment }}"
fi
bash platform/scripts/run_platform.sh $MODE_FLAG $ENV_FLAG "${{ inputs.contract }}"
- name: Post stage summary comment to PR - name: Post stage summary comment to PR
if: success() && github.event_name == 'pull_request' if: success() && github.event_name == 'pull_request'
+9 -1
View File
@@ -60,6 +60,10 @@ on:
description: Change request ID (required for decommission mode — validated against CMDB) description: Change request ID (required for decommission mode — validated against CMDB)
type: string type: string
default: "" default: ""
environment:
description: Target environment override (dev/qa/prod/dr); when empty, the contract's environment field is used
type: string
default: ""
permissions: permissions:
id-token: write id-token: write
@@ -120,7 +124,11 @@ jobs:
;; ;;
*) echo "Unknown mode: ${{ inputs.mode }}"; exit 1 ;; *) echo "Unknown mode: ${{ inputs.mode }}"; exit 1 ;;
esac esac
bash platform/scripts/run_platform.sh $MODE_FLAG "${{ inputs.contract }}" ENV_FLAG=""
if [ -n "${{ inputs.environment }}" ]; then
ENV_FLAG="--environment ${{ inputs.environment }}"
fi
bash platform/scripts/run_platform.sh $MODE_FLAG $ENV_FLAG "${{ inputs.contract }}"
- name: Post stage summary comment to PR - name: Post stage summary comment to PR
if: success() && github.event_name == 'pull_request' if: success() && github.event_name == 'pull_request'
+11
View File
@@ -0,0 +1,11 @@
# ACDL sample consumer contract — microservice module (dev)
# Per-environment contract (REQ-105). Promotion = running the dev job;
# no environment field editing. Interpolation resolves against dev.json.
uses: acdl/pipelines/deploy.yaml@v1.6
module: microservice
environment: dev
inputs:
bucket_name: acdl-${env.environment}-${contract.module}-${env.account_id}-${env.region}
region: ${env.region}
image: public.ecr.aws/docker/library/nginx:latest
port: 80
+11
View File
@@ -0,0 +1,11 @@
# ACDL sample consumer contract — microservice module (dr)
# Per-environment contract (REQ-105). Promotion = running the dr job;
# no environment field editing. Interpolation resolves against dr.json.
uses: acdl/pipelines/deploy.yaml@v1.6
module: microservice
environment: dr
inputs:
bucket_name: acdl-${env.environment}-${contract.module}-${env.account_id}-${env.region}
region: ${env.region}
image: public.ecr.aws/docker/library/nginx:latest
port: 80
+11
View File
@@ -0,0 +1,11 @@
# ACDL sample consumer contract — microservice module (prod)
# Per-environment contract (REQ-105). Promotion = running the prod job;
# no environment field editing. Interpolation resolves against prod.json.
uses: acdl/pipelines/deploy.yaml@v1.6
module: microservice
environment: prod
inputs:
bucket_name: acdl-${env.environment}-${contract.module}-${env.account_id}-${env.region}
region: ${env.region}
image: public.ecr.aws/docker/library/nginx:latest
port: 80
+11
View File
@@ -0,0 +1,11 @@
# ACDL sample consumer contract — microservice module (qa)
# Per-environment contract (REQ-105). Promotion = running the qa job;
# no environment field editing. Interpolation resolves against qa.json.
uses: acdl/pipelines/deploy.yaml@v1.6
module: microservice
environment: qa
inputs:
bucket_name: acdl-${env.environment}-${contract.module}-${env.account_id}-${env.region}
region: ${env.region}
image: public.ecr.aws/docker/library/nginx:latest
port: 80
+10
View File
@@ -0,0 +1,10 @@
# ACDL sample consumer contract — static-assets module (dev)
# Per-environment contract (REQ-105). The dev default
# (contracts/static-assets.yaml) remains for backwards compat; this file
# is the explicit per-env dev contract. Interpolation resolves against dev.json.
uses: acdl/pipelines/deploy.yaml@v1.6
module: static-assets
environment: dev
inputs:
bucket_name: acdl-${env.environment}-${contract.module}-${env.account_id}-${env.region}
region: ${env.region}
+9
View File
@@ -0,0 +1,9 @@
# ACDL sample consumer contract — static-assets module (dr)
# Per-environment contract (REQ-105). Promotion = running the dr job;
# no environment field editing. Interpolation resolves against dr.json.
uses: acdl/pipelines/deploy.yaml@v1.6
module: static-assets
environment: dr
inputs:
bucket_name: acdl-${env.environment}-${contract.module}-${env.account_id}-${env.region}
region: ${env.region}
+9
View File
@@ -0,0 +1,9 @@
# ACDL sample consumer contract — static-assets module (prod)
# Per-environment contract (REQ-105). Promotion = running the prod job;
# no environment field editing. Interpolation resolves against prod.json.
uses: acdl/pipelines/deploy.yaml@v1.6
module: static-assets
environment: prod
inputs:
bucket_name: acdl-${env.environment}-${contract.module}-${env.account_id}-${env.region}
region: ${env.region}
+9
View File
@@ -0,0 +1,9 @@
# ACDL sample consumer contract — static-assets module (qa)
# Per-environment contract (REQ-105). Promotion = running the qa job;
# no environment field editing. Interpolation resolves against qa.json.
uses: acdl/pipelines/deploy.yaml@v1.6
module: static-assets
environment: qa
inputs:
bucket_name: acdl-${env.environment}-${contract.module}-${env.account_id}-${env.region}
region: ${env.region}
+15 -5
View File
@@ -459,10 +459,20 @@ def resolve(contract_path, repo_root=None, environment_override=None):
if __name__ == "__main__": if __name__ == "__main__":
if len(sys.argv) != 3: if len(sys.argv) < 3:
print("usage: contract_resolver.py <contract.yaml> <out.json>", file=sys.stderr) print("usage: contract_resolver.py <contract.yaml> <out.json> [--environment <name>]", file=sys.stderr)
sys.exit(2) sys.exit(2)
result = resolve(sys.argv[1]) contract_path = sys.argv[1]
with open(sys.argv[2], "w") as fh: out_path = sys.argv[2]
env_override = None
if "--environment" in sys.argv:
idx = sys.argv.index("--environment")
if idx + 1 < len(sys.argv):
env_override = sys.argv[idx + 1]
# Also honor the ACDL_ENVIRONMENT_OVERRIDE env var (used by run_platform.sh).
if env_override is None and os.environ.get("ACDL_ENVIRONMENT_OVERRIDE"):
env_override = os.environ["ACDL_ENVIRONMENT_OVERRIDE"]
result = resolve(contract_path, environment_override=env_override)
with open(out_path, "w") as fh:
json.dump(result, fh, indent=2) json.dump(result, fh, indent=2)
print(f"resolver: resolved {sys.argv[1]} -> {sys.argv[2]}", file=sys.stderr) print(f"resolver: resolved {contract_path} -> {out_path}", file=sys.stderr)
+81 -1
View File
@@ -376,4 +376,84 @@ window if needed. The CMK is permanently deleted after the window expires.
The uptime monitoring stack (deployed with separate state) is not The uptime monitoring stack (deployed with separate state) is not
automatically destroyed by the decommission. It must be destroyed automatically destroyed by the decommission. It must be destroyed
separately (or left running to monitor the decommissioned stack's separately (or left running to monitor the decommissioned stack's
endpoints going dark). endpoints going dark).
## Per-environment deployment
ACDL supports a **promotion-without-editing** model: you do not edit the
`environment:` field in a contract to promote dev → qa → prod → dr.
Instead, there is **one CI job per environment**, each pointing at its
respective contract (or the same contract + the `environment` workflow
input). Promotion = running the matching job.
### Two shapes (both supported)
**Shape 1 — per-environment contract files:** a consumer repo has one
contract per environment (e.g. `.acdl/static-assets.dev.yaml`,
`.acdl/static-assets.qa.yaml`, …). Each sets `environment:` to its own
name and uses interpolation so env-specific values differ automatically:
```yaml
# .acdl/static-assets.qa.yaml
uses: acdl/pipelines/deploy.yaml@v1.9
module: static-assets
environment: qa
inputs:
bucket_name: acdl-${env.environment}-${contract.module}-${env.account_id}-${env.region}
region: ${env.region}
```
**Shape 2 — single contract + `environment` workflow input:** the
reusable deploy workflow (`acdl/.github/workflows/deploy.yml@v1.9`)
declares an `environment` input. When non-empty, it overrides the
contract's `environment` field at load time (before interpolation), so
the same contract can be promoted by passing a different environment:
```yaml
# .github/workflows/deploy-qa.yml (caller workflow)
on: workflow_dispatch:
inputs:
approve_qa:
description: "Set to true to approve the QA promotion"
type: boolean
required: true
jobs:
deploy-qa:
uses: acdl/.github/workflows/deploy.yml@v1.9
with:
environment: qa
contract: .acdl/contract.yaml
```
### One job per environment
A consumer repo's `.github/workflows/` directory has one caller workflow
per environment:
| File | Environment | Gate |
|------|-------------|------|
| `deploy-dev.yml` | dev | autonomous (no gate, confidence ≥ 0.50) |
| `deploy-qa.yml` | qa | QA HITL (`approve_qa` workflow_dispatch input; `github.actor` is the approver of record) |
| `deploy-prod.yml` | prod | SRE HITL (`approve_prod`; separation-of-duties enforced) |
| `deploy-dr.yml` | dr | SRE HITL (`approve_dr`) |
**Promotion = running the matching job.** No `environment:` field editing.
The approver identity is recorded to the DynamoDB outbox
(`approver_qa` / `approver_prod` / `approver_dr`) and the separation-of-
duties check blocks a prod promotion when `approver_qa == approver_prod`
(see `core/hitl_matrix_design.md`).
### Interpolation reference
| Token | Resolves to | Example |
|-------|-------------|---------|
| `${env.environment}` | the environment name (dev/qa/prod/dr) | `qa` |
| `${env.region}` | the environment's AWS region | `us-east-1` |
| `${env.account_id}` | the environment's AWS account id | `123456789012` |
| `${env.state_backend.bucket}` | the environment's state bucket | `acdl-qa-state` |
| `${env.network.vpc_cidr}` | the environment's VPC CIDR | `10.1.0.0/16` |
| `${contract.module}` | the contract's module name | `static-assets` |
| `${contract.environment}` | the contract's environment field | `qa` |
| `${contract.inputs.<name>}` | a contract input value | (as declared) |
Unknown tokens raise `ValueError` (fail loud). Expansion is recursive
(nested map/list values expand too).
+18 -1
View File
@@ -42,15 +42,26 @@ QUIET=0
DEPLOY_UPTIME=0 DEPLOY_UPTIME=0
DECOMMISSION=0 DECOMMISSION=0
CHANGE_REQUEST_ID="" CHANGE_REQUEST_ID=""
ENVIRONMENT_OVERRIDE=""
CONTRACT="" CONTRACT=""
# Parse args; --environment takes a value (either --environment=VALUE or
# --environment VALUE). The contract / changeRequestId are the remaining
# positional args.
_prev=""
for arg in "$@"; do for arg in "$@"; do
if [ "$_prev" = "--environment" ]; then
ENVIRONMENT_OVERRIDE="$arg"; _prev=""
continue
fi
case "$arg" in case "$arg" in
--check-only) CHECK_ONLY=1 ;; --check-only) CHECK_ONLY=1 ;;
--plan-only) PLAN_ONLY=1 ;; --plan-only) PLAN_ONLY=1 ;;
--quiet) QUIET=1 ;; --quiet) QUIET=1 ;;
--deploy-uptime) DEPLOY_UPTIME=1 ;; --deploy-uptime) DEPLOY_UPTIME=1 ;;
--decommission) DECOMMISSION=1 ;; --decommission) DECOMMISSION=1 ;;
--environment=*) ENVIRONMENT_OVERRIDE="${arg#*=}" ;;
--environment) _prev="--environment" ;;
--*) echo "FAIL: unknown flag: $arg" >&2; exit 1 ;; --*) echo "FAIL: unknown flag: $arg" >&2; exit 1 ;;
*) *)
if [ "$DECOMMISSION" = "1" ] && [ -z "$CHANGE_REQUEST_ID" ]; then if [ "$DECOMMISSION" = "1" ] && [ -z "$CHANGE_REQUEST_ID" ]; then
@@ -102,7 +113,13 @@ TF_DIR="$WORK/tf"
rm -rf "$WORK"; mkdir -p "$TF_DIR" rm -rf "$WORK"; mkdir -p "$TF_DIR"
echo "=== Step 0: environment onboarding check ===" echo "=== Step 0: environment onboarding check ==="
if [ -f "$CONTRACT" ]; then if [ -n "$ENVIRONMENT_OVERRIDE" ]; then
export ACDL_ENVIRONMENT_OVERRIDE="$ENVIRONMENT_OVERRIDE"
python3 core/environment_check.py --env="$ENVIRONMENT_OVERRIDE" || {
echo "FAIL: environment not bound — see the onboarding prompt above" >&2
exit 1
}
elif [ -f "$CONTRACT" ]; then
python3 core/environment_check.py "$CONTRACT" || { python3 core/environment_check.py "$CONTRACT" || {
echo "FAIL: environment not bound — see the onboarding prompt above" >&2 echo "FAIL: environment not bound — see the onboarding prompt above" >&2
exit 1 exit 1
@@ -0,0 +1,45 @@
"""REQ-106: consumer guide documents per-env caller workflows."""
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
GUIDE = ROOT / "docs" / "consumer-guide.md"
def test_consumer_guide_has_per_env_section():
text = GUIDE.read_text()
assert "Per-environment deployment" in text
assert "promotion-without-editing" in text.lower() or "promotion = running the matching job" in text.lower()
def test_consumer_guide_has_four_caller_examples():
text = GUIDE.read_text()
assert "deploy-dev" in text
assert "deploy-qa" in text
assert "deploy-prod" in text
assert "deploy-dr" in text
def test_consumer_guide_documents_environment_input():
text = GUIDE.read_text()
assert "environment" in text
assert "workflow input" in text.lower() or "workflow_call" in text.lower() or "environment:" in text
def test_consumer_guide_documents_hitl_gates():
text = GUIDE.read_text()
assert "approve_qa" in text
assert "approve_prod" in text
assert "approve_dr" in text
assert "separation-of-duties" in text.lower() or "separation of duties" in text.lower()
def test_consumer_guide_has_interpolation_reference():
text = GUIDE.read_text()
assert "${env.environment}" in text
assert "${env.account_id}" in text
assert "${contract.module}" in text
def test_consumer_guide_states_no_field_editing():
text = GUIDE.read_text()
assert "no" in text.lower() and "environment" in text.lower() and "editing" in text.lower()
+81
View File
@@ -0,0 +1,81 @@
"""REQ-106: deploy workflow environment input + run_platform.sh --environment.
Both deploy workflows (Gitea + GitHub) declare the environment input,
are byte-identical, and the resolver supports environment_override.
"""
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
from core.contract_resolver import resolve
GITHUB = ROOT / ".github" / "workflows" / "deploy.yml"
GITEA = ROOT / ".gitea" / "workflows" / "deploy.yml"
def test_both_deploy_workflows_exist():
assert GITHUB.is_file()
assert GITEA.is_file()
def test_deploy_workflows_byte_identical():
assert GITHUB.read_text() == GITEA.read_text(), "deploy workflows must be byte-identical"
def test_deploy_workflow_has_environment_input():
text = GITHUB.read_text()
assert "environment:" in text
assert "Target environment override" in text
# The input is declared in the workflow_call inputs section.
assert 'default: ""' in text
def test_deploy_workflow_passes_env_flag_to_run_platform():
text = GITHUB.read_text()
assert "--environment" in text
assert "ENV_FLAG" in text
assert "inputs.environment" in text
def test_resolver_environment_override_changes_env():
"""D-088: environment_override changes the resolved environment."""
stack = resolve(str(ROOT / "contracts" / "static-assets.yaml"),
environment_override="qa")
s3 = [r for r in stack["resources"] if r["type"] == "aws:s3:bucket"][0]
assert "qa" in s3["inputs"]["bucket_name"]
def test_resolver_environment_override_prod():
stack = resolve(str(ROOT / "contracts" / "static-assets.yaml"),
environment_override="prod")
s3 = [r for r in stack["resources"] if r["type"] == "aws:s3:bucket"][0]
assert "prod" in s3["inputs"]["bucket_name"]
def test_resolver_environment_override_dr():
stack = resolve(str(ROOT / "contracts" / "static-assets.yaml"),
environment_override="dr")
s3 = [r for r in stack["resources"] if r["type"] == "aws:s3:bucket"][0]
assert "dr" in s3["inputs"]["bucket_name"]
def test_resolver_no_override_uses_contract_env():
stack = resolve(str(ROOT / "contracts" / "static-assets.yaml"))
s3 = [r for r in stack["resources"] if r["type"] == "aws:s3:bucket"][0]
assert "dev" in s3["inputs"]["bucket_name"]
def test_resolver_override_none_uses_contract_env():
"""Passing environment_override=None uses the contract's environment."""
stack = resolve(str(ROOT / "contracts" / "static-assets.yaml"),
environment_override=None)
s3 = [r for r in stack["resources"] if r["type"] == "aws:s3:bucket"][0]
assert "dev" in s3["inputs"]["bucket_name"]
def test_run_platform_sh_has_environment_flag():
text = (ROOT / "scripts" / "run_platform.sh").read_text()
assert "--environment" in text
assert "ENVIRONMENT_OVERRIDE" in text
assert "ACDL_ENVIRONMENT_OVERRIDE" in text
+87
View File
@@ -0,0 +1,87 @@
"""REQ-105: per-environment contract files exist + validate + resolve."""
import json
import sys
from pathlib import Path
import jsonschema
import pytest
import yaml
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
from core.contract_resolver import resolve
SCHEMA = json.loads((ROOT / "schemas" / "contract.schema.json").read_text())
PER_ENV_CONTRACTS = [
"contracts/static-assets.dev.yaml",
"contracts/static-assets.qa.yaml",
"contracts/static-assets.prod.yaml",
"contracts/static-assets.dr.yaml",
"contracts/microservice.dev.yaml",
"contracts/microservice.qa.yaml",
"contracts/microservice.prod.yaml",
"contracts/microservice.dr.yaml",
]
@pytest.mark.parametrize("rel", PER_ENV_CONTRACTS)
def test_per_env_contract_validates_against_schema(rel):
contract = yaml.safe_load((ROOT / rel).read_text())
jsonschema.validate(contract, SCHEMA)
@pytest.mark.parametrize("rel", PER_ENV_CONTRACTS)
def test_per_env_contract_resolves(rel):
stack = resolve(str(ROOT / rel))
assert stack["stack"]["name"] in ("static-assets", "microservice")
def test_static_assets_dev_has_dev_environment():
c = yaml.safe_load((ROOT / "contracts/static-assets.dev.yaml").read_text())
assert c["environment"] == "dev"
def test_static_assets_qa_has_qa_environment():
c = yaml.safe_load((ROOT / "contracts/static-assets.qa.yaml").read_text())
assert c["environment"] == "qa"
def test_static_assets_prod_has_prod_environment():
c = yaml.safe_load((ROOT / "contracts/static-assets.prod.yaml").read_text())
assert c["environment"] == "prod"
def test_static_assets_dr_has_dr_environment():
c = yaml.safe_load((ROOT / "contracts/static-assets.dr.yaml").read_text())
assert c["environment"] == "dr"
def test_per_env_contracts_use_interpolation():
"""Each per-env contract uses ${env.*} interpolation for the bucket name."""
for rel in PER_ENV_CONTRACTS:
text = (ROOT / rel).read_text()
assert "${env.environment}" in text
assert "${env.account_id}" in text
def test_per_env_qa_resolves_to_qa_bucket_name():
stack = resolve(str(ROOT / "contracts/static-assets.qa.yaml"))
s3 = [r for r in stack["resources"] if r["type"] == "aws:s3:bucket"][0]
assert s3["inputs"]["bucket_name"] == "acdl-qa-static-assets-000000000000-us-east-1"
def test_per_env_prod_resolves_to_prod_bucket_name():
stack = resolve(str(ROOT / "contracts/static-assets.prod.yaml"))
s3 = [r for r in stack["resources"] if r["type"] == "aws:s3:bucket"][0]
assert s3["inputs"]["bucket_name"] == "acdl-prod-static-assets-000000000000-us-east-1"
def test_default_dev_contract_still_works():
"""The existing contracts/static-assets.yaml remains the dev default."""
c = yaml.safe_load((ROOT / "contracts/static-assets.yaml").read_text())
assert c["environment"] == "dev"
stack = resolve(str(ROOT / "contracts/static-assets.yaml"))
s3 = [r for r in stack["resources"] if r["type"] == "aws:s3:bucket"][0]
assert s3["inputs"]["bucket_name"] == "acdl-dev-static-assets-000000000000-us-east-1"