Skip to content

Weekly image audit

Weekly image audit #1

Workflow file for this run

# ===========================================================================
# 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}`);
}