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) 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.3").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_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_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_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()