Prechádzať zdrojové kódy

feat: versioned registry tags (build checksum) + latest; publish --push; duplicate guard via publication ledger with --force

Alexander Korolev 1 mesiac pred
rodič
commit
8e20fd1dad

+ 23 - 11
AGENTS.md

@@ -631,19 +631,31 @@ commands = ["cmd", { command = "cmd", on_failure = "warn" }]
 
 ```toml
 [registry]
-base = "ghcr.io/kilork"   # push ref: {base}/{image-name}:{tag}
+base = "ghcr.io/kilork"   # push refs: {base}/{image-name}:{version} + :latest
 ```
 
-After a **successful local build** (`sm121 build` or `serve --build-node`),
-the image is pushed from the build node as `{base}/{image-name}:{tag}`
-(e.g. `ghcr.io/kilork/glm53-jovian-r35:local`). The push runs in a tmux
-session on the node (`sm121-push-<recipe>`, log `/tmp/<recipe>-push.log`)
-with the same 30 s poll / 90 min cap as the build — a 30 GB push must not
-ride on one SSH session. **The launcher stores no registry credentials**:
-the node must be logged in once (`ssh <node> && docker login ghcr.io`);
-a failed push fails the build with the log tail + a `docker login` hint.
-`--no-push` (build + serve) skips the push even when `[registry]` is set.
-Without the section, builds behave exactly as before.
+**Versioned tags, never `:local`.** The version is the image's build
+checksum (recursive SHA-256 of the image dir — the same value stamped as the
+`sm121.checksum` label), so identical content always maps to the identical
+tag: re-publishing "the same thing" cannot create a duplicate, changed
+content is a new version tag. Each successful local build
+(`sm121 build` / `serve --build-node`) then pushes
+`{base}/{image-name}:<checksum:12>` + `:latest` from the build node.
+`sm121 publish --push` publishes without a build: it finds the image on
+`--node` (or probes all cluster nodes), takes the version from the
+`sm121.checksum` label (image ID fallback for unlabeled prebuilt images),
+and pushes from that node.
+
+Duplicate guard: a publication ledger (`published.json` next to the global
+config) records `{repo → {version, image digest, time}}`; a re-push with an
+unchanged image digest is skipped (with a note) unless `--force`
+(`build`/`serve`/`publish`). The push runs in a tmux session on the node
+(`sm121-push-<label>`, log `/tmp/<label>-push.log`) with the same 30 s poll /
+90 min cap as the build — a 30 GB push must not ride on one SSH session.
+**The launcher stores no registry credentials**: the node must be logged in
+once (`ssh <node> && docker login ghcr.io`); a failed push fails with the log
+tail + a `docker login` hint. `--no-push` (build + serve) skips the push even
+when `[registry]` is set; without the section, builds behave as before.
 
 ## Power
 

+ 3 - 2
locales/de/main.ftl

@@ -218,10 +218,11 @@ health-status-online = ● online
 health-status-offline = ✗ offline
 health-container-line =     { $name }  { $status }  { $image }
 
-dry-run-would-push = [dry-run] würde nach dem Build { $ref } pushen
+dry-run-would-push = [dry-run] würde nach dem Build { $ref} pushen (und { $latest} aktualisieren)
 push-mapping-failed = kann [registry].base { $base } nicht auf einen Push-Ref für { $image } abbilden
 pushing-image = Image nach { $ref } wird gepusht (tmux: { $session })...
 push-timeout = Push von { $ref} auf { $node} ist nach { $elapsed} abgelaufen — tmux-Session { $session} / Log { $log} auf dem Knoten prüfen.
 push-failed = Push von { $ref} auf { $node} ist nach { $elapsed} fehlgeschlagen.\n{ $log }\nHinweis: Wenn das Log unauthorized/denied zeigt — `docker login { $host }` auf { $node} ausführen (der Launcher speichert keine Registry-Credentials).
-pushed-image = Image { $ref} gepusht ({ $elapsed })
+pushed-image = Image { $ref} gepusht, { $latest} aktualisiert ({ $elapsed })
 push-running = Push läuft seit { $elapsed }...
+push-already-published = { $ref} bereits veröffentlicht ({ $label}): unveränderter Image-Digest — übersprungen, zum erneuten Push --force nutzen

+ 3 - 2
locales/en/main.ftl

@@ -237,10 +237,11 @@ stop-not-verified = Containers not verified stopped on: { $nodes }
 report-col-up = Up
 report-col-launches = Launches
 
-dry-run-would-push = [dry-run] would push { $ref } after the build
+dry-run-would-push = [dry-run] would push { $ref } (and refresh { $latest }) after the build
 push-mapping-failed = cannot map [registry].base { $base } to a push ref for { $image }
 pushing-image = Pushing image to { $ref } (tmux: { $session })...
 push-timeout = Push of { $ref } on { $node } timed out after { $elapsed } — inspect tmux session { $session } / log { $log } on the node.
 push-failed = Push of { $ref } on { $node } failed after { $elapsed }.\n{ $log }\nHint: if the log says unauthorized/denied — run `docker login { $host }` on { $node } (the launcher stores no registry credentials).
-pushed-image = Pushed { $ref } ({ $elapsed })
+pushed-image = Pushed { $ref } and refreshed { $latest } ({ $elapsed })
 push-running = Push running for { $elapsed }...
+push-already-published = { $ref } already published ({ $label}): unchanged image digest — skipping, use --force to re-publish

+ 3 - 2
locales/es/main.ftl

@@ -218,10 +218,11 @@ health-status-online = ● en línea
 health-status-offline = ✗ fuera de línea
 health-container-line =     { $name }  { $status }  { $image }
 
-dry-run-would-push = [dry-run] haría push de { $ref } tras el build
+dry-run-would-push = [dry-run] haría push de { $ref} (y actualizaría { $latest }) tras el build
 push-mapping-failed = no se puede mapear [registry].base { $base } a un ref de push para { $image }
 pushing-image = Haciendo push de la imagen a { $ref} (tmux: { $session })...
 push-timeout = El push de { $ref} en { $node} expiró tras { $elapsed} — inspeccionar la sesión tmux { $session} / el log { $log} en el nodo.
 push-failed = El push de { $ref} en { $node} falló tras { $elapsed}.\n{ $log }\nSugerencia: si el log dice unauthorized/denied — ejecutar `docker login { $host }` en { $node} (el launcher no guarda credenciales del registro).
