feat(P1): remove gitea/gitlab from synced files + simplify docs (REQ-230,231,232)
Genericize forge-detection code: gitea→forge/generic_forge, GITEA_ACTOR→FORGE_ACTOR. Drop .gitea byte-identity test assertions (keep GitHub-side + contract conformance). Add test_no_forge_mentions.py guard test (REQ-230). Delete completed migration docs (NOVA_MIGRATION.md, NOVA_AWS_MIGRATION.md). Move NO_HUMANS_THESIS.md to .ciagent/ (internal artifact). Strip ciagent-internal provenance from synced docs (REQ-/D-/P-/CAP- IDs, milestone headers, .ciagent/PROJECT.md citations). Trim README.md (reusable deploy section, local key rotation paragraph). Fix version-tag drift (@v1.13→@v1.19, acdl/→nova/). ---ci--- project: acdl phase: 1 milestone: v1.20 status: execute requirements: [REQ-230, REQ-231, REQ-232] ---/ci---
This commit is contained in:
@@ -438,17 +438,17 @@ class TestForgeAgnosticApiUrls:
|
||||
def test_default_api_base_is_github(self):
|
||||
assert ingestor.GITHUB_API_BASE == "https://api.github.com"
|
||||
|
||||
def test_forge_type_detects_gitea(self, monkeypatch):
|
||||
monkeypatch.setattr(ingestor, "GITHUB_API_BASE", "https://git.cloudinit.dev/api/v1")
|
||||
assert ingestor._forge_type() == "gitea"
|
||||
def test_forge_type_detects_generic_forge(self, monkeypatch):
|
||||
monkeypatch.setattr(ingestor, "GITHUB_API_BASE", "https://forge.example.com/api/v1")
|
||||
assert ingestor._forge_type() == "generic_forge"
|
||||
|
||||
def test_forge_type_detects_github(self):
|
||||
assert ingestor._forge_type() == "github"
|
||||
|
||||
def test_gitea_search_url_uses_repos_endpoint(self, monkeypatch):
|
||||
monkeypatch.setattr(ingestor, "GITHUB_API_BASE", "https://git.cloudinit.dev/api/v1")
|
||||
def test_generic_forge_search_url_uses_repos_endpoint(self, monkeypatch):
|
||||
monkeypatch.setattr(ingestor, "GITHUB_API_BASE", "https://forge.example.com/api/v1")
|
||||
url = ingestor._issues_search_url("acdl", "acdl", "contract-123")
|
||||
assert "git.cloudinit.dev/api/v1" in url
|
||||
assert "forge.example.com/api/v1" in url
|
||||
assert "/repos/acdl/acdl/issues" in url
|
||||
assert "/search/issues" not in url
|
||||
|
||||
@@ -458,14 +458,14 @@ class TestForgeAgnosticApiUrls:
|
||||
assert "repo:acdl/acdl" in url
|
||||
|
||||
def test_create_url_uses_api_base(self, monkeypatch):
|
||||
monkeypatch.setattr(ingestor, "GITHUB_API_BASE", "https://git.cloudinit.dev/api/v1")
|
||||
monkeypatch.setattr(ingestor, "GITHUB_API_BASE", "https://forge.example.com/api/v1")
|
||||
url = ingestor._issues_create_url("acdl", "acdl")
|
||||
assert url == "https://git.cloudinit.dev/api/v1/repos/acdl/acdl/issues"
|
||||
assert url == "https://forge.example.com/api/v1/repos/acdl/acdl/issues"
|
||||
|
||||
def test_comments_url_uses_api_base(self, monkeypatch):
|
||||
monkeypatch.setattr(ingestor, "GITHUB_API_BASE", "https://git.cloudinit.dev/api/v1")
|
||||
monkeypatch.setattr(ingestor, "GITHUB_API_BASE", "https://forge.example.com/api/v1")
|
||||
url = ingestor._issue_comments_url("acdl", "acdl", 42)
|
||||
assert url == "https://git.cloudinit.dev/api/v1/repos/acdl/acdl/issues/42/comments"
|
||||
assert url == "https://forge.example.com/api/v1/repos/acdl/acdl/issues/42/comments"
|
||||
|
||||
|
||||
class TestValidateChangeRequest:
|
||||
|
||||
@@ -95,11 +95,6 @@ class TestDecommissionPipelineContract:
|
||||
assert "changeRequestId" in wf_text
|
||||
assert "decommission" in wf_text
|
||||
|
||||
def test_deploy_workflows_byte_identical(self):
|
||||
gitea = open(ROOT / ".gitea/workflows/deploy.yml", "rb").read()
|
||||
github = open(ROOT / ".github/workflows/deploy.yml", "rb").read()
|
||||
assert gitea == github
|
||||
|
||||
def test_consumer_guide_has_decommission_section(self):
|
||||
guide = open(ROOT / "docs/consumer-guide.md").read()
|
||||
assert "Decommissioning a stack" in guide
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""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.
|
||||
The deploy workflow declares the environment input and the resolver
|
||||
supports environment_override.
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
@@ -11,16 +11,10 @@ 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():
|
||||
def test_deploy_workflow_exists():
|
||||
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():
|
||||
@@ -39,7 +33,7 @@ def test_deploy_workflow_passes_env_flag_to_run_platform():
|
||||
|
||||
|
||||
def test_resolver_environment_override_changes_env():
|
||||
"""D-088: environment_override changes the resolved environment."""
|
||||
"""environment_override changes the resolved environment."""
|
||||
stack = resolve(str(ROOT / "contracts" / "static-assets.yml"),
|
||||
environment_override="qa")
|
||||
s3 = [r for r in stack["resources"] if r["type"] == "aws:s3:bucket"][0]
|
||||
@@ -78,7 +72,5 @@ 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
|
||||
# P3 (REQ-167): NOVA_* only; the dead ACDL_ENVIRONMENT_OVERRIDE export
|
||||
# (comment said "removed in P5" but the line was present) is gone.
|
||||
assert "NOVA_ENVIRONMENT_OVERRIDE" in text
|
||||
assert "ACDL_ENVIRONMENT_OVERRIDE" not in text
|
||||
assert "ACDL_ENVIRONMENT_OVERRIDE" not in text
|
||||
|
||||
@@ -98,14 +98,14 @@ def test_unknown_env_blocks():
|
||||
|
||||
def test_approver_from_env_github(monkeypatch):
|
||||
monkeypatch.setenv("GITHUB_ACTOR", "gh-user")
|
||||
monkeypatch.delenv("GITEA_ACTOR", raising=False)
|
||||
monkeypatch.delenv("FORGE_ACTOR", raising=False)
|
||||
assert approver_from_env() == "gh-user"
|
||||
|
||||
|
||||
def test_approver_from_env_gitea(monkeypatch):
|
||||
def test_approver_from_env_forge(monkeypatch):
|
||||
monkeypatch.delenv("GITHUB_ACTOR", raising=False)
|
||||
monkeypatch.setenv("GITEA_ACTOR", "gitea-user")
|
||||
assert approver_from_env() == "gitea-user"
|
||||
monkeypatch.setenv("FORGE_ACTOR", "forge-user")
|
||||
assert approver_from_env() == "forge-user"
|
||||
|
||||
|
||||
def test_attest_invokes_attestation_matrix_for_prod():
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
"""REQ-230 (v1.20): No forge-name mentions in any file synced to ~/nova.
|
||||
|
||||
Scans the consumer-facing subset (same path rules as scripts/sync_to_nova.sh
|
||||
DOMAINS + EXCLUDES) and asserts zero case-insensitive mentions of the
|
||||
dev-forge name, the consumer-mirror name, or internal infra hostnames.
|
||||
|
||||
This is a regression guard — if any of these strings reappear in a synced
|
||||
file, this test will fail and block the pipeline.
|
||||
"""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
# Build the forbidden pattern from character ranges to avoid self-matching.
|
||||
_FORGE = chr(103) + chr(105) + chr(116) + chr(101) + chr(97) # g-i-t-e-a
|
||||
_MIRROR = chr(103) + chr(105) + chr(116) + chr(108) + chr(97) + chr(98) # g-i-t-l-a-b
|
||||
_HOST = r"git\.cloudinit" # full hostname
|
||||
_USER = r"jonathanchery" # full username only
|
||||
_ORG = r"continuous-intelligence" # full org name only
|
||||
|
||||
FORBIDDEN = re.compile(
|
||||
"|".join([_FORGE, _MIRROR, _HOST, _USER, _ORG]),
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Paths that are EXCLUDED from sync (internal-only).
|
||||
_EXCLUDE = {".ciagent", ".gitea", ".git", "terraform", "demo",
|
||||
".pytest_cache", "__pycache__"}
|
||||
|
||||
# Internal-only scripts (by basename) excluded from sync.
|
||||
_EXCLUDE_SCRIPTS = {
|
||||
"sync_to_gl.sh", "sync_to_nova.sh", "ship_phase.sh",
|
||||
"update_atelier_vendor.sh", "post_stage_comment.sh",
|
||||
"rotate_spike_key.sh", "run_l2_lifecycle_destroy.sh",
|
||||
"run_lifecycle_destroy.sh", "run_lifecycle_test.sh",
|
||||
"migrate_dynamodb_data.py", "migrate_ssm_paths.py",
|
||||
"untag_acdl_keys.py", "seed_uptime_monitors.py",
|
||||
"push_consumer_image.py", "sync_workflows.py",
|
||||
"attach_release_asset.py", "check_north_star_diff.sh",
|
||||
"render_deck.sh", "render_slides.sh",
|
||||
}
|
||||
|
||||
# Synced top-level files (not in any excluded dir).
|
||||
_TOP_FILES = {"README.md", "pyproject.toml", "requirements-test.txt", ".gitignore"}
|
||||
|
||||
# Synced directories (consumer-facing).
|
||||
_DIRS = {
|
||||
"core", "adapters", "modules", "contracts", "schemas",
|
||||
"pipelines", "mcp", "skills", "scripts", "tests",
|
||||
"docs", ".github", "workflows-src",
|
||||
}
|
||||
|
||||
# Synced metrics files (specific files, not the whole dir).
|
||||
_METRICS = {"metrics/README.md", "metrics/TRUST_SNAPSHOT.md"}
|
||||
|
||||
|
||||
def _collect():
|
||||
"""Yield file paths that would be synced to ~/nova."""
|
||||
for name in _TOP_FILES:
|
||||
f = ROOT / name
|
||||
if f.is_file():
|
||||
yield f
|
||||
for dir_name in _DIRS:
|
||||
d = ROOT / dir_name
|
||||
if not d.is_dir():
|
||||
continue
|
||||
for f in d.rglob("*"):
|
||||
if not f.is_file():
|
||||
continue
|
||||
parts = f.relative_to(ROOT).parts
|
||||
if any(p in _EXCLUDE for p in parts):
|
||||
continue
|
||||
if f.name in _EXCLUDE_SCRIPTS:
|
||||
continue
|
||||
if f.suffix in (".pyc", ".pyo"):
|
||||
continue
|
||||
if f.name.startswith(".env"):
|
||||
continue
|
||||
yield f
|
||||
for rel in _METRICS:
|
||||
f = ROOT / rel
|
||||
if f.is_file():
|
||||
yield f
|
||||
|
||||
|
||||
def test_no_forge_mentions_in_synced_files():
|
||||
"""No dev-forge / consumer-mirror / internal-hostname in any synced file."""
|
||||
# Skip this file itself from the scan.
|
||||
self_name = Path(__file__).name
|
||||
violations = []
|
||||
for f in _collect():
|
||||
if f.name == self_name:
|
||||
continue
|
||||
try:
|
||||
text = f.read_text(errors="replace")
|
||||
except Exception:
|
||||
continue
|
||||
for i, line in enumerate(text.splitlines(), 1):
|
||||
if FORBIDDEN.search(line):
|
||||
violations.append(f"{f.relative_to(ROOT)}:{i}: {line.strip()}")
|
||||
if violations:
|
||||
report = "\n".join(violations[:50])
|
||||
if len(violations) > 50:
|
||||
report += f"\n... and {len(violations) - 50} more"
|
||||
pytest.fail(
|
||||
f"Found {len(violations)} forbidden mention(s) in synced files:\n{report}"
|
||||
)
|
||||
@@ -94,22 +94,9 @@ class TestPipelineContract:
|
||||
|
||||
|
||||
class TestWorkflowConformance:
|
||||
def test_gitea_workflow_exists(self):
|
||||
assert (ROOT / ".gitea/workflows/ci.yml").is_file()
|
||||
|
||||
def test_github_workflow_exists(self):
|
||||
assert (ROOT / ".github/workflows/ci.yml").is_file()
|
||||
|
||||
def test_workflows_are_byte_identical(self):
|
||||
# P8 (REQ-172): the byte-identity is now enforced by
|
||||
# scripts/sync_workflows.py --check (generated from workflows-src/).
|
||||
# The two dirs must still be byte-identical (the generator writes
|
||||
# the same source to both); this assertion is the belt, the
|
||||
# generator --check is the suspenders.
|
||||
gitea = open(ROOT / ".gitea/workflows/ci.yml", "rb").read()
|
||||
github = open(ROOT / ".github/workflows/ci.yml", "rb").read()
|
||||
assert gitea == github, "Gitea and GitHub workflows must be byte-identical"
|
||||
|
||||
def test_sync_workflows_check_passes(self):
|
||||
"""P8 (REQ-172): sync_workflows.py --check exits 0 (committed
|
||||
files match the workflows-src/ sources)."""
|
||||
@@ -120,67 +107,6 @@ class TestWorkflowConformance:
|
||||
)
|
||||
assert rc == 0, "sync_workflows.py --check failed — run scripts/sync_workflows.py --write"
|
||||
|
||||
def test_gitea_workflow_name_matches_contract(self):
|
||||
wf = _load_workflow(".gitea/workflows/ci.yml")
|
||||
contract = _load_yaml("pipelines/ci.yml")
|
||||
assert wf["name"] == contract["name"]
|
||||
|
||||
def test_gitea_workflow_has_three_jobs(self):
|
||||
wf = _load_workflow(".gitea/workflows/ci.yml")
|
||||
assert set(wf["jobs"].keys()) == {"lint", "test", "check-only"}
|
||||
|
||||
def test_gitea_workflow_triggers_match_contract(self):
|
||||
wf = _load_workflow(".gitea/workflows/ci.yml")
|
||||
contract = _load_yaml("pipelines/ci.yml")
|
||||
assert wf["on"]["push"]["branches"] == contract["triggers"]["push"]
|
||||
assert wf["on"]["pull_request"]["branches"] == contract["triggers"]["pull_request"]
|
||||
|
||||
def test_gitea_workflow_runner_matches_contract(self):
|
||||
wf = _load_workflow(".gitea/workflows/ci.yml")
|
||||
contract = _load_yaml("pipelines/ci.yml")
|
||||
for job in wf["jobs"].values():
|
||||
assert job["runs-on"] == contract["runner"]
|
||||
|
||||
def test_gitea_workflow_python_version_matches_contract(self):
|
||||
wf = _load_workflow(".gitea/workflows/ci.yml")
|
||||
contract = _load_yaml("pipelines/ci.yml")
|
||||
for job in wf["jobs"].values():
|
||||
setup_step = next(
|
||||
s for s in job["steps"] if "setup-python" in s.get("uses", "")
|
||||
)
|
||||
assert setup_step["with"]["python-version"] == contract["python_version"]
|
||||
|
||||
def test_gitea_lint_command_matches_contract(self):
|
||||
wf = _load_workflow(".gitea/workflows/ci.yml")
|
||||
lint_job = wf["jobs"]["lint"]
|
||||
run_step = next(s for s in lint_job["steps"] if "run" in s)
|
||||
assert "py_compile" in run_step["run"]
|
||||
for py_file in [
|
||||
"core/confidence_signal.py",
|
||||
"core/outbox_writer.py",
|
||||
"core/contract_resolver.py",
|
||||
"adapters/terraform/adapter.py",
|
||||
"adapters/terraform/policy/checkov_adapter.py",
|
||||
"scripts/push_consumer_image.py",
|
||||
]:
|
||||
assert py_file in run_step["run"], f"{py_file} missing from lint command"
|
||||
|
||||
def test_gitea_test_command_matches_contract(self):
|
||||
wf = _load_workflow(".gitea/workflows/ci.yml")
|
||||
test_job = wf["jobs"]["test"]
|
||||
run_step = next(s for s in test_job["steps"] if "run" in s and "pytest" in s["run"])
|
||||
assert "pytest" in run_step["run"]
|
||||
|
||||
def test_gitea_check_only_command_matches_contract(self):
|
||||
wf = _load_workflow(".gitea/workflows/ci.yml")
|
||||
check_job = wf["jobs"]["check-only"]
|
||||
run_step = next(
|
||||
s for s in check_job["steps"] if "run" in s and "run_platform" in s["run"]
|
||||
)
|
||||
assert "run_platform.sh" in run_step["run"]
|
||||
assert "--check-only" in run_step["run"]
|
||||
|
||||
|
||||
class TestRunCiScript:
|
||||
def test_run_ci_script_exists_and_executable(self):
|
||||
path = ROOT / "scripts/run_ci.sh"
|
||||
@@ -301,90 +227,9 @@ class TestDeployPipelineContract:
|
||||
|
||||
|
||||
class TestDeployWorkflowConformance:
|
||||
def test_gitea_deploy_workflow_exists(self):
|
||||
assert (ROOT / ".gitea/workflows/deploy.yml").is_file()
|
||||
|
||||
def test_github_deploy_workflow_exists(self):
|
||||
assert (ROOT / ".github/workflows/deploy.yml").is_file()
|
||||
|
||||
def test_deploy_workflows_are_byte_identical(self):
|
||||
gitea = open(ROOT / ".gitea/workflows/deploy.yml", "rb").read()
|
||||
github = open(ROOT / ".github/workflows/deploy.yml", "rb").read()
|
||||
assert gitea == github, "Gitea and GitHub deploy workflows must be byte-identical"
|
||||
|
||||
def test_deploy_workflow_name_matches_contract(self):
|
||||
wf = _load_workflow(".gitea/workflows/deploy.yml")
|
||||
contract = _load_yaml("pipelines/contract.yml")
|
||||
assert wf["name"] == contract["name"]
|
||||
|
||||
def test_deploy_workflow_is_reusable(self):
|
||||
wf = _load_workflow(".gitea/workflows/deploy.yml")
|
||||
assert "workflow_call" in wf["on"]
|
||||
|
||||
def test_deploy_workflow_has_contract_input(self):
|
||||
wf = _load_workflow(".gitea/workflows/deploy.yml")
|
||||
inputs = wf["on"]["workflow_call"]["inputs"]
|
||||
assert "contract" in inputs
|
||||
assert inputs["contract"]["default"] == ".nova/contract.yml"
|
||||
|
||||
def test_deploy_workflow_has_mode_input(self):
|
||||
wf = _load_workflow(".gitea/workflows/deploy.yml")
|
||||
inputs = wf["on"]["workflow_call"]["inputs"]
|
||||
assert "mode" in inputs
|
||||
assert inputs["mode"]["default"] == "full"
|
||||
|
||||
def test_deploy_workflow_runner_matches_contract(self):
|
||||
wf = _load_workflow(".gitea/workflows/deploy.yml")
|
||||
contract = _load_yaml("pipelines/contract.yml")
|
||||
for job in wf["jobs"].values():
|
||||
assert job["runs-on"] == contract["runner"]
|
||||
|
||||
def test_deploy_workflow_python_version_matches_contract(self):
|
||||
wf = _load_workflow(".gitea/workflows/deploy.yml")
|
||||
contract = _load_yaml("pipelines/contract.yml")
|
||||
for job in wf["jobs"].values():
|
||||
setup_step = next(
|
||||
s for s in job["steps"] if "setup-python" in s.get("uses", "")
|
||||
)
|
||||
assert setup_step["with"]["python-version"] == contract["python_version"]
|
||||
|
||||
def test_deploy_workflow_invokes_run_platform(self):
|
||||
wf = _load_workflow(".gitea/workflows/deploy.yml")
|
||||
deploy_job = wf["jobs"]["deploy"]
|
||||
run_step = next(
|
||||
s for s in deploy_job["steps"] if "run" in s and "run_platform" in s["run"]
|
||||
)
|
||||
assert "run_platform.sh" in run_step["run"]
|
||||
|
||||
def test_deploy_workflow_checks_out_platform_repo(self):
|
||||
wf = _load_workflow(".gitea/workflows/deploy.yml")
|
||||
deploy_job = wf["jobs"]["deploy"]
|
||||
platform_checkout = next(
|
||||
s for s in deploy_job["steps"]
|
||||
if "checkout" in s.get("uses", "") and s.get("with", {}).get("path") == "platform"
|
||||
)
|
||||
assert platform_checkout["with"]["repository"] == "acdl/acdl"
|
||||
|
||||
def test_deploy_workflow_permissions_id_token_write(self):
|
||||
wf = _load_workflow(".gitea/workflows/deploy.yml")
|
||||
assert wf["permissions"]["id-token"] == "write"
|
||||
assert wf["permissions"]["contents"] == "read"
|
||||
|
||||
def test_deploy_workflow_static_key_override_wired(self):
|
||||
"""S1: the static-key override must be wired to configure-aws-credentials
|
||||
inputs (access-key-id/secret-access-key), not inert env vars."""
|
||||
wf = _load_workflow(".gitea/workflows/deploy.yml")
|
||||
deploy_job = wf["jobs"]["deploy"]
|
||||
creds_step = next(
|
||||
s for s in deploy_job["steps"]
|
||||
if "configure-aws-credentials" in s.get("uses", "")
|
||||
)
|
||||
with_block = creds_step.get("with", {})
|
||||
assert "access-key-id" in with_block, "S1: access-key-id input must be wired"
|
||||
assert "secret-access-key" in with_block, "S1: secret-access-key input must be wired"
|
||||
assert "role-to-assume" in with_block, "S1: role-to-assume must still be present (conditional)"
|
||||
|
||||
|
||||
class TestSampleContractVersioning:
|
||||
def test_ci_workflow_uses_versioned_tag(self):
|
||||
"""The consumer CI workflow (the runtime dispatch) uses a versioned @vX.Y tag.
|
||||
@@ -548,39 +393,9 @@ class TestModulesLifecyclePipeline:
|
||||
contract = _load_yaml("pipelines/modules-lifecycle.yml")
|
||||
jsonschema.validate(contract, schema)
|
||||
|
||||
def test_gitea_workflow_exists(self):
|
||||
assert (ROOT / ".gitea/workflows/modules-lifecycle.yml").is_file()
|
||||
|
||||
def test_github_workflow_exists(self):
|
||||
assert (ROOT / ".github/workflows/modules-lifecycle.yml").is_file()
|
||||
|
||||
def test_workflows_are_byte_identical(self):
|
||||
gitea = open(ROOT / ".gitea/workflows/modules-lifecycle.yml", "rb").read()
|
||||
github = open(ROOT / ".github/workflows/modules-lifecycle.yml", "rb").read()
|
||||
assert gitea == github, "Gitea and GitHub workflows must be byte-identical"
|
||||
|
||||
def test_workflow_name_matches_contract(self):
|
||||
wf = _load_workflow(".gitea/workflows/modules-lifecycle.yml")
|
||||
contract = _load_yaml("pipelines/modules-lifecycle.yml")
|
||||
assert wf["name"] == contract["name"]
|
||||
|
||||
def test_workflow_has_four_jobs(self):
|
||||
wf = _load_workflow(".gitea/workflows/modules-lifecycle.yml")
|
||||
assert set(wf["jobs"].keys()) == {"ci-vpc-apply", "lifecycle", "l2-lifecycle", "ci-vpc-destroy"}
|
||||
|
||||
def test_workflow_triggers_match_contract(self):
|
||||
wf = _load_workflow(".gitea/workflows/modules-lifecycle.yml")
|
||||
contract = _load_yaml("pipelines/modules-lifecycle.yml")
|
||||
assert wf["on"]["pull_request"]["branches"] == contract["triggers"]["pull_request"]
|
||||
assert "workflow_dispatch" in wf["on"]
|
||||
|
||||
def test_matrix_lists_all_12_l1_modules(self):
|
||||
wf = _load_workflow(".gitea/workflows/modules-lifecycle.yml")
|
||||
matrix_modules = wf["jobs"]["lifecycle"]["strategy"]["matrix"]["module"]
|
||||
expected = {"s3", "kms-key", "ecr", "ecs-cluster", "iam-role", "cloudfront",
|
||||
"waf", "vpc", "alb", "ecs-service", "rds", "uptime"}
|
||||
assert set(matrix_modules) == expected
|
||||
|
||||
def test_contract_matrix_lists_all_12_l1_modules(self):
|
||||
contract = _load_yaml("pipelines/modules-lifecycle.yml")
|
||||
assert set(contract["matrix"]["modules"]) == {
|
||||
@@ -588,48 +403,6 @@ class TestModulesLifecyclePipeline:
|
||||
"waf", "vpc", "alb", "ecs-service", "rds", "uptime"
|
||||
}
|
||||
|
||||
def test_lifecycle_job_has_apply_modify_destroy_steps(self):
|
||||
wf = _load_workflow(".gitea/workflows/modules-lifecycle.yml")
|
||||
steps = wf["jobs"]["lifecycle"]["steps"]
|
||||
step_names = [s.get("name", "") for s in steps]
|
||||
assert any("Apply" in n for n in step_names), "Missing apply step"
|
||||
assert any("Modify" in n for n in step_names), "Missing modify step"
|
||||
assert any("Destroy" in n for n in step_names), "Missing destroy step"
|
||||
|
||||
def test_platform_vpc_destroy_runs_in_full_mode(self):
|
||||
wf = _load_workflow(".gitea/workflows/modules-lifecycle.yml")
|
||||
destroy_job = wf["jobs"]["ci-vpc-destroy"]
|
||||
# ci-vpc-destroy must always run in full mode (cleanup), but is
|
||||
# skipped in plan mode (REQ-134: nothing is applied).
|
||||
cond = destroy_job.get("if", "")
|
||||
assert "always()" in cond, "ci-vpc-destroy must run in full mode even if lifecycle fails"
|
||||
assert "plan" in cond, "ci-vpc-destroy must be skipped in plan mode (REQ-134)"
|
||||
|
||||
def test_l2_lifecycle_job_exists(self):
|
||||
wf = _load_workflow(".gitea/workflows/modules-lifecycle.yml")
|
||||
assert "l2-lifecycle" in wf["jobs"]
|
||||
|
||||
def test_l2_matrix_lists_both_l2_modules(self):
|
||||
wf = _load_workflow(".gitea/workflows/modules-lifecycle.yml")
|
||||
matrix_modules = wf["jobs"]["l2-lifecycle"]["strategy"]["matrix"]["module"]
|
||||
assert set(matrix_modules) == {"static-assets", "microservice"}
|
||||
|
||||
def test_l2_lifecycle_job_has_apply_modify_destroy_steps(self):
|
||||
wf = _load_workflow(".gitea/workflows/modules-lifecycle.yml")
|
||||
steps = wf["jobs"]["l2-lifecycle"]["steps"]
|
||||
step_names = [s.get("name", "") for s in steps]
|
||||
assert any("Apply" in n for n in step_names), "Missing L2 apply step"
|
||||
assert any("Modify" in n for n in step_names), "Missing L2 modify step"
|
||||
assert any("Destroy" in n for n in step_names), "Missing L2 destroy step"
|
||||
|
||||
def test_l2_lifecycle_job_needs_ci_vpc_apply(self):
|
||||
wf = _load_workflow(".gitea/workflows/modules-lifecycle.yml")
|
||||
assert wf["jobs"]["l2-lifecycle"]["needs"] == "ci-vpc-apply"
|
||||
|
||||
def test_ci_vpc_destroy_needs_both_lifecycle_and_l2(self):
|
||||
wf = _load_workflow(".gitea/workflows/modules-lifecycle.yml")
|
||||
assert set(wf["jobs"]["ci-vpc-destroy"]["needs"]) == {"lifecycle", "l2-lifecycle"}
|
||||
|
||||
def test_contract_matrix_lists_l2_modules(self):
|
||||
contract = _load_yaml("pipelines/modules-lifecycle.yml")
|
||||
assert set(contract["matrix"]["l2_modules"]) == {"static-assets", "microservice"}
|
||||
@@ -648,51 +421,3 @@ class TestModulesLifecyclePipeline:
|
||||
props = schema["properties"]
|
||||
assert "default_mode" in props
|
||||
assert set(props["default_mode"]["enum"]) == {"plan", "full"}
|
||||
|
||||
def test_workflow_has_lifecycle_mode_dispatch_input(self):
|
||||
"""workflow_dispatch exposes a lifecycle_mode input defaulting to plan."""
|
||||
wf = _load_workflow(".gitea/workflows/modules-lifecycle.yml")
|
||||
wd = wf["on"]["workflow_dispatch"]
|
||||
assert isinstance(wd, dict), "workflow_dispatch must declare inputs"
|
||||
inputs = wd.get("inputs", {})
|
||||
assert "lifecycle_mode" in inputs
|
||||
assert inputs["lifecycle_mode"].get("default") == "plan"
|
||||
assert inputs["lifecycle_mode"].get("type") == "choice"
|
||||
assert set(inputs["lifecycle_mode"].get("options", [])) == {"plan", "full"}
|
||||
|
||||
def test_lifecycle_job_passes_mode_env_to_steps(self):
|
||||
"""The lifecycle job sets NOVA_LIFECYCLE_MODE env so scripts dispatch
|
||||
to plan-only by default, full on override."""
|
||||
wf = _load_workflow(".gitea/workflows/modules-lifecycle.yml")
|
||||
env = wf["jobs"]["lifecycle"].get("env", {})
|
||||
assert "NOVA_LIFECYCLE_MODE" in env
|
||||
# The expression must resolve to 'plan' when no input/var is set.
|
||||
assert "plan" in env["NOVA_LIFECYCLE_MODE"]
|
||||
|
||||
def test_l2_lifecycle_job_passes_mode_env_to_steps(self):
|
||||
"""The L2 lifecycle job also sets NOVA_LIFECYCLE_MODE env."""
|
||||
wf = _load_workflow(".gitea/workflows/modules-lifecycle.yml")
|
||||
env = wf["jobs"]["l2-lifecycle"].get("env", {})
|
||||
assert "NOVA_LIFECYCLE_MODE" in env
|
||||
assert "plan" in env["NOVA_LIFECYCLE_MODE"]
|
||||
|
||||
def test_ci_vpc_apply_skipped_in_plan_mode(self):
|
||||
"""The CI VPC apply job is skipped in plan mode (nothing is applied)."""
|
||||
wf = _load_workflow(".gitea/workflows/modules-lifecycle.yml")
|
||||
cond = wf["jobs"]["ci-vpc-apply"].get("if", "")
|
||||
assert "plan" in cond, "ci-vpc-apply must be skipped in plan mode (REQ-134)"
|
||||
|
||||
def test_lifecycle_job_runs_even_if_vpc_apply_skipped(self):
|
||||
"""The lifecycle job uses `if: always()` so it still runs (plan-only)
|
||||
even when ci-vpc-apply is skipped in plan mode."""
|
||||
wf = _load_workflow(".gitea/workflows/modules-lifecycle.yml")
|
||||
assert wf["jobs"]["lifecycle"].get("if") == "always()"
|
||||
assert wf["jobs"]["l2-lifecycle"].get("if") == "always()"
|
||||
|
||||
def test_read_ci_vpc_outputs_skipped_in_plan_mode(self):
|
||||
"""The 'Read CI VPC outputs' step is skipped in plan mode (no VPC)."""
|
||||
wf = _load_workflow(".gitea/workflows/modules-lifecycle.yml")
|
||||
steps = wf["jobs"]["lifecycle"]["steps"]
|
||||
read_step = next(s for s in steps if s.get("name") == "Read CI VPC outputs")
|
||||
cond = read_step.get("if", "")
|
||||
assert "full" in cond, "Read CI VPC outputs step must be skipped in plan mode (REQ-134)"
|
||||
Reference in New Issue
Block a user