GitHub Actions

Last updated

Two workflows for a node group on the webhook adapter, built from the Hetscale Deploy action, hetscalehq/deploy-action, listed on the GitHub Marketplace as Hetscale Deploy. The first deploys a new release to the servers that run, while the group’s deploy lock holds scaling back; the second deploys each new server Hetscale creates. Each step of the action is one call of the API, and the action’s README lists every input and output.

What do I need?

  • A node group on the webhook adapter, Live.
  • An API token, made on the panel’s API tokens page by a member who may operate the group (on Team: an operator, an admin or the owner), with the abilities Lock groups (groups:lock), Change releases (releases:write), Read nodes (nodes:read) and Report nodes (nodes:report); for the variant that replaces the servers instead, also Replace older servers (rollouts:write). The new-server workflow can use a second token with only Read nodes and Report nodes. Store the token as the repository secret HETSCALE_TOKEN, and the group’s id (grp_…) as the repository variable HETSCALE_GROUP.
  • The SSH access your deploy job already has to the servers.

How do I deploy a release to the running servers?

One workflow runs on every push to main. It takes the deploy lock, records the release, reads the serving servers, deploys to each of them as the job already does, reports Hetscale’s servers deployed, and releases the lock:

name: Deploy

on:
  push:
    branches: [main]

permissions:
  contents: read

# One deploy per group at a time: the lock has no owner, so this is what keeps runs apart.
concurrency:
  group: hetscale-deploy-${{ vars.HETSCALE_GROUP }}
  cancel-in-progress: false

jobs:
  deploy:
    runs-on: ubuntu-latest
    timeout-minutes: 25 # below the lock's ttl of 1800 seconds
    env:
      IMAGE: ghcr.io/acme/app:${{ github.sha }}
    steps:
      - uses: actions/checkout@v7

      # Build and push $IMAGE here, as the job already does.

      - name: Take the deploy lock
        id: lock
        uses: hetscalehq/deploy-action@v1
        with:
          command: lock
          token: ${{ secrets.HETSCALE_TOKEN }}
          group: ${{ vars.HETSCALE_GROUP }}
          ttl: 1800

      - name: Record the release
        id: release
        uses: hetscalehq/deploy-action@v1
        with:
          command: release
          token: ${{ secrets.HETSCALE_TOKEN }}
          group: ${{ vars.HETSCALE_GROUP }}
          ref: ${{ env.IMAGE }}

      - name: Read the serving servers
        id: nodes
        uses: hetscalehq/deploy-action@v1
        with:
          command: nodes
          token: ${{ secrets.HETSCALE_TOKEN }}
          group: ${{ vars.HETSCALE_GROUP }}
          release-id: ${{ steps.release.outputs.release-id }}
          address: ipv4 # private_ip when the runner reaches the private network

      - name: Deploy to each server
        shell: bash
        env:
          HOSTS: ${{ steps.nodes.outputs.hosts }} # outputs reach scripts through env, never ${{ }} in run
        run: |
          while IFS= read -r host; do
            [ -n "$host" ] || continue
            ssh -n "deploy@$host" "./deploy.sh $IMAGE" # the deploy your job already runs
          done <<< "$HOSTS"

      - name: Report the servers deployed # never with if: always(), which would report a failed deploy
        uses: hetscalehq/deploy-action@v1
        with:
          command: deployed
          token: ${{ secrets.HETSCALE_TOKEN }}
          release-id: ${{ steps.release.outputs.release-id }}
          node-ids: ${{ steps.nodes.outputs.managed-node-ids }}

      - name: Release the deploy lock
        if: always() && steps.lock.outcome != 'skipped'
        uses: hetscalehq/deploy-action@v1
        with:
          command: unlock
          token: ${{ secrets.HETSCALE_TOKEN }}
          group: ${{ vars.HETSCALE_GROUP }}
  • The release is recorded before the deploy, so every server created from then on boots it.
  • nodes, given the release just recorded, first waits for servers that started on an earlier release, so that they serve and are in hosts when the deploy runs: up to its wait input, 600 seconds by default. Any still starting at the end are named in in-flight-older, with a warning, and stay older until a rollout or the next deploy.
  • The deploy reaches every serving server, the seed included. deployed reports only Hetscale’s own servers, and skips one that left or failed meanwhile, with a warning.
  • deployed never runs with if: always(), which would report a failed deploy. If the deploy fails half-way, run the job again, or make the earlier release current on the group’s Deployment page.
  • Outputs reach scripts through env:, never as ${{ }} inside run:. In a public repository, the deploy step’s env:, with the addresses, shows in its log.
  • Servers without public addresses have ipv4: null: set address: private_ip when the runner reaches the private network (Servers without public addresses).
  • Keep ssh -n: inside a while read loop, ssh would otherwise read the rest of the host list from standard input.

Let Hetscale replace the servers instead

