mirror of
https://github.com/spantaleev/matrix-docker-ansible-deploy.git
synced 2026-09-19 06:10:10 +00:00
Select Molecule scenarios by shared dependency usage
This commit is contained in:
@@ -111,7 +111,7 @@
|
||||
"automergeType": "branch"
|
||||
},
|
||||
{
|
||||
"description": "Molecule's own dependencies merge via branch push (no PR, no email). Anything under molecule-shared/ - the helper container images, Postgres, and the Python pins - triggers the Molecule workflow, and a change to a shared file makes it run every scenario, so an update only merges once the whole suite has passed on it. A failure surfaces as a PR instead. This is how a new Postgres major reaches us: the bump runs every scenario against it before anything is merged.",
|
||||
"description": "Molecule's own dependencies merge via branch push (no PR, no email). Shared helper image updates run their consuming scenarios, including indirect consumers through shared tasks. Shared Python and Ansible requirements and workflow changes run every scenario. An update only merges once the selected checks pass; a failure surfaces as a PR instead. A new Postgres major is exercised by every Postgres-consuming scenario before merging.",
|
||||
"matchFileNames": [
|
||||
"molecule-shared/**",
|
||||
".github/workflows/molecule.yml"
|
||||
|
||||
@@ -16,11 +16,15 @@ on: # yamllint disable-line rule:truthy
|
||||
- "roles/custom/**"
|
||||
- "molecule-shared/**"
|
||||
- ".github/workflows/molecule.yml"
|
||||
- "bin/molecule-select-roles.py"
|
||||
- "bin/test-molecule-select-roles.py"
|
||||
pull_request:
|
||||
paths:
|
||||
- "roles/custom/**"
|
||||
- "molecule-shared/**"
|
||||
- ".github/workflows/molecule.yml"
|
||||
- "bin/molecule-select-roles.py"
|
||||
- "bin/test-molecule-select-roles.py"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
role:
|
||||
@@ -52,6 +56,9 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Test scenario selection
|
||||
run: python3 bin/test-molecule-select-roles.py
|
||||
|
||||
- name: Detect roles with a Molecule scenario that this change touches
|
||||
id: detect
|
||||
env:
|
||||
@@ -60,77 +67,7 @@ jobs:
|
||||
BEFORE_SHA: ${{ github.event.before }}
|
||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
INPUT_ROLE: ${{ inputs.role }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
have_scenario() {
|
||||
[ -f "roles/custom/$1/molecule/default/molecule.yml" ]
|
||||
}
|
||||
|
||||
# An explicit request through workflow_dispatch wins over detection.
|
||||
if [ -n "${INPUT_ROLE}" ]; then
|
||||
if have_scenario "${INPUT_ROLE}"; then
|
||||
printf 'roles=["%s"]\n' "${INPUT_ROLE}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "No scenario at roles/custom/${INPUT_ROLE}/molecule/default" >&2
|
||||
exit 1
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# New branches have an all-zero before SHA. Compare them with their
|
||||
# common ancestor with the default branch, so the first Renovate push
|
||||
# tests only the roles it changes. The same fallback handles a force
|
||||
# push whose previous commit is no longer available locally.
|
||||
# Manual runs without a role, and pushes with no usable comparison,
|
||||
# still consider every scenario.
|
||||
base=""
|
||||
case "${EVENT_NAME}" in
|
||||
pull_request) base="${BASE_SHA}" ;;
|
||||
push)
|
||||
if [ -n "${BEFORE_SHA}" ] && [ "${BEFORE_SHA}" != "0000000000000000000000000000000000000000" ] \
|
||||
&& git cat-file -e "${BEFORE_SHA}^{commit}" 2>/dev/null; then
|
||||
base="${BEFORE_SHA}"
|
||||
elif [ -n "${DEFAULT_BRANCH}" ] && [ "${GITHUB_REF}" != "refs/heads/${DEFAULT_BRANCH}" ]; then
|
||||
base="$(git merge-base HEAD "refs/remotes/origin/${DEFAULT_BRANCH}" || true)"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
# molecule-shared/ is used by every scenario, so a change there means
|
||||
# every role has to run, not just the ones whose own files moved.
|
||||
shared_changed=""
|
||||
if [ -n "${base}" ]; then
|
||||
shared_changed="$(git diff --name-only "${base}" HEAD -- 'molecule-shared/*' '.github/workflows/molecule.yml' || true)"
|
||||
fi
|
||||
|
||||
if [ -n "${base}" ] && [ -z "${shared_changed}" ]; then
|
||||
changed="$(git diff --name-only "${base}" HEAD -- 'roles/custom/*' || true)"
|
||||
candidates="$(printf '%s\n' "${changed}" | awk -F/ 'NF>2 {print $3}' | sort -u)"
|
||||
echo "Changed roles: ${candidates:-none}"
|
||||
else
|
||||
if [ -n "${shared_changed}" ]; then
|
||||
echo "Shared Molecule files changed; considering every role"
|
||||
fi
|
||||
candidates="$(find roles/custom -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | sort)"
|
||||
fi
|
||||
|
||||
selected=""
|
||||
for role in ${candidates}; do
|
||||
if have_scenario "${role}"; then
|
||||
selected="${selected} ${role}"
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "${selected}" ]; then
|
||||
echo "Nothing to test"
|
||||
echo 'roles=[]' >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Testing:${selected}"
|
||||
json="$(printf '%s\n' ${selected} | jq -R . | jq -c -s .)"
|
||||
echo "roles=${json}" >> "$GITHUB_OUTPUT"
|
||||
run: python3 bin/molecule-select-roles.py
|
||||
|
||||
molecule:
|
||||
name: "Molecule: ${{ matrix.role }}"
|
||||
|
||||
@@ -44,3 +44,9 @@ repos:
|
||||
language: script
|
||||
files: '(\.github/renovate\.json|bin/check-molecule-automerge-list\.py|roles/custom/[^/]+/molecule/default/molecule\.yml)'
|
||||
pass_filenames: false
|
||||
- id: test-molecule-select-roles
|
||||
name: Test Molecule scenario selection
|
||||
entry: python3 bin/test-molecule-select-roles.py
|
||||
language: system
|
||||
files: '(\.github/workflows/molecule\.yml|bin/(test-)?molecule-select-roles\.py)'
|
||||
pass_filenames: false
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
# SPDX-FileCopyrightText: 2026 Slavi Pantaleev
|
||||
#
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
"""Select Molecule scenarios affected by a Git comparison, using only the stdlib.
|
||||
|
||||
Shared dependencies use literal molecule-shared/... paths and image variable names.
|
||||
Follow these references transitively, in both revisions. This is deliberately not an
|
||||
Ansible interpreter: unfamiliar image-pin syntax or unresolved dependencies run all
|
||||
scenarios instead of risking an incomplete automerge gate.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import posixpath
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
IMAGE_VARS = "molecule-shared/vars.yml"
|
||||
GLOBAL_FILES = {
|
||||
"molecule-shared/requirements.txt",
|
||||
"molecule-shared/requirements.yml",
|
||||
"molecule-shared/playbook-context.yml",
|
||||
".github/workflows/molecule.yml",
|
||||
"bin/molecule-select-roles.py",
|
||||
"bin/test-molecule-select-roles.py",
|
||||
}
|
||||
SCENARIO = re.compile(r"roles/custom/([^/]+)/molecule/default/molecule\.yml$")
|
||||
SHARED_PATH = re.compile(r"molecule-shared/[\w./-]+")
|
||||
IMAGE_NAME = re.compile(r"\bmolecule_shared_image_\w+\b")
|
||||
IMAGE_PIN = re.compile(r"(molecule_shared_image_\w+):\s*(['\"])([\w./:@+-]+)\2\s*(?:#.*)?$")
|
||||
|
||||
|
||||
class Uncertain(Exception):
|
||||
"""The change cannot safely be narrowed to particular scenarios."""
|
||||
|
||||
|
||||
class Git:
|
||||
def __init__(self, root):
|
||||
self.root = root
|
||||
|
||||
def run(self, *args, input=None):
|
||||
return subprocess.run(
|
||||
["git", *args], cwd=self.root, input=input, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE, check=True,
|
||||
).stdout
|
||||
|
||||
def commit(self, ref):
|
||||
return self.run("rev-parse", "--verify", "--end-of-options", f"{ref}^{{commit}}").decode().strip()
|
||||
|
||||
def snapshot(self, ref):
|
||||
entries = {}
|
||||
for entry in self.run("ls-tree", "-rz", ref, "--", "roles/custom", "molecule-shared").split(b"\0"):
|
||||
if not entry:
|
||||
continue
|
||||
metadata, path = entry.decode().split("\t", 1)
|
||||
mode, kind, oid = metadata.split()
|
||||
if path.startswith("molecule-shared/") or "/molecule/" in path:
|
||||
if kind != "blob":
|
||||
raise Uncertain(f"Unsupported Git entry: {path}")
|
||||
entries[path] = (mode, oid)
|
||||
|
||||
# Read the blobs in one process; a subprocess per scenario file is slow.
|
||||
oids = list(dict.fromkeys(oid for mode, oid in entries.values()))
|
||||
data = self.run("cat-file", "--batch", input="".join(f"{oid}\n" for oid in oids).encode())
|
||||
blobs = {}
|
||||
offset = 0
|
||||
for oid in oids:
|
||||
end = data.index(b"\n", offset)
|
||||
size = int(data[offset:end].split()[2])
|
||||
blobs[oid] = data[end + 1:end + 1 + size].decode()
|
||||
offset = end + size + 2
|
||||
return {path: (mode, blobs[oid]) for path, (mode, oid) in entries.items()}
|
||||
|
||||
|
||||
def comparison_base(git, head, env):
|
||||
"""Preserve push/PR comparison semantics, including new Renovate branches."""
|
||||
try:
|
||||
if env.get("EVENT_NAME") == "pull_request":
|
||||
return git.commit(env["BASE_SHA"])
|
||||
if env.get("EVENT_NAME") == "push":
|
||||
before = env.get("BEFORE_SHA", "")
|
||||
if before and set(before) != {"0"}:
|
||||
try:
|
||||
return git.commit(before)
|
||||
except subprocess.CalledProcessError:
|
||||
pass
|
||||
default = env.get("DEFAULT_BRANCH", "")
|
||||
if default and env.get("GITHUB_REF") != f"refs/heads/{default}":
|
||||
return git.run("merge-base", head, f"refs/remotes/origin/{default}").decode().strip()
|
||||
except (KeyError, subprocess.CalledProcessError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def image_pins(snapshot):
|
||||
"""Accept only flat, quoted literal image pins; other YAML runs all scenarios."""
|
||||
if IMAGE_VARS not in snapshot or snapshot[IMAGE_VARS][0] == "120000":
|
||||
raise Uncertain("Missing or symlinked shared image pins")
|
||||
pins = {}
|
||||
document_started = False
|
||||
for line in snapshot[IMAGE_VARS][1].splitlines():
|
||||
if not line.strip() or line.startswith("#"):
|
||||
continue
|
||||
if line == "---" and not pins and not document_started:
|
||||
document_started = True
|
||||
continue
|
||||
match = IMAGE_PIN.fullmatch(line)
|
||||
if not match or match[1] in pins:
|
||||
raise Uncertain("Unrecognized shared image-pin format")
|
||||
pins[match[1]] = match[3]
|
||||
if not pins:
|
||||
raise Uncertain("No shared image pins")
|
||||
return pins
|
||||
|
||||
|
||||
def dependencies(snapshot, role):
|
||||
"""Return file and variable dependencies, following shared files and symlinks.
|
||||
|
||||
Scan all scenario files, including nested tasks and fixtures. Ignore full-line
|
||||
comments. Do not scan vars.yml's definitions: loading the mapping does not
|
||||
mean a scenario uses every image in it.
|
||||
"""
|
||||
pending = [path for path in snapshot if path.startswith(f"roles/custom/{role}/molecule/")]
|
||||
files, variables = set(), set()
|
||||
while pending:
|
||||
path = pending.pop()
|
||||
if path in files:
|
||||
continue
|
||||
files.add(path)
|
||||
if path not in snapshot:
|
||||
raise Uncertain(f"Unresolved dependency: {path}")
|
||||
mode, content = snapshot[path]
|
||||
if mode == "120000":
|
||||
pending.append(posixpath.normpath(posixpath.join(posixpath.dirname(path), content)))
|
||||
continue
|
||||
if path == IMAGE_VARS:
|
||||
continue
|
||||
content = "\n".join(line for line in content.splitlines() if not line.lstrip().startswith("#"))
|
||||
references = SHARED_PATH.findall(content)
|
||||
if content.count("molecule-shared/") != len(references):
|
||||
raise Uncertain(f"Nonliteral shared dependency in {path}")
|
||||
pending.extend(references)
|
||||
names = IMAGE_NAME.findall(content)
|
||||
if content.count("molecule_shared_image_") != len(names):
|
||||
raise Uncertain(f"Nonliteral shared image variable in {path}")
|
||||
variables.update(names)
|
||||
return files, variables
|
||||
|
||||
|
||||
def select_roles(git, base, head="HEAD", role=""):
|
||||
# Failure to enumerate the head must fail the job, never report an empty gate.
|
||||
paths = git.run("ls-tree", "-rz", "--name-only", head, "--", "roles/custom").decode().split("\0")
|
||||
available = {match[1] for path in paths if (match := SCENARIO.fullmatch(path))}
|
||||
if role:
|
||||
if role not in available:
|
||||
raise ValueError(f"No scenario at roles/custom/{role}/molecule/default")
|
||||
return [role]
|
||||
if not base:
|
||||
print("No comparison requested or available; testing every scenario", file=sys.stderr)
|
||||
return sorted(available)
|
||||
|
||||
try:
|
||||
# Disable rename detection so both old and new paths contribute consumers.
|
||||
diff = git.run("diff", "--no-renames", "--name-only", "-z", base, head, "--")
|
||||
changed = set(diff.decode().split("\0")) - {""}
|
||||
if changed & GLOBAL_FILES:
|
||||
raise Uncertain("Test infrastructure changed: " + ", ".join(sorted(changed & GLOBAL_FILES)))
|
||||
selected = {path.split("/")[2] for path in changed if path.startswith("roles/custom/")}
|
||||
shared = {path for path in changed if path.startswith("molecule-shared/")}
|
||||
if shared:
|
||||
snapshots = [git.snapshot(base), git.snapshot(head)]
|
||||
changed_images = set()
|
||||
if IMAGE_VARS in shared:
|
||||
old, new = map(image_pins, snapshots)
|
||||
if old.keys() != new.keys():
|
||||
raise Uncertain("Shared image variables added or removed")
|
||||
changed_images = {key for key in old if old[key] != new[key]}
|
||||
shared.remove(IMAGE_VARS)
|
||||
|
||||
if shared or changed_images:
|
||||
consumers = {dependency: set() for dependency in shared | changed_images}
|
||||
for snapshot in snapshots:
|
||||
for candidate in available:
|
||||
files, variables = dependencies(snapshot, candidate)
|
||||
for dependency in consumers.keys() & (files | variables):
|
||||
consumers[dependency].add(candidate)
|
||||
for dependency, roles in sorted(consumers.items()):
|
||||
if not roles:
|
||||
raise Uncertain(f"No known consumers for {dependency}")
|
||||
print(f"{dependency}: {len(roles)} scenario(s)", file=sys.stderr)
|
||||
selected.update(roles)
|
||||
return sorted(selected & available)
|
||||
except (Uncertain, subprocess.CalledProcessError, UnicodeError) as exc:
|
||||
print(f"Testing every scenario: {exc}", file=sys.stderr)
|
||||
return sorted(available)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--base", help="Compare against this revision; otherwise use GitHub event variables")
|
||||
parser.add_argument("--head", default="HEAD", help="Revision to test (default: HEAD)")
|
||||
parser.add_argument("--role", default=os.environ.get("INPUT_ROLE", ""), help="Run one named role")
|
||||
args = parser.parse_args()
|
||||
git = Git(Path.cwd())
|
||||
base = args.base if args.base is not None else comparison_base(git, args.head, os.environ)
|
||||
roles = select_roles(git, base, args.head, args.role)
|
||||
result = json.dumps(roles, separators=(",", ":"))
|
||||
print(result)
|
||||
if output := os.environ.get("GITHUB_OUTPUT"):
|
||||
with open(output, "a") as stream:
|
||||
stream.write(f"roles={result}\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,246 @@
|
||||
# SPDX-FileCopyrightText: 2026 Slavi Pantaleev
|
||||
#
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
"""Regression tests for the Molecule CI matrix; no Docker or Python packages needed."""
|
||||
|
||||
import contextlib
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
# Importing the script should not leave __pycache__ in the repository.
|
||||
sys.dont_write_bytecode = True
|
||||
SCRIPT = Path(__file__).with_name("molecule-select-roles.py")
|
||||
SPEC = importlib.util.spec_from_file_location("selector", SCRIPT)
|
||||
selector = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(selector)
|
||||
|
||||
|
||||
class SelectionTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.root = Path(self.temp.name)
|
||||
self.git = selector.Git(self.root)
|
||||
self.git.run("init", "--quiet", "--initial-branch=main")
|
||||
self.git.run("config", "user.name", "Molecule selector test")
|
||||
self.git.run("config", "user.email", "test@example.com")
|
||||
self.git.run("config", "commit.gpgsign", "false")
|
||||
self.roles = ["matrix-database", "matrix-livekit", "matrix-web"]
|
||||
self.write(selector.IMAGE_VARS, '\n'.join([
|
||||
'---',
|
||||
'molecule_shared_image_curl: "curl:1"',
|
||||
'molecule_shared_image_postgres: "postgres:1"',
|
||||
'molecule_shared_image_livekit: "livekit:1"',
|
||||
]) + '\n')
|
||||
self.write("molecule-shared/requirements.yml", "roles: []\n")
|
||||
self.write("molecule-shared/tasks/postgres.yml", '{{ molecule_shared_image_postgres }}\n')
|
||||
self.write("molecule-shared/tasks/probe.yml", "molecule-shared/probe.py\n")
|
||||
self.write("molecule-shared/probe.py", "print('probe')\n")
|
||||
for role in self.roles:
|
||||
self.write(f"roles/custom/{role}/molecule/default/molecule.yml", "---\n")
|
||||
self.scenario(role, "verify.yml", '{{ molecule_shared_image_curl }}\n')
|
||||
self.scenario(role, "prepare.yml", "molecule-shared/vars.yml\n")
|
||||
self.path(f"roles/custom/{role}/molecule/default/requirements.yml").symlink_to(
|
||||
"../../../../../molecule-shared/requirements.yml"
|
||||
)
|
||||
self.scenario("matrix-database", "prepare.yml", "molecule-shared/vars.yml\nmolecule-shared/tasks/postgres.yml\n")
|
||||
self.scenario("matrix-livekit", "tasks/sfu.yml", '{{ molecule_shared_image_livekit }}\n')
|
||||
self.scenario("matrix-web", "tasks/probe.yml", "molecule-shared/tasks/probe.yml\n")
|
||||
self.base = self.commit()
|
||||
self.git.run("update-ref", "refs/remotes/origin/main", self.base)
|
||||
|
||||
def path(self, name):
|
||||
return self.root / name
|
||||
|
||||
def write(self, name, content):
|
||||
path = self.path(name)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content)
|
||||
|
||||
def scenario(self, role, name, content):
|
||||
self.write(f"roles/custom/{role}/molecule/default/{name}", content)
|
||||
|
||||
def commit(self):
|
||||
self.git.run("add", "--all")
|
||||
self.git.run("commit", "--quiet", "--allow-empty", "-m", "Test fixture")
|
||||
return self.git.commit("HEAD")
|
||||
|
||||
def bump(self, image):
|
||||
path = self.path(selector.IMAGE_VARS)
|
||||
path.write_text(path.read_text().replace(f'"{image}:1"', f'"{image}:2"'))
|
||||
|
||||
def selected(self, base=None):
|
||||
self.commit()
|
||||
with contextlib.redirect_stderr(io.StringIO()):
|
||||
return selector.select_roles(self.git, base or self.base)
|
||||
|
||||
def test_livekit_bump_only_runs_its_consumer(self):
|
||||
self.bump("livekit")
|
||||
self.assertEqual(self.selected(), ["matrix-livekit"])
|
||||
|
||||
def test_postgres_bump_follows_shared_task(self):
|
||||
self.bump("postgres")
|
||||
self.assertEqual(self.selected(), ["matrix-database"])
|
||||
|
||||
def test_curl_bump_runs_every_consumer(self):
|
||||
self.bump("curl")
|
||||
self.assertEqual(self.selected(), self.roles)
|
||||
|
||||
def test_multiple_image_bumps_union_with_direct_role_changes(self):
|
||||
self.bump("livekit")
|
||||
self.bump("postgres")
|
||||
self.write("roles/custom/matrix-web/defaults/main.yml", "version: 2\n")
|
||||
self.assertEqual(self.selected(), self.roles)
|
||||
|
||||
def test_direct_changes_ignore_roles_without_scenarios(self):
|
||||
self.write("roles/custom/matrix-web/defaults/main.yml", "version: 2\n")
|
||||
self.write("roles/custom/matrix-untested/defaults/main.yml", "version: 2\n")
|
||||
self.assertEqual(self.selected(), ["matrix-web"])
|
||||
|
||||
def test_documentation_change_runs_nothing(self):
|
||||
self.write("docs/example.md", "Documentation\n")
|
||||
self.assertEqual(self.selected(), [])
|
||||
|
||||
def test_image_pin_comments_and_quoting_do_not_run_scenarios(self):
|
||||
path = self.path(selector.IMAGE_VARS)
|
||||
path.write_text("# New comment\n" + path.read_text().replace('"', "'"))
|
||||
self.assertEqual(self.selected(), [])
|
||||
|
||||
def test_shared_task_change_only_runs_consumers(self):
|
||||
self.write("molecule-shared/tasks/postgres.yml", '{{ molecule_shared_image_postgres }}\n# Changed\n')
|
||||
self.assertEqual(self.selected(), ["matrix-database"])
|
||||
|
||||
def test_shared_fixture_change_follows_nested_references(self):
|
||||
self.write("molecule-shared/probe.py", "print('updated probe')\n")
|
||||
self.assertEqual(self.selected(), ["matrix-web"])
|
||||
|
||||
def test_deleted_helper_uses_previous_consumers(self):
|
||||
self.path("molecule-shared/probe.py").unlink()
|
||||
self.write("molecule-shared/tasks/probe.yml", "# Probe removed\n")
|
||||
self.assertEqual(self.selected(), ["matrix-web"])
|
||||
|
||||
def test_renamed_helper_unions_previous_and_current_consumers(self):
|
||||
self.path("molecule-shared/probe.py").rename(self.path("molecule-shared/new-probe.py"))
|
||||
self.write("molecule-shared/tasks/probe.yml", "# Probe moved\n")
|
||||
self.scenario("matrix-livekit", "tasks/new-probe.yml", "molecule-shared/new-probe.py\n")
|
||||
self.assertEqual(self.selected(), ["matrix-livekit", "matrix-web"])
|
||||
|
||||
def test_deleted_role_is_not_selected(self):
|
||||
self.path("roles/custom/matrix-livekit/molecule/default/molecule.yml").unlink()
|
||||
self.assertEqual(self.selected(), [])
|
||||
|
||||
def test_new_scenario_is_selected(self):
|
||||
self.write("roles/custom/matrix-new/molecule/default/molecule.yml", "---\n")
|
||||
self.assertEqual(self.selected(), ["matrix-new"])
|
||||
|
||||
def test_global_changes_run_all(self):
|
||||
for name in sorted(selector.GLOBAL_FILES):
|
||||
with self.subTest(name=name):
|
||||
self.git.run("reset", "--hard", self.base)
|
||||
self.write(name, "# Infrastructure changed\n")
|
||||
self.assertEqual(self.selected(), self.roles)
|
||||
|
||||
def test_unknown_shared_file_runs_all(self):
|
||||
self.write("molecule-shared/new-helper.yml", "---\n")
|
||||
self.assertEqual(self.selected(), self.roles)
|
||||
|
||||
def test_unconsumed_image_bump_runs_all(self):
|
||||
self.scenario("matrix-livekit", "tasks/sfu.yml", "# No image reference\n")
|
||||
self.base = self.commit()
|
||||
self.bump("livekit")
|
||||
self.assertEqual(self.selected(), self.roles)
|
||||
|
||||
def test_unsupported_pins_run_all(self):
|
||||
original = self.path(selector.IMAGE_VARS).read_text()
|
||||
for content in [
|
||||
'---\n' + original,
|
||||
original + 'other_setting: true\n',
|
||||
original + 'molecule_shared_image_new: "new:1"\n',
|
||||
original.replace('molecule_shared_image_livekit: "livekit:1"\n', ''),
|
||||
original + 'molecule_shared_image_curl: "curl:2"\n',
|
||||
original.replace('"livekit:1"', '"{{ another_variable }}"'),
|
||||
]:
|
||||
with self.subTest(content=content):
|
||||
self.write(selector.IMAGE_VARS, content)
|
||||
self.assertEqual(self.selected(), self.roles)
|
||||
|
||||
def test_missing_pins_run_all(self):
|
||||
self.path(selector.IMAGE_VARS).unlink()
|
||||
self.assertEqual(self.selected(), self.roles)
|
||||
|
||||
def test_unresolved_references_run_all(self):
|
||||
for reference in [
|
||||
'molecule-shared/tasks/{{ helper }}.yml',
|
||||
'molecule-shared/{{ helper }}.yml',
|
||||
'molecule-shared/missing.yml',
|
||||
"{{ lookup('vars', 'molecule_shared_image_' + name) }}",
|
||||
]:
|
||||
with self.subTest(reference=reference):
|
||||
self.scenario("matrix-web", "tasks/dynamic.yml", reference)
|
||||
self.bump("livekit")
|
||||
self.assertEqual(self.selected(), self.roles)
|
||||
|
||||
def test_shared_reference_cycles_terminate(self):
|
||||
self.write("molecule-shared/tasks/postgres.yml", 'molecule-shared/tasks/postgres.yml\n{{ molecule_shared_image_postgres }}\n')
|
||||
self.assertEqual(self.selected(), ["matrix-database"])
|
||||
|
||||
def test_comments_do_not_introduce_unresolved_references(self):
|
||||
self.scenario("matrix-web", "tasks/comments.yml", "# See molecule-shared/probe.py.\n# Helpers live in molecule-shared/.\n")
|
||||
self.base = self.commit()
|
||||
self.bump("livekit")
|
||||
self.assertEqual(self.selected(), ["matrix-livekit"])
|
||||
|
||||
def test_symlinked_shared_helper_is_followed(self):
|
||||
self.path("molecule-shared/tasks/probe.yml").unlink()
|
||||
self.path("molecule-shared/tasks/probe.yml").symlink_to("../probe.py")
|
||||
self.base = self.commit()
|
||||
self.write("molecule-shared/probe.py", "print('changed')\n")
|
||||
self.assertEqual(self.selected(), ["matrix-web"])
|
||||
|
||||
def test_invalid_comparison_runs_all(self):
|
||||
self.assertEqual(self.selected(base="missing-commit"), self.roles)
|
||||
|
||||
def test_manual_dispatch_all_or_one_role(self):
|
||||
with contextlib.redirect_stderr(io.StringIO()):
|
||||
self.assertEqual(selector.select_roles(self.git, None), self.roles)
|
||||
self.assertEqual(selector.select_roles(self.git, None, role="matrix-web"), ["matrix-web"])
|
||||
with self.assertRaises(ValueError):
|
||||
selector.select_roles(self.git, None, role="../outside")
|
||||
|
||||
def test_event_comparison_bases(self):
|
||||
self.bump("livekit")
|
||||
head = self.commit()
|
||||
push = {"EVENT_NAME": "push", "DEFAULT_BRANCH": "main", "GITHUB_REF": "refs/heads/renovate/test"}
|
||||
for before in [self.base, "0" * 40, "unavailable", ""]:
|
||||
with self.subTest(before=before):
|
||||
self.assertEqual(selector.comparison_base(self.git, head, dict(push, BEFORE_SHA=before)), self.base)
|
||||
self.assertEqual(selector.comparison_base(self.git, head, {"EVENT_NAME": "pull_request", "BASE_SHA": self.base}), self.base)
|
||||
self.assertIsNone(selector.comparison_base(self.git, head, {"EVENT_NAME": "workflow_dispatch"}))
|
||||
self.assertIsNone(selector.comparison_base(self.git, head, dict(push, GITHUB_REF="refs/heads/main")))
|
||||
self.assertIsNone(selector.comparison_base(self.git, head, dict(push, DEFAULT_BRANCH="missing")))
|
||||
self.assertIsNone(selector.comparison_base(self.git, head, {"EVENT_NAME": "pull_request", "BASE_SHA": "missing"}))
|
||||
|
||||
def test_cli_writes_github_output(self):
|
||||
self.bump("livekit")
|
||||
self.commit()
|
||||
output = self.path("github-output")
|
||||
output.write_text("previous=value\n")
|
||||
env = dict(os.environ, INPUT_ROLE="", GITHUB_OUTPUT=str(output))
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(SCRIPT), "--base", self.base], cwd=self.root,
|
||||
env=env, capture_output=True, text=True, check=True,
|
||||
)
|
||||
self.assertEqual(json.loads(result.stdout), ["matrix-livekit"])
|
||||
self.assertEqual(output.read_text(), 'previous=value\nroles=["matrix-livekit"]\n')
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -28,7 +28,17 @@ Molecule is deliberately **not** part of the `prek` hooks. A run is far too slow
|
||||
|
||||
`.github/workflows/molecule.yml` does not run every scenario on every push — with one repository holding every role, that would be unaffordable. Its first job works out which roles the push actually touched, keeps the ones that have a scenario, and builds the job matrix from those. A documentation change runs nothing.
|
||||
|
||||
For pushes, CI compares against the previous commit. On a new branch, or when the previous commit is unavailable after a force push, it instead compares against the branch's common ancestor with the default branch. This keeps a new Renovate branch limited to the roles it changes. If no usable comparison exists, it falls back to running every scenario. Changes to `molecule-shared/` or the Molecule workflow also run every scenario. `workflow_dispatch` accepts an optional role name; leaving it empty runs every scenario.
|
||||
For pushes, CI compares against the previous branch tip. On a new branch, or when the previous commit is unavailable after a force push, it instead compares against the branch's common ancestor with the default branch. This keeps a new Renovate branch limited to the roles it changes. If no usable comparison exists, it falls back to running every scenario. `workflow_dispatch` accepts an optional role name; leaving it empty runs every scenario.
|
||||
|
||||
`bin/molecule-select-roles.py` selects directly changed roles and the consumers of changed shared dependencies:
|
||||
|
||||
- A value change in `molecule-shared/vars.yml` selects scenarios using that image variable. Loading `vars.yml` alone does not count as using every image. Comment and formatting changes that preserve the supported pin format run nothing.
|
||||
- Shared tasks and fixtures select their consumers, following references transitively. For example, a Postgres image bump selects scenarios including `molecule-shared/tasks/postgres.yml`, while a LiveKit server image bump selects only the LiveKit JWT service scenario.
|
||||
- Changes to shared Python or Ansible requirements, `playbook-context.yml`, the workflow, or the selector and its tests run every scenario.
|
||||
|
||||
The selector reads both Git revisions, so removed references and renamed helpers still select their previous consumers. It discovers dependencies from literal `molecule-shared/...` paths, symlinks, and `molecule_shared_image_*` variable names in scenario files and the shared files they reference. Use these literal references when adding helpers. It does not interpret Ansible or Jinja: unresolved paths, unrecognized pin formats, added or removed image variables, and shared changes with no known consumers fall back to every scenario.
|
||||
|
||||
Run the selector's regression tests with `python3 bin/test-molecule-select-roles.py`; these also run in CI and the relevant prek hook. To inspect a committed change locally, use `python3 bin/molecule-select-roles.py --base <base-commit> --head <head-commit>`. The script prints the selected roles as JSON and explains shared-dependency selections or full-suite fallbacks on stderr.
|
||||
|
||||
## Automerge
|
||||
|
||||
@@ -172,10 +182,7 @@ it from `prepare.yml` and point the role at it with its own `_database_engine`,
|
||||
and credentials. Give the database and user names that differ from the role's defaults - then the
|
||||
component reaching the database at all proves the role built its connection string out of them.
|
||||
|
||||
The image is pinned in `molecule-shared/vars.yml` at the major the postgres role deploys to new
|
||||
installations, and Renovate carries it forward. When a new major lands, the PR bumping that pin
|
||||
runs every scenario against it, which is the earliest warning we get that a component does not
|
||||
cope with it.
|
||||
The image is pinned in `molecule-shared/vars.yml` at the major the postgres role deploys to new installations, and Renovate carries it forward. When a new major lands, the PR bumping that pin runs every scenario that uses Postgres against it, which is the earliest warning we get that a component does not cope with it.
|
||||
|
||||
Prefer asserting on the schema the component created over a file on disk: tables can only appear
|
||||
once it has resolved the hostname, authenticated, and run its migrations.
|
||||
|
||||
@@ -15,8 +15,8 @@ molecule_shared_image_python: "docker.io/library/python:3.14-alpine"
|
||||
|
||||
# Postgres for scenarios whose role has a database. Pinned at the major the postgres role
|
||||
# deploys to new installations, and left to Renovate from there: when a new major lands,
|
||||
# the PR bumping this pin runs every scenario against it, which is the earliest warning we
|
||||
# get that a component does not cope. Users who upgrade Postgres promptly are on that major
|
||||
# the PR bumping this pin runs every scenario that uses Postgres against it, which is the
|
||||
# earliest warning we get that a component does not cope. Users who upgrade Postgres promptly are on that major
|
||||
# well before the postgres role makes it the default for new installations.
|
||||
# renovate: datasource=docker depName=docker.io/library/postgres
|
||||
molecule_shared_image_postgres: "docker.io/library/postgres:18.6-alpine"
|
||||
|
||||
Reference in New Issue
Block a user