Chapter 31Lesson 02~265 minutes

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.

Hands-onworkflow_dispatchPollingRun guardsgh api

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.

Next lesson

GitHub CLI, REST/GraphQL APIs, Workflow Dispatch, and Automation Control: Configuration, Design Patterns, and Trade-Offs

Continue with the next lesson to build on the current concepts, evidence, security boundaries, and operational practices.

Knowledge check

Why does the guided controller use REST dispatch instead of searching after gh workflow run?

What must be checked immediately before canceling a lab run?

Why can a deterministic failed rerun be useful?

When does gh api --paginate matter?

Why does the target workflow use permissions: {}?

Official references and version notes

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
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0Send only Ethereum/ERC-20 compatible assets to this address.