diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index a9be0047..588a7658 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -11,8 +11,7 @@ on: description: Retain native installers for a release build type: boolean default: false - push: - branches: [main] + workflow_dispatch: pull_request: permissions: diff --git a/.github/workflows/ci-review.yml b/.github/workflows/ci-review.yml index 2764bbb0..bb760f38 100644 --- a/.github/workflows/ci-review.yml +++ b/.github/workflows/ci-review.yml @@ -1,21 +1,22 @@ # Actions secrets: MINIMAX_API_KEY, FEISHU_WEBHOOK_URL; optional FEISHU_WEBHOOK_SECRET. -# workflow_run activates after this file reaches the default branch. +# Review the merged source and existing PR checks without running CI again. name: CI review and Feishu notification on: - workflow_run: - workflows: [core-check] + pull_request_target: branches: [main] - types: [completed] + types: [closed] permissions: contents: read actions: read + checks: read + statuses: read pull-requests: read jobs: review: - if: github.event.workflow_run.event == 'push' && github.event.workflow_run.head_branch == 'main' + if: github.event.pull_request.merged == true runs-on: ${{ vars.OAC_USE_GITHUB_RUNNERS == 'true' && 'ubuntu-24.04' || 'blacksmith-2vcpu-ubuntu-2404' }} timeout-minutes: 30 env: @@ -26,7 +27,7 @@ jobs: # The triggering revision is already on main, so its rules and code are trusted. - uses: actions/checkout@v7 with: - ref: ${{ github.event.workflow_run.head_sha }} + ref: ${{ github.event.pull_request.merge_commit_sha }} fetch-depth: 2 persist-credentials: false - name: Review and send Feishu card with Claude Code @@ -54,17 +55,15 @@ jobs: allowed_non_write_users: '*' prompt: | 你是 CI 的负责人,负责审核 CI 结果并给出结论。 - 仓库 ${{ github.repository }} 的 main 分支刚跑完一次 core-check CI: - - 运行 ID:${{ github.event.workflow_run.id }}(第 ${{ github.event.workflow_run.run_attempt }} 次尝试) - - 结论:${{ github.event.workflow_run.conclusion }} - - 运行链接:${{ github.event.workflow_run.html_url }} - - commit:${{ github.event.workflow_run.head_sha }}(已 checkout 到当前目录) + 仓库 ${{ github.repository }} 的 PR #${{ github.event.pull_request.number }} 已合入 main: + - PR:https://github.com/${{ github.repository }}/pull/${{ github.event.pull_request.number }} + - 合并 commit:${{ github.event.pull_request.merge_commit_sha }}(已 checkout 到当前目录) + + 用 gh pr checks ${{ github.event.pull_request.number }} --repo ${{ github.repository }} 查询这个 PR 已有的检查结果,必要时读取对应 Actions 日志。不要触发新的 CI,也不要把合并本身当作检查通过的证据;管理员可能绕过门禁。检查失败、缺失或尚未完成时如实报告。 任务:给飞书群发一张中文卡片(Card 2.0)总结这次 CI,尽量 10 轮以内给出结论,工具调用尽可能的并行。 环境里有 gh(已登录,GH_TOKEN)、git、node 和完整的仓库代码。 - 如果这个 commit 不是某个 PR 合入 main 产生的(例如直接 push),不发卡片,直接结束。 - 卡片要让手机上的读者快速看懂,如果一切正常,表达的尽可能简单: 1. 哪个 PR(github id + PR 标题 + 链接) 2. 改了什么(实际行为变化,1–3 条) diff --git a/.github/workflows/website.yml b/.github/workflows/website.yml index dbe8cd3e..b5af01e8 100644 --- a/.github/workflows/website.yml +++ b/.github/workflows/website.yml @@ -1,5 +1,5 @@ # Publishes the website (landing page and documentation) to GitHub Pages from -# main, and builds it for documentation-only pull requests. The website reads the +# main. Pull request validation belongs to core-check. The website reads the # existing docs/, contracts/ and docs.json in place; see website/README.md. name: website @@ -11,15 +11,12 @@ on: - docs/** - contracts/** - docs.json + - package.json + - pnpm-workspace.yaml + - .npmrc + - Makefile - .github/actions/node/** - .github/workflows/website.yml - # Changes under website/ are checked by core-check; documentation-only - # changes build the site here so broken links surface before merge. - pull_request: - paths: - - docs/** - - contracts/** - - docs.json workflow_dispatch: permissions: @@ -28,7 +25,7 @@ permissions: concurrency: group: website-${{ github.ref }} # Never cancel a run that may be deploying main. - cancel-in-progress: ${{ github.event_name == 'pull_request' }} + cancel-in-progress: false jobs: build: diff --git a/docs/maintainers.md b/docs/maintainers.md index abd59f65..3eef76a3 100644 --- a/docs/maintainers.md +++ b/docs/maintainers.md @@ -156,9 +156,9 @@ With `draft_release=true` the result is an unpublished `build-` draft ## Continuous integration -Every PR and main push runs `core-check` and reports the required status `check`. `scripts/ci_plan.py` owns the only input-to-check map. Component rules require both a matching directory or script prefix and a matching file suffix; exact dependency, workflow and shared build inputs have explicit rules. Rules accumulate across shared consumers and mixed changes. Paths with no matching build/test rule run hygiene only. Add the corresponding rule when introducing a new component, language, build input or resource location. +Every PR runs `core-check` and reports the required status `check`. Main uses GitHub branch protection requiring this check and an up-to-date branch before merging, so merging does not start another copy of the test suite. Changes must enter through checked PRs; an administrator bypass does not establish CI success. Main pushes publish the website when its inputs change. Version tags and manual release builds run the full release gate at their exact source commit. `scripts/ci_plan.py` owns the only input-to-check map. Component rules require both a matching directory or script prefix and a matching file suffix; exact dependency, workflow and shared build inputs have explicit rules. Rules accumulate across shared consumers and mixed changes. Paths with no matching build/test rule run hygiene only. Add the corresponding rule when introducing a new component, language, build input or resource location. -The planner compares the PR event's tested merge commit with its verified first parent. Main pushes use the verified event `before`/`after` range with the same selection rules. NUL-delimited Git output and disabled rename detection retain both old and new paths. The plan and reasons appear in the run summary. Missing or inconsistent history, mismatched checkouts, rewritten pushes, invalid paths, empty diffs, planner/orchestration changes and shared build inputs select the full gate. Release and explicit-ref calls always select every group. +The planner compares the PR event's tested merge commit with its verified first parent. NUL-delimited Git output and disabled rename detection retain both old and new paths. The plan and reasons appear in the run summary. Missing or inconsistent history, mismatched checkouts, invalid paths, planner/orchestration changes and shared build inputs select the full gate. A verified empty diff selects hygiene only. Release, manual and explicit-ref calls always select every group. | Group | Checks and consumers | | --- | --- | @@ -169,21 +169,26 @@ The planner compares the PR event's tested merge commit with its verified first | `harness` | Claude SDK tests and packaging, MiniMax companion scripts | | `example` | Optional application typecheck, tests, build and isolated browser acceptance | | `web` | TypeScript, Web/client tests and Web build | +| `website` | Website build and output checks for website, published documentation and dependency changes | | `web-acceptance` | Full Web browser suite in four isolated shards after Web unit/build success; each keeps one worker | | `api` | Reusable official-client acceptance against standalone commands and migrations; image acceptance when image/build/helper inputs change, and in every full gate | | `native` | Reusable Linux, macOS and Windows builds, filesystem/process/Harness checks and native installation; all three platforms can run concurrently | | `lint` | Reusable actionlint check, including local composite actions | -Known workflow changes select their consumers: the CI review and actionlint workflows run hygiene and lint; native workflow changes add native checks; API acceptance workflow changes add API checks with container acceptance enabled. The shared Node action selects every job that uses it plus lint. A new or unclassified workflow/action selects the full gate until its consumers are declared in the planner. Planner tests and CI measurement scripts run hygiene; changing the planner itself runs the full gate. +`.github/actionlint.yaml` selects hygiene and lint. Known workflow changes select their consumers: the CI review and actionlint workflows run hygiene and lint; native workflow changes add native checks; API acceptance workflow changes add API checks with container acceptance enabled; website workflow changes add website checks. The shared Node action selects every job that uses it plus lint. A new or unclassified workflow/action selects the full gate until its consumers are declared in the planner. Planner tests and CI measurement scripts run hygiene; changing the planner itself runs the full gate. Compose template and Compose test changes select both `distribution` fixtures and the `compose` smoke job. Run `python3 scripts/compose-smoke.py` locally with Docker available to repeat it. The script uses a unique project, an automatically assigned loopback port and artifacts under `~/.oac/tests/`; it removes its containers and volumes on exit. CI also performs cleanup after a failed or interrupted smoke step. Diagnostics show container status without printing HTTP response bodies or sign-in keys. This checks the declared release images and generic Compose behavior; it does not run a Dokploy/Coolify instance or execute a model. Go module and workspace inputs select backend, API (including the container), native and distribution checks. Each Node module owns its manifest and lockfile. Website dependencies select website checks; Web dependencies select Web and browser checks; example dependencies select example checks; shared TypeScript client dependencies select Web, browser and example checks; Claude adapter dependencies select Harness, native and distribution checks. Shared package-manager configuration selects all Node consumers. The root TypeScript configuration selects Web and example checks; the adapter TypeScript configuration selects Harness and native checks. Each selected set includes hygiene. Mixed changes accumulate their consumers, and every job reads the same plan instead of maintaining its own path list. For example, a notification-only PR skips database, browser and native jobs, while a notification plus Core change adds backend and API checks. -Ordinary Markdown and documentation-site configuration run hygiene only, including documentation inside source directories. Generated catalog files and configuration reference sections retain their distribution freshness checks. Core `.go`, `.sql`, helper scripts and configuration inputs select backend/API checks; Web source, styles and assets select Web checks. Embedded native assets and declared test fixture directories select their consumers regardless of suffix, including Markdown prompts and extensionless data. Installer changes add distribution checks. Web changes add Web checks and all browser shards; Core/DB changes add backend and official-client acceptance. Shared contracts, SDKs, Runtime inputs and dependencies propagate to their consumers according to the planner. Generated catalog and protocol inputs include the installer, client and UI consumers. Do not duplicate path lists in reusable workflows or put a `paths` filter on the required workflow. +Published documentation and assets under `docs/` and `contracts/`, plus `docs.json`, select hygiene and website. Other ordinary Markdown, including documentation inside source directories, selects hygiene only. Generated catalog files and configuration reference sections retain their distribution freshness checks. Core `.go`, `.sql`, helper scripts and configuration inputs select backend/API checks; Web source, styles and assets select Web checks. Embedded native assets and declared test fixture directories select their consumers regardless of suffix, including Markdown prompts and extensionless data. Installer changes add distribution checks. Web changes add Web checks and all browser shards; Core/DB changes add backend and official-client acceptance. Shared contracts, SDKs, Runtime inputs and dependencies propagate to their consumers according to the planner. Generated catalog and protocol inputs include the installer, client and UI consumers. Do not duplicate path lists in reusable workflows or put a `paths` filter on the required workflow. The final `check` runs even when planning or a dependency fails. It requires a successful, valid plan, every selected job to be successful, and every unselected job to be skipped. Failure, cancellation, a missing job, an unexpected skip or an unexpected execution fails the gate. API/native reusable workflows are direct dependencies of this gate. A newer run on the same PR cancels its predecessor. Release checks run at their requested immutable ref; native packaging executes once inside those checks, and the distribution build waits for them. +Use **Actions → core-check → Run workflow** for a manual full check. For a transient failure, use GitHub's **Re-run failed jobs** so successful jobs remain completed. PR updates cancel the superseded run through Actions concurrency. Build and dependency caches speed execution; they do not stand in for successful tests. Native release installers are passed between jobs using Actions artifacts, within the same release workflow. + +The `CI review and Feishu notification` workflow runs once after a PR merges into main. It checks out the merged commit, reads that PR's existing checks and logs, and reports their actual status. It does not trigger another test run. Closing an unmerged PR does not invoke the review. The workflow uses `pull_request_target` only for the merged event and never checks out an unmerged PR head with notification credentials. + Browser jobs own separate fixtures and servers; increasing workers against the shared mutable fixture is unsafe. Failed browser jobs retain reports/traces for seven days. Native failure phase summaries are retained for seven days and detailed output stays in the Actions logs; credentials and temporary installation trees are not uploaded. Successful native archives are uploaded only for explicit manual packaging or releases, without recompressing the compressed archive. Release distribution artifacts retain their existing recovery policy; failed publication can reuse the original build as described above. The local Node composite action installs the pinned pnpm and caches its package store by lockfile, OS, architecture, Node version and pnpm version. It caches downloaded packages, not `node_modules`; installs remain frozen. Go partitions retain the existing module/compiler caches described under [publication](#publish-a-version). Cache hits seed work and never replace tests. The three native platforms run independently; parallel execution reduces elapsed time without reducing total machine time. diff --git a/scripts/ci_plan.py b/scripts/ci_plan.py index 600b27e7..e2747ce5 100644 --- a/scripts/ci_plan.py +++ b/scripts/ci_plan.py @@ -4,7 +4,6 @@ import argparse import json import os -import re from pathlib import Path, PurePosixPath import subprocess @@ -18,8 +17,9 @@ ".github/workflows/api-acceptance.yml": ("api", "lint"), ".github/workflows/native.yml": ("native", "lint"), ".github/workflows/actionlint.yml": ("lint",), + ".github/actionlint.yaml": ("lint",), ".github/workflows/ci-review.yml": ("lint",), - ".github/workflows/website.yml": ("lint",), + ".github/workflows/website.yml": ("website", "lint"), ".github/actions/node/action.yml": (*NODE_JOBS, "lint"), "scripts/ci_plan.py": JOBS, "scripts/ci_plan_test.py": ("hygiene",), @@ -39,6 +39,7 @@ "packages/agents-client/package.json": ("web", "web-acceptance", "example"), "packages/claude-sdk-adapter/pnpm-lock.yaml": ("harness", "native", "distribution"), "packages/claude-sdk-adapter/package.json": ("harness", "native", "distribution"), + "docs.json": ("website",), "tsconfig.base.json": ("web", "web-acceptance", "example"), } # Each rule requires BOTH a path prefix and a file suffix. Rules accumulate @@ -51,6 +52,7 @@ (("apps/web/",), WEB, ("web", "web-acceptance")), (("services/web/",), (*GO, "Dockerfile"), ("distribution", "web", "web-acceptance")), (("example/",), WEB, ("example",)), + (("docs/", "contracts/"), ("",), ("website",)), (("website/",), (*WEB, ".vue", ".md"), ("website",)), (("services/core/",), CORE, ("backend", "api")), (("services/core/internal/nativeinstaller/",), GO, ("native", "distribution")), @@ -112,7 +114,7 @@ def full(reason): def select(paths): if not paths: - return full("Empty diff; run the full gate") + return {"version": 1, "jobs": ["hygiene"], "image": False, "reasons": ["Verified empty diff"]} jobs = {"hygiene"} image = False reasons = [] @@ -159,19 +161,6 @@ def changed_paths(base, head): def event_plan(event_name, event, requested_ref=""): if requested_ref: return full("Explicit ref: full gate") - if event_name == "push": - try: - if event.get("ref") != "refs/heads/main" or event.get("forced") or event.get("deleted"): - return full("Non-main or rewritten push: full gate") - before, after = event["before"], event["after"] - if any(not isinstance(sha, str) or not re.fullmatch(r"[0-9a-f]{40}", sha) or sha == "0" * 40 for sha in (before, after)): - raise ValueError("Invalid push commits") - if git("rev-parse", "HEAD").decode().strip() != after: - raise ValueError("Checkout does not match push head") - git("merge-base", "--is-ancestor", before, after) - return select(changed_paths(before, after)) - except (KeyError, TypeError, ValueError, UnicodeError, subprocess.CalledProcessError) as err: - return full(f"Push diff unavailable ({type(err).__name__}); full gate") if event_name != "pull_request": return full("Manual or reusable run: full gate") try: diff --git a/scripts/ci_plan_test.py b/scripts/ci_plan_test.py index acf11146..02791e08 100644 --- a/scripts/ci_plan_test.py +++ b/scripts/ci_plan_test.py @@ -14,9 +14,10 @@ class SelectionTests(unittest.TestCase): def jobs(self, *paths): return set(ci.select(paths)["jobs"]) - def test_documents_only_need_repository_integrity(self): - for path in ("docs/maintainers.md", "README.md", "contracts/agents-api/admin-api.md", "docs/assets/logo.svg"): - self.assertEqual(self.jobs(path), {"hygiene"}) + def test_published_documents_also_build_the_website(self): + for path in ("docs/maintainers.md", "contracts/agents-api/admin-api.md", "docs/assets/logo.svg", "docs.json"): + self.assertEqual(self.jobs(path), {"hygiene", "website"}) + self.assertEqual(self.jobs("README.md"), {"hygiene"}) def test_installer_does_not_download_a_browser_or_run_database_tests(self): self.assertEqual(self.jobs("deploy/install/install.py", "scripts/install-release.test.py"), {"hygiene", "distribution"}) @@ -75,8 +76,8 @@ def test_core_fixtures_retain_client_and_installer_consumers(self): with self.subTest(path=path): self.assertTrue({"backend", "api", "distribution"} <= self.jobs(path)) - def test_shared_inputs_planner_and_empty_diffs_are_full(self): - for paths in ([], ["Makefile"], [".github/workflows/new.yml"], + def test_shared_inputs_and_planner_are_full(self): + for paths in (["Makefile"], [".github/workflows/new.yml"], [".github/actions/new/action.yml"], [".github/workflows/check.yml"], [".github/workflows/release.yml"], ["scripts/ci_plan.py"], ["../outside"], ["/outside"]): self.assertEqual(set(ci.select(paths)["jobs"]), set(ci.JOBS)) self.assertTrue(ci.select(paths)["image"]) @@ -85,7 +86,7 @@ def test_workflow_changes_select_only_their_consumers(self): for workflow, selected in { "ci-review": {"hygiene", "lint"}, "actionlint": {"hygiene", "lint"}, - "website": {"hygiene", "lint"}, + "website": {"hygiene", "website", "lint"}, "native": {"hygiene", "native", "lint"}, "api-acceptance": {"hygiene", "api", "lint"}, }.items(): @@ -158,13 +159,13 @@ def test_every_job_has_a_plan_condition(self): def test_mixed_changes_accumulate(self): self.assertEqual(self.jobs("docs/maintainers.md", "deploy/install/install.py", "apps/web/src/app.tsx"), - {"hygiene", "distribution", "web", "web-acceptance"}) + {"hygiene", "distribution", "web", "web-acceptance", "website"}) def test_installer_pr_300_replay(self): self.assertEqual(self.jobs( "deploy/install-release.sh", "deploy/install/README.md", "deploy/install/install.py", "deploy/install/install_display.py", "deploy/install/test_install.py", "deploy/install/test_install_output.py", - "docs/getting-started/install.md", "scripts/install-release.test.py"), {"hygiene", "distribution"}) + "docs/getting-started/install.md", "scripts/install-release.test.py"), {"hygiene", "distribution", "website"}) def test_workflow_graph_cannot_silently_omit_or_add_a_gate_dependency(self): workflow = (Path(__file__).resolve().parents[1] / ".github/workflows/check.yml").read_text().split("jobs:\n", 1)[1] @@ -177,7 +178,7 @@ def test_workflow_graph_cannot_silently_omit_or_add_a_gate_dependency(self): def test_only_matching_directory_and_suffix_trigger_product_checks(self): for path in ("services/core/notes.md", "apps/daemon/design.md", "internal/architecture.md", "apps/web/notes.md", "scripts/build-core.sh.md", "new-component/source.rs", - "docs/example.go", "services/core/code.go.bak"): + "services/core/code.go.bak"): self.assertEqual(self.jobs(path), {"hygiene"}, path) self.assertEqual(self.jobs("services/core/code.go"), {"hygiene", "backend", "api"}) self.assertEqual(self.jobs("apps/web/src/style.css"), {"hygiene", "web", "web-acceptance"}) @@ -198,6 +199,26 @@ def test_tracked_program_sources_have_a_matching_rule(self): if Path(path).suffix in {".go", ".sql", ".ts", ".tsx", ".mjs", ".sh", ".ps1"} or path.endswith("Dockerfile"): self.assertNotEqual(self.jobs(path), {"hygiene"}, path) + def test_verified_empty_diff_only_needs_hygiene(self): + self.assertEqual(self.jobs(), {"hygiene"}) + + def test_actionlint_config_selects_lint(self): + self.assertEqual(self.jobs(".github/actionlint.yaml"), {"hygiene", "lint"}) + + def test_ci_and_deployment_have_distinct_triggers(self): + root = Path(__file__).resolve().parents[1] + check = (root / ".github/workflows/check.yml").read_text() + website = (root / ".github/workflows/website.yml").read_text() + review = (root / ".github/workflows/ci-review.yml").read_text() + self.assertIn(" pull_request:", check) + self.assertIn(" workflow_dispatch:", check) + self.assertNotIn(" push:", check) + self.assertIn(" push:", website) + self.assertNotIn(" pull_request:", website) + self.assertIn("pull_request.merged == true", review) + self.assertIn("ref: ${{ github.event.pull_request.merge_commit_sha }}", review) + self.assertNotIn("workflow_run", review) + def test_non_pr_events_always_run_full(self): for event in ("push", "workflow_dispatch", "workflow_call"): self.assertEqual(ci.event_plan(event, {})["jobs"], list(ci.JOBS)) @@ -243,7 +264,7 @@ def git(*args): paths = ci.changed_paths(base, "HEAD") self.assertEqual(set(paths), {"docs/old.md", "services/core/deleted.go", "apps/web/renamed\nwith space.ts"}) plan = ci.event_plan("pull_request", {"pull_request": {"base": {"sha": base}, "head": {"sha": head}}}) - self.assertEqual(set(plan["jobs"]), {"hygiene", "backend", "api", "web", "web-acceptance"}) + self.assertEqual(set(plan["jobs"]), {"hygiene", "backend", "api", "web", "web-acceptance", "website"}) (clone / "old.md").write_text("untracked content cannot change the diff\n") self.assertEqual(paths, ci.changed_paths(base, "HEAD")) finally: @@ -297,73 +318,3 @@ def test_malformed_plan_cannot_turn_checks_off(self): if __name__ == "__main__": unittest.main() - - -class DocumentationPushTests(unittest.TestCase): - def setUp(self): - self.before, self.after = "a" * 40, "b" * 40 - self.event = {"ref": "refs/heads/main", "before": self.before, "after": self.after, "forced": False, "deleted": False} - - def plan(self, paths, event=None, ref=""): - with patch.object(ci, "git", side_effect=[self.after.encode(), b""]), patch.object(ci, "changed_paths", return_value=paths) as diff: - plan = ci.event_plan("push", self.event if event is None else event, ref) - return plan, diff - - def test_doc_site_configuration_and_docs_only_push_skip_product_checks(self): - paths = ["docs.json", ".mintignore", "docs/getting-started/index.md", "README.md"] - self.assertEqual(set(ci.select(paths)["jobs"]), {"hygiene"}) - plan, diff = self.plan(paths) - self.assertEqual(set(plan["jobs"]), {"hygiene"}) - diff.assert_called_once_with(self.before, self.after) - - def test_generated_documentation_keeps_freshness_checks(self): - plan, _ = self.plan(["docs/configuration.md", "contracts/agents-api/harness-catalog.md"]) - self.assertEqual(set(plan["jobs"]), {"hygiene", "distribution"}) - - def test_main_pushes_use_the_same_directory_suffix_rules_as_prs(self): - for paths in (["README.md", "services/core/cmd/server/main.go"], ["new.md"], [], - ["apps/web/src/app.tsx"], ["docs.json", "scripts/generate-harness-catalog.py"]): - with self.subTest(paths=paths): - self.assertEqual(self.plan(paths)[0], ci.select(paths)) - - def test_releases_and_untrusted_pushes_keep_full_gate(self): - self.assertEqual(self.plan(["README.md"], ref=self.after)[0]["jobs"], list(ci.JOBS)) - for fields in ({"ref": "refs/tags/v1"}, {"forced": True}, {"deleted": True}, {"before": "0" * 40}, {"before": "--bad"}, {"after": "c" * 40}): - with self.subTest(fields=fields): - self.assertEqual(self.plan(["README.md"], self.event | fields)[0]["jobs"], list(ci.JOBS)) - with patch.object(ci, "git", side_effect=subprocess.CalledProcessError(1, "git")): - self.assertEqual(ci.event_plan("push", self.event)["jobs"], list(ci.JOBS)) - - def test_push_uses_entire_commit_range_and_fails_closed_on_shallow_history(self): - with tempfile.TemporaryDirectory() as tmp: - repo = Path(tmp) / "source" - repo.mkdir() - def git(*args): - return subprocess.check_output(["git", "-C", str(repo), *args], stderr=subprocess.DEVNULL).decode().strip() - git("init", "-b", "main") - git("config", "user.email", "ci-test@example.invalid") - git("config", "user.name", "CI test") - (repo / "README.md").write_text("base\n") - git("add", "."); git("commit", "-m", "base") - before = git("rev-parse", "HEAD") - (repo / "docs.json").write_text("{}\n") - git("add", "."); git("commit", "-m", "docs") - docs_head = git("rev-parse", "HEAD") - previous = Path.cwd() - try: - os.chdir(repo) - event = self.event | {"before": before, "after": docs_head} - self.assertEqual(ci.event_plan("push", event)["jobs"], ["hygiene"]) - (repo / "services/core").mkdir(parents=True) - (repo / "services/core/code.go").write_text("package example\n") - git("add", "."); git("commit", "-m", "code") - (repo / "README.md").write_text("updated\n") - git("add", "."); git("commit", "-m", "docs again") - event["after"] = git("rev-parse", "HEAD") - self.assertEqual(set(ci.event_plan("push", event)["jobs"]), {"hygiene", "backend", "api"}) - clone = Path(tmp) / "shallow" - subprocess.run(["git", "clone", "--depth=2", repo.as_uri(), str(clone)], check=True, capture_output=True) - os.chdir(clone) - self.assertEqual(ci.event_plan("push", event)["jobs"], list(ci.JOBS)) - finally: - os.chdir(previous)