发布可以跟上只多了文档的测试环境头
四个生产工作流改用同一条发布修订:最近一次代码门禁成功的提交可以发布,后面只多文档不再挡住。生产环境拒绝测试验证码。63 个还没上生产的迁移写了分级、演练脚本和首发步骤。没有点任何生产按钮。 自测:发布修订 7 通过,演练安全 3 通过,tsc 0 错,lint 0 error。本机空库演练在服务角色读人物表处按设计停止(退出码 4,其他人物已确认行数为 0)。 Co-Authored-By: Grok (agent loop)
This commit is contained in:
co-authored by
Grok (agent loop)
parent
f4c53e7f9f
commit
00e6efdbfa
@@ -0,0 +1,65 @@
|
||||
"""Safety checks for the disposable migration rehearsal. No production database."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPT = ROOT / "deploy" / "rehearse-production-migrations.sh"
|
||||
GIT_BASH = Path(r"D:\Program Files\Git\bin\bash.exe")
|
||||
BASH = str(GIT_BASH) if GIT_BASH.exists() else "bash"
|
||||
|
||||
|
||||
def run(env: dict[str, str]) -> subprocess.CompletedProcess[str]:
|
||||
full = os.environ.copy()
|
||||
full.update(env)
|
||||
return subprocess.run(
|
||||
[BASH, str(SCRIPT)],
|
||||
cwd=ROOT,
|
||||
env=full,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
|
||||
|
||||
def test_refuses_a_non_production_path() -> None:
|
||||
completed = run({"DEPLOY_PATH": "/tmp/not-production"})
|
||||
assert completed.returncode == 1
|
||||
assert "unexpected production path" in completed.stderr
|
||||
|
||||
|
||||
def test_refuses_the_production_database_name() -> None:
|
||||
completed = run(
|
||||
{
|
||||
"JYOTISHA_REHEARSAL_LOCAL": "1",
|
||||
"JYOTISHA_REHEARSAL_DATABASE_URL": "postgresql://postgres:secret-value@127.0.0.1:1/jyotisha",
|
||||
}
|
||||
)
|
||||
assert completed.returncode == 1
|
||||
assert "refusing to touch the production database" in completed.stderr
|
||||
assert "secret-value" not in completed.stdout
|
||||
assert "secret-value" not in completed.stderr
|
||||
|
||||
|
||||
def test_plan_keeps_the_stop_file_out_of_the_first_batch() -> None:
|
||||
completed = run(
|
||||
{
|
||||
"JYOTISHA_REHEARSAL_LOCAL": "1",
|
||||
"JYOTISHA_REHEARSAL_PLAN": "1",
|
||||
"JYOTISHA_REHEARSAL_DATABASE_URL": "postgresql://postgres:secret-value@127.0.0.1:1/rehearse_plan",
|
||||
}
|
||||
)
|
||||
assert completed.returncode == 0, completed.stderr
|
||||
text = completed.stdout
|
||||
assert "rehearsal_plan=1" in text
|
||||
assert "database_name=rehearse_plan" in text
|
||||
assert "stop_before=20260925030000_chart_subject_trust_and_limit.sql" in text
|
||||
assert "stop_in_before=no" in text
|
||||
values = dict(line.split("=", 1) for line in text.splitlines() if "=" in line)
|
||||
assert int(values["batch_before"]) < int(values["batch_all"])
|
||||
assert "secret-value" not in completed.stdout
|
||||
assert "secret-value" not in completed.stderr
|
||||
@@ -0,0 +1,253 @@
|
||||
"""Resolver for the production release SHA. Gitea responses are local fixtures."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPT = ROOT / "deploy" / "resolve-release-revision.sh"
|
||||
GATED = ROOT / "deploy" / "gated-paths.txt"
|
||||
PYTHON = ROOT / ".venv" / "Scripts" / "python.exe"
|
||||
GIT_BASH = Path(r"D:\Program Files\Git\bin\bash.exe")
|
||||
BASH = str(GIT_BASH) if GIT_BASH.exists() else "bash"
|
||||
|
||||
|
||||
def run_git(repo: Path, *args: str) -> str:
|
||||
completed = subprocess.run(
|
||||
["git", "-c", "user.email=dev@example.com", "-c", "user.name=Release Test", *args],
|
||||
cwd=repo,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
)
|
||||
return completed.stdout.strip()
|
||||
|
||||
|
||||
def make_repo(tmp_path: Path) -> dict[str, str]:
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir()
|
||||
run_git(repo, "init")
|
||||
frontend = repo / "frontend"
|
||||
frontend.mkdir()
|
||||
(frontend / "app.txt").write_text("gated\n", encoding="utf-8")
|
||||
run_git(repo, "add", "frontend/app.txt")
|
||||
run_git(repo, "commit", "-m", "gated")
|
||||
first = run_git(repo, "rev-parse", "HEAD")
|
||||
docs = repo / "docs"
|
||||
docs.mkdir()
|
||||
(docs / "note.md").write_text("docs only\n", encoding="utf-8")
|
||||
run_git(repo, "add", "docs/note.md")
|
||||
run_git(repo, "commit", "-m", "docs")
|
||||
second = run_git(repo, "rev-parse", "HEAD")
|
||||
(frontend / "more.txt").write_text("more gated\n", encoding="utf-8")
|
||||
run_git(repo, "add", "frontend/more.txt")
|
||||
run_git(repo, "commit", "-m", "more gated")
|
||||
third = run_git(repo, "rev-parse", "HEAD")
|
||||
return {"repo": str(repo), "r": first, "h": second, "later": third}
|
||||
|
||||
|
||||
def ref_payload(branch: str, sha: str) -> list[dict]:
|
||||
return [{"ref": f"refs/heads/{branch}", "object": {"sha": sha}}]
|
||||
|
||||
|
||||
def gate_payload(sha: str, run_id: int = 42) -> dict:
|
||||
return {
|
||||
"workflow_runs": [
|
||||
{
|
||||
"id": run_id,
|
||||
"path": ".gitea/workflows/backend-quality-gate.yml",
|
||||
"head_sha": sha,
|
||||
"head_branch": "staging",
|
||||
"event": "push",
|
||||
"conclusion": "success",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def write_fixtures(directory: Path, manifest: list[dict], files: dict[str, object]) -> None:
|
||||
directory.mkdir()
|
||||
for name, payload in files.items():
|
||||
(directory / name).write_text(json.dumps(payload), encoding="utf-8")
|
||||
(directory / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
|
||||
|
||||
|
||||
def invoke(repo: str, fixture: Path, env_extra: dict[str, str]) -> subprocess.CompletedProcess[str]:
|
||||
env = os.environ.copy()
|
||||
# is-docs-only-range.sh calls python3. Windows Git bash has no python3,
|
||||
# and CreateProcess would rather hit the WSL stub than a PATH python.
|
||||
shim = fixture.parent / "python3-shim"
|
||||
shim.mkdir(exist_ok=True)
|
||||
launcher = shim / "python3"
|
||||
launcher.write_text(
|
||||
"#!/bin/bash\n" + f'exec "{PYTHON.as_posix()}" "$@"\n',
|
||||
encoding="utf-8",
|
||||
newline="\n",
|
||||
)
|
||||
env.update(
|
||||
{
|
||||
"JYOTISHA_HTTP_FIXTURE_DIR": str(fixture),
|
||||
"JYOTISHA_DOCS_ONLY_MODE": "git",
|
||||
"JYOTISHA_PYTHON": str(PYTHON),
|
||||
"GATED_PATHS_FILE": str(GATED),
|
||||
"PATH": str(shim) + os.pathsep + env.get("PATH", ""),
|
||||
}
|
||||
)
|
||||
env.update(env_extra)
|
||||
return subprocess.run(
|
||||
[BASH, str(SCRIPT)],
|
||||
cwd=repo,
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def outputs(completed: subprocess.CompletedProcess[str]) -> dict[str, str]:
|
||||
return dict(line.split("=", 1) for line in completed.stdout.splitlines() if "=" in line)
|
||||
|
||||
|
||||
def test_equal_heads_pass(tmp_path: Path) -> None:
|
||||
shas = make_repo(tmp_path)
|
||||
fixture = tmp_path / "equal"
|
||||
write_fixtures(
|
||||
fixture,
|
||||
[
|
||||
{"match": "refs/heads/main", "file": "main.json"},
|
||||
{"match": "refs/heads/staging", "file": "staging.json"},
|
||||
{"match": "event=push", "file": "gate.json"},
|
||||
],
|
||||
{
|
||||
"main.json": ref_payload("main", shas["r"]),
|
||||
"staging.json": ref_payload("staging", shas["r"]),
|
||||
"gate.json": gate_payload(shas["r"]),
|
||||
},
|
||||
)
|
||||
completed = invoke(shas["repo"], fixture, {"REQUESTED_SHA": shas["r"]})
|
||||
assert completed.returncode == 0, completed.stderr
|
||||
assert outputs(completed)["release_sha"] == shas["r"]
|
||||
assert outputs(completed)["head_sha"] == shas["r"]
|
||||
assert outputs(completed)["gate_run_id"] == "42"
|
||||
|
||||
|
||||
def test_docs_only_advance_passes(tmp_path: Path) -> None:
|
||||
shas = make_repo(tmp_path)
|
||||
fixture = tmp_path / "docs"
|
||||
write_fixtures(
|
||||
fixture,
|
||||
[
|
||||
{"match": "refs/heads/main", "file": "main.json"},
|
||||
{"match": "refs/heads/staging", "file": "staging.json"},
|
||||
{"match": "event=push", "file": "gate.json"},
|
||||
],
|
||||
{
|
||||
"main.json": ref_payload("main", shas["h"]),
|
||||
"staging.json": ref_payload("staging", shas["h"]),
|
||||
"gate.json": gate_payload(shas["r"], 7),
|
||||
},
|
||||
)
|
||||
completed = invoke(shas["repo"], fixture, {"REQUESTED_SHA": shas["r"]})
|
||||
assert completed.returncode == 0, completed.stderr
|
||||
assert outputs(completed)["release_sha"] == shas["r"]
|
||||
assert outputs(completed)["head_sha"] == shas["h"]
|
||||
assert outputs(completed)["gate_run_id"] == "7"
|
||||
|
||||
|
||||
def test_gated_advance_is_refused(tmp_path: Path) -> None:
|
||||
shas = make_repo(tmp_path)
|
||||
fixture = tmp_path / "gated"
|
||||
write_fixtures(
|
||||
fixture,
|
||||
[
|
||||
{"match": "refs/heads/main", "file": "main.json"},
|
||||
{"match": "refs/heads/staging", "file": "staging.json"},
|
||||
{"match": "event=push", "file": "gate.json"},
|
||||
],
|
||||
{
|
||||
"main.json": ref_payload("main", shas["later"]),
|
||||
"staging.json": ref_payload("staging", shas["later"]),
|
||||
"gate.json": gate_payload(shas["h"]),
|
||||
},
|
||||
)
|
||||
completed = invoke(shas["repo"], fixture, {"REQUESTED_SHA": shas["h"]})
|
||||
assert completed.returncode == 1
|
||||
assert "not a docs-only ancestor" in completed.stderr
|
||||
|
||||
|
||||
def test_non_ancestor_is_refused(tmp_path: Path) -> None:
|
||||
shas = make_repo(tmp_path)
|
||||
fixture = tmp_path / "diverge"
|
||||
write_fixtures(
|
||||
fixture,
|
||||
[
|
||||
{"match": "refs/heads/main", "file": "main.json"},
|
||||
{"match": "refs/heads/staging", "file": "staging.json"},
|
||||
{"match": "event=push", "file": "gate.json"},
|
||||
],
|
||||
{
|
||||
"main.json": ref_payload("main", shas["r"]),
|
||||
"staging.json": ref_payload("staging", shas["r"]),
|
||||
"gate.json": gate_payload(shas["later"]),
|
||||
},
|
||||
)
|
||||
completed = invoke(shas["repo"], fixture, {"REQUESTED_SHA": shas["later"]})
|
||||
assert completed.returncode == 1
|
||||
assert "not a docs-only ancestor" in completed.stderr
|
||||
|
||||
|
||||
def test_missing_gate_run_is_refused(tmp_path: Path) -> None:
|
||||
shas = make_repo(tmp_path)
|
||||
fixture = tmp_path / "nogate"
|
||||
write_fixtures(
|
||||
fixture,
|
||||
[
|
||||
{"match": "refs/heads/main", "file": "main.json"},
|
||||
{"match": "refs/heads/staging", "file": "staging.json"},
|
||||
{"match": "event=push", "file": "gate.json"},
|
||||
],
|
||||
{
|
||||
"main.json": ref_payload("main", shas["r"]),
|
||||
"staging.json": ref_payload("staging", shas["r"]),
|
||||
"gate.json": {"workflow_runs": []},
|
||||
},
|
||||
)
|
||||
completed = invoke(shas["repo"], fixture, {"REQUESTED_SHA": shas["r"]})
|
||||
assert completed.returncode == 1
|
||||
assert "no successful exact-SHA production quality gate run found" in completed.stderr
|
||||
|
||||
|
||||
def test_divergent_main_is_refused(tmp_path: Path) -> None:
|
||||
shas = make_repo(tmp_path)
|
||||
fixture = tmp_path / "split"
|
||||
write_fixtures(
|
||||
fixture,
|
||||
[
|
||||
{"match": "refs/heads/main", "file": "main.json"},
|
||||
{"match": "refs/heads/staging", "file": "staging.json"},
|
||||
],
|
||||
{
|
||||
"main.json": ref_payload("main", shas["r"]),
|
||||
"staging.json": ref_payload("staging", shas["h"]),
|
||||
},
|
||||
)
|
||||
completed = invoke(shas["repo"], fixture, {"REQUESTED_SHA": shas["r"]})
|
||||
assert completed.returncode == 1
|
||||
assert "main and staging must identify the same reviewed release" in completed.stderr
|
||||
|
||||
|
||||
def test_api_failure_exits_2(tmp_path: Path) -> None:
|
||||
shas = make_repo(tmp_path)
|
||||
fixture = tmp_path / "down"
|
||||
write_fixtures(
|
||||
fixture,
|
||||
[{"match": "refs/heads/staging", "file": "unused.json", "status": 500}],
|
||||
{"unused.json": {}},
|
||||
)
|
||||
completed = invoke(shas["repo"], fixture, {"REQUESTED_SHA": shas["r"]})
|
||||
assert completed.returncode == 2
|
||||
assert "Gitea API request failed" in completed.stderr
|
||||
Reference in New Issue
Block a user