Skip to content
54 changes: 54 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# The graft GitHub App.
#
# Runs anywhere that takes a container — a VM, Fly, Cloud Run, App Runner. It
# needs git on the PATH (it fetches pull request refs) and nothing else at
# runtime.
#
# Two constraints shape the stages, and both were found the hard way:
#
# - `npm ci` runs this package's `prepare` script, which IS the build. So the
# sources have to be present before the install, not after it — a manifests-
# only copy fails with "The specified path does not exist: 'tsconfig.json'".
# - The runtime cannot reinstall. `npm ci --omit=dev` would run `prepare` again
# without tsc present, and `--ignore-scripts` would skip the native builds
# tree-sitter needs. So the compiled node_modules is carried over from the
# build stage and pruned in place.
FROM node:22-bookworm-slim AS build
WORKDIR /app
# node-gyp needs a real toolchain, and the slim image has none: since the repo
# pinned node-gyp 12, `npm ci` builds tree-sitter's grammars from source and dies
# on "find Python ... could not be run". Build stage only — the runtime image
# below never compiles anything and stays slim.
RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
COPY . .
RUN npm ci

# node 22, not 20: commander@15 declares `node >=22.12`, and running under 20
# left `npm ci` warning EBADENGINE on every build. The runtime base must match
# the build base — the native bindings compiled above are copied, not rebuilt.
FROM node:22-bookworm-slim
# git is a runtime dependency here, not a build one: the App fetches each pull
# request's merge ref.
RUN apt-get update \
&& apt-get install -y --no-install-recommends git ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
# devDependencies are dead weight next to a process that clones code written by
# strangers. Pruning keeps the native bindings that were already compiled;
# --ignore-scripts stops `prepare` from trying to rebuild without tsc.
RUN npm prune --omit=dev --ignore-scripts && npm cache clean --force

# Never root: this process handles untrusted source and has no reason to be able
# to write outside its own tree.
USER node
ENV PORT=3000
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s \
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
CMD ["node", "dist/app/main.js"]
96 changes: 96 additions & 0 deletions deploy/apprunner.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
#!/usr/bin/env bash
#
# Build the App, push it to ECR, and create or update its App Runner service.
#
# Idempotent: run it again to deploy a new build. Nothing here is graft-specific
# beyond the names at the top — it is the whole deploy.
#
# Prerequisites: awscli v2, docker, and credentials for the account that owns the
# Route 53 zone. The secrets must exist first (see `secrets` below).
set -euo pipefail

SERVICE="${SERVICE:-graft-app}"
REGION="${AWS_REGION:-us-east-1}"
REPO="${REPO:-$SERVICE}"
PUBLIC_URL="${GRAFT_PUBLIC_URL:?set GRAFT_PUBLIC_URL, e.g. https://graft.nanonets.ai}"
ACCOUNT="$(aws sts get-caller-identity --query Account --output text)"
ECR="${ACCOUNT}.dkr.ecr.${REGION}.amazonaws.com"
IMAGE="${ECR}/${REPO}:$(git rev-parse --short HEAD)"

# Names of the secrets this expects in Secrets Manager. Create them once:
#
# aws secretsmanager create-secret --name graft/app-id --secret-string 123456
# aws secretsmanager create-secret --name graft/webhook-secret --secret-string "$(openssl rand -hex 32)"
# aws secretsmanager create-secret --name graft/private-key --secret-string file://graft.private-key.pem
#
# The private key is multi-line PEM; Secrets Manager keeps it verbatim and the
# app also accepts the `\n`-escaped form, so either survives a round trip.
SEC_APP_ID="arn:aws:secretsmanager:${REGION}:${ACCOUNT}:secret:graft/app-id"
SEC_WEBHOOK="arn:aws:secretsmanager:${REGION}:${ACCOUNT}:secret:graft/webhook-secret"
SEC_KEY="arn:aws:secretsmanager:${REGION}:${ACCOUNT}:secret:graft/private-key"

echo "→ ECR repository"
aws ecr describe-repositories --repository-names "$REPO" --region "$REGION" >/dev/null 2>&1 \
|| aws ecr create-repository --repository-name "$REPO" --region "$REGION" >/dev/null

aws ecr get-login-password --region "$REGION" | docker login --username AWS --password-stdin "$ECR" >/dev/null

echo "→ build $IMAGE"
# --platform is not optional from a Mac: App Runner is x86_64 only, and an arm64
# image fails at runtime with an exec-format error rather than at push time.
docker build --platform linux/amd64 -t "$IMAGE" .
docker push "$IMAGE"

ARN="$(aws apprunner list-services --region "$REGION" \
--query "ServiceSummaryList[?ServiceName=='${SERVICE}'].ServiceArn | [0]" --output text)"

