Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .github/workflows/ci-scope.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
name: CI change scope
on:
workflow_call:
outputs:
runtime:
description: Whether runtime and image checks are needed
value: ${{ jobs.scope.outputs.runtime }}
permissions:
contents: read
jobs:
scope:
runs-on: ubuntu-26.04
timeout-minutes: 5
outputs:
runtime: ${{ steps.scope.outputs.runtime }}
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
fetch-depth: 0
- name: Test change classification
run: python3 -m unittest discover -s tests/ci -v
- name: Classify changed files
id: scope
env:
CI_EVENT: ${{ github.event_name }}
CI_BASE: ${{ github.event.pull_request.base.sha || github.event.before }}
CI_HEAD: ${{ github.event.pull_request.head.sha || github.sha }}
run: python3 scripts/ci-change-scope.py
125 changes: 79 additions & 46 deletions .github/workflows/docker-image.yml

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions .github/workflows/host-artifacts.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@ on:
permissions:
contents: read
jobs:
changes:
uses: ./.github/workflows/ci-scope.yml

lifecycle:
needs: [changes]
if: ${{ needs.changes.outputs.runtime == 'true' }}
strategy:
fail-fast: false
matrix:
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/quadlet.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,12 @@ permissions:
contents: read

jobs:
changes:
uses: ./.github/workflows/ci-scope.yml

lifecycle:
needs: [changes]
if: ${{ needs.changes.outputs.runtime == 'true' }}
runs-on: ubuntu-26.04
timeout-minutes: 25
strategy:
Expand Down
8 changes: 7 additions & 1 deletion .github/workflows/sample-apps.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,12 @@ permissions:
contents: read

jobs:
changes:
uses: ./.github/workflows/ci-scope.yml

samples:
needs: [changes]
if: ${{ needs.changes.outputs.runtime == 'true' }}
strategy:
fail-fast: false
matrix:
Expand Down Expand Up @@ -63,7 +68,8 @@ jobs:
sudo journalctl -u podman.service --no-pager -n 100

assemble:
needs: samples
needs: [changes, samples]
if: ${{ needs.changes.outputs.runtime == 'true' }}
runs-on: ubuntu-26.04
timeout-minutes: 15
steps:
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/sops.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@ on:
permissions:
contents: read
jobs:
changes:
uses: ./.github/workflows/ci-scope.yml

lifecycle:
needs: [changes]
if: ${{ needs.changes.outputs.runtime == 'true' }}
runs-on: ubuntu-26.04
timeout-minutes: 25
strategy:
Expand Down
7 changes: 7 additions & 0 deletions .github/workflows/unit-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,12 @@ permissions:
contents: read

jobs:
changes:
uses: ./.github/workflows/ci-scope.yml

test:
needs: [changes]
if: ${{ needs.changes.outputs.runtime == 'true' }}
runs-on: ubuntu-26.04
env:
CGO_ENABLED: '1'
Expand Down Expand Up @@ -39,6 +44,8 @@ jobs:
run: go test -race -mod=readonly -tags "$BUILDTAGS" ./pkg/engine -run 'TestStatus|TestMirror|TestCommonGit|TestHelperContainer|TestRetirement|TestConfigReloadCan|TestRollback|TestDisconnected|TestFileTransfer'

fedora:
needs: [changes]
if: ${{ needs.changes.outputs.runtime == 'true' }}
runs-on: ubuntu-26.04
container: quay.io/fedora/fedora:44
env:
Expand Down
12 changes: 12 additions & 0 deletions docs/release_notes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,18 @@ Release notes
Unreleased
----------

Documentation-only CI
~~~~~~~~~~~~~~~~~~~~~~

