background-shape
Orchestrating GitHub Actions From n8n, Webhooks, Dispatch, and Sanity
May 11, 2023 · 7 min read · by Muhammad Amal programming

TL;DR — Use repository_dispatch for cross-repo and external triggers; use workflow_dispatch for human or admin invocations. / Authenticate with a GitHub App, not a PAT, the moment you have more than one workflow doing this. / Keep n8n as the orchestrator and GitHub Actions as the executor — don’t blur the line, or you’ll have business logic in three places.

There’s a pattern I keep seeing teams stumble into. They start using n8n for the easy stuff — “post a Slack message when a Jira ticket changes.” Then someone realizes they can call APIs from it. Then they’re writing complex CI-adjacent logic inside n8n function nodes, and six months later nobody can explain why the staging deploy depends on a workflow named “Untitled Workflow 47.”

The fix is a clean split. n8n is great for orchestration: listening for events, deciding what to do, calling out to other systems. GitHub Actions is great for execution: anything that needs a checkout, a build environment, or a deploy target. The trick is wiring them together cleanly. This post is about that wiring.

It builds on the n8n self-hosted setup from earlier this month and assumes you have a webhook worker process up and running.

Two ways to trigger a workflow from outside

GitHub gives you two event types to start a workflow externally. They’re easy to confuse and they have different semantics.

workflow_dispatch is the “Run workflow” button in the Actions UI. It targets a specific workflow file by name. It supports typed inputs declared in the workflow YAML. It’s perfect for human-triggered admin actions: “run the staging refresh,” “kick off the data backfill.”

repository_dispatch is a server-to-server event. It doesn’t target a specific workflow — any workflow listening for an on: repository_dispatch event with a matching types filter will run. It supports an arbitrary client_payload JSON object. It’s the right choice when the external system (n8n, Jira, anything) is the source of truth for the trigger.

For automation, I use repository_dispatch almost exclusively. The reason is composability: I can fire one event and have three workflows respond to it in parallel, with each filtering on event.action.

A minimal workflow listening for repository_dispatch:

name: Backlog Refresh

on:
  repository_dispatch:
    types: [refresh-backlog]

jobs:
  refresh:
    runs-on: ubuntu-22.04
    steps:
      - uses: actions/checkout@v3
      - name: Use payload
        run: |
          echo "Source: ${{ github.event.client_payload.source }}"
          echo "Triggered by: ${{ github.event.client_payload.actor }}"
      - name: Setup Node
        uses: actions/setup-node@v3
        with:
          node-version: '18'
      - run: npm ci
      - run: node scripts/refresh-backlog.js
        env:
          LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }}
          PAYLOAD: ${{ toJSON(github.event.client_payload) }}

And the n8n side: an HTTP Request node configured to POST to https://api.github.com/repos/{owner}/{repo}/dispatches with a JSON body:

{
  "event_type": "refresh-backlog",
  "client_payload": {
    "source": "linear-webhook",
    "actor": "{{ $json.actor.email }}",
    "issue_id": "{{ $json.data.id }}"
  }
}

That’s the whole loop. n8n sees a Linear event, decides whether to act, fires a repository_dispatch, and a GitHub Actions workflow handles the actual work.

Authentication: stop using personal access tokens

The lazy path is to mint a personal access token, drop it in n8n credentials, and call it done. This works. It’s also a footgun.

Three problems with PATs for production automation:

  1. They inherit the permissions of one human. When that human’s permissions change (or they leave), things break in non-obvious ways.
  2. The classic PAT expiration model is “never,” which gets you audit-fail status in any halfway-serious security review.
  3. There’s no scoping below “all repos this user has access to” for classic tokens. Fine-grained PATs help, but they still belong to a person.

The right answer for any serious automation is a GitHub App. Specifically:

  • Create an App in your organization (Settings -> Developer settings -> GitHub Apps).
  • Grant it only the permissions it needs. For repository_dispatch, that’s Contents: Read & Write plus Actions: Read & Write.
  • Install it on the specific repos that need to receive events.
  • Generate a private key (PEM) and store it in your secret manager.
  • At runtime, mint an installation access token (valid one hour, JWT-based) and use that for API calls.

The token-minting flow in Node:

const { createAppAuth } = require('@octokit/auth-app');
const { Octokit } = require('@octokit/rest');

const auth = createAppAuth({
  appId: process.env.GH_APP_ID,
  privateKey: process.env.GH_APP_PRIVATE_KEY,
  installationId: process.env.GH_APP_INSTALLATION_ID,
});

const { token } = await auth({ type: 'installation' });

