Skip to content

Commit 7b600fa

Browse files
authored
Merge pull request #2161 from larsewi/baseimages
Extracted platform config to platforms.json and automated image updates
2 parents 91832df + a419325 commit 7b600fa

File tree

5 files changed

+188
-79
lines changed

5 files changed

+188
-79
lines changed

.github/workflows/build-base-images.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
name: Build base images
22

33
on:
4-
workflow_dispatch:
4+
schedule:
5+
- cron: "0 0 * * 0" # Every Sunday at midnight UTC
6+
workflow_dispatch: # Allows manual trigger
57

68
jobs:
79
build-and-push:
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
name: Update base image versions
2+
3+
on:
4+
schedule:
5+
- cron: "0 0 * * 1" # Every Monday at midnight UTC
6+
workflow_dispatch: # Allows manual trigger
7+
8+
jobs:
9+
update:
10+
runs-on: ubuntu-latest
11+
permissions:
12+
contents: write
13+
pull-requests: write
14+
steps:
15+
- name: Checkout repository
16+
uses: actions/checkout@v6
17+
18+
- name: Update platforms.json
19+
run: ./build-in-container.py --update
20+
21+
- name: Create pull request
22+
uses: peter-evans/create-pull-request@v8
23+
with:
24+
commit-message: "Updated base image versions in platforms.json"
25+
branch: update-base-images
26+
title: "Updated base image versions"
27+
body: |
28+
Automated update of `image_version` in `platforms.json` to the
29+
latest tags from ghcr.io.

build-in-container.md

Lines changed: 40 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -38,19 +38,22 @@ specified, defaults will:
3838
| `--role` | `agent` or `hub` (not required for `--push-image`) |
3939
| `--build-type` | `DEBUG` or `RELEASE` (not required for `--push-image`) |
4040

41+
None of the above arguments are required for `--update`.
42+
4143
### Optional arguments
4244

43-
| Option | Default | Description |
44-
| ------------------ | -------------------------------- | ----------------------------------------------------------- |
45-
| `--output-dir` | `./output` | Where to write output packages |
46-
| `--cache-dir` | `~/.cache/cfengine/buildscripts` | Dependency cache directory |
47-
| `--build-number` | `1` | Build number for package versioning |
48-
| `--version` | auto | Override version string |
49-
| `--rebuild-image` | | Force rebuild of Docker image (bypasses Docker layer cache) |
50-
| `--push-image` | | Build image and push to registry, then exit |
51-
| `--shell` | | Drop into a bash shell inside the container for debugging |
52-
| `--list-platforms` | | List available platforms and exit |
53-
| `--source-dir` | parent of `buildscripts/` | Root directory containing repos |
45+
| Option | Default | Description |
46+
| ------------------ | -------------------------------- | ------------------------------------------------------------------- |
47+
| `--output-dir` | `./output` | Where to write output packages |
48+
| `--cache-dir` | `~/.cache/cfengine/buildscripts` | Dependency cache directory |
49+
| `--build-number` | `1` | Build number for package versioning |
50+
| `--version` | auto | Override version string |
51+
| `--rebuild-image` | | Force rebuild of Docker image (bypasses Docker layer cache) |
52+
| `--push-image` | | Build image and push to registry, then exit |
53+
| `--update` | | Fetch latest image versions from registry and update platforms.json |
54+
| `--shell` | | Drop into a bash shell inside the container for debugging |
55+
| `--list-platforms` | | List available platforms and exit |
56+
| `--source-dir` | parent of `buildscripts/` | Root directory containing repos |
5457

5558
## Supported platforms
5659

@@ -62,8 +65,8 @@ specified, defaults will:
6265
| `debian-11` | `debian:11` |
6366
| `debian-12` | `debian:12` |
6467

