Skip to content

Blacksmith and Depot through GitHub Actions

Run a Pando repo's tests on Blacksmith or Depot runners, started by a GitHub Actions repository dispatch.

Blacksmith and Depot sell runners for GitHub Actions. Neither documents a way to start a job from a git server other than GitHub, so with Pando they run through GitHub Actions: a push to Pando starts a workflow in a GitHub repo you keep for CI, through GitHub’s repository dispatch. The workflow runs on a Blacksmith or Depot runner, fetches the commit from Pando, runs your tests, and posts the check back to Pando.

How it flows

  1. You push a branch to Pando.
  2. Pando’s web hook sends the push, signed, to a relay you run.
  3. The relay checks the signature and calls GitHub: POST https://api.github.com/repos/<org>/<ci-repo>/dispatches.
  4. GitHub starts the workflow on a Blacksmith or Depot runner. It fetches the commit from Pando with the hook’s read token and runs your tests.
  5. Its last step posts the check to Pando with the hook’s status token.

The relay is needed because a Pando hook sends Pando’s own JSON, while GitHub’s dispatch API wants your GitHub token and its own body. Pando does not run the relay for you.

What you need

  • A private GitHub repo for CI, in a GitHub organization. Blacksmith does not support personal repos. The workflow file must be on its default branch.
  • The runner’s GitHub app installed on that organization: Blacksmith’s or Depot’s.
  • A GitHub token that can call the dispatch API. A classic token needs the repo scope. The fine-grained permission it needs: unknown.
  • A relay: any HTTPS address you run, such as a Cloudflare Worker.
  • Owner rights on the Pando repository, to add the hook.

1. The workflow

In the CI repo, on its default branch, add .github/workflows/pando.yml:

name: pando
on:
  repository_dispatch:
    types: [pando-push]
jobs:
  test:
    runs-on: blacksmith-2vcpu-ubuntu-2404   # Depot: depot-ubuntu-24.04
    steps:
      - name: Fetch the commit from Pando
        env:
          CLONE_URL: ${{ github.event.client_payload.clone_url }}
          READ_TOKEN: ${{ github.event.client_payload.read_token }}
          SHA: ${{ github.event.client_payload.sha }}
        run: |
          git init -q repo
          cd repo
          git -c "http.extraHeader=Authorization: Bearer $READ_TOKEN" fetch -q --depth 1 "$CLONE_URL" "$SHA"
          git checkout -q --detach FETCH_HEAD
      - name: Test
        working-directory: repo
        run: ./test.sh
      - name: Post the check to Pando
        if: always()
        env:
          STATUS_URL: ${{ github.event.client_payload.status_url }}
          STATUS_TOKEN: ${{ github.event.client_payload.status_token }}
          STATE: ${{ job.status == 'success' && 'success' || 'failure' }}
          RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
        run: |
          curl -fsS -X POST "$STATUS_URL" \
            -H "Authorization: Bearer $STATUS_TOKEN" \
            -H "Content-Type: application/json" \
            -d "{\"state\":\"$STATE\",\"context\":\"ci\",\"target_url\":\"$RUN_URL\"}"

Only the runs-on label differs between Blacksmith and Depot. No page read says a repository dispatch behaves differently on their runners than on GitHub’s own; that is assumed, not tested.

2. The relay

The relay receives Pando’s hook, checks its X-Pando-Signature-256 header (sha256= and the HMAC-SHA256 of the raw body, keyed with the hook’s secret), and sends GitHub a dispatch. GitHub takes at most 10 top-level properties in client_payload, under 64 KB in all, and an event_type of at most 100 characters.

A sketch as a Cloudflare Worker, with the secrets PANDO_HOOK_SECRET and GITHUB_TOKEN:

// Pando's push hook in, GitHub's repository dispatch out.
const CI_REPO = "your-org/your-ci-repo";

export default {
  async fetch(request, env) {
    const body = await request.text();
    const sig = (request.headers.get("x-pando-signature-256") ?? "").replace(/^sha256=/, "");
    const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(env.PANDO_HOOK_SECRET), { name: "HMAC", hash: "SHA-256" }, false, ["verify"]);
    const bytes = new Uint8Array((sig.match(/../g) ?? []).map((h) => parseInt(h, 16)));
    if (!(await crypto.subtle.verify("HMAC", key, bytes, new TextEncoder().encode(body)))) return new Response("bad signature", { status: 401 });

    const p = JSON.parse(body);
    if (p.event !== "push" || /^0+$/.test(p.head)) return new Response("nothing to build");

    const res = await fetch(`https://api.github.com/repos/${CI_REPO}/dispatches`, {
      method: "POST",
      headers: { authorization: `Bearer ${env.GITHUB_TOKEN}`, accept: "application/vnd.github+json", "content-type": "application/json", "user-agent": "pando-relay" },
      body: JSON.stringify({
        event_type: "pando-push",
        client_payload: { repo: p.repo, branch: p.branch, sha: p.head, clone_url: p.clone_url, read_token: p.read_token, status_url: p.status_url, status_token: p.status_token },
      }),
    });

    // Anything but 2xx makes Pando try the delivery again.
    return new Response(res.ok ? "dispatched" : `GitHub answered ${res.status}`, { status: res.ok ? 200 : 502 });
  },
};

3. The hook in Pando

Make a credential for the API, then add a web hook to the repo whose pushes should run the workflow. Run this in a terminal:

pando credential create --name ci-setup --out ~/ci-setup.token
curl -fsS -X POST "https://<server>/v1/monorepos/<owner>/<name>/repos/<repo>/hooks" \
  -H "Authorization: Bearer $(cat ~/ci-setup.token)" \
  -H "Content-Type: application/json" \
  -d '{"type":"web","url":"https://<your-relay>","secret":"<the relay secret>","events":["push"],"app":"github-actions"}'

<repo> is the repo’s path with each / turned into - (projects/web is projects-web). app names the CI service on the checks this hook’s deliveries post. Revoke the credential afterwards with pando credential revoke <id> (pando credential list shows the id). See createHook in the API reference.

Then require the check, if Merge should wait for it (see Checks and CI):

[repos."projects/web"]
required_checks = ["ci"]

Tokens and what can see them

  • The read token reads one repo and lasts 30 minutes from the delivery. A job that waits longer in GitHub’s queue fails at the fetch; push again.
  • The status token posts checks on its one commit, in its one repo, for 24 hours.
  • Payload values show in the run’s logs. Treat both tokens as visible to anyone who can read the CI repo’s Actions logs, and keep that repo private.

Unknown

  • The fine-grained GitHub permission for repository dispatch.
  • GitHub’s rate limit for repository dispatch.
  • Whether Blacksmith or Depot add anything of their own to a dispatched run. Their docs read on 2026-10-02 say nothing either way.
Navigation

Type to search…

↑↓ navigate↵ selectEsc close