Documentation menu

Connecting a Jetty Runbook to GitHub

This guide shows how to let a Jetty runbook push commits to a GitHub repository and open pull requests. The worked example is a runbook that writes a short haiku, commits it as haikus/<UTC epoch seconds>.md (for example haikus/1792614032.md) on a new branch, and opens a pull request for it. When told to, it pushes the commit straight to a branch instead. You can reuse the same setup for any runbook that changes code, regenerates files, updates documentation or otherwise needs to land its work in a repository.

Credential

GITHUB_TOKEN

A fine-grained token for one repository

Permissions

Contents, Pull requests

Read and write on both, nothing else

Token lifetime

366 days at most

Then every run fails with 401 until it is renewed

Each run

A branch and a pull request

Or a push straight to the base branch

Time needed: about 15 minutes, one time.

Prerequisites:

  • A GitHub account with write access to the destination repository
  • A Jetty collection where you can set environment variables and create tasks
  • curl on your machine (only for the one-time token check)

How it works

Runbooks run in an isolated sandbox that has git but no GitHub login. You give the runbook one credential:

VariableDescription
GITHUB_TOKENA fine-grained personal access token limited to the destination repository, allowed to read and write its contents and its pull requests

During each run, the runbook tells git to read the token from the environment, clones the repository over HTTPS, pushes a branch, and opens the pull request through the GitHub REST API. GITHUB_TOKEN is the name Jetty's integrations page uses for GitHub, and the name the gh CLI picks up by itself if your sandbox image has it.

SET UP ONCEEVERY RUNGitHubfine-grained token, 1 repoJetty collectionenvironment: GITHUB_TOKENRunbook frontmattersecrets: GITHUB_TOKENSandboxenv GITHUB_TOKENgithelper reads the envGitHub REST APIAuthorization headerYour repositoryclone · push a branch · open the pull requestTHE VALUE NEVER LANDS INthe runbook text or the task's varsa clone URL (git would keep it in .git/config)~/.gitconfig, which holds the name ${GITHUB_TOKEN}a log line or an evaluation
Where the token travels: minted once on GitHub, stored once in the collection, admitted by the runbook's secrets: block, and read from the sandbox environment on every run by git and by the REST call. Its value never lands in the runbook, a URL, a config file or a log.

Permissions. The token gets two repository permissions. GitHub adds a third on its own, and a fourth is needed only for Actions files:

Repository permissionAccessNeeded for
ContentsRead and writeClone and push
Pull requestsRead and writeOpen the PR
MetadataRead-onlyAdded by GitHub on its own
WorkflowsRead and writeOnly if your runbook edits files under .github/workflows/

The token can't reach any other repository, and it can't change settings, secrets or Actions workflows.

Note

Why not a deploy key, or your own gh login?

An SSH deploy key can push, but it can't open a pull request. The token your gh CLI holds is a classic OAuth token with the repo scope over every repository you can reach, far more than one runbook needs. A fine-grained token scoped to one repository is the smallest credential that does the whole job. Teams that want a credential not tied to a person can use a GitHub App instead: its installation tokens last an hour and are minted per run, at the cost of a key-exchange step this guide doesn't cover.

Set up GitHub

Steps 1 and 2 are done once, on GitHub.

Step 1: Pick the destination repository

Create a repository or pick an existing one, and note its owner/name and its base branch (main, usually). Two recommendations:

  1. Protect the base branch (Settings > Rules or Branches > require a pull request before merging). The runbook can then propose changes but never land them unreviewed. The example runbook opens a pull request by default for exactly this reason.
  2. The base branch needs at least one commit. An empty repository has no branch to clone, so add a README first.

Step 2: Create a fine-grained personal access token

  1. On GitHub, open your profile > Settings > Developer settings > Personal access tokens > Fine-grained tokens > Generate new token (direct link: github.com/settings/personal-access-tokens/new).
  2. Token name: something like jetty-github-runbook. Expiration: pick a date and write it down (see the warning below).
  3. Resource owner: the account or organization that owns the repository.
  4. Repository access: Only select repositories, then pick the destination repository.
  5. Permissions > Repository permissions: set Contents to Read and write and Pull requests to Read and write. Leave everything else at No access. (Metadata: Read-only is selected for you.)
  6. Click Generate token and copy it. It starts with github_pat_ and is shown once.

