# Letting Claude Code push its own branch to GitHub

Since part 2 of this series I run two Claude Code containers: one for brownfield projects (existing codebases with a long history), one for new projects. The previous post was about the brownfield container, where Claude only gets a history-free copy of a repo with old credentials in its history, and I fetch its commits from that copy through a fetch-only remote.

The container for new projects is a different story. The repos in it, like the one behind the blog you're reading, are new, they live on GitHub, and there's nothing in their history to hide. So there's no copy: Claude works directly in the original project, the one I have open myself. The only thing it couldn't do was push. In part 1 that was deliberate:

```dockerfile
# No remote credentials (SSH key / PAT) are configured anywhere in this
# image, so `git push` / `git pull` against a private remote will fail at
# auth: only local commits are possible.
```

The cost: every time Claude finished something, I had to push its branch to GitHub myself. Now Claude does that too.

## The workflow now

1. Claude creates a feature branch off master, does the work and commits.
2. Claude pushes that branch to GitHub.
3. From there it's a normal pull request, and I review and approve it.

No cherry-picking here, unlike the brownfield container: the branch already starts from master, so it can go straight into a PR. Claude publishing a branch costs me nothing. What reaches master still goes through my PR approval.

## The setup

Three things: the GitHub CLI in the image, git using it for credentials, and a token that stays out of the image.

**1. The GitHub CLI**, in the Dockerfile:

```dockerfile
# --- GitHub CLI (gh) ---
RUN mkdir -p -m 755 /etc/apt/keyrings \
    && curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
        -o /etc/apt/keyrings/githubcli-archive-keyring.gpg \
    && chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \
    && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
        > /etc/apt/sources.list.d/github-cli.list \
    && apt-get update \
    && apt-get install -y --no-install-recommends gh \
    && rm -rf /var/lib/apt/lists/*
```

**2. git using `gh` for GitHub credentials.** Two extra lines in the `git config` step from part 1, which runs as the non-root user:

```dockerfile
RUN git config --global --add safe.directory /workspace \
    && git config --global user.name "$GIT_USER_NAME" \
    && git config --global user.email "$GIT_USER_EMAIL" \
    && git config --global credential.https://github.com.helper '' \
    && git config --global --add credential.https://github.com.helper '!/usr/bin/gh auth git-credential' \
    && dotnet nuget locals all --clear
```

The empty helper line clears any other credential helper for GitHub, so git only asks `gh`. Both lines only apply to `https://github.com`, so nothing changes for other remotes.

**3. A token, outside the image.** Anything in a Dockerfile ends up in the image layers, so the token doesn't go there. It goes in a gitignored env file on the mounted folder, which every shell in the container already loads:

```bash
export GH_TOKEN=github_pat_...
```

`gh` reads `GH_TOKEN` by itself, so there's no `gh auth login` step. And because the file lives on the mounted folder, not in the image, the token survives a rebuild of the container.

## The token

It's a fine-grained personal access token with access to only the repos Claude works on, and only one permission: **Contents: Read and write**. That's enough to push branches. It can't change repo settings, and it can't touch my other repos.

What a fine-grained token can't do is limit which branches it pushes to. A branch rule on GitHub closes that gap. On my repos, and especially the public ones, master requires a pull request that I have to approve. Claude can publish branches, but only my approval gets anything into master.

## Checking it works

After a rebuild:

```bash
gh auth status                  # logged in via GH_TOKEN
git push --dry-run origin HEAD  # authenticates, pushes nothing
```

If the dry run ends in a line like `* [new branch] HEAD -> my-branch` without asking for a username, the whole chain works: git asks `gh`, and `gh` uses the token.

## Why not do this in the brownfield container too?

Because access that allows pushing also allows fetching. For the brownfield repo in the previous post, one `git fetch` would pull the real history, old credentials included, into the container. There, commits only move one way, through a fetch-only remote. Pushing only becomes an option once that history is purged with `git filter-repo` from part 3 and the credentials are rotated.

---

*Co-authored with Claude.*
