GitHub Actions Manual Approval: Approve Deploys From Your Phone

Published:2026-11-08

The built-in way to add manual approval to GitHub Actions is an environment with required reviewers. On GitHub Free, Pro, and Team plans, required reviewers work only in public repositories. For private repositories, use a step that waits instead, such as an issue-based approval action or blipdrop/nodd-action, which sends the approval to an iPhone.

Why pause a deploy for a person

Production deploys, database migrations, and releases are things you want automated, but you still want a person to look before they run. Even a reviewed change on main may be wrong to ship right now because of the time of day or other work in flight.

Teams usually want three things:

Environments with required reviewers

This is GitHub's built-in option. Create an environment such as production in the repository settings and add required reviewers to its protection rules. A job that sets environment pauses before it starts and waits for approval.

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4
      - run: ./deploy.sh

According to GitHub's documentation:

GitHub Mobile can send push notifications for deployment review requests and lets reviewers approve or reject them (announced in GitHub's changelog in 2021).

The strengths are real: the job stops before it starts, and approval is tied to who can use the environment's secrets. The rules live in settings, outside the workflow file, which makes them easy to audit.

The limit is availability. On GitHub Free, Pro, and Team plans, required reviewers and wait timers are available only for public repositories. Private repositories need GitHub Enterprise. Also, a rejected job never starts, so that job cannot read and act on the reviewer's comment.

Issue-based approval actions

Community actions fill the gap for private repositories. The widely used trstringer/manual-approval opens an issue during the run and assigns it to approvers. A comment such as approve or lgtm lets the run continue, and deny fails it.

- uses: trstringer/manual-approval@v1
  with:
    secret: ${{ github.TOKEN }}
    approvers: user1,user2
    minimum-approvals: 1
    issue-title: "Deploying v1.3.5 to prod"

The trade-off is that the step keeps running on a runner while it waits, so it uses runner time. Its README notes that the wait is subject to the workflow time limit and usage costs. Approval is decided by the words in a comment, so identity rests on the GitHub session.

Comparison

ComparedEnvironments / Issue-based action / Nodd GitHub Action
Where you approveGitHub web, GitHub Mobile / A GitHub issue / The Nodd app on an iPhone
Private repos on Free, Pro, TeamNo (needs Enterprise) / Yes / Yes
Identity check on each responseGitHub session / GitHub session / Face ID every time
Where it waitsBefore the job starts (no runner) / Inside a step / Inside a step
Rejection reason in later stepsNo (the job never starts) / Result value only / Yes, the note output
How long it waitsFails after 30 days / Up to job and workflow limits / Until 15 minutes after expires-in
Two or more approversNo (one approval is enough) / minimum-approvals / CLI --approvals (Team plan)
Protects secretsWithholds environment secrets until approved / No / No

If your repository is public or you are on GitHub Enterprise, start with environment protection rules. If the repository is private and your approvers live in GitHub, an issue-based action is enough. The next option fits when you want to decide from a notification without opening GitHub, require Face ID for the answer, or get the rejection reason back into the workflow.

Approve on an iPhone with the Nodd GitHub Action

Nodd is an iPhone app that receives questions from scripts and CI and returns your Face ID response to the program that asked. blipdrop/nodd-action@v1 waits for approval inside a step.

  1. Create a channel in the Nodd app and issue a send key
  2. Add it to your repository secrets as NODD_KEY
  3. Add the step to your workflow. The runner needs curl and jq
jobs:
  deploy:
    runs-on: ubuntu-latest
    timeout-minutes: 60
    steps:
      - uses: actions/checkout@v4

      - id: approval
        uses: blipdrop/nodd-action@v1
        with:
          key: ${{ secrets.NODD_KEY }}
          title: Deploy to production?
          body: ${{ github.sha }}
          level: high
          wait: "true"
          expires-in: "1800"

      - name: Show the rejection reason
        if: failure() && steps.approval.outputs.decision == 'rejected'
        env:
          NOTE: ${{ steps.approval.outputs.note }}
        run: echo "$NOTE"

      - run: ./deploy.sh

With wait: "true", the step succeeds only when an approval is recorded. Rejection, expiry, cancellation, and failure all fail the job, so ./deploy.sh runs only after approval. If the responder writes a reason, it comes back in the note output. note is text a person wrote, so pass it through env as shown instead of putting it directly in run.

Inputs and outputs

keyRequired. Send key, passed from secrets and masked in logs
titleRequired. Notification title
bodyBody
levellow, normal, or high. Default normal
wait"true" asks for approval and waits. Default "false"
expires-inResponse deadline in seconds. Default "3600"
api-urlAPI URL
Output idMessage ID
Output decisionapproved, rejected, expired, cancelled, or failed. Set only with wait: "true"
Output noteThe reason the responder wrote, if any. Line breaks become spaces

Without wait, the same action just sends a notification, for example when a deploy finishes or fails.

Use the CLI in a workflow step

The GitHub Action has no inputs for multiple approvers or escalation. When you need them, use nodd ask from the CLI. In CI, skip login and pass the send key in NODD_KEY. Binaries are on Releases (the version below is the one current when this was written).

- name: Install nodd
  run: |
    curl -sSL https://github.com/blipdrop/nodd/releases/download/v0.3.0/nodd-x86_64-unknown-linux-musl.tar.gz \
      | tar xz -C "$RUNNER_TEMP"
    echo "$RUNNER_TEMP" >> "$GITHUB_PATH"

- name: Approve the production DB change
  env:
    NODD_KEY: ${{ secrets.NODD_KEY }}
  run: |
    nodd ask "Change the production database?" --level high \
      --field Environment=production \
      --approvals 2 --escalate-after 600 --expires-in 1800

nodd ask exits 0 on approval, 10 on rejection, and 11 when it ends without a response, such as on expiry, so the step succeeds only on approval. A rejection reason goes to standard error as Note: reason. More than one approver with --approvals, and --escalate-after, need the Team plan.

Things to watch

FAQ

Can I use environment required reviewers in a private repository?
On GitHub Free, Pro, and Team plans, required reviewers and wait timers are available only for public repositories. Private repositories need GitHub Enterprise. Otherwise, use an action that waits for approval inside a step.
Can I approve a deployment from GitHub Mobile?
Yes. Required reviewers on an environment get push notifications in GitHub Mobile and can approve or reject from the app.
Can the workflow read the reason for a rejection?
With the Nodd GitHub Action, the reason the responder wrote is in the note output. The CLI prints it to standard error as Note. A job rejected through environments never starts, so that job cannot read the comment.
What happens if nobody responds?
With environments, a job that is not approved within 30 days fails automatically. With Nodd, the message becomes expired after its deadline and the job fails. Neither continues without approval.
Can Nodd require two approvers?
Add --approvals 2 to nodd ask in the CLI and it waits until two people approve. More than one approver needs the Team plan, and the GitHub Action has no input for it.

Nodd delivers questions from scripts, CI, and AI agents to your iPhone, and returns your Face ID approval to the program that asked. There is a free plan.

Related guides