Warning

This step decides when scheduled runs stop working. Fine-grained tokens always expire. GitHub's default policy caps them at 366 days, and an organization can set a shorter cap. When the token expires, every run fails at its first GitHub call with 401 Bad credentials. Put the expiry date in your calendar: renewing is this step again, plus updating GITHUB_TOKEN in Step 3, one tool call away if your coding agent has the Jetty MCP server.

Note

Organization repositories

When the resource owner is an organization, the organization's settings decide whether fine-grained tokens are allowed at all and whether an owner has to approve each one. A token waiting for approval behaves like one with no access: the repository comes back 404. Owners approve tokens under the organization's Settings > Personal access tokens.

Configure Jetty

Step 3: Store the token in your Jetty collection

Add GITHUB_TOKEN as an environment variable on your Jetty collection. Any of these three ways works.

Web app. Open Settings > Integrations and add GITHUB_TOKEN.

Coding agent. If you work through the Jetty plugin for Claude Code or the Jetty MCP server in Cursor, VS Code, Windsurf or Zed, save the token in a file of its own, for example ~/github-token.txt, so it never goes through your chat input or shell history, and ask the agent to store it. It calls the set-environment-vars tool for you:

Read the token in ~/github-token.txt and store it as GITHUB_TOKEN on my <your-collection> collection.

The tool keeps your other variables and only adds or replaces this key; the same tool with a null value deletes it. Two things to know: the agent reads the file and sends the value in its tool call, so the token still lands in that session's transcript, and the tool's reply echoes the collection's variables back to the agent, values included. Use it from a session you'd trust with every key in that collection, and delete the file afterwards. check-secrets (Step 4) then confirms the key is set.

API. Send a PATCH request to the collection's environment endpoint. The body must be wrapped in environment_variables. The call merges this key into your existing variables.

curl -X PATCH "https://flows-api.jetty.io/api/v1/collections/<your-collection>/environment" \
  -H "Authorization: Bearer $JETTY_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"environment_variables": {"GITHUB_TOKEN": "github_pat_..."}}'

Step 4: Declare the secret in your runbook

Important

Collection variables aren't automatically visible to a runbook. A runbook only receives the environment variables listed in the secrets: block of its frontmatter. If you skip this, the sandbox sees no GITHUB_TOKEN and the run stops at preflight.

---
name: github-haiku
version: 1.0.0
description: Write an original haiku, commit it to a GitHub repository on a new branch and open a pull request
secrets:
  GITHUB_TOKEN: { env: GITHUB_TOKEN }
---

env: names the environment variable the run delivers the value in; when the two names are the same, GITHUB_TOKEN: {} means the same thing. If you work through the Jetty plugin or MCP server, its check-secrets preflight reads this block and tells you which of the declared secrets your collection has set.

Create and test the runbook

Step 5: Hand the token to git without writing it down

One line makes every git command in the run authenticate:

git config --global credential.helper '!f() { echo "username=x-access-token"; echo "password=${GITHUB_TOKEN}"; }; f'

Whenever git talks to github.com, it asks this helper for credentials, and the helper answers from the environment. The file git writes, ~/.gitconfig, holds the text ${GITHUB_TOKEN}, not the value. Don't put the token in the clone URL instead (https://x-access-token:<token>@github.com/...): git saves that URL in .git/config, where every later command, log line and evaluation can read it. For the REST API, send the token as a header: Authorization: Bearer $GITHUB_TOKEN.

Step 6: Create the task

The example runbook has six steps. One of them is the work; the other five are the same for any runbook that lands its work in a repository:

