|
# Mirrors branch/tag activity and open PRs from the source repo (private) to the target repo (TARGET_ORG/TARGET_REPO_NAME). |
|
# Runs ONLY on SOURCE_OWNER/SOURCE_REPO_NAME (guarded below). |
|
# |
|
# Secrets on SOURCE_OWNER/SOURCE_REPO_NAME: |
|
# MIRROR_TO_ORG_TOKEN — PAT with write on TARGET_ORG/TARGET_REPO_NAME ONLY |
|
# Source reads use the workflow GITHUB_TOKEN (this repo). See private-to-org-repo-mirror-setup.md |
|
|
|
name: Mirror to org (TARGET_REPO_NAME) |
|
|
|
on: |
|
push: |
|
delete: |
|
pull_request: |
|
types: [opened, edited, synchronize, reopened, closed] |
|
workflow_dispatch: |
|
inputs: |
|
backfill_open_prs: |
|
description: Sync all open PRs from source repo to target repo |
|
type: boolean |
|
default: true |
|
|
|
permissions: |
|
contents: read |
|
pull-requests: read |
|
|
|
env: |
|
TARGET_REPO: TARGET_ORG/TARGET_REPO_NAME |
|
SOURCE_REPO: SOURCE_OWNER/SOURCE_REPO_NAME |
|
|
|
jobs: |
|
mirror-push: |
|
if: >- |
|
github.repository == 'SOURCE_OWNER/SOURCE_REPO_NAME' && |
|
github.event_name == 'push' && |
|
!startsWith(github.ref, 'refs/pull/') |
|
runs-on: ubuntu-latest |
|
steps: |
|
- name: Checkout |
|
uses: actions/checkout@v4 |
|
with: |
|
fetch-depth: 0 |
|
persist-credentials: false |
|
|
|
- name: Push ref to target repo |
|
env: |
|
MIRROR_TOKEN: ${{ secrets.MIRROR_TO_ORG_TOKEN }} |
|
run: | |
|
set -euo pipefail |
|
git config --local --unset-all http.https://github.com/.extraheader || true |
|
git remote add org "https://x-access-token:${MIRROR_TOKEN}@github.com/${TARGET_REPO}.git" |
|
|
|
if [[ "${GITHUB_REF}" == refs/heads/* ]]; then |
|
branch="${GITHUB_REF#refs/heads/}" |
|
push_output="$(git push org "HEAD:refs/heads/${branch}" --force 2>&1)" || push_rc=$? |
|
if [ "${push_rc:-0}" -ne 0 ]; then |
|
if echo "${push_output}" | grep -qiE 'workflow.*scope|without.*workflow'; then |
|
echo "::warning::Push blocked for branch ${branch}: PAT needs Workflows permission on TARGET_REPO_NAME" |
|
echo "${push_output}" |
|
exit 0 |
|
fi |
|
echo "${push_output}" |
|
exit 1 |
|
fi |
|
echo "Mirrored branch ${branch}" |
|
elif [[ "${GITHUB_REF}" == refs/tags/* ]]; then |
|
tag="${GITHUB_REF#refs/tags/}" |
|
git push org "HEAD:refs/tags/${tag}" --force |
|
echo "Mirrored tag ${tag}" |
|
else |
|
echo "Skipping unsupported ref: ${GITHUB_REF}" |
|
fi |
|
|
|
mirror-delete: |
|
if: >- |
|
github.repository == 'SOURCE_OWNER/SOURCE_REPO_NAME' && |
|
github.event_name == 'delete' && |
|
github.event.ref_type == 'branch' |
|
runs-on: ubuntu-latest |
|
steps: |
|
- name: Delete branch on target repo |
|
env: |
|
MIRROR_TOKEN: ${{ secrets.MIRROR_TO_ORG_TOKEN }} |
|
run: | |
|
set -euo pipefail |
|
git clone --bare "https://x-access-token:${MIRROR_TOKEN}@github.com/${TARGET_REPO}.git" /tmp/org-mirror |
|
cd /tmp/org-mirror |
|
git push origin ":refs/heads/${{ github.event.ref }}" || echo "Branch already absent on target repo" |
|
|
|
sync-pull-request: |
|
if: >- |
|
github.repository == 'SOURCE_OWNER/SOURCE_REPO_NAME' && |
|
github.event_name == 'pull_request' |
|
runs-on: ubuntu-latest |
|
steps: |
|
- name: Checkout PR head |
|
uses: actions/checkout@v4 |
|
with: |
|
ref: ${{ github.event.pull_request.head.sha }} |
|
fetch-depth: 0 |
|
persist-credentials: false |
|
|
|
- name: Push PR head branch to org |
|
id: push-head |
|
env: |
|
MIRROR_TOKEN: ${{ secrets.MIRROR_TO_ORG_TOKEN }} |
|
run: | |
|
set -euo pipefail |
|
branch="${{ github.event.pull_request.head.ref }}" |
|
|
|
case "${branch}" in |
|
fix/mirror-*|chore/mirror-*) |
|
echo "pushed=false" >> "${GITHUB_OUTPUT}" |
|
echo "skip_reason=infra_branch" >> "${GITHUB_OUTPUT}" |
|
echo "Skip push for infra branch ${branch} (mirror setup only)" |
|
exit 0 |
|
;; |
|
esac |
|
|
|
git config --local --unset-all http.https://github.com/.extraheader || true |
|
git remote add org "https://x-access-token:${MIRROR_TOKEN}@github.com/${TARGET_REPO}.git" |
|
push_output="$(git push org "HEAD:refs/heads/${branch}" --force 2>&1)" || push_rc=$? |
|
if [ "${push_rc:-0}" -ne 0 ]; then |
|
if echo "${push_output}" | grep -qiE 'workflow.*scope|without.*workflow'; then |
|
echo "::warning::Push blocked for PR branch ${branch}: PAT needs Workflows permission on TARGET_REPO_NAME" |
|
echo "${push_output}" |
|
echo "pushed=false" >> "${GITHUB_OUTPUT}" |
|
echo "skip_reason=workflow_scope" >> "${GITHUB_OUTPUT}" |
|
exit 0 |
|
fi |
|
echo "${push_output}" |
|
exit 1 |
|
fi |
|
echo "pushed=true" >> "${GITHUB_OUTPUT}" |
|
|
|
- name: Open or update mirrored PR |
|
if: >- |
|
github.event.action != 'closed' && |
|
steps.push-head.outputs.pushed == 'true' |
|
env: |
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
|
MIRROR_TOKEN: ${{ secrets.MIRROR_TO_ORG_TOKEN }} |
|
run: | |
|
set -euo pipefail |
|
source /dev/stdin <<'MIRROR_HELPERS' |
|
write_mirror_pr_body() { |
|
local source_repo="$1" source_number="$2" source_url="$3" source_author="$4" pr_body="$5" out_file="$6" |
|
{ |
|
echo "Mirrored from [${source_repo}#${source_number}](${source_url})." |
|
echo |
|
echo "**Original author:** @${source_author}" |
|
echo "**Source PR:** ${source_url}" |
|
echo |
|
echo "---" |
|
echo |
|
printf '%s\n' "${pr_body}" |
|
} > "${out_file}" |
|
} |
|
apply_original_author_on_target_pr() { |
|
local target_repo="$1" target_pr_number="$2" source_author="$3" |
|
[ -n "${target_pr_number}" ] && [ -n "${source_author}" ] || return 0 |
|
gh pr edit "${target_pr_number}" --repo "${target_repo}" --add-assignee "${source_author}" 2>/dev/null \ |
|
&& echo "Assigned @${source_author} on target PR #${target_pr_number}" \ |
|
|| echo "::notice::Could not assign @${source_author} (may not be target org member)" |
|
gh pr edit "${target_pr_number}" --repo "${target_repo}" --add-reviewer "${source_author}" 2>/dev/null \ |
|
&& echo "Requested review from @${source_author}" \ |
|
|| echo "::notice::Could not request review from @${source_author}" |
|
} |
|
create_or_update_mirrored_pr() { |
|
local target_repo="$1" branch="$2" base="$3" title="$4" body_file="$5" source_author="$6" |
|
local existing target_pr_number target_pr_url ahead_by |
|
if ! git ls-remote "https://x-access-token:${MIRROR_TOKEN}@github.com/${target_repo}.git" \ |
|
"refs/heads/${branch}" | grep -q .; then |
|
echo "::warning::Skip target PR: refs/heads/${branch} not on ${target_repo} (push may have been blocked)" |
|
return 0 |
|
fi |
|
ahead_by="$(gh api "repos/${target_repo}/compare/${base}...${branch}" --jq '.ahead_by // empty' 2>/dev/null || true)" |
|
if [ "${ahead_by}" = "0" ]; then |
|
echo "::notice::Skip target PR create: no commits between ${base} and ${branch} on ${target_repo}" |
|
return 0 |
|
fi |
|
existing="$(gh pr list --repo "${target_repo}" --head "${branch}" --base "${base}" --state open --json number --jq '.[0].number // empty')" |
|
if [ -n "${existing}" ]; then |
|
gh pr edit "${existing}" --repo "${target_repo}" --title "[mirror] ${title}" --body-file "${body_file}" |
|
target_pr_number="${existing}" |
|
echo "Updated target PR #${existing}" |
|
elif target_pr_url="$(gh pr create --repo "${target_repo}" --head "${branch}" --base "${base}" --title "[mirror] ${title}" --body-file "${body_file}" 2>&1)"; then |
|
target_pr_number="${target_pr_url##*/}" |
|
echo "Created target PR #${target_pr_number}" |
|
else |
|
echo "::warning::Could not create target PR for ${branch} -> ${base}: ${target_pr_url}" |
|
return 0 |
|
fi |
|
apply_original_author_on_target_pr "${target_repo}" "${target_pr_number}" "${source_author}" |
|
} |
|
MIRROR_HELPERS |
|
|
|
branch="${{ github.event.pull_request.head.ref }}" |
|
base="${{ github.event.pull_request.base.ref }}" |
|
source_pr="${{ github.event.pull_request.number }}" |
|
title="${{ github.event.pull_request.title }}" |
|
source_author="${{ github.event.pull_request.user.login }}" |
|
source_url="https://github.com/${{ github.repository }}/pull/${source_pr}" |
|
pr_body="$(gh api "repos/${{ github.repository }}/pulls/${source_pr}" --jq '.body // ""')" |
|
|
|
body_file="$(mktemp)" |
|
write_mirror_pr_body "${{ github.repository }}" "${source_pr}" "${source_url}" "${source_author}" "${pr_body}" "${body_file}" |
|
|
|
export GH_TOKEN="${MIRROR_TOKEN}" |
|
create_or_update_mirrored_pr "${TARGET_REPO}" "${branch}" "${base}" "${title}" "${body_file}" "${source_author}" |
|
|
|
- name: Close mirrored PR when source closes |
|
if: github.event.action == 'closed' |
|
env: |
|
GH_TOKEN: ${{ secrets.MIRROR_TO_ORG_TOKEN }} |
|
run: | |
|
set -euo pipefail |
|
branch="${{ github.event.pull_request.head.ref }}" |
|
base="${{ github.event.pull_request.base.ref }}" |
|
source_url="https://github.com/${{ github.repository }}/pull/${{ github.event.pull_request.number }}" |
|
|
|
existing="$(gh pr list --repo "${TARGET_REPO}" --head "${branch}" --base "${base}" --state open --json number --jq '.[0].number // empty')" |
|
if [ -n "${existing}" ]; then |
|
gh pr close "${existing}" --repo "${TARGET_REPO}" \ |
|
--comment "Mirrored source PR closed: ${source_url}" |
|
echo "Closed target PR #${existing}" |
|
fi |
|
|
|
backfill-open-prs: |
|
if: >- |
|
github.repository == 'SOURCE_OWNER/SOURCE_REPO_NAME' && |
|
github.event_name == 'workflow_dispatch' && |
|
inputs.backfill_open_prs |
|
runs-on: ubuntu-latest |
|
steps: |
|
- name: Verify mirror token can reach org repo |
|
env: |
|
MIRROR_TOKEN: ${{ secrets.MIRROR_TO_ORG_TOKEN }} |
|
run: | |
|
set -euo pipefail |
|
if [ -z "${MIRROR_TOKEN}" ]; then |
|
echo "::error::MIRROR_TO_ORG_TOKEN secret is empty on SOURCE_OWNER/SOURCE_REPO_NAME" |
|
exit 1 |
|
fi |
|
|
|
http_code="$(curl -sS -o /tmp/repo-check.json -w "%{http_code}" \ |
|
-H "Authorization: Bearer ${MIRROR_TOKEN}" \ |
|
-H "Accept: application/vnd.github+json" \ |
|
"https://api.github.com/repos/${TARGET_REPO}")" |
|
|
|
if [ "${http_code}" = "200" ]; then |
|
echo "Mirror token can access ${TARGET_REPO}" |
|
exit 0 |
|
fi |
|
|
|
echo "::error::Mirror token cannot access ${TARGET_REPO} (HTTP ${http_code})" |
|
cat /tmp/repo-check.json || true |
|
echo "" |
|
echo "GitHub often returns 404 ('Repository not found') when the token lacks access." |
|
echo "Fix checklist:" |
|
echo " 1. Fine-grained PAT: Resource owner = TARGET_ORG" |
|
echo " 2. Repository access includes TARGET_REPO_NAME" |
|
echo " 3. Permissions: Contents (R/W), Pull requests (R/W), Workflows (R/W) if mirroring dev" |
|
echo " 4. SSO: Settings → Developer settings → Fine-grained tokens → Configure SSO → Authorize TARGET_ORG" |
|
echo " 5. Token owner must be an org member with access to TARGET_REPO_NAME" |
|
echo " 6. Update secret MIRROR_TO_ORG_TOKEN on SOURCE_OWNER/SOURCE_REPO_NAME (no extra spaces)" |
|
exit 1 |
|
|
|
- name: Backfill open PR branches and mirrored PRs |
|
env: |
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
|
MIRROR_TOKEN: ${{ secrets.MIRROR_TO_ORG_TOKEN }} |
|
run: | |
|
set -uo pipefail |
|
failures=0 |
|
workflow_scope_failures=0 |
|
mirror_dir="$(mktemp -d)" |
|
|
|
echo "Fetching all branches from source (bare mirror)..." |
|
git clone --mirror \ |
|
"https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git" \ |
|
"${mirror_dir}/source.git" |
|
cd "${mirror_dir}/source.git" |
|
target_url="https://x-access-token:${MIRROR_TOKEN}@github.com/${TARGET_REPO}.git" |
|
|
|
while IFS= read -r pr; do |
|
number="$(echo "${pr}" | jq -r '.number')" |
|
branch="$(echo "${pr}" | jq -r '.headRefName')" |
|
base="$(echo "${pr}" | jq -r '.baseRefName')" |
|
title="$(echo "${pr}" | jq -r '.title')" |
|
body="$(echo "${pr}" | jq -r '.body // ""')" |
|
source_author="$(echo "${pr}" | jq -r '.author.login')" |
|
source_url="https://github.com/${{ github.repository }}/pull/${number}" |
|
|
|
case "${branch}" in |
|
fix/mirror-*|chore/mirror-*) |
|
echo "Skip infra PR #${number} (${branch}) — mirror setup only" |
|
continue |
|
;; |
|
esac |
|
|
|
echo "Backfilling PR #${number} (${branch} -> ${base})" |
|
|
|
if ! git show-ref --verify --quiet "refs/heads/${branch}"; then |
|
echo "::warning::Skip PR #${number}: refs/heads/${branch} missing in source mirror" |
|
failures=$((failures + 1)) |
|
continue |
|
fi |
|
|
|
push_output="$(git push "${target_url}" "refs/heads/${branch}:refs/heads/${branch}" --force 2>&1)" || push_rc=$? |
|
if [ "${push_rc:-0}" -ne 0 ]; then |
|
if echo "${push_output}" | grep -qiE 'workflow.*scope|without.*workflow'; then |
|
echo "::warning::Push blocked for PR #${number} (${branch}): PAT needs Workflows permission on TARGET_REPO_NAME" |
|
echo "${push_output}" |
|
workflow_scope_failures=$((workflow_scope_failures + 1)) |
|
continue |
|
fi |
|
echo "::warning::Push failed for PR #${number} (${branch})" |
|
echo "${push_output}" |
|
failures=$((failures + 1)) |
|
continue |
|
fi |
|
unset push_rc |
|
|
|
body_file="$(mktemp)" |
|
{ |
|
echo "Mirrored from [${{ github.repository }}#${number}](${source_url})." |
|
echo |
|
echo "**Original author:** @${source_author}" |
|
echo "**Source PR:** ${source_url}" |
|
echo |
|
echo "---" |
|
echo |
|
printf '%s\n' "${body}" |
|
} > "${body_file}" |
|
|
|
export GH_TOKEN="${MIRROR_TOKEN}" |
|
if ! git ls-remote "${target_url}" "refs/heads/${branch}" | grep -q .; then |
|
echo "::warning::Skip target PR for #${number}: branch ${branch} not on target repo" |
|
failures=$((failures + 1)) |
|
continue |
|
fi |
|
ahead_by="$(gh api "repos/${TARGET_REPO}/compare/${base}...${branch}" --jq '.ahead_by // empty' 2>/dev/null || true)" |
|
if [ "${ahead_by}" = "0" ]; then |
|
echo "::notice::Skip target PR for #${number}: no commits between ${base} and ${branch} on target repo" |
|
continue |
|
fi |
|
existing="$(gh pr list --repo "${TARGET_REPO}" --head "${branch}" --base "${base}" --state open --json number --jq '.[0].number // empty')" |
|
target_pr_number="" |
|
if [ -n "${existing}" ]; then |
|
gh pr edit "${existing}" --repo "${TARGET_REPO}" --title "[mirror] ${title}" --body-file "${body_file}" \ |
|
|| failures=$((failures + 1)) |
|
target_pr_number="${existing}" |
|
elif target_pr_url="$(gh pr create --repo "${TARGET_REPO}" --head "${branch}" --base "${base}" --title "[mirror] ${title}" --body-file "${body_file}" 2>&1)"; then |
|
target_pr_number="${target_pr_url##*/}" |
|
else |
|
echo "::warning::Could not create target PR for #${number}: ${target_pr_url}" |
|
failures=$((failures + 1)) |
|
fi |
|
if [ -n "${target_pr_number}" ]; then |
|
gh pr edit "${target_pr_number}" --repo "${TARGET_REPO}" --add-assignee "${source_author}" 2>/dev/null \ |
|
&& echo "Assigned @${source_author} on target PR #${target_pr_number}" \ |
|
|| echo "::notice::Could not assign @${source_author} on PR #${target_pr_number}" |
|
gh pr edit "${target_pr_number}" --repo "${TARGET_REPO}" --add-reviewer "${source_author}" 2>/dev/null \ |
|
&& echo "Requested review from @${source_author}" \ |
|
|| echo "::notice::Could not request review from @${source_author}" |
|
fi |
|
done < <(gh pr list --repo "${{ github.repository }}" --state open \ |
|
--json number,headRefName,baseRefName,title,body,author | jq -c '.[]') |
|
|
|
cd / |
|
rm -rf "${mirror_dir}" |
|
|
|
if [ "${failures}" -gt 0 ]; then |
|
echo "::warning::Backfill completed with ${failures} failure(s)" |
|
exit 1 |
|
fi |
|
if [ "${workflow_scope_failures}" -gt 0 ]; then |
|
echo "::notice::Product PRs mirrored. ${workflow_scope_failures} branch(es) skipped — add Workflows: Read and write to PAT (see .github/SETUP-GUIDE.md)" |
|
fi |