This variant keeps the lock and deploys to the seed itself, because a rollout never replaces the seed, which would otherwise keep serving the earlier build. Then it releases the lock and starts a rollout: Hetscale replaces the older servers one at a time, and the new-server workflow below deploys each new one. Replace the steps from Read the serving servers to the end with:

      - name: Read the serving servers
        id: nodes
        uses: hetscalehq/deploy-action@v1
        with:
          command: nodes
          token: ${{ secrets.HETSCALE_TOKEN }}
          group: ${{ vars.HETSCALE_GROUP }}

      - name: Deploy to the seed
        shell: bash
        env:
          HOSTS: ${{ steps.nodes.outputs.adopted-hosts }}
        run: |
          while IFS= read -r host; do
            [ -n "$host" ] || continue
            ssh -n "deploy@$host" "./deploy.sh $IMAGE"
          done <<< "$HOSTS"

      - name: Release the deploy lock
        if: always() && steps.lock.outcome != 'skipped'
        uses: hetscalehq/deploy-action@v1
        with:
          command: unlock
          token: ${{ secrets.HETSCALE_TOKEN }}
          group: ${{ vars.HETSCALE_GROUP }}

      - name: Replace the older servers
        uses: hetscalehq/deploy-action@v1
        with:
          command: rollout
          token: ${{ secrets.HETSCALE_TOKEN }}
          group: ${{ vars.HETSCALE_GROUP }}

rollout succeeds with an empty rollout-id when every server already runs the current release, and with a warning when a rollout already runs.

How does a new server get the release?

Hetscale sends node.created to the group’s endpoint. GitHub starts a workflow only for a request that carries a GitHub token, and Hetscale’s deliveries carry only its own headers, so a small relay sits between them. As a Cloudflare Worker:

// Verifies Hetscale's node.created and starts the workflow. Secrets: HETSCALE_SECRET (the
// endpoint's signing secret) and GITHUB_DISPATCH_TOKEN; variables: GITHUB_REPO ("owner/repo")
// and WORKFLOW ("new-server.yml").
const encoder = new TextEncoder();

async function hmacHex(secret, message) {
  const key = await crypto.subtle.importKey(
    'raw',
    encoder.encode(secret),
    { name: 'HMAC', hash: 'SHA-256' },
    false,
    ['sign'],
  );
  const mac = new Uint8Array(await crypto.subtle.sign('HMAC', key, encoder.encode(message)));
  return [...mac].map((byte) => byte.toString(16).padStart(2, '0')).join('');
}

export default {
  async fetch(request, env) {
    const body = await request.text();
    if (request.headers.get('X-Hetscale-Event') !== 'node.created') {
      return new Response(null, { status: 204 });
    }
    const timestamp = request.headers.get('X-Hetscale-Timestamp') ?? '';
    if (!(Date.now() / 1000 - Number(timestamp) <= 300)) {
      return new Response('stale delivery', { status: 400 });
    }
    const expected = encoder.encode(await hmacHex(env.HETSCALE_SECRET, `${timestamp}.${body}`));
    const valid = (request.headers.get('X-Hetscale-Signature') ?? '').split(',').some((value) => {
      const [scheme, hex = ''] = value.trim().split('=');
      const given = encoder.encode(hex);
      return (
        scheme === 'v1' &&
        given.byteLength === expected.byteLength &&
        crypto.subtle.timingSafeEqual(given, expected)
      );
    });
    if (!valid) return new Response('bad signature', { status: 401 });

    const event = JSON.parse(body);
    const github = await fetch(
      `https://api.github.com/repos/${env.GITHUB_REPO}/actions/workflows/${env.WORKFLOW}/dispatches`,
      {
        method: 'POST',
        headers: {
          Authorization: `Bearer ${env.GITHUB_DISPATCH_TOKEN}`,
          Accept: 'application/vnd.github+json',
          'User-Agent': 'hetscale-relay',
        },
        body: JSON.stringify({
          ref: 'main',
          inputs: {
            node_id: event.node.id,
            ip: event.node.ipv4 ?? '',
            release: event.release.id,
            generation: String(event.group.generation),
          },
        }),
      },
    );
    // A 2xx only once GitHub took the dispatch (204), so Hetscale retries otherwise.
    return new Response(null, { status: github.ok ? 204 : 502 });
  },
};
  • The relay answers 204 to every event other than node.created, and refuses a delivery whose X-Hetscale-Timestamp is more than five minutes old.
  • It computes the HMAC-SHA256 of <timestamp>.<body> with the endpoint’s secret and compares it, in constant time, with each v1= value of X-Hetscale-Signature: there are two during the 24 hours after you rotate the secret.
  • GITHUB_DISPATCH_TOKEN is a fine-grained GitHub token for that one repository with Actions read and write. It stays in the relay; Hetscale never sees it.
  • The relay answers 2xx only after GitHub’s 204, so Hetscale retries a delivery GitHub did not take.

The workflow it starts deploys to the new server and reports the outcome:

name: Deploy a new server

