Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ jobs:
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
fetch-depth: 1
ref: ${{ inputs.commit_sha || github.sha }}
- uses: actions/setup-node@v6
with:
Expand Down
13 changes: 5 additions & 8 deletions .github/workflows/fetch-cbp.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,8 @@ jobs:
fetch:
runs-on: ubuntu-latest
steps:
# fetch-depth: 0 is load-bearing, not hygiene. This workflow validates a
# full site build, and `npm run build` runs build-aggregates.mjs, which
# reconstructs the 30-day window with `git log --since=... -- crossings.json`.
# Under the default depth-1 clone that log returns one commit, so every
# aggregate silently collapsed to ~2 samples while the pages kept claiming
# "30 days of CBP data" (found 2026-07-27; rankings.json had fallen to 1
# entry). deploy.yml already does this; it just stopped being the deployer.
# Full history is needed here to materialize the rolling 30-day snapshot
# artifact. Site builds consume the artifact and can use shallow clones.
- uses: actions/checkout@v7
with:
fetch-depth: 0
Expand All @@ -40,14 +35,16 @@ jobs:
run: node scripts/fetch-cbp.mjs
- name: Fetch USD/MXN exchange rate
run: node scripts/fetch-fx.mjs
- name: Materialize rolling snapshot history
run: npm run history:build -- --include-current
- name: Validate static site
run: npm run build
- name: Commit validated data snapshot
id: snapshot
run: |
git config user.name "borderpulse-bot"
git config user.email "borderpulse-bot@users.noreply.github.com"
git add public/data/crossings.json public/data/exchange-rate.json
git add public/data/crossings.json public/data/exchange-rate.json public/data/snapshot-history.json
if git diff --cached --quiet; then
echo "No data changes."
echo "changed=false" >> "$GITHUB_OUTPUT"
Expand Down
118 changes: 118 additions & 0 deletions .github/workflows/publish-vercel.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
name: Publish bundled data to Vercel
run-name: Vercel ${{ vars.VERCEL_PUBLISH_TARGET || 'preview' }} - ${{ github.event_name }}

on:
schedule:
- cron: '17 8 * * *'
push:
branches: [main]
paths-ignore:
- 'public/data/**'
- 'public/og/**'
- 'public/sitemap.xml'
- 'public/rss.xml'
- 'docs/**'
- '**.md'
- 'drafts/**'
workflow_dispatch:

permissions:
contents: read
issues: write

# Independent of the 15-minute writer and the sole Pages deployer.
concurrency:
group: vercel-publication
cancel-in-progress: false