-pushed-image = Imagen { $ref} con push hecho ({ $elapsed })
+pushed-image = Imagen { $ref} con push hecho, { $latest} actualizada ({ $elapsed })
 push-running = Push en curso desde hace { $elapsed }...
+push-already-published = { $ref} ya publicado ({ $label}): digest de imagen sin cambios — omitido, use --force para volver a publicar

+ 3 - 2
locales/fr/main.ftl

@@ -218,10 +218,11 @@ health-status-online = ● en ligne
 health-status-offline = ✗ hors ligne
 health-container-line =     { $name }  { $status }  { $image }
 
-dry-run-would-push = [dry-run] pousserait { $ref } après le build
+dry-run-would-push = [dry-run] pousserait { $ref} (et mettrait à jour { $latest }) après le build
 push-mapping-failed = impossible d'associer [registry].base { $base } à un ref de push pour { $image }
 pushing-image = Push de l'image vers { $ref} (tmux : { $session })...
 push-timeout = Le push de { $ref} sur { $node} a expiré après { $elapsed} — examiner la session tmux { $session} / le log { $log} sur le nœud.
 push-failed = Le push de { $ref} sur { $node} a échoué après { $elapsed}.\n{ $log }\nAstuce : si le log affiche unauthorized/denied — exécuter `docker login { $host }` sur { $node} (le launcher ne stocke aucune credential de registre).
-pushed-image = Image { $ref} poussée ({ $elapsed })
+pushed-image = Image { $ref} poussée, { $latest} mise à jour ({ $elapsed })
 push-running = Push en cours depuis { $elapsed }...
+push-already-published = { $ref} déjà publié ({ $label}) : digest d'image inchangé — ignoré, utiliser --force pour repousser

+ 3 - 2
locales/it/main.ftl

@@ -218,10 +218,11 @@ health-status-online = ● online
 health-status-offline = ✗ offline
 health-container-line =     { $name }  { $status }  { $image }
 
-dry-run-would-push = [dry-run] farebbe push di { $ref } dopo il build
+dry-run-would-push = [dry-run] farebbe push di { $ref} (e aggiornerebbe { $latest }) dopo il build
 push-mapping-failed = impossibile mappare [registry].base { $base } a un ref di push per { $image}
 pushing-image = Push dell'immagine su { $ref} (tmux: { $session })...
 push-timeout = Il push di { $ref} su { $node} è scaduto dopo { $elapsed} — ispezionare la sessione tmux { $session} / il log { $log} sul nodo.
 push-failed = Il push di { $ref} su { $node} è fallito dopo { $elapsed}.\n{ $log }\nSuggerimento: se il log dice unauthorized/denied — eseguire `docker login { $host }` su { $node} (il launcher non salva credenziali del registro).
-pushed-image = Immagine { $ref} pushata ({ $elapsed })
+pushed-image = Immagine { $ref} pushata, { $latest} aggiornata ({ $elapsed })
 push-running = Push in corso da { $elapsed }...
+push-already-published = { $ref} già pubblicato ({ $label}): digest immagine invariato — saltato, usare --force per ripubblicare

+ 3 - 2
locales/ru/main.ftl

@@ -247,10 +247,11 @@ report-col-score = Счёт /100
 report-col-tps1 = Tok/s c1
 report-col-tps2 = Tok/s c2
 
-dry-run-would-push = (dry-run) после сборки запушу { $ref }
+dry-run-would-push = (dry-run) после сборки запушу { $ref} (и обновлю { $latest })
 push-mapping-failed = нельзя сопоставить [registry].base { $base } с push-референсом для { $image }
 pushing-image = Пушим образ в { $ref } (tmux: { $session })...
 push-timeout = Push образа { $ref } на { $node} не завершился за { $elapsed } — смотрите tmux-сессию { $session } / лог { $log } на ноде.
 push-failed = Push образа { $ref} на { $node} не удался за { $elapsed }.\n{ $log }\nПодсказка: если в логе unauthorized/denied — выполните `docker login { $host }` на { $node} (launcher не хранит креденшелы реестра).
-pushed-image = Образ { $ref} запушен ({ $elapsed })
+pushed-image = Образ { $ref} запушен, { $latest} обновлён ({ $elapsed })
 push-running = Push идёт { $elapsed }...
+push-already-published = { $ref} уже опубликован ({ $label}): digest изображения не изменился — пропускаю, для повторного push — --force

+ 15 - 0
src/cli.rs

@@ -352,6 +352,21 @@ pub struct PublishArgs {
     #[arg(long)]
     pub gist: bool,
 
+    /// After bundling, push the recipe's image to the image registry
+    /// (`[registry]` config) under its version tag + `latest`.
+    #[arg(long)]
+    pub push: bool,
+
+    /// Node that has the image for `--push` (default: probe all cluster
+    /// nodes for the first match).
+    #[arg(long)]
+    pub node: Option<String>,
+
+    /// Re-push even if the publication ledger already records this image
+    /// digest (with `--push`).
+    #[arg(long)]
+    pub force: bool,
+
     /// Upload the bundle to a recipe registry (see `sm121 registry --help`)
     #[arg(long, value_name = "URL")]
     pub registry: Option<String>,

+ 23 - 151
src/docker/build.rs

@@ -211,11 +211,21 @@ pub async fn handle_build(
         );
         if !opts.no_push
             && let Some(reg) = config.registry.as_ref()
-            && let Some(push_ref) = push::registry_ref(&reg.base, &recipe.docker_image)
+            && let dir_path = Path::new("docker-images").join(dir)
+            && let Ok((_, checksum)) = build_checksum(
+                &dir_path,
+                recipe.build_profile.as_deref(),
+                &recipe.build.versions,
+            )
+            && let Some((version_ref, latest_ref)) = push::registry_refs(
+                &reg.base,
+                &recipe.docker_image,
+                &checksum[..12.min(checksum.len())],
+            )
         {
             println!(
                 "{}",
-                fluent_i18n::t!("dry-run-would-push", { "ref" => &push_ref })
+                fluent_i18n::t!("dry-run-would-push", { "ref" => version_ref, "latest" => latest_ref })
             );
         }
         return Ok(false);
@@ -298,7 +308,15 @@ pub async fn handle_build(
         .await?;
         // Push to the configured registry (no-op without `[registry]`).
         if !opts.no_push {
-            push::push_image_on_node(config, build_node, &recipe.docker_image, recipe_name).await?;
+            push::push_image(
+                config,
+                build_node,
+                &recipe.docker_image,
+                &checksum[..12.min(checksum.len())],
+                false,
+                "build",
+            )
+            .await?;
         }
     }
     Ok(should_build)
@@ -346,152 +364,6 @@ pub(crate) fn format_elapsed(d: std::time::Duration) -> String {
         format!("{}m {:02}s", mins, secs)
     }
 }
