Skip to content

Commit 753f735

Browse files
authored
Merge pull request #2189 from larsewi/update-base-image-shas
Refresh base image SHAs in platforms.json via weekly workflow
2 parents 381a951 + 2815895 commit 753f735

2 files changed

Lines changed: 101 additions & 6 deletions

File tree

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
name: Update base image SHAs
2+
3+
on:
4+
schedule:
5+
- cron: "0 1 * * 1" # Every Monday at 01:00 UTC
6+
workflow_dispatch: # Allows manual trigger
7+
8+
jobs:
9+
update:
10+
if: contains(fromJSON('["cfengine","mendersoftware","NorthernTechHQ"]'), github.repository_owner)
11+
runs-on: ubuntu-latest
12+
permissions:
13+
contents: write
14+
pull-requests: write
15+
steps:
16+
- name: Checkout repository
17+
uses: actions/checkout@v6
18+
19+
- name: Update platforms.json
20+
run: ./build-in-container.py --update-sha
21+
22+
- name: Create pull request
23+
uses: peter-evans/create-pull-request@v8
24+
with:
25+
commit-message: "Updated base image SHAs in platforms.json"
26+
branch: update-base-image-shas
27+
title: "Updated base image SHAs"
28+
body: |
29+
Automated update of `base_image_sha` in `platforms.json` to the
30+
current manifest digests from Docker Hub.
31+
reviewers: |
32+
larsewi
33+
craigcomstock

build-in-container.py

Lines changed: 68 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,13 @@
1919
log = logging.getLogger("build-in-container")
2020

2121
IMAGE_REGISTRY = "ghcr.io/cfengine"
22+
CONFIG_PATH = Path(__file__).resolve().parent / "platforms.json"
2223

2324

2425
@functools.cache
2526
def get_config():
2627
"""Load and cache platform configuration from platforms.json."""
27-
config_path = Path(__file__).resolve().parent / "platforms.json"
28-
return json.loads(config_path.read_text())
28+
return json.loads(CONFIG_PATH.read_text())
2929

3030

3131
def detect_source_dir():
@@ -185,8 +185,60 @@ def update_platform_versions(platform_name=None):
185185
config[name]["image_version"] = latest
186186
log.info(f"{name}: {old} -> {latest}")
187187

188-
config_path = Path(__file__).resolve().parent / "platforms.json"
189-
config_path.write_text(json.dumps(config, indent=2) + "\n")
188+
CONFIG_PATH.write_text(json.dumps(config, indent=2) + "\n")
189+
190+
191+
def latest_base_image_digest(base_image):
192+
"""Fetch current manifest digest from Docker Hub for a base image."""
193+
# Docker Hub's v2 API path requires a namespace. Official images (ubuntu,
194+
# debian, ...) live under "library/".
195+
repo, tag = base_image.rsplit(":", 1)
196+
repo = f"library/{repo}"
197+
198+
# The v2 API requires a bearer token even for anonymous public pulls.
199+
token_url = (
200+
"https://auth.docker.io/token"
201+
f"?service=registry.docker.io&scope=repository:{repo}:pull"
202+
)
203+
token = json.loads(urllib.request.urlopen(token_url).read())["token"]
204+
205+
# Accept only the OCI multi-arch index format: this gives the fat manifest
206+
# digest (what `docker pull` pins to) rather than an arch-specific one.
207+
# Docker Hub official images are all published as OCI indexes today; if an
208+
# image is ever served in the older Docker manifest.list.v2 format instead,
209+
# the registry will reject the request with 406.
210+
manifest_url = f"https://registry-1.docker.io/v2/{repo}/manifests/{tag}"
211+
accept = "application/vnd.oci.image.index.v1+json"
212+
213+
# HEAD skips the manifest body; the digest comes back in a response header.
214+
req = urllib.request.Request(
215+
manifest_url,
216+
headers={"Authorization": f"Bearer {token}", "Accept": accept},
217+
method="HEAD",
218+
)
219+
with urllib.request.urlopen(req) as resp:
220+
return resp.headers.get("Docker-Content-Digest")
221+
222+
223+
def update_base_image_shas(platform_name=None):
224+
"""Update base_image_sha in platforms.json to the latest Docker Hub digest."""
225+
config = get_config()
226+
227+
platforms = [platform_name] if platform_name else list(config.keys())
228+
for name in platforms:
229+
base_image = config[name]["base_image"]
230+
latest = latest_base_image_digest(base_image)
231+
if latest is None:
232+
log.warning(f"No digest returned for {base_image}, skipping.")
233+
continue
234+
old = config[name]["base_image_sha"]
235+
if old == latest:
236+
log.info(f"{name}: {base_image} already at {latest}")
237+
else:
238+
config[name]["base_image_sha"] = latest
239+
log.info(f"{name}: {base_image} {old} -> {latest}")
240+
241+
CONFIG_PATH.write_text(json.dumps(config, indent=2) + "\n")
190242

191243

192244
def run_container(args, image_tag, source_dir, script_dir):
@@ -310,6 +362,12 @@ def parse_args():
310362
action="store_true",
311363
help="Fetch latest image version from registry and update platforms.json",
312364
)
365+
parser.add_argument(
366+
"--update-sha",
367+
dest="update_sha",
368+
action="store_true",
369+
help="Fetch latest base image digest from Docker Hub and update platforms.json",
370+
)
313371
parser.add_argument(
314372
"--shell",
315373
action="store_true",
@@ -332,8 +390,8 @@ def parse_args():
332390
print(f" {name:15s} ({config['base_image']})")
333391
sys.exit(0)
334392

335-
if args.update:
336-
# --platform is optional for --update; updates all if omitted
393+
if args.update or args.update_sha:
394+
# --platform is optional for these modes; updates all if omitted
337395
return args
338396

339397
# --platform is always required (except --list-platforms/--update handled above)
@@ -367,6 +425,10 @@ def main():
367425
update_platform_versions(args.platform)
368426
return
369427

428+
if args.update_sha:
429+
update_base_image_shas(args.platform)
430+
return
431+
370432
# Detect source directory
371433
if args.source_dir:
372434
source_dir = Path(args.source_dir).resolve()

0 commit comments

Comments
 (0)