jobs:
publish:
# Remains off until the credential and preview publication are approved.
if: vars.VERCEL_PUBLISH_ENABLED == 'true' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
timeout-minutes: 20
env:
VERCEL_ORG_ID: team_nUpE7oimh9LSdodnuGLxZhgr
VERCEL_PROJECT_ID: prj_ftFsRRgb2QcOKUmhG3If4rWRWI8e
PUBLISH_TARGET: ${{ vars.VERCEL_PUBLISH_TARGET || 'preview' }}
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
steps:
# Resolve current main once at job start, then build/deploy that exact SHA.
# A queued code push must not roll a newer deployment back to old data.
- uses: actions/checkout@v7
with:
ref: main
fetch-depth: 1
- uses: actions/setup-node@v6
with:
node-version: '24'
cache: npm
- name: Check publication authorization
shell: bash
run: |
set -euo pipefail
test -n "$VERCEL_TOKEN" || { echo 'Missing approved VERCEL_TOKEN'; exit 1; }
case "$PUBLISH_TARGET" in preview|production) ;; *) echo 'Invalid publication target'; exit 1;; esac
- run: npm ci
- name: Install pinned deployment CLI
run: npm install --global vercel@50.1.6
- name: Validate collector output and record provenance
run: npm run publish:validate -- --write-manifest
- name: Pull matching environment settings
run: vercel pull --yes --environment="$PUBLISH_TARGET" --token="$VERCEL_TOKEN"
- name: Build locally for the selected target
shell: bash
run: |
set -euo pipefail
if [ "$PUBLISH_TARGET" = production ]; then
vercel build --prod --token="$VERCEL_TOKEN"
else
vercel build --token="$VERCEL_TOKEN"
fi
- name: Run release checks against the built artifact
run: npm test
- name: Recheck data freshness before uploading
run: npm run publish:validate
- name: Deploy the verified prebuilt output
id: deploy
shell: bash
run: |
set -euo pipefail
args=(deploy --prebuilt --token="$VERCEL_TOKEN")
if [ "$PUBLISH_TARGET" = production ]; then args+=(--prod); fi
deployment_url=$(vercel "${args[@]}")
echo "url=$deployment_url" >> "$GITHUB_OUTPUT"
{
echo "### Vercel $PUBLISH_TARGET publication"
echo "Deployment: $deployment_url"
echo "Source commit: $(git rev-parse HEAD)"
echo '```json'
cat public/data/publication.json
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
- name: Retain publication evidence
uses: actions/upload-artifact@v4
with:
name: vercel-publication-${{ github.run_id }}
path: public/data/publication.json
retention-days: 14
- name: Report failure or recovery
if: always()
uses: actions/github-script@v9
env:
JOB_STATUS: ${{ job.status }}
with:
script: |
const title = '[ops] Vercel daily publication failing';
const {data: issues} = await github.rest.issues.listForRepo({
owner: context.repo.owner, repo: context.repo.repo, state: 'open', creator: 'github-actions[bot]', per_page: 100,
});
const existing = issues.find(issue => issue.title === title);
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
if (process.env.JOB_STATUS === 'failure' && !existing) {
await github.rest.issues.create({owner: context.repo.owner, repo: context.repo.repo, title,
body: `Publication failed: ${runUrl}. The workflow does not delete or disable the previous deployment. Inspect the failed step before retrying; do not relax the data gates.`});
} else if (process.env.JOB_STATUS === 'success' && existing) {
await github.rest.issues.createComment({owner: context.repo.owner, repo: context.repo.repo, issue_number: existing.number, body: `Recovered: ${runUrl}`});
await github.rest.issues.update({owner: context.repo.owner, repo: context.repo.repo, issue_number: existing.number, state: 'closed'});
}
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,6 @@ drafts/blog/auto-a/queue.json
# (those are PR artifacts); the all-items raw feed is regenerated each run.
drafts/curation/latest-feed.json
.playwright-mcp/
.vercel
.env*.local
/public/data/publication.json
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# CHANGELOG

## Unreleased — controlled daily Vercel publication

- Added a disabled-by-default daily/application-change GitHub publisher that builds Vercel output locally, runs release checks and uploads prebuilt artifacts. Data-only commits do not trigger it. Preview is the default; production needs an explicit target change after approval.
- Added publication provenance and gates for collector freshness, identity/count consistency, rolling history coverage and matching latest observations, and USD/MXN data. Failed validation never uploads a replacement deployment.
- Hardened the Vercel client fallback: malformed responses cannot invent a fresh timestamp, older static data cannot replace newer browser data, and requests time out. Added upstream, malformed-data, recovery and publish-gate tests.
- Documented activation, measured usage baseline, cutover and the exact captured Pages DNS rollback. No new credential, production deployment, DNS change or paid service is implied by this entry.

## Unreleased — combined free-product release checks

- Combined comparison sharing with guide-to-waits and favorites without coupling the release to Vercel. A storage write failure now leaves the favorite unchanged and shows an EN/ES error instead of a false saved confirmation.
Expand Down
13 changes: 7 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,19 +42,20 @@ If the user just says **"what's new"** or **"catch me up"** or
skipped by `deploy.yml`'s push trigger, so fetch-cbp explicitly
`workflow_dispatch`es deploy.yml with the exact commit SHA — bot pushes
do not trigger workflows on their own.
- **`fetch-depth: 0` is load-bearing** on any workflow that runs
`npm run build`. `build-aggregates.mjs` reconstructs its 30-day window
with `git log`, so a shallow clone silently produces empty aggregates.
This has already caused one three-week silent outage (see CHANGELOG
2026-07-27).
- **`fetch-depth: 0` is load-bearing for the collector**, which materializes
`public/data/snapshot-history.json`. Site builds read that explicit artifact
and can use shallow clones. Never regenerate history from a shallow clone.
The daily Vercel publisher is independent of the 15-minute data writer;
see `docs/REFRESH-OPERATIONS.md` for activation and rollback gates.
- **Vite + React + Tailwind + shadcn/ui.** Code-split leaf routes
via `React.lazy`. The eager entry chunk is budgeted in
`scripts/check-bundle-size.mjs` and enforced by `npm test` — check
there for the current number rather than trusting a figure in prose,
which is how it drifted 16 KB unnoticed.
- **Public JSON feeds** at `/data/crossings.json`, `/data/aggregates/{slug}.json`,
`/data/timelines/...`, `/data/blog/...`, `/data/stats.json`.
- **No backend.** No auth, no database, no SMS, no email. Anything that
- **Vercel previews add one cached official-CBP function.** No auth, database,
SMS or email. Anything that
needs persistence lives in browser localStorage. Anything that needs
cron lives in GitHub Actions.

Expand Down
27 changes: 27 additions & 0 deletions DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,33 @@ Format: date · one-line decision · short why.

---

## 2026-09-29 · Publish Vercel bundles daily from GitHub Actions

The 15-minute collector and Pages rollback remain intact. Vercel current waits
use the cached CBP function; bundled history, FX and generated routes publish
daily plus application changes. Build in GitHub Actions and upload prebuilt
output, keeping automatic Vercel Git builds disconnected. Publication rejects
stale or inconsistent collector data; a failed gate leaves the previous site
available. Preview is the default and activation requires an approved scoped
credential. Production/domain changes remain a separate decision. Target $0
additional monthly spend; do not pause the shared Vercel team and DIGITO.

---

## 2026-09-29 · Evaluate Vercel with a public-app preview

Keep the existing GitHub Pages site and automatic ads while testing Vercel.
Vercel builds use the same Vite app plus a five-minute CDN-cached CBP endpoint;
the client retains the last successful reading and falls back to the published
static snapshot with its original timestamp when the endpoint fails. A compact
30-day snapshot artifact makes historical aggregates independent of build-host
Git history. Do not connect every data commit to a Vercel build: the scheduled
writer runs too often for that to be a cost-controlled default. A production
domain move needs a production-equivalent preview, usage review, and owner
approval first.

---

## 2026-08-20 · Freshness thresholds come from measured delivery, not the cron

Issue #58 asked for stale/fresh thresholds "derived from the configured
Expand Down
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,14 @@ Real-time US-Mexico border wait times. Static site on GitHub Pages at [borderpul

Paid southbound Google Maps estimates are paused and are not part of the production workflow. The retained `scripts/fetch-sb.mjs` utility is historical/manual only; production serves the official northbound CBP feed.

The client (`src/components/utils/dataService.js`) reads those static JSON files. No API, no auth, no rate limits.
On Pages, the client reads those static JSON files. Vercel previews use the
five-minute CDN-cached `/api/public/crossings` endpoint with the static snapshot
as fallback; both preserve actual timestamps. No account is required.

The collector also materializes rolling 30-day history. The prepared Vercel
publisher builds bundled data daily at 08:17 UTC and when app code changes,
not for every data commit. It is disabled until credential approval and starts
in preview. See [refresh operations](docs/REFRESH-OPERATIONS.md).

## Local dev
```bash
Expand Down
4 changes: 4 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ cross-border experience; it is a product hypothesis, not a claim of demand.

### Now: earn trust in the free entry point

- [~] Review the cost-controlled Vercel preview from the current public app:
verify automatic ads, live CBP endpoint, static fallback, 30-day history,
and measured usage before any domain move. The public site stays on Pages
until Sebastian approves a production cutover.
- Keep official northbound CBP data, source timestamps, honest stale states and
the static fallback reliable. Keep the free experience supported by ads.
- Measure whether people complete a crossing decision and return. Review
Expand Down
33 changes: 33 additions & 0 deletions api/public/crossings.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { CBP_URL, createCbpPayload } from '../../scripts/fetch-cbp.mjs';

// Serve one official snapshot from the CDN for five minutes. The frontend
// keeps its last good response and can fall back to the static Pages feed.
export const config = { maxDuration: 10 };

function reply(res, status, body, cache = false) {
res.status(status);
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Cache-Control', cache ? 'public, max-age=0, must-revalidate' : 'no-store');
if (cache) {
res.setHeader('Vercel-CDN-Cache-Control', 'public, s-maxage=300, stale-while-revalidate=600');
res.setHeader('CDN-Cache-Control', 'public, s-maxage=300, stale-while-revalidate=600');
}
res.end(JSON.stringify(body));
}

export default async function handler(req, res) {
if (req.method !== 'GET') return reply(res, 405, { error: 'Method not allowed' });

try {
const upstream = await fetch(CBP_URL, {
headers: { 'User-Agent': 'borderpulse.com/1.0 (live-data-cache)' },
signal: AbortSignal.timeout(8_000),
});
if (!upstream.ok) throw new Error(`CBP source returned ${upstream.status}`);
const payload = createCbpPayload(await upstream.json());
return reply(res, 200, payload, true);
} catch (error) {
console.error('[public-crossings]', error);
return reply(res, 502, { error: 'Official CBP data is temporarily unavailable' });
}
}
Loading
Loading