shiftlabs Start free
Menu Run tools in GitHub Actions

Run tools in GitHub Actions

Your tools and credentials stay in GitHub, and your own runners can sit inside your network. A shiftlabs workflow starts the run, waits for the result it posts back, and asks GitHub how it ended when nothing arrives.

How it works

The github-actions-runbook template has a workflow, github-runbook, that runs a script from your repository in GitHub Actions and waits for its result. Use it from its agent, from your own workflows, or with Run now.

  • Start

    github-runbook starts .github/workflows/shiftlabs-runbook.yml in your repository with the GitHub API's workflow_dispatch, asking for the run's id with return_run_details. The start step doesn't retry. GitHub takes no idempotency key, so if a run of github-runbook is started again, so is the GitHub run.

  • Wait

    It passes step.eventUrl("result") to the GitHub workflow as the input callback_url, then waits with step.waitForEvent("result") for up to minutes. A result that isn't signed with your key, or is another run's, is skipped, and the wait goes on with the time left, up to 5 times.

  • Report

    The GitHub workflow runs runbooks/<runbook>.sh. Its last step signs the result with your key and posts it to the callback URL, even when the runbook failed.

  • Check

    If no signed result arrives in time, github-runbook asks GitHub for the run. A finished run's conclusion decides. A run still going ends the shiftlabs run as timed out.

  • The GitHub token is a connection's secret. The gateway adds it to requests to api.github.com, and your code never holds it.
  • The credentials your runbooks use stay in GitHub, as GitHub secrets and variables.
  • A self-hosted runner inside your network can reach private systems. To send the result, it needs outbound HTTPS to hooks.shiftlabs.org. Without it, the check after the timeout still finds how the run ended.

Set it up

  • 1. The GitHub workflow

    Add the file below to your repository as .github/workflows/shiftlabs-runbook.yml, on main. GitHub only starts a workflow_dispatch workflow that is on the default branch.

  • 2. A runbook

    Add a script as runbooks/<name>.sh, such as runbooks/hello.sh. Names are 1 to 64 lowercase letters, digits and hyphens.

  • 3. A token

    Create a fine-grained personal access token for that one repository, with the Actions permission set to read and write.

  • 4. A signing key

    Make a random key, such as with openssl rand -hex 32. Save it in GitHub as the repository secret SHIFTLABS_SIGNING_KEY, and in shiftlabs as the secret GITHUB_RUNBOOK_SIGNING_KEY marked hmac. The GitHub workflow signs each result with it, and github-runbook checks the signature with sign(), so the key never enters your code.

  • 5. The secrets, the connection and the var

    An admin saves the token as the secret GITHUB_ACTIONS_TOKEN with no hosts, saves the signing key, and creates the connection github-actions. A developer links it to both workflows and sets the var GITHUB_RUNBOOK_REPO.

  • 6. The template

    Deploy it from Agents → Start from a template, or write it into a project with npx shiftlabs init --template github-actions-runbook. See Templates.

yaml.github/workflows/shiftlabs-runbook.yml
name: shiftlabs runbook
run-name: runbook ${{ inputs.runbook }}

on:
  workflow_dispatch:
    inputs:
      runbook:
        description: The runbook to run, runbooks/<runbook>.sh
        required: true
        type: string
      args:
        description: Arguments for the runbook as JSON, in SHIFTLABS_ARGS
        required: false
        default: "{}"
        type: string
      callback_url:
        description: Where the result goes. shiftlabs sets it.
        required: false
        default: ""
        type: string

permissions:
  contents: read

defaults:
  run:
    shell: bash

