mirror of
https://github.com/okxlin/appstore.git
synced 2026-09-23 16:01:01 +00:00
898 lines
37 KiB
Python
898 lines
37 KiB
Python
import importlib.util
|
|
import json
|
|
import os
|
|
import pathlib
|
|
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_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(["manyfold"], primary["manyfold-linuxserver"])
|
|
self.assertEqual(["netbox"], primary["netbox-linuxserver"])
|
|
self.assertEqual(
|
|
["unifi-network-application"],
|
|
primary["unifi-network-application-linuxserver"],
|
|
)
|
|
self.assertEqual(
|
|
["woodpecker-server", "woodpecker-agent"],
|
|
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("startsWith(github.ref_name, 'selfhosted-renovate/')", 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")
|
|
|
|
self.assertIn("def digest_reference(image):", workflow)
|
|
self.assertIn(
|
|
're.fullmatch(r"([^@\\s]+)@sha256:([0-9a-fA-F]{64})", image)',
|
|
workflow,
|
|
)
|
|
self.assertIn("old_digest[0] != new_digest[0]", workflow)
|
|
self.assertIn("old_digest[1] == new_digest[1]", workflow)
|
|
self.assertIn("filename_is_compose != previous_is_compose", workflow)
|
|
self.assertIn("digest_only=true", workflow)
|
|
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()
|