From a3c772d6cad961cc34ddb7488ca3943e2113f0ee Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Tue, 3 Nov 2026 16:42:57 -0500 Subject: [PATCH 1/4] feature: minor and patch image releases merge themselves once their checks pass, and each major release opens a PR to review --- .github/scripts/image_updates.sh | 184 +++++++++++++++++++++++++ .github/workflows/image-updates.yml | 201 ++++++++++++++++++++++++++++ .github/workflows/panel-scripts.yml | 5 +- tests/scripts/image_updates_test.sh | 181 +++++++++++++++++++++++++ tests/scripts/stub/crane | 13 ++ 5 files changed, 583 insertions(+), 1 deletion(-) create mode 100755 .github/scripts/image_updates.sh create mode 100644 .github/workflows/image-updates.yml create mode 100644 tests/scripts/image_updates_test.sh create mode 100755 tests/scripts/stub/crane diff --git a/.github/scripts/image_updates.sh b/.github/scripts/image_updates.sh new file mode 100755 index 0000000..32627f6 --- /dev/null +++ b/.github/scripts/image_updates.sh @@ -0,0 +1,184 @@ +#!/bin/bash +# Finds newer releases of the third-party images the panel pins, for +# .github/workflows/image-updates.yml. Run it from inside the repository. +# +# image_updates.sh plan one line per update: +# image_updates.sh apply OLD NEW rewrites every pin of OLD to NEW +# image_updates.sh title the commit title for the plan lines on stdin +# +# A pin is a tracked `image: ` line or a shell `_IMAGE=` +# assignment whose tag is a version. An update keeps the tag's shape: its `v`, +# its number of version parts and the suffix after the version (`-alpine`, +# `-pg18`, `-k3s1`) stay the same, so a tag only moves to another release of +# the same build, and prereleases (`-rc1`, `.rc22`) never match. A pinned +# digest is re-pinned to the new tag's. An update is major when the first +# version part changes, or below 1.0 the second. +set -euo pipefail + +# Pins that only move by hand. +IGNORED_PATHS=( + # The upstream cert-manager release file, held on 1.17: from 1.18 the + # HTTP-01 solver's Ingress is rejected by the older ingress-nginx some + # panels still run, and renewals quietly stop. + ':!overlays/cert-manager-crds/cert-manager.yaml' + # The system-upgrade-controller's release assets, which + # tests/scripts/system_upgrade_test.sh checks against their sha256. + ':!base/system-upgrade/' + # The old releases the TimescaleDB upgrade tests start from. + ':!tests/timescaledb/' + # This script's own test fixtures. + ':!tests/scripts/image_updates_test.sh' + # Workflows, which the app's token can't push. + ':!.github/' +) + +die() { + echo "image_updates.sh: $*" >&2 + exit 1 +} + +cd "$(git rev-parse --show-toplevel)" + +# Every distinct ref pinned in the tracked files. +pins() { + git grep -I -h -o -E \ + -e 'image:[[:space:]]*"?[^[:space:]"]+' \ + -e '^[[:space:]]*[A-Z0-9_]+_IMAGE=[^[:space:]"]+' \ + -- "${IGNORED_PATHS[@]}" | + sed -E 's/^.*(image:[[:space:]]*"?|_IMAGE=)//' | + sort -u +} + +# The newest tags in the list on stdin that keep the shape of tag $1, as +# "minor " and "major " lines. +newer_tags() { + awk -v current="$1" ' + # Splits a tag into its v, version and suffix. 0 for a tag that is not + # a plain version, like latest, 19beta1-alpine or 31.0.rc22-arm64. + function parse(tag, out) { + out["v"] = "" + if (tag ~ /^v[0-9]/) { + out["v"] = "v" + tag = substr(tag, 2) + } + if (!match(tag, /^[0-9]+(\.[0-9]+)*/)) return 0 + out["version"] = substr(tag, 1, RLENGTH) + out["suffix"] = substr(tag, RLENGTH + 1) + return out["suffix"] == "" || out["suffix"] ~ /^-/ + } + # 1 when version a is newer than version b, which has as many parts. + function newer(a, b, x, y, n, i) { + n = split(a, x, ".") + split(b, y, ".") + for (i = 1; i <= n; i++) { + if (x[i] + 0 != y[i] + 0) return x[i] + 0 > y[i] + 0 + } + return 0 + } + BEGIN { + if (!parse(current, cur)) exit + parts = split(cur["version"], c, ".") + } + { + if (!parse($0, cand)) next + if (cand["v"] != cur["v"] || cand["suffix"] != cur["suffix"]) next + if (split(cand["version"], n, ".") != parts) next + if (!newer(cand["version"], cur["version"])) next + kind = "minor" + if (n[1] + 0 != c[1] + 0 || (c[1] + 0 == 0 && parts > 1 && n[2] + 0 != c[2] + 0)) kind = "major" + if (!(kind in best) || newer(cand["version"], best[kind])) { + best[kind] = cand["version"] + best_tag[kind] = $0 + } + } + END { + if ("minor" in best_tag) print "minor", best_tag["minor"] + if ("major" in best_tag) print "major", best_tag["major"] + } + ' +} + +plan() { + local ref name tag digest kind new_tag new_digest tags + pins | while IFS= read -r ref; do + digest="" + if [[ $ref == *@* ]]; then + digest="${ref#*@}" + fi + name="${ref%%@*}" + tag="${name##*:}" + # name@digest, a registry port with no tag after it, or a tag like + # latest that no release moves + if [[ $name != *:* || ! $tag =~ ^v?[0-9]+(\.[0-9]+)*(-.*)?$ ]]; then + continue + fi + name="${name%:*}" + if ! tags="$(crane ls "$name" 2>&1 &2 + continue + fi + newer_tags "$tag" <<<"$tags" | while read -r kind new_tag; do + if [ -z "$digest" ]; then + echo "$kind $ref $name:$new_tag" + elif new_digest="$(crane digest "$name:$new_tag" 2>&1 &2 + fi + done + done +} + +apply() { + local old="$1" new="$2" files=() before + while IFS= read -r file; do + files+=("$file") + done < <(git grep -I -l -F -e "$old" -- "${IGNORED_PATHS[@]}") + [ ${#files[@]} -gt 0 ] || die "$old is not pinned anywhere" + before="$(cat -- "${files[@]}" | git hash-object --stdin)" + OLD="$old" NEW="$new" perl -pi -e \ + 's/(image:[ \t]*"?|_IMAGE=)\Q$ENV{OLD}\E(?=["\s]|$)/$1$ENV{NEW}/g' -- "${files[@]}" + if [ "$(cat -- "${files[@]}" | git hash-object --stdin)" = "$before" ]; then + die "$old is mentioned, but not pinned, in ${files[*]}" + fi +} + +# "redis 8.12.0-alpine" for docker.io/library/redis:8.12.0-alpine@sha256:... +label() { + local ref="${1%%@*}" name + name="${ref%:*}" + case "${name%%/*}" in + *.* | *:* | localhost) [[ $name == */* ]] && name="${name#*/}" ;; + esac + name="${name#library/}" + echo "$name ${ref##*:}" +} + +title() { + local kind old new labels=() list + while read -r kind old new; do + [ -n "$new" ] || continue + labels+=("$(label "$new")") + done + [ ${#labels[@]} -gt 0 ] || die "no updates on stdin" + list="${labels[0]}" + if [ ${#labels[@]} -gt 1 ]; then + list="$(printf '%s, ' "${labels[@]:0:${#labels[@]}-1}")" + list="${list%, } and ${labels[${#labels[@]}-1]}" + fi + if [ ${#list} -gt 200 ]; then + list="${#labels[@]} images" + fi + echo "chore: $list" +} + +case "${1:-}" in + plan) plan ;; + apply) + [ $# -eq 3 ] || die "usage: image_updates.sh apply OLD NEW" + apply "$2" "$3" + ;; + title) title ;; + *) die "usage: image_updates.sh plan | apply OLD NEW | title" ;; +esac diff --git a/.github/workflows/image-updates.yml b/.github/workflows/image-updates.yml new file mode 100644 index 0000000..aa367e1 --- /dev/null +++ b/.github/workflows/image-updates.yml @@ -0,0 +1,201 @@ +name: Image updates + +# Moves the third-party images the panel pins to their newest releases; see +# .github/scripts/image_updates.sh for what counts as one and which pins only +# move by hand. Minor and patch releases go into one PR that merges itself +# once its checks pass. Each major release gets a PR of its own, assigned to +# REVIEWERS to check that the panel still works on it. Closing a major PR skips +# that release; a newer major release opens a new PR. +on: + schedule: + - cron: "0 13 * * 1" + workflow_dispatch: + +permissions: {} + +concurrency: + group: image-updates + +env: + REVIEWERS: lukepolo + CRANE_VERSION: v0.22.1 + CRANE_SHA256: 0ab7a1d6932a213aed964ce97666c3077fe691c8606413674a8b3e0b9ec4cda0 + GH_REPO: ${{ github.repository }} + +defaults: + run: + shell: bash + +jobs: + update: + if: github.repository == '5stackgg/5stack-panel' + runs-on: ubuntu-latest + timeout-minutes: 45 + # GITHUB_TOKEN only reads checks and keeps the labels. Branches and PRs + # come from the app's token, because ones from GITHUB_TOKEN start no checks + # and no release. + permissions: + contents: read + checks: read + statuses: read + pull-requests: read + issues: write + steps: + - name: Get a token from the 5Stack Ops app + id: app + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ vars.OPS_APP_CLIENT_ID }} + private-key: ${{ secrets.OPS_APP_KEY }} + permission-contents: write + permission-pull-requests: write + + - uses: actions/checkout@v4 + with: + token: ${{ steps.app.outputs.token }} + fetch-depth: 0 + + - name: Install crane + run: | + curl -fsSLo "$RUNNER_TEMP/crane.tar.gz" \ + "https://github.com/google/go-containerregistry/releases/download/$CRANE_VERSION/go-containerregistry_Linux_x86_64.tar.gz" + echo "$CRANE_SHA256 $RUNNER_TEMP/crane.tar.gz" | sha256sum -c - + tar -xzf "$RUNNER_TEMP/crane.tar.gz" -C "$RUNNER_TEMP" crane + echo "$RUNNER_TEMP" >>"$GITHUB_PATH" + + # The steps below switch to branches made from main, so they run a copy + # of this commit's script rather than whichever one the branch has. + - name: Keep this run's script + run: | + cp .github/scripts/image_updates.sh "$RUNNER_TEMP/image_updates.sh" + echo "SCRIPT=$RUNNER_TEMP/image_updates.sh" >>"$GITHUB_ENV" + + - name: Commit as the app + env: + GH_TOKEN: ${{ steps.app.outputs.token }} + APP_SLUG: ${{ steps.app.outputs.app-slug }} + run: | + id="$(gh api "/users/$APP_SLUG%5Bbot%5D" --jq .id)" + git config user.name "${APP_SLUG}[bot]" + git config user.email "$id+${APP_SLUG}[bot]@users.noreply.github.com" + + - name: Keep the labels + env: + GH_TOKEN: ${{ github.token }} + run: | + gh label create image-update --force --color 0e8a16 \ + --description "Minor and patch image releases, merged once the checks pass" + gh label create image-update:major --force --color d93f0b \ + --description "A major image release to check before merging" + + - name: Merge the minor and patch releases + env: + GH_TOKEN: ${{ steps.app.outputs.token }} + CHECKS_TOKEN: ${{ github.token }} + run: | + branch=image-updates/minor + "$SCRIPT" plan >"$RUNNER_TEMP/plan" + cat "$RUNNER_TEMP/plan" + awk '$1 == "minor"' "$RUNNER_TEMP/plan" >"$RUNNER_TEMP/minor" + pr="$(gh pr list --head "$branch" --state open --json number --jq '.[0].number // empty')" + if [ ! -s "$RUNNER_TEMP/minor" ]; then + if [ -n "$pr" ]; then + gh pr close "$pr" --delete-branch --comment "main already has these releases." + fi + exit 0 + fi + + git switch -q -C "$branch" origin/main + while read -r _ old new <&3; do + "$SCRIPT" apply "$old" "$new" + done 3<"$RUNNER_TEMP/minor" + title="$("$SCRIPT" title <"$RUNNER_TEMP/minor")" + git commit -q -am "$title" + git push -q --force origin "$branch" + head="$(git rev-parse HEAD)" + if [ -z "$pr" ]; then + gh pr create --base main --head "$branch" --title "$title" --body "" --label image-update + pr="$(gh pr view "$branch" --json number --jq .number)" + else + gh pr edit "$pr" --title "$title" + fi + + # Let the checks the push started register before waiting on them. + sleep 90 + if ! GH_TOKEN="$CHECKS_TOKEN" gh pr checks "$pr" --watch --fail-fast --interval 30; then + gh pr edit "$pr" --add-assignee "$REVIEWERS" + echo "::error::#$pr's checks failed, so it was left open for $REVIEWERS" + exit 1 + fi + if ! gh pr merge "$pr" --squash --match-head-commit "$head"; then + gh pr edit "$pr" --add-assignee "$REVIEWERS" + echo "::error::#$pr passed its checks but could not be merged, so it was left open for $REVIEWERS" + exit 1 + fi + + - name: Open a PR for each major release + if: ${{ !cancelled() }} + env: + GH_TOKEN: ${{ steps.app.outputs.token }} + run: | + # true when origin's $1 changes main the way HEAD does + same_change() { + [ "$(git diff "origin/main...origin/$1" | git patch-id --stable)" = \ + "$(git diff origin/main HEAD | git patch-id --stable)" ] + } + + # Planned again on main, which may now have the minor releases. + git fetch -q origin main + git switch -q --discard-changes --detach origin/main + "$SCRIPT" plan >"$RUNNER_TEMP/plan" + awk '$1 == "major"' "$RUNNER_TEMP/plan" >"$RUNNER_TEMP/major" + cat "$RUNNER_TEMP/major" + + while read -r kind old new <&3; do + name="${new%%@*}" + tag="${name##*:}" + name="${name%:*}" + old_tag="${old%%@*}" + old_tag="${old_tag##*:}" + slug="$(printf '%s' "$name" | tr '/:' '--')" + branch="image-updates/major/$slug/$tag" + state="$(gh pr list --head "$branch" --state all --json state \ + --jq 'map(.state) | if any(. == "OPEN") then "open" elif length > 0 then "closed" else "" end')" + # Merged, or closed to skip this release. + if [ "$state" = closed ]; then + continue + fi + + git switch -q --discard-changes -C "$branch" origin/main + "$SCRIPT" apply "$old" "$new" + title="$("$SCRIPT" title <<<"$kind $old $new")" + git commit -q -am "$title" + + if [ "$state" = open ]; then + # main changed this pin since the PR was opened, as a minor + # release of the same image does: rebuild it so it still merges. + if ! same_change "$branch"; then + git push -q --force origin "$branch" + fi + continue + fi + + git push -q --force origin "$branch" + label="${title#chore: }" + gh pr create --base main --head "$branch" --title "$title" \ + --label image-update:major --assignee "$REVIEWERS" --reviewer "$REVIEWERS" \ + --body "$(printf '%s\n' \ + "$label is a new major release, up from $old_tag, so it waits for a review instead of merging itself. Before merging, check that the panel still works on it:" \ + "" \ + "- [ ] Read its release notes from $old_tag up to $tag for breaking changes and migrations." \ + "- [ ] Try it on a test panel. This branch is published to the \`branch/$branch\` channel: \`sudo ./service.sh --channel branch/$branch\`." \ + "" \ + "Closing this PR skips $tag. A newer major release opens a new PR.")" + pr="$(gh pr view "$branch" --json number --jq .number)" + + gh pr list --state open --label image-update:major --json number,headRefName \ + --jq ".[] | select(.headRefName | startswith(\"image-updates/major/$slug/\")) | select(.headRefName != \"$branch\") | .number" | + while read -r superseded; do + gh pr close "$superseded" --delete-branch --comment "Superseded by #$pr." + done + done 3<"$RUNNER_TEMP/major" diff --git a/.github/workflows/panel-scripts.yml b/.github/workflows/panel-scripts.yml index fb594cb..0908be9 100644 --- a/.github/workflows/panel-scripts.yml +++ b/.github/workflows/panel-scripts.yml @@ -25,6 +25,7 @@ on: - "base/backups/**" - "base/game-server-node-connector/**" - "overlays/nvidia/**" + - ".github/scripts/**" - .github/workflows/panel-scripts.yml pull_request: paths: @@ -49,6 +50,7 @@ on: - "base/backups/**" - "base/game-server-node-connector/**" - "overlays/nvidia/**" + - ".github/scripts/**" - .github/workflows/panel-scripts.yml jobs: @@ -56,7 +58,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - run: shellcheck -x service.sh utils/ensure_auth_cookie_domain.sh utils/ensure_node_ip.sh utils/dev_game_server.sh utils/gpu_nodes.sh utils/choose_node.sh tests/scripts/*.sh tests/scripts/stub/kubectl + - run: shellcheck -x service.sh utils/ensure_auth_cookie_domain.sh utils/ensure_node_ip.sh utils/dev_game_server.sh utils/gpu_nodes.sh utils/choose_node.sh .github/scripts/image_updates.sh tests/scripts/*.sh tests/scripts/stub/kubectl tests/scripts/stub/crane - run: bash tests/scripts/setup_env_test.sh - run: bash tests/scripts/apply_overlay_test.sh - run: bash tests/scripts/debug_test.sh @@ -72,3 +74,4 @@ jobs: - run: bash tests/scripts/postgres_backup_test.sh - run: bash tests/scripts/choose_node_test.sh - run: bash tests/scripts/nvidia_fallback_test.sh + - run: bash tests/scripts/image_updates_test.sh diff --git a/tests/scripts/image_updates_test.sh b/tests/scripts/image_updates_test.sh new file mode 100644 index 0000000..37cac2d --- /dev/null +++ b/tests/scripts/image_updates_test.sh @@ -0,0 +1,181 @@ +#!/bin/bash + +# shellcheck source=tests/scripts/lib.sh +source "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +STUB_BIN="$(cd "$(dirname "${BASH_SOURCE[0]}")/stub" && pwd)" +SCRIPT="$TEST_ROOT/.github/scripts/image_updates.sh" +OLD_DIGEST="sha256:$(printf "%064d" 1)" +NEW_DIGEST="sha256:$(printf "%064d" 2)" + +# pin FILE LINE...: a tracked file of the test repository. +pin() { + mkdir -p "$(dirname "$WORK_DIR/repo/$1")" + printf '%s\n' "${@:2}" >"$WORK_DIR/repo/$1" +} + +# tags REPOSITORY TAG...: what the registry lists for REPOSITORY. +tags() { + mkdir -p "$WORK_DIR/tags" + printf '%s\n' "${@:2}" >"$WORK_DIR/tags/$(printf '%s' "$1" | tr / _)" +} + +digest() { + echo "$1 $2" >>"$WORK_DIR/digests" +} + +image_updates() { + [ -d "$WORK_DIR/repo/.git" ] || git init -q "$WORK_DIR/repo" + ( + cd "$WORK_DIR/repo" || exit 1 + git add -A + PATH="$STUB_BIN:$PATH" bash "$SCRIPT" "$@" + ) +} + +test_the_newest_minor_and_the_newest_major_are_planned() { + pin base/typesense.yaml " image: typesense/typesense:30.2" + tags typesense/typesense 29.1 30.0 30.2 30.3 30.10 31.0 32.1 + assert_eq "$(image_updates plan)" "minor typesense/typesense:30.2 typesense/typesense:30.10 +major typesense/typesense:30.2 typesense/typesense:32.1" +} + +test_an_update_keeps_the_suffix_and_the_number_of_version_parts() { + pin base/redis.yaml " image: redis:8.10.3-alpine" + tags redis 8.10.3-alpine 8.10.4-alpine3.23 8.11-alpine 8.12.0-alpine 8.12.1 9.0.0-rc1-alpine 8.12.1-alpine-arm64 + assert_eq "$(image_updates plan)" "minor redis:8.10.3-alpine redis:8.12.0-alpine" +} + +test_an_update_keeps_the_v() { + pin base/hasura.yaml " - image: hasura/graphql-engine:v2.51.3-ce.cli-migrations-v3" + tags hasura/graphql-engine v2.51.4-ce.cli-migrations-v3 2.51.5-ce.cli-migrations-v3 v2.51.9 v2.52.0-beta.1-ce.cli-migrations-v3 + assert_eq "$(image_updates plan)" "minor hasura/graphql-engine:v2.51.3-ce.cli-migrations-v3 hasura/graphql-engine:v2.51.4-ce.cli-migrations-v3" +} + +test_prereleases_and_tags_that_are_not_versions_are_skipped() { + pin base/typesense.yaml " image: typesense/typesense:30.2" + tags typesense/typesense latest main 31.0.rc22-arm64 30.3rc1 30.2 + assert_eq "$(image_updates plan)" "" +} + +# Below 1.0 a minor release may break things, as a major does from 1.0 on. +test_below_1_0_a_new_second_part_is_major() { + pin base/rustfs.yaml " image: rustfs/rc:v0.1.36" + tags rustfs/rc v0.1.35 v0.1.37 v0.2.0 v1.0.0 + assert_eq "$(image_updates plan)" "minor rustfs/rc:v0.1.36 rustfs/rc:v0.1.37 +major rustfs/rc:v0.1.36 rustfs/rc:v1.0.0" +} + +test_a_one_part_version_only_moves_to_a_major() { + pin base/backup.yaml " image: postgres:18-alpine" + tags postgres 17-alpine 18-alpine 18.1-alpine 19-alpine 19beta1-alpine 19-alpine3.22 + assert_eq "$(image_updates plan)" "major postgres:18-alpine postgres:19-alpine" +} + +test_a_pinned_digest_is_repinned_to_the_new_tags() { + pin base/redis.yaml " image: redis:8.10.3-alpine@$OLD_DIGEST" + tags redis 8.10.4-alpine + digest redis:8.10.4-alpine "$NEW_DIGEST" + assert_eq "$(image_updates plan)" "minor redis:8.10.3-alpine@$OLD_DIGEST redis:8.10.4-alpine@$NEW_DIGEST" +} + +test_a_digest_that_is_not_a_sha256_is_not_pinned() { + pin base/redis.yaml " image: redis:8.10.3-alpine@$OLD_DIGEST" + tags redis 8.10.4-alpine + digest redis:8.10.4-alpine "sha256:\$(reboot)" + assert_eq "$(image_updates plan 2>"$WORK_DIR/stderr")" "" + assert_contains "$(cat "$WORK_DIR/stderr")" "::warning::could not read the digest of redis:8.10.4-alpine" +} + +test_shell_and_quoted_pins_are_planned() { + pin tests/lib.sh "K3S_IMAGE=rancher/k3s:v1.36.5-k3s1" + pin overlays/x.yaml ' image: "coturn/coturn:4.18.0"' + tags rancher/k3s v1.36.5-rc1-k3s1 v1.36.6-k3s1 v1.36.5-k3s2 + tags coturn/coturn 4.18.1 4.18.0-r0-debian-amd64 + assert_eq "$(image_updates plan)" "minor coturn/coturn:4.18.0 coturn/coturn:4.18.1 +minor rancher/k3s:v1.36.5-k3s1 rancher/k3s:v1.36.6-k3s1" +} + +test_refs_without_a_version_tag_never_reach_the_registry() { + pin base/api.yaml " - image: ghcr.io/5stackgg/api:latest" + # shellcheck disable=SC2016 # a pin of an unexpanded variable + pin tests/t.sh "SEED_IMAGE=timescale/timescaledb@sha256:abc" 'image: $PEBBLE_IMAGE' + assert_eq "$(image_updates plan)" "" + [ ! -e "$WORK_DIR/crane-calls" ] || fail "crane was asked: $(cat "$WORK_DIR/crane-calls")" +} + +test_held_pins_are_not_planned() { + pin overlays/cert-manager-crds/cert-manager.yaml ' image: "quay.io/jetstack/cert-manager-controller:v1.17.4"' + pin base/system-upgrade/system-upgrade-controller.yaml " image: rancher/system-upgrade-controller:v0.20.2" + pin tests/timescaledb/major_upgrade_test.sh "OLD_IMAGE=timescale/timescaledb:2.30.2-pg17" + pin tests/scripts/image_updates_test.sh "K3S_IMAGE=rancher/k3s:v1.36.5-k3s1" + pin .github/workflows/x.yml " image: rancher/k3s:v1.36.5-k3s1" + tags rancher/k3s v1.36.6-k3s1 + tags quay.io/jetstack/cert-manager-controller v1.17.4 v1.21.2 + tags rancher/system-upgrade-controller v0.20.2 v0.20.3 + tags timescale/timescaledb 2.30.2-pg17 2.31.0-pg17 + assert_eq "$(image_updates plan)" "" +} + +test_an_unreadable_repository_skips_only_its_own_pins() { + pin base/a.yaml " image: busybox:1.38" " image: coturn/coturn:4.18.0" + tags busybox 1.38 1.39 + assert_eq "$(image_updates plan 2>"$WORK_DIR/stderr")" "minor busybox:1.38 busybox:1.39" + assert_contains "$(cat "$WORK_DIR/stderr")" "::warning::could not list the tags of coturn/coturn" +} + +test_apply_rewrites_every_pin_and_nothing_else() { + pin base/a.yaml \ + " image: busybox:1.38" \ + ' - image: "busybox:1.38"' \ + " image: busybox:1.38.1" \ + " image: mybusybox:1.38" \ + " image: docker.io/busybox:1.38" + pin tests/a.sh \ + "TEST_IMAGE=busybox:1.38" \ + "OTHER=busybox:1.38" \ + 'echo "pulling image busybox:1.38"' + pin tests/timescaledb/a.sh "OLD_IMAGE=busybox:1.38" + image_updates apply busybox:1.38 busybox:1.39 || fail "apply failed" + assert_eq "$(cat "$WORK_DIR/repo/base/a.yaml")" " image: busybox:1.39 + - image: \"busybox:1.39\" + image: busybox:1.38.1 + image: mybusybox:1.38 + image: docker.io/busybox:1.38" + assert_eq "$(cat "$WORK_DIR/repo/tests/a.sh")" "TEST_IMAGE=busybox:1.39 +OTHER=busybox:1.38 +echo \"pulling image busybox:1.38\"" + assert_eq "$(cat "$WORK_DIR/repo/tests/timescaledb/a.sh")" "OLD_IMAGE=busybox:1.38" +} + +test_apply_moves_a_digest_pin_whole() { + pin base/a.yaml " image: redis:8.10.3-alpine@$OLD_DIGEST" + image_updates apply "redis:8.10.3-alpine@$OLD_DIGEST" "redis:8.10.4-alpine@$NEW_DIGEST" || fail "apply failed" + assert_eq "$(cat "$WORK_DIR/repo/base/a.yaml")" " image: redis:8.10.4-alpine@$NEW_DIGEST" +} + +test_apply_fails_when_the_ref_is_not_pinned() { + pin tests/a.sh 'echo "pulling image busybox:1.38"' + if image_updates apply busybox:1.38 busybox:1.39 2>"$WORK_DIR/stderr"; then + fail "apply succeeded" + fi + assert_contains "$(cat "$WORK_DIR/stderr")" "busybox:1.38 is mentioned, but not pinned, in tests/a.sh" + if image_updates apply busybox:1.37 busybox:1.39 2>"$WORK_DIR/stderr"; then + fail "apply succeeded" + fi + assert_contains "$(cat "$WORK_DIR/stderr")" "busybox:1.37 is not pinned anywhere" +} + +test_the_title_names_each_image_and_its_new_tag() { + pin README.md "readme" + assert_eq "$(image_updates title <<<"minor redis:8.10.3-alpine redis:8.12.0-alpine@sha256:abc")" \ + "chore: redis 8.12.0-alpine" + assert_eq "$(printf '%s\n' \ + "minor redis:8.10.3-alpine redis:8.12.0-alpine@sha256:abc" \ + "minor ghcr.io/letsencrypt/pebble:2.10.1 ghcr.io/letsencrypt/pebble:2.10.2" \ + "major docker.io/library/postgres:18-alpine docker.io/library/postgres:19-alpine" | + image_updates title)" \ + "chore: redis 8.12.0-alpine, letsencrypt/pebble 2.10.2 and postgres 19-alpine" +} + +run_tests diff --git a/tests/scripts/stub/crane b/tests/scripts/stub/crane new file mode 100755 index 0000000..3ba173f --- /dev/null +++ b/tests/scripts/stub/crane @@ -0,0 +1,13 @@ +#!/bin/bash + +# crane for tests/scripts/image_updates_test.sh: the tags of a repository come +# from $WORK_DIR/tags/, and digests from +# " " lines in $WORK_DIR/digests. + +echo "$*" >>"$WORK_DIR/crane-calls" + +case "$1" in + ls) cat "$WORK_DIR/tags/$(printf '%s' "$2" | tr / _)" ;; + digest) awk -v ref="$2" '$1 == ref { print $2; found = 1 } END { exit !found }' "$WORK_DIR/digests" 2>/dev/null ;; + *) exit 2 ;; +esac From ed2cdeff2349683ce12c94605c7b644ade19a8df Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Tue, 3 Nov 2026 16:43:11 -0500 Subject: [PATCH 2/4] test: run the image updates workflow from its own PR, short of merging --- .github/workflows/image-updates.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/image-updates.yml b/.github/workflows/image-updates.yml index aa367e1..0a60f70 100644 --- a/.github/workflows/image-updates.yml +++ b/.github/workflows/image-updates.yml @@ -10,6 +10,12 @@ on: schedule: - cron: "0 13 * * 1" workflow_dispatch: + # TEMPORARY: tests this workflow from its own PR, short of merging. Drop + # before merging. + pull_request: + paths: + - .github/workflows/image-updates.yml + - .github/scripts/image_updates.sh permissions: {} @@ -127,6 +133,11 @@ jobs: echo "::error::#$pr's checks failed, so it was left open for $REVIEWERS" exit 1 fi + # TEMPORARY: the test run from this workflow's own PR stops here. + if [ "$GITHUB_EVENT_NAME" = pull_request ]; then + echo "::notice::#$pr passed its checks; a test run leaves it unmerged" + exit 0 + fi if ! gh pr merge "$pr" --squash --match-head-commit "$head"; then gh pr edit "$pr" --add-assignee "$REVIEWERS" echo "::error::#$pr passed its checks but could not be merged, so it was left open for $REVIEWERS" From c78157572f97ad3736360c64ccf3ee81a5cbe0c0 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Tue, 3 Nov 2026 16:44:00 -0500 Subject: [PATCH 3/4] fix: the major releases step only runs once the setup steps have --- .github/workflows/image-updates.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/image-updates.yml b/.github/workflows/image-updates.yml index 0a60f70..0f95381 100644 --- a/.github/workflows/image-updates.yml +++ b/.github/workflows/image-updates.yml @@ -95,6 +95,7 @@ jobs: --description "A major image release to check before merging" - name: Merge the minor and patch releases + id: minor env: GH_TOKEN: ${{ steps.app.outputs.token }} CHECKS_TOKEN: ${{ github.token }} @@ -145,7 +146,8 @@ jobs: fi - name: Open a PR for each major release - if: ${{ !cancelled() }} + # Also after the minor releases' PR fails, but not when the setup did. + if: ${{ !cancelled() && steps.minor.outcome != 'skipped' }} env: GH_TOKEN: ${{ steps.app.outputs.token }} run: | From 280643bcb3e8aa057cee703b772be47a67cedbf5 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Tue, 3 Nov 2026 17:36:22 -0500 Subject: [PATCH 4/4] chore: drop the image updates workflow's test run from its own PR --- .github/workflows/image-updates.yml | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/.github/workflows/image-updates.yml b/.github/workflows/image-updates.yml index 0f95381..d164791 100644 --- a/.github/workflows/image-updates.yml +++ b/.github/workflows/image-updates.yml @@ -10,12 +10,6 @@ on: schedule: - cron: "0 13 * * 1" workflow_dispatch: - # TEMPORARY: tests this workflow from its own PR, short of merging. Drop - # before merging. - pull_request: - paths: - - .github/workflows/image-updates.yml - - .github/scripts/image_updates.sh permissions: {} @@ -134,11 +128,6 @@ jobs: echo "::error::#$pr's checks failed, so it was left open for $REVIEWERS" exit 1 fi - # TEMPORARY: the test run from this workflow's own PR stops here. - if [ "$GITHUB_EVENT_NAME" = pull_request ]; then - echo "::notice::#$pr passed its checks; a test run leaves it unmerged" - exit 0 - fi if ! gh pr merge "$pr" --squash --match-head-commit "$head"; then gh pr edit "$pr" --add-assignee "$REVIEWERS" echo "::error::#$pr passed its checks but could not be merged, so it was left open for $REVIEWERS"