fix(ci): publish images after large mixed diffs (#13011)

Quiet grep exits on the first image-visible path. Under pipefail,
echo can then fail with SIGPIPE and the gate incorrectly skips images.

Consume the full path list without changing the exclusion rules.
Test the actual workflow gate with disposable Git history, including
large mixed diffs, and run the regression in the lightweight lint job.

Assisted-by: nib:gpt-6.1-sol

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
mudler-agentandEttore Di Giacinto authored and GitHub committed 2026-11-01 15:43:32 +01:00
1 parent 968c6651ae
commit 9bd5d0f915
3 files changed
+163 -1

No files matched your search

+157
View File
@@ -0,0 +1,157 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MIT
"""Run the image workflow's real publication gate with disposable git history."""
import os
from pathlib import Path
import re
import subprocess
import tempfile
import textwrap
import unittest
WORKFLOW = Path(__file__).resolve().parents[1] / "workflows" / "image.yml"
def gate_script():
# This deliberately supports only the workflow's block-scalar layout, not
# arbitrary YAML. Fail closed if the job/step becomes ambiguous or moves.
lines = WORKFLOW.read_text().splitlines()
jobs = [i for i, line in enumerate(lines) if re.fullmatch(r" +changes:", line)]
if len(jobs) != 1:
raise AssertionError("expected one changes job")
start = jobs[0]
indent = len(lines[start]) - len(lines[start].lstrip())
end = next((i for i in range(start + 1, len(lines))
if lines[i].strip() and not lines[i].lstrip().startswith("#")
and len(lines[i]) - len(lines[i].lstrip()) <= indent), len(lines))
job = lines[start + 1:end]
steps = [i for i, line in enumerate(job)
if re.fullmatch(r" +- id: decide", line)]
if len(steps) != 1:
raise AssertionError("expected one changes -> decide step")
start = steps[0]
indent = len(job[start]) - len(job[start].lstrip())
end = next((i for i in range(start + 1, len(job))
if job[i].strip() and not job[i].lstrip().startswith("#")
and len(job[i]) - len(job[i].lstrip()) <= indent), len(job))
step = job[start + 1:end]
runs = [i for i, line in enumerate(step) if re.fullmatch(r" +run: \|", line)]
if len(runs) != 1:
raise AssertionError("expected one literal run block in changes -> decide")
start = runs[0]
indent = len(step[start]) - len(step[start].lstrip())
end = next((i for i in range(start + 1, len(step))
if step[i].strip()
and len(step[i]) - len(step[i].lstrip()) <= indent), len(step))
script = textwrap.dedent("\n".join(step[start + 1:end]))
if not script.strip():
raise AssertionError("empty changes -> decide run block")
return script + "\n"
class ImagePublicationGate(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.script = gate_script()
def setUp(self):
self.tmp = tempfile.TemporaryDirectory(prefix="image-publication-gate-")
self.addCleanup(self.tmp.cleanup)
self.repo = Path(self.tmp.name) / "repo"
self.repo.mkdir()
self.git("init", "--quiet")
self.git("config", "user.name", "Gate Test")
self.git("config", "user.email", "gate-test@example.invalid")
self.git("config", "commit.gpgsign", "false")
self.git("commit", "--quiet", "--allow-empty", "-m", "base")
self.before = self.git("rev-parse", "HEAD").strip()
self.after = self.before
def git(self, *args):
return subprocess.run(
["git", *args], cwd=self.repo, check=True, text=True,
stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=30,
).stdout
def change(self, paths):
for name in paths:
path = self.repo / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("fixture\n")
self.git("add", ".")
self.git("commit", "--quiet", "-m", "changes")
self.after = self.git("rev-parse", "HEAD").strip()
def assert_gate(self, expected, before=None, ref="refs/heads/master"):
output = Path(self.tmp.name) / "output"
output.write_text("")
env = dict(os.environ, BEFORE=self.before if before is None else before,
AFTER=self.after, GITHUB_REF=ref, GITHUB_OUTPUT=str(output),
LC_ALL="C")
result = subprocess.run(
["bash", "-e", "-o", "pipefail", "-c", self.script],
cwd=self.repo, env=env, text=True, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, timeout=30,
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(output.read_text(), f"build={str(expected).lower()}\n",
result.stdout[-250:] + result.stderr)
def test_small_code(self):
self.change(["core/main.go"])
self.assert_gate(True)
def test_small_mixed(self):
self.change(["core/main.go", "docs/guide.txt", "README.md"])
self.assert_gate(True)
def test_excluded_classes(self):
for path in ["docs/guide.txt", "gallery/model.yaml", "examples/demo.py",
"README.md", "core/nested/notes.md"]:
with self.subTest(path=path):
self.before = self.after
self.change([path])
self.assert_gate(False)
def large_paths(self):
paths = [f"docs/guide-{i:05d}-{'x' * 55}.txt" for i in range(3500)]
self.assertGreater(len(paths), 2869)
self.assertGreater(len(("\n".join(paths) + "\n").encode()), 140000)
return paths
def test_large_mixed_early_match_repeated(self):
# Sorting puts the code match first, with far more than a pipe buffer
# behind it. Repeat to guard against scheduling-dependent SIGPIPE.
self.change(["aaa-code.go", *self.large_paths()])
diff = self.git("diff", "--name-only", self.before, self.after)
self.assertTrue(diff.startswith("aaa-code.go\n"))
self.assertGreater(len(diff.encode()), 140000)
self.assertGreater(len(diff.splitlines()), 2869)
for attempt in range(8):
with self.subTest(attempt=attempt):
self.assert_gate(True)
def test_large_excluded_only(self):
self.change(self.large_paths())
self.assert_gate(False)
def test_empty_diff(self):
self.assert_gate(True)
def test_missing_zero_unresolvable_base(self):
self.change(["docs/guide.txt"])
for before in ["", "0" * 40, "f" * 40]:
with self.subTest(before=before):
self.assert_gate(True, before=before)
def test_tag_push(self):
self.change(["docs/guide.txt"])
self.assert_gate(True, ref="refs/tags/v-test")
self.assert_gate(True, before="", ref="refs/tags/v-test")
self.assert_gate(True, before=self.after, ref="refs/tags/v-test")
if __name__ == "__main__":
unittest.main(verbosity=2)
+2 -1
View File
@@ -52,7 +52,8 @@
files="$(git diff --name-only "${BEFORE}" "${AFTER}")"
echo "changed files:"; echo "${files:-<none>}"
[ -z "${files}" ] && emit true "empty diff: building every image"
if echo "${files}" | grep -qvE '^(gallery/|docs/|examples/)|\.md$'; then
# Consume every path so an early match cannot SIGPIPE echo under pipefail.
if echo "${files}" | grep -vE '^(gallery/|docs/|examples/)|\.md$' >/dev/null; then
emit true "push touches image-visible content: building"
fi
emit false "only gallery/docs/markdown changed: images identical, skipping"
+4
View File
@@ -125,6 +125,10 @@ jobs:
- name: run CI script tests
run: make test-ci-scripts
# Exercise the actual publication gate without building any images.
- name: test image publication gate
run: python3 .github/ci/test-image-publication-gate.py
# The shared python backend helpers (Options[] parsing, engine-arg
# mapping, model reference resolution) are stdlib-only, so their tests
# ride along here instead of waiting on a multi-GB backend image build.