Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions .github/workflows/runtime-dev-cleanup.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
name: runtime-dev-cleanup

# Prune immutable runtime-dev-YYYYMMDD-<sha> 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 }}"
182 changes: 182 additions & 0 deletions runtime-overlay/scripts/release-workflow/prune-dev-runtime-releases.sh
Original file line number Diff line number Diff line change
@@ -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-<sha> 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)."
74 changes: 74 additions & 0 deletions test/runtime-overlay/test_prune_dev_runtime_releases.bats
Original file line number Diff line number Diff line change
@@ -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 ]
}