Chapter 13Lesson 02~180 minutes

GitHub Actions Foundations: Workflows, Events, YAML, Permissions, and Execution Model: Guided Hands-On Workflow and Core Operations

The safest first workflow is one whose state is easy to predict. In this lesson you will create a disposable public repository, commit one narrowly scoped workflow, run it manually, trigger it with one controlled push, and prove the run identity from independent GitHub and Git evidence.

workflow_dispatchgh runRead-only permissionsRun evidence

Learning objectives

  • Create a disposable public repository and inspect Actions state before adding a workflow.
  • Commit a minimal workflow_dispatch + narrowly filtered push workflow with contents: read.
  • Use the Actions UI, gh workflow, gh run list/view, Git, and REST output to prove event/ref/SHA/job/step state.
  • Demonstrate read-only repository metadata access through the job’s GITHUB_TOKEN without printing the token.
  • Disable the workflow after the lab and review logs for accidental sensitive/excessive output.

Availability and role: GitHub.com, GitHub Free, one personal public repository, and repository owner/write access. Standard GitHub-hosted runners are free for public repositories. If repository or organization policy disables Actions or blocks the selected action, use the documented simulation in this lesson rather than weakening a real organization policy.

1. Scenario and preflight: Atlas Relay validation

You are adding the first automation to a tiny training repository called Atlas Relay. The goal is not a real build; it is to make run identity observable. The workflow is read-only, manually triggerable, and has one narrow push path so you can compare two event types.

gh --version
gh auth status --active --hostname github.com

OWNER="$(gh api user \
  -H "X-GitHub-Api-Version: 2026-03-10" \
  --jq .login)"
LAB="actions-foundations-$(date +%Y%m%d-%H%M%S)"
REPO="$OWNER/$LAB"

gh repo create "$REPO" --public --add-readme \
  --description "Disposable Chapter 13 GitHub Actions lab"

gh repo view "$REPO" --json nameWithOwner,visibility,defaultBranchRef,url,viewerPermission
gh workflow list -R "$REPO" --all --json id,name,path,state
gh run list -R "$REPO" --limit 5 --json databaseId,event,status,conclusion,headSha,url

Expected: the repository exists and is public; workflow/run lists are empty. This proves the “before” state instead of assuming it.

2. Clone and create a versioned workflow file

