Skip to content

Instantly share code, notes, and snippets.

@iCTX0780
Created July 5, 2026 14:45
Show Gist options
  • Select an option

  • Save iCTX0780/c5e90d207f087ef25fb2d7cca9ec7179 to your computer and use it in GitHub Desktop.

Select an option

Save iCTX0780/c5e90d207f087ef25fb2d7cca9ec7179 to your computer and use it in GitHub Desktop.
Private to org GitHub repo mirror
# 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

Private → org repo mirror — placeholders

Replace these in mirror-to-org.template.yml and private-to-org-repo-mirror-setup.md. Legacy/canonical apply only after cutover — see private-to-org-repo-mirror.md.

Mirror phase

Placeholder Fictional value
TARGET_ORG acme-corp
TARGET_REPO acme-corp/widget-app
TARGET_REPO_NAME widget-app
SOURCE_OWNER your-user
SOURCE_REPO_NAME widget-app-private
SOURCE_REPO your-user/widget-app-private
MIRROR_TOKEN_SECRET MIRROR_TO_ORG_TOKEN

Cutover (after mirroring disabled)

Role Repo Git remote
Canonical acme-corp/widget-app origin
Legacy your-user/widget-app-private legacy

Private → org repo mirror — setup

Replace placeholders: TARGET_ORG, TARGET_REPO, TARGET_REPO_NAME, SOURCE_OWNER, SOURCE_REPO_NAME, SOURCE_REPO, MIRROR_TOKEN_SECRET.

Language during mirroring: source (private) and target (org). Do not label repos legacy/canonical until cutover.

1. Target repo

Create or use TARGET_REPO. Seed branches from the source repo before backfill:

git remote add target git@github.com:TARGET_REPO.git
git push target main dev

2. Fine-grained PAT (org-scoped)

GitHub → Settings → Developer settings → Fine-grained tokens

Setting Value
Resource owner TARGET_ORG
Repository access TARGET_REPO_NAME only
Contents Read and write
Pull requests Read and write
Workflows Read and write

If the org uses SAML SSO: Configure SSO → Authorize for TARGET_ORG.

Verify:

curl -sS -o /dev/null -w "%{http_code}\n" \
  -H "Authorization: Bearer YOUR_PAT" \
  https://api.github.com/repos/TARGET_REPO
# expect 200

3. Secret on source repo only

SOURCE_REPO → Settings → Secrets → Actions

Secret Value
MIRROR_TOKEN_SECRET PAT from step 2
gh secret set MIRROR_TOKEN_SECRET --repo SOURCE_REPO

4. Install workflow on source repo

On SOURCE_REPO only (not the target repo):

  1. Copy mirror-to-org.template.yml.github/workflows/mirror-to-org.yml
  2. Replace placeholders in env: and every job if: guard
  3. Wire secrets.MIRROR_TOKEN_SECRET in each step

5. Backfill open PRs (one time)

Source repo → Actions → Mirror to org → Run workflow → Backfill open PRs.

Target PRs get title prefix [mirror] and body fields Original author + Source PR.

6. Ongoing sync

Push and PR events on the source repo sync to the target automatically. Branches fix/mirror-* and chore/mirror-* are skipped.

Troubleshooting

Issue Fix
Workflow skipped Run is on source repo, not target
Auth failed on push PAT needs write on target repo; check SSO
Repository not found Wrong PAT scope or SSO not authorized
Workflow scope error Add Workflows: Read and write on PAT
Target PR not created PAT needs pull request write
Target PR author is token owner Expected; body lists original author

Disable mirroring

Remove or disable the workflow on the source repo when cutover is complete. See private-to-org-repo-mirror.md for canonical/legacy remotes.

Private → org GitHub repo mirror

One-way sync from a source repo (private) to a target repo (org) via GitHub Actions. Use this only while mirroring — not after cutover.

Vocabulary (use consistently)

Term When Meaning
Source repo Mirroring Private repo where work happens during transition
Target repo Mirroring Org repo receiving mirrored branches and PRs
Canonical Cutover only Target repo after flip — becomes origin
Legacy Cutover only Source repo after flip — becomes legacy remote, read-only

Do not use legacy / canonical in the mirror workflow or setup steps. Reserve them for the cutover section below.

Files in this gist

File Use
private-to-org-repo-mirror-setup.md Mirror setup checklist
mirror-to-org.template.yml Workflow — install on source repo only
private-to-org-repo-mirror-placeholders.md Fictional values for each placeholder

Placeholders

Placeholder Role Fictional example
TARGET_ORG Org slug acme-corp
TARGET_REPO Target org/repo acme-corp/widget-app
TARGET_REPO_NAME Target repo name widget-app
SOURCE_OWNER Source repo owner your-user
SOURCE_REPO_NAME Source repo name widget-app-private
SOURCE_REPO Source owner/repo your-user/widget-app-private
MIRROR_TOKEN_SECRET Actions secret on source repo MIRROR_TO_ORG_TOKEN

What mirroring syncs

Source repo event Target repo
Push branch Same branch (force push)
Push tag Same tag
Delete branch Branch deleted
PR open/update Head pushed + target PR ([mirror] title)
PR close Target PR closed with source link
Manual dispatch Backfill all open source PRs

Workflow runs only on SOURCE_REPO.

Cutover (stop mirroring)

When the target repo is ready to be the only working repo:

  1. Sync integration branch (e.g. dev) to target
  2. Flip remotes on each clone:
    • Canonicalorigin (target repo)
    • Legacylegacy (source repo, read-only)
  3. Disable mirror workflow on source repo
  4. Archive source repo after a grace period
git remote rename origin legacy
git remote add origin git@github.com:TARGET_ORG/TARGET_REPO_NAME.git
git fetch origin
git branch -u origin/dev dev

Quick install (mirror phase)

# On source repo only
cp mirror-to-org.template.yml .github/workflows/mirror-to-org.yml
# Replace all placeholders (see private-to-org-repo-mirror-setup.md)
gh secret set MIRROR_TO_ORG_TOKEN --repo SOURCE_OWNER/SOURCE_REPO_NAME
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment