Weekly image audit #1
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # =========================================================================== | |
| # Weekly image audit — check all Docker images are still available | |
| # | |
| # Runs every Monday at 6 AM UTC. For each image referenced in any | |
| # docker-compose.yml, uses `crane manifest` (no pull required) to verify | |
| # the image exists on its registry. Creates a GitHub issue listing any | |
| # unavailable images so you can update the compose file before the next | |
| # deployment. | |
| # | |
| # Also handles ${VAR:-default} interpolation so env-var image references | |
| # (like ${IMMICH_VERSION:-release}) are resolved to their defaults before | |
| # checking. | |
| # | |
| # To run manually: Actions → Weekly image audit → Run workflow | |
| # =========================================================================== | |
| name: Weekly image audit | |
| on: | |
| schedule: | |
| - cron: "0 6 * * 1" # Every Monday at 6 AM UTC | |
| workflow_dispatch: # Allow manual trigger from Actions tab | |
| permissions: | |
| issues: write | |
| contents: read | |
| jobs: | |
| audit: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Install crane | |
| run: | | |
| VERSION=$(curl -fsSL https://api.github.com/repos/google/go-containerregistry/releases/latest \ | |
| | grep '"tag_name"' | cut -d'"' -f4) | |
| curl -fsSL "https://github.com/google/go-containerregistry/releases/download/${VERSION}/go-containerregistry_Linux_x86_64.tar.gz" \ | |
| | tar xz crane | |
| sudo mv crane /usr/local/bin/crane | |
| crane version | |
| - name: Extract and resolve image references | |
| id: images | |
| run: | | |
| python3 - <<'PYEOF' | |
| import re, subprocess, sys | |
| from pathlib import Path | |
| # Collect all image lines from compose files | |
| raw_images = set() | |
| for f in Path("services").rglob("docker-compose.yml"): | |
| for line in f.read_text().splitlines(): | |
| m = re.match(r'^\s+image:\s+(.+)$', line) | |
| if m: | |
| raw_images.add(m.group(1).strip()) | |
| # Resolve ${VAR:-default} → default, skip ${VAR} (no default) | |
| resolved = set() | |
| for img in raw_images: | |
| img = re.sub(r'\$\{[^}]+:-([^}]+)\}', r'\1', img) # replace ${VAR:-default} with default | |
| if '${' in img: | |
| continue # still has unresolvable var — skip | |
| resolved.add(img) | |
| # Write to file for next step | |
| with open("/tmp/images.txt", "w") as fh: | |
| fh.write("\n".join(sorted(resolved))) | |
| print(f"Found {len(resolved)} resolvable image references") | |
| PYEOF | |
| - name: Check image availability | |
| id: check | |
| run: | | |
| FAILED=() | |
| OK=0 | |
| SKIPPED=0 | |
| while IFS= read -r image; do | |
| [[ -z "$image" ]] && continue | |
| # crane needs explicit registry for short names (docker.io) | |
| echo -n " Checking $image ... " | |
| if crane manifest "$image" &>/dev/null; then | |
| echo "OK" | |
| OK=$((OK + 1)) | |
| else | |
| echo "NOT FOUND" | |
| FAILED+=("$image") | |
| fi | |
| done < /tmp/images.txt | |
| echo "" | |
| echo "Results: $OK OK, ${#FAILED[@]} failed" | |
| # Write failed list to output | |
| if [[ ${#FAILED[@]} -gt 0 ]]; then | |
| printf '%s\n' "${FAILED[@]}" > /tmp/failed_images.txt | |
| echo "has_failures=true" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "has_failures=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Close any existing audit issue (images all healthy) | |
| if: steps.check.outputs.has_failures == 'false' | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const issues = await github.rest.issues.listForRepo({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| labels: 'broken-image', | |
| state: 'open' | |
| }); | |
| for (const issue of issues.data) { | |
| await github.rest.issues.update({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: issue.number, | |
| state: 'closed', | |
| body: issue.body + '\n\n---\n_Automatically closed — all images are now available._' | |
| }); | |
| console.log(`Closed issue #${issue.number}`); | |
| } | |
| console.log('All images healthy — no issues to open.'); | |
| - name: Create or update issue for unavailable images | |
| if: steps.check.outputs.has_failures == 'true' | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const fs = require('fs'); | |
| const failed = fs.readFileSync('/tmp/failed_images.txt', 'utf8').trim().split('\n'); | |
| const date = new Date().toISOString().split('T')[0]; | |
| const body = [ | |
| `## Unavailable Docker images — ${date}`, | |
| '', | |
| 'The weekly image audit found the following images that could not be resolved.', | |
| 'These images may have been renamed, deleted, or moved to a private registry.', | |
| 'Update the affected `docker-compose.yml` files before next deployment.', | |
| '', | |
| '| Image | Action needed |', | |
| '|-------|---------------|', | |
| ...failed.map(img => `| \`${img}\` | Update or replace image reference |`), | |
| '', | |
| '**To re-run this audit:** Actions → Weekly image audit → Run workflow', | |
| '', | |
| `_Audit run: ${new Date().toISOString()}_` | |
| ].join('\n'); | |
| // Check for existing open audit issue | |
| const existing = await github.rest.issues.listForRepo({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| labels: 'broken-image', | |
| state: 'open' | |
| }); | |
| if (existing.data.length > 0) { | |
| // Update existing issue | |
| await github.rest.issues.update({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: existing.data[0].number, | |
| body: body | |
| }); | |
| console.log(`Updated issue #${existing.data[0].number}`); | |
| } else { | |
| // Ensure label exists | |
| try { | |
| await github.rest.issues.createLabel({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| name: 'broken-image', | |
| color: 'e11d48', | |
| description: 'Docker image no longer available in registry' | |
| }); | |
| } catch (e) { /* label may already exist */ } | |
| // Create new issue | |
| const issue = await github.rest.issues.create({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| title: `[Image Audit] ${failed.length} unavailable image(s) — ${date}`, | |
| body: body, | |
| labels: ['broken-image'] | |
| }); | |
| console.log(`Created issue #${issue.data.number}`); | |
| } |