# Deploy integrations

Hoot Lens can log a change marker for every production deploy, so "Did it help?" has a before and after without anyone calling `log_change`. There are two routes, and they can be used together: a signed deploy hook your host calls, or the GitHub Action your workflow runs. Both write the same kind of marker: a `source` of `deploy:<provider>`, the first line of the commit message as the title, the short commit sha as the release, and a link to the commit when the sender gives one. Markers made this way carry a Deploy label in the dashboard. No author name or email is read or stored, and an email address inside a commit's first line is replaced with `[email removed]`.

Provider details below were checked against the providers' own documentation on 2026-10-07 (Netlify deploy notifications and its OpenAPI deploy object, Vercel webhooks and the webhooks API reference, GitHub Actions metadata syntax). Nothing here was run against a live Netlify, Vercel or GitHub account; the signature code is tested against signers written from those documents.

## Deploy hooks

Settings, Deploy hooks (owners and editors). Each hook belongs to one project, has one provider, and can be revoked. A project holds ten live hooks.

| Provider | Signature | Secret comes from | Events recorded |
| --- | --- | --- | --- |
| Netlify | `X-Webhook-Signature`, a JWS (HS256) whose claims `iss` = `netlify` and `sha256` = SHA-256 of the body | Hoot Lens makes it; paste it as the JWS secret token | Deploy succeeded (body `state: "ready"`), `context: "production"` |
| Vercel | `x-vercel-signature`, hex HMAC-SHA1 of the raw body | Vercel makes it and shows it once; paste it into Hoot Lens | `deployment.succeeded` and `deployment.ready`, `payload.target: "production"` |
| Any CI | `X-HootLens-Signature: sha256=<hex>` and `X-HootLens-Timestamp` (epoch seconds) | Hoot Lens makes it | A body with `id` and `environment` absent or `production` |

Every hook logs production deploys only unless "Also log previews and branch deploys" is on. Other notifications from the same sender (a build starting, a project created) are answered 200 and ignored. A deploy is logged once: the marker id comes from the hook and the provider's deploy id, so retries, and Vercel sending both `deployment.ready` and `deployment.succeeded`, make one marker. A bad signature, an unknown hook, a revoked hook and a Vercel hook still waiting for its secret all answer the same `401 {"error":"Unauthorized"}`.

A hook can name one page group (chosen when it is made, or `PATCH`ed). Without one the change is for the whole site. Mapping changed paths to page groups is not offered: a deploy notification does not say which files or pages changed, so there is nothing to map from.

### Netlify

1. Settings, Deploy hooks, choose Netlify, Add hook. Copy the address and the secret; the secret is shown once.
2. In Netlify: Project configuration, Notifications, Deploy notifications, Add notification, Outgoing webhook.
3. Event to listen for: Deploy succeeded. URL to notify: the hook address. JWS secret token: the secret.
4. Save. The next production deploy logs a change. Netlify's body is the deploy object: Hoot Lens reads `id`, `state`, `context`, `commit_ref`, `commit_url`, `title` (the commit message for Git deploys), `published_at` and `created_at`.

### Vercel

1. Settings, Deploy hooks, choose Vercel, Add hook. Copy the address.
2. In Vercel: team Settings, Webhooks, Create Webhook. Events: Deployment Ready or Deployment Succeeded. Choose the project and paste the address as the endpoint.
3. Vercel shows a secret once. Paste it into Hoot Lens under "Secret from Vercel" and save. The hook refuses every request until then.
4. Vercel's webhook page lists `payload.deployment.id`, `payload.deployment.meta` and `payload.target`. The commit message and sha come from `meta` keys that Vercel's Git integration fills (`githubCommitMessage`, `githubCommitSha`, `githubCommitOrg`, `githubCommitRepo`, and the GitLab and Bitbucket equivalents). The webhook page does not list those keys, so a deploy without them is still logged, with the title "Deployed to production" and no commit. A commit link is built only for GitHub.

### Any CI (signed JSON)

Send a POST after a production deploy succeeds. Body (extra fields are ignored):

```json
{ "id": "run-4821", "environment": "production", "title": "Shorter hero headline", "sha": "0123456789abcdef0123456789abcdef01234567", "commitUrl": "https://example.com/acme/site/commit/0123456", "release": "2026.10.7", "at": 1790000000000 }
```

Only `id` is required. `status` other than `success` or `succeeded` is ignored. `at` is epoch milliseconds, epoch seconds or an ISO date and defaults to now. `commitUrl` must be https.

Sign `<timestamp>.<exact body>` with HMAC-SHA256 and the hook secret. Requests whose timestamp is more than five minutes from the server's clock are refused, which limits replay.

```sh
BODY=$(jq -cn --arg id "$BUILD_ID" --arg title "$COMMIT_MESSAGE" --arg sha "$COMMIT_SHA" \
  '{id: $id, environment: "production", title: $title, sha: $sha}')
TS=$(date +%s)
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$HOOTLENS_HOOK_SECRET" | sed 's/^.* //')
curl -fsS -X POST "$HOOTLENS_HOOK_URL" \
  -H "Content-Type: application/json" \
  -H "X-HootLens-Timestamp: $TS" \
  -H "X-HootLens-Signature: sha256=$SIG" \
  -d "$BODY"
```

The answer is `200 {"status":"recorded"|"duplicate"|"ignored"|"limit","changeId"?}`. `limit` means the project already holds 500 markers.

### Why the secret is not a one-way hash

An access token is checked by comparing hashes. A webhook signature is an HMAC, and checking one needs the secret itself. Hoot Lens therefore keeps each hook secret sealed with AES-256-GCM under a server key (`HOOTLENS_HOOK_KEY`), bound to the hook id, and opens it only to verify a request. It is never returned by any endpoint after creation. Revoking a hook erases it. Deploy hooks cannot be made on a server without that key.

## GitHub Action

`integrations/github-action` is a JavaScript action (`runs.using: node24`; GitHub's metadata syntax accepts `node20` and `node24`) with a committed bundle in `dist/`, built with `npm run build` in that folder. It has no runtime dependencies. A test fails if `dist/` is out of date with `src/`.

It can be used from this repository (`uses: Parallel-Platforms/hootlens/integrations/github-action@<tag or sha>`) or, to give it a short name and its own release tags, published as a dedicated repository by copying that folder to the repository root (the paths in `action.yml` are relative, so it works unchanged). Pin to a sha in production workflows.

Create a Hoot Lens access token (Settings does not make tokens; use the AI assistant page, Access keys) and store it as a repository secret. `log-change` needs the `annotate` scope; `pr-comment` needs `read`. Use separate tokens, so the deploy job cannot read data. The project id is public.

### Mode log-change

Run after the production deploy job. Idempotent per commit: Hoot Lens is asked for existing `deploy:github-action` markers for the commit first, so a re-run logs nothing new.

```yaml
name: Deploy
on:
  push:
    branches: [main]
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: ./deploy.sh # your deploy
  log-change:
    needs: deploy
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: Parallel-Platforms/hootlens/integrations/github-action@main
        with:
          token: ${{ secrets.HOOTLENS_ANNOTATE_TOKEN }}
          project: proj_xxxxxxxx
          # title defaults to the merged pull request title, then the commit message
          # release defaults to the short commit sha
```

Inputs: `token`, `project`, `api-url` (defaults to the hosted API), `github-token` (defaults to the job token; reads the pull request title), `title`, `note`, `release`, `page-group`, `sha`, `at`, `fail-on-error`. Outputs: `logged`, `change-id`, `title`. A failed log is an error annotation, and the step stays green unless `fail-on-error: true`, so a deploy never turns red over a note. A rejected Hoot Lens token, or a mistake in the workflow file, always fails the step.

### Mode pr-comment

On a schedule or by hand, finds changes a deploy logged (hook or Action) that name a commit, finds the merged pull request that contains the commit, asks Hoot Lens for the change's impact and keeps one comment per change on that pull request.

```yaml
name: Hoot Lens results
on:
  schedule:
    - cron: "17 9 * * *"
  workflow_dispatch:
jobs:
  comment:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      pull-requests: write
    steps:
      - uses: Parallel-Platforms/hootlens/integrations/github-action@main
        with:
          mode: pr-comment
          token: ${{ secrets.HOOTLENS_READ_TOKEN }}
          project: proj_xxxxxxxx
```

The comment repeats the API's own words: the report `summary`, each goal's `verdict.text`, the visit counts per side and the report's `caveats`. Nothing is reworded. It is written only when the API reports no visits still needed on either side (`needMore` is zero for both); while it says there are too few visits the action logs that and posts nothing. The comment carries a hidden marker so the next run updates it in place, and does nothing when the text is unchanged. If GitHub refuses the token (403 or 401) the action warns once, posts nothing and stops trying; a pull request that cannot be compared is a warning and the rest continue. `dry-run: true` logs the comment instead of posting it. Other inputs: `lookback-days` (21), `impact-days`, `max-pulls` (20).

The action talks to GitHub with plain REST calls (`fetch`), not Octokit, to keep the bundle small. The tests stand in for both APIs with a mock `fetch` and a fake GitHub client.
