A script that runs once on your machine and a script that runs unattended a thousand times are different artefacts. This lesson is about the second kind.
The commands are the ones you already know. What changes is everything around them: what happens when a call fails, when the network is slow, when the filter matches more than you expected, and when the script runs twice.
The header, and what it actually does
Section titled “The header, and what it actually does”Most shell automation guides open with this line and move on. It is worth understanding, because one of its three parts causes more confusion than the other two combined.
#!/usr/bin/env bashset -euo pipefail-e — exit immediately if a command returns non-zero. Without it, a script continues past
failures and produces confident wrong output.
-u — treat unset variables as an error. Catches typos, and catches the case where an expected
environment variable is missing rather than silently substituting an empty string. rm -rf "$DIR/"
with an unset DIR is the reason this matters.
-o pipefail — a pipeline fails if any stage fails, not just the last. Without it,
gh api ... | jq ... reports success when gh failed and jq happily processed nothing.
Quoting
Section titled “Quoting”Every variable expansion gets double quotes. Not as style — as correctness.
# Wrong: breaks on any title containing a spacegh issue create --title $TITLE
# Rightgh issue create --title "$TITLE"Unquoted expansion undergoes word splitting and glob expansion. A repository named * is unlikely; a
title with a space is certain. The habit costs nothing and removes an entire class of bug.
Never parse human output
Section titled “Never parse human output”The rule from the cluster overview, stated once more because it is where most automation breaks:
# Fragilegh pr list | awk '{print $1}'
# Robustgh pr list --json number --jq '.[].number'Column layout, colour and truncation are presentation, and gh changes them between releases. Worse,
gh detects whether output is a terminal and formats differently — so a script can behave one way
when you test it interactively and another way in CI.
Reading JSON safely
Section titled “Reading JSON safely”--jq is built into gh and needs no jq binary. Use it for extraction; use the jq binary when you
need to combine data from several calls.
gh pr list --json number,title,author \ --jq '.[] | "\(.number)\t\(.author.login)\t\(.title)"'Reading multi-field records into shell variables is where scripts usually go wrong. Do not split on spaces — use a delimiter that cannot appear in the data:
gh pr list --state open --json number,title \ --jq '.[] | [.number, .title] | @tsv' \| while IFS=$'\t' read -r number title; do printf 'PR #%s — %s\n' "$number" "$title" doneWhat it doesEmits tab-separated fields and reads them into named variables, one record per line.
Why we run itTitles contain spaces; tabs they do not. Setting IFS to a tab and using read -r gives correct parsing without eval or word-splitting hazards.
Expected resultOne line printed per pull request, with fields correctly separated.
@tsv is the important part: it escapes embedded tabs and newlines, so a title containing either
cannot corrupt the parse.
Worked examples
Section titled “Worked examples”Failing checks on your open pull requests
Section titled “Failing checks on your open pull requests”#!/usr/bin/env bashset -euo pipefail
gh pr list --author "@me" --state open --json number,title \ --jq '.[] | [.number, .title] | @tsv' \| while IFS=$'\t' read -r number title; do failing=$(gh pr checks "$number" --json name,state \ --jq '[.[] | select(.state == "FAILURE") | .name] | join(", ")' 2>/dev/null || echo "") if [ -n "$failing" ]; then printf '#%s %s\n failing: %s\n' "$number" "$title" "$failing" fi doneNote || echo "" on the inner call. A pull request with no checks makes gh pr checks exit
non-zero, which under set -e would kill the whole script. Handling it explicitly is the difference
between a report and a crash on the third item.
Repository inventory
Section titled “Repository inventory”#!/usr/bin/env bashset -euo pipefail
ORG="${1:?usage: inventory.sh ORG}"
gh repo list "$ORG" --limit 500 --no-archived \ --json nameWithOwner,visibility,pushedAt,defaultBranchRef \ --jq '.[] | [.nameWithOwner, .visibility, .pushedAt, .defaultBranchRef.name] | @tsv' \> "inventory-$ORG.tsv"
wc -l < "inventory-$ORG.tsv"${1:?message} fails immediately with a usage message when the argument is missing — better than
running against the wrong target because a variable was empty.
Creating an Issue from command output
Section titled “Creating an Issue from command output”#!/usr/bin/env bashset -euo pipefail
if ! output=$(./run-audit.sh 2>&1); then gh issue create \ --title "Nightly audit failed: $(date -u +%Y-%m-%d)" \ --body "$(printf 'The nightly audit exited non-zero.\n\n```\n%s\n```\n' "$output")" \ --label automationfiThe if ! construction is the pattern from earlier: the failure is expected and handled, so it must
be tested rather than left to set -e.
Rate limits
Section titled “Rate limits”Every gh call is an API request. A loop over two hundred repositories making three calls each is
six hundred requests, and authenticated users get 5,000 per hour.
Check before a large run:
remaining=$(gh api rate_limit --jq '.resources.core.remaining')if [ "$remaining" -lt 500 ]; then reset=$(gh api rate_limit --jq '.resources.core.reset') echo "only $remaining requests left; resets at $(date -d "@$reset")" >&2 exit 1fiThree habits keep usage down:
- Request more per call.
--limit 500with--jsonbeats five hundred individualviewcalls. - Cache during development.
--cache 10mwhile iterating on a filter. - Consider GraphQL when you need related data — one query instead of N+1 requests. See GraphQL API.
Secondary limits also exist for content-creating requests, and they are stricter. A loop creating Issues or comments should pause between iterations rather than firing as fast as it can.
Idempotency
Section titled “Idempotency”A script that runs on a schedule will run twice — after a retry, a duplicate trigger, or a manual re-run. Make the second run harmless.
Check before creating:
existing=$(gh issue list --search "in:title \"$TITLE\"" --state open \ --json number --jq '.[0].number // empty')if [ -n "$existing" ]; then echo "issue already exists: #$existing"else gh issue create --title "$TITLE" --body "$BODY"fiPrefer operations that are naturally idempotent. Adding a label that is already present is a no-op. Creating a second Issue is not.
Make the desired state the input, not the change. “Ensure this label exists” survives repetition; “create this label” does not.
Safety for anything bulk
Section titled “Safety for anything bulk”Two further rules:
Never automate against a repository you cannot afford to break. Test on a disposable one.
Log what you did. A script that acts silently gives you nothing to reconstruct from when it acts wrongly.
Argument handling
Section titled “Argument handling”A script that will be run by someone other than you needs to say what it wants.
#!/usr/bin/env bashset -euo pipefail
usage() { cat >&2 <<'USAGE'usage: stale-report.sh [-d DAYS] [-l LABEL] OWNER/REPO
-d DAYS consider issues stale after this many days (default: 90) -l LABEL restrict to issues carrying this label -h show this messageUSAGE exit 2}
days=90label=""
while getopts ":d:l:h" opt; do case "$opt" in d) days="$OPTARG" ;; l) label="$OPTARG" ;; h) usage ;; :) echo "option -$OPTARG requires an argument" >&2; usage ;; \?) echo "unknown option -$OPTARG" >&2; usage ;; esacdoneshift $((OPTIND - 1))
repo="${1:-}"[ -n "$repo" ] || usage[[ "$repo" == */* ]] || { echo "expected OWNER/REPO, got '$repo'" >&2; exit 2; }getopts is built in, portable and sufficient for anything short of a full CLI. Validating the
repository’s shape before using it catches the most common invocation error — passing just a
repository name, or passing a URL — before it produces a confusing 404.
Exiting 2 for usage errors distinguishes “you called this wrongly” from 1, “it ran and failed”,
which matters to whatever is calling your script.
Cleaning up
Section titled “Cleaning up”A script that creates temporary files should remove them however it exits, including on interrupt:
tmpdir=$(mktemp -d)cleanup() { rm -rf "$tmpdir"; }trap cleanup EXIT INT TERMtrap ... EXIT runs on normal exit, on set -e failure, and on the traps you list. Without it, a
script interrupted halfway leaves temporary state behind — and for anything writing partial output,
that state can be mistaken for a completed run.
For scripts that must not run twice concurrently — a scheduled sweep, say — a lock is worth the three lines:
exec 9>"/tmp/$(basename "$0").lock"flock -n 9 || { echo "another instance is running" >&2; exit 1; }Two copies of a labelling sweep running simultaneously will duplicate work and can double-apply comments.
Parallelism, carefully
Section titled “Parallelism, carefully”Sequential loops over hundreds of repositories are slow. xargs -P parallelises them:
gh repo list ORG --limit 200 --no-archived --json nameWithOwner --jq '.[].nameWithOwner' \| xargs -P 4 -I {} sh -c ' count=$(gh issue list --repo "$1" --state open --json number --jq "length") [ "$count" -gt 0 ] && printf "%4d %s\n" "$count" "$1" ' _ {}Four at a time is a reasonable default. Two cautions.
Rate limits are shared. Parallelism does not increase your budget; it spends it faster. Four workers hitting the secondary limit produce failures four times as quickly.
Output interleaves. Two workers writing partial lines simultaneously produce corrupted output.
printf of a complete line is usually atomic for short lines, but for anything structured, write to
per-worker files and concatenate afterwards.
Reporting progress
Section titled “Reporting progress”A script that prints nothing for four minutes is indistinguishable from one that has hung.
total=$(gh repo list "$ORG" --limit 500 --json nameWithOwner --jq 'length')i=0gh repo list "$ORG" --limit 500 --json nameWithOwner --jq '.[].nameWithOwner' \| while read -r repo; do i=$((i + 1)) printf '[%d/%d] %s\n' "$i" "$total" "$repo" >&2 # ...work... doneProgress to stderr, data to stdout. The script stays pipeable and the operator can see it moving.
A complete example
Section titled “A complete example”Pulling the practices together — a stale-Issue reporter that is safe to schedule:
#!/usr/bin/env bashset -euo pipefail
REPO="${1:?usage: stale.sh OWNER/REPO}"DAYS="${DAYS:-90}"DRY_RUN="${DRY_RUN:-true}"
log() { printf '[%s] %s\n' "$(date -u +%H:%M:%S)" "$*" >&2; }
remaining=$(gh api rate_limit --jq '.resources.core.remaining')log "rate limit: $remaining remaining"[ "$remaining" -ge 200 ] || { log "insufficient budget"; exit 75; }
cutoff=$(date -u -d "$DAYS days ago" +%Y-%m-%dT%H:%M:%SZ)count=0
while IFS=$'\t' read -r number title; do count=$((count + 1)) if [ "$DRY_RUN" = "true" ]; then printf 'would label #%s\t%s\n' "$number" "$title" else gh api --method POST "repos/$REPO/issues/$number/labels" -f "labels[]=stale" --silent log "labelled #$number" sleep 1 # respect secondary limits fidone < <( gh api "repos/$REPO/issues?state=open&per_page=100" --paginate \ --jq --arg cutoff "$cutoff" \ '.[] | select(has("pull_request") | not) | select(.updated_at < $cutoff) | select([.labels[].name] | index("stale") | not) | [.number, .title] | @tsv')
log "$count issue(s) matched"Every practice from this lesson is in there: strict mode, a budget check, pagination, the pull
request filter, idempotency through the existing-label check, dry-run by default, pacing for
secondary limits, logging to stderr, and process substitution so count survives the loop.
Common mistakes
Section titled “Common mistakes”No set -euo pipefail. Failures pass silently.
Unquoted variables. Breaks on the first space.
Parsing table output. Breaks on a CLI upgrade.
Splitting multi-field output on spaces. Use @tsv and IFS.
Assuming set -e catches everything. It does not fire in conditions.
Ignoring rate limits. Works in testing, fails at scale.
No dry-run on destructive loops. No undo.
Assuming variables survive a while read pipeline. They do not; use process substitution.
Exercise
Section titled “Exercise”- Write a script that lists your open pull requests with their failing checks, using
@tsvand awhileloop. - Add
set -euo pipefailand confirm it still works — fix anything that now exits early. - Add a rate-limit check at the top that refuses to run below 500 remaining.
- Add a
DRY_RUNguard defaulting totrue, and arunwrapper. - Deliberately break the
ghcommand and confirm the script exits non-zero rather than continuing. - Run it twice and confirm the second run is harmless.
Timeouts
Section titled “Timeouts”A gh command that hangs blocks a script indefinitely. In a scheduled job that is worse than a
failure, because a hung job holds resources and produces no signal.
timeout 30 gh api "repos/$OWNER/$REPO" --silent || { status=$? if [ "$status" -eq 124 ]; then echo "request timed out" >&2 else echo "request failed with status $status" >&2 fi exit 1}timeout returns 124 specifically when it kills the command, which distinguishes a hang from an
ordinary failure — useful information when deciding whether to retry.
The most common cause of a hang is not the network but an interactive prompt: gh asking a question
no one is there to answer. gh config set prompt disabled, or GH_PROMPT_DISABLED=1, makes it fail
with a clear error instead of waiting.
Reading from and writing to files
Section titled “Reading from and writing to files”Body text that contains Markdown, quotes or newlines should never travel through a shell argument:
# Fragilegh issue create --title "$t" --body "$(cat report.md)"
# Robustgh issue create --title "$t" --body-file report.md
# From a pipeline./generate-report.sh | gh issue create --title "$t" --body-file ---body-file - reads standard input, which is the form to use when the body is generated rather than
stored. The same applies to gh pr create --body-file and to gh api -F body=@file.
For output, redirect rather than capturing when the data is large:
gh api "repos/$OWNER/$REPO/issues?per_page=100" --paginate > issues.jsonjq 'length' issues.jsonCapturing megabytes into a shell variable works and is slower than it needs to be, and some shells have limits you will find at an inconvenient moment.
A checklist for anything scheduled
Section titled “A checklist for anything scheduled”Before a script runs unattended:
set -euo pipefail, and every expansion quoted.- All output structured — no parsing of human-readable tables.
- Authentication from the environment, never stored on the machine if it is shared.
- A rate-limit check before any large sweep.
- Timeouts on anything that could hang.
- Idempotent, so a duplicate run is harmless.
- Dry-run by default for anything destructive.
- Logs to stderr, data to stdout.
- Meaningful exit codes, and non-zero on partial failure.
- Tested with the credential unset, to confirm it fails clearly.
Item 10 is the one most often skipped and the most useful. A script that behaves mysteriously when a token has expired is a script someone will debug for an hour; one that says “authentication failed — set GH_TOKEN” and exits 78 is one they fix in a minute.
What you learned
Section titled “What you learned”set -euo pipefailcatches three distinct failure classes, and-edoes not fire in conditions.- Quote every expansion;
@tsvplusIFSis the correct way to read multi-field records. - Never parse human-readable output — it changes between releases and by terminal detection.
- Rate limits apply per call; batch, cache, and consider GraphQL for related data.
- Idempotency means a second run is harmless, which schedules make inevitable.
- Bulk operations default to dry-run, because there is no undo.
Where to start
Section titled “Where to start”If you are writing your first gh automation, the order that works: get it correct interactively
first, then wrap it in a script with set -euo pipefail, then add the dry-run guard, then add the
rate-limit check, and only then schedule it.
Skipping straight to a scheduled script is how a filter mistake becomes two hundred mislabelled Issues. Each step above is cheap, and each catches a different class of problem before it runs unattended.
Better diagnostics with trap
Section titled “Better diagnostics with trap”set -e exits on failure and tells you nothing about where. For a script that runs unattended, that
is the difference between a fixable report and a mystery.
#!/usr/bin/env bashset -euo pipefail
on_error() { local exit_code=$? line=$1 printf '[ERROR] line %s exited %s: %s\n' "$line" "$exit_code" "$BASH_COMMAND" >&2 exit "$exit_code"}trap 'on_error $LINENO' ERR$BASH_COMMAND holds the command that was running, and $LINENO its line. The result is a failure
message naming both:
[ERROR] line 47 exited 1: gh api repos/acme/nonexistent --silentCompared with the default — silent exit with a status code — that is the whole diagnosis.
Combine it with the cleanup trap from earlier; the two coexist because they fire on different signals:
tmpdir=$(mktemp -d)trap 'rm -rf "$tmpdir"' EXITtrap 'on_error $LINENO' ERREXIT runs on every exit including the error path, so cleanup still happens. ERR runs first and
supplies the diagnosis.
For a scheduled job, printing that line into a log that someone reads later is worth considerably more than the two lines it costs to add.