Conditional bootc build
Running image builds on a schedule makes sense for security – you want your bootc images to pick up the latest Fedora updates automatically. But scheduling a daily build that rebuilds every image every day, even when nothing upstream changed, wastes runner time and fills the registry with identical layers.
This post covers how I added upstream change detection to my Forgejo CI pipelines for gorgeOS, so that scheduled builds are truly no-ops when the upstream image hasn’t moved.
The Setup
gorgeOS is a stack of bootc images built
on top of fedora-bootc:
fedora-bootc:43
└── c-os:base
└── c-os:cloud
└── c-os:docker
└── c-os-deployments:docker_site_a
└── c-os-deployments:docker_site_b
Each level depends on the one above it. The builds run on a Forgejo Actions
schedule (daily) using a buildah runner with podman available.
The problem: if Fedora pushes no updates, every nightly run rebuilds all six images across two Fedora versions (F43 + F44) for nothing.
The Approach: OCI Labels as State
The cleanest solution I found stores all state in the registry itself, using an OCI label stamped onto every built image:
upstream-digest=sha256:
On each scheduled run, before building, the job:
- Fetches the current digest of the upstream image via
skopeo inspect - Reads the
upstream-digestlabel from the previously published image (also viaskopeo inspect) - If the digests match → skip build and push
- If they differ, or no previous image exists → full rebuild
No git commits, no external databases, no Action variables. The registry is the state store.
The Cascade Works for Free
The needs: chain in the orchestrating workflow (base → cloud → docker) means
the cascade resolves automatically:
- If
fedora-bootchasn’t changed →baseis skipped →cloudchecksbase’s digest (unchanged) →cloudis skipped →dockercheckscloud’s digest (unchanged) →dockeris skipped - If
fedora-bootcupdated →baserebuilds →cloudsees the newbasedigest →cloudrebuilds → and so on down the stack
No extra coordination logic needed.
Implementation
Reusable workflow: new upstream_image input
The reusable workflow (image_build_and_push.yaml) gets two new optional inputs:
upstream_image:
description: "Full image reference to check for upstream changes (omit to always rebuild)"
required: false
default: ""
type: string
force_build:
description: "Force a rebuild regardless of upstream digest"
required: false
default: false
type: boolean
Making upstream_image optional means push-triggered per-image workflows that don’t pass it continue to work exactly as before – they always rebuild.
The check step
- name: check upstream digest
id: check_upstream
env:
UPSTREAM_IMAGE_INPUT: ${{ inputs.upstream_image }}
FORCE_BUILD: ${{ inputs.force_build }}
run: |
if [ "$FORCE_BUILD" = "true" ]; then
echo "needs_rebuild=true" >> $GITHUB_OUTPUT
echo "upstream_digest=" >> $GITHUB_OUTPUT
exit 0
fi
if [ -z "$UPSTREAM_IMAGE_INPUT" ]; then
echo "needs_rebuild=true" >> $GITHUB_OUTPUT
echo "upstream_digest=" >> $GITHUB_OUTPUT
exit 0
fi
UPSTREAM_DIGEST=$(skopeo inspect docker://$UPSTREAM_IMAGE_INPUT \
2>/dev/null | jq -r '.Digest // empty')
if [ -z "$UPSTREAM_DIGEST" ]; then
echo "Failed to fetch upstream digest; forcing rebuild"
echo "needs_rebuild=true" >> $GITHUB_OUTPUT
echo "upstream_digest=" >> $GITHUB_OUTPUT
exit 0
fi
PREV_DIGEST=$(skopeo inspect \
docker://${{ vars.CONTAINER_REGISTRY }}/${{ env.FORGEJO_REPOSITORY }}:${{ inputs.tag }}-${{ inputs.fedora_version }} \
2>/dev/null | jq -r '.Labels["upstream-digest"] // empty') || PREV_DIGEST=""
if [ "$UPSTREAM_DIGEST" = "$PREV_DIGEST" ] && [ -n "$PREV_DIGEST" ]; then
echo "needs_rebuild=false" >> $GITHUB_OUTPUT
else
echo "needs_rebuild=true" >> $GITHUB_OUTPUT
fi
echo "upstream_digest=$UPSTREAM_DIGEST" >> $GITHUB_OUTPUT
A few things worth noting:
Graceful degradation: if skopeo can’t reach the upstream registry (network blip, image not yet published), the step forces a rebuild rather than silently skipping. Better to build unnecessarily than to silently do nothing.
No label on failure: the upstream_digest output is left empty when the
fetch fails. The build step only stamps the --label upstream-digest=... when
it has a real digest to record, so a failed run doesn’t poison the skip detection
for future runs.
Expression injection protection: ${{ inputs.upstream_image }} is passed
via an env: block and referenced as $UPSTREAM_IMAGE_INPUT in the script,
not interpolated directly into the shell command. This avoids an injection vector
if the input ever contains shell metacharacters.
Stamping the label on build
- name: build image
if: steps.check_upstream.outputs.needs_rebuild == 'true'
env:
UPSTREAM_DIGEST: ${{ steps.check_upstream.outputs.upstream_digest }}
run: |
LABEL_ARG=""
if [ -n "$UPSTREAM_DIGEST" ]; then
LABEL_ARG="--label upstream-digest=$UPSTREAM_DIGEST"
fi
podman build \
--build-arg FEDORA_VERSION=${{ inputs.fedora_version }} \
$LABEL_ARG \
-t ${{ vars.CONTAINER_REGISTRY }}/${{ env.FORGEJO_REPOSITORY }}:${{ inputs.tag }}-${{ inputs.fedora_version }} \
${{ inputs.dir }}
Orchestrating workflow: wiring up the upstream references
Each job in the orchestrating build.yaml now declares which image it tracks:
build_base:
with:
upstream_image: registry.fedoraproject.org/fedora-bootc:${{ matrix.fedora_version }}
build_cloud:
needs: [build_base]
with:
upstream_image: ${{ vars.CONTAINER_REGISTRY }}/oci/c-os:base-${{ matrix.fedora_version }}
build_docker:
needs: [build_cloud]
with:
upstream_image: ${{ vars.CONTAINER_REGISTRY }}/oci/c-os:cloud-${{ matrix.fedora_version }}
The matrix runs F43 and F44 independently, so an F44-only Fedora update only triggers F44 rebuilds.
One Gotcha: skopeo and podman share credentials
skopeo inspect on a private registry needs authentication. I initially worried
I’d have to run a separate skopeo login, but podman login and skopeo share
the same auth.json credential store ($XDG_RUNTIME_DIR/containers/auth.json
or ~/.config/containers/auth.json). A single podman login at the top of the
job covers both.
The Escape Hatch
Sometimes you need to force a rebuild regardless of digests – after a Containerfile change, or to pick up a specific package update that landed without a Fedora base image change. The force_build input handles this:
workflow_dispatch:
inputs:
force_build:
description: "Force Trigger a rebuild"
default: false
type: boolean
Pass force_build: true and the digest check short-circuits immediately to needs_rebuild=true, bypassing all the skopeo logic.
Result
Daily scheduled runs now complete in a few minutes on quiet days: the digest check runs for each image, finds nothing changed, logs the skip, and exits green. Actual builds only fire when fedora-bootc publishes a new digest – which is the right trigger.
Comments
Talk about this post on Mastodon or Bluesky with #m1ch20260507ForgejoRunneresAdvanced.
To comment here, reply to the post on Mastodon or on Bluesky.
Loading comments contacts graz.social or mastodon.social (wherever the post is) and Bluesky, see the privacy policy.