CONFIG=$(cat <<JSON
{
"ImageRepository": {
"ImageIdentifier": "${IMAGE}",
"ImageRepositoryType": "ECR",
"ImageConfiguration": {
"Port": "3000",
"RuntimeEnvironmentVariables": { "GRAFT_PUBLIC_URL": "${PUBLIC_URL}" },
"RuntimeEnvironmentSecrets": {
"GRAFT_APP_ID": "${SEC_APP_ID}",
"GRAFT_WEBHOOK_SECRET": "${SEC_WEBHOOK}",
"GRAFT_APP_PRIVATE_KEY": "${SEC_KEY}"
}
}
},
"AutoDeploymentsEnabled": false,
"AuthenticationConfiguration": {
"AccessRoleArn": "arn:aws:iam::${ACCOUNT}:role/service-role/AppRunnerECRAccessRole"
}
}
JSON
)

if [ "$ARN" = "None" ] || [ -z "$ARN" ]; then
echo "→ create service"
# MaxSize 1 is load-bearing, not thrift: viewer pages are held in the process,
# so a second instance would 404 links minted by the first. Removing this cap
# means moving PageStore to S3 or a database first.
aws apprunner create-service --region "$REGION" \
--service-name "$SERVICE" \
--source-configuration "$CONFIG" \
--instance-configuration "Cpu=1 vCPU,Memory=2 GB,InstanceRoleArn=arn:aws:iam::${ACCOUNT}:role/${SERVICE}-instance" \
--health-check-configuration "Protocol=HTTP,Path=/healthz,Interval=10,Timeout=5,HealthyThreshold=1,UnhealthyThreshold=5" \
--auto-scaling-configuration-arn "$(aws apprunner create-auto-scaling-configuration \
--region "$REGION" --auto-scaling-configuration-name "${SERVICE}-single" \
--max-size 1 --min-size 1 --max-concurrency 20 \
--query AutoScalingConfiguration.AutoScalingConfigurationArn --output text)" \
--query "Service.ServiceUrl" --output text
else
echo "→ update service"
aws apprunner update-service --region "$REGION" --service-arn "$ARN" \
--source-configuration "$CONFIG" --query "Service.ServiceUrl" --output text
aws apprunner start-deployment --region "$REGION" --service-arn "$ARN" >/dev/null
fi

echo
echo "Service URL above. Next:"
echo " 1. aws apprunner associate-custom-domain --region $REGION --service-arn <arn> --domain-name ${PUBLIC_URL#https://}"
echo " 2. add the CNAME records it returns to Route 53 (validation + the domain itself)"
echo " 3. set the App's webhook URL to ${PUBLIC_URL}/webhook and tick Active"
152 changes: 152 additions & 0 deletions docs/github-app.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
# The graft GitHub App

Posts a blast-radius comment on every pull request, and hosts the interactive
graph behind a signed link.

It exists because of one limit that no amount of workflow YAML gets around: **a
`pull_request` job on a fork gets a read-only token**, so it cannot comment, and
`pull_request_target` cannot check out fork code without opting into running it.
An App's installation token belongs to the *base* repository, so a fork PR is
ordinary work. Two things follow for free: the page is served by the App (a
private repo never needs a public `gh-pages`), and installing takes one click
instead of a workflow file per repo.

## What it does per pull request

1. Verifies the webhook signature, queues the job, answers `202` — GitHub gives
up on a delivery after ten seconds and a review takes longer.
2. Fetches `refs/pull/<n>/merge` and the base branch, shallow.
3. Builds the structural graph, computes the radius, renders the comment.
4. Stores the viewer page and links it with a signed URL.
5. Edits its existing comment rather than adding one per push.

Work is superseded per pull request: five pushes in a minute produce one review,
not five, because the first four comments would be overwritten anyway.

## Setting it up

### 1. Register the App

<https://github.com/settings/apps/new> (or your org's settings → Developer
settings → GitHub Apps → New).

| Field | Value |
| --- | --- |
| Webhook URL | `https://<your-host>/webhook` |
| Webhook secret | a long random string — keep it, it is `GRAFT_WEBHOOK_SECRET` |
| Repository permissions | **Contents: Read-only**, **Pull requests: Read & write** |
| Subscribe to events | **Pull request** |
| Where can this be installed | your choice |

Nothing else. Contents-read is what clones the code; pull-requests-write is what
posts the comment. It never needs Actions, Checks, Administration or write
access to code.

Then **Generate a private key** — the download is the only copy — and note the
**App ID**.

### 2. Run it

