From 04355fa322bd03746abaca37c287ddd4601bc543 Mon Sep 17 00:00:00 2001 From: okxlin <61420215+okxlin@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:29:29 +0800 Subject: [PATCH] fix: allow digest-only Renovate automerge Treat exact same-reference SHA-256 digest replacements as image-only updates that do not require an app-version marker. Reconcile all whitelisted Renovate PRs through the final automerge gate so labels and marker prefilters cannot strand eligible updates. --- .github/scripts/test_renovate_app_version.py | 27 +++++++++++++ .../renovate-automerge-reconcile.yml | 18 +-------- .github/workflows/renovate-automerge.yml | 38 ++++++++++++++++++- 3 files changed, 66 insertions(+), 17 deletions(-) diff --git a/.github/scripts/test_renovate_app_version.py b/.github/scripts/test_renovate_app_version.py index 711acbb41..182f31abf 100644 --- a/.github/scripts/test_renovate_app_version.py +++ b/.github/scripts/test_renovate_app_version.py @@ -811,6 +811,33 @@ class RenovateAppVersionTests(unittest.TestCase): 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: diff --git a/.github/workflows/renovate-automerge-reconcile.yml b/.github/workflows/renovate-automerge-reconcile.yml index 78e924606..c70aaaf21 100644 --- a/.github/workflows/renovate-automerge-reconcile.yml +++ b/.github/workflows/renovate-automerge-reconcile.yml @@ -21,7 +21,7 @@ jobs: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Find eligible Renovate PRs missing automerge label + - name: Find eligible Renovate PRs id: scan env: GH_TOKEN: ${{ github.token }} @@ -36,7 +36,7 @@ jobs: mapfile -t pr_numbers < <( gh pr list --repo "$REPO" --state open --limit 100 \ - --json number,author,labels,isDraft,baseRefName,headRefName,headRepositoryOwner,isCrossRepository \ + --json number,author,isDraft,baseRefName,headRefName,headRepositoryOwner,isCrossRepository \ --jq '.[] | select( .author.login == "app/renovate" or @@ -51,7 +51,6 @@ jobs: ) | select(.isDraft | not) | select(.baseRefName == "localApps") - | select(([.labels[].name] | index("renovate-auto")) | not) | .number' ) @@ -89,19 +88,6 @@ jobs: continue fi - pull_commits=$(gh api "repos/$REPO/pulls/$pr_number/commits" --paginate --jq '.[].commit.message' || true) - base_sha=$(gh pr view "$pr_number" --repo "$REPO" --json baseRefOid --jq '.baseRefOid' || true) - head_sha=$(gh pr view "$pr_number" --repo "$REPO" --json headRefOid --jq '.headRefOid' || true) - compare_commits='' - if [ -n "$base_sha" ] && [ -n "$head_sha" ]; then - compare_commits=$(gh api "repos/$REPO/compare/$base_sha...$head_sha" --jq '.commits[].commit.message' || true) - fi - - all_commits="$(printf '%s\n%s\n' "$pull_commits" "$compare_commits")" - if ! echo "$all_commits" | grep -Fq 'Update app version [skip ci]'; then - continue - fi - eligible+=("$pr_number") done diff --git a/.github/workflows/renovate-automerge.yml b/.github/workflows/renovate-automerge.yml index 74cad8070..4ace10564 100644 --- a/.github/workflows/renovate-automerge.yml +++ b/.github/workflows/renovate-automerge.yml @@ -159,12 +159,21 @@ jobs: reject(f"compose must contain exactly one service: {path}") return data, services + def digest_reference(image): + if not isinstance(image, str): + return None + match = re.fullmatch(r"([^@\s]+)@sha256:([0-9a-fA-F]{64})", image) + if match is None: + return None + return match.group(1), match.group(2).lower() + try: with open("/tmp/renovate-pr-files.json", encoding="utf-8") as fh: files = json.load(fh) apps = set() compose_files = [] + digest_only = True for file_info in files: filename = file_info["filename"] @@ -178,8 +187,14 @@ jobs: reject(f"changed file outside apps/: {path}") apps.add(match.group(1)) - if filename.endswith("/docker-compose.yml") or (previous and previous.endswith("/docker-compose.yml")): + filename_is_compose = filename.endswith("/docker-compose.yml") + previous_is_compose = bool(previous and previous.endswith("/docker-compose.yml")) + if filename_is_compose or previous_is_compose: compose_files.append((previous or filename, filename)) + if previous and filename_is_compose != previous_is_compose: + digest_only = False + else: + digest_only = False if len(apps) != 1: reject(f"PR must affect exactly one app, got {', '.join(sorted(apps))}") @@ -205,6 +220,16 @@ jobs: if not old_image or not new_image or old_image == new_image: reject(f"compose service image must change: {new_resolved_path}") + old_digest = digest_reference(old_image) + new_digest = digest_reference(new_image) + if ( + old_digest is None + or new_digest is None + or old_digest[0] != new_digest[0] + or old_digest[1] == new_digest[1] + ): + digest_only = False + old_data.pop("version", None) new_data.pop("version", None) for service in old_services.values(): @@ -221,6 +246,11 @@ jobs: checked += 1 print(f"single-service image-only update verified for {app_name} across {checked} compose file(s)") + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output: + if digest_only: + output.write("digest_only=true\n") + else: + output.write("digest_only=false\n") except GateReject as exc: print(str(exc), file=sys.stderr) sys.exit(42) @@ -260,6 +290,12 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | + if [ "${{ steps.shape.outputs.digest_only }}" = "true" ]; then + echo "Digest-only image update; app-version marker is not required" + echo "found=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + PR_NUMBER="${{ github.event.pull_request.number || inputs.pr_number }}" REPO="${{ github.repository }}"