jobs:
  run:
    runs-on: ubuntu-latest
    timeout-minutes: 20
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      # Inputs reach the scripts through env only, never pasted into them.
      - name: Run the runbook
        id: runbook
        env:
          RUNBOOK: ${{ inputs.runbook }}
          SHIFTLABS_ARGS: ${{ inputs.args }}
        run: |
          if [[ ! "$RUNBOOK" =~ ^[a-z0-9][a-z0-9-]{0,63}$ || ! -f "runbooks/$RUNBOOK.sh" ]]; then
            echo "No runbook runbooks/$RUNBOOK.sh" | tee "$RUNNER_TEMP/output.txt"
            echo "exit_code=2" >> "$GITHUB_OUTPUT"
            exit 2
          fi
          set +e
          bash "runbooks/$RUNBOOK.sh" 2>&1 | tee "$RUNNER_TEMP/output.txt"
          code=${PIPESTATUS[0]}
          echo "exit_code=$code" >> "$GITHUB_OUTPUT"
          exit "$code"
      - name: Send the result to shiftlabs
        if: ${{ always() && inputs.callback_url != '' }}
        continue-on-error: true
        env:
          CALLBACK_URL: ${{ inputs.callback_url }}
          OUTCOME: ${{ steps.runbook.outcome }}
          EXIT_CODE: ${{ steps.runbook.outputs.exit_code }}
          SIGNING_KEY: ${{ secrets.SHIFTLABS_SIGNING_KEY }}
        run: |
          if [[ -z "$SIGNING_KEY" ]]; then
            echo "::error::Add the repository secret SHIFTLABS_SIGNING_KEY, with the value of GITHUB_RUNBOOK_SIGNING_KEY in shiftlabs."
            exit 1
          fi
          case "$OUTCOME" in
            success) conclusion=success ;;
            cancelled) conclusion=cancelled ;;
            *) conclusion=failure ;;
          esac
          [[ "$EXIT_CODE" =~ ^[0-9]+$ ]] || EXIT_CODE=""
          touch "$RUNNER_TEMP/output.txt"
          tail -c 4000 "$RUNNER_TEMP/output.txt" | jq -Rs . > "$RUNNER_TEMP/output.json"
          output_sha=$(jq -j . "$RUNNER_TEMP/output.json" | openssl dgst -sha256 | awk '{print $NF}')
          signature=$(printf '%s' "$GITHUB_RUN_ID.$conclusion.$EXIT_CODE.$output_sha" | openssl dgst -sha256 -hmac "$SIGNING_KEY" | awk '{print $NF}')
          jq -n --arg run_id "$GITHUB_RUN_ID" --arg conclusion "$conclusion" --arg exit_code "$EXIT_CODE" \
            --arg signature "$signature" --slurpfile output "$RUNNER_TEMP/output.json" \
            '{run_id: $run_id, conclusion: $conclusion, exit_code: ($exit_code | tonumber? // null), output: $output[0], signature: $signature}' |
            curl -sS --fail-with-body --retry 3 -X POST -H "content-type: application/json" --data-binary @- "$CALLBACK_URL"
shellrunbooks/hello.sh
#!/usr/bin/env bash
set -euo pipefail
name=$(jq -r '.name // "world"' <<<"$SHIFTLABS_ARGS")
echo "hello, $name"

A runbook's arguments arrive as one JSON object in SHIFTLABS_ARGS. Read them with jq, and quote every value you use. Its exit code decides the run's conclusion.

shellcurl
# The token, with no hosts: only the connection sends it (admin key).
curl -X PUT https://api.shiftlabs.org/v1/secrets/GITHUB_ACTIONS_TOKEN \
  -H "authorization: Bearer $SHIFTLABS_API_KEY" -H "content-type: application/json" \
  -d "{\"value\": \"$GITHUB_TOKEN\"}"

# The signing key, marked hmac: code can sign with it but never read it (admin key).
curl -X PUT https://api.shiftlabs.org/v1/secrets/GITHUB_RUNBOOK_SIGNING_KEY \
  -H "authorization: Bearer $SHIFTLABS_API_KEY" -H "content-type: application/json" \
  -d "{\"value\": \"$SIGNING_KEY\", \"hmac\": true}"
gh secret set SHIFTLABS_SIGNING_KEY --repo octo-org/ops-runbooks --body "$SIGNING_KEY"

# The connection (admin key).
curl -X PUT https://api.shiftlabs.org/v1/connections/github-actions \
  -H "authorization: Bearer $SHIFTLABS_API_KEY" -H "content-type: application/json" \
  -d '{"hosts": ["api.github.com"],
       "template": {"headers": {"Authorization": "Bearer {{ secret.GITHUB_ACTIONS_TOKEN }}"}}}'

