Chapter 12Lesson 05~190 minutes

Checkpoint Lab — GitHub CLI, gh Authentication, Repository Operations, and Scripting Workflows

The checkpoint assembles the chapter into a small operational artifact: a repository report that can run in a human shell or CI without guessing context, prompting, exposing secrets, or hiding failures. You will also prove that your error path behaves predictably when authorization is denied.

Checkpoint labCI safetyError handlingRead-only report

Learning objectives

  • Build a read-only repository-report script that returns repository metadata, open Issues, and open pull requests as structured JSON.
  • Make host/repository context, prompt behavior, paging, output, and exit codes deterministic enough for CI.
  • Verify independently that the report targets the intended repository and does not mutate GitHub state.
  • Exercise a sanitized authorization-denied fixture and optionally confirm the same control path with a short-lived least-privilege token.
  • Write an operator handoff that states inputs, outputs, failure codes, security constraints, and cleanup.
Checkpoint boundary: report execution is read-only. The only hosted cleanup mutations close disposable work and archive the disposable repository. The mandatory authorization-failure test uses a sanitized local fixture; a live underprivileged-token rehearsal is optional and security-sensitive.

1. Checkpoint scenario and assumptions

You are preparing a small repository-report job for CI. It must identify one repository, return repository metadata plus open Issues and pull requests, never prompt, never print credentials, and use predictable status codes. Its default behavior is entirely read-only.

Mandatory path: GitHub.com, GitHub Free, the disposable public repository from Lesson 2 (or a fresh equivalent), GitHub CLI, Bash/Git Bash. A PowerShell implementation is provided for Windows-native automation. No paid plan, organization, self-hosted runner, or production credential is required.
Requirement Assumption / proof
Repository Explicit OWNER/REPO; prove with gh repo view ... --json nameWithOwner.
Host Explicit github.com by default; script accepts another host only when intentionally supplied.
Authentication Existing safe gh login for human lab; CI would inject a job-scoped token such as GH_TOKEN.
Authorization Read access is sufficient for the report. Optional live permission-failure rehearsal uses a separate short-lived, intentionally underprivileged fine-grained PAT.
Mutations None in report mode. Cleanup mutations are explicit and confined to the disposable lab.

2. Predict before execution

Write these predictions in your local lab note before running the script:

  1. Prediction A: running the report changes no Git ref, Issue, pull request, repository setting, workflow, package, or API object; it only performs authenticated reads and writes JSON to stdout.
  2. Prediction B: moving to another local Git directory does not change the report target because the repository is supplied explicitly.
  3. Prediction C: an authorization-denied result causes a non-zero report status and no mutation; the script never retries the denied operation as a write.

After each phase, verify the prediction independently with gh repo view, Issue/PR list state, and local Git refs.

3. Build the Bash/Git Bash repository-report script

Create repo-report.sh. This version maps failures to documented wrapper codes while preserving original CLI stderr. It never uses set -x and never reads/prints a token value.

#!/usr/bin/env bash
set -u

REPO="${1:-}"
HOST="${2:-github.com}"

# Wrapper exit contract:
# 0 success, 64 usage, 65 auth, 66 repo read, 67 issue read, 68 PR read
if [[ -z "$REPO" ]]; then
  echo "usage: repo-report.sh OWNER/REPO [HOST]" >&2
  exit 64
fi

export GH_PROMPT_DISABLED=1
export GH_HOST="$HOST"
export GH_REPO="$REPO"
export NO_COLOR=1

if ! gh auth status --active --hostname "$HOST" >/dev/null 2>&1; then
  echo "authentication preflight failed for host=$HOST" >&2
  exit 65
fi

if ! repo_json="$(gh repo view "$REPO" \
  --json nameWithOwner,visibility,defaultBranchRef,isArchived,url,viewerPermission)"; then
  echo "repository read failed for $HOST/$REPO" >&2
  exit 66
fi

if ! issues_json="$(gh issue list -R "$REPO" --state open --limit 100 \
  --json number,title,updatedAt,url)"; then
  echo "issue read failed for $HOST/$REPO" >&2
  exit 67
fi

