I wanted to share a GitHub Actions workflow I’m using to automatically:
- Track an upstream Git repository
- Detect when its HEAD commit changes
- Build multiple container images (multi-arch)
- Push them to GHCR
- And skip builds when nothing changed
It also supports a manual force build and stores the last processed upstream commit in a file so it persists between runs.
What this workflow does
- Runs hourly (or manually).
- Clones an upstream repo.
- Compares the latest commit with a locally stored .upstream-commit file.
- If unchanged → skips builds.
- If changed (or forced) → builds & pushes images.
- Records the new commit back into this repo.
This avoids wasting CI minutes on unnecessary builds.
Why the .upstream-commit file?
GitHub Actions does not persist state between runs.
By committing the upstream commit hash into the repo, we get a simple, reliable “memory” of what was last built.
Images build:
- honse-honsefarm.server (amd64 + arm64)
- honse-honsefarm.fileserver (amd64 + arm64)
- honse-honsefarm.adminpanel (amd64)
Each image is tagged as:
- <short-commit>
- dev-latest
- latest
Example Workflow
name: Build honse-farm images to GHCR
on:
schedule:
- cron: "0 * * * *" # hourly, tweak as you like
workflow_dispatch:
inputs:
force_build:
description: "Force build even if upstream commit did not change"
type: boolean
default: false
permissions:
contents: write # we will commit .upstream-commit
packages: write # push to GHCR
env:
UPSTREAM_REPO: https://git.honse.farm/astraea/honse-farm.git
UPSTREAM_BRANCH: main
REGISTRY: ghcr.io/%REPOSITORY%
jobs:
prepare:
runs-on: ubuntu-latest
outputs:
build_needed: ${{ steps.decide.outputs.build_needed }}
new_commit: ${{ steps.upstream.outputs.new_commit }}
tag: ${{ steps.tag.outputs.tag }}
steps:
# 1) Checkout THIS GitHub repo (with .upstream-commit)
- name: Checkout this repo
uses: actions/checkout@v4
# 2) Read last upstream commit (local copy marker)
- name: Read last upstream commit (if any)
id: last
run: |
if [ -f .upstream-commit ]; then
LAST=$(cat .upstream-commit | tr -d '\n')
echo "Last upstream commit: $LAST"
echo "last_commit=$LAST" >> "$GITHUB_OUTPUT"
else
echo "No previous upstream commit recorded."
echo "last_commit=" >> "$GITHUB_OUTPUT"
fi
# 3) Clone upstream and get current HEAD
- name: Clone upstream
run: |
set -euo pipefail
git clone "$UPSTREAM_REPO" upstream-honse
cd upstream-honse
git checkout "$UPSTREAM_BRANCH"
NEW=$(git rev-parse HEAD)
echo "Current upstream dev commit: $NEW"
echo "new_commit=$NEW" >> "$GITHUB_OUTPUT"
id: upstream
# 4) Short-circuit if no change
- name: Decide whether to build
id: decide
run: |
LAST="${{ steps.last.outputs.last_commit }}"
NEW="${{ steps.upstream.outputs.new_commit }}"
FORCE="${{ inputs.force_build }}"
if [ "$FORCE" = "true" ]; then
echo "Force build requested."
echo "build_needed=true" >> "$GITHUB_OUTPUT"
elif [ -n "$LAST" ] && [ "$LAST" = "$NEW" ]; then
echo "No new upstream commit. Skipping build."
echo "build_needed=false" >> "$GITHUB_OUTPUT"
else
echo "Upstream changed (last='$LAST', new='$NEW'). Will build."
echo "build_needed=true" >> "$GITHUB_OUTPUT"
fi
- name: Set image tag
id: tag
run: |
NEW="${{ steps.upstream.outputs.new_commit }}"
echo "tag=${NEW::7}" >> "$GITHUB_OUTPUT"
build-images:
needs: prepare
if: needs.prepare.outputs.build_needed == 'true'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- name: server
dockerfile: HonseFarm.Server/Dockerfile
image: honse-honsefarm.server
platforms: linux/amd64,linux/arm64
- name: fileserver
dockerfile: HonseFarm.Fileserver/Dockerfile
image: honse-honsefarm.fileserver
platforms: linux/amd64,linux/arm64
- name: adminpanel
dockerfile: HonseFarm.Adminpanel/Dockerfile
image: honse-honsefarm.adminpanel
platforms: linux/amd64
steps:
- name: Clone upstream
run: |
set -euo pipefail
git clone "$UPSTREAM_REPO" upstream-honse
cd upstream-honse
git checkout "$UPSTREAM_BRANCH"
# Set up buildx & QEMU ONLY if we need to build
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
run: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io \
-u "${{ github.actor }}" --password-stdin
- name: Build & push ${{ matrix.name }}
run: |
set -euo pipefail
TAG="${{ needs.prepare.outputs.tag }}"
DOCKERFILE="${{ matrix.dockerfile }}"
IMAGE="${REGISTRY}/${{ matrix.image }}"
cd upstream-honse
git checkout "$UPSTREAM_BRANCH"
if [ ! -f "$DOCKERFILE" ]; then
echo "WARNING: ${DOCKERFILE} not found. Skipping."
exit 0
fi
docker buildx build \
--platform "${{ matrix.platforms }}" \
--file "$DOCKERFILE" \
--tag "${IMAGE}:${TAG}" \
--tag "${IMAGE}:dev-latest" \
--tag "${IMAGE}:latest" \
--push \
.
# 8) Update .upstream-commit and commit it back
record-upstream:
needs: [prepare, build-images]
if: needs.prepare.outputs.build_needed == 'true' && success()
runs-on: ubuntu-latest
steps:
- name: Checkout farm-machina (this repo)
uses: actions/checkout@v4
- name: Update .upstream-commit and push
run: |
set -euo pipefail
NEW="${{ needs.prepare.outputs.new_commit }}"
echo "$NEW" > .upstream-commit
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add .upstream-commit
if git diff --cached --quiet; then
echo "No changes to commit (unexpected)."
else
git commit -m "Sync upstream honse-farm dev at ${NEW}"
git push
fi