#!/bin/bash set -euo pipefail log() { printf '%s\n' "$*" >&2 } fail() { log "ERROR: $*" exit 1 } trim() { # Remove leading and trailing ASCII whitespace from a string. local value="$1" value="${value#"${value%%[![:space:]]*}"}" value="${value%"${value##*[![:space:]]}"}" printf '%s' "$value" } url_encode() { # URL-encode a single path or query component using jq's RFC 3986 encoding. jq -rn --arg value "$1" '$value|@uri' } resolve_token() { # Resolve the already-merged token input passed in RUNNER_TOKEN. log "Trying token from RUNNER_TOKEN" if [[ -n "${RUNNER_TOKEN:-}" ]]; then log "Using token from RUNNER_TOKEN" printf '%s' "${RUNNER_TOKEN}" return 0 fi return 1 } resolve_keep_count() { # Parse KEEP_COUNT and ensure it is a non-negative integer. local raw_value="${INPUT_KEEP_COUNT:-2}" if [[ -z "${raw_value}" ]]; then raw_value="2" fi if [[ ! "${raw_value}" =~ ^[0-9]+$ ]]; then fail "Invalid keep_count: ${raw_value}" fi printf '%s' "${raw_value}" } resolve_package_names() { # Normalize PACKAGE_NAMES into a unique, newline-separated list. local raw_value="${INPUT_PACKAGE_NAMES:-}" local normalized token local -A seen=() local -a package_names=() if [[ -z "$(trim "${raw_value}")" ]]; then fail "Missing PACKAGE_NAMES" fi normalized="${raw_value//$'\n'/,}" IFS=',' read -r -a tokens <<< "${normalized}" for token in "${tokens[@]}"; do token="$(trim "${token}")" [[ -n "${token}" ]] || continue if [[ -z "${seen["${token}"]+x}" ]]; then seen["${token}"]=1 package_names+=("${token}") fi done if (( ${#package_names[@]} == 0 )); then fail "Missing PACKAGE_NAMES" fi printf '%s\n' "${package_names[@]}" } parse_repo_context() { # Convert the repository slug into owner/repo parts. local repository="$1" local owner repo repository="$(trim "${repository}")" if [[ -z "${repository}" || "${repository}" != */* ]]; then fail "Invalid GITEA_REPOSITORY: ${repository:-}" fi owner="${repository%%/*}" repo="${repository#*/}" if [[ -z "${owner}" || -z "${repo}" || "${repo}" == */* ]]; then fail "Invalid GITEA_REPOSITORY: ${repository}" fi printf '%s\t%s\n' "${owner}" "${repo}" } api_request() { # Perform one HTTP request and return status metadata as TSV. # stdout format: http_codestatus_textrequest_id local method="$1" local path="$2" local body_file="$3" local headers_file="$4" local url http_code status_text request_id status_line url="${GITEA_SERVER_URL%/}${path}" if ! 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 fail "Request failed: ${method} ${path}" fi status_line="$(head -n 1 "${headers_file}" | tr -d '\r')" status_text="$(printf '%s' "${status_line}" | cut -d' ' -f2-)" request_id="$( awk -F': *' 'tolower($1)=="x-gitea-request-id" || tolower($1)=="x-request-id" {value=$2} END {print value}' "${headers_file}" | tr -d '\r' )" if [[ -n "${request_id}" ]]; then log "${method} ${path} -> ${status_text} request_id=${request_id}" else log "${method} ${path} -> ${status_text}" fi printf '%s\t%s\t%s\n' "${http_code}" "${status_text}" "${request_id}" } fetch_package_versions() { # Fetch and aggregate all package versions for a single package name. # Params: # $1 owner # $2 package_name # stdout: # JSON array of version objects. local owner="$1" local package_name="$2" local page=1 local limit=100 local aggregate_file page_file headers_file meta http_code status_text request_id page_length path local encoded_owner encoded_package_name encoded_owner="$(url_encode "${owner}")" encoded_package_name="$(url_encode "${package_name}")" aggregate_file="$(mktemp)" printf '[]' > "${aggregate_file}" while :; do path="/api/v1/packages/${encoded_owner}/nuget/${encoded_package_name}?page=${page}&limit=${limit}" page_file="$(mktemp)" headers_file="$(mktemp)" meta="$(api_request GET "${path}" "${page_file}" "${headers_file}")" IFS=$'\t' read -r http_code status_text request_id <<< "${meta}" rm -f "${headers_file}" if [[ "${http_code}" == "404" ]]; then rm -f "${page_file}" "${aggregate_file}" printf '[]' return 0 fi if [[ ! "${http_code}" =~ ^2 ]]; then rm -f "${page_file}" "${aggregate_file}" fail "Unexpected response for package ${package_name}: ${status_text}" fi 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() { # Build the delete candidate file for the requested package names. # Params: # $1 owner # $2 keep_count # $3 candidate_file # $4... package names # stdout: # package_counttotal_version_countkept_countcandidate_count local owner="$1" local keep_count="$2" local candidate_file="$3" shift 3 local -a package_names=("$@") local package_name versions_json total_versions candidates_json local package_count=0 local total_version_count=0 local kept_count=0 local candidate_count=0 : > "${candidate_file}" for package_name in "${package_names[@]}"; do versions_json="$(fetch_package_versions "${owner}" "${package_name}")" if [[ "$(jq 'length' <<<"${versions_json}")" -eq 0 ]]; then log "No versions found for package ${package_name}" continue fi package_count=$((package_count + 1)) total_versions="$(jq 'length' <<<"${versions_json}")" total_version_count=$((total_version_count + total_versions)) log "Package ${package_name}: total_versions=${total_versions} keep_count=${keep_count}" 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)[] | [.version, .created_at] | @tsv' <<<"${versions_json}") if (( total_versions <= keep_count )); then log " keep all ${total_versions} versions" kept_count=$((kept_count + total_versions)) continue fi kept_count=$((kept_count + keep_count)) candidates_json="$( jq -c --argjson keep "${keep_count}" \ 'sort_by(.created_at, .version) | .[0:(length - $keep)]' <<<"${versions_json}" )" while IFS=$'\t' read -r package version created_at; do [[ -z "${package}" ]] && continue log "Candidate to delete: package ${package} version ${version} (created: ${created_at})" printf '%s\t%s\t%s\n' "${package}" "${version}" "${created_at}" >> "${candidate_file}" candidate_count=$((candidate_count + 1)) done < <(jq -r '.[] | [.name, .version, .created_at] | @tsv' <<<"${candidates_json}") done printf '%s\t%s\t%s\t%s\n' "${package_count}" "${total_version_count}" "${kept_count}" "${candidate_count}" } process_candidates() { # Delete each queued version and summarize the result. # Params: # $1 owner # $2 candidate_file # $3 package_count # $4 total_version_count # $5 kept_count # $6 candidate_count local owner="$1" local candidate_file="$2" local package_count="$3" local total_version_count="$4" local kept_count="$5" local candidate_count="$6" local deleted_count=0 local error_count=0 local package_name version _created_at local body_file headers_file meta http_code status_text request_id if [[ ! -s "${candidate_file}" ]]; then log "No delete candidates found" log "Summary: packages=${package_count} versions=${total_version_count} kept=${kept_count} candidates=0 deleted=0 errors=0" return 0 fi while IFS=$'\t' read -r package_name version _created_at; do [[ -z "${package_name}" ]] && continue body_file="$(mktemp)" headers_file="$(mktemp)" meta="$(api_request DELETE "/api/v1/packages/$(url_encode "${owner}")/nuget/$(url_encode "${package_name}")/$(url_encode "${version}")" "${body_file}" "${headers_file}")" IFS=$'\t' read -r http_code status_text request_id <<< "${meta}" rm -f "${body_file}" "${headers_file}" if [[ "${http_code}" =~ ^2 ]]; then log "Deleted package ${package_name} version ${version} -> ${status_text}" deleted_count=$((deleted_count + 1)) else if [[ -n "${request_id}" ]]; then log "ERROR: DELETE package ${package_name} version ${version} -> ${status_text} request_id=${request_id}" else log "ERROR: DELETE package ${package_name} version ${version} -> ${status_text}" fi error_count=$((error_count + 1)) fi done < "${candidate_file}" log "Summary: packages=${package_count} versions=${total_version_count} kept=${kept_count} candidates=${candidate_count} deleted=${deleted_count} errors=${error_count}" } main() { # Entry point for the Docker container. Resolves inputs, builds candidates, # and applies deletes for the selected NuGet packages. local token keep_count repository owner _repo package_names_csv local -a package_names local candidate_file summary package_count total_version_count kept_count candidate_count 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" repository="${GITEA_REPOSITORY:-}" IFS=$'\t' read -r owner _repo <<< "$(parse_repo_context "${repository}")" keep_count="$(resolve_keep_count)" mapfile -t package_names < <(resolve_package_names) package_names_csv="$(IFS=,; echo "${package_names[*]}")" log "keep_count=${keep_count}" log "package_names=${package_names_csv}" log "Token source resolved successfully" candidate_file="$(mktemp)" trap "rm -f -- '${candidate_file}'" EXIT summary="$(collect_package_candidates "${owner}" "${keep_count}" "${candidate_file}" "${package_names[@]}")" IFS=$'\t' read -r package_count total_version_count kept_count candidate_count <<< "${summary}" if (( package_count == 0 )); then log "No matching packages found for requested package_names" fi process_candidates "${owner}" "${candidate_file}" "${package_count}" "${total_version_count}" "${kept_count}" "${candidate_count}" log "Stage 4 complete" } if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then main "$@" fi