Skip to content

Workflow recipes

Copy-pasteable workflows for the Action, and the reasoning behind the parts that are easy to get wrong.

All of these live in examples/workflows/ too.

Action inputs

InputDefaultNotes
jev-keyRequired. Pass a secret, never a literal.
github-token${{ github.token }}
config-path.github/dispatch.yml
templates-path.github/dispatch-templates
modefrom configshadow, suggest, or auto
commandtriagetriage, escalate, or backfill
dry-runfalseDecide and log, whatever the mode says
upload-logtrueWrite .dispatch/decisions.jsonl

Action outputs

OutputMeaning
decision-keyIdempotency key of the decision made
appliedNumber of actions applied
suggestedNumber proposed but not applied
suppressedNumber a rule wanted but a gate blocked
modeThe mode the run actually used

Day 0: shadow mode with a decision log

Start here. This writes nothing to the repository at all.

yaml
name: Dispatch

on:
  issues:
    types: [opened, edited, reopened]
  pull_request:
    types: [opened, edited, ready_for_review, synchronize]
  issue_comment:
    types: [created]

permissions:
  contents: read
  issues: write
  pull-requests: write

concurrency:
  group: dispatch-${{ github.event.issue.number || github.event.pull_request.number }}
  cancel-in-progress: false

jobs:
  triage:
    runs-on: ubuntu-latest
    steps:
      - uses: JeelGajera/Dispatch/apps/action@v0.1.0
        id: dispatch
        with:
          jev-key: ${{ secrets.TYPESAFE_API_KEY }}

      - name: upload the decision log
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: dispatch-decisions-${{ github.run_id }}
          path: .dispatch/decisions.jsonl
          if-no-files-found: ignore
          retention-days: 30

The permissions block still grants writes even in shadow mode. That is deliberate: when you promote to suggest you change one line in .github/dispatch.yml rather than editing the workflow, and nothing about the workflow has to be re-reviewed.

Why concurrency matters

GitHub has undocumented secondary rate limits on content creation, and exceeding them blocks an installation rather than throttling it. Two runs racing on the same item can also produce two comments, which breaks the one-comment-per-item guarantee.

cancel-in-progress: false because a cancelled triage wastes the provider call it already paid for.


Nightly escalations

Some rules depend on time passing. "This has been waiting on a reproduction for fourteen days" is not an event GitHub sends, so it needs a schedule.

yaml
name: Dispatch escalations

on:
  schedule:
    - cron: '30 3 * * *'
  workflow_dispatch:

permissions:
  contents: read
  issues: write
  pull-requests: write

jobs:
  escalate:
    runs-on: ubuntu-latest
    steps:
      - uses: JeelGajera/Dispatch/apps/action@v0.1.0
        with:
          jev-key: ${{ secrets.TYPESAFE_API_KEY }}
          command: escalate

Matching configuration:

yaml
escalations:
  staleNeedsRepro:
    afterDays: 14
    then:
      - { op: comment, template: stale.md, key: stale }
      - { op: close, reason: not_planned }

allowDestructive: [close] # required, or the close is suppressed

Pull requests from forks

Read this before using pull_request_target

pull_request from a fork gets a read-only token and no secrets. Correct and safe — and it means Dispatch cannot label the pull request or read your API key.

pull_request_target gets a write token and your secrets, and runs the workflow from the base branch. It is safe only if you never check out or execute the pull request's code. An attacker opening a pull request controls that code completely; running it with this token and these secrets is a repository takeover.

So the workflow below has no checkout step, and must never gain one.

yaml
name: Dispatch (fork pull requests)

on:
  pull_request_target:
    types: [opened, edited, ready_for_review, synchronize]

permissions:
  contents: read
  pull-requests: write
  issues: write

concurrency:
  group: dispatch-pr-${{ github.event.pull_request.number }}
  cancel-in-progress: false

jobs:
  triage:
    runs-on: ubuntu-latest
    steps:
      # NO actions/checkout. See the warning above.
      - uses: JeelGajera/Dispatch/apps/action@v0.1.0
        with:
          jev-key: ${{ secrets.TYPESAFE_API_KEY }}

Dispatch reads pull request metadata through the API and executes nothing from the head. If you need to build or test fork code, do it in a separate pull_request workflow with no secrets.


Gate a merge on the triage result

Use the outputs to fail a check when something needs a human.

yaml
jobs:
  triage:
    runs-on: ubuntu-latest
    outputs:
      suppressed: ${{ steps.dispatch.outputs.suppressed }}
    steps:
      - uses: JeelGajera/Dispatch/apps/action@v0.1.0
        id: dispatch
        with:
          jev-key: ${{ secrets.TYPESAFE_API_KEY }}
          mode: auto

      - name: needs a human
        if: steps.dispatch.outputs.suggested != '0'
        run: |
          echo "Dispatch proposed ${{ steps.dispatch.outputs.suggested }} action(s) it was not confident enough to apply." >> "$GITHUB_STEP_SUMMARY"

Resist making this exit 1 until dispatch eval shows calibration you trust. A blocking check driven by an uncalibrated threshold is how a useful tool becomes one people route around.


Try a config change without committing it

yaml
on:
  workflow_dispatch:
    inputs:
      mode:
        type: choice
        options: [shadow, suggest, auto]
        default: shadow

jobs:
  triage:
    runs-on: ubuntu-latest
    steps:
      - uses: JeelGajera/Dispatch/apps/action@v0.1.0
        with:
          jev-key: ${{ secrets.TYPESAFE_API_KEY }}
          mode: ${{ inputs.mode }}
          dry-run: true

dry-run: true decides and logs whatever the mode says, so you can see what autowould do on a live repository without it doing anything.


Pin the version

yaml
- uses: JeelGajera/Dispatch/apps/action@v0 # moving tag, follows every 0.x release
- uses: JeelGajera/Dispatch/apps/action@v0.1.0 # exact release
- uses: JeelGajera/Dispatch/apps/action@3f9a1c2 # pinned commit, most cautious

@v0 is the usual choice: it picks up each 0.x release without a workflow edit. Pin an exact release or a commit when you want to decide yourself when the version moves, and let Dependabot open the bump as a pull request.

@latest is not available. GitHub resolves the part after @ as a git branch, tag or commit, and Dispatch publishes no tag by that name. The Latest badge on a Releases page is a release marker rather than a git tag, so it cannot be referenced from a workflow — a moving tag like @v0 is how that is done.

The Action runs from the committed dist/ bundle, so a pinned commit pins the exact code that will run. CI enforces that the committed bundle matches its sources.


Self-hosted runners

Nothing special is required, but note that the Action stores its decision log in the Actions cache, keyed per repository. On a self-hosted runner with a persistent workspace, .dispatch/ may survive between runs — harmless, since the cache is restored over it, but worth knowing if you are debugging why a decision was served from cache.

Use /dispatch recheck on the item, or --force from the CLI, to bypass it.

MIT licensed.