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.
Learning objectives
- Create a disposable public repository and inspect Actions state before adding a workflow.
-
Commit a minimal
workflow_dispatch+ narrowly filteredpushworkflow withcontents: 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_TOKENwithout 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?
The push trigger was filtered to lab/**. The
workflow-file path was outside that filter. Manual dispatch
remained available after the file existed on the default branch.
What does
gh run view --json headSha prove?
It identifies the run’s event-associated head SHA reported by GitHub. Compare it with the Git ref/commit you intended; do not treat it as universally equivalent to a PR head SHA for every event.
Why can the job call GitHub APIs even though you never created a PAT?
GitHub minted a job-scoped GITHUB_TOKEN, a GitHub
App installation token for the workflow repository. Its
effective permissions are constrained by the explicit workflow
permissions and repository/org policy.
Does disabling the workflow delete old run logs?
No. Disabling prevents new runs from that workflow; historical workflow-run objects and logs remain according to GitHub retention/policy.
A public repository’s Actions tab is missing. Should you immediately add a new workflow file?
No. Inspect repository/organization policy and permissions first. Actions may be disabled; changing a valuable policy is different from fixing a workflow definition.
Further reading — current official GitHub sources
- GitHub Docs — Understanding GitHub Actions
- GitHub Docs — Workflow syntax
- GitHub Docs — Events that trigger workflows
- GitHub Docs — Contexts reference
- GitHub Docs — GITHUB_TOKEN
- GitHub Docs — Secure use reference
- GitHub CLI — gh run list
- GitHub CLI — gh run view
- GitHub REST API — workflow runs (2026-03-10)
- GitHub Docs — Manually running a workflow
- GitHub CLI — gh workflow run
- GitHub CLI — gh workflow disable
- GitHub Docs — Managing Actions settings for a repository
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.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0
Send only Ethereum/ERC-20 compatible assets to this
address.