110 Commits

Author SHA1 Message Date
dc09f57ad8 fix(web): stop redirecting visitors to the vhost's internal port
All checks were successful
deploy / Build api + worker (static musl) (push) Successful in 6m59s
deploy / Deploy moments-worker to frootmig (push) Successful in 19s
deploy / Deploy moments-api to nikola (push) Successful in 32s
deploy / Build prerendered web (push) Successful in 4m38s
deploy / Deploy web to oolon (push) Successful in 21s
refresh / Rebuild prerendered web (push) Successful in 4m51s
refresh / Deploy refreshed web to oolon (push) Successful in 39s
Any route asked for without a trailing slash sent the visitor to a port
nothing answers on from outside:

  $ curl -sSI https://rob.tn/activity
  HTTP/2 301
  location: https://rob.tn:14443/activity/

`try_files $uri $uri/` 301s a slash-less directory URL to add the slash,
and nginx builds that Location as an absolute URL from its own
$server_port. This vhost listens on WEB_LISTEN — 127.0.0.1:14443, behind
the edge's stream SNI router — so the redirect advertised 14443 instead of
the 443 the client used. The browser then sat on a TCP connect that never
completes and gave up only after its own timeout, 60s+, before showing an
error. It reads as the site hanging.

`absolute_redirect off` makes the Location relative, so the client keeps
whatever scheme, host and port it actually used. `port_in_redirect off`
would drop the port too, but this also stops nginx asserting a scheme and
host it cannot know from behind the router.

Every /activity, /blog, /cv and /project/... request without the trailing
slash was affected — external links, bookmarks, typed URLs, crawlers. In-app
navigation never round-trips to the server, and `/` needs no directory
redirect, which is why the homepage always loaded fine and this stayed
hidden. It is not a regression from any recent change; it follows from the
vhost listening on a shifted port.

Verified by reproducing the port leak in a container listening on 8081
published as 18081 — `Location: http://127.0.0.1:8081/activity/` before the
directive, `Location: /activity/` after — then rendering the real template
through script/render-site-conf.py (9 placeholders in, none surviving) and
passing `nginx -t` on the result.

Closes #9
2026-08-17 14:03:22 +03:00
6ee4cf5299 feat(api): make healthz verify the schema, not just that the process is up
All checks were successful
deploy / Build api + worker (static musl) (push) Successful in 5m33s
deploy / Deploy moments-worker to frootmig (push) Successful in 17s
deploy / Deploy moments-api to nikola (push) Successful in 19s
deploy / Build prerendered web (push) Successful in 4m0s
deploy / Deploy web to oolon (push) Successful in 20s
`/v1/healthz` returned a static "ok" without touching the database, so
the deploy's health probe passed regardless of whether the schema the
binary expects had been migrated. An api newer than its schema sailed
through the probe and then failed one query at a time on whatever column
was missing — the exact failure that job ordering now prevents, with
nothing to catch it if that assumption ever breaks again.

It now compares the applied migration version against
`moments_data::expected_schema_version()`, derived from the migrations
compiled into this binary via the same `sqlx::migrate!` MIGRATOR that
applies them. There is no second list of expected columns to drift from
the real one.

  schema >= expected  -> 200 "ok (schema 6)"
  schema <  expected  -> 503, error-level journal line naming both versions
  no migrations       -> 503
  cannot read table   -> 200 "degraded: schema unverified (...)"

A newer schema than expected stays healthy: that is an api rollback under
a migrated database, and this binary's queries are still satisfiable. The
reverse is not.

The degraded case exists because `_sqlx_migrations` is created by
moments_rw and reaches moments_ro through the default privileges in
asset/sql/bootstrap-moments.sql. A role provisioned before those grants
would get 42501, and failing the probe over that would take a working api
offline for a permissions detail. `StoreError` gains an `Inaccessible`
variant so the two are told apart by SQLSTATE (42501, 42P01) rather than
by sniffing message text, and the condition is loud in both the journal
and the probe output.

Also corrected the startup comment in moments-api: it claimed the
api/worker ordering came from systemd dependencies, which cannot be true
across two hosts. It comes from deploy.yml.

Verified against postgres 16 with the production role split replicated
(moments_rw owning the schema, moments_ro granted through
bootstrap-moments.sql, plus a legacy role without those grants) and the
migrations applied by the real worker binary: current schema -> 200 "ok
(schema 6)"; version 6 row deleted -> 503 "schema at 5, this binary
expects 6"; table emptied -> 503 "no migrations applied"; legacy role ->
200 degraded, with `curl -fsS` exiting 0 and printing the reason. First
confirmed that moments_ro can in fact read `_sqlx_migrations` under the
documented grants, so the normal path is the precise one.

Refs #8
2026-08-17 13:10:10 +03:00
7e186f76ae fix(ci): restart the api only after the worker has finished migrating
Some checks failed
deploy / Build prerendered web (push) Blocked by required conditions
deploy / Build api + worker (static musl) (push) Successful in 5m33s
deploy / Deploy moments-worker to frootmig (push) Successful in 18s
deploy / Deploy moments-api to nikola (push) Successful in 22s
deploy / Deploy web to oolon (push) Has been cancelled
The worker owns migrations — it connects as moments_rw, while the api is
SELECT-only and would fail with `permission denied for schema public` if
it tried. Nothing ordered the two deploy jobs, though, and they run
against different hosts (nikola, frootmig), so the systemd ordering the
comment in moments-api/src/main.rs appeals to cannot reach between them.

