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
The steps
Set up GitHub
Configure Jetty
Create and test the runbook
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
curlon 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:
| Variable | Description |
|---|---|
GITHUB_TOKEN | A 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.
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 permission | Access | Needed for |
|---|---|---|
| Contents | Read and write | Clone and push |
| Pull requests | Read and write | Open the PR |
| Metadata | Read-only | Added by GitHub on its own |
| Workflows | Read and write | Only 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:
- 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.
- 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
- 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).
- Token name: something like
jetty-github-runbook. Expiration: pick a date and write it down (see the warning below). - Resource owner: the account or organization that owns the repository.
- Repository access: Only select repositories, then pick the destination repository.
- 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.)
- 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 step | What it does | When you adapt it |
|---|---|---|
| 1. Preflight | Checks that GITHUB_TOKEN is in the environment and git is installed; stops early if not | Keep |
| 2. Write the haiku | Produces the content | Replace with your own work |
| 3. Clone, commit and push | Sets the credential helper, clones the base branch, commits on a new branch (or the base branch) and pushes | Keep; make your changes after the git clone line |
| 4. Open the pull request | Posts the branch to the GitHub REST API; skipped on a direct push | Keep; change the title and body |
| 5. Verify | Confirms the branch is on the remote and the pull request is open | Keep |
| 6. Summary | Writes summary.md with PASS or FAIL and the reason | Keep |
---
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 reasonAdapting 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 breathTo 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 defaultvarsapply. - 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:
- 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 credentialsuntil you store a new one (Step 3), which an agent with the Jetty MCP server can do for you. - Watch the result, not just the run status. A run can show as
completedeven when the runbook recorded aFAILinsummary.md, for example because GitHub rejected the push. For unattended schedules, checksummary.mdor the repository's pull requests, or have your runbook send a notification on failure.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
summary.md says MISSING GITHUB_TOKEN | The variable isn't listed in the runbook's secrets: frontmatter, or isn't set on the collection | Add the secrets: block (Step 4) and check the collection environment (Step 3) |
GitHub rejected the token, or 401 Bad credentials | The token expired, was revoked, or was pasted with a typo | Create 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 Found | Wrong repo value, the token wasn't granted this repository, or the organization hasn't approved the token yet | Check 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 request | The token's Pull requests permission is missing or read-only | Edit the token: Pull requests > Read and write |
remote: Permission to <repo> denied or 403 on git push | The token's Contents permission is read-only | Edit the token: Contents > Read and write |
GH006: Protected branch update failed or GH013: Repository rule violations found on git push | The 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 workflow | The 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 shells | Run the whole Step 3 block as one script |
fatal: Remote branch main not found | The repository is empty, or base_branch is wrong | Add 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 nothing | Keep 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.