mirror of
https://github.com/okxlin/appstore.git
synced 2026-09-22 16:00:58 +00:00
Harden Renovate caching and digest versioning
Strip image digests before deriving app directory versions, while continuing to reject digest-only images. Document the cache privacy boundary, restrict the workflow token to read-only, and bound full scans to 90 minutes.
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
};
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
Reference in New Issue
Block a user