Checkpoint Lab — GitHub CLI, REST/GraphQL APIs, Workflow Dispatch, and Automation Control
Build a rerunnable control script that dispatches a lab workflow, owns the returned run ID, waits for terminal state, preserves evidence, and refuses to mutate a different lab run unless every guard matches.
Learning objectives
- Predict controller and workflow state transitions before any API mutation.
- Dispatch through the versioned REST API and capture the exact returned workflow run ID.
- Wait for one exact run to become terminal while preserving repository/workflow/request/source guards.
- Prove the cancellation script refuses a different lab request even when given a valid run ID.
- Deliver an evidence packet with API version, auth identity, workflow/run IDs, attempts, status, artifacts, rate-limit observations and limitations.
1. Checkpoint mission
Build a disposable repository named
gha-api-control-checkpoint. Publish the target
workflow, then write a local controller that dispatches one run,
captures the exact workflow_run_id returned by the
current REST endpoint, waits for terminal state, and preserves
resource/evidence metadata. A separate cancellation helper must
refuse to cancel a valid run that belongs to a different request ID.
The objective is not to prove that you can make HTTP calls. The objective is to prove that the controller cannot silently mutate the wrong run, cannot confuse request acceptance with execution success, and leaves enough evidence to reconstruct every control decision.
2. Current assumptions and preflight
| Assumption | Checkpoint value / verification |
|---|---|
| GitHub behavior | GitHub.com semantics verified 2026-09-10. |
| REST API |
2026-03-10 explicitly sent; current dispatch
response includes workflow_run_id and URLs.
|
| GitHub CLI |
Current release reference 2.100.0; record your
installed gh --version.
|
| Runner |
ubuntu-24.04; record actual runner metadata
from the run.
|
| Evidence action |
actions/upload-artifact v7.0.1 →
043fb46d1a93c77aae656e7c1c64a875d1fc6a0a.
|
| Credentials | Disposable repository only; authenticated gh session or least-privileged fine-grained/App credential; never print token. |
| Cloud/enterprise | None required. No package registry, environment approval, self-hosted runner or cloud account. |
gh --version
gh auth status
gh api user --jq '.login'
gh repo view --json nameWithOwner,url,visibility
git rev-parse HEAD
git status --short
gh api \
-H 'Accept: application/vnd.github+json' \
-H 'X-GitHub-Api-Version: 2026-03-10' \
rate_limit --jq '.resources.core'
3. Predict state changes before dispatch
Write checkpoint-predictions.md before running the
controller. At minimum make these predictions, then later mark each
as verified/disproved:
-
Prediction A: one REST dispatch returns a numeric
run ID; that exact run has event
workflow_dispatch, the resolved workflow ID and the controller request ID indisplay_title. - Prediction B: a pass run transitions queued/in-progress → completed/success and publishes one bounded evidence artifact without repository-write permissions inside the job.
- Prediction C: the cancellation helper exits with REFUSE and sends no cancel POST when a valid run ID is paired with the wrong request ID.
- Prediction D: an ordinary cancellation of a correctly owned wait run returns accepted control state first and only later becomes terminal/cancelled.
- Prediction E: no token, Authorization header or whole GitHub context appears in the evidence packet.
4. Exact checkpoint target workflow
Use this same intentionally small workflow. It creates one artifact
that belongs to a run/attempt and has no external deployment side
effects. The request ID is attacker-controlled input in principle,
so it is passed through env and validated before shell
use.
name: Control target
run-name: "control-lab ${{ inputs.request_id }} / ${{ inputs.behavior }}"
on:
workflow_dispatch:
inputs:
request_id:
description: "Controller-generated lab request ID"
type: string
required: true
behavior:
description: "Synthetic behavior"
type: choice
required: true
options: [pass, fail, wait]
permissions: {}
jobs:
control:
name: Controlled lab job
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Validate bounded inputs
shell: bash
env:
REQUEST_ID: ${{ inputs.request_id }}
BEHAVIOR: ${{ inputs.behavior }}
run: |
set -euo pipefail
[[ "$REQUEST_ID" =~ ^lab-[A-Za-z0-9._-]{1,80}$ ]] || {
echo "invalid request_id" >&2
exit 2
}
case "$BEHAVIOR" in
pass|fail|wait) ;;
*) echo "invalid behavior" >&2; exit 2 ;;
esac
- name: Record run-owned evidence
shell: bash
env:
REQUEST_ID: ${{ inputs.request_id }}
BEHAVIOR: ${{ inputs.behavior }}
run: |
set -euo pipefail
{
printf 'request_id=%s\n' "$REQUEST_ID"
printf 'behavior=%s\n' "$BEHAVIOR"
printf 'run_id=%s\n' "$GITHUB_RUN_ID"
printf 'run_attempt=%s\n' "$GITHUB_RUN_ATTEMPT"
printf 'sha=%s\n' "$GITHUB_SHA"
printf 'ref=%s\n' "$GITHUB_REF"
printf 'runner_os=%s\n' "$RUNNER_OS"
} > control-evidence.txt
- name: Execute synthetic behavior
shell: bash
env:
BEHAVIOR: ${{ inputs.behavior }}
run: |
set -euo pipefail
case "$BEHAVIOR" in
pass) echo "synthetic success" ;;
fail) echo "synthetic failure" >&2; exit 17 ;;
wait) echo "waiting so exact cancellation can be tested"; sleep 90 ;;
esac
- name: Retain bounded evidence
if: ${{ always() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: control-evidence-${{ github.run_id }}-${{ github.run_attempt }}
path: control-evidence.txt
retention-days: 7
if-no-files-found: error
5. Exact dispatch-and-wait controller
Save this as scripts/control-checkpoint.sh. It issues
one dispatch POST and then performs only exact-run reads until
terminal state. Because the current API returns the run ID, there is
no “latest run” search in the normal path.
#!/usr/bin/env bash
set -euo pipefail
: "${GH_REPO:?Set GH_REPO=OWNER/gha-api-control-checkpoint}"
API_VER="2026-03-10"
WORKFLOW="control-target.yml"
REF="${REF:-main}"
BEHAVIOR="${1:-pass}"
EVIDENCE_DIR="checkpoint-evidence"
mkdir -p "$EVIDENCE_DIR"
case "$BEHAVIOR" in pass|fail|wait) ;; *) echo "invalid behavior" >&2; exit 2;; esac
REQUEST_ID="lab-$(date -u +%Y%m%dT%H%M%SZ)-$$-$RANDOM"
WORKFLOW_ID=$(gh api \
-H 'Accept: application/vnd.github+json' \
-H "X-GitHub-Api-Version: $API_VER" \
"repos/$GH_REPO/actions/workflows/$WORKFLOW" --jq '.id')
# Single dispatch mutation. The response gives exact run identity.
RUN_ID=$(gh api -X POST \
-H 'Accept: application/vnd.github+json' \
-H "X-GitHub-Api-Version: $API_VER" \
"repos/$GH_REPO/actions/workflows/$WORKFLOW_ID/dispatches" \
-f "ref=$REF" \
-f "inputs[request_id]=$REQUEST_ID" \
-f "inputs[behavior]=$BEHAVIOR" \
--jq '.workflow_run_id')
[[ "$RUN_ID" =~ ^[0-9]+$ ]] || exit 3
echo "created run_id=$RUN_ID request_id=$REQUEST_ID workflow_id=$WORKFLOW_ID behavior=$BEHAVIOR"
EXPECTED_TITLE="control-lab $REQUEST_ID / $BEHAVIOR"
printf 'repository=%s\nworkflow_path=%s\nworkflow_id=%s\nrun_id=%s\nrequest_id=%s\nbehavior=%s\napi_version=%s\n' \
"$GH_REPO" "$WORKFLOW" "$WORKFLOW_ID" "$RUN_ID" "$REQUEST_ID" "$BEHAVIOR" "$API_VER" \
> "$EVIDENCE_DIR/control-$RUN_ID.txt"
terminal=false
for _ in $(seq 1 100); do
row=$(gh api \
-H 'Accept: application/vnd.github+json' \
-H "X-GitHub-Api-Version: $API_VER" \
"repos/$GH_REPO/actions/runs/$RUN_ID" \
--jq '[.workflow_id,.event,.display_title,.status,(.conclusion // ""),.head_sha,.run_attempt,.html_url] | @tsv')
IFS=$'\t' read -r actual_wf event title status conclusion sha attempt url <<< "$row"
[[ "$actual_wf" == "$WORKFLOW_ID" ]] || { echo "REFUSE workflow mismatch" >&2; exit 4; }
[[ "$event" == "workflow_dispatch" ]] || { echo "REFUSE event mismatch" >&2; exit 4; }
[[ "$title" == "$EXPECTED_TITLE" ]] || { echo "REFUSE request mismatch" >&2; exit 4; }
printf '%s\n' "$row" > "$EVIDENCE_DIR/latest-$RUN_ID.tsv"
if [[ "$status" == "completed" ]]; then terminal=true; break; fi
sleep 3
done
$terminal || { echo "timeout" >&2; exit 11; }
gh api \
-H 'Accept: application/vnd.github+json' \
-H "X-GitHub-Api-Version: $API_VER" \
"repos/$GH_REPO/actions/runs/$RUN_ID" \
> "$EVIDENCE_DIR/run-$RUN_ID-attempt-$attempt.json"
gh api \
-H 'Accept: application/vnd.github+json' \
-H "X-GitHub-Api-Version: $API_VER" \
"repos/$GH_REPO/actions/runs/$RUN_ID/artifacts" \
> "$EVIDENCE_DIR/artifacts-$RUN_ID-attempt-$attempt.json"
printf 'run_id=%s attempt=%s conclusion=%s sha=%s url=%s\n' \
"$RUN_ID" "$attempt" "$conclusion" "$sha" "$url"
[[ "$conclusion" == "success" ]]
6. Run A — pass, wait, preserve evidence
export GH_REPO=OWNER/gha-api-control-checkpoint
chmod +x scripts/control-checkpoint.sh scripts/cancel-exact.sh
scripts/control-checkpoint.sh pass
# Record the printed run_id, attempt, conclusion, SHA and URL.
# Cross-check independently with CLI and REST.
gh run view RUN_A_ID --json databaseId,attempt,event,headSha,status,conclusion,url,workflowDatabaseId
gh api -H 'X-GitHub-Api-Version: 2026-03-10' "repos/$GH_REPO/actions/runs/RUN_A_ID" --jq '{id,run_attempt,event,workflow_id,head_sha,status,conclusion,display_title,html_url}'
Run A must be a distinct run at attempt 1. If the controller returns success but the API says a different workflow ID/request title, the checkpoint fails even if the job itself is green.
7. Exact cancellation helper
Save the helper below as scripts/cancel-exact.sh. It
accepts variables only after re-reading the run. The critical guard
is exact request ownership, not merely “same repository” or “same
workflow.”
#!/usr/bin/env bash
set -euo pipefail
: "${GH_REPO:?}"
: "${RUN_ID:?}"
: "${REQUEST_ID:?}"
: "${BEHAVIOR:?}"
API_VER="2026-03-10"
WORKFLOW="control-target.yml"
WORKFLOW_ID=$(gh api \
-H 'Accept: application/vnd.github+json' \
-H "X-GitHub-Api-Version: $API_VER" \
"repos/$GH_REPO/actions/workflows/$WORKFLOW" --jq '.id')
EXPECTED_TITLE="control-lab $REQUEST_ID / $BEHAVIOR"
row=$(gh api \
-H 'Accept: application/vnd.github+json' \
-H "X-GitHub-Api-Version: $API_VER" \
"repos/$GH_REPO/actions/runs/$RUN_ID" \
--jq '[.workflow_id,.event,.display_title,.status,(.conclusion // ""),.html_url] | @tsv')
IFS=$'\t' read -r actual_wf event title status conclusion url <<< "$row"
[[ "$actual_wf" == "$WORKFLOW_ID" ]] || { echo "REFUSE: workflow mismatch" >&2; exit 4; }
[[ "$event" == "workflow_dispatch" ]] || { echo "REFUSE: event mismatch" >&2; exit 4; }
[[ "$title" == "$EXPECTED_TITLE" ]] || { echo "REFUSE: request ownership mismatch" >&2; exit 4; }
[[ "$status" == "queued" || "$status" == "in_progress" ]] || {
echo "REFUSE: run is not an active cancellable lab run" >&2
exit 5
}
echo "Cancelling exact owned run $RUN_ID ($url)"
gh api -X POST \
-H 'Accept: application/vnd.github+json' \
-H "X-GitHub-Api-Version: $API_VER" \
"repos/$GH_REPO/actions/runs/$RUN_ID/cancel" --silent
8. Run B — prove refusal against an unrelated request
Create a second owned disposable wait run with a different
request ID. The point is to test the guard without involving another
person’s workflow. Record RUN_B_ID and its real
REQUEST_B_ID from that controller output/ledger.
# Start a wait run in a second terminal; capture its RUN_B_ID and REQUEST_B_ID.
scripts/control-checkpoint.sh wait || true
# Negative test: pair Run B with Run A's request ID.
export RUN_ID=RUN_B_ID
export REQUEST_ID=REQUEST_A_ID
export BEHAVIOR=wait
scripts/cancel-exact.sh && {
echo "ERROR: guard unexpectedly allowed unrelated request" >&2
exit 1
}
# Expected: REFUSE: request ownership mismatch. No cancel POST is sent.
# Positive cleanup: use Run B's actual request ID.
export REQUEST_ID=REQUEST_B_ID
scripts/cancel-exact.sh
After the positive cancel, read the exact Run B until it reports terminal/cancelled. Preserve the failed negative-guard command output as checkpoint evidence. Do not force-cancel unless ordinary cancellation demonstrably stalls and you have preserved the run evidence.
9. Optional Run C — controlled failure and rerun semantics
Dispatch behavior=fail to create an intentional first
failure. Preserve attempt 1 metadata/artifact/log link, then run
gh run rerun RUN_C_ID --failed. Verify the same run
ID/source has a higher attempt and still fails because the original
input is unchanged. This is optional for the checkpoint pass
condition but completes the chapter’s rerun evidence model.
gh run view RUN_C_ID --attempt 1 --json attempt,conclusion,headSha,jobs,url > run-c-attempt-1.json
gh run rerun RUN_C_ID --failed
gh run view RUN_C_ID --json attempt,conclusion,headSha,jobs,url
10. Read-only GraphQL cross-check
Resolve the target workflow node_id through REST and
query its recent runs through GraphQL. Record
pageInfo and any errors. This is a
cross-check, not the controller mutation path.
NODE_ID=$(gh api -H 'X-GitHub-Api-Version: 2026-03-10' "repos/$GH_REPO/actions/workflows/control-target.yml" --jq '.node_id')
gh api graphql -F id="$NODE_ID" -f query='
query($id: ID!) {
node(id: $id) {
... on Workflow {
databaseId name state
runs(first: 10, orderBy: {field: CREATED_AT, direction: DESC}) {
nodes { databaseId runAttempt event displayTitle url }
pageInfo { hasNextPage endCursor }
}
}
}
}' > checkpoint-evidence/graphql-workflow-runs.json
11. Required evidence packet
-
checkpoint-predictions.mdwith each prediction verified/disproved. - Authenticated actor identity and installed gh version without any token value or Authorization header.
- Repository name/visibility and exact target workflow path, numeric ID and GraphQL node ID.
-
API version
2026-03-10, relevant request methods/endpoints and observed response/final states. - Run A ID/attempt/event/SHA/status/conclusion/display title/URL plus artifact metadata.
- Run B real request/run identity, the negative REFUSE evidence, the later authorized cancel evidence and terminal state.
- If Run C is used: attempt 1 preserved before rerun, then the incremented attempt with same source identity.
- REST pagination/rate-limit note and GraphQL cursor/error/rate-limit note.
- An assumptions/limitations note: no cloud, deployment environment or external target exists in this lab; API response proves GitHub control state only.
12. Verification checklist
- No mutation selects a run by latest/list position.
- The normal dispatch path consumes the server-returned workflow_run_id.
- Every cancel/rerun target is read back and checked against repository/workflow/event/request ownership first.
- Polling is bounded and reads one exact run; it does not retry POST inside the loop.
- The controller distinguishes accepted HTTP status from final run conclusion.
- The negative cancellation test refuses a valid run with the wrong request ID and sends no cancel request.
- The positive cleanup cancellation acts only on the exact wait run and verifies terminal state afterward.
- Evidence contains no token, Authorization header, OIDC token, secret context or production identifier.
- Pagination and rate-limit behavior are documented rather than assumed away.
13. Faithful local simulation path
If GitHub is unavailable, simulate the controller as a finite-state
machine using local JSON fixtures: one dispatch response containing
workflow_run_id, successive GET responses for
queued/in-progress/completed, and a second run with a mismatched
request title. Run the guard logic against those fixtures and prove
it refuses the mismatched run. Mark the result as a simulation:
local fixtures cannot prove GitHub authentication, API permissions,
rate limits, queueing, actual run attempts or server-side
cancellation behavior.
14. Cleanup and rollback
- Do not delete Run A/B/C until evidence review is complete; deletion is not required for lab cleanup.
- Ensure the owned wait run is terminal so no GitHub-hosted minutes continue unintentionally.
- Revoke any fine-grained PAT created solely for the exercise; normal gh OAuth login can remain according to your workstation policy.
- Delete the disposable repository only after evidence is no longer needed.
- If any real credential was accidentally printed during experimentation, revoke/rotate it rather than relying on local file deletion.
15. What Chapter 31 adds — and the bridge to Chapter 32
Chapter 31 adds an Actions control-plane operating model: versioned API contracts, exact resource identity, least-privileged automation identities, pagination, rate-limit-aware observation, reconcilable mutations and guarded reruns/cancellation. Chapter 32 takes the same evidence discipline into performance and cost: queue time, parallelism, cache effectiveness, usage signals and optimization decisions must be measured without sacrificing correctness.
Knowledge check
What is the checkpoint’s strongest defense against cancelling the wrong run?
The mutation helper re-reads the exact numeric run and requires workflow ID, workflow_dispatch event and exact controller request ID/display-title ownership before POST.
Why is Run B a safe “unrelated run” test?
It is still a disposable run created by the learner, but it carries a different request ID. The guard can prove refusal without touching another person’s work.
What should a controller do after cancel returns 202?
Poll/read the exact run until terminal state, then inspect any external side effects separately.
Why is an optional failing rerun expected to fail again?
A rerun keeps the original source/event inputs; it is same-run diagnostic repetition, not a corrected new run.
What evidence proves dispatch did not depend on a race-prone list query?
The controller ledger records the workflow_run_id returned directly by the versioned dispatch response and later reads that same run ID.
Official references and version notes
- GitHub REST API versions — Current supported REST versions and X-GitHub-Api-Version behavior; examples pin 2026-03-10.
- REST: workflows — Workflow discovery and current workflow_dispatch endpoint, permissions, inputs and response schema.
- REST: workflow runs — Exact run read, cancel, rerun, failed-job rerun and logs endpoints.
- REST: workflow jobs — Exact workflow-job identity and job rerun/log APIs.
- REST pagination — Link headers, per_page and reliable traversal of paginated collections.
- REST rate limits — Primary/secondary rate-limit behavior and retry guidance.
- GraphQL Actions schema — Workflow and WorkflowRun objects, runAttempt and cursor-based run connections.
- GraphQL rate/query limits — Point budgets, cursor constraints and query limits.
- GitHub CLI: gh api — Authenticated REST/GraphQL requests, headers, --paginate, --slurp and JSON selection.
- GitHub CLI: workflow run — Manual workflow dispatch through gh and supported input/ref forms.
- GitHub CLI: run list — Run listing and exact filters/JSON fields.
- GitHub CLI: run view — Attempt-aware run inspection, jobs and logs.
- GitHub CLI: run watch — Run watching and current fine-grained PAT limitation.
- GitHub CLI: run rerun — Full/failed/specific-job reruns and the job databaseId requirement.
- GitHub CLI releases — Current GitHub CLI release history; lab assumption recorded as 2.100.0 on 2026-09-10.
- Personal access tokens — Fine-grained token preference and guidance to use GitHub Apps for long-lived organization automation.
- actions/upload-artifact v7.0.1 — Full-SHA pin used to retain tiny run evidence in the disposable target workflow.
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.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0Send only Ethereum/ERC-20 compatible assets to this
address.