Skip to content

gh workflow: Inspecting and Triggering GitHub Actions Workflows

Lesson 6 of 10Intermediate10 min readGitHub Engineering · GitHub CLIVerified: gh 2.98.0 on Ubuntu 24.04, August 2026

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.

CommandDoes
listList workflows in the repository
viewShow a workflow, its recent runs, or its YAML
runTrigger a workflow_dispatch event
enable / disableTurn a workflow on or off
Terminal window
gh workflow list
gh workflow list --all
gh workflow view build.yml
gh workflow view build.yml --yaml
gh workflow view 12345678

Workflows 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.

Terminal window
gh workflow run deploy.yml

What 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.

By default the workflow runs on the repository’s default branch. --ref chooses another:

Terminal window
gh workflow run deploy.yml --ref release/2.0
gh workflow run test.yml --ref my-feature-branch

Two 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.

Workflows can declare inputs, and -f supplies them:

Terminal window
gh workflow run deploy.yml -f environment=staging -f version=1.4.0
gh workflow run deploy.yml --ref release/2.0 -f environment=production -f dry_run=true

Corresponding 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: false

For anything with many inputs, or generated ones, JSON on standard input is cleaner than a long line of -f flags:

Terminal window
echo '{"environment":"staging","version":"1.4.0","dry_run":"true"}' \
| gh workflow run deploy.yml --json
Terminal window
gh workflow disable nightly.yml
gh workflow enable nightly.yml

Disabling 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.

gh workflow run returns as soon as the event is accepted; it does not wait. To follow the run, switch to gh run:

Terminal window
gh workflow run deploy.yml -f environment=staging
sleep 5
gh run list --workflow deploy.yml --limit 1
gh run watch

The 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.

gh workflow list shows workflows and their state, which is less obvious than it sounds because “state” has three values that mean different things.

Terminal window
gh workflow list --all --json id,name,path,state \
--jq '.[] | [.state, .name, .path] | @tsv'

Output:

active Build .github/workflows/build.yml
active Test .github/workflows/test.yml
disabled_manually Nightly audit .github/workflows/nightly.yml
disabled_inactivity Weekly report .github/workflows/weekly.yml

active — 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.

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:

Terminal window
gh workflow view deploy.yml --yaml | head -20
gh api "repos/OWNER/REPO/contents/.github/workflows/deploy.yml?ref=main" \
-H "Accept: application/vnd.github.raw" | head -20

The 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 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: false

Called with:

Terminal window
gh workflow run deploy.yml \
-f environment=production \
-f version=1.4.0 \
-f skip_tests=false

Inside 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 }}"

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: choice beats 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”.

Terminal window
gh workflow run backfill.yml -f table=orders -f since=2026-08-01 -f dry_run=true

A 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.

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.

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 bash
set -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"
done

Two questions that inventory answers.

Which scheduled workflows have been disabled for inactivity? They stopped running and nobody was told:

Terminal window
... | 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:

Terminal window
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
done

pull_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.

Workflows can declare concurrency groups, which matters for dispatch-driven operations:

concurrency:
group: deploy-${{ inputs.environment }}
cancel-in-progress: false

Two 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.

Triggering a workflow in another repository is a common integration, and it needs a credential the workflow token cannot provide:

Terminal window
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:

Terminal window
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.

  1. Add a workflow with a workflow_dispatch trigger and one choice input.
  2. Commit and push it to the default branch.
  3. Run gh workflow list and confirm it appears.
  4. Trigger it with gh workflow run <file> -f <input>=<value>.
  5. Find the resulting run with gh run list --workflow <file> --limit 1.
  6. Disable the workflow, attempt to trigger it again, and observe the failure.

A workflow can call another, which changes what gh workflow list shows and what you can dispatch.

# .github/workflows/deploy.yml — the caller
on:
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: inherit

The 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.

The recurring value of workflow_dispatch is that it turns an ad-hoc operation into a recorded one.

Terminal window
gh workflow run rotate-credentials.yml -f service=payments -f dry_run=true
gh run list --workflow rotate-credentials.yml --limit 5 \
--json databaseId,displayTitle,createdAt,conclusion,event

The 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.

  • gh workflow manages definitions; gh run manages executions.
  • A workflow only responds to gh workflow run if it declares workflow_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.

Before pushing a workflow change, it is worth knowing what triggers it and whether that is what you intended.

Terminal window
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:

Terminal window
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
done

That 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.

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.

Terminal window
# The definition as deployed on the default branch
gh workflow view ci.yml --yaml
# The definition on a specific ref — what actually ran
gh api "repos/OWNER/REPO/contents/.github/workflows/ci.yml?ref=my-branch" \
-H "Accept: application/vnd.github.raw"
# Diff the two
diff <(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.

Professional ToolkitThe gh api recipes and the PR triage and release-notes scripts are in the Professional Toolkit.