Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
d2e3f9a
Move privileged CI config to Pulumi ESC
dmytrocraft May 24, 2026
324bd5a
Clarify ESC reads AWS Secrets Manager values
dmytrocraft May 24, 2026
72dc783
Record issue 20 closeout evidence
dmytrocraft May 24, 2026
f45a6dd
Align CI secret container description
dmytrocraft May 24, 2026
aea75e0
Cover operations alert triage write ordering
dmytrocraft May 24, 2026
38fc80a
Clarify ESC projects role ARNs from Secrets Manager
dmytrocraft May 24, 2026
ba5f371
Refresh issue 20 closeout head
dmytrocraft May 24, 2026
4e73652
Harden ESC source of truth boundaries
dmytrocraft May 24, 2026
5eb202e
Manage alert reconcile environment controls
dmytrocraft May 24, 2026
0321fa7
Refresh ESC source boundary evidence
dmytrocraft May 24, 2026
6dcf615
Clarify audited closeout head
dmytrocraft May 24, 2026
fd313a4
Clarify ESC uses AWS Secrets Manager source
dmytrocraft May 24, 2026
fa627d8
Refresh closeout evidence and alert reconcile guard
dmytrocraft May 24, 2026
5302cd3
Avoid self-referential closeout check status
dmytrocraft May 24, 2026
1c2df7c
Require SRE evidence for alert reconcile
dmytrocraft May 24, 2026
47fc724
Document ESC Secrets Manager cutover
dmytrocraft May 24, 2026
38dd8a7
Replace Pulumi ESC CI config with AWS Secrets Manager
dmytrocraft May 25, 2026
e7f12cf
Cover AWS CI config validation branches
dmytrocraft May 25, 2026
7b6fd47
Remove direct apply fallback from test deploys
dmytrocraft May 25, 2026
c20e7ef
Document explicit AWS CI role requirements
dmytrocraft May 25, 2026
72bd9eb
Remove stale ESC and apply fallback docs
dmytrocraft May 25, 2026
42228c7
Add operations alert canonical backfill
dmytrocraft May 25, 2026
93c1d14
Fix evidence workflow CI config trust
dmytrocraft May 25, 2026
e0242f0
Stop logging CI secret identifiers
dmytrocraft May 25, 2026
ce74d6e
Refresh AWS CI closeout evidence
dmytrocraft May 25, 2026
d406514
Limit operations alert backfill inputs
dmytrocraft May 25, 2026
622e9aa
Remove Pulumi Cloud and direct apply fallbacks
dmytrocraft May 25, 2026
6f51606
Refresh AWS cutover evidence and docs
dmytrocraft May 25, 2026
95bcb9e
Forbid direct Pulumi up in GitHub Actions
dmytrocraft May 25, 2026
9ca40ab
Refresh AWS cutover closeout evidence
dmytrocraft May 25, 2026
1470d28
Fail fast on missing PR AWS config role
dmytrocraft May 25, 2026
b5d005b
Refresh evidence for PR config role guard
dmytrocraft May 25, 2026
7f89bb4
Format AWS config contract test
dmytrocraft May 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .env.dist
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,5 @@ AWS_ACCESS_KEY_ID=
AWS_PROFILE=
AWS_SECRET_ACCESS_KEY=
AWS_SESSION_TOKEN=
PULUMI_ACCESS_TOKEN=
PULUMI_BACKEND_URL=
PULUMI_SECRETS_PROVIDER=
240 changes: 240 additions & 0 deletions .github/actions/load-aws-ci-env/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
name: Load AWS Secrets Manager CI environment
description: >-
Assumes a GitHub OIDC AWS config-read role, loads CI configuration from
AWS Secrets Manager, and validates required deployment variables.
inputs:
environment:
description: Fixed CI configuration suffix such as test-pr, test, prod-preview, or prod.
required: true
purpose:
description: Human-readable reason for loading this configuration.
required: false
default: "Pulumi deployment"
required-keys:
description: Newline-delimited environment variables that must exist in the JSON secret.
required: true
config-role-arn:
description: Non-secret AWS IAM role ARN trusted by GitHub OIDC to read this CI config secret.
required: true
aws-region:
description: AWS region containing the AWS Secrets Manager CI config secret.
required: true
outputs:
aws-account-id:
description: AWS account ID loaded from the CI config secret.
value: ${{ steps.collect.outputs.aws-account-id }}
aws-region:
description: AWS region loaded from the CI config secret.
value: ${{ steps.collect.outputs.aws-region }}
aws-preview-role-arn:
description: Preview role ARN loaded from the CI config secret.
value: ${{ steps.collect.outputs.aws-preview-role-arn }}
aws-apply-role-arn:
description: Apply role ARN loaded from the CI config secret.
value: ${{ steps.collect.outputs.aws-apply-role-arn }}
aws-drift-role-arn:
description: Drift role ARN loaded from the CI config secret.
value: ${{ steps.collect.outputs.aws-drift-role-arn }}
aws-operations-alert-triage-role-arn:
description: Operations alert triage role ARN loaded from the CI config secret.
value: ${{ steps.collect.outputs.aws-operations-alert-triage-role-arn }}
pulumi-backend-url:
description: Pulumi backend URL loaded from the CI config secret.
value: ${{ steps.collect.outputs.pulumi-backend-url }}
pulumi-secrets-provider:
description: Pulumi KMS secrets provider loaded from the CI config secret.
value: ${{ steps.collect.outputs.pulumi-secrets-provider }}
runs:
using: composite
steps:
- name: Resolve AWS Secrets Manager target
id: aws-target
shell: bash
env:
CI_CONFIG_ENVIRONMENT: ${{ inputs.environment }}
CI_CONFIG_ROLE_ARN: ${{ inputs.config-role-arn }}
CI_CONFIG_AWS_REGION: ${{ inputs.aws-region }}
run: |
set -euo pipefail
python3 - <<'PY'
import os
import re
import sys

