Chapter 14Lesson 02~190 minutes

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.

workflow_dispatchPath filtersJob outputsgh workflow

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 env and 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 needs in 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_CHANNEL is 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?

What changed between manual run A and B?

Why was retry_count wrapped in fromJSON()?

What proves that consume is allowed to read the prepare output?

Why is archiving preferred to permanent repository deletion here?

Next lesson

Next: Workflow Triggers, Filters, Expressions, Contexts, Variables, Inputs, and Outputs: 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.