-
 #[cfg(test)]
-mod tests {
-    use super::format_elapsed;
-    use crate::docker::build_dir::{build_checksum, parse_versions_env, serialize_versions_env};
-    use std::collections::HashMap;
-    use std::fs;
-    use std::path::PathBuf;
-    use std::time::Duration;
-
-    fn temp_test_dir(name: &str) -> PathBuf {
-        let p =
-            std::env::temp_dir().join(format!("sm121-build-test-{}-{}", std::process::id(), name));
-        fs::remove_dir_all(&p).ok();
-        fs::create_dir_all(&p).unwrap();
-        p
-    }
-
-    #[test]
-    fn format_elapsed_seconds_and_minutes() {
-        assert_eq!(format_elapsed(Duration::from_secs(0)), "0s");
-        assert_eq!(format_elapsed(Duration::from_secs(30)), "30s");
-        assert_eq!(format_elapsed(Duration::from_secs(59)), "59s");
-        assert_eq!(format_elapsed(Duration::from_secs(60)), "1m 00s");
-        assert_eq!(format_elapsed(Duration::from_secs(90)), "1m 30s");
-        assert_eq!(format_elapsed(Duration::from_secs(125)), "2m 05s");
-    }
-
-    #[test]
-    fn parse_versions_env_skips_comments_and_blanks() {
-        let raw = "# comment\n\nKEY=val\nOTHER=a=b\n  SPACED  =  x  \nnoeq\n";
-        let pins = parse_versions_env(raw);
-        assert_eq!(pins.get("KEY"), Some(&"val".to_string()));
-        assert_eq!(pins.get("OTHER"), Some(&"a=b".to_string()));
-        assert_eq!(pins.get("SPACED"), Some(&"x".to_string()));
-        assert!(!pins.contains_key("noeq"));
-        assert_eq!(pins.len(), 3);
-    }
-
-    #[test]
-    fn parse_versions_env_quotes_are_normalized() {
-        // quoted / unquoted / single-quoted forms of the same value must
-        // resolve identically (stable checksums across edits); inner quotes
-        // and non-matching outer quotes stay verbatim.
-        let raw = "A=\"x:tag@sha256:abc\"\nB='y'\nC=z\nD=\"m@n\"'\"op\"'\nE=\"half\nF='weird\"\n";
-        let pins = parse_versions_env(raw);
-        assert_eq!(pins.get("A"), Some(&"x:tag@sha256:abc".to_string()));
-        assert_eq!(pins.get("B"), Some(&"y".to_string()));
-        assert_eq!(pins.get("C"), Some(&"z".to_string()));
-        assert_eq!(pins.get("D"), Some(&"\"m@n\"'\"op\"'".to_string())); // mismatched outer quotes stay
-        assert_eq!(pins.get("E"), Some(&"\"half".to_string()));
-        assert_eq!(pins.get("F"), Some(&"'weird\"".to_string()));
-    }
-
-    #[test]
-    fn serialize_versions_env_sorted_one_per_line() {
-        let mut pins = std::collections::BTreeMap::new();
-        pins.insert("B".into(), "2".into());
-        pins.insert("A".into(), "1".into());
-        let s = serialize_versions_env(&pins);
-        assert_eq!(
-            s,
-            "# Merged versions.env \u{2014} generated by sm121 (profile + recipe [build.versions] applied)\nA=1\nB=2\n"
-        );
-    }
-
-    #[test]
-    fn build_checksum_no_profile_no_overrides_returns_none_and_dir_checksum() {
-        let dir = temp_test_dir("no_overrides");
-        fs::write(dir.join("versions.env"), "A=1\nB=2\n").unwrap();
-        let (merged, ck) = build_checksum(&dir, None, &HashMap::new()).unwrap();
-        assert!(merged.is_none());
-        let (_, ck2) = build_checksum(&dir, None, &HashMap::new()).unwrap();
-        assert_eq!(ck, ck2);
-        fs::remove_dir_all(&dir).ok();
-    }
-
-    #[test]
-    fn build_checksum_overrides_change_checksum_and_produce_merged() {
-        let dir = temp_test_dir("overrides");
-        fs::write(dir.join("versions.env"), "A=1\nB=2\n").unwrap();
-        let no_over = build_checksum(&dir, None, &HashMap::new()).unwrap().1;
-        let mut over = HashMap::new();
-        over.insert("A".to_string(), "99".to_string());
-        over.insert("C".to_string(), "3".to_string());
-        let (merged, with_over) = build_checksum(&dir, None, &over).unwrap();
-        assert_ne!(no_over, with_over);
-        let m = merged.unwrap();
-        assert!(m.contains("A=99"));
-        assert!(m.contains("C=3"));
-        assert!(m.contains("B=2"));
-        assert!(!m.contains("\nA=1\n"));
-        over.insert("A".to_string(), "100".to_string());
-        let (_, ck_changed) = build_checksum(&dir, None, &over).unwrap();
-        assert_ne!(with_over, ck_changed);
-        fs::remove_dir_all(&dir).ok();
-    }
-
-    #[test]
-    fn build_checksum_profile_merges_over_base_and_under_overrides() {
-        let dir = temp_test_dir("profile");
-        fs::write(dir.join("versions.env"), "A=1\nB=2\nC=3\n").unwrap();
-        fs::create_dir_all(dir.join("profiles")).unwrap();
-        fs::write(
-            dir.join("profiles").join("gen.env"),
-            "# profile pins (comment line ignored)\nA=10\nC=30\n",
-        )
-        .unwrap();
-        // profile alone
-        let (merged, with_profile) = build_checksum(&dir, Some("gen"), &HashMap::new()).unwrap();
-        let m = merged.unwrap();
-        assert!(m.contains("A=10"), "profile wins over base: {m}");
-        assert!(m.contains("C=30"), "{m}");
-        assert!(m.contains("B=2"), "base key untouched: {m}");
-        let base = build_checksum(&dir, None, &HashMap::new()).unwrap().1;
-        assert_ne!(base, with_profile, "profile changes the checksum");
-        // recipe override still wins over the profile
-        let mut over = HashMap::new();
-        over.insert("A".to_string(), "99".to_string());
-        let (merged, with_both) = build_checksum(&dir, Some("gen"), &over).unwrap();
-        let m = merged.unwrap();
-        assert!(m.contains("A=99"), "recipe wins over profile: {m}");
-        assert!(m.contains("C=30"));
-        assert_ne!(with_profile, with_both);
-        // a profile edit changes the checksum (the file is in the build dir,
-        // so the dir checksum moves too)
-        fs::write(dir.join("profiles").join("gen.env"), "A=10\nC=31\n").unwrap();
-        let (_, edited) = build_checksum(&dir, Some("gen"), &HashMap::new()).unwrap();
-        assert_ne!(with_profile, edited);
-        fs::remove_dir_all(&dir).ok();
-    }
-
-    #[test]
-    fn build_checksum_missing_profile_lists_available() {
-        let dir = temp_test_dir("profile_missing");
-        fs::write(dir.join("versions.env"), "A=1\n").unwrap();
-        fs::create_dir_all(dir.join("profiles")).unwrap();
-        fs::write(dir.join("profiles").join("gen.env"), "A=10\n").unwrap();
-        let err = build_checksum(&dir, Some("nope"), &HashMap::new())
-            .unwrap_err()
-            .to_string();
-        assert!(err.contains("build profile 'nope' not found"), "{err}");
-        assert!(
-            err.contains("gen.env"),
-            "error lists available profiles: {err}"
-        );
-        fs::remove_dir_all(&dir).ok();
-    }
-}
+#[path = "build_tests.rs"]
+mod tests;

