From 0ad8712e73b46518de364faebc0b977d83887be9 Mon Sep 17 00:00:00 2001 From: "Al @h0lybyte" <5599058+h0lybyte@users.noreply.github.com> Date: Wed, 25 Feb 2026 18:24:29 -0500 Subject: [PATCH] feat(ci): add weekly Docker smoke test with digest pinning and cache warming Weekly cron (Sunday 4am UTC) that: - Resolves latest SHA digests for all Docker base images via crane - Pins digests in Dockerfiles for deterministic builds - Builds all 7 Docker images in parallel to validate and warm GHA caches - Auto-PRs digest changes to dev if all builds pass Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci-docker-smoke-test.yml | 248 +++++++++++++++++++++ scripts/resolve-docker-digests.sh | 166 ++++++++++++++ 2 files changed, 414 insertions(+) create mode 100644 .github/workflows/ci-docker-smoke-test.yml create mode 100755 scripts/resolve-docker-digests.sh diff --git a/.github/workflows/ci-docker-smoke-test.yml b/.github/workflows/ci-docker-smoke-test.yml new file mode 100644 index 0000000000..b814ca9657 --- /dev/null +++ b/.github/workflows/ci-docker-smoke-test.yml @@ -0,0 +1,248 @@ +name: CI - Docker Smoke Test + +on: + schedule: + - cron: '0 4 * * 0' # Sunday 04:00 UTC + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: true + +permissions: {} + +jobs: + resolve_digests: + name: Resolve Base Image Digests + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + outputs: + digests_changed: ${{ steps.resolve.outputs.digests_changed }} + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + submodules: true + + - name: Install crane + uses: imjasonh/setup-crane@v0.4 + + - name: Resolve digests + id: resolve + run: | + chmod +x scripts/resolve-docker-digests.sh + EXIT_CODE=0 + ./scripts/resolve-docker-digests.sh \ + apps/mc/Dockerfile \ + apps/herbmail/axum-herbmail/Dockerfile \ + apps/memes/axum-memes/Dockerfile \ + apps/discordsh/axum-discordsh/Dockerfile \ + apps/irc/irc-gateway/Dockerfile \ + apps/discordsh/notification-bot/Dockerfile \ + apps/kbve/kilobase/Dockerfile \ + || EXIT_CODE=$? + + if [ $EXIT_CODE -eq 0 ]; then + echo "digests_changed=true" >> "$GITHUB_OUTPUT" + elif [ $EXIT_CODE -eq 2 ]; then + echo "digests_changed=false" >> "$GITHUB_OUTPUT" + else + echo "Script failed with exit code $EXIT_CODE" + exit 1 + fi + + - name: Upload pinned Dockerfiles + if: steps.resolve.outputs.digests_changed == 'true' + uses: actions/upload-artifact@v4 + with: + name: pinned-dockerfiles + path: | + apps/mc/Dockerfile + apps/herbmail/axum-herbmail/Dockerfile + apps/memes/axum-memes/Dockerfile + apps/discordsh/axum-discordsh/Dockerfile + apps/irc/irc-gateway/Dockerfile + apps/discordsh/notification-bot/Dockerfile + apps/kbve/kilobase/Dockerfile + retention-days: 1 + + - name: Upload digest report + if: steps.resolve.outputs.digests_changed == 'true' + uses: actions/upload-artifact@v4 + with: + name: digest-report + path: /tmp/digest-report.md + retention-days: 1 + + smoke_build: + name: Smoke Build - ${{ matrix.name }} + needs: resolve_digests + runs-on: ubuntu-latest + timeout-minutes: 90 + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: + - name: mc + dockerfile: apps/mc/Dockerfile + context: apps/mc + cache_scope: smoke-mc + submodules: true + - name: herbmail + dockerfile: apps/herbmail/axum-herbmail/Dockerfile + context: . + cache_scope: smoke-herbmail + submodules: false + - name: memes + dockerfile: apps/memes/axum-memes/Dockerfile + context: . + cache_scope: smoke-memes + submodules: false + - name: discordsh + dockerfile: apps/discordsh/axum-discordsh/Dockerfile + context: . + cache_scope: smoke-discordsh + submodules: false + - name: irc-gateway + dockerfile: apps/irc/irc-gateway/Dockerfile + context: . + cache_scope: smoke-irc-gateway + submodules: false + - name: notification-bot + dockerfile: apps/discordsh/notification-bot/Dockerfile + context: apps/discordsh/notification-bot + cache_scope: smoke-notification-bot + submodules: false + - name: kilobase + dockerfile: apps/kbve/kilobase/Dockerfile + context: apps/kbve/kilobase + cache_scope: smoke-kilobase + submodules: false + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + submodules: ${{ matrix.submodules }} + + - name: Download pinned Dockerfiles + if: needs.resolve_digests.outputs.digests_changed == 'true' + uses: actions/download-artifact@v4 + with: + name: pinned-dockerfiles + path: . + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build ${{ matrix.name }} + uses: docker/build-push-action@v6 + with: + context: ${{ matrix.context }} + file: ${{ matrix.dockerfile }} + push: false + load: false + tags: smoke-test/${{ matrix.name }}:latest + cache-from: type=gha,scope=${{ matrix.cache_scope }} + cache-to: type=gha,scope=${{ matrix.cache_scope }},mode=max + + create_pr: + name: Create Digest Update PR + needs: [resolve_digests, smoke_build] + if: needs.smoke_build.result == 'success' && needs.resolve_digests.outputs.digests_changed == 'true' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + pull-requests: write + + steps: + - name: Checkout dev branch + uses: actions/checkout@v6 + with: + ref: dev + + - name: Download pinned Dockerfiles + uses: actions/download-artifact@v4 + with: + name: pinned-dockerfiles + path: . + + - name: Download digest report + uses: actions/download-artifact@v4 + with: + name: digest-report + path: /tmp + + - name: Commit and push + id: commit + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + BRANCH_NAME="chore/docker-digest-pin-$(date +%Y%m%d)" + + git add \ + apps/mc/Dockerfile \ + apps/herbmail/axum-herbmail/Dockerfile \ + apps/memes/axum-memes/Dockerfile \ + apps/discordsh/axum-discordsh/Dockerfile \ + apps/irc/irc-gateway/Dockerfile \ + apps/discordsh/notification-bot/Dockerfile \ + apps/kbve/kilobase/Dockerfile + + if git diff --cached --quiet; then + echo "No changes to commit" + echo "pushed=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + git checkout -b "$BRANCH_NAME" + git commit -m "chore(docker): pin base image digests $(date +%Y-%m-%d)" + git push -u origin "$BRANCH_NAME" + + echo "pushed=true" >> "$GITHUB_OUTPUT" + echo "branch_name=$BRANCH_NAME" >> "$GITHUB_OUTPUT" + + - name: Create PR + if: steps.commit.outputs.pushed == 'true' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + BRANCH_NAME="${{ steps.commit.outputs.branch_name }}" + REPORT=$(cat /tmp/digest-report.md 2>/dev/null || echo "No report available") + + EXISTING=$(gh pr list --base dev --head "$BRANCH_NAME" --state open --json number -q '.[0].number' || echo "") + if [ -n "$EXISTING" ]; then + echo "PR #$EXISTING already exists for $BRANCH_NAME" + exit 0 + fi + + cat > /tmp/pr-body.md < [ ...] +# +# Prerequisites: crane (https://github.com/google/go-containerregistry) +# +# Exit codes: +# 0 — digests changed (Dockerfiles modified in-place) +# 1 — error +# 2 — no changes needed + +set -euo pipefail + +DOCKERFILES=("$@") +REPORT_FILE="/tmp/digest-report.md" +CHANGES_DETECTED=0 + +if [ ${#DOCKERFILES[@]} -eq 0 ]; then + echo "Usage: $0 [ ...]" >&2 + exit 1 +fi + +if ! command -v crane &>/dev/null; then + echo "Error: crane is not installed" >&2 + exit 1 +fi + +declare -A IMAGE_DIGESTS +declare -A IMAGE_ERRORS +declare -A UNIQUE_IMAGES + +# Step 1: Collect all unique external base images from all Dockerfiles +for df in "${DOCKERFILES[@]}"; do + if [ ! -f "$df" ]; then + echo "Warning: $df not found, skipping" >&2 + continue + fi + + while IFS= read -r line; do + # Skip empty lines and comments + [[ -z "$line" || "$line" =~ ^[[:space:]]*# ]] && continue + + # Match FROM lines + if [[ "$line" =~ ^[Ff][Rr][Oo][Mm][[:space:]] ]]; then + rest="${line#[Ff][Rr][Oo][Mm] }" + # Trim leading whitespace + rest="${rest#"${rest%%[![:space:]]*}"}" + + # Strip --platform=XXX if present + if [[ "$rest" =~ ^--platform=[^[:space:]]+[[:space:]]+(.*) ]]; then + rest="${BASH_REMATCH[1]}" + fi + + # Extract image reference (first token) + image_ref="${rest%% *}" + + # Strip existing @sha256:... digest + image_base="${image_ref%%@*}" + + # Skip scratch + [[ "$image_base" == "scratch" ]] && continue + + # Skip stage aliases: external images always contain "/" or ":" + if [[ "$image_base" != *"/"* && "$image_base" != *":"* ]]; then + continue + fi + + UNIQUE_IMAGES["$image_base"]=1 + fi + done < "$df" +done + +# Step 2: Resolve digests for each unique image +echo "Resolving digests for ${#UNIQUE_IMAGES[@]} unique base images..." +{ + echo "| Image | Digest | Status |" + echo "|-------|--------|--------|" +} > "$REPORT_FILE" + +for image in "${!UNIQUE_IMAGES[@]}"; do + echo " Resolving: $image" + if digest=$(crane digest --platform linux/amd64 "$image" 2>&1); then + IMAGE_DIGESTS["$image"]="$digest" + echo " -> $digest" + echo "| \`$image\` | \`${digest:0:19}...\` | resolved |" >> "$REPORT_FILE" + else + IMAGE_ERRORS["$image"]="$digest" + echo " !! FAILED: $digest" >&2 + echo "| \`$image\` | N/A | FAILED |" >> "$REPORT_FILE" + fi +done + +# Step 3: Rewrite Dockerfiles in-place with pinned digests +for df in "${DOCKERFILES[@]}"; do + [ ! -f "$df" ] && continue + + tmpfile="${df}.tmp" + + while IFS= read -r line || [[ -n "$line" ]]; do + # Only process FROM lines + if [[ "$line" =~ ^[Ff][Rr][Oo][Mm][[:space:]] ]]; then + rest="${line#[Ff][Rr][Oo][Mm] }" + rest="${rest#"${rest%%[![:space:]]*}"}" + + # Parse platform prefix + platform_prefix="" + if [[ "$rest" =~ ^(--platform=[^[:space:]]+)[[:space:]]+(.*) ]]; then + platform_prefix="${BASH_REMATCH[1]} " + rest="${BASH_REMATCH[2]}" + fi + + # Split image ref from alias + image_ref="${rest%% *}" + alias_part="" + if [[ "$rest" == *" "* ]]; then + alias_part=" ${rest#* }" + fi + + # Get base image without digest + image_base="${image_ref%%@*}" + + # Check if we have a digest for this image + if [[ -n "${IMAGE_DIGESTS[$image_base]+x}" ]]; then + new_digest="${IMAGE_DIGESTS[$image_base]}" + + # Check if digest changed + old_digest="" + if [[ "$image_ref" == *"@"* ]]; then + old_digest="${image_ref#*@}" + fi + if [[ "$old_digest" != "$new_digest" ]]; then + CHANGES_DETECTED=1 + fi + + echo "FROM ${platform_prefix}${image_base}@${new_digest}${alias_part}" + else + echo "$line" + fi + else + echo "$line" + fi + done < "$df" > "$tmpfile" + + mv "$tmpfile" "$df" +done + +# Step 4: Summary +if [ ${#IMAGE_ERRORS[@]} -gt 0 ]; then + echo "" + echo "WARNING: ${#IMAGE_ERRORS[@]} images failed to resolve:" >&2 + for image in "${!IMAGE_ERRORS[@]}"; do + echo " - $image: ${IMAGE_ERRORS[$image]}" >&2 + done +fi + +echo "" +echo "Summary: ${#IMAGE_DIGESTS[@]} resolved, ${#IMAGE_ERRORS[@]} failed" + +if [ $CHANGES_DETECTED -eq 1 ]; then + echo "Digests changed — Dockerfiles updated" + exit 0 +else + echo "No digest changes detected" + exit 2 +fi