Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
6e6001d
perf(newtab): use a Set for seen-id lookup in refresh()
BrandonML Sep 11, 2026
cc82823
refactor(server): extract getBestPicture out of normalizeCards
BrandonML Sep 11, 2026
57b676e
refactor(options): add $ DOM-lookup helper, matching newtab.js
BrandonML Sep 11, 2026
0d7d79b
feat(newtab): add report-issue link to generic refresh failures
BrandonML Sep 11, 2026
47b3a60
test(scripts): add unit tests for zip.js's crc32 and dosDateTime
BrandonML Sep 11, 2026
2ed9f19
test(extension): add direct tests for locationFromBrowser
BrandonML Sep 11, 2026
f72493c
fix(server): stop bodyOf() from silently killing the socket on abort
BrandonML Sep 11, 2026
e729395
test(options): assert the primary close-settings tab-navigation path
BrandonML Sep 11, 2026
93eddad
feat(server): rate-limit /api/nearby-cats per client IP
BrandonML Sep 11, 2026
459e98a
feat(newtab): content-aware crop for portrait photos (fixes #24)
BrandonML Sep 11, 2026
51bf851
chore(cws): recapture store screenshots with far less whitespace (#28)
BrandonML Sep 11, 2026
8a43638
Merge perf/seen-ids-set into dev
BrandonML Sep 11, 2026
b59b65f
Merge refactor/get-best-picture into dev
BrandonML Sep 11, 2026
6a251fc
Merge refactor/options-dollar-helper into dev
BrandonML Sep 11, 2026
a1dfed4
Merge test/zip-crc32-dosdatetime into dev
BrandonML Sep 11, 2026
c9efa49
Merge test/location-from-browser into dev
BrandonML Sep 11, 2026
c463fd7
Merge feat/report-issue-link into dev
BrandonML Sep 11, 2026
a6c57a2
Merge test/options-close-primary-path into dev
BrandonML Sep 11, 2026
020e1dd
Merge test/server-request-timeout into dev
BrandonML Sep 11, 2026
4a7bc6f
Merge feat/rate-limiting into dev
BrandonML Sep 11, 2026
a43cc9f
Merge feat/content-aware-portrait-crop into dev
BrandonML Sep 11, 2026
ee7e853
Merge chore/cws-screenshot-crop into dev
BrandonML Sep 11, 2026
d1f5b66
incriment version number
BrandonML Sep 11, 2026
6041952
Fix detached-looking notice link and add explore link to no-results n…
BrandonML Sep 11, 2026
0c4c3c6
Add "Share this cat" via the Web Share API
BrandonML Sep 11, 2026
238fcf4
Merge feat/no-results-notice-links into dev
BrandonML Sep 11, 2026
dfa3d18
Merge feat/share-this-cat into dev
BrandonML Sep 11, 2026
984ed27
Automate CI, release tagging, and deploy verification
BrandonML Sep 11, 2026
b467722
Merge chore/release-process-automation into dev
BrandonML Sep 11, 2026
1a66b54
Wire in the real Edge Add-ons share link, add a committed release run…
BrandonML Sep 11, 2026
e6d3125
Merge chore/edge-link-and-release-doc into dev
BrandonML Sep 11, 2026
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
30 changes: 30 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
name: CI

on:
push:
branches: [main, dev]
pull_request:
branches: [main, dev]

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

# Matches the Dockerfile's node:22-alpine so CI runs the same Node
# version the server actually deploys on.
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm

- run: npm ci
- run: npm test

# The live RescueGroups integration test hits the real API and needs a
# real key -- it's excluded from CI on purpose (see README "Run the
# live RescueGroups integration test..."), same as it's excluded from
# `npm test` locally. Run `npm run test:live` manually before merging
# any change to search radius, pagination, or the RescueGroups query
# contract.
84 changes: 84 additions & 0 deletions .github/workflows/deploy-verify.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
name: Verify production deploy

# Northflank's own build trigger on the `tabby` service fires independently
# on push to main (see buildConfiguration.pathIgnoreRules there) -- this
# workflow doesn't control or trigger that build, it just confirms it
# actually landed. Runs in parallel with Northflank's build, so it has to
# wait/poll rather than assume the deploy is already done by the time this
# job starts.
on:
push:
branches: [main]

env:
PROD_URL: https://p01--tabby--bklqdgzwx4md.code.run

jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Check whether this push touches a deployed path
id: changed
run: |
# Mirrors the tabby service's own buildConfiguration allow-list
# (isAllowList: true, pathIgnoreRules: ["server/**", "Dockerfile"])
# -- if Northflank wouldn't rebuild for this push, waiting for the
# live sha to change would just time out for no reason.
if git diff --name-only "${{ github.event.before }}" "${{ github.sha }}" | grep -qE '^(server/|Dockerfile$)'; then
echo "deploy=true" >> "$GITHUB_OUTPUT"
else
echo "deploy=false" >> "$GITHUB_OUTPUT"
echo "No server/** or Dockerfile changes in this push -- Northflank will not rebuild, skipping verification."
fi

- name: Wait for the live deployment to report this commit
if: steps.changed.outputs.deploy == 'true'
run: |
set -e
EXPECTED="${{ github.sha }}"
echo "Waiting for $PROD_URL/healthz to report sha=$EXPECTED ..."
for i in $(seq 1 40); do
ACTUAL=$(curl -fsS "$PROD_URL/healthz" 2>/dev/null | jq -r '.sha // empty' || echo "")
if [ "$ACTUAL" = "$EXPECTED" ]; then
echo "Live deployment matches ($ACTUAL) after $((i * 15))s."
exit 0
fi
echo " [$i/40] still on $ACTUAL, retrying in 15s..."
sleep 15
done
echo "::error::Timed out after 10 minutes waiting for $PROD_URL to report sha=$EXPECTED (last seen: $ACTUAL). Check the tabby service's build/deploy status on Northflank before proceeding to store submission."
exit 1

- name: Smoke test the live API
if: steps.changed.outputs.deploy == 'true'
run: |
set -e

echo "-- /healthz --"
curl -fsS "$PROD_URL/healthz" | jq .

echo "-- POST /api/nearby-cats --"
NEARBY=$(curl -fsS -X POST "$PROD_URL/api/nearby-cats" \
-H "Content-Type: application/json" \
-d '{"location":{"postalcode":"10001"},"page":1}')
echo "$NEARBY" | jq '{cardCount: (.cards | length), radiusMiles}'
CARD_COUNT=$(echo "$NEARBY" | jq '.cards | length')
if [ "$CARD_COUNT" -lt 1 ]; then
echo "::error::/api/nearby-cats returned zero cards for a known-good ZIP -- possible RescueGroups outage or regression, not necessarily a bad deploy."
exit 1
fi
IMAGE_URL=$(echo "$NEARBY" | jq -r '.cards[0].imageUrl')

echo "-- GET /api/photo-thumb --"
curl -fsS -o /dev/null -w "status=%{http_code} size=%{size_download}\n" \
"$PROD_URL/api/photo-thumb?url=$(node -e "console.log(encodeURIComponent(process.argv[1]))" "$IMAGE_URL")"

echo "-- GET /api/photo-share --"
curl -fsS -o /dev/null -w "status=%{http_code} size=%{size_download}\n" \
"$PROD_URL/api/photo-share?url=$(node -e "console.log(encodeURIComponent(process.argv[1]))" "$IMAGE_URL")"

echo "All smoke checks passed -- safe to proceed with store submission."
38 changes: 38 additions & 0 deletions .github/workflows/tag-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
name: Tag release

# manifest.json and package.json are always bumped together by
# scripts/release.js, so manifest.json's version is the single source of
# truth for "what version just landed on main." Runs on every push to main
# and no-ops if that version is already tagged, so it's safe to fire on
# every merge rather than needing to detect "was this a release commit."
on:
push:
branches: [main]

permissions:
contents: write

jobs:
tag:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Create and push a version tag if one doesn't already exist
run: |
set -e
VERSION=$(node -p "require('./manifest.json').version")
TAG="v$VERSION"

if git rev-parse "$TAG" >/dev/null 2>&1; then
echo "Tag $TAG already exists -- nothing to do."
exit 0
fi

git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag -a "$TAG" -m "Release $TAG"
git push origin "$TAG"
echo "Created and pushed $TAG."
8 changes: 8 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
FROM node:22-alpine
WORKDIR /app
# package.json declares "type": "module" -- server/index.js and
# server/rescuegroups.js rely on that for import/export syntax to parse as
# ESM. Node 22 happens to auto-detect module syntax when no package.json is
# present at all, which is why this worked before without it, but that's an
# implicit, version-specific fallback -- copying the real package.json makes
# module resolution explicit and correct regardless of the Node version this
# image is ever rebased to.
COPY package.json ./package.json
COPY server ./server
EXPOSE 8787
CMD ["node", "server/index.js"]
18 changes: 16 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ Tabby is a Manifest V3 Chrome extension that replaces the new tab page with a ne
- Browser-coordinate lookup with native postal-code fallback.
- Server-side 25 -> 75 -> 150 -> 250 mile radius ladder, escalating on cumulative deduplicated results until 40 unique cats are found.
- RescueGroups `available/cats/haspic` query, nearest-first sorting, picture validation, organization join, and safe profile-url fallback.
- Content-aware crop for portrait photos: a row-wise edge-energy heuristic finds the likely subject band instead of always anchoring to the top, via a small hostname-locked analysis-thumbnail proxy (`GET /api/photo-thumb`) that works around RescueGroups' CDN sending no CORS headers.
- "Share this cat" via the Web Share API, attaching the actual photo (fetched through a second hostname-locked, share-sized proxy, `GET /api/photo-share`, for the same CORS reason as the crop analysis above) alongside the cat's details, a Tabby tagline, and its RescueGroups profile link. Degrades to a link-only native share if the photo can't be attached, and to a clipboard-copy if the platform has no Web Share API at all.
- No third-party runtime dependencies; Node's built-in test runner.

## Run locally
Expand All @@ -34,17 +36,29 @@ Deploying the server is a separate step from packaging the extension; whatever h

- `RG_API_KEY` — set in the platform's secret manager, never committed.
- `ALLOW_ORIGIN` — the installed extension's exact `chrome-extension://<id>` origin, not `*`. Each store (Chrome Web Store, Edge Add-ons, ...) assigns its own extension ID even for an identical package, so once the extension is published to more than one store this needs a comma-separated list of every store's origin, e.g. `chrome-extension://elfpnkoboidkgahmoggodpnmekfodcig,chrome-extension://fieeoalehgckgnkohkdblljmgaemaiho` (Edge extensions also use the `chrome-extension://` scheme, not `edge-extension://`). The server reflects back whichever of these matches the incoming request's `Origin` header; an origin not in the list gets refused by the browser. This is only knowable once the extension has been uploaded to each store's dashboard at least once (that's what assigns the permanent ID), so it's normal to deploy once with `ALLOW_ORIGIN` unset (permissive, with a logged warning) and circle back to lock it down afterward — and to append to the list, rather than replace it, each time the extension is published to a new store.
- `NODE_ENV=production` — currently gates stack-trace logging; a follow-up change will also use it to select the production RescueGroups API endpoint instead of the dev/sandbox one.
- `NODE_ENV=production` — gates stack-trace logging in error responses. RescueGroups has only one API endpoint (`server/rescuegroups.js`'s `BASE_URL`); local dev, tests, and production all call the same live API, there is no separate dev/sandbox endpoint to select between.

The in-memory cache is correct as-is for the intended deployment target: a single persistent Node process (for example Render, Railway, Fly.io, or Northflank). It would need to be replaced with a shared cache (for example KV/Redis) only if the server is ever scaled to multiple concurrent instances, or moved to a serverless/edge platform (Vercel functions, Cloudflare Workers) where in-process state isn't reliably shared or persistent between requests — those platforms would also require restructuring `server/index.js` away from its current `node:http` `createServer` model.

`/api/nearby-cats` is also rate-limited per client IP (30 requests / 5 minutes, in-memory, same deployment assumption as the cache above) — a cache miss costs a real RescueGroups API call, so this bounds how much a script varying postal codes/coordinates can cost regardless of the response cache. The client IP is taken from `X-Forwarded-For` when present (Northflank and similar platforms terminate the real connection and forward, so `request.socket.remoteAddress` alone would otherwise be the platform's internal proxy address for every request), falling back to the raw socket address only when that header is absent, as in local dev. If a future host doesn't set `X-Forwarded-For` in front of this server, every request would be seen as one shared IP.

Once the server has a real HTTPS URL, package the extension with:

```powershell
npm.cmd run release 1.0.0 https://your-deployed-backend.example.com
```

This bumps `manifest.json`/`package.json` to the given version and zips `manifest.json` + `extension/` for the Chrome Web Store — it does not touch `server/` or its hosting environment. The `<backendUrl>` argument is required (must be `https://`) and is only baked into the **staged copy that gets zipped** — `extension/config.js` in the repo itself is never modified, so it always stays at `http://localhost:8787` for local dev and the test suite (which asserts requests go to `localhost:8787`). There's no manual edit-then-revert step needed for a release.
This bumps `manifest.json`/`package.json` to the given version and zips `manifest.json` + `extension/` for the Chrome Web Store — it does not touch `server/` or its hosting environment. The `<backendUrl>` argument is required (must be `https://`) and is only baked into the **staged copy that gets zipped** — `extension/config.js` in the repo itself is never modified, so it always stays at `http://localhost:8787` for local dev and the test suite (which asserts requests go to `localhost:8787`). There's no manual edit-then-revert step needed for a release. The same zip is submitted to both the Chrome Web Store and Edge Add-ons — Edge is Chromium-based and needs no code changes.

## CI/CD

Three GitHub Actions workflows automate the release process end to end — see [RELEASE.md](RELEASE.md) for the step-by-step runbook:

- **`ci.yml`** — runs `npm test` on every push and pull request to `main`/`dev`, pinned to Node 22 to match the Dockerfile. `main` has a ruleset (Settings → Rules → Rulesets) requiring this check to pass and requiring a pull request before merging — the workflow alone doesn't block anything, only the ruleset does.
- **`tag-release.yml`** — on every push to `main`, tags the commit `v<version>` (read from `manifest.json`) if that tag doesn't already exist. Idempotent, so it's safe to fire on every push rather than needing to detect "was this actually a release."
- **`deploy-verify.yml`** — on every push to `main` that touches `server/**` or `Dockerfile` (mirroring the `tabby` service's own Northflank build trigger), polls the live `/healthz` endpoint until its `sha` field (see below) matches the pushed commit, then smoke-tests `/api/nearby-cats`, `/api/photo-thumb`, and `/api/photo-share` against production. A **green run is the signal that it's safe to build and submit the release to CWS/EWS** — since Northflank deploys in minutes and store review takes hours, the server is always live and correct well before any user's browser updates to a new extension version, as long as server changes stay additive/backward-compatible with whatever extension version is still in the wild.

`/healthz` reports `{ status: "ok", sha }`, where `sha` is Northflank's auto-injected `NF_DEPLOYMENT_SHA` runtime env var (the exact git commit of the running build) — `null` locally, where that variable is never set. This is what lets `deploy-verify.yml` confirm the *new* code is actually live, not just that some process answered the health check.

To manually smoke-test the unpacked extension against a real deployed backend (as opposed to packaging a release), temporarily edit `BACKEND_URL` in `extension/config.js` yourself, reload the unpacked extension, test, then revert the edit (`git checkout -- extension/config.js`) before committing anything or running the test suite.

Expand Down
29 changes: 29 additions & 0 deletions RELEASE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Release runbook

Steps to ship a change from `dev` to `main`, which auto-deploys the server to Northflank, and on to the Chrome Web Store / Edge Add-ons.

## 1. One-time setup (only needs doing once, ever)

- [ ] `main` has a ruleset requiring the `test` status check (from `.github/workflows/ci.yml`) to pass before merging, and requiring changes to go through a pull request. See GitHub → repo → Settings → Rules → Rulesets.

## 2. Pre-merge

- [ ] Open a pull request from `dev` into `main` — don't merge locally and push. Going through a PR is what lets the ruleset above actually gate the merge on CI passing (a direct push of an untested commit is rejected outright, since it can never satisfy the required check).
- [ ] Wait for the `test` check to go green. If it's red, stop — do not merge.
- [ ] Merge the PR.

## 3. Post-merge (fully automated — just watch)

- [ ] `.github/workflows/tag-release.yml` tags the commit `v<version>` (read from `manifest.json`). Check the repo's Tags page.
- [ ] `.github/workflows/deploy-verify.yml` polls the live server's `/healthz` until it reports this exact commit's SHA (`NF_DEPLOYMENT_SHA`, injected by Northflank), then smoke-tests `/api/nearby-cats`, `/api/photo-thumb`, and `/api/photo-share` against production. **A green run is the signal that it's safe to proceed to store submission.** If it goes red or times out, stop and check the `tabby` service's build/deploy status directly on Northflank before doing anything else.

## 4. Store submission (manual — both stores require a human in their dashboard)

- [ ] Build the release zip: `npm run release <version> https://p01--tabby--bklqdgzwx4md.code.run`
- [ ] Upload `dist/v<version>.zip` to the Chrome Web Store dashboard, submit for review.
- [ ] Upload the same zip to the Edge Add-ons dashboard, submit for review.
- [ ] Update listing copy/screenshots if the release changes what's visibly different to a user.

## Why store review lag isn't a problem

Northflank deploys in minutes; CWS/EWS review takes hours, and browsers don't auto-update installed extensions instantly even after approval — so there's always a window where some users are on an older extension version talking to the new server. This is safe as long as every server change is **additive**: never remove or change an existing endpoint/field an older client version depends on in the same release that ships a client relying on the removal. A new endpoint an old client never calls is always safe to ship ahead of the client that uses it.
2 changes: 1 addition & 1 deletion cws/CHROMEWEBSTORE.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ English
4. **Settings** — the redesigned settings page (stacked "Use my location" / ZIP layout from the recent redesign).
5. **Status chips & fee** — a card showing the "Adoption pending" / "Special needs" chips and a normalized adoption fee, to demonstrate the extension surfaces real, practical adoption details.

All screenshots were captured from the actual `extension/newtab.html` and `extension/options.html` running unmodified, with realistic sample data standing in for a live RescueGroups response (an automated capture can't depend on a real user's location or the live API's current inventory) — layout, CSS, and copy are all pixel-real, not a mockup.
All screenshots use the real `extension/newtab.css`/`newtab.js`/`options.js` unmodified — layout, CSS, and copy are all pixel-real, not a mockup — with realistic sample data standing in for a live RescueGroups response (an automated capture can't depend on a real user's location or the live API's current inventory). Per GitHub issue #28, the original captures showed the real 620px-wide card centered on a 1280×800 canvas with huge empty margins (close to half the image blank), since `.shell`'s max-width is fixed regardless of viewport. Recaptured via `screenshot-frame.source.html` / `screenshot-frame-options.source.html` (see those files for the exact per-screenshot URLs), which scale the rendered card up to ~75–90% of the canvas width without touching the real responsive layout at all. The card's own aspect ratio is close to square, so filling 80%+ of a wide 1280×800 canvas by width while keeping every line of text on-screen isn't simultaneously achievable — screenshots 1 and 5 trade a bit of width (down to ~63–75%) to keep their relevant text (name / status chips & fee) uncropped, rather than hit 80% at the cost of visibly truncated text; screenshots 2–4 reach ~75–90% width with nothing cropped.


## Permissions Justification
Expand Down
Binary file modified cws/screenshot-1-main-card.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified cws/screenshot-2-explore.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified cws/screenshot-3-first-run.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified cws/screenshot-4-settings.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified cws/screenshot-5-fee-tags.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading