diff --git a/.github/scripts/ci-slash.py b/.github/scripts/ci-slash.py index 61efa97fdf4..528df3feee7 100644 --- a/.github/scripts/ci-slash.py +++ b/.github/scripts/ci-slash.py @@ -13,7 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Admit `/ci` comments and dispatch a pinned draft-PR CI workflow.""" +"""Admit `/ci` comments and dispatch a draft-PR CI workflow.""" import json, os, re, sys, urllib.request COMMAND = re.compile(r'^/ci(?:\s+(.+))?$') @@ -29,7 +29,7 @@ OPTIONS = { 'fuzz chaos': ('integration.yml', 'chaos', 'chaos fuzz'), 'fuzz all': ('integration.yml', 'all', 'all fuzz'), } -HELP = '''Available draft-PR CI commands:\n\n- `/ci` or `/ci rust` — Rust CI\n- `/ci integration` — integration CI without fuzz\n- `/ci checks` or `/ci docs`\n- `/ci fuzz standalone|distributed|chaos`\n- `/ci fuzz all` — all fuzz suites (admin only)\n\nCommands require repository admin permission and a same-repository open draft PR. CI is pinned to the current head SHA; comment again after a push.''' +HELP = '''Available draft-PR CI commands:\n\n- `/ci` — standard CI\n- `/ci rust` — Rust CI\n- `/ci integration` — integration CI without fuzz\n- `/ci checks` or `/ci docs`\n- `/ci fuzz standalone|distributed|chaos`\n- `/ci fuzz all` — all fuzz suites (admin only)\n\nCommands require the PR author or repository write/maintain/admin permission and an open draft PR. Fork PRs require repository write permission and rerun existing pull-request CI; fuzz commands are same-repository only. `/ci fuzz all` requires admin permission. Same-repository CI runs the branch head at dispatch time. Fork CI reruns the original pull-request revision (within GitHub rerun limits); sync your branch with the updated CI configuration first.''' def api(path): req=urllib.request.Request(os.environ['GITHUB_API_URL']+path, headers={'Authorization':'Bearer '+os.environ['GITHUB_TOKEN'],'Accept':'application/vnd.github+json'}) @@ -46,7 +46,7 @@ def reject(number, text): out(skip='true',pr_number=number,reply='CI command ign def main(): if os.environ.get('DISPATCH_SENDER') != 'github-actions[bot]': return reject('', 'invalid dispatch sender.') comment=api('/repos/'+os.environ['GITHUB_REPOSITORY']+'/issues/comments/'+os.environ['COMMENT_ID']) - body=comment.get('body','').splitlines()[0].strip(); m=COMMAND.fullmatch(body) + body=(comment.get('body') or '').partition('\n')[0].strip(); m=COMMAND.fullmatch(body) number=str(comment.get('issue_url','').rstrip('/').split('/')[-1]) if not m: return reject(number,'comment is not a `/ci` command.') arg=(m.group(1) or '').strip().lower() @@ -54,13 +54,38 @@ def main(): if arg=='help': out(skip='true',pr_number=number,reply=HELP); return 0 pr=api('/repos/'+os.environ['GITHUB_REPOSITORY']+'/pulls/'+number) if pr.get('state')!='open' or not pr.get('draft'): return reject(number,'PR must be open and draft.') - if pr.get('head',{}).get('repo',{}).get('full_name') != os.environ['GITHUB_REPOSITORY']: return reject(number,'fork PRs are not admitted.') - head=pr.get('head',{}).get('sha','') - if head != os.environ.get('DISPATCH_HEAD_SHA') or not re.fullmatch('[0-9a-f]{40}',head): return reject(number,'PR head changed; comment again.') + fork=pr.get('head',{}).get('repo',{}).get('full_name') != os.environ['GITHUB_REPOSITORY'] + if fork and arg.startswith('fuzz '): return reject(number,'fuzz commands require a same-repository PR.') + head=pr.get('head',{}) + head_sha=head.get('sha','') + head_ref=head.get('ref','') + if head_sha != os.environ.get('DISPATCH_HEAD_SHA') or not re.fullmatch('[0-9a-f]{40}',head_sha) or not head_ref: return reject(number,'PR head changed; comment again.') actor=comment.get('user',{}).get('login','') - permission=api('/repos/'+os.environ['GITHUB_REPOSITORY']+'/collaborators/'+actor+'/permission').get('user',{}).get('permission') - if permission!='admin': return reject(number,'repository admin permission is required.') + if not actor: return reject(number,'comment author is missing.') + is_author=actor==pr.get('user',{}).get('login') + if fork or arg=='fuzz all' or not is_author: + permission=api('/repos/'+os.environ['GITHUB_REPOSITORY']+'/collaborators/'+actor+'/permission').get('permission') + if arg=='fuzz all': + if permission!='admin': return reject(number,'repository admin permission is required for `/ci fuzz all`.') + elif permission not in ('write','maintain','admin'): + return reject(number,'PR author or repository write permission is required.') workflow, profile, label=OPTIONS[arg] - out(skip='false',pr_number=number,head_sha=head,workflow=workflow,fuzz_profile=profile,reply=f'Dispatched {label} for `{head}`.') + if fork: + runs=api('/repos/'+os.environ['GITHUB_REPOSITORY']+'/actions/runs?event=pull_request&head_sha='+head_sha+'&per_page=100')['workflow_runs'] + selected={} + for run in runs: + name=run.get('path','').split('@',1)[0].removeprefix('.github/workflows/') + if name not in workflow.split(',') or name in selected: continue + if run.get('head_sha') != head_sha or run.get('head_repository',{}).get('full_name') != head['repo']['full_name'] or run.get('head_branch') != head_ref: continue + selected[name]=run + if not selected: return reject(number,'no pull-request CI runs found for this head; push a new commit first.') + if arg and workflow not in selected: return reject(number,'selected CI has no pull-request run for this head.') + if any(run['status'] != 'completed' for run in selected.values()): return reject(number,'pull-request CI is still pending; approve or wait for it, then comment again.') + for run in selected.values(): + original=run if run.get('run_attempt',1)==1 else api('/repos/'+os.environ['GITHUB_REPOSITORY']+'/actions/runs/'+str(run['id'])+'/attempts/1') + if original.get('conclusion') != 'skipped': return reject(number,'only originally skipped draft CI runs can be requested; push a new commit while draft.') + out(skip='false',pr_number=number,run_ids=','.join(str(run['id']) for run in selected.values()),reply=f'Requested rerun of {", ".join(selected)} for `{head_sha}`.') + return 0 + out(skip='false',pr_number=number,head_sha=head_sha,head_ref=head_ref,workflow=workflow,fuzz_profile=profile,reply=f'Dispatched {label} for branch `{head_ref}`.') return 0 if __name__ == '__main__': sys.exit(main()) diff --git a/.github/scripts/tests/test_ci_slash.py b/.github/scripts/tests/test_ci_slash.py index afcf8031cdb..c8c01ceda15 100644 --- a/.github/scripts/tests/test_ci_slash.py +++ b/.github/scripts/tests/test_ci_slash.py @@ -14,8 +14,11 @@ # limitations under the License. import importlib.util +import json import os import tempfile +import subprocess +import textwrap import unittest from pathlib import Path from unittest.mock import patch @@ -27,6 +30,9 @@ ci = importlib.util.module_from_spec(spec) spec.loader.exec_module(ci) +OPTIONS_TO_TEST = ["/ci", "/ci rust", "/ci fuzz chaos", "/ci fuzz all"] + + class CiSlashTest(unittest.TestCase): def setUp(self): self.output = tempfile.NamedTemporaryFile(delete=False) @@ -54,23 +60,145 @@ class CiSlashTest(unittest.TestCase): self.assertIn("skip=true", output) self.assertIn("Available draft-PR CI commands", output) - def test_dispatches_full_suite_at_verified_head(self): + def test_dispatches_full_suite_with_top_level_admin_permission(self): output = self.run_main([ {"body": "/ci", "issue_url": "https://api.github.test/repos/GreptimeTeam/greptimedb/issues/42", "user": {"login": "admin"}}, - {"state": "open", "draft": True, "head": {"sha": "a" * 40, "repo": {"full_name": "GreptimeTeam/greptimedb"}}}, - {"user": {"permission": "admin"}}, + {"state": "open", "draft": True, "head": {"sha": "a" * 40, "ref": "fix/draft-ci", "repo": {"full_name": "GreptimeTeam/greptimedb"}}}, + {"permission": "admin", "user": {"login": "admin"}}, ]) self.assertIn("skip=false", output) + self.assertIn("head_ref=fix/draft-ci", output) self.assertIn("workflow=rust.yml,integration.yml,checks.yml,docs.yml", output) def test_rejects_non_draft_before_permission_lookup(self): output = self.run_main([ {"body": "/ci fuzz chaos", "issue_url": "https://api.github.test/repos/GreptimeTeam/greptimedb/issues/42", "user": {"login": "admin"}}, - {"state": "open", "draft": False, "head": {"sha": "a" * 40, "repo": {"full_name": "GreptimeTeam/greptimedb"}}}, + {"state": "open", "draft": False, "head": {"sha": "a" * 40, "ref": "fix/draft-ci", "repo": {"full_name": "GreptimeTeam/greptimedb"}}}, ]) self.assertIn("skip=true", output) self.assertIn("PR must be open and draft", output) + def test_rejects_missing_head_ref_before_permission_lookup(self): + output = self.run_main([ + {"body": "/ci", "issue_url": "https://api.github.test/repos/GreptimeTeam/greptimedb/issues/42", "user": {"login": "admin"}}, + {"state": "open", "draft": True, "head": {"sha": "a" * 40, "repo": {"full_name": "GreptimeTeam/greptimedb"}}}, + ]) + self.assertIn("skip=true", output) + self.assertIn("PR head changed; comment again.", output) + + def test_permission_matrix(self): + for command in OPTIONS_TO_TEST: + for author in (True, False): + for permission in ("admin", "maintain", "write", "triage", "read", "none", None): + with self.subTest(command=command, author=author, permission=permission): + Path(self.output.name).write_text("") + responses = [ + {"body": command, "issue_url": "https://api.github.test/repos/GreptimeTeam/greptimedb/issues/42", "user": {"login": "actor"}}, + {"state": "open", "draft": True, "user": {"login": "actor" if author else "other"}, "head": {"sha": "a" * 40, "ref": "fix/draft-ci", "repo": {"full_name": "GreptimeTeam/greptimedb"}}}, + ] + if command == "/ci fuzz all" or not author: + responses.append({"permission": permission}) + output = self.run_main(responses) + allowed = permission == "admin" if command == "/ci fuzz all" else author or permission in ("admin", "maintain", "write") + self.assertIn("skip=false" if allowed else "skip=true", output) + + def test_reply_uses_issue_comment_endpoint(self): + workflow = SCRIPT.parents[1] / "workflows" / "ci-slash.yml" + step = workflow.read_text().split(" - name: Reply with command result\n", 1)[1] + command = textwrap.dedent(step.split(" run: |\n", 1)[1]) + reply = "Denied: `example`\nSecond line with $variables and 'quotes'" + with tempfile.TemporaryDirectory() as directory: + gh = Path(directory) / "gh" + gh.write_text("#!/usr/bin/env python3\nimport json, sys\nprint(json.dumps(sys.argv[1:]))\n") + gh.chmod(0o755) + env = {**os.environ, "PATH": directory + os.pathsep + os.environ["PATH"], "GITHUB_REPOSITORY": "GreptimeTeam/greptimedb", "PR_NUMBER": "42", "REPLY": reply, "ADMIT_OUTCOME": "success", "DISPATCH_OUTCOME": "success"} + result = subprocess.run(["bash", "-eu", "-c", command], env=env, check=True, capture_output=True, text=True) + self.assertEqual(json.loads(result.stdout), ["api", "--method", "POST", "/repos/GreptimeTeam/greptimedb/issues/42/comments", "-f", "body=" + reply]) + + def test_failure_reply_and_partial_dispatch(self): + workflow = (SCRIPT.parents[1] / "workflows" / "ci-slash.yml").read_text() + dispatch = textwrap.dedent(workflow.split(" - name: Dispatch selected CI workflow\n", 1)[1].split(" run: |\n", 1)[1].split(" - name: Reply", 1)[0]) + reply = textwrap.dedent(workflow.split(" - name: Reply with command result\n", 1)[1].split(" run: |\n", 1)[1]) + with tempfile.TemporaryDirectory() as directory: + gh = Path(directory) / "gh" + log = Path(directory) / "calls" + gh.write_text("#!/usr/bin/env python3\nimport json, os, sys\nwith open(os.environ['CALLS'], 'a') as f: f.write(json.dumps(sys.argv[1:]) + '\\n')\nsys.exit(1 if '/runs/8/rerun' in sys.argv[-1] else 0)\n") + gh.chmod(0o755) + env = {**os.environ, "PATH": directory + os.pathsep + os.environ["PATH"], "CALLS": str(log), "GITHUB_REPOSITORY": "GreptimeTeam/greptimedb", "RUN_IDS": "7,8,9", "PR_NUMBER": "42", "REPLY": "Success", "ADMIT_OUTCOME": "success", "DISPATCH_OUTCOME": "failure", "GITHUB_SERVER_URL": "https://github.com", "GITHUB_RUN_ID": "123"} + result = subprocess.run(["bash", "-eu", "-c", dispatch], env=env, capture_output=True) + self.assertNotEqual(result.returncode, 0) + subprocess.run(["bash", "-eu", "-c", reply], env=env, check=True, capture_output=True) + calls = [json.loads(line) for line in log.read_text().splitlines()] + self.assertEqual([call[-1] for call in calls[:2]], ["/repos/GreptimeTeam/greptimedb/actions/runs/7/rerun", "/repos/GreptimeTeam/greptimedb/actions/runs/8/rerun"]) + self.assertIn("CI trigger failed; some workflows may already have been requested", calls[2][-1]) + self.assertIn("/actions/runs/123", calls[2][-1]) + self.assertIn("!cancelled()", workflow.split(" - name: Reply with command result", 1)[1]) + + def test_fork_rerun_gates_cover_standard_jobs(self): + import re + for name, count in [("rust", 7), ("integration", 5), ("checks", 5), ("docs", 3)]: + workflow = (SCRIPT.parents[1] / "workflows" / (name + ".yml")).read_text() + gates = re.findall(r"^ if:.*github.run_attempt > 1.*$", workflow, re.MULTILINE) + self.assertEqual(len(gates), count, name) + self.assertTrue(all("always()" not in gate for gate in gates)) + + def test_command_permissions(self): + workflow = SCRIPT.parents[1] / "workflows" / "slash-command-dispatch.yml" + config = workflow.read_text().split("config: >-", 1)[1].split(" - name:", 1)[0] + self.assertEqual(json.loads(config), [ + {"command": "query-regression", "permission": "admin", "issue_type": "pull-request"}, + {"command": "ci", "permission": "none", "issue_type": "pull-request"}, + ]) + + def test_fork_requires_writer_even_for_author(self): + for permission in ("read", "write", "maintain", "admin"): + with self.subTest(permission=permission): + Path(self.output.name).write_text("") + responses = [ + {"body": "/ci", "issue_url": "https://api.github.test/issues/42", "user": {"login": "actor"}}, + {"state": "open", "draft": True, "user": {"login": "actor"}, "head": {"sha": "a" * 40, "ref": "fork-branch", "repo": {"full_name": "actor/greptimedb"}}}, + {"permission": permission}, + ] + if permission != "read": + responses.append({"workflow_runs": [ + {"id": 7, "path": ".github/workflows/rust.yml", "head_sha": "a" * 40, "head_branch": "fork-branch", "head_repository": {"full_name": "actor/greptimedb"}, "status": "completed", "conclusion": "skipped"}, + {"id": 8, "path": ".github/workflows/checks.yml", "head_sha": "a" * 40, "head_branch": "fork-branch", "head_repository": {"full_name": "someone/greptimedb"}, "status": "completed", "conclusion": "skipped"}, + ]}) + output = self.run_main(responses) + if permission == "read": + self.assertIn("skip=true", output) + else: + self.assertIn("run_ids=7\n", output) + self.assertNotIn("head_ref=", output) + + def test_fork_rejects_non_draft_original_run(self): + output = self.run_main([ + {"body": "/ci rust", "issue_url": "https://api.github.test/issues/42", "user": {"login": "writer"}}, + {"state": "open", "draft": True, "head": {"sha": "a" * 40, "ref": "fork-branch", "repo": {"full_name": "actor/greptimedb"}}}, + {"permission": "write"}, + {"workflow_runs": [{"id": 7, "path": ".github/workflows/rust.yml@main", "head_sha": "a" * 40, "head_branch": "fork-branch", "head_repository": {"full_name": "actor/greptimedb"}, "status": "completed", "conclusion": "success", "run_attempt": 2}]}, + {"conclusion": "success"}, + ]) + self.assertIn("only originally skipped draft CI runs", output) + self.assertIn("skip=true", output) + + def test_author_cannot_bypass_pr_guards(self): + for change, reason in [ + ({"state": "closed"}, "PR must be open and draft"), + ({"draft": False}, "PR must be open and draft"), + ({"head": {"sha": "b" * 40, "ref": "fix/draft-ci", "repo": {"full_name": "GreptimeTeam/greptimedb"}}}, "PR head changed"), + ]: + with self.subTest(change=change): + Path(self.output.name).write_text("") + pr = {"state": "open", "draft": True, "user": {"login": "actor"}, "head": {"sha": "a" * 40, "ref": "fix/draft-ci", "repo": {"full_name": "GreptimeTeam/greptimedb"}}} + pr.update(change) + output = self.run_main([ + {"body": "/ci rust", "issue_url": "https://api.github.test/repos/GreptimeTeam/greptimedb/issues/42", "user": {"login": "actor"}}, + pr, + ]) + self.assertIn("skip=true", output) + self.assertIn(reason, output) + if __name__ == "__main__": unittest.main() diff --git a/.github/workflows/cargo-lock-check.yml b/.github/workflows/cargo-lock-check.yml index 847be806c5f..cfea53c6750 100644 --- a/.github/workflows/cargo-lock-check.yml +++ b/.github/workflows/cargo-lock-check.yml @@ -5,6 +5,7 @@ name: Cargo.lock Diff Check # posts/updates a PR comment when the threshold is exceeded. on: pull_request: + types: [opened, synchronize, reopened, ready_for_review] paths: - "Cargo.lock" diff --git a/.github/workflows/check-git-deps.yml b/.github/workflows/check-git-deps.yml index 6ebf79f2072..a4ec488e850 100644 --- a/.github/workflows/check-git-deps.yml +++ b/.github/workflows/check-git-deps.yml @@ -2,6 +2,7 @@ name: Check Git Dependencies on Main Branch on: pull_request: + types: [opened, synchronize, reopened, ready_for_review] branches: [main] paths: - 'Cargo.toml' diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 6e591ba9dfd..561ee2c7473 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -32,7 +32,7 @@ concurrency: jobs: check-typos-and-docs: - if: ${{ github.repository == 'GreptimeTeam/greptimedb' && (github.event_name != 'pull_request' || github.event.pull_request.draft == false) }} + if: ${{ github.repository == 'GreptimeTeam/greptimedb' && (github.event_name != 'pull_request' || github.event.pull_request.draft == false || github.run_attempt > 1) }} name: Check typos and docs runs-on: ubuntu-latest steps: @@ -47,7 +47,7 @@ jobs: || (echo "'config/config.md' is not up-to-date, please run 'make config-docs'." && exit 1) license-header-check: - if: ${{ github.repository == 'GreptimeTeam/greptimedb' && (github.event_name != 'pull_request' || github.event.pull_request.draft == false) }} + if: ${{ github.repository == 'GreptimeTeam/greptimedb' && (github.event_name != 'pull_request' || github.event.pull_request.draft == false || github.run_attempt > 1) }} runs-on: ubuntu-latest name: Check License Header steps: @@ -63,7 +63,7 @@ jobs: config: licenserc-enterprise.toml github-script-tests: - if: ${{ github.repository == 'GreptimeTeam/greptimedb' && (github.event_name != 'pull_request' || github.event.pull_request.draft == false) }} + if: ${{ github.repository == 'GreptimeTeam/greptimedb' && (github.event_name != 'pull_request' || github.event.pull_request.draft == false || github.run_attempt > 1) }} name: GitHub Script Tests runs-on: ubuntu-latest timeout-minutes: 5 @@ -100,7 +100,7 @@ jobs: python3 tests/perf/test_agent_observability_summary.py check: - if: ${{ github.repository == 'GreptimeTeam/greptimedb' && (github.event_name != 'pull_request' || github.event.pull_request.draft == false) }} + if: ${{ github.repository == 'GreptimeTeam/greptimedb' && (github.event_name != 'pull_request' || github.event.pull_request.draft == false || github.run_attempt > 1) }} name: Check runs-on: ${{ matrix.os }} strategy: @@ -129,7 +129,7 @@ jobs: run: cargo check --locked --workspace --all-targets --all-features toml: - if: ${{ github.repository == 'GreptimeTeam/greptimedb' && (github.event_name != 'pull_request' || github.event.pull_request.draft == false) }} + if: ${{ github.repository == 'GreptimeTeam/greptimedb' && (github.event_name != 'pull_request' || github.event.pull_request.draft == false || github.run_attempt > 1) }} name: Toml Check runs-on: ubuntu-latest timeout-minutes: 60 diff --git a/.github/workflows/ci-slash.yml b/.github/workflows/ci-slash.yml index 1af8e60d95c..e9fb392d381 100644 --- a/.github/workflows/ci-slash.yml +++ b/.github/workflows/ci-slash.yml @@ -30,23 +30,46 @@ jobs: DISPATCH_SENDER: ${{ github.event.sender.login }} DISPATCH_HEAD_SHA: ${{ github.event.client_payload.pull_request.head.sha }} run: python3 .github/scripts/ci-slash.py - - name: Reply with command result - if: ${{ steps.admit.outputs.reply != '' }} - env: - GH_TOKEN: ${{ github.token }} - REPLY: ${{ steps.admit.outputs.reply }} - PR_NUMBER: ${{ steps.admit.outputs.pr_number }} - run: printf '%s\n' "${REPLY}" | gh pr comment "${PR_NUMBER}" --body-file - - name: Dispatch selected CI workflow + id: dispatch if: ${{ steps.admit.outputs.skip == 'false' }} env: GH_TOKEN: ${{ github.token }} + RUN_IDS: ${{ steps.admit.outputs.run_ids }} WORKFLOWS: ${{ steps.admit.outputs.workflow }} - HEAD_SHA: ${{ steps.admit.outputs.head_sha }} + HEAD_REF: ${{ steps.admit.outputs.head_ref }} FUZZ_PROFILE: ${{ steps.admit.outputs.fuzz_profile }} run: | + if [[ -n "${RUN_IDS}" ]]; then + IFS=, read -ra runs <<<"${RUN_IDS}" + for run in "${runs[@]}"; do + gh api --method POST "/repos/${GITHUB_REPOSITORY}/actions/runs/${run}/rerun" + done + exit 0 + fi IFS=, read -ra workflows <<<"${WORKFLOWS}" for workflow in "${workflows[@]}"; do + inputs=(-F 'inputs[ci_command]=true') + if [[ "${workflow}" == 'integration.yml' ]]; then + inputs+=(-f "inputs[fuzz_profile]=${FUZZ_PROFILE}") + fi gh api --method POST "/repos/${GITHUB_REPOSITORY}/actions/workflows/${workflow}/dispatches" \ - -f ref="${HEAD_SHA}" -F 'inputs[ci_command]=true' -f "inputs[fuzz_profile]=${FUZZ_PROFILE}" + -f ref="${HEAD_REF}" "${inputs[@]}" done + - name: Reply with command result + if: ${{ !cancelled() && github.event.sender.login == 'github-actions[bot]' }} + env: + GH_TOKEN: ${{ github.token }} + REPLY: ${{ steps.admit.outputs.reply }} + PR_NUMBER: ${{ steps.admit.outputs.pr_number || github.event.client_payload.github.payload.issue.number }} + ADMIT_OUTCOME: ${{ steps.admit.outcome }} + DISPATCH_OUTCOME: ${{ steps.dispatch.outcome }} + # Use the issue-comment endpoint with the existing issues: write permission. + run: | + [[ "${PR_NUMBER}" =~ ^[0-9]+$ ]] || exit 1 + if [[ "${ADMIT_OUTCOME}" == failure || "${DISPATCH_OUTCOME}" == failure ]]; then + REPLY="CI trigger failed; some workflows may already have been requested. See ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID} for details." + fi + [[ -n "${REPLY}" ]] || exit 1 + gh api --method POST "/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \ + -f "body=${REPLY}" diff --git a/.github/workflows/dependency-check.yml b/.github/workflows/dependency-check.yml index 93a366c4b08..014a41e45c6 100644 --- a/.github/workflows/dependency-check.yml +++ b/.github/workflows/dependency-check.yml @@ -2,6 +2,7 @@ name: Check Dependencies on: pull_request: + types: [opened, synchronize, reopened, ready_for_review] branches: - main diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 3006dc7f954..c9a6e8caaf3 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -38,7 +38,7 @@ name: Docs CI jobs: typos: - if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false || github.run_attempt > 1 }} name: Spell Check with Typos runs-on: ubuntu-latest steps: @@ -48,7 +48,7 @@ jobs: - uses: crate-ci/typos@v1.50.2 license-header-check: - if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false || github.run_attempt > 1 }} runs-on: ubuntu-latest name: Check License Header steps: @@ -58,7 +58,7 @@ jobs: - uses: korandoru/hawkeye@v5 required-checks: - if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false || github.run_attempt > 1 }} name: ${{ matrix.check }} runs-on: ubuntu-slim strategy: diff --git a/.github/workflows/grafana.yml b/.github/workflows/grafana.yml index 6f3f1aa8fdf..2ec57b3cf5d 100644 --- a/.github/workflows/grafana.yml +++ b/.github/workflows/grafana.yml @@ -2,6 +2,7 @@ name: Check Grafana Panels on: pull_request: + types: [opened, synchronize, reopened, ready_for_review] branches: - main paths: diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 4e0634fbc57..e33abac3330 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -43,7 +43,7 @@ concurrency: jobs: build: - if: ${{ github.repository == 'GreptimeTeam/greptimedb' && (github.event_name != 'pull_request' || github.event.pull_request.draft == false) }} + if: ${{ github.repository == 'GreptimeTeam/greptimedb' && (github.event_name != 'pull_request' || github.event.pull_request.draft == false || github.run_attempt > 1) }} name: Build GreptimeDB binaries runs-on: ${{ matrix.os }} strategy: @@ -104,7 +104,7 @@ jobs: version: current sqlness: - if: ${{ github.repository == 'GreptimeTeam/greptimedb' && (github.event_name != 'pull_request' || github.event.pull_request.draft == false) }} + if: ${{ github.repository == 'GreptimeTeam/greptimedb' && (github.event_name != 'pull_request' || github.event.pull_request.draft == false || github.run_attempt > 1) }} name: Sqlness Test (${{ matrix.mode.name }}) needs: build runs-on: ${{ matrix.os }} @@ -184,7 +184,7 @@ jobs: retention-days: 3 export-import-v2-e2e: - if: ${{ github.repository == 'GreptimeTeam/greptimedb' && (github.event_name != 'pull_request' || github.event.pull_request.draft == false) }} + if: ${{ github.repository == 'GreptimeTeam/greptimedb' && (github.event_name != 'pull_request' || github.event.pull_request.draft == false || github.run_attempt > 1) }} name: Export/Import V2 E2E Test needs: build runs-on: ubuntu-latest @@ -254,7 +254,7 @@ jobs: retention-days: 3 run-multi-lang-tests: - if: ${{ github.repository == 'GreptimeTeam/greptimedb' && (github.event_name != 'pull_request' || github.event.pull_request.draft == false) }} + if: ${{ github.repository == 'GreptimeTeam/greptimedb' && (github.event_name != 'pull_request' || github.event.pull_request.draft == false || github.run_attempt > 1) }} name: Run Multi-language SDK Tests needs: build uses: ./.github/workflows/run-multi-lang-tests.yml @@ -262,7 +262,7 @@ jobs: artifact-name: bins compat-updater-check: - if: ${{ github.repository == 'GreptimeTeam/greptimedb' && (github.event_name != 'pull_request' || github.event.pull_request.draft == false) && (github.event_name == 'merge_group' || github.event_name == 'pull_request') }} + if: ${{ github.repository == 'GreptimeTeam/greptimedb' && (github.event_name != 'pull_request' || github.event.pull_request.draft == false || github.run_attempt > 1) && (github.event_name == 'merge_group' || github.event_name == 'pull_request') }} name: Check compat version updater runs-on: ubuntu-latest timeout-minutes: 10 diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 5778d3856bb..7302ce310b1 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -32,7 +32,7 @@ concurrency: jobs: fmt: - if: ${{ github.repository == 'GreptimeTeam/greptimedb' && (github.event_name != 'pull_request' || github.event.pull_request.draft == false) }} + if: ${{ github.repository == 'GreptimeTeam/greptimedb' && (github.event_name != 'pull_request' || github.event.pull_request.draft == false || github.run_attempt > 1) }} name: Rustfmt runs-on: ubuntu-latest timeout-minutes: 60 @@ -50,7 +50,7 @@ jobs: run: make fmt-check clippy: - if: ${{ github.repository == 'GreptimeTeam/greptimedb' && (github.event_name != 'pull_request' || github.event.pull_request.draft == false) }} + if: ${{ github.repository == 'GreptimeTeam/greptimedb' && (github.event_name != 'pull_request' || github.event.pull_request.draft == false || github.run_attempt > 1) }} name: Clippy runs-on: ubuntu-latest timeout-minutes: 60 @@ -76,7 +76,7 @@ jobs: run: make clippy check-udeps: - if: ${{ github.repository == 'GreptimeTeam/greptimedb' && (github.event_name != 'pull_request' || github.event.pull_request.draft == false) }} + if: ${{ github.repository == 'GreptimeTeam/greptimedb' && (github.event_name != 'pull_request' || github.event.pull_request.draft == false || github.run_attempt > 1) }} name: Check Unused Dependencies runs-on: ubuntu-latest timeout-minutes: 60 @@ -94,7 +94,7 @@ jobs: run: make check-udeps check-riscv64: - if: ${{ github.repository == 'GreptimeTeam/greptimedb' && (github.event_name != 'pull_request' || github.event.pull_request.draft == false) }} + if: ${{ github.repository == 'GreptimeTeam/greptimedb' && (github.event_name != 'pull_request' || github.event.pull_request.draft == false || github.run_attempt > 1) }} name: Check riscv64 build runs-on: ubuntu-latest timeout-minutes: 90 @@ -120,7 +120,7 @@ jobs: run: cargo check --locked --workspace --all-targets --features servers/dashboard --target riscv64gc-unknown-linux-gnu check-windows: - if: ${{ github.repository == 'GreptimeTeam/greptimedb' && (github.event_name != 'pull_request' || github.event.pull_request.draft == false) }} + if: ${{ github.repository == 'GreptimeTeam/greptimedb' && (github.event_name != 'pull_request' || github.event.pull_request.draft == false || github.run_attempt > 1) }} name: Check Windows build runs-on: windows-2022 timeout-minutes: 60 @@ -191,7 +191,7 @@ jobs: run: cargo nextest run --locked --workspace -F dashboard --no-fail-fast conflict-check: - if: ${{ github.repository == 'GreptimeTeam/greptimedb' && (github.event_name != 'pull_request' || github.event.pull_request.draft == false) }} + if: ${{ github.repository == 'GreptimeTeam/greptimedb' && (github.event_name != 'pull_request' || github.event.pull_request.draft == false || github.run_attempt > 1) }} name: Check for conflict runs-on: ubuntu-latest steps: @@ -202,7 +202,7 @@ jobs: uses: olivernybroe/action-conflict-finder@v4.0 test: - if: ${{ github.repository == 'GreptimeTeam/greptimedb' && github.event_name != 'merge_group' && (github.event_name != 'pull_request' || github.event.pull_request.draft == false) }} + if: ${{ github.repository == 'GreptimeTeam/greptimedb' && github.event_name != 'merge_group' && (github.event_name != 'pull_request' || github.event.pull_request.draft == false || github.run_attempt > 1) }} runs-on: ubuntu-22.04-arm timeout-minutes: 60 needs: [conflict-check, clippy, fmt, check-udeps] diff --git a/.github/workflows/slash-command-dispatch.yml b/.github/workflows/slash-command-dispatch.yml index 26fa27b79dd..c47a9ed9ced 100644 --- a/.github/workflows/slash-command-dispatch.yml +++ b/.github/workflows/slash-command-dispatch.yml @@ -1,13 +1,13 @@ name: Slash Command Dispatch -# ChatOps front door: parse `/command` on PR comments, check admin -# permission, and repository_dispatch to a per-command handler. Handlers +# ChatOps front door: parse `/command` on PR comments, apply command-specific +# permissions, and repository_dispatch to a per-command handler. Handlers # own allowlists, SHA admission, and the actual work. Same-repo dispatch # uses github.token with contents: write; GitHub starts the handler run # for GITHUB_TOKEN-created repository_dispatch events. Do not pass the # long-lived GH_PERSONAL_ACCESS_TOKEN into this third-party action. # -# To add a command: list it under `commands` and add a workflow with +# To add a command: add it to `config` and add a workflow with # `on.repository_dispatch.types: ["-command"]`. on: @@ -30,8 +30,17 @@ jobs: uses: peter-evans/slash-command-dispatch@9bdcd7914ec1b75590b790b844aa3b8eee7c683a # v5.0.2 with: token: ${{ github.token }} - permission: admin - issue-type: pull-request - commands: | - query-regression - ci + # CI admission re-fetches the comment and checks author/member permissions. + config: >- + [ + {"command": "query-regression", "permission": "admin", "issue_type": "pull-request"}, + {"command": "ci", "permission": "none", "issue_type": "pull-request"} + ] + - name: Report CI dispatch failure + if: ${{ failure() && (github.event.comment.body == '/ci' || startsWith(github.event.comment.body, '/ci ')) }} + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.issue.number }} + run: | + gh api --method POST "/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \ + -f "body=CI command dispatch failed. See ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID} for details."