# Link it to both workflows, and name the repository (developer key).
for w in github-runbook github-runbook-runs; do
  curl -X PUT https://api.shiftlabs.org/v1/workflows/$w/connections \
    -H "authorization: Bearer $SHIFTLABS_API_KEY" -H "content-type: application/json" \
    -d '{"connections": ["github-actions"]}'
done
curl -X PUT https://api.shiftlabs.org/v1/vars/GITHUB_RUNBOOK_REPO \
  -H "authorization: Bearer $SHIFTLABS_API_KEY" -H "content-type: application/json" \
  -d '{"value": "octo-org/ops-runbooks"}'

The workflow

github-runbook in TypeScript and in Python. The template deploys the TypeScript one. Both take the same payload and return the same result.

github-runbook
import { workflow } from "shiftlabs";

// The GitHub workflow this starts and the branch it runs from. Change them to start another.
const WORKFLOW_FILE = "shiftlabs-runbook.yml";
const REF = "main";
// The same value as the GitHub repository secret SHIFTLABS_SIGNING_KEY, which signs each result.
const SIGNING_KEY = "GITHUB_RUNBOOK_SIGNING_KEY";
// Results that aren't this run's, or aren't signed, are skipped this many times at most before GitHub is asked.
const MAX_WAITS = 5;
const REPO = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
const RUNBOOK = /^[a-z0-9][a-z0-9-]{0,63}$/;
const HEADERS = { accept: "application/vnd.github+json", "user-agent": "shiftlabs-template", "x-github-api-version": "2022-11-28" };

type Payload = { runbook: string; args?: Record<string, string | number | boolean>; minutes?: number };
type Sent = { run_id?: unknown; conclusion?: unknown; exit_code?: unknown; output?: unknown; signature?: unknown };
type Result = { conclusion: string; exitCode: number | null; output: string };

// No Authorization header: the github-actions connection sets it.
async function github<T>(path: string, init: RequestInit = {}): Promise<T> {
  const response = await fetch(`https://api.github.com${path}`, { ...init, headers: { ...HEADERS, ...init.headers } });
  if (response.ok) return (await response.json()) as T;
  const answer = `GitHub answered ${response.status} for ${path}: ${(await response.text()).slice(0, 300)}`;
  if (response.status === 401 || response.status === 403 || response.status === 404) {
    throw new Error(`${answer}\nCheck that the github-actions connection is linked to this workflow, its token can use Actions in the repository, and ${WORKFLOW_FILE} is on ${REF}.`);
  }
  throw new Error(answer);
}

async function sha256(text: string): Promise<string> {
  const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
  return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
}

function same(a: string, b: string): boolean {
  if (a.length !== b.length) return false;
  let differ = 0;
  for (let i = 0; i < a.length; i++) differ |= a.charCodeAt(i) ^ b.charCodeAt(i);
  return differ === 0;
}

function duration(ms: number): string {
  return ms < 120_000 ? `${Math.round(ms / 1_000)} seconds` : `${Math.round(ms / 60_000)} minutes`;
}

