GitHub CLI, REST/GraphQL APIs, Workflow Dispatch, and Automation Control: Guided Hands-On Workflow
Control a disposable workflow through GitHub CLI and the current REST API, capture the exact dispatch-created run ID, poll it safely, guard cancellation/reruns, and compare a read-only GraphQL view.
Learning objectives
- Create a disposable workflow whose run name carries a validated controller request ID.
- Inspect workflow/run state with gh and REST before issuing any mutation.
- Dispatch with the current REST endpoint and capture the exact returned workflow_run_id.
- Poll one exact run with workflow/event/title guards, then preserve run and artifact metadata.
- Cancel/rerun only a controller-owned lab run and compare REST control with a read-only GraphQL query.
1. Disposable scenario and threat model
Create a throwaway repository named
gha-api-control-lab. The repository contains one manual
workflow, control-target.yml, with three synthetic
behaviors: pass, fail and wait. There are no production secrets,
cloud credentials or external targets. The controller runs from your
workstation with GitHub CLI and is allowed to mutate only runs that
carry the controller-generated request ID.
The trust boundary is deliberate. Workflow inputs are external data, so they are passed through environment variables and validated before shell use. The controller never derives ownership from “latest” or a mutable list position. Current REST dispatch gives the exact run ID, and every later mutation re-reads that run and proves workflow ID, event and run-name/request identity before acting.
2. Preflight: prove client, repository and permissions first
# In the disposable repository clone
gh --version # course reference: 2.100.0 current on 2026-09-10
gh auth status
gh api user --jq '.login'
gh repo view --json nameWithOwner,url,visibility
git rev-parse HEAD
git status --short
# Current REST contract and rate state
gh api \
-H 'Accept: application/vnd.github+json' \
-H 'X-GitHub-Api-Version: 2026-03-10' \
rate_limit --jq '.resources.core'
For a fine-grained PAT, the mutation path needs repository Actions
write; the read path needs Actions
read. If you authenticate interactively with
gh auth login, record the authenticated account and
repository access, not the token value. Do not enable HTTP tracing
that prints Authorization headers.
3. Build the target workflow: small, bounded and evidence-producing
The target workflow is not a deployment. It exists only to give the
controller safe state transitions to observe.
run-name embeds the validated request ID so an
ambiguous client-side failure can later be reconciled.
permissions: {} means the job itself does not need
repository write access. A GitHub-maintained artifact action is
pinned to a full commit SHA and stores only synthetic run metadata.
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
4. Publish and inspect the workflow resource before dispatch
git add .github/workflows/control-target.yml
git commit -m "lab: add guarded control target"
git push origin main
# Read-only workflow discovery. Record both path and numeric ID.
gh workflow list --all --json id,name,path,state
gh api \
-H 'Accept: application/vnd.github+json' \
-H 'X-GitHub-Api-Version: 2026-03-10' \
repos/{owner}/{repo}/actions/workflows/control-target.yml \
--jq '{id,name,path,state,html_url}'
If the workflow is disabled, missing from the default branch or the path resolves to the wrong ID, stop. A control script should never “try the POST and see” when the read-only resource preflight already disproves its assumptions.
5. List runs safely: inventory is not ownership
gh run list --workflow control-target.yml --limit 10 --json databaseId,attempt,event,headSha,status,conclusion,url
# When completeness matters, use pagination explicitly.
gh api --paginate -H 'Accept: application/vnd.github+json' -H 'X-GitHub-Api-Version: 2026-03-10' 'repos/{owner}/{repo}/actions/workflows/control-target.yml/runs?per_page=100' --jq '.workflow_runs[] | [.id,.run_attempt,.status,.conclusion,.head_sha] | @tsv'
Listing is useful for situational awareness and historical analysis. It is not how this controller chooses a mutation target. List order can change while your script runs; a new human or scheduled run can appear between “list” and “cancel.”
6. Dispatch and consume the exact returned run ID
For interactive use,
gh workflow run control-target.yml -f request_id=... -f
behavior=pass
is ergonomic and may print the created run URL. For automation, use
gh api against the versioned REST endpoint so the
response contract is explicit. The current endpoint returns
workflow_run_id directly.
export GH_REPO=OWNER/gha-api-control-lab
API_VER=2026-03-10
WORKFLOW=control-target.yml
REF=main
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')
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]=pass' \
--jq '.workflow_run_id')
printf 'workflow_id=%s run_id=%s request_id=%s
' "$WORKFLOW_ID" "$RUN_ID" "$REQUEST_ID"
The returned run ID is now the controller primary key. The script
still reads back workflow_id, event and
display_title because a number alone is not
human-reviewable ownership evidence.
7. A rerunnable dispatch-and-wait controller
Save the following as scripts/control-lab.sh. It
performs one mutation—the dispatch—and then uses only GET requests
until the exact run becomes terminal. If a guard fails, it stops
instead of searching for a replacement run.
#!/usr/bin/env bash
set -euo pipefail
: "${GH_REPO:?Set GH_REPO=OWNER/REPO for the disposable repository}"
API_VER="2026-03-10"
WORKFLOW="${WORKFLOW:-control-target.yml}"
REF="${REF:-main}"
BEHAVIOR="${1:-pass}"
case "$BEHAVIOR" in pass|fail|wait) ;; *) echo "behavior must be pass|fail|wait" >&2; exit 2;; esac
REQUEST_ID="lab-$(date -u +%Y%m%dT%H%M%SZ)-$$-$RANDOM"
[[ "$REQUEST_ID" =~ ^lab-[A-Za-z0-9._-]{1,80}$ ]] || exit 2
# Resolve the workflow resource before mutation.
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'
})"
# Current API returns the exact created run ID. Never replace this with “latest run”.
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]+$ ]] || { echo "missing numeric run id" >&2; exit 3; }
EXPECTED_TITLE="control-lab $REQUEST_ID / $BEHAVIOR"
mkdir -p control-evidence
printf 'repo=%s\nworkflow_id=%s\nrun_id=%s\nrequest_id=%s\nbehavior=%s\n' \
"$GH_REPO" "$WORKFLOW_ID" "$RUN_ID" "$REQUEST_ID" "$BEHAVIOR" \
> "control-evidence/controller-$RUN_ID.txt"
echo "Created exact run: $RUN_ID ($REQUEST_ID)"
# Poll one exact run. One GET per interval; no mutative retry loop.
for _ in $(seq 1 100); do
IFS=$'\t' read -r ACTUAL_WF EVENT TITLE STATUS CONCLUSION SHA ATTEMPT URL < <(
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'
)
[[ "$ACTUAL_WF" == "$WORKFLOW_ID" ]] || { echo "REFUSE: workflow guard mismatch" >&2; exit 4; }
[[ "$EVENT" == "workflow_dispatch" ]] || { echo "REFUSE: event guard mismatch" >&2; exit 4; }
[[ "$TITLE" == "$EXPECTED_TITLE" ]] || { echo "REFUSE: request-title guard mismatch" >&2; exit 4; }
printf 'run=%s attempt=%s status=%s conclusion=%s sha=%s url=%s\n' \
"$RUN_ID" "$ATTEMPT" "$STATUS" "$CONCLUSION" "$SHA" "$URL"
if [[ "$STATUS" == "completed" ]]; then
gh api \
-H 'Accept: application/vnd.github+json' \
-H "X-GitHub-Api-Version: $API_VER" \
"repos/$GH_REPO/actions/runs/$RUN_ID" \
> "control-evidence/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" \
> "control-evidence/artifacts-$RUN_ID-attempt-$ATTEMPT.json"
[[ "$CONCLUSION" == "success" ]] && exit 0
exit 10
fi
sleep 3
done
echo "timed out waiting for exact run $RUN_ID" >&2
exit 11
8. Cancel only the exact owned wait run
Cancellation is a write operation and the REST endpoint currently
returns HTTP 202 Accepted. That still means the controller must poll
afterward if it needs proof that the runner/job stopped. Before
sending cancel, re-read the run and require the exact workflow ID,
workflow_dispatch event, request ID embedded in the
display title and an eligible non-terminal status.
guard_run() {
local run_id="$1" request_id="$2" behavior="$3"
[[ "$run_id" =~ ^[0-9]+$ ]] || { echo "REFUSE: nonnumeric run id" >&2; return 2; }
[[ "$request_id" =~ ^lab-[A-Za-z0-9._-]{1,80}$ ]] || { echo "REFUSE: request id" >&2; return 2; }
case "$behavior" in pass|fail|wait) ;; *) echo "REFUSE: behavior" >&2; return 2;; esac
local expected_title="control-lab $request_id / $behavior"
local row actual_wf event title status conclusion attempt sha url
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 // ""),.run_attempt,.head_sha,.html_url] | @tsv')"
IFS=$'\t' read -r actual_wf event title status conclusion attempt sha url <<< "$row"
[[ "$actual_wf" == "$WORKFLOW_ID" ]] || { echo "REFUSE: workflow mismatch" >&2; return 4; }
[[ "$event" == "workflow_dispatch" ]] || { echo "REFUSE: event mismatch" >&2; return 4; }
[[ "$title" == "$expected_title" ]] || { echo "REFUSE: request ownership mismatch" >&2; return 4; }
printf '%s\t%s\t%s\t%s\t%s\n' "$status" "$conclusion" "$attempt" "$sha" "$url"
}
# Example: the variables come from the controller ledger, never from “latest”.
row="$(guard_run "$RUN_ID" "$REQUEST_ID" wait)" || exit $?
status="${row%%$'\t'*}"
[[ "$status" == "queued" || "$status" == "in_progress" ]] || {
echo "REFUSE: run is not cancellable lab work" >&2
exit 5
}
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
# Read until status/conclusion proves the result.
9. Preserve failure, then rerun the narrowest equivalent scope
A deterministic behavior=fail run should remain failed
after a rerun because reruns keep the original source ref/SHA and
event inputs. Preserve attempt 1 metadata/log URL/artifact metadata
first. Then, if the purpose is diagnostic, rerun only failed jobs.
The current REST response is HTTP 201 Created and the same run ID
receives a higher attempt.
# Preserve exact first-failure state.
gh api \
-H 'Accept: application/vnd.github+json' \
-H 'X-GitHub-Api-Version: 2026-03-10' \
"repos/$GH_REPO/actions/runs/$RUN_ID" > "run-$RUN_ID-attempt-1.json"
gh api -X POST \
-H 'Accept: application/vnd.github+json' \
-H 'X-GitHub-Api-Version: 2026-03-10' \
"repos/$GH_REPO/actions/runs/$RUN_ID/rerun-failed-jobs" --silent
# Or the equivalent CLI after the same guards:
gh run rerun "$RUN_ID" --failed
Do not use the browser job URL suffix as
gh run rerun --job input. Query
gh run view RUN_ID --json jobs --jq ".jobs[] |
{name,databaseId}"
and use that databaseId if you intentionally rerun a
single job.
10. Compare GraphQL where it adds value
GraphQL is useful for a shaped, cursor-aware read of the workflow
resource and its recent runs. Resolve the workflow REST
node_id, then query the GraphQL
Workflow/WorkflowRun types. This remains
read-only; the chapter does not invent GraphQL mutations where the
documented Actions control endpoints already exist in REST.
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 url
runs(first: 5, orderBy: {field: CREATED_AT, direction: DESC}) {
nodes { databaseId runNumber runAttempt event displayTitle url }
pageInfo { hasNextPage endCursor }
}
}
}
}'
11. Expected observations and evidence
| Operation | Immediate evidence | Final evidence |
|---|---|---|
| Read workflow | 200 + ID/path/state | No mutation; same workflow resource remains. |
| Dispatch | 200 + exact workflow_run_id/URLs | Run transitions queued/in_progress/completed; exact conclusion verified. |
| Cancel | 202 Accepted | Exact owned run reaches cancelled/terminal state; external side effects, if any, require separate checks. |
| Rerun failed jobs | 201 Created | Same run ID/source with incremented attempt and new job execution evidence. |
| GraphQL read | 200 payload and cursors | Selected Workflow/WorkflowRun fields; inspect payload errors/pageInfo. |
12. Cleanup and rollback
- Preserve the failed/cancelled run evidence until the exercise review is complete.
- Cancel only the exact wait run whose request ID and workflow ID match the local ledger.
- Do not delete workflow runs merely to make the Actions page tidy; deletion is evidence destruction and is not required.
- Delete the disposable repository only after confirming the lab evidence is no longer needed.
- If you created a fine-grained PAT only for the lab, revoke it after use; do not paste it into notes, logs or shell history.
13. Challenge: choose the control layer
Your platform team needs to trigger a workflow, correlate the exact
run, gather a few connected run fields and notify a service when it
finishes. Choose which pieces belong to
gh workflow run, versioned REST, GraphQL and a webhook.
Explain why “poll the latest run every second and cancel whichever
is red” is the wrong design even if it appears to work in a quiet
repository.
Knowledge check
Why does the guided controller use REST dispatch instead of
searching after gh workflow run?
The current REST contract returns the exact workflow_run_id directly, eliminating a race-prone “find latest” correlation step.
What must be checked immediately before canceling a lab run?
At minimum repository, exact run ID, workflow ID, event/request ownership and eligible current status; then poll final state after the 202 response.
Why can a deterministic failed rerun be useful?
It proves attempt semantics and gathers diagnostics while preserving the same source/input; it should not be mistaken for testing a source-code fix.
When does gh api --paginate matter?
Whenever a complete collection matters. Reading only page 1 can silently miss runs/workflows and make automation decisions wrong.
Why does the target workflow use
permissions: {}?
The synthetic job does not need repository API write privileges. The external controller identity owns dispatch/cancel/rerun permissions separately.
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.