From 3d2d7c23409e2844e7fb3db647adbef0de3de3be Mon Sep 17 00:00:00 2001 From: Lars Erik Wik Date: Wed, 22 Apr 2026 13:48:30 +0200 Subject: [PATCH 1/3] Add --update-sha mode to refresh base image digests in platforms.json Queries Docker Hub for the current manifest digest of each platform's base_image and rewrites base_image_sha accordingly. Intended to be wired into the dependency update workflow so upstream base image pins are refreshed alongside other dependencies. Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: Lars Erik Wik --- build-in-container.py | 68 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 66 insertions(+), 2 deletions(-) diff --git a/build-in-container.py b/build-in-container.py index 620d7a04d..413317b5f 100755 --- a/build-in-container.py +++ b/build-in-container.py @@ -189,6 +189,60 @@ def update_platform_versions(platform_name=None): config_path.write_text(json.dumps(config, indent=2) + "\n") +def latest_base_image_digest(base_image): + """Fetch current manifest digest from Docker Hub for a base image.""" + # Docker Hub's v2 API path requires a namespace. Official images (ubuntu, + # debian, ...) live under "library/". + repo, tag = base_image.rsplit(":", 1) + repo = f"library/{repo}" + + # The v2 API requires a bearer token even for anonymous public pulls. + token_url = ( + "https://auth.docker.io/token" + f"?service=registry.docker.io&scope=repository:{repo}:pull" + ) + token = json.loads(urllib.request.urlopen(token_url).read())["token"] + + # Accept only the OCI multi-arch index format: this gives the fat manifest + # digest (what `docker pull` pins to) rather than an arch-specific one. + # Docker Hub official images are all published as OCI indexes today; if an + # image is ever served in the older Docker manifest.list.v2 format instead, + # the registry will reject the request with 406. + manifest_url = f"https://registry-1.docker.io/v2/{repo}/manifests/{tag}" + accept = "application/vnd.oci.image.index.v1+json" + + # HEAD skips the manifest body; the digest comes back in a response header. + req = urllib.request.Request( + manifest_url, + headers={"Authorization": f"Bearer {token}", "Accept": accept}, + method="HEAD", + ) + with urllib.request.urlopen(req) as resp: + return resp.headers.get("Docker-Content-Digest") + + +def update_base_image_shas(platform_name=None): + """Update base_image_sha in platforms.json to the latest Docker Hub digest.""" + config = get_config() + + platforms = [platform_name] if platform_name else list(config.keys()) + for name in platforms: + base_image = config[name]["base_image"] + latest = latest_base_image_digest(base_image) + if latest is None: + log.warning(f"No digest returned for {base_image}, skipping.") + continue + old = config[name]["base_image_sha"] + if old == latest: + log.info(f"{name}: {base_image} already at {latest}") + else: + config[name]["base_image_sha"] = latest + log.info(f"{name}: {base_image} {old} -> {latest}") + + config_path = Path(__file__).resolve().parent / "platforms.json" + config_path.write_text(json.dumps(config, indent=2) + "\n") + + def run_container(args, image_tag, source_dir, script_dir): """Run the build inside a Docker container.""" output_dir = Path(args.output_dir).resolve() @@ -310,6 +364,12 @@ def parse_args(): action="store_true", help="Fetch latest image version from registry and update platforms.json", ) + parser.add_argument( + "--update-sha", + dest="update_sha", + action="store_true", + help="Fetch latest base image digest from Docker Hub and update platforms.json", + ) parser.add_argument( "--shell", action="store_true", @@ -332,8 +392,8 @@ def parse_args(): print(f" {name:15s} ({config['base_image']})") sys.exit(0) - if args.update: - # --platform is optional for --update; updates all if omitted + if args.update or args.update_sha: + # --platform is optional for these modes; updates all if omitted return args # --platform is always required (except --list-platforms/--update handled above) @@ -367,6 +427,10 @@ def main(): update_platform_versions(args.platform) return + if args.update_sha: + update_base_image_shas(args.platform) + return + # Detect source directory if args.source_dir: source_dir = Path(args.source_dir).resolve() From f90105658ea80b43a47c0d519109bbec3a9509f9 Mon Sep 17 00:00:00 2001 From: Lars Erik Wik Date: Wed, 22 Apr 2026 14:19:37 +0200 Subject: [PATCH 2/3] Refactored platforms.json path into a CONFIG_PATH constant The same Path(__file__).resolve().parent / "platforms.json" expression was computed in three places. Defining it once removes the duplication without changing behavior. Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: Lars Erik Wik --- build-in-container.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/build-in-container.py b/build-in-container.py index 413317b5f..adc21fe68 100755 --- a/build-in-container.py +++ b/build-in-container.py @@ -19,13 +19,13 @@ log = logging.getLogger("build-in-container") IMAGE_REGISTRY = "ghcr.io/cfengine" +CONFIG_PATH = Path(__file__).resolve().parent / "platforms.json" @functools.cache def get_config(): """Load and cache platform configuration from platforms.json.""" - config_path = Path(__file__).resolve().parent / "platforms.json" - return json.loads(config_path.read_text()) + return json.loads(CONFIG_PATH.read_text()) def detect_source_dir(): @@ -185,8 +185,7 @@ def update_platform_versions(platform_name=None): config[name]["image_version"] = latest log.info(f"{name}: {old} -> {latest}") - config_path = Path(__file__).resolve().parent / "platforms.json" - config_path.write_text(json.dumps(config, indent=2) + "\n") + CONFIG_PATH.write_text(json.dumps(config, indent=2) + "\n") def latest_base_image_digest(base_image): @@ -239,8 +238,7 @@ def update_base_image_shas(platform_name=None): config[name]["base_image_sha"] = latest log.info(f"{name}: {base_image} {old} -> {latest}") - config_path = Path(__file__).resolve().parent / "platforms.json" - config_path.write_text(json.dumps(config, indent=2) + "\n") + CONFIG_PATH.write_text(json.dumps(config, indent=2) + "\n") def run_container(args, image_tag, source_dir, script_dir): From 2815895276e265229c8979e3bee7d45dd4489807 Mon Sep 17 00:00:00 2001 From: Lars Erik Wik Date: Wed, 22 Apr 2026 14:31:33 +0200 Subject: [PATCH 3/3] Added workflow to update base image SHAs in platforms.json Runs build-in-container.py --update-sha on a weekly schedule and opens a PR with any refreshed digests. Modeled on update-base-images.yml, which performs the analogous job for image_version. Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: Lars Erik Wik --- .github/workflows/update-base-image-shas.yml | 33 ++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 .github/workflows/update-base-image-shas.yml diff --git a/.github/workflows/update-base-image-shas.yml b/.github/workflows/update-base-image-shas.yml new file mode 100644 index 000000000..f670e3763 --- /dev/null +++ b/.github/workflows/update-base-image-shas.yml @@ -0,0 +1,33 @@ +name: Update base image SHAs + +on: + schedule: + - cron: "0 1 * * 1" # Every Monday at 01:00 UTC + workflow_dispatch: # Allows manual trigger + +jobs: + update: + if: contains(fromJSON('["cfengine","mendersoftware","NorthernTechHQ"]'), github.repository_owner) + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Update platforms.json + run: ./build-in-container.py --update-sha + + - name: Create pull request + uses: peter-evans/create-pull-request@v8 + with: + commit-message: "Updated base image SHAs in platforms.json" + branch: update-base-image-shas + title: "Updated base image SHAs" + body: | + Automated update of `base_image_sha` in `platforms.json` to the + current manifest digests from Docker Hub. + reviewers: | + larsewi + craigcomstock