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
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
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
Fix
Unset the header before pushing:
git config --local --unset-all "http.https://github.com/.extraheader"
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
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
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
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)
Scopes:
- repo
- workflow
2. Allow PAT Access
Organization Settings
→ Personal Access Tokens
Ensure PAT access is allowed.
3. Add the Secret
Private Repo
→ Settings
→ Environments
→ production
→ Add Secret
Name:
ARCEON_PAT
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)
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
is safe here as long as nobody commits directly to the mirror repository.
Which they shouldn't.
All Checks Passed <3
happy noises 🦅🦅🦅
TL;DR Checklist
- ✅ Unset the default auth header before pushing to another repository
- ✅ PAT needs
repo+workflowscopes - ✅ 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)