From 91fd925d4cdcfb6503a5bca3244909dfcf18d57c Mon Sep 17 00:00:00 2001 From: imbajin Date: Mon, 5 Oct 2026 02:40:40 +0800 Subject: [PATCH 1/7] chore(ci): cancel superseded PR runs - Keep the newest run for each workflow and PR - Verify source freshness before automatic retries - Test retry decisions and document the CI policy --- .github/scripts/check-rerun.py | 92 ++++++++++++++++ .github/scripts/test_check_rerun.py | 132 +++++++++++++++++++++++ .github/workflows/check-dependencies.yml | 5 + .github/workflows/cluster-test-ci.yml | 5 + .github/workflows/codeql-analysis.yml | 5 + .github/workflows/commons-ci.yml | 5 + .github/workflows/docker-build-ci.yml | 5 + .github/workflows/helm-chart-ci.yml | 5 + .github/workflows/licence-checker.yml | 5 + .github/workflows/pd-store-ci.yml | 5 + .github/workflows/rerun-ci.yml | 37 ++++++- .github/workflows/server-ci.yml | 8 ++ docs/CONTRIBUTING.md | 12 +++ 13 files changed, 320 insertions(+), 1 deletion(-) create mode 100644 .github/scripts/check-rerun.py create mode 100644 .github/scripts/test_check_rerun.py diff --git a/.github/scripts/check-rerun.py b/.github/scripts/check-rerun.py new file mode 100644 index 0000000000..c0b501474f --- /dev/null +++ b/.github/scripts/check-rerun.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Check the failed run again immediately before an automatic retry.""" + +import argparse +import json +import os +import subprocess +from urllib.parse import quote, urlencode + + +def api(path): + result = subprocess.run( + ["gh", "api", "--method", "GET", path], check=True, + capture_output=True, text=True, + ) + return json.loads(result.stdout) + + +def decide(repository, run_id, expected_attempt, max_reruns, fetch=api): + run = fetch(f"repos/{repository}/actions/runs/{run_id}") + if (run.get("status") != "completed" or run.get("conclusion") != "failure" + or run.get("run_attempt") != expected_attempt): + return "skip", "source run changed or is no longer a completed failure" + if expected_attempt > max_reruns: + return "skip", "retry limit reached" + sha = run.get("head_sha") + if not sha: + return "skip", "missing head SHA" + if run.get("event") == "pull_request": + def local(candidate): + base_repository = candidate.get("base", {}).get("repo", {}) + return (base_repository.get("full_name") == repository + or base_repository.get("url") == f"https://api.github.com/repos/{repository}") + + candidates = [pr for pr in run.get("pull_requests", []) if local(pr)] + if not candidates: + candidates = [pr for pr in fetch( + f"repos/{repository}/commits/{quote(sha, safe='')}/pulls") if local(pr)] + for candidate in candidates: + number = candidate.get("number") + if not isinstance(number, int): + continue + pr = fetch(f"repos/{repository}/pulls/{number}") + if (pr.get("base", {}).get("repo", {}).get("full_name") == repository + and pr.get("state") == "open" and pr.get("head", {}).get("sha") == sha): + return "rerun", "open PR head unchanged" + return "skip", "no open PR in this repository with the failed head" + if run.get("event") == "push": + branch = run.get("head_branch") + if not branch: + return "skip", "missing push branch" + # Read from the workflow repository, never a similarly named fork branch. + commits = fetch(f"repos/{repository}/commits?" + urlencode({"sha": branch, "per_page": 1})) + if commits and commits[0].get("sha") == sha: + return "rerun", "push branch head unchanged" + return "skip", "push branch head moved" + return "skip", "unsupported source event" + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repository", required=True) + parser.add_argument("--run-id", required=True, type=int) + parser.add_argument("--run-attempt", required=True, type=int) + parser.add_argument("--max-reruns", required=True, type=int) + args = parser.parse_args() + try: + action, reason = decide(args.repository, args.run_id, args.run_attempt, args.max_reruns) + except (subprocess.SubprocessError, OSError, ValueError, KeyError, TypeError, AttributeError): + action, reason = "skip", "unable to verify current run and source metadata" + print(f"{action}: {reason}") + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output: + output.write(f"action={action}\nreason={reason}\n") + + +if __name__ == "__main__": + main() diff --git a/.github/scripts/test_check_rerun.py b/.github/scripts/test_check_rerun.py new file mode 100644 index 0000000000..06676ef73f --- /dev/null +++ b/.github/scripts/test_check_rerun.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Behavioral tests for retry freshness, without credentials or network.""" +import copy +import importlib.util +from pathlib import Path +import unittest +import os +import subprocess +import tempfile +from unittest.mock import patch + +spec = importlib.util.spec_from_file_location("check_rerun", Path(__file__).with_name("check-rerun.py")) +checker = importlib.util.module_from_spec(spec) +spec.loader.exec_module(checker) + + +class FreshnessTest(unittest.TestCase): + def setUp(self): + self.repository = "hugegraph/hugegraph-toolchain" + self.run = {"status": "completed", "conclusion": "failure", "run_attempt": 1, + "event": "pull_request", "head_sha": "old", "head_branch": "feature"} + self.pr = {"number": 7, "state": "open", "head": {"sha": "old"}, + "base": {"repo": {"full_name": self.repository}}} + self.run["pull_requests"] = [copy.deepcopy(self.pr)] + self.calls = [] + + def fetch(self, path): + self.calls.append(path) + if "/actions/runs/" in path: + return self.run + if "/commits/" in path: + return [self.pr] + if "/commits?" in path: + return [{"sha": self.pr["head"]["sha"]}] + if "/pulls/" in path: + return self.pr + raise AssertionError(path) + + def decide(self): + return checker.decide(self.repository, 42, 1, 2, self.fetch)[0] + + def test_current_open_pr(self): + self.assertEqual("rerun", self.decide()) + + def test_slim_workflow_run_pr_association(self): + self.run["pull_requests"] = [{"number": 7, "base": {"repo": {"id": 1, "name": "hugegraph-toolchain", + "url": f"https://api.github.com/repos/{self.repository}"}}}] + self.assertEqual("rerun", self.decide()) + + def test_api_failure_fails_closed(self): + with tempfile.TemporaryDirectory() as directory: + output = Path(directory) / "output" + with patch.dict(os.environ, {"GITHUB_OUTPUT": str(output)}), patch( + "sys.argv", ["check-rerun.py", "--repository", self.repository, + "--run-id", "42", "--run-attempt", "1", "--max-reruns", "2"]), patch.object( + checker, "decide", side_effect=subprocess.CalledProcessError(1, "gh")): + checker.main() + self.assertIn("action=skip\n", output.read_text()) + + def test_closed_pr(self): + self.pr["state"] = "closed" + self.assertEqual("skip", self.decide()) + + def test_updated_pr(self): + self.pr["head"]["sha"] = "new" + self.assertEqual("skip", self.decide()) + + def test_empty_association_falls_back_to_commit(self): + self.run["pull_requests"] = [] + self.assertEqual("rerun", self.decide()) + self.assertTrue(any("/commits/old/pulls" in p for p in self.calls)) + + def test_other_repository_pr_is_ignored(self): + self.run["pull_requests"][0]["base"]["repo"]["full_name"] = "apache/hugegraph-toolchain" + self.assertEqual("rerun", self.decide()) + self.assertTrue(any("/commits/old/pulls" in p for p in self.calls)) + + def test_foreign_pr_fallback_still_rejects_foreign_pr(self): + self.run["pull_requests"][0]["base"]["repo"]["full_name"] = "apache/hugegraph-toolchain" + self.pr["base"]["repo"]["full_name"] = "apache/hugegraph-toolchain" + self.assertEqual("skip", self.decide()) + + def test_changed_attempt(self): + self.run["run_attempt"] = 2 + self.assertEqual("skip", self.decide()) + + def test_current_run_must_remain_failed_and_completed(self): + for key, value in (("status", "in_progress"), ("conclusion", "success"), + ("conclusion", "cancelled")): + with self.subTest(key=key, value=value): + old = self.run[key] + self.run[key] = value + self.assertEqual("skip", self.decide()) + self.run[key] = old + + def test_retry_limit(self): + self.run["run_attempt"] = 3 + self.assertEqual("skip", checker.decide(self.repository, 42, 3, 2, self.fetch)[0]) + + def test_push_checks_repository_and_encodes_branch(self): + self.run.update(event="push", head_branch="release/test") + self.assertEqual("rerun", self.decide()) + self.assertIn(f"repos/{self.repository}/commits?sha=release%2Ftest&per_page=1", self.calls) + self.pr["head"]["sha"] = "new" + self.assertEqual("skip", self.decide()) + + def test_unsupported_event(self): + self.run["event"] = "workflow_dispatch" + self.assertEqual("skip", self.decide()) + + def test_missing_metadata(self): + self.run["head_sha"] = None + self.assertEqual("skip", self.decide()) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/check-dependencies.yml b/.github/workflows/check-dependencies.yml index fc6de5b560..ef9ff04fc3 100644 --- a/.github/workflows/check-dependencies.yml +++ b/.github/workflows/check-dependencies.yml @@ -22,6 +22,11 @@ on: permissions: contents: read +# Keep the newest PR run; preserve independent push, release and manual runs. +concurrency: + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: dependency-check: runs-on: ubuntu-latest diff --git a/.github/workflows/cluster-test-ci.yml b/.github/workflows/cluster-test-ci.yml index 3edcfcb874..cacd82eeb5 100644 --- a/.github/workflows/cluster-test-ci.yml +++ b/.github/workflows/cluster-test-ci.yml @@ -27,6 +27,11 @@ on: - '!**/AGENTS.md' - '!**/CLAUDE.md' +# Keep the newest PR run; preserve independent push, release and manual runs. +concurrency: + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: cluster-test: runs-on: ubuntu-latest diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 23f1807dc2..5b017919cf 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -9,6 +9,11 @@ on: schedule: - cron: '33 0 * * 5' +# Keep the newest PR run; preserve independent push, release and manual runs. +concurrency: + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: analyze: env: diff --git a/.github/workflows/commons-ci.yml b/.github/workflows/commons-ci.yml index 0e7d4d8d95..29f32aa6ad 100644 --- a/.github/workflows/commons-ci.yml +++ b/.github/workflows/commons-ci.yml @@ -9,6 +9,11 @@ on: - 'test-*' pull_request: +# Keep the newest PR run; preserve independent push, release and manual runs. +concurrency: + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: java-runtime: uses: ./.github/workflows/java-runtime.yml diff --git a/.github/workflows/docker-build-ci.yml b/.github/workflows/docker-build-ci.yml index 368791dbea..98d20674f1 100644 --- a/.github/workflows/docker-build-ci.yml +++ b/.github/workflows/docker-build-ci.yml @@ -44,6 +44,11 @@ on: - '!**/AGENTS.md' - '!**/CLAUDE.md' +# Keep the newest PR run; preserve independent push, release and manual runs. +concurrency: + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: docker-bake-check: runs-on: ubuntu-latest diff --git a/.github/workflows/helm-chart-ci.yml b/.github/workflows/helm-chart-ci.yml index 1ca124731f..e78dd03655 100644 --- a/.github/workflows/helm-chart-ci.yml +++ b/.github/workflows/helm-chart-ci.yml @@ -27,6 +27,11 @@ on: permissions: contents: read +# Keep the newest PR run; preserve independent push, release and manual runs. +concurrency: + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: lint-and-render: runs-on: ubuntu-latest diff --git a/.github/workflows/licence-checker.yml b/.github/workflows/licence-checker.yml index 3d36ad4597..491ccf57b5 100644 --- a/.github/workflows/licence-checker.yml +++ b/.github/workflows/licence-checker.yml @@ -7,6 +7,11 @@ on: - 'release-*' pull_request: +# Keep the newest PR run; preserve independent push, release and manual runs. +concurrency: + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: check-license: runs-on: ubuntu-latest diff --git a/.github/workflows/pd-store-ci.yml b/.github/workflows/pd-store-ci.yml index 108e83d3a5..3810b4e2dc 100644 --- a/.github/workflows/pd-store-ci.yml +++ b/.github/workflows/pd-store-ci.yml @@ -28,6 +28,11 @@ on: - '!**/CLAUDE.md' # TODO: consider merge to one ci.yml file +# Keep the newest PR run; preserve independent push, release and manual runs. +concurrency: + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: rocksdb-compatibility: permissions: diff --git a/.github/workflows/rerun-ci.yml b/.github/workflows/rerun-ci.yml index 3fdd4c0f56..7cfe2e0607 100644 --- a/.github/workflows/rerun-ci.yml +++ b/.github/workflows/rerun-ci.yml @@ -71,13 +71,48 @@ jobs: permissions: actions: write contents: read + pull-requests: read runs-on: ubuntu-latest steps: + # workflow_run is privileged: only load code from the default-branch commit. + - name: Checkout trusted retry checker + uses: actions/checkout@v7 + with: + ref: ${{ github.sha }} + persist-credentials: false + sparse-checkout: .github/scripts + + - name: Check source freshness before waiting + id: initial-freshness + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + RUN_ID: ${{ github.event.workflow_run.id }} + RUN_ATTEMPT: ${{ github.event.workflow_run.run_attempt }} + run: | + python3 .github/scripts/check-rerun.py \ + --repository "$GH_REPO" --run-id "$RUN_ID" \ + --run-attempt "$RUN_ATTEMPT" --max-reruns "$MAX_RERUNS" + - name: Wait before rerun + if: steps.initial-freshness.outputs.action == 'rerun' + run: sleep "$RETRY_DELAY_SECONDS" + + - name: Check source freshness after waiting + id: freshness + if: steps.initial-freshness.outputs.action == 'rerun' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + RUN_ID: ${{ github.event.workflow_run.id }} + RUN_ATTEMPT: ${{ github.event.workflow_run.run_attempt }} run: | - sleep "$RETRY_DELAY_SECONDS" + python3 .github/scripts/check-rerun.py \ + --repository "$GH_REPO" --run-id "$RUN_ID" \ + --run-attempt "$RUN_ATTEMPT" --max-reruns "$MAX_RERUNS" - name: Rerun failed jobs + if: steps.freshness.outputs.action == 'rerun' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_REPO: ${{ github.repository }} diff --git a/.github/workflows/server-ci.yml b/.github/workflows/server-ci.yml index ef47e8f294..6aa038d1f3 100644 --- a/.github/workflows/server-ci.yml +++ b/.github/workflows/server-ci.yml @@ -8,6 +8,11 @@ on: - 'test-*' pull_request: +# Keep the newest PR run; preserve independent push, release and manual runs. +concurrency: + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: java-runtime: uses: ./.github/workflows/java-runtime.yml @@ -24,6 +29,9 @@ jobs: with: persist-credentials: false + - name: Test CI retry freshness checks + run: python3 -m unittest discover -s .github/scripts -p 'test_check_rerun.py' + - name: Run wait-storage.sh peer failover tests run: hugegraph-server/hugegraph-dist/src/assembly/travis/test-wait-storage.sh diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index bfa9d7e66f..aa1a8c6849 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -148,6 +148,18 @@ Please click on "Details" to find the problem if any check does not pass. If there are checks not passed or changes requested, then continue to modify the code and push again. +### CI runs and automatic retries + +PR test workflows keep only the newest run for each workflow and PR. Updating a PR +cancels its older queued or running checks; push, release and manual runs remain independent. +Reusable workflows inherit cancellation from their caller and do not add a competing group. + +Automatic retries are limited to failed jobs within the existing retry budget. After the +retry delay, CI checks that the run is still failed and that the PR is open at the same +head commit, or that the pushed branch still points to that commit. Obsolete runs and +runs whose state cannot be verified are skipped. This policy applies to YAML workflows; +GitHub-managed Copilot and automatic dependency submission runs are configured separately. + ## 6. Further changes after review If we have not passed the review, don't be discouraged. Usually a commit needs to be reviewed several times before being accepted! Please follow the review comments and make further changes. From fc0bd90f4426565bb1e0db6c1756a9281eac0172 Mon Sep 17 00:00:00 2001 From: imbajin Date: Tue, 6 Oct 2026 00:36:04 +0800 Subject: [PATCH 2/7] chore(ci): gate affected module tests - select related Linux tests and verify reusable results - defer compatibility and security checks until core tests pass - isolate retries and migrate required check names - shorten the contribution guide and document CI policy --- .asf.yaml | 6 +- .github/scripts/check-rerun.py | 65 +++- .github/scripts/ci-policy.py | 443 +++++++++++++++++++++++ .github/scripts/test_check_rerun.py | 56 ++- .github/scripts/test_ci_policy.py | 327 +++++++++++++++++ .github/workflows/check-dependencies.yml | 24 +- .github/workflows/cluster-test-ci.yml | 33 +- .github/workflows/codeql-analysis.yml | 15 +- .github/workflows/commons-ci.yml | 13 +- .github/workflows/docker-build-ci.yml | 35 +- .github/workflows/helm-chart-ci.yml | 12 +- .github/workflows/pd-store-ci.yml | 46 +-- .github/workflows/server-ci.yml | 212 +++++++++-- docs/CONTRIBUTING.md | 249 +++---------- docs/ci.md | 54 +++ 15 files changed, 1209 insertions(+), 381 deletions(-) create mode 100644 .github/scripts/ci-policy.py create mode 100644 .github/scripts/test_ci_policy.py create mode 100644 docs/ci.md diff --git a/.asf.yaml b/.asf.yaml index 39b9cb32bf..086a7bf515 100644 --- a/.asf.yaml +++ b/.asf.yaml @@ -38,12 +38,8 @@ github: strict: false # contexts are the names of checks that must pass (now only enable the basic check) contexts: - - Analyze (java) - - CodeQL - check-license - # Require the fixed memory result gate, independent of the runtime JDK list. - # Commons CI continues to run without blocking merges. - - Server memory tests + - affected-module-tests required_pull_request_reviews: dismiss_stale_reviews: true require_code_owner_reviews: false diff --git a/.github/scripts/check-rerun.py b/.github/scripts/check-rerun.py index c0b501474f..24fff7b72c 100644 --- a/.github/scripts/check-rerun.py +++ b/.github/scripts/check-rerun.py @@ -17,18 +17,41 @@ """Check the failed run again immediately before an automatic retry.""" import argparse +import io import json +import zipfile import os import subprocess from urllib.parse import quote, urlencode -def api(path): +def api(path, binary=False): result = subprocess.run( ["gh", "api", "--method", "GET", path], check=True, - capture_output=True, text=True, + capture_output=True, timeout=30, ) - return json.loads(result.stdout) + return result.stdout if binary else json.loads(result.stdout) + + +def tested_base_matches(repository, run_id, pr, sha, fetch): + artifacts = fetch(f"repos/{repository}/actions/runs/{run_id}/artifacts?per_page=100")["artifacts"] + candidates = [a for a in artifacts if a.get("name") == "ci-plan" and not a.get("expired")] + for artifact in candidates: + payload = fetch(f"repos/{repository}/actions/artifacts/{artifact['id']}/zip", binary=True) + with zipfile.ZipFile(io.BytesIO(payload)) as archive: + info = archive.getinfo("plan.json") + if info.file_size > 1048576: + continue + plan = json.loads(archive.read(info)) + base = pr.get("base", {}).get("sha") + if (plan.get("repository") != repository or plan.get("pr") != pr.get("number") + or plan.get("head") != sha or not base or plan.get("base") != base + or not plan.get("testedMergeSHA")): + continue + merge = fetch(f"repos/{repository}/git/commits/{plan['testedMergeSHA']}") + if [parent["sha"] for parent in merge.get("parents", [])] == [base, sha]: + return True + return False def decide(repository, run_id, expected_attempt, max_reruns, fetch=api): @@ -47,18 +70,50 @@ def local(candidate): return (base_repository.get("full_name") == repository or base_repository.get("url") == f"https://api.github.com/repos/{repository}") + source = run.get("head_repository") or {} + source_owner = (source.get("owner") or {}).get("login") + source_name = source.get("full_name") + source_branch = run.get("head_branch") + if not source_owner or not source_name or not source_branch: + return "skip", "missing source repository or branch metadata" + def source_matches(pr): + head = pr.get("head") or {} + repo = head.get("repo") or {} + return (head.get("ref") == source_branch and repo.get("full_name") == source_name + and (repo.get("owner") or {}).get("login") == source_owner) + fallback_owner = None candidates = [pr for pr in run.get("pull_requests", []) if local(pr)] if not candidates: candidates = [pr for pr in fetch( f"repos/{repository}/commits/{quote(sha, safe='')}/pulls") if local(pr)] + if not candidates: + owner = run.get("head_repository", {}).get("owner", {}).get("login") + branch = run.get("head_branch") + if owner and branch: + fallback_owner = owner + # Fork runs can have no commit/PR association in either endpoint. + # Use the qualified fork owner, never the branch name alone. + for page in range(1, 101): + batch = fetch(f"repos/{repository}/pulls?" + urlencode({ + "state": "open", "head": f"{owner}:{branch}", + "per_page": 100, "page": page})) + candidates.extend(pr for pr in batch if local(pr) + and pr.get("head", {}).get("ref") == branch + and pr.get("head", {}).get("repo", {}).get("owner", {}).get("login") == owner) + if len(batch) < 100: + break for candidate in candidates: number = candidate.get("number") if not isinstance(number, int): continue pr = fetch(f"repos/{repository}/pulls/{number}") + if not source_matches(pr): + continue if (pr.get("base", {}).get("repo", {}).get("full_name") == repository and pr.get("state") == "open" and pr.get("head", {}).get("sha") == sha): - return "rerun", "open PR head unchanged" + if tested_base_matches(repository, run_id, pr, sha, fetch): + return "rerun", "open PR head and tested base unchanged" + return "skip", "tested base moved or immutable CI plan proof unavailable" return "skip", "no open PR in this repository with the failed head" if run.get("event") == "push": branch = run.get("head_branch") @@ -81,7 +136,7 @@ def main(): args = parser.parse_args() try: action, reason = decide(args.repository, args.run_id, args.run_attempt, args.max_reruns) - except (subprocess.SubprocessError, OSError, ValueError, KeyError, TypeError, AttributeError): + except (subprocess.SubprocessError, OSError, ValueError, KeyError, TypeError, AttributeError, zipfile.BadZipFile): action, reason = "skip", "unable to verify current run and source metadata" print(f"{action}: {reason}") with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output: diff --git a/.github/scripts/ci-policy.py b/.github/scripts/ci-policy.py new file mode 100644 index 0000000000..821b8f1ab7 --- /dev/null +++ b/.github/scripts/ci-policy.py @@ -0,0 +1,443 @@ +#!/usr/bin/env python3 +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Conservative CI selection and GitHub-verified test receipt reuse.""" + +import argparse +import hashlib +import io +import json +import os +from pathlib import Path +import subprocess +import zipfile +from urllib.parse import urlencode + +MODULES = { + "server": ["server", "commons", "struct", "pd", "store", "hstore", "cluster", "docker", "helm", "dependency_license"], + "toolchain": ["client", "loader", "tools", "spark", "hubble", "go"], +} +PREFIXES = { + "server": {"hugegraph-server/": "server", "hugegraph-commons/": "commons", + "hugegraph-struct/": "struct", "hugegraph-pd/": "pd", "hugegraph-store/": "store", + "hugegraph-cluster-test/": "cluster", "docker/": "docker", "helm/": "helm"}, + "toolchain": {"hugegraph-client/": "client", "hugegraph-client-go/": "go", + "hugegraph-loader/": "loader", "hugegraph-tools/": "tools", + "hugegraph-spark-connector/": "spark", "hugegraph-hubble/": "hubble"}, +} +WORKFLOWS = { + "server": {"server-ci.yml": ["server"], "commons-ci.yml": ["commons"], + "pd-store-ci.yml": ["pd", "store", "hstore"], "cluster-test-ci.yml": ["cluster"], + "docker-build-ci.yml": ["docker"], "helm-chart-ci.yml": ["helm"], + "codeql-analysis.yml": [], "riscv64-ci.yml": ["server"], "check-dependencies.yml": ["dependency_license"]}, + "toolchain": {"client-ci.yml": ["client"], "client-go-ci.yml": ["go"], + "loader-ci.yml": ["loader"], "tools-ci.yml": ["tools"], + "spark-connector-ci.yml": ["spark"], "hubble-ci.yml": ["hubble"], + "codeql-analysis.yml": []}, +} +DEPENDENTS = { + "server": {"commons": ["server", "pd", "store", "hstore", "cluster"], + "struct": ["server", "pd", "store", "hstore", "cluster"], + "server": ["cluster"], "pd": ["store", "hstore", "cluster"], + "store": ["hstore", "cluster"], "hstore": ["cluster"]}, + "toolchain": {"client": ["loader", "tools", "spark", "hubble"], "loader": ["hubble"]}, +} + + +def git(*args): + return subprocess.check_output(["git", *args], text=True).strip() + + +def api(path, binary=False): + result = subprocess.run(["gh", "api", "--method", "GET", path], check=True, capture_output=True, timeout=30) + return result.stdout if binary else json.loads(result.stdout) + + +def documentation(path, mode="100644"): + """Only prose and static documentation assets; never source or config.""" + if mode != "100644": + return False + p = Path(path) + prose_names = {"README.md", "README_CN.md", "README_ZH.md", "AGENTS.md"} + module_roots = {prefix.rstrip("/") for mapping in PREFIXES.values() for prefix in mapping} + module_roots.update("hugegraph-server/" + module for module in [ + "hugegraph-api", "hugegraph-core", "hugegraph-dist", "hugegraph-example", + "hugegraph-hbase", "hugegraph-hstore", "hugegraph-rocksdb", "hugegraph-test"]) + if p.name in prose_names and (str(p.parent) == "." or str(p.parent) in module_roots): + return True + return path.startswith("docs/") and p.suffix.lower() in {".md", ".rst", ".txt", ".png", ".jpg", ".jpeg", ".svg"} + + +def unsafe_documentation(ref, paths): + entries = subprocess.check_output(["git", "ls-tree", "-rz", "--full-tree", ref]).split(b"\0") + for entry in entries: + if not entry: + continue + metadata, name = entry.split(b"\t", 1) + path = name.decode("utf-8", "surrogateescape") + if path in paths and documentation(path) and metadata.split(b" ", 1)[0] != b"100644": + return True + return False + + +def select(project, paths): + selected = set() + for path in paths: + if documentation(path): + continue + if project == "server" and (Path(path).name == "pom.xml" or path.startswith("install-dist/")): + selected.add("dependency_license") + if path.startswith(".github/workflows/") and Path(path).name in WORKFLOWS[project]: + selected.update(WORKFLOWS[project][Path(path).name]) + continue + if path.endswith(".proto"): + return set(MODULES[project]) + if project == "server": + if path.startswith("hugegraph-server/hugegraph-hstore/"): + selected.add("hstore") + continue + if path.startswith("hugegraph-server/hugegraph-dist/"): + selected.update(["server", "pd", "store", "hstore", "docker", "cluster"]) + continue + if path.startswith(("hugegraph-server/hugegraph-core/", "hugegraph-server/hugegraph-api/", + "hugegraph-server/hugegraph-test/")): + selected.update(["server", "hstore"]) + continue + module = next((value for prefix, value in PREFIXES[project].items() if path.startswith(prefix)), None) + if module is None or path.endswith(".proto"): + return set(MODULES[project]) + selected.add(module) + if project == "server" and ("Dockerfile" in Path(path).name or "docker-entrypoint" in path): + selected.add("docker") + changed = True + while changed: + before = set(selected) + for module in before: + selected.update(DEPENDENTS[project].get(module, [])) + changed = before != selected + return selected + + +def fingerprint(ref, policy=False): + """Hash blob identities, including paths; exclude only the strict prose whitelist.""" + entries = subprocess.check_output(["git", "ls-tree", "-rz", "--full-tree", ref]).split(b"\0") + digest = hashlib.sha256() + for entry in sorted(entries, key=lambda e: e.split(b"\t", 1)[-1]): + if not entry: + continue + metadata, name = entry.split(b"\t", 1) + path = name.decode("utf-8", "surrogateescape") + if policy: + if not (path.startswith(".github/") or path == ".asf.yaml"): + continue + elif documentation(path, metadata.split(b" ", 1)[0].decode()): + continue + digest.update(metadata + b"\t" + name + b"\0") + return digest.hexdigest() + + +def remote_fingerprint(repository, sha, policy_only=False, fetch=api): + tree = fetch(f"repos/{repository}/git/trees/{sha}?recursive=1") + if tree.get("truncated"): + raise ValueError("truncated proof tree") + digest = hashlib.sha256() + for entry in sorted(tree["tree"], key=lambda item: item["path"].encode()): + path = entry["path"] + if entry["type"] == "tree": + continue + if policy_only: + if not (path.startswith(".github/") or path == ".asf.yaml"): + continue + elif documentation(path, entry["mode"]): + continue + digest.update((entry["mode"] + " " + entry["type"] + " " + entry["sha"] + + "\t" + path + "\0").encode()) + return digest.hexdigest() + + +def proof_pr(plan, run, fetch): + associations = run.get("pull_requests", []) + if any(pr.get("number") == plan["pr"] for pr in associations): + return True + owner = run.get("head_repository", {}).get("owner", {}).get("login") + branch = run.get("head_branch") + if not owner or not branch: + return False + candidates = pages(f"repos/{plan['repository']}/pulls?" + urlencode({ + "state": "open", "head": f"{owner}:{branch}"}), fetch) + return any(pr.get("number") == plan["pr"] + and pr.get("base", {}).get("repo", {}).get("full_name") == plan["repository"] + and pr.get("head", {}).get("ref") == branch + and pr.get("head", {}).get("repo", {}).get("full_name") == plan["source"] + for pr in candidates) + + +def suites(project, selected): + if project == "toolchain": + return sorted(selected) + result = [] + if "server" in selected: + result.extend(["server_memory", "server_rocksdb"]) + for module in ["commons", "cluster", "docker", "helm", "dependency_license"]: + if module in selected: + result.append(module) + if selected.intersection({"pd", "store", "hstore", "struct"}): + result.append("pd_store") + return sorted(result) + + +def pages(path, fetch=api): + result = [] + for page in range(1, 11): + value = fetch(path + ("&" if "?" in path else "?") + urlencode({"per_page": 100, "page": page})) + batch = value["jobs"] if isinstance(value, dict) else value + result.extend(batch) + if len(batch) < 100: + return result + raise ValueError("API pagination exceeded verification bound") + + +def successful_jobs(repository, run_id, suite, fetch=api): + jobs = pages(f"repos/{repository}/actions/runs/{run_id}/jobs", fetch) + matching = [job for job in jobs if job.get("name") == suite or job.get("name", "").startswith((suite + " / ", suite + " ("))] + if not matching or any(job.get("conclusion") != "success" for job in matching): + raise ValueError("missing or unsuccessful suite jobs") + return sorted(job["id"] for job in matching) + + +def validate_receipt(plan, receipt, fetch=api): + for key in ["schema", "repository", "project", "pr", "source", "branch", "base", "inputFingerprint", "policyFingerprint"]: + if receipt.get(key) != plan.get(key): + raise ValueError("receipt provenance differs: " + key) + proof = receipt.get("proofs", {}) + required = plan["expected"] + if not required or not set(required).issubset(proof): + raise ValueError("receipt lacks selected suites") + verified = {} + for suite in required: + item = proof[suite] + run = fetch(f"repos/{plan['repository']}/actions/runs/{int(item['runID'])}") + if (run.get("status") != "completed" + or run.get("event") != "pull_request" or run.get("head_sha") != item.get("head") + or run.get("workflow_id") != plan["workflowID"] + or run.get("head_repository", {}).get("full_name") != plan["source"] + or run.get("head_branch") != plan["branch"]): + raise ValueError("proof run is not a successful trusted workflow") + successful_jobs(plan["repository"], item["runID"], "affected-module-tests", fetch) + if not proof_pr(plan, run, fetch): + raise ValueError("proof run lacks matching PR association") + merge = fetch(f"repos/{plan['repository']}/git/commits/{item['testedMergeSHA']}") + if [parent["sha"] for parent in merge.get("parents", [])] != [plan["base"], item["head"]]: + raise ValueError("tested merge parents differ") + actual_input = remote_fingerprint(plan["repository"], item["testedMergeSHA"], fetch=fetch) + if plan["project"] == "toolchain": + context = json.dumps(plan.get("externalInputs", {}), sort_keys=True, separators=(",", ":")) + actual_input = hashlib.sha256((actual_input + context).encode()).hexdigest() + if (actual_input != plan["inputFingerprint"] or remote_fingerprint( + plan["repository"], item["testedMergeSHA"], policy_only=True, fetch=fetch) + != plan["policyFingerprint"]): + raise ValueError("actual tested inputs differ from claimed receipt") + ids = successful_jobs(plan["repository"], item["runID"], suite, fetch) + if ids != item.get("jobIDs"): + raise ValueError("actual job evidence differs") + verified[suite] = item + return verified + + +def find_reuse(plan, fetch=api): + """Receipts are uploaded by this workflow, not read from the PR tree.""" + repository = plan["repository"] + current = fetch(f"repos/{repository}/actions/runs/{os.environ['GITHUB_RUN_ID']}") + plan["workflowID"] = current["workflow_id"] + runs = fetch(f"repos/{repository}/actions/workflows/{plan['workflowID']}/runs?" + urlencode({ + "event": "pull_request", "status": "completed", "per_page": 30}))["workflow_runs"] + for run in runs: + if run["id"] == int(os.environ["GITHUB_RUN_ID"]): + continue + if (run.get("head_repository", {}).get("full_name") != plan["source"] + or run.get("head_branch") != plan["branch"]): + continue + if not proof_pr(plan, run, fetch): + continue + artifacts = fetch(f"repos/{repository}/actions/runs/{run['id']}/artifacts?per_page=100")["artifacts"] + for artifact in artifacts: + size = artifact.get("size_in_bytes") + if (artifact.get("name") != "ci-test-receipt" or artifact.get("expired") + or type(size) is not int or not 0 < size <= 1048576): + continue + try: + payload = fetch(f"repos/{repository}/actions/artifacts/{artifact['id']}/zip", binary=True) + with zipfile.ZipFile(io.BytesIO(payload)) as archive: + entries = archive.infolist() + if len(entries) != 1 or entries[0].filename != "receipt.json": + raise ValueError("unexpected receipt archive entries") + info = entries[0] + mode = (info.external_attr >> 16) & 0o170000 + if (info.is_dir() or info.flag_bits & 1 or mode not in {0, 0o100000} + or info.file_size > 1048576): + raise ValueError("oversized receipt") + receipt = json.loads(archive.read(info)) + if receipt.get("runID") != run["id"]: + raise ValueError("receipt not uploaded by claimed run") + successful_jobs(repository, run["id"], "affected-module-tests", fetch) + verified = validate_receipt(plan, receipt, fetch) + jobs = pages(f"repos/{repository}/actions/runs/{run['id']}/jobs", fetch) + def good_matches(predicate, minimum=1): + matching = [job for job in jobs if predicate(job.get("name", "").lower())] + return len(matching) >= minimum and all(job.get("conclusion") == "success" for job in matching) + plan["optionalSuccess"] = { + "compatibility": (good_matches(lambda n: "hbase compatibility" in n) + and good_matches(lambda n: "macos" in n, 2) + and good_matches(lambda n: "riscv64" in n)), + "security": good_matches(lambda n: n.startswith(("codeql /", "security /"))), + } + return verified + except (ValueError, KeyError, TypeError, AttributeError, zipfile.BadZipFile): + continue + return {} + + +def create_plan(project, event, repository, fetch=api, external_inputs=None): + plan = {"schema": 1, "project": project, "repository": repository, "pr": 0, + "source": repository, "branch": "", "base": "", "head": git("rev-parse", "HEAD"), + "reused": {}, "workflowID": 0, "reason": "affected inputs", + "externalInputs": external_inputs or {}, "testedMergeSHA": git("rev-parse", "HEAD")} + try: + pr = event.get("pull_request") + if pr: + # Refresh PR metadata: event payload may predate a new push or base movement. + live = fetch(f"repos/{repository}/pulls/{pr['number']}") + if (live.get("state") != "open" or live["head"]["sha"] != pr["head"]["sha"] + or live["base"]["repo"]["full_name"] != repository): + raise ValueError("PR metadata changed") + plan.update(pr=pr["number"], source=live["head"]["repo"]["full_name"], + base=pr["base"]["sha"], head=live["head"]["sha"], branch=live["head"]["ref"]) + parents = git("show", "-s", "--format=%P", plan["testedMergeSHA"]).split() + if parents != [plan["base"], plan["head"]]: + raise ValueError("checkout is not the event PR merge") + if live["base"]["sha"] != plan["base"]: + raise ValueError("base advanced after event checkout") + # head/base objects must exist locally; workflow fetches both before planning. + ancestor = git("merge-base", plan["base"], plan["head"]) + paths = git("diff", "--no-renames", "--name-only", ancestor, plan["head"]).splitlines() + else: + plan["base"] = event.get("before", "") + paths = git("diff", "--no-renames", "--name-only", plan["base"], plan["head"]).splitlines() + selected = select(project, paths) + if unsafe_documentation(plan["base"], paths) or unsafe_documentation(plan["testedMergeSHA"], paths): + selected = set(MODULES[project]) + plan["inputFingerprint"] = fingerprint(plan["testedMergeSHA"]) + if project == "toolchain": + context = json.dumps(external_inputs or {}, sort_keys=True, separators=(",", ":")) + plan["inputFingerprint"] = hashlib.sha256((plan["inputFingerprint"] + context).encode()).hexdigest() + plan["policyFingerprint"] = fingerprint(plan["testedMergeSHA"], policy=True) + plan["expected"] = suites(project, selected) + if pr and plan["expected"] and (project != "toolchain" or external_inputs): + plan["reused"] = find_reuse(plan, fetch) + except (subprocess.SubprocessError, OSError, ValueError, KeyError, TypeError, AttributeError): + selected = set(MODULES[project]) + plan.update(reused={}, reason="verification unavailable: full required coverage") + plan.setdefault("inputFingerprint", "") + plan.setdefault("policyFingerprint", "") + plan["expected"] = suites(project, selected) + if not selected: + plan["reason"] = "cumulative PR diff contains only plain prose documentation; modules unaffected" + elif plan["reused"]: + plan["reason"] = "affected inputs match independently verified successful tests" + plan["selected"] = sorted(selected) + for module in MODULES[project]: + module_suites = suites(project, {module}) + plan[module] = module in selected and not (module_suites and all(s in plan["reused"] for s in module_suites)) + plan["pd_store"] = any(plan.get(m, False) for m in ["pd", "store", "hstore", "struct"]) + plan["needsFixture"] = project == "toolchain" and any(plan.get(m, False) for m in MODULES[project]) + plan["fixture"] = plan["needsFixture"] + plan["compatibility"] = not plan.get("optionalSuccess", {}).get("compatibility", False) and project == "server" and bool(selected.intersection({"server", "commons", "struct", "pd", "store", "hstore", "cluster"})) + plan["security"] = (bool(selected) and not plan.get("optionalSuccess", {}).get("security", False)) or any(p.startswith(".github/workflows/codeql") for p in locals().get("paths", [])) + plan["security_languages"] = json.dumps(["java"]) + return plan + + +def gate(plan, results, fetch=api): + if results.get("plan", {}).get("result") != "success": + raise ValueError("planner did not succeed") + proofs = dict(plan["reused"]) + executed = [] + for suite in plan["expected"]: + if suite in proofs: + continue + if results.get(suite, {}).get("result") != "success": + raise ValueError("selected suite did not succeed: " + suite) + executed.append(suite) + if plan["project"] == "toolchain": + if set(executed).intersection({"client", "loader", "tools", "spark", "go"}): + if results.get("fixture", {}).get("result") != "success": + raise ValueError("selected tests lack successful fixture") + if "hubble" in executed and results.get("hubble-fixture", {}).get("result") != "success": + raise ValueError("selected Hubble tests lack successful baseline fixture") + receipt = {key: plan[key] for key in ["schema", "repository", "project", "pr", "source", "branch", "base", + "head", "testedMergeSHA", "inputFingerprint", "policyFingerprint", "workflowID"]} + receipt["runID"] = int(os.environ.get("GITHUB_RUN_ID", "0")) + # Successful tests can gate even if receipt publication is unavailable. Never reuse + # unverified evidence: a missing API proof only disables future receipt reuse. + for suite in executed: + try: + ids = successful_jobs(plan["repository"], receipt["runID"], suite, fetch) + proofs[suite] = {"runID": receipt["runID"], "head": plan["head"], "testedMergeSHA": plan["testedMergeSHA"], "jobIDs": ids} + except (subprocess.SubprocessError, OSError, ValueError, KeyError, TypeError, AttributeError): + pass + receipt["proofs"] = proofs + receipt["executed"] = executed + receipt["reused"] = sorted(plan["reused"]) + summary = "Selection: " + plan.get("reason", "affected inputs") + "\nExecuted: " + ", ".join(executed) + "\nReused: " + ", ".join(receipt["reused"]) + if os.environ.get("GITHUB_STEP_SUMMARY"): + with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as out: + out.write("## Affected module tests\n" + summary + "\n\nUnselected modules: " + + ", ".join(set(MODULES[plan["project"]]) - set(plan["selected"])) + "\n") + print(summary) + return receipt + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + sub = parser.add_subparsers(dest="command", required=True) + p = sub.add_parser("plan") + p.add_argument("--project", choices=MODULES, required=True) + p.add_argument("--event-path", required=True) + p.add_argument("--repository", required=True) + p.add_argument("--output", required=True) + p.add_argument("--inputs-json") + p = sub.add_parser("gate") + p.add_argument("--plan", required=True) + p.add_argument("--results", required=True) + p.add_argument("--output", required=True) + args = parser.parse_args() + if args.command == "plan": + external = json.loads(Path(args.inputs_json).read_text()) if args.inputs_json else None + value = create_plan(args.project, json.loads(Path(args.event_path).read_text()), args.repository, + external_inputs=external) + if os.environ.get("GITHUB_OUTPUT"): + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output: + for key, item in value.items(): + if isinstance(item, bool): + output.write(f"{key}={str(item).lower()}\n") + output.write(f"security_languages={value['security_languages']}\nplan_file={args.output}\n") + else: + value = gate(json.loads(Path(args.plan).read_text()), json.loads(Path(args.results).read_text())) + Path(args.output).write_text(json.dumps(value, indent=2) + "\n") + + +if __name__ == "__main__": + main() diff --git a/.github/scripts/test_check_rerun.py b/.github/scripts/test_check_rerun.py index 06676ef73f..225a75cb83 100644 --- a/.github/scripts/test_check_rerun.py +++ b/.github/scripts/test_check_rerun.py @@ -16,6 +16,9 @@ """Behavioral tests for retry freshness, without credentials or network.""" import copy +import io +import json +import zipfile import importlib.util from pathlib import Path import unittest @@ -33,13 +36,24 @@ class FreshnessTest(unittest.TestCase): def setUp(self): self.repository = "hugegraph/hugegraph-toolchain" self.run = {"status": "completed", "conclusion": "failure", "run_attempt": 1, - "event": "pull_request", "head_sha": "old", "head_branch": "feature"} - self.pr = {"number": 7, "state": "open", "head": {"sha": "old"}, - "base": {"repo": {"full_name": self.repository}}} + "event": "pull_request", "head_sha": "old", "head_branch": "feature", + "head_repository": {"full_name": "alice/toolchain", "owner": {"login": "alice"}}} + self.pr = {"number": 7, "state": "open", "head": {"sha": "old", "ref": "feature", "repo": {"full_name": "alice/toolchain", "owner": {"login": "alice"}}}, + "base": {"sha": "base", "repo": {"full_name": self.repository}}} self.run["pull_requests"] = [copy.deepcopy(self.pr)] self.calls = [] - def fetch(self, path): + def fetch(self, path, binary=False): + if "/artifacts?" in path: + return {"artifacts": [{"name": "ci-plan", "id": 99, "expired": False}]} + if "/actions/artifacts/" in path: + data = io.BytesIO() + with zipfile.ZipFile(data, "w") as archive: + archive.writestr("plan.json", json.dumps({"repository": self.repository, "pr": 7, + "head": "old", "base": "base", "testedMergeSHA": "merge"})) + return data.getvalue() + if "/git/commits/" in path: + return {"parents": [{"sha": "base"}, {"sha": "old"}]} self.calls.append(path) if "/actions/runs/" in path: return self.run @@ -47,6 +61,8 @@ def fetch(self, path): return [self.pr] if "/commits?" in path: return [{"sha": self.pr["head"]["sha"]}] + if "/pulls?" in path: + return [self.pr] if "/pulls/" in path: return self.pr raise AssertionError(path) @@ -85,6 +101,20 @@ def test_empty_association_falls_back_to_commit(self): self.assertEqual("rerun", self.decide()) self.assertTrue(any("/commits/old/pulls" in p for p in self.calls)) + def test_fork_empty_associations_owner_qualified_fallback(self): + self.run.update(pull_requests=[], head_repository={"full_name": "alice/toolchain", "owner": {"login": "alice"}}) + self.pr["head"].update(ref="feature", repo={"full_name": "alice/toolchain", "owner": {"login": "alice"}}) + def fetch(path, **kwargs): + if "/commits/old/pulls" in path: + return [] + if "/pulls?" in path: + self.assertIn("head=alice%3Afeature", path) + return [self.pr] + return self.fetch(path, **kwargs) + self.assertEqual("rerun", checker.decide(self.repository, 42, 1, 2, fetch)[0]) + self.pr["head"]["repo"]["owner"]["login"] = "bob" + self.assertEqual("skip", checker.decide(self.repository, 42, 1, 2, fetch)[0]) + def test_other_repository_pr_is_ignored(self): self.run["pull_requests"][0]["base"]["repo"]["full_name"] = "apache/hugegraph-toolchain" self.assertEqual("rerun", self.decide()) @@ -95,6 +125,24 @@ def test_foreign_pr_fallback_still_rejects_foreign_pr(self): self.pr["base"]["repo"]["full_name"] = "apache/hugegraph-toolchain" self.assertEqual("skip", self.decide()) + def test_same_head_moved_base_is_stale(self): + self.pr["base"]["sha"] = "new-base" + self.assertEqual("skip", self.decide()) + + def test_missing_immutable_plan_refuses_retry(self): + def fetch(path, **kwargs): + if "/artifacts?" in path: + return {"artifacts": []} + return self.fetch(path, **kwargs) + self.assertEqual("skip", checker.decide(self.repository, 42, 1, 2, fetch)[0]) + + def test_same_sha_other_fork_cannot_authorize_retry(self): + self.pr["head"]["repo"].update(full_name="bob/toolchain", owner={"login": "bob"}) + self.assertEqual("skip", self.decide()) + self.pr["head"]["repo"].update(full_name="alice/toolchain", owner={"login": "alice"}) + self.pr["head"]["ref"] = "other" + self.assertEqual("skip", self.decide()) + def test_changed_attempt(self): self.run["run_attempt"] = 2 self.assertEqual("skip", self.decide()) diff --git a/.github/scripts/test_ci_policy.py b/.github/scripts/test_ci_policy.py new file mode 100644 index 0000000000..6a46829046 --- /dev/null +++ b/.github/scripts/test_ci_policy.py @@ -0,0 +1,327 @@ +#!/usr/bin/env python3 +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Executable Git fixtures and API proof tests for CI selection.""" +import hashlib +import importlib.util +import io +import zipfile +import json +import os +from pathlib import Path +import subprocess +import tempfile +import unittest +from unittest.mock import patch + +spec = importlib.util.spec_from_file_location("policy", Path(__file__).with_name("ci-policy.py")) +policy = importlib.util.module_from_spec(spec) +spec.loader.exec_module(policy) + + +class PolicyTest(unittest.TestCase): + def setUp(self): + mock = patch.object(policy, "remote_fingerprint", side_effect=lambda *a, **k: "policy" if k.get("policy_only") else "inputs") + mock.start() + self.addCleanup(mock.stop) + + def test_dependency_expansion(self): + self.assertEqual({"client", "loader", "tools", "spark", "hubble"}, + policy.select("toolchain", ["hugegraph-client/src/A.java"])) + self.assertEqual({"loader", "hubble"}, policy.select("toolchain", ["hugegraph-loader/src/A.java"])) + self.assertEqual({"server", "cluster"}, policy.select("server", ["hugegraph-server/A.java"])) + + def test_server_backend_and_startup_dependents(self): + selected = policy.select("server", ["hugegraph-server/hugegraph-hstore/src/test/HstoreTableTest.java"]) + self.assertIn("hstore", selected) + self.assertIn("pd_store", policy.suites("server", selected)) + selected = policy.select("server", ["hugegraph-server/hugegraph-dist/src/assembly/travis/test-start-hugegraph-pd.sh"]) + self.assertIn("pd_store", policy.suites("server", selected)) + self.assertIn("docker", selected) + for module in ["hugegraph-core", "hugegraph-api", "hugegraph-test"]: + selected = policy.select("server", [f"hugegraph-server/{module}/src/main/A.java"]) + self.assertIn("hstore", selected) + self.assertIn("server", selected) + self.assertEqual(set(), policy.select("server", ["hugegraph-server/hugegraph-hstore/README.md"])) + self.assertNotIn("hstore", policy.select("server", ["hugegraph-server/hugegraph-rocksdb/src/main/A.java"])) + + def test_single_workflow_has_no_global_fanout(self): + self.assertEqual({"hubble"}, policy.select("toolchain", [".github/workflows/hubble-ci.yml"])) + self.assertEqual({"docker"}, policy.select("server", [".github/workflows/docker-build-ci.yml"])) + + def test_unknown_and_proto_fail_conservative(self): + for path in ["pom.xml", ".github/scripts/new.py", "hugegraph-pd/api.proto", "mystery"]: + self.assertEqual(set(policy.MODULES["server"]), policy.select("server", [path])) + + def test_strict_docs_allowlist(self): + self.assertEqual(set(), policy.select("server", ["README.md", "docs/guide.md"])) + for path in ["hugegraph-client/src/test/resources/README.md", "docs/type.ts", "docs/config.yml", "docs/example.java", "hugegraph-server/type.ts"]: + self.assertTrue(policy.select("server", [path])) + + def plan(self): + return {"schema": 1, "project": "toolchain", "repository": "apache/t", "pr": 7, + "source": "alice/t", "branch": "feature", "base": "base", "head": "new", "testedMergeSHA": "merge", "inputFingerprint": hashlib.sha256(b"inputs{}").hexdigest(), + "policyFingerprint": "policy", "workflowID": 99, "expected": ["client"], + "selected": ["client"], "reused": {}} + + def receipt(self): + r = self.plan() + r["proofs"] = {"client": {"runID": 42, "head": "old", "testedMergeSHA": "oldmerge", "jobIDs": [123]}} + return r + + def fetch(self, path): + if "/git/commits/" in path: + return {"parents": [{"sha": "base"}, {"sha": "old"}]} + if path.endswith("/jobs?per_page=100&page=1"): + return {"jobs": [{"name": "client / client-ci", "id": 123, "conclusion": "success"}, + {"name": "affected-module-tests", "id": 124, "conclusion": "success"}]} + return {"status": "completed", "conclusion": "success", "event": "pull_request", + "head_sha": "old", "head_branch": "feature", "workflow_id": 99, "head_repository": {"full_name": "alice/t"}, + "pull_requests": [{"number": 7, "base": {"sha": "base"}}]} + + def test_valid_original_proof(self): + with patch.object(policy, "remote_fingerprint", side_effect=lambda *a, **k: "policy" if k.get("policy_only") else "inputs"): + self.assertIn("client", policy.validate_receipt(self.plan(), self.receipt(), self.fetch)) + + def test_empty_fork_association_owner_branch_fallback(self): + def fetch(path): + if "/pulls?" in path: + self.assertIn("head=alice%3Afeature", path) + return [{"number": 7, "head": {"ref": "feature", "repo": {"full_name": "alice/t"}}, + "base": {"repo": {"full_name": "apache/t"}}}] + value = self.fetch(path) + if "/actions/runs/" in path and "/jobs?" not in path: + value.update(pull_requests=[], head_branch="feature", + head_repository={"full_name": "alice/t", "owner": {"login": "alice"}}) + return value + self.assertIn("client", policy.validate_receipt(self.plan(), self.receipt(), fetch)) + + def test_nonblocking_failure_keeps_core_proof_but_bad_gate_rejects(self): + def fetch(path): + value = self.fetch(path) + if "/jobs?" not in path and "/actions/runs/" in path: + value["conclusion"] = "failure" + return value + self.assertIn("client", policy.validate_receipt(self.plan(), self.receipt(), fetch)) + def bad_gate(path): + value = fetch(path) + if "/jobs?" in path: + value["jobs"][1]["conclusion"] = "failure" + return value + with self.assertRaises(ValueError): + policy.validate_receipt(self.plan(), self.receipt(), bad_gate) + + def test_artifact_core_reuse_with_failed_compatibility(self): + receipt = self.receipt() + receipt["runID"] = 42 + data = io.BytesIO() + with zipfile.ZipFile(data, "w") as archive: + archive.writestr("receipt.json", json.dumps(receipt)) + artifact_size = 256 + def fetch(path, binary=False): + if "/actions/workflows/" in path: + self.assertIn("status=completed", path) + return {"workflow_runs": [{"id": 42, "head_repository": {"full_name": "alice/t"}, "head_branch": "feature", "pull_requests": [{"number": 7}]}]} + if "/artifacts?" in path: + return {"artifacts": [{"name": "ci-test-receipt", "id": 3, "expired": False, "size_in_bytes": artifact_size}]} + if "/actions/artifacts/" in path: + return data.getvalue() + value = self.fetch(path) + if "/jobs?" in path: + value["jobs"].append({"name": "HBase compatibility", "id": 9, "conclusion": "failure"}) + elif "/actions/runs/" in path: + value["conclusion"] = "failure" + return value + plan = self.plan() + with patch.dict(os.environ, {"GITHUB_RUN_ID": "99"}): + proof = policy.find_reuse(plan, fetch) + self.assertIn("client", proof) + self.assertFalse(plan["optionalSuccess"]["compatibility"]) + self.assertFalse(plan["optionalSuccess"]["security"]) + for artifact_size in [None, 0, 1048577, "256"]: + with patch.dict(os.environ, {"GITHUB_RUN_ID": "99"}): + self.assertEqual({}, policy.find_reuse(self.plan(), fetch)) + artifact_size = 256 + data = io.BytesIO() + with zipfile.ZipFile(data, "w") as archive: + archive.writestr("receipt.json", json.dumps(receipt)) + archive.writestr("unexpected.txt", "extra") + with patch.dict(os.environ, {"GITHUB_RUN_ID": "99"}): + self.assertEqual({}, policy.find_reuse(self.plan(), fetch)) + + def test_tested_merge_parent_mismatch_rejects(self): + def fetch(path): + if "/git/commits/" in path: + return {"parents": [{"sha": "advanced-base"}, {"sha": "old"}]} + return self.fetch(path) + with self.assertRaises(ValueError): + policy.validate_receipt(self.plan(), self.receipt(), fetch) + + def test_actual_tree_fingerprint_mismatch_rejects(self): + with patch.object(policy, "remote_fingerprint", return_value="forged"): + with self.assertRaises(ValueError): + policy.validate_receipt(self.plan(), self.receipt(), self.fetch) + + def test_provenance_changes_refuse_reuse(self): + for key in ["base", "inputFingerprint", "policyFingerprint", "source", "repository", "pr", "schema"]: + receipt = self.receipt() + receipt[key] = "changed" + with self.subTest(key=key), self.assertRaises(ValueError): + policy.validate_receipt(self.plan(), receipt, self.fetch) + + def test_original_run_and_actual_job_proof_required(self): + for failure in ["failure", "cancelled", "skipped"]: + def fetch(path): + value = self.fetch(path) + if "jobs?" in path: + value["jobs"][0]["conclusion"] = failure + return value + with self.subTest(failure=failure), self.assertRaises(ValueError): + policy.validate_receipt(self.plan(), self.receipt(), fetch) + receipt = self.receipt() + receipt["proofs"]["client"]["jobIDs"] = [456] + with self.assertRaises(ValueError): + policy.validate_receipt(self.plan(), receipt, self.fetch) + + def test_gate_rejects_missing_failed_cancelled_skipped(self): + for result in [None, "failure", "cancelled", "skipped"]: + with self.subTest(result=result), self.assertRaises(ValueError): + policy.gate(self.plan(), {"plan": {"result": "success"}, "client": {"result": result}}, self.fetch) + + def test_gate_success_receipt_and_verified_reuse(self): + with patch.dict(os.environ, {"GITHUB_RUN_ID": "42"}): + result = policy.gate(self.plan(), {"plan": {"result": "success"}, "client": {"result": "success"}, "fixture": {"result": "success"}}, self.fetch) + self.assertEqual([123], result["proofs"]["client"]["jobIDs"]) + plan = self.plan() + plan["reused"] = result["proofs"] + self.assertEqual(["client"], policy.gate(plan, {"plan": {"result": "success"}, "client": {"result": "skipped"}}, self.fetch)["reused"]) + + def test_gate_refuses_failed_planner_even_empty_or_reused(self): + for result in ["failure", "skipped", "cancelled", None]: + plan = self.plan() + plan["expected"] = [] + with self.subTest(result=result), self.assertRaises(ValueError): + policy.gate(plan, {"plan": {"result": result}}, self.fetch) + + def test_fixture_missing_rejects_successful_test(self): + with self.assertRaises(ValueError): + policy.gate(self.plan(), {"plan": {"result": "success"}, "client": {"result": "success"}}, self.fetch) + plan = self.plan() + plan["expected"] = ["hubble"] + with self.assertRaises(ValueError): + policy.gate(plan, {"plan": {"result": "success"}, "hubble": {"result": "success"}}, self.fetch) + + def test_proof_failure_disables_receipt_without_falsifying_gate(self): + def failure(path): + raise subprocess.CalledProcessError(1, "gh") + result = policy.gate(self.plan(), {"plan": {"result": "success"}, "client": {"result": "success"}, + "fixture": {"result": "success"}}, failure) + self.assertEqual({}, result["proofs"]) + self.assertEqual(["client"], result["executed"]) + + def test_doc_update_reuses_only_with_valid_receipt(self): + event = {"pull_request": {"number": 7, "head": {"sha": "new"}, "base": {"sha": "base"}}} + live = {"state": "open", "head": {"sha": "new", "ref": "feature", "repo": {"full_name": "alice/t"}}, + "base": {"sha": "base", "repo": {"full_name": "apache/t"}}} + def git(*args): + if args[0] == "show": + return "base new" + if args[0] == "diff": + return "hugegraph-client/src/A.java\nREADME.md" + return "new" + with patch.object(policy, "unsafe_documentation", return_value=False), patch.object(policy, "git", side_effect=git), patch.object(policy, "fingerprint", return_value="hash"), patch.object( + policy, "find_reuse", return_value={m: {"runID": 42} for m in ["client", "loader", "tools", "spark", "hubble"]}): + plan = policy.create_plan("toolchain", event, "apache/t", lambda p: live, + external_inputs={"serverSHA": "immutable"}) + self.assertFalse(plan["client"]) + self.assertFalse(plan["needsFixture"]) + self.assertTrue(plan["security"]) + with patch.object(policy, "unsafe_documentation", return_value=False), patch.object(policy, "git", side_effect=git), patch.object(policy, "fingerprint", return_value="hash"), patch.object( + policy, "find_reuse", return_value={}): + plan = policy.create_plan("toolchain", event, "apache/t", lambda p: live, + external_inputs={"serverSHA": "immutable"}) + self.assertTrue(plan["client"]) + self.assertTrue(plan["needsFixture"]) + + def test_real_git_fingerprint_and_cli(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + def git(*args): + return subprocess.check_output(["git", *args], cwd=root, text=True).strip() + git("init", "-q") + git("config", "user.email", "ci@example.invalid") + git("config", "user.name", "CI") + (root / "README.md").write_text("initial") + git("add", ".") + git("commit", "-qm", "base") + base = git("rev-parse", "HEAD") + (root / "README.md").write_text("docs change") + git("commit", "-qam", "docs") + head = git("rev-parse", "HEAD") + old = os.getcwd() + try: + os.chdir(root) + self.assertEqual(policy.fingerprint(base), policy.fingerprint(head)) + (root / "type.ts").write_text("type changed") + git("add", ".") + git("commit", "-qm", "types") + self.assertNotEqual(policy.fingerprint(base), policy.fingerprint("HEAD")) + (root / "README.md").chmod(0o755) + git("add", "README.md") + git("commit", "-qm", "executable prose") + self.assertTrue(policy.unsafe_documentation("HEAD", ["README.md"])) + self.assertNotEqual(policy.fingerprint(base), policy.fingerprint("HEAD")) + (root / "README.md").unlink() + (root / "README.md").symlink_to("type.ts") + git("add", "README.md") + git("commit", "-qm", "symlink prose") + self.assertTrue(policy.unsafe_documentation("HEAD", ["README.md"])) + (root / "hugegraph-server").mkdir() + (root / "hugegraph-server" / "A.java").write_text("source") + git("add", ".") + git("commit", "-qm", "source") + before_rename = git("rev-parse", "HEAD") + (root / "docs").mkdir() + git("mv", "hugegraph-server/A.java", "docs/renamed.md") + git("commit", "-qm", "rename code to prose") + changed = git("diff", "--no-renames", "--name-only", before_rename, "HEAD").splitlines() + self.assertIn("server", policy.select("server", changed)) + finally: + os.chdir(old) + event = root / "event.json" + event.write_text(json.dumps({"before": base})) + output = root / "output" + subprocess.run(["python3", str(Path(policy.__file__).resolve()), "plan", "--project", "server", + "--repository", "apache/server", "--event-path", str(event), + "--output", str(root / "plan.json")], cwd=root, check=True, + env={**os.environ, "GITHUB_OUTPUT": str(output)}) + self.assertIn("server=true\n", output.read_text()) + self.assertEqual(policy.suites("server", set(policy.MODULES["server"])), + json.loads((root / "plan.json").read_text())["expected"]) + + def test_api_failure_runs_full_required(self): + event = {"pull_request": {"number": 7, "head": {"sha": "old"}}} + with patch.object(policy, "git", return_value="head"): + def fail(path): + raise subprocess.CalledProcessError(1, "gh") + plan = policy.create_plan("toolchain", event, "apache/t", fail) + self.assertEqual(set(policy.MODULES["toolchain"]), set(plan["selected"])) + self.assertTrue(plan["security"]) + self.assertEqual({}, plan["reused"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/check-dependencies.yml b/.github/workflows/check-dependencies.yml index ef9ff04fc3..73ef769c67 100644 --- a/.github/workflows/check-dependencies.yml +++ b/.github/workflows/check-dependencies.yml @@ -2,31 +2,12 @@ name: "3rd-party" on: - push: - branches: - - 'release-*' - pull_request: - # Keep source/resources and shared build inputs; skip named documentation files. - paths: - - '.github/workflows/.java-version' - - '**/pom.xml' - - '.mvn/**' - - '.github/configs/**' - - 'install-dist/**' - - '**/*.jar' - - '.github/workflows/check-dependencies.yml' - - '!**/README*' - - '!**/AGENTS.md' - - '!**/CLAUDE.md' + workflow_call: + workflow_dispatch: permissions: contents: read -# Keep the newest PR run; preserve independent push, release and manual runs. -concurrency: - group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.run_id }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} - jobs: dependency-check: runs-on: ubuntu-latest @@ -64,6 +45,7 @@ jobs: - name: 'Checkout Repository' uses: actions/checkout@v7 - name: 'Dependency Review' + if: github.event_name == 'pull_request' uses: actions/dependency-review-action@v5 # Refer: https://github.com/actions/dependency-review-action with: diff --git a/.github/workflows/cluster-test-ci.yml b/.github/workflows/cluster-test-ci.yml index cacd82eeb5..20d08cc20d 100644 --- a/.github/workflows/cluster-test-ci.yml +++ b/.github/workflows/cluster-test-ci.yml @@ -1,36 +1,11 @@ name: "Cluster Test CI" on: - push: - branches: - - master - - 'release-*' - - 'test-*' - pull_request: - # Keep source/resources and shared build inputs; skip named documentation files. - paths: - - '.github/workflows/.java-version' - - 'pom.xml' - - '.mvn/**' - - 'style/**' - - '.editorconfig' - - '.github/configs/**' - - 'hugegraph-commons/**' - - 'hugegraph-struct/**' - - 'hugegraph-pd/**' - - 'hugegraph-store/**' - - 'hugegraph-server/**' - - 'hugegraph-cluster-test/**' - - 'install-dist/**' - - '.github/workflows/cluster-test-ci.yml' - - '!**/README*' - - '!**/AGENTS.md' - - '!**/CLAUDE.md' + workflow_call: + workflow_dispatch: -# Keep the newest PR run; preserve independent push, release and manual runs. -concurrency: - group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.run_id }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} +permissions: + contents: read jobs: cluster-test: diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 5b017919cf..ce53d83068 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -1,18 +1,15 @@ name: "CodeQL" on: - push: - branches: [ master, release-* ] - pull_request: - # The branches below must be a subset of the branches above - # branches: [ master ] # enable in all PRs + workflow_call: + workflow_dispatch: schedule: - cron: '33 0 * * 5' -# Keep the newest PR run; preserve independent push, release and manual runs. -concurrency: - group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.run_id }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} +permissions: + contents: read + actions: read + security-events: write jobs: analyze: diff --git a/.github/workflows/commons-ci.yml b/.github/workflows/commons-ci.yml index 29f32aa6ad..1aa9242da5 100644 --- a/.github/workflows/commons-ci.yml +++ b/.github/workflows/commons-ci.yml @@ -1,18 +1,11 @@ name: "HugeGraph-Commons CI" on: + workflow_call: workflow_dispatch: - push: - branches: - - master - - 'release-*' - - 'test-*' - pull_request: -# Keep the newest PR run; preserve independent push, release and manual runs. -concurrency: - group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.run_id }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} +permissions: + contents: read jobs: java-runtime: diff --git a/.github/workflows/docker-build-ci.yml b/.github/workflows/docker-build-ci.yml index 98d20674f1..fc7d531274 100644 --- a/.github/workflows/docker-build-ci.yml +++ b/.github/workflows/docker-build-ci.yml @@ -18,36 +18,11 @@ name: "Docker Build CI" on: - push: - branches: - - master - - 'release-*' - pull_request: - # Keep source/resources and shared build inputs; skip named documentation files. - paths: - - 'pom.xml' - - '.mvn/**' - - 'style/**' - - '.editorconfig' - - '.github/configs/**' - - 'hugegraph-commons/**' - - 'hugegraph-struct/**' - - 'hugegraph-pd/**' - - 'hugegraph-store/**' - - 'hugegraph-server/**' - - 'hugegraph-cluster-test/**' - - 'install-dist/**' - - 'docker/**' - - '.dockerignore' - - '.github/workflows/docker-build-ci.yml' - - '!**/README*' - - '!**/AGENTS.md' - - '!**/CLAUDE.md' - -# Keep the newest PR run; preserve independent push, release and manual runs. -concurrency: - group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.run_id }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} + workflow_call: + workflow_dispatch: + +permissions: + contents: read jobs: docker-bake-check: diff --git a/.github/workflows/helm-chart-ci.yml b/.github/workflows/helm-chart-ci.yml index e78dd03655..28df8f5230 100644 --- a/.github/workflows/helm-chart-ci.yml +++ b/.github/workflows/helm-chart-ci.yml @@ -18,20 +18,12 @@ name: "Helm Chart CI" on: - push: - branches: [ master, 'release-*' ] - paths: [ 'helm/**', '.github/workflows/helm-chart-ci.yml' ] - pull_request: - paths: [ 'helm/**', '.github/workflows/helm-chart-ci.yml' ] + workflow_call: + workflow_dispatch: permissions: contents: read -# Keep the newest PR run; preserve independent push, release and manual runs. -concurrency: - group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.run_id }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} - jobs: lint-and-render: runs-on: ubuntu-latest diff --git a/.github/workflows/pd-store-ci.yml b/.github/workflows/pd-store-ci.yml index 3810b4e2dc..f1bf062efa 100644 --- a/.github/workflows/pd-store-ci.yml +++ b/.github/workflows/pd-store-ci.yml @@ -1,37 +1,11 @@ name: "HugeGraph-PD & Store & Hstore CI" on: - push: - branches: - - master - - 'release-*' - - 'test-*' - pull_request: - # Keep source/resources and shared build inputs; skip named documentation files. - paths: - - '.github/workflows/.java-version' - - 'pom.xml' - - '.mvn/**' - - 'style/**' - - '.editorconfig' - - '.github/configs/**' - - 'hugegraph-commons/**' - - 'hugegraph-struct/**' - - 'hugegraph-pd/**' - - 'hugegraph-store/**' - - 'hugegraph-server/**' - - '.github/workflows/pd-store-ci.yml' - - 'docker/**' - - 'install-dist/**' - - '!**/README*' - - '!**/AGENTS.md' - - '!**/CLAUDE.md' - -# TODO: consider merge to one ci.yml file -# Keep the newest PR run; preserve independent push, release and manual runs. -concurrency: - group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.run_id }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} + workflow_call: + workflow_dispatch: + +permissions: + contents: read jobs: rocksdb-compatibility: @@ -39,8 +13,8 @@ jobs: contents: read runs-on: ubuntu-24.04 env: - BEFORE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} - AFTER_SHA: ${{ github.event.pull_request.head.sha || github.event.after }} + BEFORE_SHA: ${{ github.event.pull_request.base.sha || github.event.before || github.sha }} + AFTER_SHA: ${{ github.event.pull_request.head.sha || github.event.after || github.sha }} steps: - name: Require exact comparison revisions run: | @@ -198,7 +172,7 @@ jobs: if ! command -v "$tool" >/dev/null 2>&1; then echo "can_run=false" >> "$GITHUB_OUTPUT" echo "skip_reason=missing tool: $tool" >> "$GITHUB_OUTPUT" - exit 0 + exit 1 fi done echo "can_run=true" >> "$GITHUB_OUTPUT" @@ -319,14 +293,14 @@ jobs: if ! command -v "$tool" >/dev/null 2>&1; then echo "can_run=false" >> "$GITHUB_OUTPUT" echo "skip_reason=missing tool: $tool" >> "$GITHUB_OUTPUT" - exit 0 + exit 1 fi done LIMIT_N=$(ulimit -n) if [[ "$LIMIT_N" != "unlimited" ]] && (( LIMIT_N < 1024 )); then echo "can_run=false" >> "$GITHUB_OUTPUT" echo "skip_reason=ulimit -n is $LIMIT_N (store requires >= 1024)" >> "$GITHUB_OUTPUT" - exit 0 + exit 1 fi echo "can_run=true" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/server-ci.yml b/.github/workflows/server-ci.yml index 6aa038d1f3..729fdbdc8a 100644 --- a/.github/workflows/server-ci.yml +++ b/.github/workflows/server-ci.yml @@ -7,19 +7,62 @@ on: - 'release-*' - 'test-*' pull_request: + workflow_dispatch: # Keep the newest PR run; preserve independent push, release and manual runs. concurrency: - group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.run_id }} + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.run_id }}-${{ github.run_attempt == 1 && 'head' || format('rerun-{0}', github.run_id) }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} +permissions: + contents: read + actions: read + pull-requests: read + jobs: + plan: + runs-on: ubuntu-24.04 + outputs: + server: ${{ steps.select.outputs.server }} + commons: ${{ steps.select.outputs.commons }} + pd_store: ${{ steps.select.outputs.pd_store }} + cluster: ${{ steps.select.outputs.cluster }} + docker: ${{ steps.select.outputs.docker }} + helm: ${{ steps.select.outputs.helm }} + dependency_license: ${{ steps.select.outputs.dependency_license }} + compatibility: ${{ steps.select.outputs.compatibility }} + security: ${{ steps.select.outputs.security }} + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + persist-credentials: false + - name: Test selection, receipts and retry policy + run: python3 -m unittest discover -s .github/scripts -p 'test_*.py' + - name: Select affected tests + id: select + env: + GH_TOKEN: ${{ github.token }} + run: >- + python3 .github/scripts/ci-policy.py plan --project server + --event-path "$GITHUB_EVENT_PATH" --repository "$GITHUB_REPOSITORY" + --output plan.json + - uses: actions/upload-artifact@v4 + with: + name: ci-plan + path: plan.json + retention-days: 7 + overwrite: true + java-runtime: uses: ./.github/workflows/java-runtime.yml permissions: contents: read wait-storage-shell-test: + name: server_memory / startup contracts + needs: plan + if: needs.plan.outputs.server == 'true' permissions: contents: read runs-on: ubuntu-24.04 @@ -40,8 +83,9 @@ jobs: # Keep the job ID for the Codecov configuration validator. # The old required check name is provided by the temporary result job below. build-server: - needs: java-runtime - name: Server memory (Java ${{ matrix.JAVA_VERSION }}) + needs: [plan, java-runtime] + if: needs.plan.outputs.server == 'true' + name: server_memory (Java ${{ matrix.JAVA_VERSION }}) runs-on: ubuntu-24.04 env: &linux-server-env USE_STAGE: 'false' # Whether to include the stage repository. @@ -135,13 +179,13 @@ jobs: if ! command -v "$tool" >/dev/null 2>&1; then echo "can_run=false" >> "$GITHUB_OUTPUT" echo "skip_reason=missing tool: $tool" >> "$GITHUB_OUTPUT" - exit 0 + exit 1 fi done if ! command -v fuser >/dev/null 2>&1; then echo "can_run=false" >> "$GITHUB_OUTPUT" echo "skip_reason=missing tool: fuser (required for Linux port cleanup)" >> "$GITHUB_OUTPUT" - exit 0 + exit 1 fi echo "can_run=true" >> "$GITHUB_OUTPUT" @@ -193,51 +237,133 @@ jobs: disable_search: true fail_ci_if_error: false + server-rocksdb: + name: server_rocksdb (Java ${{ matrix.JAVA_VERSION }}) + needs: [plan, java-runtime] + if: needs.plan.outputs.server == 'true' + runs-on: ubuntu-24.04 + env: *linux-server-env + strategy: + fail-fast: false + matrix: + BACKEND: [ rocksdb ] + JAVA_VERSION: ${{ fromJSON(needs.java-runtime.outputs.versions || '["unavailable"]') }} + steps: *linux-server-steps + + commons: + name: commons + needs: plan + if: needs.plan.outputs.commons == 'true' + uses: ./.github/workflows/commons-ci.yml + secrets: inherit + + pd_store: + name: pd_store + needs: plan + if: needs.plan.outputs.pd_store == 'true' + uses: ./.github/workflows/pd-store-ci.yml + secrets: inherit + + cluster: + name: cluster + needs: plan + if: needs.plan.outputs.cluster == 'true' + uses: ./.github/workflows/cluster-test-ci.yml + secrets: inherit + + docker: + name: docker + needs: plan + if: needs.plan.outputs.docker == 'true' + uses: ./.github/workflows/docker-build-ci.yml + secrets: inherit + + helm: + name: helm + needs: plan + if: needs.plan.outputs.helm == 'true' + uses: ./.github/workflows/helm-chart-ci.yml + + dependency_license: + name: dependency_license + needs: plan + if: needs.plan.outputs.dependency_license == 'true' + uses: ./.github/workflows/check-dependencies.yml + + affected-module-tests: + name: affected-module-tests + needs: [plan, java-runtime, wait-storage-shell-test, build-server, server-rocksdb, commons, pd_store, cluster, docker, helm, dependency_license] + if: ${{ !cancelled() }} + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/download-artifact@v4 + with: + name: ci-plan + - name: Validate selected suites and publish receipt + env: + GH_TOKEN: ${{ github.token }} + JOB_RESULTS: ${{ toJSON(needs) }} + run: | + python3 - <<'PYGATE' + import json, os + results = json.loads(os.environ['JOB_RESULTS']) + if results['plan']['result'] != 'success': + raise SystemExit('CI planning failed') + if results['plan']['outputs'].get('server') == 'true': + for job in ['java-runtime', 'wait-storage-shell-test']: + if results[job]['result'] != 'success': + raise SystemExit('Selected prerequisite failed: ' + job) + results['server_memory'] = results['build-server'] + results['server_rocksdb'] = results['server-rocksdb'] + with open('results.json', 'w') as output: + json.dump(results, output) + PYGATE + python3 .github/scripts/ci-policy.py gate --plan plan.json --results results.json --output receipt.json + - uses: actions/upload-artifact@v4 + with: + name: ci-test-receipt + path: receipt.json + retention-days: 14 + overwrite: true + + # Temporary protection aliases; skipped modules pass only through the verified gate. server-memory-required: - # This is the only Server check referenced by .asf.yaml; keep its name stable. name: Server memory tests - needs: [ java-runtime, build-server ] - # Always evaluate results, including dependency failures and unexpected skips. - if: ${{ always() }} + needs: affected-module-tests + if: ${{ !cancelled() }} runs-on: ubuntu-24.04 - permissions: {} - env: &memory-results - RUNTIME_RESULT: ${{ needs.java-runtime.result }} - MEMORY_RESULT: ${{ needs.build-server.result }} - steps: &require-memory-success - - name: Require the runtime configuration and all memory tests to pass - run: | - if [ "$RUNTIME_RESULT" != "success" ] || [ "$MEMORY_RESULT" != "success" ]; then - echo "::error::Runtime configuration: $RUNTIME_RESULT; memory tests: $MEMORY_RESULT" - exit 1 - fi + env: &gate-result + GATE_RESULT: ${{ needs.affected-module-tests.result }} + steps: &require-gate + - run: test "$GATE_RESULT" = success - # Temporary alias for live protection. Remove after ASF activates the stable context. - # Reuse the real result check so a skipped matrix cannot satisfy the old requirement. legacy-server-memory-required: name: build-server (memory, 11) - needs: [ java-runtime, build-server ] - if: ${{ always() }} + needs: affected-module-tests + if: ${{ !cancelled() }} runs-on: ubuntu-24.04 - permissions: {} - env: *memory-results - steps: *require-memory-success + env: *gate-result + steps: *require-gate - # Optional coverage uses the same Linux environment, resolved JDKs and test steps. - # Keep it separate so backend failures do not become required memory failures. build-server-backends: - needs: java-runtime - name: build-server (${{ matrix.BACKEND }}, ${{ matrix.JAVA_VERSION }}) + name: HBase compatibility (Java ${{ matrix.JAVA_VERSION }}) + needs: [plan, java-runtime, affected-module-tests] + if: needs.plan.outputs.compatibility == 'true' runs-on: ubuntu-24.04 env: *linux-server-env strategy: fail-fast: false matrix: - BACKEND: [ rocksdb, hbase ] + BACKEND: [ hbase ] JAVA_VERSION: ${{ fromJSON(needs.java-runtime.outputs.versions || '["unavailable"]') }} steps: *linux-server-steps build-server-macos-rocksdb: + needs: [plan, affected-module-tests] + if: needs.plan.outputs.compatibility == 'true' runs-on: ${{ matrix.os }} strategy: fail-fast: false @@ -312,6 +438,28 @@ jobs: fi build-server-riscv64: + needs: [plan, affected-module-tests] + if: needs.plan.outputs.compatibility == 'true' uses: ./.github/workflows/riscv64-ci.yml permissions: contents: read + + codeql: + needs: [plan, affected-module-tests] + if: vars.CI_OPTIMIZED_REQUIRED != 'true' || needs.plan.outputs.security == 'true' + uses: ./.github/workflows/codeql-analysis.yml + permissions: + contents: read + actions: read + security-events: write + secrets: inherit + + legacy-analyze-required: + name: Analyze (java) + needs: codeql + if: ${{ !cancelled() && vars.CI_OPTIMIZED_REQUIRED != 'true' }} + runs-on: ubuntu-24.04 + env: + SCAN_RESULT: ${{ needs.codeql.result }} + steps: + - run: test "$SCAN_RESULT" = success diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index aa1a8c6849..537b65da26 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -1,199 +1,68 @@ -# How to Contribute to HugeGraph +# Contributing to HugeGraph -> Refer [website-doc](https://hugegraph.apache.org/docs/contribution-guidelines/) for the latest information. +Keep each contribution focused on one problem, with enough context and validation for review. +Report reproducible bugs and proposed features through [GitHub issues](https://github.com/apache/hugegraph/issues). -Thanks for taking the time to contribute! -As an open source project, HugeGraph is looking forward to being contributed from everyone, and we are also grateful to all the contributors. +## Prepare a change -The following is a contribution guide for HugeGraph: +- Read [AGENTS.md](../AGENTS.md) and the guidance for the modules you will change. + The same repository rules apply to changes made by people and coding agents. +- Work on a topic branch or isolated worktree based on the intended target branch. + Preserve existing uncommitted work; stage only files belonging to your change. +- Check existing implementations and dependencies before adding new abstractions or libraries. + Keep unrelated cleanup out of the PR. +- Follow [.editorconfig](../.editorconfig) and [Checkstyle](../style/checkstyle.xml): + four-space indentation, 120-column Java lines, and no wildcard imports. -image +## Build and validate -## 1. Preparation +Use Java 11+ and Maven 3.5+. Compiler settings and project versions are defined in +[pom.xml](../pom.xml); module guidance describes test profiles and service prerequisites. +Run commands from the repository root. -**Recommended**: You can use [GitHub desktop](https://desktop.github.com/) to greatly simplify the PR process. +```bash +# Build the server and its dependencies without running tests +mvn clean install -pl hugegraph-server -am -DskipTests -We can contribute by reporting issues, submitting code patches or any other feedback. - -Before submitting the code, we need to do some preparation: - -1. Sign up or login to GitHub: [https://github.com](https://github.com) - -2. Fork HugeGraph repo from GitHub: [https://github.com/apache/hugegraph/fork](https://github.com/apache/hugegraph/fork) - -3. Clone code from fork repo to local: [https://github.com/${GITHUB_USER_NAME}/hugegraph](https://github.com/${GITHUB_USER_NAME}/hugegraph) - - ```shell - # clone code from remote to local repo - git clone https://github.com/${GITHUB_USER_NAME}/hugegraph.git hugegraph - ``` - -4. Configure local HugeGraph repo - - ```shell - cd hugegraph - - # add upstream to synchronize the latest code - git remote add hugegraph https://github.com/apache/hugegraph - - # set name and email to push code to github - git config user.name "{full-name}" # like "Jermy Li" - git config user.email "{email-address-of-github}" # like "jermy@apache.org" - ``` - -## 2. Create an Issue on GitHub - -If you encounter bugs or have any questions, please go to [GitHub Issues](https://github.com/apache/hugegraph/issues) to report them and feel free to [create an issue](https://github.com/apache/hugegraph/issues/new). - -## 3. Make changes of code locally - -#### 3.1 Create a new branch - -Please don't use master branch for development. Instead, we should create a new branch: - -```shell -# checkout master branch -git checkout master -# pull the latest code from official hugegraph -git pull hugegraph -# create new branch: bugfix-branch -git checkout -b bugfix-branch +# Before pushing code, format and compile +mvn editorconfig:format +mvn clean compile -Dmaven.javadoc.skip=true ``` -#### 3.2 Change the code - -Assume that we need to modify some files like "HugeGraph.java" and "HugeFactory.java": - -```shell -# modify code to fix a bug -vim hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/HugeGraph.java -vim hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/HugeFactory.java -# run test locally (optional) -mvn test -Pcore-test,memory -``` -Note: Code style is defined by the `.editorconfig` file at the repository root. Checkstyle rules are defined in `style/checkstyle.xml`. Configure your IDE accordingly. - -##### 3.2.1 Check licenses -If we want to add new third-party dependencies to the `HugeGraph` project, we need to do the following things: -1. Find the third-party dependent repository, put the dependent `license` file into [./install-dist/release-docs/licenses/](https://github.com/apache/hugegraph/tree/master/install-dist/release-docs/licenses) path. -2. Declare the dependency in [./install-dist/release-docs/LICENSE](https://github.com/apache/hugegraph/blob/master/install-dist/release-docs/LICENSE) `LICENSE` information. -3. Find the NOTICE file in the repository and append it to [./install-dist/release-docs/NOTICE](https://github.com/apache/hugegraph/blob/master/install-dist/release-docs/NOTICE) file (skip this step if there is no NOTICE file). -4. Execute locally [./install-dist/scripts/dependency/regenerate_known_dependencies.sh](https://github.com/apache/hugegraph/blob/master/install-dist/scripts/dependency/regenerate_known_dependencies.sh) to update the dependency list [known-dependencies.txt](https://github.com/apache/hugegraph/blob/master/install-dist/scripts/dependency/known-dependencies.txt) (or manually update). - -**Example**: A new third-party dependency is introduced into the project -> `ant-1.9.1.jar` -- The project source code is located at: https://github.com/apache/ant/tree/rel/1.9.1 -- LICENSE file: https://github.com/apache/ant/blob/rel/1.9.1/LICENSE -- NOTICE file: https://github.com/apache/ant/blob/rel/1.9.1/NOTICE - -The license information of `ant-1.9.1.jar` needs to be specified in the LICENSE file, and the notice information needs to be specified in the NOTICE file. The detailed LICENSE file corresponding to ant-1.9.1.jar needs to be copied to our licenses/ directory. Finally, update the known-dependencies.txt file. - -#### 3.3 Commit changes to git repo - -After the code has been completed, we submit them to the local git repo: - -```shell -# add files to local git index -git add hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/HugeGraph.java -git add hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/HugeFactory.java -# commit to local git repo -git commit -``` - -Please edit the commit message after running `git commit`, we can explain what and how to fix a bug or implement a feature, the following is an example: - -```sh -Fix bug: run deploy multiple times - -fix #ISSUE_ID -``` - -> Please remember to fill in the issue id, which GitHub generated after issue creation. - -#### 3.4 Push commit to GitHub fork repo - -Push the local commit to GitHub fork repo: - -```shell -# push the local commit to fork repo -git push origin bugfix-branch:bugfix-branch -``` - -Note that since GitHub requires submitting code through `username + token` (instead of using `username + password` directly), you need to create a GitHub token from https://github.com/settings/tokens: -image - -## 4. Create a Pull Request - -Go to the web page of GitHub fork repo, there would be a chance to create a Pull Request after pushing to a new branch, click the button "Compare & pull request" to do it. Then edit the description for proposed changes, which can just be copied from the commit message. - -User-visible feature, configuration or deployment behavior changes must ship with matching -documentation. Include repository documentation in the same PR. When website documentation -is affected, link a paired [hugegraph-doc](https://github.com/apache/hugegraph-doc) PR and -coordinate both merges. A follow-up issue alone does not replace required documentation. -In the PR template, mark pending documentation as `Doc - TODO` until it is ready; -use `Doc - Done` with the documentation files or PR link, or `Doc - No Need` for internal-only changes. - -Note: please make sure the email address you used to submit the code is bound to the GitHub account. For how to bind the email address, please refer to https://github.com/settings/emails: -image - -## 5. Code review - - Maintainers will start the code review after all the **automatic** checks are passed: - -- Check: Contributor License Agreement is signed -- Check: Travis CI builds are passed (automatically Test and Deploy) - -The commit will be accepted and merged if there is no problem after review. - -Please click on "Details" to find the problem if any check does not pass. - -If there are checks not passed or changes requested, then continue to modify the code and push again. - -### CI runs and automatic retries - -PR test workflows keep only the newest run for each workflow and PR. Updating a PR -cancels its older queued or running checks; push, release and manual runs remain independent. -Reusable workflows inherit cancellation from their caller and do not add a competing group. - -Automatic retries are limited to failed jobs within the existing retry budget. After the -retry delay, CI checks that the run is still failed and that the PR is open at the same -head commit, or that the pushed branch still points to that commit. Obsolete runs and -runs whose state cannot be verified are skipped. This policy applies to YAML workflows; -GitHub-managed Copilot and automatic dependency submission runs are configured separately. - -## 6. Further changes after review - -If we have not passed the review, don't be discouraged. Usually a commit needs to be reviewed several times before being accepted! Please follow the review comments and make further changes. - -After the further changes, we submit them to the local repo: - -```shell -# commit all updated files in a new commit, -# please feel free to enter any appropriate commit message, note that -# we will squash all commits in the pull request as one commit when -# merging into the master branch. -git commit -a -``` - -> If there are conflicts that prevent the code from being merged, we need to rebase on master branch: -> -> ```shell -> # synchronize the latest code -> git checkout master -> git pull hugegraph -> # rebase on master -> git checkout bugfix-branch -> git rebase -i master -> ``` - -And push it to GitHub fork repo again: - -```shell -# force push the local commit to fork repo -git push -f origin bugfix-branch:bugfix-branch -``` - -GitHub will automatically update the Pull Request after we push it, wait for code review. - -For Any question, please contact us through [dev@hugegraph.apache.org](mailto:dev@hugegraph.apache.org) ([subscriber](https://hugegraph.apache.org/docs/contribution-guidelines/subscribe/) only) - - +Run the affected module tests and add regression coverage when behavior changes. +A successful build with skipped tests does not validate behavior; Commons tests require +`-DskipCommonsTests=false`. For documentation-only changes, verify links and paths and run +`git diff --check`. Inspect formatter output before staging to avoid unrelated edits. + +See [CI policy](ci.md) for affected-module selection, required checks, result reuse, +and automatic cancellation and retries. Report what you actually validated and any limitations. + +## Documentation and dependencies + +- User-visible features, configuration and deployment changes need matching repository + documentation in the same PR. When website documentation is affected, link a paired + [apache/hugegraph-doc](https://github.com/apache/hugegraph-doc) PR and coordinate both merges. + A follow-up issue does not replace the required documentation. +- In the PR template, use `Doc - TODO` while documentation is pending, `Doc - Done` with + its location when ready, or `Doc - No Need` for internal-only changes. +- New or updated dependencies need appropriate license and notice updates in the root + and [release documentation](../install-dist/release-docs/), including bundled license files. + Update the [dependency inventory](../install-dist/scripts/dependency/known-dependencies.txt) + using [the regeneration script](../install-dist/scripts/dependency/regenerate_known_dependencies.sh). + +## Submit and review + +Use `type(scope): description` for commits; omit the scope when it is unclear. +Types are `feat`, `fix`, `chore`, `docs`, `refactor`, or `BREAKING CHANGE`. +Start the description with a lowercase verb, keep it under 50 characters, and include +three to five core change points in the body. For example: `fix(server): handle missing vertices`. + +Push your topic branch and open or update a PR against the intended upstream branch. +Use the [PR template](../.github/PULL_REQUEST_TEMPLATE.md) to explain the problem, +the resulting behavior, relevant validation, and documentation status. Link the related +issue when applicable. A short example or diagram is useful when it clarifies the change; +commit histories and raw test logs are unnecessary. + +Address review findings within the agreed scope and validate the affected paths again. +Resolve a discussion after its issue is fixed or clarified. Inspect failing CI jobs before +retrying; a rerun is appropriate for a temporary infrastructure failure, not a reproducible bug. diff --git a/docs/ci.md b/docs/ci.md new file mode 100644 index 0000000000..ee476eb3cc --- /dev/null +++ b/docs/ci.md @@ -0,0 +1,54 @@ +# CI policy + +Pull requests and supported branch pushes enter `HugeGraph-Server CI`. The workflow +always reports `affected-module-tests`; license checks run independently. Module +workflows are reusable and can also be started manually. + +| Changed inputs | Required Linux coverage | +| --- | --- | +| Server private implementation | Memory, RocksDB and Cluster | +| Server core, API or shared tests | Memory, RocksDB, HStore and Cluster | +| HStore adapter | PD/Store/HStore suite and Cluster | +| Distribution or shared startup scripts | Server, PD/Store/HStore, Docker and Cluster | +| Commons or Struct | Their tests and affected Server, PD, Store, HStore and Cluster tests | +| PD or Store | PD/Store/HStore suite and Cluster | +| Cluster, Docker or Helm | The corresponding suite | +| A module workflow | Its suite and known downstream suites | +| Dependencies, shared build inputs or unknown paths | Conservative full coverage | + +The selector follows dependency edges. PD, Store, HStore and Struct share one suite +in this first stage. Selected tests must succeed; failure, cancellation or an +unexpected skip cannot satisfy the gate. Startup prerequisites are enforced. + +HBase, macOS, RISC-V and CodeQL run after the core gate. Their results remain visible, +but they are outside `affected-module-tests`. This orders work within a PR; it does +not grant runner priority over other PRs. Existing scheduled CodeQL scanning remains. + +## Documentation updates + +Only explicitly allowed prose and static documentation assets qualify. Source, +types, tests, dependencies and CI configuration never qualify as documentation. +An unchanged test input can reuse a receipt from the same open PR only when its +base, source repository and CI policy match. GitHub job records must prove the +selected suites completed successfully. The current commit gets a fresh gate and +summary identifying executed and reused suites. Missing or unverifiable evidence +causes the tests to run again. + +The plan and successful receipt are saved as `ci-plan` and `ci-test-receipt` +artifacts. They are evidence, not credentials; each reused proof is checked against +GitHub rather than trusted solely because an artifact exists. + +## Protection migration + +`.asf.yaml` requests `check-license` and `affected-module-tests`. Temporary memory +and Java analysis aliases preserve the previous protection names. Leave +`CI_OPTIMIZED_REQUIRED` unset until the live branch protection uses the new gate; +CodeQL continues scanning every update during this transition. Setting the +repository variable to `true` then permits unchanged documentation updates to +avoid redundant security scanning. Enabling the variable is a separate rollout +operation. + +Automatic retry verifies the run and PR/branch before and after its existing wait. +A moved PR base or missing immutable plan also prevents an obsolete retry. +A retry has a separate concurrency group, so retrying an old commit cannot cancel +the newest PR run. Only failed jobs are retried. From a23281d0091fd69265e9c074cd6a847d5a8fe84d Mon Sep 17 00:00:00 2001 From: imbajin Date: Tue, 6 Oct 2026 01:32:02 +0800 Subject: [PATCH 3/7] fix(ci): isolate license check reruns - preserve cancellation of superseded PR checks - separate reruns from current-head license checks - retain independent non-PR execution groups --- .github/workflows/licence-checker.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/licence-checker.yml b/.github/workflows/licence-checker.yml index 491ccf57b5..c62e9437fa 100644 --- a/.github/workflows/licence-checker.yml +++ b/.github/workflows/licence-checker.yml @@ -9,7 +9,7 @@ on: # Keep the newest PR run; preserve independent push, release and manual runs. concurrency: - group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.run_id }} + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.run_id }}-${{ github.run_attempt == 1 && 'head' || format('rerun-{0}', github.run_id) }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: From 105761ca950416e85ae5eb29dad80c97f64d7201 Mon Sep 17 00:00:00 2001 From: imbajin Date: Tue, 6 Oct 2026 03:59:02 +0800 Subject: [PATCH 4/7] fix(ci): restore coverage and verified reuse - cover Server dependencies and deployment contracts - verify optional proofs across documentation updates - run post-gate checks after intentionally skipped suites - document migration limits and pending runtime validation --- .github/scripts/ci-policy.py | 205 +++++++++++++++++++----- .github/scripts/test_ci_policy.py | 220 +++++++++++++++++++++++++- .github/workflows/docker-build-ci.yml | 17 ++ .github/workflows/server-ci.yml | 8 +- docs/ci.md | 17 +- 5 files changed, 415 insertions(+), 52 deletions(-) diff --git a/.github/scripts/ci-policy.py b/.github/scripts/ci-policy.py index 821b8f1ab7..bf17ff6940 100644 --- a/.github/scripts/ci-policy.py +++ b/.github/scripts/ci-policy.py @@ -17,6 +17,7 @@ """Conservative CI selection and GitHub-verified test receipt reuse.""" import argparse +import base64 import hashlib import io import json @@ -39,7 +40,7 @@ "hugegraph-spark-connector/": "spark", "hugegraph-hubble/": "hubble"}, } WORKFLOWS = { - "server": {"server-ci.yml": ["server"], "commons-ci.yml": ["commons"], + "server": {"server-ci.yml": MODULES["server"], "commons-ci.yml": ["commons"], "pd-store-ci.yml": ["pd", "store", "hstore"], "cluster-test-ci.yml": ["cluster"], "docker-build-ci.yml": ["docker"], "helm-chart-ci.yml": ["helm"], "codeql-analysis.yml": [], "riscv64-ci.yml": ["server"], "check-dependencies.yml": ["dependency_license"]}, @@ -51,7 +52,7 @@ DEPENDENTS = { "server": {"commons": ["server", "pd", "store", "hstore", "cluster"], "struct": ["server", "pd", "store", "hstore", "cluster"], - "server": ["cluster"], "pd": ["store", "hstore", "cluster"], + "server": ["pd", "store", "hstore", "cluster"], "pd": ["store", "hstore", "cluster"], "store": ["hstore", "cluster"], "hstore": ["cluster"]}, "toolchain": {"client": ["loader", "tools", "spark", "hubble"], "loader": ["hubble"]}, } @@ -105,9 +106,18 @@ def select(project, paths): continue if path.endswith(".proto"): return set(MODULES[project]) + if project == "server" and path == "hugegraph-commons/hugegraph-common/src/main/resources/version.properties": + selected.update(["commons", "docker"]) + continue + if project == "server" and path == "hugegraph-store/hg-store-dist/src/assembly/static/bin/util.sh": + selected.update(["store", "docker"]) + continue + if project == "toolchain" and path.startswith("hugegraph-client/assembly/travis/"): + selected.update(["client", "go"]) + continue if project == "server": if path.startswith("hugegraph-server/hugegraph-hstore/"): - selected.add("hstore") + selected.update(["server", "hstore"]) continue if path.startswith("hugegraph-server/hugegraph-dist/"): selected.update(["server", "pd", "store", "hstore", "docker", "cluster"]) @@ -131,7 +141,26 @@ def select(project, paths): return selected -def fingerprint(ref, policy=False): +def external_context(inputs, selected): + context = dict(inputs or {}) + if "hubble" not in selected: + context.pop("hubble", None) + return json.dumps(context, sort_keys=True, separators=(",", ":")) + + +def packaged_readme_state(ref): + path = "hugegraph-hubble/README.md" + entry = git("ls-tree", ref, "--", path) + if not entry: + return "missing" + mode, kind, blob = entry.split("\t", 1)[0].split() + if mode != "100644" or kind != "blob": + return "invalid-mode:" + mode + ":" + blob + content = subprocess.check_output(["git", "cat-file", "blob", blob]) + return "valid" if content.strip() else "empty:" + blob + + +def fingerprint(ref, policy=False, project=None): """Hash blob identities, including paths; exclude only the strict prose whitelist.""" entries = subprocess.check_output(["git", "ls-tree", "-rz", "--full-tree", ref]).split(b"\0") digest = hashlib.sha256() @@ -146,10 +175,14 @@ def fingerprint(ref, policy=False): elif documentation(path, metadata.split(b" ", 1)[0].decode()): continue digest.update(metadata + b"\t" + name + b"\0") + if project == "toolchain" and not policy: + state = packaged_readme_state(ref) + if state != "valid": + digest.update(("required-packaged-readme:" + state).encode()) return digest.hexdigest() -def remote_fingerprint(repository, sha, policy_only=False, fetch=api): +def remote_fingerprint(repository, sha, policy_only=False, fetch=api, project=None): tree = fetch(f"repos/{repository}/git/trees/{sha}?recursive=1") if tree.get("truncated"): raise ValueError("truncated proof tree") @@ -165,6 +198,17 @@ def remote_fingerprint(repository, sha, policy_only=False, fetch=api): continue digest.update((entry["mode"] + " " + entry["type"] + " " + entry["sha"] + "\t" + path + "\0").encode()) + if project == "toolchain" and not policy_only: + entry = next((item for item in tree["tree"] if item["path"] == "hugegraph-hubble/README.md"), None) + state = "missing" + if entry: + if entry["mode"] != "100644" or entry["type"] != "blob": + state = "invalid-mode:" + entry["mode"] + ":" + entry["sha"] + else: + blob = fetch(f"repos/{repository}/git/blobs/{entry['sha']}") + state = "valid" if base64.b64decode(blob["content"]).strip() else "empty:" + entry["sha"] + if state != "valid": + digest.update(("required-packaged-readme:" + state).encode()) return digest.hexdigest() @@ -218,6 +262,29 @@ def successful_jobs(repository, run_id, suite, fetch=api): return sorted(job["id"] for job in matching) +def validate_proof_run(plan, item, fetch=api): + run = fetch(f"repos/{plan['repository']}/actions/runs/{int(item['runID'])}") + if (run.get("status") != "completed" + or run.get("event") != "pull_request" or run.get("head_sha") != item.get("head") + or run.get("workflow_id") != plan["workflowID"] + or run.get("head_repository", {}).get("full_name") != plan["source"] + or run.get("head_branch") != plan["branch"]): + raise ValueError("proof run is not a successful trusted workflow") + if not proof_pr(plan, run, fetch): + raise ValueError("proof run lacks matching PR association") + merge = fetch(f"repos/{plan['repository']}/git/commits/{item['testedMergeSHA']}") + if [parent["sha"] for parent in merge.get("parents", [])] != [plan["base"], item["head"]]: + raise ValueError("tested merge parents differ") + actual_input = remote_fingerprint(plan["repository"], item["testedMergeSHA"], fetch=fetch, project=plan["project"]) + if plan["project"] == "toolchain": + context = external_context(plan.get("externalInputs", {}), plan["selected"]) + actual_input = hashlib.sha256((actual_input + context).encode()).hexdigest() + if (actual_input != plan["inputFingerprint"] or remote_fingerprint( + plan["repository"], item["testedMergeSHA"], policy_only=True, fetch=fetch) + != plan["policyFingerprint"]): + raise ValueError("actual tested inputs differ from claimed receipt") + + def validate_receipt(plan, receipt, fetch=api): for key in ["schema", "repository", "project", "pr", "source", "branch", "base", "inputFingerprint", "policyFingerprint"]: if receipt.get(key) != plan.get(key): @@ -229,27 +296,8 @@ def validate_receipt(plan, receipt, fetch=api): verified = {} for suite in required: item = proof[suite] - run = fetch(f"repos/{plan['repository']}/actions/runs/{int(item['runID'])}") - if (run.get("status") != "completed" - or run.get("event") != "pull_request" or run.get("head_sha") != item.get("head") - or run.get("workflow_id") != plan["workflowID"] - or run.get("head_repository", {}).get("full_name") != plan["source"] - or run.get("head_branch") != plan["branch"]): - raise ValueError("proof run is not a successful trusted workflow") + validate_proof_run(plan, item, fetch) successful_jobs(plan["repository"], item["runID"], "affected-module-tests", fetch) - if not proof_pr(plan, run, fetch): - raise ValueError("proof run lacks matching PR association") - merge = fetch(f"repos/{plan['repository']}/git/commits/{item['testedMergeSHA']}") - if [parent["sha"] for parent in merge.get("parents", [])] != [plan["base"], item["head"]]: - raise ValueError("tested merge parents differ") - actual_input = remote_fingerprint(plan["repository"], item["testedMergeSHA"], fetch=fetch) - if plan["project"] == "toolchain": - context = json.dumps(plan.get("externalInputs", {}), sort_keys=True, separators=(",", ":")) - actual_input = hashlib.sha256((actual_input + context).encode()).hexdigest() - if (actual_input != plan["inputFingerprint"] or remote_fingerprint( - plan["repository"], item["testedMergeSHA"], policy_only=True, fetch=fetch) - != plan["policyFingerprint"]): - raise ValueError("actual tested inputs differ from claimed receipt") ids = successful_jobs(plan["repository"], item["runID"], suite, fetch) if ids != item.get("jobIDs"): raise ValueError("actual job evidence differs") @@ -257,13 +305,94 @@ def validate_receipt(plan, receipt, fetch=api): return verified +PROVENANCE = ["schema", "repository", "project", "pr", "source", "branch", "base", + "inputFingerprint", "policyFingerprint"] + + +def optional_groups(plan): + if plan["project"] == "toolchain": + return {"security": {"security / Analyze (java)", "security / Analyze (javascript)", + "security / Analyze (python)", "security / dependency-review"}} + runtime = (Path(__file__).resolve().parents[1] / "workflows/.java-version").read_text().strip() + return {"security": {"codeql / Analyze (java)"}, + "compatibility": {f"HBase compatibility (Java {runtime})", + "build-server-macos-rocksdb (macos-15-intel)", + "build-server-macos-rocksdb (macos-15, -Xms512m -Xmx2g)", + "build-server-riscv64 / build-server-riscv64"}} + + +def optional_group_jobs(jobs, group): + def belongs(name): + if group == "security": + return name.startswith(("codeql /", "security /")) + return name.startswith(("HBase compatibility", "build-server-macos-rocksdb", "build-server-riscv64 /")) + return [job for job in jobs if belongs(job.get("name", ""))] + + +def optional_jobs(jobs, names): + # Reject incomplete/duplicate matrices; proof identities must describe the full group. + matching = [job for job in jobs if job.get("name") in names] + if len(matching) != len(names) or {job["name"] for job in matching} != names: + raise ValueError("optional group is incomplete") + if any(job.get("conclusion") != "success" for job in matching): + raise ValueError("optional group did not succeed") + return sorted(matching, key=lambda job: job["name"]) + + +def optional_proofs(plan, receipt, jobs, blocked, fetch=api): + proofs = {} + for group, names in optional_groups(plan).items(): + if group in blocked: + continue + try: + current = optional_group_jobs(jobs, group) + if any(job.get("name") not in names for job in current): + raise ValueError("unknown optional group job") + # Skipped wrapper jobs can forward proof. A partial or failed execution cannot. + executed = [job for job in current if job.get("conclusion") != "skipped"] + if executed: + matching = optional_jobs(jobs, names) + item = {key: receipt[key] for key in PROVENANCE} + item.update(runID=receipt["runID"], head=receipt["head"], + testedMergeSHA=receipt["testedMergeSHA"]) + validate_proof_run(plan, item, fetch) + item.update(jobIDs=[job["id"] for job in matching], jobNames=[job["name"] for job in matching]) + else: + item = receipt.get("optionalProofs", {}).get(group) + if not item: + continue + if any(item.get(key) != plan.get(key) for key in PROVENANCE): + raise ValueError("optional provenance differs") + validate_proof_run(plan, item, fetch) + successful_jobs(plan["repository"], item["runID"], "affected-module-tests", fetch) + original = optional_group_jobs( + pages(f"repos/{plan['repository']}/actions/runs/{item['runID']}/jobs", fetch), group) + if any(job.get("name") not in names for job in original): + raise ValueError("unknown original optional group job") + matching = optional_jobs(original, names) + if (item.get("jobIDs") != [job["id"] for job in matching] + or item.get("jobNames") != [job["name"] for job in matching]): + raise ValueError("optional job evidence differs") + proofs[group] = item + except (subprocess.SubprocessError, OSError, ValueError, KeyError, TypeError, AttributeError): + continue + return proofs + + def find_reuse(plan, fetch=api): """Receipts are uploaded by this workflow, not read from the PR tree.""" repository = plan["repository"] current = fetch(f"repos/{repository}/actions/runs/{os.environ['GITHUB_RUN_ID']}") plan["workflowID"] = current["workflow_id"] - runs = fetch(f"repos/{repository}/actions/workflows/{plan['workflowID']}/runs?" + urlencode({ - "event": "pull_request", "status": "completed", "per_page": 30}))["workflow_runs"] + runs = [] + for page in range(1, 6): + batch = fetch(f"repos/{repository}/actions/workflows/{plan['workflowID']}/runs?" + urlencode({ + "event": "pull_request", "status": "completed", "branch": plan["branch"], + "per_page": 30, "page": page}))["workflow_runs"] + runs.extend(batch) + if len(batch) < 30: + break + blocked_optional = set() for run in runs: if run["id"] == int(os.environ["GITHUB_RUN_ID"]): continue @@ -295,18 +424,14 @@ def find_reuse(plan, fetch=api): successful_jobs(repository, run["id"], "affected-module-tests", fetch) verified = validate_receipt(plan, receipt, fetch) jobs = pages(f"repos/{repository}/actions/runs/{run['id']}/jobs", fetch) - def good_matches(predicate, minimum=1): - matching = [job for job in jobs if predicate(job.get("name", "").lower())] - return len(matching) >= minimum and all(job.get("conclusion") == "success" for job in matching) - plan["optionalSuccess"] = { - "compatibility": (good_matches(lambda n: "hbase compatibility" in n) - and good_matches(lambda n: "macos" in n, 2) - and good_matches(lambda n: "riscv64" in n)), - "security": good_matches(lambda n: n.startswith(("codeql /", "security /"))), - } + plan["optionalProofs"] = optional_proofs(plan, receipt, jobs, blocked_optional, fetch) + plan["optionalSuccess"] = {group: group in plan["optionalProofs"] + for group in ["compatibility", "security"]} return verified except (ValueError, KeyError, TypeError, AttributeError, zipfile.BadZipFile): continue + # Missing/unverifiable evidence from a newer same-PR run is conservative for optional lanes. + blocked_optional.update(optional_groups(plan)) return {} @@ -339,9 +464,12 @@ def create_plan(project, event, repository, fetch=api, external_inputs=None): selected = select(project, paths) if unsafe_documentation(plan["base"], paths) or unsafe_documentation(plan["testedMergeSHA"], paths): selected = set(MODULES[project]) - plan["inputFingerprint"] = fingerprint(plan["testedMergeSHA"]) + if project == "toolchain" and packaged_readme_state(plan["testedMergeSHA"]) != "valid": + selected = set(MODULES[project]) + plan["selected"] = sorted(selected) + plan["inputFingerprint"] = fingerprint(plan["testedMergeSHA"], project=project) if project == "toolchain": - context = json.dumps(external_inputs or {}, sort_keys=True, separators=(",", ":")) + context = external_context(external_inputs, selected) plan["inputFingerprint"] = hashlib.sha256((plan["inputFingerprint"] + context).encode()).hexdigest() plan["policyFingerprint"] = fingerprint(plan["testedMergeSHA"], policy=True) plan["expected"] = suites(project, selected) @@ -399,6 +527,7 @@ def gate(plan, results, fetch=api): except (subprocess.SubprocessError, OSError, ValueError, KeyError, TypeError, AttributeError): pass receipt["proofs"] = proofs + receipt["optionalProofs"] = plan.get("optionalProofs", {}) receipt["executed"] = executed receipt["reused"] = sorted(plan["reused"]) summary = "Selection: " + plan.get("reason", "affected inputs") + "\nExecuted: " + ", ".join(executed) + "\nReused: " + ", ".join(receipt["reused"]) diff --git a/.github/scripts/test_ci_policy.py b/.github/scripts/test_ci_policy.py index 6a46829046..a42a2773aa 100644 --- a/.github/scripts/test_ci_policy.py +++ b/.github/scripts/test_ci_policy.py @@ -42,7 +42,7 @@ def test_dependency_expansion(self): self.assertEqual({"client", "loader", "tools", "spark", "hubble"}, policy.select("toolchain", ["hugegraph-client/src/A.java"])) self.assertEqual({"loader", "hubble"}, policy.select("toolchain", ["hugegraph-loader/src/A.java"])) - self.assertEqual({"server", "cluster"}, policy.select("server", ["hugegraph-server/A.java"])) + self.assertEqual({"server", "pd", "store", "hstore", "cluster"}, policy.select("server", ["hugegraph-server/A.java"])) def test_server_backend_and_startup_dependents(self): selected = policy.select("server", ["hugegraph-server/hugegraph-hstore/src/test/HstoreTableTest.java"]) @@ -56,7 +56,80 @@ def test_server_backend_and_startup_dependents(self): self.assertIn("hstore", selected) self.assertIn("server", selected) self.assertEqual(set(), policy.select("server", ["hugegraph-server/hugegraph-hstore/README.md"])) - self.assertNotIn("hstore", policy.select("server", ["hugegraph-server/hugegraph-rocksdb/src/main/A.java"])) + self.assertIn("hstore", policy.select("server", ["hugegraph-server/hugegraph-rocksdb/src/main/A.java"])) + + def test_audited_consumer_edges(self): + expected = {"server", "pd", "store", "hstore", "cluster"} + for path in ["hugegraph-server/hugegraph-hstore/src/A.java", "hugegraph-server/hugegraph-rocksdb/A.java"]: + self.assertTrue(expected.issubset(policy.select("server", [path]))) + self.assertNotIn("helm", policy.select("server", [path])) + self.assertEqual(set(policy.MODULES["server"]), policy.select("server", [".github/workflows/server-ci.yml"])) + self.assertTrue({"commons", "docker"}.issubset(policy.select("server", [ + "hugegraph-commons/hugegraph-common/src/main/resources/version.properties"]))) + self.assertIn("go", policy.select("toolchain", ["hugegraph-client/assembly/travis/start-hugegraph-servers.sh"])) + self.assertNotIn("go", policy.select("toolchain", ["hugegraph-client/src/A.java"])) + self.assertNotIn("server", policy.select("server", ["hugegraph-pd/src/A.java"])) + self.assertTrue({"store", "docker"}.issubset(policy.select("server", [ + "hugegraph-store/hg-store-dist/src/assembly/static/bin/util.sh"]))) + + def test_version_resource_follows_real_docker_consumer(self): + root = Path(__file__).resolve().parents[2] + consumer = root / ".github/workflows/docker-build-ci.yml" + if not consumer.exists(): + self.skipTest("Server Docker consumer is not in the Toolchain repository") + resource = next(line.strip().rstrip(")") for line in consumer.read_text().splitlines() + if line.strip().startswith("hugegraph-commons/") and "version.properties" in line) + self.assertTrue((root / resource).is_file()) + self.assertTrue({"commons", "docker"}.issubset(policy.select("server", [resource]))) + + def test_external_hubble_baseline_only_affects_hubble(self): + a = {"server": {"sha": "release"}, "hubble": {"sha": "old"}} + b = {"server": {"sha": "release"}, "hubble": {"sha": "new"}} + self.assertEqual(policy.external_context(a, ["go"]), policy.external_context(b, ["go"])) + self.assertNotEqual(policy.external_context(a, ["hubble"]), policy.external_context(b, ["hubble"])) + + def test_packaged_readme_content_contract(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + def git(*args): + return subprocess.check_output(["git", *args], cwd=root, text=True).strip() + git("init", "-q") + git("config", "user.email", "ci@example.invalid") + git("config", "user.name", "CI") + (root / "hugegraph-hubble").mkdir() + readme = root / "hugegraph-hubble/README.md" + readme.write_text("nonempty docs") + git("add", ".") + git("commit", "-qm", "valid readme") + old = os.getcwd() + try: + os.chdir(root) + initial = policy.fingerprint("HEAD", project="toolchain") + readme.write_text("changed nonempty docs") + git("commit", "-qam", "valid docs edit") + self.assertEqual(initial, policy.fingerprint("HEAD", project="toolchain")) + for content in ["", " \t\n"]: + readme.write_text(content) + git("commit", "-qam", "empty docs") + self.assertNotEqual("valid", policy.packaged_readme_state("HEAD")) + self.assertNotEqual(initial, policy.fingerprint("HEAD", project="toolchain")) + readme.write_text("nonempty") + readme.chmod(0o755) + git("add", ".") + git("commit", "-qm", "executable docs") + self.assertNotEqual("valid", policy.packaged_readme_state("HEAD")) + readme.unlink() + readme.symlink_to("missing-target") + git("add", ".") + git("commit", "-qm", "symlink docs") + self.assertNotEqual("valid", policy.packaged_readme_state("HEAD")) + readme.unlink() + git("add", ".") + git("commit", "-qm", "deleted docs") + self.assertEqual("missing", policy.packaged_readme_state("HEAD")) + self.assertNotEqual(initial, policy.fingerprint("HEAD", project="toolchain")) + finally: + os.chdir(old) def test_single_workflow_has_no_global_fanout(self): self.assertEqual({"hubble"}, policy.select("toolchain", [".github/workflows/hubble-ci.yml"])) @@ -162,6 +235,145 @@ def fetch(path, binary=False): with patch.dict(os.environ, {"GITHUB_RUN_ID": "99"}): self.assertEqual({}, policy.find_reuse(self.plan(), fetch)) + def optional_fixture(self, receipts, optional_results=None): + optional_results = optional_results or {} + def fetch(path, binary=False): + if "/actions/workflows/" in path: + return {"workflow_runs": [{"id": run_id, "head_repository": {"full_name": "alice/t"}, + "head_branch": "feature", "pull_requests": [{"number": 7}]} + for run_id in sorted(receipts, reverse=True)]} + if "/artifacts?" in path: + run_id = int(path.split("/runs/")[1].split("/")[0]) + return {"artifacts": [{"name": "ci-test-receipt", "id": run_id, + "expired": False, "size_in_bytes": 512}]} + if "/actions/artifacts/" in path: + run_id = int(path.split("/artifacts/")[1].split("/")[0]) + data = io.BytesIO() + with zipfile.ZipFile(data, "w") as archive: + archive.writestr("receipt.json", json.dumps(receipts[run_id])) + return data.getvalue() + if "/git/commits/" in path: + head = "new" if path.endswith("/merge") else "old" + return {"parents": [{"sha": "base"}, {"sha": head}]} + if "/jobs?" in path: + run_id = int(path.split("/runs/")[1].split("/")[0]) + jobs = [{"name": "affected-module-tests", "id": run_id * 10, "conclusion": "success"}] + if run_id == 42: + jobs.append({"name": "client / client-ci", "id": 123, "conclusion": "success"}) + for index, name in enumerate(sorted(policy.optional_groups(self.plan())["security"])): + result = optional_results.get(run_id, "success" if run_id == 42 else "skipped") + jobs.append({"name": name, "id": run_id * 100 + index, "conclusion": result}) + return {"jobs": jobs} + value = self.fetch(path) + if "/actions/runs/43" in path: + value["head_sha"] = "new" + return value + return fetch + + def find_optional(self, receipts, optional_results=None): + plan = self.plan() + with patch.dict(os.environ, {"GITHUB_RUN_ID": "99"}), patch.object( + policy, "remote_fingerprint", side_effect=lambda *a, **k: "policy" if k.get("policy_only") else "inputs"): + plan["reused"] = policy.find_reuse(plan, self.optional_fixture(receipts, optional_results)) + return plan + + def test_optional_original_proof_survives_two_documentation_updates(self): + original = self.receipt() + original.update(runID=42, head="old", testedMergeSHA="oldmerge") + second = self.find_optional({42: original}) + self.assertTrue(second["optionalSuccess"]["security"]) + self.assertEqual(42, second["optionalProofs"]["security"]["runID"]) + with patch.dict(os.environ, {"GITHUB_RUN_ID": "43"}): + forwarded = policy.gate(second, {"plan": {"result": "success"}}, self.fetch) + third = self.find_optional({43: forwarded, 42: original}) + self.assertTrue(third["optionalSuccess"]["security"]) + self.assertEqual(second["optionalProofs"], third["optionalProofs"]) + self.assertEqual(second["reused"], third["reused"]) + for result in ["failure", "cancelled", "timed_out", None]: + with self.subTest(result=result): + latest = self.find_optional({43: forwarded, 42: original}, {43: result}) + self.assertTrue(latest["reused"]) + self.assertFalse(latest["optionalSuccess"]["security"]) + + def test_newer_unverifiable_receipt_blocks_older_optional_green(self): + original = self.receipt() + original.update(runID=42, head="old", testedMergeSHA="oldmerge") + invalid = dict(original, runID=43, source="other/t") + plan = self.find_optional({43: invalid, 42: original}) + self.assertTrue(plan["reused"]) + self.assertFalse(plan["optionalSuccess"]["security"]) + + def test_server_optional_matrix_follows_project_runtime(self): + plan = dict(self.plan(), project="server") + runtime_file = Path(__file__).resolve().parents[1] / "workflows/.java-version" + if not runtime_file.exists(): + self.skipTest("Server runtime configuration is not in the Toolchain repository") + runtime = runtime_file.read_text().strip() + names = policy.optional_groups(plan)["compatibility"] + self.assertIn(f"HBase compatibility (Java {runtime})", names) + self.assertEqual(4, len(names)) + jobs = [{"name": name, "id": index, "conclusion": "success"} + for index, name in enumerate(sorted(names))] + self.assertEqual(4, len(policy.optional_jobs(jobs, names))) + for missing in range(len(jobs)): + with self.assertRaises(ValueError): + policy.optional_jobs(jobs[:missing] + jobs[missing + 1:], names) + + def test_optional_forwarded_forgery_and_original_failure_reject(self): + original = self.receipt() + original.update(runID=42, head="old", testedMergeSHA="oldmerge") + second = self.find_optional({42: original}) + with patch.dict(os.environ, {"GITHUB_RUN_ID": "43"}): + forwarded = policy.gate(second, {"plan": {"result": "success"}}, self.fetch) + for field in ["jobIDs", "jobNames", "source", "base", "inputFingerprint", "policyFingerprint", "head"]: + changed = json.loads(json.dumps(forwarded)) + changed["optionalProofs"]["security"][field] = [999] if field == "jobIDs" else "forged" + with self.subTest(field=field): + latest = self.find_optional({43: changed, 42: original}) + self.assertTrue(latest["reused"]) + self.assertFalse(latest["optionalSuccess"]["security"]) + latest = self.find_optional({43: forwarded, 42: original}, {42: "failure"}) + self.assertFalse(latest["optionalSuccess"]["security"]) + + def test_optional_group_requires_complete_known_matrix(self): + plan = self.plan() + original = self.receipt() + original.update(runID=42, head="old", testedMergeSHA="oldmerge") + fetch = self.optional_fixture({42: original}) + for mutation in ["missing", "unknown", "duplicate", "api-error"]: + def bad_fetch(path, binary=False): + if "/actions/runs/" in path and mutation == "api-error": + raise OSError("unavailable") + value = fetch(path, binary) + if "/jobs?" in path: + if mutation == "missing": + value["jobs"].pop() + elif mutation == "unknown": + value["jobs"].append({"name": "security / Analyze (unknown)", "id": 999, "conclusion": "success"}) + elif mutation == "duplicate": + value["jobs"].append(dict(value["jobs"][-1])) + return value + jobs = fetch("repos/apache/t/actions/runs/42/jobs?per_page=100&page=1")["jobs"] + if mutation != "api-error": + jobs = bad_fetch("repos/apache/t/actions/runs/42/jobs?per_page=100&page=1")["jobs"] + with patch.object(policy, "remote_fingerprint", side_effect=lambda *a, **k: "policy" if k.get("policy_only") else "inputs"): + self.assertEqual({}, policy.optional_proofs(plan, original, jobs, set(), bad_fetch)) + + def test_branch_filtered_bounded_run_pagination(self): + calls = [] + def fetch(path, binary=False): + if "/actions/workflows/" in path: + calls.append(path) + self.assertIn("branch=feature", path) + if "page=1" in path: + return {"workflow_runs": [{"id": n, "head_repository": {"full_name": "alice/t"}, + "head_branch": "other"} for n in range(30)]} + return {"workflow_runs": []} + return self.fetch(path) + with patch.dict(os.environ, {"GITHUB_RUN_ID": "99"}): + self.assertEqual({}, policy.find_reuse(self.plan(), fetch)) + self.assertEqual(2, len(calls)) + def test_tested_merge_parent_mismatch_rejects(self): def fetch(path): if "/git/commits/" in path: @@ -242,14 +454,14 @@ def git(*args): if args[0] == "diff": return "hugegraph-client/src/A.java\nREADME.md" return "new" - with patch.object(policy, "unsafe_documentation", return_value=False), patch.object(policy, "git", side_effect=git), patch.object(policy, "fingerprint", return_value="hash"), patch.object( + with patch.object(policy, "packaged_readme_state", return_value="valid"), patch.object(policy, "unsafe_documentation", return_value=False), patch.object(policy, "git", side_effect=git), patch.object(policy, "fingerprint", return_value="hash"), patch.object( policy, "find_reuse", return_value={m: {"runID": 42} for m in ["client", "loader", "tools", "spark", "hubble"]}): plan = policy.create_plan("toolchain", event, "apache/t", lambda p: live, external_inputs={"serverSHA": "immutable"}) self.assertFalse(plan["client"]) self.assertFalse(plan["needsFixture"]) self.assertTrue(plan["security"]) - with patch.object(policy, "unsafe_documentation", return_value=False), patch.object(policy, "git", side_effect=git), patch.object(policy, "fingerprint", return_value="hash"), patch.object( + with patch.object(policy, "packaged_readme_state", return_value="valid"), patch.object(policy, "unsafe_documentation", return_value=False), patch.object(policy, "git", side_effect=git), patch.object(policy, "fingerprint", return_value="hash"), patch.object( policy, "find_reuse", return_value={}): plan = policy.create_plan("toolchain", event, "apache/t", lambda p: live, external_inputs={"serverSHA": "immutable"}) diff --git a/.github/workflows/docker-build-ci.yml b/.github/workflows/docker-build-ci.yml index fd5b8bc31a..bbe99acea0 100644 --- a/.github/workflows/docker-build-ci.yml +++ b/.github/workflows/docker-build-ci.yml @@ -88,6 +88,23 @@ jobs: all(. == "docker")) ' /tmp/hugegraph-bake.json + deployment-contracts: + runs-on: ubuntu-latest + timeout-minutes: 25 + steps: + - uses: actions/checkout@v7 + - name: Verify deployment shell contracts + run: | + bash hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh + bash hugegraph-server/hugegraph-dist/docker/docker-entrypoint-test.sh + bash hugegraph-server/hugegraph-dist/src/assembly/travis/test-check-port.sh \ + hugegraph-server/hugegraph-dist/src/assembly/static \ + hugegraph-store/hg-store-dist/src/assembly/static/bin/util.sh + - name: Verify Compose contracts + run: bash docker/test-compose.sh render + - name: Run Compose auth-on smoke tests + run: bash docker/test-compose.sh smoke + docker-build: runs-on: ubuntu-latest strategy: diff --git a/.github/workflows/server-ci.yml b/.github/workflows/server-ci.yml index e5a98a9dcf..30351653f7 100644 --- a/.github/workflows/server-ci.yml +++ b/.github/workflows/server-ci.yml @@ -384,7 +384,7 @@ jobs: build-server-backends: name: HBase compatibility (Java ${{ matrix.JAVA_VERSION }}) needs: [plan, java-runtime, affected-module-tests] - if: needs.plan.outputs.compatibility == 'true' + if: ${{ !cancelled() && needs.plan.result == 'success' && needs.affected-module-tests.result == 'success' && needs.plan.outputs.compatibility == 'true' }} runs-on: ubuntu-24.04 env: *linux-server-env strategy: @@ -396,7 +396,7 @@ jobs: build-server-macos-rocksdb: needs: [plan, affected-module-tests] - if: needs.plan.outputs.compatibility == 'true' + if: ${{ !cancelled() && needs.plan.result == 'success' && needs.affected-module-tests.result == 'success' && needs.plan.outputs.compatibility == 'true' }} runs-on: ${{ matrix.os }} strategy: fail-fast: false @@ -474,14 +474,14 @@ jobs: # Keep the emulated Java 17 RISC-V native-runtime signal visible while the # platform lane stabilizes, but do not use it as a gate yet. needs: [plan, affected-module-tests] - if: needs.plan.outputs.compatibility == 'true' + if: ${{ !cancelled() && needs.plan.result == 'success' && needs.affected-module-tests.result == 'success' && needs.plan.outputs.compatibility == 'true' }} uses: ./.github/workflows/riscv64-ci.yml permissions: contents: read codeql: needs: [plan, affected-module-tests] - if: vars.CI_OPTIMIZED_REQUIRED != 'true' || needs.plan.outputs.security == 'true' + if: ${{ !cancelled() && needs.plan.result == 'success' && needs.affected-module-tests.result == 'success' && (vars.CI_OPTIMIZED_REQUIRED != 'true' || needs.plan.outputs.security == 'true') }} uses: ./.github/workflows/codeql-analysis.yml permissions: contents: read diff --git a/docs/ci.md b/docs/ci.md index ee476eb3cc..3ab5eb5735 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -6,13 +6,13 @@ workflows are reusable and can also be started manually. | Changed inputs | Required Linux coverage | | --- | --- | -| Server private implementation | Memory, RocksDB and Cluster | -| Server core, API or shared tests | Memory, RocksDB, HStore and Cluster | -| HStore adapter | PD/Store/HStore suite and Cluster | +| Any non-document Server module input, including RocksDB and HStore | Memory, RocksDB, PD/Store/HStore and Cluster | | Distribution or shared startup scripts | Server, PD/Store/HStore, Docker and Cluster | | Commons or Struct | Their tests and affected Server, PD, Store, HStore and Cluster tests | | PD or Store | PD/Store/HStore suite and Cluster | -| Cluster, Docker or Helm | The corresponding suite | +| Cluster, Docker or Helm | The corresponding suite; Docker includes Compose render/smoke and startup contracts | +| Store port utility | PD/Store/HStore, Cluster and Docker startup contracts | +| Central `server-ci.yml` | All Linux suites controlled by the caller | | A module workflow | Its suite and known downstream suites | | Dependencies, shared build inputs or unknown paths | Conservative full coverage | @@ -21,7 +21,8 @@ in this first stage. Selected tests must succeed; failure, cancellation or an unexpected skip cannot satisfy the gate. Startup prerequisites are enforced. HBase, macOS, RISC-V and CodeQL run after the core gate. Their results remain visible, -but they are outside `affected-module-tests`. This orders work within a PR; it does +but they are outside `affected-module-tests`. Post-gate conditions explicitly +require a successful plan and gate, including when unrelated modules were skipped. This orders work within a PR; it does not grant runner priority over other PRs. Existing scheduled CodeQL scanning remains. ## Documentation updates @@ -30,9 +31,13 @@ Only explicitly allowed prose and static documentation assets qualify. Source, types, tests, dependencies and CI configuration never qualify as documentation. An unchanged test input can reuse a receipt from the same open PR only when its base, source repository and CI policy match. GitHub job records must prove the -selected suites completed successfully. The current commit gets a fresh gate and +selected suites completed successfully. Receipt lookup is restricted to the PR +branch and bounded pages; repository and PR provenance are still checked. The current commit gets a fresh gate and summary identifying executed and reused suites. Missing or unverifiable evidence causes the tests to run again. +For consecutive documentation updates, optional-check proofs retain their original +run and exact job identities and are reverified before forwarding. A newer failed +check or unverifiable evidence prevents reuse of an older optional success. The plan and successful receipt are saved as `ci-plan` and `ci-test-receipt` artifacts. They are evidence, not credentials; each reused proof is checked against From 77148c7ba93b2eba39879acfbffabc53151699dd Mon Sep 17 00:00:00 2001 From: imbajin Date: Tue, 6 Oct 2026 04:27:49 +0800 Subject: [PATCH 5/7] fix(ci): validate current deployment images - keep third-party review outside the required gate - share four image builds and verify their runtime inputs - check service health, readiness and authentication - retain Hubble smoke and document deployment coverage --- .github/scripts/check-docker-images.sh | 81 ++++++++++++ .github/scripts/ci-policy.py | 3 +- .github/scripts/docker-deployment.py | 146 ++++++++++++++++++++++ .github/scripts/test_ci_policy.py | 22 +++- .github/scripts/test_docker_deployment.py | 145 +++++++++++++++++++++ .github/workflows/docker-build-ci.yml | 98 +++------------ .github/workflows/server-ci.yml | 2 +- docs/ci.md | 17 ++- 8 files changed, 428 insertions(+), 86 deletions(-) create mode 100644 .github/scripts/check-docker-images.sh create mode 100644 .github/scripts/docker-deployment.py create mode 100644 .github/scripts/test_docker_deployment.py diff --git a/.github/scripts/check-docker-images.sh b/.github/scripts/check-docker-images.sh new file mode 100644 index 0000000000..15ac6f9195 --- /dev/null +++ b/.github/scripts/check-docker-images.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +set -euo pipefail +: "${IMAGE_TAG:?set the unique CI image tag}" +for pair in \ + hugegraph-pd/Dockerfile=hugegraph/pd \ + hugegraph-store/Dockerfile=hugegraph/store \ + hugegraph-server/Dockerfile=hugegraph/hugegraph \ + hugegraph-server/Dockerfile-hstore=hugegraph/server; do + ( + dockerfile="${pair%%=*}" + IMAGE_ID=$(docker image inspect --format '{{.Id}}' "${pair#*=}:${IMAGE_TAG}") + echo "Checking ${pair#*=}:${IMAGE_TAG} ($IMAGE_ID)" + HC=$(docker image inspect --format '{{json .Config.Healthcheck}}' "$IMAGE_ID") + [[ "$HC" != null ]] || { echo "Missing HEALTHCHECK: $dockerfile"; exit 1; } + + JAVA_VERSION=$(docker run --rm --pull never --entrypoint java "$IMAGE_ID" -version 2>&1) + echo "$JAVA_VERSION" + grep -Eq 'version "17\.' <<< "$JAVA_VERSION" || { + echo "ERROR: expected a Java 17 runtime in $dockerfile" + exit 1 + } + + if [[ "$dockerfile" == hugegraph-server/* ]]; then + STATE=$(docker run --rm --pull never "$IMAGE_ID" bash -c \ + 'source /hugegraph-server/bin/util.sh && port_listen_state 8080') + echo "port_listen_state 8080 -> $STATE" + # Require a usable answer from the socket-table tool in the image. + [[ "$STATE" == "free" || "$STATE" == "busy" ]] || { + echo "ERROR: no usable socket-table tool (ss/netstat) in $dockerfile" + exit 1 + } + fi + + if [[ "$dockerfile" == hugegraph-server/* ]]; then + CHECK_DIR=$(mktemp -d) + trap 'rm -rf "$CHECK_DIR"' EXIT + docker run --rm --pull never --entrypoint bash \ + -v "$CHECK_DIR:/check" "$IMAGE_ID" -c \ + 'cp /hugegraph-server/lib/hugegraph-api-*.jar \ + /hugegraph-server/lib/hugegraph-common-*.jar /check/' + + API_JAR=$(find "$CHECK_DIR" -name 'hugegraph-api-*.jar' -print -quit) + COMMON_JAR=$(find "$CHECK_DIR" -name 'hugegraph-common-*.jar' -print -quit) + EXPECTED_MANIFEST=$(sed -n \ + 's|.*\([^<]*\).*|\1|p' \ + hugegraph-server/hugegraph-api/pom.xml) + ACTUAL_MANIFEST=$(unzip -p "$API_JAR" META-INF/MANIFEST.MF | + sed -n 's/^Implementation-Version: *//p' | tr -d '\r') + EXPECTED_PROPERTY=$(sed -n 's/^ApiVersion=//p' \ + hugegraph-commons/hugegraph-common/src/main/resources/version.properties) + ACTUAL_PROPERTY=$(unzip -p "$COMMON_JAR" version.properties | + sed -n 's/^ApiVersion=//p' | tr -d '\r') + + [[ "$ACTUAL_MANIFEST" == "$EXPECTED_MANIFEST" ]] || { + echo "ERROR: API manifest is $ACTUAL_MANIFEST; expected $EXPECTED_MANIFEST" + exit 1 + } + [[ "$ACTUAL_PROPERTY" == "$EXPECTED_PROPERTY" ]] || { + echo "ERROR: API property is $ACTUAL_PROPERTY; expected $EXPECTED_PROPERTY" + exit 1 + } + fi + ) +done diff --git a/.github/scripts/ci-policy.py b/.github/scripts/ci-policy.py index bf17ff6940..09f523a632 100644 --- a/.github/scripts/ci-policy.py +++ b/.github/scripts/ci-policy.py @@ -235,7 +235,8 @@ def suites(project, selected): result = [] if "server" in selected: result.extend(["server_memory", "server_rocksdb"]) - for module in ["commons", "cluster", "docker", "helm", "dependency_license"]: + # Third-party inventory/review remains visible without becoming a merge gate. + for module in ["commons", "cluster", "docker", "helm"]: if module in selected: result.append(module) if selected.intersection({"pd", "store", "hstore", "struct"}): diff --git a/.github/scripts/docker-deployment.py b/.github/scripts/docker-deployment.py new file mode 100644 index 0000000000..3b1283b1e5 --- /dev/null +++ b/.github/scripts/docker-deployment.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Start the four locally built PR images using the shipped Compose files.""" + +import base64 +import json +import os +from pathlib import Path +import subprocess +import sys +import urllib.error +import urllib.request + + +ADMIN_PASSWORD = "ci-compose-password" +PD_PASSWORD = "ci-compose-pd-secret" + + +def command(*args): + return subprocess.check_output(args, text=True, timeout=60).strip() + + +def verify_image(container, image, expected): + actual = command("docker", "inspect", "--format", "{{.Image}}", container) + if not expected or actual != expected: + raise RuntimeError(f"Container {container} uses {actual}; expected {image} ({expected})") + + +def response(url, credentials=None): + request = urllib.request.Request(url) + if credentials: + encoded = base64.b64encode(credentials.encode()).decode() + request.add_header("Authorization", "Basic " + encoded) + try: + with urllib.request.urlopen(request, timeout=15) as result: + return result.status, result.read().decode() + except urllib.error.HTTPError as error: + return error.code, error.read().decode() + + +def expect_response(url, expected=200, credentials=None): + actual, body = response(url, credentials) + if actual != expected: + raise RuntimeError(f"{url}: HTTP {actual}, expected {expected}") + return body + + +def verify_server(): + url = "http://localhost:8080" + expect_response(url + "/graphspaces/DEFAULT/graphs", 401) + graphs = json.loads(expect_response(url + "/graphspaces/DEFAULT/graphs", + credentials=f"admin:{ADMIN_PASSWORD}")) + names = graphs.get("graphs") if isinstance(graphs, dict) else None + if not isinstance(names, list) or "hugegraph" not in names: + raise RuntimeError("Server did not return its initialized hugegraph in the graphs array") + payload = json.loads(expect_response(url + "/versions")) + versions = payload.get("versions") if isinstance(payload, dict) else None + if not isinstance(versions, dict) or versions.get("version") != "v1" or any( + not isinstance(versions.get(key), str) or not versions[key] + for key in ("core", "gremlin", "api")): + raise RuntimeError("Server did not return the expected versions object") + + +def verify_storage(): + for port in (8620, 8520): + if expect_response(f"http://localhost:{port}/v1/health") != "": + raise RuntimeError(f"Port {port} did not return the PD/Store health response") + ready = json.loads(expect_response("http://localhost:8620/v1/ready")) + if not isinstance(ready, dict) or ready.get("ready") is not True: + raise RuntimeError("PD did not report ready=true") + url = "http://localhost:8620/v1/stores" + expect_response(url, 401) + payload = json.loads(expect_response(url, credentials=f"hg:{PD_PASSWORD}")) + data = payload.get("data") if isinstance(payload, dict) else None + if (not isinstance(data, dict) or payload.get("status") != 0 or + not isinstance(data.get("stores"), list) or not data["stores"]): + raise RuntimeError("Authenticated PD did not return its registered stores array") + + +def smoke(tag, topology, images): + root = Path(__file__).resolve().parents[2] + project = f"hg-pr-{tag}-{Path(topology).stem}" + compose = ["docker", "compose", "-p", project, "-f", str(root / "docker" / topology)] + # Capture identities before startup so a tag change cannot bless an unrelated container. + expected = {service: command("docker", "image", "inspect", "--format", "{{.Id}}", + f"{repository}:{tag}") for service, repository in images.items()} + failed = False + try: + subprocess.run(compose + ["up", "-d", "--pull", "never", "--wait", + "--wait-timeout", "600"] + list(images), check=True, timeout=660) + for service, repository in images.items(): + container = command(*compose, "ps", "-q", service) + if not container or "\n" in container: + raise RuntimeError(f"Expected exactly one {service} container") + verify_image(container, f"{repository}:{tag}", expected[service]) + health = command("docker", "inspect", "--format", "{{.State.Health.Status}}", container) + if health != "healthy": + raise RuntimeError(f"{service} is {health}") + verify_server() + if "pd" in images: + verify_storage() + except BaseException: + failed = True + for args in (["ps"], ["logs", "--no-color", "--tail", "200"]): + try: + subprocess.run(compose + args, check=False, timeout=60) + except (OSError, subprocess.SubprocessError) as error: + print(f"Could not collect Compose diagnostics: {error}", file=sys.stderr) + raise + finally: + try: + subprocess.run(compose + ["down", "-v", "--remove-orphans"], check=True, timeout=120) + except (OSError, subprocess.SubprocessError) as error: + if not failed: + raise + print(f"Compose cleanup also failed: {error}", file=sys.stderr) + + +def main(tag): + if not tag.startswith("ci-") or any(c not in "abcdefghijklmnopqrstuvwxyz0123456789-" for c in tag): + raise ValueError("Use a unique ci- image tag") + os.environ.update(HUGEGRAPH_VERSION=tag, HUGEGRAPH_PULL_POLICY="never", + HUGEGRAPH_ADMIN_PASSWORD=ADMIN_PASSWORD, + HUGEGRAPH_AUTH_TOKEN_SECRET="0123456789abcdef" * 4, + HG_PD_AUTH_SECRET_KEY=PD_PASSWORD) + smoke(tag, "docker-compose.yml", {"server": "hugegraph/hugegraph"}) + smoke(tag, "docker-compose-hstore.yml", {"pd": "hugegraph/pd", "store": "hugegraph/store", + "server": "hugegraph/server"}) + + +if __name__ == "__main__": + main(sys.argv[1]) diff --git a/.github/scripts/test_ci_policy.py b/.github/scripts/test_ci_policy.py index a42a2773aa..0c0b417559 100644 --- a/.github/scripts/test_ci_policy.py +++ b/.github/scripts/test_ci_policy.py @@ -74,7 +74,7 @@ def test_audited_consumer_edges(self): def test_version_resource_follows_real_docker_consumer(self): root = Path(__file__).resolve().parents[2] - consumer = root / ".github/workflows/docker-build-ci.yml" + consumer = root / ".github/scripts/check-docker-images.sh" if not consumer.exists(): self.skipTest("Server Docker consumer is not in the Toolchain repository") resource = next(line.strip().rstrip(")") for line in consumer.read_text().splitlines() @@ -413,6 +413,26 @@ def test_gate_rejects_missing_failed_cancelled_skipped(self): with self.subTest(result=result), self.assertRaises(ValueError): policy.gate(self.plan(), {"plan": {"result": "success"}, "client": {"result": result}}, self.fetch) + def test_third_party_failure_does_not_exempt_required_tests(self): + selected = policy.select("server", ["pom.xml"]) + self.assertIn("dependency_license", selected) + expected = {"server_memory", "server_rocksdb", "commons", "pd_store", "cluster", "docker", "helm"} + self.assertEqual(expected, set(policy.suites("server", selected))) + plan = self.plan() + plan.update(project="server", selected=sorted(selected), expected=sorted(expected)) + results = {suite: {"result": "success"} for suite in expected} + results["plan"] = {"result": "success"} + for result in ["failure", "cancelled", "skipped", None]: + results["dependency_license"] = {"result": result} + with self.subTest(third_party=result): + receipt = policy.gate(plan, results, self.fetch) + self.assertEqual(expected, set(receipt["executed"])) + self.assertNotIn("dependency_license", receipt["proofs"]) + for result in ["failure", "cancelled", "skipped", None]: + results["cluster"] = {"result": result} + with self.subTest(cluster=result), self.assertRaisesRegex(ValueError, "cluster"): + policy.gate(plan, results, self.fetch) + def test_gate_success_receipt_and_verified_reuse(self): with patch.dict(os.environ, {"GITHUB_RUN_ID": "42"}): result = policy.gate(self.plan(), {"plan": {"result": "success"}, "client": {"result": "success"}, "fixture": {"result": "success"}}, self.fetch) diff --git a/.github/scripts/test_docker_deployment.py b/.github/scripts/test_docker_deployment.py new file mode 100644 index 0000000000..6595c13490 --- /dev/null +++ b/.github/scripts/test_docker_deployment.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Reject successful service checks performed against an unrelated image.""" + +import importlib.util +from pathlib import Path +import unittest +from unittest.mock import patch + +spec = importlib.util.spec_from_file_location("deployment", Path(__file__).with_name("docker-deployment.py")) +deployment = importlib.util.module_from_spec(spec) +spec.loader.exec_module(deployment) + + +class ImageIdentityTest(unittest.TestCase): + def test_rejects_old_image_even_when_service_started(self): + with patch.object(deployment, "command", return_value="sha256:old"): + with self.assertRaisesRegex(RuntimeError, "uses sha256:old"): + deployment.verify_image("container", "hugegraph/server:ci-1-1", "sha256:pr") + + def test_rejects_missing_image_identity(self): + with patch.object(deployment, "command", return_value=""): + with self.assertRaises(RuntimeError): + deployment.verify_image("container", "hugegraph/server:ci-1-1", "") + + def test_accepts_exact_built_image(self): + with patch.object(deployment, "command", return_value="sha256:pr"): + deployment.verify_image("container", "hugegraph/server:ci-1-1", "sha256:pr") + + def test_identity_failure_cleans_compose_and_never_checks_http(self): + with patch.object(deployment, "command", side_effect=["sha256:pr", "container"]), \ + patch.object(deployment, "verify_image", side_effect=RuntimeError("wrong image")), \ + patch.object(deployment.subprocess, "run") as run, \ + patch.object(deployment, "response") as response: + with self.assertRaisesRegex(RuntimeError, "wrong image"): + deployment.smoke("ci-1-1", "docker-compose.yml", {"server": "hugegraph/hugegraph"}) + calls = [call.args[0] for call in run.call_args_list] + startup = next(args for args in calls if "up" in args) + self.assertEqual(startup[-1], "server") + self.assertEqual(startup[startup.index("--pull") + 1], "never") + self.assertNotIn("hubble", startup) + self.assertTrue(any("logs" in args for args in calls)) + self.assertEqual(calls[-1][-3:], ["down", "-v", "--remove-orphans"]) + response.assert_not_called() + + def test_startup_timeout_preserves_failure_and_cleans_compose(self): + timeout = deployment.subprocess.TimeoutExpired("up", 660) + cleanup = deployment.subprocess.CalledProcessError(1, "down") + with patch.object(deployment, "command", return_value="sha256:pr"), \ + patch.object(deployment.subprocess, "run", side_effect=[timeout, None, None, cleanup]) as run: + with self.assertRaises(deployment.subprocess.TimeoutExpired): + deployment.smoke("ci-1-1", "docker-compose.yml", {"server": "hugegraph/hugegraph"}) + self.assertEqual(run.call_args_list[-1].args[0][-3:], ["down", "-v", "--remove-orphans"]) + + def test_hstore_starts_only_pr_services_and_cleans_after_checks(self): + images = {"pd": "hugegraph/pd", "store": "hugegraph/store", "server": "hugegraph/server"} + identities = ["sha256:pd", "sha256:store", "sha256:server"] + containers = [value for service in images for value in (service, f"sha256:{service}", "healthy")] + with patch.object(deployment, "command", side_effect=identities + containers), \ + patch.object(deployment.subprocess, "run") as run, \ + patch.object(deployment, "verify_server") as server, \ + patch.object(deployment, "verify_storage") as storage: + deployment.smoke("ci-1-1", "docker-compose-hstore.yml", images) + startup = run.call_args_list[0].args[0] + self.assertEqual(startup[-3:], ["pd", "store", "server"]) + self.assertNotIn("hubble", startup) + server.assert_called_once_with() + storage.assert_called_once_with() + self.assertEqual(run.call_args_list[-1].args[0][-3:], ["down", "-v", "--remove-orphans"]) + + +class PayloadTest(unittest.TestCase): + VERSIONS = '{"versions":{"version":"v1","core":"1.7.0","gremlin":"3.7.3","api":"0.74"}}' + GRAPHS = '{"graphs":["hugegraph"]}' + + def test_accepts_public_versions_and_authenticated_graphs(self): + with patch.object(deployment, "response", side_effect=[(401, ""), (200, self.GRAPHS), + (200, self.VERSIONS)]) as response: + deployment.verify_server() + calls = response.call_args_list + self.assertIsNone(calls[0].args[1]) + self.assertEqual(calls[1].args[1], "admin:ci-compose-password") + self.assertIsNone(calls[2].args[1]) + + def test_rejects_graph_error_body_even_when_http_status_is_200(self): + for body in ('{"status":-1,"error":"Unauthorized"}', '{"graphs":"hugegraph"}', + '{"graphs":[]}'): + with self.subTest(body=body), \ + patch.object(deployment, "response", side_effect=[(401, ""), (200, body)]): + with self.assertRaisesRegex(RuntimeError, "graphs array"): + deployment.verify_server() + + def test_rejects_invalid_versions_even_when_http_status_is_200(self): + for body in ('{"status":200}', '{"versions":{"version":"v1","core":"1.7.0"}}', + '{"versions":{"version":"v1","core":"1","api":"","gremlin":"3"}}'): + with self.subTest(body=body), \ + patch.object(deployment, "response", side_effect=[(401, ""), (200, self.GRAPHS), (200, body)]): + with self.assertRaisesRegex(RuntimeError, "versions object"): + deployment.verify_server() + + def test_rejects_unauthenticated_server_access(self): + with patch.object(deployment, "response", return_value=(200, self.GRAPHS)): + with self.assertRaisesRegex(RuntimeError, "expected 401"): + deployment.verify_server() + + def test_accepts_pd_store_health_readiness_and_authenticated_registration(self): + responses = [(200, ""), (200, ""), (200, '{"ready":true}'), (401, ""), + (200, '{"status":0,"data":{"stores":[{"address":"store:8500"}]}}')] + with patch.object(deployment, "response", side_effect=responses) as response: + deployment.verify_storage() + self.assertEqual(response.call_args_list[-1].args[1], "hg:ci-compose-pd-secret") + + def test_rejects_pd_false_readiness_or_error_body(self): + for body in ('{"ready":false}', '{"ready":"true"}', '{"status":-1}'): + with self.subTest(body=body), \ + patch.object(deployment, "response", side_effect=[(200, ""), (200, ""), (200, body)]): + with self.assertRaisesRegex(RuntimeError, "ready=true"): + deployment.verify_storage() + + def test_rejects_health_error_body_even_when_http_status_is_200(self): + with patch.object(deployment, "response", return_value=(200, '{"error":"Unauthorized"}')): + with self.assertRaisesRegex(RuntimeError, "health response"): + deployment.verify_storage() + + def test_rejects_unregistered_store_or_pd_error(self): + for body in ('{"status":0,"data":{"stores":[]}}', + '{"status":-1,"data":{"stores":[{}]}}'): + responses = [(200, ""), (200, ""), (200, '{"ready":true}'), (401, ""), (200, body)] + with self.subTest(body=body), patch.object(deployment, "response", side_effect=responses): + with self.assertRaisesRegex(RuntimeError, "registered stores array"): + deployment.verify_storage() diff --git a/.github/workflows/docker-build-ci.yml b/.github/workflows/docker-build-ci.yml index bbe99acea0..56883b88f8 100644 --- a/.github/workflows/docker-build-ci.yml +++ b/.github/workflows/docker-build-ci.yml @@ -107,94 +107,28 @@ jobs: docker-build: runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - dockerfile: - - hugegraph-pd/Dockerfile - - hugegraph-store/Dockerfile - - hugegraph-server/Dockerfile - - hugegraph-server/Dockerfile-hstore - + timeout-minutes: 45 + env: + IMAGE_TAG: ci-${{ github.run_id }}-${{ github.run_attempt }} + SOURCE_REVISION: ${{ github.sha }} steps: - name: Checkout uses: actions/checkout@v7 - - name: Build ${{ matrix.dockerfile }} - run: | - # Keep the build log visible: -q would swallow Maven's output and - # leave only "exit code: 1" when the build stage fails. - docker build --progress=plain --iidfile "$RUNNER_TEMP/iid" \ - --build-arg SOURCE_REVISION="$GITHUB_SHA" \ - -f ${{ matrix.dockerfile }} . - IMAGE_ID=$(cat "$RUNNER_TEMP/iid") - echo "Built: $IMAGE_ID" - echo "IMAGE_ID=$IMAGE_ID" >> "$GITHUB_ENV" - HC=$(docker inspect --format='{{json .Config.Healthcheck}}' "$IMAGE_ID") - echo "Healthcheck: $HC" - [[ "$HC" != "null" ]] || { echo "ERROR: HEALTHCHECK missing in ${{ matrix.dockerfile }}"; exit 1; } - - - name: Verify Java 17 runtime in ${{ matrix.dockerfile }} + - name: Build and load all four PR images with a shared Maven stage run: | - JAVA_VERSION=$(docker run --rm --entrypoint java "$IMAGE_ID" -version 2>&1) - echo "$JAVA_VERSION" - grep -Eq 'version "17\.' <<< "$JAVA_VERSION" || { - echo "ERROR: expected a Java 17 runtime in ${{ matrix.dockerfile }}" - exit 1 - } - - - name: Test server entrypoint property mapping - if: matrix.dockerfile == 'hugegraph-server/Dockerfile' - run: bash hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh + docker buildx create --driver docker-container --use + docker buildx bake --file docker/bake.hcl --progress=plain \ + --set '*.platform=linux/amd64' \ + pd store server-standalone server-hstore - # The startup preflight needs a socket-table tool, and the base image - # ships none of its own. Without one every start reports "unknown" and - # a duplicate start is no longer refused, so assert the image can - # actually answer. Only the server images run check_port. - # TODO(docker-ci): this pins the probe dependency, not the behaviour it - # protects. A full duplicate-start/stop check needs a booted server with - # a backend, which belongs with the e2e job rather than the image build. - - name: Port preflight can answer inside ${{ matrix.dockerfile }} - if: ${{ startsWith(matrix.dockerfile, 'hugegraph-server/') }} + - name: Verify image runtime contracts run: | - STATE=$(docker run --rm "$IMAGE_ID" bash -c \ - 'source /hugegraph-server/bin/util.sh && port_listen_state 8080') - echo "port_listen_state 8080 -> $STATE" - # Assert a positive answer rather than "not unknown": an empty $STATE - # would otherwise satisfy the check. The step's default `bash -e` - # already aborts on a failed `docker run`, so this only states intent. - [[ "$STATE" == "free" || "$STATE" == "busy" ]] || { - echo "ERROR: no usable socket-table tool (ss/netstat) in ${{ matrix.dockerfile }}" - exit 1 - } - - - name: Server image API versions match source - if: ${{ startsWith(matrix.dockerfile, 'hugegraph-server/') }} - run: | - CHECK_DIR=$(mktemp -d) - trap 'rm -rf "$CHECK_DIR"' EXIT - docker run --rm --entrypoint bash \ - -v "$CHECK_DIR:/check" "$IMAGE_ID" -c \ - 'cp /hugegraph-server/lib/hugegraph-api-*.jar \ - /hugegraph-server/lib/hugegraph-common-*.jar /check/' + bash .github/scripts/check-docker-images.sh + bash hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh - API_JAR=$(find "$CHECK_DIR" -name 'hugegraph-api-*.jar' -print -quit) - COMMON_JAR=$(find "$CHECK_DIR" -name 'hugegraph-common-*.jar' -print -quit) - EXPECTED_MANIFEST=$(sed -n \ - 's|.*\([^<]*\).*|\1|p' \ - hugegraph-server/hugegraph-api/pom.xml) - ACTUAL_MANIFEST=$(unzip -p "$API_JAR" META-INF/MANIFEST.MF | - sed -n 's/^Implementation-Version: *//p' | tr -d '\r') - EXPECTED_PROPERTY=$(sed -n 's/^ApiVersion=//p' \ - hugegraph-commons/hugegraph-common/src/main/resources/version.properties) - ACTUAL_PROPERTY=$(unzip -p "$COMMON_JAR" version.properties | - sed -n 's/^ApiVersion=//p' | tr -d '\r') + - name: Verify deployment checker contracts + run: python3 -m unittest discover -s .github/scripts -p 'test_docker_deployment.py' - [[ "$ACTUAL_MANIFEST" == "$EXPECTED_MANIFEST" ]] || { - echo "ERROR: API manifest is $ACTUAL_MANIFEST; expected $EXPECTED_MANIFEST" - exit 1 - } - [[ "$ACTUAL_PROPERTY" == "$EXPECTED_PROPERTY" ]] || { - echo "ERROR: API property is $ACTUAL_PROPERTY; expected $EXPECTED_PROPERTY" - exit 1 - } + - name: Start the exact PR images and verify health and authentication + run: python3 .github/scripts/docker-deployment.py "$IMAGE_TAG" diff --git a/.github/workflows/server-ci.yml b/.github/workflows/server-ci.yml index 30351653f7..4dd4dd85d2 100644 --- a/.github/workflows/server-ci.yml +++ b/.github/workflows/server-ci.yml @@ -325,7 +325,7 @@ jobs: affected-module-tests: name: affected-module-tests - needs: [plan, java-runtime, wait-storage-shell-test, build-server, server-rocksdb, commons, pd_store, cluster, docker, helm, dependency_license] + needs: [plan, java-runtime, wait-storage-shell-test, build-server, server-rocksdb, commons, pd_store, cluster, docker, helm] if: ${{ !cancelled() }} runs-on: ubuntu-24.04 steps: diff --git a/docs/ci.md b/docs/ci.md index 3ab5eb5735..554f97c556 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -1,7 +1,7 @@ # CI policy Pull requests and supported branch pushes enter `HugeGraph-Server CI`. The workflow -always reports `affected-module-tests`; license checks run independently. Module +always reports `affected-module-tests`; the required `check-license` runs independently. Module workflows are reusable and can also be started manually. | Changed inputs | Required Linux coverage | @@ -20,6 +20,21 @@ The selector follows dependency edges. PD, Store, HStore and Struct share one su in this first stage. Selected tests must succeed; failure, cancellation or an unexpected skip cannot satisfy the gate. Startup prerequisites are enforced. +Third-party dependency inventory and vulnerability review retain their existing +non-blocking policy. They run when selected, but are excluded from the core gate +and test-reuse receipts. Their failure does not prevent affected module tests or +post-gate checks from reporting results; the required license check is unchanged. + +The Docker suite builds and loads the four production images from the current +checkout on one Linux runner, sharing their Maven build through BuildKit Bake. +It verifies image healthcheck and Java contracts, then starts standalone Server +and the PD/Store/Server topology with unique run tags and pulling disabled. +Runtime checks match each container's image ID to the build, require healthy +services and validate Server version and authenticated graph-list responses. +PD must report readiness and a registered Store; unauthenticated graph and PD +metadata access must be rejected. The existing Hubble Compose smoke +remains a separate compatibility check; it does not verify the new PR images. + HBase, macOS, RISC-V and CodeQL run after the core gate. Their results remain visible, but they are outside `affected-module-tests`. Post-gate conditions explicitly require a successful plan and gate, including when unrelated modules were skipped. This orders work within a PR; it does From bef7404fd97e7e0516e330c68d4d77def7abe802 Mon Sep 17 00:00:00 2001 From: imbajin Date: Tue, 6 Oct 2026 15:55:07 +0800 Subject: [PATCH 6/7] fix(ci): require current-run validation - remove untrusted result reuse and PR artifact retries - cover PD and Store distribution image consumers - reject stale or incomplete pull request inputs - retain strict current push retries and document limits --- .github/scripts/check-rerun.py | 85 +--- .github/scripts/ci-policy.py | 401 +++-------------- .github/scripts/test_check_rerun.py | 132 ++---- .github/scripts/test_ci_policy.py | 665 +++++++++++----------------- .github/workflows/rerun-ci.yml | 5 +- .github/workflows/server-ci.yml | 13 +- docs/ci.md | 52 ++- 7 files changed, 381 insertions(+), 972 deletions(-) diff --git a/.github/scripts/check-rerun.py b/.github/scripts/check-rerun.py index 24fff7b72c..e5134b72ea 100644 --- a/.github/scripts/check-rerun.py +++ b/.github/scripts/check-rerun.py @@ -17,41 +17,18 @@ """Check the failed run again immediately before an automatic retry.""" import argparse -import io import json -import zipfile import os import subprocess -from urllib.parse import quote, urlencode +from urllib.parse import urlencode -def api(path, binary=False): +def api(path): result = subprocess.run( ["gh", "api", "--method", "GET", path], check=True, capture_output=True, timeout=30, ) - return result.stdout if binary else json.loads(result.stdout) - - -def tested_base_matches(repository, run_id, pr, sha, fetch): - artifacts = fetch(f"repos/{repository}/actions/runs/{run_id}/artifacts?per_page=100")["artifacts"] - candidates = [a for a in artifacts if a.get("name") == "ci-plan" and not a.get("expired")] - for artifact in candidates: - payload = fetch(f"repos/{repository}/actions/artifacts/{artifact['id']}/zip", binary=True) - with zipfile.ZipFile(io.BytesIO(payload)) as archive: - info = archive.getinfo("plan.json") - if info.file_size > 1048576: - continue - plan = json.loads(archive.read(info)) - base = pr.get("base", {}).get("sha") - if (plan.get("repository") != repository or plan.get("pr") != pr.get("number") - or plan.get("head") != sha or not base or plan.get("base") != base - or not plan.get("testedMergeSHA")): - continue - merge = fetch(f"repos/{repository}/git/commits/{plan['testedMergeSHA']}") - if [parent["sha"] for parent in merge.get("parents", [])] == [base, sha]: - return True - return False + return json.loads(result.stdout) def decide(repository, run_id, expected_attempt, max_reruns, fetch=api): @@ -65,57 +42,11 @@ def decide(repository, run_id, expected_attempt, max_reruns, fetch=api): if not sha: return "skip", "missing head SHA" if run.get("event") == "pull_request": - def local(candidate): - base_repository = candidate.get("base", {}).get("repo", {}) - return (base_repository.get("full_name") == repository - or base_repository.get("url") == f"https://api.github.com/repos/{repository}") - - source = run.get("head_repository") or {} - source_owner = (source.get("owner") or {}).get("login") - source_name = source.get("full_name") - source_branch = run.get("head_branch") - if not source_owner or not source_name or not source_branch: - return "skip", "missing source repository or branch metadata" - def source_matches(pr): - head = pr.get("head") or {} - repo = head.get("repo") or {} - return (head.get("ref") == source_branch and repo.get("full_name") == source_name - and (repo.get("owner") or {}).get("login") == source_owner) - fallback_owner = None - candidates = [pr for pr in run.get("pull_requests", []) if local(pr)] - if not candidates: - candidates = [pr for pr in fetch( - f"repos/{repository}/commits/{quote(sha, safe='')}/pulls") if local(pr)] - if not candidates: - owner = run.get("head_repository", {}).get("owner", {}).get("login") - branch = run.get("head_branch") - if owner and branch: - fallback_owner = owner - # Fork runs can have no commit/PR association in either endpoint. - # Use the qualified fork owner, never the branch name alone. - for page in range(1, 101): - batch = fetch(f"repos/{repository}/pulls?" + urlencode({ - "state": "open", "head": f"{owner}:{branch}", - "per_page": 100, "page": page})) - candidates.extend(pr for pr in batch if local(pr) - and pr.get("head", {}).get("ref") == branch - and pr.get("head", {}).get("repo", {}).get("owner", {}).get("login") == owner) - if len(batch) < 100: - break - for candidate in candidates: - number = candidate.get("number") - if not isinstance(number, int): - continue - pr = fetch(f"repos/{repository}/pulls/{number}") - if not source_matches(pr): - continue - if (pr.get("base", {}).get("repo", {}).get("full_name") == repository - and pr.get("state") == "open" and pr.get("head", {}).get("sha") == sha): - if tested_base_matches(repository, run_id, pr, sha, fetch): - return "rerun", "open PR head and tested base unchanged" - return "skip", "tested base moved or immutable CI plan proof unavailable" - return "skip", "no open PR in this repository with the failed head" + return "skip", "PR automatic retry paused: CI artifacts cannot independently prove the tested code" if run.get("event") == "push": + if (run.get("repository", {}).get("full_name") != repository + or run.get("head_repository", {}).get("full_name") != repository): + return "skip", "push source repository differs" branch = run.get("head_branch") if not branch: return "skip", "missing push branch" @@ -136,7 +67,7 @@ def main(): args = parser.parse_args() try: action, reason = decide(args.repository, args.run_id, args.run_attempt, args.max_reruns) - except (subprocess.SubprocessError, OSError, ValueError, KeyError, TypeError, AttributeError, zipfile.BadZipFile): + except (subprocess.SubprocessError, OSError, ValueError, KeyError, TypeError, AttributeError): action, reason = "skip", "unable to verify current run and source metadata" print(f"{action}: {reason}") with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output: diff --git a/.github/scripts/ci-policy.py b/.github/scripts/ci-policy.py index 09f523a632..b35b20d2bd 100644 --- a/.github/scripts/ci-policy.py +++ b/.github/scripts/ci-policy.py @@ -14,18 +14,13 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Conservative CI selection and GitHub-verified test receipt reuse.""" +"""Conservative CI selection and gating of this run's selected tests.""" import argparse -import base64 -import hashlib -import io import json import os from pathlib import Path import subprocess -import zipfile -from urllib.parse import urlencode MODULES = { "server": ["server", "commons", "struct", "pd", "store", "hstore", "cluster", "docker", "helm", "dependency_license"], @@ -62,9 +57,9 @@ def git(*args): return subprocess.check_output(["git", *args], text=True).strip() -def api(path, binary=False): +def api(path): result = subprocess.run(["gh", "api", "--method", "GET", path], check=True, capture_output=True, timeout=30) - return result.stdout if binary else json.loads(result.stdout) + return json.loads(result.stdout) def documentation(path, mode="100644"): @@ -109,13 +104,12 @@ def select(project, paths): if project == "server" and path == "hugegraph-commons/hugegraph-common/src/main/resources/version.properties": selected.update(["commons", "docker"]) continue - if project == "server" and path == "hugegraph-store/hg-store-dist/src/assembly/static/bin/util.sh": - selected.update(["store", "docker"]) - continue if project == "toolchain" and path.startswith("hugegraph-client/assembly/travis/"): selected.update(["client", "go"]) continue if project == "server": + if path.startswith(("hugegraph-pd/hg-pd-dist/", "hugegraph-store/hg-store-dist/")): + selected.add("docker") if path.startswith("hugegraph-server/hugegraph-hstore/"): selected.update(["server", "hstore"]) continue @@ -141,94 +135,6 @@ def select(project, paths): return selected -def external_context(inputs, selected): - context = dict(inputs or {}) - if "hubble" not in selected: - context.pop("hubble", None) - return json.dumps(context, sort_keys=True, separators=(",", ":")) - - -def packaged_readme_state(ref): - path = "hugegraph-hubble/README.md" - entry = git("ls-tree", ref, "--", path) - if not entry: - return "missing" - mode, kind, blob = entry.split("\t", 1)[0].split() - if mode != "100644" or kind != "blob": - return "invalid-mode:" + mode + ":" + blob - content = subprocess.check_output(["git", "cat-file", "blob", blob]) - return "valid" if content.strip() else "empty:" + blob - - -def fingerprint(ref, policy=False, project=None): - """Hash blob identities, including paths; exclude only the strict prose whitelist.""" - entries = subprocess.check_output(["git", "ls-tree", "-rz", "--full-tree", ref]).split(b"\0") - digest = hashlib.sha256() - for entry in sorted(entries, key=lambda e: e.split(b"\t", 1)[-1]): - if not entry: - continue - metadata, name = entry.split(b"\t", 1) - path = name.decode("utf-8", "surrogateescape") - if policy: - if not (path.startswith(".github/") or path == ".asf.yaml"): - continue - elif documentation(path, metadata.split(b" ", 1)[0].decode()): - continue - digest.update(metadata + b"\t" + name + b"\0") - if project == "toolchain" and not policy: - state = packaged_readme_state(ref) - if state != "valid": - digest.update(("required-packaged-readme:" + state).encode()) - return digest.hexdigest() - - -def remote_fingerprint(repository, sha, policy_only=False, fetch=api, project=None): - tree = fetch(f"repos/{repository}/git/trees/{sha}?recursive=1") - if tree.get("truncated"): - raise ValueError("truncated proof tree") - digest = hashlib.sha256() - for entry in sorted(tree["tree"], key=lambda item: item["path"].encode()): - path = entry["path"] - if entry["type"] == "tree": - continue - if policy_only: - if not (path.startswith(".github/") or path == ".asf.yaml"): - continue - elif documentation(path, entry["mode"]): - continue - digest.update((entry["mode"] + " " + entry["type"] + " " + entry["sha"] - + "\t" + path + "\0").encode()) - if project == "toolchain" and not policy_only: - entry = next((item for item in tree["tree"] if item["path"] == "hugegraph-hubble/README.md"), None) - state = "missing" - if entry: - if entry["mode"] != "100644" or entry["type"] != "blob": - state = "invalid-mode:" + entry["mode"] + ":" + entry["sha"] - else: - blob = fetch(f"repos/{repository}/git/blobs/{entry['sha']}") - state = "valid" if base64.b64decode(blob["content"]).strip() else "empty:" + entry["sha"] - if state != "valid": - digest.update(("required-packaged-readme:" + state).encode()) - return digest.hexdigest() - - -def proof_pr(plan, run, fetch): - associations = run.get("pull_requests", []) - if any(pr.get("number") == plan["pr"] for pr in associations): - return True - owner = run.get("head_repository", {}).get("owner", {}).get("login") - branch = run.get("head_branch") - if not owner or not branch: - return False - candidates = pages(f"repos/{plan['repository']}/pulls?" + urlencode({ - "state": "open", "head": f"{owner}:{branch}"}), fetch) - return any(pr.get("number") == plan["pr"] - and pr.get("base", {}).get("repo", {}).get("full_name") == plan["repository"] - and pr.get("head", {}).get("ref") == branch - and pr.get("head", {}).get("repo", {}).get("full_name") == plan["source"] - for pr in candidates) - - def suites(project, selected): if project == "toolchain": return sorted(selected) @@ -244,218 +150,44 @@ def suites(project, selected): return sorted(result) -def pages(path, fetch=api): - result = [] - for page in range(1, 11): - value = fetch(path + ("&" if "?" in path else "?") + urlencode({"per_page": 100, "page": page})) - batch = value["jobs"] if isinstance(value, dict) else value - result.extend(batch) - if len(batch) < 100: - return result - raise ValueError("API pagination exceeded verification bound") - - -def successful_jobs(repository, run_id, suite, fetch=api): - jobs = pages(f"repos/{repository}/actions/runs/{run_id}/jobs", fetch) - matching = [job for job in jobs if job.get("name") == suite or job.get("name", "").startswith((suite + " / ", suite + " ("))] - if not matching or any(job.get("conclusion") != "success" for job in matching): - raise ValueError("missing or unsuccessful suite jobs") - return sorted(job["id"] for job in matching) +class StaleInputError(RuntimeError): + """The checkout no longer represents the current PR inputs.""" -def validate_proof_run(plan, item, fetch=api): - run = fetch(f"repos/{plan['repository']}/actions/runs/{int(item['runID'])}") - if (run.get("status") != "completed" - or run.get("event") != "pull_request" or run.get("head_sha") != item.get("head") - or run.get("workflow_id") != plan["workflowID"] - or run.get("head_repository", {}).get("full_name") != plan["source"] - or run.get("head_branch") != plan["branch"]): - raise ValueError("proof run is not a successful trusted workflow") - if not proof_pr(plan, run, fetch): - raise ValueError("proof run lacks matching PR association") - merge = fetch(f"repos/{plan['repository']}/git/commits/{item['testedMergeSHA']}") - if [parent["sha"] for parent in merge.get("parents", [])] != [plan["base"], item["head"]]: - raise ValueError("tested merge parents differ") - actual_input = remote_fingerprint(plan["repository"], item["testedMergeSHA"], fetch=fetch, project=plan["project"]) - if plan["project"] == "toolchain": - context = external_context(plan.get("externalInputs", {}), plan["selected"]) - actual_input = hashlib.sha256((actual_input + context).encode()).hexdigest() - if (actual_input != plan["inputFingerprint"] or remote_fingerprint( - plan["repository"], item["testedMergeSHA"], policy_only=True, fetch=fetch) - != plan["policyFingerprint"]): - raise ValueError("actual tested inputs differ from claimed receipt") +def require_current_pr(plan, fetch): + if not plan["pr"]: + return + live = fetch(f"repos/{plan['repository']}/pulls/{plan['pr']}") + if (live.get("state") != "open" or live["head"]["sha"] != plan["head"] + or live["base"]["sha"] != plan["base"] + or live["head"]["repo"]["full_name"] != plan["source"] + or live["head"]["ref"] != plan["branch"] + or live["base"]["repo"]["full_name"] != plan["repository"]): + raise StaleInputError("PR inputs changed; refresh the branch and start a new PR run") -def validate_receipt(plan, receipt, fetch=api): - for key in ["schema", "repository", "project", "pr", "source", "branch", "base", "inputFingerprint", "policyFingerprint"]: - if receipt.get(key) != plan.get(key): - raise ValueError("receipt provenance differs: " + key) - proof = receipt.get("proofs", {}) - required = plan["expected"] - if not required or not set(required).issubset(proof): - raise ValueError("receipt lacks selected suites") - verified = {} - for suite in required: - item = proof[suite] - validate_proof_run(plan, item, fetch) - successful_jobs(plan["repository"], item["runID"], "affected-module-tests", fetch) - ids = successful_jobs(plan["repository"], item["runID"], suite, fetch) - if ids != item.get("jobIDs"): - raise ValueError("actual job evidence differs") - verified[suite] = item - return verified - - -PROVENANCE = ["schema", "repository", "project", "pr", "source", "branch", "base", - "inputFingerprint", "policyFingerprint"] - - -def optional_groups(plan): - if plan["project"] == "toolchain": - return {"security": {"security / Analyze (java)", "security / Analyze (javascript)", - "security / Analyze (python)", "security / dependency-review"}} - runtime = (Path(__file__).resolve().parents[1] / "workflows/.java-version").read_text().strip() - return {"security": {"codeql / Analyze (java)"}, - "compatibility": {f"HBase compatibility (Java {runtime})", - "build-server-macos-rocksdb (macos-15-intel)", - "build-server-macos-rocksdb (macos-15, -Xms512m -Xmx2g)", - "build-server-riscv64 / build-server-riscv64"}} - - -def optional_group_jobs(jobs, group): - def belongs(name): - if group == "security": - return name.startswith(("codeql /", "security /")) - return name.startswith(("HBase compatibility", "build-server-macos-rocksdb", "build-server-riscv64 /")) - return [job for job in jobs if belongs(job.get("name", ""))] - - -def optional_jobs(jobs, names): - # Reject incomplete/duplicate matrices; proof identities must describe the full group. - matching = [job for job in jobs if job.get("name") in names] - if len(matching) != len(names) or {job["name"] for job in matching} != names: - raise ValueError("optional group is incomplete") - if any(job.get("conclusion") != "success" for job in matching): - raise ValueError("optional group did not succeed") - return sorted(matching, key=lambda job: job["name"]) - - -def optional_proofs(plan, receipt, jobs, blocked, fetch=api): - proofs = {} - for group, names in optional_groups(plan).items(): - if group in blocked: - continue - try: - current = optional_group_jobs(jobs, group) - if any(job.get("name") not in names for job in current): - raise ValueError("unknown optional group job") - # Skipped wrapper jobs can forward proof. A partial or failed execution cannot. - executed = [job for job in current if job.get("conclusion") != "skipped"] - if executed: - matching = optional_jobs(jobs, names) - item = {key: receipt[key] for key in PROVENANCE} - item.update(runID=receipt["runID"], head=receipt["head"], - testedMergeSHA=receipt["testedMergeSHA"]) - validate_proof_run(plan, item, fetch) - item.update(jobIDs=[job["id"] for job in matching], jobNames=[job["name"] for job in matching]) - else: - item = receipt.get("optionalProofs", {}).get(group) - if not item: - continue - if any(item.get(key) != plan.get(key) for key in PROVENANCE): - raise ValueError("optional provenance differs") - validate_proof_run(plan, item, fetch) - successful_jobs(plan["repository"], item["runID"], "affected-module-tests", fetch) - original = optional_group_jobs( - pages(f"repos/{plan['repository']}/actions/runs/{item['runID']}/jobs", fetch), group) - if any(job.get("name") not in names for job in original): - raise ValueError("unknown original optional group job") - matching = optional_jobs(original, names) - if (item.get("jobIDs") != [job["id"] for job in matching] - or item.get("jobNames") != [job["name"] for job in matching]): - raise ValueError("optional job evidence differs") - proofs[group] = item - except (subprocess.SubprocessError, OSError, ValueError, KeyError, TypeError, AttributeError): - continue - return proofs - - -def find_reuse(plan, fetch=api): - """Receipts are uploaded by this workflow, not read from the PR tree.""" - repository = plan["repository"] - current = fetch(f"repos/{repository}/actions/runs/{os.environ['GITHUB_RUN_ID']}") - plan["workflowID"] = current["workflow_id"] - runs = [] - for page in range(1, 6): - batch = fetch(f"repos/{repository}/actions/workflows/{plan['workflowID']}/runs?" + urlencode({ - "event": "pull_request", "status": "completed", "branch": plan["branch"], - "per_page": 30, "page": page}))["workflow_runs"] - runs.extend(batch) - if len(batch) < 30: - break - blocked_optional = set() - for run in runs: - if run["id"] == int(os.environ["GITHUB_RUN_ID"]): - continue - if (run.get("head_repository", {}).get("full_name") != plan["source"] - or run.get("head_branch") != plan["branch"]): - continue - if not proof_pr(plan, run, fetch): - continue - artifacts = fetch(f"repos/{repository}/actions/runs/{run['id']}/artifacts?per_page=100")["artifacts"] - for artifact in artifacts: - size = artifact.get("size_in_bytes") - if (artifact.get("name") != "ci-test-receipt" or artifact.get("expired") - or type(size) is not int or not 0 < size <= 1048576): - continue - try: - payload = fetch(f"repos/{repository}/actions/artifacts/{artifact['id']}/zip", binary=True) - with zipfile.ZipFile(io.BytesIO(payload)) as archive: - entries = archive.infolist() - if len(entries) != 1 or entries[0].filename != "receipt.json": - raise ValueError("unexpected receipt archive entries") - info = entries[0] - mode = (info.external_attr >> 16) & 0o170000 - if (info.is_dir() or info.flag_bits & 1 or mode not in {0, 0o100000} - or info.file_size > 1048576): - raise ValueError("oversized receipt") - receipt = json.loads(archive.read(info)) - if receipt.get("runID") != run["id"]: - raise ValueError("receipt not uploaded by claimed run") - successful_jobs(repository, run["id"], "affected-module-tests", fetch) - verified = validate_receipt(plan, receipt, fetch) - jobs = pages(f"repos/{repository}/actions/runs/{run['id']}/jobs", fetch) - plan["optionalProofs"] = optional_proofs(plan, receipt, jobs, blocked_optional, fetch) - plan["optionalSuccess"] = {group: group in plan["optionalProofs"] - for group in ["compatibility", "security"]} - return verified - except (ValueError, KeyError, TypeError, AttributeError, zipfile.BadZipFile): - continue - # Missing/unverifiable evidence from a newer same-PR run is conservative for optional lanes. - blocked_optional.update(optional_groups(plan)) - return {} - - -def create_plan(project, event, repository, fetch=api, external_inputs=None): +def create_plan(project, event, repository, fetch=api): plan = {"schema": 1, "project": project, "repository": repository, "pr": 0, "source": repository, "branch": "", "base": "", "head": git("rev-parse", "HEAD"), - "reused": {}, "workflowID": 0, "reason": "affected inputs", - "externalInputs": external_inputs or {}, "testedMergeSHA": git("rev-parse", "HEAD")} + "reason": "affected inputs", "testedMergeSHA": git("rev-parse", "HEAD")} try: pr = event.get("pull_request") - if pr: - # Refresh PR metadata: event payload may predate a new push or base movement. - live = fetch(f"repos/{repository}/pulls/{pr['number']}") - if (live.get("state") != "open" or live["head"]["sha"] != pr["head"]["sha"] - or live["base"]["repo"]["full_name"] != repository): - raise ValueError("PR metadata changed") - plan.update(pr=pr["number"], source=live["head"]["repo"]["full_name"], - base=pr["base"]["sha"], head=live["head"]["sha"], branch=live["head"]["ref"]) + if "pull_request" in event: + if not isinstance(pr, dict) or type(pr.get("number")) is not int or pr["number"] <= 0: + raise StaleInputError("PR event lacks a valid number") + plan["pr"] = pr["number"] + # An incomplete PR event must never fall back to a push plan with pr=0. + try: + plan.update(source=pr["head"]["repo"]["full_name"], base=pr["base"]["sha"], + head=pr["head"]["sha"], branch=pr["head"]["ref"]) + except (KeyError, TypeError) as error: + raise StaleInputError("PR event lacks required input identity") from error + if not all(isinstance(plan[key], str) and plan[key] for key in ("source", "base", "head", "branch")): + raise StaleInputError("PR event has an empty input identity") parents = git("show", "-s", "--format=%P", plan["testedMergeSHA"]).split() if parents != [plan["base"], plan["head"]]: - raise ValueError("checkout is not the event PR merge") - if live["base"]["sha"] != plan["base"]: - raise ValueError("base advanced after event checkout") + raise StaleInputError("checkout is not the event PR merge; start a new PR run") + require_current_pr(plan, fetch) # head/base objects must exist locally; workflow fetches both before planning. ancestor = git("merge-base", plan["base"], plan["head"]) paths = git("diff", "--no-renames", "--name-only", ancestor, plan["head"]).splitlines() @@ -465,79 +197,51 @@ def create_plan(project, event, repository, fetch=api, external_inputs=None): selected = select(project, paths) if unsafe_documentation(plan["base"], paths) or unsafe_documentation(plan["testedMergeSHA"], paths): selected = set(MODULES[project]) - if project == "toolchain" and packaged_readme_state(plan["testedMergeSHA"]) != "valid": - selected = set(MODULES[project]) - plan["selected"] = sorted(selected) - plan["inputFingerprint"] = fingerprint(plan["testedMergeSHA"], project=project) - if project == "toolchain": - context = external_context(external_inputs, selected) - plan["inputFingerprint"] = hashlib.sha256((plan["inputFingerprint"] + context).encode()).hexdigest() - plan["policyFingerprint"] = fingerprint(plan["testedMergeSHA"], policy=True) plan["expected"] = suites(project, selected) - if pr and plan["expected"] and (project != "toolchain" or external_inputs): - plan["reused"] = find_reuse(plan, fetch) except (subprocess.SubprocessError, OSError, ValueError, KeyError, TypeError, AttributeError): selected = set(MODULES[project]) - plan.update(reused={}, reason="verification unavailable: full required coverage") - plan.setdefault("inputFingerprint", "") - plan.setdefault("policyFingerprint", "") + plan["reason"] = "verification unavailable: full required coverage" plan["expected"] = suites(project, selected) if not selected: plan["reason"] = "cumulative PR diff contains only plain prose documentation; modules unaffected" - elif plan["reused"]: - plan["reason"] = "affected inputs match independently verified successful tests" plan["selected"] = sorted(selected) for module in MODULES[project]: - module_suites = suites(project, {module}) - plan[module] = module in selected and not (module_suites and all(s in plan["reused"] for s in module_suites)) + plan[module] = module in selected plan["pd_store"] = any(plan.get(m, False) for m in ["pd", "store", "hstore", "struct"]) plan["needsFixture"] = project == "toolchain" and any(plan.get(m, False) for m in MODULES[project]) plan["fixture"] = plan["needsFixture"] - plan["compatibility"] = not plan.get("optionalSuccess", {}).get("compatibility", False) and project == "server" and bool(selected.intersection({"server", "commons", "struct", "pd", "store", "hstore", "cluster"})) - plan["security"] = (bool(selected) and not plan.get("optionalSuccess", {}).get("security", False)) or any(p.startswith(".github/workflows/codeql") for p in locals().get("paths", [])) + plan["compatibility"] = project == "server" and bool(selected.intersection({"server", "commons", "struct", "pd", "store", "hstore", "cluster"})) + plan["security"] = bool(selected) or any(p.startswith(".github/workflows/codeql") for p in locals().get("paths", [])) plan["security_languages"] = json.dumps(["java"]) return plan -def gate(plan, results, fetch=api): +def gate(plan, results, fetch=None): if results.get("plan", {}).get("result") != "success": raise ValueError("planner did not succeed") - proofs = dict(plan["reused"]) - executed = [] for suite in plan["expected"]: - if suite in proofs: - continue if results.get(suite, {}).get("result") != "success": raise ValueError("selected suite did not succeed: " + suite) - executed.append(suite) if plan["project"] == "toolchain": - if set(executed).intersection({"client", "loader", "tools", "spark", "go"}): + if set(plan["expected"]).intersection({"client", "loader", "tools", "spark", "go"}): if results.get("fixture", {}).get("result") != "success": raise ValueError("selected tests lack successful fixture") - if "hubble" in executed and results.get("hubble-fixture", {}).get("result") != "success": + if "hubble" in plan["expected"] and results.get("hubble-fixture", {}).get("result") != "success": raise ValueError("selected Hubble tests lack successful baseline fixture") - receipt = {key: plan[key] for key in ["schema", "repository", "project", "pr", "source", "branch", "base", - "head", "testedMergeSHA", "inputFingerprint", "policyFingerprint", "workflowID"]} - receipt["runID"] = int(os.environ.get("GITHUB_RUN_ID", "0")) - # Successful tests can gate even if receipt publication is unavailable. Never reuse - # unverified evidence: a missing API proof only disables future receipt reuse. - for suite in executed: - try: - ids = successful_jobs(plan["repository"], receipt["runID"], suite, fetch) - proofs[suite] = {"runID": receipt["runID"], "head": plan["head"], "testedMergeSHA": plan["testedMergeSHA"], "jobIDs": ids} - except (subprocess.SubprocessError, OSError, ValueError, KeyError, TypeError, AttributeError): - pass - receipt["proofs"] = proofs - receipt["optionalProofs"] = plan.get("optionalProofs", {}) - receipt["executed"] = executed - receipt["reused"] = sorted(plan["reused"]) - summary = "Selection: " + plan.get("reason", "affected inputs") + "\nExecuted: " + ", ".join(executed) + "\nReused: " + ", ".join(receipt["reused"]) + require_current_pr(plan, fetch or api) + report = {key: plan[key] for key in ["schema", "repository", "project", "pr", "source", "branch", "base", + "head", "testedMergeSHA", "selected"]} + report["runID"] = int(os.environ.get("GITHUB_RUN_ID", "0")) + report["runAttempt"] = int(os.environ.get("GITHUB_RUN_ATTEMPT", "1")) + report["executed"] = plan["expected"] + report["results"] = {suite: results[suite]["result"] for suite in plan["expected"]} + summary = "Selection: " + plan.get("reason", "affected inputs") + "\nExecuted: " + ", ".join(plan["expected"]) if os.environ.get("GITHUB_STEP_SUMMARY"): with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as out: out.write("## Affected module tests\n" + summary + "\n\nUnselected modules: " - + ", ".join(set(MODULES[plan["project"]]) - set(plan["selected"])) + "\n") + + ", ".join(sorted(set(MODULES[plan["project"]]) - set(plan["selected"]))) + "\n") print(summary) - return receipt + return report def main(): @@ -548,16 +252,13 @@ def main(): p.add_argument("--event-path", required=True) p.add_argument("--repository", required=True) p.add_argument("--output", required=True) - p.add_argument("--inputs-json") p = sub.add_parser("gate") p.add_argument("--plan", required=True) p.add_argument("--results", required=True) p.add_argument("--output", required=True) args = parser.parse_args() if args.command == "plan": - external = json.loads(Path(args.inputs_json).read_text()) if args.inputs_json else None - value = create_plan(args.project, json.loads(Path(args.event_path).read_text()), args.repository, - external_inputs=external) + value = create_plan(args.project, json.loads(Path(args.event_path).read_text()), args.repository) if os.environ.get("GITHUB_OUTPUT"): with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output: for key, item in value.items(): diff --git a/.github/scripts/test_check_rerun.py b/.github/scripts/test_check_rerun.py index 225a75cb83..f2823ce581 100644 --- a/.github/scripts/test_check_rerun.py +++ b/.github/scripts/test_check_rerun.py @@ -14,11 +14,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Behavioral tests for retry freshness, without credentials or network.""" -import copy -import io -import json -import zipfile +"""Behavioral tests for push retry freshness, without credentials or network.""" import importlib.util from pathlib import Path import unittest @@ -34,50 +30,25 @@ class FreshnessTest(unittest.TestCase): def setUp(self): - self.repository = "hugegraph/hugegraph-toolchain" + self.repository = "apache/hugegraph" self.run = {"status": "completed", "conclusion": "failure", "run_attempt": 1, - "event": "pull_request", "head_sha": "old", "head_branch": "feature", - "head_repository": {"full_name": "alice/toolchain", "owner": {"login": "alice"}}} - self.pr = {"number": 7, "state": "open", "head": {"sha": "old", "ref": "feature", "repo": {"full_name": "alice/toolchain", "owner": {"login": "alice"}}}, - "base": {"sha": "base", "repo": {"full_name": self.repository}}} - self.run["pull_requests"] = [copy.deepcopy(self.pr)] + "event": "push", "head_sha": "old", "head_branch": "release/test", + "repository": {"full_name": self.repository}, + "head_repository": {"full_name": self.repository}} + self.current_head = "old" self.calls = [] - def fetch(self, path, binary=False): - if "/artifacts?" in path: - return {"artifacts": [{"name": "ci-plan", "id": 99, "expired": False}]} - if "/actions/artifacts/" in path: - data = io.BytesIO() - with zipfile.ZipFile(data, "w") as archive: - archive.writestr("plan.json", json.dumps({"repository": self.repository, "pr": 7, - "head": "old", "base": "base", "testedMergeSHA": "merge"})) - return data.getvalue() - if "/git/commits/" in path: - return {"parents": [{"sha": "base"}, {"sha": "old"}]} + def fetch(self, path): self.calls.append(path) - if "/actions/runs/" in path: + if path == f"repos/{self.repository}/actions/runs/42": return self.run - if "/commits/" in path: - return [self.pr] - if "/commits?" in path: - return [{"sha": self.pr["head"]["sha"]}] - if "/pulls?" in path: - return [self.pr] - if "/pulls/" in path: - return self.pr - raise AssertionError(path) + if path == f"repos/{self.repository}/commits?sha=release%2Ftest&per_page=1": + return [{"sha": self.current_head}] if self.current_head else [] + raise AssertionError("Unexpected lookup: " + path) def decide(self): return checker.decide(self.repository, 42, 1, 2, self.fetch)[0] - def test_current_open_pr(self): - self.assertEqual("rerun", self.decide()) - - def test_slim_workflow_run_pr_association(self): - self.run["pull_requests"] = [{"number": 7, "base": {"repo": {"id": 1, "name": "hugegraph-toolchain", - "url": f"https://api.github.com/repos/{self.repository}"}}}] - self.assertEqual("rerun", self.decide()) - def test_api_failure_fails_closed(self): with tempfile.TemporaryDirectory() as directory: output = Path(directory) / "output" @@ -88,60 +59,10 @@ def test_api_failure_fails_closed(self): checker.main() self.assertIn("action=skip\n", output.read_text()) - def test_closed_pr(self): - self.pr["state"] = "closed" - self.assertEqual("skip", self.decide()) - - def test_updated_pr(self): - self.pr["head"]["sha"] = "new" - self.assertEqual("skip", self.decide()) - - def test_empty_association_falls_back_to_commit(self): - self.run["pull_requests"] = [] - self.assertEqual("rerun", self.decide()) - self.assertTrue(any("/commits/old/pulls" in p for p in self.calls)) - - def test_fork_empty_associations_owner_qualified_fallback(self): - self.run.update(pull_requests=[], head_repository={"full_name": "alice/toolchain", "owner": {"login": "alice"}}) - self.pr["head"].update(ref="feature", repo={"full_name": "alice/toolchain", "owner": {"login": "alice"}}) - def fetch(path, **kwargs): - if "/commits/old/pulls" in path: - return [] - if "/pulls?" in path: - self.assertIn("head=alice%3Afeature", path) - return [self.pr] - return self.fetch(path, **kwargs) - self.assertEqual("rerun", checker.decide(self.repository, 42, 1, 2, fetch)[0]) - self.pr["head"]["repo"]["owner"]["login"] = "bob" - self.assertEqual("skip", checker.decide(self.repository, 42, 1, 2, fetch)[0]) - - def test_other_repository_pr_is_ignored(self): - self.run["pull_requests"][0]["base"]["repo"]["full_name"] = "apache/hugegraph-toolchain" - self.assertEqual("rerun", self.decide()) - self.assertTrue(any("/commits/old/pulls" in p for p in self.calls)) - - def test_foreign_pr_fallback_still_rejects_foreign_pr(self): - self.run["pull_requests"][0]["base"]["repo"]["full_name"] = "apache/hugegraph-toolchain" - self.pr["base"]["repo"]["full_name"] = "apache/hugegraph-toolchain" - self.assertEqual("skip", self.decide()) - - def test_same_head_moved_base_is_stale(self): - self.pr["base"]["sha"] = "new-base" - self.assertEqual("skip", self.decide()) - - def test_missing_immutable_plan_refuses_retry(self): - def fetch(path, **kwargs): - if "/artifacts?" in path: - return {"artifacts": []} - return self.fetch(path, **kwargs) - self.assertEqual("skip", checker.decide(self.repository, 42, 1, 2, fetch)[0]) - - def test_same_sha_other_fork_cannot_authorize_retry(self): - self.pr["head"]["repo"].update(full_name="bob/toolchain", owner={"login": "bob"}) - self.assertEqual("skip", self.decide()) - self.pr["head"]["repo"].update(full_name="alice/toolchain", owner={"login": "alice"}) - self.pr["head"]["ref"] = "other" + def test_pr_is_paused_without_reading_artifacts_or_candidates(self): + self.run.update(event="pull_request", head_repository={"full_name": "alice/hugegraph"}) self.assertEqual("skip", self.decide()) + self.assertEqual([f"repos/{self.repository}/actions/runs/42"], self.calls) def test_changed_attempt(self): self.run["run_attempt"] = 2 @@ -159,20 +80,31 @@ def test_current_run_must_remain_failed_and_completed(self): def test_retry_limit(self): self.run["run_attempt"] = 3 self.assertEqual("skip", checker.decide(self.repository, 42, 3, 2, self.fetch)[0]) + self.run["run_attempt"] = 2 + self.assertEqual("rerun", checker.decide(self.repository, 42, 2, 2, self.fetch)[0]) def test_push_checks_repository_and_encodes_branch(self): - self.run.update(event="push", head_branch="release/test") self.assertEqual("rerun", self.decide()) self.assertIn(f"repos/{self.repository}/commits?sha=release%2Ftest&per_page=1", self.calls) - self.pr["head"]["sha"] = "new" + self.current_head = "new" self.assertEqual("skip", self.decide()) - - def test_unsupported_event(self): - self.run["event"] = "workflow_dispatch" + self.current_head = None self.assertEqual("skip", self.decide()) - def test_missing_metadata(self): - self.run["head_sha"] = None + def test_push_rejects_other_repository(self): + for field in ["repository", "head_repository"]: + with self.subTest(field=field): + self.run[field] = {"full_name": "alice/hugegraph"} + self.assertEqual("skip", self.decide()) + self.run[field] = {"full_name": self.repository} + + def test_missing_metadata_and_unsupported_event(self): + for field in ["head_sha", "head_branch", "repository", "head_repository"]: + old = self.run.pop(field) + with self.subTest(field=field): + self.assertEqual("skip", self.decide()) + self.run[field] = old + self.run["event"] = "workflow_dispatch" self.assertEqual("skip", self.decide()) diff --git a/.github/scripts/test_ci_policy.py b/.github/scripts/test_ci_policy.py index 0c0b417559..78a822902e 100644 --- a/.github/scripts/test_ci_policy.py +++ b/.github/scripts/test_ci_policy.py @@ -14,11 +14,8 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Executable Git fixtures and API proof tests for CI selection.""" -import hashlib +"""Executable Git fixtures and fail-closed gate tests for CI selection.""" import importlib.util -import io -import zipfile import json import os from pathlib import Path @@ -31,12 +28,17 @@ policy = importlib.util.module_from_spec(spec) spec.loader.exec_module(policy) - class PolicyTest(unittest.TestCase): + + def live_pr(self): + return {"state": "open", "head": {"sha": "head", "ref": "feature", "repo": {"full_name": "alice/server"}}, + "base": {"sha": "base", "repo": {"full_name": "apache/server"}}} + def setUp(self): - mock = patch.object(policy, "remote_fingerprint", side_effect=lambda *a, **k: "policy" if k.get("policy_only") else "inputs") - mock.start() - self.addCleanup(mock.stop) + # All tests stay local; successful PR gates query only this current-PR fixture. + api_mock = patch.object(policy, "api", return_value=self.live_pr()) + self.addCleanup(api_mock.stop) + self.api_mock = api_mock.start() def test_dependency_expansion(self): self.assertEqual({"client", "loader", "tools", "spark", "hubble"}, @@ -72,6 +74,28 @@ def test_audited_consumer_edges(self): self.assertTrue({"store", "docker"}.issubset(policy.select("server", [ "hugegraph-store/hg-store-dist/src/assembly/static/bin/util.sh"]))) + def test_pd_store_distribution_inputs_include_docker_consumers(self): + for module, distribution, startup in [ + ("pd", "hg-pd-dist", "start-hugegraph-pd.sh"), + ("store", "hg-store-dist", "start-hugegraph-store.sh"), + ]: + for relative in [f"src/assembly/static/bin/{startup}", + "src/assembly/static/conf/application.yml", + "src/assembly/descriptor/server-assembly.xml", "pom.xml"]: + path = f"hugegraph-{module}/{distribution}/{relative}" + with self.subTest(path=path): + selected = policy.select("server", [path]) + self.assertTrue({module, "hstore", "cluster", "docker"}.issubset(selected)) + self.assertIn("pd_store", policy.suites("server", selected)) + self.assertNotIn("server", selected) + self.assertNotIn("helm", selected) + self.assertEqual(relative == "pom.xml", "dependency_license" in selected) + source = f"hugegraph-{module}/hg-{module}-core/src/main/java/Example.java" + with self.subTest(path=source): + selected = policy.select("server", [source]) + self.assertTrue({module, "hstore", "cluster"}.issubset(selected)) + self.assertNotIn("docker", selected) + def test_version_resource_follows_real_docker_consumer(self): root = Path(__file__).resolve().parents[2] consumer = root / ".github/scripts/check-docker-images.sh" @@ -82,55 +106,6 @@ def test_version_resource_follows_real_docker_consumer(self): self.assertTrue((root / resource).is_file()) self.assertTrue({"commons", "docker"}.issubset(policy.select("server", [resource]))) - def test_external_hubble_baseline_only_affects_hubble(self): - a = {"server": {"sha": "release"}, "hubble": {"sha": "old"}} - b = {"server": {"sha": "release"}, "hubble": {"sha": "new"}} - self.assertEqual(policy.external_context(a, ["go"]), policy.external_context(b, ["go"])) - self.assertNotEqual(policy.external_context(a, ["hubble"]), policy.external_context(b, ["hubble"])) - - def test_packaged_readme_content_contract(self): - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - def git(*args): - return subprocess.check_output(["git", *args], cwd=root, text=True).strip() - git("init", "-q") - git("config", "user.email", "ci@example.invalid") - git("config", "user.name", "CI") - (root / "hugegraph-hubble").mkdir() - readme = root / "hugegraph-hubble/README.md" - readme.write_text("nonempty docs") - git("add", ".") - git("commit", "-qm", "valid readme") - old = os.getcwd() - try: - os.chdir(root) - initial = policy.fingerprint("HEAD", project="toolchain") - readme.write_text("changed nonempty docs") - git("commit", "-qam", "valid docs edit") - self.assertEqual(initial, policy.fingerprint("HEAD", project="toolchain")) - for content in ["", " \t\n"]: - readme.write_text(content) - git("commit", "-qam", "empty docs") - self.assertNotEqual("valid", policy.packaged_readme_state("HEAD")) - self.assertNotEqual(initial, policy.fingerprint("HEAD", project="toolchain")) - readme.write_text("nonempty") - readme.chmod(0o755) - git("add", ".") - git("commit", "-qm", "executable docs") - self.assertNotEqual("valid", policy.packaged_readme_state("HEAD")) - readme.unlink() - readme.symlink_to("missing-target") - git("add", ".") - git("commit", "-qm", "symlink docs") - self.assertNotEqual("valid", policy.packaged_readme_state("HEAD")) - readme.unlink() - git("add", ".") - git("commit", "-qm", "deleted docs") - self.assertEqual("missing", policy.packaged_readme_state("HEAD")) - self.assertNotEqual(initial, policy.fingerprint("HEAD", project="toolchain")) - finally: - os.chdir(old) - def test_single_workflow_has_no_global_fanout(self): self.assertEqual({"hubble"}, policy.select("toolchain", [".github/workflows/hubble-ci.yml"])) self.assertEqual({"docker"}, policy.select("server", [".github/workflows/docker-build-ci.yml"])) @@ -144,351 +119,7 @@ def test_strict_docs_allowlist(self): for path in ["hugegraph-client/src/test/resources/README.md", "docs/type.ts", "docs/config.yml", "docs/example.java", "hugegraph-server/type.ts"]: self.assertTrue(policy.select("server", [path])) - def plan(self): - return {"schema": 1, "project": "toolchain", "repository": "apache/t", "pr": 7, - "source": "alice/t", "branch": "feature", "base": "base", "head": "new", "testedMergeSHA": "merge", "inputFingerprint": hashlib.sha256(b"inputs{}").hexdigest(), - "policyFingerprint": "policy", "workflowID": 99, "expected": ["client"], - "selected": ["client"], "reused": {}} - - def receipt(self): - r = self.plan() - r["proofs"] = {"client": {"runID": 42, "head": "old", "testedMergeSHA": "oldmerge", "jobIDs": [123]}} - return r - - def fetch(self, path): - if "/git/commits/" in path: - return {"parents": [{"sha": "base"}, {"sha": "old"}]} - if path.endswith("/jobs?per_page=100&page=1"): - return {"jobs": [{"name": "client / client-ci", "id": 123, "conclusion": "success"}, - {"name": "affected-module-tests", "id": 124, "conclusion": "success"}]} - return {"status": "completed", "conclusion": "success", "event": "pull_request", - "head_sha": "old", "head_branch": "feature", "workflow_id": 99, "head_repository": {"full_name": "alice/t"}, - "pull_requests": [{"number": 7, "base": {"sha": "base"}}]} - - def test_valid_original_proof(self): - with patch.object(policy, "remote_fingerprint", side_effect=lambda *a, **k: "policy" if k.get("policy_only") else "inputs"): - self.assertIn("client", policy.validate_receipt(self.plan(), self.receipt(), self.fetch)) - - def test_empty_fork_association_owner_branch_fallback(self): - def fetch(path): - if "/pulls?" in path: - self.assertIn("head=alice%3Afeature", path) - return [{"number": 7, "head": {"ref": "feature", "repo": {"full_name": "alice/t"}}, - "base": {"repo": {"full_name": "apache/t"}}}] - value = self.fetch(path) - if "/actions/runs/" in path and "/jobs?" not in path: - value.update(pull_requests=[], head_branch="feature", - head_repository={"full_name": "alice/t", "owner": {"login": "alice"}}) - return value - self.assertIn("client", policy.validate_receipt(self.plan(), self.receipt(), fetch)) - - def test_nonblocking_failure_keeps_core_proof_but_bad_gate_rejects(self): - def fetch(path): - value = self.fetch(path) - if "/jobs?" not in path and "/actions/runs/" in path: - value["conclusion"] = "failure" - return value - self.assertIn("client", policy.validate_receipt(self.plan(), self.receipt(), fetch)) - def bad_gate(path): - value = fetch(path) - if "/jobs?" in path: - value["jobs"][1]["conclusion"] = "failure" - return value - with self.assertRaises(ValueError): - policy.validate_receipt(self.plan(), self.receipt(), bad_gate) - - def test_artifact_core_reuse_with_failed_compatibility(self): - receipt = self.receipt() - receipt["runID"] = 42 - data = io.BytesIO() - with zipfile.ZipFile(data, "w") as archive: - archive.writestr("receipt.json", json.dumps(receipt)) - artifact_size = 256 - def fetch(path, binary=False): - if "/actions/workflows/" in path: - self.assertIn("status=completed", path) - return {"workflow_runs": [{"id": 42, "head_repository": {"full_name": "alice/t"}, "head_branch": "feature", "pull_requests": [{"number": 7}]}]} - if "/artifacts?" in path: - return {"artifacts": [{"name": "ci-test-receipt", "id": 3, "expired": False, "size_in_bytes": artifact_size}]} - if "/actions/artifacts/" in path: - return data.getvalue() - value = self.fetch(path) - if "/jobs?" in path: - value["jobs"].append({"name": "HBase compatibility", "id": 9, "conclusion": "failure"}) - elif "/actions/runs/" in path: - value["conclusion"] = "failure" - return value - plan = self.plan() - with patch.dict(os.environ, {"GITHUB_RUN_ID": "99"}): - proof = policy.find_reuse(plan, fetch) - self.assertIn("client", proof) - self.assertFalse(plan["optionalSuccess"]["compatibility"]) - self.assertFalse(plan["optionalSuccess"]["security"]) - for artifact_size in [None, 0, 1048577, "256"]: - with patch.dict(os.environ, {"GITHUB_RUN_ID": "99"}): - self.assertEqual({}, policy.find_reuse(self.plan(), fetch)) - artifact_size = 256 - data = io.BytesIO() - with zipfile.ZipFile(data, "w") as archive: - archive.writestr("receipt.json", json.dumps(receipt)) - archive.writestr("unexpected.txt", "extra") - with patch.dict(os.environ, {"GITHUB_RUN_ID": "99"}): - self.assertEqual({}, policy.find_reuse(self.plan(), fetch)) - - def optional_fixture(self, receipts, optional_results=None): - optional_results = optional_results or {} - def fetch(path, binary=False): - if "/actions/workflows/" in path: - return {"workflow_runs": [{"id": run_id, "head_repository": {"full_name": "alice/t"}, - "head_branch": "feature", "pull_requests": [{"number": 7}]} - for run_id in sorted(receipts, reverse=True)]} - if "/artifacts?" in path: - run_id = int(path.split("/runs/")[1].split("/")[0]) - return {"artifacts": [{"name": "ci-test-receipt", "id": run_id, - "expired": False, "size_in_bytes": 512}]} - if "/actions/artifacts/" in path: - run_id = int(path.split("/artifacts/")[1].split("/")[0]) - data = io.BytesIO() - with zipfile.ZipFile(data, "w") as archive: - archive.writestr("receipt.json", json.dumps(receipts[run_id])) - return data.getvalue() - if "/git/commits/" in path: - head = "new" if path.endswith("/merge") else "old" - return {"parents": [{"sha": "base"}, {"sha": head}]} - if "/jobs?" in path: - run_id = int(path.split("/runs/")[1].split("/")[0]) - jobs = [{"name": "affected-module-tests", "id": run_id * 10, "conclusion": "success"}] - if run_id == 42: - jobs.append({"name": "client / client-ci", "id": 123, "conclusion": "success"}) - for index, name in enumerate(sorted(policy.optional_groups(self.plan())["security"])): - result = optional_results.get(run_id, "success" if run_id == 42 else "skipped") - jobs.append({"name": name, "id": run_id * 100 + index, "conclusion": result}) - return {"jobs": jobs} - value = self.fetch(path) - if "/actions/runs/43" in path: - value["head_sha"] = "new" - return value - return fetch - - def find_optional(self, receipts, optional_results=None): - plan = self.plan() - with patch.dict(os.environ, {"GITHUB_RUN_ID": "99"}), patch.object( - policy, "remote_fingerprint", side_effect=lambda *a, **k: "policy" if k.get("policy_only") else "inputs"): - plan["reused"] = policy.find_reuse(plan, self.optional_fixture(receipts, optional_results)) - return plan - - def test_optional_original_proof_survives_two_documentation_updates(self): - original = self.receipt() - original.update(runID=42, head="old", testedMergeSHA="oldmerge") - second = self.find_optional({42: original}) - self.assertTrue(second["optionalSuccess"]["security"]) - self.assertEqual(42, second["optionalProofs"]["security"]["runID"]) - with patch.dict(os.environ, {"GITHUB_RUN_ID": "43"}): - forwarded = policy.gate(second, {"plan": {"result": "success"}}, self.fetch) - third = self.find_optional({43: forwarded, 42: original}) - self.assertTrue(third["optionalSuccess"]["security"]) - self.assertEqual(second["optionalProofs"], third["optionalProofs"]) - self.assertEqual(second["reused"], third["reused"]) - for result in ["failure", "cancelled", "timed_out", None]: - with self.subTest(result=result): - latest = self.find_optional({43: forwarded, 42: original}, {43: result}) - self.assertTrue(latest["reused"]) - self.assertFalse(latest["optionalSuccess"]["security"]) - - def test_newer_unverifiable_receipt_blocks_older_optional_green(self): - original = self.receipt() - original.update(runID=42, head="old", testedMergeSHA="oldmerge") - invalid = dict(original, runID=43, source="other/t") - plan = self.find_optional({43: invalid, 42: original}) - self.assertTrue(plan["reused"]) - self.assertFalse(plan["optionalSuccess"]["security"]) - - def test_server_optional_matrix_follows_project_runtime(self): - plan = dict(self.plan(), project="server") - runtime_file = Path(__file__).resolve().parents[1] / "workflows/.java-version" - if not runtime_file.exists(): - self.skipTest("Server runtime configuration is not in the Toolchain repository") - runtime = runtime_file.read_text().strip() - names = policy.optional_groups(plan)["compatibility"] - self.assertIn(f"HBase compatibility (Java {runtime})", names) - self.assertEqual(4, len(names)) - jobs = [{"name": name, "id": index, "conclusion": "success"} - for index, name in enumerate(sorted(names))] - self.assertEqual(4, len(policy.optional_jobs(jobs, names))) - for missing in range(len(jobs)): - with self.assertRaises(ValueError): - policy.optional_jobs(jobs[:missing] + jobs[missing + 1:], names) - - def test_optional_forwarded_forgery_and_original_failure_reject(self): - original = self.receipt() - original.update(runID=42, head="old", testedMergeSHA="oldmerge") - second = self.find_optional({42: original}) - with patch.dict(os.environ, {"GITHUB_RUN_ID": "43"}): - forwarded = policy.gate(second, {"plan": {"result": "success"}}, self.fetch) - for field in ["jobIDs", "jobNames", "source", "base", "inputFingerprint", "policyFingerprint", "head"]: - changed = json.loads(json.dumps(forwarded)) - changed["optionalProofs"]["security"][field] = [999] if field == "jobIDs" else "forged" - with self.subTest(field=field): - latest = self.find_optional({43: changed, 42: original}) - self.assertTrue(latest["reused"]) - self.assertFalse(latest["optionalSuccess"]["security"]) - latest = self.find_optional({43: forwarded, 42: original}, {42: "failure"}) - self.assertFalse(latest["optionalSuccess"]["security"]) - - def test_optional_group_requires_complete_known_matrix(self): - plan = self.plan() - original = self.receipt() - original.update(runID=42, head="old", testedMergeSHA="oldmerge") - fetch = self.optional_fixture({42: original}) - for mutation in ["missing", "unknown", "duplicate", "api-error"]: - def bad_fetch(path, binary=False): - if "/actions/runs/" in path and mutation == "api-error": - raise OSError("unavailable") - value = fetch(path, binary) - if "/jobs?" in path: - if mutation == "missing": - value["jobs"].pop() - elif mutation == "unknown": - value["jobs"].append({"name": "security / Analyze (unknown)", "id": 999, "conclusion": "success"}) - elif mutation == "duplicate": - value["jobs"].append(dict(value["jobs"][-1])) - return value - jobs = fetch("repos/apache/t/actions/runs/42/jobs?per_page=100&page=1")["jobs"] - if mutation != "api-error": - jobs = bad_fetch("repos/apache/t/actions/runs/42/jobs?per_page=100&page=1")["jobs"] - with patch.object(policy, "remote_fingerprint", side_effect=lambda *a, **k: "policy" if k.get("policy_only") else "inputs"): - self.assertEqual({}, policy.optional_proofs(plan, original, jobs, set(), bad_fetch)) - - def test_branch_filtered_bounded_run_pagination(self): - calls = [] - def fetch(path, binary=False): - if "/actions/workflows/" in path: - calls.append(path) - self.assertIn("branch=feature", path) - if "page=1" in path: - return {"workflow_runs": [{"id": n, "head_repository": {"full_name": "alice/t"}, - "head_branch": "other"} for n in range(30)]} - return {"workflow_runs": []} - return self.fetch(path) - with patch.dict(os.environ, {"GITHUB_RUN_ID": "99"}): - self.assertEqual({}, policy.find_reuse(self.plan(), fetch)) - self.assertEqual(2, len(calls)) - - def test_tested_merge_parent_mismatch_rejects(self): - def fetch(path): - if "/git/commits/" in path: - return {"parents": [{"sha": "advanced-base"}, {"sha": "old"}]} - return self.fetch(path) - with self.assertRaises(ValueError): - policy.validate_receipt(self.plan(), self.receipt(), fetch) - - def test_actual_tree_fingerprint_mismatch_rejects(self): - with patch.object(policy, "remote_fingerprint", return_value="forged"): - with self.assertRaises(ValueError): - policy.validate_receipt(self.plan(), self.receipt(), self.fetch) - - def test_provenance_changes_refuse_reuse(self): - for key in ["base", "inputFingerprint", "policyFingerprint", "source", "repository", "pr", "schema"]: - receipt = self.receipt() - receipt[key] = "changed" - with self.subTest(key=key), self.assertRaises(ValueError): - policy.validate_receipt(self.plan(), receipt, self.fetch) - - def test_original_run_and_actual_job_proof_required(self): - for failure in ["failure", "cancelled", "skipped"]: - def fetch(path): - value = self.fetch(path) - if "jobs?" in path: - value["jobs"][0]["conclusion"] = failure - return value - with self.subTest(failure=failure), self.assertRaises(ValueError): - policy.validate_receipt(self.plan(), self.receipt(), fetch) - receipt = self.receipt() - receipt["proofs"]["client"]["jobIDs"] = [456] - with self.assertRaises(ValueError): - policy.validate_receipt(self.plan(), receipt, self.fetch) - - def test_gate_rejects_missing_failed_cancelled_skipped(self): - for result in [None, "failure", "cancelled", "skipped"]: - with self.subTest(result=result), self.assertRaises(ValueError): - policy.gate(self.plan(), {"plan": {"result": "success"}, "client": {"result": result}}, self.fetch) - - def test_third_party_failure_does_not_exempt_required_tests(self): - selected = policy.select("server", ["pom.xml"]) - self.assertIn("dependency_license", selected) - expected = {"server_memory", "server_rocksdb", "commons", "pd_store", "cluster", "docker", "helm"} - self.assertEqual(expected, set(policy.suites("server", selected))) - plan = self.plan() - plan.update(project="server", selected=sorted(selected), expected=sorted(expected)) - results = {suite: {"result": "success"} for suite in expected} - results["plan"] = {"result": "success"} - for result in ["failure", "cancelled", "skipped", None]: - results["dependency_license"] = {"result": result} - with self.subTest(third_party=result): - receipt = policy.gate(plan, results, self.fetch) - self.assertEqual(expected, set(receipt["executed"])) - self.assertNotIn("dependency_license", receipt["proofs"]) - for result in ["failure", "cancelled", "skipped", None]: - results["cluster"] = {"result": result} - with self.subTest(cluster=result), self.assertRaisesRegex(ValueError, "cluster"): - policy.gate(plan, results, self.fetch) - - def test_gate_success_receipt_and_verified_reuse(self): - with patch.dict(os.environ, {"GITHUB_RUN_ID": "42"}): - result = policy.gate(self.plan(), {"plan": {"result": "success"}, "client": {"result": "success"}, "fixture": {"result": "success"}}, self.fetch) - self.assertEqual([123], result["proofs"]["client"]["jobIDs"]) - plan = self.plan() - plan["reused"] = result["proofs"] - self.assertEqual(["client"], policy.gate(plan, {"plan": {"result": "success"}, "client": {"result": "skipped"}}, self.fetch)["reused"]) - - def test_gate_refuses_failed_planner_even_empty_or_reused(self): - for result in ["failure", "skipped", "cancelled", None]: - plan = self.plan() - plan["expected"] = [] - with self.subTest(result=result), self.assertRaises(ValueError): - policy.gate(plan, {"plan": {"result": result}}, self.fetch) - - def test_fixture_missing_rejects_successful_test(self): - with self.assertRaises(ValueError): - policy.gate(self.plan(), {"plan": {"result": "success"}, "client": {"result": "success"}}, self.fetch) - plan = self.plan() - plan["expected"] = ["hubble"] - with self.assertRaises(ValueError): - policy.gate(plan, {"plan": {"result": "success"}, "hubble": {"result": "success"}}, self.fetch) - - def test_proof_failure_disables_receipt_without_falsifying_gate(self): - def failure(path): - raise subprocess.CalledProcessError(1, "gh") - result = policy.gate(self.plan(), {"plan": {"result": "success"}, "client": {"result": "success"}, - "fixture": {"result": "success"}}, failure) - self.assertEqual({}, result["proofs"]) - self.assertEqual(["client"], result["executed"]) - - def test_doc_update_reuses_only_with_valid_receipt(self): - event = {"pull_request": {"number": 7, "head": {"sha": "new"}, "base": {"sha": "base"}}} - live = {"state": "open", "head": {"sha": "new", "ref": "feature", "repo": {"full_name": "alice/t"}}, - "base": {"sha": "base", "repo": {"full_name": "apache/t"}}} - def git(*args): - if args[0] == "show": - return "base new" - if args[0] == "diff": - return "hugegraph-client/src/A.java\nREADME.md" - return "new" - with patch.object(policy, "packaged_readme_state", return_value="valid"), patch.object(policy, "unsafe_documentation", return_value=False), patch.object(policy, "git", side_effect=git), patch.object(policy, "fingerprint", return_value="hash"), patch.object( - policy, "find_reuse", return_value={m: {"runID": 42} for m in ["client", "loader", "tools", "spark", "hubble"]}): - plan = policy.create_plan("toolchain", event, "apache/t", lambda p: live, - external_inputs={"serverSHA": "immutable"}) - self.assertFalse(plan["client"]) - self.assertFalse(plan["needsFixture"]) - self.assertTrue(plan["security"]) - with patch.object(policy, "packaged_readme_state", return_value="valid"), patch.object(policy, "unsafe_documentation", return_value=False), patch.object(policy, "git", side_effect=git), patch.object(policy, "fingerprint", return_value="hash"), patch.object( - policy, "find_reuse", return_value={}): - plan = policy.create_plan("toolchain", event, "apache/t", lambda p: live, - external_inputs={"serverSHA": "immutable"}) - self.assertTrue(plan["client"]) - self.assertTrue(plan["needsFixture"]) - - def test_real_git_fingerprint_and_cli(self): + def test_real_git_modes_rename_and_cli(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory) def git(*args): @@ -506,16 +137,13 @@ def git(*args): old = os.getcwd() try: os.chdir(root) - self.assertEqual(policy.fingerprint(base), policy.fingerprint(head)) (root / "type.ts").write_text("type changed") git("add", ".") git("commit", "-qm", "types") - self.assertNotEqual(policy.fingerprint(base), policy.fingerprint("HEAD")) (root / "README.md").chmod(0o755) git("add", "README.md") git("commit", "-qm", "executable prose") self.assertTrue(policy.unsafe_documentation("HEAD", ["README.md"])) - self.assertNotEqual(policy.fingerprint(base), policy.fingerprint("HEAD")) (root / "README.md").unlink() (root / "README.md").symlink_to("type.ts") git("add", "README.md") @@ -544,15 +172,224 @@ def git(*args): self.assertEqual(policy.suites("server", set(policy.MODULES["server"])), json.loads((root / "plan.json").read_text())["expected"]) - def test_api_failure_runs_full_required(self): - event = {"pull_request": {"number": 7, "head": {"sha": "old"}}} - with patch.object(policy, "git", return_value="head"): - def fail(path): - raise subprocess.CalledProcessError(1, "gh") - plan = policy.create_plan("toolchain", event, "apache/t", fail) - self.assertEqual(set(policy.MODULES["toolchain"]), set(plan["selected"])) - self.assertTrue(plan["security"]) - self.assertEqual({}, plan["reused"]) + def plan(self): + return {"schema": 1, "project": "server", "repository": "apache/server", "pr": 7, + "source": "alice/server", "branch": "feature", "base": "base", "head": "head", + "testedMergeSHA": "merge", "expected": ["server_memory", "server_rocksdb", "pd_store", "cluster"], + "selected": ["server", "pd", "store", "hstore", "cluster"]} + + def test_gate_rejects_missing_failed_cancelled_skipped(self): + plan = self.plan() + results = {suite: {"result": "success"} for suite in plan["expected"]} + results["plan"] = {"result": "success"} + # Even an old plan with claimed successes cannot exempt any current suite. + plan["reused"] = {suite: {"runID": 42, "testedMergeSHA": "forged"} for suite in plan["expected"]} + for suite in plan["expected"]: + for result in [None, "failure", "cancelled", "skipped"]: + with self.subTest(suite=suite, result=result), self.assertRaisesRegex(ValueError, suite): + policy.gate(plan, dict(results, **{suite: {"result": result}})) + self.assertEqual(plan["expected"], policy.gate(plan, results)["executed"]) + + def test_third_party_failure_does_not_exempt_required_tests(self): + selected = policy.select("server", ["pom.xml"]) + self.assertIn("dependency_license", selected) + expected = {"server_memory", "server_rocksdb", "commons", "pd_store", "cluster", "docker", "helm"} + self.assertEqual(expected, set(policy.suites("server", selected))) + plan = dict(self.plan(), selected=sorted(selected), expected=sorted(expected)) + results = {suite: {"result": "success"} for suite in expected} + results["plan"] = {"result": "success"} + for result in ["failure", "cancelled", "skipped", None]: + results["dependency_license"] = {"result": result} + self.assertEqual(expected, set(policy.gate(plan, results)["executed"])) + results["cluster"] = {"result": "failure"} + with self.assertRaisesRegex(ValueError, "cluster"): + policy.gate(plan, results) + + def test_incomplete_pr_event_never_becomes_a_push_plan(self): + event = {"pull_request": dict(self.live_pr(), number=7)} + cases = [] + for change in ("null_repo", "missing_repo", "missing_number", "empty_ref"): + altered = json.loads(json.dumps(event)) + pr = altered["pull_request"] + if change == "null_repo": + pr["head"]["repo"] = None + elif change == "missing_repo": + del pr["head"]["repo"] + elif change == "missing_number": + del pr["number"] + else: + pr["head"]["ref"] = "" + cases.append(altered) + cases.extend([{"pull_request": {}}, {"pull_request": None}]) + with patch.object(policy, "git", return_value="merge"): + for altered in cases: + with self.subTest(event=altered), self.assertRaises(policy.StaleInputError): + policy.create_plan('server', altered, 'apache/server', + lambda _: self.fail("Incomplete identity must fail before any API query")) + + def test_gate_refuses_failed_planner_even_empty(self): + plan = dict(self.plan(), expected=[], selected=[]) + for result in ["failure", "skipped", "cancelled", None]: + with self.subTest(result=result), self.assertRaises(ValueError): + policy.gate(plan, {"plan": {"result": result}}) + report = policy.gate(plan, {"plan": {"result": "success"}}) + self.assertEqual([], report["executed"]) + self.assertNotIn("proofs", report) + + def test_cumulative_source_and_docs_always_run_current_tests_without_historical_apis(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + def git(*args): + return subprocess.check_output(["git", *args], cwd=root, text=True).strip() + git("init", "-q") + git("config", "user.email", "ci@example.invalid") + git("config", "user.name", "CI") + (root / "README.md").write_text("base") + git("add", ".") + git("commit", "-qm", "base") + base = git("rev-parse", "HEAD") + git("checkout", "-qb", "feature") + (root / "hugegraph-server").mkdir() + source = root / "hugegraph-server/A.java" + source.write_text("source A") + git("add", ".") + git("commit", "-qm", "source") + for text in ["docs B after test failure", "docs C after claimed old success"]: + (root / "README.md").write_text(text) + git("commit", "-qam", "docs") + head = git("rev-parse", "HEAD") + # Synthetic PR merge; a different tree with the same parents also exists. + tree = git("rev-parse", "HEAD^{tree}") + merge = git("commit-tree", tree, "-p", base, "-p", head, "-m", "PR merge") + false_merge = git("commit-tree", git("rev-parse", base + "^{tree}"), + "-p", base, "-p", head, "-m", "unexecuted tree") + self.assertNotEqual(merge, false_merge) + git("checkout", "--detach", "-q", merge) + live = {"state": "open", "head": {"sha": head, "ref": "feature", "repo": {"full_name": "alice/server"}}, + "base": {"sha": base, "repo": {"full_name": "apache/server"}}} + event = {"pull_request": dict(live, number=7)} + calls = [] + def fetch(path): + calls.append(path) + self.assertEqual("repos/apache/server/pulls/7", path) + return live + old = os.getcwd() + try: + os.chdir(root) + plan = policy.create_plan("server", event, "apache/server", fetch) + finally: + os.chdir(old) + self.assertEqual(["repos/apache/server/pulls/7"], calls) + self.assertTrue(plan["server"]) + self.assertTrue(plan["pd_store"]) + self.assertTrue(plan["cluster"]) + self.assertTrue(plan["compatibility"]) + self.assertTrue(plan["security"]) + self.assertNotIn("reused", plan) + results = {suite: {"result": "success"} for suite in plan["expected"]} + results["plan"] = {"result": "success"} + results["server_memory"] = {"result": "skipped"} + with self.assertRaisesRegex(ValueError, "server_memory"): + policy.gate(plan, results) + git("checkout", "-q", "feature") + + def test_pure_docs_pr_and_metadata_fail_closed(self): + live = self.live_pr() + event = {"pull_request": dict(live, number=7)} + def git(*args): + if args[0] == "show": + return "base head" + if args[0] == "diff": + return "README.md\ndocs/ci.md" + return "merge" + with patch.object(policy, "git", side_effect=git), patch.object(policy, "unsafe_documentation", return_value=False): + plan = policy.create_plan("server", event, "apache/server", lambda p: live) + self.assertEqual([], plan["expected"]) + self.assertFalse(plan["server"]) + self.assertFalse(plan["security"]) + advanced = dict(live, base={"sha": "new-base", "repo": {"full_name": "apache/server"}}) + with self.assertRaises(policy.StaleInputError): + policy.create_plan("server", event, "apache/server", lambda p: advanced) + with patch.object(policy, "git", side_effect=lambda *a: "wrong parents" if a[0] == "show" else git(*a)): + with self.assertRaises(policy.StaleInputError): + policy.create_plan("server", event, "apache/server", lambda p: live) + + def test_api_failure_runs_full_required_without_losing_pr_identity(self): + event = {"pull_request": dict(self.live_pr(), number=7)} + def git(*args): + return "base head" if args[0] == "show" else "merge" + def fail(path): + raise subprocess.CalledProcessError(1, "gh") + with patch.object(policy, "git", side_effect=git): + plan = policy.create_plan("server", event, "apache/server", fail) + self.assertEqual(set(policy.MODULES["server"]), set(plan["selected"])) + self.assertEqual((7, "alice/server", "feature", "base", "head"), + tuple(plan[key] for key in ["pr", "source", "branch", "base", "head"])) + results = {suite: {"result": "success"} for suite in plan["expected"]} + results.update(plan={"result": "success"}, fixture={"result": "success"}, + **{"hubble-fixture": {"result": "success"}}) + with self.assertRaises(subprocess.CalledProcessError): + policy.gate(plan, results, fail) + self.assertEqual(plan["expected"], policy.gate(plan, results)["executed"]) + self.api_mock.assert_called_once_with("repos/apache/server/pulls/7") + + def test_real_pr_git_snapshot_and_final_gate_freshness(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + def git(*args): + return subprocess.check_output(["git", *args], cwd=root, text=True).strip() + git("init", "-q") + git("config", "user.email", "ci@example.invalid") + git("config", "user.name", "CI") + (root / "README.md").write_text("base") + git("add", ".") + git("commit", "-qm", "base") + base = git("rev-parse", "HEAD") + git("checkout", "-qb", "feature") + (root / "hugegraph-client").mkdir() + (root / "hugegraph-client/A.java").write_text("source") + git("add", ".") + git("commit", "-qm", "source") + head = git("rev-parse", "HEAD") + tree = git("rev-parse", "HEAD^{tree}") + merge = git("commit-tree", tree, "-p", base, "-p", head, "-m", "PR merge") + advanced_head = git("commit-tree", tree, "-p", head, "-m", "new source head") + advanced_base = git("commit-tree", git("rev-parse", base + "^{tree}"), + "-p", base, "-m", "new target base") + git("checkout", "--detach", "-q", merge) + live = self.live_pr() + live["head"]["sha"], live["base"]["sha"] = head, base + event = {"pull_request": dict(live, number=7)} + old = os.getcwd() + try: + os.chdir(root) + plan = policy.create_plan("server", event, "apache/server", lambda _: live) + results = {suite: {"result": "success"} for suite in plan["expected"]} + results.update(plan={"result": "success"}, fixture={"result": "success"}, + **{"hubble-fixture": {"result": "success"}}) + self.assertEqual(plan["expected"], policy.gate(plan, results, lambda _: live)["executed"]) + for section, field, value in [("head", "sha", advanced_head), ("base", "sha", advanced_base), + ("head", "ref", "other-branch"), + ("head", "repo", {"full_name": "other/fork"}), + ("base", "repo", {"full_name": "other/target"})]: + changed = dict(live, **{section: dict(live[section], **{field: value})}) + with self.subTest(section=section, field=field): + with self.assertRaises(policy.StaleInputError): + policy.create_plan("server", event, "apache/server", lambda _: changed) + with self.assertRaises(policy.StaleInputError): + policy.gate(plan, results, lambda _: changed) + with self.assertRaises(policy.StaleInputError): + policy.gate(plan, results, lambda _: dict(live, state="closed")) + git("checkout", "--detach", "-q", head) + with self.assertRaises(policy.StaleInputError): + policy.create_plan("server", event, "apache/server", lambda _: live) + finally: + os.chdir(old) + + def test_push_gate_does_not_query_pr(self): + plan = dict(self.plan(), pr=0, expected=[], selected=[]) + policy.gate(plan, {"plan": {"result": "success"}}, + lambda _: self.fail("push gate must not query a PR")) if __name__ == "__main__": diff --git a/.github/workflows/rerun-ci.yml b/.github/workflows/rerun-ci.yml index 7cfe2e0607..d76223d5b1 100644 --- a/.github/workflows/rerun-ci.yml +++ b/.github/workflows/rerun-ci.yml @@ -18,7 +18,7 @@ env: jobs: decide-rerun-action: - if: ${{ github.event.workflow_run.conclusion == 'failure' }} + if: ${{ github.event.workflow_run.conclusion == 'failure' && github.event.workflow_run.event == 'push' }} runs-on: ubuntu-latest outputs: action: ${{ steps.decision.outputs.action }} @@ -37,7 +37,7 @@ jobs: action="skip" reason="unsupported event: $EVENT_NAME" - if [[ "$EVENT_NAME" == "push" || "$EVENT_NAME" == "pull_request" ]]; then + if [[ "$EVENT_NAME" == "push" ]]; then if (( RUN_ATTEMPT > MAX_RERUNS )); then reason="retry limit reached" else @@ -71,7 +71,6 @@ jobs: permissions: actions: write contents: read - pull-requests: read runs-on: ubuntu-latest steps: # workflow_run is privileged: only load code from the default-branch commit. diff --git a/.github/workflows/server-ci.yml b/.github/workflows/server-ci.yml index 4dd4dd85d2..2bc2dce581 100644 --- a/.github/workflows/server-ci.yml +++ b/.github/workflows/server-ci.yml @@ -16,7 +16,6 @@ concurrency: permissions: contents: read - actions: read pull-requests: read jobs: @@ -37,7 +36,7 @@ jobs: with: fetch-depth: 0 persist-credentials: false - - name: Test selection, receipts and retry policy + - name: Test selection, gate and retry policy run: python3 -m unittest discover -s .github/scripts -p 'test_*.py' - name: Select affected tests id: select @@ -335,7 +334,7 @@ jobs: - uses: actions/download-artifact@v4 with: name: ci-plan - - name: Validate selected suites and publish receipt + - name: Validate selected suites and report results env: GH_TOKEN: ${{ github.token }} JOB_RESULTS: ${{ toJSON(needs) }} @@ -354,15 +353,15 @@ jobs: with open('results.json', 'w') as output: json.dump(results, output) PYGATE - python3 .github/scripts/ci-policy.py gate --plan plan.json --results results.json --output receipt.json + python3 .github/scripts/ci-policy.py gate --plan plan.json --results results.json --output test-results.json - uses: actions/upload-artifact@v4 with: - name: ci-test-receipt - path: receipt.json + name: ci-test-results + path: test-results.json retention-days: 14 overwrite: true - # Temporary protection aliases; skipped modules pass only through the verified gate. + # Temporary protection aliases; skipped modules pass only through the current-run gate. server-memory-required: name: Server memory tests needs: affected-module-tests diff --git a/docs/ci.md b/docs/ci.md index 554f97c556..9c5cd9fb80 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -7,11 +7,11 @@ workflows are reusable and can also be started manually. | Changed inputs | Required Linux coverage | | --- | --- | | Any non-document Server module input, including RocksDB and HStore | Memory, RocksDB, PD/Store/HStore and Cluster | -| Distribution or shared startup scripts | Server, PD/Store/HStore, Docker and Cluster | +| Server distribution or shared startup scripts | Server, PD/Store/HStore, Docker and Cluster | | Commons or Struct | Their tests and affected Server, PD, Store, HStore and Cluster tests | -| PD or Store | PD/Store/HStore suite and Cluster | +| PD or Store distribution inputs (`hg-pd-dist/`, `hg-store-dist/`), including scripts, configs, assembly descriptors and POMs | PD/Store/HStore, Cluster and Docker | +| Other PD or Store inputs | PD/Store/HStore suite and Cluster | | Cluster, Docker or Helm | The corresponding suite; Docker includes Compose render/smoke and startup contracts | -| Store port utility | PD/Store/HStore, Cluster and Docker startup contracts | | Central `server-ci.yml` | All Linux suites controlled by the caller | | A module workflow | Its suite and known downstream suites | | Dependencies, shared build inputs or unknown paths | Conservative full coverage | @@ -22,12 +22,12 @@ unexpected skip cannot satisfy the gate. Startup prerequisites are enforced. Third-party dependency inventory and vulnerability review retain their existing non-blocking policy. They run when selected, but are excluded from the core gate -and test-reuse receipts. Their failure does not prevent affected module tests or +and current-run test results. Their failure does not prevent affected module tests or post-gate checks from reporting results; the required license check is unchanged. The Docker suite builds and loads the four production images from the current checkout on one Linux runner, sharing their Maven build through BuildKit Bake. -It verifies image healthcheck and Java contracts, then starts standalone Server +It verifies healthcheck presence and Java contracts, then starts standalone Server and the PD/Store/Server topology with unique run tags and pulling disabled. Runtime checks match each container's image ID to the build, require healthy services and validate Server version and authenticated graph-list responses. @@ -44,19 +44,26 @@ not grant runner priority over other PRs. Existing scheduled CodeQL scanning rem Only explicitly allowed prose and static documentation assets qualify. Source, types, tests, dependencies and CI configuration never qualify as documentation. -An unchanged test input can reuse a receipt from the same open PR only when its -base, source repository and CI policy match. GitHub job records must prove the -selected suites completed successfully. Receipt lookup is restricted to the PR -branch and bounded pages; repository and PR provenance are still checked. The current commit gets a fresh gate and -summary identifying executed and reused suites. Missing or unverifiable evidence -causes the tests to run again. -For consecutive documentation updates, optional-check proofs retain their original -run and exact job identities and are reverified before forwarding. A newer failed -check or unverifiable evidence prevents reuse of an older optional success. +A PR containing only allowed documentation changes can omit unaffected modules. +A source PR that later receives a documentation update still selects its cumulative +source changes and runs the necessary module tests, compatibility and security +checks on every update. Cross-run success reuse is paused: an artifact's claimed +merge SHA, parents and job IDs do not independently prove the original execution tree. +A trustworthy execution-proof design is separate work. -The plan and successful receipt are saved as `ci-plan` and `ci-test-receipt` -artifacts. They are evidence, not credentials; each reused proof is checked against -GitHub rather than trusted solely because an artifact exists. +The plan and current-run results are saved as `ci-plan` and `ci-test-results` +artifacts. Results describe only this run's successful selected suites; they are +not authentication evidence and never authorize a later run to skip tests. + +PR planning records the event's head, base and source before querying live metadata. +A known mismatch with the checkout merge or current PR fails planning; expanding +coverage cannot make an outdated checkout current. After all selected jobs and +fixtures succeed, the gate rechecks the open PR's head, base and source using a +read-only API request. Changed inputs or unavailable metadata fail the gate. +Refresh the branch and start a new PR event after head or base movement: GitHub +reruns retain the original commit and event, so rerunning alone cannot refresh the +base. An unknown API or selection failure falls back to full coverage; a manual +full rerun can recover a transient failure while the recorded inputs remain current. ## Protection migration @@ -68,7 +75,10 @@ repository variable to `true` then permits unchanged documentation updates to avoid redundant security scanning. Enabling the variable is a separate rollout operation. -Automatic retry verifies the run and PR/branch before and after its existing wait. -A moved PR base or missing immutable plan also prevents an obsolete retry. -A retry has a separate concurrency group, so retrying an old commit cannot cancel -the newest PR run. Only failed jobs are retried. +Automatic retry is limited to failed pushes. It verifies the run attempt, source +repository and current branch head before and after its existing wait; at most two +automatic reruns are allowed. Only failed jobs are retried. PR automatic retry is +paused because it relied on the PR's CI plan artifact to attest the tested base. +For a transient PR failure with unchanged inputs, use a manual full rerun so shared +build and fixture artifacts are rebuilt together with their consumers. A retry retains its separate concurrency +group and cannot cancel a newer PR run. From 2a377062519ad45b8c085b9add752a4f2d1411d4 Mon Sep 17 00:00:00 2001 From: imbajin Date: Tue, 6 Oct 2026 17:00:04 +0800 Subject: [PATCH 7/7] fix(ci): preserve CodeQL scan identity Keep the existing analysis key across reusable callers Keep per-language scan categories aligned with master Quote staged Maven settings paths for workflow validation --- .github/workflows/codeql-analysis.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index ce53d83068..ba82f833f4 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -15,6 +15,8 @@ jobs: analyze: env: USE_STAGE: 'false' # Whether to include the stage repository. + # Preserve the master scan identity when invoked by a reusable-workflow caller. + CODEQL_ACTION_ANALYSIS_KEY: .github/workflows/codeql-analysis.yml:analyze name: Analyze runs-on: ubuntu-latest permissions: @@ -40,8 +42,8 @@ jobs: - name: use staged maven repo settings if: ${{ env.USE_STAGE == 'true' }} run: | - cp $HOME/.m2/settings.xml /tmp/settings.xml - mv -vf .github/configs/settings.xml $HOME/.m2/settings.xml + cp "$HOME/.m2/settings.xml" /tmp/settings.xml + mv -vf .github/configs/settings.xml "$HOME/.m2/settings.xml" # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL @@ -71,3 +73,5 @@ jobs: - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v4 + with: + category: .github/workflows/codeql-analysis.yml:analyze/language:${{ matrix.language }}