Stats Submission
Internal ops runbook. Not user-facing.
What this covers
How a school’s stats.json travels from the submission form into a merge request and
eventually gets merged and released. Written after a “Failed to create commit” incident that
traced back to missing fine-grained PAT permissions on STATS_COMMIT_TOKEN.
The real flow (browser-based)
The live submission path is not a CI job — it is the form rendered by the stats-submit
shortcode (layouts/shortcodes/stats-submit.html). When a user submits:
- The browser computes the reporting Friday and the
stats-weekly-YYYY-Wwwbranch name (mirroringscripts/rotate_stats.py). - JS POSTs to GitLab
POST /projects/techlit-africa%2Fhelp/repository/commits, creating the branch plusstats.json/<date>/<slug>.json(or switches the action toupdateif the file already exists). - JS POSTs to
POST /projects/.../merge_requeststo open the MR againstmain. - On Friday, the
friday-automergescheduled job merges the newest openstats-weekly-*MR. - Merging to
maintriggerspages, which runsbin/releaseand publishes the site.
scripts/submit_stats.shand asubmit-statsCI job are described in AGENTS.md but do not exist in.gitlab-ci.yml. That path is stale — do not rely on it.
Tokens
There is exactly one token that matters: STATS_COMMIT_TOKEN.
- It is baked into the public page at build time via
{{ os.Getenv "STATS_COMMIT_TOKEN" }}in the submit shortcode, and the browser uses it asPRIVATE-TOKEN. - It is also used by
friday-automerge(.gitlab-ci.yml) asPRIVATE-TOKENto merge the MR. - (It was historically also used by
submit_stats.shforgit clone, but that job is gone.)
STATS_TRIGGER_TOKEN was previously listed in the Hugo security.exec allowlist and in AGENTS.md
but is unused anywhere in code or CI. It has been removed — do not recreate it.
Required permissions (the root cause of “Failed to create commit”)
STATS_COMMIT_TOKEN must be a fine-grained personal access token (or a project access token)
granted, on the techlit-africa/help project:
- Repository → Create — covers
POST /repository/commits - Merge Request → Read, Create, Merge — covers MR creation in the form and MR merge in
friday-automerge
Fine-grained PATs have no global api scope — each endpoint maps to a resource + action, so
you must add the help project as a resource and enable the permissions above. A
write_repository-only token cannot call the REST API at all (insufficient_granular_scope).
Security caveat
Because the token is embedded in public HTML (View Source), anyone can extract it. Prefer a
project access token scoped to help over a personal PAT, so a leak is limited to one project.
Never grant broader permissions than needed.
After changing the token
- Editing permissions on an existing token does not change its value, so the already deployed page keeps working — no rebuild needed.
- Rotating / regenerating the token does change its value, so you must rebuild and redeploy or the live page keeps submitting with the dead old token.
Debugging “Failed to create commit”
This message is a generic fallback in stats-submit.html. The retry loop only handles two specific
GitLab errors (branch already exists, A file with this name already exists) and throws
“Failed to create commit” for everything else, discarding the real message. Real causes:
insufficient_granular_scope— token lacks the permissions listed above (the incident cause).- 401 / 403 — token invalid, expired, rotated-but-not-rebuilt, or wrong scope.
- 404 — wrong project path (shouldn’t happen; the path is
techlit-africa%2Fhelp). - 429 — rate limited.
Get the real error
Run from a shell with the token exported:
curl -s "https://gitlab.com/api/v4/personal_access_tokens/self" \
--header "PRIVATE-TOKEN: $STATS_COMMIT_TOKEN"401 Unauthorized→ token is empty / expired / revoked.{"error":"insufficient_granular_scope",...}→ missing permission (see above).- A JSON object with
scopes/permissions→ token is fine; the failure is elsewhere.
Directly test the commit permission
BR="stats-test-$(date +%s)"
curl -s -w "\nHTTP %{http_code}\n" -X POST \
"https://gitlab.com/api/v4/projects/techlit-africa%2Fhelp/repository/commits" \
--header "PRIVATE-TOKEN: $STATS_COMMIT_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"branch\":\"$BR\",\"start_branch\":\"main\",\"commit_message\":\"test: perm check\",\"actions\":[{\"action\":\"create\",\"file_path\":\"stats.json/99-99-9999/_permtest.json\",\"content\":\"{}\"}]}"
# clean up the test branch afterward:
curl -s -X DELETE \
"https://gitlab.com/api/v4/projects/techlit-africa%2Fhelp/repository/branches/$BR" \
--header "PRIVATE-TOKEN: $STATS_COMMIT_TOKEN"A 201 proves the token and permissions are correct.
Testing locally (before pushing)
Workflow: export STATS_COMMIT_TOKEN=..., run just server, then confirm (a) the site builds and
the doc renders, and (b) a submission reaches GitLab. The form hits the real help repo and opens
a real MR, so clean up the test branch afterward.
What can go wrong:
- Empty token in the form → 401. The token is read at build time, so it must be exported
before
just serverstarts. - Form does nothing after 7pm Fri / weekends (Africa/Nairobi) → that is the client-side lockout, not an error.
- Build fails / page missing → usually a bad
hugo.tomledit; runhugo configto validate. - Still “Failed to create commit” → token lacks
Repository: Create/Merge Request: Create, Merge, or it was rotated without a rebuild (live page still ships the old value).
Open follow-ups (not done yet)
- Surface the real GitLab error in the form instead of “Failed to create commit”.
- Migrate
STATS_COMMIT_TOKENto a project access token. - Consider moving the token out of public HTML via a small proxy.