mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 07:13:12 +00:00
chore(cd): retire leftover Terraform app CD path
This commit is contained in:
parent
68ad7362df
commit
f42576e2db
17 changed files with 6 additions and 2193 deletions
1238
.github/workflows/deploy.yml
vendored
1238
.github/workflows/deploy.yml
vendored
File diff suppressed because it is too large
Load diff
|
|
@ -73,8 +73,6 @@ remains in the matrix. HCP plan/apply roles stay in org-baseline; this
|
||||||
repository never manages `hcptf-*` roles.
|
repository never manages `hcptf-*` roles.
|
||||||
|
|
||||||
The former G12 version-only HCP apply guard is not part of the repository gate.
|
The former G12 version-only HCP apply guard is not part of the repository gate.
|
||||||
`scripts/check-terraform-release-plan.py` remains only while
|
|
||||||
`.github/workflows/deploy.yml` is still present.
|
|
||||||
|
|
||||||
G13 fails when the same diff contains both `terraform/` and deployable
|
G13 fails when the same diff contains both `terraform/` and deployable
|
||||||
application files. Workflow, documentation, and gate-script changes may share
|
application files. Workflow, documentation, and gate-script changes may share
|
||||||
|
|
|
||||||
|
|
@ -1,328 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
"""Reject HCP Terraform plans that are not a version-only Elastic Beanstalk update.
|
|
||||||
|
|
||||||
This script may read a local plan JSON file or download plan JSON from the
|
|
||||||
documented HashiCorp endpoint:
|
|
||||||
|
|
||||||
GET https://app.terraform.io/api/v2/plans/:id/json-output
|
|
||||||
|
|
||||||
The download follows exactly one redirect, and only to archivist.terraform.io.
|
|
||||||
It does not create, apply, discard, or poll runs.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import ssl
|
|
||||||
import sys
|
|
||||||
import urllib.error
|
|
||||||
import urllib.request
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Callable
|
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
|
|
||||||
RELEASE_ADDRESS = "module.environment.aws_elastic_beanstalk_environment.this"
|
|
||||||
API_HOST = "app.terraform.io"
|
|
||||||
ARCHIVE_HOST = "archivist.terraform.io"
|
|
||||||
PLAN_ID_RE = re.compile(r"^plan-[A-Za-z0-9]+$")
|
|
||||||
VERSION_LABEL_RE = re.compile(r"^[0-9a-f]{40}-[0-9]+-[0-9]+$")
|
|
||||||
IGNORED_ACTIONS = {"no-op", "read"}
|
|
||||||
UNSAFE_ACTIONS = {"create", "delete"}
|
|
||||||
# Wholly unknown computed attributes may be ignored. Nested unknowns on any
|
|
||||||
# other attribute are treated as changes so the version-only guard fails closed.
|
|
||||||
COMPUTED_UNKNOWN_ATTRIBUTES = frozenset({"instances", "load_balancers"})
|
|
||||||
REDIRECT_STATUSES = {301, 302, 303, 307, 308}
|
|
||||||
|
|
||||||
UrlOpen = Callable[..., Any]
|
|
||||||
|
|
||||||
|
|
||||||
class _NoRedirectHandler(urllib.request.HTTPRedirectHandler):
|
|
||||||
"""Return the redirect response instead of following it."""
|
|
||||||
|
|
||||||
def http_error_301(self, req, fp, code, msg, headers):
|
|
||||||
return self._capture(req, fp, code, headers)
|
|
||||||
|
|
||||||
http_error_302 = http_error_303 = http_error_307 = http_error_308 = http_error_301
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _capture(req, fp, code, headers):
|
|
||||||
response = urllib.response.addinfourl(fp, headers, req.full_url, code=code)
|
|
||||||
response.msg = "Redirect"
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
def _urlopen_without_redirects(
|
|
||||||
*handlers: urllib.request.BaseHandler,
|
|
||||||
) -> UrlOpen:
|
|
||||||
context = ssl.create_default_context()
|
|
||||||
opener = urllib.request.build_opener(
|
|
||||||
urllib.request.HTTPSHandler(context=context),
|
|
||||||
_NoRedirectHandler,
|
|
||||||
*handlers,
|
|
||||||
)
|
|
||||||
return opener.open
|
|
||||||
|
|
||||||
|
|
||||||
def parse_args() -> argparse.Namespace:
|
|
||||||
parser = argparse.ArgumentParser()
|
|
||||||
source = parser.add_mutually_exclusive_group(required=True)
|
|
||||||
source.add_argument(
|
|
||||||
"plan_json",
|
|
||||||
type=Path,
|
|
||||||
nargs="?",
|
|
||||||
help="Local Terraform plan JSON. Mutually exclusive with --plan-id.",
|
|
||||||
)
|
|
||||||
source.add_argument(
|
|
||||||
"--plan-id",
|
|
||||||
help="HCP Terraform plan ID. Downloads JSON from app.terraform.io.",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--expected-version-label",
|
|
||||||
required=True,
|
|
||||||
help="Immutable application version the plan must apply.",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--evidence-out",
|
|
||||||
type=Path,
|
|
||||||
help="Write machine-readable proof after every assertion passes.",
|
|
||||||
)
|
|
||||||
return parser.parse_args()
|
|
||||||
|
|
||||||
|
|
||||||
def download_plan_json(
|
|
||||||
plan_id: str,
|
|
||||||
token: str,
|
|
||||||
*,
|
|
||||||
urlopen: UrlOpen | None = None,
|
|
||||||
handlers: tuple[urllib.request.BaseHandler, ...] = (),
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
if not PLAN_ID_RE.fullmatch(plan_id):
|
|
||||||
raise ValueError(f"plan id {plan_id!r} is not a valid HCP plan id")
|
|
||||||
if not token:
|
|
||||||
raise ValueError("TF_API_TOKEN is required to download plan JSON")
|
|
||||||
|
|
||||||
opener = urlopen or _urlopen_without_redirects(*handlers)
|
|
||||||
api_url = f"https://{API_HOST}/api/v2/plans/{plan_id}/json-output"
|
|
||||||
request = urllib.request.Request(
|
|
||||||
api_url,
|
|
||||||
method="GET",
|
|
||||||
headers={
|
|
||||||
"Authorization": f"Bearer {token}",
|
|
||||||
"Content-Type": "application/vnd.api+json",
|
|
||||||
"Accept": "application/json",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
first = _open_pinned(opener, request, allowed_host=API_HOST)
|
|
||||||
try:
|
|
||||||
if first.status == 204:
|
|
||||||
raise ValueError(
|
|
||||||
"plan JSON is not ready; refusing to poll the plans endpoint"
|
|
||||||
)
|
|
||||||
if first.status not in REDIRECT_STATUSES:
|
|
||||||
raise ValueError(
|
|
||||||
f"expected a redirect from {API_HOST}, got HTTP {first.status}"
|
|
||||||
)
|
|
||||||
location = first.headers.get("Location")
|
|
||||||
if not location:
|
|
||||||
raise ValueError(f"{API_HOST} redirect is missing a Location header")
|
|
||||||
archive = urlparse(location)
|
|
||||||
if archive.scheme != "https" or archive.hostname != ARCHIVE_HOST:
|
|
||||||
raise ValueError(
|
|
||||||
"refusing redirect that is not https://"
|
|
||||||
f"{ARCHIVE_HOST}/"
|
|
||||||
)
|
|
||||||
archive_request = urllib.request.Request(location, method="GET")
|
|
||||||
second = _open_pinned(opener, archive_request, allowed_host=ARCHIVE_HOST)
|
|
||||||
try:
|
|
||||||
if second.status in REDIRECT_STATUSES:
|
|
||||||
raise ValueError(
|
|
||||||
f"refusing a second redirect from {ARCHIVE_HOST}"
|
|
||||||
)
|
|
||||||
if second.status != 200:
|
|
||||||
raise ValueError(
|
|
||||||
f"plan JSON download from {ARCHIVE_HOST} returned "
|
|
||||||
f"HTTP {second.status}"
|
|
||||||
)
|
|
||||||
payload = second.read()
|
|
||||||
finally:
|
|
||||||
second.close()
|
|
||||||
finally:
|
|
||||||
first.close()
|
|
||||||
|
|
||||||
plan = json.loads(payload.decode("utf-8"))
|
|
||||||
if not isinstance(plan, dict):
|
|
||||||
raise ValueError("plan JSON must be an object")
|
|
||||||
return plan
|
|
||||||
|
|
||||||
|
|
||||||
def _open_pinned(urlopen: UrlOpen, request: urllib.request.Request, *, allowed_host: str):
|
|
||||||
parsed = urlparse(request.full_url)
|
|
||||||
if parsed.scheme != "https" or parsed.hostname != allowed_host:
|
|
||||||
raise ValueError(
|
|
||||||
f"refusing to contact {parsed.scheme}://{parsed.hostname} "
|
|
||||||
f"(pinned host is {allowed_host})"
|
|
||||||
)
|
|
||||||
context = ssl.create_default_context()
|
|
||||||
try:
|
|
||||||
return urlopen(request, context=context, timeout=30)
|
|
||||||
except TypeError:
|
|
||||||
return urlopen(request, timeout=30)
|
|
||||||
|
|
||||||
|
|
||||||
def _is_nested_unknown(value: Any) -> bool:
|
|
||||||
if isinstance(value, dict):
|
|
||||||
return any(item is True or _is_nested_unknown(item) for item in value.values())
|
|
||||||
if isinstance(value, list):
|
|
||||||
return any(item is True or _is_nested_unknown(item) for item in value)
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def changed_attributes(change: dict[str, Any]) -> set[str]:
|
|
||||||
before = change.get("before") or {}
|
|
||||||
after = change.get("after") or {}
|
|
||||||
unknown = change.get("after_unknown") or {}
|
|
||||||
keys = set(before) | set(after) | set(unknown)
|
|
||||||
changed: set[str] = set()
|
|
||||||
for key in keys:
|
|
||||||
unknown_value = unknown.get(key)
|
|
||||||
if unknown_value is True:
|
|
||||||
if key in COMPUTED_UNKNOWN_ATTRIBUTES:
|
|
||||||
continue
|
|
||||||
changed.add(key)
|
|
||||||
continue
|
|
||||||
if _is_nested_unknown(unknown_value):
|
|
||||||
changed.add(key)
|
|
||||||
continue
|
|
||||||
if before.get(key) != after.get(key):
|
|
||||||
changed.add(key)
|
|
||||||
return changed
|
|
||||||
|
|
||||||
|
|
||||||
def validate_plan(plan: dict[str, Any], expected_label: str) -> list[str]:
|
|
||||||
violations: list[str] = []
|
|
||||||
if not VERSION_LABEL_RE.fullmatch(expected_label):
|
|
||||||
violations.append(
|
|
||||||
"expected version label must be <full-sha>-<run-id>-<attempt>"
|
|
||||||
)
|
|
||||||
return violations
|
|
||||||
|
|
||||||
updates: list[dict[str, Any]] = []
|
|
||||||
for resource in plan.get("resource_changes", []):
|
|
||||||
if resource.get("mode", "managed") != "managed":
|
|
||||||
continue
|
|
||||||
address = resource.get("address", "<unknown>")
|
|
||||||
change = resource.get("change") or {}
|
|
||||||
actions = list(change.get("actions") or [])
|
|
||||||
action_set = set(actions)
|
|
||||||
if action_set <= IGNORED_ACTIONS:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if change.get("importing"):
|
|
||||||
violations.append(f"{address}: import actions are not allowed")
|
|
||||||
|
|
||||||
unsafe = sorted(action_set & UNSAFE_ACTIONS)
|
|
||||||
if unsafe:
|
|
||||||
violations.append(f"{address}: unsafe actions {unsafe}")
|
|
||||||
if "replace" in action_set or actions in (
|
|
||||||
["delete", "create"],
|
|
||||||
["create", "delete"],
|
|
||||||
):
|
|
||||||
violations.append(f"{address}: replacement is not allowed")
|
|
||||||
|
|
||||||
if "update" in action_set:
|
|
||||||
updates.append(resource)
|
|
||||||
if action_set != {"update"}:
|
|
||||||
violations.append(
|
|
||||||
f"{address}: update must be the only action, got {actions}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if address != RELEASE_ADDRESS and action_set - IGNORED_ACTIONS:
|
|
||||||
violations.append(
|
|
||||||
f"{address}: managed address is outside the version-only release"
|
|
||||||
)
|
|
||||||
|
|
||||||
if len(updates) != 1:
|
|
||||||
violations.append(
|
|
||||||
f"expected exactly one managed update, found {len(updates)}"
|
|
||||||
)
|
|
||||||
return violations
|
|
||||||
|
|
||||||
resource = updates[0]
|
|
||||||
address = resource.get("address", "<unknown>")
|
|
||||||
if address != RELEASE_ADDRESS:
|
|
||||||
violations.append(
|
|
||||||
f"{address}: expected update address {RELEASE_ADDRESS}"
|
|
||||||
)
|
|
||||||
return violations
|
|
||||||
|
|
||||||
change = resource.get("change") or {}
|
|
||||||
changed = changed_attributes(change)
|
|
||||||
if changed != {"version_label"}:
|
|
||||||
violations.append(
|
|
||||||
f"{address}: expected only version_label to change, found "
|
|
||||||
f"{sorted(changed) if changed else 'no attribute changes'}"
|
|
||||||
)
|
|
||||||
|
|
||||||
after = change.get("after") or {}
|
|
||||||
actual = after.get("version_label")
|
|
||||||
if actual != expected_label:
|
|
||||||
violations.append(
|
|
||||||
f"{address}: after version_label {actual!r} does not match "
|
|
||||||
f"{expected_label!r}"
|
|
||||||
)
|
|
||||||
|
|
||||||
unknown = change.get("after_unknown") or {}
|
|
||||||
if unknown.get("version_label") is True:
|
|
||||||
violations.append(f"{address}: version_label after value is unknown")
|
|
||||||
|
|
||||||
return violations
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
args = parse_args()
|
|
||||||
if args.plan_id:
|
|
||||||
try:
|
|
||||||
plan = download_plan_json(args.plan_id, os.environ.get("TF_API_TOKEN", ""))
|
|
||||||
except (OSError, ValueError, json.JSONDecodeError, urllib.error.URLError) as exc:
|
|
||||||
print(f"FAIL: could not download plan JSON: {exc}", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
else:
|
|
||||||
if args.plan_json is None:
|
|
||||||
print("FAIL: plan JSON path or --plan-id is required", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
plan = json.loads(args.plan_json.read_text(encoding="utf-8"))
|
|
||||||
|
|
||||||
violations = validate_plan(plan, args.expected_version_label)
|
|
||||||
if violations:
|
|
||||||
print("FAIL: Terraform plan is not a version-only release", file=sys.stderr)
|
|
||||||
for violation in violations:
|
|
||||||
print(f" - {violation}", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
if args.evidence_out:
|
|
||||||
evidence = {
|
|
||||||
"address": RELEASE_ADDRESS,
|
|
||||||
"expected_version_label": args.expected_version_label,
|
|
||||||
"managed_updates": 1,
|
|
||||||
"changed_attributes": ["version_label"],
|
|
||||||
"creates": 0,
|
|
||||||
"deletes": 0,
|
|
||||||
"replacements": 0,
|
|
||||||
}
|
|
||||||
args.evidence_out.write_text(
|
|
||||||
json.dumps(evidence, indent=2, sort_keys=True) + "\n",
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
print(
|
|
||||||
"PASS: version-only plan updates "
|
|
||||||
f"{RELEASE_ADDRESS} version_label to {args.expected_version_label}"
|
|
||||||
)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
|
|
@ -1,295 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
"""Deterministic tests for check-terraform-release-plan.py."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import importlib.util
|
|
||||||
import io
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import urllib.request
|
|
||||||
from email.message import EmailMessage
|
|
||||||
from pathlib import Path
|
|
||||||
from urllib.request import Request
|
|
||||||
|
|
||||||
SCRIPT = Path(__file__).with_name("check-terraform-release-plan.py")
|
|
||||||
FIXTURES = Path(__file__).with_name("testdata") / "terraform-release-plans"
|
|
||||||
EXPECTED_LABEL = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1"
|
|
||||||
PLAN_ID = "plan-8F5JFydVYAmtTjET"
|
|
||||||
|
|
||||||
|
|
||||||
def run_case(
|
|
||||||
fixture_name: str,
|
|
||||||
*,
|
|
||||||
expected_label: str = EXPECTED_LABEL,
|
|
||||||
) -> subprocess.CompletedProcess[str]:
|
|
||||||
return subprocess.run(
|
|
||||||
[
|
|
||||||
sys.executable,
|
|
||||||
str(SCRIPT),
|
|
||||||
str(FIXTURES / fixture_name),
|
|
||||||
"--expected-version-label",
|
|
||||||
expected_label,
|
|
||||||
],
|
|
||||||
check=False,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class FakeResponse:
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
url: str,
|
|
||||||
status: int,
|
|
||||||
headers: dict[str, str] | None = None,
|
|
||||||
body: bytes = b"",
|
|
||||||
) -> None:
|
|
||||||
self.url = url
|
|
||||||
self.status = status
|
|
||||||
self.headers = headers or {}
|
|
||||||
self._body = body
|
|
||||||
|
|
||||||
def read(self) -> bytes:
|
|
||||||
return self._body
|
|
||||||
|
|
||||||
def close(self) -> None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def load_check_module():
|
|
||||||
spec = importlib.util.spec_from_file_location("check_terraform_release_plan", SCRIPT)
|
|
||||||
module = importlib.util.module_from_spec(spec)
|
|
||||||
assert spec.loader is not None
|
|
||||||
spec.loader.exec_module(module)
|
|
||||||
return module
|
|
||||||
|
|
||||||
|
|
||||||
def test_download_pinning() -> list[str]:
|
|
||||||
module = load_check_module()
|
|
||||||
fixture = (FIXTURES / "version-only.json").read_bytes()
|
|
||||||
archive_url = "https://archivist.terraform.io/v1/object/example"
|
|
||||||
calls: list[str] = []
|
|
||||||
|
|
||||||
def fake_urlopen(request: Request, **_kwargs):
|
|
||||||
url = request.full_url
|
|
||||||
calls.append(url)
|
|
||||||
host = request.host if hasattr(request, "host") else ""
|
|
||||||
if url.startswith("https://app.terraform.io/api/v2/plans/"):
|
|
||||||
if request.get_header("Authorization") != "Bearer test-token":
|
|
||||||
raise AssertionError("API request is missing the bearer token")
|
|
||||||
if "/runs" in url or "/apply" in url or "/discard" in url:
|
|
||||||
raise AssertionError(f"download contacted a run-control path: {url}")
|
|
||||||
return FakeResponse(
|
|
||||||
url=url,
|
|
||||||
status=307,
|
|
||||||
headers={"Location": archive_url},
|
|
||||||
)
|
|
||||||
if url == archive_url:
|
|
||||||
if request.get_header("Authorization"):
|
|
||||||
raise AssertionError("archivist request must not send TF_API_TOKEN")
|
|
||||||
return FakeResponse(url=url, status=200, body=fixture)
|
|
||||||
raise AssertionError(f"unexpected URL {url} host={host}")
|
|
||||||
|
|
||||||
plan = module.download_plan_json(PLAN_ID, "test-token", urlopen=fake_urlopen)
|
|
||||||
failures: list[str] = []
|
|
||||||
if plan["resource_changes"][1]["address"] != (
|
|
||||||
"module.environment.aws_elastic_beanstalk_environment.this"
|
|
||||||
):
|
|
||||||
failures.append("download did not return the version-only fixture")
|
|
||||||
if calls != [
|
|
||||||
f"https://app.terraform.io/api/v2/plans/{PLAN_ID}/json-output",
|
|
||||||
archive_url,
|
|
||||||
]:
|
|
||||||
failures.append(f"download URLs were {calls}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
module.download_plan_json("run-not-a-plan", "test-token", urlopen=fake_urlopen)
|
|
||||||
failures.append("invalid plan id was accepted")
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def redirect_elsewhere(request: Request, **_kwargs):
|
|
||||||
return FakeResponse(
|
|
||||||
url=request.full_url,
|
|
||||||
status=307,
|
|
||||||
headers={"Location": "https://evil.example/plan.json"},
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
module.download_plan_json(PLAN_ID, "test-token", urlopen=redirect_elsewhere)
|
|
||||||
failures.append("redirect to a non-archivist host was accepted")
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def double_redirect(request: Request, **_kwargs):
|
|
||||||
if request.full_url.startswith("https://app.terraform.io/"):
|
|
||||||
return FakeResponse(
|
|
||||||
url=request.full_url,
|
|
||||||
status=307,
|
|
||||||
headers={"Location": archive_url},
|
|
||||||
)
|
|
||||||
return FakeResponse(
|
|
||||||
url=request.full_url,
|
|
||||||
status=307,
|
|
||||||
headers={"Location": "https://archivist.terraform.io/v1/object/other"},
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
module.download_plan_json(PLAN_ID, "test-token", urlopen=double_redirect)
|
|
||||||
failures.append("second archivist redirect was accepted")
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def not_ready(request: Request, **_kwargs):
|
|
||||||
return FakeResponse(url=request.full_url, status=204)
|
|
||||||
|
|
||||||
try:
|
|
||||||
module.download_plan_json(PLAN_ID, "test-token", urlopen=not_ready)
|
|
||||||
failures.append("HTTP 204 was polled or accepted")
|
|
||||||
except ValueError as exc:
|
|
||||||
if "poll" not in str(exc):
|
|
||||||
failures.append(f"HTTP 204 error was {exc}")
|
|
||||||
|
|
||||||
source = SCRIPT.read_text(encoding="utf-8")
|
|
||||||
for banned in ("/apply", "/discard", "/runs"):
|
|
||||||
if banned in source:
|
|
||||||
failures.append(f"download client contains run-control path {banned}")
|
|
||||||
|
|
||||||
return failures
|
|
||||||
|
|
||||||
|
|
||||||
def _scripted_https_handler(fixture: bytes, archive_url: str):
|
|
||||||
calls: list[str] = []
|
|
||||||
api_prefix = "https://app.terraform.io/api/v2/plans/"
|
|
||||||
|
|
||||||
class ScriptedHTTPSHandler(urllib.request.BaseHandler):
|
|
||||||
handler_order = 100
|
|
||||||
|
|
||||||
def https_open(self, req: Request):
|
|
||||||
url = req.full_url
|
|
||||||
calls.append(url)
|
|
||||||
headers = EmailMessage()
|
|
||||||
if url.startswith(api_prefix):
|
|
||||||
headers["Location"] = archive_url
|
|
||||||
body = b""
|
|
||||||
status = 307
|
|
||||||
msg = "Temporary Redirect"
|
|
||||||
elif url == archive_url:
|
|
||||||
body = fixture
|
|
||||||
status = 200
|
|
||||||
msg = "OK"
|
|
||||||
else:
|
|
||||||
raise AssertionError(f"unexpected URL {url}")
|
|
||||||
response = urllib.response.addinfourl(
|
|
||||||
io.BytesIO(body),
|
|
||||||
headers,
|
|
||||||
url,
|
|
||||||
code=status,
|
|
||||||
)
|
|
||||||
response.msg = msg
|
|
||||||
return response
|
|
||||||
|
|
||||||
return ScriptedHTTPSHandler(), calls
|
|
||||||
|
|
||||||
|
|
||||||
def test_download_standard_opener_redirect() -> list[str]:
|
|
||||||
"""urllib follows the HCP 307; the guard must still inspect that first hop."""
|
|
||||||
module = load_check_module()
|
|
||||||
fixture = (FIXTURES / "version-only.json").read_bytes()
|
|
||||||
archive_url = "https://archivist.terraform.io/v1/object/example"
|
|
||||||
api_url = f"https://app.terraform.io/api/v2/plans/{PLAN_ID}/json-output"
|
|
||||||
failures: list[str] = []
|
|
||||||
|
|
||||||
following_handler, following_calls = _scripted_https_handler(fixture, archive_url)
|
|
||||||
followed = urllib.request.build_opener(following_handler).open(api_url)
|
|
||||||
try:
|
|
||||||
if followed.status != 200:
|
|
||||||
failures.append(
|
|
||||||
f"standard opener first status was {followed.status}, not 200"
|
|
||||||
)
|
|
||||||
if following_calls != [api_url, archive_url]:
|
|
||||||
failures.append(f"standard opener URLs were {following_calls}")
|
|
||||||
finally:
|
|
||||||
followed.close()
|
|
||||||
|
|
||||||
guard_handler, guard_calls = _scripted_https_handler(fixture, archive_url)
|
|
||||||
try:
|
|
||||||
plan = module.download_plan_json(
|
|
||||||
PLAN_ID,
|
|
||||||
"test-token",
|
|
||||||
handlers=(guard_handler,),
|
|
||||||
)
|
|
||||||
except ValueError as exc:
|
|
||||||
failures.append(f"no-redirect download failed: {exc}")
|
|
||||||
return failures
|
|
||||||
|
|
||||||
if plan["resource_changes"][1]["address"] != (
|
|
||||||
"module.environment.aws_elastic_beanstalk_environment.this"
|
|
||||||
):
|
|
||||||
failures.append("no-redirect download did not return the version-only fixture")
|
|
||||||
if guard_calls != [api_url, archive_url]:
|
|
||||||
failures.append(f"no-redirect download URLs were {guard_calls}")
|
|
||||||
|
|
||||||
following_urlopen_handler, _ = _scripted_https_handler(fixture, archive_url)
|
|
||||||
following_urlopen = urllib.request.build_opener(following_urlopen_handler).open
|
|
||||||
try:
|
|
||||||
module.download_plan_json(
|
|
||||||
PLAN_ID,
|
|
||||||
"test-token",
|
|
||||||
urlopen=following_urlopen,
|
|
||||||
)
|
|
||||||
failures.append("redirect-following urlopen was accepted as the first hop")
|
|
||||||
except ValueError as exc:
|
|
||||||
if "expected a redirect" not in str(exc):
|
|
||||||
failures.append(f"following urlopen error was {exc}")
|
|
||||||
|
|
||||||
return failures
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
cases = [
|
|
||||||
("version-only", run_case("version-only.json"), 0),
|
|
||||||
("wrong-label", run_case("wrong-label.json"), 1),
|
|
||||||
("eb-setting-change", run_case("eb-setting-change.json"), 1),
|
|
||||||
("nested-unknown-tags", run_case("nested-unknown-tags.json"), 1),
|
|
||||||
("unknown-only-description", run_case("unknown-only-description.json"), 1),
|
|
||||||
("iam-update", run_case("iam-update.json"), 1),
|
|
||||||
("dns-update", run_case("dns-update.json"), 1),
|
|
||||||
("create", run_case("create.json"), 1),
|
|
||||||
("delete", run_case("delete.json"), 1),
|
|
||||||
("replace", run_case("replace.json"), 1),
|
|
||||||
("multiple-updates", run_case("multiple-updates.json"), 1),
|
|
||||||
("empty", run_case("empty.json"), 1),
|
|
||||||
]
|
|
||||||
failures = [
|
|
||||||
(name, result, expected)
|
|
||||||
for name, result, expected in cases
|
|
||||||
if result.returncode != expected
|
|
||||||
]
|
|
||||||
download_failures = test_download_pinning()
|
|
||||||
redirect_failures = test_download_standard_opener_redirect()
|
|
||||||
download_failures.extend(redirect_failures)
|
|
||||||
if failures or download_failures:
|
|
||||||
if failures:
|
|
||||||
print(
|
|
||||||
"FAIL: release plan-check cases failed: "
|
|
||||||
+ ", ".join(name for name, _, _ in failures),
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
for name, result, expected in failures:
|
|
||||||
print(
|
|
||||||
f"{name}: expected {expected}, got {result.returncode}\n"
|
|
||||||
f"{result.stdout}{result.stderr}",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
for item in download_failures:
|
|
||||||
print(f"FAIL: {item}", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
print("PASS: Terraform release plan safety checks")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
{
|
|
||||||
"resource_changes": [
|
|
||||||
{
|
|
||||||
"address": "module.environment.aws_elastic_beanstalk_environment.this",
|
|
||||||
"mode": "managed",
|
|
||||||
"type": "aws_elastic_beanstalk_environment",
|
|
||||||
"change": {
|
|
||||||
"actions": ["create"],
|
|
||||||
"before": null,
|
|
||||||
"after": {
|
|
||||||
"version_label": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
{
|
|
||||||
"resource_changes": [
|
|
||||||
{
|
|
||||||
"address": "module.environment.aws_elastic_beanstalk_environment.this",
|
|
||||||
"mode": "managed",
|
|
||||||
"type": "aws_elastic_beanstalk_environment",
|
|
||||||
"change": {
|
|
||||||
"actions": ["delete"],
|
|
||||||
"before": {
|
|
||||||
"version_label": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
|
|
||||||
},
|
|
||||||
"after": null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
{
|
|
||||||
"resource_changes": [
|
|
||||||
{
|
|
||||||
"address": "module.environment.aws_route53_record.api_alias[0]",
|
|
||||||
"mode": "managed",
|
|
||||||
"type": "aws_route53_record",
|
|
||||||
"change": {
|
|
||||||
"actions": ["update"],
|
|
||||||
"before": {
|
|
||||||
"alias": [
|
|
||||||
{
|
|
||||||
"name": "awseb--awseb-cmpb3ypfib53-1654918745.us-east-1.elb.amazonaws.com",
|
|
||||||
"zone_id": "Z35SXDOTRQ7X7K"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"after": {
|
|
||||||
"alias": [
|
|
||||||
{
|
|
||||||
"name": "shoc-backend-dev.us-east-1.elasticbeanstalk.com",
|
|
||||||
"zone_id": "Z117KPS5GTRQ2G"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
{
|
|
||||||
"resource_changes": [
|
|
||||||
{
|
|
||||||
"address": "module.environment.aws_elastic_beanstalk_environment.this",
|
|
||||||
"mode": "managed",
|
|
||||||
"type": "aws_elastic_beanstalk_environment",
|
|
||||||
"change": {
|
|
||||||
"actions": ["update"],
|
|
||||||
"before": {
|
|
||||||
"version_label": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1",
|
|
||||||
"setting": [
|
|
||||||
{
|
|
||||||
"namespace": "aws:elasticbeanstalk:application:environment",
|
|
||||||
"name": "ASPNETCORE_ENVIRONMENT",
|
|
||||||
"value": "Production"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"tags": { "env": "dev" }
|
|
||||||
},
|
|
||||||
"after": {
|
|
||||||
"version_label": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1",
|
|
||||||
"setting": [
|
|
||||||
{
|
|
||||||
"namespace": "aws:elasticbeanstalk:application:environment",
|
|
||||||
"name": "ASPNETCORE_ENVIRONMENT",
|
|
||||||
"value": "Development"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"tags": { "env": "dev" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
{
|
|
||||||
"resource_changes": []
|
|
||||||
}
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
{
|
|
||||||
"resource_changes": [
|
|
||||||
{
|
|
||||||
"address": "module.environment.aws_iam_role.github_deploy",
|
|
||||||
"mode": "managed",
|
|
||||||
"type": "aws_iam_role",
|
|
||||||
"change": {
|
|
||||||
"actions": ["update"],
|
|
||||||
"before": {
|
|
||||||
"permissions_boundary": "arn:aws:iam::396287094661:policy/shoc-backend-dev-deploy-boundary"
|
|
||||||
},
|
|
||||||
"after": {
|
|
||||||
"permissions_boundary": null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
{
|
|
||||||
"resource_changes": [
|
|
||||||
{
|
|
||||||
"address": "module.environment.aws_elastic_beanstalk_environment.this",
|
|
||||||
"mode": "managed",
|
|
||||||
"type": "aws_elastic_beanstalk_environment",
|
|
||||||
"change": {
|
|
||||||
"actions": ["update"],
|
|
||||||
"before": {
|
|
||||||
"version_label": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1",
|
|
||||||
"setting": [],
|
|
||||||
"tags": { "env": "dev" }
|
|
||||||
},
|
|
||||||
"after": {
|
|
||||||
"version_label": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1",
|
|
||||||
"setting": [],
|
|
||||||
"tags": { "env": "dev" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"address": "module.environment.aws_iam_role.github_deploy",
|
|
||||||
"mode": "managed",
|
|
||||||
"type": "aws_iam_role",
|
|
||||||
"change": {
|
|
||||||
"actions": ["update"],
|
|
||||||
"before": { "description": "old" },
|
|
||||||
"after": { "description": "new" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
{
|
|
||||||
"resource_changes": [
|
|
||||||
{
|
|
||||||
"address": "module.environment.aws_elastic_beanstalk_environment.this",
|
|
||||||
"mode": "managed",
|
|
||||||
"type": "aws_elastic_beanstalk_environment",
|
|
||||||
"change": {
|
|
||||||
"actions": ["update"],
|
|
||||||
"before": {
|
|
||||||
"version_label": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1",
|
|
||||||
"setting": [
|
|
||||||
{
|
|
||||||
"namespace": "aws:elasticbeanstalk:environment",
|
|
||||||
"name": "EnvironmentType",
|
|
||||||
"value": "LoadBalanced"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"tags": { "env": "dev", "project": "shoc" }
|
|
||||||
},
|
|
||||||
"after": {
|
|
||||||
"version_label": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1",
|
|
||||||
"setting": [
|
|
||||||
{
|
|
||||||
"namespace": "aws:elasticbeanstalk:environment",
|
|
||||||
"name": "EnvironmentType",
|
|
||||||
"value": "LoadBalanced"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"tags": { "env": "prod", "project": "shoc" }
|
|
||||||
},
|
|
||||||
"after_unknown": {
|
|
||||||
"instances": true,
|
|
||||||
"load_balancers": true,
|
|
||||||
"tags": { "env": true }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
{
|
|
||||||
"resource_changes": [
|
|
||||||
{
|
|
||||||
"address": "module.environment.aws_elastic_beanstalk_environment.this",
|
|
||||||
"mode": "managed",
|
|
||||||
"type": "aws_elastic_beanstalk_environment",
|
|
||||||
"change": {
|
|
||||||
"actions": ["delete", "create"],
|
|
||||||
"before": {
|
|
||||||
"version_label": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1",
|
|
||||||
"name": "shoc-backend-dev"
|
|
||||||
},
|
|
||||||
"after": {
|
|
||||||
"version_label": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1",
|
|
||||||
"name": "shoc-backend-dev"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
{
|
|
||||||
"resource_changes": [
|
|
||||||
{
|
|
||||||
"address": "module.environment.aws_elastic_beanstalk_environment.this",
|
|
||||||
"mode": "managed",
|
|
||||||
"type": "aws_elastic_beanstalk_environment",
|
|
||||||
"change": {
|
|
||||||
"actions": ["update"],
|
|
||||||
"before": {
|
|
||||||
"version_label": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1",
|
|
||||||
"setting": [
|
|
||||||
{
|
|
||||||
"namespace": "aws:elasticbeanstalk:environment",
|
|
||||||
"name": "EnvironmentType",
|
|
||||||
"value": "LoadBalanced"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"tags": { "env": "dev", "project": "shoc" }
|
|
||||||
},
|
|
||||||
"after": {
|
|
||||||
"version_label": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1",
|
|
||||||
"setting": [
|
|
||||||
{
|
|
||||||
"namespace": "aws:elasticbeanstalk:environment",
|
|
||||||
"name": "EnvironmentType",
|
|
||||||
"value": "LoadBalanced"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"tags": { "env": "dev", "project": "shoc" }
|
|
||||||
},
|
|
||||||
"after_unknown": {
|
|
||||||
"instances": true,
|
|
||||||
"load_balancers": true,
|
|
||||||
"description": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
{
|
|
||||||
"resource_changes": [
|
|
||||||
{
|
|
||||||
"address": "module.environment.aws_iam_role.runtime",
|
|
||||||
"mode": "managed",
|
|
||||||
"type": "aws_iam_role",
|
|
||||||
"change": {
|
|
||||||
"actions": ["no-op"],
|
|
||||||
"before": { "name": "shoc-backend-dev" },
|
|
||||||
"after": { "name": "shoc-backend-dev" }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"address": "module.environment.aws_elastic_beanstalk_environment.this",
|
|
||||||
"mode": "managed",
|
|
||||||
"type": "aws_elastic_beanstalk_environment",
|
|
||||||
"change": {
|
|
||||||
"actions": ["update"],
|
|
||||||
"before": {
|
|
||||||
"version_label": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1",
|
|
||||||
"setting": [
|
|
||||||
{
|
|
||||||
"namespace": "aws:elasticbeanstalk:environment",
|
|
||||||
"name": "EnvironmentType",
|
|
||||||
"value": "LoadBalanced"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"tags": { "env": "dev", "project": "shoc" }
|
|
||||||
},
|
|
||||||
"after": {
|
|
||||||
"version_label": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1",
|
|
||||||
"setting": [
|
|
||||||
{
|
|
||||||
"namespace": "aws:elasticbeanstalk:environment",
|
|
||||||
"name": "EnvironmentType",
|
|
||||||
"value": "LoadBalanced"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"tags": { "env": "dev", "project": "shoc" }
|
|
||||||
},
|
|
||||||
"after_unknown": {
|
|
||||||
"instances": true,
|
|
||||||
"load_balancers": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
{
|
|
||||||
"resource_changes": [
|
|
||||||
{
|
|
||||||
"address": "module.environment.aws_elastic_beanstalk_environment.this",
|
|
||||||
"mode": "managed",
|
|
||||||
"type": "aws_elastic_beanstalk_environment",
|
|
||||||
"change": {
|
|
||||||
"actions": ["update"],
|
|
||||||
"before": {
|
|
||||||
"version_label": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1",
|
|
||||||
"setting": [],
|
|
||||||
"tags": { "env": "dev" }
|
|
||||||
},
|
|
||||||
"after": {
|
|
||||||
"version_label": "cccccccccccccccccccccccccccccccccccccccc-9-9",
|
|
||||||
"setting": [],
|
|
||||||
"tags": { "env": "dev" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -143,7 +143,7 @@ workflow waits for CI, tags `vX.Y.Z-staging` from main HEAD with
|
||||||
`GITHUB_TOKEN`, then calls deploy. Do not cut prod yet; leave
|
`GITHUB_TOKEN`, then calls deploy. Do not cut prod yet; leave
|
||||||
`PROD_APP_CD_ENABLED` unset and do not create the `prod` GitHub Environment.
|
`PROD_APP_CD_ENABLED` unset and do not create the `prod` GitHub Environment.
|
||||||
Staging remains `adoption_complete=false` with a pinned API CNAME until its
|
Staging remains `adoption_complete=false` with a pinned API CNAME until its
|
||||||
import apply is proven.
|
import apply is proven after the first `vX.Y.Z-staging` GitHub-owned zip.
|
||||||
|
|
||||||
HCP workspaces stay VCS-driven with auto-apply on after cutover. Speculative
|
HCP workspaces stay VCS-driven with auto-apply on after cutover. Speculative
|
||||||
plans on every PR are the infra gate. Do not point `TFC_AWS_*` at
|
plans on every PR are the infra gate. Do not point `TFC_AWS_*` at
|
||||||
|
|
@ -153,10 +153,6 @@ Terraform changes stay in separate PRs so a merge cannot race an HCP apply
|
||||||
against an app deploy. Terraform-only merges skip `deploy.yaml`. App-only
|
against an app deploy. Terraform-only merges skip `deploy.yaml`. App-only
|
||||||
tags skip HCP when trigger patterns do not match.
|
tags skip HCP when trigger patterns do not match.
|
||||||
|
|
||||||
Until cutover, `.github/workflows/deploy.yml` still uses `TF_API_TOKEN` and
|
|
||||||
`TERRAFORM_APP_CD_ENABLED`. Keep those secrets and the version-only plan guard
|
|
||||||
on that leftover path only.
|
|
||||||
|
|
||||||
### Credentials
|
### Credentials
|
||||||
|
|
||||||
Store `DEPLOY_ROLE_ARN` as a GitHub Environment **variable** (`dev`,
|
Store `DEPLOY_ROLE_ARN` as a GitHub Environment **variable** (`dev`,
|
||||||
|
|
@ -164,23 +160,18 @@ Store `DEPLOY_ROLE_ARN` as a GitHub Environment **variable** (`dev`,
|
||||||
`repo:Sea-Haven-Industries/shoc-backend:environment:<env>` plus
|
`repo:Sea-Haven-Industries/shoc-backend:environment:<env>` plus
|
||||||
`job_workflow_ref` for `.github/workflows/deploy.yaml` at `refs/heads/main`
|
`job_workflow_ref` for `.github/workflows/deploy.yaml` at `refs/heads/main`
|
||||||
and `refs/tags/v*`. Adding another deploy workflow is a cross-family IAM
|
and `refs/tags/v*`. Adding another deploy workflow is a cross-family IAM
|
||||||
change. After cutover, drop `TF_API_TOKEN` from GitHub Environments. The new
|
change. The new CD path does not use `TF_API_TOKEN`.
|
||||||
CD path does not use it.
|
|
||||||
|
|
||||||
GitHub Environment deployment branch and tag policies are repository
|
GitHub Environment deployment branch and tag policies are repository
|
||||||
settings, not this diff. Update them before the first merge to `main` and
|
settings, not this diff. The policy matches `GITHUB_REF` of the workflow run.
|
||||||
the first staging cut. The policy matches `GITHUB_REF` of the workflow run.
|
|
||||||
Branch patterns never match tag refs; adding `v*` as a branch pattern fails
|
Branch patterns never match tag refs; adding `v*` as a branch pattern fails
|
||||||
the same way as an empty allowlist.
|
the same way as an empty allowlist.
|
||||||
|
|
||||||
1. `dev` — allow branch `main`. Keep `dev` allowed while leftover
|
1. `dev` — allow branch `main`.
|
||||||
`.github/workflows/deploy.yml` still deploys from that branch.
|
2. `staging` — **tag-type** policy matching `v*.*.*-staging` for
|
||||||
2. `staging` — add a **tag-type** policy matching `v*.*.*-staging` for
|
|
||||||
`deploy-tag.yaml`. Allow branch `main` because Actions → Release is
|
`deploy-tag.yaml`. Allow branch `main` because Actions → Release is
|
||||||
`workflow_dispatch` on `main` and then calls `deploy.yaml`
|
`workflow_dispatch` on `main` and then calls `deploy.yaml`
|
||||||
(`GITHUB_TOKEN` tag pushes do not start `deploy-tag.yaml`). Keep
|
(`GITHUB_TOKEN` tag pushes do not start `deploy-tag.yaml`).
|
||||||
`staging` allowed while leftover `deploy.yml` still deploys from that
|
|
||||||
branch.
|
|
||||||
|
|
||||||
Do not create the `prod` environment yet. Leave `PROD_APP_CD_ENABLED`
|
Do not create the `prod` environment yet. Leave `PROD_APP_CD_ENABLED`
|
||||||
unset. Until the `prod` environment exists with reviewers, do not run
|
unset. Until the `prod` environment exists with reviewers, do not run
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue