name: Artifact Retention # Prune old nightly artifacts from GHCR: keep the newest KEEP dated versions of # each cozystack package the current nightly publishes, delete older ones. # Nightlies are GHCR OCI tags (0.0.0-nightly... on every # component image plus cozystack-packages, cozy-installer and cozystack-nocloud) # — not git tags or releases — so this prunes container-package versions via # the GitHub Packages API. # # Strictly scoped: only versions whose tags are ALL of the form # 0.0.0-nightly... are eligible. A version that also # carries the floating `nightly` tag (the newest) or any release tag (vX.Y.Z, # latest, rc, main) is never touched, so stable/rc/main artifacts can never be # pruned. # # Promotion also creates one temporary cozystack-packages tag of the form # promotion-vX.Y.Z-from-vX.Y.Z-rc.N-run-ID-ATTEMPT. Every dispatch gets a unique # tag, so a retry never moves one and leaves an untagged manifest behind. A # successful promotion adds a stable tag to the same manifest, which makes it # ineligible below. A candidate stays protected while an open bot-authored # release PR, or the tip of main or a release-X.Y branch, pins its digest; # otherwise it becomes eligible after PROMOTION_TTL_DAYS only when ALL its tags # are still temporary. # # Dry-run by default; flip the input or the RETENTION_APPLY repo variable to # actually delete. Deletion is irreversible. # # NOTE (admin): the cozystack-ci app installation token cannot delete here: it # lists package versions, yet DELETE of a listed version answered "404 Package # not found". This repository's GITHUB_TOKEN, with the job's packages: write # and no change to package settings, deleted the same version from a package # linked to this repository, so deletes use it. A package linked to another # repository is skipped with a warning. on: schedule: - cron: '30 4 * * *' # daily 04:30 UTC, after the nightly has published workflow_dispatch: inputs: apply: description: "Actually delete (false = dry-run, the default)" required: false default: false type: boolean permissions: contents: read env: ORG: cozystack KEEP: '14' PROMOTION_TTL_DAYS: '30' concurrency: group: artifact-retention cancel-in-progress: false jobs: prune: name: Prune old GHCR temporary artifacts runs-on: ubuntu-latest permissions: contents: read packages: write pull-requests: read steps: - name: Checkout uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: persist-credentials: false - name: Set up flux uses: fluxcd/flux2/action@04acaec6161ac4fb1a82ffafa88901c03271d34f # v2.8.6 with: version: "2.8.6" - name: Generate GitHub App token id: app-token uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1 with: app-id: ${{ secrets.COZYSTACK_CI_APP_ID }} private-key: ${{ secrets.COZYSTACK_CI_PRIVATE_KEY }} owner: cozystack - name: Prune env: GH_TOKEN: ${{ steps.app-token.outputs.token }} # workflow_dispatch input wins; otherwise the repo variable; else dry-run. APPLY: ${{ github.event.inputs.apply || vars.RETENTION_APPLY || 'false' }} # See the admin NOTE above for why deletes do not use the app token. DELETE_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail if [ "$APPLY" = "true" ]; then echo "MODE: APPLY (deleting)"; else echo "MODE: dry-run"; fi # Protect every immutable packages digest a live promotion still # depends on. Age alone does not imply abandonment: an approved # release PR can legitimately stay open longer than the TTL. # # A candidate becomes permanently safe only when finalize's retag adds # the stable tag to its manifest, because that is what makes the # all(promotion-*) predicate at the bottom stop matching it. Finalize # runs post-merge, so an open-PR-only protection has a hole exactly # where a half-finished release sits: finalize creates the write-once # stable git tag, then dies before the retag, and the digest is now # pinned by a merge commit and a tag nobody can move, referenced by no # open PR, on a manifest carrying only its temporary tag. Deleting it # leaves a release that can be finished only by hand. # # Two sources, therefore, read differently on purpose. A failure in # either blocks the promotion sweep at the bottom and nothing else — # the nightly sweep in between shares no state with it and must not # be held hostage to a bad pin on a release branch. promotion_retention_blocked=0 protected_promotion_digests="" protect_digest() { protected_promotion_digests="${protected_promotion_digests:+${protected_promotion_digests} }$1" } # Source 1 — open, trusted, same-repository promote PRs opened by the # release bot. Their head is SUPPOSED to carry exactly one candidate # pin, so anything else is an anomaly: block rather than skip. # # The listing that feeds the loop defers the same way instead of # aborting the step. Under `set -euo pipefail` an unhandled failure # here exits before the nightly sweep below has run, which is the # coupling the deferred guard exists to remove — and the handler must # empty the list rather than leave a partially paginated one, because # a shorter list is a smaller protected set. Blocked plus empty is the # fail-closed reading: nothing can be called abandoned while the # protected set is unknown. release_pr_shas="$(gh api --paginate "/repos/${GITHUB_REPOSITORY}/pulls?state=open&per_page=100" \ | jq -s -r --arg repo "$GITHUB_REPOSITORY" 'add[] | select(.user.login == "cozystack-ci[bot]") | select(.head.repo.full_name == $repo) | select(.head.ref | test("^release-[0-9]+\\.[0-9]+\\.[0-9]+$")) | select(any(.labels[]?; .name == "release")) | .head.sha')" || { echo "::error::Could not list open pull requests; refusing retention of promotion candidates." promotion_retention_blocked=1 release_pr_shas="" } for sha in $release_pr_shas; do values="$(gh api \ -H 'Accept: application/vnd.github.raw+json' \ "/repos/${GITHUB_REPOSITORY}/contents/packages/core/installer/values.yaml?ref=${sha}")" || { echo "::error::Open release PR at ${sha} has unreadable installer values; refusing retention of promotion candidates." promotion_retention_blocked=1 continue } candidate_ref="$(printf '%s\n' "$values" | awk ' /^[[:space:]]*platformSourceRef:[[:space:]]*/ { sub(/^[[:space:]]*platformSourceRef:[[:space:]]*/, "") gsub(/^[\047"]|[\047"][[:space:]]*$/, "") print }')" ref_lines="$(printf '%s\n' "$candidate_ref" | wc -l | tr -d ' ')" if ! printf '%s\n' "$candidate_ref" | grep -Eq '^digest=sha256:[0-9a-f]{64}$' \ || [ "$ref_lines" -ne 1 ]; then echo "::error::Open release PR at ${sha} has no single immutable platformSourceRef; refusing retention of promotion candidates." promotion_retention_blocked=1 continue fi protect_digest "${candidate_ref#digest=}" done # Source 2 — the tips of the branches a promote PR can merge into. # This is what covers the window between merge and retag. Read # permissively, the opposite of source 1: protect every digest pin # found and treat a branch without one as contributing nothing. A # maintenance line cut before this pipeline existed cannot pin a # promotion candidate, because none existed when it was cut, so its # missing or tag-form pin is not an anomaly and must not wedge the # sweep forever. Reading permissively can only ever protect more. # # Permissive is about what a branch CONTAINS, not about whether the # branches were listed at all: a failed listing enumerates no branches # and so silently protects nothing, which is the one way this source # can narrow the protected set. It therefore blocks and empties, like # source 1, and for the same reason does it here rather than by # letting `set -e` take the nightly sweep with it. base_branches="$(gh api --paginate "/repos/${GITHUB_REPOSITORY}/branches?per_page=100" \ | jq -s -r 'add[] | .name | select(. == "main" or test("^release-[0-9]+\\.[0-9]+$"))')" || { echo "::error::Could not list base branches; refusing retention of promotion candidates." promotion_retention_blocked=1 base_branches="" } for branch in $base_branches; do values="$(gh api \ -H 'Accept: application/vnd.github.raw+json' \ "/repos/${GITHUB_REPOSITORY}/contents/packages/core/installer/values.yaml?ref=${branch}" 2>/dev/null)" || { echo " ${branch}: no readable installer values; nothing to protect" continue } branch_digests="$(printf '%s\n' "$values" \ | grep -oE 'digest=sha256:[0-9a-f]{64}' | sed 's/^digest=//' | sort -u || true)" for digest in $branch_digests; do protect_digest "$digest" done done protected_count="$(printf '%s\n' "$protected_promotion_digests" | awk '{print NF}')" echo "Protecting ${protected_count} promotion candidate digest(s) named by an open release PR or a base-branch tip." # The cozystack/* packages to sweep. GitHub refuses to list org # container packages to an app installation token (400 Invalid # argument), so the names come from the published nightly tree; see # hack/retention-packages.sh for why that tree and what it covers. # # An empty list is never "nothing to do": the nightly publishes into # these packages every day, so empty means the names could not be # resolved. That skips the nightly sweep and fails the run, but not # before the promotion sweep below: it reads one fixed package, and # what keeps a candidate safe is the protected set, which no part of # this list feeds. # # A package whose versions cannot be read, or a version that cannot # be deleted, is likewise reported and skipped so the rest is still # swept, then fails the run at the end. The loop is fed by a # here-string rather than a pipe so the flag survives it. sweep_failed=0 unlinked="" linked_count=0 gh_err="$(mktemp)" tree="$(mktemp -d)" pkgs="$(flux pull artifact "oci://ghcr.io/${ORG}/cozystack/cozystack-packages:nightly" --output "$tree" >/dev/null \ && ./hack/retention-packages.sh "$tree")" || { echo "::error::Could not resolve the ${ORG} container packages to sweep." pkgs="" } if [ -z "$pkgs" ]; then echo "::error::No package names resolved; skipping the nightly sweep." sweep_failed=1 else echo "Sweeping $(printf '%s\n' "$pkgs" | wc -l | tr -d ' ') package(s):" printf '%s\n' "$pkgs" | sed 's/^/ /' fi while IFS= read -r pkg; do [ -n "$pkg" ] || continue enc="$(jq -rn --arg p "$pkg" '$p|@uri')" # DELETE_TOKEN was shown to delete from a package linked to this # repository; one linked elsewhere is not attempted, only named in # a warning, so it cannot fail every apply run. linked="$(gh api "/orgs/${ORG}/packages/container/${enc}" --jq '.repository.full_name // ""' 2>"$gh_err")" || { echo "::error::Could not read ${pkg}; not swept: $(cat "$gh_err")" sweep_failed=1 continue } if [ "$linked" != "$GITHUB_REPOSITORY" ]; then unlinked="${unlinked:+${unlinked} }${pkg}" continue fi linked_count=$((linked_count + 1)) # Version ids whose tags are non-empty AND every tag is a dated # nightly (so the floating `nightly` tag and any release tag exclude # the version). API returns versions newest-first, so the dated # nightlies are already in newest->oldest order. # `gh api --paginate --jq` runs the filter per page and emits one # array per page, so length/slicing break once a package spans # multiple pages. Slurp the raw pages and merge with `jq -s add` # first, then count and slice the combined array. ids="$(gh api --paginate "/orgs/${ORG}/packages/container/${enc}/versions" 2>"$gh_err" \ | jq -s '[add[] | select((.metadata.container.tags | length) > 0) | select(.metadata.container.tags | all(test("^0\\.0\\.0-nightly\\.[0-9]{8}(\\.[0-9]+)*$"))) | .id]')" || { echo "::error::Could not read ${pkg} versions; not swept: $(cat "$gh_err")" sweep_failed=1 continue } total="$(printf '%s' "$ids" | jq 'length')" [ "$total" -gt "$KEEP" ] || { echo "── ${pkg}: ${total} nightlies (<= ${KEEP}); nothing to prune"; continue; } drop_ids="$(printf '%s' "$ids" | jq -r ".[${KEEP}:][]")" echo "── ${pkg}: ${total} nightlies, keep ${KEEP}, drop $((total - KEEP))" printf '%s\n' "$drop_ids" | xargs ./hack/retention-delete.sh "$pkg" version || sweep_failed=1 done <<< "$pkgs" [ -z "$unlinked" ] || echo "::warning::not linked to ${GITHUB_REPOSITORY}, skipped: ${unlinked}" # One unlinked package is expected; all of them means nothing was swept. if [ -n "$pkgs" ] && [ "$linked_count" -eq 0 ]; then echo "::error::None of the resolved packages is linked to ${GITHUB_REPOSITORY}; the nightly sweep pruned nothing." sweep_failed=1 fi # Anything unresolved above means the protected set is incomplete, so # no candidate can be called abandoned. Skip this sweep entirely and # fail the job. Deliberately here and not at the point of failure: the # nightly sweep above is unrelated work that had no reason to be # cancelled by a bad pin on a release branch, and it has already run. if [ "$promotion_retention_blocked" -eq 1 ]; then echo "::error::promotion candidate protection could not be resolved; skipping the promotion sweep." exit 1 fi # A promoted manifest acquires vX.Y.Z (and possibly latest) in addition # to its temporary tag, so the all(tags match promotion-*) predicate is # the safety boundary: published bytes are never eligible. Digests # named by an open promote PR or by a base-branch tip are excluded # separately, which is what covers a candidate between merge and # retag. Only cozystack-packages receives these candidate tags. pkg='cozystack/cozystack-packages' enc="$(jq -rn --arg p "$pkg" '$p|@uri')" cutoff="$(date -u -d "${PROMOTION_TTL_DAYS} days ago" '+%Y-%m-%dT%H:%M:%SZ')" promotion_ids="$(gh api --paginate "/orgs/${ORG}/packages/container/${enc}/versions" 2>"$gh_err" \ | jq -s --arg cutoff "$cutoff" --arg protected "$protected_promotion_digests" '[add[] | .name as $digest | select((.metadata.container.tags | length) > 0) | select(.metadata.container.tags | all(test("^promotion-v[0-9]+\\.[0-9]+\\.[0-9]+-from-v[0-9]+\\.[0-9]+\\.[0-9]+-rc\\.[0-9]+-run-[0-9]+-[0-9]+$"))) | select(.updated_at < $cutoff) | select(($protected | split(" ") | index($digest)) == null) | .id]')" || { echo "::error::Could not read ${pkg} versions; promotion candidates not swept: $(cat "$gh_err")" sweep_failed=1 promotion_ids='[]' } promotion_total="$(printf '%s' "$promotion_ids" | jq 'length')" echo "── ${pkg}: ${promotion_total} abandoned promotion candidate(s) older than ${PROMOTION_TTL_DAYS} days" printf '%s' "$promotion_ids" | jq -r '.[]' \ | xargs ./hack/retention-delete.sh "$pkg" "promotion candidate version" || sweep_failed=1 if [ "$sweep_failed" -eq 1 ]; then echo "::error::some versions could not be read or deleted; see the errors above." exit 1 fi echo "Done."