Repository navigation
Bypass protected branches with deploy keys #1343
Description
Feature Request
Description
The current suggested method to bypass protected branches is the following:
The GITHUB_TOKEN secret is automatically configured by GitHub, with the same permissions role as the user who triggered the workflow run. This causes a problem if your default branch is protected to specific users.
You can work around this by storing an administrator's Personal Access Token as a separate secret and using that instead of GITHUB_TOKEN. In this case, you will also need to pass the new token to actions/checkout (as the token input) in order to gain push access.
Not everyone would like to use PATs (see reasoning below). After researching this topic intensely lately I stumbled across the following solution: https://github.com/sbellone/release-workflow-example. This repository showcases how one can bypass the branch protection on GitHub via deployment keys.
Use cases
Why PATs are problematic for automation:
- User dependency and automation fragility: PATs are bound to individual user accounts. When a user leaves the organization, changes teams, or has their account disabled, all automations relying on their PAT immediately break. This creates a critical single point of failure where the "function of systems" becomes dependent on individual employment status.
- Security breach exposure: PATs grant access to ALL private repositories that the user account can access, not just the specific repository needed for automation. If a PAT is compromised or leaked, the attack surface is significantly broader than necessary. According to Checkmarx research, compromised PATs have been used in large-scale automated attacks on GitHub accounts.
- Token rotation and lifecycle management complexity: While PATs can have expiration dates, managing rotation at scale becomes tedious and error-prone. Classic PATs historically didn't require expiration, meaning compromised tokens could be misused indefinitely if undetected. Organizations using PATs in CI/CD pipelines face constant maintenance burden to rotate tokens before expiration.
- Auditing and accountability challenges: Actions performed using PATs can be harder to trace, especially when multiple people or systems share the same token. If several team members have access to a shared PAT secret, determining who actually performed an action becomes difficult.
- Requires privileged account creation: To use PATs for branch protection bypass, organizations often need to create "bot" or "service" user accounts with elevated permissions, then manage MFA for these accounts.
- Broad permission scope: Even with fine-grained PATs, the permissions model still grants wider access than necessary for simple deployment workflows. PATs fundamentally operate at the user-account level rather than the repository level.
Why deploy keys are superior:
- Repository-scoped access: Deploy keys grant access to a SINGLE repository only. The SSH key is attached directly to the repository rather than a user account. This implements the principle of least privilege - if a deploy key is compromised, the blast radius is limited to one repository.
- No user dependency: Deploy keys are not linked to individual user accounts or organization membership. If the user who created the deploy key leaves the organization, the key remains functional because it's tied to the repository, not the person. This eliminates automation failure due to personnel changes.
- Simpler rotation with rulesets: With GitHub's ruleset feature (not legacy branch protection), deploy keys can be added to the bypass list directly. The rotation process involves generating a new SSH key pair and updating the secret - no user account management required[web:sbellone].
- No expiration management overhead: Deploy keys don't have built-in expiration dates, eliminating the constant rotation burden. While this could be seen as a security concern, it's offset by the limited scope (single repository only) and the fact that organizations can implement their own rotation policies as needed.
- Clear security boundaries: Since deploy keys are repository-specific, security posture is more transparent. Each repository explicitly declares which keys have access, making audit and review straightforward.
- Read-only by default: Deploy keys are read-only by default and require explicit configuration to grant write access. This fail-safe design prevents accidental write permissions.
Possible implementation
This workflow would allow us to bypass the protected branch as follows:
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
ssh-key: ${{ secrets.DEPLOY_KEY }}Alternative solutions
Continue using PATs
Activity
codejedi365 commentedon Oct 24, 2025
@dxvidparham, if this works, you are a hero. I've been unable to find a solution myself and as someone who cares about sustainment and security this has been frustrating to say the least.
So it seems like the feature for PSR is really just a change in the docs to provide this method as the instructions, right? As long as Git can authenticate, then PSR will work to publish a commit and tag.
dxvidparham commentedon Oct 24, 2025
I tried the example from the repository mentioned above and got it to work, but failed in my own GitHub workflow where I use the python semantic release workflow. And according to this discussion it seems like multiple people succeeded to bypass a protected branch in their own setups by following the workflow with the deploy keys.
My suspicion is that the GITHUB_TOKEN overwrites the deploy key. Could that be why I`m failing?
This is my workflow:
codejedi365 commentedon Oct 24, 2025
I think you need to set the
semantic_release.remote.ignore_token_for_pushtotruebecause of how the push command is generated (why idk and I didn't touch it if it was working). If you set this totrue, then it will use a genericgit pushwhich will rely on the repo git configuration for auth configuration.dxvidparham commentedon Oct 28, 2025
Thanks, that was definitely helpful. But where I'm running into an issue now is this:
If I understand it correctly, then the priority order of the authentication process is:
If we could flip that then I think it would be possible to use a deploy key, but as it stands now the deploy key will be ignored because the GITHUB_TOKEN takes precedence.
codejedi365 commentedon Oct 28, 2025
Are you actually hitting an error or are you anticipating one based on my previously worded (deploy-keys unaware) guide?
Regardless of what the documentation says, I am telling you that the code (in default mode) performs 3 remote connections.
In remote connections 1 & 2, its a git command
git push $ORIGIN_URL [branch | tag]where$ORIGIN_URLis the full remote url to includes the authentication token unlessremote.ignore_token_for_pushis set totrue. Because I told you to setremote.ignore_token_for_push = true, your$ORIGIN_URLdoes not include any authentication so it will rely on.git/configand the associated SSH configuration which you set to be the deploy key.For remote connection 3, the only option is an API token for authentication. You should pass the
GITHUB_TOKENvalue to enable the ability of PSR to make a VCS release via the API. Branch protections do not hinder this action. If you don't pass a token, then this remote connection will fail unless you disable this action with the--no-vcs-releasecli switch.Please try this out and let me know if you receive an error or if it works.
dxvidparham commentedon Oct 29, 2025
Yes, so the error message I receive is the following:
This is due to the fact that the SSH keys are out of scope, and don't have access to the Docker container python semantic releases is using. Which I was able to solve by using the webfactory ssh agent instead:
But now I'm receiving this error message:
Which suggests that the deploy key is not used within the Docker container, hence we can't bypass the branch protection rules. Unless there are any flaws in my understanding.
codejedi365 commentedon Oct 29, 2025
Thanks for the error message details. The second error you received I think is because you removed the
ssh-key: ${{ secrets.DEPLOY_KEY }}from the checkout action. When checkout action occurs it uses the GITHUB_TOKEN by default in the git clone process. This leaves you with agit remote -vdefinition that includes the GITHUB_TOKEN over HTTPS and not SSH. So even if your ssh-agent is ready and properly passed to the container (I have no idea if this is accomplished by default), PSR's action ofgit pushdid not use SSH. When GitHub runs a Container action, it passes your repo to the container via a file mount of your repository folder only (hence the first error, no SSH key).You have 2 options:
try adding the ssh-key definition back to checkout, and looking into if the SSH_AGENT_SOCK is passed to the PSR container. I have no idea if this will just work and I'm out of ideas beyond this. I wouldn't put too much into making the action work as the action will change to a composite action in the future.
Scrap the PSR action, and instead just use a traditional shell action. I can pretty much guarantee this will work without all the silly complexity (and slowness) of containers.
jobs: release: runs-on: ubuntu-latest concurrency: group: ${{ github.workflow }}-release-${{ github.ref_name }} cancel-in-progress: false permissions: contents: write id-token: write outputs: released: ${{ steps.release.outputs.released }} version: ${{ steps.release.outputs.version }} tag: ${{ steps.release.outputs.tag }} steps: - name: Checkout Repository uses: actions/checkout@v4 with: ssh-key: ${{ secrets.DEPLOY_KEY }} ref: ${{ github.ref_name }} fetch-depth: 0 - name: Configure git # This will be how the release is committed, your current user/emails don't match run: | git config --local user.email "[email protected]" git config --local user.name "GitHub Actions" - name: Force branch to workflow SHA run: git reset --hard ${{ github.sha }} - name: Setup Python uses: actions/setup-python@v5 with: python-version: '3.11' - name: Setup Poetry # Add python-semantic-release ~= 10.4 to dependencies uses: snok/install-poetry@v1 with: version: latest virtualenvs-in-project: true - name: Python Semantic Release id: release - uses: python-semantic-release/[email protected] - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - git_committer_name: "github-actions" - git_committer_email: "[email protected]" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + poetry run semantic-release versionYou may have a few changes depending on Poetry and virtual environments and I am no expert on poetry usage so I leave that to you. Either way because you have the repository checked out via SSH, and the ssh key available then refer to my previous message of how PSR will work with its remote connections.
Let me know what you choose and what happens now.
dxvidparham commentedon Oct 30, 2025
Thanks for the assist. Sadly, I wasn't able to get it working, even with your suggested changes. Now, I receive this error message:
I think at this point it's best to discard the approach and fallback to the proven method with using a PAT.
codejedi365 commentedon Oct 30, 2025
That's very disappointing. I will have to look into this further as now it should just be only an access token issue for the API. I assume that you did see a successful tag populate into the repository?
dxvidparham commentedon Oct 31, 2025
Yes, the tag has been successfully populated in the repository. It feels like there’s just one small piece missing to make it fully work. But I'm struggling to see what it is.
codejedi365 commentedon Oct 31, 2025
Ok great. Please just double check that you have granted the token permissions of
contents: writeand that PSR's log did not issue a warning about a missing or empty token. You should also trigger very verbose mode of PSR with the-vvprior to the version subcommand to make sure you have enough logging of your run into errors in the future. Protected branches should not have any effect on the releases API which is what PSR is using under the hood.dxvidparham commentedon Nov 19, 2025
Sorry for my late response. I'll be able to test tomorrow and give further feedback.
dxvidparham commentedon Nov 20, 2025
Thanks for the pointers! You were absolutely right about the token permissions and the API access.
It turns out the root cause of the 401 error was a mismatch in environment variables. PSR defaults to looking for `GH_TOKEN` for the `github` remote type, but my workflow provides `GITHUB_TOKEN`. Because of this, PSR couldn't authenticate with the API to create the release, even though the git push (via SSH) was configured correctly with `ignore_token_for_push = true`.
So basically what I had to do was this:
I’ll stress-test it over the next few days, and if it proves to work reliably, I can provide more information if needed and help extend the documentation.
dxvidparham commentedon Nov 22, 2025
Everything works flawlessly. I created this repository to stress-test the setup once more, add detailed instructions, and make it easy to provide a fully reproducible example. I hope this helps!
https://github.com/dxvidparham/psr-deploy-keys-for-protected-branches
ragchuck commentedon Jan 23, 2026
I'm trying to replicate this in my environment (we're using GHES in my environment, so
domainis set).In my workflow the
verify_upstream_unchangedfunction fails.My assumption is the git fetch command doesn't seem to be using the ssh-key nor the given
github_token, becauseignore_token_for_pushis set totrue.cmdline: git fetch -v -- origin
stderr: 'fatal: Could not read from remote repository.'
Logs
Any ideas?
codejedi365 commentedon Jan 26, 2026
@ragchuck, yeah, you can't use the GitHub action for this activity. You need to run it via the command line. This is because the GitHub action is a Docker Action and the SSH authentication mechanism is lost during the context change. See my more detailed explanation above.
github-actions commentedon Apr 27, 2026
It has been 90 days since the last update on this confirmed issue. @python-semantic-release/team can you provide an update on the status of this issue?