Skip to main content

max / makenotwork

server: write build, issue and scan metadata into refs/notes/mnw/* MNW already computes all three and kept them only in Postgres. Mirroring them into the repository is the export promise applied to metadata: a creator who leaves takes their CI history with them, because it was a git object all along rather than a row in a table only we can read. - mnw/builds, on the commit the built tag names. Written after run_build returns and from a re-read row, so it says what the build finally was whichever of the several exits it took. - mnw/issues, on each commit whose message referenced one. Collected in the pass that already writes the issue comments, so the note and the comments cannot disagree about what a message said. - mnw/scan, on the commit a release was built from, once every artifact has a verdict. Per release rather than per artifact: one of three clean is not a verdict, and writing it three times would leave two misleading states in the notes ref's history. All three overwrite rather than merge, and none can fail its caller. The verdict on a release is the worst verdict on any artifact in it. The browser now needs to know these exist. A note in a namespace the write routes refuse is rendered without edit controls and labelled with where it came from; before this the prefix was always empty, so the form beside it was never reachable. It asks the write path's own validator rather than testing the prefix again, so the button appears exactly when a save would be accepted.
Author: Max Johnson <me@maxj.phd> · 2026-08-09 03:31 UTC
Signed with PGP, not checked
Commit: df5678599b734949de6ddde5c767d9d779a8c723
Parent: 274f440
13 files changed, +692 insertions, -0 deletions
@@ -282,9 +282,91 @@
282 282 let ctx = ctx.clone();
283 283 tokio::spawn(async move {
284 284 run_build(&ctx, &build, &config).await;
285 + // After the run rather than inside it, and from a re-read row rather
286 + // than the one above: `run_build` has several exits and the note has to
287 + // say what the build finally was, whichever one it took. A path added
288 + // later is covered without anybody remembering to cover it.
289 + annotate_build(&ctx, build.id, &config).await;
285 290 });
286 291 }
287 292
293 + /// Mirror a finished build's outcome into `refs/notes/mnw/builds`.
294 + ///
295 + /// Every failure here is a warning and nothing more. The build result is in
296 + /// Postgres and served from there; the note is a copy of it that the creator
297 + /// keeps when they leave.
298 + async fn annotate_build(ctx: &BuildCtx, build_id: db::BuildId, config: &DbBuildConfig) {
299 + let build = match db::builds::get_build(&ctx.db, build_id).await {
300 + Ok(Some(b)) => b,
301 + Ok(None) => return,
302 + Err(e) => {
303 + tracing::warn!(build_id = %build_id, error = ?e, "could not re-read build to annotate it");
304 + return;
305 + }
306 + };
307 +
308 + let Some((owner, repo_name, repo_id)) = repo_for_build(ctx, config.repo_id).await else {
309 + return;
310 + };
311 +
312 + // The tag is what triggered the build; the note goes on the commit it names,
313 + // which is the page somebody actually opens. Peeling also covers an
314 + // annotated tag, where the ref points at a tag object rather than a commit.
315 + let Some(target) = crate::routes::git::notes_server::resolve_tag_commit(
316 + &ctx.config,
317 + &owner,
318 + &repo_name,
319 + &build.tag,
320 + )
321 + .await
322 + else {
323 + tracing::warn!(
324 + build_id = %build_id, tag = %build.tag,
325 + "build tag does not resolve to a commit; no note written"
326 + );
327 + return;
328 + };
329 +
330 + crate::routes::git::notes_server::note_build(
331 + &crate::routes::git::notes_server::OwnedRepo {
332 + db: &ctx.db,
333 + config: &ctx.config,
334 + id: repo_id,
335 + owner: &owner,
336 + name: &repo_name,
337 + },
338 + target,
339 + &build,
340 + &config.targets,
341 + )
342 + .await;
343 + }
344 +
345 + /// Resolve a build config's repo to the `(owner, name, id)` the notes writer
346 + /// needs.
347 + async fn repo_for_build(
348 + ctx: &BuildCtx,
349 + repo_id: db::GitRepoId,
350 + ) -> Option<(String, String, db::GitRepoId)> {
351 + let repo = match db::git_repos::get_repo_by_id(&ctx.db, repo_id).await {
352 + Ok(Some(r)) => r,
353 + Ok(None) => return None,
354 + Err(e) => {
355 + tracing::warn!(error = ?e, "could not load the repo behind a build");
356 + return None;
357 + }
358 + };
359 + let owner = match db::users::get_user_by_id(&ctx.db, repo.user_id).await {
360 + Ok(Some(u)) => u,
361 + Ok(None) => return None,
362 + Err(e) => {
363 + tracing::warn!(error = ?e, "could not load the owner of a build's repo");
364 + return None;
365 + }
366 + };
367 + Some((owner.username.to_string(), repo.name.clone(), repo.id))
368 + }
369 +
288 370 fn build_failure_message(succeeded: usize, failed: usize, first_error: Option<&str>) -> String {
289 371 if succeeded == 0 {
290 372 first_error
@@ -593,6 +593,7 @@
593 593 cdn_base_url: std::sync::Arc::from(state.config.cdn_base_url.as_str()),
594 594 synckit_s3: state.storage.synckit_s3.clone(),
595 595 public_s3: state.storage.public_s3.clone(),
596 + config: state.config.clone(),
596 597 });
597 598 let worker_count = makenotwork::constants::SCAN_WORKER_COUNT;
598 599 let worker_shutdown_rx = shutdown_tx.subscribe();
@@ -7245,6 +7245,10 @@
7245 7245 font-size: var(--text-fine);
7246 7246 }
7247 7247 .git-note-namespace { font-family: var(--font-mono); }
7248 + /* Sits beside the namespace rather than being spread across the header: the
7249 + auto margin absorbs the free space `space-between` would otherwise put
7250 + between the two, leaving the attribution on the right where it was. */
7251 + .git-note-owned { margin-right: auto; opacity: 0.6; }
7248 7252 .git-note-attribution { opacity: 0.6; }
7249 7253 .git-note-namespaces {
7250 7254 display: flex;
@@ -345,6 +345,24 @@
345 345
346 346 /// Set the release ID on a build (after successful artifact upload).
347 347 #[tracing::instrument(skip_all)]
348 + /// The build that produced a release, if one did.
349 + ///
350 + /// `None` for a release uploaded by hand rather than built by the pipeline,
351 + /// which is an ordinary case and not an error: there is no tag to annotate.
352 + pub(crate) async fn get_build_by_release(
353 + pool: &PgPool,
354 + release_id: OtaReleaseId,
355 + ) -> Result<Option<DbBuild>> {
356 + let build = sqlx::query_as::<_, DbBuild>(
357 + "SELECT * FROM ota_builds WHERE release_id = $1 ORDER BY created_at DESC LIMIT 1",
358 + )
359 + .bind(release_id)
360 + .fetch_optional(pool)
361 + .await?;
362 +
363 + Ok(build)
364 + }
365 +
348 366 pub(crate) async fn set_build_release(
349 367 pool: &PgPool,
350 368 build_id: BuildId,
@@ -243,6 +243,36 @@
243 243
244 244 /// Get an artifact by release, target, and arch.
245 245 #[tracing::instrument(skip_all)]
246 + /// One artifact by id. What the scan worker has after it finishes: it is told
247 + /// which artifact it scanned, not which release the artifact belongs to.
248 + pub(crate) async fn get_artifact_by_id(
249 + pool: &PgPool,
250 + artifact_id: OtaArtifactId,
251 + ) -> Result<Option<DbOtaArtifact>> {
252 + let artifact = sqlx::query_as::<_, DbOtaArtifact>("SELECT * FROM ota_artifacts WHERE id = $1")
253 + .bind(artifact_id)
254 + .fetch_optional(pool)
255 + .await?;
256 +
257 + Ok(artifact)
258 + }
259 +
260 + /// Every artifact in a release, ordered so a rendering of them is stable
261 + /// between two reads.
262 + pub(crate) async fn list_artifacts(
263 + pool: &PgPool,
264 + release_id: OtaReleaseId,
265 + ) -> Result<Vec<DbOtaArtifact>> {
266 + let artifacts = sqlx::query_as::<_, DbOtaArtifact>(
267 + "SELECT * FROM ota_artifacts WHERE release_id = $1 ORDER BY target, arch",
268 + )
269 + .bind(release_id)
270 + .fetch_all(pool)
271 + .await?;
272 +
273 + Ok(artifacts)
274 + }
275 +
246 276 pub(crate) async fn get_artifact(
247 277 pool: &PgPool,
248 278 release_id: OtaReleaseId,
@@ -108,6 +108,10 @@
108 108 /// the public bucket. `None` when the public bucket isn't configured (image
109 109 /// promotes then fail closed, leaving the entity on its unserved staging key).
110 110 pub public_s3: Option<Arc<dyn StorageBackend>>,
111 + /// Carried for one reason: an OTA artifact's verdict is mirrored into
112 + /// `refs/notes/mnw/scan` on the commit its release was built from, and
113 + /// finding the repository on disk needs `build.git_repos_path`.
114 + pub config: crate::config::Config,
111 115 }
112 116
113 117 /// Decide the final status for a non-quarantined scan.
@@ -310,6 +314,13 @@
310 314 // row was stamped by the promote or the held-image branch above.)
311 315 update_entity_status(&ctx.db, kind, target_id, entity_status).await?;
312 316
317 + // An OTA artifact's verdict belongs in the repository it was built from, not
318 + // only in a table. Runs after the stamp above so the release's artifacts are
319 + // read back with this one's verdict already in them.
320 + if kind == ScanTargetKind::OtaArtifact {
321 + annotate_release_scan(ctx, db::OtaArtifactId::from(target_id)).await;
322 + }
323 +
313 324 // Verdict + duration metrics (Run 20 Observability): quarantine/hold/error
314 325 // rates and scan latency are now graphable at /metrics.
315 326 let verdict_label = match entity_status {
@@ -693,6 +704,80 @@
693 704 /// results, but there's no status to flip on those entities (the `ItemImage`
694 705 /// cover shares the `items` row but must NOT flip `items.scan_status`, which
695 706 /// gates the audio/video, not the cover).
707 + /// Mirror a release's scan verdicts into `refs/notes/mnw/scan`, once every
708 + /// artifact in it has one.
709 + ///
710 + /// Waiting for the last artifact is the point. A note saying one of three is
711 + /// clean is not a verdict, and writing it per artifact would leave two
712 + /// misleading intermediate states in the notes ref's history for anyone reading
713 + /// it later. Every early return here is an ordinary case: a release nobody
714 + /// built from a tag has no commit to annotate.
715 + async fn annotate_release_scan(ctx: &WorkerContext, artifact_id: db::OtaArtifactId) {
716 + let Ok(Some(artifact)) = db::ota::get_artifact_by_id(&ctx.db, artifact_id).await else {
717 + return;
718 + };
719 + let Ok(artifacts) = db::ota::list_artifacts(&ctx.db, artifact.release_id).await else {
720 + return;
721 + };
722 + if artifacts.is_empty()
723 + || artifacts.iter().any(|a| {
724 + matches!(
725 + a.scan_status,
726 + FileScanStatus::Pending | FileScanStatus::Scanning
727 + )
728 + })
729 + {
730 + return;
731 + }
732 +
733 + // The build is what ties a release to a tag, and so to a commit. A release
734 + // uploaded by hand has none.
735 + let Ok(Some(build)) = db::builds::get_build_by_release(&ctx.db, artifact.release_id).await
736 + else {
737 + return;
738 + };
739 + let Ok(Some(config)) = db::builds::get_build_config_by_app(&ctx.db, build.app_id).await else {
740 + return;
741 + };
742 + let Ok(Some(repo)) = db::git_repos::get_repo_by_id(&ctx.db, config.repo_id).await else {
743 + return;
744 + };
745 + let Ok(Some(owner)) = db::users::get_user_by_id(&ctx.db, repo.user_id).await else {
746 + return;
747 + };
748 + let owner = owner.username.to_string();
749 +
750 + let Some(target) = crate::routes::git::notes_server::resolve_tag_commit(
751 + &ctx.config,
752 + &owner,
753 + &repo.name,
754 + &build.tag,
755 + )
756 + .await
757 + else {
758 + return;
759 + };
760 +
761 + let verdicts: Vec<(String, FileScanStatus)> = artifacts
762 + .iter()
763 + .map(|a| (format!("{}/{}", a.target, a.arch), a.scan_status))
764 + .collect();
765 +
766 + crate::routes::git::notes_server::note_scan(
767 + &crate::routes::git::notes_server::OwnedRepo {
768 + db: &ctx.db,
769 + config: &ctx.config,
770 + id: repo.id,
771 + owner: &owner,
772 + name: &repo.name,
773 + },
774 + target,
775 + &build.version,
776 + &verdicts,
777 + )
778 + .await;
779 + }
780 +
696 781 async fn update_entity_status(
697 782 db: &PgPool,
698 783 kind: ScanTargetKind,
@@ -13,7 +13,11 @@
13 13 </p>
14 14 {% endif %}
15 15
16 + {# A note in a namespace the write routes refuse gets no form. makenot.work
17 + writes refs/notes/mnw/*, and offering an edit box that saves to a 422 is
18 + worse than offering nothing. #}
16 19 {% for note in notes %}
20 + {% if !note.read_only %}
17 21 <form class="git-note-form" method="post" action="/git/{{ owner }}/{{ repo_name }}/commit/{{ detail.oid }}/notes">
18 22 <input type="hidden" name="_csrf" value="{{ token }}">
19 23 <input type="hidden" name="namespace" value="{{ note.namespace }}">
@@ -28,6 +32,7 @@
28 32 <input type="hidden" name="namespace" value="{{ note.namespace }}">
29 33 <button type="submit">Remove the note in {{ note.namespace }}</button>
30 34 </form>
35 + {% endif %}
31 36 {% endfor %}
32 37
33 38 <form class="git-note-form" method="post" action="/git/{{ owner }}/{{ repo_name }}/commit/{{ detail.oid }}/notes">
@@ -7,6 +7,11 @@
7 7 <div class="git-note">
8 8 <div class="git-note-header">
9 9 <span class="git-note-namespace">{{ note.namespace }}</span>
10 + {% if note.read_only %}
11 + {# Says where it came from, so a note nobody on the repo wrote does
12 + not read as one of theirs they have forgotten. #}
13 + <span class="git-note-owned">written by makenot.work</span>
14 + {% endif %}
10 15 {% if let Some(a) = note.attribution %}
11 16 <span class="git-note-attribution">
12 17 {% if a.exact %}