371 lines
11 KiB
Bash
Executable File
371 lines
11 KiB
Bash
Executable File
#!/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}"
|
|
|
|
raw_value="$(trim "${raw_value}")"
|
|
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:-<empty>}"
|
|
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_code<TAB>status_text<TAB>request_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="${PAGE_LIMIT:-100}"
|
|
local aggregate_file page_file headers_file meta http_code status_text request_id page_length path tmp_file
|
|
local encoded_owner encoded_package_name
|
|
|
|
if [[ ! "${limit}" =~ ^[0-9]+$ ]] || (( limit <= 0 )); then
|
|
fail "Invalid PAGE_LIMIT: ${limit}"
|
|
fi
|
|
|
|
encoded_owner="$(url_encode "${owner}")"
|
|
encoded_package_name="$(url_encode "${package_name}")"
|
|
|
|
aggregate_file="$(mktemp)"
|
|
page_file="$(mktemp)"
|
|
headers_file="$(mktemp)"
|
|
printf '[]' > "${aggregate_file}"
|
|
|
|
while :; do
|
|
path="/api/v1/packages/${encoded_owner}/nuget/${encoded_package_name}?page=${page}&limit=${limit}"
|
|
: > "${page_file}"
|
|
: > "${headers_file}"
|
|
meta="$(api_request GET "${path}" "${page_file}" "${headers_file}")"
|
|
IFS=$'\t' read -r http_code status_text request_id <<< "${meta}"
|
|
|
|
if [[ "${http_code}" == "404" ]]; then
|
|
rm -f "${page_file}" "${headers_file}" "${aggregate_file}"
|
|
printf '[]'
|
|
return 0
|
|
fi
|
|
|
|
if [[ ! "${http_code}" =~ ^2 ]]; then
|
|
rm -f "${page_file}" "${headers_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}"
|
|
|
|
if (( page_length < limit )); then
|
|
break
|
|
fi
|
|
|
|
page=$((page + 1))
|
|
done
|
|
|
|
cat "${aggregate_file}"
|
|
rm -f "${page_file}" "${headers_file}" "${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_count<TAB>total_version_count<TAB>kept_count<TAB>candidate_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 encoded_owner encoded_package_name encoded_version
|
|
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
|
|
|
|
body_file="$(mktemp)"
|
|
headers_file="$(mktemp)"
|
|
encoded_owner="$(url_encode "${owner}")"
|
|
while IFS=$'\t' read -r package_name version _created_at; do
|
|
[[ -z "${package_name}" ]] && continue
|
|
|
|
encoded_package_name="$(url_encode "${package_name}")"
|
|
encoded_version="$(url_encode "${version}")"
|
|
: > "${body_file}"
|
|
: > "${headers_file}"
|
|
meta="$(api_request DELETE "/api/v1/packages/${encoded_owner}/nuget/${encoded_package_name}/${encoded_version}" "${body_file}" "${headers_file}")"
|
|
IFS=$'\t' read -r http_code status_text request_id <<< "${meta}"
|
|
|
|
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}"
|
|
rm -f "${body_file}" "${headers_file}"
|
|
}
|
|
|
|
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
|