Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
132 changes: 132 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,22 @@ jobs:
working-directory: ./evaluate-automerge
run: make test

test-resolve-stack-config:
name: Test resolve-stack-config composite action
runs-on: ubuntu-24.04
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0

- name: Install uv
uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6

- name: Run tests
working-directory: ./resolve-stack-config
run: make test

test-terraform-deploy:
name: Test terraform-deploy composite action
runs-on: ubuntu-24.04
Expand Down Expand Up @@ -402,6 +418,122 @@ jobs:
test "$(echo "$PAYLOAD" | jq -r '.data.attributes.git.commit_sha')" = "$GITHUB_SHA"
test "$(echo "$PAYLOAD" | jq -r '.data.attributes.git.repository_url')" = "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY"

test-e2e-terraform-deploy-stack-config:
name: Test E2E terraform-deploy (stack-config integration)
runs-on: ubuntu-24.04
permissions:
contents: read
services:
moto:
image: motoserver/moto:5.1.22@sha256:117238c6e7e3b566387c4c74e6fb0b6fdc34a094920c2154cf06f11dc72d9f37
ports:
- 5000:5000
env:
AWS_ACCESS_KEY_ID: testing
AWS_SECRET_ACCESS_KEY: testing
AWS_DEFAULT_REGION: us-east-1
AWS_ENDPOINT_URL: http://localhost:5000
steps:
- name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0

- name: Prepare ephemeral SSH deploy key
id: ssh-key
run: |
ssh-keygen -t ed25519 -f "$RUNNER_TEMP/test-key" -N "" -q
{
echo "private-key<<EOF"
cat "$RUNNER_TEMP/test-key"
echo "EOF"
} >> "$GITHUB_OUTPUT"

- name: Prepare two identical Terraform stacks under nested paths
run: |
cat > /tmp/main.tf << 'EOF'
terraform {
required_version = ">= 1.9.0"
}

variable "echoed" {
type = string
default = "default-value"
}

output "echoed" {
value = var.echoed
}
EOF
# Nested paths so the pattern `stacks/dev/**` matches the stack-dir.
# `**` requires at least one segment after it, so a flat layout like
# `stack-match` would not match `stack-match/**`.
mkdir -p stacks/dev/app-match stacks/dev/app-no-match
cp /tmp/main.tf stacks/dev/app-match/main.tf
cp /tmp/main.tf stacks/dev/app-no-match/main.tf

- name: Deploy stack with matching stack-config pattern
id: deploy-match
uses: ./terraform-deploy
with:
config: |
{
"dev": {
"accountId": "123456789012",
"defaultRegion": "us-east-1",
"deploymentRoleArn": "arn:aws:iam::123456789012:role/test",
"name": "test-dev"
}
}
stack-dir: "stacks/dev/app-match"
environment: "dev"
github-deploy-key: ${{ steps.ssh-key.outputs.private-key }}
cancel-if-stale: "false"
stack-config: |
[
{
"pattern": "stacks/dev/**",
"envVars": { "TF_VAR_echoed": "injected" }
}
]

- name: Verify injected TF_VAR reached Terraform output
env:
OUTPUTS: ${{ steps.deploy-match.outputs.terraform-outputs }}
run: |
echo "$OUTPUTS"
test "$(echo "$OUTPUTS" | jq -r '.echoed')" = "injected"

- name: Deploy stack with non-matching stack-config pattern
id: deploy-no-match
uses: ./terraform-deploy
with:
config: |
{
"dev": {
"accountId": "123456789012",
"defaultRegion": "us-east-1",
"deploymentRoleArn": "arn:aws:iam::123456789012:role/test",
"name": "test-dev"
}
}
stack-dir: "stacks/dev/app-no-match"
environment: "dev"
github-deploy-key: ${{ steps.ssh-key.outputs.private-key }}
cancel-if-stale: "false"
stack-config: |
[
{
"pattern": "stacks/prod/**",
"envVars": { "TF_VAR_echoed": "should-not-be-set" }
}
]

