diff --git a/.gitea/scripts/ci-required-drift.py b/.gitea/scripts/ci-required-drift.py index 7b89a144c..7813f3f00 100755 --- a/.gitea/scripts/ci-required-drift.py +++ b/.gitea/scripts/ci-required-drift.py @@ -385,8 +385,12 @@ def detect_drift(branch: str) -> tuple[list[str], dict]: contexts = set(protection.get("status_check_contexts") or []) # ----- F1: job exists in CI but not under sentinel.needs ----- + # Post-#1766 contract: the sentinel may deliberately have no `needs:` + # and instead poll path-relevant statuses dynamically. In that case + # F1 is a false positive — skip it. F1b (typos in existing needs) + # is naturally skipped when needs is empty. missing_from_needs = sorted(jobs - needs) - if missing_from_needs: + if missing_from_needs and needs: findings.append( "F1 — jobs in ci.yml NOT under sentinel `needs:` " "(sentinel doesn't gate them):\n" @@ -512,8 +516,11 @@ def render_body(branch: str, findings: list[str], debug: dict) -> str: "", "## Resolution", "", - "- **F1 / F1b**: add the missing job to `all-required.needs:` " - "in `.gitea/workflows/ci.yml`, or remove the stale entry.", + "- **F1 / F1b**: if the sentinel job has a `needs:` block, add " + "the missing job to it in `.gitea/workflows/ci.yml`, or remove " + "the stale entry. If the sentinel deliberately has no `needs:` " + "(path-aware polling sentinel per post-#1766 contract), this " + "finding is expected and F1 is skipped.", "- **F2**: rename the protection context to match an emitter, " "or remove it from `status_check_contexts` " "(PATCH `/api/v1/repos/{owner}/{repo}/branch_protections/{branch}`).", diff --git a/.gitea/scripts/tests/test_ci_required_drift.py b/.gitea/scripts/tests/test_ci_required_drift.py new file mode 100644 index 000000000..e5c7bbd3d --- /dev/null +++ b/.gitea/scripts/tests/test_ci_required_drift.py @@ -0,0 +1,178 @@ +import importlib.util +import sys +from pathlib import Path +from unittest.mock import patch + +import pytest + +SCRIPT = Path(__file__).resolve().parents[1] / "ci-required-drift.py" +spec = importlib.util.spec_from_file_location("ci_required_drift", SCRIPT) +drift = importlib.util.module_from_spec(spec) +sys.modules[spec.name] = drift +spec.loader.exec_module(drift) + +# Module-level constants are loaded from env at import time; set them +# explicitly so unit tests can import without the full env contract. +drift.SENTINEL_JOB = "all-required" +drift.CI_WORKFLOW_PATH = ".gitea/workflows/ci.yml" +drift.AUDIT_WORKFLOW_PATH = ".gitea/workflows/audit-force-merge.yml" + + +# --------------------------------------------------------------------------- +# Helper fixtures +# --------------------------------------------------------------------------- + +def _make_ci_doc(jobs: dict) -> dict: + return {"jobs": jobs} + + +def _make_audit_doc(required_checks: list[str]) -> dict: + return { + "jobs": { + "audit": { + "steps": [ + {"env": {"REQUIRED_CHECKS": "\n".join(required_checks)}} + ] + } + } + } + + +# --------------------------------------------------------------------------- +# sentinel_needs +# --------------------------------------------------------------------------- + +def test_sentinel_needs_returns_empty_when_absent(): + doc = _make_ci_doc({"all-required": {"runs-on": "ubuntu-latest"}}) + assert drift.sentinel_needs(doc) == set() + + +def test_sentinel_needs_parses_list(): + doc = _make_ci_doc( + {"all-required": {"needs": ["platform-build", "canvas-build"]}} + ) + assert drift.sentinel_needs(doc) == {"platform-build", "canvas-build"} + + +def test_sentinel_needs_parses_string(): + doc = _make_ci_doc({"all-required": {"needs": "platform-build"}}) + assert drift.sentinel_needs(doc) == {"platform-build"} + + +# --------------------------------------------------------------------------- +# ci_job_names / ci_jobs_all +# --------------------------------------------------------------------------- + +def test_ci_job_names_excludes_sentinel_and_event_gated(): + doc = _make_ci_doc( + { + "platform-build": {}, + "canvas-build": {"if": "github.event_name == 'pull_request'"}, + "main-push": {"if": "github.ref == 'refs/heads/main'"}, + "all-required": {}, + } + ) + assert drift.ci_job_names(doc) == {"platform-build"} + + +def test_ci_jobs_all_includes_event_gated(): + doc = _make_ci_doc( + { + "platform-build": {}, + "canvas-build": {"if": "github.event_name == 'pull_request'"}, + "all-required": {}, + } + ) + assert drift.ci_jobs_all(doc) == {"platform-build", "canvas-build"} + + +# --------------------------------------------------------------------------- +# detect_drift — F1 / F1b with mocked I/O +# --------------------------------------------------------------------------- + +SAMPLE_PROTECTION = { + "status_check_contexts": [ + "CI / all-required (pull_request)", + "Secret scan / Scan diff for credential-shaped strings (pull_request)", + ] +} + + +def test_detect_drift_no_needs_sentinel_skips_f1(): + """Post-#1766 contract: all-required has no needs: → F1 is a false positive.""" + ci = _make_ci_doc( + { + "platform-build": {}, + "canvas-build": {}, + "all-required": {}, + } + ) + audit = _make_audit_doc( + [ + "CI / all-required (pull_request)", + "Secret scan / Scan diff for credential-shaped strings (pull_request)", + ] + ) + + with patch.object(drift, "load_yaml", side_effect=[ci, audit]): + with patch.object(drift, "api", return_value=(200, SAMPLE_PROTECTION)): + findings, debug = drift.detect_drift("main") + + assert findings == [] + assert debug["sentinel_needs"] == [] + + +def test_detect_drift_typo_in_needs_triggers_f1b(): + """F1b still catches typos when needs exists.""" + ci = _make_ci_doc( + { + "platform-build": {}, + "all-required": {"needs": ["platfom-build"]}, # typo + } + ) + audit = _make_audit_doc(["CI / all-required (pull_request)"]) + + with patch.object(drift, "load_yaml", side_effect=[ci, audit]): + with patch.object(drift, "api", return_value=(200, SAMPLE_PROTECTION)): + findings, _ = drift.detect_drift("main") + + assert any("F1b" in f for f in findings) + assert any("platfom-build" in f for f in findings) + + +def test_detect_drift_missing_job_in_needs_triggers_f1(): + """F1 still fires when needs is non-empty and jobs are missing.""" + ci = _make_ci_doc( + { + "platform-build": {}, + "canvas-build": {}, + "all-required": {"needs": ["platform-build"]}, + } + ) + audit = _make_audit_doc(["CI / all-required (pull_request)"]) + + with patch.object(drift, "load_yaml", side_effect=[ci, audit]): + with patch.object(drift, "api", return_value=(200, SAMPLE_PROTECTION)): + findings, _ = drift.detect_drift("main") + + assert any("F1 —" in f for f in findings) + assert any("canvas-build" in f for f in findings) + assert not any("F1b" in f for f in findings) + + +def test_detect_drift_no_f1_when_needs_empty_even_with_jobs(): + """Explicit regression guard: empty needs + existing jobs = no F1.""" + ci = _make_ci_doc( + { + "platform-build": {}, + "canvas-build": {}, + "all-required": {"needs": []}, + } + ) + audit = _make_audit_doc(["CI / all-required (pull_request)"]) + + with patch.object(drift, "load_yaml", side_effect=[ci, audit]): + with patch.object(drift, "api", return_value=(200, SAMPLE_PROTECTION)): + findings, _ = drift.detect_drift("main") + + assert not any("F1 —" in f for f in findings) diff --git a/workspace-server/internal/handlers/org_include_test.go b/workspace-server/internal/handlers/org_include_test.go index 83716599a..6539caf3f 100644 --- a/workspace-server/internal/handlers/org_include_test.go +++ b/workspace-server/internal/handlers/org_include_test.go @@ -2,6 +2,7 @@ package handlers import ( "os" + "os/exec" "path/filepath" "strings" "testing" @@ -9,6 +10,16 @@ import ( "gopkg.in/yaml.v3" ) +// runCmd wraps exec.Command for convenience in tests. +func runCmd(name string, args ...string) (exitCode int, stdout, stderr string) { + cmd := exec.Command(name, args...) + out, err := cmd.CombinedOutput() + if err != nil { + return -1, string(out), err.Error() + } + return 0, string(out), "" +} + // resolveYAMLIncludes is the preprocessor Phase 3 uses to split org.yaml // into per-team / per-role files. These tests cover the happy path, // nested includes, path traversal defense, cycle detection, depth cap, @@ -191,31 +202,31 @@ func TestResolveYAMLIncludes_SiblingDirAccess(t *testing.T) { // resolves cleanly via !include and unmarshal into OrgTemplate produces // the full workspace tree. Guards against split regressions landing on // main before they can be caught by a deploy. +// +// Previously skipped because /org-templates/molecule-dev/ was a stale +// in-tree copy with a broken !include graph. The extraction completed +// and the canonical copy now lives at molecule-ai/molecule-ai-org-template- +// molecule-dev. This test fetches it via HTTPS (no token needed — the repo +// is public) to exercise the real include resolution on every CI run. func TestResolveYAMLIncludes_RealMoleculeDev(t *testing.T) { - // The in-tree copy at /org-templates/molecule-dev/ is being removed - // in favor of the standalone Molecule-AI/molecule-ai-org-template- - // molecule-dev repo (see .gitignore comment). Until that removal - // lands, the in-tree copy is stale and its !include graph is broken - // (teams/dev.yaml references missing core-platform.yaml etc.), so - // this integration test is skipped. Re-enable once the extraction - // PR lands and this test is rewritten to fetch the standalone repo - // or replaced with a self-contained fixture. - t.Skip("org-templates/molecule-dev is being extracted to a standalone repo; see .gitignore comment") - - // Locate the monorepo root from the test file location. - // Test runs in platform/internal/handlers/; org template is at - // ../../../org-templates/molecule-dev/org.yaml. - here, err := os.Getwd() - if err != nil { - t.Fatalf("getwd: %v", err) + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available in this runtime") } - orgDir := filepath.Clean(filepath.Join(here, "..", "..", "..", "org-templates", "molecule-dev")) - orgFile := filepath.Join(orgDir, "org.yaml") + tmp := t.TempDir() + // Clone the canonical standalone org template. No token needed — the + // repo is public on the same Gitea instance. + res, _, _ := runCmd("git", "clone", "--depth", "1", + "https://git.moleculesai.app/molecule-ai/molecule-ai-org-template-molecule-dev.git", + tmp) + if res != 0 { + t.Skipf("could not clone standalone org template (skipping integration test): exit %d", res) + } + orgFile := filepath.Join(tmp, "org.yaml") data, err := os.ReadFile(orgFile) if err != nil { - t.Skipf("molecule-dev/org.yaml not found (skipping integration test): %v", err) + t.Skipf("org.yaml not found in standalone clone (skipping integration test): %v", err) } - expanded, err := resolveYAMLIncludes(data, orgDir) + expanded, err := resolveYAMLIncludes(data, tmp) if err != nil { t.Fatalf("resolveYAMLIncludes on real org.yaml: %v", err) } @@ -223,17 +234,18 @@ func TestResolveYAMLIncludes_RealMoleculeDev(t *testing.T) { if err := yaml.Unmarshal(expanded, &tmpl); err != nil { t.Fatalf("unmarshal expanded yaml: %v", err) } - // Sanity: should have PM + Marketing Lead at top, and PM should have - // at least Research Lead, Dev Lead, Documentation Specialist, Triage - // Operator as children (the Phase 3 split targets). - if len(tmpl.Workspaces) < 2 { - t.Fatalf("expected ≥2 top-level workspaces, got %d", len(tmpl.Workspaces)) + // Sanity: should have PM + Marketing Lead + Dev Lead (via !external) at + // top. PM's direct children were slimmed in Phase 3d: Dev Lead and its + // subtree moved to molecule-dev-department, so PM now has Research Lead + // as its only direct child. + if len(tmpl.Workspaces) < 3 { + t.Fatalf("expected ≥3 top-level workspaces, got %d", len(tmpl.Workspaces)) } names := map[string]bool{} for _, w := range tmpl.Workspaces { names[w.Name] = true } - for _, want := range []string{"PM", "Marketing Lead"} { + for _, want := range []string{"PM", "Marketing Lead", "Dev Lead"} { if !names[want] { t.Errorf("expected top-level workspace %q, not found", want) } @@ -245,8 +257,8 @@ func TestResolveYAMLIncludes_RealMoleculeDev(t *testing.T) { break } } - if pm == nil || len(pm.Children) < 4 { - t.Errorf("PM should have ≥4 children after include resolution, got %d", len(pm.Children)) + if pm == nil || len(pm.Children) < 1 { + t.Errorf("PM should have ≥1 child after include resolution, got %d", len(pm.Children)) } } @@ -270,3 +282,8 @@ workspaces: t.Errorf("no-op changed semantics; orig=%+v expanded=%+v", orig, expanded) } } + +// TestResolveYAMLIncludes_RealMoleculeDev clones molecule-ai-org-template-molecule-dev +// via HTTPS and validates the full org include resolution. The exec.LookPath guard +// ensures the test skips gracefully when git is unavailable in the runtime. +// CI trigger: 2026-05-25T06:07 UTC