name: Build & Push Docker Image
on:
push:
branches: [main]
tags: ["macp-runtime-v*"]
workflow_call:
inputs:
version:
description: "Released workspace version, e.g. 0.8.1 (no leading v)"
type: string
required: true
workflow_dispatch:
inputs:
ref:
description: "Git ref to build, e.g. macp-runtime-v0.8.1 (blank = build the default branch)"
type: string
required: false
default: ""
version:
description: "Override the version tag (blank = derive from ref)"
type: string
required: false
default: ""
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
docker:
name: Build Docker Image
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
timeout-minutes: 90
steps:
- name: Resolve build parameters
id: resolve
env:
EVENT_NAME: ${{ github.event_name }}
REF_NAME: ${{ github.ref_name }}
REF_TYPE: ${{ github.ref_type }}
SHA: ${{ github.sha }}
INPUT_REF: ${{ inputs.ref }}
INPUT_VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
tag_prefix="macp-runtime-v"
# github.event_name is NOT a reliable way to detect a workflow_call
# invocation: per GitHub's reusable-workflow reference, the called
# workflow's github context -- event_name included -- is "the same
# as the caller workflow", never the literal string "workflow_call".
# Since release-plz.yml is itself triggered by `push`, EVENT_NAME
# reads "push" on the live release path too. This inheritance rule
# applies to workflow_dispatch just as much as push: it is genuine
# ONLY because release-plz.yml (today's only caller) has no
# workflow_dispatch trigger of its own for event_name to inherit --
# not because of any property of workflow_dispatch itself. If a
# future caller of this workflow ever gains one, a dispatched
# caller run would inherit EVENT_NAME=workflow_dispatch with no
# `ref` input (docker.yml's own workflow_dispatch.ref is not part
# of the workflow_call input schema), which is indistinguishable
# from a genuine no-ref dispatch below -- so that branch guards
# against exactly that ambiguity rather than assuming it away.
if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
if [ -n "$INPUT_REF" ]; then
# Without this guard a dispatch with ref: some-branch would
# resolve is_release=false *and* push_latest=true, pushing
# latest/main/a SHA tag built from the wrong tree.
case "$INPUT_REF" in
"$tag_prefix"*) ;;
*)
echo "::error::workflow_dispatch 'ref' must be a ${tag_prefix}* tag, got '$INPUT_REF'"
exit 1
;;
esac
ref="$INPUT_REF"
if [ -n "$INPUT_VERSION" ]; then
version="$INPUT_VERSION"
else
version="${INPUT_REF#"$tag_prefix"}"
fi
is_release=true
push_latest=false
elif [ -n "$INPUT_VERSION" ]; then
# `version` without `ref` is meaningless for a genuine dispatch
# (documented as "blank = derive from ref") and is also what an
# inherited workflow_call-from-a-dispatched-caller would look
# like, per the note above. Either way this must never fall
# through to a silent branch build that discards `version` and
# emits no semver tag.
echo "::error::workflow_dispatch 'version' was supplied without a 'ref'. If this is a release build routed through a dispatched caller, docker.yml cannot currently distinguish that from a plain dispatch -- see the comment above this block."
exit 1
else
# A manual "refresh latest from main" affordance -- valid only
# when the dispatch itself targets a branch. Without this
# guard, dispatching directly against a tag ref with no `ref`
# input (the exact wrong command Phase 2's own plan warns
# about -- `--ref macp-runtime-vX.Y.Z` instead of `--ref main
# -f ref=...`) would silently build that tag's tree as an
# ordinary non-release build: no semver tag, a bare SHA tag
# pushed anyway, exit green.
if [ "$REF_TYPE" != "branch" ]; then
echo "::error::workflow_dispatch with no 'ref' input must target a branch (dispatched against '$REF_NAME', ref_type '$REF_TYPE'). To build a specific release tag, pass it via the 'ref' input instead of dispatching directly against that tag."
exit 1
fi
# `latest` only moves when the dispatch itself targets the
# default branch -- a no-ref dispatch against a feature
# branch must not move it.
ref="$SHA"
version=""
is_release=false
if [ "$REF_NAME" = "main" ]; then
push_latest=true
else
push_latest=false
fi
fi
elif [ -n "$INPUT_VERSION" ]; then
# workflow_call: release-plz tags the release-PR merge commit on
# `main`, which *is* the caller's github.sha -- so the caller's
# commit is already the right thing to check out, with no ref
# input needed. An empty INPUT_VERSION here is indistinguishable
# from a genuine push and falls through to the branch-build arm
# below instead of failing loudly -- see the header comment; the
# caller must never invoke this workflow with an empty version.
ref="$SHA"
version="$INPUT_VERSION"
is_release=true
push_latest=false
elif [ "$EVENT_NAME" = "push" ]; then
if [ "$REF_TYPE" = "tag" ]; then
# The `tags:` trigger above already restricts this path to
# macp-runtime-v* refs.
ref="$REF_NAME"
version="${REF_NAME#"$tag_prefix"}"
is_release=true
push_latest=false
else
ref="$SHA"
version=""
is_release=false
push_latest=true
fi
else
echo "::error::unsupported trigger for docker.yml: event_name='$EVENT_NAME', no version input present, not a push"
exit 1
fi
# Converts docker/metadata-action's silent warn-and-skip on an
# unparseable version (it does no macp-runtime-v prefix stripping)
# into a hard failure -- a release build must never emit no semver
# tag and still exit green.
if [ "$is_release" = "true" ]; then
if ! [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([-+].*)?$ ]]; then
echo "::error::resolved version '$version' is not a valid semver (expected X.Y.Z)"
exit 1
fi
fi
{
echo "ref=$ref"
echo "version=$version"
echo "is_release=$is_release"
echo "push_latest=$push_latest"
} >> "$GITHUB_OUTPUT"
echo "resolved: event=$EVENT_NAME ref=$ref version=$version is_release=$is_release push_latest=$push_latest"
- name: Checkout repository
uses: actions/checkout@v7
with:
ref: ${{ steps.resolve.outputs.ref }}
- name: Cross-check the resolved version against the checked-out tree
if: steps.resolve.outputs.is_release == 'true'
env:
RESOLVED_VERSION: ${{ steps.resolve.outputs.version }}
run: |
set -euo pipefail
tree_version=$(grep -A5 '^\[workspace.package\]' Cargo.toml | grep -m1 '^version' \
| sed -E 's/^version[[:space:]]*=[[:space:]]*"([^"]+)".*/\1/')
if [ -z "$tree_version" ]; then
echo "::error::could not parse [workspace.package].version out of Cargo.toml"
exit 1
fi
if [ "$tree_version" != "$RESOLVED_VERSION" ]; then
echo "::error::resolved version '$RESOLVED_VERSION' does not match the checked-out Cargo.toml version '$tree_version'"
exit 1
fi
echo "version cross-check OK: $tree_version"
- name: Capture the checked-out commit
id: resolve_sha
run: |
set -euo pipefail
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Set up QEMU (arm64 emulation)
uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e
- name: Log in to GitHub Container Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
flavor: |
latest=false
labels: |
org.opencontainers.image.revision=${{ steps.resolve_sha.outputs.sha }}
tags: |
type=raw,value=latest,enable=${{ steps.resolve.outputs.push_latest }}
type=ref,event=branch,enable=${{ steps.resolve.outputs.is_release != 'true' }}
type=ref,event=pr,enable=${{ steps.resolve.outputs.is_release != 'true' }}
type=sha,prefix=,enable=${{ steps.resolve.outputs.is_release != 'true' }}
type=semver,pattern={{version}},value=${{ steps.resolve.outputs.version }},enable=${{ steps.resolve.outputs.is_release }}
type=semver,pattern={{major}}.{{minor}},value=${{ steps.resolve.outputs.version }},enable=${{ steps.resolve.outputs.is_release }}
- name: Build and push
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a with:
context: .
push: true
platforms: linux/amd64,linux/arm64
provenance: true
sbom: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max