Merge pull request 'feat(ci)(hard-gate): lint-workflow-yaml catches Gitea-1.22.6-hostile shapes' (#671) from infra/lint-workflow-yaml-hostile-shapes into main
Some checks failed
Block internal-flavored paths / Block forbidden paths (push) Successful in 4s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (push) Successful in 6s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 7s
CI / Detect changes (push) Successful in 10s
E2E API Smoke Test / detect-changes (push) Successful in 10s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 11s
Handlers Postgres Integration / detect-changes (push) Successful in 11s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 11s
CI / Shellcheck (E2E scripts) (push) Successful in 3s
CI / Canvas (Next.js) (push) Successful in 3s
CI / Platform (Go) (push) Successful in 3s
CI / Canvas Deploy Reminder (push) Has been skipped
CI / Python Lint & Test (push) Successful in 4s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 4s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 5s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 4s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 3s
CI / all-required (push) Successful in 2s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (push) Failing after 1m4s
main-red-watchdog / watchdog (push) Successful in 30s
gate-check-v3 / gate-check (push) Compensated by status-reaper (workflow has no push: trigger; Gitea 1.22.6 hardcoded-suffix bug — see .gitea/scripts/status-reaper.py)
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Compensated by status-reaper (workflow has no push: trigger; Gitea 1.22.6 hardcoded-suffix bug — see .gitea/scripts/status-reaper.py)
ci-required-drift / drift (push) Successful in 52s
Sweep stale AWS Secrets Manager secrets / Sweep AWS Secrets Manager (push) Compensated by status-reaper (workflow has no push: trigger; Gitea 1.22.6 hardcoded-suffix bug — see .gitea/scripts/status-reaper.py)
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Compensated by status-reaper (workflow has no push: trigger; Gitea 1.22.6 hardcoded-suffix bug — see .gitea/scripts/status-reaper.py)
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 2s
E2E Staging SaaS (full lifecycle) / pr-validate (push) Successful in 29s
status-reaper / reap (push) Successful in 1m4s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (push) Failing after 4m41s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Compensated by status-reaper (workflow has no push: trigger; Gitea 1.22.6 hardcoded-suffix bug — see .gitea/scripts/status-reaper.py)
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Compensated by status-reaper (workflow has no push: trigger; Gitea 1.22.6 hardcoded-suffix bug — see .gitea/scripts/status-reaper.py)
Some checks failed
Block internal-flavored paths / Block forbidden paths (push) Successful in 4s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (push) Successful in 6s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 7s
CI / Detect changes (push) Successful in 10s
E2E API Smoke Test / detect-changes (push) Successful in 10s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 11s
Handlers Postgres Integration / detect-changes (push) Successful in 11s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 11s
CI / Shellcheck (E2E scripts) (push) Successful in 3s
CI / Canvas (Next.js) (push) Successful in 3s
CI / Platform (Go) (push) Successful in 3s
CI / Canvas Deploy Reminder (push) Has been skipped
CI / Python Lint & Test (push) Successful in 4s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 4s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 5s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 4s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 3s
CI / all-required (push) Successful in 2s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (push) Failing after 1m4s
main-red-watchdog / watchdog (push) Successful in 30s
gate-check-v3 / gate-check (push) Compensated by status-reaper (workflow has no push: trigger; Gitea 1.22.6 hardcoded-suffix bug — see .gitea/scripts/status-reaper.py)
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Compensated by status-reaper (workflow has no push: trigger; Gitea 1.22.6 hardcoded-suffix bug — see .gitea/scripts/status-reaper.py)
ci-required-drift / drift (push) Successful in 52s
Sweep stale AWS Secrets Manager secrets / Sweep AWS Secrets Manager (push) Compensated by status-reaper (workflow has no push: trigger; Gitea 1.22.6 hardcoded-suffix bug — see .gitea/scripts/status-reaper.py)
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Compensated by status-reaper (workflow has no push: trigger; Gitea 1.22.6 hardcoded-suffix bug — see .gitea/scripts/status-reaper.py)
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 2s
E2E Staging SaaS (full lifecycle) / pr-validate (push) Successful in 29s
status-reaper / reap (push) Successful in 1m4s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (push) Failing after 4m41s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Compensated by status-reaper (workflow has no push: trigger; Gitea 1.22.6 hardcoded-suffix bug — see .gitea/scripts/status-reaper.py)
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Compensated by status-reaper (workflow has no push: trigger; Gitea 1.22.6 hardcoded-suffix bug — see .gitea/scripts/status-reaper.py)
This commit is contained in:
commit
b462270201
369
.gitea/scripts/lint-workflow-yaml.py
Executable file
369
.gitea/scripts/lint-workflow-yaml.py
Executable file
@ -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
|
||||
`<workflow> / <job> (<event>)` 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/<org-suspended>/... 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 <path>
|
||||
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 `<workflow> / <job> (<event>)`; 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: <foo>@<ref>` — match the value form Gitea/act actually parse.
|
||||
# We need to distinguish:
|
||||
# - `actions/checkout@<sha>` 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<owner>[A-Za-z0-9_.\-]+)
|
||||
/
|
||||
(?P<repo>[A-Za-z0-9_.\-]+)
|
||||
/ # mandatory subpath separator => cross-repo composite/reusable
|
||||
(?P<path>[^@\s]+)
|
||||
@
|
||||
(?P<ref>\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())
|
||||
75
.gitea/workflows/lint-workflow-yaml.yml
Normal file
75
.gitea/workflows/lint-workflow-yaml.yml
Normal file
@ -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
|
||||
413
tests/test_lint_workflow_yaml.py
Normal file
413
tests/test_lint_workflow_yaml.py
Normal file
@ -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 "<workflow> / <job> (<event>)" 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}"
|
||||
)
|
||||
Loading…
Reference in New Issue
Block a user