Skip to content

Automating GitHub with Bash and the GitHub CLI

Lesson 9 of 10Intermediate → Advanced11 min readGitHub Engineering · GitHub CLIVerified: gh 2.98.0, bash 5.2, jq 1.7 on Ubuntu 24.04, August 2026

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.

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 bash
set -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.

Every variable expansion gets double quotes. Not as style — as correctness.

Terminal window
# Wrong: breaks on any title containing a space
gh issue create --title $TITLE
# Right
gh 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.

The rule from the cluster overview, stated once more because it is where most automation breaks:

Terminal window
# Fragile
gh pr list | awk '{print $1}'
# Robust
gh 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.

--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.

Terminal window
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:

Terminal window
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"
done

What 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.

#!/usr/bin/env bash
set -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
done

Note || 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.

#!/usr/bin/env bash
set -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.

#!/usr/bin/env bash
set -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 automation
fi

The if ! construction is the pattern from earlier: the failure is expected and handled, so it must be tested rather than left to set -e.

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:

Terminal window
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 1
fi

Three habits keep usage down:

  • Request more per call. --limit 500 with --json beats five hundred individual view calls.
  • Cache during development. --cache 10m while 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.

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:

Terminal window
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"
fi

Prefer 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.

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.

A script that will be run by someone other than you needs to say what it wants.

#!/usr/bin/env bash
set -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 message
USAGE
exit 2
}
days=90
label=""
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 ;;
esac
done
shift $((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.

A script that creates temporary files should remove them however it exits, including on interrupt:

Terminal window
tmpdir=$(mktemp -d)
cleanup() { rm -rf "$tmpdir"; }
trap cleanup EXIT INT TERM

trap ... 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:

Terminal window
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.

Sequential loops over hundreds of repositories are slow. xargs -P parallelises them:

Terminal window
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.

A script that prints nothing for four minutes is indistinguishable from one that has hung.

Terminal window
total=$(gh repo list "$ORG" --limit 500 --json nameWithOwner --jq 'length')
i=0
gh 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...
done

Progress to stderr, data to stdout. The script stays pipeable and the operator can see it moving.

Pulling the practices together — a stale-Issue reporter that is safe to schedule:

#!/usr/bin/env bash
set -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
fi
done < <(
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.

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.

  1. Write a script that lists your open pull requests with their failing checks, using @tsv and a while loop.
  2. Add set -euo pipefail and confirm it still works — fix anything that now exits early.
  3. Add a rate-limit check at the top that refuses to run below 500 remaining.
  4. Add a DRY_RUN guard defaulting to true, and a run wrapper.
  5. Deliberately break the gh command and confirm the script exits non-zero rather than continuing.
  6. Run it twice and confirm the second run is harmless.

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.

Terminal window
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.

Body text that contains Markdown, quotes or newlines should never travel through a shell argument:

Terminal window
# Fragile
gh issue create --title "$t" --body "$(cat report.md)"
# Robust
gh 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:

Terminal window
gh api "repos/$OWNER/$REPO/issues?per_page=100" --paginate > issues.json
jq 'length' issues.json

Capturing 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.

Before a script runs unattended:

  1. set -euo pipefail, and every expansion quoted.
  2. All output structured — no parsing of human-readable tables.
  3. Authentication from the environment, never stored on the machine if it is shared.
  4. A rate-limit check before any large sweep.
  5. Timeouts on anything that could hang.
  6. Idempotent, so a duplicate run is harmless.
  7. Dry-run by default for anything destructive.
  8. Logs to stderr, data to stdout.
  9. Meaningful exit codes, and non-zero on partial failure.
  10. 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.

  • set -euo pipefail catches three distinct failure classes, and -e does not fire in conditions.
  • Quote every expansion; @tsv plus IFS is 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.

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.

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 bash
set -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 --silent

Compared 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:

Terminal window
tmpdir=$(mktemp -d)
trap 'rm -rf "$tmpdir"' EXIT
trap 'on_error $LINENO' ERR

EXIT 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.

Professional ToolkitThe gh api recipes and the PR triage and release-notes scripts are in the Professional Toolkit.