Skip to content

Instantly share code, notes, and snippets.

@Kenya-West
Created May 2, 2026 16:02
Show Gist options
  • Select an option

  • Save Kenya-West/83c58f3eb8a59bed9ebd6ad62b33ee10 to your computer and use it in GitHub Desktop.

Select an option

Save Kenya-West/83c58f3eb8a59bed9ebd6ad62b33ee10 to your computer and use it in GitHub Desktop.
GitHub Releases Artifact Mirror. Mirrors GitHub releases into destination release systems through vendor modules

GitHub Releases Artifact Mirror

This script mirrors GitHub releases into destination release systems through vendor modules.

Files

  • release-mirror.sh — main runner.
  • release-mirror.conf — example Bash-native config.
  • module-gitverse.sh — GitVerse destination module.

Behavior

For each selected GitHub release:

  1. Read release metadata from GitHub.
  2. Download release assets locally.
  3. For each destination:
    • load the vendor module;
    • check if a release with the same tag already exists;
    • skip if present;
    • create release if absent;
    • upload assets.

This is intentionally idempotent at release-tag level. It does not update an existing destination release.

Requirements

sudo apt-get install -y curl jq coreutils findutils

Usage

chmod +x release-mirror.sh
chmod 600 release-mirror.conf

./release-mirror.sh --config ./release-mirror.conf --verbose

Notes

Public GitHub release downloads work without a token, but rate limits are stricter. Set SOURCE_GITHUB_TOKEN when you need higher limits or private repository access.

Destination token headers are configurable with DEST_<name>_TOKEN_HEADER, for example:

DEST_gitverse_TOKEN_HEADER="Authorization: Bearer {{TOKEN}}"
DEST_gitlab_TOKEN_HEADER="PRIVATE-TOKEN: {{TOKEN}}"
DEST_custom_TOKEN_HEADER="X-API-Key: {{TOKEN}}"

DEST_<name>_SSH_KEY is accepted in config so modules for platforms with SSH-based release publication can use it. The GitVerse module does not use SSH for release API calls.

Credits

GPT-5.5 lol.

