Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 21 additions & 12 deletions .github/workflows/evals-behavioral.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ jobs:
- 'packages/junior-evals/evals/sentry/**'
- 'packages/junior-evals/package.json'
- 'packages/junior-evals/scripts/report.mjs'
- 'packages/junior-evals/scripts/cloudflare-tunnel*'
- 'packages/junior-evals/tests/component/eval-egress.test.ts'
- '.github/workflows/evals-behavioral.yml'
- 'packages/junior-evals/global-setup.ts'
- 'packages/junior-evals/postgres-global-setup.ts'
- 'packages/junior-evals/src/behavior-harness.ts'
Expand Down Expand Up @@ -125,6 +128,10 @@ jobs:
--health-timeout 5s
--health-retries 5
env:
CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_ZONE_ID: ${{ vars.CLOUDFLARE_ZONE_ID }}
CLOUDFLARE_TUNNEL_BASE_DOMAIN: ${{ vars.CLOUDFLARE_TUNNEL_BASE_DOMAIN }}
JUNIOR_EVAL_SHARD: ${{ matrix.shard }}
JUNIOR_EVAL_REDIS_URL: redis://127.0.0.1:6379
DATABASE_URL: postgres://junior:junior@localhost:5432/junior
AI_GATEWAY_API_KEY: ${{ secrets.AI_GATEWAY_API_KEY }}
Expand All @@ -135,25 +142,27 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup-node-pnpm
- name: Install cloudflared
run: |
set -euo pipefail
curl --fail --location --silent --show-error \
https://github.com/cloudflare/cloudflared/releases/download/2026.7.2/cloudflared-linux-amd64 \
--output "$RUNNER_TEMP/cloudflared"
echo "ec905ea7b7e327ff8abdde8cb64697a2152de74dbcdbf6aec9db8364eb3886cd $RUNNER_TEMP/cloudflared" | sha256sum --check
chmod +x "$RUNNER_TEMP/cloudflared"
echo "$RUNNER_TEMP" >> "$GITHUB_PATH"
"$RUNNER_TEMP/cloudflared" version
- name: Install latest cloudflared
env:
GH_TOKEN: ${{ github.token }}
run: node packages/junior-evals/scripts/cloudflare-tunnel.mjs install
- name: Verify eval egress lifecycle
run: pnpm --filter @sentry/junior-evals test tests/integration/eval-egress.test.ts
run: |
node --test packages/junior-evals/scripts/cloudflare-tunnel.test.mjs
pnpm --filter @sentry/junior-evals test tests/component/eval-egress.test.ts
- name: Run behavioral evals
id: run
continue-on-error: true
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
VITEST_EVALS_OUTPUT_FILE: behavioral-results-${{ matrix.shard }}.json
VITEST_EVALS_REPORT_LEVEL: info
run: pnpm --filter @sentry/junior-evals evals:behavioral --shard=${{ matrix.shard }}/4
run: node packages/junior-evals/scripts/cloudflare-tunnel.mjs run pnpm --filter @sentry/junior-evals evals:behavioral --shard=${{ matrix.shard }}/4
- name: Remove eval tunnel
if: always()
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: node packages/junior-evals/scripts/cloudflare-tunnel.mjs cleanup
- name: Require behavioral eval results
id: results
if: steps.run.conclusion != 'skipped'
Expand Down
33 changes: 21 additions & 12 deletions .github/workflows/evals-integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ jobs:
- 'packages/junior-evals/evals/integration/**'
- 'packages/junior-evals/package.json'
- 'packages/junior-evals/scripts/report.mjs'
- 'packages/junior-evals/scripts/cloudflare-tunnel*'
- 'packages/junior-evals/tests/component/eval-egress.test.ts'
- '.github/workflows/evals-integration.yml'
- 'packages/junior-evals/global-setup.ts'
- 'packages/junior-evals/postgres-global-setup.ts'
- 'packages/junior-evals/src/behavior-harness.ts'
Expand Down Expand Up @@ -111,6 +114,10 @@ jobs:
--health-timeout 5s
--health-retries 5
env:
CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_ZONE_ID: ${{ vars.CLOUDFLARE_ZONE_ID }}
CLOUDFLARE_TUNNEL_BASE_DOMAIN: ${{ vars.CLOUDFLARE_TUNNEL_BASE_DOMAIN }}
JUNIOR_EVAL_SHARD: ${{ matrix.shard }}
JUNIOR_EVAL_REDIS_URL: redis://127.0.0.1:6379
DATABASE_URL: postgres://junior:junior@localhost:5432/junior
AI_GATEWAY_API_KEY: ${{ secrets.AI_GATEWAY_API_KEY }}
Expand All @@ -121,23 +128,25 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup-node-pnpm
- name: Install cloudflared
run: |
set -euo pipefail
curl --fail --location --silent --show-error \
https://github.com/cloudflare/cloudflared/releases/download/2026.7.2/cloudflared-linux-amd64 \
--output "$RUNNER_TEMP/cloudflared"
echo "ec905ea7b7e327ff8abdde8cb64697a2152de74dbcdbf6aec9db8364eb3886cd $RUNNER_TEMP/cloudflared" | sha256sum --check
chmod +x "$RUNNER_TEMP/cloudflared"
echo "$RUNNER_TEMP" >> "$GITHUB_PATH"
"$RUNNER_TEMP/cloudflared" version
- name: Install latest cloudflared
env:
GH_TOKEN: ${{ github.token }}
run: node packages/junior-evals/scripts/cloudflare-tunnel.mjs install
- name: Verify eval egress lifecycle
run: pnpm --filter @sentry/junior-evals test tests/integration/eval-egress.test.ts
run: |
node --test packages/junior-evals/scripts/cloudflare-tunnel.test.mjs
pnpm --filter @sentry/junior-evals test tests/component/eval-egress.test.ts
- name: Run integration evals
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
VITEST_EVALS_OUTPUT_FILE: integration-results-${{ matrix.shard }}.json
VITEST_EVALS_REPORT_LEVEL: info
run: pnpm --filter @sentry/junior-evals evals:integration --shard=${{ matrix.shard }}/2
run: node packages/junior-evals/scripts/cloudflare-tunnel.mjs run pnpm --filter @sentry/junior-evals evals:integration --shard=${{ matrix.shard }}/2
- name: Remove eval tunnel
if: always()
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: node packages/junior-evals/scripts/cloudflare-tunnel.mjs cleanup
- name: Upload integration eval results
if: always() && !cancelled()
uses: actions/upload-artifact@v4
Expand Down
4 changes: 2 additions & 2 deletions packages/junior-evals/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,12 +148,12 @@ Pass eval file paths, `-t` filters, and shard options directly after the suite s
- Router cases assert exact model profile and reasoning level selections and fail the `router / run` job hard on mismatch. They do not use the aggregate pass-rate floor.
- The simplest Gateway and Sandbox setup is `VERCEL_OIDC_TOKEN` alone.
- The fallback CI setup is `AI_GATEWAY_API_KEY` plus `VERCEL_TOKEN` + `VERCEL_TEAM_ID` + `VERCEL_PROJECT_ID`.
- Behavioral and integration global setup starts one Cloudflare Quick Tunnel for the suite so Vercel Sandbox can reach the eval egress proxy. Transient tunnel allocation failures retry up to five times with backoff. Local runs require `cloudflared` on `PATH`; CI installs a pinned binary.
- Behavioral and integration global setup starts one public eval egress proxy for the suite. CI uses an account-backed Cloudflare tunnel per invocation through `scripts/cloudflare-tunnel.mjs` and installs the latest binary with SHA-256 verification. Local runs require `cloudflared` on `PATH` and use Quick Tunnels with up to five allocation attempts. See `evals/github-actions.md` for the exact token permissions, TLS setup, and cleanup.
- Behavioral and integration state always uses a loopback Redis. Local runs default to `redis://127.0.0.1:6382`; CI sets `JUNIOR_EVAL_REDIS_URL` for its Redis service.
- Set the GitHub Actions repository secret `SENTRY_EVALS_API_KEY` to upload results to `evals.sentry.dev`. Each suite uploads one run after execution, with all shards combined. Existing score gates and artifacts stay in place. Without the key, uploads are skipped.
- Setup details for GitHub Actions live in `evals/github-actions.md`.

