What Leaks Through the Pipe That Publishes
A private-to-public sync excluded every file it was supposed to exclude and still published the committer's identity, the commit bodies, and a sentence naming the directory it had just hidden.
- Author
- Aaron Smith
- Reading time
- 8 min
NOREPLY="$(gh api user --jq '"\(.id)+\(.login)@users.noreply.github.com"')"
git config user.name "$(gh api user --jq '.login')"
git config user.email "${NOREPLY}"
Those three lines went into scripts/sync-public.sh a couple of weeks ago, in a commit whose subject reads "Commit sync as GitHub noreply identity to avoid publishing private email." The script mirrors a private repository to a public one. Without those lines it commits under whatever user.email the operator's global git config happens to hold, and pushes that address into a public history that nobody rewrites.
The exclusion list the same script reads is 36 lines long and contains 17 path patterns. Not one of them could have stopped this, because the thing being published is not a file.
The exclusion list names files, and only files
The mechanism is .gitignore.public, converted at run time into an rsync exclude file. The script appends five more entries to it before the copy runs: .gitignore.public itself, .github/, .env, and two agent-configuration paths. Twenty-two patterns in total, every one of them a path.
Judged on its own terms it works. The private tree carries docs/support_manual/ and docs/security-decisions.md, both listed under "Private content" in .gitignore.public. Asking the public repository for that directory returns HTTP 404: gh api repos/<owner>/<public-mirror>/contents/docs has no docs to return. The filter did exactly what it was written to do.
Then read the commit that created the support manual.
The commit that announced its own exclusion
One private commit from the winter arrived on the public repository with a byte-identical message. Two of its six bullet points read:
- Add comprehensive support manual (architecture, deployment, troubleshooting, monitoring/runbooks) in docs/support_manual/
- Exclude docs/support_manual/ from public repo
The directory was withheld. The sentence describing the directory, its contents, its path, and the fact that it was deliberately withheld went out over the same push. A reader of the public repository cannot open the runbooks, and does not need to in order to know they exist, what four subjects they cover, and where they live.
This was not a one-off. Of the 18 commits on the public repository's main branch, 12 were authored by github-actions[bot], and 11 of those 12 carry a multi-line body. The retired workflow set COMMIT_MSG: ${{ github.event.head_commit.message || 'Sync from private repo' }}, and the message field of that event is the whole message, subject and body together. One of the 11 states in the public record that "the deployer IAM user lacks cloudwatch:PutMetricAlarm" and that the account's Lambda concurrency limit is too low for reservations. That is a named identity, a named missing permission, and a service limit the account had not raised at the time.
The bodies were never inspected because nobody thought of them as output. They were thought of as workflow plumbing, which is what they look like right up until they render on github.com.
Two characters of difference
The replacement script builds its message from the private repository too, but not the same part of it:
# before (workflow): the whole message, subject and body
COMMIT_MSG: ${{ github.event.head_commit.message }}
# after (script): subject line only
COMMIT_MSG="$(git -C "${PRIVATE_DIR}" log -1 --pretty=%s)"
%B is the raw body; %s is the subject alone. The change closes the channel entirely rather than filtering it, which matters here because the class of content that channel carries is unbounded. Of the 61 commits on the private main branch, 56 carry a Co-Authored-By trailer and one carries a session trailer whose value is a URL into a full transcript of the working session that produced the change. A subject line cannot carry a URL by accident; a body can carry anything a developer typed while thinking.
The identity is chosen by whoever runs the script
The third channel is the one the opening lines close, and it is the one that surprised me, because retiring the automation is what opened it. The GitHub Actions workflow set user.email to github-actions[bot]@users.noreply.github.com on line 46. Running as a bot is why 12 of those 18 public commits are attributable to nobody in particular. Moving the same job onto a laptop removed the stored credential and, in the same move, handed authorship to whatever the local git config said.
GitHub does offer a backstop. With "Block command line pushes that expose my email" enabled, a push carrying a private address is rejected with remote: error: GH007: Your push would publish a private email address. Its documented scope is narrower than it sounds: GitHub states that on each push it checks "the most recent commit," and blocks if that commit's author email is private on the account. One commit per push, regardless of how many the push contains. For a mirror that publishes exactly one synthetic commit per run, that is full coverage. For a push of forty commits it is 1 in 40, and the coverage you get is an accident of your batch size rather than a property of the control.
Path-shaped boundaries
Three channels, one shape. A publication boundary written entirely as path patterns leaves every channel that is not a path outside it by construction. It happens because the tool everyone reaches for is .gitignore, and .gitignore can only express paths, so the review that follows inherits the tool's vocabulary and asks only "which files ship." Author, email, message body, tag names, ref names and push timestamps are all outside that question.
The cheapest fix is not a longer exclusion list. It is cloning the published repository and reading git log --format='%an <%ae>%n%B' from end to end, because that reads the artifact rather than the intent.
The pipeline that signs its own work
The bookkeeping is a channel too. The oldest commit on the public repository is titled "Sync from private repo" because the first three versions of the workflow hardcoded that message: git commit -m "Sync from private repo" --allow-empty, then git push public main --force over whatever was there. The expression that copied the private subject through, ${{ github.event.head_commit.message || 'Sync from private repo' }}, arrived about three minutes after that first commit was published. What reads like a fallback firing is the blunter earlier design showing through.
The public repository now also carries a git tag named last-sync, force-moved to the tip on every successful run by the new script so the next run can detect commits it did not produce. Both are visible to anyone with a browser, and together they say that this repository is downstream of something else, that the something else is private, and roughly how often it publishes.
I am not arguing that tag should go. It is load-bearing, and the alternative I considered first was worse: comparing content between the two trees fires on every sync, because the private side is legitimately ahead every time. The point here is narrower. The tag was designed as an input to the pipeline's own logic and it is also, unavoidably, an output to the public.
Why nobody reviewed the pipe
None of this was reviewed for five months, and the reason is that the pipeline stopped running without stopping visibly. All times are from the GitHub Actions run history on the private repository and the commit history on the public one.
| Offset | Event | What was observable |
|---|---|---|
| T-0 | A workflow run publishes to the mirror | Green. Last workflow sync that ever landed |
| T+141 days | A worker fix merges directly on the public repo | Public repo is now ahead of private |
| T+148 days | First pushes to main since T-0; two runs fail |
remote: Invalid username or token at "Commit and push" |
| T+148 days, +13 min | Third run reports success | It printed "No changes to sync" and exited before the push |
| T+148 days, +54 min | Workflow retired, its publish token deleted | — |
The gap between rows one and three is 148 days in which the credential expired and nothing recorded it, because the workflow triggers on push to main and nothing was pushed. Its only other trigger was a manual dispatch nobody used, so there was no failed run to notice. The interesting row is the fourth: a green run thirteen minutes after two red ones, on the same expired token, green precisely because the public repository was already ahead and rsync found nothing to change. A token issued with an expiry fails on a date somebody picked when they created it, which makes expiry a scheduled event rather than a risk. What is not scheduled is anyone finding out.
The strongest objection
The strongest objection is that none of these three disclosures is a vulnerability. A GitHub noreply address is public by design and derived from a numeric account ID that anyone can look up. A Co-Authored-By trailer naming a model is not a secret. That a private counterpart exists at all is inferable from the name of the public one. Treating a commit body as a data-protection surface is how a team ends up with a review queue in front of git push, and the predictable result is that people stop publishing rather than wait.
Most of that is right for this repository, and I would not have written a control for it in isolation. What survives the objection is that severity is a property of the content, not of the channel, and the channel is uninspected either way. The same pipe, in a repository whose commit bodies carry a customer name, an internal hostname, or a ticket URL behind single sign-on, publishes those with exactly the same number of review steps: zero.
The other half of the answer is cost. Closing the body channel was %B to %s. Closing the identity channel was three lines of git config. Neither buys a queue, so the objection's real target — friction — is not what is on offer.
Read the artifact, not the filter
The three lines at the top of this post replaced a default, and so did the other two fixes. Git's default author is the local config. The workflow's default message is the whole message. The event payload's fallback subject announces the private upstream by name. Nobody chose any of that, which is the reason it survived twelve published commits and five months.
The rule I take from this: audit a publication pipeline by reading what it actually published, not by reading its exclusion list. Clone the public repository into a fresh directory and run git log --format='%an <%ae>%n%B' over the entire history, plus git tag -l and git ls-remote --heads. It takes ten minutes. If anything in that output tells you something the file tree does not, your exclusion list was never the boundary. It was the part of the boundary that happened to be shaped like a path.