+ 158 - 0
src/docker/build_tests.rs

@@ -0,0 +1,158 @@
+use super::format_elapsed;
+use crate::docker::build_dir::{build_checksum, parse_versions_env, serialize_versions_env};
+use std::collections::HashMap;
+use std::fs;
+use std::path::PathBuf;
+use std::time::Duration;
+
+#[test]
+fn registry_refs_version_and_latest() {
+    use crate::docker::push::registry_refs;
+    let (v, l) = registry_refs("ghcr.io/kilork", "glm53-jovian-r35:local", "ead6e95a333d").unwrap();
+    assert_eq!(v, "ghcr.io/kilork/glm53-jovian-r35:ead6e95a333d");
+    assert_eq!(l, "ghcr.io/kilork/glm53-jovian-r35:latest");
+    // slashed image name → last segment; base slash-trimmed
+    let (v, _) = registry_refs("ghcr.io/kilork/", "foo/bar:1.2", "abc123").unwrap();
+    assert_eq!(v, "ghcr.io/kilork/bar:abc123");
+    // empty base/version → None
+    assert_eq!(registry_refs("", "img:tag", "abc"), None);
+    assert_eq!(registry_refs("ghcr.io/k", "img:tag", ""), None);
+}
+
+fn temp_test_dir(name: &str) -> PathBuf {
+    let p = std::env::temp_dir().join(format!("sm121-build-test-{}-{}", std::process::id(), name));
+    fs::remove_dir_all(&p).ok();
+    fs::create_dir_all(&p).unwrap();
+    p
+}
+
+#[test]
+fn format_elapsed_seconds_and_minutes() {
+    assert_eq!(format_elapsed(Duration::from_secs(0)), "0s");
+    assert_eq!(format_elapsed(Duration::from_secs(30)), "30s");
+    assert_eq!(format_elapsed(Duration::from_secs(59)), "59s");
+    assert_eq!(format_elapsed(Duration::from_secs(60)), "1m 00s");
+    assert_eq!(format_elapsed(Duration::from_secs(90)), "1m 30s");
+    assert_eq!(format_elapsed(Duration::from_secs(125)), "2m 05s");
+}
+
+#[test]
+fn parse_versions_env_skips_comments_and_blanks() {
+    let raw = "# comment\n\nKEY=val\nOTHER=a=b\n  SPACED  =  x  \nnoeq\n";
+    let pins = parse_versions_env(raw);
+    assert_eq!(pins.get("KEY"), Some(&"val".to_string()));
+    assert_eq!(pins.get("OTHER"), Some(&"a=b".to_string()));
+    assert_eq!(pins.get("SPACED"), Some(&"x".to_string()));
+    assert!(!pins.contains_key("noeq"));
+    assert_eq!(pins.len(), 3);
+}
+
+#[test]
+fn parse_versions_env_quotes_are_normalized() {
+    // quoted / unquoted / single-quoted forms of the same value must
+    // resolve identically (stable checksums across edits); inner quotes
+    // and non-matching outer quotes stay verbatim.
+    let raw = "A=\"x:tag@sha256:abc\"\nB='y'\nC=z\nD=\"m@n\"'\"op\"'\nE=\"half\nF='weird\"\n";
+    let pins = parse_versions_env(raw);
+    assert_eq!(pins.get("A"), Some(&"x:tag@sha256:abc".to_string()));
+    assert_eq!(pins.get("B"), Some(&"y".to_string()));
+    assert_eq!(pins.get("C"), Some(&"z".to_string()));
+    assert_eq!(pins.get("D"), Some(&"\"m@n\"'\"op\"'".to_string())); // mismatched outer quotes stay
+    assert_eq!(pins.get("E"), Some(&"\"half".to_string()));
+    assert_eq!(pins.get("F"), Some(&"'weird\"".to_string()));
+}
+
+#[test]
+fn serialize_versions_env_sorted_one_per_line() {
+    let mut pins = std::collections::BTreeMap::new();
+    pins.insert("B".into(), "2".into());
+    pins.insert("A".into(), "1".into());
+    let s = serialize_versions_env(&pins);
+    assert_eq!(
+        s,
+        "# Merged versions.env \u{2014} generated by sm121 (profile + recipe [build.versions] applied)\nA=1\nB=2\n"
+    );
+}
+
+#[test]
+fn build_checksum_no_profile_no_overrides_returns_none_and_dir_checksum() {
+    let dir = temp_test_dir("no_overrides");
+    fs::write(dir.join("versions.env"), "A=1\nB=2\n").unwrap();
+    let (merged, ck) = build_checksum(&dir, None, &HashMap::new()).unwrap();
+    assert!(merged.is_none());
+    let (_, ck2) = build_checksum(&dir, None, &HashMap::new()).unwrap();
+    assert_eq!(ck, ck2);
+    fs::remove_dir_all(&dir).ok();
+}
+
+#[test]
+fn build_checksum_overrides_change_checksum_and_produce_merged() {
+    let dir = temp_test_dir("overrides");
+    fs::write(dir.join("versions.env"), "A=1\nB=2\n").unwrap();
+    let no_over = build_checksum(&dir, None, &HashMap::new()).unwrap().1;
+    let mut over = HashMap::new();
+    over.insert("A".to_string(), "99".to_string());
+    over.insert("C".to_string(), "3".to_string());
+    let (merged, with_over) = build_checksum(&dir, None, &over).unwrap();
+    assert_ne!(no_over, with_over);
+    let m = merged.unwrap();
+    assert!(m.contains("A=99"));
+    assert!(m.contains("C=3"));
+    assert!(m.contains("B=2"));
+    assert!(!m.contains("\nA=1\n"));
+    over.insert("A".to_string(), "100".to_string());
+    let (_, ck_changed) = build_checksum(&dir, None, &over).unwrap();
+    assert_ne!(with_over, ck_changed);
+    fs::remove_dir_all(&dir).ok();
+}
+
+#[test]
+fn build_checksum_profile_merges_over_base_and_under_overrides() {
+    let dir = temp_test_dir("profile");
+    fs::write(dir.join("versions.env"), "A=1\nB=2\nC=3\n").unwrap();
+    fs::create_dir_all(dir.join("profiles")).unwrap();
+    fs::write(
+        dir.join("profiles").join("gen.env"),
+        "# profile pins (comment line ignored)\nA=10\nC=30\n",
+    )
+    .unwrap();
+    // profile alone
+    let (merged, with_profile) = build_checksum(&dir, Some("gen"), &HashMap::new()).unwrap();
+    let m = merged.unwrap();
+    assert!(m.contains("A=10"), "profile wins over base: {m}");
+    assert!(m.contains("C=30"), "{m}");
+    assert!(m.contains("B=2"), "base key untouched: {m}");
+    let base = build_checksum(&dir, None, &HashMap::new()).unwrap().1;
+    assert_ne!(base, with_profile, "profile changes the checksum");
+    // recipe override still wins over the profile
+    let mut over = HashMap::new();
+    over.insert("A".to_string(), "99".to_string());
+    let (merged, with_both) = build_checksum(&dir, Some("gen"), &over).unwrap();
+    let m = merged.unwrap();
+    assert!(m.contains("A=99"), "recipe wins over profile: {m}");
+    assert!(m.contains("C=30"));
+    assert_ne!(with_profile, with_both);
+    // a profile edit changes the checksum (the file is in the build dir,
+    // so the dir checksum moves too)
+    fs::write(dir.join("profiles").join("gen.env"), "A=10\nC=31\n").unwrap();
+    let (_, edited) = build_checksum(&dir, Some("gen"), &HashMap::new()).unwrap();
+    assert_ne!(with_profile, edited);
+    fs::remove_dir_all(&dir).ok();
+}
+
+#[test]
+fn build_checksum_missing_profile_lists_available() {
+    let dir = temp_test_dir("profile_missing");
+    fs::write(dir.join("versions.env"), "A=1\n").unwrap();
+    fs::create_dir_all(dir.join("profiles")).unwrap();
+    fs::write(dir.join("profiles").join("gen.env"), "A=10\n").unwrap();
+    let err = build_checksum(&dir, Some("nope"), &HashMap::new())
+        .unwrap_err()
+        .to_string();
+    assert!(err.contains("build profile 'nope' not found"), "{err}");
+    assert!(
+        err.contains("gen.env"),
+        "error lists available profiles: {err}"
+    );
+    fs::remove_dir_all(&dir).ok();
+}

