4"""Fail if anything but the one blessed script builds the ``ra8-ci`` image.
8``just ci`` boots a locally-built image tagged ``ra8-ci:latest``. #521 made
9that image a pure function of its allowlisted build context -- it carries the
10context's sha256 as an OCI label, and ``scripts/ci/devcontainer_image.sh`` is
11the ONE thing that builds it, rebuilding rather than reusing a cached image
12whose label disagrees with the tree. That guarantee holds only while
13``devcontainer_image.sh`` is the *only* builder. It was not before: the
14deleted ``inner-local.sh`` -- unreferenced, predating
15``RA8_GATE_REGISTRY``, carrying hand-copied gate bodies -- also ran
16``docker build -t ra8-ci:latest`` with the old "present, so reuse it forever"
17logic. Nothing would have noticed a new one appearing (#528).
19This is the same hole ``check_ci_parity.py`` closes for workflow ``run:``
20bodies: a second, drifting home for a thing that must have exactly one. The
21image needs the equivalent.
23WHAT IT FORBIDS, PRECISELY
24--------------------------
25A container-image BUILD (``docker build`` / ``podman build`` /
26``buildah bud`` / the ``"${RUNTIME[@]}" build`` array form) whose ``-t`` / ``--tag``
27target names the ``ra8-ci`` image, in any first-party file under ``scripts/``,
28``infra/``, ``.github/`` or ``just/`` OTHER than
29``scripts/ci/devcontainer_image.sh``.
31Naming it precisely matters, because ``ra8-ci`` is also a runner LABEL
32(``runs-on: ra8-ci``), a filesystem PATH (``/var/lib/ra8-ci/``) and a scale-set
33NAME all over the tree -- none of which build anything. So the rule keys on a
34build invocation AND a tag argument, not on the string appearing:
36 * ``ra8-ci`` and ``ra8-ci:latest`` match; the tag may be given literally or
37 through a shell variable this checker resolves within the same file (that
38 is how ``devcontainer_image.sh`` itself spells it:
39 ``IMAGE_TAG="${RA8_CI_IMAGE:-ra8-ci:latest}"`` then ``-t "$IMAGE_TAG"``).
40 * ``ra8-ci-runner:v2`` and ``ra8-devcontainer:latest`` do NOT match -- the
41 ``ci_runner`` role builds those and is a different, legitimate subject.
42 * The retired ``ra8-firmware-dev`` and ``ra8-firmware-test`` tags are
43 forbidden even on a run-only command: either one would recreate a second,
44 unversioned developer image beside the digest-labelled ``ra8-ci`` image.
45 * Any other direct build from ``.devcontainer/Dockerfile`` is forbidden
46 outside the deployed runner-image role. A new tag cannot evade the rule.
50Command reconstruction joins shell backslash continuations and YAML
51``cmd:``/``run:`` block scalars, so both a resurrected shell builder and a new
52Ansible ``command:`` one are caught. Markdown is out of scope: a doc is not a
53build path. A NON-VACUITY FLOOR asserts the one known builder is still
54detected on every real run, so a reconstruction that quietly stopped matching
55fails loudly instead of reporting a clean, empty tree.
57Run with ``--selftest`` to prove both directions; ``--list`` to print the
58builders it currently sees.
60Exit 0 when the sole builder is the only one, 1 when a second builder exists,
612 when the scan itself collapsed (the floor is not met).
64from __future__
import annotations
71from pathlib
import Path
73sys.path.insert(0, str(Path(__file__).resolve().parent))
75import runner_image_cleanup_policy
as runner_cleanup
76from selftest_assert
import expect, report
81SOLE_BUILDER =
"scripts/ci/devcontainer_image.sh"
86SELF =
"scripts/checks/check_ci_image_single_builder.py"
89SCOPE_DIRS = (
"scripts/",
"infra/",
".github/",
"just/")
90SCOPE_FILES = frozenset({
"justfile"})
93THIRD_PARTY_PREFIXES = (
"libs/third_party/",
"apps/shared_libs/third_party/")
98DEPLOYED_RUNNER_BUILDER =
"infra/ansible/roles/ci_runner/tasks/main.yml"
99DOCKER_RUNNER_DEPLOY =
"infra/ansible/roles/ci_runner_docker/tasks/deploy.yml"
100CAPACITY_HELPER =
"scripts/ci/fleet_capacity.sh"
105BUILD_RE = re.compile(
106 r"\b(?:docker|podman)(?:\s+buildx)?\s+build\b"
107 r"|\bbuildah\s+bud\b"
112TAG_RE = re.compile(
r"""(?:--tag|(?<![\w-])-t)(?:=|\s+)("[^"]*"|'[^']*'|\S+)""")
115ASSIGN_RE = re.compile(
r"^\s*(?:export\s+)?([A-Za-z_]\w*)=(.*)$")
118VARREF_RE = re.compile(
r"\$\{?(?P<var>[A-Za-z_]\w*)(?::-(?P<default>[^}]*))?\}?")
123CI_IMAGE_RE = re.compile(
r"(?:^|/)ra8-ci(?::|$)")
124LEGACY_DEV_IMAGE_RE = re.compile(
r"(?<![\w-])ra8-firmware-(?:dev|test)(?::|\b)")
125DEVCONTAINER_CONTEXT_RE = re.compile(
r"(?<![\w.])\.devcontainer(?:[/\"'\s]|$)")
128BLOCK_KEY_RE = re.compile(
129 r"^(?P<indent>\s*)(?:-\s+)?(?:[\w.]+\s+)?(?:cmd|run|shell|script):\s*[|>][+-]?\s*$"
145def _repo_root() -> Path:
146 """The repository root, via git."""
149 [
"git",
"rev-parse",
"--show-toplevel"],
157def strip_comment(line: str) -> str:
158 """Drop a shell/YAML ``#`` comment, respecting single and double quotes.
160 A ``#`` starts a comment only at the start of the line or after
161 whitespace; one glued to a word (``foo#bar``, a URL fragment) is data.
164 for i, char
in enumerate(line):
165 if char ==
"'" and not in_d:
167 elif char ==
'"' and not in_s:
169 elif char ==
"#" and not in_s
and not in_d
and (i == 0
or line[i - 1].isspace()):
174def logical_commands(text: str) -> list[str]:
175 """Reconstruct whole shell commands from `text`.
177 Joins backslash line-continuations and YAML ``cmd:``/``run:`` block
178 scalars, so a ``-t`` argument on a different physical line than its
179 ``build`` verb is still seen as one command. Comments are stripped first.
181 lines = text.split(
"\n")
186 block = BLOCK_KEY_RE.match(lines[i])
188 key_indent = len(block.group(
"indent"))
196 if (len(bl) - len(bl.lstrip())) <= key_indent:
198 body.append(strip_comment(bl).strip().rstrip(
"\\").strip())
200 out.append(
" ".join(part
for part
in body
if part))
203 cur = strip_comment(lines[i])
204 while cur.rstrip().endswith(
"\\")
and i + 1 < total:
205 cur = cur.rstrip()[:-1] +
" " + strip_comment(lines[i + 1])
212def collect_assignments(text: str) -> dict[str, str]:
213 """Every ``VAR=value`` in `text`, values with one layer of quotes removed."""
214 assigns: dict[str, str] = {}
215 for raw
in text.split(
"\n"):
216 match = ASSIGN_RE.match(strip_comment(raw))
218 assigns[match.group(1)] = _unquote(match.group(2).strip())
222def _unquote(token: str) -> str:
223 """Strip one balanced pair of surrounding single or double quotes."""
224 token = token.strip()
225 if len(token) >= MIN_QUOTED_LEN
and token[0] == token[-1]
and token[0]
in "\"'":
230def resolve(token: str, assigns: dict[str, str], depth: int = 0) -> str:
231 """Resolve a ``$VAR`` / ``${VAR:-default}`` token against `assigns`.
233 Bounded recursion; an unknown variable with no default resolves to itself,
234 so an unresolvable tag simply fails to match rather than crashing.
236 token = _unquote(token)
237 if depth > MAX_RESOLVE_DEPTH:
239 ref = VARREF_RE.fullmatch(token)
242 var, default = ref.group(
"var"), ref.group(
"default")
244 return resolve(assigns[var], assigns, depth + 1)
245 if default
is not None:
246 return resolve(default, assigns, depth + 1)
250def ci_tags_built(text: str) -> list[str]:
251 """The resolved ``ra8-ci`` tags this file's build commands target.
253 Empty when the file builds no ``ra8-ci`` image -- whether it builds nothing,
254 builds a different image, or references ``ra8-ci`` only as a label or path.
256 assigns = collect_assignments(text)
258 for command
in logical_commands(text):
259 if not BUILD_RE.search(command):
261 for raw_tag
in TAG_RE.findall(command):
262 resolved = resolve(raw_tag, assigns)
263 if CI_IMAGE_RE.search(resolved):
264 hits.append(resolved)
268def legacy_dev_images(text: str) -> list[str]:
269 """Retired developer image names in active commands or assignments."""
270 commands =
"\n".join(logical_commands(text))
271 return sorted(set(LEGACY_DEV_IMAGE_RE.findall(commands)))
274def builds_devcontainer_context(text: str) -> bool:
275 """Whether a build command names the repository devcontainer context."""
276 assignments = collect_assignments(text)
279 for name, value
in assignments.items()
280 if DEVCONTAINER_CONTEXT_RE.search(resolve(value, assignments))
281 or DEVCONTAINER_CONTEXT_RE.search(value)
283 for command
in logical_commands(text):
284 if not BUILD_RE.search(command):
286 if DEVCONTAINER_CONTEXT_RE.search(command):
288 if any(re.search(rf
"\$\{{?{re.escape(name)}(?:\}}|\b)", command)
for name
in context_vars):
293def in_scope(rel: str) -> bool:
294 """True for a first-party non-Markdown file under one of the scope dirs."""
296 (rel.startswith(SCOPE_DIRS)
or rel
in SCOPE_FILES)
297 and not rel.endswith(
".md")
299 and not rel.startswith(THIRD_PARTY_PREFIXES)
303def scoped_files(root: Path) -> list[str]:
304 """Every in-scope git-tracked file, repo-relative and sorted."""
305 proc = subprocess.run(
306 [
"git",
"ls-files",
"-z"],
312 return sorted(rel
for rel
in proc.stdout.split(
"\0")
if rel
and in_scope(rel))
315def find_builders(root: Path, rels: list[str]) -> dict[str, list[str]]:
316 """Map each in-scope file that builds ``ra8-ci`` to the tags it targets."""
317 builders: dict[str, list[str]] = {}
320 text = (root / rel).read_text(encoding=
"utf-8")
321 except (UnicodeDecodeError, OSError):
323 tags = ci_tags_built(text)
329def find_context_builders(root: Path, rels: list[str]) -> list[str]:
330 """Files directly building the repository devcontainer context."""
334 text = (root / rel).read_text(encoding=
"utf-8")
335 except (UnicodeDecodeError, OSError):
337 if builds_devcontainer_context(text):
342def find_legacy_images(root: Path, rels: list[str]) -> dict[str, list[str]]:
343 """Map files still naming a retired developer image."""
344 hits: dict[str, list[str]] = {}
347 text = (root / rel).read_text(encoding=
"utf-8")
348 except (UnicodeDecodeError, OSError):
350 names = legacy_dev_images(text)
364 'IMAGE_TAG="${RA8_CI_IMAGE:-ra8-ci:latest}"\n'
366 '"${RUNTIME[@]}" build \\\n'
367 ' --label "$LABEL_KEY=$want" \\\n'
368 ' -t "$IMAGE_TAG" \\\n'
369 ' -f "$CONTEXT_DIR/Dockerfile" \\\n'
375 "#!/usr/bin/env bash\ndocker build -t ra8-ci:latest -f .devcontainer/Dockerfile .devcontainer\n"
379BAD_INDIRECT =
'IMG="ra8-ci:latest"\npodman build -t "$IMG" .devcontainer\n'
384 " ansible.builtin.command:\n"
386 " buildah bud --isolation chroot\n"
387 " -t ra8-ci:latest\n"
388 " -f ctx/.devcontainer/Dockerfile\n"
389 " ctx/.devcontainer\n"
394 "- name: Build the runner image\n"
395 " ansible.builtin.command:\n"
397 " buildah bud --isolation chroot\n"
398 " -t {{ ci_runner_image }}\n"
399 " -f ctx/runner/Dockerfile ctx/runner\n"
404 'IMAGE_TAG="ra8-firmware-docs:latest"\n'
405 'docker build -t "$IMAGE_TAG" -f docs/container/Dockerfile docs/container\n'
408BAD_LEGACY_RUN =
"docker run --rm ra8-firmware-dev:latest just tests::build\n"
409BAD_OTHER_CONTEXT_TAG = (
410 "docker build -t local-dev:latest -f .devcontainer/Dockerfile .devcontainer\n"
412BAD_INDIRECT_CONTEXT = (
413 'CONTEXT_DIR="$REPO_ROOT/.devcontainer"\n'
414 'docker build -t local-dev:latest -f "$CONTEXT_DIR/Dockerfile" "$CONTEXT_DIR"\n'
418OK_LABEL_PATH =
"runs-on: ra8-ci\nlabels: [ra8-ci]\ndir: /var/lib/ra8-ci/build-context\n"
421OK_RUN_ONLY =
"docker run -t ra8-ci:latest just ci\n"
424GOOD_RUNNER_CLEANUP = f
"""
425- name: Build the devcontainer toolchain image (single source of truth)
426 ansible.builtin.command:
428 buildah bud --label {runner_cleanup.MANAGED_IMAGE_LABEL}
429 --label {runner_cleanup.MANAGED_IMAGE_KIND}=devcontainer -t devcontainer .
430- name: Build the runner image (devcontainer + actions-runner)
431 ansible.builtin.command:
433 buildah bud --label {runner_cleanup.MANAGED_IMAGE_LABEL}
434 --label {runner_cleanup.MANAGED_IMAGE_KIND}=runner -t runner .
435- name: Find superseded managed runner images
436 ansible.builtin.command:
441 - label={runner_cleanup.MANAGED_IMAGE_LABEL}
443 - label={runner_cleanup.MANAGED_IMAGE_KIND}=runner
448 register: ci_runner_dangling_runner_images
450- name: Remove superseded managed runner images
451 ansible.builtin.command:
456 loop: "{{{{ ci_runner_dangling_runner_images.stdout_lines }}}}"
457 when: item | length > 0
459- name: Find superseded managed devcontainer images
460 ansible.builtin.command:
465 - label={runner_cleanup.MANAGED_IMAGE_LABEL}
467 - label={runner_cleanup.MANAGED_IMAGE_KIND}=devcontainer
472 register: ci_runner_dangling_devcontainer_images
474- name: Remove superseded managed devcontainer images
475 ansible.builtin.command:
480 loop: "{{{{ ci_runner_dangling_devcontainer_images.stdout_lines }}}}"
481 when: item | length > 0
483- name: Inventory CRI images after publishing the current runner
484 ansible.builtin.command:
491 register: ci_runner_cri_images
492 when: not ansible_check_mode
494- name: Reset the superseded runner image selection
495 ansible.builtin.set_fact:
496 ci_runner_stale_cri_images: []
497 ci_runner_image_repository: "{{{{ ci_runner_image | regex_replace(':[^/:]+$', '') }}}}"
498 when: not ansible_check_mode
500- name: Select superseded untagged runner images
501 ansible.builtin.set_fact:
502 ci_runner_stale_cri_images: "{{{{ ci_runner_stale_cri_images + [item.id] }}}}"
503 loop: "{{{{ (ci_runner_cri_images.stdout | from_json).images }}}}"
505 - not ansible_check_mode
506 - item.repoTags | default([]) | length == 0
508 item.repoDigests | default([])
511 '^' ~ (ci_runner_image_repository | regex_escape)
512 ~ '@sha256:[0-9a-f]{{64}}$'
516- name: Remove superseded untagged runner images
517 ansible.builtin.command:
523 loop: "{{{{ ci_runner_stale_cri_images }}}}"
524 when: not ansible_check_mode
526- name: Install the ra8-ci runner scale set
527 kubernetes.core.helm:
528 force_conflicts: true
532GOOD_DOCKER_CLEANUP = f
"""
533- name: Assert every container uses the validated Docker-native image
534 ansible.builtin.assert:
536- name: Find superseded managed Docker runner images
537 when: not ansible_check_mode
538 ansible.builtin.command:
546 - label={runner_cleanup.MANAGED_IMAGE_LABEL}
548 - label={runner_cleanup.MANAGED_IMAGE_KIND}=runner
551 register: ci_runner_docker_dangling_images
553- name: Remove superseded managed Docker runner images
554 when: not ansible_check_mode
555 ansible.builtin.command:
561 loop: "{{{{ ci_runner_docker_dangling_images.stdout_lines }}}}"
563- name: Read back the caps the kernel is actually enforcing
564 ansible.builtin.command:
565 argv: [docker, inspect]
571 (
True, GOOD_SOLE,
"the sole-builder shape is detected as a builder"),
572 (
True, BAD_LITERAL,
"a literal 'docker build -t ra8-ci:latest' is detected"),
573 (
True, BAD_INDIRECT,
"a variable-indirected ra8-ci build is detected"),
574 (
True, BAD_ANSIBLE,
"a folded-YAML buildah bud of ra8-ci is detected"),
575 (
False, OK_RUNNER,
"the ci_runner Jinja-var build is NOT flagged"),
576 (
False, OK_OTHER_TAG,
"an unrelated image build is NOT flagged"),
577 (
False, OK_LABEL_PATH,
"ra8-ci as a label/path is NOT flagged"),
578 (
False, OK_RUN_ONLY,
"'docker run -t ra8-ci' (run, not build) is NOT flagged"),
582def _selftest_detection(failures: list[str]) ->
None:
583 """Assert ci_tags_built fires and stays quiet on the right inputs."""
584 for should_fire, text, label
in _DETECTION_CASES:
585 expect(bool(ci_tags_built(text))
is should_fire, label, failures)
587 bool(legacy_dev_images(BAD_LEGACY_RUN)),
588 "a retired developer image is detected even on run-only use",
592 not legacy_dev_images(OK_RUN_ONLY),
593 "the canonical ra8-ci run is not a retired-image finding",
597 builds_devcontainer_context(BAD_OTHER_CONTEXT_TAG),
598 "a differently-tagged devcontainer build is detected",
602 builds_devcontainer_context(BAD_INDIRECT_CONTEXT),
603 "a variable-indirected devcontainer context is detected",
607 not builds_devcontainer_context(OK_OTHER_TAG),
608 "an unrelated image context stays out of scope",
613def _selftest_runner_cleanup(failures: list[str]) ->
None:
614 """Assert ownership and dangling-only cleanup are both mandatory."""
616 not runner_cleanup.errors(GOOD_RUNNER_CLEANUP),
617 "an owned dangling-image cleanup is accepted",
620 unsafe = GOOD_RUNNER_CLEANUP.replace(
"dangling=true",
"dangling=false", 1)
622 bool(runner_cleanup.errors(unsafe)),
623 "a non-dangling cleanup selector is rejected",
626 pin_filtered = GOOD_RUNNER_CLEANUP.replace(
627 " - item.repoTags | default([]) | length == 0\n",
628 " - item.repoTags | default([]) | length == 0\n - not item.pinned | default(false)\n",
631 bool(runner_cleanup.errors(pin_filtered)),
632 "a cleanup that preserves pinned stale generations is rejected",
635 unsafe_helm = GOOD_RUNNER_CLEANUP.replace(
"force_conflicts: true",
"force_conflicts: false")
637 bool(runner_cleanup.errors(unsafe_helm)),
638 "a Helm deploy that cannot reclaim transient field ownership is rejected",
642 "kc patch autoscalingrunnerset -n namespace scale-set \\\n"
643 ' --field-manager=helm --type=merge -p \'{"spec":{"maxRunners":0}}\'\n'
646 not runner_cleanup.capacity_field_manager_errors(good_capacity),
647 "the ARC capacity patch shares Helm's field manager",
652 runner_cleanup.capacity_field_manager_errors(
653 good_capacity.replace(
"--field-manager=helm ",
"")
656 "an independently managed ARC capacity patch is rejected",
660 not runner_cleanup.consumer_errors(GOOD_DOCKER_CLEANUP),
661 "an owned dangling Docker image cleanup is accepted",
664 broad_consumer = GOOD_DOCKER_CLEANUP.replace(
"dangling=true",
"dangling=false")
666 bool(runner_cleanup.consumer_errors(broad_consumer)),
667 "a non-dangling Docker consumer cleanup is rejected",
672def _selftest_end_to_end(failures: list[str]) ->
None:
673 """Assert the offender computation over a synthetic tree, both directions."""
675 second =
"scripts/ci/inner-local.sh"
676 with tempfile.TemporaryDirectory()
as tmp:
678 (root /
"scripts/ci").mkdir(parents=
True)
679 (root /
"scripts/checks").mkdir(parents=
True)
680 (root / SOLE_BUILDER).write_text(GOOD_SOLE, encoding=
"utf-8")
681 rels = [SOLE_BUILDER]
683 builders = find_builders(root, rels)
684 offenders = sorted(set(builders) - {SOLE_BUILDER})
686 SOLE_BUILDER
in builders,
687 "clean tree: the sole builder is detected (the floor)",
690 expect(
not offenders,
"clean tree: no offenders", failures)
692 (root / second).write_text(BAD_LITERAL, encoding=
"utf-8")
694 builders = find_builders(root, rels)
695 offenders = sorted(set(builders) - {SOLE_BUILDER})
697 offenders == [second],
698 "a second builder is reported as an offender",
703def _selftest_extended_end_to_end(failures: list[str]) ->
None:
704 """Assert context/tag regressions are reported over a synthetic tree."""
705 context_rel =
"just/devcontainer.just"
706 legacy_rel =
"scripts/ci/old-wrapper.sh"
707 with tempfile.TemporaryDirectory()
as tmp:
709 (root /
"just").mkdir()
710 (root /
"scripts/ci").mkdir(parents=
True)
711 (root / context_rel).write_text(BAD_OTHER_CONTEXT_TAG, encoding=
"utf-8")
712 (root / legacy_rel).write_text(BAD_LEGACY_RUN, encoding=
"utf-8")
713 rels = [context_rel, legacy_rel]
715 find_context_builders(root, rels) == [context_rel],
716 "a second devcontainer-context builder is reported",
720 sorted(find_legacy_images(root, rels)) == [legacy_rel],
721 "a retired developer image use is reported",
726def _selftest_floor_on_real_tree(failures: list[str]) ->
None:
727 """The real tree must still contain the sole builder -- the non-vacuity floor."""
731 except (subprocess.CalledProcessError, FileNotFoundError):
733 expect(resolved,
"git rev-parse resolves the repo root", failures)
736 builders = find_builders(root, scoped_files(root))
737 offenders = sorted(set(builders) - {SOLE_BUILDER})
738 context_builders = find_context_builders(root, scoped_files(root))
739 allowed_context_builders = {SOLE_BUILDER, DEPLOYED_RUNNER_BUILDER}
740 context_offenders = sorted(set(context_builders) - allowed_context_builders)
741 legacy = find_legacy_images(root, scoped_files(root))
743 SOLE_BUILDER
in builders,
744 f
"the real tree still detects {SOLE_BUILDER} as the builder",
747 expect(
not offenders, f
"the real tree has no second builder (saw {offenders})", failures)
749 not context_offenders,
750 f
"the real tree has no second devcontainer-context builder (saw {context_offenders})",
753 expect(
not legacy, f
"the real tree has no retired developer image tag (saw {legacy})", failures)
754 runner_source = (root / DEPLOYED_RUNNER_BUILDER).read_text(encoding=
"utf-8")
755 cleanup_errors = runner_cleanup.errors(runner_source)
758 f
"the deployed runner producer cleans only owned dangling images ({cleanup_errors})",
761 capacity_errors = runner_cleanup.capacity_field_manager_errors(
762 (root / CAPACITY_HELPER).read_text(encoding=
"utf-8")
766 f
"the ARC capacity patch shares Helm field ownership ({capacity_errors})",
769 consumer_errors = runner_cleanup.consumer_errors(
770 (root / DOCKER_RUNNER_DEPLOY).read_text(encoding=
"utf-8")
774 f
"Docker runner consumers clean only owned dangling images ({consumer_errors})",
779def selftest() -> int:
780 """Prove the detector fires and stays quiet, and that the floor holds."""
781 print(
"check_ci_image_single_builder.py --selftest")
782 failures: list[str] = []
783 _selftest_detection(failures)
784 _selftest_runner_cleanup(failures)
785 _selftest_end_to_end(failures)
786 _selftest_extended_end_to_end(failures)
787 _selftest_floor_on_real_tree(failures)
788 return report(failures)
791def report_extended_violations(root: Path, rels: list[str]) -> bool:
792 """Report direct context builders and retired developer image names."""
794 context_builders = find_context_builders(root, rels)
795 allowed_context_builders = {SOLE_BUILDER, DEPLOYED_RUNNER_BUILDER}
796 context_offenders = sorted(set(context_builders) - allowed_context_builders)
797 for rel
in context_offenders:
799 f
" {rel}: directly builds .devcontainer under a second image contract",
803 legacy = find_legacy_images(root, rels)
804 for rel, names
in sorted(legacy.items()):
805 print(f
" {rel}: uses retired developer image {' '.join(names)}", file=sys.stderr)
807 cleanup_errors = runner_cleanup.errors(
808 (root / DEPLOYED_RUNNER_BUILDER).read_text(encoding=
"utf-8")
810 cleanup_errors.extend(
811 runner_cleanup.capacity_field_manager_errors(
812 (root / CAPACITY_HELPER).read_text(encoding=
"utf-8")
815 cleanup_errors.extend(
816 runner_cleanup.consumer_errors((root / DOCKER_RUNNER_DEPLOY).read_text(encoding=
"utf-8"))
818 for message
in cleanup_errors:
819 print(f
" {message}", file=sys.stderr)
823 f
"Route writable developer runs through scripts/ci/devcontainer_run.sh;\n"
824 f
"only {SOLE_BUILDER} may build its digest-labelled image.",
830def main(argv: list[str]) -> int:
831 """Fail if any file but the sole builder builds the ``ra8-ci`` image."""
832 ap = argparse.ArgumentParser(description=
"One builder for ra8-ci:latest, and only one.")
833 ap.add_argument(
"--selftest", action=
"store_true", help=
"assert both directions")
834 ap.add_argument(
"--list", action=
"store_true", help=
"print every detected ra8-ci builder")
835 args = ap.parse_args(argv[1:])
841 rels = scoped_files(root)
842 builders = find_builders(root, rels)
845 for rel
in sorted(builders):
846 print(f
"{rel}: {' '.join(builders[rel])}")
851 if SOLE_BUILDER
not in builders:
853 "check_ci_image_single_builder.py: FATAL -- the known builder\n"
855 " was NOT detected building ra8-ci. Either the file was renamed (update\n"
856 " SOLE_BUILDER in this checker) or the command reconstruction stopped\n"
857 " matching (a collapsed scan must fail, not report clean).",
862 offenders = sorted(set(builders) - {SOLE_BUILDER})
865 f
"\n{len(offenders)} file(s) build the ra8-ci image besides {SOLE_BUILDER}:\n",
868 for rel
in offenders:
869 print(f
" {rel}: builds {' '.join(builders[rel])}", file=sys.stderr)
871 "\nra8-ci:latest must have exactly one builder so its context-digest\n"
872 "staleness guarantee cannot be bypassed (#521, #528). Route this build\n"
873 f
"through {SOLE_BUILDER}, or -- if it is a different image -- give it a\n"
874 "different tag (the ci_runner role builds ra8-ci-runner / ra8-devcontainer).",
879 if report_extended_violations(root, rels):
883 "check_ci_image_single_builder.py: one digest-labelled developer image; "
884 f
"{SOLE_BUILDER} is its only builder."
889if __name__ ==
"__main__":
890 sys.exit(
main(sys.argv))
void main(void)
The application entry point Reset_Handler hands control to.