# GitHub Actions recipes

Use these workflows when you want a repeatable scheduled job without running a
server. Keep HollyHR tokens in GitHub Actions secrets, run the workflows in a
private repository when they emit HR data, and start with read-only scopes.

Recommended repository secrets:

| Secret                 | Used for                                                            |
| ---------------------- | ------------------------------------------------------------------- |
| `HOLLYHR_API_BASE_URL` | Tenant API base URL, for example `https://acme.hollyhr.com/api/v1`. |
| `HOLLYHR_API_TOKEN`    | Scoped HollyHR API key. Start with read-only scopes.                |
| `HOLLYHR_MCP_URL`      | Tenant MCP URL, for example `https://acme.hollyhr.com/api/mcp`.     |
| `SLACK_WEBHOOK_URL`    | Slack incoming webhook for the digest recipe.                       |

## SDK first-call smoke

This workflow installs the public SDK and runs the packaged first-call example.
Use it as a scheduled confidence check that the key, base URL, network path,
and people read scope still work.

```yaml
name: HollyHR API smoke

on:
  workflow_dispatch:
  schedule:
    - cron: "15 8 * * 1-5"

jobs:
  first-call:
    runs-on: ubuntu-latest
    steps:
      - name: Install SDK
        run: |
          npm init -y
          npm install @hollyhr/api-client

      - name: Run first-call example
        env:
          HOLLYHR_API_BASE_URL: ${{ secrets.HOLLYHR_API_BASE_URL }}
          HOLLYHR_API_TOKEN: ${{ secrets.HOLLYHR_API_TOKEN }}
        run: node node_modules/@hollyhr/api-client/examples/first-call.mjs
```

Minimum HollyHR scopes:

```text
people:read
```

## Slack who's-away digest

This workflow posts a weekday digest of approved absences for the next seven
days. It deliberately posts only dates and names, not absence categories.

```yaml
name: HollyHR Slack who's away

on:
  workflow_dispatch:
  schedule:
    - cron: "30 8 * * 1-5"

jobs:
  digest:
    runs-on: ubuntu-latest
    steps:
      - name: Post Slack digest
        env:
          HOLLYHR_API_BASE_URL: ${{ secrets.HOLLYHR_API_BASE_URL }}
          HOLLYHR_API_TOKEN: ${{ secrets.HOLLYHR_API_TOKEN }}
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
        run: |
          node <<'EOF'
          const apiBaseUrl = process.env.HOLLYHR_API_BASE_URL;
          const apiToken = process.env.HOLLYHR_API_TOKEN;
          const slackWebhookUrl = process.env.SLACK_WEBHOOK_URL;

          if (!apiBaseUrl || !apiToken || !slackWebhookUrl) {
            throw new Error("Missing HOLLYHR_API_BASE_URL, HOLLYHR_API_TOKEN, or SLACK_WEBHOOK_URL");
          }

          async function holly(path) {
            const response = await fetch(`${apiBaseUrl}${path}`, {
              headers: {
                Authorization: `Bearer ${apiToken}`,
                Accept: "application/json",
              },
            });

            if (!response.ok) {
              throw new Error(`HollyHR API request failed: ${response.status} ${await response.text()}`);
            }

            return response.json();
          }

          async function listAll(path) {
            const items = [];
            let cursor;

            do {
              const separator = path.includes("?") ? "&" : "?";
              const page = await holly(
                `${path}${cursor ? `${separator}cursor=${encodeURIComponent(cursor)}` : ""}`,
              );
              items.push(...page.data);
              cursor = page.pagination?.next_cursor;
            } while (cursor);

            return items;
          }

          function isoDate(date) {
            return date.toISOString().slice(0, 10);
          }

          function addDays(date, days) {
            const next = new Date(date);
            next.setUTCDate(next.getUTCDate() + days);
            return next;
          }

          const today = new Date();
          const from = isoDate(today);
          const to = isoDate(addDays(today, 6));
          const [people, timeOff] = await Promise.all([
            listAll("/people?limit=100"),
            listAll(`/time-off?limit=100&status=approved&from=${from}&to=${to}`),
          ]);

          const peopleById = new Map(people.map((person) => [person.id, person]));
          const lines = timeOff.map((entry) => {
            const person = peopleById.get(entry.person_id);
            const name = person?.display_name ?? "Someone";
            const department = person?.department?.name ? ` (${person.department.name})` : "";
            const dates =
              entry.start_date === entry.end_date
                ? entry.start_date
                : `${entry.start_date} to ${entry.end_date}`;
            return `- ${name}${department}: away, ${dates}`;
          });

          const text =
            lines.length > 0
              ? `Who's away from ${from} to ${to}:\n${lines.join("\n")}`
              : `Nobody is away from ${from} to ${to}.`;

          const slackResponse = await fetch(slackWebhookUrl, {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ text }),
          });

          if (!slackResponse.ok) {
            throw new Error(`Slack webhook failed: ${slackResponse.status} ${await slackResponse.text()}`);
          }
          EOF