```bash
docker build -t graft-app .
docker run -p 3000:3000 \
-e GRAFT_APP_ID=123456 \
-e GRAFT_APP_PRIVATE_KEY="$(cat graft.private-key.pem)" \
-e GRAFT_WEBHOOK_SECRET=... \
-e GRAFT_PUBLIC_URL=https://graft.example.com \
graft-app
```

The image needs `git` and nothing else at runtime, runs as non-root, and answers
`/healthz` with its queue depth. Any container host works — Fly, Cloud Run, ECS,
a VM. `GRAFT_PUBLIC_URL` must be the origin GitHub and your reviewers can reach,
because it is what the comment's link is built from.

The process refuses to start if any of those four are missing: a server that
boots without a webhook secret looks healthy and silently rejects every delivery.

### 3. Install it on a repository

App settings → Install App → pick the repos. **Installing requires admin on the
repository** (or org-owner for an org-wide install) — the one thing an App does
not get you around.

## Security

The App clones code written by strangers on every fork PR while holding a token
for the base repository, so:

- **Nothing from the repo is executed** — no `npm install`, no build step, no
postinstall. The graph comes from tree-sitter reading source text.
- **Git is told not to run anything either**: `core.hooksPath=/dev/null`,
`GIT_TERMINAL_PROMPT=0`, `GIT_CONFIG_NOSYSTEM=1`, no submodule recursion.
- **The token never lands in the checkout.** It is passed per-invocation as an
auth header, not baked into a remote URL that `.git/config` and the reflog
would keep. It is redacted out of error text before anything is logged.
- **Pages are capabilities, not public URLs.** `/p/<id>?t=<hmac>` — an unknown
page and a bad token are both `404`, so the endpoint cannot be used to
discover which pull requests exist. Links expire with the page they point at.

## What is not built yet

- **Naming.** Areas fall back to their hub symbol. `--name`'s one cached LLM call
is not wired in, and sending a private repo's source to a model should be an
explicit per-installation opt-in, not a default.
- **Persistence.** Pages live in memory, so a deploy drops them; the next push to
a PR rebuilds its page. A shared store is the fix when there is more than one
instance.
- **The evidence quotes** in the comment's collapsed list arrive when #180 lands
(`markdownReport(report, { root })` — additive, one line here).

## Deploying to AWS App Runner

`deploy/apprunner.sh` is the whole deploy: build for x86_64, push to ECR, create
or update the service. Run it again for every new build.

### Once, before the first deploy

```bash
aws sso login # or however this account authenticates

# 1. Secrets. The private key is multi-line PEM and survives verbatim.
aws secretsmanager create-secret --name graft/app-id --secret-string 123456
aws secretsmanager create-secret --name graft/webhook-secret --secret-string "$(openssl rand -hex 32)"
aws secretsmanager create-secret --name graft/private-key --secret-string file://graft.private-key.pem

# 2. The role App Runner uses to PULL the image from ECR.
aws iam create-role --role-name AppRunnerECRAccessRole --path /service-role/ \
--assume-role-policy-document '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"build.apprunner.amazonaws.com"},"Action":"sts:AssumeRole"}]}'
aws iam attach-role-policy --role-name AppRunnerECRAccessRole \
--policy-arn arn:aws:iam::aws:policy/service-role/AWSAppRunnerServicePolicyForECRAccess

# 3. The role the RUNNING container uses to read those secrets.
aws iam create-role --role-name graft-app-instance \
--assume-role-policy-document '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"tasks.apprunner.amazonaws.com"},"Action":"sts:AssumeRole"}]}'
aws iam put-role-policy --role-name graft-app-instance --policy-name read-graft-secrets \
--policy-document '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"secretsmanager:GetSecretValue","Resource":"arn:aws:secretsmanager:*:*:secret:graft/*"}]}'
```

Two roles, because App Runner separates them: one is assumed by the *build*
side to pull the image, the other by the *running* task to read configuration.
Giving the second one only `graft/*` keeps this service away from every other
secret in the account.

### Every deploy

```bash
GRAFT_PUBLIC_URL=https://graft.nanonets.ai ./deploy/apprunner.sh
```

Then, once: `associate-custom-domain`, add the CNAMEs it prints to Route 53
(one validates the certificate, one points the domain at the service), and set
the App's webhook URL to `https://graft.nanonets.ai/webhook`.

### Two things the config is deliberate about

- **`--platform linux/amd64`.** App Runner is x86_64 only; an image built on an
Apple Silicon Mac pushes fine and then fails at runtime with an exec-format
error, which reads like a broken entrypoint.
- **One instance, pinned.** Viewer pages live in the process, so a second
instance would 404 links minted by the first. Raising `--max-size` means
moving `PageStore` to S3 or a database first — the cap is correctness, not
cost control.
Loading
Loading