Docs-only pull requests and main pushes now skip Go, image-build, and Podman
runtime jobs. A shared lightweight change check classifies ``docs/**``, root
Markdown files, and ``.readthedocs.yml``/``.readthedocs.yaml`` as documentation.
The strict Sphinx job continues to run. Code, examples, dependency files, scripts,
workflow/action changes, and unknown paths keep the full runtime checks, including
mixed code/docs changes. Scheduled/manual sample checks still run. Missing Git
history conservatively runs tests. Job-level skips preserve check results without
workflow-level path filtering.

Trailing slashes in Git target paths
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Expand Down
42 changes: 42 additions & 0 deletions scripts/ci-change-scope.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
#!/usr/bin/env python3
"""Conservatively decide whether a GitHub event needs runtime/image jobs."""
import os
import subprocess


def documentation_only(paths):
return bool(paths) and all(
path.startswith("docs/")
or path in {".readthedocs.yml", ".readthedocs.yaml"}
or ("/" not in path and path.endswith(".md"))
for path in paths
)


def runtime_required(event, base, head):
if event not in {"pull_request", "push"} or not base or not head:
return True
try:
if event == "pull_request":
base = subprocess.check_output(
["git", "merge-base", base, head], text=True
).strip()
paths = subprocess.check_output(
["git", "diff", "--name-only", "--no-renames", "-z", base, head]
).decode("utf-8", errors="surrogateescape").split("\0")
except subprocess.CalledProcessError:
# Missing history or an unresolvable push baseline must not skip tests.
return True
return not documentation_only([path for path in paths if path])


if __name__ == "__main__":
runtime = runtime_required(
os.environ.get("CI_EVENT", ""),
os.environ.get("CI_BASE", ""),
os.environ.get("CI_HEAD", ""),
)
result = "true" if runtime else "false"
print(f"Runtime/image checks required: {result}")
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output:
output.write(f"runtime={result}\n")
83 changes: 83 additions & 0 deletions tests/ci/test_change_scope.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import importlib.util
from pathlib import Path
import subprocess
import os
import tempfile
import unittest
from unittest.mock import patch

spec = importlib.util.spec_from_file_location(
"scope", Path(__file__).resolve().parents[2] / "scripts/ci-change-scope.py"
)
scope = importlib.util.module_from_spec(spec)
spec.loader.exec_module(scope)


class ChangeScopeTests(unittest.TestCase):
def test_documentation(self):
self.assertTrue(scope.documentation_only([
"docs/methods.rst", "docs/conf.py", "README.md", ".readthedocs.yml"
]))

def test_runtime_inputs_and_mixed_changes(self):
for path in ["pkg/engine/kube.go", "go.mod", "go.sum", "Dockerfile",
"examples/readme.md", "examples/kube/pod.yaml", "scripts/build.sh",
".github/workflows/docs.yml", ".github/actions/setup-podman/action.yml",
"tests/ci/test_change_scope.py", "unknown-file"]:
with self.subTest(path=path):
self.assertFalse(scope.documentation_only(["docs/index.rst", path]))
self.assertFalse(scope.documentation_only([]))

def test_manual_scheduled_and_missing_history_run(self):
for event in ["schedule", "workflow_dispatch", "unknown"]:
self.assertTrue(scope.runtime_required(event, "base", "head"))
self.assertTrue(scope.runtime_required("push", "", "head"))
with patch.object(scope.subprocess, "check_output", side_effect=subprocess.CalledProcessError(1, "git")):
self.assertTrue(scope.runtime_required("push", "missing", "head"))

def test_null_delimiters_and_renames(self):
with patch.object(scope.subprocess, "check_output", return_value=b"docs/a\nb.rst\0README.md\0") as command:
self.assertFalse(scope.runtime_required("push", "base", "head"))
self.assertIn("--no-renames", command.call_args.args[0])
# Moving a runtime file into docs must still exercise runtime checks.
with patch.object(scope.subprocess, "check_output", return_value=b"pkg/old.go\0docs/old.go\0"):
self.assertTrue(scope.runtime_required("push", "base", "head"))

def test_real_git_diff_docs_mixed_and_code_to_docs_rename(self):
with tempfile.TemporaryDirectory() as directory:
previous = os.getcwd()
try:
os.chdir(directory)
def git(*args):
return subprocess.check_output(["git", *args], stderr=subprocess.DEVNULL, text=True).strip()
git("init")
git("config", "user.name", "CI test")
git("config", "user.email", "ci@example.invalid")
Path("docs").mkdir()
Path("docs/index.rst").write_text("before")
Path("runtime.go").write_text("package engine")
git("add", ".")
git("commit", "-m", "base")
base = git("rev-parse", "HEAD")
Path("docs/index.rst").write_text("after")
git("add", ".")
git("commit", "-m", "docs")
docs = git("rev-parse", "HEAD")
self.assertFalse(scope.runtime_required("push", base, docs))
self.assertFalse(scope.runtime_required("pull_request", base, docs))
git("mv", "runtime.go", "docs/runtime.go")
git("commit", "-m", "move runtime into docs")
self.assertTrue(scope.runtime_required("push", docs, git("rev-parse", "HEAD")))
self.assertTrue(scope.runtime_required("pull_request", base, git("rev-parse", "HEAD")))
finally:
os.chdir(previous)

def test_pull_request_uses_merge_base(self):
with patch.object(scope.subprocess, "check_output", side_effect=["ancestor\n", b"docs/index.rst\0"]) as command:
self.assertFalse(scope.runtime_required("pull_request", "base", "head"))
self.assertEqual(command.call_args_list[0].args[0], ["git", "merge-base", "base", "head"])
self.assertEqual(command.call_args_list[1].args[0][-2:], ["ancestor", "head"])


if __name__ == "__main__":
unittest.main()
Loading