diff --git a/.github/workflows/runtime-dev-cleanup.yml b/.github/workflows/runtime-dev-cleanup.yml new file mode 100644 index 0000000000..b045b25003 --- /dev/null +++ b/.github/workflows/runtime-dev-cleanup.yml @@ -0,0 +1,76 @@ +name: runtime-dev-cleanup + +# Prune immutable runtime-dev-YYYYMMDD- prereleases. The floating +# runtime-dev-latest release is ignored, and the tag currently pinned by +# config-dev is passed as protected so fresh dev image builds do not lose +# their baked runtime overlay. +on: + schedule: + - cron: '17 4 * * *' + push: + branches: [dev] + paths: + - 'runtime-overlay/**' + - 'config-dev' + - '.github/workflows/runtime-dev-cleanup.yml' + workflow_dispatch: + inputs: + keep_count: + description: 'Newest immutable runtime-dev releases to retain' + type: string + default: '5' + keep_days: + description: 'Also retain immutable runtime-dev releases this many days old or newer' + type: string + default: '14' + dry_run: + description: 'Log what would be deleted without deleting' + type: choice + options: ['true', 'false'] + default: 'true' + +permissions: + contents: write + +# Runtime dev publish on dev uses runtime-release-refs/heads/dev. Reusing that +# group keeps cleanup from deleting while a dev runtime release is publishing. +concurrency: + group: runtime-release-refs/heads/dev + cancel-in-progress: false + +jobs: + prune: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + # The runtime dev stream is anchored on dev. Scheduled workflows + # are loaded from the default branch, so check out dev explicitly + # before reading config-dev or running the helper script. + ref: dev + persist-credentials: false + + - name: Resolve protected config-dev runtime tag + id: config-dev + run: | + set -euo pipefail + set -a + # shellcheck disable=SC1091 + . ./config-dev + set +a + if [[ -z "${AIRPLANES_RUNTIME_OVERLAY_TAG:-}" ]]; then + echo "::error::config-dev did not set AIRPLANES_RUNTIME_OVERLAY_TAG" + exit 1 + fi + echo "tag=$AIRPLANES_RUNTIME_OVERLAY_TAG" >> "$GITHUB_OUTPUT" + + - name: Prune stale immutable dev runtime releases + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RUNTIME_DEV_RELEASE_KEEP_COUNT: ${{ github.event.inputs.keep_count || '5' }} + RUNTIME_DEV_RELEASE_KEEP_DAYS: ${{ github.event.inputs.keep_days || '14' }} + RUNTIME_DEV_RELEASE_DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }} + run: | + bash runtime-overlay/scripts/release-workflow/prune-dev-runtime-releases.sh \ + --protected-tag "${{ steps.config-dev.outputs.tag }}" diff --git a/runtime-overlay/scripts/release-workflow/prune-dev-runtime-releases.sh b/runtime-overlay/scripts/release-workflow/prune-dev-runtime-releases.sh new file mode 100755 index 0000000000..0948748a2e --- /dev/null +++ b/runtime-overlay/scripts/release-workflow/prune-dev-runtime-releases.sh @@ -0,0 +1,182 @@ +#!/usr/bin/env bash +# prune-dev-runtime-releases.sh - delete stale immutable runtime-dev releases. +# +# Retention is additive: +# - keep the newest RUNTIME_DEV_RELEASE_KEEP_COUNT immutable dev releases; +# - keep releases created within RUNTIME_DEV_RELEASE_KEEP_DAYS days; +# - keep any --protected-tag values, even if old. +# +# Only tags matching runtime-dev-YYYYMMDD- are eligible. The floating +# runtime-dev-latest release and stable runtime-v* releases are ignored. + +set -euo pipefail + +die() { + echo "prune-dev-runtime-releases: $*" >&2 + exit 1 +} + +usage() { + cat >&2 <<'USAGE' +Usage: prune-dev-runtime-releases.sh [--repo owner/name] [--protected-tag tag] + +Environment: + GITHUB_REPOSITORY default repo when --repo is omitted + RUNTIME_DEV_RELEASE_KEEP_COUNT newest immutable dev releases to retain (default: 5) + RUNTIME_DEV_RELEASE_KEEP_DAYS age window to retain, in days (default: 14) + RUNTIME_DEV_RELEASE_DRY_RUN true|false (default: true) + RUNTIME_DEV_RELEASE_LIST_LIMIT gh release list limit (default: 1000) + +Test-only environment: + RUNTIME_DEV_RELEASES_JSON JSON array with tagName and createdAt + RUNTIME_DEV_RELEASE_NOW_EPOCH fixed "now" epoch for age calculation +USAGE +} + +is_uint() { + [[ "$1" =~ ^[0-9]+$ ]] +} + +repo="${GITHUB_REPOSITORY:-}" +protected_tags=() + +while [[ $# -gt 0 ]]; do + case "$1" in + --repo) + [[ $# -ge 2 ]] || die "--repo requires a value" + repo="$2" + shift 2 + ;; + --protected-tag) + [[ $# -ge 2 ]] || die "--protected-tag requires a value" + protected_tags+=("$2") + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + usage + die "unknown argument: $1" + ;; + esac +done + +[[ -n "$repo" ]] || die "missing --repo or GITHUB_REPOSITORY" + +keep_count="${RUNTIME_DEV_RELEASE_KEEP_COUNT:-5}" +keep_days="${RUNTIME_DEV_RELEASE_KEEP_DAYS:-14}" +dry_run="${RUNTIME_DEV_RELEASE_DRY_RUN:-true}" +list_limit="${RUNTIME_DEV_RELEASE_LIST_LIMIT:-1000}" +now_epoch="${RUNTIME_DEV_RELEASE_NOW_EPOCH:-$(date -u +%s)}" + +is_uint "$keep_count" || die "RUNTIME_DEV_RELEASE_KEEP_COUNT must be an unsigned integer" +is_uint "$keep_days" || die "RUNTIME_DEV_RELEASE_KEEP_DAYS must be an unsigned integer" +is_uint "$list_limit" || die "RUNTIME_DEV_RELEASE_LIST_LIMIT must be an unsigned integer" +is_uint "$now_epoch" || die "RUNTIME_DEV_RELEASE_NOW_EPOCH must be an unsigned integer epoch" + +case "$dry_run" in + true|false) ;; + *) die "RUNTIME_DEV_RELEASE_DRY_RUN must be true or false" ;; +esac + +fetch_releases_json() { + if [[ -n "${RUNTIME_DEV_RELEASES_JSON:-}" ]]; then + printf '%s\n' "$RUNTIME_DEV_RELEASES_JSON" + return + fi + + command -v gh >/dev/null 2>&1 || die "gh is required" + gh release list \ + -R "$repo" \ + --limit "$list_limit" \ + --json tagName,createdAt +} + +command -v jq >/dev/null 2>&1 || die "jq is required" + +declare -A protected=() +for tag in "${protected_tags[@]}"; do + [[ -n "$tag" ]] || continue + protected["$tag"]=1 +done + +release_json="$(fetch_releases_json)" +candidate_tsv="$( + jq -r ' + map(select(.tagName | test("^runtime-dev-[0-9]{8}-[0-9a-f]{7,40}$"))) + | sort_by(.createdAt) + | reverse + | .[] + | [.createdAt, .tagName] + | @tsv + ' <<<"$release_json" +)" + +if [[ -z "$candidate_tsv" ]]; then + echo "No immutable runtime-dev releases found in $repo." + exit 0 +fi + +cutoff_epoch=0 +if (( keep_days > 0 )); then + cutoff_epoch=$((now_epoch - keep_days * 86400)) +fi + +index=0 +to_delete=() + +echo "Runtime dev release cleanup policy:" +echo " repo: $repo" +echo " keep_count: $keep_count" +echo " keep_days: $keep_days" +echo " dry_run: $dry_run" +if ((${#protected_tags[@]} > 0)); then + printf ' protected_tags: %s\n' "${protected_tags[*]}" +else + echo " protected_tags: none" +fi + +while IFS=$'\t' read -r created_at tag; do + [[ -n "$tag" ]] || continue + + reason="" + if [[ -n "${protected[$tag]:-}" ]]; then + reason="protected" + elif (( index < keep_count )); then + reason="newest" + else + if ! created_epoch="$(date -u -d "$created_at" +%s 2>/dev/null)"; then + die "could not parse createdAt for $tag: $created_at" + fi + if (( keep_days > 0 && created_epoch >= cutoff_epoch )); then + reason="age" + fi + fi + + if [[ -n "$reason" ]]; then + echo "keep $tag ($created_at; $reason)" + else + echo "delete $tag ($created_at)" + to_delete+=("$tag") + fi + + index=$((index + 1)) +done <<<"$candidate_tsv" + +if ((${#to_delete[@]} == 0)); then + echo "No stale immutable runtime-dev releases to delete." + exit 0 +fi + +if [[ "$dry_run" == "true" ]]; then + echo "Dry run only; would delete ${#to_delete[@]} release(s)." + exit 0 +fi + +for tag in "${to_delete[@]}"; do + gh release delete "$tag" -R "$repo" --yes --cleanup-tag +done + +echo "Deleted ${#to_delete[@]} stale immutable runtime-dev release(s)." diff --git a/test/runtime-overlay/test_prune_dev_runtime_releases.bats b/test/runtime-overlay/test_prune_dev_runtime_releases.bats new file mode 100644 index 0000000000..d76f9a312e --- /dev/null +++ b/test/runtime-overlay/test_prune_dev_runtime_releases.bats @@ -0,0 +1,74 @@ +#!/usr/bin/env bats + +# Tests for runtime-overlay/scripts/release-workflow/prune-dev-runtime-releases.sh. + +bats_require_minimum_version 1.5.0 + +setup() { + REPO_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)" + SCRIPT="$REPO_ROOT/runtime-overlay/scripts/release-workflow/prune-dev-runtime-releases.sh" + [ -x "$SCRIPT" ] || skip "prune-dev-runtime-releases.sh not executable" + command -v jq >/dev/null 2>&1 || skip "jq not installed" +} + +fixture_json() { + cat <<'JSON' +[ + {"tagName":"runtime-dev-latest","createdAt":"2026-04-01T00:00:00Z"}, + {"tagName":"runtime-v1.0.0","createdAt":"2026-04-01T00:00:00Z"}, + {"tagName":"runtime-dev-20260522-aaaaaaa","createdAt":"2026-05-22T00:00:00Z"}, + {"tagName":"runtime-dev-20260521-bbbbbbb","createdAt":"2026-05-21T00:00:00Z"}, + {"tagName":"runtime-dev-20260520-ccccccc","createdAt":"2026-05-20T00:00:00Z"}, + {"tagName":"runtime-dev-20260501-ddddddd","createdAt":"2026-05-01T00:00:00Z"}, + {"tagName":"runtime-dev-20260430-eeeeeee","createdAt":"2026-04-30T00:00:00Z"} +] +JSON +} + +@test "dry run keeps newest, recent, and protected immutable dev releases" { + now_epoch="$(date -u -d '2026-05-22T00:00:00Z' +%s)" + + RUNTIME_DEV_RELEASES_JSON="$(fixture_json)" \ + RUNTIME_DEV_RELEASE_NOW_EPOCH="$now_epoch" \ + RUNTIME_DEV_RELEASE_KEEP_COUNT=2 \ + RUNTIME_DEV_RELEASE_KEEP_DAYS=7 \ + RUNTIME_DEV_RELEASE_DRY_RUN=true \ + run "$SCRIPT" --repo airplanes-live/image \ + --protected-tag runtime-dev-20260501-ddddddd + + [ "$status" -eq 0 ] + [[ "$output" == *"keep runtime-dev-20260522-aaaaaaa"* ]] + [[ "$output" == *"keep runtime-dev-20260521-bbbbbbb"* ]] + [[ "$output" == *"keep runtime-dev-20260520-ccccccc"* ]] + [[ "$output" == *"keep runtime-dev-20260501-ddddddd"* ]] + [[ "$output" == *"delete runtime-dev-20260430-eeeeeee"* ]] + [[ "$output" == *"Dry run only; would delete 1 release(s)."* ]] + [[ "$output" != *"runtime-dev-latest ("* ]] + [[ "$output" != *"runtime-v1.0.0 ("* ]] +} + +@test "non-dry run deletes selected releases with cleanup-tag" { + mkdir -p "$BATS_TEST_TMPDIR/bin" + cat > "$BATS_TEST_TMPDIR/bin/gh" <<'STUB' +#!/usr/bin/env bash +printf '%s\n' "$*" >> "$GH_LOG" +STUB + chmod 0755 "$BATS_TEST_TMPDIR/bin/gh" + + export PATH="$BATS_TEST_TMPDIR/bin:$PATH" + export GH_LOG="$BATS_TEST_TMPDIR/gh.log" + now_epoch="$(date -u -d '2026-05-22T00:00:00Z' +%s)" + + RUNTIME_DEV_RELEASES_JSON='[ + {"tagName":"runtime-dev-20260430-eeeeeee","createdAt":"2026-04-30T00:00:00Z"} + ]' \ + RUNTIME_DEV_RELEASE_NOW_EPOCH="$now_epoch" \ + RUNTIME_DEV_RELEASE_KEEP_COUNT=0 \ + RUNTIME_DEV_RELEASE_KEEP_DAYS=0 \ + RUNTIME_DEV_RELEASE_DRY_RUN=false \ + run "$SCRIPT" --repo airplanes-live/image + + [ "$status" -eq 0 ] + run grep -F "release delete runtime-dev-20260430-eeeeeee -R airplanes-live/image --yes --cleanup-tag" "$GH_LOG" + [ "$status" -eq 0 ] +}