ADR-073 — Syncing a project's source: a lease, one pull request per branch, the handed-out sha¶
Status¶
Accepted (2026-10-05; the per-branch pull request, "up to date" by the files
that differ, the repositories a sync skips and the deployment note settled
by the product owner in conversation; the lease, the clearing rule and the
handed-out sha by the orchestrator of the merge, validated by the spec
challenger). Implemented by merge task M3-07:
apps/api/src/modules/project/sync.ts (the request, the leased pass, the
two webhook handlers, the page's state) on the adapters
apps/api/src/github/sync.ts; migration 0072_project_sync (regenerated after 0071_rpn_calculator). Tested by
modules/project/sync.db.test.ts against local bare repositories.
Scope: F-PROJ-12 (amended the same day), the project module's sync and
its GitHub App's pull_request subscription.
Relations: depends on ADR-064 (the
claims and leases of a project's GitHub work, reused as they are) and on
ADR-062 (the distribution repository,
which a sync updates and the restores read); completes D12
(docs/merge/08-decisions.md).
Context¶
heig-classroom's sync.ts ran as one pg-boss job per assignment: it
updated the squashed repository, force-pushed sync/<branch> to every
live, unlocked student repository and opened or commented one pull request
per repository, keyed on student_repos.sync_pr_number. Its distribution
commit was titled with the SOURCE's short sha, which the students then read
in their pull request — the existence of the source is the staff's alone in
Quiz (N-SEC-20). A repository was "up to date" when GitHub's compare said
ahead_by === 0, which a teacher's revert-and-redo defeats. Two syncs
could run at once; a project with several handed-out branches kept one
pull request number for all of them; and "ahead" had no number, F-PROJ-12
asks for one.
Decision¶
- The source ahead, per branch, from the sha handed out. The build
and each sync record the source's sha per branch
(
projects.source_heads, null on a project built before and on heig-classroom's imported rows, which then show "ahead" with no number). A push to a handed-out branch of a SOURCE repository marks every non-archived project of that source — drafts included, so Publish never hands out a stale distribution — with the push's sha and the server's receipt (source_ahead_sha,source_pushed_at) and the commits the branch holds past its handed-out sha (source_ahead, GitHub's compare, in the background; null when it cannot tell). A push whose head already is the handed-out sha marks nothing. The source repository needs no push receipt. - The request updates the distribution; a leased job does the rest.
POST /app/api/projects/:pid/synctakes the project'ssync_job_atlease (ADR-064 §3's claim, renewal and release, a third column beside the deadline's and the dispatches', never backdated: nothing re-claims a sync but the staff; held:409 sync_in_progress), updates the distribution repository in the request — seconds, on the asynchronous git runner, as the build does — so that a rewritten source under thewholestrategy is refused there and then (409 source_rewritten, auditedproject.sync_failed, nothing changed, the lease given back), records the handed-out shas, auditsproject.sync_requestedand sends ONEproject.syncjob (202). A draft syncs its distribution and nothing else; an archived project nothing (409 project_archived). Never refused for a source that is not ahead: a retry reaches the repositories that failed. The ticker never claims this lease: a sync is the staff's act. - The pass, in
runLeased's frame: every student repository, four at a time, re-read just before its push and skipped when it is not live, past its EFFECTIVE deadline — locked or not — or locked (syncSkips,@quiz/domain). The distribution's head is recorded inbot_commits(sync)BEFOREsync/<branch>is forced to it (the one ref the App ever forces; left where it is when already there, so a retry pushes nothing again). Up to date is GitHub's compare of<branch>...sync/<branch>listing no file, notahead_by === 0: a repository up to date gets no pull request. - One pull request per handed-out branch, never two
(
project_sync_prs(repo_id, branch, pr_number, state, updated_at), replacingsync_pr_number/sync_pr_state, which the migration copies into the default branch's row): the stored one is reused while GitHub says it is open, else the open one fromsync/<branch>is found by its head, else one is opened; an open one is commented on when the update moved its head. The App'spull_requestevents keep each row's state (open,merged,closed), accepted only for a pull request the App authored fromsync/<branch>; a replay about an older pull request than the row's changes nothing. The row's default branch is what the page shows. - What the students receive never names the source (N-SEC-20): the distribution's commit message carries no sha, and the pull request's title and comments carry the DISTRIBUTION's. Every text written into GitHub is English (D12).
- The outcome per repository is stored (
project_repos.sync_outcome,sync_outcome_at: opened, updated, up to date, failed, skipped) and the page shows the counts of the repositories' last outcomes; the pass is auditedproject.syncedwith the counts and the failed repositories' names. A failure is recorded, the pass goes on, and the frame gives the lease back (the staff ask again at once; a retry pushes and comments only where the head moved).source_ahead_shais cleared only when no repository failed and it still is one of the shas the pass synced — a push landing meanwhile keeps the source ahead. - Protected files need nothing more: a restore puts back the distribution's CURRENT version (ADR-062 addendum), so after a sync it restores the new one and leaves out a file already the distribution's; asserted by one test.
Consequences¶
- The production App must subscribe to
pull_request(M2-06 listedworkflow_run,member,repository); without it the pull requests' state staysopenuntil the next sync reads them. - A project with several handed-out branches gets one pull request per branch per repository; the row shows the default branch's.
- A teacher who syncs twice without a new push spends one compare per repository and no push, comment or pull request.
- F-PROJ-20's import maps heig-classroom's
sync_pr_numberto the default branch's row andsource_headsto null.
Alternatives considered¶
- The whole sync in the job (heig-classroom): a rewritten source under
wholewould fail silently in the background; the request is the place to refuse it, and the distribution update takes the seconds a build does. - Clearing
source_ahead_shaat the request: a push during the pass, or a repository that failed, would then hide that the source is ahead. - One pull request per repository for every branch (heig-classroom): a branch's update cannot be merged onto another branch.
ahead_by === 0as "up to date": a teacher's change and its revert would send an empty pull request to every student.- A second queue policy or a singleton: ADR-064 rejected them already (#273); the lease is the one mechanism.