|
| 1 | +name: check-image-versions |
| 2 | + |
| 3 | +# Automatically checks whether any upstream container image has received a new |
| 4 | +# release, and opens a pull-request to update the matching Helm chart when one |
| 5 | +# is found. Container-registry tags are the sole source of truth: repository |
| 6 | +# release tags are never consulted, because they can diverge from the tags that |
| 7 | +# are actually published to Docker Hub or GHCR. |
| 8 | + |
| 9 | +on: |
| 10 | + schedule: |
| 11 | + # Run every Sunday at midnight UTC |
| 12 | + - cron: '0 0 * * 0' |
| 13 | + workflow_dispatch: |
| 14 | + |
| 15 | +jobs: |
| 16 | + check-image-versions: |
| 17 | + name: Check for new upstream image versions |
| 18 | + runs-on: ubuntu-latest |
| 19 | + permissions: |
| 20 | + contents: write |
| 21 | + pull-requests: write |
| 22 | + packages: read |
| 23 | + steps: |
| 24 | + # Use AUTO_PR_TOKEN (a PAT or GitHub-App token) when available so that |
| 25 | + # PRs opened by this workflow trigger the repository's pull_request |
| 26 | + # workflows (chart lint, conventional-commit checks, etc.). Falls back |
| 27 | + # to GITHUB_TOKEN if the secret is not configured, but PRs created with |
| 28 | + # GITHUB_TOKEN will NOT trigger downstream workflows. |
| 29 | + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 |
| 30 | + with: |
| 31 | + fetch-depth: 0 |
| 32 | + token: ${{ secrets.AUTO_PR_TOKEN || secrets.GITHUB_TOKEN }} |
| 33 | + |
| 34 | + - name: Check and create PRs for new versions |
| 35 | + env: |
| 36 | + GH_TOKEN: ${{ secrets.AUTO_PR_TOKEN || secrets.GITHUB_TOKEN }} |
| 37 | + run: | |
| 38 | + set -euo pipefail |
| 39 | +
|
| 40 | + # ── Git identity ─────────────────────────────────────────────────── |
| 41 | + git config user.name "github-actions[bot]" |
| 42 | + git config user.email "github-actions[bot]@users.noreply.github.com" |
| 43 | +
|
| 44 | + UPSTREAM_CONFIG="scripts/upstream-versions.json" |
| 45 | +
|
| 46 | + # Iterate over every chart defined in the config |
| 47 | + CHARTS=$(jq -r 'keys[]' "$UPSTREAM_CONFIG") |
| 48 | +
|
| 49 | + for CHART in $CHARTS; do |
| 50 | + echo "==========================================" |
| 51 | + echo "Checking chart: $CHART" |
| 52 | + echo "==========================================" |
| 53 | +
|
| 54 | + # ── Read config fields ───────────────────────────────────────── |
| 55 | + REGISTRY=$(jq -r --arg c "$CHART" '.[$c].registry' "$UPSTREAM_CONFIG") |
| 56 | + IMAGE=$(jq -r --arg c "$CHART" '.[$c].image' "$UPSTREAM_CONFIG") |
| 57 | + TAG_REGEX=$(jq -r --arg c "$CHART" '.[$c].tag_regex' "$UPSTREAM_CONFIG") |
| 58 | + TAG_PATTERN=$(jq -r --arg c "$CHART" '.[$c].tag_pattern // ""' "$UPSTREAM_CONFIG") |
| 59 | + APP_PREFIX=$(jq -r --arg c "$CHART" '.[$c].app_version_prefix // ""' "$UPSTREAM_CONFIG") |
| 60 | +
|
| 61 | + echo " Registry : $REGISTRY" |
| 62 | + echo " Image : $IMAGE" |
| 63 | + echo " Tag regex : $TAG_REGEX" |
| 64 | +
|
| 65 | + # ── Fetch candidate tags from the container registry ─────────── |
| 66 | + case "$REGISTRY" in |
| 67 | + ghcr) |
| 68 | + # GHCR container package. The path segment before the last '/' |
| 69 | + # is the org; the remainder is the package name (which may |
| 70 | + # contain '/' for nested packages, though none of ours do). |
| 71 | + GHCR_ORG="${IMAGE%%/*}" |
| 72 | + GHCR_PKG="${IMAGE#*/}" |
| 73 | + RAW_TAGS=$(gh api "/orgs/${GHCR_ORG}/packages/container/${GHCR_PKG}/versions" \ |
| 74 | + --paginate \ |
| 75 | + --jq '.[].metadata.container.tags[]' 2>/dev/null || echo "") |
| 76 | + ;; |
| 77 | + dockerhub) |
| 78 | + # Docker Hub v2 tags API. Paginate through the `next` link so |
| 79 | + # we do not miss releases that are older than the first 100. |
| 80 | + RAW_TAGS="" |
| 81 | + URL="https://hub.docker.com/v2/repositories/${IMAGE}/tags?page_size=100" |
| 82 | + # Bound the pagination loop so a broken `next` link cannot spin |
| 83 | + # forever. |
| 84 | + for _ in $(seq 1 20); do |
| 85 | + [[ -z "$URL" || "$URL" == "null" ]] && break |
| 86 | + RESP=$(curl -sS "$URL" || echo '{}') |
| 87 | + PAGE=$(echo "$RESP" | jq -r '.results[]?.name' 2>/dev/null || echo "") |
| 88 | + if [[ -n "$PAGE" ]]; then |
| 89 | + RAW_TAGS="${RAW_TAGS}${PAGE}"$'\n' |
| 90 | + fi |
| 91 | + URL=$(echo "$RESP" | jq -r '.next // empty' 2>/dev/null || echo "") |
| 92 | + done |
| 93 | + ;; |
| 94 | + *) |
| 95 | + echo " WARNING: unsupported registry '$REGISTRY' for $CHART, skipping" |
| 96 | + continue |
| 97 | + ;; |
| 98 | + esac |
| 99 | +
|
| 100 | + if [[ -z "${RAW_TAGS// /}" ]]; then |
| 101 | + echo " WARNING: no tags returned from $REGISTRY for $IMAGE, skipping" |
| 102 | + continue |
| 103 | + fi |
| 104 | +
|
| 105 | + NEW_VALUES_TAG=$(printf '%s\n' "$RAW_TAGS" \ |
| 106 | + | grep -E "$TAG_REGEX" \ |
| 107 | + | sort -V \ |
| 108 | + | tail -1 || echo "") |
| 109 | +
|
| 110 | + if [[ -z "$NEW_VALUES_TAG" ]]; then |
| 111 | + echo " WARNING: no tag on $IMAGE matched $TAG_REGEX, skipping" |
| 112 | + continue |
| 113 | + fi |
| 114 | + echo " Latest registry tag: $NEW_VALUES_TAG" |
| 115 | +
|
| 116 | + # ── Derive the appVersion string ─────────────────────────────── |
| 117 | + # Strip the shared image-tag prefix (e.g. "v") to obtain the |
| 118 | + # numeric core, then prepend the desired appVersion prefix. |
| 119 | + CORE_VERSION="${NEW_VALUES_TAG#$TAG_PATTERN}" |
| 120 | + NEW_APP_VERSION="${APP_PREFIX}${CORE_VERSION}" |
| 121 | +
|
| 122 | + # ── Read current image tag from values.yaml ──────────────────── |
| 123 | + VALUES_FILE="charts/${CHART}/values.yaml" |
| 124 | + if [[ ! -f "$VALUES_FILE" ]]; then |
| 125 | + echo " WARNING: values.yaml not found for $CHART, skipping" |
| 126 | + continue |
| 127 | + fi |
| 128 | +
|
| 129 | + CURRENT_TAG=$(grep '^ tag:' "$VALUES_FILE" | head -1 | awk '{print $2}' | tr -d '"' | tr -d "'") |
| 130 | + echo " Current values.yaml tag: $CURRENT_TAG" |
| 131 | +
|
| 132 | + # ── Skip if already up to date ───────────────────────────────── |
| 133 | + if [[ "$CURRENT_TAG" == "$NEW_VALUES_TAG" ]]; then |
| 134 | + echo " Already up to date" |
| 135 | + continue |
| 136 | + fi |
| 137 | +
|
| 138 | + # ── Downgrade guard ──────────────────────────────────────────── |
| 139 | + # sort -V handles semver-ish ordering including trailing packaging |
| 140 | + # revisions ("11.0.1-1" > "11.0.1") and 4-segment tags. Skip if |
| 141 | + # the newest registry tag is not strictly greater than the current |
| 142 | + # values.yaml tag. |
| 143 | + HIGHEST=$(printf '%s\n%s\n' "$CURRENT_TAG" "$NEW_VALUES_TAG" | sort -V | tail -1) |
| 144 | + if [[ "$HIGHEST" != "$NEW_VALUES_TAG" ]]; then |
| 145 | + echo " Resolved tag $NEW_VALUES_TAG is not newer than current $CURRENT_TAG, skipping" |
| 146 | + continue |
| 147 | + fi |
| 148 | +
|
| 149 | + echo " Update available: $CURRENT_TAG -> $NEW_VALUES_TAG" |
| 150 | +
|
| 151 | + BRANCH_NAME="chore/${CHART}-${CORE_VERSION}" |
| 152 | +
|
| 153 | + # Skip if an open PR already exists for this update. |
| 154 | + EXISTING_PR=$(gh pr list --head "$BRANCH_NAME" --state open --json number --jq '.[0]?.number // empty') |
| 155 | + if [[ -n "$EXISTING_PR" ]]; then |
| 156 | + echo " PR #$EXISTING_PR already open for $BRANCH_NAME, skipping" |
| 157 | + continue |
| 158 | + fi |
| 159 | +
|
| 160 | + # ── Apply the update ─────────────────────────────────────────── |
| 161 | + echo " Applying update..." |
| 162 | + ./scripts/update-chart-version.sh "$CHART" "$NEW_VALUES_TAG" "$NEW_APP_VERSION" |
| 163 | +
|
| 164 | + # ── Commit and push ──────────────────────────────────────────── |
| 165 | + git checkout -b "$BRANCH_NAME" |
| 166 | + git add "charts/${CHART}/values.yaml" "charts/${CHART}/Chart.yaml" |
| 167 | + git commit -m "chore: update ${CHART} image to ${NEW_VALUES_TAG}" |
| 168 | +
|
| 169 | + # Remove a stale remote branch left over from a previous failed run |
| 170 | + # (only safe to do when no open PR was found above). |
| 171 | + if git ls-remote --exit-code --heads origin "$BRANCH_NAME" >/dev/null 2>&1; then |
| 172 | + echo " Stale remote branch found with no open PR, deleting" |
| 173 | + git push origin --delete "$BRANCH_NAME" |
| 174 | + fi |
| 175 | +
|
| 176 | + git push -u origin "$BRANCH_NAME" |
| 177 | +
|
| 178 | + # ── Open PR ──────────────────────────────────────────────────── |
| 179 | + # printf is used instead of a multi-line string so that each body |
| 180 | + # line starts at column 0, preventing GitHub's CommonMark renderer |
| 181 | + # from treating the indented content as a code block. |
| 182 | + case "$REGISTRY" in |
| 183 | + ghcr) IMAGE_REF="ghcr.io/${IMAGE}:${NEW_VALUES_TAG}" ;; |
| 184 | + dockerhub) IMAGE_REF="docker.io/${IMAGE}:${NEW_VALUES_TAG}" ;; |
| 185 | + *) IMAGE_REF="${IMAGE}:${NEW_VALUES_TAG}" ;; |
| 186 | + esac |
| 187 | +
|
| 188 | + PR_BODY=$(printf '%s\n' \ |
| 189 | + "## Summary" \ |
| 190 | + "- Updates \`${CHART}\` image tag from \`${CURRENT_TAG}\` to \`${NEW_VALUES_TAG}\`" \ |
| 191 | + "- Source: \`${IMAGE_REF}\`" \ |
| 192 | + "" \ |
| 193 | + "## Changes" \ |
| 194 | + "- \`charts/${CHART}/values.yaml\`: \`image.tag\` → \`${NEW_VALUES_TAG}\`" \ |
| 195 | + "- \`charts/${CHART}/Chart.yaml\`: \`appVersion\` → \`${NEW_APP_VERSION}\`, chart \`version\` patch bumped" \ |
| 196 | + "" \ |
| 197 | + "## Test plan" \ |
| 198 | + "- [ ] CI lint passes" \ |
| 199 | + "- [ ] Manual smoke-test if needed" \ |
| 200 | + "" \ |
| 201 | + "🤖 Generated automatically by check-image-versions workflow") |
| 202 | +
|
| 203 | + PR_CREATED=false |
| 204 | + if PR_OUTPUT=$(gh pr create \ |
| 205 | + --title "chore: update ${CHART} image to ${NEW_VALUES_TAG}" \ |
| 206 | + --body "$PR_BODY" \ |
| 207 | + --base main \ |
| 208 | + --head "$BRANCH_NAME" 2>&1); then |
| 209 | + echo "$PR_OUTPUT" |
| 210 | + PR_CREATED=true |
| 211 | + else |
| 212 | + if echo "$PR_OUTPUT" | grep -qi 'already exists'; then |
| 213 | + echo " PR already exists for $BRANCH_NAME, skipping create" |
| 214 | + else |
| 215 | + echo "$PR_OUTPUT" |
| 216 | + exit 1 |
| 217 | + fi |
| 218 | + fi |
| 219 | +
|
| 220 | + # Return to main for the next chart |
| 221 | + git checkout main |
| 222 | +
|
| 223 | + if [[ "$PR_CREATED" == "true" ]]; then |
| 224 | + echo " PR created successfully!" |
| 225 | + fi |
| 226 | + done |
| 227 | +
|
| 228 | + echo "" |
| 229 | + echo "==========================================" |
| 230 | + echo "Image version check complete" |
| 231 | + echo "==========================================" |
0 commit comments