Skip to content

Instantly share code, notes, and snippets.

@Richard-Barrett
Created May 13, 2025 15:58
Show Gist options
  • Select an option

  • Save Richard-Barrett/2d214d35b1833156b6dc333d541d0662 to your computer and use it in GitHub Desktop.

Select an option

Save Richard-Barrett/2d214d35b1833156b6dc333d541d0662 to your computer and use it in GitHub Desktop.
Migrate GitHub Packages from One Account to Another
#!/bin/bash
# GitHub Package Migrator
# Migrates packages (NuGet, npm, RubyGems, Docker) between GitHub organizations
# Requires: curl, jq, docker, dotnet, gem
# Usage: ./github_pkg_migrator.sh <source_org> <target_org> <source_token> <target_token>
SOURCE_ORG="$1"
TARGET_ORG="$2"
SOURCE_TOKEN="$3"
TARGET_TOKEN="$4"
PACKAGE_TYPES=("nuget" "npm" "rubygems" "container")
# Validate dependencies
check_dependencies() {
local missing=()
for cmd in curl jq docker dotnet gem; do
if ! command -v "$cmd" &> /dev/null; then
missing+=("$cmd")
fi
done
if [ ${#missing[@]} -gt 0 ]; then
echo "❌ Missing dependencies: ${missing[*]}"
exit 1
fi
}
# Validate input parameters
if [ -z "$SOURCE_ORG" ] || [ -z "$TARGET_ORG" ] || [ -z "$SOURCE_TOKEN" ] || [ -z "$TARGET_TOKEN" ]; then
echo "Usage: $0 <source_org> <target_org> <source_token> <target_token>"
exit 1
fi
check_dependencies
SOURCE_HEADERS=(-H "Authorization: token $SOURCE_TOKEN" -H "Accept: application/vnd.github.v3+json")
TARGET_HEADERS=(-H "Authorization: token $TARGET_TOKEN" -H "Accept: application/vnd.github.v3+json")
# Get all paginated package versions from source
get_package_versions() {
local org=$1 type=$2 package=$3 page=1
local versions=()
while :; do
response=$(curl -s "${SOURCE_HEADERS[@]}" "https://api.github.com/orgs/$org/packages/$type/$package/versions?page=$page&per_page=100")
if ! echo "$response" | jq -e 'type == "array"' >/dev/null; then
echo " ⚠️ Unexpected API response for $org/$package ($type):"
echo "$response" | jq .
break
fi
# Handle both NuGet and container version formats
if [ "$type" == "nuget" ]; then
page_versions=$(echo "$response" | jq -r '.[].metadata.container.tags[]? // .[].metadata.version? // .[].name?')
else
page_versions=$(echo "$response" | jq -r '.[].metadata.container.tags[]? // .[].name?')
fi
[ -z "$page_versions" ] && break
versions+=($page_versions)
((page++))
done
printf "%s\n" "${versions[@]}" | sort -u
}
# Get all package versions from target
get_target_versions() {
local org=$1 type=$2 package=$3 page=1
local versions=()
while :; do
response=$(curl -s "${TARGET_HEADERS[@]}" "https://api.github.com/orgs/$org/packages/$type/$package/versions?page=$page&per_page=100")
if ! echo "$response" | jq -e 'type == "array"' >/dev/null; then
echo " ⚠️ Unexpected API response for $org/$package ($type):"
echo "$response" | jq .
break
fi
if [ "$type" == "nuget" ]; then
page_versions=$(echo "$response" | jq -r '.[].metadata.container.tags[]? // .[].metadata.version? // .[].name?')
else
page_versions=$(echo "$response" | jq -r '.[].metadata.container.tags[]? // .[].name?')
fi
[ -z "$page_versions" ] && break
versions+=($page_versions)
((page++))
done
printf "%s\n" "${versions[@]}" | sort -u
}
# Migrate NuGet packages
migrate_nuget() {
local pkg=$1
echo "🔁 NuGet: $pkg"
versions=$(get_package_versions "$SOURCE_ORG" "nuget" "$pkg")
target_versions=$(get_target_versions "$TARGET_ORG" "nuget" "$pkg")
for v in $versions; do
if [ -z "$v" ]; then
echo " ⚠️ Skipping null version"
continue
fi
if echo "$target_versions" | grep -q "^$v$"; then
echo " ⏩ Skipping $pkg version $v — already exists in target."
continue
fi
echo " → Version: $v"
file="$pkg.$v.nupkg"
# Download package
if ! curl -sfL "${SOURCE_HEADERS[@]}" \
"https://nuget.pkg.github.com/$SOURCE_ORG/download/$pkg/$v/$file" -o "$file"; then
echo " ❌ Failed to download $file"
continue
fi
# Push package
if dotnet nuget push "$file" \
--source "https://nuget.pkg.github.com/$TARGET_ORG/index.json" \
--api-key "$TARGET_TOKEN"; then
echo " ✅ Successfully pushed $pkg v$v"
rm -f "$file"
else
echo " ❌ Failed to push $pkg v$v"
fi
done
}
# Migrate npm packages (stub implementation)
migrate_npm() {
local pkg=$1
echo "⚠️ npm: $pkg — publishing stub (requires local package folder)"
echo " ℹ️ npm migration requires manual intervention or local package files"
}
# Migrate RubyGems packages
# Migrate RubyGems packages
migrate_rubygems() {
local pkg=$1
echo "🔁 RubyGems (binary transfer): $pkg"
if [[ -z "$SOURCE_TOKEN" || -z "$TARGET_TOKEN" ]]; then
echo " ❌ SOURCE_TOKEN and TARGET_TOKEN must be set"
return 1
fi
# API endpoint to get gem versions
local api_url="https://api.github.com/orgs/$SOURCE_ORG/packages/rubygems/$pkg/versions"
echo "📦 Fetching versions for $pkg..."
versions=$(curl -s -H "Authorization: Bearer $SOURCE_TOKEN" "$api_url" | jq -r '.[].name')
if [[ -z "$versions" ]]; then
echo " ❌ No versions found or API error for $pkg"
return 1
fi
tmp_dir=$(mktemp -d)
pushd "$tmp_dir" > /dev/null || return 1
for version in $versions; do
gem_file="${pkg}-${version}.gem"
gem_url="https://rubygems.pkg.github.com/$SOURCE_ORG/gems/$gem_file"
echo "⬇️ Downloading $gem_file..."
if ! curl -sfL -H "Authorization: Bearer $SOURCE_TOKEN" -o "$gem_file" "$gem_url"; then
echo " ⚠️ Failed to download $gem_file — skipping"
continue
fi
echo "⬆️ Pushing $gem_file to $TARGET_ORG..."
if GEM_HOST_API_KEY="$TARGET_TOKEN" gem push --host "https://rubygems.pkg.github.com/$TARGET_ORG" "$gem_file"; then
echo " ✅ Successfully pushed $gem_file"
else
echo " ❌ Failed to push $gem_file"
fi
done
popd > /dev/null
rm -rf "$tmp_dir"
}
# migrate_rubygems() {
# local pkg=$1
# echo "🔁 RubyGems: $pkg"
# # Prompt for email once if not already set
# if [[ -z "$RUBYGEMS_EMAIL" ]]; then
# read -p "Enter your GitHub (RubyGems) email: " RUBYGEMS_EMAIL
# fi
# # Choose token source
# local token="${RUBYGEMS_TOKEN:-$GEM_HOST_API_KEY:-$TARGET_TOKEN}"
# if [[ -z "$token" ]]; then
# read -s -p "Enter your GitHub RubyGems token (PAT): " token
# echo
# fi
# if [[ -z "$token" ]]; then
# echo " ❌ No token provided. Cannot push $pkg"
# return 1
# fi
# # Export for gem CLI usage
# export GEM_HOST_API_KEY="$token"
# # Also write to ~/.gem/credentials as a fallback
# mkdir -p ~/.gem
# echo ":github: Bearer $token" > ~/.gem/credentials
# chmod 0600 ~/.gem/credentials
# temp_dir=$(mktemp -d)
# if ! git clone --depth=1 "https://github.com/$SOURCE_ORG/$pkg.git" "$temp_dir"; then
# echo " ❌ Failed to clone source repo for $pkg"
# rm -rf "$temp_dir"
# return 1
# fi
# pushd "$temp_dir" > /dev/null || return 1
# if ! gem build "$pkg.gemspec"; then
# echo " ❌ Failed to build gem for $pkg"
# popd > /dev/null
# rm -rf "$temp_dir"
# return 1
# fi
# GEM_FILE=$(ls *.gem 2>/dev/null | head -n 1)
# if [[ -z "$GEM_FILE" ]]; then
# echo " ❌ No .gem file found after build"
# popd > /dev/null
# rm -rf "$temp_dir"
# return 1
# fi
# if gem push "$GEM_FILE" --host "https://rubygems.pkg.github.com/$TARGET_ORG"; then
# echo " ✅ Successfully pushed $GEM_FILE to $TARGET_ORG as $RUBYGEMS_EMAIL"
# else
# echo " ❌ Failed to push $GEM_FILE"
# fi
# popd > /dev/null
# rm -rf "$temp_dir"
# }
# Migrate Docker images
migrate_container() {
local pkg=$1
echo "🔁 Container: $pkg"
# Convert organization names to lowercase using tr
local source_org_lower=$(echo "$SOURCE_ORG" | tr '[:upper:]' '[:lower:]')
local target_org_lower=$(echo "$TARGET_ORG" | tr '[:upper:]' '[:lower:]')
# Docker image format: ghcr.io/org/image:tag
local source_image="ghcr.io/$source_org_lower/$pkg"
local target_image="ghcr.io/$target_org_lower/$pkg"
versions=$(get_package_versions "$SOURCE_ORG" "container" "$pkg")
target_versions=$(get_target_versions "$TARGET_ORG" "container" "$pkg")
# Login to source GHCR first (only once)
echo "$SOURCE_TOKEN" | docker login ghcr.io -u USERNAME --password-stdin
# Pull all tags from the source
for tag in $versions; do
echo " → Pulling $pkg:$tag from source..."
if ! docker pull "$source_image:$tag"; then
echo " ❌ Failed to pull $pkg:$tag"
continue
fi
# Tag the image for the target
docker tag "$source_image:$tag" "$target_image:$tag"
echo " → Tagged $pkg:$tag for the target"
done
# Now log in to target GHCR (only once)
echo "$TARGET_TOKEN" | docker login ghcr.io -u USERNAME --password-stdin
# Push all tagged images to the target
for tag in $versions; do
if echo "$target_versions" | grep -q "^$tag$"; then
echo " ⏩ Skipping $pkg:$tag — already exists in target."
continue
fi
echo " → Pushing $pkg:$tag to target..."
if docker push "$target_image:$tag"; then
echo " ✅ Successfully pushed $pkg:$tag"
else
echo " ❌ Failed to push $pkg:$tag"
fi
done
# Cleanup local images
docker rmi "$source_image:$tag" "$target_image:$tag" >/dev/null 2>&1
}
# Main migration loop
for TYPE in "${PACKAGE_TYPES[@]}"; do
echo -e "\n🔎 Checking packages of type: $TYPE"
# Get all packages of current type
packages=$(curl -s "${SOURCE_HEADERS[@]}" \
"https://api.github.com/orgs/$SOURCE_ORG/packages?package_type=$TYPE" | \
jq -r '.[].name')
# Fallback to user-level packages for containers if org packages not found
if [[ -z "$packages" && "$TYPE" == "container" ]]; then
echo " ℹ️ No organization packages found, checking user packages..."
packages=$(curl -s "${SOURCE_HEADERS[@]}" \
"https://api.github.com/user/packages?package_type=$TYPE" | \
jq -r '.[].name')
fi
if [ -z "$packages" ]; then
echo " ℹ️ No packages found for type $TYPE"
continue
fi
# Process each package
for pkg in $packages; do
echo -e "\n🔄 Processing package: $pkg ($TYPE)"
case "$TYPE" in
nuget) migrate_nuget "$pkg" ;;
npm) migrate_npm "$pkg" ;;
rubygems) migrate_rubygems "$pkg" ;;
container) migrate_container "$pkg" ;;
esac
done
done
echo -e "\n🏁 Migration completed!"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment