DEV Community

Cover image for Syncing a Private GitHub Repo to a Public Org Repo with GitHub Actions (and the auth trap nobody tells you about)
Shreya Dutta
Shreya Dutta

Posted on

Syncing a Private GitHub Repo to a Public Org Repo with GitHub Actions (and the auth trap nobody tells you about)

I recently set up a CI/CD pipeline for TraceHawk — my DevSecOps scanner project under the ArceonSec GitHub org.

The goal was simple: dev happens in a private repo, and when code reaches prod, it automatically syncs to the public org repo.

Simple in theory. Two hours of PAT hell in practice 🙄

Here's everything I learned so you don't have to.


The Setup

Three branches in the private repo:

  • main — active development
  • staging — CI checks run here (Ruff, Docker build, Gitleaks, Semgrep, health check)
  • prod — triggers automatic sync to the public ArceonSec org repo

The public org repo (ArceonSec/tracehawk) only has main and always mirrors prod.

Nobody commits there directly.

Also because I am the only member :')


The Workflow

name: Sync To Public Repository

on:
  push:
    branches:
      - prod

jobs:
  sync-to-public:
    name: Sync Prod To ArceonSec
    runs-on: ubuntu-latest
    timeout-minutes: 10
    environment:
      name: production

    steps:
      - name: Checkout Repository
        uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Configure Git
        run: |
          git config user.name "GitHub Actions"
          git config user.email "actions@github.com"

      - name: Remove Default Auth Header
        run: |
          git config --local --unset-all "http.https://github.com/.extraheader"

      - name: Add Public Repository Remote
        run: |
          git remote add arceon \
          https://x-access-token:${{ secrets.ARCEON_PAT }}@github.com/ArceonSec/tracehawk.git

      - name: Push Prod Branch To Public Main
        run: |
          git push arceon prod:main --force
Enter fullscreen mode Exit fullscreen mode

Looks straightforward.

It wasn't.


Trap #1 — The Auth Header Override

This one got me the hardest.

actions/checkout sets a local Git config header called:

http.https://github.com/.extraheader
Enter fullscreen mode Exit fullscreen mode

with the default GITHUB_TOKEN authorization.

This happens silently in the background.

When you add a second remote and try to push using your PAT, GitHub Actions still uses this default header — effectively ignoring your PAT entirely.

The push fails with:

remote: Permission to ArceonSec/tracehawk.git denied to github-actions[bot].
fatal: unable to access: 403
Enter fullscreen mode Exit fullscreen mode

Fix

Unset the header before pushing:

git config --local --unset-all "http.https://github.com/.extraheader"
Enter fullscreen mode Exit fullscreen mode

Add this before adding the remote.

This was the root cause of most of my pain.


Trap #2 — PAT Scopes

You need exactly two scopes on your Classic PAT:

  • repo — to push to the org repo
  • workflow — because your repository contains .github/workflows/ files

Without workflow, GitHub blocks the push:

refusing to allow a Personal Access Token to create or update workflow
without `workflow` scope
Enter fullscreen mode Exit fullscreen mode

Fix

Add the workflow scope and regenerate the token.


Trap #3 — Org PAT Access

If you're pushing to an organization repository, the org must allow PAT access.

Navigate to:

github.com/organizations/YOUR_ORG/settings/personal-access-tokens
Enter fullscreen mode Exit fullscreen mode

Make sure both:

  • Classic PATs
  • Fine-grained PATs

are allowed.

Some organizations restrict PAT access by default.


Trap #4 — Environment Secrets vs Repository Secrets

I used a production environment block:

environment:
  name: production
Enter fullscreen mode Exit fullscreen mode

This changes where GitHub looks for secrets.

Instead of repository secrets, GitHub now checks environment secrets.

If ARCEON_PAT exists only as a repository secret, the workflow won't find it.

Fix

Either:

  • Add the secret to the environment
  • Remove the environment block

I kept the environment block because it allows deployment protection rules later.


The PAT Setup (Correct Way)

1. Create a Classic PAT

GitHub
→ Settings
→ Developer Settings
→ Personal Access Tokens
→ Tokens (classic)
Enter fullscreen mode Exit fullscreen mode

Scopes:

  • repo
  • workflow

2. Allow PAT Access

Organization Settings
→ Personal Access Tokens
Enter fullscreen mode Exit fullscreen mode

Ensure PAT access is allowed.

3. Add the Secret

Private Repo
→ Settings
→ Environments
→ production
→ Add Secret
Enter fullscreen mode Exit fullscreen mode

Name:

ARCEON_PAT
Enter fullscreen mode Exit fullscreen mode

The Full Branch Flow

main (dev)
    ↓ manual merge when ready

staging
    ↓ CI runs
    ↓ Ruff
    ↓ Docker build
    ↓ Gitleaks
    ↓ Semgrep
    ↓ Health check

prod
    ↓ sync workflow fires

ArceonSec/tracehawk (main)
Enter fullscreen mode Exit fullscreen mode

Dev stays private.

Public repo stays clean and presentable.


Why --force?

Since ArceonSec is a pure mirror, prod should always win.

If histories diverge (for example, someone manually pushes to the public repo), a normal push gets rejected.

git push arceon prod:main --force
Enter fullscreen mode Exit fullscreen mode

is safe here as long as nobody commits directly to the mirror repository.

Which they shouldn't.


All Checks Passed <3

All checks passed <3

happy noises 🦅🦅🦅


TL;DR Checklist

  • ✅ Unset the default auth header before pushing to another repository
  • ✅ PAT needs repo + workflow scopes
  • ✅ Allow PAT access in organization settings
  • ✅ Put the secret in the correct location (environment secret if using environment:)
  • ✅ Never commit directly to the mirror repository

That's it.

Took me two hours to figure out.

Should take you five minutes now :)


Thanks for reading :>

Chat with me on X/Twitter :)

Top comments (0)