Runbook stepWhat it doesWhen you adapt it
1. PreflightChecks that GITHUB_TOKEN is in the environment and git is installed; stops early if notKeep
2. Write the haikuProduces the contentReplace with your own work
3. Clone, commit and pushSets the credential helper, clones the base branch, commits on a new branch (or the base branch) and pushesKeep; make your changes after the git clone line
4. Open the pull requestPosts the branch to the GitHub REST API; skipped on a direct pushKeep; change the title and body
5. VerifyConfirms the branch is on the remote and the pull request is openKeep
6. SummaryWrites summary.md with PASS or FAIL and the reasonKeep
The complete example runbook
---
name: github-haiku
version: 1.0.0
description: Write an original haiku, commit it to a GitHub repository as haikus/<UTC epoch seconds>.md on a new branch and open a pull request, or push it straight to the base branch
secrets:
  GITHUB_TOKEN: { env: GITHUB_TOKEN }
---

# GitHub Haiku

You write one original haiku, commit it to a GitHub repository as `haikus/<UTC epoch seconds>.md`
(for example `haikus/1792612345.md`) on a new branch, and open a pull request for it. When asked,
you push the commit straight to the base branch instead of opening a pull request.

## Inputs

- Repository, as `owner/name`: `{{repo}}`
- Base branch: `{{base_branch}}`
- Open a pull request (`true`) or push straight to the base branch (`false`): `{{open_pr}}`
- Theme (optional, may be empty): `{{theme}}`

The token is already in the environment as `GITHUB_TOKEN`. Never print, echo, or write its value
to any file or log, never put it in a URL, and never run `env` or `printenv` without filtering it out.

## Step 1 — Preflight

    mkdir -p /app/results
    [ -n "${GITHUB_TOKEN:-}" ] || echo "MISSING GITHUB_TOKEN"
    git --version
    command -v gh >/dev/null && gh --version | head -1 || echo "gh: not installed (fine, this runbook uses curl)"

If the token is missing, write `/app/results/summary.md` saying so, and stop.

## Step 2 — Write the haiku

Compose one original haiku (three lines, 5-7-5 syllables). If a theme was given, use it;
otherwise pick a seasonal image fitting today's date. Count the syllables of every line
and rewrite until each line is exactly 5, 7, 5. Save it to `/app/results/haiku.txt`
as exactly three lines followed by a trailing newline, with no title or other text.

## Step 3 — Clone, commit and push

