Skip to content
Check out our first blog! Read the Git & GitLab Onboarding Guide
Dev
Stats Submission

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:

  1. The browser computes the reporting Friday and the stats-weekly-YYYY-Www branch name (mirroring scripts/rotate_stats.py).
  2. JS POSTs to GitLab POST /projects/techlit-africa%2Fhelp/repository/commits, creating the branch plus stats.json/<date>/<slug>.json (or switches the action to update if the file already exists).
  3. JS POSTs to POST /projects/.../merge_requests to open the MR against main.
  4. On Friday, the friday-automerge scheduled job merges the newest open stats-weekly-* MR.
  5. Merging to main triggers pages, which runs bin/release and publishes the site.

scripts/submit_stats.sh and a submit-stats CI 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 as PRIVATE-TOKEN.
  • It is also used by friday-automerge (.gitlab-ci.yml) as PRIVATE-TOKEN to merge the MR.
  • (It was historically also used by submit_stats.sh for git 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 server starts.
  • 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.toml edit; run hugo config to 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_TOKEN to a project access token.
  • Consider moving the token out of public HTML via a small proxy.