|
| 1 | +--- |
| 2 | +# Mirror all repositories in the LizardByte GitHub organization to GitLab. |
| 3 | + |
| 4 | +name: Mirror GitHub to GitLab |
| 5 | +permissions: {} |
| 6 | + |
| 7 | +on: |
| 8 | + schedule: |
| 9 | + - cron: '0 3 * * *' |
| 10 | + workflow_dispatch: |
| 11 | + |
| 12 | +concurrency: |
| 13 | + group: mirror-github-to-gitlab |
| 14 | + cancel-in-progress: false |
| 15 | + |
| 16 | +jobs: |
| 17 | + mirror: |
| 18 | + name: Mirror GitHub to GitLab |
| 19 | + permissions: {} |
| 20 | + runs-on: ubuntu-latest |
| 21 | + steps: |
| 22 | + # Keep private repository metadata out of a matrix because matrix values are visible in public workflow runs. |
| 23 | + - name: Get repositories |
| 24 | + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 |
| 25 | + with: |
| 26 | + github-token: ${{ secrets.GH_BOT_TOKEN }} |
| 27 | + script: | |
| 28 | + const fs = require('fs'); |
| 29 | + const opts = github.rest.repos.listForOrg.endpoint.merge({ org: context.repo.owner }); |
| 30 | + const repos = await github.paginate(opts); |
| 31 | + const gitlabTarget = (repo) => { |
| 32 | + const prefixNames = { '.': 'dot-', '-': 'dash-', '_': 'underscore-' }; |
| 33 | + const lowerName = repo.name.toLowerCase(); |
| 34 | + let targetName = lowerName.replace( |
| 35 | + /^[._-]+/, |
| 36 | + (prefix) => [...prefix].map((character) => prefixNames[character]).join(''), |
| 37 | + ); |
| 38 | + targetName = targetName |
| 39 | + .replace(/[^a-z0-9_.-]+/g, '-') |
| 40 | + .replace(/[._-]{2,}/g, '-') |
| 41 | + .replace(/[._-]+$/g, ''); |
| 42 | +
|
| 43 | + if (targetName.endsWith('.git') || targetName.endsWith('.atom')) { |
| 44 | + targetName += '-repo'; |
| 45 | + } |
| 46 | + if (!targetName) { |
| 47 | + targetName = 'repository'; |
| 48 | + } |
| 49 | +
|
| 50 | + const transformed = targetName !== lowerName; |
| 51 | + return { |
| 52 | + targetName: transformed ? targetName : repo.name, |
| 53 | + targetPath: transformed ? `${targetName}-${repo.id}` : repo.name, |
| 54 | + }; |
| 55 | + }; |
| 56 | + const repositoryData = repos.map((repo) => ({ |
| 57 | + cloneUrl: repo.clone_url, |
| 58 | + name: repo.name, |
| 59 | + targetVisibility: repo.visibility === 'public' ? 'public' : 'private', |
| 60 | + ...gitlabTarget(repo), |
| 61 | + })); |
| 62 | +
|
| 63 | + fs.writeFileSync('repositories.json', JSON.stringify(repositoryData), { mode: 0o600 }); |
| 64 | + core.info(`Prepared ${repositoryData.length} repositories for mirroring.`); |
| 65 | +
|
| 66 | + - name: Mirror repositories |
| 67 | + shell: bash |
| 68 | + env: |
| 69 | + GIT_TERMINAL_PROMPT: '0' |
| 70 | + GITHUB_TOKEN: ${{ secrets.GH_BOT_TOKEN }} |
| 71 | + GITLAB_API_URL: https://gitlab.com/api/v4 |
| 72 | + GITLAB_GROUP: lizardbyte |
| 73 | + GITLAB_TOKEN: ${{ secrets.GITLAB_TOKEN }} |
| 74 | + run: | |
| 75 | + set -euo pipefail |
| 76 | +
|
| 77 | + if [[ -z "${GITHUB_TOKEN}" ]] || [[ -z "${GITLAB_TOKEN}" ]]; then |
| 78 | + echo "::error::GH_BOT_TOKEN and GITLAB_TOKEN must both be configured." |
| 79 | + exit 1 |
| 80 | + fi |
| 81 | +
|
| 82 | + repository_file="${GITHUB_WORKSPACE}/repositories.json" |
| 83 | + response_file="$(mktemp)" |
| 84 | + temp_root="$(mktemp -d)" |
| 85 | + trap 'rm -f "${response_file}" "${repository_file}"; rm -rf "${temp_root}"' EXIT |
| 86 | +
|
| 87 | + gitlab_request() { |
| 88 | + local method="$1" |
| 89 | + local url="$2" |
| 90 | + local data="${3:-}" |
| 91 | + local curl_args=( |
| 92 | + --silent |
| 93 | + --output "${response_file}" |
| 94 | + --write-out '%{http_code}' |
| 95 | + --request "${method}" |
| 96 | + --header "Accept: application/json" |
| 97 | + --header "PRIVATE-TOKEN: ${GITLAB_TOKEN}" |
| 98 | + ) |
| 99 | +
|
| 100 | + if [[ -n "${data}" ]]; then |
| 101 | + curl_args+=( |
| 102 | + --header "Content-Type: application/json" |
| 103 | + --data "${data}" |
| 104 | + ) |
| 105 | + fi |
| 106 | +
|
| 107 | + curl "${curl_args[@]}" "${url}" |
| 108 | + } |
| 109 | +
|
| 110 | + encoded_group="$(jq -rn --arg value "${GITLAB_GROUP}" '$value | @uri')" |
| 111 | + if ! status="$(gitlab_request GET "${GITLAB_API_URL}/groups/${encoded_group}")"; then |
| 112 | + echo "::error::Unable to query the GitLab group." |
| 113 | + exit 1 |
| 114 | + fi |
| 115 | + if [[ "${status}" != "200" ]]; then |
| 116 | + echo "::error::Unable to query the GitLab group (HTTP ${status})." |
| 117 | + exit 1 |
| 118 | + fi |
| 119 | + group_id="$(jq -er '.id' "${response_file}")" |
| 120 | +
|
| 121 | + github_auth="$(printf 'x-access-token:%s' "${GITHUB_TOKEN}" | base64 --wrap=0)" |
| 122 | + gitlab_auth="$(printf 'oauth2:%s' "${GITLAB_TOKEN}" | base64 --wrap=0)" |
| 123 | + echo "::add-mask::${github_auth}" |
| 124 | + echo "::add-mask::${gitlab_auth}" |
| 125 | +
|
| 126 | + repository_count="$(jq -er 'length' "${repository_file}")" |
| 127 | + for ((index = 0; index < repository_count; index++)); do |
| 128 | + repository="$(jq -ec ".[${index}]" "${repository_file}")" |
| 129 | + source_name="$(jq -er '.name' <<< "${repository}")" |
| 130 | + source_clone_url="$(jq -er '.cloneUrl' <<< "${repository}")" |
| 131 | + target_name="$(jq -er '.targetName' <<< "${repository}")" |
| 132 | + target_path="$(jq -er '.targetPath' <<< "${repository}")" |
| 133 | + target_visibility="$(jq -er '.targetVisibility' <<< "${repository}")" |
| 134 | + description="Mirror of ${source_clone_url}" |
| 135 | +
|
| 136 | + if [[ "${target_visibility}" == "private" ]]; then |
| 137 | + echo "::add-mask::${source_name}" |
| 138 | + echo "::add-mask::${source_clone_url}" |
| 139 | + echo "::add-mask::${target_name}" |
| 140 | + echo "::add-mask::${target_path}" |
| 141 | + echo "::add-mask::${description}" |
| 142 | + fi |
| 143 | +
|
| 144 | + echo "Mirroring repository $((index + 1)) of ${repository_count}." |
| 145 | + project_path="${GITLAB_GROUP}/${target_path}" |
| 146 | + encoded_project_path="$(jq -rn --arg value "${project_path}" '$value | @uri')" |
| 147 | + if [[ "${target_visibility}" == "private" ]]; then |
| 148 | + echo "::add-mask::${project_path}" |
| 149 | + echo "::add-mask::${encoded_project_path}" |
| 150 | + fi |
| 151 | +
|
| 152 | + if ! status="$(gitlab_request GET "${GITLAB_API_URL}/projects/${encoded_project_path}")"; then |
| 153 | + echo "::error::Unable to query the GitLab project for repository $((index + 1))." |
| 154 | + exit 1 |
| 155 | + fi |
| 156 | +
|
| 157 | + if [[ "${status}" == "404" ]]; then |
| 158 | + payload="$( |
| 159 | + jq -nc \ |
| 160 | + --arg name "${target_name}" \ |
| 161 | + --arg path "${target_path}" \ |
| 162 | + --argjson namespace_id "${group_id}" \ |
| 163 | + --arg visibility "${target_visibility}" \ |
| 164 | + '{ |
| 165 | + name: $name, |
| 166 | + path: $path, |
| 167 | + namespace_id: $namespace_id, |
| 168 | + visibility: $visibility, |
| 169 | + initialize_with_readme: false |
| 170 | + }' |
| 171 | + )" |
| 172 | + if ! status="$(gitlab_request POST "${GITLAB_API_URL}/projects" "${payload}")"; then |
| 173 | + echo "::error::Unable to create the GitLab project for repository $((index + 1))." |
| 174 | + exit 1 |
| 175 | + fi |
| 176 | + if [[ "${status}" != "201" ]]; then |
| 177 | + echo "::error::Unable to create the GitLab project for repository $((index + 1)) (HTTP ${status})." |
| 178 | + exit 1 |
| 179 | + fi |
| 180 | + elif [[ "${status}" != "200" ]]; then |
| 181 | + echo "::error::Unable to query the GitLab project for repository $((index + 1)) (HTTP ${status})." |
| 182 | + exit 1 |
| 183 | + fi |
| 184 | +
|
| 185 | + project_id="$(jq -er '.id' "${response_file}")" |
| 186 | +
|
| 187 | + # Make a private source private before changing metadata or pushing any Git data. |
| 188 | + if [[ "${target_visibility}" == "private" ]]; then |
| 189 | + privacy_payload="$(jq -nc '{visibility: "private"}')" |
| 190 | + if ! status="$( |
| 191 | + gitlab_request PUT "${GITLAB_API_URL}/projects/${project_id}" "${privacy_payload}" |
| 192 | + )"; then |
| 193 | + echo "::error::Unable to secure the GitLab project for repository $((index + 1))." |
| 194 | + exit 1 |
| 195 | + fi |
| 196 | + if [[ "${status}" != "200" ]] || \ |
| 197 | + [[ "$(jq -er '.visibility' "${response_file}")" != "private" ]]; then |
| 198 | + echo "::error::GitLab privacy verification failed for repository $((index + 1))." |
| 199 | + exit 1 |
| 200 | + fi |
| 201 | + fi |
| 202 | +
|
| 203 | + payload="$( |
| 204 | + jq -nc \ |
| 205 | + --arg description "${description}" \ |
| 206 | + --arg visibility "${target_visibility}" \ |
| 207 | + '{description: $description, visibility: $visibility}' |
| 208 | + )" |
| 209 | + if ! status="$( |
| 210 | + gitlab_request PUT "${GITLAB_API_URL}/projects/${project_id}" "${payload}" |
| 211 | + )"; then |
| 212 | + echo "::error::Unable to update the GitLab project for repository $((index + 1))." |
| 213 | + exit 1 |
| 214 | + fi |
| 215 | + if [[ "${status}" != "200" ]]; then |
| 216 | + echo "::error::Unable to update the GitLab project for repository $((index + 1)) (HTTP ${status})." |
| 217 | + exit 1 |
| 218 | + fi |
| 219 | +
|
| 220 | + actual_description="$(jq -er '.description // ""' "${response_file}")" |
| 221 | + actual_visibility="$(jq -er '.visibility' "${response_file}")" |
| 222 | + if [[ "${actual_description}" != "${description}" ]]; then |
| 223 | + echo "::error::GitLab description verification failed for repository $((index + 1))." |
| 224 | + exit 1 |
| 225 | + fi |
| 226 | + if [[ "${actual_visibility}" != "${target_visibility}" ]]; then |
| 227 | + echo "::error::GitLab visibility verification failed for repository $((index + 1))." |
| 228 | + exit 1 |
| 229 | + fi |
| 230 | +
|
| 231 | + target_clone_url="$(jq -er '.http_url_to_repo' "${response_file}")" |
| 232 | + if [[ "${target_visibility}" == "private" ]]; then |
| 233 | + echo "::add-mask::${target_clone_url}" |
| 234 | + fi |
| 235 | +
|
| 236 | + mirror_dir="${temp_root}/repository.git" |
| 237 | + if ! git \ |
| 238 | + -c http.https://github.com/.extraheader="AUTHORIZATION: basic ${github_auth}" \ |
| 239 | + clone --mirror --quiet "${source_clone_url}" "${mirror_dir}"; then |
| 240 | + echo "::error::Unable to clone GitHub repository $((index + 1))." |
| 241 | + exit 1 |
| 242 | + fi |
| 243 | + if ! git \ |
| 244 | + -C "${mirror_dir}" \ |
| 245 | + -c http.https://gitlab.com/.extraheader="AUTHORIZATION: basic ${gitlab_auth}" \ |
| 246 | + push --mirror --quiet "${target_clone_url}"; then |
| 247 | + echo "::error::Unable to push GitLab mirror $((index + 1))." |
| 248 | + exit 1 |
| 249 | + fi |
| 250 | + rm -rf "${mirror_dir}" |
| 251 | + done |
| 252 | +
|
| 253 | + echo "Mirrored ${repository_count} repositories to GitLab." |
0 commit comments