if ! prs_json="$(gh pr list -R "$REPO" --state open --limit 100 \
  --json number,title,isDraft,headRefName,baseRefName,updatedAt,url)"; then
  echo "pull-request read failed for $HOST/$REPO" >&2
  exit 68
fi

# Command substitution is non-TTY; these are JSON values. Keep them quoted.
printf '{"repository":%s,"openIssues":%s,"openPullRequests":%s}\n' \
  "$repo_json" "$issues_json" "$prs_json"

Run it and capture status separately from the JSON:

./repo-report.sh "$REPO" github.com > repo-report.json
status=$?
printf 'exit=%s\n' "$status"
python -m json.tool repo-report.json
Why python -m json.tool? It validates/presents the final JSON in the lab. The report itself does not depend on Python. In a production consumer, use the language/runtime already responsible for the next processing step.

4. PowerShell equivalent for Windows-native CI

The same contract can be expressed without Bash. PowerShell must check $LASTEXITCODE immediately after each external gh invocation. Do not rely on exceptions alone for native-process failures in all PowerShell configurations.

param(
  [Parameter(Mandatory=$true)][string]$Repo,
  [string]$HostName = "github.com"
)

$env:GH_PROMPT_DISABLED = "1"
$env:GH_HOST = $HostName
$env:GH_REPO = $Repo
$env:NO_COLOR = "1"

gh auth status --active --hostname $HostName *> $null
if ($LASTEXITCODE -ne 0) {
  Write-Error "authentication preflight failed for host=$HostName"
  exit 65
}

$repoJson = gh repo view $Repo --json nameWithOwner,visibility,defaultBranchRef,isArchived,url,viewerPermission
if ($LASTEXITCODE -ne 0) { exit 66 }

$issuesJson = gh issue list -R $Repo --state open --limit 100 --json number,title,updatedAt,url
if ($LASTEXITCODE -ne 0) { exit 67 }

$prsJson = gh pr list -R $Repo --state open --limit 100 --json number,title,isDraft,headRefName,baseRefName,updatedAt,url
if ($LASTEXITCODE -ne 0) { exit 68 }

[ordered]@{
  repository = ($repoJson | ConvertFrom-Json)
  openIssues = @($issuesJson | ConvertFrom-Json)
  openPullRequests = @($prsJson | ConvertFrom-Json)
} | ConvertTo-Json -Depth 8
exit 0

Run: .\repo-report.ps1 -Repo $REPO | Set-Content repo-report.json, then Get-Content repo-report.json | ConvertFrom-Json.

5. Verify target and read-only behavior independently

Do not trust the report merely because it returned valid JSON. Compare it with independent GitHub and Git inspection:

gh repo view "$REPO" --json nameWithOwner,url,defaultBranchRef,viewerPermission
gh issue list -R "$REPO" --state open --json number,title,url
gh pr list -R "$REPO" --state open --json number,title,headRefOid,url

# Local Git evidence: report execution should not change refs
git status --short
git show-ref --heads
  • The report’s repository.nameWithOwner equals the explicit REPO argument.
  • Open Issue/PR counts and identifiers agree with independent first-class queries.
  • No new local commits/branches appear because the report is read-only.
  • No Issue title/state, PR head SHA, repository setting, or workflow state changes as a result of report execution.

6. Prove that current-directory context cannot redirect the report

Change to a different Git repository (any safe local clone), then run the report with the same explicit argument:

cd /path/to/another/local/repository
./path/to/repo-report.sh "$REPO" github.com > second-report.json

# The reported nameWithOwner must still be the explicit target.
python - <<'PY'
import json
print(json.load(open('second-report.json'))['repository']['nameWithOwner'])
PY

This verifies Prediction B. If the target changes, the script still contains inferred repository context somewhere and is not CI-safe.

7. Test an intentional permission failure safely

Mandatory no-new-credential test: exercise the report’s permission/error classifier with a sanitized 403 fixture. This proves that the CI control path fails closed without creating or exposing a real token.

cat > forbidden-fixture.json <<'EOF'
{
  "status": 403,
  "message": "Resource not accessible by personal access token",
  "documentation_url": "https://docs.github.com/rest"
}
EOF

