diff --git a/.github/renovate-controller-policy.md b/.github/renovate-controller-policy.md index e47b71b8e..75042d7bd 100644 --- a/.github/renovate-controller-policy.md +++ b/.github/renovate-controller-policy.md @@ -39,6 +39,20 @@ The workflow stores Renovate's public package lookup cache and repository extrac GitHub Actions cache entries are immutable, so each run uses a unique key and restores the newest compatible prefix. The configuration hash separates policy generations, while the broader fallback retains public package lookup data after policy changes. Monitor the reported directory size and repository cache inventory to avoid churn against GitHub's default cache quota. +Restore and save are separate steps. A failed registry scan may still save valid package cache entries for the next run, but the workflow refuses to save a cache larger than 512 MiB. + +## Cache security boundary + +- Treat cache contents as readable by contributors who can open pull requests. Never write credentials, tokens, generated environment files, or private registry responses under `/tmp/renovate-cache`. +- Keep `RENOVATE_CACHE_PRIVATE_PACKAGES` set to `false`. +- Keep cache writes limited to this scheduled/manual workflow; do not add `pull_request` or `pull_request_target` triggers. +- Keep `actions/cache` pinned to a reviewed commit SHA. +- Keep the workflow's default `GITHUB_TOKEN` read-only. Renovate writes through the dedicated `GITHUBTOKEN` secret. +- Keep a bounded job timeout so a stalled registry cannot consume a runner indefinitely. +- The restored directory contains Renovate data, not executable project scripts. Do not add cached paths to `PATH`, source files from them, or execute binaries restored from the cache. +- A cache miss or corrupt entry must degrade to a fresh lookup. It must not bypass Renovate validation, sidecar guards, app-version checks, or maintainer review. +- Keep Docker Hub credentials scoped to `docker.io`, `index.docker.io`, and `registry-1.docker.io`. Do not use a hostless Docker rule that would send them to unrelated registries. + ## Change checklist After changing either controller: diff --git a/.github/renovate-global.js b/.github/renovate-global.js index 435314864..f0ad8f374 100644 --- a/.github/renovate-global.js +++ b/.github/renovate-global.js @@ -1,6 +1,11 @@ const username = process.env.RENOVATE_DOCKERHUB_USERNAME; const password = process.env.RENOVATE_DOCKERHUB_TOKEN; const dockerConfig = require("./renovate-docker.json"); +const dockerHubHosts = [ + "docker.io", + "index.docker.io", + "registry-1.docker.io", +]; if (!username || !password) { throw new Error( @@ -16,12 +21,10 @@ module.exports = { branchPrefix: "selfhosted-renovate/", dependencyDashboard: true, dependencyDashboardTitle: "Dependency Dashboard (Docker Images)", - hostRules: [ - { - hostType: "docker", - matchHost: "docker.io", - username, - password, - }, - ], + hostRules: dockerHubHosts.map((matchHost) => ({ + hostType: "docker", + matchHost, + username, + password, + })), }; diff --git a/.github/scripts/renovate_app_version.py b/.github/scripts/renovate_app_version.py index e5d183844..61a498a7e 100644 --- a/.github/scripts/renovate_app_version.py +++ b/.github/scripts/renovate_app_version.py @@ -31,8 +31,9 @@ def services(compose: dict[str, Any]) -> dict[str, dict[str, Any]]: def image_tag(image: object) -> str: value = str(image or "").strip() - if not value or "@" in value: + if not value: return "" + value = value.split("@", 1)[0].strip() slash = value.rfind("/") colon = value.rfind(":") if colon <= slash: diff --git a/.github/scripts/test_renovate_app_version.py b/.github/scripts/test_renovate_app_version.py index 166dcb214..7f1432997 100644 --- a/.github/scripts/test_renovate_app_version.py +++ b/.github/scripts/test_renovate_app_version.py @@ -26,6 +26,19 @@ def compose(images): class RenovateAppVersionTests(unittest.TestCase): + 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_single_service_uses_the_changed_service_tag(self): module = load_module() @@ -39,6 +52,18 @@ class RenovateAppVersionTests(unittest.TestCase): 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() @@ -176,6 +201,8 @@ class RenovateAppVersionTests(unittest.TestCase): workflow = (REPO_ROOT / ".github" / "workflows" / "renovate.yml").read_text(encoding="utf-8") self.assertIn("concurrency:\n group: renovate-full-scan\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") @@ -201,7 +228,11 @@ class RenovateAppVersionTests(unittest.TestCase): ) self.assertIn( - "uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0", + "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) @@ -211,6 +242,9 @@ class RenovateAppVersionTests(unittest.TestCase): 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_and_self_hosted_renovate_have_disjoint_manager_scopes(self): hosted = json.loads((REPO_ROOT / "renovate.json").read_text(encoding="utf-8")) @@ -260,16 +294,20 @@ class RenovateAppVersionTests(unittest.TestCase): 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[0]))" + 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_rule = json.loads(result.stdout) + host_rules = json.loads(result.stdout) self.assertEqual(0, result.returncode) - self.assertEqual("docker", host_rule["hostType"]) - self.assertEqual("docker.io", host_rule["matchHost"]) - self.assertEqual("renovate-user", host_rule["username"]) - self.assertEqual("test-token", host_rule["password"]) + 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" diff --git a/.github/workflows/renovate.yml b/.github/workflows/renovate.yml index a93ccc76a..91a905277 100644 --- a/.github/workflows/renovate.yml +++ b/.github/workflows/renovate.yml @@ -13,14 +13,19 @@ concurrency: group: renovate-full-scan cancel-in-progress: true +permissions: + contents: read + jobs: renovate: runs-on: ubuntu-latest + timeout-minutes: 90 steps: - name: Checkout repository uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Restore Renovate cache - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + id: renovate-cache + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: /tmp/renovate-cache key: renovate-docker-v1-${{ runner.os }}-v43-${{ hashFiles('.github/renovate-docker.json', '.github/renovate-global.js') }}-${{ github.run_id }} @@ -55,10 +60,28 @@ jobs: RENOVATE_REPOSITORIES: ${{ github.repository }} RENOVATE_REPOSITORY_CACHE: enabled - name: Report Renovate cache size + id: cache-metrics if: always() run: | + save_cache=false + max_cache_bytes=536870912 + if [[ -d /tmp/renovate-cache ]]; then du -sh /tmp/renovate-cache + cache_bytes=$(du -sb /tmp/renovate-cache | cut -f1) + if (( cache_bytes <= max_cache_bytes )); then + save_cache=true + else + echo "::warning::Renovate cache is ${cache_bytes} bytes; skip saving because it exceeds ${max_cache_bytes} bytes." + fi else echo "Renovate cache directory was not created." fi + + echo "save=$save_cache" >> "$GITHUB_OUTPUT" + - name: Save Renovate cache + if: always() && steps.cache-metrics.outputs.save == 'true' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: /tmp/renovate-cache + key: ${{ steps.renovate-cache.outputs.cache-primary-key }}