```

Minimum HollyHR scopes:

```text
people:read time_off:read
```

## Payroll readiness export artifact

The payroll readiness export is read-only but elevated. Run this only in a
private repository, keep artifact retention short, and limit access to the
people who are allowed to handle payroll handoff material.

```yaml
name: HollyHR payroll readiness export

on:
  workflow_dispatch:

jobs:
  export:
    runs-on: ubuntu-latest
    steps:
      - name: Download payroll readiness package
        env:
          HOLLYHR_API_BASE_URL: ${{ secrets.HOLLYHR_API_BASE_URL }}
          HOLLYHR_API_TOKEN: ${{ secrets.HOLLYHR_API_TOKEN }}
        run: |
          mkdir -p out
          curl -fsS "$HOLLYHR_API_BASE_URL/payroll-readiness-export" \
            -H "Authorization: Bearer $HOLLYHR_API_TOKEN" \
            -H "Accept: application/json" \
            -o out/hollyhr-payroll-readiness-export.json

      - name: Upload short-lived artifact
        uses: actions/upload-artifact@v4
        with:
          name: hollyhr-payroll-readiness-export
          path: out/hollyhr-payroll-readiness-export.json
          retention-days: 7
```

Minimum HollyHR scopes:

```text
payroll_exports:read
```

## MCP metadata and transport smoke

This workflow proves that protected-resource metadata is reachable and that the
hosted MCP endpoint returns the expected `405 Method Not Allowed` for
authenticated `GET /api/mcp` when server-to-client SSE is disabled.

```yaml
name: HollyHR MCP metadata smoke

on:
  workflow_dispatch:
  schedule:
    - cron: "45 8 * * 1-5"

jobs:
  mcp:
    runs-on: ubuntu-latest
    steps:
      - name: Check MCP metadata and GET behavior
        env:
          HOLLYHR_MCP_URL: ${{ secrets.HOLLYHR_MCP_URL }}
          HOLLYHR_API_TOKEN: ${{ secrets.HOLLYHR_API_TOKEN }}
        run: |
          set -euo pipefail

          resource_origin="${HOLLYHR_MCP_URL%/api/mcp}"
          prm_url="$resource_origin/.well-known/oauth-protected-resource/api/mcp"

          curl -fsS "$prm_url" | tee prm.json
          node -e 'const prm = JSON.parse(require("node:fs").readFileSync("prm.json", "utf8")); if (!prm.resource || !Array.isArray(prm.scopes_supported)) throw new Error("Invalid PRM");'

          status="$(curl -sS -o /dev/null -w "%{http_code}" "$HOLLYHR_MCP_URL" \
            -H "Authorization: Bearer $HOLLYHR_API_TOKEN" \
            -H "Accept: application/json, text/event-stream" \
            -H "MCP-Protocol-Version: 2025-11-25")"

          test "$status" = "405"
```

Minimum HollyHR scopes:

```text
organisation:read people:read reference:read time_off:read
```

For full MCP initialize/tool-call coverage, use [MCP smoke test](/mcp-smoke)
from the HollyHR repo or connect a real MCP host through
[AI connectors](/ai-connectors).