export default workflow<Payload>(async ({ payload, vars, step, log, sign }) => {
  const repo = vars.GITHUB_RUNBOOK_REPO ?? "";
  if (!REPO.test(repo)) throw new Error("set the GITHUB_RUNBOOK_REPO var to the repository with the runbooks, as owner/name");
  const runbook = String(payload.runbook ?? "");
  if (!RUNBOOK.test(runbook)) throw new Error("runbook must be 1 to 64 lowercase letters, digits and hyphens");
  const args = JSON.stringify(payload.args ?? {});
  if (args.length > 20_000) throw new Error("args must be at most 20,000 characters as JSON");
  const minutes = payload.minutes ?? 30;
  if (!Number.isInteger(minutes) || minutes < 1 || minutes > 360) throw new Error("minutes must be a whole number from 1 to 360");
  try {
    await sign(SIGNING_KEY, "check");
  } catch {
    throw new Error(`add the secret ${SIGNING_KEY}, marked hmac, with the value of the GitHub secret SHIFTLABS_SIGNING_KEY`);
  }

  const callback = step.eventUrl("result");
  // No retries: GitHub takes no idempotency key, so a retried dispatch could start a second run.
  const run = await step.run("start the GitHub run", { retries: { limit: 0, delay: "1 second" } }, async () => {
    const started = await github<{ workflow_run_id?: number; html_url: string }>(`/repos/${repo}/actions/workflows/${WORKFLOW_FILE}/dispatches`, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ ref: REF, inputs: { runbook, args, callback_url: callback }, return_run_details: true }),
    });
    if (!started.workflow_run_id) throw new Error("GitHub started the run without returning its id");
    return { id: Number(started.workflow_run_id), url: String(started.html_url) };
  });
  log.info("GitHub run started", run);

  // Anyone who can read the GitHub run can see the callback URL, so only a result signed with the key counts.
  async function accepted(sent: Sent | null): Promise<Result | null> {
    if (sent === null || typeof sent !== "object" || String(sent.run_id) !== String(run.id) || typeof sent.signature !== "string") return null;
    const exitCode = Number.isInteger(sent.exit_code) ? (sent.exit_code as number) : null;
    const output = typeof sent.output === "string" ? sent.output : "";
    const signed = `${sent.run_id}.${sent.conclusion}.${exitCode ?? ""}.${await sha256(output)}`;
    if (!same(await sign(SIGNING_KEY, signed), sent.signature)) return null;
    return { conclusion: String(sent.conclusion), exitCode, output: output.slice(-4000) };
  }

  const waitFrom = await step.run("wait from", () => Date.now());
  let result: Result | null = null;
  let skipped = 0;
  for (let wait = 0; wait < MAX_WAITS && result === null; wait++) {
    const now = wait === 0 ? waitFrom : await step.run("time waited", () => Date.now());
    const left = minutes * 60_000 - (now - waitFrom);
    if (left < 1_000) break;
    const sent = await step.waitForEvent<Sent>("result", { timeout: left });
    if (sent === null) break;
    result = await accepted(sent);
    if (result === null) {
      skipped++;
      log.warn("ignored a result that isn't this run's or isn't signed with the key");
    }
  }
  if (result !== null) return finish(result.conclusion, "callback", { exitCode: result.exitCode, output: result.output });
  const waited = skipped === MAX_WAITS ? (await step.run("time waited", () => Date.now())) - waitFrom : minutes * 60_000;

  const checked = await step.run("check the GitHub run", { retries: { limit: 2, delay: "5 seconds" } }, async () => {
    const found = await github<{ status: string; conclusion: string | null }>(`/repos/${repo}/actions/runs/${run.id}`);
    return { status: String(found.status), conclusion: String(found.conclusion) };
  });
  if (checked.status !== "completed") {
    throw new Error(`timed out: GitHub run ${run.id} is still ${checked.status} after ${duration(waited)} and sent no signed result. ${run.url}`);
  }
  return finish(checked.conclusion, "check", {});

  function finish(conclusion: string, via: "callback" | "check", more: { exitCode?: number | null; output?: string }) {
    if (conclusion !== "success") {
      const tail = more.output ? `\n${more.output.slice(-1000)}` : "";
      throw new Error(`GitHub run ${run.id} ended ${conclusion}. ${run.url}${tail}`);
    }
    return { runbook, conclusion, runId: run.id, url: run.url, via, ...more };
  }
});
  • runbook

    The script to run, runbooks/<runbook>.sh. Required.

  • args

    An object of strings, numbers and booleans, at most 20,000 characters as JSON. The runbook reads it from SHIFTLABS_ARGS.

  • minutes

    How long to wait for the result, 1 to 360, default 30. Make it longer than the job's timeout-minutes plus the time a run can wait for a runner.

  • The result

    {runbook, conclusion, runId, url, via}. via is callback when the GitHub workflow sent the result, with its exitCode and the last 4,000 characters of its output, or check when GitHub was asked after the timeout.

step.call
const restarted = await step.call("restart the api", {
  workflow: "github-runbook",
  payload: { runbook: "restart-api", args: { service: "api" } },
});

step.call returns the result. When the runbook fails or times out, the step fails with that error. The agent's github-runbook tool waits for an approver first. The agent hears the result of a run that ends within 5 minutes. For a longer one, ask it for the latest runs.

