Chapter 31Lesson 05~285 minutes

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.

CheckpointExact run IDCancellation guardEvidence packetAutomation control

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 in display_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.md with 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.

Next lesson

Performance, Queue Time, Parallelism, Caching, Usage, and Cost Optimization: Core Concepts and Mental Model

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

Knowledge check

What is the checkpoint’s strongest defense against cancelling the wrong run?

Why is Run B a safe “unrelated run” test?

What should a controller do after cancel returns 202?

Why is an optional failing rerun expected to fail again?

What evidence proves dispatch did not depend on a race-prone list query?

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.