On the run that shipped the `events.repo` migration, the worker happened
to restart 5 seconds before the api. Reversed, the api would have come up
querying a column that did not exist yet and errored on every request
touching it — and `/v1/healthz` returns a static "ok" without touching
the database, so the deploy's own health probe would have passed it as
fine.

deploy-api now needs deploy-worker. That ordering is only worth anything
if the worker job stays open until migrations are actually done, and it
didn't: the unit is Type=simple, so `systemctl restart` returns once the
process is exec'd and `is-active` is true immediately, neither of which
says anything about migrations. So the job now waits for the worker to
log "worker started", which it does only after awaiting store.migrate().
The search is scoped to the unit's current InvocationID so a previous
start's line cannot satisfy the wait, and it gives up loudly after 120s
or as soon as the process exits.

The wait deliberately avoids `journalctl | grep -q`: a consumer that
exits on first match closes the pipe, journalctl dies of SIGPIPE, and
under `pipefail` the match reads as a failure — which is exactly what the
first draft did, and it would have failed every deploy on a worker that
started perfectly. journalctl --grep does the filtering instead and the
result is tested for emptiness, with no pipe in the pipeline.

Verified by extracting the gate out of the workflow and running it
against fake systemctl/journalctl: started -> exit 0 on the first check;
process gone -> exit 1 immediately with "worker exited before reporting
startup"; no InvocationID -> exit 1 immediately; never logs but stays
active -> loops and fails on timeout. Job graph re-checked acyclic with
every `needs:` resolving to a real job (binaries -> worker -> api -> web).

Closes #8
2026-08-17 13:04:19 +03:00
ef8521b9fb fix(ci): prerender the web bundle after the api/worker deploy, not beside it
Some checks failed
deploy / Build api + worker (static musl) (push) Waiting to run
deploy / Build prerendered web (push) Has been cancelled
deploy / Deploy moments-api to nikola (push) Has been cancelled
deploy / Deploy moments-worker to frootmig (push) Has been cancelled
deploy / Deploy web to oolon (push) Has been cancelled
`build-web` had no `needs:`, so it ran in parallel with the binary build
and both deploy jobs. The prerender fetches VITE_API_BASE at build time,
which means the job baked whatever the api happened to be serving at the
moment it ran — old binary or new, depending on which runner finished
first.

Both outcomes showed up on consecutive runs. Run 68 won by ~25 seconds
and the repo-visibility fix reached the api and the crawler snapshot
together. Run 69 lost: the api served the new activity/summary private
aggregate while the baked /activity/ snapshot had none, so the 15 August
card read "47 changes in 3 repositories" where the api said 54 with 7
private. Browsers hydrate and refetch, so a visitor saw the right page —
but curl, which is what crawlers and AI screeners get, saw the stale one
until refresh.yml was dispatched by hand. Nothing in the pipeline noticed.

deploy-worker is in the needs list alongside deploy-api because the
worker owns migrations: a schema change isn't live until it has
restarted, so an api that depends on one isn't answering correctly before
then either.

The graph is now serial — binaries -> api/worker -> web — which costs the
overlap between the web build and the binary build. Worth it: the
alternative is a published snapshot whose correctness depends on runner
scheduling, failing silently and for a whole day.

Job graph verified acyclic with every `needs:` resolving to a real job.

Closes #8
2026-08-17 12:58:17 +03:00
12e9d4097f feat: count private-repo work in the activity summary
All checks were successful
deploy / Build prerendered web (push) Successful in 4m32s
deploy / Deploy web to oolon (push) Successful in 21s
deploy / Build api + worker (static musl) (push) Successful in 5m32s
deploy / Deploy moments-worker to frootmig (push) Successful in 17s
deploy / Deploy moments-api to nikola (push) Successful in 19s
refresh / Rebuild prerendered web (push) Successful in 4m12s
refresh / Deploy refreshed web to oolon (push) Successful in 36s
The summary cards only ever counted public activity, so a period spent
mostly or entirely in private repos read as near-idle — or vanished
altogether — while the contribution graph directly above it showed that
period as busy. The two views disagreed with no explanation on the page.

`activity/summary` now emits, alongside the named per-repo rows, at most
one row per period with `private = true` and a null source/repo, holding
that period's private-repo change count. `source` and `repo` on
`RepoPeriodCount` become nullable to carry it. The card counts it towards
the period's changes but never towards its repository count (the lump
covers an unknown number of repos), renders it last, unlinked, and
without a language bar — a language mix would narrow the lump back down
to the repo it was hiding.

Not split by forge: the per-period total is already derivable from
`activity/daily`, which counts private activity, so publishing it adds no
information that isn't on the contribution graph already. A per-forge
breakdown would be new. `?source=` does narrow the aggregate, which makes
per-forge counts recoverable by diffing two requests — chosen knowingly,
because a summary that contradicts the filter it was given is worse than
a forge attribution on an unattributed count.

`include_private` keeps its meaning: with it set, the named branch takes
everything and the aggregate is empty, so a future authenticated view
sees repos rather than a lump.

Verified against postgres 16 with seeded public/private history: the
summary now reconciles with `activity/daily` per period (3 public + 7
private = the graph's 10), a private-only period appears at all where it
previously did not, no aggregate row is emitted for a period with nothing
private, and the row sorts last within its period. Prerendered `/activity`
against that data renders "8 changes in 1 repository + private work"
with a muted `private 6` row, "4 changes in private repositories" for the
private-only day, and contains zero occurrences of the private repo's
name in either the markup or the dehydrated query cache.

Closes #7
2026-08-15 20:31:40 +03:00
815bfa7deb fix: reconcile repo visibility instead of trusting the ingest-time flag
All checks were successful
deploy / Build api + worker (static musl) (push) Successful in 5m24s
deploy / Deploy moments-worker to frootmig (push) Successful in 17s
deploy / Deploy moments-api to nikola (push) Successful in 24s
deploy / Build prerendered web (push) Successful in 4m36s
deploy / Deploy web to oolon (push) Successful in 23s
`events.public` was decided once, when a row was ingested, from whatever
the forge reported at that moment — and every poller is incremental (the
github events feed caps at 90 days, search at its top-1000 window, the
per-repo scanner at a `since` cursor, the gitea feed at page 1 after the
first run). Nothing ever revisited a repo, so flipping one to private
upstream only relabelled whatever activity happened afterwards: its
history kept serving commit messages, issue titles and the repo name
indefinitely. The reverse flip was equally frozen.

The github and gitea sources now run a reconciliation pass before
ingesting. github reuses its existing repo discovery, which already
re-reads `private`/`isPrivate` for everything reachable, and only spends
a request on repos we're still exposing that discovery didn't return;
gitea has no equivalent bulk endpoint, so it asks per repo, once a day
rather than once a tick. Repos already hidden are skipped — they can't
leak, and staying hidden is the safe direction to err in. A 404 counts
as private (with the user's own token, a repo still in reach answers 200
even when private, so 404 means gone or transferred), while rate limits
and transient errors flip nothing; the pass runs in both directions, so
a spurious hide is undone by the next successful poll.

That needs a repo key the worker can UPDATE against, hence `events.repo`
— a stored generated column, and now the single definition of the
payload -> repo mapping that list_events, list_projects,
activity_summary and language_daily_counts each carried their own copy
of. Consolidating them fixes an attribution gap along the way:
/search/issues items carry neither `repo.name` nor
`repository.full_name`, only `repository_url`, so every issue and PR
backfilled through search resolved to NULL in all four queries. Those
events now attach to their repo, which both makes them reconcilable and
means they show up in /projects and /activity/summary.

Also closes a leak that predates the flip problem: `/v1/languages/repos`
had no visibility gate at all, and repo_languages is populated for every
repo the worker discovers, private ones included. Repo names were on the
wire (and baked into the prerendered HTML via the dehydrated query
cache) regardless of what `events.public` said. Rather than a second
visibility column to keep in sync, the response now derives it — a
repo's languages are exposed exactly when at least one of its events is.

Verified against postgres 16: the generated column extracts every
payload shape the four sources produce (and NULLs a non-github
`repository_url`), the reconciliation UPDATE flips all three github
event shapes for a repo in one statement and is a no-op on re-run, the
gitea host filter excludes other hosts while treating rows predating the
`_host` stamp as local, and /v1/languages/repos drops a repo once its
events go private.

Closes #6
2026-08-15 19:41:33 +03:00
8797cc7a47 feat(web): forge icons + language bars in activity summary cards
All checks were successful
deploy / Build api + worker (static musl) (push) Successful in 6m6s
deploy / Build prerendered web (push) Successful in 7m50s
deploy / Deploy moments-worker to frootmig (push) Successful in 17s
deploy / Deploy web to oolon (push) Successful in 23s
deploy / Deploy moments-api to nikola (push) Successful in 30s
refresh / Rebuild prerendered web (push) Successful in 8m11s
refresh / Deploy refreshed web to oolon (push) Successful in 22s
Summary rows now use the dash cards' visual language: a 3-column grid
of forge icon + repo link, the repo's language distribution bar, and a
right-aligned change count — filling the dead space between name and
count. Sources without an icon (bugzilla) keep the text label.

github.svg and mozilla.svg are white-filled for the dark dash cards
and vanished on the white timeline cards, so forgeIcon() is now
background-aware and serves new dark-filled variants on light
surfaces. The previously copy-pasted forgeIcon in DashPage and
ProjectPage moved to a shared lib/forge.ts, and DashPage's
repo-languages shaping became the shared useRepoLanguages() hook used
by both dash and timeline. The /activity prerender prefetches
repo-languages so icons and bars bake into the crawler snapshot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LY4nbfHC9qt726gVrdAh3X
2026-08-03 11:10:16 +03:00
c66ce33bd9 feat(web): fixed summary windows with a paging range navigator
All checks were successful
deploy / Build prerendered web (push) Successful in 8m5s
deploy / Build api + worker (static musl) (push) Successful in 5m46s
deploy / Deploy moments-api to nikola (push) Successful in 24s
deploy / Deploy web to oolon (push) Successful in 28s
deploy / Deploy moments-worker to frootmig (push) Successful in 17s
refresh / Rebuild prerendered web (push) Successful in 7m41s
refresh / Deploy refreshed web to oolon (push) Successful in 22s
Summary mode no longer exposes the two-thumb start/end slider — each
bucket now has a fixed window (daily: 30d, weekly: 91d, monthly: 365d,
yearly: 5y) and a single-handle slider with ‹/› pagers that moves the
window through time one span at a time, keeping its length constant.
Bucket switches stay anchored to the window's end. The per-event
drill-down view keeps the adjustable range slider and activity limit.

Bounded windows also cap the scan the summary query asks of postgres,
where the old view's arbitrary ranges did not.

The prerender shares the new summaryRange helper so the baked daily
key stays byte-identical to the client's first render.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LY4nbfHC9qt726gVrdAh3X
2026-08-02 11:38:39 +03:00
957770257c feat: summarise /activity by period with per-repo change counts
All checks were successful
deploy / Build api + worker (static musl) (push) Successful in 5m31s
deploy / Deploy moments-worker to frootmig (push) Successful in 20s
deploy / Deploy moments-api to nikola (push) Successful in 25s
deploy / Build prerendered web (push) Successful in 8m48s
deploy / Deploy web to oolon (push) Successful in 27s
The bare /activity route (no timespan modifier) now shows one timeline
card per period — each listing the repos touched and the number of
changes in each — with a daily/weekly/monthly/yearly bucket selector,
so the view answers "where did my time go" at a glance. Each card
drills down to the existing per-event view via /activity/from..to,
which is unchanged.

Backed by a new GET /v1/activity/summary?from&to&bucket&source
endpoint: public events only (private repo names never leak), bucketed
with date_trunc on the UTC-shifted timestamp, reusing the same
per-source repo extraction as /v1/projects. Blog events carry no repo
and are excluded.

The /activity prerender now bakes the daily summary instead of the
event list. On the first deploy the live API won't have the endpoint
yet when build-web prerenders; that route degrades to the client-side
fallback and self-heals on the daily refresh re-bake.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LY4nbfHC9qt726gVrdAh3X
2026-08-02 11:04:21 +03:00
9905cef1d2 Merge pull request 'fix(ci): share one nginx-vhost renderer; drop unused deploy.sh' (#5) from fix/render-drift-remove-deploy-sh into main
All checks were successful
deploy / Build prerendered web (push) Successful in 8m43s
deploy / Deploy web to oolon (push) Successful in 17s
deploy / Build api + worker (static musl) (push) Successful in 6m12s
deploy / Deploy moments-worker to frootmig (push) Successful in 17s
deploy / Deploy moments-api to nikola (push) Successful in 29s
refresh / Rebuild prerendered web (push) Successful in 8m23s
refresh / Deploy refreshed web to oolon (push) Successful in 29s
2026-07-26 12:54:48 +00:00
3260bfb35b fix(ci): share one nginx-vhost renderer; drop unused deploy.sh
The nightly refresh.yml and deploy.yml each substituted asset/nginx/
site.conf.tmpl with their own inline python. When bb2f5b1 templated the
listen line as {{WEB_LISTEN}} (moving the vhost behind oolon's stream SNI
router), it added the substitution to the template and deploy.yml but not
to refresh.yml. The daily refresh then rsynced a literal
`listen {{WEB_LISTEN}};` into /etc/nginx/conf.d/rob.tn.conf, `nginx -t`
failed for the whole edge, and — because the file is written into the live
conf.d before it is tested — every vhost's reload (including the step@
cert renewals) stayed frozen. Internal vhosts, cichlid.internal among
them, served certs that had expired days earlier while the renewed certs
sat unused on disk.

- Replace both inline renderers with script/render-site-conf.py, shared by
  deploy.yml and refresh.yml so they cannot drift on what they substitute.
- Guard rails: the renderer fails if any {{PLACEHOLDER}} lacks an env value
  or survives substitution, so a forgotten/misnamed variable is a red build
  on the runner instead of a broken vhost on the edge.
- Add the missing WEB_LISTEN to refresh.yml's env (the immediate drift).
- Rename the template's {{DOCROOT}} to {{WEB_ROOT}} so every placeholder
  maps to the env var of the same name.
- Remove script/deploy.sh: the third, unused renderer of the same template
  (superseded by the Actions workflows) and a standing source of drift.
- Docs (readme, CLAUDE.md) updated to the Actions-only deploy path.

Known follow-up (needs a sudoers change + infra-setup re-run on oolon, so
out of scope here): the rendered vhost is still rsynced straight into the
live conf.d and only then `nginx -t`'d, so a valid-but-wrong config could
still wedge nginx. Stage-validate-swap with rollback would close that.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsH1rcWQYtRVhvaftiKm22
2026-07-26 15:52:18 +03:00
bb2f5b1f9b fix(web): stop binding 443 behind the edge SNI router; verify the reload
Some checks failed
deploy / Build prerendered web (push) Successful in 7m14s
deploy / Deploy web to oolon (push) Successful in 18s
deploy / Build api + worker (static musl) (push) Successful in 5m41s
deploy / Deploy moments-worker to frootmig (push) Successful in 19s
deploy / Deploy moments-api to nikola (push) Successful in 24s
refresh / Rebuild prerendered web (push) Successful in 7m15s
refresh / Deploy refreshed web to oolon (push) Failing after 26s
oolon's TCP 443 belongs to the stream SNI router, which ssl_prereads the
handshake and forwards to the local https tier on 127.0.0.1:14443 with
PROXY protocol. site.conf.tmpl predates that and still bound 443 itself,
so every deploy and every daily refresh rsynced a vhost that collides
with the router.

Nothing in the pipeline caught it. `nginx -t` only detects duplicate
listeners within a context, not across http{} and stream{}, and
`systemctl reload` merely sends SIGHUP, so it exits 0 while nginx logs
"bind() to 0.0.0.0:443 failed (98: Address already in use) ... still
could not bind()", aborts the reconfiguration and keeps its old cycle.
The deploy went green while oolon's running config was frozen. It stayed
frozen for a day, stranding every cert the step@ timers renewed on disk
until eleven internal vhosts were serving expired certs. A cold start
would have failed outright, taking the whole public edge down.

Template the listen line from WEB_LISTEN (manifest web.config.listen for
script/deploy.sh, which renders the same template), and assert that the
reload landed by requiring a fresh worker generation, dumping the nginx
error log when it did not.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0182wzZE8DguMPWhxD21gfP2
2026-07-21 11:42:39 +03:00
3d7cbd90d6 fix(infra): own pg_ident mappings in an app-specific moments.conf
Some checks failed
deploy / Build prerendered web (push) Successful in 6m59s
deploy / Build api + worker (static musl) (push) Failing after 11m22s
deploy / Deploy web to oolon (push) Successful in 28s
deploy / Deploy moments-api to nikola (push) Has been skipped
deploy / Deploy moments-worker to frootmig (push) Has been skipped
refresh / Rebuild prerendered web (push) Successful in 7m3s
refresh / Deploy refreshed web to oolon (push) Successful in 18s
Write the cert_cn -> role mappings to pg_ident.conf.d/moments.conf
instead of per-host <cert_cn>.conf files, so provisioning for other
apps that own drop-ins for the same hosts can never clobber or drop
moments access. db-perms.sh now also migrates any moments_ro/rw lines
found in legacy host-named files into moments.conf verbatim (deleting
legacy files left empty), corrects the standby hostname to
frankie.hanzalova.internal, and warns-and-continues on an unreachable
pg host instead of aborting the run.

Closes #3

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dcsi987fK5ftZEP6w3HXEd
2026-07-02 09:29:00 +03:00
fd33acd33a feat(web): enable gzip in the nginx vhost
Some checks failed
deploy / Build api + worker (static musl) (push) Successful in 6m9s
deploy / Build prerendered web (push) Successful in 7m32s
deploy / Deploy moments-api to nikola (push) Successful in 56s
deploy / Deploy web to oolon (push) Successful in 54s
deploy / Deploy moments-worker to frootmig (push) Successful in 57s
refresh / Rebuild prerendered web (push) Successful in 6m59s
refresh / Deploy refreshed web to oolon (push) Has been cancelled
The prerendered pages are large (the dashboard bakes the full all-time
activity dataset, ~900 KB), so compress text responses on the wire. text/html
is always compressed when gzip is on; the listed gzip_types also cover the JS
bundle, CSS, SVG icons, and (via gzip_proxied any) the JSON from the /api/
upstream. The home page drops to ~90 KB on the wire. woff2 is pre-compressed
and intentionally omitted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7zF7Kf4JqDwa6M8Qgge9M
2026-06-25 15:52:43 +03:00
5e4cf21e2d ci: prerender against the internal mesh API + fail loud if empty
All checks were successful
deploy / Build api + worker (static musl) (push) Successful in 6m11s
deploy / Build prerendered web (push) Successful in 7m28s
deploy / Deploy web to oolon (push) Successful in 55s
deploy / Deploy moments-api to nikola (push) Successful in 59s
deploy / Deploy moments-worker to frootmig (push) Successful in 1m1s
The fedora-44 runner reaches the public internet (the CV gist on
api.github.com prerendered fine) but not the public rob.tn — split-horizon
DNS — so every moments-API-sourced route baked empty loading states while the
build still "succeeded". prefetchQuery swallows fetch errors, so the empty
snapshot shipped silently.

- Point VITE_API_BASE at the internal API the deploy already reaches
  (http://nikola.kosherinata.internal:42424/v1), verified reachable over the
  mesh. Only affects the SSR/prerender build; the browser bundle still uses the
  relative /api/v1.
- run-prerender.mjs now aborts (exit 1) if zero dynamic routes are enumerated,
  i.e. the API was unreachable — so an empty snapshot can never deploy again.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7zF7Kf4JqDwa6M8Qgge9M
2026-06-25 14:14:11 +03:00
ba96cddebc ci: install web deps with --ignore-scripts + explicit rebuild
All checks were successful
deploy / Build prerendered web (push) Successful in 2m53s
deploy / Deploy web to oolon (push) Successful in 58s
deploy / Build api + worker (static musl) (push) Successful in 6m10s
deploy / Deploy moments-api to nikola (push) Successful in 55s
deploy / Deploy moments-worker to frootmig (push) Successful in 55s
CI's pnpm did not honor the build-script allowlist from package.json (removed
in pnpm 10) nor from pnpm-workspace.yaml under `pnpm --dir ui`, so build-web
kept failing with ERR_PNPM_IGNORED_BUILDS. Make it version- and discovery-
independent: run in ui/ via working-directory, install with --ignore-scripts
(no approval gate), then `pnpm rebuild @swc/core esbuild` to place the native
binaries vite needs. Verified locally: cold install + rebuild + vite build all
succeed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7zF7Kf4JqDwa6M8Qgge9M
2026-06-25 14:00:47 +03:00
8e11d02e90 ci: move pnpm build-script allowlist to pnpm-workspace.yaml
Some checks failed
deploy / Build prerendered web (push) Failing after 2m13s
deploy / Deploy web to oolon (push) Has been skipped
deploy / Build api + worker (static musl) (push) Successful in 6m35s
deploy / Deploy moments-worker to frootmig (push) Successful in 54s
deploy / Deploy moments-api to nikola (push) Successful in 56s
CI's newer pnpm warns "the pnpm field in package.json is no longer read"
and still failed build-web with ERR_PNPM_IGNORED_BUILDS. The settings moved
to pnpm-workspace.yaml in pnpm 10+. Put onlyBuiltDependencies (esbuild,
@swc/core) and ignoredBuiltDependencies (react-vertical-timeline-component)
there and drop the dead package.json field. Verified with a cold
`pnpm install --frozen-lockfile`: postinstalls run, no error, lockfile
unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7zF7Kf4JqDwa6M8Qgge9M
2026-06-25 13:47:59 +03:00
163a36fa08 ci: allow esbuild/@swc native build scripts under pnpm 10
Some checks failed
deploy / Build prerendered web (push) Failing after 2m10s
deploy / Deploy web to oolon (push) Has been skipped
deploy / Build api + worker (static musl) (push) Successful in 6m45s
deploy / Deploy moments-worker to frootmig (push) Successful in 58s
deploy / Deploy moments-api to nikola (push) Successful in 1m1s
The fedora-44 runner's pnpm 10 blocks dependency build scripts by default,
so `pnpm install` failed the web build with ERR_PNPM_IGNORED_BUILDS. esbuild
and @swc/core need their postinstall to place native binaries for vite; allow
them via pnpm.onlyBuiltDependencies, and explicitly ignore the harmless
react-vertical-timeline-component postinstall. Does not change the lockfile.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7zF7Kf4JqDwa6M8Qgge9M
2026-06-25 13:35:00 +03:00
ed5acd9f4e ci: split build across rust + fedora runners, deploy on fedora-44
Some checks failed
deploy / Build api + worker (static musl) (push) Successful in 7m57s
deploy / Deploy moments-worker to frootmig (push) Successful in 1m1s
deploy / Deploy moments-api to nikola (push) Successful in 1m6s
deploy / Build prerendered web (push) Failing after 2m1s
deploy / Deploy web to oolon (push) Has been skipped
The `rust` runner image has cargo + musl but no node/pnpm, so the web build
(and the previous npm-install workaround) can't run there. The fedora runner
images bake in node + pnpm + rsync. Split the build:
- build-binaries on `rust`  (cargo musl + lint/test gate)
- build-web      on `fedora-44` (pnpm install + prerender, no install step)
Deploy jobs move to `fedora-44` (has rsync/ssh/pnpm/ca-trust) and depend on
the relevant build job.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7zF7Kf4JqDwa6M8Qgge9M
2026-06-25 13:19:57 +03:00
a7750def17 ci: install pnpm via npm (gongfoo rust image lacks corepack)
Some checks failed
deploy / Build api + worker + web (push) Waiting to run
deploy / Deploy moments-api to nikola (push) Has been cancelled
deploy / Deploy moments-worker to frootmig (push) Has been cancelled
deploy / Deploy web to oolon (push) Has been cancelled
The build job failed with `corepack: command not found`: Fedora's nodejs
package (gongfoo runner-rust image) ships node + npm but not corepack. The
job runs as root, so install pnpm globally via npm instead. Longer term,
bake pnpm into the runner image to drop this step.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7zF7Kf4JqDwa6M8Qgge9M
2026-06-25 13:13:59 +03:00
3761333ac4 fix: make the workspace pass the CI lint/test gate
Some checks failed
deploy / Build api + worker + web (push) Failing after 5m59s
deploy / Deploy moments-api to nikola (push) Has been skipped
deploy / Deploy moments-worker to frootmig (push) Has been skipped
deploy / Deploy web to oolon (push) Has been skipped
The new Gitea Actions build gate runs `cargo fmt --check`, `clippy -D warnings`,
and `cargo test` — stricter than the old deploy.sh, which only `cargo build`d.
That surfaced pre-existing drift that never compiled under the test/clippy
profile:

- apply rustfmt across the workspace (formatting only, no logic changes)
- moments-data: add the missing `prune_events` to the test-only `NoopWriter`
  stub (the EventWriter trait gained it with the blog-prune feature; a plain
  `cargo build` never compiles the `#[cfg(test)]` stub, so it went stale)
- moments-api: `.max().min()` -> `.clamp()`, and build `usvg::Options` with
  struct-update syntax instead of post-Default field assignment

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7zF7Kf4JqDwa6M8Qgge9M
2026-06-25 13:00:40 +03:00
1b753f991f feat: prerender every route + Gitea Actions deploy
Some checks failed
deploy / Build api + worker + web (push) Failing after 53s
deploy / Deploy moments-api to nikola (push) Has been skipped
deploy / Deploy moments-worker to frootmig (push) Has been skipped
deploy / Deploy web to oolon (push) Has been skipped
Make the site fully prerendered so a plain curl returns complete content
for every route (crawlers / AI screening tools see real text, not an empty
#root), while humans keep full client interactivity.

Prerender:
- Build-time per-route render: prefetch data, renderToString, inline the
  dehydrated react-query cache as window.__RQ_STATE__; client hydrateRoots
  and refetches live (activity stays fresh; crawlers get the baked snapshot).
- New entry-server.tsx + prerender/{prefetch,routes,meta}.ts + run-prerender.mjs;
  shared lib/ranges.ts keeps SSR and client query keys identical.
- pnpm build now: tsc -b -> vite client build -> ssr build -> prerender.
- API base absolute at build (VITE_API_BASE), relative /api/v1 in the browser.
- CSS imports moved to the client entry so the tree imports under Node.
- schema.org Person + Occupation JSON-LD and per-route title/description/og.
- UTC + explicit field widths on shared date formatting so SSR and client
  hydration match byte-for-byte (fixes hydration mismatch on /activity).
- Strip non-text gist content from the CV fetch (1MB -> 25KB gzipped page).

Deploy (Gitea Actions, replaces script/deploy.sh):
- deploy.yml: on push to main, lint/test gate, build api+worker as static
  musl binaries (pure-rustls, no glibc skew) + prerendered web, deploy each
  over SSH as gitea_ci with scoped sudo.
- refresh.yml: daily cron re-bakes only the web snapshot so gist/activity
  edits propagate without a push or bouncing the api/worker.
- script/infra-setup.sh + asset/sudoers.d/{api,worker,web}-host.conf for
  one-time per-host provisioning. Secrets: RSYNC_SSH_KEY, QUERY_GITHUB_TOKEN,
  QUERY_GITEA_TOKEN.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7zF7Kf4JqDwa6M8Qgge9M
2026-06-25 12:53:46 +03:00
70b4b265c3 style(blog): scale down in-content post headings
markdown section headings (## etc.) rendered at bootstrap defaults,
making them as large as the post title. size .blog-post h1-h4 down to
1.4/1.25/1.1/1rem so sections read as subordinate to the title.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 10:15:46 +03:00
908ab33bd2 style(blog): smaller list headers, readable date color
list-view post titles were h3 at the bootstrap default, large enough
to wrap; size them to 1.4rem. dates used bootstrap's text-muted, whose
dark grey contrasts poorly on the dark background — replace with a
blog-date class matching the site's opacity-based muted-text pattern,
on the list and the detail page.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 10:14:02 +03:00
37c44906bb feat(blog): prune posts removed or renamed upstream
the blog repo is the source of truth for the full set of posts, but
upserts alone never delete: removing a file or changing a slug or
filename left the old row serving forever. each poll now reconciles —
after upserting the current tree, events under source='blog' whose id
is not in the parsed set are deleted via a new EventWriter::prune_events
port. nothing is lost: git still has every post, and restoring or
fixing a file re-ingests it on the next tip change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 22:56:54 +03:00
cd3dc2d82d fix(ui): add @types/node for process.env in vite.config
tsc -b type-checks vite.config.ts during the production build (pnpm
lint does not), and the API_PROXY_TARGET override added there needs
node types for `process`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 22:48:26 +03:00
88ce993df3 feat(blog): add markdown blog sourced from a gitea repo
posts are markdown files with yaml frontmatter (title, slug, date;
optional draft/public) in the grenade/blog repo. the worker's new
BlogSource polls the repo — one branch-tip request when nothing
changed — and upserts posts into events with source='blog' and
occurred_at from the frontmatter date, so imported posts keep their
original publish dates and backfill the contribution graph.

- new /v1/blog and /v1/blog/{slug} endpoints over the existing
  EventReader port; drafts stay hidden via the public gate
- new /blog and /blog/:slug routes, nav link, activity-feed entry
  with post icon and filter toggle; relative image srcs resolve to
  gitea raw urls
- shared Markdown component extracted from ProjectPage
- vite proxy target overridable via API_PROXY_TARGET for local dev

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 22:44:56 +03:00
2821548e6e feat(ui): add avg-by-hour panel to dashboard stats
Complements the existing avg-by-weekday chart with its orthogonal
partner: which hour of the day the user typically commits. The api
buckets events by EXTRACT(hour FROM occurred_at AT TIME ZONE $tz) so
the chart matches the clock the user sees rather than UTC; the UI
passes the browser's resolved IANA timezone. Renders as 24 mini-bars
below the weekday chart with labels every 4 hours and per-bar
tooltips showing the average events/day at that hour.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 16:34:17 +03:00
72eeb547af chore(deploy): self-heal /tmp perms before staging
frootmig periodically has its /tmp reset from the standard sticky-
world-writable 1777 to root-owned 0755 (cause not yet pinned down),
which breaks the unprivileged rsync of the deploy stage dir and
surfaces as a cryptic "Permission denied" plus a follow-on install
failure. Stat /tmp before each rsync and, if the mode is off, sudo
chmod it back to 1777 — visible in the deploy log so it's obvious
which host keeps drifting.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 07:59:06 +03:00
86411bb88e fix(worker): dedup gitea events from overlapping user and org feeds
Gitea writes one Action row per interested user-context. A push to an
org repo by user U produces two rows — one with user_id=U, one with
user_id=org — differing only in `id` and `user_id`. Polling both the
user feed and org feeds (which we do, and need to, since neither alone
catches every cross-namespace event) surfaced both rows; the
`gitea:{action_row_id}` id gave them distinct ids, so the upsert dedup
never fired and ~38% of events on org-repo project pages rendered
twice. Switch to a content-derived id keyed on (op_type, act_user_id,
repo_id, ref_name, comment_id, created) so the two rows collide on
upsert, and add a migration that re-keys existing rows to the same
formula while collapsing the duplicates already in the table.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 07:53:43 +03:00
acb061baca chore(deploy): build rust binaries in a podman container
Workstation runs Fedora 44 (glibc 2.43); servers are still on F42 and
F43. A native release build produces ELFs the older glibc can't load
(GLIBC_2.43 not found), and the api/worker units fail-loop on every
deploy. Build inside docker.io/library/rust:1-bookworm (glibc 2.36)
so the artifacts are forward-compatible with every Fedora target.
Output goes to target/deploy/ to keep separate from native dev
builds, and the cargo registry/git index are cached in named podman
volumes so subsequent builds are incremental. podman is a hard
requirement; no docker fallback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 16:05:13 +03:00
8a7177a54a feat(ui): render GFM and embedded HTML in project READMEs
ReactMarkdown was running with no plugins, so README headers full of
raw <div align=center>, tables, <details>/<summary>, and other GFM
markup rendered as escaped text. Wire in remark-gfm for tables and
GFM features, rehype-raw for embedded HTML, and rehype-sanitize with
an extended schema that permits README-typical tags and attributes
(align, target, width/height, picture/source, etc.) while still
blocking script/iframe/object — READMEs come from external repos so
they need adversarial-input handling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 16:05:05 +03:00
818a535903 feat(worker): capture commits on non-default branches and forks
The ingestion paths each had a gap that let non-default-branch work
slip through: /search/commits silently excludes forks, the per-repo
REST commit scan only walked the default branch, and the user events
feed ages out after 90 days. Catch them by enumerating branches per
repo and scanning each (with per-branch state cursors so a brand-new
branch isn't cut off by the default branch's cursor), pre-filtering
branches via a GraphQL HEAD-author check so big upstream forks like
azure-docs don't trigger hundreds of wasted REST calls, treating
GitHub's HTTP 500 on author-filtered empty branches as "no commits"
rather than a server error, and adding fork:true to the search query.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 16:04:58 +03:00
9a8c0955b5 chore: phrasing 2026-05-12 13:20:11 +03:00
25eab2d795 feat: add robots.txt allowing all crawlers including social bots
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-11 16:49:55 +03:00
2130032d46 chore: update Cargo.lock for fontdb dependency
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-11 16:35:08 +03:00
92a66422ab feat(ui): add meta description, og:locale, and og:site_name
Adds the standard HTML meta description (for SEO), og:locale, and
og:site_name tags flagged by Open Graph validators.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-11 16:32:33 +03:00
94b6fbe42d feat(ui): add og:logo meta tag pointing to 512px icon
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-11 16:29:40 +03:00
048646a7c1 feat(ui): add og:url meta tag for canonical URL
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-11 16:27:47 +03:00
1f2fea3427 fix: load system fonts for OG image text rendering
usvg's default Options creates an empty fontdb, so no fonts are found
for text rendering regardless of what's installed. Load system fonts
into a fontdb::Database and set the default font family to Noto Sans.

Also picks up a formatting change to index.html from a linter.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-11 16:17:17 +03:00
d539892b70 fix: scale OG contribution graph to fill 1200x630 canvas
Compute cell size from available width so the graph fills the canvas
instead of rendering at a fixed small size. Scale year labels
proportionally. Position headline and subtitle at the top with the
graph centered in the remaining space.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-11 16:11:05 +03:00
a57682e610 feat: improve OG image and meta tags for social sharing
- Resize OG image from ~676x216 to 1200x630 (recommended size)
- Add "rob thijssen" headline text overlay to the OG image
- Center the contribution graph within the canvas
- Expand og:title to 55 chars and og:description to 148 chars
  to meet social platform optimal lengths

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-11 16:08:29 +03:00
22c80fd7af feat(ui): transpose weekday averages to vertical bar chart
Show days on the X axis and volume on the Y axis, replacing the
horizontal bar layout with vertical bars for a more natural
time-series reading direction.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-11 16:02:30 +03:00
8b5656ef26 fix: specify sans-serif font in OG image SVG text elements
The SVG text elements had no font-family, causing usvg to default to
Times New Roman which isn't installed on the server. Specifying
sans-serif uses the system default and silences the warnings.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-11 15:56:24 +03:00
dd1de38b2f feat(ui): show more languages in top languages chart
Increase from 10 to 14 rows so languages visible in the contribution
graph (e.g. Svelte, C++) also appear in the legend.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-11 15:52:33 +03:00
283b2126c0 feat(ui): color contribution graph circles by dominant language
Replace fixed green palette with per-period dominant language colors.
Each circle's hue reflects the language with the most commits for that
day (last-year graph) or month (all-time graph), with opacity scaled
by volume quartile. Language data comes from the existing language
daily counts endpoint.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-11 15:50:19 +03:00
e8dcb5fcaf feat(ui): show private activity count on timeline when no public events
When viewing a date range with zero public activities, the status line
now shows the count of private contributions (derived from daily counts
which include private repos). Helps explain gaps in the public timeline.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-11 15:41:22 +03:00
b41e8c330a feat: include private repo contributions in graph metrics
Aggregate graph endpoints (daily counts, language daily counts, source
summaries, OG image) now include private repository activity. These
endpoints only expose numeric counts — no commit messages, repo names,
or other metadata — so private details remain hidden. The activity
timeline continues to serve only public events.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-11 15:35:22 +03:00
f386e0b574 feat(ui): reshape all-time graph and add dashboard stats panels
Transpose AllTimeGraph to show years on X axis and months on Y axis
instead of year-per-row with weekly columns. Add TopLanguages bar chart
(all-time code volume by language) and ContributionStats panel (current
and longest streaks, busiest day, active days, weekday averages) in a
three-column row matching the project card grid.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-11 15:27:39 +03:00
111a2af573 feat(ui): language distribution bar on project cards
Extract LanguageBar into a shared component used by both DashPage
(compact, bar only) and ProjectPage (full, with percentage labels).
Remove redundant forge source text from project cards since the
forge icon already indicates it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-06 07:13:41 +03:00