The workflow file is Git content, so create it locally and review its diff before pushing. The manual trigger avoids accidental repeated execution; the push trigger is constrained to lab/**, so ordinary README edits do not run it.

gh repo clone "$REPO" "$LAB-work"
cd "$LAB-work"
DEFAULT_BRANCH="$(gh repo view "$REPO" --json defaultBranchRef --jq .defaultBranchRef.name)"
mkdir -p .github/workflows lab
cat > .github/workflows/foundations.yml <<'YAML'
name: Chapter 13 foundations

on:
  workflow_dispatch:
  push:
    paths:
      - 'lab/**'

permissions:
  contents: read

jobs:
  observe:
    runs-on: ubuntu-latest
    steps:
      - name: Safe run identity
        env:
          EVENT_NAME: ${{ github.event_name }}
          REPOSITORY: ${{ github.repository }}
          RUN_REF: ${{ github.ref }}
          RUN_SHA: ${{ github.sha }}
          ACTOR: ${{ github.actor }}
          WORKFLOW_REF: ${{ github.workflow_ref }}
          WORKFLOW_SHA: ${{ github.workflow_sha }}
          RUNNER_OS_SAFE: ${{ runner.os }}
          RUNNER_ARCH_SAFE: ${{ runner.arch }}
        run: |
          printf 'event=%s\nrepo=%s\nref=%s\nsha=%s\nactor=%s\n' \
            "$EVENT_NAME" "$REPOSITORY" "$RUN_REF" "$RUN_SHA" "$ACTOR"
          printf 'workflow_ref=%s\nworkflow_sha=%s\nrunner=%s/%s\n' \
            "$WORKFLOW_REF" "$WORKFLOW_SHA" "$RUNNER_OS_SAFE" "$RUNNER_ARCH_SAFE"

      - name: Prove read-only repository API access
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          gh api \
            -H "X-GitHub-Api-Version: 2026-03-10" \
            "repos/$GITHUB_REPOSITORY" \
            --jq '{full_name, visibility, default_branch}'
YAML

git diff -- .github/workflows/foundations.yml
git add .github/workflows/foundations.yml
git commit -m "ci: add observable foundations workflow"
git push origin "$DEFAULT_BRANCH"
WORKFLOW_COMMIT="$(git rev-parse HEAD)"
printf 'workflow commit=%s\n' "$WORKFLOW_COMMIT"

Shell note: The heredoc example is for Bash/Git Bash/zsh. In PowerShell, create the same UTF-8 YAML with a here-string and Set-Content. The YAML itself is shell-independent.

Expected: the workflow appears under Actions, but the workflow-file commit itself does not satisfy the lab/** path filter. Therefore the push that introduced the workflow should not create the filtered push run.

3. Trigger one manual run and identify it from three surfaces

workflow_dispatch must exist on the default branch before it can be manually triggered. Run it explicitly by file name and repository, then wait for the newest matching run.

gh workflow view foundations.yml -R "$REPO" --yaml
gh workflow run foundations.yml -R "$REPO" --ref "$DEFAULT_BRANCH"

RUN_ID=""
for attempt in {1..12}; do
  RUN_ID="$(gh run list -R "$REPO" \
    --workflow foundations.yml --event workflow_dispatch --limit 1 \
    --json databaseId --jq '.[0].databaseId // empty')"
  [[ -n "$RUN_ID" ]] && break
  sleep 5
done
[[ -n "$RUN_ID" ]] || { echo 'No workflow_dispatch run appeared after bounded polling' >&2; exit 1; }

printf 'run id=%s\n' "$RUN_ID"
gh run watch "$RUN_ID" -R "$REPO" --exit-status

gh run view "$RUN_ID" -R "$REPO" \
  --json databaseId,attempt,event,status,conclusion,headBranch,headSha,jobs,url \
  --jq '{id:.databaseId,attempt,event,status,conclusion,headBranch,headSha,jobs:[.jobs[]|{name,status,conclusion}],url}'

gh run view "$RUN_ID" -R "$REPO" --log

Interpret the evidence. event should be workflow_dispatch. headSha should identify the selected ref’s commit. The logs should contain only the explicitly selected metadata and the repository metadata response—not the token or entire github context.

4. Verify causality with Git and the REST API

One interface can display stale or misunderstood information. Verify the same run independently. The REST endpoint is read-only and explicitly versioned.

git fetch origin "$DEFAULT_BRANCH"
REMOTE_SHA="$(git rev-parse "origin/$DEFAULT_BRANCH")"
RUN_SHA="$(gh run view "$RUN_ID" -R "$REPO" --json headSha --jq .headSha)"
printf 'remote=%s\nrun=%s\n' "$REMOTE_SHA" "$RUN_SHA"

gh api \
  -H "Accept: application/vnd.github+json" \
  -H "X-GitHub-Api-Version: 2026-03-10" \
  "repos/$REPO/actions/runs/$RUN_ID" \
  --jq '{id,event,status,conclusion,head_branch,head_sha,run_attempt,workflow_id,html_url}'

If the two SHAs differ, stop and explain why before treating the run as evidence. You may have dispatched an older branch/ref, pushed a newer commit after dispatch, or selected the wrong run.

5. Add one controlled push trigger and compare event identity

Now change only a path covered by the filter. Predict first: the push creates a new commit on the default branch and should produce exactly one new push run of the same workflow.

printf 'chapter-13-signal\n' > lab/signal.txt
git add lab/signal.txt
git commit -m "test: trigger foundations workflow"
PUSH_SHA="$(git rev-parse HEAD)"
git push origin "$DEFAULT_BRANCH"

PUSH_RUN=""
for attempt in {1..12}; do
  PUSH_RUN="$(gh run list -R "$REPO" \
    --workflow foundations.yml --event push --commit "$PUSH_SHA" --limit 1 \
    --json databaseId --jq '.[0].databaseId // empty')"
  [[ -n "$PUSH_RUN" ]] && break
  sleep 5
done
[[ -n "$PUSH_RUN" ]] || { echo 'No push run appeared for PUSH_SHA after bounded polling' >&2; exit 1; }

gh run watch "$PUSH_RUN" -R "$REPO" --exit-status
gh run view "$PUSH_RUN" -R "$REPO" \
  --json event,headSha,headBranch,conclusion,url
printf 'expected push sha=%s\n' "$PUSH_SHA"

The workflow did not “reuse” the manual run. GitHub created a separate workflow-run object because a different event occurred. The run should bind to the pushed SHA.

6. Prove what the workflow did—and did not—receive permission to do

The workflow declares only contents: read. The API metadata request succeeds because it is read-only. No step writes Issues, releases, repository contents, checks, or packages. This matters because the token exists whether or not your steps explicitly reference secrets.GITHUB_TOKEN; actions can access github.token. Least privilege must therefore be established at the job/workflow boundary, not by “hiding” the token variable.

State Before workflow After successful runs
Git refs Default branch only Two additional commits: workflow + signal
Workflow object Absent foundations.yml enabled
Workflow runs None One manual + one push run
GITHUB_TOKEN Not applicable outside jobs Minted separately per job, expired after jobs
Issue/release/package state Unchanged Still unchanged

7. Disable the lab workflow and audit logs

Disabling is reversible and prevents future matching events from starting this workflow. Do it explicitly instead of leaving training automation live indefinitely.

gh workflow disable foundations.yml -R "$REPO"
gh workflow list -R "$REPO" --all --json id,name,path,state

# Review the two run logs manually; do not pipe secrets or full contexts into output.
gh run view "$RUN_ID" -R "$REPO" --log
gh run view "$PUSH_RUN" -R "$REPO" --log

Verification: the workflow state is disabled; historical runs remain evidence; logs contain only the fields you intentionally printed; there are no uploaded artifacts in this lesson. Deleting a workflow file is separate from disabling the hosted workflow state.

8. Challenge: choose the correct surface

Your teammate asks: “Which exact commit did yesterday’s failed validation test, and did the workflow definition itself come from that same commit?” Do not rerun anything. Build the answer from gh run view --json, the run log fields workflow_ref/workflow_sha, and Git inspection. Explain why the Actions UI alone is useful but not sufficient evidence for both identities.

9. Lesson summary

You created one narrowly scoped workflow, observed manual and push events, bound runs to Git SHAs, verified the same run through REST, used explicit read-only token permissions, and disabled the workflow after the lab. The important skill is not the YAML syntax—it is predicting and verifying which hosted and Git states changed.

Knowledge check

Why did the commit that added foundations.yml not have to trigger the push workflow?

What does gh run view --json headSha prove?

Why can the job call GitHub APIs even though you never created a PAT?

Does disabling the workflow delete old run logs?

A public repository’s Actions tab is missing. Should you immediately add a new workflow file?

Next lesson

Next: GitHub Actions Foundations: Workflows, Events, YAML, Permissions, and Execution Model: Configuration, Design Choices, and Tradeoffs

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.