Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .github/workflows/update-base-image-shas.yml
Original file line number Diff line number Diff line change
@@ -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
74 changes: 68 additions & 6 deletions build-in-container.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down Expand Up @@ -185,8 +185,60 @@ 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):
"""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"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, so we grab an image from docker, install our build dependencies, then we push that to ghcr.io/cfengine. Think of this as the base image of our base image.

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.write_text(json.dumps(config, indent=2) + "\n")


def run_container(args, image_tag, source_dir, script_dir):
Expand Down Expand Up @@ -310,6 +362,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",
Expand All @@ -332,8 +390,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)
Expand Down Expand Up @@ -367,6 +425,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()
Expand Down
Loading