gh workflow operates on workflow definitions — the YAML files in .github/workflows/.
gh run operates on executions of them.
Keeping that distinction straight makes the two command families obvious. A workflow is a recipe; a run is one attempt at cooking it.
The subcommands
Section titled “The subcommands”| Command | Does |
|---|---|
list | List workflows in the repository |
view | Show a workflow, its recent runs, or its YAML |
run | Trigger a workflow_dispatch event |
enable / disable | Turn a workflow on or off |
Listing and viewing
Section titled “Listing and viewing”gh workflow listgh workflow list --allgh workflow view build.ymlgh workflow view build.yml --yamlgh workflow view 12345678Workflows can be referenced by file name, by name as declared in the YAML, or by numeric
ID. In scripts, prefer the file name: it is stable, visible in the repository, and does not change
when someone edits the name: field.
--yaml prints the definition as it exists on the default branch, which is a quick way to check what
is actually deployed versus what is in your working copy.
Triggering a workflow
Section titled “Triggering a workflow”gh workflow run deploy.ymlWhat it doesTriggers a workflow_dispatch event for the named workflow on the default branch.
Why we run itManual triggering is how you run a deploy, a nightly job on demand, or a maintenance task without pushing a commit to cause it.
Expected resultA confirmation that the workflow was requested. The run itself appears shortly afterwards.
Selecting a ref
Section titled “Selecting a ref”By default the workflow runs on the repository’s default branch. --ref chooses another:
gh workflow run deploy.yml --ref release/2.0gh workflow run test.yml --ref my-feature-branchTwo things are worth being precise about here, because they cause real confusion.
The workflow definition is read from the ref you specify. So a workflow that exists only on your
branch can be dispatched with --ref my-feature-branch, but not from main where it does not yet
exist.
The workflow_dispatch trigger must also be present on that ref. Adding the trigger on a branch
and dispatching from main will not work.
Passing inputs
Section titled “Passing inputs”Workflows can declare inputs, and -f supplies them:
gh workflow run deploy.yml -f environment=staging -f version=1.4.0gh workflow run deploy.yml --ref release/2.0 -f environment=production -f dry_run=trueCorresponding to:
on: workflow_dispatch: inputs: environment: description: Target environment required: true type: choice options: [staging, production] version: description: Version to deploy required: true dry_run: description: Report what would happen without doing it type: boolean default: falseFor anything with many inputs, or generated ones, JSON on standard input is cleaner than a long line
of -f flags:
echo '{"environment":"staging","version":"1.4.0","dry_run":"true"}' \ | gh workflow run deploy.yml --jsonEnabling and disabling
Section titled “Enabling and disabling”gh workflow disable nightly.ymlgh workflow enable nightly.ymlDisabling is genuinely useful in two situations: a scheduled workflow that is failing repeatedly and generating noise while you investigate, and a workflow you want to stop without deleting its file and its history.
Note also that GitHub disables scheduled workflows automatically in repositories with no activity for an extended period. If a nightly job silently stopped running, check whether it is disabled before debugging the schedule.
Watching what you triggered
Section titled “Watching what you triggered”gh workflow run returns as soon as the event is accepted; it does not wait. To follow the run,
switch to gh run:
gh workflow run deploy.yml -f environment=stagingsleep 5gh run list --workflow deploy.yml --limit 1gh run watchThe brief wait exists because the run is created asynchronously — querying immediately can return the
previous run, which in a deployment script means reporting success for something that already
finished. Scripts that trigger and then verify should match on the run’s creation time or its
headSha, not simply take the most recent one.
Understanding what you are looking at
Section titled “Understanding what you are looking at”gh workflow list shows workflows and their state, which is less obvious than it sounds because
“state” has three values that mean different things.
gh workflow list --all --json id,name,path,state \ --jq '.[] | [.state, .name, .path] | @tsv'Output:
active Build .github/workflows/build.ymlactive Test .github/workflows/test.ymldisabled_manually Nightly audit .github/workflows/nightly.ymldisabled_inactivity Weekly report .github/workflows/weekly.ymlactive — will run when triggered.
disabled_manually — someone turned it off. Intentional.
disabled_inactivity — GitHub turned it off. Scheduled workflows in repositories with no recent
activity are disabled automatically, and this is the single most common reason a schedule job
“silently stopped working”. Nothing failed; it was switched off.
That distinction is invisible in the default output and obvious in the JSON, which is a good illustration of why structured output is worth reaching for even interactively.
Working out why a dispatch failed
Section titled “Working out why a dispatch failed”gh workflow run fails for a small number of reasons, and the message is not always the clearest
guide.
“Workflow does not have workflow_dispatch trigger.” The definition on the target ref lacks the
trigger. Check what is actually deployed rather than what is in your working copy:
gh workflow view deploy.yml --yaml | head -20gh api "repos/OWNER/REPO/contents/.github/workflows/deploy.yml?ref=main" \ -H "Accept: application/vnd.github.raw" | head -20The second command reads the file from a specific ref, which settles the “but I added it” question definitively.
“Workflow not found.” Usually a name-versus-filename mix-up. gh workflow list shows both.
HTTP 403. The token lacks the workflow scope, or the repository has Actions disabled entirely.
Nothing appears to happen. The dispatch succeeded and the run is queued. gh run list a few
seconds later will show it.
Inputs in practice
Section titled “Inputs in practice”Inputs are the part most worth getting right, because a dispatch workflow with good inputs replaces a great deal of manual process.
on: workflow_dispatch: inputs: environment: description: Target environment required: true type: choice options: [staging, production] version: description: Version to deploy, e.g. 1.4.0 required: true type: string skip_tests: description: Skip the pre-deploy test run type: boolean default: falseCalled with:
gh workflow run deploy.yml \ -f environment=production \ -f version=1.4.0 \ -f skip_tests=falseInside the workflow they arrive under inputs:
jobs: deploy: runs-on: ubuntu-latest steps: - name: Show what was requested run: | echo "environment: ${{ inputs.environment }}" echo "version: ${{ inputs.version }}" echo "skip tests: ${{ inputs.skip_tests }}"Dispatch as an operational interface
Section titled “Dispatch as an operational interface”The pattern worth taking from this lesson is that a dispatch workflow is a safe, audited button for an operation people would otherwise do by hand.
Compared with someone running a script on their laptop, a dispatch workflow gives you:
- An audit trail. Who triggered it, when, with what inputs.
- Consistent environment. The runner, not whatever is installed locally.
- Managed credentials. Repository secrets, not a token in someone’s shell history.
- Constrained inputs.
type: choicebeats hoping nobody mistypes an environment name. - Reviewable logic. The script went through a pull request.
Good candidates: deployments, cache invalidation, data backfills, report generation, and anything currently living in a runbook as “ask someone with production access to run this”.
gh workflow run backfill.yml -f table=orders -f since=2026-08-01 -f dry_run=trueA dry-run input on anything destructive is worth making mandatory rather than defaulted, so the person triggering it has to state their intent either way.
Common mistakes
Section titled “Common mistakes”Expecting gh workflow run to work without workflow_dispatch. The trigger must exist on the
target ref.
Adding the trigger on a branch and dispatching from main. The definition is read from the ref.
Treating boolean inputs as booleans. They arrive as strings.
Assuming the command waits. It returns immediately.
Taking the most recent run as the one you triggered. It may be the previous one.
Referencing workflows by display name in scripts. The file name is stable; the name is not.
Auditing workflows across repositories
Section titled “Auditing workflows across repositories”Workflow files run with repository credentials, which makes them worth inventorying — a change to one is a privilege change rather than a code change.
#!/usr/bin/env bashset -euo pipefail
ORG="${1:?usage: audit-workflows.sh ORG}"
gh repo list "$ORG" --limit 200 --source --no-archived \ --json nameWithOwner --jq '.[].nameWithOwner' \| while read -r repo; do if ! wf=$(gh workflow list --repo "$repo" --all --json name,state,path 2>/dev/null); then continue fi jq -r --arg r "$repo" '.[] | [$r, .state, .path] | @tsv' <<<"$wf" doneTwo questions that inventory answers.
Which scheduled workflows have been disabled for inactivity? They stopped running and nobody was told:
... | awk -F'\t' '$2 == "disabled_inactivity"'Which repositories have workflows using pull_request_target? That trigger runs with full
repository permissions in the context of a fork’s pull request, and it is a well-known source of
credential exposure when misused:
gh api "repos/OWNER/REPO/contents/.github/workflows" --jq '.[].path' \| while read -r path; do if gh api "repos/OWNER/REPO/contents/$path" -H "Accept: application/vnd.github.raw" \ | grep -q 'pull_request_target'; then echo "REVIEW: $path" fi donepull_request_target has legitimate uses and every one deserves a careful read. Finding them across
an organisation is the kind of question the CLI answers in a few lines and a settings page never
will.
Concurrency and cancellation
Section titled “Concurrency and cancellation”Workflows can declare concurrency groups, which matters for dispatch-driven operations:
concurrency: group: deploy-${{ inputs.environment }} cancel-in-progress: falseTwo deployments to the same environment should not run simultaneously, and the second should queue
rather than cancel the first — hence cancel-in-progress: false. For CI on a pull request the
opposite is usually right: a new push should cancel the superseded run.
Getting this backwards on a deploy workflow produces a half-finished deployment cancelled by the next one, which is a genuinely bad failure mode and an easy configuration to copy from a CI example without noticing.
Dispatching across repositories
Section titled “Dispatching across repositories”Triggering a workflow in another repository is a common integration, and it needs a credential the workflow token cannot provide:
gh workflow run deploy.yml --repo other-org/other-repo -f version="$VERSION"From inside Actions, the built-in GITHUB_TOKEN is scoped to its own repository and will fail here.
The options are a GitHub App token, which is the right answer, or a
personal access token, which ties the integration to a person.
The receiving workflow can also use repository_dispatch, which is designed for external triggers and
carries an arbitrary payload:
gh api --method POST "repos/OWNER/REPO/dispatches" \ -f event_type=deploy-requested \ -F 'client_payload={"version":"1.4.0","environment":"staging"}'on: repository_dispatch: types: [deploy-requested]
jobs: deploy: runs-on: ubuntu-latest steps: - run: echo "Deploying ${{ github.event.client_payload.version }}"repository_dispatch suits triggers from outside GitHub entirely — a deployment system, a monitoring
alert, a release pipeline in another tool. workflow_dispatch suits a human pressing a button. Both
end up running a workflow; the difference is who is expected to be calling.
Exercise
Section titled “Exercise”- Add a workflow with a
workflow_dispatchtrigger and onechoiceinput. - Commit and push it to the default branch.
- Run
gh workflow listand confirm it appears. - Trigger it with
gh workflow run <file> -f <input>=<value>. - Find the resulting run with
gh run list --workflow <file> --limit 1. - Disable the workflow, attempt to trigger it again, and observe the failure.
Reusable workflows and dispatch
Section titled “Reusable workflows and dispatch”A workflow can call another, which changes what gh workflow list shows and what you can dispatch.
# .github/workflows/deploy.yml — the calleron: workflow_dispatch: inputs: environment: { type: choice, options: [staging, production], required: true }
jobs: deploy: uses: acme/workflows/.github/workflows/deploy.yml@v2 with: environment: ${{ inputs.environment }} secrets: inheritThe called workflow does not appear in gh workflow list for this repository — it lives
elsewhere. Only the caller is dispatchable, which is usually what you want but is confusing when you
are looking for a workflow you know runs.
secrets: inherit passes the caller’s secrets through. It is convenient and broad; naming the
specific secrets is better where the called workflow needs only some of them.
Dispatch as an audited operation
Section titled “Dispatch as an audited operation”The recurring value of workflow_dispatch is that it turns an ad-hoc operation into a recorded one.
gh workflow run rotate-credentials.yml -f service=payments -f dry_run=truegh run list --workflow rotate-credentials.yml --limit 5 \ --json databaseId,displayTitle,createdAt,conclusion,eventThe run record shows who triggered it, when, with what inputs, and what happened — none of which exists when someone runs a script on their laptop.
For anything currently documented as “ask someone with production access to run this”, converting it to a dispatch workflow is usually a clear improvement: the access moves to the repository, the inputs are constrained, and the history is automatic.
What you learned
Section titled “What you learned”gh workflowmanages definitions;gh runmanages executions.- A workflow only responds to
gh workflow runif it declaresworkflow_dispatch. - Both the definition and the trigger are read from the ref you dispatch against.
- Inputs arrive as strings regardless of declared type.
- The command returns immediately, so scripts must find the run deliberately rather than assuming the latest is theirs.
- Scheduled workflows can be disabled automatically in inactive repositories.
Checking what will run
Section titled “Checking what will run”Before pushing a workflow change, it is worth knowing what triggers it and whether that is what you intended.
gh workflow view ci.yml --yaml | sed -n '/^on:/,/^[a-z]/p'Reading the trigger block is a five-second check that catches a large proportion of workflow mistakes
— a workflow that runs on every push when it should run on pull requests, one missing the
merge_group trigger a merge queue needs, or a schedule on a repository quiet enough that GitHub
will disable it.
For a repository with many workflows, the same question across all of them:
gh workflow list --all --json name,path --jq '.[] | .path' \| while read -r path; do printf '\n== %s ==\n' "$path" gh api "repos/$GH_REPO/contents/$path" -H "Accept: application/vnd.github.raw" \ | sed -n '/^on:/,/^[a-z]/p' | head -12 doneThat produces a trigger inventory, which is the fastest way to answer “what actually runs when someone opens a pull request” on a repository you have just inherited.
Inspecting a workflow on another ref
Section titled “Inspecting a workflow on another ref”gh workflow view --yaml reads the definition from the default branch. When you are debugging why a
workflow behaved differently on a branch, that is the wrong file.
# The definition as deployed on the default branchgh workflow view ci.yml --yaml
# The definition on a specific ref — what actually rangh api "repos/OWNER/REPO/contents/.github/workflows/ci.yml?ref=my-branch" \ -H "Accept: application/vnd.github.raw"
# Diff the twodiff <(gh workflow view ci.yml --yaml) \ <(gh api "repos/OWNER/REPO/contents/.github/workflows/ci.yml?ref=my-branch" \ -H "Accept: application/vnd.github.raw")That diff answers the question directly. Workflows run from the ref being built for most events, so a
branch with an older or modified workflow file produces different behaviour from the same commit on
main — and nothing in the run output makes that obvious.
The exception worth knowing is pull_request_target and schedule, which run the definition from the
default branch rather than from the ref. That asymmetry is deliberate — it is what makes
pull_request_target able to use secrets safely — and it means the diff above is the wrong check for
those two events, where the default branch version is the one that ran.