#!/usr/bin/env bash
# GitVerse release mirror vendor module.
# This file is sourced by release-mirror.sh.
#
# Required destination variables:
# DEST_<name>_OWNER
# DEST_<name>_REPO
# DEST_<name>_TOKEN
#
# Optional:
# DEST_<name>_API
# DEST_<name>_TOKEN_HEADER
# DEST_<name>_ACCEPT
# DEST_<name>_TARGET_COMMITISH
# DEST_<name>_DRAFT
# DEST_<name>_PRERELEASE
# DEST_<name>_AUTHORIZED_ONLY
# DEST_<name>_UPLOAD_ASSETS
# DEST_<name>_SSH_KEY
#
# DEST_<name>_SSH_KEY is intentionally accepted for platforms/modules that
# support release publication through SSH. GitVerse release API itself uses HTTP.
release_vendor_gitverse_assert() {
command -v curl >/dev/null 2>&1 || die "GitVerse module requires curl"
command -v jq >/dev/null 2>&1 || die "GitVerse module requires jq"
}
gitverse_dest_required() {
local dest="$1"
local suffix="$2"
local value
value="$(get_dest_var "$dest" "$suffix")"
[[ -n "$value" ]] || die "Destination '$dest' has no DEST_${dest}_${suffix}"
printf '%s\n' "$value"
}
gitverse_api() {
local dest="$1"
local api
api="$(get_dest_var "$dest" "API")"
printf '%s\n' "${api:-https://api.gitverse.ru}"
}
gitverse_accept() {
local dest="$1"
local accept
accept="$(get_dest_var "$dest" "ACCEPT")"
printf '%s\n' "${accept:-application/vnd.gitverse.object+json;version=1}"
}
gitverse_token_header() {
local dest="$1"
local header
header="$(get_dest_var "$dest" "TOKEN_HEADER")"
printf '%s\n' "${header:-Authorization: Bearer {{TOKEN}}}"
}
gitverse_curl_headers() {
local dest="$1"
local token header accept
token="$(get_dest_var "$dest" "TOKEN")"
header="$(gitverse_token_header "$dest")"
accept="$(gitverse_accept "$dest")"
printf '%s\0%s\0' "-H" "Accept: $accept"
if [[ -n "$token" ]]; then
header="${header//'{{TOKEN}}'/$token}"
printf '%s\0%s\0' "-H" "$header"
fi
}
gitverse_request() {
local dest="$1"
local method="$2"
local path="$3"
local response_file="$4"
local body_file="${5:-}"
shift 5 || true
local api url status
api="$(gitverse_api "$dest")"
url="${api%/}${path}"
local -a headers=()
while IFS= read -r -d '' part; do
headers+=("$part")
done < <(gitverse_curl_headers "$dest")
status="$(curl_status "$method" "$url" "$body_file" "$response_file" "${headers[@]}" "$@")"
printf '%s\n' "$status"
}
release_vendor_gitverse_release_exists() {
local dest="$1"
local tag="$2"
local owner repo tag_encoded response status
owner="$(gitverse_dest_required "$dest" "OWNER")"
repo="$(gitverse_dest_required "$dest" "REPO")"
tag_encoded="$(urlencode "$tag")"
response="$(mktemp)"
status="$(gitverse_request "$dest" "GET" "/repos/$owner/$repo/releases/tags/$tag_encoded" "$response" "")"
case "$status" in
200)
rm -f "$response"
return 0
;;
404)
rm -f "$response"
return 1
;;
*)
log "ERROR" "GitVerse '$dest': unexpected status while checking release '$tag': HTTP $status"
sed 's/^/ /' "$response" >&2 || true
rm -f "$response"
return 2
;;
esac
}
release_vendor_gitverse_create_release() {
local dest="$1"
local release_dir="$2"
local owner repo response body status
owner="$(gitverse_dest_required "$dest" "OWNER")"
repo="$(gitverse_dest_required "$dest" "REPO")"
gitverse_dest_required "$dest" "TOKEN" >/dev/null
response="$(mktemp)"
body="$(mktemp)"
local tag name release_body target_commitish draft prerelease authorized_only
tag="$(jq -r '.tag_name' "$release_dir/release.json")"
name="$(jq -r '.name // .tag_name' "$release_dir/release.json")"
release_body="$(jq -r '.body // ""' "$release_dir/release.json")"
target_commitish="$(get_dest_var "$dest" "TARGET_COMMITISH")"
target_commitish="${target_commitish:-$(jq -r '.target_commitish // empty' "$release_dir/release.json")}"
draft="$(bool_json "$(get_dest_var "$dest" "DRAFT")")"
prerelease="$(get_dest_var "$dest" "PRERELEASE")"
if [[ -z "$prerelease" ]]; then
prerelease="$(jq -r '.prerelease // false' "$release_dir/release.json")"
fi
prerelease="$(bool_json "$prerelease")"
authorized_only="$(bool_json "$(get_dest_var "$dest" "AUTHORIZED_ONLY")")"
jq -n \
--arg tag_name "$tag" \
--arg name "$name" \
--arg target_commitish "$target_commitish" \
--arg body "$release_body" \
--argjson draft "$draft" \
--argjson prerelease "$prerelease" \
--argjson is_authorized_only "$authorized_only" \
'{
tag_name: $tag_name,
name: $name,
body: $body,
draft: $draft,
prerelease: $prerelease,
is_authorized_only: $is_authorized_only
}
+ (if $target_commitish == "" then {} else {target_commitish: $target_commitish} end)' > "$body"
status="$(gitverse_request "$dest" "POST" "/repos/$owner/$repo/releases" "$response" "$body" \
-H "Content-Type: application/json")"
case "$status" in
200|201)
jq -r '.id' "$response"
rm -f "$response" "$body"
return 0
;;
409)
log "ERROR" "GitVerse '$dest': release '$tag' conflicted during create. Another run may have created it."
sed 's/^/ /' "$response" >&2 || true
rm -f "$response" "$body"
return 1
;;
*)
log "ERROR" "GitVerse '$dest': failed to create release '$tag': HTTP $status"
sed 's/^/ /' "$response" >&2 || true
rm -f "$response" "$body"
return 1
;;
esac
}
release_vendor_gitverse_upload_assets() {
local dest="$1"
local release_id="$2"
local release_dir="$3"
local upload_assets
upload_assets="$(get_dest_var "$dest" "UPLOAD_ASSETS")"
upload_assets="${upload_assets:-true}"
[[ "$upload_assets" == "true" ]] || {
log "INFO" "GitVerse '$dest': asset upload disabled"
return 0
}
local owner repo assets_dir
owner="$(gitverse_dest_required "$dest" "OWNER")"
repo="$(gitverse_dest_required "$dest" "REPO")"
gitverse_dest_required "$dest" "TOKEN" >/dev/null
assets_dir="$release_dir/assets"
[[ -d "$assets_dir" ]] || {
log "INFO" "GitVerse '$dest': no assets directory for release_id=$release_id"
return 0
}
local file response status
find "$assets_dir" -type f -maxdepth 1 -print0 | sort -z | while IFS= read -r -d '' file; do
response="$(mktemp)"
log "INFO" "GitVerse '$dest': uploading asset $(basename "$file")"
local -a headers=()
while IFS= read -r -d '' part; do
headers+=("$part")
done < <(gitverse_curl_headers "$dest")
local api url
api="$(gitverse_api "$dest")"
url="${api%/}/repos/$owner/$repo/releases/$release_id/assets"
status="$(curl -sS -L -X POST -o "$response" -w "%{http_code}" \
"${headers[@]}" \
-F "attachment=@${file}" \
"$url")"
case "$status" in
200|201)
rm -f "$response"
;;
*)
log "ERROR" "GitVerse '$dest': failed to upload asset $(basename "$file"): HTTP $status"
sed 's/^/ /' "$response" >&2 || true
rm -f "$response"
return 1
;;
esac
done
}
# Bash-native config file.
# This file is sourced by release-mirror.sh.
# Keep permissions strict if it contains secrets:
# chmod 600 release-mirror.conf
# -------------------------------------------------------------------
# Source GitHub repository
# -------------------------------------------------------------------
SOURCE_GITHUB_OWNER="DNSCrypt"
SOURCE_GITHUB_REPO="dnscrypt-resolvers"
# Optional. Public GitHub releases can be downloaded without a token,
# but unauthenticated requests have stricter rate limits.
SOURCE_GITHUB_TOKEN=""
# Header where the GitHub token is placed. {{TOKEN}} is substituted.
SOURCE_GITHUB_TOKEN_HEADER="Authorization: Bearer {{TOKEN}}"
# Supported:
# latest - mirror latest GitHub release
# tags - mirror only tags listed in SOURCE_RELEASE_TAGS
# all - mirror first page of releases from GitHub API
SOURCE_RELEASE_SELECTOR="latest"
# Required only when SOURCE_RELEASE_SELECTOR="tags".
SOURCE_RELEASE_TAGS=""
# Release filters.
SOURCE_INCLUDE_DRAFTS="false"
SOURCE_INCLUDE_PRERELEASES="true"
# Download GitHub release assets and upload them to destination releases.
DOWNLOAD_ASSETS="true"
# Also download GitHub generated source archives and upload them as release assets.
DOWNLOAD_SOURCE_ARCHIVES="false"
SOURCE_ARCHIVE_FORMATS="tarball zipball"
# -------------------------------------------------------------------
# Work directory behavior
# -------------------------------------------------------------------
# Supported values:
# temp - use temporary directory and delete after run
# persistent - reuse local release cache between runs
WORK_MODE="persistent"
WORK_DIR="/var/lib/release-mirror/dnscrypt-resolvers"
# Vendor modules directory.
MODULES_DIR="./modules"
# -------------------------------------------------------------------
# Destinations
# -------------------------------------------------------------------
# Destination names must be Bash-safe identifiers:
# letters, numbers, underscore. No dash.
DESTINATIONS=(
gitverse
)
# -------------------------------------------------------------------
# GitVerse destination
# -------------------------------------------------------------------
DEST_gitverse_VENDOR="gitverse"
DEST_gitverse_OWNER="owner"
DEST_gitverse_REPO="repo"
# GitVerse release API uses HTTP auth.
DEST_gitverse_TOKEN=""
# Header where destination PAT is placed. {{TOKEN}} is substituted.
# This can be changed for vendors that expect another header, for example:
# DEST_x_TOKEN_HEADER="PRIVATE-TOKEN: {{TOKEN}}"
# DEST_x_TOKEN_HEADER="Authorization: token {{TOKEN}}"
DEST_gitverse_TOKEN_HEADER="Authorization: Bearer {{TOKEN}}"
# Optional API and accept header overrides.
DEST_gitverse_API="https://api.gitverse.ru"
DEST_gitverse_ACCEPT="application/vnd.gitverse.object+json;version=1"
# Optional. If empty, copied from GitHub release target_commitish.
# Usually this should point to a branch/commit/tag that already exists in destination.
DEST_gitverse_TARGET_COMMITISH="master"
DEST_gitverse_DRAFT="false"
DEST_gitverse_PRERELEASE=""
DEST_gitverse_AUTHORIZED_ONLY="false"
DEST_gitverse_UPLOAD_ASSETS="true"
# Accepted for modules/vendors that support release publishing over SSH.
# GitVerse release API itself does not use this.
DEST_gitverse_SSH_KEY="/home/user/.ssh/id_rsa"
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_NAME="$(basename "$0")"
CONFIG_FILE=""
VERBOSE=0
WORK_DIR=""
RELEASES_DIR=""
CLEANUP_WORK_DIR=false
log() {
local level="$1"; shift
local ts
ts="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
[[ "$level" == "DEBUG" && "$VERBOSE" -lt 1 ]] && return 0
printf '%s [%s] %s\n' "$ts" "$level" "$*" >&2
}
die() {
log "ERROR" "$*"
exit 1
}
usage() {
cat <<EOF
Usage:
$SCRIPT_NAME --config <path> [--verbose]
Options:
--config <path> Path to Bash-native release mirror config file
--verbose Enable verbose logging. Can be repeated.
--help Show help
Required commands:
bash, curl, jq, date, mktemp, mkdir, basename, find, sha256sum
EOF
}
expand_path() {
local path="$1"
case "$path" in
"~") printf '%s\n' "$HOME" ;;
"~/"*) printf '%s/%s\n' "$HOME" "${path#"~/"}" ;;
*) printf '%s\n' "$path" ;;
esac
}
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
--config)
[[ $# -ge 2 ]] || die "--config requires a path"
CONFIG_FILE="$2"
shift 2
;;
--verbose)
VERBOSE=$((VERBOSE + 1))
shift
;;
--help|-h)
usage
exit 0
;;
*)
die "Unknown argument: $1"
;;
esac
done
}
require_command() {
local name="$1"
command -v "$name" >/dev/null 2>&1 || die "Required command not found: $name"
}
get_dest_var() {
local dest_name="$1"
local suffix="$2"
local var_name="DEST_${dest_name}_${suffix}"
printf '%s' "${!var_name-}"
}
get_config_var() {
local name="$1"
printf '%s' "${!name-}"
}
urlencode() {
jq -rn --arg v "$1" '$v|@uri'
}
json_string() {
jq -Rn --arg v "$1" '$v'
}
bool_json() {
case "${1:-false}" in
true|TRUE|yes|YES|1) printf 'true' ;;
false|FALSE|no|NO|0|"") printf 'false' ;;
*) die "Invalid boolean value: $1" ;;
esac
}
http_headers_from_spec() {
# Reads header specs from config variables and expands {{TOKEN}}.
# Header spec supports either:
# "Authorization: Bearer {{TOKEN}}"
# "PRIVATE-TOKEN: {{TOKEN}}"
# It prints null-delimited curl args: -H <header>.
local token="$1"; shift
local header
for header in "$@"; do
[[ -n "$header" ]] || continue
header="${header//'{{TOKEN}}'/$token}"
printf '%s\0%s\0' "-H" "$header"
done
}
curl_status() {
# Usage:
# curl_status METHOD URL BODY_FILE RESPONSE_FILE CURL_EXTRA_ARGS...
# BODY_FILE may be empty.
local method="$1"
local url="$2"
local body_file="$3"
local response_file="$4"
shift 4
local status
local args=(-sS -L -X "$method" -o "$response_file" -w "%{http_code}")
if [[ -n "$body_file" ]]; then
args+=(--data-binary "@$body_file")
fi
status="$(curl "${args[@]}" "$@" "$url")"
printf '%s\n' "$status"
}
load_config() {
[[ -n "$CONFIG_FILE" ]] || die "Missing --config <path>"
[[ -f "$CONFIG_FILE" ]] || die "Config file does not exist: $CONFIG_FILE"
# shellcheck source=/dev/null
source "$CONFIG_FILE"
: "${SOURCE_GITHUB_OWNER:?SOURCE_GITHUB_OWNER is required}"
: "${SOURCE_GITHUB_REPO:?SOURCE_GITHUB_REPO is required}"
: "${DESTINATIONS:?DESTINATIONS array is required}"
SOURCE_GITHUB_API="${SOURCE_GITHUB_API:-https://api.github.com}"
SOURCE_GITHUB_TOKEN="${SOURCE_GITHUB_TOKEN:-}"
SOURCE_GITHUB_TOKEN_HEADER="${SOURCE_GITHUB_TOKEN_HEADER:-Authorization: Bearer {{TOKEN}}}"
SOURCE_RELEASE_SELECTOR="${SOURCE_RELEASE_SELECTOR:-latest}"
SOURCE_RELEASE_TAGS="${SOURCE_RELEASE_TAGS:-}"
SOURCE_INCLUDE_DRAFTS="${SOURCE_INCLUDE_DRAFTS:-false}"
SOURCE_INCLUDE_PRERELEASES="${SOURCE_INCLUDE_PRERELEASES:-true}"
WORK_MODE="${WORK_MODE:-temp}"
WORK_DIR="${WORK_DIR:-}"
DOWNLOAD_ASSETS="${DOWNLOAD_ASSETS:-true}"
DOWNLOAD_SOURCE_ARCHIVES="${DOWNLOAD_SOURCE_ARCHIVES:-false}"
SOURCE_ARCHIVE_FORMATS="${SOURCE_ARCHIVE_FORMATS:-tarball zipball}"
# Vendor modules are expected to be named: module-<vendor>.sh next to the config
if [[ "$WORK_MODE" != "temp" && "$WORK_MODE" != "persistent" ]]; then
die "WORK_MODE must be either 'temp' or 'persistent'"
fi
if [[ "$WORK_MODE" == "persistent" && -z "$WORK_DIR" ]]; then
die "WORK_DIR is required when WORK_MODE=persistent"
fi
}
prepare_workdir() {
if [[ "$WORK_MODE" == "temp" ]]; then
WORK_DIR="$(mktemp -d)"
CLEANUP_WORK_DIR=true
else
WORK_DIR="$(expand_path "$WORK_DIR")"
mkdir -p "$WORK_DIR"
CLEANUP_WORK_DIR=false
fi
RELEASES_DIR="$WORK_DIR/releases"
mkdir -p "$RELEASES_DIR"
log "INFO" "Work mode: $WORK_MODE"
log "INFO" "Work directory: $WORK_DIR"
}
cleanup() {
if [[ "$CLEANUP_WORK_DIR" == "true" && -n "${WORK_DIR:-}" && -d "$WORK_DIR" ]]; then
log "DEBUG" "Cleaning temporary work directory: $WORK_DIR"
rm -rf "$WORK_DIR"
fi
}
github_headers() {
local token="${SOURCE_GITHUB_TOKEN:-}"
local header="${SOURCE_GITHUB_TOKEN_HEADER:-Authorization: Bearer {{TOKEN}}}"
printf '%s\0%s\0' "-H" "Accept: application/vnd.github+json"
printf '%s\0%s\0' "-H" "X-GitHub-Api-Version: 2022-11-28"
if [[ -n "$token" ]]; then
header="${header//'{{TOKEN}}'/$token}"
printf '%s\0%s\0' "-H" "$header"
fi
}
curl_with_nul_headers() {
# First arg is output file; second arg URL; stdin is null-delimited curl args.
local out_file="$1"
local url="$2"
shift 2
local -a extra=()
while IFS= read -r -d '' part; do
extra+=("$part")
done
curl -sS -L "${extra[@]}" "$@" -o "$out_file" "$url"
}
github_api_get() {
local path="$1"
local out_file="$2"
local url="${SOURCE_GITHUB_API%/}${path}"
github_headers | curl_with_nul_headers "$out_file" "$url"
}
github_download() {
local url="$1"
local out_file="$2"
if [[ -n "${SOURCE_GITHUB_TOKEN:-}" ]]; then
github_headers | curl_with_nul_headers "$out_file" "$url"
else
curl -sS -L -o "$out_file" "$url"
fi
}
fetch_source_releases() {
local releases_json="$WORK_DIR/source-releases.json"
case "$SOURCE_RELEASE_SELECTOR" in
latest)
github_api_get "/repos/$SOURCE_GITHUB_OWNER/$SOURCE_GITHUB_REPO/releases/latest" "$releases_json.one"
jq -s '.' "$releases_json.one" > "$releases_json"
;;
tags)
[[ -n "$SOURCE_RELEASE_TAGS" ]] || die "SOURCE_RELEASE_TAGS is required when SOURCE_RELEASE_SELECTOR=tags"
: > "$releases_json.tmp"
local tag encoded
printf '[' > "$releases_json"
local first=true
for tag in $SOURCE_RELEASE_TAGS; do
encoded="$(urlencode "$tag")"
github_api_get "/repos/$SOURCE_GITHUB_OWNER/$SOURCE_GITHUB_REPO/releases/tags/$encoded" "$releases_json.one"
if [[ "$first" == "true" ]]; then
first=false
else
printf ',' >> "$releases_json"
fi
cat "$releases_json.one" >> "$releases_json"
done
printf ']\n' >> "$releases_json"
;;
all)
github_api_get "/repos/$SOURCE_GITHUB_OWNER/$SOURCE_GITHUB_REPO/releases?per_page=100" "$releases_json"
;;
*)
die "SOURCE_RELEASE_SELECTOR must be one of: latest, tags, all"
;;
esac
jq -e 'type == "array"' "$releases_json" >/dev/null || die "GitHub releases response is not an array"
printf '%s\n' "$releases_json"
}
release_is_selected() {
local release_json="$1"
local draft prerelease include_drafts include_prereleases
draft="$(jq -r '.draft // false' "$release_json")"
prerelease="$(jq -r '.prerelease // false' "$release_json")"
include_drafts="$(bool_json "$SOURCE_INCLUDE_DRAFTS")"
include_prereleases="$(bool_json "$SOURCE_INCLUDE_PRERELEASES")"
[[ "$draft" == "true" && "$include_drafts" != "true" ]] && return 1
[[ "$prerelease" == "true" && "$include_prereleases" != "true" ]] && return 1
return 0
}
materialize_release() {
local release_json="$1"
local tag="$2"
local release_dir="$RELEASES_DIR/$tag"
local assets_dir="$release_dir/assets"
mkdir -p "$assets_dir"
cp "$release_json" "$release_dir/release.json"
if [[ "$DOWNLOAD_ASSETS" == "true" ]]; then
local count i name url
count="$(jq '.assets | length' "$release_json")"
for (( i=0; i<count; i++ )); do
name="$(jq -r ".assets[$i].name" "$release_json")"
url="$(jq -r ".assets[$i].browser_download_url" "$release_json")"
[[ -n "$name" && "$name" != "null" ]] || die "Release '$tag' has asset without name"
[[ -n "$url" && "$url" != "null" ]] || die "Release '$tag' asset '$name' has no download URL"
log "INFO" "Downloading GitHub asset: tag=$tag asset=$name"
github_download "$url" "$assets_dir/$name"
done
fi
if [[ "$DOWNLOAD_SOURCE_ARCHIVES" == "true" ]]; then
local fmt archive_url archive_name
for fmt in $SOURCE_ARCHIVE_FORMATS; do
case "$fmt" in
tarball)
archive_url="$(jq -r '.tarball_url // empty' "$release_json")"
archive_name="${SOURCE_GITHUB_REPO}-${tag}.tar.gz"
;;
zipball)
archive_url="$(jq -r '.zipball_url // empty' "$release_json")"
archive_name="${SOURCE_GITHUB_REPO}-${tag}.zip"
;;
*)
die "Unsupported SOURCE_ARCHIVE_FORMATS value: $fmt"
;;
esac
[[ -n "$archive_url" ]] || continue
log "INFO" "Downloading GitHub source archive: tag=$tag archive=$archive_name"
github_download "$archive_url" "$assets_dir/$archive_name"
done
fi
find "$assets_dir" -type f -maxdepth 1 -print0 | sort -z | while IFS= read -r -d '' file; do
sha256sum "$file"
done > "$release_dir/SHA256SUMS"
printf '%s\n' "$release_dir"
}
load_vendor_module() {
local vendor="$1"
local module_file
module_file="$(get_config_var "VENDOR_${vendor}_MODULE")"
module_file="${module_file:-$(dirname "$CONFIG_FILE")/module-${vendor}.sh}"
module_file="$(expand_path "$module_file")"
[[ -f "$module_file" ]] || die "Vendor module not found for '$vendor': $module_file"
# shellcheck source=/dev/null
source "$module_file"
local fn="release_vendor_${vendor}_assert"
declare -F "$fn" >/dev/null || die "Vendor module '$vendor' does not define $fn"
"$fn"
}
destination_release_exists() {
local dest="$1"
local vendor="$2"
local tag="$3"
local fn="release_vendor_${vendor}_release_exists"
declare -F "$fn" >/dev/null || die "Vendor module '$vendor' does not define $fn"
"$fn" "$dest" "$tag"
}
destination_create_release() {
local dest="$1"
local vendor="$2"
local release_dir="$3"
local fn="release_vendor_${vendor}_create_release"
declare -F "$fn" >/dev/null || die "Vendor module '$vendor' does not define $fn"
"$fn" "$dest" "$release_dir"
}
destination_upload_assets() {
local dest="$1"
local vendor="$2"
local release_id="$3"
local release_dir="$4"
local fn="release_vendor_${vendor}_upload_assets"
declare -F "$fn" >/dev/null || die "Vendor module '$vendor' does not define $fn"
"$fn" "$dest" "$release_id" "$release_dir"
}
sync_release_to_destination() {
local dest="$1"
local release_dir="$2"
local vendor tag
vendor="$(get_dest_var "$dest" "VENDOR")"
tag="$(jq -r '.tag_name' "$release_dir/release.json")"
[[ -n "$vendor" ]] || {
log "ERROR" "Destination '$dest' has no DEST_${dest}_VENDOR"
return 1
}
load_vendor_module "$vendor"
log "INFO" "Destination '$dest': checking release tag '$tag'"
if destination_release_exists "$dest" "$vendor" "$tag"; then
log "INFO" "Destination '$dest': release '$tag' already exists, skipping"
return 0
fi
log "INFO" "Destination '$dest': release '$tag' is missing, creating"
local release_id
release_id="$(destination_create_release "$dest" "$vendor" "$release_dir")"
[[ -n "$release_id" ]] || {
log "ERROR" "Destination '$dest': vendor returned empty release id"
return 1
}
destination_upload_assets "$dest" "$vendor" "$release_id" "$release_dir"
log "INFO" "Destination '$dest': release '$tag' mirrored"
}
main() {
parse_args "$@"
require_command bash
require_command curl
require_command jq
require_command date
require_command mktemp
require_command mkdir
require_command basename
require_command find
require_command sort
require_command sha256sum
load_config
prepare_workdir
trap cleanup EXIT
log "INFO" "Release artifact mirror run started"
local releases_json
releases_json="$(fetch_source_releases)"
local total
total="$(jq 'length' "$releases_json")"
log "INFO" "GitHub releases fetched: $total"
local success_count=0
local failure_count=0
local skipped_count=0
local i release_tmp tag release_dir dest
for (( i=0; i<total; i++ )); do
release_tmp="$WORK_DIR/release-$i.json"
jq ".[$i]" "$releases_json" > "$release_tmp"
tag="$(jq -r '.tag_name' "$release_tmp")"
[[ -n "$tag" && "$tag" != "null" ]] || die "GitHub release index $i has no tag_name"
if ! release_is_selected "$release_tmp"; then
log "INFO" "Skipping GitHub release '$tag' due to draft/prerelease filters"
skipped_count=$((skipped_count + 1))
continue
fi
release_dir="$(materialize_release "$release_tmp" "$tag")"
for dest in "${DESTINATIONS[@]}"; do
if sync_release_to_destination "$dest" "$release_dir"; then
success_count=$((success_count + 1))
else
failure_count=$((failure_count + 1))
fi
done
done
log "INFO" "Release artifact mirror run finished: success=$success_count failure=$failure_count skipped=$skipped_count"
[[ "$failure_count" -eq 0 ]] || exit 2
}
main "$@"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment