Files
appstore/.github/scripts/test_renovate_app_version.py
T

1320 lines
59 KiB
Python

import importlib.util
import json
import os
import pathlib
import re
import subprocess
import sys
import tempfile
import unittest
import yaml
SCRIPT_PATH = pathlib.Path(__file__).with_name("renovate_app_version.py")
RETRY_SCRIPT_PATH = pathlib.Path(__file__).with_name("renovate_retry_gate.py")
REPO_ROOT = pathlib.Path(__file__).resolve().parents[2]
DOCKER_CONFIG = REPO_ROOT / ".github" / "renovate-docker.json"
HOSTED_CONFIG = REPO_ROOT / "renovate.json"
def load_module():
spec = importlib.util.spec_from_file_location("renovate_app_version", SCRIPT_PATH)
if spec is None or spec.loader is None:
raise RuntimeError(f"unable to load {SCRIPT_PATH}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def load_retry_module():
spec = importlib.util.spec_from_file_location("renovate_retry_gate", RETRY_SCRIPT_PATH)
if spec is None or spec.loader is None:
raise RuntimeError(f"unable to load {RETRY_SCRIPT_PATH}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def compose(images):
return {"services": {name: {"image": image} for name, image in images.items()}}
class RenovateAppVersionTests(unittest.TestCase):
def test_retired_apps_are_not_active_or_automerge_eligible(self):
registry = yaml.safe_load(
(REPO_ROOT / ".github" / "retired-apps.yml").read_text(encoding="utf-8")
)
retired = {item["key"] for item in registry["retiredApps"]}
whitelist = {
line.strip()
for line in (
REPO_ROOT / ".github" / "renovate-automerge-whitelist.txt"
).read_text(encoding="utf-8").splitlines()
if line.strip() and not line.lstrip().startswith("#")
}
self.assertEqual({"ais-ninja", "nezha-agent", "pandora", "reader"}, retired)
self.assertTrue(all(not (REPO_ROOT / "apps" / key).exists() for key in retired))
self.assertTrue(retired.isdisjoint(whitelist))
def test_mend_is_primary_for_actions_and_compose_updates(self):
config = json.loads(HOSTED_CONFIG.read_text(encoding="utf-8"))
self.assertEqual({"github-actions", "docker-compose"}, set(config["enabledManagers"]))
self.assertIn(
"github>okxlin/appstore//.github/renovate-docker.json",
config["extends"],
)
def test_deepseek_harness_workstation_tags_are_recognized(self):
config = json.loads(DOCKER_CONFIG.read_text(encoding="utf-8"))
rule = next(
item
for item in config["packageRules"]
if item.get("description") == "Track DeepSeek Harness workstation release tags"
)
self.assertEqual(["ghcr.io/okxlin/deepseek-harness"], rule["matchPackageNames"])
self.assertEqual(
["apps/deepseek-harness-workstation/*-workstation/docker-compose.yml"],
rule["matchFileNames"],
)
self.assertFalse(rule["ignoreUnstable"])
python_regex = rule["versioning"].removeprefix("regex:").replace("(?<", "(?P<")
matcher = re.compile(python_regex)
for tag in (
"0.2.1-rc.3-workstation",
"0.2.2-alpha.2-workstation",
"0.2.2-rc.1-workstation",
"0.2.2-workstation",
):
with self.subTest(tag=tag):
match = matcher.fullmatch(tag)
self.assertIsNotNone(match)
self.assertEqual("workstation", match.group("compatibility"))
def test_version_directory_update_preserves_old_version(self):
old_version = "0.2.1-rc.2-workstation"
new_version = "0.2.1-rc.3-workstation"
app_key = "demo"
source = pathlib.Path("apps") / app_key / old_version
target = pathlib.Path("apps") / app_key / new_version
compose = source / "docker-compose.yml"
old_image = f"ghcr.io/okxlin/deepseek-harness:{old_version}"
new_image = f"ghcr.io/okxlin/deepseek-harness:{new_version}"
with tempfile.TemporaryDirectory(prefix="renovate-version-copy-") as tmp:
repo = pathlib.Path(tmp)
(repo / compose).parent.mkdir(parents=True)
(repo / compose).write_text(
f"services:\n app:\n image: {old_image}\n", encoding="utf-8"
)
(repo / source / "data.yml").write_text("name: demo\n", encoding="utf-8")
subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
subprocess.run(
["git", "config", "user.email", "test@example.com"], cwd=repo, check=True
)
subprocess.run(
["git", "config", "user.name", "Test User"], cwd=repo, check=True
)
subprocess.run(["git", "add", "apps"], cwd=repo, check=True)
subprocess.run(["git", "commit", "-qm", "add old version"], cwd=repo, check=True)
base_ref = subprocess.run(
["git", "rev-parse", "HEAD"],
cwd=repo,
check=True,
text=True,
capture_output=True,
).stdout.strip()
changed_compose = (repo / compose).read_text(encoding="utf-8").replace(
old_image, new_image
)
(repo / compose).write_text(changed_compose, encoding="utf-8")
script = REPO_ROOT / ".github" / "workflows" / "renovate-app-version.sh"
command = ["bash", str(script), app_key, old_version, compose.as_posix(), base_ref]
first = subprocess.run(
command, cwd=repo, text=True, capture_output=True, check=False
)
self.assertEqual(0, first.returncode, first.stderr)
self.assertEqual(old_image, (repo / compose).read_text(encoding="utf-8").split("image: ", 1)[1].strip())
self.assertIn(new_image, (repo / target / "docker-compose.yml").read_text(encoding="utf-8"))
self.assertTrue((repo / target / "data.yml").is_file())
self.assertEqual(
"",
subprocess.run(
["git", "status", "--porcelain", "--", source.as_posix()],
cwd=repo,
check=True,
text=True,
capture_output=True,
).stdout,
)
(repo / compose).write_text(changed_compose, encoding="utf-8")
second = subprocess.run(
command, cwd=repo, text=True, capture_output=True, check=False
)
self.assertEqual(0, second.returncode, second.stderr)
self.assertIn("already exists", second.stdout)
self.assertEqual(old_image, (repo / compose).read_text(encoding="utf-8").split("image: ", 1)[1].strip())
def test_source_selector_chooses_newest_primary_image_and_ignores_new_directories(self):
with tempfile.TemporaryDirectory(prefix="renovate-source-selection-") as tmp:
repo = pathlib.Path(tmp)
versions = {
"apps/demo/1.9.0/docker-compose.yml": "example/demo:1.9.0",
"apps/demo/1.9.0-postgres16/docker-compose.yml": "example/demo:1.9.0",
"apps/demo/1.10.0/docker-compose.yml": "example/demo:1.10.0",
"apps/other/2.0.0/docker-compose.yml": "example/other:2.0.0",
"apps/multi/1.9.0/docker-compose.yml": {
"api": "example/multi-api:1.9.0",
"worker": "example/multi-worker:1.9.0",
},
"apps/multi/1.10.0/docker-compose.yml": {
"api": "example/multi-api:1.10.0",
"worker": "example/multi-worker:1.10.0",
},
"apps/same-image/0.30.2/docker-compose.yml": "example/same-image:0.40.1",
"apps/same-image/0.30.3/docker-compose.yml": "example/same-image:0.40.1",
}
for path, images in versions.items():
compose_path = repo / path
compose_path.parent.mkdir(parents=True, exist_ok=True)
service_images = images if isinstance(images, dict) else {"app": images}
lines = ["services:"]
for service, image in service_images.items():
lines.extend([f" {service}:", f" image: {image}"])
compose_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=repo, check=True)
subprocess.run(["git", "config", "user.name", "Test User"], cwd=repo, check=True)
subprocess.run(["git", "add", "apps"], cwd=repo, check=True)
subprocess.run(["git", "commit", "-qm", "add source versions"], cwd=repo, check=True)
base_ref = subprocess.run(
["git", "rev-parse", "HEAD"], cwd=repo, check=True, text=True, capture_output=True
).stdout.strip()
target_tags = {
"demo": "1.11.0",
"multi": "1.11.0",
"other": "2.1.0",
"same-image": "0.40.2",
}
for path, images in versions.items():
compose_path = repo / path
service_images = images if isinstance(images, dict) else {"app": images}
updated = compose_path.read_text(encoding="utf-8")
target_tag = target_tags[path.split("/")[1]]
for image in service_images.values():
updated = updated.replace(image, f"{image.rsplit(':', 1)[0]}:{target_tag}")
compose_path.write_text(updated, encoding="utf-8")
subprocess.run(["git", "add", "apps"], cwd=repo, check=True)
subprocess.run(["git", "commit", "-qm", "update image tags"], cwd=repo, check=True)
head_ref = subprocess.run(
["git", "rev-parse", "HEAD"], cwd=repo, check=True, text=True, capture_output=True
).stdout.strip()
changed_file_list = repo / "changed-files.zlist"
changed_file_list.write_bytes(
b"\0".join([
b"apps/demo/1.9.0/docker-compose.yml",
b"apps/demo/1.10.0/docker-compose.yml",
b"apps/demo/1.9.0-postgres16/docker-compose.yml",
b"apps/demo/1.11.0/docker-compose.yml", # Added in the PR, not a source.
b"apps/other/2.0.0/docker-compose.yml",
b"apps/multi/1.9.0/docker-compose.yml",
b"apps/multi/1.10.0/docker-compose.yml",
b"apps/same-image/0.30.2/docker-compose.yml",
b"apps/same-image/0.30.3/docker-compose.yml",
b"",
])
)
policy_file = repo / "policy.json"
policy_file.write_text('{"multi": ["api"]}\n', encoding="utf-8")
output_file = repo / "selected.zlist"
script = REPO_ROOT / ".github" / "scripts" / "renovate_app_version_sources.py"
result = subprocess.run(
[
sys.executable,
str(script),
"--base-ref",
base_ref,
"--head-ref",
head_ref,
"--changed-files",
str(changed_file_list),
"--policy-file",
str(policy_file),
"--output-file",
str(output_file),
],
cwd=repo,
text=True,
capture_output=True,
check=False,
)
self.assertEqual(0, result.returncode, result.stderr)
self.assertEqual(
[
"apps/demo/1.10.0/docker-compose.yml",
"apps/multi/1.10.0/docker-compose.yml",
"apps/other/2.0.0/docker-compose.yml",
"apps/same-image/0.30.3/docker-compose.yml",
],
output_file.read_bytes().decode("utf-8").rstrip("\0").split("\0"),
)
def test_source_selector_ignores_base_only_non_compose_paths(self):
with tempfile.TemporaryDirectory(prefix="renovate-source-base-only-") as tmp:
repo = pathlib.Path(tmp)
app_compose = repo / "apps" / "demo" / "1.0.0" / "docker-compose.yml"
app_compose.parent.mkdir(parents=True)
app_compose.write_text(
"services:\n app:\n image: example/demo:1.0.0\n",
encoding="utf-8",
)
subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=repo, check=True)
subprocess.run(["git", "config", "user.name", "Test User"], cwd=repo, check=True)
subprocess.run(["git", "add", "apps"], cwd=repo, check=True)
subprocess.run(["git", "commit", "-qm", "add demo source"], cwd=repo, check=True)
common_ref = subprocess.run(
["git", "rev-parse", "HEAD"], cwd=repo, check=True, text=True, capture_output=True
).stdout.strip()
unrelated = repo / "apps" / "gomodel" / "0.2.2" / ".env.sample"
unrelated.parent.mkdir(parents=True)
unrelated.write_text("EXAMPLE=value\n", encoding="utf-8")
subprocess.run(["git", "add", "apps/gomodel"], cwd=repo, check=True)
subprocess.run(["git", "commit", "-qm", "add base-only package"], cwd=repo, check=True)
base_ref = subprocess.run(
["git", "rev-parse", "HEAD"], cwd=repo, check=True, text=True, capture_output=True
).stdout.strip()
subprocess.run(["git", "checkout", "-q", "-b", "renovate", common_ref], cwd=repo, check=True)
app_compose.write_text(
"services:\n app:\n image: example/demo:1.1.0\n",
encoding="utf-8",
)
subprocess.run(["git", "add", "apps/demo"], cwd=repo, check=True)
subprocess.run(["git", "commit", "-qm", "update demo image"], cwd=repo, check=True)
head_ref = subprocess.run(
["git", "rev-parse", "HEAD"], cwd=repo, check=True, text=True, capture_output=True
).stdout.strip()
two_tree = subprocess.run(
["git", "diff", "--name-only", "-z", base_ref, head_ref],
cwd=repo, check=True, capture_output=True,
).stdout
three_dot = subprocess.run(
["git", "diff", "--name-only", "-z", f"{base_ref}...{head_ref}"],
cwd=repo, check=True, capture_output=True,
).stdout
self.assertIn(b"apps/gomodel/0.2.2/.env.sample", two_tree)
self.assertNotIn(b"apps/gomodel/0.2.2/.env.sample", three_dot)
changed_file_list = repo / "changed-files.zlist"
changed_file_list.write_bytes(two_tree)
policy_file = repo / "policy.json"
policy_file.write_text("{}\n", encoding="utf-8")
output_file = repo / "selected.zlist"
script = REPO_ROOT / ".github" / "scripts" / "renovate_app_version_sources.py"
result = subprocess.run(
[
sys.executable, str(script), "--base-ref", base_ref, "--head-ref", head_ref,
"--changed-files", str(changed_file_list), "--policy-file", str(policy_file),
"--output-file", str(output_file),
], cwd=repo, text=True, capture_output=True, check=False,
)
self.assertEqual(0, result.returncode, result.stderr)
self.assertEqual(
["apps/demo/1.0.0/docker-compose.yml"],
output_file.read_bytes().decode("utf-8").rstrip("\0").split("\0"),
)
def test_source_selector_fails_on_ambiguous_newest_version(self):
with tempfile.TemporaryDirectory(prefix="renovate-source-ambiguity-") as tmp:
repo = pathlib.Path(tmp)
versions = {
"apps/demo/1.9.0/docker-compose.yml": "example/demo:1.9.0",
"apps/demo/1.10.0/docker-compose.yml": "example/demo:1.10.0",
"apps/demo/1.10.0-postgres16/docker-compose.yml": "example/demo:1.10.0",
}
for path, image in versions.items():
compose_path = repo / path
compose_path.parent.mkdir(parents=True, exist_ok=True)
compose_path.write_text(f"services:\n app:\n image: {image}\n", encoding="utf-8")
subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=repo, check=True)
subprocess.run(["git", "config", "user.name", "Test User"], cwd=repo, check=True)
subprocess.run(["git", "add", "apps"], cwd=repo, check=True)
subprocess.run(["git", "commit", "-qm", "add source versions"], cwd=repo, check=True)
base_ref = subprocess.run(
["git", "rev-parse", "HEAD"], cwd=repo, check=True, text=True, capture_output=True
).stdout.strip()
for path, image in versions.items():
compose_path = repo / path
compose_path.write_text(
compose_path.read_text(encoding="utf-8").replace(image, "example/demo:1.11.0"),
encoding="utf-8",
)
subprocess.run(["git", "add", "apps"], cwd=repo, check=True)
subprocess.run(["git", "commit", "-qm", "update image tags"], cwd=repo, check=True)
head_ref = subprocess.run(
["git", "rev-parse", "HEAD"], cwd=repo, check=True, text=True, capture_output=True
).stdout.strip()
changed_file_list = repo / "changed-files.zlist"
changed_file_list.write_bytes(
b"apps/demo/1.10.0/docker-compose.yml\0"
b"apps/demo/1.10.0-postgres16/docker-compose.yml\0"
)
policy_file = repo / "policy.json"
policy_file.write_text("{}\n", encoding="utf-8")
output_file = repo / "selected.zlist"
script = REPO_ROOT / ".github" / "scripts" / "renovate_app_version_sources.py"
result = subprocess.run(
[
sys.executable,
str(script),
"--base-ref",
base_ref,
"--head-ref",
head_ref,
"--changed-files",
str(changed_file_list),
"--policy-file",
str(policy_file),
"--output-file",
str(output_file),
],
cwd=repo,
text=True,
capture_output=True,
check=False,
)
self.assertEqual(1, result.returncode)
self.assertIn("ambiguous newest source", result.stderr)
self.assertFalse(output_file.exists())
def test_source_selector_skips_digest_only_primary_image_updates(self):
with tempfile.TemporaryDirectory(prefix="renovate-source-digest-only-") as tmp:
repo = pathlib.Path(tmp)
compose_path = repo / "apps" / "demo" / "1.0.0" / "docker-compose.yml"
compose_path.parent.mkdir(parents=True)
compose_path.write_text(
"services:\n app:\n image: example/demo@sha256:aaaa\n",
encoding="utf-8",
)
subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=repo, check=True)
subprocess.run(["git", "config", "user.name", "Test User"], cwd=repo, check=True)
subprocess.run(["git", "add", "apps"], cwd=repo, check=True)
subprocess.run(["git", "commit", "-qm", "add digest-pinned version"], cwd=repo, check=True)
base_ref = subprocess.run(
["git", "rev-parse", "HEAD"], cwd=repo, check=True, text=True, capture_output=True
).stdout.strip()
compose_path.write_text(
"services:\n app:\n image: example/demo@sha256:bbbb\n",
encoding="utf-8",
)
subprocess.run(["git", "add", "apps"], cwd=repo, check=True)
subprocess.run(["git", "commit", "-qm", "update image digest"], cwd=repo, check=True)
head_ref = subprocess.run(
["git", "rev-parse", "HEAD"], cwd=repo, check=True, text=True, capture_output=True
).stdout.strip()
changed_file_list = repo / "changed-files.zlist"
changed_file_list.write_bytes(b"apps/demo/1.0.0/docker-compose.yml\0")
policy_file = repo / "policy.json"
policy_file.write_text("{}\n", encoding="utf-8")
output_file = repo / "selected.zlist"
script = REPO_ROOT / ".github" / "scripts" / "renovate_app_version_sources.py"
result = subprocess.run(
[
sys.executable,
str(script),
"--base-ref",
base_ref,
"--head-ref",
head_ref,
"--changed-files",
str(changed_file_list),
"--policy-file",
str(policy_file),
"--output-file",
str(output_file),
],
cwd=repo,
text=True,
capture_output=True,
check=False,
)
self.assertEqual(0, result.returncode, result.stderr)
self.assertEqual(b"", output_file.read_bytes())
def test_self_hosted_compose_scan_is_semimonthly_read_only_audit(self):
workflow = (REPO_ROOT / ".github" / "workflows" / "renovate.yml").read_text(
encoding="utf-8"
)
self.assertIn('cron: "23 3 1,15 * *"', workflow)
self.assertIn("RENOVATE_DRY_RUN: lookup", workflow)
self.assertIn("token: ${{ github.token }}", workflow)
self.assertNotIn("token: ${{ secrets.GITHUBTOKEN }}", workflow)
self.assertIn("renovate-docker-v1-${{ runner.os }}-v44-", workflow)
self.assertNotIn("renovate-docker-v1-${{ runner.os }}-v43-", workflow)
def test_retry_gate_retries_only_mature_docker_hub_429_failures(self):
module = load_retry_module()
now = module.parse_timestamp("2026-07-12T06:00:00Z")
runs = [
{
"databaseId": 10,
"status": "completed",
"conclusion": "failure",
"createdAt": "2026-07-12T04:50:00Z",
"updatedAt": "2026-07-12T05:00:00Z",
}
]
decision = module.evaluate_retry(runs, "HTTP 429 Too Many Requests from index.docker.io", now)
self.assertEqual("retry", decision["decision"])
def test_retry_gate_rejects_non_rate_limit_failures_and_retry_storms(self):
module = load_retry_module()
now = module.parse_timestamp("2026-07-12T06:00:00Z")
base_run = {
"status": "completed",
"conclusion": "failure",
"createdAt": "2026-07-12T04:50:00Z",
"updatedAt": "2026-07-12T05:00:00Z",
}
non_429 = module.evaluate_retry(
[{"databaseId": 10, **base_run}], "ordinary configuration failure", now
)
too_soon = module.evaluate_retry(
[{"databaseId": 10, **base_run, "updatedAt": "2026-07-12T05:30:00Z"}],
"Docker Hub status code 429",
now,
)
too_many = module.evaluate_retry(
[
{"databaseId": index, **base_run, "createdAt": f"2026-07-12T0{index}:00:00Z"}
for index in range(1, 7)
],
"registry-1.docker.io status code 429",
now,
)
self.assertEqual("skip", non_429["decision"])
self.assertEqual("wait", too_soon["decision"])
self.assertEqual("limit", too_many["decision"])
def test_retry_workflow_and_sidecar_branch_cleanup_are_guarded(self):
retry_workflow = (
REPO_ROOT / ".github" / "workflows" / "renovate-rate-limit-retry.yml"
).read_text(encoding="utf-8")
sidecar_workflow = (
REPO_ROOT / ".github" / "workflows" / "renovate-sidecar-guard.yml"
).read_text(encoding="utf-8")
self.assertIn("17 * * * *", retry_workflow)
self.assertIn("actions: write", retry_workflow)
self.assertIn("renovate_retry_gate.py", retry_workflow)
self.assertIn("automatic retry after Docker Hub 429", retry_workflow)
self.assertIn('[[ "$head_repo" == "$REPO" ]]', sidecar_workflow)
self.assertIn('selfhosted-renovate/*|renovate/*', sidecar_workflow)
self.assertIn("git/refs/heads/$head_ref", sidecar_workflow)
def test_image_tag_strips_digest_from_tagged_image(self):
module = load_module()
self.assertEqual(
"1.2.3",
module.image_tag("registry.example.com:5000/demo/app:v1.2.3@sha256:deadbeef"),
)
def test_image_tag_rejects_digest_only_image(self):
module = load_module()
self.assertEqual("", module.image_tag("example/demo@sha256:deadbeef"))
def test_cli_reads_base_compose_from_rename_source(self):
with tempfile.TemporaryDirectory(prefix="renovate-version-rename-") as tmp:
repo = pathlib.Path(tmp)
old_dir = repo / "apps" / "demo" / "1.0.0"
new_dir = repo / "apps" / "demo" / "1.0.1"
old_dir.mkdir(parents=True)
(old_dir / "docker-compose.yml").write_text(
yaml.safe_dump(compose({"demo": "example/demo:1.0.1"})),
encoding="utf-8",
)
policy_file = repo / "policy.json"
policy_file.write_text("{}\n", encoding="utf-8")
subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
subprocess.run(
["git", "config", "user.email", "test@example.com"],
cwd=repo,
check=True,
)
subprocess.run(
["git", "config", "user.name", "Test User"],
cwd=repo,
check=True,
)
subprocess.run(["git", "add", "apps"], cwd=repo, check=True)
subprocess.run(
["git", "commit", "-qm", "add old version"],
cwd=repo,
check=True,
)
base_ref = subprocess.run(
["git", "rev-parse", "HEAD"],
cwd=repo,
check=True,
text=True,
capture_output=True,
).stdout.strip()
subprocess.run(
["git", "mv", str(old_dir.relative_to(repo)), str(new_dir.relative_to(repo))],
cwd=repo,
check=True,
)
subprocess.run(
["git", "commit", "-qm", "rename version directory"],
cwd=repo,
check=True,
)
result = subprocess.run(
[
sys.executable,
str(SCRIPT_PATH),
"--app",
"demo",
"--old-version",
"1.0.1",
"--compose",
str((new_dir / "docker-compose.yml").relative_to(repo)),
"--base-ref",
base_ref,
"--policy-file",
str(policy_file),
],
cwd=repo,
text=True,
capture_output=True,
check=False,
)
self.assertEqual(0, result.returncode, result.stderr)
self.assertEqual("", result.stdout)
self.assertIn("skip: no service image changed", result.stderr)
def test_cli_rejects_new_compose_without_base_or_rename(self):
with tempfile.TemporaryDirectory(prefix="renovate-version-new-compose-") as tmp:
repo = pathlib.Path(tmp)
policy_file = repo / "policy.json"
policy_file.write_text("{}\n", encoding="utf-8")
subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
subprocess.run(
["git", "config", "user.email", "test@example.com"],
cwd=repo,
check=True,
)
subprocess.run(
["git", "config", "user.name", "Test User"],
cwd=repo,
check=True,
)
subprocess.run(
["git", "commit", "--allow-empty", "-qm", "empty base"],
cwd=repo,
check=True,
)
base_ref = subprocess.run(
["git", "rev-parse", "HEAD"],
cwd=repo,
check=True,
text=True,
capture_output=True,
).stdout.strip()
compose_path = repo / "apps" / "demo" / "1.0.1" / "docker-compose.yml"
compose_path.parent.mkdir(parents=True)
compose_path.write_text(
yaml.safe_dump(compose({"demo": "example/demo:1.0.1"})),
encoding="utf-8",
)
subprocess.run(["git", "add", "apps"], cwd=repo, check=True)
subprocess.run(
["git", "commit", "-qm", "add new compose"],
cwd=repo,
check=True,
)
result = subprocess.run(
[
sys.executable,
str(SCRIPT_PATH),
"--app",
"demo",
"--old-version",
"1.0.1",
"--compose",
str(compose_path.relative_to(repo)),
"--base-ref",
base_ref,
"--policy-file",
str(policy_file),
],
cwd=repo,
text=True,
capture_output=True,
check=False,
)
self.assertNotEqual(0, result.returncode)
self.assertIn("apps/demo/1.0.1/docker-compose.yml", result.stderr)
def test_historical_compose_path_must_stay_in_the_same_app(self):
module = load_module()
self.assertEqual(
"apps/demo/1.0.0/docker-compose.yml",
module.validate_historical_compose_path(
"demo", "apps/demo/1.0.0/docker-compose.yml"
),
)
with self.assertRaisesRegex(ValueError, "must be inside apps/demo"):
module.validate_historical_compose_path(
"demo", "apps/other/1.0.0/docker-compose.yml"
)
def test_single_service_uses_the_changed_service_tag(self):
module = load_module()
selection = module.select_target_version(
"demo",
compose({"app": "example/demo:1.0.0"}),
compose({"app": "example/demo:1.0.1"}),
{},
)
self.assertEqual("1.0.1", selection.version)
self.assertEqual(["app"], selection.changed_services)
def test_single_service_uses_tag_without_digest_for_directory_version(self):
module = load_module()
selection = module.select_target_version(
"demo",
compose({"app": "example/demo:1.0.0@sha256:old"}),
compose({"app": "example/demo:1.0.1@sha256:new"}),
{},
)
self.assertEqual("1.0.1", selection.version)
def test_multi_service_sidecar_only_change_does_not_rename(self):
module = load_module()
selection = module.select_target_version(
"demo",
compose({"app": "example/demo:1.0.0", "db": "postgres:17.5"}),
compose({"app": "example/demo:1.0.0", "db": "postgres:17.6"}),
{"demo": ["app"]},
)
self.assertEqual("", selection.version)
self.assertEqual("multi-service compose changed only auxiliary services", selection.reason)
def test_multi_service_without_primary_mapping_does_not_use_first_service(self):
module = load_module()
selection = module.select_target_version(
"flux-panel",
compose(
{
"mysql": "mysql:5.7",
"backend": "bqlpfy/springboot-backend:1.4.3",
"frontend": "bqlpfy/vite-frontend:1.4.3",
}
),
compose(
{
"mysql": "mysql:5.7",
"backend": "bqlpfy/springboot-backend:1.4.3",
"frontend": "bqlpfy/vite-frontend:v3.1.2",
}
),
{},
)
self.assertEqual("", selection.version)
self.assertEqual("multi-service compose has no primary service mapping", selection.reason)
def test_multi_service_primary_change_uses_primary_tag(self):
module = load_module()
selection = module.select_target_version(
"rocketchat",
compose({"rocketchat": "rocket.chat:8.6.0", "mongodb": "mongodb:8.2"}),
compose({"rocketchat": "rocket.chat:8.6.1", "mongodb": "mongodb:8.2"}),
{"rocketchat": ["rocketchat"]},
)
self.assertEqual("8.6.1", selection.version)
self.assertEqual("rocketchat", selection.service)
def test_changed_primary_services_must_have_one_coordinated_tag(self):
module = load_module()
with self.assertRaisesRegex(ValueError, "different target tags"):
module.select_target_version(
"demo",
compose({"api": "example/api:1.0.0", "web": "example/web:1.0.0"}),
compose({"api": "example/api:2.0.0", "web": "example/web:3.0.0"}),
{"demo": ["api", "web"]},
)
def test_flux_panel_is_pinned_while_upstream_is_paused(self):
config = json.loads(DOCKER_CONFIG.read_text(encoding="utf-8"))
matching = [
rule
for rule in config.get("packageRules", [])
if "apps/flux-panel/**/docker-compose.yml" in rule.get("matchFileNames", [])
]
self.assertTrue(matching)
self.assertTrue(any(rule.get("enabled") is False for rule in matching))
def test_1panel_v1_v2_tracks_are_kept_separate(self):
config = json.loads(DOCKER_CONFIG.read_text(encoding="utf-8"))
rules = config.get("packageRules", [])
def find_rule(path):
return next(
rule
for rule in rules
if path in rule.get("matchFileNames", [])
and "moelin/1panel" in rule.get("matchPackageNames", [])
)
self.assertEqual(
"/^v1\\.10\\.\\d+-lts$/",
find_rule("apps/1panel/1.*-lts/docker-compose.yml")["allowedVersions"],
)
self.assertEqual(
"/^v2\\.\\d+\\.\\d+$/",
find_rule("apps/1panel/2.*/docker-compose.yml")["allowedVersions"],
)
self.assertEqual(
"/^global-v1\\.10\\.\\d+-lts$/",
find_rule("apps/1panel/global-1.*-lts/docker-compose.yml")["allowedVersions"],
)
self.assertEqual(
"/^global-v2\\.\\d+\\.\\d+$/",
find_rule("apps/1panel/global-2.*/docker-compose.yml")["allowedVersions"],
)
for path in (
"apps/1panel/v1/docker-compose.yml",
"apps/1panel/v2/docker-compose.yml",
"apps/1panel/global-v1/docker-compose.yml",
"apps/1panel/global-v2/docker-compose.yml",
):
self.assertFalse(find_rule(path)["enabled"])
app_dir = REPO_ROOT / "apps" / "1panel"
app_data = yaml.safe_load((app_dir / "data.yml").read_text(encoding="utf-8"))
self.assertFalse(app_data["additionalProperties"]["crossVersionUpdate"])
self.assertTrue((app_dir / "1.10.34-lts").is_dir())
self.assertTrue((app_dir / "2.2.4").is_dir())
self.assertTrue((app_dir / "v1").is_dir())
self.assertTrue((app_dir / "v2").is_dir())
self.assertTrue((app_dir / "global-1.10.34-lts").is_dir())
self.assertTrue((app_dir / "global-2.2.3").is_dir())
self.assertTrue((app_dir / "global-v1").is_dir())
self.assertTrue((app_dir / "global-v2").is_dir())
self.assertFalse((app_dir / "latest").exists())
def test_multi_service_application_images_are_grouped(self):
config = json.loads(DOCKER_CONFIG.read_text(encoding="utf-8"))
groups = {rule.get("groupName"): set(rule.get("matchPackageNames", [])) for rule in config.get("packageRules", []) if rule.get("groupName")}
self.assertEqual(
{
"docker.elastic.co/elasticsearch/elasticsearch",
"docker.elastic.co/kibana/kibana",
},
groups["Kibana application images"],
)
self.assertEqual(
{
"ghcr.io/clearflask/clearflask-connect",
"ghcr.io/clearflask/clearflask-server",
},
groups["ClearFlask application images"],
)
def test_historical_tracks_and_known_sidecars_are_not_renovated(self):
config = json.loads(DOCKER_CONFIG.read_text(encoding="utf-8"))
disabled = [rule for rule in config.get("packageRules", []) if rule.get("enabled") is False]
disabled_paths = {path for rule in disabled for path in rule.get("matchFileNames", [])}
self.assertTrue(
{
"apps/geekbench/4/**",
"apps/geekbench/5/**",
"apps/headscale/0.23.0-alpha3/**",
"apps/headscale/0.26.1/**",
"apps/mysql/5.5.62/**",
}.issubset(disabled_paths)
)
self.assertTrue(
any(
"apps/*-linuxserver/**/docker-compose.yml" in rule.get("matchFileNames", [])
and {"mariadb", "mongo"}.issubset(set(rule.get("matchPackageNames", [])))
for rule in disabled
)
)
self.assertTrue(
any(
"apps/traccar/**/docker-compose.yml" in rule.get("matchFileNames", [])
and "mysql" in rule.get("matchPackageNames", [])
for rule in disabled
)
)
def test_wukongim_uses_queryable_official_images(self):
compose_text = (
REPO_ROOT / "apps" / "wukongim" / "2.0.5" / "docker-compose.yml"
).read_text(encoding="utf-8")
self.assertIn("wukongim/wukongim:v2.0.5-20240925", compose_text)
self.assertIn("prom/prometheus:v2.53.1", compose_text)
self.assertNotIn("registry.cn-shanghai.aliyuncs.com/wukongim/", compose_text)
def test_gotab_runs_from_directory_backed_persistence(self):
compose_paths = sorted(
(REPO_ROOT / "apps" / "gotab").glob("*/docker-compose.yml")
)
self.assertTrue(compose_paths)
for compose_path in compose_paths:
compose = yaml.safe_load(compose_path.read_text(encoding="utf-8"))
service = compose["services"]["gotab-server"]
self.assertEqual(["/bin/sh", "-ec"], service["entrypoint"][:2])
self.assertIn("exec ./gotab-server", service["entrypoint"][2])
self.assertIn("grep -Eq", service["entrypoint"][2])
self.assertIn("rmdir", service["entrypoint"][2])
self.assertIn("config.toml", service["entrypoint"][2])
self.assertIn("./data:/data", service["volumes"])
self.assertFalse(
any(str(volume).endswith(":/app/config.toml") for volume in service["volumes"])
)
def test_telegram_init_reads_only_config_path_from_dotenv(self):
init_paths = sorted(
(REPO_ROOT / "apps" / "telegram-linuxserver").glob("*/scripts/init.sh")
)
self.assertTrue(init_paths)
for init_path in init_paths:
with self.subTest(version=init_path.parents[1].name):
with tempfile.TemporaryDirectory(prefix="telegram-init-") as tmp:
version_dir = pathlib.Path(tmp) / "version"
scripts_dir = version_dir / "scripts"
scripts_dir.mkdir(parents=True)
copied_init = scripts_dir / "init.sh"
copied_init.write_bytes(init_path.read_bytes())
marker = version_dir / "dotenv-command-ran"
(version_dir / ".env").write_text(
"CONFIG_PATH=./data/config\n"
f"MARKER_PATH={marker}\n"
'TITLE=$(touch "$MARKER_PATH")\n',
encoding="utf-8",
)
result = subprocess.run(
["bash", str(copied_init)],
cwd=version_dir,
text=True,
capture_output=True,
check=False,
)
self.assertEqual(0, result.returncode, result.stderr)
self.assertTrue((version_dir / "data" / "config").is_dir())
self.assertFalse(marker.exists())
def test_wukongim_primary_service_and_sidecar_policy_are_explicit(self):
primary = json.loads(
(REPO_ROOT / ".github" / "renovate-primary-services.json").read_text(
encoding="utf-8"
)
)
config = json.loads(DOCKER_CONFIG.read_text(encoding="utf-8"))
self.assertEqual(["wukongim"], primary["wukongim"])
self.assertTrue(
any(
rule.get("enabled") is False
and "apps/wukongim/2.0.5/docker-compose.yml"
in rule.get("matchFileNames", [])
and "prom/prometheus" in rule.get("matchPackageNames", [])
for rule in config["packageRules"]
)
)
def test_multi_service_app_versions_follow_the_application_image(self):
primary = json.loads(
(REPO_ROOT / ".github" / "renovate-primary-services.json").read_text(
encoding="utf-8"
)
)
self.assertEqual(["flagsmith"], primary["flagsmith"])
self.assertEqual(["godoxy"], primary["godoxy"])
self.assertEqual(["kaneo"], primary["kaneo"])
self.assertEqual(["asciinema-server"], primary["asciinema-server"])
self.assertEqual(["server"], primary["bisq2-node"])
self.assertEqual(["firecrawl"], primary["firecrawl"])
self.assertEqual(["hedgedoc"], primary["hedgedoc-linuxserver"])
self.assertEqual(["manyfold"], primary["manyfold-linuxserver"])
self.assertEqual(["standalone"], primary["milvus"])
self.assertEqual(["netbox"], primary["netbox-linuxserver"])
self.assertEqual(["opencloud"], primary["opencloud"])
self.assertEqual(
["unifi-network-application"],
primary["unifi-network-application-linuxserver"],
)
self.assertEqual(["woodpecker-server"], primary["woodpecker"])
self.assertEqual(["yamtrack"], primary["yamtrack"])
self.assertEqual(["zipline"], primary["zipline"])
def test_automerge_whitelist_contains_only_single_service_apps(self):
apps = [
line.strip()
for line in (
REPO_ROOT / ".github" / "renovate-automerge-whitelist.txt"
).read_text(encoding="utf-8").splitlines()
if line.strip() and not line.lstrip().startswith("#")
]
multi_service = []
for app in apps:
for compose_path in (REPO_ROOT / "apps" / app).glob("*/docker-compose.yml"):
compose_data = yaml.safe_load(compose_path.read_text(encoding="utf-8")) or {}
services = compose_data.get("services") or {}
if len(services) != 1:
multi_service.append(f"{app}:{compose_path.parent.name}:{len(services)}")
self.assertEqual([], multi_service)
def test_self_hosted_renovate_targets_this_repository(self):
workflow = (REPO_ROOT / ".github" / "workflows" / "renovate.yml").read_text(encoding="utf-8")
self.assertIn("RENOVATE_REPOSITORIES: ${{ github.repository }}", workflow)
def test_compose_audit_is_semimonthly_or_manual_only(self):
workflow = (REPO_ROOT / ".github" / "workflows" / "renovate.yml").read_text(encoding="utf-8")
self.assertNotIn(" push:\n", workflow)
self.assertIn(' - cron: "23 3 1,15 * *"', workflow)
self.assertIn(" workflow_dispatch:", workflow)
def test_compose_audit_does_not_overlap(self):
workflow = (REPO_ROOT / ".github" / "workflows" / "renovate.yml").read_text(encoding="utf-8")
self.assertIn("concurrency:\n group: renovate-compose-audit\n cancel-in-progress: true", workflow)
self.assertIn("permissions:\n contents: read", workflow)
self.assertIn("timeout-minutes: 90", workflow)
def test_self_hosted_renovate_uses_semantic_entrypoint(self):
workflow = (REPO_ROOT / ".github" / "workflows" / "renovate.yml").read_text(encoding="utf-8")
self.assertIn("docker-cmd-file: .github/scripts/renovate-entrypoint.sh", workflow)
def test_self_hosted_renovate_requires_docker_hub_credentials(self):
workflow = (REPO_ROOT / ".github" / "workflows" / "renovate.yml").read_text(encoding="utf-8")
preflight = (
REPO_ROOT / ".github" / "workflows" / "renovate-dockerhub-preflight.yml"
).read_text(encoding="utf-8")
checker = (
REPO_ROOT / ".github" / "scripts" / "check_dockerhub_credentials.mjs"
).read_text(encoding="utf-8")
self.assertIn("configurationFile: .github/renovate-global.js", workflow)
self.assertIn(
"docker-volumes: ${{ github.workspace }}/.github/renovate-docker.json:/github-action/renovate-docker.json:ro;/tmp:/tmp",
workflow,
)
self.assertIn("RENOVATE_DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}", workflow)
self.assertIn("RENOVATE_DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}", workflow)
self.assertIn("name: Check Docker Hub credentials", workflow)
self.assertIn("node .github/scripts/check_dockerhub_credentials.mjs", workflow)
self.assertIn("workflow_dispatch:", preflight)
self.assertIn("node .github/scripts/check_dockerhub_credentials.mjs", preflight)
self.assertIn("https://auth.docker.io/token", checker)
self.assertIn("https://registry-1.docker.io/v2/library/alpine/manifests/latest", checker)
self.assertNotIn("console.log(token", checker)
self.assertNotIn("console.log(password", checker)
def test_docker_hub_preflight_validates_auth_and_manifest_without_logging_token(self):
checker = REPO_ROOT / ".github" / "scripts" / "check_dockerhub_credentials.mjs"
expression = f"""
import {{ checkDockerHubCredentials }} from {json.dumps(checker.as_uri())};
const requests = [];
const responses = [
{{ ok: true, status: 200, json: async () => ({{ token: 'bearer-secret' }}) }},
{{
ok: true,
status: 200,
headers: new Headers({{
'ratelimit-limit': '200;w=21600',
'ratelimit-remaining': '199;w=21600',
}}),
}},
];
const result = await checkDockerHubCredentials({{
username: 'test-user',
password: 'test-password',
fetchImpl: async (url, options) => {{
requests.push({{ url, authorization: options.headers.authorization }});
return responses.shift();
}},
}});
console.log(JSON.stringify({{ result, requests }}));
"""
result = subprocess.run(
["node", "--input-type=module", "-e", expression],
text=True,
capture_output=True,
check=False,
)
payload = json.loads(result.stdout)
self.assertEqual(0, result.returncode)
self.assertEqual("199;w=21600", payload["result"]["remaining"])
self.assertTrue(payload["requests"][0]["authorization"].startswith("Basic "))
self.assertEqual("Bearer bearer-secret", payload["requests"][1]["authorization"])
def test_self_hosted_renovate_uses_a_pinned_persistent_cache(self):
workflow = (REPO_ROOT / ".github" / "workflows" / "renovate.yml").read_text(
encoding="utf-8"
)
self.assertIn(
"uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0",
workflow,
)
self.assertIn(
"uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0",
workflow,
)
self.assertIn("path: /tmp/renovate-cache", workflow)
self.assertIn("${{ github.run_id }}", workflow)
self.assertIn("RENOVATE_CACHE_DIR: /tmp/renovate-cache", workflow)
self.assertIn("RENOVATE_REPOSITORY_CACHE: enabled", workflow)
self.assertIn("RENOVATE_CACHE_PRIVATE_PACKAGES: 'false'", workflow)
self.assertIn("chmod -R a+rwX /tmp/renovate-cache", workflow)
self.assertIn("du -sh /tmp/renovate-cache", workflow)
self.assertIn("max_cache_bytes=536870912", workflow)
self.assertNotIn("pull_request:\n", workflow)
self.assertNotIn("pull_request_target:\n", workflow)
def test_hosted_renovate_owns_updates_while_self_hosted_is_compose_only(self):
hosted = json.loads((REPO_ROOT / "renovate.json").read_text(encoding="utf-8"))
docker = json.loads(
(REPO_ROOT / ".github" / "renovate-docker.json").read_text(encoding="utf-8")
)
self.assertEqual(["github-actions", "docker-compose"], hosted["enabledManagers"])
self.assertEqual(["docker-compose"], docker["enabledManagers"])
self.assertTrue(
(REPO_ROOT / ".github" / "renovate-controller-policy.md").is_file()
)
def test_self_hosted_renovate_owns_a_separate_namespace(self):
config = REPO_ROOT / ".github" / "renovate-global.js"
env = os.environ.copy()
env["RENOVATE_DOCKERHUB_USERNAME"] = "renovate-user"
env["RENOVATE_DOCKERHUB_TOKEN"] = "test-token"
expression = f"const c=require({json.dumps(str(config))}); console.log(JSON.stringify(c))"
result = subprocess.run(
["node", "-e", expression], text=True, capture_output=True, env=env, check=False
)
parsed = json.loads(result.stdout)
self.assertEqual(0, result.returncode)
self.assertEqual("ignored", parsed["requireConfig"])
self.assertEqual("selfhosted-renovate/", parsed["branchPrefix"])
self.assertEqual(
"Dependency Dashboard (Docker Images)", parsed["dependencyDashboardTitle"]
)
self.assertEqual(["docker-compose"], parsed["enabledManagers"])
def test_global_config_fails_fast_without_docker_hub_credentials(self):
config = REPO_ROOT / ".github" / "renovate-global.js"
env = os.environ.copy()
env.pop("RENOVATE_DOCKERHUB_USERNAME", None)
env.pop("RENOVATE_DOCKERHUB_TOKEN", None)
result = subprocess.run(["node", "-e", f"require({json.dumps(str(config))})"], text=True, capture_output=True, env=env, check=False)
self.assertNotEqual(0, result.returncode)
self.assertIn("DOCKERHUB_USERNAME and DOCKERHUB_TOKEN", result.stderr)
def test_global_config_authenticates_docker_hub(self):
config = REPO_ROOT / ".github" / "renovate-global.js"
env = os.environ.copy()
env["RENOVATE_DOCKERHUB_USERNAME"] = "renovate-user"
env["RENOVATE_DOCKERHUB_TOKEN"] = "test-token"
expression = f"const c=require({json.dumps(str(config))}); console.log(JSON.stringify(c.hostRules))"
result = subprocess.run(["node", "-e", expression], text=True, capture_output=True, env=env, check=False)
host_rules = json.loads(result.stdout)
self.assertEqual(0, result.returncode)
self.assertEqual(
{"docker.io", "index.docker.io", "registry-1.docker.io"},
{rule["matchHost"] for rule in host_rules},
)
for host_rule in host_rules:
self.assertEqual("docker", host_rule["hostType"])
self.assertEqual("renovate-user", host_rule["username"])
self.assertEqual("test-token", host_rule["password"])
def test_self_hosted_renovate_branches_are_recognized_by_workflows(self):
workflows = REPO_ROOT / ".github" / "workflows"
app_version = (workflows / "renovate-app-version.yml").read_text(encoding="utf-8")
sidecar_guard = (workflows / "renovate-sidecar-guard.yml").read_text(encoding="utf-8")
automerge = (workflows / "renovate-automerge.yml").read_text(encoding="utf-8")
reconcile = (workflows / "renovate-automerge-reconcile.yml").read_text(encoding="utf-8")
labeling = (workflows / "label-third-party-prs.yml").read_text(encoding="utf-8")
self.assertIn("'selfhosted-renovate/'", app_version)
self.assertIn("pull_request_target:", app_version)
self.assertIn("startsWith(github.event.pull_request.head.ref", app_version)
self.assertIn("path: trusted-helper", app_version)
self.assertIn("persist-credentials: false", app_version)
self.assertIn("ref: ${{ steps.updated-files.outputs.base_ref }}", app_version)
self.assertIn("renovate_app_version_sources.py", app_version)
self.assertIn('--head-ref "$(git rev-parse HEAD)"', app_version)
self.assertIn("selected-source-files.zlist", app_version)
self.assertIn('git diff --name-only -z "$base_ref...HEAD"', app_version)
self.assertIn('git cat-file -e "$BASE_REF:$changed_file"', app_version)
self.assertIn('git diff --cached --quiet "$BASE_REF" -- "$source_dir"', app_version)
self.assertIn('git restore --source="$BASE_REF" --staged --worktree -- "$changed_dir"', app_version)
self.assertIn(
'bash "$GITHUB_WORKSPACE/trusted-helper/.github/workflows/renovate-app-version.sh"',
app_version,
)
for workflow in (sidecar_guard, automerge, labeling):
self.assertIn("github.event.pull_request.head.repo.full_name == github.repository", workflow)
self.assertIn(
"startsWith(github.event.pull_request.head.ref, 'selfhosted-renovate/')",
workflow,
)
self.assertIn("headRefName,headRepositoryOwner,isCrossRepository", reconcile)
self.assertIn('.headRefName | startswith("selfhosted-renovate/")', reconcile)
def test_automerge_allows_exact_digest_only_updates_without_version_marker(self):
workflow = (
REPO_ROOT / ".github" / "workflows" / "renovate-automerge.yml"
).read_text(encoding="utf-8")
shape_helper = (
REPO_ROOT / ".github" / "scripts" / "renovate_automerge_shape.py"
).read_text(encoding="utf-8")
self.assertIn("renovate_automerge_shape.py", workflow)
self.assertIn("Verify Renovate PR identity", workflow)
self.assertIn('.user.login == "renovate[bot]"', workflow)
self.assertIn('.user.login == "app/renovate"', workflow)
self.assertIn('.head.repo.full_name == $repo', workflow)
self.assertIn('(.head.ref | startswith("renovate/"))', workflow)
self.assertIn('(.head.ref | startswith("selfhosted-renovate/"))', workflow)
self.assertIn("--files-json /tmp/renovate-pr-files.json", workflow)
self.assertIn('echo "head_sha=$HEAD_SHA"', workflow)
self.assertIn('--match-head-commit "$VERIFIED_HEAD_SHA"', workflow)
self.assertIn("PR head changed after shape verification", workflow)
self.assertIn('gh api "repos/$REPO/git/ref/heads/localApps"', workflow)
self.assertIn("localApps moved after shape verification", workflow)
self.assertIn(
're.fullmatch(r"([^@\\s]+)@sha256:([0-9a-fA-F]{64})", image)',
shape_helper,
)
self.assertIn("old_digest[0] == new_digest[0]", shape_helper)
self.assertIn("old_digest[1] != new_digest[1]", shape_helper)
self.assertIn('echo "digest_only=$digest_only"', workflow)
self.assertIn('"digest_only": digest_only', shape_helper)
self.assertIn("steps.shape.outputs.digest_only", workflow)
self.assertIn("Digest-only image update; app-version marker is not required", workflow)
self.assertIn("Update app version [skip ci]", workflow)
def test_reconcile_delegates_all_whitelisted_open_prs_to_automerge_gate(self):
workflow = (
REPO_ROOT / ".github" / "workflows" / "renovate-automerge-reconcile.yml"
).read_text(encoding="utf-8")
self.assertNotIn('index("renovate-auto")', workflow)
self.assertNotIn("Update app version [skip ci]", workflow)
self.assertIn("gh workflow run renovate-automerge.yml", workflow)
def test_semantic_entrypoint_blocks_external_host_abort(self):
entrypoint = REPO_ROOT / ".github" / "scripts" / "renovate-entrypoint.sh"
with tempfile.TemporaryDirectory(prefix="renovate-entrypoint-test-") as tmp:
fake_renovate = pathlib.Path(tmp) / "renovate"
fake_renovate.write_text(
"#!/usr/bin/env bash\n"
"echo 'INFO: External host error causing abort - skipping'\n"
"echo '\"result\": \"external-host-error\"'\n",
encoding="utf-8",
)
fake_renovate.chmod(0o755)
env = os.environ.copy()
env["PATH"] = f"{tmp}:{env['PATH']}"
result = subprocess.run(["bash", str(entrypoint)], text=True, capture_output=True, env=env, check=False)
self.assertNotEqual(0, result.returncode)
self.assertIn("Renovate aborted because an external host failed", result.stderr)
def test_semantic_entrypoint_preserves_clean_success(self):
entrypoint = REPO_ROOT / ".github" / "scripts" / "renovate-entrypoint.sh"
with tempfile.TemporaryDirectory(prefix="renovate-entrypoint-test-") as tmp:
fake_renovate = pathlib.Path(tmp) / "renovate"
fake_renovate.write_text("#!/usr/bin/env bash\necho 'Repository finished'\n", encoding="utf-8")
fake_renovate.chmod(0o755)
env = os.environ.copy()
env["PATH"] = f"{tmp}:{env['PATH']}"
result = subprocess.run(["bash", str(entrypoint)], text=True, capture_output=True, env=env, check=False)
self.assertEqual(0, result.returncode)
def test_semantic_entrypoint_preserves_renovate_failure(self):
entrypoint = REPO_ROOT / ".github" / "scripts" / "renovate-entrypoint.sh"
with tempfile.TemporaryDirectory(prefix="renovate-entrypoint-test-") as tmp:
fake_renovate = pathlib.Path(tmp) / "renovate"
fake_renovate.write_text("#!/usr/bin/env bash\necho 'ordinary failure'\nexit 23\n", encoding="utf-8")
fake_renovate.chmod(0o755)
env = os.environ.copy()
env["PATH"] = f"{tmp}:{env['PATH']}"
result = subprocess.run(["bash", str(entrypoint)], text=True, capture_output=True, env=env, check=False)
self.assertEqual(23, result.returncode)
if __name__ == "__main__":
unittest.main()