</>CodeWithKarani

Secrets, environments and required reviewers

Karani GeoffreyKarani Geoffrey8 min read

Most developers meet GitHub secrets the same way. You need a database password in a workflow, you find Settings, Secrets and variables, Actions, you paste it in, the field turns into eight asterisks and never shows you the value again. It feels like a vault. That feeling is the problem, because it is not a vault, and the difference between the two kinds of secret GitHub offers is the difference between a value your whole team can read and a value that will not be released until someone at the client's organisation says so.

This is the piece that makes the whole no-access approach acceptable to a security team. Not the encryption. The gate. If you are building towards deploying Frappe apps to a client server you are not allowed to touch, this article is where the client stops being a bystander to your pipeline and becomes the thing that authorises it.

repository secrets are readable by anyone who can push to the repository. Environment secrets are held back until the environment's protection rules pass, so put anything that matters in an environment and make the client a required reviewer on it.

  • Write access equals secret access. There is no third state, whatever the asterisks suggest.
  • An environment is the only place in GitHub Actions where a human approval can stand between a workflow and a value.
  • Up to six required reviewers, any one of whom can approve. Unapproved jobs fail after 30 days.
  • Add deployment tag protection so the environment can only ever be reached from a real version tag, not from a branch someone pushed.

Where a secret lives is who can read it

GitHub gives you three scopes, and they nest. What changes between them is not how strongly the value is encrypted, which is the same everywhere. What changes is the audience.

Secret scope drawn as containment Three nested boxes. The outermost is organisation secrets, readable by every workflow in every repository the organisation shares them with. Inside it is repository secrets, readable by anyone with write access to that repository. Innermost is environment secrets, which are released to a job only after the environment's protection rules have passed. A note records that the encryption is identical at all three levels and only the audience changes. Organisation secrets Repository secrets Environment secrets production Held behind the protection rules Not handed to the job until the gate opens Every repository you share it with Convenient, and the widest blast radius available Anyone with write access Not in the settings screen, but they can print it from a workflow they push. Same thing. Only a job that passed the gate Approval, wait timer, branch and tag rules all evaluate before the value is released The encryption is identical at all three levels. Only the audience changes, and the audience is the security property.
Moving a value inwards does not make it better encrypted. It makes fewer things able to ask for it.

The sentence to internalise is GitHub's own: any user with write access to your repository has read access to all secrets configured in it. Not because the UI shows them, but because they can push this and read the output:

      - run: echo "${{ secrets.DB_ROOT_PASSWORD }}" | base64

The mask does not catch that, because the masked string is the plaintext and what appears in the log is its base64 encoding. GitHub is explicit that automatic redaction is not guaranteed. Redaction is there to stop you leaking a secret by accident. It is not a control against anyone who has decided to extract one.

There is a nastier variant of this on public repositories, where a fork's pull request can be manoeuvred into running with the base repository's secrets. That is worth knowing in its own right: the pwn request, and how fork pull requests steal your secrets.

So if a junior developer on a three-month contract has push access to the repository that deploys your client's ERP, that developer has the client's database credentials. Not "could escalate to". Has.

Environments, and the only gate GitHub gives you

An environment is a named target you attach to a job. Creating one takes ten seconds under Settings, Environments, and attaching it takes one line:

jobs:
  deploy:
    runs-on: [self-hosted, dev-client-local]
    environment: production
    steps:
      - uses: actions/checkout@v5
      - run: /home/frappe/deploy.sh ${{ github.ref_name }}

On its own that line does almost nothing. It groups deployments in the UI and gives you a URL to click. Its value is that it is the only hook in GitHub Actions where a rule can sit between a job and its execution. Four rules are available, and they evaluate before the job starts and before any environment secret is handed over.

Required reviewers. Up to six people or teams. Any one of them approving is enough, which is worth knowing before you list six and assume you have built a quorum - you have built six independent keys, not a committee. Turn on "prevent self-review" so the person who triggered the run cannot wave their own change through.

Wait timer. A delay between one and 43,200 minutes before the job proceeds. Useful as a cooling-off period on a production deploy, and not billed, because nothing is executing while it waits.

Deployment branches and tags. A pattern list of what is allowed to reach this environment at all. This is the one people skip and it is the one that closes the obvious hole.

Custom protection rules. An external service that has to say yes. Real, and more machinery than most of us need.

The approval belongs to the client

Here is the arrangement I now use, and it is the single most useful thing in this article. The required reviewer on the production environment is not me and it is not anyone at my company. It is the client's IT lead.