valid_suffixes = {"test-pr", "test", "prod-preview", "prod"}
suffix = os.environ["CI_CONFIG_ENVIRONMENT"].strip()
if suffix not in valid_suffixes:
print(f"Unsupported CI configuration suffix: {suffix}", file=sys.stderr)
sys.exit(1)

role_arn = os.environ["CI_CONFIG_ROLE_ARN"].strip()
role_match = re.fullmatch(r"arn:aws:iam::(\d{12}):role/[A-Za-z0-9+=,.@_/-]+", role_arn)
if not role_match:
print("config-role-arn must be an AWS IAM role ARN.", file=sys.stderr)
sys.exit(1)

region = os.environ["CI_CONFIG_AWS_REGION"].strip()
if not re.fullmatch(r"[a-z]{2}-[a-z]+-\d", region):
print("aws-region must look like an AWS region, for example us-east-1.", file=sys.stderr)
sys.exit(1)

repository = os.environ.get("GITHUB_REPOSITORY", "")
if "/" not in repository:
print("GITHUB_REPOSITORY must be present.", file=sys.stderr)
sys.exit(1)
repo_name = repository.split("/", 1)[1]
repo_slug = re.sub(r"[^a-z0-9-]+", "-", repo_name.lower()).strip("-")
if not repo_slug:
print("Unable to derive repository slug for CI secret ID.", file=sys.stderr)
sys.exit(1)

secret_id = f"/{repo_slug}/ci/{suffix}"
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output:
output.write(f"secret_id={secret_id}\n")
output.write(f"config_account_id={role_match.group(1)}\n")
output.write(f"aws_region={region}\n")
PY

- name: Record AWS Secrets Manager source-of-truth boundary
shell: bash
env:
CI_CONFIG_SECRET_ID: ${{ steps.aws-target.outputs.secret_id }}
CI_CONFIG_PURPOSE: ${{ inputs.purpose }}
run: |
set -euo pipefail
{
echo "### AWS Secrets Manager CI configuration"
echo "- Secret ID: \`${CI_CONFIG_SECRET_ID}\`"
echo "- Purpose: ${CI_CONFIG_PURPOSE}"
echo "- Source of truth: AWS Secrets Manager"
echo "- Pulumi Cloud/ESC: not used"
echo "- Secret values are not printed in workflow logs"
} >> "${GITHUB_STEP_SUMMARY}"

- name: Configure AWS config-read credentials
uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7
with:
role-to-assume: ${{ inputs.config-role-arn }}
role-session-name: gha-ci-config-${{ github.run_id }}
aws-region: ${{ steps.aws-target.outputs.aws_region }}
allowed-account-ids: ${{ steps.aws-target.outputs.config_account_id }}