Run this exactly, as one script. It tells git to read the token from the environment (so the token
is never written to disk), clones the base branch, adds the haiku on a new branch, and pushes.

    set -euo pipefail
    export GIT_TERMINAL_PROMPT=0
    git config --global credential.helper '!f() { echo "username=x-access-token"; echo "password=${GITHUB_TOKEN}"; }; f'
    LOGIN=$(curl -sS -H "Authorization: Bearer $GITHUB_TOKEN" -H "Accept: application/vnd.github+json" \
      https://api.github.com/user | python3 -c 'import sys,json; print(json.load(sys.stdin).get("login",""))')
    [ -n "$LOGIN" ] || { echo "GitHub rejected the token"; exit 1; }
    git config --global user.name "Jetty Runbook"
    git config --global user.email "[email protected]"

    TS=$(date -u +%s)
    if [ "{{open_pr}}" = "false" ]; then BRANCH="{{base_branch}}"; else BRANCH="jetty/haiku-$TS"; fi
    rm -rf /app/work
    git clone -q --depth 1 --branch "{{base_branch}}" "https://github.com/{{repo}}.git" /app/work
    cd /app/work
    [ "$BRANCH" = "{{base_branch}}" ] || git checkout -q -b "$BRANCH"
    mkdir -p haikus
    { printf '# Haiku %s\n\n' "$TS"; cat /app/results/haiku.txt; } > "haikus/$TS.md"
    git add "haikus/$TS.md"
    git commit -q -m "Add haiku $TS" -m "Written by the github-haiku Jetty runbook."
    git push -q origin "HEAD:$BRANCH"
    printf '%s\n%s\n%s\n' "$TS" "$BRANCH" "$(git rev-parse HEAD)" > /app/results/commit.txt
    git ls-remote --heads origin "$BRANCH" | tee /app/results/remote_ref.txt

`/app/results/remote_ref.txt` must show the commit hash from the third line of `commit.txt` next to
`refs/heads/<branch>`. If the push was rejected, record the error message (never the token) in
`/app/results/summary.md` and stop.

## Step 4 — Open the pull request

Skip this step when `{{open_pr}}` is `false`. Otherwise run this exactly:

    set -euo pipefail
    TS=$(sed -n 1p /app/results/commit.txt); BRANCH=$(sed -n 2p /app/results/commit.txt)
    python3 - "$TS" "$BRANCH" <<'PY' > /tmp/pr_request.json
    import json, sys
    ts, branch = sys.argv[1], sys.argv[2]
    lines = open('/app/results/haiku.txt').read().strip().splitlines()
    body = "A haiku written by the github-haiku Jetty runbook.\n\n" + "\n".join("> " + l for l in lines) + "\n"
    print(json.dumps({"title": f"Add haiku {ts}", "head": branch, "base": "{{base_branch}}", "body": body}))
    PY
    curl -sS -X POST -H "Authorization: Bearer $GITHUB_TOKEN" -H "Accept: application/vnd.github+json" \
      -H "X-GitHub-Api-Version: 2022-11-28" --data-binary @/tmp/pr_request.json \
      "https://api.github.com/repos/{{repo}}/pulls" | tee /app/results/pr.json

## Step 5 — Verify

For a pull request: `/app/results/pr.json` must contain a `number`, an `html_url`, `"state": "open"`,
and a `head.ref` equal to the branch on the second line of `commit.txt`. Then confirm with GitHub:

    N=$(python3 -c 'import json; print(json.load(open("/app/results/pr.json")).get("number",""))')
    curl -sS -H "Authorization: Bearer $GITHUB_TOKEN" -H "Accept: application/vnd.github+json" \
      "https://api.github.com/repos/{{repo}}/pulls/$N" \
      | python3 -c 'import sys,json; d=json.load(sys.stdin); print(d.get("state"), d.get("head",{}).get("ref"), d.get("html_url"))'

For a direct push: `remote_ref.txt` already shows the commit on `{{base_branch}}`, and nothing more is needed.

If `pr.json` has a `message` instead of a `number` (an error), retry Step 4 once. If the retry also fails,
record the message (never the token) and stop.

## Step 6 — Summary

Write `/app/results/summary.md` with:
- the haiku
- the repository, branch and commit hash, and the pull request URL (or "pushed to `<base branch>`" when no PR was opened)
- whether `gh` was available in the sandbox (from Step 1)
- the result: `PASS` if Step 5 verified, otherwise `FAIL` with the reason

Adapting the example. Keep runbook Steps 1, 3, 4 and 5 as they are and replace Step 2 with your own work. For changes to the repository's own files, do the work after the git clone line, inside /app/work, and replace the mkdir, printf and git add lines with git add -A. Run the project's tests before the git push line so the pull request arrives green, and change the title and body in runbook Step 4 to describe the change.

Task configuration. Create the task with default values for the variables, so a run with no inputs still knows where to push:

"init_params": {
  "instruction": "<contents of RUNBOOK.md>",
  "vars": { "repo": "<owner/name>", "base_branch": "main", "open_pr": "true", "theme": "" }
}

Use the python312-uv sandbox snapshot or similar; the runbook only needs git, curl and python3. This runbook finishes in a couple of minutes, so a timeout_sec of 900 is plenty.

Step 7: Run a test

Start a run from the Jetty web app, or from the API:

curl -X POST "https://flows-api.jetty.io/api/v1/run/<your-collection>/github-haiku" \
  -H "Authorization: Bearer $JETTY_API_TOKEN" \
  -F 'init_params={"vars":{"repo":"<owner/name>","base_branch":"main","open_pr":"true","theme":"autumn"}}'

When the run finishes, its results include pr.json with the new pull request's number and html_url, commit.txt with the branch and commit hash, and summary.md showing PASS. In the repository you'll find a branch jetty/haiku-<epoch> and an open pull request, for example:

Add haiku 1792614032                                    #1
jetty/haiku-1792614032 → main
─────────────────────────────────────────────────────────
A haiku written by the github-haiku Jetty runbook.

> Cold wind strips the oak
> leaves spiral down in silence
> autumn holds its breath

To test the direct-push mode, pass "open_pr":"false": the commit lands on main with no pull request. If main is protected, GitHub rejects the push with GH006: Protected branch update failed and the run records a FAIL. That's the protection doing its job, not a bug in the runbook.

Running on a schedule

The runbook works unchanged on a Jetty schedule:

  • Inputs. Scheduled runs pass vars (repo, base_branch, open_pr, theme) into the runbook's {{…}} variables. If a schedule doesn't set them, the task's default vars apply.
  • No collisions. Each run names its branch and file with the current epoch second, so runs never overwrite each other.
  • Concurrency. A personal access token doesn't rotate when used, so overlapping runs are fine.
  • One pull request per run. A daily schedule opens a daily pull request. Merge or close them, or change runbook Step 3 to reuse one fixed branch (git push --force-with-lease) and runbook Step 4 to update the existing pull request instead of opening another, if you'd rather have one living pull request than a pile.

Before relying on a schedule, check two things:

  1. Token lifetime. The token expires on the date you chose in Step 2, 366 days at most. After that, every run fails with 401 Bad credentials until you store a new one (Step 3), which an agent with the Jetty MCP server can do for you.
  2. Watch the result, not just the run status. A run can show as completed even when the runbook recorded a FAIL in summary.md, for example because GitHub rejected the push. For unattended schedules, check summary.md or the repository's pull requests, or have your runbook send a notification on failure.

Troubleshooting

SymptomCauseFix
summary.md says MISSING GITHUB_TOKENThe variable isn't listed in the runbook's secrets: frontmatter, or isn't set on the collectionAdd the secrets: block (Step 4) and check the collection environment (Step 3)
GitHub rejected the token, or 401 Bad credentialsThe token expired, was revoked, or was pasted with a typoCreate a new token (Step 2) and update GITHUB_TOKEN (Step 3), from the web app, your coding agent or the API
remote: Repository not found, or the API returns 404 Not FoundWrong repo value, the token wasn't granted this repository, or the organization hasn't approved the token yetCheck owner/name; edit the token's Repository access; ask an organization owner to approve it
403 … Resource not accessible by personal access token when opening the pull requestThe token's Pull requests permission is missing or read-onlyEdit the token: Pull requests > Read and write
remote: Permission to <repo> denied or 403 on git pushThe token's Contents permission is read-onlyEdit the token: Contents > Read and write
GH006: Protected branch update failed or GH013: Repository rule violations found on git pushThe run pushed straight to a protected branch (open_pr is false)Leave open_pr at true, or push to a branch the rules allow
refusing to allow a Personal Access Token to create or update workflowThe change touches .github/workflows/Add the Workflows: Read and write permission to the token
fatal: could not read Username for 'https://github.com'The credential helper wasn't configured, usually because the runbook's Step 3 script was run line by line in separate shellsRun the whole Step 3 block as one script
fatal: Remote branch main not foundThe repository is empty, or base_branch is wrongAdd a first commit (Step 1) or fix base_branch
422 … A pull request already exists or No commits between main and <branch>A branch name was reused, or the commit changed nothingKeep one branch per run (the epoch suffix does this); check that runbook Step 3 wrote a file before committing

Security checklist

  • Keep the token to one repository and two permissions. Widen it only for what a runbook actually needs (Workflows for Actions files, Issues for issue comments).
  • Keep the token only in the Jetty collection environment. Never put it in the runbook text or the task's vars.
  • Let git read the token through the credential helper (Step 5), never through the clone URL. The runbook tells the agent never to print the token; keep that line if you adapt it.
  • Protect the base branch, so the runbook proposes and people merge.
  • Pull requests opened with the token appear under the token owner's name. If they should come from a bot identity, mint the token from a machine user account, or use a GitHub App.
  • To cut off access immediately, delete the token at github.com/settings/personal-access-tokens (or, for an organization, under its Settings > Personal access tokens). A run in progress fails at its next GitHub call.