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
9 changes: 7 additions & 2 deletions .github/actions/verify/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,13 @@ runs:
DEPLOY_DOMAIN: ${{ inputs.deploy-domain }}
# No `playwright install` step: playwright.config.ts uses channel: "chrome",
# which resolves to ubuntu-latest's preinstalled Google Chrome — Playwright's
# own bundled Chromium download is never used and would be wasted work.
- name: Run E2E tests
# own bundled Chromium download is never used and would be wasted work. The
# Firefox/WebKit subset runs in its own `e2e-cross-browser` job in ci.yml.
# `pnpm test:e2e` runs both Chromium projects: `chromium` against `vite dev`, and
# `chromium-preview` (e2e/*.preview.spec.ts) against `pnpm build && vite preview`,
# whose web server builds dist/ itself. Keeping both in this step covers ci.yml and
# the deploy.yml gate alike.
- name: Run E2E tests (dev server and production preview)
shell: bash
run: pnpm test:e2e
env:
Expand Down
14 changes: 14 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,17 @@ jobs:
with:
vite-github-client-id: ${{ vars.VITE_GITHUB_CLIENT_ID }}
deploy-domain: ${{ vars.DEPLOY_DOMAIN }}
# Runs the E2E tests tagged @cross-browser on Firefox and WebKit. Those use Playwright's own
# browser builds, which the Chromium-only `ci` job never needs to download. CROSS_BROWSER=1
# leaves out the preview project and its build.
e2e-cross-browser:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: ./.github/actions/setup
- name: Install Firefox and WebKit
run: pnpm exec playwright install --with-deps firefox webkit
- name: Run cross-browser E2E tests
run: pnpm test:e2e:cross-browser
env:
VITE_GITHUB_CLIENT_ID: ${{ vars.VITE_GITHUB_CLIENT_ID }}
43 changes: 42 additions & 1 deletion .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ jobs:
with:
ref: ${{ inputs.tag }}
persist-credentials: false
# The build stamp, the Worker deploy tag and the post-deploy check must all name the
# commit that was checked out. github.sha is the caller's commit, which differs from
# inputs.tag on a manual re-deploy of an older release.
- name: Resolve deployed commit
id: rev
run: printf 'sha=%s\n' "$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- uses: ./.github/actions/verify
with:
vite-github-client-id: ${{ vars.VITE_GITHUB_CLIENT_ID }}
Expand All @@ -32,16 +38,29 @@ jobs:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
- run: pnpm run build
env:
# GITHUB_SHA itself cannot be overridden, so the build reads this variable first.
BUILD_GIT_SHA: ${{ steps.rev.outputs.sha }}
VITE_GITHUB_CLIENT_ID: ${{ vars.VITE_GITHUB_CLIENT_ID }}
VITE_JIRA_CLIENT_ID: ${{ vars.VITE_JIRA_CLIENT_ID }}
VITE_SENTRY_DSN: ${{ vars.VITE_SENTRY_DSN }}
VITE_TURNSTILE_SITE_KEY: ${{ vars.VITE_TURNSTILE_SITE_KEY }}
# Fail before deploying if the bundle was stamped with any other commit.
- name: Verify build stamp
env:
EXPECTED_SHA: ${{ steps.rev.outputs.sha }}
run: |
expected="${EXPECTED_SHA:0:12}"
sha="$(jq -r '.sha // empty' dist/client/version.json)"
if [[ "$sha" != "$expected" ]]; then
printf 'dist/client/version.json names %s, expected %s\n' "$sha" "$expected" >&2
exit 1
fi
- name: Deploy to Cloudflare Workers
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
LC_ALL: C.UTF-8
DEPLOY_TAG: ${{ github.sha }}
DEPLOY_TAG: ${{ steps.rev.outputs.sha }}
RAW_MSG: ${{ github.event.head_commit.message }}
run: |
DEPLOY_TAG="${DEPLOY_TAG:0:25}"
Expand All @@ -50,3 +69,25 @@ jobs:
DEPLOY_MSG="${DEPLOY_MSG:0:100}"
DEPLOY_MSG="${DEPLOY_MSG:-manual deploy ${DEPLOY_TAG}}"
pnpm exec wrangler deploy --tag "$DEPLOY_TAG" --message "$DEPLOY_MSG"
# Update detection ignores a /version.json that is missing or served as the SPA's HTML
# fallback, so confirm the live file is JSON and names the build that was just deployed.
- name: Verify deployed version.json
if: ${{ vars.DEPLOY_DOMAIN != '' }}
env:
DEPLOY_DOMAIN: ${{ vars.DEPLOY_DOMAIN }}
EXPECTED_SHA: ${{ steps.rev.outputs.sha }}
run: |
expected="${EXPECTED_SHA:0:12}"
for attempt in 1 2 3 4 5 6; do
out="$(curl -sS --max-time 10 -w '\n%{content_type}' "https://${DEPLOY_DOMAIN}/version.json" || true)"
ctype="${out##*$'\n'}"
json="${out%$'\n'*}"
sha="$(jq -r '.sha // empty' <<< "$json" 2>/dev/null || true)"
if [[ "$ctype" == application/json* && "$sha" == "$expected" ]]; then
printf 'version.json OK (%s)\n' "$sha"
exit 0
fi
sleep $((attempt * 5))
done
printf 'version.json did not report %s with a JSON content type\n' "$expected" >&2
exit 1
2 changes: 1 addition & 1 deletion .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@
"group": ["*/CustomTabModal", "../shared/CustomTabModal"],
"importNamePattern": "^",
"allowTypeImports": true,
"message": "Lazy-load CustomTabModal via lazy(() => import('../shared/CustomTabModal')) so it is excluded from initial dashboard/settings chunks."
"message": "Lazy-load CustomTabModal via lazyWithReload(() => import('../shared/CustomTabModal')) so it is excluded from initial dashboard/settings chunks."
},
{
"group": ["./github-emoji-map.json", "**/github-emoji-map.json"],
Expand Down
9 changes: 7 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,10 @@ Fine-grained PATs need Actions (read), Contents (read), Issues (read), and Pull

```bash
pnpm test # unit tests (Vitest — root + mcp/)
pnpm test:e2e # Playwright E2E tests (chromium)
pnpm test:e2e # Playwright E2E tests (chromium against vite dev, plus *.preview.spec.ts against pnpm build && vite preview; rebuilds dist/)
pnpm test:e2e:preview # Only the *.preview.spec.ts E2E tests (stale-chunk and offline handling on the production build)
E2E_SERVERS=dev pnpm test:e2e # Skip the production build and preview server (dev-server specs only)
pnpm test:e2e:cross-browser # Only the @cross-browser-tagged E2E tests, on Firefox + WebKit (first run: pnpm exec playwright install firefox webkit)
pnpm test:status-smoke # Live-network check that githubstatus.com still reports the tracked component names (also runs on a weekly schedule in CI)
pnpm run typecheck # TypeScript validation (root + mcp/)
pnpm lint # Type-aware lint check (oxlint — root + mcp/)
Expand All @@ -52,7 +55,7 @@ To test MCP tools interactively, use the MCP Inspector:
npx @modelcontextprotocol/inspector tsx mcp/src/index.ts
```

CI runs typecheck, lint (`oxlint`), unit tests, and E2E tests on every PR, and lint is also enforced pre-commit via `prek` alongside typecheck and unit tests. Make sure they pass locally before pushing.
CI runs typecheck, lint (`oxlint`), unit tests, and E2E tests (Chromium, plus the `@cross-browser` subset on Firefox and WebKit in a separate job) on every PR, and lint is also enforced pre-commit via `prek` alongside typecheck and unit tests. Make sure they pass locally before pushing.

To run a specific test file:

Expand All @@ -76,6 +79,8 @@ pnpm test -- tests/path/to/test.ts

**Validation:** Zod v4 for all runtime validation. Note that nested `.default({})` doesn't apply inner field defaults — be explicit.

**Persisted schemas (`src/shared/schemas.ts`, `src/shared/view-schema.ts`):** the build fingerprints what `ConfigSchema` and `ViewStateSchema` accept; when a deploy changes it, tabs still on the older build turn read-only until reloaded. Keys, types, enums, patterns, array limits and defaults are covered automatically. A limit enforced by `.refine()` is invisible to that, so put it in `PERSISTED_LIMITS` (or export it like `TRACKED_USER_AVATAR_PREFIX`) and let the refine read it; a test fails if a refine is added without being accounted for. Tag a new E2E test `{ tag: "@cross-browser" }` to also run it on Firefox and WebKit in CI.

## Testing

Tests live in `tests/` and mirror the `src/` directory structure. Test files end in `.test.ts` or `.test.tsx`. BDD step definitions end in `.steps.tsx` and are also picked up by Vitest automatically.
Expand Down
5 changes: 3 additions & 2 deletions DEPLOY.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ Cloudflare Worker secrets are set. In CI, the deploy workflow runs
5. **MCP relay** — If deploying to a custom domain, set `MCP_RELAY_ALLOWED_ORIGINS=https://YOUR-DOMAIN` when running the MCP server (`https://gh.gordoncode.dev` is allowed by default)
6. **Sentry error reporting** — Set `VITE_SENTRY_DSN` (build-time, via GitHub Actions variable) and `SENTRY_DSN` (Worker secret, via `wrangler secret put`) to the **same** DSN value. The Worker tunnel (`/api/error-reporting`) validates the incoming envelope DSN against `env.SENTRY_DSN` — different values cause all Sentry events to silently return 403. Leave both empty to disable. **Worker-only** — does not work on static deploys.
7. **Cloudflare Turnstile** — Create a widget and set `VITE_TURNSTILE_SITE_KEY` in `.env`. Only needed for the planned Jira token sealing feature. **Worker-only.**
8. **WAF smoke tests** — Set `DEPLOY_DOMAIN` as a GitHub Actions variable (e.g., `your-domain.example.com`). CI runs `pnpm test:waf https://$DEPLOY_DOMAIN` automatically. If `DEPLOY_DOMAIN` is not set, the WAF test is skipped. Run locally with: `pnpm test:waf https://YOUR-DOMAIN`. **Worker-only.**
8. **WAF smoke tests** — Set `DEPLOY_DOMAIN` as a GitHub Actions variable (e.g., `your-domain.example.com`). CI runs `pnpm test:waf https://$DEPLOY_DOMAIN` automatically. If `DEPLOY_DOMAIN` is not set, the WAF test is skipped, and so is the post-deploy `version.json` verification in `deploy.yml`. Run locally with: `pnpm test:waf https://YOUR-DOMAIN`. **Worker-only.**
9. **Social metadata** — Update `og:image` and `og:url` in `index.html` to your domain
10. **Security contact** — Update the email and scope domain in `SECURITY.md`
11. **README** — Update the "Live demo" URL in `README.md`
Expand Down Expand Up @@ -220,6 +220,7 @@ The two tunnel endpoints (`/api/error-reporting` and `/api/csp-report`) receive

- **Hashed bundles (`/_app/*`):** Vite outputs content-hashed JS and CSS bundles to `dist/client/_app/`. `public/_headers` configures `Cache-Control: public, max-age=31536000, immutable` (one year) on `/_app/*` while inheriting the global security headers from `/*`.
- **Entry HTML and unhashed public assets (`index.html`, `/assets/*`):** `index.html` and unhashed images under `public/assets/*` (favicon, logos, social preview) have no custom `Cache-Control` rule in `public/_headers` and use the Cloudflare Workers Static Assets platform default (`Cache-Control: public, max-age=0, must-revalidate`), ensuring browsers revalidate `index.html` on every load.
- **Build metadata (`/version.json`):** The build emits `version.json` at the client root (`{ version, sha, builtAt, schemaFp }`; `schemaFp` fingerprints the persisted config/view schema — structure, enums, defaults and the `.refine()` limits in `PERSISTED_LIMITS` — so an open tab on an older layout turns read-only; comments and refactors don't change it). `public/_headers` serves it with `Cache-Control: no-store` so open tabs always see the latest deployment, and the WAF Path Allowlist includes it. When `DEPLOY_DOMAIN` is set, the deploy workflow's **Verify deployed version.json** step runs after `wrangler deploy` and fails the job unless the live file is served as `application/json` and its `sha` equals the first 12 characters of the deployed commit (retrying for about 105 seconds to ride out propagation).
- **One-time cache miss on initial `/_app/` rollout:** Moving hashed bundles from `/assets/` to `/_app/` causes a one-time cache miss on the deployment that introduces `/_app/`.
- **Stale-chunk 404 handling (`stale_asset_404`):** Under SPA `not_found_handling`, missing non-navigation requests (such as `<script>` or `import()` fetches for a chunk from a superseded deploy) invoke the Worker and `env.ASSETS.fetch(request)` returns the `200 text/html` SPA fallback. When `url.pathname.startsWith("/_app/")`, `assetResponse.status === 200`, and `Content-Type` starts with `text/html`, the Worker logs `stale_asset_404` and returns `404 Not Found` (`text/plain; charset=utf-8`) with `Cache-Control: no-store` and `SECURITY_HEADERS` so neither the browser nor CDN caches HTML under an immutable chunk URL.
- **Accepted navigation edge case:** A direct browser *navigation* (`Sec-Fetch-Mode: navigate`) to a non-existent `/_app/*` URL is answered by Cloudflare's static asset router with `index.html` before the Worker runs and inherits the `/_app/*` immutable header from `_headers`. This is harmless (no application route or asset reference ever navigates to a missing `/_app/*` path) and avoids routing every `/_app/*` asset hit through the Worker.
Expand Down Expand Up @@ -257,7 +258,7 @@ pnpm run build
wrangler deploy
```

In CI (`.github/workflows/deploy.yml`), production deployments are triggered automatically by `.github/workflows/release.yml` (`workflow_call`) when a `github-tracker@x.y.z` release PR merges, or manually via `workflow_dispatch`. The workflow runs `pnpm exec wrangler deploy --tag "$DEPLOY_TAG" --message "$DEPLOY_MSG"` with values passed via `env:` (`DEPLOY_TAG` truncated to 25 characters of `GITHUB_SHA`; `DEPLOY_MSG` taken from the first line of the commit message with ASCII control characters stripped and truncated to 100 UTF-8 characters under `LC_ALL=C.UTF-8`, or `manual deploy <tag>` on `workflow_dispatch`).
In CI (`.github/workflows/deploy.yml`), production deployments are triggered automatically by `.github/workflows/release.yml` (`workflow_call`) when a `github-tracker@x.y.z` release PR merges, or manually via `workflow_dispatch`. The workflow runs `pnpm exec wrangler deploy --tag "$DEPLOY_TAG" --message "$DEPLOY_MSG"` with values passed via `env:` (`DEPLOY_TAG` truncated to 25 characters of the checked-out commit — resolved with `git rev-parse HEAD` after checkout and also passed to the build as `BUILD_GIT_SHA` (read before `GITHUB_SHA`, which Actions does not allow overriding), checked against `dist/client/version.json` before deploying, and used as the expected SHA of the post-deploy `version.json` check, so a manual re-deploy of an older tag is stamped with that tag's commit rather than the caller's; `DEPLOY_MSG` taken from the first line of the commit message with ASCII control characters stripped and truncated to 100 UTF-8 characters under `LC_ALL=C.UTF-8`, or `manual deploy <tag>` on `workflow_dispatch`).

## Releases

Expand Down
Loading
Loading