diff --git a/.gitea/scripts/lint-workflow-yaml.py b/.gitea/scripts/lint-workflow-yaml.py new file mode 100755 index 00000000..1147fb12 --- /dev/null +++ b/.gitea/scripts/lint-workflow-yaml.py @@ -0,0 +1,369 @@ +#!/usr/bin/env python3 +"""lint-workflow-yaml — catch Gitea-1.22.6-hostile workflow YAML shapes. + +This script enforces six structural rules that have historically caused +silent CI failures on Gitea Actions (1.22.6) — workflows that the server's +YAML parser rejects with `[W] ignore invalid workflow ...` and registers +for zero events, or shape conventions that produce ambiguous status +contexts. Each rule maps to a documented incident in saved memory. + +Rules (4 fatal + 1 fatal cross-file + 1 heuristic-warn): + 1. `workflow_dispatch.inputs:` block — Gitea 1.22.6 mis-parses the + `inputs` keys as sibling event types and rejects the whole file. + Memory: feedback_gitea_workflow_dispatch_inputs_unsupported. + Origin: 2026-05-11 PyPI freeze (publish-runtime). + 2. `on: workflow_run:` event — not enumerated in Gitea 1.22.6's + supported event list (verified via modules/actions/workflows.go + enumeration; task #81). Workflow registers, fires for 0 events. + 3. `name:` containing `/` — breaks the + ` / ()` commit-status context convention; + downstream parsers (sop-tier-check, status-reaper) tokenize on `/`. + 4. `name:` collision across files — Gitea routes commit-status updates + by `name` and behavior on collision is undefined (status-reaper + rev1 fail-loud). + 5. Cross-repo `uses: org/repo/path@ref` — blocked while + `[actions].DEFAULT_ACTIONS_URL=github` is the server default; + resolves to github.com//... and 404s. + Memory: feedback_gitea_cross_repo_uses_blocked. Cross-link: task #109. + 6. (HEURISTIC, warn-not-fail) Steps reference `https://api.github.com` + or `https://github.com/.../releases/download` without a + workflow-level `env.GITHUB_SERVER_URL` set to the Gitea instance. + Memory: feedback_act_runner_github_server_url. + +Per `feedback_smoke_test_vendor_truth_not_shape_match`: fixtures used to +validate this lint must mirror real Gitea 1.22.6 YAML semantics, not +Python yaml-parser quirks. The test suite at tests/test_lint_workflow_yaml.py +includes a vendor-truth fixture (the exact publish-runtime regression). + +Usage: + python3 .gitea/scripts/lint-workflow-yaml.py + Lint every `*.yml` in `.gitea/workflows/`. + + python3 .gitea/scripts/lint-workflow-yaml.py --workflow-dir + Lint a custom directory (used by tests/test_lint_workflow_yaml.py). + +Exit codes: + 0 — clean OR only heuristic-warnings emitted. + 1 — at least one fatal rule (1-5) violated. + 2 — YAML parse error or argv usage error. +""" +from __future__ import annotations + +import argparse +import collections +import glob +import os +import re +import sys +from pathlib import Path +from typing import Any, Iterable + +try: + import yaml +except ImportError: + print("::error::PyYAML is required. Install with: pip install PyYAML", file=sys.stderr) + sys.exit(2) + + +# YAML quirk: bare `on:` at the top level parses to the Python `True` +# (because `on` is a YAML 1.1 boolean alias). Handle both keys. +def _get_on(d: dict) -> Any: + if not isinstance(d, dict): + return None + if "on" in d: + return d["on"] + if True in d: + return d[True] + return None + + +# --------------------------------------------------------------------------- +# Rule 1 — workflow_dispatch.inputs block (Gitea 1.22.6 parser rejects) +# --------------------------------------------------------------------------- + +def check_workflow_dispatch_inputs(filename: str, doc: Any) -> list[str]: + """Return per-violation error lines if `workflow_dispatch.inputs` is set.""" + errors: list[str] = [] + on = _get_on(doc) + if not isinstance(on, dict): + return errors + wd = on.get("workflow_dispatch") + if isinstance(wd, dict) and wd.get("inputs"): + errors.append( + f"::error file={filename}::Rule 1 (FATAL): " + f"`on.workflow_dispatch.inputs:` block detected. Gitea 1.22.6 " + f"silently rejects the entire workflow with `[W] ignore invalid " + f"workflow: unknown on type: map[...]`. Drop the `inputs:` block " + f"and derive parameters from tag name / env / external query. " + f"Memory: feedback_gitea_workflow_dispatch_inputs_unsupported." + ) + return errors + + +# --------------------------------------------------------------------------- +# Rule 2 — on: workflow_run (not supported on Gitea 1.22.6) +# --------------------------------------------------------------------------- + +def check_workflow_run_event(filename: str, doc: Any) -> list[str]: + """Return per-violation error lines if `on: workflow_run:` is used.""" + errors: list[str] = [] + on = _get_on(doc) + if isinstance(on, dict) and "workflow_run" in on: + errors.append( + f"::error file={filename}::Rule 2 (FATAL): `on: workflow_run:` " + f"event used. Gitea 1.22.6 does NOT support `workflow_run` " + f"(verified via modules/actions/workflows.go enumeration; " + f"task #81). Workflow will fire for zero events. Use a " + f"`schedule:` cron OR a `push:` trigger with `paths:` filter " + f"on the upstream workflow file as the cross-workflow gate." + ) + elif isinstance(on, list) and "workflow_run" in on: + errors.append( + f"::error file={filename}::Rule 2 (FATAL): `on: workflow_run` " + f"in event list. Not supported on Gitea 1.22.6 — task #81." + ) + return errors + + +# --------------------------------------------------------------------------- +# Rule 3 — name: contains "/" (breaks status-context tokenization) +# --------------------------------------------------------------------------- + +def check_name_with_slash(filename: str, doc: Any) -> list[str]: + """Return per-violation error lines if workflow `name:` contains a slash.""" + errors: list[str] = [] + if not isinstance(doc, dict): + return errors + name = doc.get("name") + if isinstance(name, str) and "/" in name: + errors.append( + f"::error file={filename}::Rule 3 (FATAL): workflow `name: " + f"{name!r}` contains `/`. The commit-status context convention " + f"is ` / ()`; embedding `/` in the " + f"workflow name makes downstream parsers (sop-tier-check, " + f"status-reaper) tokenize ambiguously. Rename to use `-` or " + f"` ` instead." + ) + return errors + + +# --------------------------------------------------------------------------- +# Rule 4 — cross-file name collision +# --------------------------------------------------------------------------- + +def check_name_collision_across_files( + docs_by_file: dict[str, Any], +) -> list[str]: + """Return per-collision error lines if two files share the same `name:`.""" + errors: list[str] = [] + by_name: dict[str, list[str]] = collections.defaultdict(list) + for filename, doc in docs_by_file.items(): + if isinstance(doc, dict): + n = doc.get("name") + if isinstance(n, str) and n: + by_name[n].append(filename) + for n, files in sorted(by_name.items()): + if len(files) > 1: + errors.append( + f"::error::Rule 4 (FATAL): workflow `name: {n!r}` collision " + f"across {len(files)} files: {files}. Gitea routes " + f"commit-status updates by `name`; collision yields " + f"undefined behavior. Give each workflow a unique `name:`." + ) + return errors + + +# --------------------------------------------------------------------------- +# Rule 5 — cross-repo `uses: org/repo/path@ref` +# --------------------------------------------------------------------------- + +# `uses: @` — match the value form Gitea/act actually parse. +# We need to distinguish: +# - `actions/checkout@` OK (bare org/repo@ref, no subpath) +# - `./.gitea/actions/foo` OK (local path) +# - `docker://image:tag` OK (docker-image form) +# - `molecule-ai/molecule-ci/.gitea/actions/audit-force-merge@main` BAD +USES_CROSS_REPO_RE = re.compile( + r"""^ + (?P[A-Za-z0-9_.\-]+) + / + (?P[A-Za-z0-9_.\-]+) + / # mandatory subpath separator => cross-repo composite/reusable + (?P[^@\s]+) + @ + (?P\S+) + $""", + re.VERBOSE, +) + + +def _iter_uses(doc: Any) -> Iterable[str]: + """Yield every `uses:` string from job steps in a workflow document.""" + if not isinstance(doc, dict): + return + jobs = doc.get("jobs") + if not isinstance(jobs, dict): + return + for job in jobs.values(): + if not isinstance(job, dict): + continue + # reusable workflow: `uses:` at the job level + if isinstance(job.get("uses"), str): + yield job["uses"] + steps = job.get("steps") + if not isinstance(steps, list): + continue + for step in steps: + if isinstance(step, dict) and isinstance(step.get("uses"), str): + yield step["uses"] + + +def check_cross_repo_uses(filename: str, doc: Any) -> list[str]: + """Return per-violation error lines for cross-repo `uses:` references.""" + errors: list[str] = [] + for uses in _iter_uses(doc): + # Skip docker:// and local ./ + if uses.startswith(("docker://", "./", "../")): + continue + m = USES_CROSS_REPO_RE.match(uses.strip()) + if m: + errors.append( + f"::error file={filename}::Rule 5 (FATAL): cross-repo " + f"`uses: {uses}` detected. Gitea 1.22.6 with " + f"`[actions].DEFAULT_ACTIONS_URL=github` resolves this to " + f"github.com/{m.group('owner')}/{m.group('repo')} which " + f"404s (org suspended 2026-05-06). Inline the shared bash " + f"into `.gitea/scripts/` until task #109 (actions mirror) " + f"ships. Memory: feedback_gitea_cross_repo_uses_blocked." + ) + return errors + + +# --------------------------------------------------------------------------- +# Rule 6 — heuristic: github.com/api refs without workflow-level +# GITHUB_SERVER_URL (WARN-not-FAIL per halt-condition 3) +# --------------------------------------------------------------------------- + +# Match `https://api.github.com/...` (API call) — that's the actionable +# pattern. We intentionally do NOT match `https://github.com/.../releases/ +# download/...` (jq-release pin) nor `https://github.com/${{ github.repository +# }}` (OCI label) because those are documented benign references on current +# main and would 100% false-positive (3 hits, per Phase 1 audit). +GITHUB_API_REF_RE = re.compile( + r"https://api\.github\.com\b|https://github\.com/api/", + re.IGNORECASE, +) + + +def _has_workflow_level_server_url(doc: Any) -> bool: + if not isinstance(doc, dict): + return False + env = doc.get("env") + if isinstance(env, dict) and "GITHUB_SERVER_URL" in env: + return True + return False + + +def check_github_server_url_missing(filename: str, doc: Any, raw: str) -> list[str]: + """Return warn-lines (NOT errors) if api.github.com is referenced without + workflow-level GITHUB_SERVER_URL. Heuristic — false-positives possible. + """ + warns: list[str] = [] + if not GITHUB_API_REF_RE.search(raw): + return warns + if _has_workflow_level_server_url(doc): + return warns + warns.append( + f"::warning file={filename}::Rule 6 (WARN, heuristic): file " + f"references `https://api.github.com` without a workflow-level " + f"`env.GITHUB_SERVER_URL: https://git.moleculesai.app`. The " + f"act_runner default for `${{{{ github.server_url }}}}` is " + f"github.com, which can break actions that auth-condition on " + f"server_url (e.g. actions/setup-go). If this curl is " + f"intentionally hitting GitHub (e.g. public release pin), ignore. " + f"Memory: feedback_act_runner_github_server_url." + ) + return warns + + +# --------------------------------------------------------------------------- +# Driver +# --------------------------------------------------------------------------- + +def main(argv: list[str] | None = None) -> int: + p = argparse.ArgumentParser( + description="Lint Gitea Actions workflow YAML for 1.22.6-hostile shapes." + ) + p.add_argument( + "--workflow-dir", + default=".gitea/workflows", + help="Directory of workflow *.yml files (default: .gitea/workflows).", + ) + args = p.parse_args(argv) + + wf_dir = Path(args.workflow_dir) + if not wf_dir.exists(): + # Empty / missing dir = nothing to lint, not a failure. + print(f"::notice::No workflow directory at {wf_dir}; skipping.") + return 0 + + yml_paths = sorted( + glob.glob(str(wf_dir / "*.yml")) + glob.glob(str(wf_dir / "*.yaml")) + ) + if not yml_paths: + print(f"::notice::No workflow files in {wf_dir}; nothing to lint.") + return 0 + + fatal_errors: list[str] = [] + warnings: list[str] = [] + docs_by_file: dict[str, Any] = {} + + for path in yml_paths: + rel = os.path.relpath(path) + try: + raw = Path(path).read_text() + doc = yaml.safe_load(raw) + except yaml.YAMLError as e: + fatal_errors.append( + f"::error file={rel}::YAML parse error: {e}. Cannot lint " + f"a file the parser rejects." + ) + continue + docs_by_file[rel] = doc + + # Per-file checks + fatal_errors.extend(check_workflow_dispatch_inputs(rel, doc)) + fatal_errors.extend(check_workflow_run_event(rel, doc)) + fatal_errors.extend(check_name_with_slash(rel, doc)) + fatal_errors.extend(check_cross_repo_uses(rel, doc)) + warnings.extend(check_github_server_url_missing(rel, doc, raw)) + + # Cross-file checks + fatal_errors.extend(check_name_collision_across_files(docs_by_file)) + + # Emit warnings first (non-blocking) + for w in warnings: + print(w) + + if not fatal_errors: + n = len(yml_paths) + print( + f"::notice::lint-workflow-yaml: {n} workflow file(s) checked, " + f"no fatal Gitea-1.22.6-hostile shapes. " + f"({len(warnings)} heuristic warning(s) emitted.)" + ) + return 0 + + # Emit fatal errors + print( + f"::error::lint-workflow-yaml: {len(fatal_errors)} fatal violation(s) " + f"across {len(yml_paths)} workflow file(s). See rule documentation " + f"in .gitea/scripts/lint-workflow-yaml.py docstring." + ) + for e in fatal_errors: + print(e) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.gitea/workflows/lint-workflow-yaml.yml b/.gitea/workflows/lint-workflow-yaml.yml new file mode 100644 index 00000000..1b2b7120 --- /dev/null +++ b/.gitea/workflows/lint-workflow-yaml.yml @@ -0,0 +1,75 @@ +name: Lint workflow YAML (Gitea-1.22.6-hostile shapes) + +# Tier-2 hard-gate lint (RFC internal#219 §1, charter §SOP-N rule (m)). +# Catches six Gitea-1.22.6-hostile workflow-YAML shapes BEFORE they reach +# `main`. Each rule maps to a documented incident in saved memory: +# +# 1. workflow_dispatch.inputs — feedback_gitea_workflow_dispatch_inputs_unsupported +# (2026-05-11 PyPI freeze 24h) +# 2. on: workflow_run — task #81 (Gitea 1.22.6 lacks the event) +# 3. name: containing "/" — breaks status-context tokenization +# 4. cross-file name collision — status-reaper rev1 fail-loud class +# 5. cross-repo uses: org/r/p@r — feedback_gitea_cross_repo_uses_blocked +# (DEFAULT_ACTIONS_URL=github → 404) +# 6. (WARN) api.github.com refs — feedback_act_runner_github_server_url +# without workflow-level GITHUB_SERVER_URL +# +# Empirical history this hardens against: +# - status-reaper rev1 caught rule-4 (name-collision) class +# - sop-tier-refire DOA'd on rule-2 (workflow_run partial) +# - #319 bootstrap-paradox (chained-defect class, related) +# - internal#329 dispatcher race (adjacent) +# - 2026-05-11 publish-runtime: rule-1, 24h PyPI freeze +# +# Triggers: +# - pull_request: pre-merge gate — block hostile shapes before they land +# - push: post-merge regression detection — catch direct-to-main edits +# +# Per RFC internal#219 §1 contract: continue-on-error: true during the +# surface-broken-shapes phase. Follow-up PR flips off after surfaced +# defects are triaged. The push-trigger ensures we catch regressions +# even if the pull_request gate is bypassed by branch-protection drift. + +on: + pull_request: + paths: + - '.gitea/workflows/**' + - '.gitea/scripts/lint-workflow-yaml.py' + - 'tests/test_lint_workflow_yaml.py' + push: + branches: [main, staging] + paths: + - '.gitea/workflows/**' + - '.gitea/scripts/lint-workflow-yaml.py' + - 'tests/test_lint_workflow_yaml.py' + +# Belt-and-suspenders against runner default +# (feedback_act_runner_github_server_url). +env: + GITHUB_SERVER_URL: https://git.moleculesai.app + +jobs: + lint: + name: Lint workflow YAML for Gitea-1.22.6-hostile shapes + runs-on: ubuntu-latest + # Phase 3 (RFC #219 §1): surface broken shapes without blocking PRs. + # Follow-up PR flips this off after the 4 existing-on-main rule-2 + # (workflow_run) violations are migrated to a supported trigger. + continue-on-error: true + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: '3.11' + + - name: Install PyYAML + run: pip install --quiet 'PyYAML>=6.0' + + - name: Lint .gitea/workflows/*.yml + run: python3 .gitea/scripts/lint-workflow-yaml.py + + - name: Run lint-workflow-yaml unit tests + run: | + pip install --quiet pytest + python3 -m pytest tests/test_lint_workflow_yaml.py -v diff --git a/tests/test_lint_workflow_yaml.py b/tests/test_lint_workflow_yaml.py new file mode 100644 index 00000000..7d964db0 --- /dev/null +++ b/tests/test_lint_workflow_yaml.py @@ -0,0 +1,413 @@ +"""Tests for `.gitea/scripts/lint-workflow-yaml.py` — Gitea-1.22.6-hostile shape lint. + +Hard-gate (Tier-2) lint that catches workflow YAML shapes Gitea 1.22.6 +silently rejects, so they never reach `main`. The six anti-patterns are +documented in saved memory; this test suite is the structural enforcement. + +Per-rule positive (anti-pattern present -> exit 1) + negative (clean -> exit 0) +cases, plus a multi-file collision case and an aggregation case. + +Run: + python3 -m pytest tests/test_lint_workflow_yaml.py -v + +Dependencies: stdlib + PyYAML. No network. + +Cross-links: +- feedback_gitea_workflow_dispatch_inputs_unsupported (rule 1) +- internal task #81 (rule 2 — workflow_run unsupported) +- feedback_workflow_name_with_slash_breaks_parsing (rule 3, if filed) +- feedback_gitea_cross_repo_uses_blocked (rule 5) +- feedback_act_runner_github_server_url (rule 6) +- feedback_smoke_test_vendor_truth_not_shape_match (test-shape rule) +""" +from __future__ import annotations + +import subprocess +import sys +import textwrap +from pathlib import Path + +import pytest # noqa: F401 (declares the dep) + +REPO_ROOT = Path(__file__).resolve().parents[1] +SCRIPT = REPO_ROOT / ".gitea" / "scripts" / "lint-workflow-yaml.py" + + +def _run_lint(workflow_dir: Path) -> subprocess.CompletedProcess: + """Invoke the lint as a subprocess against an isolated workflow dir.""" + return subprocess.run( + [sys.executable, str(SCRIPT), "--workflow-dir", str(workflow_dir)], + capture_output=True, + text=True, + ) + + +def _write(workflow_dir: Path, name: str, content: str) -> Path: + """Write a workflow YAML fixture and return its path.""" + workflow_dir.mkdir(parents=True, exist_ok=True) + p = workflow_dir / name + p.write_text(textwrap.dedent(content).lstrip()) + return p + + +# --------------------------------------------------------------------------- +# Rule 1 — workflow_dispatch.inputs (Gitea 1.22.6 parser rejects) +# --------------------------------------------------------------------------- + +WD_INPUTS_BAD = """ + name: bad-wd-inputs + on: + workflow_dispatch: + inputs: + version: + description: "version" + required: true + type: string + jobs: + x: + runs-on: ubuntu-latest + steps: + - run: echo hi +""" + +WD_INPUTS_OK = """ + name: ok-wd-no-inputs + on: + workflow_dispatch: + push: + branches: [main] + jobs: + x: + runs-on: ubuntu-latest + steps: + - run: echo hi +""" + + +def test_rule1_workflow_dispatch_inputs_detects_violation(tmp_path): + _write(tmp_path, "bad.yml", WD_INPUTS_BAD) + r = _run_lint(tmp_path) + assert r.returncode == 1 + assert "workflow_dispatch.inputs" in r.stdout + assert "bad.yml" in r.stdout + + +def test_rule1_workflow_dispatch_inputs_passes_when_absent(tmp_path): + _write(tmp_path, "ok.yml", WD_INPUTS_OK) + r = _run_lint(tmp_path) + assert r.returncode == 0, f"stdout={r.stdout}\nstderr={r.stderr}" + + +# --------------------------------------------------------------------------- +# Rule 2 — workflow_run event (not supported on Gitea 1.22.6) +# --------------------------------------------------------------------------- + +WF_RUN_BAD = """ + name: bad-workflow-run + on: + workflow_run: + workflows: ["upstream"] + types: [completed] + jobs: + x: + runs-on: ubuntu-latest + steps: + - run: echo hi +""" + +WF_RUN_OK = """ + name: ok-no-workflow-run + on: + push: + branches: [main] + jobs: + x: + runs-on: ubuntu-latest + steps: + - run: echo hi +""" + + +def test_rule2_workflow_run_event_detects_violation(tmp_path): + _write(tmp_path, "bad.yml", WF_RUN_BAD) + r = _run_lint(tmp_path) + assert r.returncode == 1 + assert "workflow_run" in r.stdout + assert "bad.yml" in r.stdout + + +def test_rule2_workflow_run_event_passes_when_absent(tmp_path): + _write(tmp_path, "ok.yml", WF_RUN_OK) + r = _run_lint(tmp_path) + assert r.returncode == 0, f"stdout={r.stdout}\nstderr={r.stderr}" + + +# --------------------------------------------------------------------------- +# Rule 3 — name: contains "/" (breaks " / ()" parsing) +# --------------------------------------------------------------------------- + +NAME_SLASH_BAD = """ + name: ci / build + on: [push] + jobs: + x: + runs-on: ubuntu-latest + steps: + - run: echo hi +""" + +NAME_SLASH_OK = """ + name: ci-build + on: [push] + jobs: + x: + runs-on: ubuntu-latest + steps: + - run: echo hi +""" + + +def test_rule3_name_with_slash_detects_violation(tmp_path): + _write(tmp_path, "bad.yml", NAME_SLASH_BAD) + r = _run_lint(tmp_path) + assert r.returncode == 1 + assert "name" in r.stdout.lower() + assert "/" in r.stdout + assert "bad.yml" in r.stdout + + +def test_rule3_name_with_slash_passes_when_absent(tmp_path): + _write(tmp_path, "ok.yml", NAME_SLASH_OK) + r = _run_lint(tmp_path) + assert r.returncode == 0, f"stdout={r.stdout}\nstderr={r.stderr}" + + +# --------------------------------------------------------------------------- +# Rule 4 — name collision across files (cross-file) +# --------------------------------------------------------------------------- + +COLLISION_A = """ + name: shared-name + on: [push] + jobs: + x: + runs-on: ubuntu-latest + steps: + - run: echo a +""" + +COLLISION_B = """ + name: shared-name + on: [push] + jobs: + x: + runs-on: ubuntu-latest + steps: + - run: echo b +""" + +DISTINCT_A = """ + name: name-a + on: [push] + jobs: + x: + runs-on: ubuntu-latest + steps: + - run: echo a +""" + +DISTINCT_B = """ + name: name-b + on: [push] + jobs: + x: + runs-on: ubuntu-latest + steps: + - run: echo b +""" + + +def test_rule4_name_collision_across_two_files_detects_violation(tmp_path): + _write(tmp_path, "a.yml", COLLISION_A) + _write(tmp_path, "b.yml", COLLISION_B) + r = _run_lint(tmp_path) + assert r.returncode == 1 + assert ("collision" in r.stdout.lower()) or ("duplicate" in r.stdout.lower()) + assert "shared-name" in r.stdout + + +def test_rule4_name_collision_passes_when_names_distinct(tmp_path): + _write(tmp_path, "a.yml", DISTINCT_A) + _write(tmp_path, "b.yml", DISTINCT_B) + r = _run_lint(tmp_path) + assert r.returncode == 0, f"stdout={r.stdout}\nstderr={r.stderr}" + + +# --------------------------------------------------------------------------- +# Rule 5 — cross-repo `uses: org/repo/...@ref` (blocked on 1.22.6) +# --------------------------------------------------------------------------- + +CROSS_REPO_BAD = """ + name: bad-cross-repo + on: [push] + jobs: + x: + runs-on: ubuntu-latest + steps: + - uses: molecule-ai/molecule-ci/.gitea/actions/audit-force-merge@main +""" + +# actions/checkout — bare `org/repo@ref` form — allowed. Rule 5 targets +# `org/repo/SUBPATH@ref` cross-repo composite/reusable references because +# only those resolve through `[actions].DEFAULT_ACTIONS_URL`+org-suspended-host. +CROSS_REPO_OK = """ + name: ok-no-cross-repo + on: [push] + jobs: + x: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + - run: echo hi +""" + + +def test_rule5_cross_repo_uses_detects_violation(tmp_path): + _write(tmp_path, "bad.yml", CROSS_REPO_BAD) + r = _run_lint(tmp_path) + assert r.returncode == 1 + assert ("cross-repo" in r.stdout.lower()) or ("uses" in r.stdout.lower()) + assert "bad.yml" in r.stdout + + +def test_rule5_cross_repo_uses_passes_when_only_actions_org(tmp_path): + _write(tmp_path, "ok.yml", CROSS_REPO_OK) + r = _run_lint(tmp_path) + assert r.returncode == 0, f"stdout={r.stdout}\nstderr={r.stderr}" + + +# --------------------------------------------------------------------------- +# Rule 6 — GITHUB_SERVER_URL heuristic (warn-not-fail per halt-condition 3) +# --------------------------------------------------------------------------- + +GH_API_REF_NO_SERVER = """ + name: warn-server-url + on: [push] + jobs: + x: + runs-on: ubuntu-latest + steps: + - run: curl https://api.github.com/repos/foo/bar +""" + +GH_API_REF_WITH_SERVER = """ + name: ok-server-url-set + on: [push] + env: + GITHUB_SERVER_URL: https://git.moleculesai.app + jobs: + x: + runs-on: ubuntu-latest + steps: + - run: curl https://api.github.com/repos/foo/bar +""" + + +def test_rule6_github_server_url_missing_is_warning_not_fatal(tmp_path): + """Heuristic rule — emits warning but does NOT exit 1. + + Per halt-condition 3: heuristic may false-positive (current main has 3: + OCI label + jq-release URL refs). Downgrade to warn-not-fail. + """ + _write(tmp_path, "warn.yml", GH_API_REF_NO_SERVER) + r = _run_lint(tmp_path) + assert r.returncode == 0 + combined = (r.stdout + r.stderr).lower() + assert ("github_server_url" in combined) or ("::warning" in combined) + + +def test_rule6_github_server_url_present_no_warning(tmp_path): + _write(tmp_path, "ok.yml", GH_API_REF_WITH_SERVER) + r = _run_lint(tmp_path) + assert r.returncode == 0 + # No warning emitted (server URL is set) + assert "::warning" not in r.stdout + + +# --------------------------------------------------------------------------- +# Aggregation — single file with multiple anti-patterns +# --------------------------------------------------------------------------- + +MULTI_VIOLATIONS = """ + name: ci / multi + on: + workflow_dispatch: + inputs: + v: + type: string + workflow_run: + workflows: [up] + types: [completed] + jobs: + x: + runs-on: ubuntu-latest + steps: + - uses: molecule-ai/molecule-ci/.gitea/actions/x@main +""" + + +def test_all_violations_aggregated_single_file(tmp_path): + _write(tmp_path, "multi.yml", MULTI_VIOLATIONS) + r = _run_lint(tmp_path) + assert r.returncode == 1 + out = r.stdout + # All four FATAL rules should be reported (1, 2, 3, 5) + assert "workflow_dispatch.inputs" in out + assert "workflow_run" in out + assert "/" in out # rule 3 surfaces the slash + assert ("cross-repo" in out.lower()) or ("uses" in out.lower()) + + +# --------------------------------------------------------------------------- +# Empty-dir / no-workflows edge case +# --------------------------------------------------------------------------- + +def test_no_workflows_exits_zero(tmp_path): + r = _run_lint(tmp_path) + assert r.returncode == 0 + + +# --------------------------------------------------------------------------- +# Vendor-truth: rule 1 catches the exact 2026-05-11 publish-runtime.yml shape +# --------------------------------------------------------------------------- + +# The exact YAML shape from feedback_gitea_workflow_dispatch_inputs_unsupported +# that caused publish-runtime-v1.0.0 to silently freeze PyPI at 0.1.129 for ~24h. +PUBLISH_RUNTIME_VENDOR_TRUTH = """ + name: publish-runtime + on: + push: + tags: ['runtime-v*'] + workflow_dispatch: + inputs: + version: + description: "Version to publish (e.g. 0.1.6). Required for manual dispatch." + required: true + type: string + jobs: + x: + runs-on: ubuntu-latest + steps: + - run: echo hi +""" + + +def test_rule1_catches_2026_05_11_publish_runtime_regression(tmp_path): + """Vendor-truth fixture: the exact YAML shape that froze PyPI for 24h.""" + _write(tmp_path, "publish-runtime.yml", PUBLISH_RUNTIME_VENDOR_TRUTH) + r = _run_lint(tmp_path) + assert r.returncode == 1, ( + "Lint must catch the 2026-05-11 publish-runtime regression " + f"(memory: feedback_gitea_workflow_dispatch_inputs_unsupported)." + f"\nstdout={r.stdout}" + )