+ 179 - 59
src/docker/push.rs

@@ -1,58 +1,180 @@
 //! Post-build registry push (`[registry]` config section).
+//!
+//! Images are pushed as **version tags**, not `:local`: the version is the
+//! image's build checksum (recursive SHA-256 of the image dir, the same
+//! value stamped as the `sm121.checksum` label) — identical content always
+//! maps to the identical tag, so re-publishing "the same thing" cannot
+//! create a duplicate; changed content is a new version. Each push also
+//! refreshes the mutable `latest` tag.
+//!
+//! A local publication ledger (`published.json` next to the global config)
+//! records what was published and with which image digest: a re-push with
+//! an unchanged digest is skipped (with a note) unless forced. The push
+//! itself runs in a tmux session on the build node — a 30 GB push must not
+//! ride on a single SSH session — polled with the same 30 s / 90 min loop
+//! as the build. The launcher stores no registry credentials: the node must
+//! be `docker login`-ed once.
 
 use super::build::{DEFAULT_MAX_BUILD_SECS, format_elapsed};
 use super::shq;
 use crate::config::{Config, Node};
 use crate::ssh::run_ssh;
 use anyhow::Result;
+use std::collections::HashMap;
 use tracing::info;
 
-/// Push a freshly built image to the configured `[registry]` (no-op when
-/// the section is absent). Runs `docker push` in a tmux session on the
-/// build node — a 30 GB push must not ride on a single SSH session — and
-/// polls it the same way the build itself is polled (30 s loop, 90 min cap).
-pub async fn push_image_on_node(
+/// One published image: its version tag, the local image digest it was
+/// published from, and when. Keyed by the repo name (the image name without
+/// its tag).
+#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
+pub struct PublishedEntry {
+    pub version: String,
+    pub image_id: String,
+    pub published_at: String,
+}
+
+/// Ledger file: `<global config dir>/published.json`.
+fn ledger_path() -> Option<std::path::PathBuf> {
+    crate::config::default_config_dir().map(|d| d.join("published.json"))
+}
+
+fn read_published() -> HashMap<String, PublishedEntry> {
+    let Some(p) = ledger_path() else {
+        return HashMap::new();
+    };
+    std::fs::read_to_string(p)
+        .ok()
+        .and_then(|s| serde_json::from_str(&s).ok())
+        .unwrap_or_default()
+}
+
+fn record_published(name: &str, entry: &PublishedEntry) {
+    let Some(p) = ledger_path() else {
+        return;
+    };
+    let mut m = read_published();
+    m.insert(name.to_string(), entry.clone());
+    if let Some(parent) = p.parent() {
+        let _ = std::fs::create_dir_all(parent);
+    }
+    let _ = std::fs::write(&p, serde_json::to_string_pretty(&m).unwrap_or_default());
+}
+
+/// Map a local image to the two pushed refs: `{base}/{name}:{version}` and
+/// `{base}/{name}:latest`. `None` when the mapping is meaningless (empty
+/// base, image name, or version).
+pub(crate) fn registry_refs(
+    base: &str,
+    image_tag: &str,
+    version: &str,
+) -> Option<(String, String)> {
+    let base = base.trim().trim_end_matches('/');
+    let name = image_tag.trim();
+    let version = version.trim();
+    if base.is_empty() || name.is_empty() || version.is_empty() {
+        return None;
+    }
+    let name_part = name.rsplit_once(':').map(|(n, _)| n).unwrap_or(name);
+    let repo = name_part.rsplit('/').next()?.trim();
+    if repo.is_empty() {
+        return None;
+    }
+    Some((
+        format!("{base}/{repo}:{version}"),
+        format!("{base}/{repo}:latest"),
+    ))
+}
+
+/// Push a built image to the configured `[registry]` (no-op when the section
+/// is absent). `version` = the build checksum (first 12 hex chars); both the
+/// version tag and `latest` are pushed. Skips (with a note) when the ledger
+/// records this exact image digest under the same version, unless `force`.
+pub async fn push_image(
     config: &Config,
     node: &Node,
     image_tag: &str,
-    recipe_name: &str,
+    version: &str,
+    force: bool,
+    label: &str,
 ) -> Result<()> {
     let Some(registry) = config.registry.as_ref() else {
         return Ok(());
     };
-    let Some(push_ref) = registry_ref(&registry.base, image_tag) else {
+    let Some((version_ref, latest_ref)) = registry_refs(&registry.base, image_tag, version) else {
         anyhow::bail!(
             "{}",
-            fluent_i18n::t!("push-mapping-failed", { "base" => registry.base.clone(), "image" => image_tag.to_string() })
+            fluent_i18n::t!("push-mapping-failed", {
+                "base" => registry.base.clone(),
+                "image" => image_tag.to_string()
+            })
         );
     };
     let host = registry.base.split('/').next().unwrap_or(&registry.base);
+    let repo_name = latest_ref
+        .rsplit_once(":latest")
+        .map(|(repo, _)| repo.to_string())
+        .unwrap_or_else(|| image_tag.to_string());
+
+    // Duplicate guard: ledger says this exact image was already published
+    // under this version → skip unless explicitly forced.
+    let image_id = run_ssh(
+        config,
+        node,
+        format!(
+            "docker image inspect --format '{{{{.Id}}}}' {} 2>/dev/null || echo MISSING",
+            shq(image_tag)
+        ),
+        false,
+    )
+    .await
+    .unwrap_or_default()
+    .trim()
+    .to_string();
+    if image_id.is_empty() || image_id == "MISSING" {
+        let node_name = node.name.clone();
+        anyhow::bail!("image '{image_tag}' not found on '{node_name}' — build it first");
+    }
+    if !force
+        && let Some(e) = read_published().get(&repo_name)
+        && e.image_id == image_id
+        && e.version == version.trim()
+    {
+        info!(
+            "{}",
+            fluent_i18n::t!("push-already-published", {
+                "ref" => version_ref.clone(),
+                "label" => label.to_string()
+            })
+        );
+        return Ok(());
+    }
 
-    let session_name = format!("sm121-push-{}", recipe_name);
-    let logfile = format!("/tmp/{}-push.log", recipe_name);
+    let session_name = format!("sm121-push-{label}");
+    let logfile = format!("/tmp/{label}-push.log");
+    let marker = format!("Push done: {version_ref}");
 
     run_ssh(
         config,
         node,
         format!(
-            "tmux kill-session -t {} 2>/dev/null || true",
-            shq(&session_name)
+            "tmux kill-session -t {s} 2>/dev/null || true",
+            s = shq(&session_name)
         ),
         false,
     )
     .await?;
-    let marker = format!("Push done: {}", push_ref);
     let tmux_cmd = format!(
-        "tmux new-session -d -s {} \"docker push {} > {} 2>&1 && echo '{}' >> {}\"",
-        shq(&session_name),
-        shq(&push_ref),
-        shq(&logfile),
-        shq(&marker),
-        shq(&logfile)
+        "tmux new-session -d -s {s} \"docker push {a} > {log} 2>&1 && docker push {b} >> {log} 2>&1 \
+         && echo '{m}' >> {log}\"",
+        s = shq(&session_name),
+        a = shq(&version_ref),
+        b = shq(&latest_ref),
+        m = shq(&marker),
+        log = shq(&logfile)
     );
     info!(
         "{}",
-        fluent_i18n::t!("pushing-image", { "ref" => &push_ref, "session" => &session_name })
+        fluent_i18n::t!("pushing-image", { "ref" => version_ref.clone(), "session" => session_name.clone() })
     );
     run_ssh(config, node, tmux_cmd, false).await?;
 
@@ -64,11 +186,11 @@ pub async fn push_image_on_node(
             anyhow::bail!(
                 "{}",
                 fluent_i18n::t!("push-timeout", {
-                    "ref" => &push_ref,
-                    "node" => &node.name,
+                    "ref" => version_ref.clone(),
+                    "node" => node.name.clone(),
                     "elapsed" => format_elapsed(elapsed),
-                    "session" => &session_name,
-                    "log" => &logfile
+                    "session" => session_name.clone(),
+                    "log" => logfile.clone()
                 })
             );
         }
@@ -76,8 +198,8 @@ pub async fn push_image_on_node(
             config,
             node,
             format!(
-                "tmux has-session -t {} 2>&1 || echo DEAD",
-                shq(&session_name)
+                "tmux has-session -t {s} 2>&1 || echo DEAD",
+                s = shq(&session_name)
             ),
             false,
         )
@@ -87,8 +209,8 @@ pub async fn push_image_on_node(
             config,
             node,
             format!(
-                "tail -10 {} 2>/dev/null || echo '(no log yet)'",
-                shq(&logfile)
+                "tail -10 {log} 2>/dev/null || echo '(no log yet)'",
+                log = shq(&logfile)
             ),
             false,
         )
@@ -103,9 +225,9 @@ pub async fn push_image_on_node(
                 config,
                 node,
                 format!(
-                    "grep -F -- {} {} >/dev/null 2>&1 && echo OK || echo NO",
-                    shq(&marker),
-                    shq(&logfile)
+                    "grep -F -- {m} {log} >/dev/null 2>&1 && echo OK || echo NO",
+                    m = shq(&marker),
+                    log = shq(&logfile)
                 ),
                 false,
             )
@@ -117,8 +239,8 @@ pub async fn push_image_on_node(
                     config,
                     node,
                     format!(
-                        "grep -oE 'digest: sha256:[0-9a-f]+ size: [0-9]+' {} | tail -1",
-                        shq(&logfile)
+                        "grep -oE 'digest: sha256:[0-9a-f]+ size: [0-9]+' {log} | tail -1",
+                        log = shq(&logfile)
                     ),
                     false,
                 )
@@ -128,22 +250,39 @@ pub async fn push_image_on_node(
                     .trim()
                     .lines()
                     .last()
-                    .map(|d| format!(" — {}", d))
+                    .map(|d| format!(" — {d}"))
                     .unwrap_or_default();
                 info!(
                     "{}{}",
-                    fluent_i18n::t!("pushed-image", { "ref" => &push_ref, "elapsed" => format_elapsed(elapsed) }),
+                    fluent_i18n::t!("pushed-image", {
+                        "ref" => version_ref.clone(),
+                        "latest" => latest_ref.clone(),
+                        "elapsed" => format_elapsed(elapsed)
+                    }),
                     digest_suffix
                 );
+                record_published(
+                    &repo_name,
+                    &PublishedEntry {
+                        version: version.trim().to_string(),
+                        image_id,
+                        published_at: crate::stamp::utc_stamp(),
+                    },
+                );
             } else {
-                let exit_log = run_ssh(config, node, format!("tail -20 {}", shq(&logfile)), false)
-                    .await
-                    .unwrap_or_default();
+                let exit_log = run_ssh(
+                    config,
+                    node,
+                    format!("tail -20 {log}", log = shq(&logfile)),
+                    false,
+                )
+                .await
+                .unwrap_or_default();
                 anyhow::bail!(
                     "{}",
                     fluent_i18n::t!("push-failed", {
-                        "ref" => &push_ref,
-                        "node" => &node.name,
+                        "ref" => version_ref.clone(),
+                        "node" => node.name.clone(),
                         "elapsed" => format_elapsed(elapsed),
                         "log" => exit_log.trim().to_string(),
                         "host" => host.to_string()
@@ -158,22 +297,3 @@ pub async fn push_image_on_node(
         );
     }
 }
-
-/// Map a local `name:tag` image to `{base}/{repo}:{tag}` (`repo` = the
-/// image name's last path segment; an untagged image pushes as `latest`).
-/// `None` when the mapping is meaningless (empty base or image name).
-pub(crate) fn registry_ref(base: &str, image_tag: &str) -> Option<String> {
-    let base = base.trim().trim_end_matches('/');
-    if base.is_empty() || image_tag.trim().is_empty() {
-        return None;
-    }
-    let (name, tag) = match image_tag.rsplit_once(':') {
-        Some((n, t)) if !t.trim().is_empty() && !t.contains('/') => (n, t),
-        _ => (image_tag, "latest"),
-    };
-    let repo = name.trim().rsplit('/').next()?.trim();
-    if repo.is_empty() {
-        return None;
-    }
-    Some(format!("{}/{repo}:{tag}", base))
-}

+ 9 - 2
src/main.rs

@@ -202,8 +202,15 @@ async fn main() -> Result<()> {
                 a.cluster.as_deref(),
                 a.out,
                 a.gist,
-                a.registry.as_deref(),
-                a.token.as_deref(),
+                &publish::RecipeRegistryOpts {
+                    url: a.registry.clone(),
+                    token: a.token.clone(),
+                },
+                publish::PushOpts {
+                    push: a.push,
+                    node: a.node.clone(),
+                    force: a.force,
+                },
             )
             .await?;
         }

+ 144 - 5
src/publish.rs

@@ -12,21 +12,34 @@ use std::collections::BTreeMap;
 use std::path::{Path, PathBuf};
 use tracing::info;
 
-use crate::config::{Config, recipe_dirs};
+use crate::config::{Config, Recipe, recipe_dirs};
 use crate::docker::build_dir::{build_checksum, flatten_overlay};
 use crate::ssh::shq;
 
 /// Publish `recipe_name` into `out` (default `./publish/<name>`), optionally
 /// creating a gist. `cluster` (optional) supplies fabric env + node count in
 /// the generated run script.
+pub struct PushOpts {
+    pub push: bool,
+    pub node: Option<String>,
+    pub force: bool,
+}
+
+/// The (separate) recipe-bundle registry: where to upload the bundle and with
+/// which token. Not to be confused with the image  push target.
+pub struct RecipeRegistryOpts {
+    pub url: Option<String>,
+    pub token: Option<String>,
+}
+
 pub async fn publish(
     config: &Config,
     recipe_name: &str,
     cluster: Option<&str>,
     out: Option<PathBuf>,
     gist: bool,
-    registry: Option<&str>,
-    token: Option<&str>,
+    recipe_registry: &RecipeRegistryOpts,
+    push_opts: PushOpts,
 ) -> Result<()> {
     let recipe = crate::config::load_recipe(recipe_name, &config.recipe_sources).await?;
     if recipe.deploy != crate::config::DeployTarget::Docker {
@@ -161,8 +174,9 @@ pub async fn publish(
         ),
     )?;
 
-    if let Some(url) = registry {
-        crate::registry::upload(&bundle, recipe_name, url, token).await?;
+    if let Some(url) = &recipe_registry.url {
+        crate::registry::upload(&bundle, recipe_name, url, recipe_registry.token.as_deref())
+            .await?;
     }
 
     if gist {
@@ -196,8 +210,133 @@ pub async fn publish(
             _ => info!("ℹ️  could not auto-update install.sh — set GIST_ID={id} manually"),
         }
     }
+
+    if push_opts.push {
+        push_recipe_image(config, &recipe, &push_opts).await?;
+    }
     Ok(())
 }
+
+/// `publish --push`: push the recipe's image to the `[registry]` registry
+/// under its version tag (the `sm121.checksum` label, or the image ID when
+/// unlabeled) + `latest`. The image is found on `--node` or by probing all
+/// cluster nodes; the push runs from that node.
+async fn push_recipe_image(config: &Config, recipe: &Recipe, opts: &PushOpts) -> Result<()> {
+    if config.registry.is_none() {
+        anyhow::bail!(
+            "publish --push requires a [registry] section in config.toml (base = ghcr.io/<you>)"
+        );
+    }
+    let image = recipe.docker_image.trim();
+    if image.is_empty() {
+        anyhow::bail!(
+            "recipe '{}' has no docker_image — nothing to push",
+            recipe.name
+        );
+    }
+    // 1. Find a node that has the image.
+    let image = image.to_string();
+    let found: Option<crate::config::Node> = if let Some(want) = &opts.node {
+        let node = config
+            .clusters
+            .iter()
+            .flat_map(|c| c.nodes.iter())
+            .find(|n| n.name.as_str() == want.as_str())
+            .with_context(|| format!("node '{want}' not in any cluster"))?
+            .clone();
+        let id = crate::ssh::run_ssh(
+            config,
+            &node,
+            format!(
+                "docker image ls --format '{{{{.ID}}}}' {img} | head -1",
+                img = shq(&image)
+            ),
+            false,
+        )
+        .await
+        .unwrap_or_default();
+        if id.trim().is_empty() {
+            None
+        } else {
+            Some(node)
+        }
+    } else {
+        let mut seen = std::collections::HashSet::new();
+        let all: Vec<crate::config::Node> = config
+            .clusters
+            .iter()
+            .flat_map(|c| c.nodes.iter().cloned())
+            .filter(|n| seen.insert(n.ssh_ip.clone()))
+            .collect();
+        let handles = all.iter().map(|node| {
+            let config = config.clone();
+            let node = node.clone();
+            let image = image.clone();
+            crate::locale::spawn_with_locale(async move {
+                let id = crate::ssh::run_ssh(
+                    &config,
+                    &node,
+                    format!(
+                        "docker image ls --format '{{{{.ID}}}}' {img} | head -1",
+                        img = shq(&image)
+                    ),
+                    false,
+                )
+                .await
+                .unwrap_or_default();
+                (node.name.clone(), id.trim().to_string())
+            })
+        });
+        let results = futures::future::join_all(handles).await;
+        all.into_iter()
+            .zip(results)
+            .find_map(|(node, h)| h.ok().filter(|(_, id)| !id.is_empty()).map(|_| node))
+    };
+    let Some(node) = found else {
+        anyhow::bail!(
+            "image '{image}' not found on {} — build it first (sm121 build --recipe {} --build-node <n>)",
+            opts.node
+                .as_deref()
+                .map(|n| format!("'{n}'"))
+                .unwrap_or_else(|| "any node".to_string()),
+            recipe.name
+        );
+    };
+    // 2. Version = the sm121.checksum label (build checksum), else the image ID.
+    let label = crate::ssh::run_ssh(
+        config,
+        &node,
+        format!(
+            "docker inspect --format '{{{{index .Config.Labels \"sm121.checksum\"}}}}' {img} 2>/dev/null || true",
+            img = shq(&image)
+        ),
+        false,
+    )
+    .await
+    .unwrap_or_default();
+    let version = if label.trim().len() >= 12 {
+        label.trim()[..12].to_string()
+    } else {
+        let id = crate::ssh::run_ssh(
+            config,
+            &node,
+            format!(
+                "docker image inspect --format '{{{{.Id}}}}' {img}",
+                img = shq(&image)
+            ),
+            false,
+        )
+        .await
+        .unwrap_or_default();
+        let id = id.trim().strip_prefix("sha256:").unwrap_or(id.trim());
+        id.get(..12).unwrap_or(id).to_string()
+    };
+    info!(
+        "publish --push: pushing '{image}' v{version} from '{}'",
+        node.name
+    );
+    crate::docker::push::push_image(config, &node, &image, &version, opts.force, "publish").await
+}
 /// Parse `versions.env` (`KEY=VAL` lines, `#` comments, optional quotes) into
 /// `--build-arg` pairs.
 pub(crate) fn versions_build_args(text: &str) -> BTreeMap<String, String> {