The web UI is where GitHub shows you things. The gh CLI is where you make GitHub do things without leaving the terminal you were already in. Once a repo is the system of record (the previous chapter), the CLI is what stops "checking GitHub" from meaning "open a new tab, wait for it to load, click three times." Every verb in this chapter runs against a repo you already have cloned, from the shell you're already sat in.
That convenience has a cost, and the cost is auth. gh authenticates as a token with a specific set of scopes, not as "you, logged in." Most days that distinction is invisible. The day it isn't, it's usually mid-PR, and it's usually .github/workflows/*.
Auth is a token with scopes, not a login
Run gh auth status and you get back an account name plus a list of scopes the current token actually carries, something like:
gh auth status
github.com
✓ Logged in to github.com account CaptainRandom00
- Token scopes: 'gist', 'read:org', 'repo'
That looks like being logged in. It is not the same as being authorised for everything gh can theoretically do. Scopes are additive and specific: repo lets you read and write repository content, but pushing a change to a workflow file under .github/workflows/ needs its own workflow scope, and GitHub checks for it at push time, not at auth time. Nothing in gh auth status warns you the gap exists until you hit it.
The push that got rejected
The failure mode is always the same shape: you're mid-PR, you touch .github/workflows/deploy.yml (a required scope check, a new step, a path filter), and the push that would have opened the PR bounces. The fix is one command, and it's worth knowing before the first time it happens rather than after:
gh auth refresh -h github.com -s workflow
gh auth refresh takes -s/--scopes to name additional scopes to add, -r/--remove-scopes to take scopes away, and --reset-scopes to drop back to the default minimum set. GitHub's own documented example is gh auth refresh --scopes write:org,read:public_key, and the behaviour it triggers is a browser reauth flow: the CLI opens your browser, you confirm the new scope against your existing GitHub session, and the token on disk gets upgraded in place. No new token to copy, no re-cloning, no re-running gh auth login from scratch.
Two things make this worth internalising rather than rediscovering under pressure. First, the fix is additive, -s workflow adds the scope without touching the others already on the token, so you don't lose repo or read:org in the process. Second, this is a one-time cost per machine per scope: once a token carries workflow, every future edit to a workflow file pushes cleanly from that terminal.
Scopes matter just as much off your own machine. .github/workflows/refresh-workshop.yml in this site's own repo carries a comment explaining exactly why the default GITHUB_TOKEN that GitHub Actions injects automatically isn't enough for every job: it can't read other private repos on the same account, and pushes it signs don't trigger downstream workflows, so a git push from inside a GITHUB_TOKEN-authenticated job never fires the deploy workflow waiting on that branch. The workaround there is a repo secret, GH_LOC_TOKEN, a classic PAT scoped to repo, that the job's gh calls use instead. The scripts that read it fall back cleanly: if (env.GH_LOC_TOKEN && !env.GH_TOKEN) env.GH_TOKEN = env.GH_LOC_TOKEN before calling gh, so local runs (already authenticated via gh auth login) never need the PAT at all, and CI runs never touch anything except the PAT. Same lesson as the workflow-scope rejection, one level up: the token in front of you decides what gh can do, and it's worth knowing what that token is before the command fails.
gh pr, gh run, gh repo: the verbs you use every day
Once auth stops being a surprise, gh collapses into three families of subcommand that cover almost everything a solo shipping operation does in a day.
gh pr is the one you'll type most. The documented subcommands are create, list, status, checkout, checks, close, comment, diff, edit, lock, merge, ready, reopen, revert, review, unlock, update-branch, and view. In practice a normal day uses a small slice of that list: gh pr create to open the PR from the branch you're already on, gh pr checks to watch CI without switching tabs, gh pr diff to re-read what you're actually shipping one more time, gh pr view --web when you do need the browser, and gh pr merge --squash --delete-branch to ship it once it's green.
gh run is the CI half of the same loop. Its subcommands, per GitHub's own manual, are cancel, delete, download, list, rerun, view, and watch, described as letting you "list, view, and watch recent workflow runs from GitHub Actions." gh run watch is the one worth forming a habit around: point it at a run and it streams status until the run finishes, instead of you refreshing a browser tab. gh run rerun --failed earns its keep on the day a deploy fails for a reason that has nothing to do with your code, a transient FTP timeout to the host is the exact case this site has hit, where re-running the failed jobs recovers the deploy without a throwaway commit just to retrigger the workflow.
gh repo doesn't have as tidy a documented verb list, but it's the one that turns gh into a scripting primitive rather than a PR helper. gh repo list <owner> --json name,primaryLanguage returns structured JSON for every repo under an account in one call, which is exactly the shape a script needs and a browser tab isn't.
When there's no subcommand: gh api and --jq
The three verb families above cover the shapes GitHub anticipated. Plenty of real questions don't fit any of them: how many commits has this repo actually got, how much CI time did each workflow burn this month, what's in a file on a branch you haven't checked out. For those, gh api is the escape hatch, and it's not a fallback you reach for occasionally, it's a first-class part of the daily toolkit.
gh api makes an authenticated request to any GitHub REST endpoint using the same token gh already holds. Its key flags: --jq <string> to filter the response through jq without a separate pipe, -X/--method (GET by default), -H/--header for extra headers, -f/--raw-field or -F/--field for parameters, --paginate to walk every page of a paginated response automatically, and --cache <duration> to avoid re-hitting the same endpoint while iterating. GitHub's own documented example is gh api repos/{owner}/{repo}/issues --jq '.[].title', and once that pattern clicks, most one-off GitHub questions become a single line: a file tree is gh api repos/OWNER/REPO/git/trees/main?recursive=1 --jq '.tree[].path', a README is gh api repos/OWNER/REPO/readme --jq '.content'.
The billing diagnosis, entirely in gh api
The clearest proof that gh api earns its place is a diagnosis that had no subcommand to reach for at all. When this site's Actions bill needed explaining (which workflow was burning minutes, how many runs, how many were failing), there was no dashboard for "runs grouped by workflow with total duration," and no gh subcommand shaped like that question either. The whole diagnosis was one call:
gh api "repos/OWNER/REPO/actions/runs?per_page=100" \
--jq '[.workflow_runs[] | {name, conclusion,
mins: (((.updated_at | fromdateiso8601) - (.created_at | fromdateiso8601)) / 60)}]
| group_by(.name)
| map({workflow: .[0].name, runs: length,
total_mins: (map(.mins) | add | round),
failures: (map(select(.conclusion=="failure")) | length)})'
gh api fetches the raw run list; --jq does the rest entirely client-side, converting timestamps to minutes per run, grouping by workflow name, and summing. No script file, no separate jq install to reach for (jq ships as gh api's built-in filter), no dashboard login. That's the pattern worth keeping: when a question has a shape GitHub's REST API can answer but gh's subcommands don't, gh api --jq gets you there in one line instead of a script.
The same escape hatch scales past one-off diagnosis into a scheduled job. This site's workshop-XP fetcher runs three different gh calls in sequence: gh repo list for the account's repos, gh api repos/{account}/{repo}/languages for byte-counts per language, and gh api repos/{account}/{repo}/commits?per_page=1 -i for a total commit count, read not from the response body but from the Link response header's rel="last" page number, since GitHub doesn't expose a total-count field on that endpoint directly. Three verbs, one script, and the last one only exists because gh api exposes response headers, which no gh subcommand does.
The habit worth keeping
None of this replaces reading a diff in the browser when a diff is genuinely easier to read there. But the default should invert: start in the terminal, reach for gh pr, gh run, or gh repo first, and drop to gh api --jq the moment the question doesn't have a subcommand shaped like it. Check gh auth status before touching .github/workflows/*, not after the push bounces. That's the whole daily driver.
Diagnose your own repo with gh api, no dashboard
Pick any repo you own with Actions history and answer a question about it that no gh subcommand answers directly: total CI minutes over the last 100 runs, grouped by workflow, with a failure count per workflow. Use gh api and --jq only, no script file.
Expected behaviour
- gh auth status run first, confirming the token scope covers reading Actions data on that repo
- A single gh api call against repos/OWNER/REPO/actions/runs with --jq doing the grouping and duration maths, not a separate script
- Output showing at least workflow name, run count, and total minutes per workflow
- One follow-up gh api --jq variant answering a second question the first command didn't (e.g. failures only, or the slowest single run)
PROVE IT Paste the exact gh api command you ran plus its output for at least two distinct workflows.
A push that edits .github/workflows/deploy.yml gets rejected even though the diff is correct and tests pass. What's the most likely cause?
Which gh auth refresh flag lets you add the missing workflow scope to an existing token?
You need to know how many CI minutes each of your workflows burned over the last 100 runs, split by pass and fail. No gh subcommand answers that directly. What do you run, and why does that approach beat writing a standalone script?
Show answer
gh api against the repo's actions/runs endpoint, piped through --jq to convert each run's created_at and updated_at into a duration, group the results by workflow name, and sum. It beats a standalone script because gh api already holds the authenticated token and jq is built into the flag, so the whole diagnosis is one command with nothing to install, save, or maintain afterwards.
↺ re-read: “The billing diagnosis, entirely in gh api”