From 744d157b019eb9756c85ecd7c8ae3c89612eae82 Mon Sep 17 00:00:00 2001 From: Yvv Date: Wed, 21 Oct 2026 21:30:52 +0200 Subject: [PATCH] =?UTF-8?q?v3=20:=20publication=20=E2=80=94=20un=20envoi?= =?UTF-8?q?=20refus=C3=A9=20repart,=20un=20d=C3=A9p=C3=B4t=20neuf=20re?= =?UTF-8?q?=C3=A7oit=20l'historique?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Douzième relecture (850f9c4 : ok), défauts anciens de publish.rs mis en jeu par le nouveau dépôt : - un commit que le dépôt n'a jamais reçu (premier envoi refusé, clé sans droit d'écriture) repart au passage suivant, au lieu d'une file marquée faite avec un dépôt vide ; - un changement de remote vers un dépôt neuf et vide lui envoie l'historique ; - au démarrage, ce qui attendait repart aussitôt (le délai croissant pouvait atteindre 6 h). trustinWallet montre l'état de la publication (en attente, dernier échec, dernier commit) et offre « Publier maintenant ». Boucle : 69 tests Rust, svelte-check, 9 vitest, e2e 13/13. Co-Authored-By: Claude Opus 5.5 --- HANDOFF.md | 9 ++-- crates/ld-server/src/access.rs | 17 ++++++- crates/ld-server/src/lib.rs | 1 + crates/ld-server/src/publish.rs | 61 ++++++++++++++++++++---- crates/ld-server/tests/api.rs | 79 +++++++++++++++++++++++++++++++ docs/adr/0005-publication-git.md | 6 ++- web/src/lib/i18n/en.ts | 8 ++++ web/src/lib/i18n/es.ts | 8 ++++ web/src/lib/i18n/fr.ts | 8 ++++ web/src/routes/acces/+page.svelte | 26 +++++++++- 10 files changed, 208 insertions(+), 15 deletions(-) diff --git a/HANDOFF.md b/HANDOFF.md index e7c4315..2b99522 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -11,7 +11,7 @@ pas encore en ligne : les points des septième, huitième et neuvième relecture (demandés par Yvv le 2026-10-21) et la publication vers git.open.us.org. - v3 Rust + Svelte à la racine du dépôt ; la maquette v2 intacte dans `maquette/`. -- Boucle : Rust (fmt, clippy `-D warnings`, 68 tests dont 15 scénarios d'API et la +- Boucle : Rust (fmt, clippy `-D warnings`, 69 tests dont 16 scénarios d'API et la publication vers un dépôt nu) · interface (svelte-check 0 avertissement, 9 vitest) · e2e 13 / 13 (C1–C12, M1). L'image Docker rejoue tout et a été essayée localement : santé, interface, volume non-root, clé de publication, lien admin initial (0600, absent @@ -22,7 +22,7 @@ pas encore en ligne : les points des septième, huitième et neuvième relecture - Relectures indépendantes avant mise en ligne, chacune traitée avant de livrer : `1f9c241 : ok`, `94907af : ok`, puis quatre « à reprendre » (`ac9c904`, `9203fd2`, `1eac6f8`, `6c0754a`), toutes sur la même question : une « coquille » (sans vote, publiée) qui change un sens, puis - `fa8e8dc : ok` (mis en ligne), `c3d6bf8 : ok`, `fd19dbf : ok`, `753b522 : ok`. La + `fa8e8dc : ok` (mis en ligne), `c3d6bf8 : ok`, `fd19dbf : ok`, `753b522 : ok`, `8a32160 : ok`, `850f9c4 : ok`. La règle est désormais simple et explicable : **une coquille touche les lettres et les espaces, rien d'autre** (CHANTIER.md, ADR 0003). Autres points traités : ordre des workflows, anciens conteneurs en `restart: always`, vote réservé aux membres de la toile dans Monnaie libre (une @@ -40,8 +40,9 @@ pas encore en ligne : les points des septième, huitième et neuvième relecture `yvv/decision` et que le dépôt se transfère d'un bloc (Yvv : « pour l'instant sur open.us ; on fera un dépôt pour l'ouverture, un transfert, et le dépôt officiel pour les textes en vigueur »). Reste à y ajouter la **clé de déploiement** du serveur, **en écriture** (sa partie publique s'affiche dans trustinWallet, - `/acces`, section Publication). Le serveur réessaie toutes les 60 s et fait lui-même le - premier envoi. Changer de dépôt plus tard : `LD_PUBLISH_REMOTE` dans `docker/app-compose.yml`. + `/acces`, section Publication). Le serveur fait lui-même le premier envoi ; en cas d'échec il + réessaie avec un délai croissant (1 min, 2, 4… jusqu'à 6 h), repart d'une minute à chaque + redémarrage, et `/acces` → Publication montre l'état et offre « Publier maintenant ». Changer de dépôt plus tard : `LD_PUBLISH_REMOTE` dans `docker/app-compose.yml`. 3. **Ouvrir trustinWallet** avec le lien administrateur initial (remis hors conversation, un seul usage : il crée l'identité administratrice). Pour l'équipe : un **lien d'équipe** (`/acces` → Liens d'équipe ; chaque personne qui l'ouvre a sa propre identité), ou une diff --git a/crates/ld-server/src/access.rs b/crates/ld-server/src/access.rs index d46b943..d7c7d3d 100644 --- a/crates/ld-server/src/access.rs +++ b/crates/ld-server/src/access.rs @@ -45,10 +45,13 @@ pub async fn overview(State(state): State, User(user): User) -> AppRes .bind(util::now()) .fetch_all(&state.db) .await?; - let publication = json!({ + let mut publication = json!({ "remote": state.cfg.publish.as_ref().map(|p| p.remote.clone()), "publicKey": crate::publish::public_key(&state), }); + if state.cfg.publish.is_some() { + publication["status"] = crate::publish::status(&state).await?; + } Ok(Json(json!({ "publication": publication, "groups": groups.iter().map(|(slug, name, policy, hidden)| json!({ @@ -165,6 +168,18 @@ pub async fn cancel_invitation( Ok(Json(json!({ "ok": true }))) } +/// « Publier maintenant » : a key just added, a remote just created — no need +/// to wait for the backoff. +pub async fn publish_now(State(state): State, User(user): User) -> AppResult> { + admin_only(&user)?; + if state.cfg.publish.is_none() { + return Err(AppError::Conflict("publication_off")); + } + crate::publish::retry_now(&state).await?; + tracing::info!(by = %user.id, "publication retried now"); + Ok(Json(json!({ "ok": true }))) +} + #[derive(Deserialize)] pub struct AdminBody { admin: bool, diff --git a/crates/ld-server/src/lib.rs b/crates/ld-server/src/lib.rs index edecb55..f59df8b 100644 --- a/crates/ld-server/src/lib.rs +++ b/crates/ld-server/src/lib.rs @@ -123,6 +123,7 @@ pub fn api_router() -> Router { .route("/access/groups/{slug}/members/{identity}", delete(access::revoke)) .route("/access/invitations", post(access::invite)) .route("/access/invitations/{hash}", delete(access::cancel_invitation)) + .route("/access/publication/retry", post(access::publish_now)) .route("/access/team-links", post(team::create)) .route("/access/team-links/{hash}", delete(team::revoke)) .route("/access/identities/{id}/admin", post(access::set_admin)) diff --git a/crates/ld-server/src/publish.rs b/crates/ld-server/src/publish.rs index 957410f..b9ad765 100644 --- a/crates/ld-server/src/publish.rs +++ b/crates/ld-server/src/publish.rs @@ -137,8 +137,9 @@ async fn git(dir: &Path, state: &AppState, args: &[&str]) -> anyhow::Result anyhow::Result { +/// commit so a push never needs a merge. Returns the clone and the commit the +/// remote branch points to, if it exists. +async fn checkout(state: &AppState) -> anyhow::Result<(PathBuf, Option)> { let publish = state.cfg.publish.as_ref().ok_or_else(|| anyhow::anyhow!("publication not configured"))?; let dir = state.cfg.data_dir.join("textes"); if !dir.join(".git").exists() { @@ -148,7 +149,8 @@ async fn checkout(state: &AppState) -> anyhow::Result { } git(&dir, state, &["remote", "set-url", "origin", &publish.remote]).await?; let has_remote_branch = git(&dir, state, &["ls-remote", "--heads", "origin", &publish.branch]).await?; - if has_remote_branch.is_empty() { + let remote_head = has_remote_branch.split_whitespace().next().map(str::to_owned); + if remote_head.is_none() { git(&dir, state, &["checkout", "-q", "-f", "-B", &publish.branch]).await?; } else { // This clone is a cache: whatever a failed pass left is re-rendered from the database. @@ -156,14 +158,15 @@ async fn checkout(state: &AppState) -> anyhow::Result { git(&dir, state, &["checkout", "-q", "-f", "-B", &publish.branch, &format!("origin/{}", publish.branch)]) .await?; } - Ok(dir) + Ok((dir, remote_head)) } async fn publish_doc(state: &AppState, doc_id: &str) -> anyhow::Result> { let st = docs::load_state(state, doc_id).await.map_err(|e| anyhow::anyhow!("{e}"))?; let Some(path) = st.doc.publish_path.clone() else { return Ok(None) }; let rendered = render(state, &st).await?; - let dir = checkout(state).await?; + let (dir, remote_head) = checkout(state).await?; + let branch = state.cfg.publish.as_ref().map(|p| p.branch.clone()).unwrap_or_else(|| "main".into()); let target = dir.join(&path); tokio::fs::create_dir_all(&target).await?; for (name, content) in &rendered { @@ -176,7 +179,15 @@ async fn publish_doc(state: &AppState, doc_id: &str) -> anyhow::Result = sqlx::query_scalar("SELECT reason FROM outbox WHERE doc_id = ? AND done_at IS NULL ORDER BY id") @@ -192,7 +203,6 @@ async fn publish_doc(state: &AppState, doc_id: &str) -> anyhow::Result anyhow::Result { /// On start, every published document is queued once more: a change that /// entered into force just before a restart, or a new rendering format, reaches -/// the repository. Unchanged documents produce no commit. +/// the repository. Unchanged documents produce no commit. What was waiting +/// after failures is tried again at once (a key just added, a new remote). pub async fn resync(state: &AppState) -> anyhow::Result<()> { if state.cfg.publish.is_none() { return Ok(()); } + retry_now(state).await?; let now = util::now(); sqlx::query( "INSERT INTO outbox (doc_id, reason, created_at, next_at) @@ -244,6 +256,39 @@ pub async fn resync(state: &AppState) -> anyhow::Result<()> { Ok(()) } +/// Everything still waiting goes out at the next pass, backoff forgotten. +pub async fn retry_now(state: &AppState) -> anyhow::Result<()> { + sqlx::query("UPDATE outbox SET next_at = ?, attempts = 0 WHERE done_at IS NULL") + .bind(util::now()) + .execute(&state.db) + .await?; + state.publish_wake.notify_one(); + Ok(()) +} + +/// What administrators see of the publication in trustinWallet. +pub async fn status(state: &AppState) -> anyhow::Result { + let pending: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM outbox WHERE done_at IS NULL").fetch_one(&state.db).await?; + let error: Option = sqlx::query_scalar( + "SELECT last_error FROM outbox WHERE done_at IS NULL AND last_error IS NOT NULL ORDER BY id DESC LIMIT 1", + ) + .fetch_optional(&state.db) + .await?; + let last: Option<(String, Option)> = sqlx::query_as( + "SELECT done_at, commit_sha FROM outbox WHERE done_at IS NOT NULL AND commit_sha IS NOT NULL + ORDER BY done_at DESC LIMIT 1", + ) + .fetch_optional(&state.db) + .await?; + Ok(json!({ + "pending": pending, + "lastError": error, + "lastPublishedAt": last.as_ref().map(|(at, _)| at.clone()), + "lastCommit": last.and_then(|(_, sha)| sha), + })) +} + /// Markdown per language + JSON. Only the units in force, in order. pub async fn render(state: &AppState, st: &DocState) -> anyhow::Result> { let titles = util::parse_lang(&st.doc.title); diff --git a/crates/ld-server/tests/api.rs b/crates/ld-server/tests/api.rs index 087c20a..a3a12c4 100644 --- a/crates/ld-server/tests/api.rs +++ b/crates/ld-server/tests/api.rs @@ -463,6 +463,11 @@ async fn access_management_is_for_administrators_only() { assert_eq!(s, StatusCode::OK); let (_, ov) = admin.call("GET", "/api/access", None).await; assert!(ov["identities"].as_array().expect("i").iter().any(|i| i["displayName"] == "Chiara")); + // « Publier maintenant » is for administrators, and needs a configured publication. + let (s, _) = user.call("POST", "/api/access/publication/retry", None).await; + assert_eq!(s, StatusCode::FORBIDDEN); + let (s, v) = admin.call("POST", "/api/access/publication/retry", None).await; + assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("publication_off"))); } #[tokio::test] @@ -682,3 +687,77 @@ async fn publication_writes_the_texts_in_force_to_the_repository() { assert_eq!(pending, 0); let _ = std::fs::remove_dir_all(&dir); } + +#[tokio::test] +async fn a_refused_first_push_goes_out_again_and_a_new_empty_repository_receives_it() { + // Tenth independent review (850f9c4), 2026-10-21: a first push refused (a key + // without write access) left the commit in the server's clone, and the next pass, + // finding nothing to commit, marked the queue done with the repository still empty. + use std::process::Command; + let dir = std::env::temp_dir().join(format!("ld-pub-retry-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("dir"); + let bare = |name: &str| { + let path = dir.join(name); + assert!(Command::new("git").args(["init", "-q", "--bare"]).arg(&path).status().expect("git").success()); + path + }; + let config = |remote: &std::path::Path| { + let mut cfg = Config::for_tests(); + cfg.data_dir = dir.join("data"); + cfg.publish = Some(ld_server::config::Publish { + remote: remote.display().to_string(), + branch: "main".into(), + key: dir.join("data/keys/publish_ed25519"), + author_name: "libreDecision".into(), + author_email: "decision@example.org".into(), + }); + cfg + }; + let has_main = |remote: &std::path::Path| { + Command::new("git") + .arg("-C") + .arg(remote) + .args(["rev-parse", "--verify", "-q", "main"]) + .output() + .expect("git") + .status + .success() + }; + let pending = |db: &sqlx::SqlitePool| { + let db = db.clone(); + async move { + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM outbox WHERE done_at IS NULL") + .fetch_one(&db) + .await + .expect("count") + } + }; + + let first = bare("first.git"); + let hook = first.join("hooks/pre-receive"); + std::fs::write(&hook, "#!/bin/sh\nexit 1\n").expect("hook"); + assert!(Command::new("chmod").arg("755").arg(&hook).status().expect("chmod").success()); + let db = open_db("sqlite::memory:").await.expect("db"); + let state = AppState::new(config(&first), db.clone()).await.expect("state"); + seed::ensure(&state).await.expect("seed"); + ld_server::publish::pass(&state).await.expect("pass"); + assert!(!has_main(&first), "the push was refused"); + assert_eq!(pending(&db).await, 1, "the document still waits"); + + // The key gets its write access: the next start sends what waited, at once. + std::fs::remove_file(&hook).expect("unhook"); + ld_server::publish::resync(&state).await.expect("resync"); + ld_server::publish::pass(&state).await.expect("pass"); + assert!(has_main(&first), "the commit kept in the clone went out"); + assert_eq!(pending(&db).await, 0); + + // A change of remote to a new, empty repository: it receives the history. + let second = bare("second.git"); + let moved = AppState::new(config(&second), db.clone()).await.expect("state"); + ld_server::publish::resync(&moved).await.expect("resync"); + ld_server::publish::pass(&moved).await.expect("pass"); + assert!(has_main(&second), "the new repository received the texts"); + assert_eq!(pending(&db).await, 0); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/docs/adr/0005-publication-git.md b/docs/adr/0005-publication-git.md index 161533c..2abdfef 100644 --- a/docs/adr/0005-publication-git.md +++ b/docs/adr/0005-publication-git.md @@ -13,7 +13,11 @@ générée par le serveur (sa partie publique s'affiche dans trustinWallet), et délai croissant. Dépôt : `git.open.us.org/yvv/decision-depot-textes-reference`, un dossier par groupe et par document — provisoire (Yvv, 2026-10-21) : viendront un dépôt pour l'ouverture, par transfert, puis le dépôt officiel pour les textes en vigueur ; changer de dépôt tient en une -ligne (`LD_PUBLISH_REMOTE`), le serveur réaligne son clone. Le français fait foi ; les traductions sont marquées éditoriales. +ligne (`LD_PUBLISH_REMOTE`) : le serveur réaligne son clone, et un dépôt neuf et vide reçoit +l'historique. Un commit que le dépôt n'a jamais reçu (premier envoi refusé, clé sans droit +d'écriture) repart au passage suivant ; au démarrage, ce qui attendait repart aussitôt ; +trustinWallet montre l'état (en attente, dernier échec, dernier commit) et offre « Publier +maintenant ». Le français fait foi ; les traductions sont marquées éditoriales. **Coût.** La publication est asynchrone (quelques secondes, plus en cas de panne) ; l'état de la file se lit dans trustinWallet. diff --git a/web/src/lib/i18n/en.ts b/web/src/lib/i18n/en.ts index 7e803bf..839457e 100644 --- a/web/src/lib/i18n/en.ts +++ b/web/src/lib/i18n/en.ts @@ -316,6 +316,13 @@ export const en: Messages = { 'access.publication.remote': "Repository: {remote}", 'access.publication.key': "The server's deploy key. Add it with write access on this repository (Settings › Repository › Deploy keys): until then, publications wait.", 'access.publication.none': "Publishing is not configured on this server.", + 'access.publication.pending.one': '{n} sending waiting', + 'access.publication.pending.other': '{n} sendings waiting', + 'access.publication.last': 'Last publication: {date} (commit {commit})', + 'access.publication.never': 'Nothing has been published to this repository yet.', + 'access.publication.error': 'Last failure: {error}', + 'access.publication.now': 'Publish now', + 'access.publication.queued': 'Publication started again.', 'access.denied': 'Access management is for trustinWallet administrators only.', 'soon.title': 'Coming soon', @@ -344,6 +351,7 @@ export const en: Messages = { 'error.name_taken': 'This name is already taken. Choose another one, adding an initial for instance.', 'error.team_limits': 'A team link lasts 1 to 90 days, for 1 to 500 people.', 'error.groups': 'Choose at least one group.', + 'error.publication_off': 'Publication is not configured on this server.', 'error.retry': 'Try again', 'common.cancel': 'Cancel', diff --git a/web/src/lib/i18n/es.ts b/web/src/lib/i18n/es.ts index 2567423..3a6aa3b 100644 --- a/web/src/lib/i18n/es.ts +++ b/web/src/lib/i18n/es.ts @@ -316,6 +316,13 @@ export const es: Messages = { 'access.publication.remote': "Repositorio: {remote}", 'access.publication.key': "Clave de despliegue del servidor. Añádela con permiso de escritura en este repositorio (Configuración › Repositorio › Claves de despliegue): sin ella, las publicaciones esperan.", 'access.publication.none': "La publicación no está configurada en este servidor.", + 'access.publication.pending.one': '{n} envío en espera', + 'access.publication.pending.other': '{n} envíos en espera', + 'access.publication.last': 'Última publicación: {date} (commit {commit})', + 'access.publication.never': 'Todavía no se ha publicado nada en este repositorio.', + 'access.publication.error': 'Último fallo: {error}', + 'access.publication.now': 'Publicar ahora', + 'access.publication.queued': 'Publicación relanzada.', 'access.denied': 'La gestión de accesos está reservada a los administradores de trustinWallet.', 'soon.title': 'Próximamente', @@ -344,6 +351,7 @@ export const es: Messages = { 'error.name_taken': 'Este nombre ya está ocupado. Elige otro, añadiendo una inicial por ejemplo.', 'error.team_limits': 'Un enlace de equipo vale de 1 a 90 días, para 1 a 500 personas.', 'error.groups': 'Elige al menos un grupo.', + 'error.publication_off': 'La publicación no está configurada en este servidor.', 'error.retry': 'Reintentar', 'common.cancel': 'Cancelar', diff --git a/web/src/lib/i18n/fr.ts b/web/src/lib/i18n/fr.ts index 6ce3a34..1520a01 100644 --- a/web/src/lib/i18n/fr.ts +++ b/web/src/lib/i18n/fr.ts @@ -319,6 +319,13 @@ export const fr = { 'access.publication.remote': "Dépôt : {remote}", 'access.publication.key': "Clé de déploiement du serveur. Ajoute-la avec le droit d'écriture sur ce dépôt (Paramètres › Dépôt › Clés de déploiement) : sans elle, les publications attendent.", 'access.publication.none': "La publication n'est pas configurée sur ce serveur.", + 'access.publication.pending.one': '{n} envoi en attente', + 'access.publication.pending.other': '{n} envois en attente', + 'access.publication.last': 'Dernière publication : {date} (commit {commit})', + 'access.publication.never': "Rien n'a encore été publié sur ce dépôt.", + 'access.publication.error': 'Dernier échec : {error}', + 'access.publication.now': 'Publier maintenant', + 'access.publication.queued': 'Publication relancée.', 'access.denied': 'La gestion des accès est réservée aux administrateurs de trustinWallet.', 'soon.title': 'À venir', @@ -347,6 +354,7 @@ export const fr = { 'error.name_taken': 'Ce nom est déjà pris. Choisis-en un autre, en ajoutant une initiale par exemple.', 'error.team_limits': "Un lien d'équipe vaut de 1 à 90 jours, pour 1 à 500 personnes.", 'error.groups': 'Choisis au moins un groupe.', + 'error.publication_off': "La publication n'est pas configurée sur ce serveur.", 'error.retry': 'Réessayer', 'common.cancel': 'Annuler', diff --git a/web/src/routes/acces/+page.svelte b/web/src/routes/acces/+page.svelte index dc855e7..24d8c11 100644 --- a/web/src/routes/acces/+page.svelte +++ b/web/src/routes/acces/+page.svelte @@ -7,9 +7,14 @@ import { i18n, t, type Key } from '$lib/i18n/index.svelte'; import { Remote } from '$lib/load.svelte'; import { session } from '$lib/session.svelte'; + import { toasts } from '$lib/toast.svelte'; interface Overview { - publication: { remote: string | null; publicKey: string | null }; + publication: { + remote: string | null; + publicKey: string | null; + status?: { pending: number; lastError: string | null; lastPublishedAt: string | null; lastCommit: string | null }; + }; groups: { slug: string; name: string; policy: string; hidden: boolean; members: { identityId: string; grantedAt: string }[] }[]; identities: { id: string; kind: 'g1' | 'invite' | 'dev'; displayName: string; g1Member: boolean; admin: boolean; lastSeenAt: string | null; teamLink: string | null }[]; invitations: { id: string; label: string; admin: boolean; groups: string[]; expiresAt: string }[]; @@ -57,6 +62,13 @@ }); } + async function publishNow() { + await act(async () => { + await api.post('/access/publication/retry'); + toasts.show(t('access.publication.queued')); + }); + } + // A new sign-in link for an invited person (another device, a lost session). async function relink(id: string, name: string) { await act(async () => { @@ -166,6 +178,15 @@
{#if data.value.publication.remote}

{t('access.publication.remote', { remote: data.value.publication.remote })}

+ {#if data.value.publication.status} + {@const st = data.value.publication.status} +

+ {#if st.lastPublishedAt}{t('access.publication.last', { date: i18n.date(st.lastPublishedAt), commit: (st.lastCommit ?? '').slice(0, 7) })}{:else}{t('access.publication.never')}{/if} + {#if st.pending} · {i18n.plural('access.publication.pending', st.pending)}{/if} +

+ {#if st.pending && st.lastError}

{t('access.publication.error', { error: st.lastError })}

{/if} + {#if st.pending}{/if} + {/if} {#if data.value.publication.publicKey}

{t('access.publication.key')}

{data.value.publication.publicKey} @@ -277,6 +298,9 @@ padding: 0.6rem 0.8rem; border-radius: var(--r-input); } + .err { + word-break: break-word; + } .fb .body { white-space: pre-wrap; }