Behavioral and integration evals require real Vercel Sandbox access and public Quick Tunnel connectivity. If either bootstrap fails, the eval fails immediately with no local fallback path. Guardian and Router evals only need AI Gateway access.
Behavioral and integration evals require real Vercel Sandbox access and public tunnel connectivity. If either bootstrap fails, the eval fails immediately with no local fallback path. Guardian and Router evals only need AI Gateway access.

## Authoring Rules

Expand Down
138 changes: 137 additions & 1 deletion packages/junior-evals/evals/github-actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,143 @@

Use this when you want PR evals to run in GitHub Actions.

The workflow installs a pinned `cloudflared` binary and starts a unique Quick Tunnel for each eval job. No Cloudflare account secret or fixed public hostname is required.
The workflow installs the latest verified `cloudflared` binary and creates a unique account-backed tunnel for each behavioral or integration job.

## Cloudflare Tunnels For CI

Behavioral and integration jobs use `scripts/cloudflare-tunnel.mjs`. Guardian
and Router jobs do not need tunnels. Local evals still use Quick Tunnels.
The script follows the API steps in [Cloudflare's tunnel setup guide](https://developers.cloudflare.com/tunnel/get-started/#create-a-tunnel):
create the tunnel, configure ingress, create the proxied CNAME, then start the connector.

### Credentials And Dashboard Permissions

Create an **account-owned API token** under **Manage Account → Account API Tokens**.
Name it `junior-ci-github`. Account tokens do not depend on a user's membership.
Use these two policies. These are the dashboard labels confirmed on 2026-09-24:

- **Cloudflare One / Zero Trust → Argo Tunnel (Legacy) → Edit**.
Scope this policy to the account that owns the DNS zone.
The API calls this **Cloudflare Tunnel Write**:
`c07321b023e944ff818fec44d8203567`.
- **DNS & Zones → DNS → Edit** (the first DNS row).
Scope this policy to the `sentry.cool` zone, not the entire account.
The API calls this **DNS Write**: `4755a26eedb94da69e1066d98aa820be`.

Do not select **Connectivity Directory**, **Account DNS Settings**, or
**Zone DNS Settings**. These are different permissions. Separate Read permissions
are not required. If the UI also selects Read, it is redundant but valid.
Check the permission IDs in the token's JSON summary before creating it.

The legacy _permission label_ does not mean that CI uses a legacy tunnel.
The script creates remotely managed tunnels with `config_src: "cloudflare"`.
Each connector uses its own tunnel token. Adding a token to an anonymous
`cloudflared tunnel --url` command would not convert it to an account tunnel.

The API token can manage all tunnels in the selected account and all DNS records
in the selected zone. It is not restricted to the `sentry-ci` hostname prefix.
Only trusted CI code may receive it. Fork PRs do not receive repository secrets.
Do not change these workflows to `pull_request_target` to expose secrets to forks.

In **GitHub → getsentry/junior → Settings → Secrets and variables → Actions**, set:

- Repository secret `CLOUDFLARE_API_TOKEN`: the token value. Never log it.
- Repository variable `CLOUDFLARE_ACCOUNT_ID`: the account ID.
- Repository variable `CLOUDFLARE_ZONE_ID`: the 32-character hexadecimal zone ID, not the name `sentry.cool`.
- Repository variable `CLOUDFLARE_TUNNEL_BASE_DOMAIN`: `sentry.cool` (the zone name, not `junior-ci.sentry.cool`).

The zone must belong to the tunnel's Cloudflare account. Do not create a tunnel
or wildcard DNS record by hand. CI uses `sentry-ci-<hash>.sentry.cool`, which
fits the `*.sentry.cool` certificate from Universal SSL. Confirm that certificate
is Active under **SSL/TLS → Edge Certificates**. No advanced certificate is needed.
Do not use a deeper base domain unless its wildcard has separate TLS coverage.
Ensure WAF, Access, and cache rules do not challenge or cache this CI traffic.
The proxy keeps its own auth.

Rotate the API token by creating a replacement with these same permissions,
updating the GitHub secret, and verifying a new job. Let existing jobs finish
cleanup before revoking the old token. To revoke a leaked connector token, delete
that invocation's tunnel. CI never needs a persistent tunnel token or `cert.pem`.

### Script And Lifecycle

From the repository root on a Linux x64 runner:

```bash
node packages/junior-evals/scripts/cloudflare-tunnel.mjs install
node packages/junior-evals/scripts/cloudflare-tunnel.mjs run pnpm --filter @sentry/junior-evals evals:integration --shard=1/2
node packages/junior-evals/scripts/cloudflare-tunnel.mjs cleanup
```

`install` resolves the latest official cloudflared release once, downloads that
release's Linux x64 asset, and verifies its published SHA-256 digest. It fails if
the digest is missing or differs. It logs the version and adds the binary to
`GITHUB_PATH`. The binary does not update itself during the job.

`run` hashes the GitHub run ID, attempt, job, shard, and a fresh UUID. The fresh
UUID also isolates repeated invocations in the same job. It creates one named
tunnel and one proxied CNAME at `sentry-ci-<hash>.sentry.cool`. No pool or locks
are needed across jobs. Each runner supports one invocation at a time on
`127.0.0.1:18787`; different jobs use different runners. The catch-all ingress
rule returns 404. Postgres and Redis are not tunnel targets.

The wrapper starts `cloudflared tunnel run --token-file ...` and the eval command.
The token file has mode 0600 under `RUNNER_TEMP`, outside the repository. The API
token is bound only to the run and cleanup steps. Cloudflare and tunnel variables
are removed from both child environments. Only the connector receives the token
file path; evals receive `JUNIOR_EVAL_EGRESS_URL` and `JUNIOR_EVAL_EGRESS_PORT`.
Neither credential is sent to Vercel Sandboxes through this interface.

**Security boundary:** environment filtering only prevents accidental inheritance.
It does not isolate the eval child from its parent. They run as the same runner
user. Eval code can read the parent's environment through `/proc` on Linux, read
connector files, or modify scripts used by later steps. All code in a secret-bearing
job must be trusted, including dependency install hooks. Do not use this wrapper
as a boundary for untrusted PR code. Isolating such code requires a separate
trusted job or service that owns the management token and cleanup. Splitting steps
on the same runner does not provide that boundary.

Global setup waits up to two minutes for public HTTPS to return this proxy's
unique health ID and the real proxy's unauthenticated 401 response. It uses normal
system DNS and certificate checks. A connected tunnel alone is not readiness.
Proxy OIDC authentication and fixture-control bearer authentication are unchanged.

The script logs the DNS record name, target, and proxy flag returned by Cloudflare.
It does not run a separate DNS readiness check. Eval global setup owns public
readiness; a DNS answer alone cannot prove the proxy works.

The wrapper stops child process groups and removes the DNS record and tunnel on
success, command failure, connector failure, SIGINT, or SIGTERM. An `always()`
workflow step retries cleanup after cancellation or forced process termination.
Cleanup saves exact names before allocation, so a lost create response is recoverable.
It authenticates saved state with HMAC-SHA256 using the API token, bound to the
repository, run, attempt, job, shard, and state path. It also checks the configured
account and zone, exact base domain, `sentry-ci-<24 hex characters>` name, and token
file path before any deletion. Invalid state fails closed and stays available for
inspection. Use the original job scope and token for a cleanup retry; token rotation
invalidates saved signatures. This protects against state edits, not token theft.
It does not retry resource creation. Errors fail the step and retain cleanup state.

A lost runner or SIGKILL can prevent all local cleanup. In that case, use the
Cloudflare dashboard to identify the inactive `sentry-ci-<hash>` tunnel from the
failed job, delete its exact `sentry-ci-<hash>.sentry.cool` CNAME, and delete the
tunnel. Check the job is no longer running first. There is no automatic sweeper
that could delete another active job's tunnel.

Run offline script contract tests with:

```bash
node --test packages/junior-evals/scripts/cloudflare-tunnel.test.mjs
```

Before merge, verify a CI run with the real token and TLS setup. Check public
readiness, concurrent shard isolation, failed-command cleanup, and cancellation
cleanup. Confirm no matching DNS record or tunnel remains after each completed
job. Offline tests cannot prove Cloudflare routing or certificate coverage.

References: [API tunnel setup](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/get-started/create-remote-tunnel-api/),
[account tokens](https://developers.cloudflare.com/fundamentals/api/get-started/account-owned-tokens/),
[Universal SSL limits](https://developers.cloudflare.com/ssl/edge-certificates/universal-ssl/limitations/).

## Required Secrets

Expand Down
Loading
Loading