From 10473d008ee7c76af468a0a41c91a565d2d7869d Mon Sep 17 00:00:00 2001 From: Enrico Rubboli Date: Tue, 6 Oct 2026 21:05:29 +0200 Subject: [PATCH 1/3] ci(docker): support arm64 builds and multi-arch manifest assembly build.py gains --platform (amd64|arm64) and --tag_suffix: tags are suffixed inside build/push (with a local alias kept unsuffixed so app image builds can still reference the base images), and the version input stays unsuffixed to avoid double-suffixing. release_docker.yml becomes platform-aware via workflow_dispatch inputs (version, platform, tag_suffix, runner) so arm64 images can be built natively (ubuntu-24.04-arm) and pushed as '-arm64' suffixed tags. The new assemble_manifests input runs a registry-side job that turns per-arch suffixed tags into multi-arch manifest lists under the unsuffixed tags (docker buildx imagetools create; nothing rebuilt, idempotent snapshot of the historical amd64 layout under explicit '-amd64' tags first). Kept in the same workflow because GitHub validates workflow registrations against the default branch, so a separate dispatch-only workflow could not be used from a release branch. Note: single-arch release builds (workflow_call) behave exactly as before. --- .github/workflows/release_docker.yml | 126 ++++++++++++++++++++++++--- build-tools/docker/README.md | 9 ++ build-tools/docker/build.py | 63 +++++++++++--- 3 files changed, 174 insertions(+), 24 deletions(-) diff --git a/.github/workflows/release_docker.yml b/.github/workflows/release_docker.yml index f7406653ab..9d86badf86 100644 --- a/.github/workflows/release_docker.yml +++ b/.github/workflows/release_docker.yml @@ -2,10 +2,33 @@ name: Build Docker on: workflow_call: + workflow_dispatch: + inputs: + version: + description: 'Version for image tags, e.g. 1.4.1 (defaults to the short commit SHA when empty)' + required: false + default: '' + platform: + description: 'Target CPU architecture (amd64 or arm64)' + required: false + default: 'amd64' + tag_suffix: + description: 'Suffix appended to every image tag, e.g. arm64 (leave empty for the default arch)' + required: false + default: '' + runner: + description: 'Runner label, e.g. ubuntu-24.04-arm for a native arm64 build' + required: false + default: 'ubuntu-latest' + assemble_manifests: + description: "Skip image builds and only assemble multi-arch manifests from already-pushed '-amd64'/'-arm64' tags" + required: false + default: 'false' jobs: build-docker-images: - runs-on: ubuntu-latest + if: github.event_name == 'workflow_call' || inputs.assemble_manifests != 'true' + runs-on: ${{ inputs.runner || 'ubuntu-latest' }} steps: - name: Checkout code uses: actions/checkout@v5 @@ -33,20 +56,28 @@ jobs: - name: Determine version id: version + env: + INPUT_VERSION: ${{ inputs.version }} run: | - if [[ $GITHUB_REF == refs/tags/* ]]; then - VERSION=${GITHUB_REF#refs/tags/} + validate() { # Validate the grammar up front (the version flows into docker build/push - # command lines inside build.py), so a non-semver tag fails here with a + # command lines inside build.py), so a bad version fails here with a # clear message. The leading 'v' is allowed here; build.py strips it. - if ! echo "$VERSION" | grep -Eq '^v?[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then - echo "derived version '$VERSION' is not [v]X.Y.Z[-suffix]; tag a semver release" >&2 + if ! echo "$1" | grep -Eq '^v?[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then + echo "derived version '$1' is not [v]X.Y.Z[-suffix]; tag a semver release or pass a valid version input" >&2 exit 1 fi - echo "VERSION=$VERSION" >> $GITHUB_OUTPUT + } + if [[ $GITHUB_REF == refs/tags/* ]]; then + VERSION=${GITHUB_REF#refs/tags/} + validate "$VERSION" + elif [[ -n "$INPUT_VERSION" ]]; then + VERSION="$INPUT_VERSION" + validate "$VERSION" else - echo "VERSION=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT + VERSION=$(git rev-parse --short HEAD) fi + echo "VERSION=$VERSION" >> $GITHUB_OUTPUT # Note: the version (and the Docker Hub username) are passed through environment # variables instead of direct ${{ }} interpolation into the run block, so that a @@ -55,9 +86,84 @@ jobs: env: VERSION: ${{ steps.version.outputs.VERSION }} DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + PLATFORM: ${{ inputs.platform || 'amd64' }} + TAG_SUFFIX: ${{ inputs.tag_suffix }} run: | - python build-tools/docker/build.py --push --docker-hub-user "$DOCKERHUB_USERNAME" \ - --version "$VERSION" --latest + args=(--push --docker-hub-user "$DOCKERHUB_USERNAME" \ + --version "$VERSION" --latest --platform "$PLATFORM") + if [[ -n "$TAG_SUFFIX" ]]; then + args+=(--tag_suffix "$TAG_SUFFIX") + fi + python build-tools/docker/build.py "${args[@]}" env: DOCKER_BUILDKIT: 1 + + # Registry-side assembly of multi-arch manifest lists from images that were + # pushed earlier by separate per-arch dispatches of the build job above + # ("--platform arm64 --tag_suffix arm64"). Nothing is rebuilt: "docker + # buildx imagetools create" only references already-pushed manifests. Runs + # from the default branch via "gh workflow run ... --ref ": GitHub + # validates the workflow registration against the default branch but reads + # the file content from the dispatched ref. + assemble-manifests: + if: github.event_name == 'workflow_dispatch' && inputs.assemble_manifests == 'true' + runs-on: ubuntu-latest + steps: + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Login to Docker Hub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Assemble and push multi-arch manifests + env: + INPUT_VERSION: ${{ inputs.version }} + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + run: | + set -euo pipefail + + VERSION="${INPUT_VERSION#v}" + if ! echo "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then + echo "invalid version '$INPUT_VERSION'; expected [v]X.Y.Z[-suffix]" >&2 + exit 1 + fi + + assemble() { + local repo="$1" tag="$2" + # Snapshot the existing (historically amd64-only) manifest under an + # explicit arch tag, unless it's already there (idempotent re-runs). + if ! docker buildx imagetools inspect "$repo:$tag-amd64" >/dev/null 2>&1; then + docker buildx imagetools create -t "$repo:$tag-amd64" "$repo:$tag" + fi + docker buildx imagetools create -t "$repo:$tag" "$repo:$tag-amd64" "$repo:$tag-arm64" + } + + # Only the app images are maintained on Docker Hub (build.py's + # push_instances pushes these six); the builder/runner-base images + # stay local to the CI runner, so there is nothing to assemble for + # them. + for repo in "$DOCKERHUB_USERNAME/node-daemon" \ + "$DOCKERHUB_USERNAME/api-blockchain-scanner-daemon" \ + "$DOCKERHUB_USERNAME/api-web-server" \ + "$DOCKERHUB_USERNAME/wallet-cli" \ + "$DOCKERHUB_USERNAME/wallet-rpc-daemon" \ + "$DOCKERHUB_USERNAME/dns-server"; do + for tag in "$VERSION" "v$VERSION" latest; do + assemble "$repo" "$tag" + done + done + + - name: Verify manifests + env: + INPUT_VERSION: ${{ inputs.version }} + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + run: | + VERSION="${INPUT_VERSION#v}" + for repo in node-daemon api-blockchain-scanner-daemon api-web-server wallet-cli wallet-rpc-daemon dns-server; do + echo "=== $DOCKERHUB_USERNAME/$repo:v$VERSION ===" + docker buildx imagetools inspect "$DOCKERHUB_USERNAME/$repo:v$VERSION" | grep -E '^Name:|Platform:' || true + done diff --git a/build-tools/docker/README.md b/build-tools/docker/README.md index 2a45ccd215..c0bf70361c 100644 --- a/build-tools/docker/README.md +++ b/build-tools/docker/README.md @@ -61,3 +61,12 @@ docker run -it --network=mintlayer-net -v ~/.mintlayer:/root/.mintlayer mintlaye replace `` with `mainnet` or `testnet` depending on what network you're running and `` with the result of the command above. This command mounts the same `~/.mintlayer` directory as a volume in the `wallet-cli` container and uses the `--rpc-cookie-file` option to specify the path to the cookie file. + +## Multi-arch (amd64 / arm64) images + +By default the build produces `linux/amd64` images, exactly as it always has. Two extra knobs exist for building other architectures: + +- `build.py --platform arm64` builds `linux/arm64` images (must run on an arm64 machine, e.g. the `ubuntu-24.04-arm` GitHub runner, since the compilation happens inside the container). +- `build.py --tag_suffix arm64` appends the suffix to every built and pushed tag (e.g. `node-daemon:1.4.1-arm64`), so a non-default architecture never overwrites the default-arch tags. + +To assemble the suffixed images into a proper multi-arch manifest list under the unsuffixed tags (`docker pull mintlayer/node-daemon` then auto-selects the architecture), dispatch the **"Assemble multi-arch Docker manifests"** workflow (`release_docker_manifest.yml`) with the release version after both architectures have been pushed. diff --git a/build-tools/docker/build.py b/build-tools/docker/build.py index 1c2351af48..03f446b702 100644 --- a/build-tools/docker/build.py +++ b/build-tools/docker/build.py @@ -26,7 +26,18 @@ def get_cargo_version(cargo_toml_path): return version -def build_docker_image(dockerfile_path, image_name, tags, num_jobs=None): +def apply_tag_suffix(tags, tag_suffix): + # Append the suffix (e.g. "arm64") to every tag, so that images built for a + # non-default architecture never overwrite the tags of the default-arch images. + if not tag_suffix: + return list(tags) + return [f"{tag}-{tag_suffix}" for tag in tags] + + +def build_docker_image(dockerfile_path, image_name, tags, num_jobs=None, platform="amd64", tag_suffix=None): + raw_tags = list(tags) + tags = apply_tag_suffix(raw_tags, tag_suffix) + # Docker build command command = f"docker build" @@ -40,8 +51,7 @@ def build_docker_image(dockerfile_path, image_name, tags, num_jobs=None): if num_jobs: command += f" --build-arg NUM_JOBS={num_jobs}" - # Force the amd64 platform in case we're building on an arm-based one. - command += " --platform linux/amd64" + command += f" --platform linux/{platform}" # Note: "plain" output is more verbose, but it makes it easier to understand what went wrong # when a problem occurs. @@ -56,6 +66,24 @@ def build_docker_image(dockerfile_path, image_name, tags, num_jobs=None): print(f"Failed to build {image_name}: {error}") exit(1) # stop the build + if tag_suffix: + # The app images reference "mintlayer-builder:latest" / "mintlayer-runner-base" + # without a suffix in their FROM lines. When building for a non-default + # architecture, the suffixed base image exists locally but the unsuffixed tag + # would be resolved from the registry (where only the amd64 variant exists). + # Add a local unsuffixed alias pointing to the image we've just built, so the + # subsequent app image builds resolve it locally. These local aliases are never + # pushed (push_instances only pushes the suffixed tags), so they cannot + # overwrite the registry's default-arch tags. + local_alias = f"{image_name}:{raw_tags[0]}" + alias_command = f"docker tag {full_tags[0]} {local_alias}" + try: + subprocess.check_call(alias_command, shell=True) + print(f"Tagged {full_tags[0]} locally as {local_alias}.") + except subprocess.CalledProcessError as error: + print(f"Failed to tag {image_name}: {error}") + exit(1) # stop the build + def push_docker_image(image_name, tags): for tag in tags: @@ -86,24 +114,24 @@ def delete_docker_image(image_name, version): # No need to fail the build here -def build_instances(tags, docker_hub_user, num_jobs): +def build_instances(tags, docker_hub_user, num_jobs, platform="amd64", tag_suffix=None): build_docker_image("build-tools/docker/Dockerfile.builder", - "mintlayer-builder", ["latest"], num_jobs) + "mintlayer-builder", ["latest"], num_jobs, platform, tag_suffix) build_docker_image("build-tools/docker/Dockerfile.runner-base", - "mintlayer-runner-base", ["latest"], num_jobs) + "mintlayer-runner-base", ["latest"], num_jobs, platform, tag_suffix) build_docker_image("build-tools/docker/Dockerfile.node-daemon", - f"{docker_hub_user}/node-daemon", tags) + f"{docker_hub_user}/node-daemon", tags, num_jobs, platform, tag_suffix) build_docker_image("build-tools/docker/Dockerfile.api-blockchain-scanner-daemon", - f"{docker_hub_user}/api-blockchain-scanner-daemon", tags) + f"{docker_hub_user}/api-blockchain-scanner-daemon", tags, num_jobs, platform, tag_suffix) build_docker_image("build-tools/docker/Dockerfile.api-web-server", - f"{docker_hub_user}/api-web-server", tags) + f"{docker_hub_user}/api-web-server", tags, num_jobs, platform, tag_suffix) build_docker_image("build-tools/docker/Dockerfile.wallet-cli", - f"{docker_hub_user}/wallet-cli", tags) + f"{docker_hub_user}/wallet-cli", tags, num_jobs, platform, tag_suffix) build_docker_image("build-tools/docker/Dockerfile.wallet-rpc-daemon", - f"{docker_hub_user}/wallet-rpc-daemon", tags) + f"{docker_hub_user}/wallet-rpc-daemon", tags, num_jobs, platform, tag_suffix) build_docker_image("build-tools/docker/Dockerfile.dns-server", - f"{docker_hub_user}/dns-server", tags) + f"{docker_hub_user}/dns-server", tags, num_jobs, platform, tag_suffix) # delete_docker_image("mintlayer-builder", "latest") @@ -125,6 +153,10 @@ def main(): parser.add_argument('--version', help='Override version number', default=None) parser.add_argument('--num_jobs', help='Number of parallel jobs', default=(os.cpu_count() or 1)) parser.add_argument('--local_tags', nargs='*', help='Additional tags to apply (these won\'t be pushed)', default=[]) + parser.add_argument('--platform', choices=['amd64', 'arm64'], default='amd64', + help='Target CPU architecture for the images (the default keeps the historical behavior)') + parser.add_argument('--tag_suffix', help='Suffix appended to every built and pushed image tag (e.g. "arm64")', + default=None) args = parser.parse_args() version = args.version if args.version else get_cargo_version(ROOT_CARGO_TOML) @@ -140,11 +172,14 @@ def main(): all_tags = args.local_tags + tags_to_push if args.build: - build_instances(all_tags, args.docker_hub_user, args.num_jobs) + # Note: the suffix is applied inside build_docker_image, so it must NOT be + # pre-applied to these tags (that would produce doubled suffixes). + build_instances(all_tags, args.docker_hub_user, args.num_jobs, + args.platform, args.tag_suffix) # Only push the image if the --push flag is provided if args.push: - push_instances(args.docker_hub_user, tags_to_push) + push_instances(args.docker_hub_user, apply_tag_suffix(tags_to_push, args.tag_suffix)) if __name__ == "__main__": From 430d64cc911bf8a9edb5e9ca933cf39e7344ddf4 Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Sun, 11 Oct 2026 17:58:21 +0200 Subject: [PATCH 2/3] fix(ci): harden the multi-arch docker release workflow --- .github/workflows/release_docker.yml | 82 ++++++++++++++++++++++++---- build-tools/docker/build.py | 47 ++++++++++------ 2 files changed, 103 insertions(+), 26 deletions(-) diff --git a/.github/workflows/release_docker.yml b/.github/workflows/release_docker.yml index 9d86badf86..b83825b61f 100644 --- a/.github/workflows/release_docker.yml +++ b/.github/workflows/release_docker.yml @@ -17,7 +17,7 @@ on: required: false default: '' runner: - description: 'Runner label, e.g. ubuntu-24.04-arm for a native arm64 build' + description: 'Runner label; only ubuntu-24.04 / ubuntu-24.04-arm are honored, anything else falls back to ubuntu-latest' required: false default: 'ubuntu-latest' assemble_manifests: @@ -25,16 +25,29 @@ on: required: false default: 'false' +# The workflow only checks out code and pushes images to Docker Hub via the +# DOCKERHUB_* secrets; it never needs more than read access to the repository. +permissions: + contents: read + jobs: build-docker-images: if: github.event_name == 'workflow_call' || inputs.assemble_manifests != 'true' - runs-on: ${{ inputs.runner || 'ubuntu-latest' }} + permissions: + contents: read + # Explicit bound below the 360-minute GitHub default, with headroom for + # slow native arm64 builds; a stuck build releases the runner in 4h. + timeout-minutes: 240 + # Allowlist the runner label: a free-form `runs-on:` would let anyone with + # workflow-dispatch rights route this build (and its Docker Hub secrets) + # to an arbitrary, possibly untrusted runner. + runs-on: ${{ (inputs.runner == 'ubuntu-24.04' || inputs.runner == 'ubuntu-24.04-arm') && inputs.runner || 'ubuntu-latest' }} steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 - name: Setup Python - uses: actions/setup-python@v6 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version-file: './build-tools/.python-version' @@ -45,11 +58,11 @@ jobs: # # Install any other dependencies your script might need - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - name: Login to Docker Hub if: github.event_name != 'pull_request' - uses: docker/login-action@v2 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} @@ -89,6 +102,16 @@ jobs: PLATFORM: ${{ inputs.platform || 'amd64' }} TAG_SUFFIX: ${{ inputs.tag_suffix }} run: | + # Validate the suffix up front: it flows into image tags on docker + # command lines, so anything outside the Docker tag grammar must fail + # here with a clear message instead of producing a broken (or + # hostile) tag. The bash regex matches the whole value (grep would + # match line by line and pass a multi-line suffix). build.py validates + # it again for standalone use. + if [[ -n "$TAG_SUFFIX" ]] && ! [[ "$TAG_SUFFIX" =~ ^[a-zA-Z0-9]([a-zA-Z0-9_.-]{0,127})?$ ]]; then + echo "invalid tag_suffix '$TAG_SUFFIX'; expected [a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}" >&2 + exit 1 + fi args=(--push --docker-hub-user "$DOCKERHUB_USERNAME" \ --version "$VERSION" --latest --platform "$PLATFORM") if [[ -n "$TAG_SUFFIX" ]]; then @@ -108,13 +131,22 @@ jobs: # the file content from the dispatched ref. assemble-manifests: if: github.event_name == 'workflow_dispatch' && inputs.assemble_manifests == 'true' + permissions: + contents: read + timeout-minutes: 60 + # Serialize registry mutations for the same version: overlapping dispatches + # would interleave the inspect/create check-then-act sequence below and can + # repoint tags from inconsistent digests. Pending runs queue instead. + concurrency: + group: assemble-manifests-${{ inputs.version }} + cancel-in-progress: false runs-on: ubuntu-latest steps: - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - name: Login to Docker Hub - uses: docker/login-action@v2 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} @@ -134,9 +166,23 @@ jobs: assemble() { local repo="$1" tag="$2" + local amd64_ready=0 + # Pre-flight: confirm every source manifest before mutating + # anything, so a missing image fails the job here instead of + # leaving this tag half-assembled (part of the per-arch tags + # created, the base tag already repointed). + docker buildx imagetools inspect "$repo:$tag-amd64" >/dev/null 2>&1 && amd64_ready=1 + if [ "$amd64_ready" -eq 0 ] && ! docker buildx imagetools inspect "$repo:$tag" >/dev/null 2>&1; then + echo "neither $repo:$tag-amd64 nor $repo:$tag found; push the amd64 images first" >&2 + exit 1 + fi + if ! docker buildx imagetools inspect "$repo:$tag-arm64" >/dev/null 2>&1; then + echo "$repo:$tag-arm64 not found; push the arm64 images first" >&2 + exit 1 + fi # Snapshot the existing (historically amd64-only) manifest under an # explicit arch tag, unless it's already there (idempotent re-runs). - if ! docker buildx imagetools inspect "$repo:$tag-amd64" >/dev/null 2>&1; then + if [ "$amd64_ready" -eq 0 ]; then docker buildx imagetools create -t "$repo:$tag-amd64" "$repo:$tag" fi docker buildx imagetools create -t "$repo:$tag" "$repo:$tag-amd64" "$repo:$tag-arm64" @@ -165,5 +211,21 @@ jobs: VERSION="${INPUT_VERSION#v}" for repo in node-daemon api-blockchain-scanner-daemon api-web-server wallet-cli wallet-rpc-daemon dns-server; do echo "=== $DOCKERHUB_USERNAME/$repo:v$VERSION ===" - docker buildx imagetools inspect "$DOCKERHUB_USERNAME/$repo:v$VERSION" | grep -E '^Name:|Platform:' || true + # A missing manifest must fail the job: reporting success here would + # defeat the purpose of the check. + if ! output="$(docker buildx imagetools inspect "$DOCKERHUB_USERNAME/$repo:v$VERSION")"; then + echo "manifest for $DOCKERHUB_USERNAME/$repo:v$VERSION is missing" >&2 + exit 1 + fi + echo "$output" | grep -E '^Name:|Platform:' + # Both architectures must be present in the manifest list; a stale + # single-arch tag must fail the job instead of shipping silently. + for platform in linux/amd64 linux/arm64; do + # imagetools output is tabwriter-aligned ("Platform: linux/amd64"), + # so tolerate any run of whitespace after the colon. + if ! grep -Eq "Platform:[[:space:]]+$platform" <<<"$output"; then + echo "manifest for $DOCKERHUB_USERNAME/$repo:v$VERSION lacks $platform" >&2 + exit 1 + fi + done done diff --git a/build-tools/docker/build.py b/build-tools/docker/build.py index 03f446b702..9b33d73514 100644 --- a/build-tools/docker/build.py +++ b/build-tools/docker/build.py @@ -1,6 +1,7 @@ import argparse import os import pathlib +import re import subprocess import tomllib @@ -38,29 +39,29 @@ def build_docker_image(dockerfile_path, image_name, tags, num_jobs=None, platfor raw_tags = list(tags) tags = apply_tag_suffix(raw_tags, tag_suffix) - # Docker build command - command = f"docker build" + command = ["docker", "build"] full_tags = [image_name] if len(tags) == 0 else [f"{image_name}:{tag}" for tag in tags]; for full_tag in full_tags: - command += f" -t {full_tag}" + command += ["-t", full_tag] - command += f" -f {dockerfile_path}" + command += ["-f", str(dockerfile_path)] if num_jobs: - command += f" --build-arg NUM_JOBS={num_jobs}" + command += ["--build-arg", f"NUM_JOBS={num_jobs}"] - command += f" --platform linux/{platform}" + command += ["--platform", f"linux/{platform}"] # Note: "plain" output is more verbose, but it makes it easier to understand what went wrong # when a problem occurs. - command += " --progress=plain" - command += " ." + command += ["--progress=plain"] + command += ["."] try: - # Run the command - subprocess.check_call(command, shell=True, cwd=ROOT_DIR) + # Run the command. An argument list (no shell) keeps every tag and path + # literal, so a crafted version/suffix cannot inject shell commands. + subprocess.check_call(command, cwd=ROOT_DIR) print(f"Built {image_name} successfully (the tags are: {full_tags}).") except subprocess.CalledProcessError as error: print(f"Failed to build {image_name}: {error}") @@ -76,9 +77,9 @@ def build_docker_image(dockerfile_path, image_name, tags, num_jobs=None, platfor # pushed (push_instances only pushes the suffixed tags), so they cannot # overwrite the registry's default-arch tags. local_alias = f"{image_name}:{raw_tags[0]}" - alias_command = f"docker tag {full_tags[0]} {local_alias}" + alias_command = ["docker", "tag", full_tags[0], local_alias] try: - subprocess.check_call(alias_command, shell=True) + subprocess.check_call(alias_command) print(f"Tagged {full_tags[0]} locally as {local_alias}.") except subprocess.CalledProcessError as error: print(f"Failed to tag {image_name}: {error}") @@ -88,10 +89,11 @@ def build_docker_image(dockerfile_path, image_name, tags, num_jobs=None, platfor def push_docker_image(image_name, tags): for tag in tags: full_image_name = f"{image_name}:{tag}" - push_command = f"docker push {full_image_name}" + + push_command = ["docker", "push", full_image_name] try: - subprocess.check_call(push_command, shell=True) + subprocess.check_call(push_command) print(f"Pushed {full_image_name} successfully.") except subprocess.CalledProcessError as error: print(f"Failed to push {full_image_name}: {error}") @@ -103,11 +105,11 @@ def delete_docker_image(image_name, version): full_image_name = f"{image_name}:{version}" # Docker rmi command - command = f"docker rmi {full_image_name}" + command = ["docker", "rmi", full_image_name] # Run the command try: - subprocess.check_call(command, shell=True) + subprocess.check_call(command) print(f"Deleted {full_image_name} successfully.") except subprocess.CalledProcessError as error: print(f"Failed to delete {full_image_name}: {error}") @@ -159,6 +161,19 @@ def main(): default=None) args = parser.parse_args() + # The suffix lands in image tags on docker command lines; anything outside + # the Docker tag grammar must fail here rather than produce a broken or + # hostile tag. (The release workflow validates the same input before + # invoking this script; this check covers standalone use.) + if args.tag_suffix and not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}", args.tag_suffix): + parser.error("--tag_suffix must match [a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}") + + # An unsuffixed non-amd64 push would silently overwrite the registry's + # default-arch (amd64) tags, which is exactly what the suffix exists to + # prevent. + if args.push and args.platform != "amd64" and not args.tag_suffix: + parser.error("--tag_suffix is required when --push is used with a --platform other than amd64") + version = args.version if args.version else get_cargo_version(ROOT_CARGO_TOML) # Note: the CI currently takes the version from the release tag, so it always starts with "v", # but the version from Cargo.toml doesn't have this prefix. From e71a5891375697ac41a06c44e221425f5daf5041 Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Sun, 11 Oct 2026 21:17:27 +0200 Subject: [PATCH 3/3] fix(ci): restrict docker dispatches and gate shared tag writes - workflow_dispatch builds and assembly now require a protected ref (master or release-*), so ad-hoc runs from feature branches cannot push versioned or latest tags with the repo Docker Hub credentials - build and assembly jobs serialize behind fixed concurrency groups - latest is stamped only for semver versions; SHA builds (the dispatch default) keep their own tags only, and anything else fails early - version validation matches the whole value (same rule as the tag suffix), so a multi-line input cannot expand into extra tag arguments - assembly logs both source digests before creating a manifest list - build.py skips the local base-image alias when no tags were passed --- .github/workflows/release_docker.yml | 61 +++++++++++++++++++++++----- build-tools/docker/build.py | 5 ++- 2 files changed, 54 insertions(+), 12 deletions(-) diff --git a/.github/workflows/release_docker.yml b/.github/workflows/release_docker.yml index b83825b61f..f4a9638b51 100644 --- a/.github/workflows/release_docker.yml +++ b/.github/workflows/release_docker.yml @@ -32,12 +32,28 @@ permissions: jobs: build-docker-images: - if: github.event_name == 'workflow_call' || inputs.assemble_manifests != 'true' + # workflow_dispatch builds may push shared mutable tags ('', + # 'v', 'latest') with the repo's Docker Hub credentials, so they + # are restricted to protected refs; feature-branch dispatches cannot push. + # Tag refs are allowed at the same trust level as the release pipeline. + if: github.event_name == 'workflow_call' || (inputs.assemble_manifests != 'true' && (github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/heads/release-') || startsWith(github.ref, 'refs/tags/v'))) permissions: contents: read # Explicit bound below the 360-minute GitHub default, with headroom for # slow native arm64 builds; a stuck build releases the runner in 4h. timeout-minutes: 240 + # Serialize ALL registry mutations (builds and assemblies mutate the same + # tag space) behind one group. GitHub keeps at most one in-progress and + # one pending run per group; a newer run cancels an older PENDING run, so + # an overlapping dispatch can drop a queued run — including a release + # build from release.yml, and the per-arch flow itself (with amd64 + # running, queueing arm64 makes it PENDING, and queueing the assembly + # then cancels it). Wait for each queued run to finish before dispatching + # the next, re-dispatch anything canceled, and re-run release.yml if a + # release run fails this way; in-progress runs are never canceled. + concurrency: + group: docker-release + cancel-in-progress: false # Allowlist the runner label: a free-form `runs-on:` would let anyone with # workflow-dispatch rights route this build (and its Docker Hub secrets) # to an arbitrary, possibly untrusted runner. @@ -75,8 +91,10 @@ jobs: validate() { # Validate the grammar up front (the version flows into docker build/push # command lines inside build.py), so a bad version fails here with a - # clear message. The leading 'v' is allowed here; build.py strips it. - if ! echo "$1" | grep -Eq '^v?[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then + # clear message. Whole-value match: grep would compare line by line + # and pass a multi-line value, which would later expand into several + # tag arguments. The leading 'v' is allowed here; build.py strips it. + if ! [[ "$1" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then echo "derived version '$1' is not [v]X.Y.Z[-suffix]; tag a semver release or pass a valid version input" >&2 exit 1 fi @@ -84,13 +102,19 @@ jobs: if [[ $GITHUB_REF == refs/tags/* ]]; then VERSION=${GITHUB_REF#refs/tags/} validate "$VERSION" + STAMP_LATEST=true elif [[ -n "$INPUT_VERSION" ]]; then VERSION="$INPUT_VERSION" validate "$VERSION" + STAMP_LATEST=true else + # Ad-hoc dispatch default: the short commit SHA. These builds keep + # their own tags and must not repoint the production 'latest' tag. VERSION=$(git rev-parse --short HEAD) + STAMP_LATEST=false fi echo "VERSION=$VERSION" >> $GITHUB_OUTPUT + echo "stamp_latest=$STAMP_LATEST" >> $GITHUB_OUTPUT # Note: the version (and the Docker Hub username) are passed through environment # variables instead of direct ${{ }} interpolation into the run block, so that a @@ -98,6 +122,7 @@ jobs: - name: Build and Push Docker images env: VERSION: ${{ steps.version.outputs.VERSION }} + STAMP_LATEST: ${{ steps.version.outputs.stamp_latest }} DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} PLATFORM: ${{ inputs.platform || 'amd64' }} TAG_SUFFIX: ${{ inputs.tag_suffix }} @@ -113,7 +138,13 @@ jobs: exit 1 fi args=(--push --docker-hub-user "$DOCKERHUB_USERNAME" \ - --version "$VERSION" --latest --platform "$PLATFORM") + --version "$VERSION" --platform "$PLATFORM") + # 'latest' is stamped only for release versions; the classification + # (semver vs commit SHA) happened in 'Determine version', so this + # step re-derives no grammar. + if [[ "$STAMP_LATEST" == 'true' ]]; then + args+=(--latest) + fi if [[ -n "$TAG_SUFFIX" ]]; then args+=(--tag_suffix "$TAG_SUFFIX") fi @@ -130,15 +161,14 @@ jobs: # validates the workflow registration against the default branch but reads # the file content from the dispatched ref. assemble-manifests: - if: github.event_name == 'workflow_dispatch' && inputs.assemble_manifests == 'true' + if: github.event_name == 'workflow_dispatch' && inputs.assemble_manifests == 'true' && (github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/heads/release-') || startsWith(github.ref, 'refs/tags/v')) permissions: contents: read timeout-minutes: 60 - # Serialize registry mutations for the same version: overlapping dispatches - # would interleave the inspect/create check-then-act sequence below and can - # repoint tags from inconsistent digests. Pending runs queue instead. + # Builds and assemblies mutate the same tag space, so both share the + # 'docker-release' group (see the build job for the pending-run caveat). concurrency: - group: assemble-manifests-${{ inputs.version }} + group: docker-release cancel-in-progress: false runs-on: ubuntu-latest steps: @@ -159,7 +189,10 @@ jobs: set -euo pipefail VERSION="${INPUT_VERSION#v}" - if ! echo "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then + # Whole-value match: grep would compare line by line and pass a + # multi-line value, which would later expand into several tag + # arguments. + if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then echo "invalid version '$INPUT_VERSION'; expected [v]X.Y.Z[-suffix]" >&2 exit 1 fi @@ -185,6 +218,14 @@ jobs: if [ "$amd64_ready" -eq 0 ]; then docker buildx imagetools create -t "$repo:$tag-amd64" "$repo:$tag" fi + # Provenance: the two sources were pushed by independent dispatches + # and can originate from different commits. Full cross-arch + # integrity needs image-level attestation; until then, log both + # digests so the pairing is auditable after the fact. + echo "amd64 digest of $repo:$tag-amd64:" + docker buildx imagetools inspect "$repo:$tag-amd64" | grep '^Digest:' + echo "arm64 digest of $repo:$tag-arm64:" + docker buildx imagetools inspect "$repo:$tag-arm64" | grep '^Digest:' docker buildx imagetools create -t "$repo:$tag" "$repo:$tag-amd64" "$repo:$tag-arm64" } diff --git a/build-tools/docker/build.py b/build-tools/docker/build.py index 9b33d73514..417cccce64 100644 --- a/build-tools/docker/build.py +++ b/build-tools/docker/build.py @@ -67,7 +67,7 @@ def build_docker_image(dockerfile_path, image_name, tags, num_jobs=None, platfor print(f"Failed to build {image_name}: {error}") exit(1) # stop the build - if tag_suffix: + if tag_suffix and raw_tags: # The app images reference "mintlayer-builder:latest" / "mintlayer-runner-base" # without a suffix in their FROM lines. When building for a non-default # architecture, the suffixed base image exists locally but the unsuffixed tag @@ -75,7 +75,8 @@ def build_docker_image(dockerfile_path, image_name, tags, num_jobs=None, platfor # Add a local unsuffixed alias pointing to the image we've just built, so the # subsequent app image builds resolve it locally. These local aliases are never # pushed (push_instances only pushes the suffixed tags), so they cannot - # overwrite the registry's default-arch tags. + # overwrite the registry's default-arch tags. Skipped when the caller + # passed no tags: there is no versioned tag to derive an alias from. local_alias = f"{image_name}:{raw_tags[0]}" alias_command = ["docker", "tag", full_tags[0], local_alias] try: