Skip to content

Pipeline Design 57

Seth Ford edited this page Feb 14, 2026 · 1 revision

Design: GitHub App for native integration — Checks API, webhooks, and first-class PR reviews

Context

Shipwright currently authenticates to GitHub exclusively via Personal Access Tokens (PATs) through the gh CLI. This works but has limitations:

  1. Polling-only issue detectionsw-daemon.sh polls GitHub Issues API on a configurable interval (~30s default), wasting API rate limit and introducing latency between issue creation and pipeline start.
  2. No branded check runs — Check runs created via PAT appear as the user, not as "Shipwright". The Checks API's full feature set (action buttons, structured output annotations, external_id correlation) requires a GitHub App identity.
  3. No bot identity for PR reviews — PR comments and reviews appear as the PAT owner, not as a [bot] account, making it unclear which feedback is automated.
  4. Rate limit pressure — PAT rate limits (5,000/hr) are shared across all gh CLI usage; GitHub App installation tokens get a separate 5,000/hr budget per installation.

Codebase constraints:

  • All scripts are Bash 3.2 compatible (no associative arrays, no readarray, no ${var,,})
  • set -euo pipefail everywhere
  • Existing auth flows in sw-github-checks.sh (521 lines), sw-github-graphql.sh (972 lines), and sw-github-deploy.sh (533 lines) all use gh api with implicit PAT auth
  • The daemon (sw-daemon.sh, 5,670 lines) has a polling loop at daemon_poll_loop() that would need a parallel webhook-driven code path
  • $NO_GITHUB env var must continue to disable all GitHub API calls including app auth
  • Test harness uses mock binaries in $PATH with canned responses — no real API calls in CI

Decision

Dual Authentication with Transparent Token Injection

Introduce a new module scripts/sw-github-app.sh that handles GitHub App authentication (JWT generation via openssl, installation token exchange and caching). When app auth is configured, this module exports GH_TOKEN with the installation token, which the gh CLI transparently uses for all subsequent API calls. This means zero changes to existing gh api call sites — the auth switch happens at the environment level.

Auth resolution order:

  1. If SHIPWRIGHT_APP_ID + SHIPWRIGHT_APP_PRIVATE_KEY_FILE are set (or configured in .claude/daemon-config.json under github_app), use GitHub App auth
  2. Otherwise, fall back to existing gh CLI PAT auth (no behavioral change)

JWT generation uses openssl dgst -sha256 -sign with a manually-constructed RS256 JWT (header.payload base64url-encoded, then signed). This avoids any dependency beyond openssl, which ships with macOS and every Linux distro. The JWT is cached for 9 minutes (GitHub allows 10-minute expiry). Installation tokens are cached for 55 minutes (GitHub allows 60-minute expiry). Cache files go in ~/.shipwright/github-app/ with atomic writes (tmp + mv).

Webhook receiver is a new scripts/sw-github-webhook.sh that uses socat (preferred) or nc (fallback) to listen on a configurable port. It reads raw HTTP, verifies HMAC-SHA256 signatures against a shared secret, parses the JSON payload with jq, and dispatches to handler functions by event type. This is intentionally minimal — production deployments will front this with ngrok, Cloudflare Tunnel, or a reverse proxy. The webhook receiver is optional — the daemon can still poll.

Data flow for webhook mode:

GitHub → webhook HTTP POST → sw-github-webhook.sh (verify signature)
  → parse event type + action
  → issues.labeled → daemon_process_issue()
  → pull_request.opened → daemon_process_pr_event()
  → check_suite.requested → daemon_process_check_request()
  → push → daemon_process_push()

Data flow for check runs with app identity:

Pipeline stage starts → sw-github-checks.sh:checks_create_run()
  → sw-github-app.sh:gh_app_ensure_token() (if app configured)
  → GH_TOKEN set to installation token
  → gh api /repos/{owner}/{repo}/check-runs (POST)
  → Check run appears with Shipwright app identity + logo

Error handling:

  • JWT generation failure → log error, fall back to PAT if available, skip if not
  • Installation token exchange failure (403/404) → log with diagnostic info (app not installed on repo?), fall back to PAT
  • Token expiry mid-pipeline → gh_app_ensure_token() called before each API batch detects expiry via cached timestamp, refreshes transparently
  • Webhook signature mismatch → 401 response, event dropped, counter incremented for monitoring
  • Webhook port already in use → error on start with diagnostic (lsof hint)
  • socat/nc not available → error with install instructions, webhook mode unavailable

PR Reviews as App Bot

When app auth is active, the pipeline's stage_pr() function creates PRs and submits reviews using the installation token. GitHub automatically shows the [bot] badge. No code change needed beyond ensuring gh_app_ensure_token() runs before PR-related API calls in sw-pipeline.sh.

Configuration Schema

Added to .claude/daemon-config.json under a new github_app key:

{
  "github_app": {
    "app_id": "",
    "installation_id": "",
    "private_key_file": "~/.shipwright/shipwright-app.pem",
    "webhook_mode": false,
    "webhook_port": 3456,
    "webhook_secret": ""
  }
}

All fields also available as env vars: SHIPWRIGHT_APP_ID, SHIPWRIGHT_APP_INSTALLATION_ID, SHIPWRIGHT_APP_PRIVATE_KEY_FILE, SHIPWRIGHT_WEBHOOK_PORT, SHIPWRIGHT_WEBHOOK_SECRET. Env vars take precedence over config file.

Alternatives Considered

  1. Replace PAT entirely with GitHub App (no dual mode) — Pros: Simpler code, single auth path, no conditional branching. Cons: Breaking change for all existing users; GitHub App setup is non-trivial (create app, generate key, install on repos); users with working PAT setups would be forced to migrate. Rejected because backward compatibility is a core Shipwright principle — the daemon config and all existing scripts must continue working without app configuration.

  2. Use a Node.js/Bun webhook server instead of bash socat/nc — Pros: More robust HTTP handling, built-in JSON parsing, proper HTTP/1.1 compliance, could share code with dashboard/server.ts. Cons: Introduces a runtime dependency (Node.js or Bun) for a core daemon feature; all other core scripts are pure bash; the webhook receiver is intentionally thin (signature verify + dispatch) and doesn't need full HTTP framework capabilities. Rejected because adding a runtime dependency for webhook receipt breaks the "bash-only core" architecture. The dashboard already uses Bun but it's optional — webhook processing should work on any server with just bash + openssl + jq.

  3. Use GitHub Actions for webhook processing instead of a local receiver — Pros: No local server needed, works behind NATs/firewalls, leverages GitHub's infrastructure. Cons: Adds latency (Actions cold start), requires a separate workflow file in each repo, harder to debug, can't process events when Actions is down, diverges from the local-first daemon architecture. Rejected because Shipwright's daemon is designed to run locally or on dedicated infrastructure, and the webhook receiver should be as close to the daemon process as possible for low-latency event processing.

  4. Use smee.io or similar webhook proxy as the primary receiver — Pros: Works behind NAT immediately, no port exposure needed. Cons: Adds external service dependency, requires smee-client npm package, introduces a relay point that could fail or have latency. Not rejected entirely — documenting smee.io as a development/testing option, but the primary receiver should be self-contained.

Implementation Plan

Files to Create

File Purpose
scripts/sw-github-app.sh GitHub App auth: JWT generation, installation token management, setup/status CLI, token caching with atomic writes
scripts/sw-github-webhook.sh Webhook HTTP receiver: socat/nc server, HMAC-SHA256 verification, event dispatch, rate limiting, lifecycle management
scripts/sw-github-app-test.sh Unit tests: JWT format validation, token caching, config loading, setup flow, auth mode detection, NO_GITHUB bypass
scripts/sw-github-webhook-test.sh Unit tests: signature verification, event routing, rate limiting, lifecycle, port conflicts, malformed payloads
templates/github-app-manifest.json GitHub App manifest for one-click creation: permissions (checks:write, issues:read, pull_requests:write, contents:read, metadata:read), webhook events (issues, pull_request, check_suite, push)

Files to Modify

File Change Risk
scripts/sw-github-checks.sh Source app module; call gh_app_ensure_token() before API calls in checks_create_run(), checks_update_run(), checks_complete_run(); add external_id and structured output object when in app mode Low — guarded by gh_app_configured(), no change when unconfigured
scripts/sw-github-graphql.sh Source app module; call gh_app_ensure_token() in _gql_query() (single injection point for all GraphQL calls) Low — single touch point, transparent fallback
scripts/sw-github-deploy.sh Source app module; call gh_app_ensure_token() in deploy_create() and deploy_update_status() Low — same pattern as checks
scripts/sw-daemon.sh Source app/webhook modules (~line 48); add github_app.* config loading in daemon_load_config(); add daemon_webhook_loop() as alternative to daemon_poll_loop(); token refresh in daemon_health_check(); new daemon webhook start/stop/status subcommand Medium — daemon is 5,670 lines, the webhook loop is a parallel code path. Mitigated by keeping webhook loop self-contained and only activated via config flag
scripts/sw-pipeline.sh Call gh_app_ensure_token() before PR creation in stage_pr() and review submission Low — additive, no behavioral change for PAT users
scripts/sw-doctor.sh Add GitHub App validation block in section 13: check private key file exists, JWT generation works, token retrieval succeeds, report auth mode Low — additive diagnostic checks
scripts/sw Add app|github-appsw-github-app.sh and webhooksw-github-webhook.sh routing in the case statement Low — two new lines in router
package.json Add sw-github-app-test.sh and sw-github-webhook-test.sh to test script chain Low — append to existing chain

Dependencies

  • No new runtime dependencies. openssl (for JWT), jq (already required), socat or nc (for webhook server — commonly available, with fallback). gh CLI (already required).
  • No new build/dev dependencies.

Risk Areas

  1. JWT generation with openssl — Base64url encoding in bash requires translating +/ to -_ and stripping = padding. The RS256 signing with openssl dgst -sha256 -sign is well-documented but the full JWT assembly in bash is error-prone. Mitigation: Dedicated test cases that validate JWT structure against known-good tokens; the app status command decodes and displays the JWT for debugging.

  2. Token caching race conditions — Multiple concurrent daemon workers could attempt token refresh simultaneously. Mitigation: Atomic writes (tmp + mv) and read-after-write verification. Installation tokens are safe to cache — GitHub returns the same token for concurrent requests within the validity window.

  3. Webhook HTTP parsing in bash — Raw HTTP parsing with read is fragile. Mitigation: Only parse what's needed (Content-Length for body read, signature header for verification); reject malformed requests early; the server is expected to sit behind a reverse proxy in production.

  4. Daemon webhook mode testing — The webhook loop is harder to unit test than the poll loop because it involves a running server process. Mitigation: Test the webhook handler functions in isolation; integration test sends requests via curl to a test server instance.

  5. sw-daemon.sh complexity — At 5,670 lines, the daemon is the largest script. Adding webhook mode increases surface area. Mitigation: Webhook functions are self-contained (daemon_webhook_loop, daemon_webhook_handler, daemon_webhook_dispatch); the existing poll loop is untouched; selection between modes is a single branch in daemon_start().

Validation Criteria

  • shipwright app status correctly reports "app" or "pat" auth mode based on configuration
  • shipwright app setup interactively collects app_id, installation_id, private key path, and webhook secret; writes to .claude/daemon-config.json
  • JWT generated by gh_app_generate_jwt() is valid RS256 (verifiable with openssl dgst -sha256 -verify)
  • Installation token is cached in ~/.shipwright/github-app/token.json with atomic writes; re-used within 55-minute window
  • gh_app_ensure_token() is a no-op when $NO_GITHUB is set
  • All existing gh api calls continue to work unchanged when no GitHub App is configured (PAT fallback)
  • Check runs created with app auth show the app's name and logo in GitHub UI (manual verification on test repo)
  • shipwright webhook start binds to configured port and responds to health check (curl -s http://localhost:3456/health)
  • Webhook receiver rejects requests with invalid HMAC-SHA256 signatures (returns 401)
  • Webhook receiver correctly routes issues.labeled events to daemon_process_issue()
  • shipwright daemon start with webhook_mode: true starts the webhook listener instead of polling
  • shipwright doctor section 13 validates GitHub App config and reports issues clearly
  • PR reviews submitted via app auth display the [bot] badge (manual verification)
  • All 24 test suites pass: 22 existing (no regressions) + 2 new (sw-github-app-test.sh, sw-github-webhook-test.sh)
  • No Bash 3.2 compatibility violations (no associative arrays, no readarray, no ${var,,})
  • Token refresh works mid-pipeline: simulate a 60-minute pipeline run and verify token is refreshed at least once
  • Webhook rate limiting caps at 10 events/sec, excess events are queued or dropped with warning

Clone this wiki locally