- name: Load AWS Secrets Manager CI values
id: load
shell: bash
env:
CI_CONFIG_SECRET_ID: ${{ steps.aws-target.outputs.secret_id }}
CI_CONFIG_ACCOUNT_ID: ${{ steps.aws-target.outputs.config_account_id }}
REQUIRED_KEYS: ${{ inputs.required-keys }}
run: |
set -euo pipefail
secret_file="$(mktemp "${RUNNER_TEMP:-/tmp}/ci-config.XXXXXX")"
cleanup() {
if command -v shred >/dev/null 2>&1; then
shred -u "${secret_file}" 2>/dev/null || rm -f "${secret_file}"
else
rm -f "${secret_file}"
fi
}
trap cleanup EXIT

aws secretsmanager get-secret-value \
--secret-id "${CI_CONFIG_SECRET_ID}" \
--query SecretString \
--output text > "${secret_file}"

python3 - "${secret_file}" <<'PY'
import json
import os
import sys
from pathlib import Path

secret_file = Path(sys.argv[1])
try:
payload = json.loads(secret_file.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
print(f"CI config secret is not valid JSON: {exc}", file=sys.stderr)
sys.exit(1)

if not isinstance(payload, dict):
print("CI config secret must be a JSON object.", file=sys.stderr)
sys.exit(1)

required = [
key.strip()
for line in os.environ["REQUIRED_KEYS"].splitlines()
for key in line.split(",")
if key.strip()
]
missing = [key for key in required if key not in payload or str(payload[key]).strip() == ""]
if missing:
print("CI config secret is missing required keys: " + ", ".join(sorted(missing)), file=sys.stderr)
sys.exit(1)

exports: dict[str, str] = {}
for key in required:
value = payload[key]
if isinstance(value, bool):
text = "true" if value else "false"
elif isinstance(value, (int, float, str)):
text = str(value).strip()
else:
print(f"CI config value {key} must be scalar.", file=sys.stderr)
sys.exit(1)
if "\n" in text or "\r" in text:
print(f"CI config value {key} must not contain newlines.", file=sys.stderr)
sys.exit(1)
exports[key] = text

expected_account_id = os.environ["CI_CONFIG_ACCOUNT_ID"]
actual_account_id = exports.get("AWS_ACCOUNT_ID")
if actual_account_id and actual_account_id != expected_account_id:
print(
"AWS_ACCOUNT_ID in CI config secret does not match config-read role account.",
file=sys.stderr,
)
sys.exit(1)

secret_id = os.environ["CI_CONFIG_SECRET_ID"]
with open(os.environ["GITHUB_ENV"], "a", encoding="utf-8") as env:
env.write(f"CI_CONFIG_SECRET_ID={secret_id}\n")
for key, value in exports.items():
env.write(f"{key}={value}\n")

with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output:
output.write("loaded=true\n")
PY

- name: Install uv for validation
uses: astral-sh/setup-uv@22695119d769bdb6f7032ad67b9bca0ef8c4a174
with:
version: "0.8.14"
enable-cache: true

- name: Validate AWS Secrets Manager CI environment
shell: bash
env:
CI_CONFIG_SECRET_ID: ${{ steps.aws-target.outputs.secret_id }}
CI_CONFIG_PURPOSE: ${{ inputs.purpose }}
REQUIRED_KEYS: ${{ inputs.required-keys }}
run: |
set -euo pipefail
uv run python scripts/validate_ci_environment.py \
--purpose "${CI_CONFIG_PURPOSE}" \
--required-keys "${REQUIRED_KEYS}"

- name: Collect CI configuration outputs
id: collect
shell: bash
run: |
set -euo pipefail
{
echo "aws-account-id=${AWS_ACCOUNT_ID}"
echo "aws-region=${AWS_REGION}"
echo "aws-preview-role-arn=${AWS_PREVIEW_ROLE_ARN:-}"
echo "aws-apply-role-arn=${AWS_APPLY_ROLE_ARN:-}"
echo "aws-drift-role-arn=${AWS_DRIFT_ROLE_ARN:-}"
echo "aws-operations-alert-triage-role-arn=${AWS_OPERATIONS_ALERT_TRIAGE_ROLE_ARN:-}"
echo "pulumi-backend-url=${PULUMI_BACKEND_URL}"
echo "pulumi-secrets-provider=${PULUMI_SECRETS_PROVIDER}"
} >> "${GITHUB_OUTPUT}"
74 changes: 20 additions & 54 deletions .github/github-actions-secrets.md
Original file line number Diff line number Diff line change
@@ -1,60 +1,26 @@
# GitHub Actions Secrets for Pulumi Workflows
# GitHub Actions Secrets

This repository uses GitHub OIDC and GitHub environment-scoped configuration
for AWS-backed Pulumi workflows. Do not add long-lived AWS access keys for
preview, apply, drift, or IAM validation jobs.
Privileged Pulumi workflows load account-local CI configuration directly from
AWS Secrets Manager through GitHub OIDC. Pulumi Cloud and Pulumi ESC are not
used.

## Environment Configuration
Repository variables:

Configure account-specific values under **Settings -> Environments**:
- `AWS_TEST_REGION`
- `AWS_TEST_PR_CI_CONFIG_ROLE_ARN`
- `AWS_TEST_CI_CONFIG_ROLE_ARN`
- `AWS_PROD_REGION`
- `AWS_PROD_PREVIEW_CI_CONFIG_ROLE_ARN`
- `AWS_PROD_CI_CONFIG_ROLE_ARN`

- `test` for trusted PR previews, test apply, and test drift.
- `prod-preview` for production preview and production drift.
- `prod` for production apply only.
AWS Secrets Manager secret IDs:

Each privileged environment should define these variables as applicable:
- `/bootstrap-infrastructure/ci/test-pr`
- `/bootstrap-infrastructure/ci/test`
- `/bootstrap-infrastructure/ci/prod-preview`
- `/bootstrap-infrastructure/ci/prod`

| Variable | Purpose |
| --- | --- |
| `AWS_ACCOUNT_ID` | Expected AWS account for `allowed-account-ids` |
| `AWS_REGION` | AWS region for OIDC and Pulumi |
| `AWS_PREVIEW_ROLE_ARN` | Preview and IAM validation role |
| `AWS_APPLY_ROLE_ARN` | Apply role for `test` and `prod` |
| `AWS_DRIFT_ROLE_ARN` | Drift role |
| `AWS_OPERATIONS_ALERT_TRIAGE_ROLE_ARN` | Dedicated role for operations alert issue triage |
| `PULUMI_BACKEND_URL` | Account-local Pulumi backend |
| `PULUMI_SECRETS_PROVIDER` | AWS KMS Pulumi secrets provider URI |
| `PULUMI_PREVIEW_STACKS` | Explicit preview stack list |
| `PULUMI_DRIFT_STACKS` | Explicit drift stack list |
| `PULUMI_PR_BACKEND_URL` | Optional backend used only by trusted PR previews |
| `PULUMI_PR_PREVIEW_STACKS` | Optional stack list used only by trusted PR previews |

Use `PULUMI_ACCESS_TOKEN` only as an environment secret when the selected
backend is Pulumi Cloud. Self-managed S3 backends do not need it.

## OIDC Trust

OIDC roles should trust the repository and the target GitHub environment:

```text
repo:VilnaCRM-Org/bootstrap-infrastructure:environment:<environment>
```

Use `allowed-account-ids: ${{ env.AWS_ACCOUNT_ID }}` in
`aws-actions/configure-aws-credentials` with `AWS_ACCOUNT_ID` populated from the
GitHub environment through job-level `env:`. That keeps the assumed account
preflight-validated and prevents a workflow from assuming a role in the wrong
account. Store role ARNs as job or workflow environment variables, not
repository-wide variables, when they differ by account or purpose.
The operations alert triage role should also trust only
`.github/workflows/operations-alert-triage.yml` on the protected main branch.

## Production Protection

The `prod` environment must require reviewers and deployment branch
restrictions before production apply is enabled. Production preview runs through
`prod-preview`; production apply verifies the same commit SHA before using the
saved Pulumi plan.

Release and template-sync credentials that are not AWS account-specific can
remain repository or organization secrets.
Use the [AWS Secrets Manager CI cutover manual](../docs/aws-secrets-manager-ci-cutover.md)
for setup and cleanup. Do not store account IDs, role ARNs, Pulumi backend URLs,
stack lists, or KMS secrets-provider URIs in GitHub Environment variables after
AWS-only CI is green.
Loading
Loading