From 01a8440305268181ca271f27d2af5e7ef325490c Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Thu, 2 Apr 2026 12:34:28 +0200 Subject: [PATCH 1/2] verify-action-build: show Docker build step summary and add --no-cache flag --- utils/verify-action-build.py | 110 +++++++++++++++++++++++++---------- 1 file changed, 79 insertions(+), 31 deletions(-) diff --git a/utils/verify-action-build.py b/utils/verify-action-build.py index 3c26fe91..1a4c7962 100644 --- a/utils/verify-action-build.py +++ b/utils/verify-action-build.py @@ -922,6 +922,7 @@ def build_in_docker( org: str, repo: str, commit_hash: str, work_dir: Path, sub_path: str = "", gh: GitHubClient | None = None, + no_cache: bool = False, ) -> tuple[Path, Path, str, str]: """Build the action in a Docker container and extract original + rebuilt dist. @@ -960,34 +961,71 @@ def build_in_docker( if node_version != "20": console.print(f" [green]✓[/green] Detected Node.js version: [bold]node{node_version}[/bold]") - with console.status("[bold blue]Building Docker image...[/bold blue]") as status: - # Build Docker image - status.update("[bold blue]Cloning repository and building action...[/bold blue]") - run( - [ - "docker", - "build", - "--build-arg", - f"NODE_VERSION={node_version}", - "--build-arg", - f"REPO_URL={repo_url}", - "--build-arg", - f"COMMIT_HASH={commit_hash}", - "--build-arg", - f"SUB_PATH={sub_path}", - "-t", - image_tag, - "-f", - str(dockerfile_path), - str(work_dir), - ], - capture_output=True, + # Build Docker image, capturing output so we can summarise the steps afterwards + docker_build_cmd = [ + "docker", + "build", + "--progress=plain", + "--build-arg", + f"NODE_VERSION={node_version}", + "--build-arg", + f"REPO_URL={repo_url}", + "--build-arg", + f"COMMIT_HASH={commit_hash}", + "--build-arg", + f"SUB_PATH={sub_path}", + "-t", + image_tag, + "-f", + str(dockerfile_path), + str(work_dir), + ] + if no_cache: + docker_build_cmd.insert(3, "--no-cache") + + with console.status("[bold blue]Building Docker image...[/bold blue]"): + build_result = subprocess.run( + docker_build_cmd, capture_output=True, text=True, ) - console.print(" [green]✓[/green] Docker image built") + if build_result.returncode != 0: + # Show full output on failure so the user can diagnose + console.print("[red]Docker build failed. Output:[/red]") + console.print(build_result.stdout) + console.print(build_result.stderr) + raise subprocess.CalledProcessError(build_result.returncode, docker_build_cmd) + + # Parse --progress=plain output for step summaries. + # Lines look like: #7 [4/12] RUN git clone ... + # #7 DONE 1.2s or #7 CACHED + build_output = build_result.stderr + build_result.stdout + step_names: dict[str, str] = {} # step_id -> description + step_status: dict[str, str] = {} # step_id -> "DONE 1.2s" / "CACHED" + for line in build_output.splitlines(): + # Step description: #5 [3/12] RUN apt-get update ... + m = re.match(r"^#(\d+)\s+(\[.+)", line) + if m: + step_names[m.group(1)] = m.group(2) + continue + # Done / cached: #5 DONE 1.2s or #5 CACHED + m = re.match(r"^#(\d+)\s+(DONE\s+[\d.]+s|CACHED)", line) + if m: + step_status[m.group(1)] = m.group(2) + + console.print() + console.rule("[bold blue]Docker build steps[/bold blue]") + for sid in sorted(step_names, key=lambda x: int(x)): + name = step_names[sid] + status_str = step_status.get(sid, "") + if "CACHED" in status_str: + console.print(f" [dim]✓ {name} (cached)[/dim]") + else: + console.print(f" [green]✓[/green] {name} [dim]{status_str}[/dim]") + console.print() + + with console.status("[bold blue]Extracting build artifacts...[/bold blue]") as status: # Extract original and rebuilt dist from container try: - status.update("[bold blue]Extracting build artifacts...[/bold blue]") run( ["docker", "create", "--name", container_name, image_tag], capture_output=True, @@ -1283,7 +1321,10 @@ def _format_diff_text(lines: list[str]) -> Text: return diff_text -def verify_single_action(action_ref: str, gh: GitHubClient | None = None, ci_mode: bool = False) -> bool: +def verify_single_action( + action_ref: str, gh: GitHubClient | None = None, ci_mode: bool = False, + no_cache: bool = False, +) -> bool: """Verify a single action reference. Returns True if verification passed.""" org, repo, sub_path, commit_hash = parse_action_ref(action_ref) @@ -1291,6 +1332,7 @@ def verify_single_action(action_ref: str, gh: GitHubClient | None = None, ci_mod work_dir = Path(tmp) original_dir, rebuilt_dir, action_type, out_dir_name = build_in_docker( org, repo, commit_hash, work_dir, sub_path=sub_path, gh=gh, + no_cache=no_cache, ) # Non-JavaScript actions (docker, composite) don't have compiled JS to verify @@ -1380,7 +1422,7 @@ def get_gh_user(gh: GitHubClient | None = None) -> str: return gh.get_authenticated_user() -def check_dependabot_prs(gh: GitHubClient) -> None: +def check_dependabot_prs(gh: GitHubClient, no_cache: bool = False) -> None: """List open dependabot PRs, verify each, and optionally merge.""" console.print() console.rule("[bold]Dependabot PR Review[/bold]") @@ -1515,11 +1557,11 @@ def check_dependabot_prs(gh: GitHubClient) -> None: sub_ref = f"{org_repo}/{sp}@{commit_hash}" else: sub_ref = f"{org_repo}@{commit_hash}" - if not verify_single_action(sub_ref, gh=gh): + if not verify_single_action(sub_ref, gh=gh, no_cache=no_cache): passed = False else: # Simple single action (no sub-path) - if not verify_single_action(f"{org_repo}@{commit_hash}", gh=gh): + if not verify_single_action(f"{org_repo}@{commit_hash}", gh=gh, no_cache=no_cache): passed = False if not passed: @@ -1632,9 +1674,15 @@ def main() -> None: action="store_true", help="Non-interactive mode: skip all prompts, auto-select defaults (for CI pipelines)", ) + parser.add_argument( + "--no-cache", + action="store_true", + help="Build Docker image from scratch without using layer cache", + ) args = parser.parse_args() ci_mode = args.ci + no_cache = args.no_cache if not shutil.which("docker"): console.print("[red]Error:[/red] docker is required but not found in PATH") @@ -1665,12 +1713,12 @@ def main() -> None: _exit(1) for ref in action_refs: console.print(f" Extracted action reference from PR #{args.from_pr}: [bold]{ref}[/bold]") - passed = all(verify_single_action(ref, gh=gh, ci_mode=ci_mode) for ref in action_refs) + passed = all(verify_single_action(ref, gh=gh, ci_mode=ci_mode, no_cache=no_cache) for ref in action_refs) _exit(0 if passed else 1) elif args.check_dependabot_prs: - check_dependabot_prs(gh=gh) + check_dependabot_prs(gh=gh, no_cache=no_cache) elif args.action_ref: - passed = verify_single_action(args.action_ref, gh=gh, ci_mode=ci_mode) + passed = verify_single_action(args.action_ref, gh=gh, ci_mode=ci_mode, no_cache=no_cache) _exit(0 if passed else 1) else: parser.print_help() From 10b1e996b1a598653dc7287f19665e995d71971b Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Sat, 4 Apr 2026 16:02:30 +0200 Subject: [PATCH 2/2] verify-action-build: address PR review comments - Rename no_cache param to cache (positive boolean) per reviewer preference - Show Docker build step summary only on failure by default - Add --show-build-steps flag to display steps on success too - Document --no-cache and --show-build-steps flags in README Generated-by: Claude Opus 4.6 (1M context) --- README.md | 4 ++ utils/verify-action-build.py | 88 ++++++++++++++++++++---------------- 2 files changed, 54 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 147340db..1336743f 100644 --- a/README.md +++ b/README.md @@ -204,6 +204,10 @@ uv run utils/verify-action-build.py --ci --from-pr 123 The `--ci` flag skips all interactive prompts (auto-selects the newest approved version for diffing, auto-accepts exclusions, disables paging). The `--from-pr` flag extracts the action reference from the given PR number. +Additional flags: +- `--no-cache` — rebuild the Docker image from scratch without using the layer cache. +- `--show-build-steps` — display a summary of Docker build steps on successful builds (the summary is always shown on failure). + > [!NOTE] > **Prerequisites:** `docker` and `uv`. When using the default mode (without `--no-gh`), `gh` (GitHub CLI, authenticated via `gh auth login`) is also required. The build runs in a `node:20-slim` container so no local Node.js installation is needed. diff --git a/utils/verify-action-build.py b/utils/verify-action-build.py index 1a4c7962..a5db0333 100644 --- a/utils/verify-action-build.py +++ b/utils/verify-action-build.py @@ -918,11 +918,41 @@ def detect_node_version( return "20" +def _print_docker_build_steps(build_result: subprocess.CompletedProcess[str]) -> None: + """Parse and display Docker build step summaries from --progress=plain output.""" + build_output = build_result.stderr + build_result.stdout + step_names: dict[str, str] = {} # step_id -> description + step_status: dict[str, str] = {} # step_id -> "DONE 1.2s" / "CACHED" + for line in build_output.splitlines(): + # Step description: #5 [3/12] RUN apt-get update ... + m = re.match(r"^#(\d+)\s+(\[.+)", line) + if m: + step_names[m.group(1)] = m.group(2) + continue + # Done / cached: #5 DONE 1.2s or #5 CACHED + m = re.match(r"^#(\d+)\s+(DONE\s+[\d.]+s|CACHED)", line) + if m: + step_status[m.group(1)] = m.group(2) + + if step_names: + console.print() + console.rule("[bold blue]Docker build steps[/bold blue]") + for sid in sorted(step_names, key=lambda x: int(x)): + name = step_names[sid] + status_str = step_status.get(sid, "") + if "CACHED" in status_str: + console.print(f" [dim]✓ {name} (cached)[/dim]") + else: + console.print(f" [green]✓[/green] {name} [dim]{status_str}[/dim]") + console.print() + + def build_in_docker( org: str, repo: str, commit_hash: str, work_dir: Path, sub_path: str = "", gh: GitHubClient | None = None, - no_cache: bool = False, + cache: bool = True, + show_build_steps: bool = False, ) -> tuple[Path, Path, str, str]: """Build the action in a Docker container and extract original + rebuilt dist. @@ -980,7 +1010,7 @@ def build_in_docker( str(dockerfile_path), str(work_dir), ] - if no_cache: + if not cache: docker_build_cmd.insert(3, "--no-cache") with console.status("[bold blue]Building Docker image...[/bold blue]"): @@ -992,35 +1022,11 @@ def build_in_docker( console.print("[red]Docker build failed. Output:[/red]") console.print(build_result.stdout) console.print(build_result.stderr) + _print_docker_build_steps(build_result) raise subprocess.CalledProcessError(build_result.returncode, docker_build_cmd) - # Parse --progress=plain output for step summaries. - # Lines look like: #7 [4/12] RUN git clone ... - # #7 DONE 1.2s or #7 CACHED - build_output = build_result.stderr + build_result.stdout - step_names: dict[str, str] = {} # step_id -> description - step_status: dict[str, str] = {} # step_id -> "DONE 1.2s" / "CACHED" - for line in build_output.splitlines(): - # Step description: #5 [3/12] RUN apt-get update ... - m = re.match(r"^#(\d+)\s+(\[.+)", line) - if m: - step_names[m.group(1)] = m.group(2) - continue - # Done / cached: #5 DONE 1.2s or #5 CACHED - m = re.match(r"^#(\d+)\s+(DONE\s+[\d.]+s|CACHED)", line) - if m: - step_status[m.group(1)] = m.group(2) - - console.print() - console.rule("[bold blue]Docker build steps[/bold blue]") - for sid in sorted(step_names, key=lambda x: int(x)): - name = step_names[sid] - status_str = step_status.get(sid, "") - if "CACHED" in status_str: - console.print(f" [dim]✓ {name} (cached)[/dim]") - else: - console.print(f" [green]✓[/green] {name} [dim]{status_str}[/dim]") - console.print() + if show_build_steps: + _print_docker_build_steps(build_result) with console.status("[bold blue]Extracting build artifacts...[/bold blue]") as status: @@ -1323,7 +1329,7 @@ def _format_diff_text(lines: list[str]) -> Text: def verify_single_action( action_ref: str, gh: GitHubClient | None = None, ci_mode: bool = False, - no_cache: bool = False, + cache: bool = True, show_build_steps: bool = False, ) -> bool: """Verify a single action reference. Returns True if verification passed.""" org, repo, sub_path, commit_hash = parse_action_ref(action_ref) @@ -1332,7 +1338,7 @@ def verify_single_action( work_dir = Path(tmp) original_dir, rebuilt_dir, action_type, out_dir_name = build_in_docker( org, repo, commit_hash, work_dir, sub_path=sub_path, gh=gh, - no_cache=no_cache, + cache=cache, show_build_steps=show_build_steps, ) # Non-JavaScript actions (docker, composite) don't have compiled JS to verify @@ -1422,7 +1428,7 @@ def get_gh_user(gh: GitHubClient | None = None) -> str: return gh.get_authenticated_user() -def check_dependabot_prs(gh: GitHubClient, no_cache: bool = False) -> None: +def check_dependabot_prs(gh: GitHubClient, cache: bool = True, show_build_steps: bool = False) -> None: """List open dependabot PRs, verify each, and optionally merge.""" console.print() console.rule("[bold]Dependabot PR Review[/bold]") @@ -1557,11 +1563,11 @@ def check_dependabot_prs(gh: GitHubClient, no_cache: bool = False) -> None: sub_ref = f"{org_repo}/{sp}@{commit_hash}" else: sub_ref = f"{org_repo}@{commit_hash}" - if not verify_single_action(sub_ref, gh=gh, no_cache=no_cache): + if not verify_single_action(sub_ref, gh=gh, cache=cache, show_build_steps=show_build_steps): passed = False else: # Simple single action (no sub-path) - if not verify_single_action(f"{org_repo}@{commit_hash}", gh=gh, no_cache=no_cache): + if not verify_single_action(f"{org_repo}@{commit_hash}", gh=gh, cache=cache, show_build_steps=show_build_steps): passed = False if not passed: @@ -1679,10 +1685,16 @@ def main() -> None: action="store_true", help="Build Docker image from scratch without using layer cache", ) + parser.add_argument( + "--show-build-steps", + action="store_true", + help="Show Docker build step summary on successful builds (always shown on failure)", + ) args = parser.parse_args() ci_mode = args.ci - no_cache = args.no_cache + cache = not args.no_cache + show_build_steps = args.show_build_steps if not shutil.which("docker"): console.print("[red]Error:[/red] docker is required but not found in PATH") @@ -1713,12 +1725,12 @@ def main() -> None: _exit(1) for ref in action_refs: console.print(f" Extracted action reference from PR #{args.from_pr}: [bold]{ref}[/bold]") - passed = all(verify_single_action(ref, gh=gh, ci_mode=ci_mode, no_cache=no_cache) for ref in action_refs) + passed = all(verify_single_action(ref, gh=gh, ci_mode=ci_mode, cache=cache, show_build_steps=show_build_steps) for ref in action_refs) _exit(0 if passed else 1) elif args.check_dependabot_prs: - check_dependabot_prs(gh=gh, no_cache=no_cache) + check_dependabot_prs(gh=gh, cache=cache, show_build_steps=show_build_steps) elif args.action_ref: - passed = verify_single_action(args.action_ref, gh=gh, ci_mode=ci_mode, no_cache=no_cache) + passed = verify_single_action(args.action_ref, gh=gh, ci_mode=ci_mode, cache=cache, show_build_steps=show_build_steps) _exit(0 if passed else 1) else: parser.print_help()