#!/bin/bash set -euo pipefail log() { printf '%s\n' "$*" >&2 } fail() { log "ERROR: $*" exit 1 } resolve_token() { local source_name env_value local sources=( "inputs.RUNNER_TOKEN:INPUT_RUNNER_TOKEN" "secrets.GITEA_TOKEN:GITEA_TOKEN" "secrets.RUNNER_TOKEN:RUNNER_TOKEN_SECRET" ) for source in "${sources[@]}"; do source_name="${source%%:*}" env_value="${source#*:}" log "Trying token from ${source_name}" if [[ -n "${!env_value:-}" ]]; then log "Using token from ${source_name}" printf '%s' "${!env_value}" return 0 fi log "Token not found in ${source_name}, trying next source" done return 1 } resolve_keep_versions() { local raw_value="${INPUT_KEEP_VERSIONS:-2}" if [[ -z "${raw_value}" ]]; then raw_value="2" fi if [[ ! "${raw_value}" =~ ^[0-9]+$ ]]; then fail "Invalid keep_versions: ${raw_value}" fi printf '%s' "${raw_value}" } resolve_dry_run() { local raw_value="${INPUT_DRY_RUN:-true}" case "${raw_value,,}" in true|1|yes|on) printf 'true' ;; false|0|no|off) printf 'false' ;; *) fail "Invalid dry_run: ${raw_value}" ;; esac } init_repo_context() { local repository="${GITEA_REPOSITORY:-}" if [[ -z "${repository}" || "${repository}" != */* ]]; then fail "Invalid GITEA_REPOSITORY: ${repository:-}" fi REPO_OWNER="${repository%%/*}" REPO_NAME="${repository#*/}" } api_request() { local method="$1" local path="$2" local url body_file headers_file url="${GITEA_SERVER_URL%/}${path}" body_file="$(mktemp)" headers_file="$(mktemp)" if ! API_HTTP_CODE="$( curl -sS \ -H "Accept: application/json" \ -H "Authorization: token ${RESOLVED_GITEA_TOKEN}" \ -X "${method}" \ -D "${headers_file}" \ -o "${body_file}" \ -w '%{http_code}' \ "${url}" )"; then rm -f "${body_file}" "${headers_file}" fail "Request failed: ${method} ${path}" fi API_RESPONSE_BODY="$(cat "${body_file}")" API_RESPONSE_HEADERS="$(cat "${headers_file}")" API_STATUS_TEXT="$(head -n 1 "${headers_file}" | tr -d '\r' | cut -d' ' -f2-)" API_REQUEST_ID="$( grep -iE '^(x-gitea-request-id|x-request-id):' "${headers_file}" | tail -n 1 | cut -d':' -f2- | tr -d '\r' | sed 's/^ *//' )" || true rm -f "${body_file}" "${headers_file}" if [[ -n "${API_REQUEST_ID}" ]]; then log "${method} ${path} -> ${API_STATUS_TEXT} request_id=${API_REQUEST_ID}" else log "${method} ${path} -> ${API_STATUS_TEXT}" fi [[ "${API_HTTP_CODE}" =~ ^2 ]] } fetch_all_pages() { local base_path="$1" local page=1 local limit=100 local aggregate_file page_file tmp_file page_path page_length aggregate_file="$(mktemp)" printf '[]' > "${aggregate_file}" while :; do page_path="${base_path}" if [[ "${page_path}" == *\?* ]]; then page_path="${page_path}&page=${page}&limit=${limit}" else page_path="${page_path}?page=${page}&limit=${limit}" fi if ! api_request GET "${page_path}"; then rm -f "${aggregate_file}" fail "Unexpected response for ${page_path}" fi page_file="$(mktemp)" printf '%s' "${API_RESPONSE_BODY}" > "${page_file}" page_length="$(jq 'length' "${page_file}")" tmp_file="$(mktemp)" jq -s '.[0] + .[1]' "${aggregate_file}" "${page_file}" > "${tmp_file}" mv "${tmp_file}" "${aggregate_file}" rm -f "${page_file}" if (( page_length < limit )); then break fi page=$((page + 1)) done cat "${aggregate_file}" rm -f "${aggregate_file}" } collect_package_candidates() { local packages_json group_json package_name total_versions candidates_json packages_json="$( fetch_all_pages "/api/v1/packages/${REPO_OWNER}?type=nuget" )" if [[ "$(jq 'length' <<<"${packages_json}")" -eq 0 ]]; then log "No nuget packages found for owner ${REPO_OWNER}" return 0 fi while IFS= read -r group_json; do package_name="$(jq -r '.[0].name' <<<"${group_json}")" total_versions="$(jq 'length' <<<"${group_json}")" PACKAGE_COUNT=$((PACKAGE_COUNT + 1)) TOTAL_VERSION_COUNT=$((TOTAL_VERSION_COUNT + total_versions)) log "Package ${package_name}: total_versions=${total_versions} keep_versions=${keep_versions}" log "Package ${package_name} versions (oldest -> newest):" while IFS=$'\t' read -r version created_at; do [[ -z "${version}" ]] && continue log " - ${version} (${created_at})" done < <(jq -r 'sort_by(.created_at)[] | [.version, .created_at] | @tsv' <<<"${group_json}") if (( total_versions <= keep_versions )); then log " keep all ${total_versions} versions" KEPT_COUNT=$((KEPT_COUNT + total_versions)) continue fi KEPT_COUNT=$((KEPT_COUNT + keep_versions)) candidates_json="$( jq -c --argjson keep "${keep_versions}" \ 'sort_by(.created_at) | .[0:(length - $keep)]' <<<"${group_json}" )" while IFS=$'\t' read -r name version created_at; do [[ -z "${name}" ]] && continue log "Candidate to delete: package ${name} version ${version} (created: ${created_at})" printf '%s\t%s\t%s\n' "${name}" "${version}" "${created_at}" >> "${CANDIDATES_FILE}" CANDIDATE_COUNT=$((CANDIDATE_COUNT + 1)) done < <(jq -r '.[] | [.name, .version, .created_at] | @tsv' <<<"${candidates_json}") done < <(jq -c 'group_by(.name)[]' <<<"${packages_json}") } process_candidates() { local dry_run="$1" local name version created_at local deleted_count=0 local skipped_count=0 local error_count=0 if [[ ! -s "${CANDIDATES_FILE}" ]]; then log "No delete candidates found" log "Summary: packages=${PACKAGE_COUNT} versions=${TOTAL_VERSION_COUNT} kept=${KEPT_COUNT} candidates=0 deleted=0 skipped=0 errors=0 dry_run=${dry_run}" return 0 fi while IFS=$'\t' read -r name version created_at; do [[ -z "${name}" ]] && continue if [[ "${dry_run}" == "true" ]]; then log "[DRY-RUN] Would delete package ${name} version ${version} (created: ${created_at})" skipped_count=$((skipped_count + 1)) continue fi if api_request DELETE "/api/v1/packages/${REPO_OWNER}/nuget/${name}/${version}"; then log "Deleted package ${name} version ${version} -> ${API_STATUS_TEXT}" deleted_count=$((deleted_count + 1)) else if [[ -n "${API_REQUEST_ID}" ]]; then log "ERROR: DELETE package ${name} version ${version} -> ${API_STATUS_TEXT} request_id=${API_REQUEST_ID}" else log "ERROR: DELETE package ${name} version ${version} -> ${API_STATUS_TEXT}" fi error_count=$((error_count + 1)) fi done < "${CANDIDATES_FILE}" log "Summary: packages=${PACKAGE_COUNT} versions=${TOTAL_VERSION_COUNT} kept=${KEPT_COUNT} candidates=${CANDIDATE_COUNT} deleted=${deleted_count} skipped=${skipped_count} errors=${error_count} dry_run=${dry_run}" } main() { local token keep_versions dry_run log "Gitea Server Url: ${GITEA_SERVER_URL:-}" log "Gitea Repository: ${GITEA_REPOSITORY:-}" if ! token="$(resolve_token)"; then fail "No Gitea token available, exiting" fi export RESOLVED_GITEA_TOKEN="$token" init_repo_context keep_versions="$(resolve_keep_versions)" dry_run="$(resolve_dry_run)" log "keep_versions=${keep_versions}" log "dry_run=${dry_run}" log "Token source resolved successfully" CANDIDATES_FILE="$(mktemp)" export CANDIDATES_FILE PACKAGE_COUNT=0 TOTAL_VERSION_COUNT=0 KEPT_COUNT=0 CANDIDATE_COUNT=0 trap 'rm -f "${CANDIDATES_FILE}"' EXIT collect_package_candidates process_candidates "${dry_run}" log "Stage 4 complete" } main "$@"