The approval gate with the client as the reviewer A flow. A version tag is pushed, the test job runs, then the deploy job requests the production environment and enters a waiting state. A gate node, highlighted, shows that the client's IT lead must approve, that one approval is enough, and that environment secrets are not released until it passes. Two outcomes follow: if nobody approves within thirty days the job fails and nothing ran, and if approved the secrets are released and the runner executes the deploy. 1. Tag v1.4.0 pushed By you, from your laptop 2. Job: test On a GitHub-hosted runner 3. Job: deploy requests environment: production, then waits Gate: required reviewer is the client's IT lead One approval is enough. Prevent self-review is on, so you cannot approve your own run. No environment secret is released to the job until this passes. Nobody approves The job fails after 30 days. Nothing executed, nothing was released, the server was never touched. Approved Secrets released, the job is dispatched, and the runner on their own server picks it up and deploys. The client is not being informed of a deployment. The client is the reason it happens.
The gate sits before execution, not after. A run that is never approved never reaches the server at all.

Say that out loud in a procurement meeting and watch what it does. "No code reaches your server unless a named person at your organisation approves it in GitHub, and there is a permanent record of who approved what and when." That is an answer to a question their auditor has, and you have given it before it was asked.

It also solves a problem that has nothing to do with security. It ends the argument about deployment windows. You tag whenever you like; it deploys when they are ready for it. Nobody has to be online at the same time, which matters when their finance team will not accept a migration during month-end close.

Tag protection, and the hole people leave open

The environment gate protects the job. It does not, by itself, control what code the job runs. If your workflow triggers on tags and someone can push any tag, the gate is guarding a door in a wall with no other bricks in it.

Two settings close that. On the environment, restrict deployment branches and tags to a specific pattern, so the production environment can only ever be reached by a run whose ref matches v*. Patterns are configured separately for branches and tags, so if you only fill in the branch box you have not restricted tags at all - a common and quiet mistake.

Then, on the repository, protect main with a ruleset requiring a pull request and a passing status check, and add a tag ruleset so only maintainers can create v* tags. Now the chain is: code enters main only through review, a release tag can only be created by a maintainer, and the tag only deploys after the client approves. Three independent gates, none of which depends on trusting the others.

Practical rules for the secrets themselves

A short list, all of it learned the hard way.

Do not put the database password in CI at all. This is the biggest one. In the runner-based deployment this series builds towards, the deploy script runs on the client's own server as the frappe user, and the site's credentials are already in sites/dev.client.local/site_config.json where the framework expects them. The workflow never needs them. If you find yourself adding a DB_PASSWORD secret, stop and ask what is running in the wrong place - usually it means a step that belongs in the client-owned deploy script has crept into the workflow file.

Name secrets so a leak is diagnosable. CLIENT_A_PROD_NOTIFY_TOKEN beats TOKEN. When something turns up in a paste bin, you want to know in one second which system to rotate.

Never interpolate a secret into a shell command. Write this:

      - name: Notify the client
        env:
          WEBHOOK: ${{ secrets.CLIENT_A_DEPLOY_WEBHOOK }}
        run: curl -sS -X POST -d "release=${GITHUB_REF_NAME}" "$WEBHOOK"

Not curl ... "${{ secrets.CLIENT_A_DEPLOY_WEBHOOK }}". The ${{ }} form is substituted into the script text before bash sees it, so the value lands in the process arguments, in any shell trace, and in the error message if the command fails. Passing through env: keeps it out of the command line.

What this does not give you

Be clear-eyed about the limits, because a security team will find them.

The gate protects the client's server from an unapproved release. It does not protect them from an approved one. If the reviewer clicks approve on a run whose workflow file was quietly changed in the same commit range, the gate has done its job and the outcome is still bad. Approval is only as good as what the approver can see, which is why tag protection and branch protection are not optional extras next to it.

It also does not protect against the runner itself being the target. The environment gate decides whether a job runs. Once it runs, it runs with whatever privileges the runner has on that machine, and that is a separate problem with separate mitigations, covered in the article on why push access is code execution.

And none of it applies if you have not yet understood why the job executes on the client's machine at all. That is what a runner actually is, which is the prerequisite for this one. If the YAML in this article looked unfamiliar, the workflow, job and step walkthrough covers the syntax.

Frequently asked questions

Can a collaborator read a repository secret?
Not in the settings UI, but yes in practice. GitHub's own guidance states that any user with write access to your repository has read access to all secrets configured in it, because they can push a workflow that prints the secret somewhere they control. Treat write access and secret access as the same permission.
Does GitHub not mask secrets in the logs?
It masks exact matches, and GitHub says automatic redaction is not guaranteed. Base64 the value, split it across two echo calls, or pipe it through a command that reformats it, and the mask no longer matches. Redaction protects against accidents, not against someone trying.
How long does a job wait for a required reviewer?
Up to 30 days, then it fails. That limit is not configurable. Waiting time is not billed, since nothing is executing on a runner while the job sits in the Waiting state.
Can I make the client the only person who can approve a deploy?
Yes, and you should. Add their IT lead as the sole required reviewer on the production environment and enable prevent self-review. From then on no release reaches their server without a named person at their organisation clicking approve, and that fact is worth more in a security review than any assurance you could write.
#GitHub Actions#CI/CD#Security#Deployment#DevOps
Keep reading

Related articles