const octokit = new Octokit({ auth: token });

await octokit.repos.createDispatchEvent({
  owner: 'my-org',
  repo: 'backlog-jobs',
  event_type: 'refresh-backlog',
  client_payload: {
    source: 'linear-webhook',
    actor: 'amal@example.com',
  },
});

For n8n, you have two choices. Either run this token mint in a tiny sidecar service that n8n calls, or use the n8n GitHub credential type configured with an App. As of n8n 0.225, App auth is supported natively in the GitHub credentials. Use it.

Signing the other direction: GitHub webhooks into n8n

The flow above is n8n -> GitHub. The reverse direction — GitHub webhook -> n8n — is just as important and often gets less hardening. Every GitHub webhook can be signed; configure a secret when creating the webhook and verify the x-hub-signature-256 header on receipt.

const crypto = require('crypto');

function verifyGitHubSignature(rawBody, header, secret) {
  if (!header) return false;
  const expected = 'sha256=' + crypto
    .createHmac('sha256', secret)
    .update(rawBody)
    .digest('hex');
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(header));
}

In n8n, drop a Function node directly after the webhook node and reject if verification fails. The official guidance is in the GitHub webhook signing docs.

Reading workflow results back

A common follow-up question: “How does n8n know whether the workflow succeeded?” Three options, in increasing order of robustness:

Option 1: Poll. After firing repository_dispatch, poll /repos/{owner}/{repo}/actions/runs until a matching run appears, then poll its status. Crude but it works. Don’t forget to filter by created time and event=repository_dispatch.

Option 2: Listen for workflow_run webhooks. Configure a webhook for workflow_run.completed events. n8n receives the result asynchronously and correlates it to the original trigger via the client_payload echoed in workflow_run.head_commit.message or a custom step that writes back an identifier.

Option 3: Have the workflow call back. Add a final step to the workflow that POSTs to an n8n webhook with the run result. This is the most reliable and the most explicit.

I default to Option 3 for anything where the downstream needs the result. The contract is clear, the latency is low, and there’s no polling overhead.

- name: Notify orchestrator
  if: always()
  run: |
    curl -X POST "$N8N_CALLBACK_URL" \
      -H "Content-Type: application/json" \
      -H "X-Signing-Token: ${{ secrets.N8N_CALLBACK_TOKEN }}" \
      -d "{\"run_id\": \"${{ github.run_id }}\", \"status\": \"${{ job.status }}\", \"correlation_id\": \"${{ github.event.client_payload.correlation_id }}\"}"

The if: always() is important — without it, the notify step doesn’t run on workflow failure, which is exactly when you need the notification most.

Where to draw the line

The reason this all stays manageable is a clear separation:

  • n8n owns: external triggers, routing, conditional logic, calling APIs that don’t need a build environment, multi-step coordination across systems.
  • GitHub Actions owns: anything involving the repo (checkout, build, test, deploy), anything needing scratch disk or a full runtime, anything where reproducibility from source matters.

When I see teams in trouble, it’s usually because business logic lives in both places. The n8n workflow knows about deploy environments and the GitHub Action knows about Linear states, and there’s no clean way to test either of them in isolation. Pick one side per concern and stick to it.

Common Pitfalls

  • Workflow not triggering on repository_dispatch. The dispatch only triggers workflows on the default branch (usually main). If your workflow YAML is on a feature branch, it won’t run. This is documented but easy to miss when testing.
  • workflow_dispatch requires the workflow to already exist on the branch. If you’re testing a new workflow file via the API, you need to push it to a branch and then call dispatch with that ref.
  • client_payload size limits. The payload caps at around 64 KB. If you’re tempted to stuff a full Jira issue in there, don’t — pass an ID and have the workflow fetch the rest.
  • Concurrent runs. By default, every dispatch starts a new run, even if one is already in flight. Use concurrency: in the workflow YAML to serialize or cancel-in-progress, depending on what you need.
  • Octokit auth caching. Installation tokens expire after one hour. The @octokit/auth-app library caches and refreshes them automatically, but if you’re using raw fetch, you’ll need to handle expiry yourself.

Wrapping Up

n8n plus GitHub Actions is one of those combinations that feels obvious in retrospect. Once you stop trying to do CI inside n8n and stop trying to do orchestration inside YAML, the seams between the two tools fall away.

Next in this series: pulling in the AI angle. I’ve been experimenting with using OpenAI’s API from n8n to auto-triage incoming tickets, and the results are surprising — both in good ways and in ways that suggest a lot of caution before you turn this loose on production tickets.