python - <<'PY'
import json, sys
x=json.load(open('forbidden-fixture.json'))
if x.get('status') == 403:
    print('authorization denied: do not retry or broaden blindly', file=sys.stderr)
    raise SystemExit(77)
raise SystemExit(0)
PY
printf 'fixture exit=%s (expected 77)\n' "$?"
Optional live free-compatible authorization rehearsal: create a short-lived fine-grained PAT locally, scoped only to the disposable repository, with repository metadata/read access but without Issues write. Put it only in a temporary process environment as GH_TOKEN; attempt an Issue edit that should be denied; capture the sanitized 403/error and exit status; then immediately unset GH_TOKEN and revoke the token. Never print, paste, store, commit, screenshot, or send the token. If it is exposed, revoke/rotate first.

The optional live test validates the same conceptual boundary: authentication can succeed while authorization for a specific mutation fails. The correct response is not “add every scope”; it is to decide whether that mutation belongs in the automation and grant only the minimal permission if justified.

8. Add one versioned raw API read as an audit cross-check

Use gh api as an independent view of repository identity—not as a replacement for the first-class report:

gh api \
  -H "Accept: application/vnd.github+json" \
  -H "X-GitHub-Api-Version: 2026-03-10" \
  "repos/$REPO" \
  --jq '{full_name,visibility,default_branch,archived}'

For a larger inventory, add --paginate only to endpoints that paginate and handle rate limits explicitly. Do not add pagination flags as decoration to single-resource GETs.

9. Operator handoff policy

Write a short REPO_REPORT_RUNBOOK.md beside the script with these rules:

  • Inputs: supported host and explicit OWNER/REPO; no implicit current-directory target.
  • Authentication: human uses secure stored login; CI injects a job-scoped token. Never enable token-display/debug logging in routine operation.
  • Authorization: report needs read-only repository/Issues/PR visibility. Any future mutation is a separate reviewed mode with least privilege and idempotence design.
  • Outputs: stdout is JSON only on success; diagnostics go to stderr; exit-code mapping is documented.
  • Extensions/aliases: report depends only on core GitHub CLI commands. Third-party extensions are not part of the trusted runtime unless separately reviewed/pinned.
  • Failure handling: no blind retry on authentication, permission, validation, or policy errors; preserve sanitized evidence and classify first.
  • Secrets: never log token values or dump the full environment. Exposed credential response begins with revoke/rotate.

10. Cleanup and rollback

The report created no hosted state, so its cleanup is local: remove repo-report.json, fixtures, and scripts if you do not want to keep them. The Chapter 12 repository itself is disposable; preserve any screenshots/log snippets with credentials excluded, then close the test Issue/PR and archive the repository through the UI or CLI as a reversible endpoint.

# Close disposable work first
gh issue close "$ISSUE_NUMBER" -R "$REPO" --comment "Chapter 12 lab complete."
gh pr close "$PR_URL" -R "$REPO" --delete-branch

# Reversible repository cleanup
gh repo archive "$REPO" --yes

# Verify hosted state
gh repo view "$REPO" --json nameWithOwner,isArchived,url
Destructive alternative: permanent repository deletion is not required by this course. If you choose to delete the disposable repository later, inspect/preserve evidence first and follow GitHub’s current deletion/restore guidance.

11. Lesson summary

The checkpoint produced a CI-oriented, read-only repository report with explicit target context, structured output, predictable wrapper exit codes, separate stdout/stderr, no secret logging, and a tested authorization-denied path. Those are the same operational properties GitHub Actions jobs will need when Chapter 13 introduces workflow events, YAML, job permissions, and runner execution.

Knowledge check

Why can the report run from any local directory without changing target?

What proves the report is read-only?

The optional fine-grained token can read the repo but an Issue edit returns 403. Is authentication broken?

Why should stdout contain JSON while diagnostics go to stderr?

What is the chapter’s bridge to GitHub Actions?

Next lesson

Next: GitHub Actions Foundations: Workflows, Events, YAML, Permissions, and Execution Model: Concepts, Architecture, and Mental Model

Further reading — current official GitHub sources

Keep the academy open

Support free, practical DevOps education.

Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0 Send only Ethereum/ERC-20 compatible assets to this address.