name: Mirror
on:
push:
workflow_dispatch:
inputs:
dry_run:
description: '只演练:校验凭据与分叉,列出将要推送/删除的 ref 但不改动远端'
type: boolean
default: false
permissions:
contents: read
concurrency:
group: mirror
cancel-in-progress: true
jobs:
mirror:
runs-on: ubuntu-24.04
timeout-minutes: 15
env:
MIRROR_OWNER: continew
GIT_TERMINAL_PROMPT: '0'
MIRROR_STRICT: ${{ vars.MIRROR_STRICT }}
steps:
- name: 克隆 GitHub 侧仓库
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
# 用 --bare 不用 --mirror:后者会把 refs/pull/*、refs/remotes/*、notes 也拉下来推过去
git clone --bare \
"https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" repo.git
- name: 过滤镜像分支
run: |
set -euo pipefail
# refspec 不支持 [0-9] 字符类;逐分支写死 refspec 时,仓库缺某个分支会让整次 push 失败,
# 所以白名单在 shell 里判定,推送用全量 glob。
while read -r b; do
case "$b" in
main|dev|[0-9]*.x) ;;
*) git -C repo.git update-ref -d "refs/heads/$b"; echo " 不镜像分支: $b" ;;
esac
done < <(git -C repo.git for-each-ref --format='%(refname:short)' refs/heads/)
- name: 镜像到 Gitee 与 AtomGit
env:
GITEE_USER: ${{ secrets.GITEE_USER }}
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
ATOMGIT_USER: ${{ secrets.ATOMGIT_USER }}
ATOMGIT_TOKEN: ${{ secrets.ATOMGIT_TOKEN }}
DRY_RUN: ${{ inputs.dry_run || 'false' }}
run: |
set -euo pipefail
DRY=""
if [ "$DRY_RUN" = "true" ]; then
DRY="--dry-run"
echo "::notice::dry-run 模式:本次不会改动镜像平台"
fi
askpass="$(mktemp)"
cat > "$askpass" <<'EOF'
case "$1" in
*Username*) printf '%s\n' "$MIRROR_USER" ;;
*) printf '%s\n' "$MIRROR_TOKEN" ;;
esac
EOF
chmod 700 "$askpass"
export GIT_ASKPASS="$askpass"
sync() {
local label="$1" user="${2:-}" token="${3:-}" host="$4"
if [ -z "$token" ]; then
echo "::warning::未配置 ${label} 凭据,本平台不会同步"
return 0
fi
local remote="https://${host}/${MIRROR_OWNER}/${GITHUB_REPOSITORY#*/}.git"
export MIRROR_USER="$user" MIRROR_TOKEN="$token"
local remote_refs
remote_refs="$(git ls-remote --symref "$remote" HEAD 'refs/heads/*' 'refs/tags/*' 2>/dev/null)" || {
echo "::error::${label} 不可达(网络、仓库不存在或权限不足)"
return 1
}
local default_branch remote_heads remote_tags
default_branch="$(awk '/^ref:/{sub("refs/heads/", "", $2); print $2}' <<<"$remote_refs")"
remote_heads="$(awk '$1 != "ref:" && $2 ~ /^refs\/heads\//{print $1" "$2}' <<<"$remote_refs")"
remote_tags="$(awk '$1 != "ref:" && $2 ~ /^refs\/tags\// && $2 !~ /\^\{\}$/{print $1" "$2}' <<<"$remote_refs")"
local diverged="" sha ref branch
while read -r sha ref; do
[ -n "$ref" ] || continue
branch="${ref#refs/heads/}"
git -C repo.git rev-parse --verify --quiet "refs/heads/${branch}" >/dev/null || continue
if ! git -C repo.git cat-file -e "${sha}^{commit}" 2>/dev/null \
|| ! git -C repo.git merge-base --is-ancestor "$sha" "refs/heads/${branch}"; then
diverged="${diverged} ${branch}=${sha}"
fi
done <<<"$remote_heads"
local diverged_tags="" tag local_sha
while read -r sha ref; do
[ -n "$ref" ] || continue
tag="${ref#refs/tags/}"
local_sha="$(git -C repo.git rev-parse --verify --quiet "refs/tags/${tag}")" || continue
[ "$local_sha" = "$sha" ] || diverged_tags="${diverged_tags} ${tag}=${sha}"
done <<<"$remote_tags"
if [ -n "${diverged}${diverged_tags}" ]; then
echo "::warning::${label} 上这些 ref 含 GitHub 没有的提交,多为 GitHub 侧强推改写历史"
local item name mirror_sha
for item in $diverged; do
name="${item%%=*}" mirror_sha="${item#*=}"
echo " 分支 ${name}:镜像侧 ${mirror_sha:0:9},将重置为 GitHub 版本"
if git -C repo.git cat-file -e "${mirror_sha}^{commit}" 2>/dev/null; then
git -C repo.git log --oneline -n 10 "refs/heads/${name}..${mirror_sha}" 2>/dev/null || true
else
echo " (提交对象不在本地克隆中,无法列出被丢弃的提交)"
fi
done
for item in $diverged_tags; do
name="${item%%=*}" mirror_sha="${item#*=}"
echo " 标签 ${name}:镜像侧 ${mirror_sha:0:9},将重置为 GitHub 版本"
done
if [ "${MIRROR_STRICT:-false}" = "true" ]; then
echo "::error::${label} 检测到分叉,MIRROR_STRICT=true 已中止镜像以免覆盖"
return 1
fi
echo "将以 GitHub 为准覆盖上述 ref;如需保留镜像侧提交,设 MIRROR_STRICT=true 后重跑"
fi
if [ -n "$default_branch" ]; then
if git -C repo.git rev-parse --verify --quiet "refs/heads/${default_branch}" >/dev/null; then
:
elif grep -q "refs/heads/${default_branch}$" <<<"$remote_heads"; then
echo "::error::${label} 的默认分支 ${default_branch} 不在镜像白名单内;--prune 会删除它但平台禁止"
echo "请先在 ${label} 把默认分支改为 dev/main 后重跑本工作流。"
return 1
else
echo "::warning::${label} 的默认分支 ${default_branch} 在镜像侧不存在,推送后请在平台确认默认分支"
fi
elif [ -n "$remote_heads" ]; then
echo "::warning::${label} 的 HEAD 未指向任何分支,推送后请在平台确认默认分支"
fi
local attempt out deleted
for attempt in 1 2 3; do
if out="$(git -C repo.git push --prune $DRY "$remote" \
'+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*' 2>&1)"; then
printf '%s\n' "$out"
if [ -n "$DRY" ]; then
echo "🧪 ${label} 演练完成,未改动远端"
else
deleted="$(grep -c '\[deleted\]' <<<"$out" || true)"
if [ "${deleted:-0}" -gt 0 ]; then
echo "✅ ${label} 已同步(删除 ${deleted} 个 ref)"
else
echo "✅ ${label} 已同步"
fi
fi
return 0
fi
printf '%s\n' "$out"
if grep -qiE 'authentication failed|permission denied|could not read (username|password)|error: 40[13]|HTTP 40[13]' <<<"$out"; then
echo "::error::${label} 认证失败:检查 token 是否过期或权限不足"
return 1
fi
if grep -qiE 'remote rejected|non-fast-forward|protect' <<<"$out"; then
echo "::error::${label} 推送被平台拒绝(分支保护或非快进),已停止重试"
return 1
fi
if [ "$attempt" -lt 3 ]; then
echo "::warning::${label} 第 ${attempt} 次推送失败,$((attempt * 5)) 秒后重试"
sleep "$((attempt * 5))"
fi
done
echo "::error::${label} 重试三次后仍推送失败"
return 1
}
rc=0
sync Gitee "${GITEE_USER:-}" "${GITEE_TOKEN:-}" gitee.com || rc=1
sync AtomGit "${ATOMGIT_USER:-}" "${ATOMGIT_TOKEN:-}" atomgit.com || rc=1
exit "$rc"