- name: Verify Terraform output is the variable default when pattern does not match
env:
OUTPUTS: ${{ steps.deploy-no-match.outputs.terraform-outputs }}
run: |
echo "$OUTPUTS"
test "$(echo "$OUTPUTS" | jq -r '.echoed')" = "default-value"

test-e2e-terraform-deploy-regression-test-terraform-v1-15-0:
# Regression test for hashicorp/terraform#38484: Terraform 1.15.0 emits the
# S3 backend's `dynamodb_table` deprecation warning to stdout, contaminating
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ on:
- "determine-stacks/*"
- "detect-stale-job/*"
- "evaluate-automerge/*"
- "resolve-stack-config/*"


jobs:
Expand Down
3 changes: 2 additions & 1 deletion .release-please-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,6 @@
"terraform-deploy": "1.7.3",
"determine-stacks": "1.2.2",
"detect-stale-job": "1.0.1",
"evaluate-automerge": "1.0.1"
"evaluate-automerge": "1.0.1",
"resolve-stack-config": "0.0.0"
}
4 changes: 4 additions & 0 deletions release-please-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@
"evaluate-automerge": {
"component": "evaluate-automerge",
"release-type": "simple"
},
"resolve-stack-config": {
"component": "resolve-stack-config",
"release-type": "simple"
}
}
}
6 changes: 6 additions & 0 deletions resolve-stack-config/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.PHONY: test

# Run unit tests
test:
@echo "Running tests..."
uv run python -m unittest test_resolve_stack_config -v
37 changes: 37 additions & 0 deletions resolve-stack-config/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
name: "Resolve stack-config"
description: "Resolve stack-config blocks against a stack-dir and emit a sourceable env file"

inputs:
stack-config:
description: "JSON array of stack configurations ( { pattern, envVars } objects). First matching pattern wins."
required: false
default: ""
stack-dir:
description: "Path of the current Terraform stack (e.g., stacks/dev/app)"
required: true

outputs:
env-file:
description: "Absolute path to a sourceable env file. Always created; empty if no patterns matched."
value: ${{ steps.resolve.outputs.env-file }}

runs:
using: "composite"
steps:
- name: Install uv
if: ${{ inputs.stack-config != '' }}
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
with:
working-directory: ${{ github.action_path }}

- name: Resolve stack-config
id: resolve
if: ${{ inputs.stack-config != '' }}
working-directory: ${{ github.action_path }}
shell: bash --noprofile --norc -euo pipefail {0}
env:
STACK_CONFIG: ${{ inputs.stack-config }}
STACK_DIR: ${{ inputs.stack-dir }}
run: |
env_file="$(uv run resolve_stack_config.py --stack-config "$STACK_CONFIG" --stack-dir "$STACK_DIR")"
echo "env-file=$env_file" >> "$GITHUB_OUTPUT"
10 changes: 10 additions & 0 deletions resolve-stack-config/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
[project]
name = "resolve-stack-config"
version = "0.1.0"
description = "Resolve stack-config blocks against a stack-dir and emit a sourceable env file"
requires-python = ">=3.13"
dependencies = []

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
159 changes: 159 additions & 0 deletions resolve-stack-config/resolve_stack_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
"""Resolve stack-config blocks against a stack-dir and emit a sourceable env file.

Iterates the block list, finds the first block whose pattern matches the current
stack-dir, validates the envVars map, and writes shell-quoted KEY=VALUE lines to
a private tempfile. The path is printed to stdout so the caller can capture it.

Usage:
python3 resolve_stack_config.py --stack-config <json> --stack-dir <path>
[--env-file-dir <path>]

Output: prints the absolute path of the env-file to stdout.
"""

import argparse
import json
import os
import re
import shlex
import stat
import sys
import tempfile
from pathlib import PurePosixPath
from typing import NotRequired, TypedDict


class Block(TypedDict):
pattern: str
envVars: NotRequired[dict[str, str]]


# Env var names must look like a conventional shell identifier. This rejects
# garbage (spaces, semicolons, leading digits) that would produce baffling
# downstream errors. Not a security boundary — the caller workflow is already
# trusted with full job privileges.
KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")