on:
  workflow_dispatch:
    inputs:
      node_id: { type: string, required: true }
      release: { type: string, required: true }
      generation: { type: string, required: true }
      ip: { type: string, required: false } # sent by the relay; the address comes from the inventory

permissions:
  contents: read

# A repeated delivery waits for the first run, and then finds nothing to deploy.
concurrency:
  group: hetscale-node-${{ inputs.node_id }}
  cancel-in-progress: false

jobs:
  deploy:
    runs-on: ubuntu-latest
    timeout-minutes: 12 # the readiness deadline (15 minutes by default) also counts the time in the queue
    steps:
      - uses: actions/checkout@v7

      - name: Read the new server
        id: node
        uses: hetscalehq/deploy-action@v1
        with:
          command: node
          token: ${{ secrets.HETSCALE_TOKEN }}
          group: ${{ vars.HETSCALE_GROUP }}
          node-id: ${{ inputs.node_id }}
          release-id: ${{ inputs.release }}
          address: ipv4

      - name: Deploy the node's release
        id: deploy
        if: steps.node.outputs.deploy == 'true'
        shell: bash
        env:
          HOST: ${{ steps.node.outputs.address }}
          REF: ${{ steps.node.outputs.release-ref }}
        run: ssh -n "deploy@$HOST" "./deploy.sh $(printf %q "$REF")"

      - name: Report ready
        if: steps.node.outputs.deploy == 'true'
        uses: hetscalehq/deploy-action@v1
        with:
          command: ready
          token: ${{ secrets.HETSCALE_TOKEN }}
          node-id: ${{ inputs.node_id }}
          release-id: ${{ inputs.release }}
          generation: ${{ inputs.generation }}

      # Only on a failed deploy, not on a cancel: a cancelled run leaves the node to a re-run or its deadline.
      - name: Report failed
        if: failure() && steps.deploy.outcome == 'failure'
        uses: hetscalehq/deploy-action@v1
        with:
          command: failed
          token: ${{ secrets.HETSCALE_TOKEN }}
          node-id: ${{ inputs.node_id }}
          release-id: ${{ inputs.release }}
          generation: ${{ inputs.generation }}
  • node takes the server’s address and its release’s ref from the inventory, never from the dispatch, and fails when the dispatch names another release than the server boots.
  • Its deploy output is true only while the server waits for its pipeline. Once its ready report is taken, the server is a load balancer target, so a repeated delivery deploys nothing. Traffic opens only when the load balancer’s health check passes.
  • The readiness deadline, 15 minutes by default, also counts the time the run waits in GitHub’s queue; hence timeout-minutes: 12. If your queue and deploy need longer, raise Readiness deadline before the Rehearsal on the node group’s Go Live page (up to 45 minutes), or under Advanced in Settings once it is Live (up to 60), and raise timeout-minutes with it.
  • ready warns, and succeeds, when the server is leaving or its deploy was cancelled; it fails for a server that failed. failed runs only on a failed deploy, not on a cancel: a cancelled run leaves the node to a re-run or its deadline.

How do I keep deploys apart?

  • The deploy lock has no owner: any token with Lock groups extends or releases it.
  • Run the deploy under concurrency: hetscale-deploy-<group> with cancel-in-progress: false, as above, and lock each group from one workflow only.
  • GitHub’s concurrency groups apply within one repository. A lock taken or released from another repository, from Kamal or from a script shares only the TTL with the workflow.
  • Release the lock in a step of its own, with if: always() && steps.lock.outcome != 'skipped'.
  • Set timeout-minutes below the TTL: a force-cancelled job or a lost runner runs no further step, and the lock then ends on its own.
  • A deploy longer than the TTL runs lock again before its long part, with a TTL of up to 7,200 seconds.

How do I pin the action?

  • uses: hetscalehq/deploy-action@v1 follows the latest v1.x.y; each release is immutable.
  • Organisations that require SHA pinning use the full commit with its version as a comment, which Dependabot keeps up to date: uses: hetscalehq/deploy-action@c9ba0fd7e9fc6997377abc947c91aaf40ff8c154 # v1.0.1.
  • A removed or renamed input or output, or a new Node runtime, comes only in a new major version.
  • The action runs on the node24 runtime: GitHub-hosted runners have it, and a self-hosted runner needs v2.328.0 or later. Behind a proxy, set NODE_USE_ENV_PROXY: 1 in the step’s env: next to HTTPS_PROXY.

What about other CI?

GitLab CI or any other CI makes the same calls with curl and jq: the Kamal page’s script shows them in order, though it reads the servers once, without the action’s wait for servers still starting on the earlier release, and the API reference lists every endpoint. A retry of a POST should send the same Idempotency-Key.

The webhook adapter explains the events, the reports and the deploy lock, Kamal applies the same calls to Kamal, and the API reference lists every endpoint.

See what Hetscale would have done with your real data — connect read-only, get your report in minutes.

Connect read-only