Some checks failed
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 7s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 9s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 10s
CI / Detect changes (pull_request) Successful in 20s
E2E API Smoke Test / detect-changes (pull_request) Successful in 19s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 21s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 20s
gate-check-v3 / gate-check (pull_request) Successful in 21s
qa-review / approved (pull_request) Failing after 13s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 23s
CI / Platform (Go) (pull_request) Successful in 6s
security-review / approved (pull_request) Failing after 12s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 4s
sop-tier-check / tier-check (pull_request) Successful in 13s
CI / Canvas (Next.js) (pull_request) Successful in 7s
CI / Python Lint & Test (pull_request) Successful in 6s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 6s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 6s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 5s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 5s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / all-required (pull_request) Successful in 4s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Failing after 1m7s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m12s
audit-force-merge / audit (pull_request) Successful in 3s
Tier-2 hardening per RFC internal#219 §1 + charter §SOP-N rule (m). New
CI lint that scans .gitea/workflows/*.yml for six structurally-hostile
shapes that Gitea 1.22.6 silently rejects or ambiguously parses, BEFORE
they reach main.
Rules (4 fatal + 1 fatal cross-file + 1 heuristic-warn):
1. on.workflow_dispatch.inputs — Gitea 1.22.6 mis-parses inputs.X as
sibling event types and rejects the entire workflow with
[W] ignore invalid workflow ... unknown on type. Memory:
feedback_gitea_workflow_dispatch_inputs_unsupported. Origin:
2026-05-11 publish-runtime-v1.0.0 silent freeze, ~24h PyPI lag.
2. on: workflow_run — not enumerated in Gitea 1.22.6 event types
(verified via modules/actions/workflows.go; task #81). Workflow
registers, fires for zero events.
3. workflow name: containing / — breaks the commit-status convention
<workflow> / <job> (<event>) used by sop-tier-check + status-reaper
to tokenize context strings.
4. cross-file name: collision — status-routing is by name; collision
yields undefined commit-status updates (status-reaper rev1 class).
5. cross-repo uses: org/repo/subpath@ref — DEFAULT_ACTIONS_URL=github
resolves to github.com/<org-suspended>/... and 404s. Memory:
feedback_gitea_cross_repo_uses_blocked. Cross-link: task #109.
6. (WARN, heuristic) api.github.com refs without workflow-level
env.GITHUB_SERVER_URL. Memory: feedback_act_runner_github_server_url.
Per halt-condition 3: downgraded to warn-not-fail to avoid the 3
known benign hits on current main (OCI source label + jq-release
pin) which use https://github.com/... not https://api.github.com/.
Empirical history this hardens against:
- status-reaper rev1 caught rule-4 (name-collision) class fail-loud
- 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 on
runtime-v1.0.0 publish
Triggers:
- pull_request — pre-merge gate
- push to main/staging — post-merge regression catch even if the PR
gate is bypassed by branch-protection drift
Per RFC #219 §1 contract: continue-on-error: true on the job during the
surface-broken-shapes phase. Follow-up PR flips off after the 3 existing
rule-2 violations on main are migrated to a supported trigger.
Existing-on-main violations surfaced by this lint (3, informational, NOT
auto-fixed per halt-condition 2):
- .gitea/workflows/redeploy-tenants-on-main.yml — rule 2
- .gitea/workflows/redeploy-tenants-on-staging.yml — rule 2
- .gitea/workflows/staging-verify.yml — rule 2
All three have on: workflow_run: triggers that will fire for zero
events. Fix path: replace with cron or with push+paths:[upstream-yml]
gate. Tracked separately (do not block this PR).
Tests:
tests/test_lint_workflow_yaml.py — 15 pytest cases:
- 6 × per-rule violation-detected (rules 1-3,5 + rule 4 cross-file
+ rule 6 heuristic-warn)
- 6 × per-rule clean-passes
- 1 × cross-file collision detected
- 1 × all-violations-aggregated single file
- 1 × empty workflow dir = exit 0
- 1 × vendor-truth: the exact 2026-05-11 publish-runtime YAML shape
from feedback_gitea_workflow_dispatch_inputs_unsupported is caught
(per feedback_smoke_test_vendor_truth_not_shape_match: fixtures
mirror real Gitea 1.22.6 semantics, not yaml-parser quirks)
15/15 tests pass locally. Lint exits 1 against current .gitea/workflows/
because of the 3 existing rule-2 violations above; that is the gate
working as intended (and continue-on-error keeps the PR-status soft
until the violations are migrated).
414 lines
12 KiB
Python
414 lines
12 KiB
Python
"""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}"
|
|
)
|