diff --git a/.gitea/workflows/container-cd.yml b/.gitea/workflows/container-cd.yml index d6d8df5..7cb39a8 100644 --- a/.gitea/workflows/container-cd.yml +++ b/.gitea/workflows/container-cd.yml @@ -45,6 +45,11 @@ on: required: false default: "" type: string + release_retention_count: + description: Number of immutable sha-* release images to retain after a successful promotion + required: false + default: 2 + type: number secrets: REGISTRY_USERNAME: required: true @@ -149,3 +154,90 @@ jobs: set -eu ssh -i ~/.ssh/id_ed25519 cicd-deploy@"${{ inputs.deploy_host }}" \ "promote ${{ inputs.deploy_service }} ${{ inputs.image_name }}:sha-${{ gitea.sha }}" + - name: Retain current and previous release images + if: ${{ startsWith(gitea.ref, 'refs/tags/v') && inputs.deploy_service != '' && inputs.deploy_host != '' }} + env: + REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }} + REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} + run: | + set -euo pipefail + + image_name='${{ inputs.image_name }}' + keep_count='${{ inputs.release_retention_count }}' + current_version='sha-${{ gitea.sha }}' + + case "$keep_count" in + ''|*[!0-9]*|0) + echo "release_retention_count must be a positive integer" + exit 1 + ;; + esac + + registry="${image_name%%/*}" + image_path="${image_name#*/}" + owner="${image_path%%/*}" + package_name="${image_path#*/}" + + if [ "$registry" = "$image_name" ] || [ "$owner" = "$image_path" ]; then + echo "image_name must include registry, owner, and package name" + exit 1 + fi + case "$owner/$package_name" in + *[!A-Za-z0-9._/-]*|*/*/*) + echo "image owner or package name contains unsupported characters" + exit 1 + ;; + esac + + api_base="https://${registry}/api/v1/packages" + versions_file="$(mktemp)" + keep_file="$(mktemp)" + trap 'rm -f "$versions_file" "$keep_file"' EXIT + + page=1 + while [ "$page" -le 100 ]; do + response="$(curl --fail --silent --show-error --retry 3 --retry-all-errors \ + --user "$REGISTRY_USERNAME:$REGISTRY_PASSWORD" \ + "${api_base}/${owner}?type=container&q=${package_name}&limit=50&page=${page}")" + item_count="$(printf '%s' "$response" | jq 'length')" + [ "$item_count" -gt 0 ] || break + printf '%s' "$response" | jq -r --arg package_name "$package_name" ' + .[] + | select((.name | ascii_downcase) == ($package_name | ascii_downcase)) + | select(.version | test("^sha-[0-9a-f]{40}$")) + | [.created_at, .version] + | @tsv + ' >> "$versions_file" + page=$((page + 1)) + done + + if ! awk -F '\t' -v current="$current_version" '$2 == current { found = 1 } END { exit(found ? 0 : 1) }' "$versions_file"; then + echo "Current release version was not returned by the Gitea Packages API: $current_version" + exit 1 + fi + + printf '%s\n' "$current_version" > "$keep_file" + previous_count=$((keep_count - 1)) + if [ "$previous_count" -gt 0 ]; then + sort -r "$versions_file" | awk -F '\t' \ + -v current="$current_version" \ + -v limit="$previous_count" \ + '$2 != current && kept < limit { print $2; kept++ }' >> "$keep_file" + fi + + deleted=0 + while IFS=$'\t' read -r _created_at version; do + [ -n "$version" ] || continue + if grep -Fqx "$version" "$keep_file"; then + echo "Keeping release image: $package_name:$version" + continue + fi + curl --fail --silent --show-error --retry 3 --retry-all-errors \ + --user "$REGISTRY_USERNAME:$REGISTRY_PASSWORD" \ + --request DELETE \ + "${api_base}/${owner}/container/${package_name}/${version}" + echo "Deleted superseded release image: $package_name:$version" + deleted=$((deleted + 1)) + done < <(sort -r "$versions_file") + + echo "Release retention complete: package=$package_name retained=$keep_count deleted=$deleted" diff --git a/README.md b/README.md index 413117a..884231a 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,14 @@ # ci-templates -Reusable offline-safe CI/CD workflows \ No newline at end of file +Reusable offline-safe CI/CD workflows. + +## Container release retention + +After a tagged release passes candidate verification and is promoted to Dev, the +container workflow keeps the current immutable `sha-` image and the most +recent previous release by default. Set `release_retention_count` on the caller +to retain a different positive number. + +Retention only matches versions in `sha-` followed by a 40-character commit +hash. Registry versions named `sha256:*`, `ci-cache`, or anything else are never +selected for deletion by this step.