Inputs and vars

  • Inputs

    What changes per run: the runbook, its arguments and the callback URL. GitHub takes up to 25 inputs, and every value is a string. The template uses three, and sends args as one JSON input, so a runbook can take as many arguments as it needs.

  • Vars

    What stays the same: the var GITHUB_RUNBOOK_REPO names the repository. The workflow file and the branch are constants at the top of the workflow. Change them to start another GitHub workflow.

  • GitHub secrets and variables

    What your runbooks need, such as cloud credentials. Keep them in GitHub, read them in the job with ${{ secrets.NAME }}, and never pass them as inputs.

  • Anyone who can read the repository can see a run's inputs, the callback URL among them. Use a private repository.
  • The YAML passes inputs to scripts through env. Never paste ${{ inputs.… }} into a run: script: an input could then run its own commands.
  • To start another GitHub workflow, add the input callback_url to it and copy the last step. Inputs it doesn't declare make GitHub refuse the dispatch with 422.

The callback

The GitHub workflow's last step posts JSON to the callback URL: run_id, conclusion (success, failure or cancelled), exit_code and output, the last 4,000 characters of what the runbook printed.

  • The URL reaches only this run and this event, but anyone who can read the GitHub run can see it. So the body also carries signature: HMAC-SHA256, with your key, of run_id.conclusion.exit_code.sha256(output). github-runbook checks it in constant time and skips a result that isn't signed, is signed wrongly, or is another run's.
  • A run that never gets a signed result asks GitHub how the run ended once minutes have passed, or after 5 skipped results.
  • The output goes to shiftlabs and shows in the run's result. GitHub hides its secrets in its own logs, not in what the runbook prints. Don't print secrets.
  • The step runs even when the runbook fails, and continue-on-error keeps a failed post from changing the run's conclusion. Without the GitHub secret SHIFTLABS_SIGNING_KEY, the step fails with an error that says so, and nothing is sent.
  • A result that arrives after the shiftlabs run has ended gets 409 {"error": "run finished"}. See Events.

Timeouts and failures

A failed GitHub run, or one still going at the timeout, fails the shiftlabs run with its link. The GitHub run itself goes on: cancel it in GitHub, or let the job's timeout-minutes stop it.

What a failed run's error says
ErrorWhy
set the GITHUB_RUNBOOK_REPO var …The var isn't set, or isn't owner/name.
add the secret GITHUB_RUNBOOK_SIGNING_KEY, marked hmac …The signing key isn't in shiftlabs, or isn't marked hmac. Nothing was started.
GitHub answered 401 …The connection isn't linked to this workflow, or the token is wrong or has expired.
GitHub answered 403 … or 404 …The token can't use Actions in the repository, the repository name is wrong, or the YAML isn't on main.
GitHub answered 422 …GitHub refused the inputs: the YAML doesn't declare one of them.
GitHub run 123 ended failure. <link>The runbook exited with another code than 0, or runbooks/<runbook>.sh doesn't exist. The end of its output follows.
GitHub run 123 ended cancelled. <link>Someone cancelled the run in GitHub, or the job ran past its timeout-minutes.
timed out: GitHub run 123 is still queued after 30 minutes …No runner picked it up, such as a self-hosted runner that is offline.
timed out: GitHub run 123 is still in_progress after 30 minutes …The runbook took longer than minutes.

A run that ends with via: "check" got no signed result in minutes. Check that the runner can reach hooks.shiftlabs.org, that the last step ran, and that SHIFTLABS_SIGNING_KEY in GitHub and GITHUB_RUNBOOK_SIGNING_KEY in shiftlabs hold the same key.

Cost

  • The job runs on your GitHub plan. GitHub-hosted runners use your account's Actions minutes, and self-hosted runners use none.
  • On shiftlabs, a runbook is one run of a few steps: the start, a time reading per wait, and the check when no signed result arrives. Waiting is not a step. It counts toward the run's lifetime, 168 hours on Free. See Plans and billing.
  • Each request to GitHub counts toward your outbound limits: 30 a minute to one host.
  • The agent's messages use model tokens like any agent's.