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:
Jon Chery
2026-08-07 18:20:29 +00:00
parent b418d429b5
commit 0d2cbdb423
51 changed files with 246 additions and 914 deletions
+10 -10
View File
@@ -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:
-5
View File
@@ -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
+5 -13
View File
@@ -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
+4 -4
View File
@@ -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():
+110
View File
@@ -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}"
)
-275
View File
@@ -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)"