65-
Adding a new Debian/Ubuntu platform requires only a new entry in the `PLATFORMS`
66-
dict in `build-in-container.py`. Adding a non-debian based platform (e.g.,
68+
Adding a new Debian/Ubuntu platform requires only a new entry in `platforms.json`.
69+
Adding a non-debian based platform (e.g.,
6770
RHEL/CentOS) requires a new `container/Dockerfile.rhel` plus platform entries.
6871

6972
## How it works
@@ -112,8 +115,8 @@ hash and skips rebuilding when nothing has changed.
112115

113116
### Container registry
114117

115-
Images are hosted at `ghcr.io/cfengine` and versioned via `IMAGE_VERSION` in
116-
`build-in-container.py`. To push a new image:
118+
Images are hosted at `ghcr.io/cfengine` and versioned per-platform via
119+
`image_version` in `platforms.json`. To push a new image:
117120

118121
```bash
119122
# Build and push a single platform
@@ -129,7 +132,24 @@ which handles authentication automatically.
129132
#### GitHub Actions workflow
130133

131134
The `build-base-images.yml` workflow builds and pushes images for every
132-
supported platform. It is triggered manually via `workflow_dispatch`.
135+
supported platform. It runs weekly (Sunday at midnight UTC) and can also be
136+
triggered manually via `workflow_dispatch`.
137+
138+
After the workflow pushes new images, update `platforms.json` to use them:
139+
140+
```bash
141+
# Update all platforms to the latest registry version
142+
./build-in-container.py --update
143+
144+
# Update a single platform
145+
./build-in-container.py --update --platform ubuntu-22
146+
```
147+
148+
The `update-base-images.yml` workflow automates this step. It runs weekly
149+
(Monday at midnight UTC) and can also be triggered manually. It calls
150+
`./build-in-container.py --update` and opens a pull request with any
151+
`platforms.json` changes. This workflow requires `contents: write` and
152+
`pull-requests: write` permissions.
133153

134154
The workflow authenticates to `ghcr.io` using the automatic `GITHUB_TOKEN`
135155
provided by GitHub Actions. For this to work:
@@ -140,16 +160,16 @@ provided by GitHub Actions. For this to work:
140160
- After the first push, each package defaults to private. To allow anonymous
141161
pulls, go to the package on GitHub (**your org → Packages**), open **Package
142162
settings**, and change the visibility to **Public**. This is a one-time step
143-
per package — new tags (e.g. from bumping `IMAGE_VERSION`) inherit the
163+
per package — new tags (e.g. from bumping `image_version`) inherit the
144164
existing visibility.
145165

146166
### Updating the toolchain
147167

148168
1. Edit `container/Dockerfile.debian` as needed
149169
2. Test locally with `--rebuild-image`
150-
3. Bump `IMAGE_VERSION` in `build-in-container.py`
151-
4. Commit the Dockerfile change + version bump
152-
5. Push new images by triggering the GitHub Actions workflow
170+
3. Commit and merge the Dockerfile change
171+
4. Push new images by triggering the `build-base-images.yml` workflow
172+
5. Trigger the `update-base-images.yml` workflow to open a PR updating `platforms.json`
153173

154174
## Debugging
155175

build-in-container.py

Lines changed: 76 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -6,49 +6,26 @@
66
"""
77

88
import argparse
9+
import datetime
10+
import functools
911
import hashlib
12+
import json
1013
import logging
1114
import subprocess
1215
import sys
16+
import urllib.request
1317
from pathlib import Path
1418

1519
log = logging.getLogger("build-in-container")
1620

1721
IMAGE_REGISTRY = "ghcr.io/cfengine"
18-
IMAGE_VERSION = "1"
19-
20-
PLATFORMS = {
21-
"ubuntu-20": {
22-
"image_tag": f"cfengine-builder-ubuntu-20:{IMAGE_VERSION}",
23-
"base_image": "ubuntu:20.04",
24-
"dockerfile": "Dockerfile.debian",
25-
"extra_build_args": {"NCURSES_PKGS": "libncurses5 libncurses5-dev"},
26-
},
27-
"ubuntu-22": {
28-
"image_tag": f"cfengine-builder-ubuntu-22:{IMAGE_VERSION}",
29-
"base_image": "ubuntu:22.04",
30-
"dockerfile": "Dockerfile.debian",
31-
"extra_build_args": {},
32-
},
33-
"ubuntu-24": {
34-
"image_tag": f"cfengine-builder-ubuntu-24:{IMAGE_VERSION}",
35-
"base_image": "ubuntu:24.04",
36-
"dockerfile": "Dockerfile.debian",
37-
"extra_build_args": {},
38-
},
39-
"debian-11": {
40-
"image_tag": f"cfengine-builder-debian-11:{IMAGE_VERSION}",
41-
"base_image": "debian:11",
42-
"dockerfile": "Dockerfile.debian",
43-
"extra_build_args": {},
44-
},
45-
"debian-12": {
46-
"image_tag": f"cfengine-builder-debian-12:{IMAGE_VERSION}",
47-
"base_image": "debian:12",
48-
"dockerfile": "Dockerfile.debian",
49-
"extra_build_args": {},
50-
},
51-
}
22+
23+
24+
@functools.cache
25+
def get_config():
26+
"""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())
5229

5330

5431
def detect_source_dir():
@@ -88,7 +65,7 @@ def image_needs_rebuild(image_tag, current_hash):
8865

8966
def build_image(platform_name, platform_config, script_dir, rebuild=False):
9067
"""Build the Docker image for the given platform."""
91-
image_tag = platform_config["image_tag"]
68+
image_tag = f"{platform_config['image_name']}:{platform_config['image_version']}"
9269
dockerfile_name = platform_config["dockerfile"]
9370
dockerfile_path = script_dir / "container" / dockerfile_name
9471
current_hash = dockerfile_hash(dockerfile_path)
@@ -104,7 +81,7 @@ def build_image(platform_name, platform_config, script_dir, rebuild=False):
10481
"-f",
10582
str(dockerfile_path),
10683
"--build-arg",
107-
f"BASE_IMAGE={platform_config['base_image']}",
84+
f"BASE_IMAGE={platform_config['base_image']}@{platform_config['base_image_sha']}",
10885
"--label",
10986
f"dockerfile-hash={current_hash}",
11087
"-t",
@@ -132,7 +109,8 @@ def build_image(platform_name, platform_config, script_dir, rebuild=False):
132109

133110
def registry_image_ref(platform_name):
134111
"""Return the fully-qualified registry image reference for a platform."""
135-
return f"{IMAGE_REGISTRY}/{PLATFORMS[platform_name]['image_tag']}"
112+
platform = get_config()[platform_name]
113+
return f"{IMAGE_REGISTRY}/{platform['image_name']}:{platform['image_version']}"
136114

137115

138116
def pull_image(platform_name):
@@ -152,24 +130,11 @@ def pull_image(platform_name):
152130
return ref
153131

154132

155-
def image_exists_in_registry(platform_name):
156-
"""Check if an image tag already exists in the registry."""
157-
ref = registry_image_ref(platform_name)
158-
result = subprocess.run(
159-
["docker", "manifest", "inspect", ref],
160-
capture_output=True,
161-
text=True,
162-
)
163-
return result.returncode == 0
164-
165-
166133
def push_image(platform_name, local_tag):
167-
"""Tag a local image with the registry reference and push it."""
168-
ref = registry_image_ref(platform_name)
169-
170-
if image_exists_in_registry(platform_name):
171-
log.error(f"Image {ref} already exists. Bump IMAGE_VERSION.")
172-
sys.exit(1)
134+
"""Tag a local image with a timestamped version and push it."""
135+
image_name = get_config()[platform_name]["image_name"]
136+
version = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%dT%H%M%SZ")
137+
ref = f"{IMAGE_REGISTRY}/{image_name}:{version}"
173138

174139
log.info(f"Tagging {local_tag} as {ref}...")
175140
result = subprocess.run(["docker", "tag", local_tag, ref])
@@ -183,6 +148,46 @@ def push_image(platform_name, local_tag):
183148
log.error("Docker push failed.")
184149
sys.exit(1)
185150

151+
log.info(f"Update image_version to \"{version}\" in platforms.json.")
152+
153+
154+
def latest_registry_version(image_name):
155+
"""Query ghcr.io for the latest tag of an image."""
156+
# Anonymous token — no credentials needed for public images
157+
token_url = f"https://ghcr.io/token?scope=repository:cfengine/{image_name}:pull"
158+
token = json.loads(urllib.request.urlopen(token_url).read())["token"]
159+
160+
tags_url = f"https://ghcr.io/v2/cfengine/{image_name}/tags/list"
161+
req = urllib.request.Request(
162+
tags_url, headers={"Authorization": f"Bearer {token}"}
163+
)
164+
tags = json.loads(urllib.request.urlopen(req).read()).get("tags", [])
165+
if not tags:
166+
return None
167+
return sorted(tags)[-1]
168+
169+
170+
def update_platform_versions(platform_name=None):
171+
"""Fetch latest image versions from the registry and update platforms.json."""
172+
config = get_config()
173+
174+
platforms = [platform_name] if platform_name else list(config.keys())
175+
for name in platforms:
176+
image_name = config[name]["image_name"]
177+
latest = latest_registry_version(image_name)
178+
if latest is None:
179+
log.warning(f"No tags found for {image_name}, skipping.")
180+
continue
181+
old = config[name]["image_version"]
182+
if old == latest:
183+
log.info(f"{name}: already at {latest}")
184+
else:
185+
config[name]["image_version"] = latest
186+
log.info(f"{name}: {old} -> {latest}")
187+
188+
config_path = Path(__file__).resolve().parent / "platforms.json"
189+
config_path.write_text(json.dumps(config, indent=2) + "\n")
190+
186191

187192
def run_container(args, image_tag, source_dir, script_dir):
188193
"""Run the build inside a Docker container."""
@@ -252,7 +257,7 @@ def parse_args():
252257
)
253258
parser.add_argument(
254259
"--platform",
255-
choices=list(PLATFORMS.keys()),
260+
choices=list(get_config().keys()),
256261
help="Target platform",
257262
)
258263
parser.add_argument(
@@ -300,6 +305,11 @@ def parse_args():
300305
action="store_true",
301306
help="Build image and push to registry, then exit",
302307
)
308+
parser.add_argument(
309+
"--update",
310+
action="store_true",
311+
help="Fetch latest image version from registry and update platforms.json",
312+
)
303313
parser.add_argument(
304314
"--shell",
305315
action="store_true",
@@ -318,11 +328,15 @@ def parse_args():
318328

319329
if args.list_platforms:
320330
print("Available platforms:")
321-
for name, config in PLATFORMS.items():
331+
for name, config in get_config().items():
322332
print(f" {name:15s} ({config['base_image']})")
323333
sys.exit(0)
324334

325-
# --platform is always required (except --list-platforms handled above)
335+
if args.update:
336+
# --platform is optional for --update; updates all if omitted
337+
return args
338+
339+
# --platform is always required (except --list-platforms/--update handled above)
326340
if not args.platform:
327341
parser.error("missing required argument --platform")
328342

@@ -349,6 +363,10 @@ def main():
349363
format="%(message)s",
350364
)
351365

366+
if args.update:
367+
update_platform_versions(args.platform)
368+
return
369+
352370
# Detect source directory
353371
if args.source_dir:
354372
source_dir = Path(args.source_dir).resolve()
@@ -357,7 +375,7 @@ def main():
357375

358376
script_dir = source_dir / "buildscripts"
359377

360-
platform_config = PLATFORMS[args.platform]
378+
platform_config = get_config()[args.platform]
361379

362380
if args.push_image:
363381
image_tag = build_image(

0 commit comments

Comments
 (0)