# Only envVars is recognized today; unknown keys are warned-and-ignored so that
# future extensions can be added without breaking older versions of this action.
KNOWN_BLOCK_KEYS = frozenset({"pattern", "envVars"})


def find_match(stack_dir: str, blocks: list[Block]) -> Block | None:
"""Return the first block whose pattern matches stack_dir, or None."""
path = PurePosixPath(stack_dir)
for block in blocks:
if path.full_match(block["pattern"]):
return block
return None


def validate_block_shape(raw_blocks: object) -> list[Block]:
"""Validate that the parsed JSON matches the expected schema."""
if not isinstance(raw_blocks, list):
raise ValueError("stack-config must be a JSON array")
result: list[Block] = []
for i, block in enumerate(raw_blocks):
if not isinstance(block, dict):
raise ValueError(f"stack-config[{i}] must be an object")
pattern = block.get("pattern")
if not isinstance(pattern, str):
raise ValueError(f"stack-config[{i}] requires a string 'pattern'")
env_vars = block.get("envVars", {})
if not isinstance(env_vars, dict):
raise ValueError(f"stack-config[{i}].envVars must be an object")
unknown = set(block) - KNOWN_BLOCK_KEYS
for key in sorted(unknown):
print(
f"::warning::stack-config[{i}]: unknown key '{key}' ignored "
f"(recognized keys: {sorted(KNOWN_BLOCK_KEYS)})",
file=sys.stderr,
)
result.append(block) # type: ignore[arg-type]
return result


def validate_env_vars(env_vars: dict[str, object]) -> dict[str, str]:
"""Validate keys and values of an envVars map."""
validated: dict[str, str] = {}
for key, value in env_vars.items():
if not isinstance(key, str) or not KEY_RE.match(key):
raise ValueError(
f"envVars key {key!r} must match {KEY_RE.pattern}"
)
if not isinstance(value, str):
raise ValueError(
f"envVars[{key!r}] must be a string, got {type(value).__name__}"
)
validated[key] = value
return validated


def write_env_file(env_vars: dict[str, str], env_file_dir: str) -> str:
"""Write shell-quoted KEY=VALUE lines to a private tempfile, return its path."""
fd, path = tempfile.mkstemp(suffix=".env", dir=env_file_dir, text=True)
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
with os.fdopen(fd, "w") as f:
for key in sorted(env_vars):
f.write(f"{key}={shlex.quote(env_vars[key])}\n")
return path


def resolve(
stack_config: str, stack_dir: str, env_file_dir: str
) -> tuple[str, list[str]]:
"""Resolve the matching block and produce an env file.

Returns (env_file_path, exported_keys). When no pattern matches (or the
input has no blocks), returns ("", []) and no file is written; the caller
is expected to guard its source against an empty path.
"""
try:
raw_blocks = json.loads(stack_config) if stack_config.strip() else []
except json.JSONDecodeError as e:
raise ValueError(f"stack-config is not valid JSON: {e}") from e

blocks = validate_block_shape(raw_blocks)
match = find_match(stack_dir, blocks)
if match is None:
return "", []

env_vars = validate_env_vars(match.get("envVars", {}))
if not env_vars:
return "", []
return write_env_file(env_vars, env_file_dir), sorted(env_vars)


if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="Resolve a stack-config block list against a stack-dir"
)
parser.add_argument(
"--stack-config",
required=True,
help="JSON array of stack-config blocks",
)
parser.add_argument(
"--stack-dir",
required=True,
help="Path of the current Terraform stack (e.g., stacks/dev/app)",
)
parser.add_argument(
"--env-file-dir",
default=os.environ.get("RUNNER_TEMP", tempfile.gettempdir()),
help="Directory in which to create the env file (default: $RUNNER_TEMP)",
)
args = parser.parse_args()

try:
env_file_path, exported = resolve(
args.stack_config, args.stack_dir, args.env_file_dir
)
except ValueError as e:
print(f"::error::{e}", file=sys.stderr)
sys.exit(1)

for key in exported:
print(f"Exported {key}", file=sys.stderr)

print(env_file_path)
Loading
Loading