Skip to content
OUTIS DOCS

GitHub

A pull request that’s ready gets merged when the keys turn, a colleague’s pull request gets your approving review, and a production deployment waiting on a protection rule gets its answer. Requests come from the App’s webhooks, or from a workflow that asks and polls.

Every organization is seeded with these three when it’s created and when it connects the App. An admin can change any field on the dashboard.

Action Keys Mode Request TTL Arm window Code TTL Self approval
github.merge-own-pr 1 counted 30m 60s 15m allowed
github.approve-pr 1 counted 4h 60s 15m denied
deploy.production 2 coincident 30m 20s 15m denied

A production deploy is two keys inside one twenty second window. With the requester struck out, that takes three eligible people.

deploy.production carries what GitHub sends: repo, env and sha, plus ref, run, deployment and installation. The App fills in the last three, and they’re how the answer finds its way back to the paused run.

Outis runs one GitHub App, and your organization installs it. Press Connect on your organization’s GitHub screen, pick your account and the repositories, and GitHub sends you back. Then, for each environment whose deployments should wait for keys, open the environment’s settings in GitHub and add the Outis App under Deployment protection rules.

The App asks for this and nothing more:

Permission Access Why
Actions read GitHub requires it of a custom deployment protection rule.
Checks read Tells the App when a pull request’s checks pass.
Contents write Merging a pull request writes to its base branch.
Deployments write Approving or rejecting a waiting deployment.
Metadata read Every App gets it.
Pull requests write Reading pull requests, leaving the review, and commenting when nothing was merged.

It subscribes to four events: deployment_protection_rule, pull_request, pull_request_review and check_suite.

If you run Outis yourself, the App’s manifest ships with the GitHub integration as app-manifest.json, with those permissions and events. Register it on GitHub (swap in your own hosts), then set the App’s id, slug, webhook secret and private key in the server’s environment. The integration’s README has the steps.

Four events are read; everything else is acknowledged and ignored.

Event What it proposes
deployment_protection_rule (requested) deploy.production, once per deployment. The workflow run pauses until the keys turn.
pull_request (opened, synchronize, ready_for_review, edited, reopened), pull_request_review (submitted), check_suite (completed, success) github.merge-own-pr, once per head sha, when GitHub reports mergeable_state as clean.
pull_request (review_requested) naming an operator github.approve-pr, once per head sha.

The requester is matched by GitHub login to a member of your organization. For a pull request that’s the author. For a deployment it’s whoever created it, except when that’s a bot: a workflow’s deployments are created by github-actions[bot], so the person who triggered the run is the requester, and the request’s summary names both.

GitHub holds a run on a protection rule for up to 30 days, but the request itself lives as long as the action’s request TTL, 30 minutes by default. When it ends without the keys, the run is rejected.

A deployment that can’t become a request is rejected right away, with the reason in the rule’s comment:

  • The requester’s GitHub login isn’t a member of your organization.
  • Outis refused the request, say no policy allows it or there aren’t enough eligible people to make the quorum.

Fix the cause, then re-run the job.

Re-running a job sends a fresh event, and it’s a new request with its own keys. GitHub creates a new deployment for each attempt, and the deployment is what Outis keys on. For an environment that creates no deployment, the key is the run, the environment and the commit. GitHub doesn’t say which attempt an event is for, so there a re-run lands on the first attempt’s request. Start a new run to ask again.

A redelivery is the same event sent again, from the App’s Advanced settings or after a failed delivery. It lands on the request the first one made, so nobody’s asked twice.

The panel for a deploy reads like this:

GITHUB DEPLOY
payments-api
ENV  production
SHA  8d93f71

That’s the repository, the environment, and the first seven characters of the commit, which is how GitHub abbreviates it. Anything that looks like production (production, prod, or a name with either as a word, like eu-production) is drawn at critical severity. The core adds the quorum line underneath, like 2 OF 3.

A merge draws MERGE PR over the pull request number, and an approval draws APPROVE PR, in a different color so the two can’t be confused. Both carry a DGST field, the same six characters the notification prints, so the operator can check they match before pressing.

Once the request is decided, the App makes one call with its installation token: the merge at the sha the operators saw, the review, or the protection rule’s answer for the run and environment the panel showed. A head that moved since then isn’t merged.

The comment on the answer says who approved it (by their Outis operator ids) or how it ended otherwise. Denied, expired and withdrawn each read differently. It also links the request on your dashboard and gives its operation hash, all inside GitHub’s 1024 character limit.

If GitHub refuses the answer (the run’s gone, it was already answered, the head moved), the request lands in failed, where your operators see it. If GitHub’s just unreachable, Outis tries again until it lands.

A workflow that doesn’t use the App, or that’s asking for something GitHub has no event for, creates a request and polls with an API key holding propose and read. The App doesn’t act on a request made this way, even if its params name an installation, so your workflow reads the decision and does the work itself. The idempotency key below is the run and its attempt, so a retried step lands on the same request and a re-run makes a new one.

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Ask for the keys, then wait for the answer
        env:
          OUTIS_KEY: ${{ secrets.OUTIS_KEY }}
        run: |
          ID=$(curl -sS -X POST "https://api.outis.tech/v1/requests" \
            -H "Content-Type: application/json" \
            -H "Authorization: Bearer $OUTIS_KEY" \
            -H "Idempotency-Key: $GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT" \
            -d "{\"action\":\"deploy.production\",\"requester\":\"$GITHUB_ACTOR\",
                 \"params\":{\"repo\":\"$GITHUB_REPOSITORY\",\"env\":\"production\",\"sha\":\"$GITHUB_SHA\"}}" \
            | jq -r .request.id)
          until [ "$(curl -sS "https://api.outis.tech/v1/requests/$ID" -H "Authorization: Bearer $OUTIS_KEY" | jq -r .request.outcome)" != "null" ]; do
            sleep 5
          done
          test "$(curl -sS "https://api.outis.tech/v1/requests/$ID" -H "Authorization: Bearer $OUTIS_KEY" | jq -r .request.outcome)" = authorized
      - name: Deploy
        run: ./deploy.sh

The GitHub integration also has its own route, POST /integrations/github/propose, behind the same kind of key. It takes the same body for its three actions and answers with request_id and the digest.

Webhooks are signed by GitHub with the App’s secret and checked on the webhook route: X-Hub-Signature-256 over the raw body, compared in constant time. A repeated delivery is caught by the request’s idempotency key, which Outis keeps for your organization, so a redelivery never becomes a second request. The request routes read no signature header; your API key is the credential.