Created
May 13, 2025 15:58
-
-
Save Richard-Barrett/2d214d35b1833156b6dc333d541d0662 to your computer and use it in GitHub Desktop.
Migrate GitHub Packages from One Account to Another
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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