From ba4133bf5d7a61e5af3766a4f1d289c47e4e5d13 Mon Sep 17 00:00:00 2001 From: Ivan Godwin Date: Sat, 18 Jul 2026 09:29:10 -0700 Subject: [PATCH] ci: build and publish multi-arch images with auto patch versioning Push to main mints the next vX.Y.Z patch tag, builds and pushes a multi-arch image to the registry, and tags the repo. A manual bump-version workflow handles minor/major bumps. This workflow deliberately holds no deployment-repo credentials: the repo is public, so the GitOps side polls the registry and pulls new tags itself rather than being pushed to from here. Co-Authored-By: Claude Fable 5 --- .gitea/workflows/build-and-publish.yml | 169 +++++++++++++++++++++++++ .gitea/workflows/bump-version.yml | 47 +++++++ 2 files changed, 216 insertions(+) create mode 100644 .gitea/workflows/build-and-publish.yml create mode 100644 .gitea/workflows/bump-version.yml diff --git a/.gitea/workflows/build-and-publish.yml b/.gitea/workflows/build-and-publish.yml new file mode 100644 index 0000000..4a8c819 --- /dev/null +++ b/.gitea/workflows/build-and-publish.yml @@ -0,0 +1,169 @@ +name: Build and Publish Container + +# Builds and publishes a multi-arch image on every push to main, minting the +# next patch version from git tags (vX.Y.Z) and pushing the tag back. +# +# NOTE (public repo): unlike private app repos, this workflow deliberately has +# NO step that pushes to the deployment (GitOps) repository and holds no +# credentials for it. Deployment repos are expected to poll the registry and +# pull new tags themselves. + +on: + push: + branches: + - main + workflow_dispatch: + inputs: + ref: + description: 'Git tag (e.g. v0.1.5) or commit SHA to rebuild. Leave empty to build latest.' + required: false + default: '' + +env: + REGISTRY: gitea.ivangodwin.com + IMAGE_NAME: ${{ gitea.repository }} + +jobs: + build-and-publish: + runs-on: docker + + permissions: + contents: write + packages: write + + steps: + - name: Checkout code + run: | + if [ -n "${{ inputs.ref }}" ]; then + git clone https://${{ gitea.actor }}:${{ gitea.token }}@gitea.ivangodwin.com/${{ gitea.repository }}.git . + git fetch --tags + git checkout "${{ inputs.ref }}" + else + git clone --depth 1 https://${{ gitea.actor }}:${{ gitea.token }}@gitea.ivangodwin.com/${{ gitea.repository }}.git . + git checkout ${{ gitea.sha }} + fi + + - name: Determine next version + run: | + REF="${{ inputs.ref }}" + if [ -n "$REF" ] && echo "$REF" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$'; then + VERSION="$REF" + echo "REBUILD=true" >> $GITHUB_ENV + else + git fetch --tags + LATEST=$(git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -1 || true) + if [ -z "$LATEST" ]; then + VERSION="v0.1.0" + else + MAJOR=$(echo "$LATEST" | cut -d. -f1 | tr -d 'v') + MINOR=$(echo "$LATEST" | cut -d. -f2) + PATCH=$(echo "$LATEST" | cut -d. -f3) + VERSION="v${MAJOR}.${MINOR}.$((PATCH + 1))" + fi + echo "REBUILD=false" >> $GITHUB_ENV + fi + echo "VERSION=${VERSION}" >> $GITHUB_ENV + + - name: Log in to Gitea Container Registry + run: | + echo "${{ secrets.CI_TOKEN }}" | docker login -u "${{ secrets.CI_USER }}" --password-stdin ${{ env.REGISTRY }} + + - name: Register QEMU emulators + run: | + docker run --rm --privileged tonistiigi/binfmt:latest --install all + + - name: Set up Docker Buildx + run: | + docker buildx inspect multiarch >/dev/null 2>&1 \ + || docker buildx create --name multiarch --driver docker-container + docker buildx use multiarch + docker buildx inspect --bootstrap + + - name: Build and push multi-arch image + run: | + IMAGE_REF="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.VERSION }}" + docker buildx build \ + --platform linux/amd64,linux/arm64 \ + --build-arg VERSION="${{ env.VERSION }}" \ + --build-arg GIT_COMMIT="$(git rev-parse --short HEAD)" \ + --build-arg BUILD_TIME="$(date -u '+%Y-%m-%d_%H:%M:%S_UTC')" \ + --no-cache \ + --provenance=false \ + -t "$IMAGE_REF" \ + --push \ + . + + - name: Tag release + if: env.REBUILD != 'true' + run: | + git tag "${{ env.VERSION }}" + git push https://${{ gitea.actor }}:${{ gitea.token }}@gitea.ivangodwin.com/${{ gitea.repository }}.git "${{ env.VERSION }}" + + - name: Clean up old container images + continue-on-error: true + timeout-minutes: 5 + env: + CI_TOKEN: ${{ secrets.CI_TOKEN }} + run: | + # Best-effort cleanup of old container image versions; must never + # fail or stall the pipeline, so the whole body runs under a hard + # timeout and the step always exits 0 itself. + cat > /tmp/cleanup.sh <<'CLEAN' + #!/bin/sh + set -u + + if ! apk add -q --no-cache curl; then + echo "curl unavailable; skipping cleanup." + exit 0 + fi + + API="https://gitea.ivangodwin.com/api/v1" + OWNER="igodwin" + NAME="notifier" + KEEP=5 + + BODY=$(curl -s --connect-timeout 15 --max-time 30 \ + -H "Authorization: token ${CI_TOKEN}" \ + "${API}/packages/${OWNER}?type=container&q=${NAME}&limit=200" || true) + + if [ -z "$BODY" ] || [ "$(printf '%s' "$BODY" | tr -d ' \t\r\n')" = "[]" ]; then + echo "No packages found." + exit 0 + fi + + VERSIONS=$(printf '%s' "$BODY" \ + | grep -oE '"version":"v[0-9]+\.[0-9]+\.[0-9]+"' \ + | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+' \ + | sort -u) + MINORS=$(printf '%s' "$VERSIONS" | grep -oE '^v[0-9]+\.[0-9]+' | sort -u) + + for MINOR in $MINORS; do + PATCHES=$(printf '%s' "$VERSIONS" | grep "^${MINOR}\." | sort -t. -k3,3n) + TOTAL=$(printf '%s\n' "$PATCHES" | grep -c .) + + if [ "$TOTAL" -le "$KEEP" ]; then + echo "${MINOR}: ${TOTAL} versions, nothing to delete" + continue + fi + + TO_DELETE=$((TOTAL - KEEP)) + echo "${MINOR}: ${TOTAL} versions, keeping ${KEEP}, deleting ${TO_DELETE}" + + printf '%s\n' "$PATCHES" | head -n "$TO_DELETE" | while read -r VER; do + STATUS=$(curl -s --connect-timeout 15 --max-time 60 -o /dev/null -w "%{http_code}" \ + -X DELETE -H "Authorization: token ${CI_TOKEN}" \ + "${API}/packages/${OWNER}/container/${NAME}/${VER}" || echo 000) + case "$STATUS" in + 200|202|204) echo " deleted ${NAME}:${VER} (HTTP ${STATUS})" ;; + *) echo " WARN: could not delete ${NAME}:${VER} (HTTP ${STATUS})" ;; + esac + done + done + CLEAN + + timeout 240 sh /tmp/cleanup.sh + rc=$? + if [ "$rc" -ne 0 ]; then + echo "cleanup did not finish cleanly (rc=${rc}); ignoring and exiting green." + fi + exit 0 diff --git a/.gitea/workflows/bump-version.yml b/.gitea/workflows/bump-version.yml new file mode 100644 index 0000000..4ee9acf --- /dev/null +++ b/.gitea/workflows/bump-version.yml @@ -0,0 +1,47 @@ +name: Bump Version + +on: + workflow_dispatch: + inputs: + component: + description: 'Version component to bump' + required: true + type: choice + options: + - minor + - major + +jobs: + bump: + runs-on: docker + + permissions: + contents: write + + steps: + - name: Checkout code + run: | + git clone --depth 1 https://${{ gitea.actor }}:${{ gitea.token }}@gitea.ivangodwin.com/${{ gitea.repository }}.git . + + - name: Compute and push new version tag + run: | + git fetch --tags + LATEST=$(git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -1 || true) + if [ -z "$LATEST" ]; then + if [ "${{ inputs.component }}" = "major" ]; then + VERSION="v1.0.0" + else + VERSION="v0.1.0" + fi + else + MAJOR=$(echo "$LATEST" | cut -d. -f1 | tr -d 'v') + MINOR=$(echo "$LATEST" | cut -d. -f2) + if [ "${{ inputs.component }}" = "major" ]; then + VERSION="v$((MAJOR + 1)).0.0" + else + VERSION="v${MAJOR}.$((MINOR + 1)).0" + fi + fi + echo "Bumping to ${VERSION}" + git tag "$VERSION" + git push https://${{ gitea.actor }}:${{ gitea.token }}@gitea.ivangodwin.com/${{ gitea.repository }}.git "$VERSION"