Workflow Triggers, Filters, Expressions, Contexts, Variables, Inputs, and Outputs: Guided Hands-On Workflow and Core Operations
You will now turn that contract into evidence. A disposable public repository will expose one workflow through two activation paths: typed manual dispatch and a narrow branch-plus-path push filter. You will inspect only selected context fields, create a non-sensitive repository variable, and trace one value from a step into a later job.
Learning objectives
- Create a disposable workflow with a typed manual-input path and a separate branch-plus-path push trigger.
-
Create/read one non-sensitive repository configuration variable
and contrast it with workflow
envand secrets. - Print only selected safe context properties, never full contexts or secret-bearing objects.
-
Create a step output, promote it to a job output, and consume it
through
needsin a later job. - Prove executed versus skipped steps/jobs from structured run evidence and logs rather than assumptions.
Lab assumptions: GitHub.com, GitHub Free, repository owner/write access, GitHub CLI authenticated to GitHub.com, Git available locally, and standard GitHub-hosted runners. The repository is disposable and public. No real secrets, deployment environments, cloud accounts, or self-hosted runners are required.
1. Create the disposable repository and inspect the blank state
The scenario is Atlas Trigger Lab. It has one
default branch for the workflow definition and one purpose-built
branch named ch14-filter for filter testing. The branch
name is deliberately fixed so the YAML does not assume that the
repository default branch is called main.
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-dataflow-$(date +%Y%m%d-%H%M%S)"
REPO="$OWNER/$LAB"
gh repo create "$REPO" --public --add-readme --description "Disposable Chapter 14 trigger/data-flow lab"
gh repo view "$REPO" --json nameWithOwner,visibility,defaultBranchRef,viewerPermission,url
gh workflow list -R "$REPO" --all --json id,name,path,state
gh run list -R "$REPO" --limit 5 --json databaseId,event,status,conclusion,url
gh variable list -R "$REPO" --json name,value,updatedAt
Expected: repository exists, workflow/run lists are
empty, and there is no CH14_CHANNEL variable. Preserve
this output as the before-state evidence.
2. Create one non-sensitive configuration variable
The value training is operational configuration, not a
credential. Store it as a repository variable and prove it exists
through both first-class CLI and versioned REST.
gh variable set CH14_CHANNEL --body "training" -R "$REPO"
gh variable list -R "$REPO" --json name,value,updatedAt --jq '.[] | select(.name=="CH14_CHANNEL")'
gh api -H "Accept: application/vnd.github+json" -H "X-GitHub-Api-Version: 2026-03-10" "repos/$REPO/actions/variables/CH14_CHANNEL"
The value is visible because variables are non-sensitive. If this were a credential, that visibility would be a design bug—use a secret or short-lived identity mechanism instead.
3. Commit the workflow contract to the default branch
Clone the repository and write the workflow. The manual path takes
typed inputs. The push path only activates for the dedicated branch
ch14-filter when at least one changed file matches
src/**. Both jobs remain read-only.
Shell note: The multi-line
cat <<'YAML' examples in this lab are for Bash,
Git Bash, or zsh. In PowerShell, create the same file with a
single-quoted here-string (@'...'@) piped to
Set-Content; the GitHub workflow YAML itself is
identical.
gh repo clone "$REPO" "$LAB-work"
cd "$LAB-work"
DEFAULT_BRANCH="$(gh repo view "$REPO" --json defaultBranchRef --jq .defaultBranchRef.name)"
mkdir -p .github/workflows
cat > .github/workflows/ch14-flow.yml <<'YAML'
name: Chapter 14 trigger and data flow
on:
workflow_dispatch:
inputs:
run_extended:
description: Run the extended validation step
type: boolean
required: true
default: false
target:
description: Select a validation target
type: choice
required: true
options:
- smoke
- full
default: smoke
push:
branches:
- ch14-filter
paths:
- 'src/**'
permissions:
contents: read
env:
WORKFLOW_LABEL: chapter-14
jobs:
prepare:
runs-on: ubuntu-latest
outputs:
selected_mode: ${{ steps.derive.outputs.mode }}
retry_count: ${{ steps.derive.outputs.retry_count }}
steps:
- name: Print selected safe metadata
env:
EVENT_NAME: ${{ github.event_name }}
RUN_REF: ${{ github.ref }}
RUN_SHA: ${{ github.sha }}
ACTOR: ${{ github.actor }}
CHANNEL: ${{ vars.CH14_CHANNEL }}
run: |
printf 'event=%s\nref=%s\nsha=%s\nactor=%s\nchannel=%s\n' \
"$EVENT_NAME" "$RUN_REF" "$RUN_SHA" "$ACTOR" "$CHANNEL"
- name: Derive output contract
id: derive
env:
EVENT_NAME: ${{ github.event_name }}
MANUAL_TARGET: ${{ inputs.target }}
run: |
if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
mode="${MANUAL_TARGET:-smoke}"
else
mode="push-validation"
fi
printf 'mode=%s\n' "$mode" >> "$GITHUB_OUTPUT"
printf 'retry_count=2\n' >> "$GITHUB_OUTPUT"
- name: Controlled numeric expression
if: ${{ fromJSON(steps.derive.outputs.retry_count) > 1 }}
run: echo "retry count is greater than one"
- name: Extended manual path
if: ${{ github.event_name == 'workflow_dispatch' && inputs.run_extended }}
run: echo "extended validation selected"
consume:
needs: prepare
runs-on: ubuntu-latest
steps:
- name: Consume job output
env:
MODE: ${{ needs.prepare.outputs.selected_mode }}
PREPARE_RESULT: ${{ needs.prepare.result }}
run: |
test -n "$MODE"
printf 'mode=%s\nprepare=%s\n' "$MODE" "$PREPARE_RESULT"
YAML
git diff -- .github/workflows/ch14-flow.yml
git add .github/workflows/ch14-flow.yml
git commit -m "ci: add Chapter 14 trigger data-flow workflow"
git push origin "$DEFAULT_BRANCH"
WORKFLOW_SHA="$(git rev-parse HEAD)"
printf 'workflow sha=%s\n' "$WORKFLOW_SHA"
Security design: Selected event fields are copied
into environment variables before the shell reads them. The
workflow never dumps toJSON(github),
secrets, the complete environment, or authorization
headers. The variable is intentionally non-sensitive.
The push that adds the workflow is on the default branch, not
ch14-filter; therefore the push trigger should not
fire. The workflow itself should now be discoverable and manually
dispatchable because it exists on the default branch.
4. Manual run A: Boolean false should skip one step
Dispatch with structured JSON so the intent is explicit. This uses the workflow’s typed boundary instead of an interactive prompt.
printf '%s
' '{"run_extended":false,"target":"smoke"}' | gh workflow run ch14-flow.yml -R "$REPO" --ref "$DEFAULT_BRANCH" --json
RUN_FALSE="$(gh run list -R "$REPO" --workflow ch14-flow.yml --event workflow_dispatch --limit 1 --json databaseId --jq '.[0].databaseId')"
gh run watch "$RUN_FALSE" -R "$REPO" --exit-status
gh run view "$RUN_FALSE" -R "$REPO" --json databaseId,event,headBranch,headSha,status,conclusion,jobs,url --jq '{id:.databaseId,event,headBranch,headSha,conclusion,jobs:[.jobs[]|{name,conclusion,steps:[.steps[]|{name,conclusion}]}],url}'
gh run view "$RUN_FALSE" -R "$REPO" --log
Expected: Extended manual path is
skipped; Controlled numeric expression executes;
downstream consume prints mode=smoke. The
output crossed step → job → needs boundaries without a
shared file.
5. Manual run B: Boolean true changes only the intended path
Change only the input values. Predict that the run exists on the
same selected ref, but the extended step now executes and the output
mode becomes full.
printf '%s
' '{"run_extended":true,"target":"full"}' | gh workflow run ch14-flow.yml -R "$REPO" --ref "$DEFAULT_BRANCH" --json
RUN_TRUE="$(gh run list -R "$REPO" --workflow ch14-flow.yml --event workflow_dispatch --limit 1 --json databaseId --jq '.[0].databaseId')"
gh run watch "$RUN_TRUE" -R "$REPO" --exit-status
gh run view "$RUN_TRUE" -R "$REPO" --log
Compare the two logs rather than comparing only green conclusions. The event/ref/SHA should be equivalent if no repository commit changed between dispatches; the input-driven step state and derived output should differ.
6. Branch/path filter: first prove a non-match, then a match
Create the dedicated test branch. The first commit changes only
docs/**, so the branch filter matches but the path
filter does not. No workflow run should be created. The second
commit changes src/**, satisfying both predicates.
git switch -c ch14-filter
mkdir -p docs
printf 'docs-only
' > docs/note.txt
git add docs/note.txt
git commit -m "docs: path-filter non-match"
DOCS_SHA="$(git rev-parse HEAD)"
git push -u origin ch14-filter
# Fresh lab expectation: no push run for this commit.
gh run list -R "$REPO" --workflow ch14-flow.yml --event push --commit "$DOCS_SHA" --limit 5 --json databaseId,event,headBranch,headSha,status,conclusion,url
mkdir -p src
printf 'trigger
' > src/signal.txt
git add src/signal.txt
git commit -m "test: satisfy branch and path filters"
MATCH_SHA="$(git rev-parse HEAD)"
git push origin ch14-filter
PUSH_RUN="$(gh run list -R "$REPO" --workflow ch14-flow.yml --event push --commit "$MATCH_SHA" --limit 1 --json databaseId --jq '.[0].databaseId')"
gh run watch "$PUSH_RUN" -R "$REPO" --exit-status
gh run view "$PUSH_RUN" -R "$REPO" --json event,headBranch,headSha,conclusion,jobs,url
gh run view "$PUSH_RUN" -R "$REPO" --log
Expected: the docs-only commit has no run; the
src/** commit creates one push run on
ch14-filter, and the derived mode is
push-validation. Manual inputs are not the source of
that push-run decision.
7. Verify the matching run through versioned REST
Use a read-only Actions endpoint to independently confirm the run identity. The API request is explicit about version and repository.
gh api -H "Accept: application/vnd.github+json" -H "X-GitHub-Api-Version: 2026-03-10" "repos/$REPO/actions/runs/$PUSH_RUN" --jq '{id,event,head_branch,head_sha,status,conclusion,run_attempt,html_url}'
Now compare head_sha to MATCH_SHA. This
proves causality without relying on timing or the visual order of
the Actions page.
8. Challenge: choose trigger filter or in-job condition
Requirement: “For every pull request to the default branch, publish
one required validation check, but run the expensive integration
test only when service/** changes.” Should the entire
required workflow use a path filter, or should the workflow always
trigger and place the path-aware logic inside jobs/steps? Explain
the branch-governance consequence before choosing.
A strong answer notices that filtering out the whole required workflow can leave an expected check pending. It is often safer to create the run consistently and conditionally skip expensive work inside it, while still publishing the required validation result.
9. Verify and clean up reversible lab state
- Two manual runs exist with different input-driven step behavior.
- The docs-only commit has no push run.
-
The matching
src/**commit has exactly the expected push run and SHA. -
CH14_CHANNELis visible as non-sensitive configuration. - No secrets/artifacts/packages/deployments were created.
gh workflow disable ch14-flow.yml -R "$REPO"
gh variable delete CH14_CHANNEL -R "$REPO"
gh workflow list -R "$REPO" --all --json id,name,path,state
gh variable list -R "$REPO" --json name,value
# Preserve run evidence and make the disposable repository read-only.
gh repo archive "$REPO" --yes
gh repo view "$REPO" --json nameWithOwner,isArchived,url
Cleanup boundary: Archiving is reversible and preserves the run/commit evidence. Permanent repository deletion is not required. If you unarchive later, leave the workflow disabled until you intentionally want it to run again.
10. Lesson summary
You proved typed manual inputs, selected safe contexts, repository
variables, expression conversion, step outputs, job outputs,
needs flow, and the AND relationship between branch and
path filters. More importantly, every behavior was verified from
run/SHA evidence instead of inferred from YAML.
Knowledge check
Why did the docs-only commit create no push run?
The branch predicate matched ch14-filter, but the
changed path did not match src/**. Both filters had
to be true.
What changed between manual run A and B?
The typed inputs changed. The workflow definition, selected ref, permissions, and variable configuration did not need to change.
Why was retry_count wrapped in
fromJSON()?
Step outputs are strings. Explicit conversion makes the numeric comparison intentional instead of relying on loose coercion.
What proves that consume is allowed to read the
prepare output?
It declares needs: prepare, and the producer job
maps the step output into
jobs.prepare.outputs.selected_mode.
Why is archiving preferred to permanent repository deletion here?
It is reversible and preserves the commits, workflows, logs, and run URLs needed as training/audit evidence.
Further reading — current official GitHub sources
- GitHub Docs — Workflow syntax
- GitHub Docs — Events that trigger workflows
- GitHub Docs — Expressions
- GitHub Docs — Contexts reference
- GitHub Docs — Variables
- GitHub Docs — Pass job outputs
- GitHub Docs — Script injections
- GitHub Docs — Secure use reference
- GitHub CLI — gh workflow run
- GitHub CLI — gh run list
- GitHub CLI — gh variable set
- GitHub REST — Actions variables
- GitHub CLI — gh workflow disable
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.