From 2414f6288d33dd8824c575c5a5e9e71a9b1193d4 Mon Sep 17 00:00:00 2001 From: Amber Arcadia Date: Thu, 26 Mar 2026 19:18:22 -0400 Subject: [PATCH 1/3] Add auto-update for melange image if older than 30 days Automatically checks the age of the local melange Docker image and pulls an updated version if it's older than 30 days. Gives users a 15-second window to abort with Ctrl+C before pulling. --- pre_commit_hooks/shellcheck_run_steps.py | 59 ++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/pre_commit_hooks/shellcheck_run_steps.py b/pre_commit_hooks/shellcheck_run_steps.py index dece31c..b3356fe 100644 --- a/pre_commit_hooks/shellcheck_run_steps.py +++ b/pre_commit_hooks/shellcheck_run_steps.py @@ -2,11 +2,16 @@ import argparse import contextlib +import json import os import subprocess +import sys import tempfile +import time from collections.abc import Mapping from collections.abc import Sequence +from datetime import datetime +from datetime import timezone from typing import Any import ruamel.yaml @@ -100,7 +105,61 @@ def do_shellcheck( return True +def check_and_update_melange_image(image: str) -> None: + """Check if melange image is older than 30 days and pull if needed.""" + try: + # Get image creation date + result = subprocess.run( + ["docker", "image", "inspect", image, "--format", "{{json .Created}}"], + capture_output=True, + text=True, + check=False, + ) + + if result.returncode != 0: + # Image doesn't exist locally, pull it + print( + f"Melange image not found locally, pulling {image}...", + file=sys.stderr, + ) + subprocess.run(["docker", "pull", image], check=True) + return + + # Parse the creation date + created_str = json.loads(result.stdout.strip()) + created_date = datetime.fromisoformat(created_str.replace("Z", "+00:00")) + now = datetime.now(timezone.utc) + age_days = (now - created_date).days + + if age_days > 30: + print( + f"⚠️ Melange image is {age_days} days old (created {created_date.strftime('%Y-%m-%d')})", + file=sys.stderr, + ) + print(f"⚠️ Pulling updated melange image: {image}", file=sys.stderr) + print( + "⚠️ Press Ctrl+C now to abort or wait 15 seconds to continue...", + file=sys.stderr, + ) + + # Give user 15 seconds to abort + time.sleep(15) + + print(f"Pulling {image}...", file=sys.stderr) + subprocess.run(["docker", "pull", image], check=True) + print("✓ Melange image updated successfully", file=sys.stderr) + + except KeyboardInterrupt: + print("\n⚠️ Update aborted by user, using existing image", file=sys.stderr) + except Exception as e: + print(f"Warning: Failed to check/update melange image: {e}", file=sys.stderr) + print("Continuing with existing image...", file=sys.stderr) + + def main(argv: Sequence[str] | None = None) -> int: + # Check and update melange image if needed + check_and_update_melange_image(MelangeImage) + parser = argparse.ArgumentParser() parser.add_argument( "filenames", From 63f0345ef1a959e85ec98a6e1914389a72623979 Mon Sep 17 00:00:00 2001 From: Amber Arcadia Date: Thu, 26 Mar 2026 21:19:32 -0400 Subject: [PATCH 2/3] Remove unused imports --- pre_commit_hooks/shellcheck_run_steps.py | 36 ++++++++---------------- 1 file changed, 11 insertions(+), 25 deletions(-) diff --git a/pre_commit_hooks/shellcheck_run_steps.py b/pre_commit_hooks/shellcheck_run_steps.py index b3356fe..8b4d081 100644 --- a/pre_commit_hooks/shellcheck_run_steps.py +++ b/pre_commit_hooks/shellcheck_run_steps.py @@ -5,9 +5,7 @@ import json import os import subprocess -import sys import tempfile -import time from collections.abc import Mapping from collections.abc import Sequence from datetime import datetime @@ -118,11 +116,11 @@ def check_and_update_melange_image(image: str) -> None: if result.returncode != 0: # Image doesn't exist locally, pull it - print( - f"Melange image not found locally, pulling {image}...", - file=sys.stderr, + subprocess.run( + ["docker", "pull", image], + check=True, + capture_output=True, ) - subprocess.run(["docker", "pull", image], check=True) return # Parse the creation date @@ -132,28 +130,16 @@ def check_and_update_melange_image(image: str) -> None: age_days = (now - created_date).days if age_days > 30: - print( - f"⚠️ Melange image is {age_days} days old (created {created_date.strftime('%Y-%m-%d')})", - file=sys.stderr, + # Pull updated image + subprocess.run( + ["docker", "pull", image], + check=True, + capture_output=True, ) - print(f"⚠️ Pulling updated melange image: {image}", file=sys.stderr) - print( - "⚠️ Press Ctrl+C now to abort or wait 15 seconds to continue...", - file=sys.stderr, - ) - - # Give user 15 seconds to abort - time.sleep(15) - - print(f"Pulling {image}...", file=sys.stderr) - subprocess.run(["docker", "pull", image], check=True) - print("✓ Melange image updated successfully", file=sys.stderr) - except KeyboardInterrupt: - print("\n⚠️ Update aborted by user, using existing image", file=sys.stderr) except Exception as e: - print(f"Warning: Failed to check/update melange image: {e}", file=sys.stderr) - print("Continuing with existing image...", file=sys.stderr) + # Print warning to stdout (like epoch check does with echo) + print(f"Warning: Failed to check/update melange image: {e}") def main(argv: Sequence[str] | None = None) -> int: From 2cde8d74c47cc6d2130fc3345c779e647beb2398 Mon Sep 17 00:00:00 2001 From: Amber Arcadia Date: Fri, 2 Oct 2026 13:50:14 -0400 Subject: [PATCH 3/3] shellcheck-run-steps: refresh via --pull=always, 7-day default, configurable Addresses the review feedback on #30: - The age check now runs after argument parsing, so `--help` no longer touches docker (stevebeattie). - The default threshold drops from 30 to 7 days and is configurable with `--max-image-age-days DAYS`; 0 refreshes on every run (dannf). - `docker_image_outdated()` returns a bool and the first `docker run` of the invocation carries `--pull=always` when it is true, replacing the two separate `docker pull` paths (dannf). A missing image takes the same route as a stale one. Also fixes timestamp parsing. `docker image inspect` prints RFC 3339 with up to nanosecond precision (the shellcheck image reports 2025-07-31T02:42:03.371039353Z) and datetime.fromisoformat() on Python 3.9 and 3.10 rejects anything but 0, 3 or 6 fractional digits, while setup.cfg still declares python_requires >= 3.9. The fraction is now stripped before parsing; verified under python:3.10-alpine. The shellcheck image is pinned by digest, so it is left alone: a digest never goes stale and `docker run` already pulls a missing one. --- pre_commit_hooks/shellcheck_run_steps.py | 105 ++++++++++++++--------- 1 file changed, 64 insertions(+), 41 deletions(-) diff --git a/pre_commit_hooks/shellcheck_run_steps.py b/pre_commit_hooks/shellcheck_run_steps.py index 8b4d081..1f83e8f 100644 --- a/pre_commit_hooks/shellcheck_run_steps.py +++ b/pre_commit_hooks/shellcheck_run_steps.py @@ -2,13 +2,14 @@ import argparse import contextlib -import json import os +import re import subprocess import tempfile from collections.abc import Mapping from collections.abc import Sequence from datetime import datetime +from datetime import timedelta from datetime import timezone from typing import Any @@ -19,8 +20,18 @@ # Please provide the output of `grype koalaman/shellcheck@sha256:` # in your PR when bumping. Referenced by SHA for safety. DefaultShellCheckImage = "koalaman/shellcheck@sha256:652a5a714dc2f5f97e36f565d4f7d2322fea376734f3ec1b04ed54ce2a0b124f" -# 0.31.6 pinning to resolve var-transforms linting error +# Tracks the latest melange; docker_image_outdated() below refreshes a stale +# local copy so new config fields (e.g. `test-resources`) compile. MelangeImage = "cgr.dev/chainguard/melange:latest" +# How old the local melange image may get before `docker run` re-pulls it. +# Override per repo with `args: [--max-image-age-days=N]`. +DefaultMaxImageAgeDays = 7 + +# `docker image inspect --format {{.Created}}` prints RFC 3339 with up to +# nanosecond precision, e.g. 2025-07-31T02:42:03.371039353Z. Python < 3.11 +# datetime.fromisoformat() accepts only 0, 3 or 6 fractional digits, so the +# fraction is dropped before parsing; day-level age does not need it. +_FRACTIONAL_SECONDS = re.compile(r"\.\d+(?=(?:Z|[+-]\d{2}:\d{2})$)") # Pipeline directories per package tier, matching stereo's dirToPipelineDirs. # Paths are relative to the repo root (the Docker /work mount). @@ -103,49 +114,44 @@ def do_shellcheck( return True -def check_and_update_melange_image(image: str) -> None: - """Check if melange image is older than 30 days and pull if needed.""" - try: - # Get image creation date - result = subprocess.run( - ["docker", "image", "inspect", image, "--format", "{{json .Created}}"], - capture_output=True, - text=True, - check=False, - ) - - if result.returncode != 0: - # Image doesn't exist locally, pull it - subprocess.run( - ["docker", "pull", image], - check=True, - capture_output=True, - ) - return - - # Parse the creation date - created_str = json.loads(result.stdout.strip()) - created_date = datetime.fromisoformat(created_str.replace("Z", "+00:00")) - now = datetime.now(timezone.utc) - age_days = (now - created_date).days - - if age_days > 30: - # Pull updated image - subprocess.run( - ["docker", "pull", image], - check=True, - capture_output=True, - ) +def parse_docker_timestamp(created: str) -> datetime: + """Parse a docker `.Created` value into an aware datetime.""" + created = _FRACTIONAL_SECONDS.sub("", created.strip()) + if created.endswith("Z"): + created = created[:-1] + "+00:00" + return datetime.fromisoformat(created) - except Exception as e: - # Print warning to stdout (like epoch check does with echo) - print(f"Warning: Failed to check/update melange image: {e}") +def docker_image_outdated(image: str, max_age: timedelta) -> bool: + """Return True if *image* is absent locally or was created more than *max_age* ago. -def main(argv: Sequence[str] | None = None) -> int: - # Check and update melange image if needed - check_and_update_melange_image(MelangeImage) + The caller turns this into `docker run --pull=always`, so there is a single + pull code path and a missing image is handled the same way as a stale one. + If docker itself is unusable the later `docker run` reports that with its + own error, so this only answers the age question. + """ + result = subprocess.run( + ["docker", "image", "inspect", image, "--format", "{{.Created}}"], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + return True + try: + created = parse_docker_timestamp(result.stdout) + except ValueError as exc: + print(f"Warning: cannot parse creation time of {image}: {exc}") + return False + age = datetime.now(timezone.utc) - created + if age > max_age: + # flush so the line lands before docker's own pull output + print(f"{image} is {age.days} days old; refreshing it", flush=True) + return True + return False + +def main(argv: Sequence[str] | None = None) -> int: parser = argparse.ArgumentParser() parser.add_argument( "filenames", @@ -164,6 +170,14 @@ def main(argv: Sequence[str] | None = None) -> int: nargs="*", help="shellcheck command", ) + parser.add_argument( + "--max-image-age-days", + type=int, + default=DefaultMaxImageAgeDays, + metavar="DAYS", + help="refresh the melange image when the local copy is older than this " + "many days (default: %(default)s; 0 refreshes on every run)", + ) args = parser.parse_args(argv) try: idx = args.filenames.index("--") @@ -173,6 +187,12 @@ def main(argv: Sequence[str] | None = None) -> int: shellcheck_args = [] filenames = args.filenames + # Decided once per invocation, after argument parsing so that `--help` + # never touches docker. The first `docker run` below performs the refresh. + pull_policy = "missing" + if docker_image_outdated(MelangeImage, timedelta(days=args.max_image_age_days)): + pull_policy = "always" + fail_cnt = 0 melange_cfg = {} for filename in filenames: @@ -187,6 +207,7 @@ def main(argv: Sequence[str] | None = None) -> int: [ "docker", "run", + f"--pull={pull_policy}", f"--volume={os.getcwd()}:/work:Z", "--rm", MelangeImage, @@ -197,6 +218,8 @@ def main(argv: Sequence[str] | None = None) -> int: ], stdout=compiled_out, ) + # One refresh per invocation is enough. + pull_policy = "missing" compiled_out.close() try: with open(compiled_out.name) as compiled_in: