ADR-062 — Building a project's distribution repository, and never deleting on GitHub¶
Status¶
Accepted (2026-10-02, settled by the product owner in conversation; the
asynchronous git runner by the orchestrator of the merge). Implemented by
merge task M3-02: createProject (apps/api/src/modules/project/lifecycle.ts),
the adapter createSquashedRepo (apps/api/src/github/squash.ts) and the
git runner (apps/api/src/github/git.ts). Tested by
modules/project/lifecycle.db.test.ts against local bare repositories.
Completes ADR-035 (the merge), D19 (docs/merge/08-decisions.md) and
F-PROJ-02, F-PROJ-16.
Amended 2026-10-12 by ADR-106 §3 (scope: a second use of addendum 1's reference; nothing here changes): the evidence run of a project graded by criteria overlays the distribution's current protected files on the student's tree, exactly as a restore takes them, and records the distribution sha it used.
Addendum (2026-10-02, product owner; merge task M3-04): the distribution repository is also the reference of the protected files. F-PROJ-08 is amended to match:
- A restore puts back the distribution repository's current version
of each protected file on the pushed branch, as heig-classroom's
revert.tsdid — not "the last commit the App pushed there", which Quiz never recorded per file. A teacher who fixesgrading.ymlin the distribution (a sync, M3-07) has the fix restored from then on. - Every push but Quiz's App's is checked, a workflow's own commit
included: a
github-actions[bot]push is recorded as agraderbot commit (its runs never count, N-SEC-21) and its protected files are restored like a student's, so a student's workflow cannot rewritegrading.ymlon their behalf. Only the App's pushes (restores, deadline commits, syncs) are exempt; the bot logins are these two only (heig-classroom's App stops acting on a repository once it is imported, M8). - The cap suspends until the staff re-enable. Past five restores in an
hour on one repository (the server's clock),
project_repos.protection_suspended_atis set, auditedproject_repo.revert_cap, and nothing is restored until the staff clear it (M3-08); every run ingested meanwhile isproject_grade_runs.to_verify. A push is answered once, by its head (reverts.head_sha, unique per repository, migration0057), so a redelivery neither restores nor counts twice; a restore leaves out a file already the distribution's, so a retry after a crash commits nothing. The restore — its bot commit and itsrevertsrow — is recorded before the branch moves onto it (N-RES-08), under the repository row's lock where the cap is counted, so two pushes cannot both pass it and a crash after the move loses neither; GitHub refusing the move (a 422 race) takes the row back. - Which files a push touched: the payload's lists, or GitHub's compare when they cannot tell (a forced push, or the 20 commits GitHub lists at most); a new branch counts every protected file as touched.
- A run on a restored head never counts (orchestrator, review of
M3-04): the commit the student pushed ran their own copy of the protected
files — a tampered
grading.ymlreports what it likes. Its runs are kept in the history,to_verify, andselectScoreRun(@quiz/domain) skips every head the restore covered, whichever came first, the run or the restore (the restore re-flags the runs already stored and reselects): the tampering push's head (reverts.head_sha), the head the restore was built on (reverts.covered_sha), and every head received on that branch between the tampering push's receipt and the restore (reverts.created_at), read frompush_receipts— S, S1, S2 pushed before S's delivery is handled all ran the altered workflow. No GitHub read.covered_shastays beside the window because the covered head's own receipt may arrive after the restore. A push on top of the restore counts again. Defence in depth: a head received as a bot's push (push_receipts.is_bot) never counts either, even with nobot_commitsrow. - The window's edges, and a move GitHub refused (2026-10-05, the spec
challenge and the product owner; merge task M3-06b). (a) The window
closes at the later of the restore's
created_at(taken before the branch is read) and the receipt ofcovered_sha(GREATEST(reverts.created_at, receipt(covered_sha)),created_atalone while the covered head has no receipt — it may arrive later or never): a head received between the two, already on the branch under the covered head, ran the altered files too. Known limit, conservative: when the covered head's receipt lands after the branch moved onto the restore, a push on top of the restore received before that late receipt falls inside the window and isto_verify— the staff verify it, the score is never wrong. (b) The window is read on the restore's branch (reverts.branch, migration0073; the receipt's branch on the rows written before): a receipt is one per sha, so a sha first received on another branch no longer drags the window onto it. Known limit, per branch receipts being out of scope: a head first received on branch A, then pushed on B inside B's window, escapes B's window; a run belongs to a sha, not a branch, so only the window is per branch. (c) A 422 on the move (a student's push raced the restore) no longer takes therevertsrow back: the row stays without its restore (revert_shanull) but with the head the attempt read (covered_sha), and the delivery is retried; a retry that restores fills the row in (its restore, its covered head, its branch, itscreated_at); a retry that finds the files put back by the student leaves the null row — the push is answered: the heads up to the refused attempt's covered head, bounded by itscreated_at(taken before the branch was read, so the fix pushed after the read has a later receipt and is outside), have their runsto_verify, never the score; the fix counts; no restore commit, no count toward the cap (a move that did not happen is no restore), no new audit action (the row is the record). The runs are flagged and the score reselected on the 422 itself, not at the retry. (d) A push that touched a protected file but whose head a clean head had already overtaken when its delivery was handled (S then the fix, before S's delivery) is read at its own head — the hit files, compared with the distribution's exactly as the branch head is (alteredFiles, the one comparison): altered, it gets the same row, its head alone (covered_shanull, nothing restored), its runs ran its own copy; the distribution's, it is a fix delivered late and writes no row — a falseto_verifyis not free, the release refuses with409 to_verifyuntil the teacher settles it. Known limit: the tampered heads between S and the clean one, never delivered as tampering themselves, count. A push whose own head leaves the protected files the distribution's writes no row: it is no tampering. The storedto_verifyof a run follows the set at every reselection (refreshScoreSelection): a run ingested before a late receipt widened its window is flagged at the repository's next reselection — a restore, a run ingested by the webhook, orreconcile.grades(15 min), which reselects every quiet repository in scope and so re-flags without a new run or a GitHub call — and the score never waited for the flag, it reads the set (selectScoreRun). The student never seesto_verify(N-SEC-20).
Context¶
Creating a project builds its distribution repository in the
classroom's organization (F-PROJ-02): <slug>-squashed, one commit per
handed-out branch after the hand-out overlay (squash), or the branches
with their history (whole). Every student repository is later pushed from
it.
heig-classroom built the repository FIRST, then inserted the assignment's row. When the insert lost the race on the unique slug, it deleted the repository it had just built; deleting a draft deleted its distribution repository too. A deletion on GitHub cannot be undone, and the App's right to delete repositories is the one right a teacher's organization has the most reason to refuse it: D19 settled that Quiz deletes rows, never a repository, and the product owner extended it on 2026-10-02 to Quiz's own leftovers.
The build takes seconds (a clone and a push per branch) and ran with
execFileSync, which stops the whole process: the live clock of an exam,
the SSE and every other request wait for it.
Decision¶
- The row first, the claim before the push. The draft is inserted
before anything is built, with
distribution_*null: the UNIQUE (classroom, slug) decides the slug (-2…-20,409 duplicate_slugbeyond), so two creations racing never take the same one and nothing built has to be undone. The repository is then created or adopted, and the row CLAIMS it — writes its id under a partial UNIQUE onprojects.distribution_repo_id(migration0056) — before anything is pushed to it: two creations racing for one empty leftover, in two classrooms of one organization, never both push into it; the loser steps to the next name. Then the repository is built, and only then does the row get the repository's name (distribution_full_name), the mark of a BUILT distribution. - Never delete on GitHub. Not a student's repository, not a
distribution repository, not a repository the App created a second ago.
A failed build deletes the ROW only and answers
502 distribution_failed; the teacher retries. - An empty leftover is adopted. A name GitHub refuses (422) is looked
at: an EMPTY and PRIVATE repository no project holds is what a failed
build leaves, and the next attempt builds into it. Anything else is
someone's — another classroom's project of the same slug, a year
earlier, in the same organization; a public repository, which a
distribution never is (F-PROJ-02); one an interrupted build still
claims — and the name steps to
-squashed-2…-squashed-20. Conversely a distribution repository is never a source: one named…-squashed[-N]or held by a project is refused (source_not_found). - Asynchronous git. The git runner uses
execFile; the request still waits for the build, the event loop does not. The token still reaches git through the environment of the one process only (invariant 15). The remotes have one seam (setRemoteBaseForTests), so the tests push to local bare repositories. - A draft whose distribution is not built — its name not written — is
listed and may be deleted, but not published (
409 distribution_missing), by hand or by the ticker: it is being built, or its build was interrupted. So a failed build only ever deletes an unpublished draft (the deletion says so in its condition), and nothing hands out a half-built repository.
Consequences¶
- An organization may hold repositories nobody uses: a failed build's empty leftover until the next attempt adopts it, a deleted project's distribution repository, a deleted classroom's student repositories. They are the organization's to keep or delete (N-DATA-03); the deletion's confirmation says so (F-PROJ-16).
- A course run year after year in one organization takes the next suffix each time a project keeps its name: twenty names, against heig-classroom's five.
- The source, its branches and the source strategy are fixed at creation (F-PROJ-03 as amended): a different source is a new project, never a rebuild of a published one's repository.
- Deleting a project, its classroom or its course purges the push receipts
of its repositories (
purgeProjectReceipts, thegithubmodule's) in the deletion's transaction, and calls nothing on GitHub.
Alternatives considered¶
- Build first, insert second (heig-classroom). The race on the slug is then lost after the build, and either the repository is deleted (refused here) or left behind non-empty under a name the next attempt cannot adopt.
- A random suffix on a collision. Every retry of a failed build would leave a new repository behind; a deterministic name is adopted instead.
- Building in a job. The teacher would get a draft that is not ready and a second state to follow; the build is seconds, and with an asynchronous runner the request can wait for it without stopping anyone.
- Claiming after the build (the first version of this decision). Two creations adopting one empty leftover both pushed into it before either wrote the row.