From 9e219ed1fe3236776c371fa102adc7264cd32efe Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Thu, 23 Jul 2026 08:55:50 -0500 Subject: [PATCH] fix(plan): run planner in pinned container --- .github/actions/plan/action.yml | 28 ++++++++++++++++++++----- .github/actions/plan/plan.py | 18 ++++++++++------ .github/actions/plan/test_plan.py | 34 +++++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 11 deletions(-) diff --git a/.github/actions/plan/action.yml b/.github/actions/plan/action.yml index f87928b4..cf4505d6 100644 --- a/.github/actions/plan/action.yml +++ b/.github/actions/plan/action.yml @@ -27,8 +27,26 @@ runs: steps: - id: expand shell: bash - run: >- - python3 "${GITHUB_ACTION_PATH}/plan.py" - --plan "${{ inputs.plan-path }}" - --group "${{ inputs.group }}" - --github-output "${GITHUB_OUTPUT}" + env: + CI_FLEET_PLAN_PATH: ${{ inputs.plan-path }} + CI_FLEET_PLAN_GROUP: ${{ inputs.group }} + run: | + set -Eeuo pipefail + tmp=$(mktemp -d) + trap 'rm -rf "$tmp"' EXIT + cp -- "${GITHUB_ACTION_PATH}/plan.py" "$tmp/plan.py" + cp -- "$CI_FLEET_PLAN_PATH" "$tmp/plan.json" + tar -C "$tmp" -cf - plan.py plan.json | + docker run --rm -i \ + --network none \ + --read-only \ + --cap-drop all \ + --security-opt no-new-privileges \ + --pids-limit 64 \ + --memory 128m \ + --cpus 0.5 \ + --user 65534:65534 \ + --tmpfs /tmp:rw,noexec,nosuid,nodev,size=4m \ + python:3.12.11-slim-bookworm@sha256:519591d6871b7bc437060736b9f7456b8731f1499a57e22e6c285135ae657bf7 \ + sh -c 'tar -xf - -C /tmp && python3 /tmp/plan.py --plan /tmp/plan.json --group "$1" --github-output -' \ + sh "$CI_FLEET_PLAN_GROUP" >>"$GITHUB_OUTPUT" diff --git a/.github/actions/plan/plan.py b/.github/actions/plan/plan.py index a486a5d3..2cafb128 100755 --- a/.github/actions/plan/plan.py +++ b/.github/actions/plan/plan.py @@ -139,12 +139,18 @@ def main() -> int: print(f"ERROR: {exc}", file=sys.stderr) return 1 encoded = json.dumps(matrix, separators=(",", ":")) - if args.github_output: - with args.github_output.open("a", encoding="utf-8") as output: - output.write(f"matrix={encoded}\n") - output.write(f"job-count={len(matrix['include'])}\n") - output.write(f"estimated-test-minutes={estimated_total}\n") - print(encoded) + output_lines = ( + f"matrix={encoded}\n" + f"job-count={len(matrix['include'])}\n" + f"estimated-test-minutes={estimated_total}\n" + ) + if args.github_output == Path("-"): + print(output_lines, end="") + else: + if args.github_output: + with args.github_output.open("a", encoding="utf-8") as output: + output.write(output_lines) + print(encoded) print( f"OK: {args.group} expands to {len(matrix['include'])} jobs " f"covering approximately {estimated_total} test-minutes", diff --git a/.github/actions/plan/test_plan.py b/.github/actions/plan/test_plan.py index 64c6b30f..8ea18c82 100755 --- a/.github/actions/plan/test_plan.py +++ b/.github/actions/plan/test_plan.py @@ -5,6 +5,8 @@ import copy import json +import subprocess +import sys import unittest from pathlib import Path @@ -53,6 +55,38 @@ def test_fast_tasks_must_also_run_in_full(self) -> None: plan["tasks"][0]["groups"] = ["fast"] self.assert_rejected(plan, "whenever it includes fast") + def test_action_runs_planner_in_pinned_runtime_container(self) -> None: + action = (ROOT / ".github/actions/plan/action.yml").read_text(encoding="utf-8") + self.assertIn( + "python:3.12.11-slim-bookworm@sha256:519591d6871b7bc437060736b9f7456b8731f1499a57e22e6c285135ae657bf7", + action, + ) + self.assertIn("--network none", action) + self.assertIn("--read-only", action) + self.assertIn("--cap-drop all", action) + self.assertNotIn('python3 "${GITHUB_ACTION_PATH}/plan.py"', action) + + def test_cli_can_emit_github_outputs_to_stdout(self) -> None: + result = subprocess.run( + [ + sys.executable, + str(ROOT / ".github/actions/plan/plan.py"), + "--plan", + str(ROOT / "examples/project/scripts/ci/plan.json"), + "--group", + "fast", + "--github-output", + "-", + ], + check=True, + capture_output=True, + text=True, + ) + self.assertEqual(result.stdout.count("matrix="), 1) + self.assertIn("job-count=6\n", result.stdout) + self.assertIn("estimated-test-minutes=20\n", result.stdout) + self.assertNotIn("\n{", result.stdout) + def test_matrix_limit_is_enforced(self) -> None: plan = sample_plan() plan["tasks"][0]["shards"] = 257