v3 : publication — un envoi refusé repart, un dépôt neuf reçoit l'historique

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 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-10-21 21:30:52 +02:00
co-authored by Claude Opus 5.5
parent 850f9c46b0
commit 744d157b01
10 changed files with 208 additions and 15 deletions
+5 -4
View File
@@ -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
+16 -1
View File
@@ -45,10 +45,13 @@ pub async fn overview(State(state): State<AppState>, 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<AppState>, User(user): User) -> AppResult<Json<serde_json::Value>> {
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,
+1
View File
@@ -123,6 +123,7 @@ pub fn api_router() -> Router<AppState> {
.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))
+53 -8
View File
@@ -137,8 +137,9 @@ async fn git(dir: &Path, state: &AppState, args: &[&str]) -> anyhow::Result<Stri
}
/// The server's own clone: a cache of the remote, realigned on it before each
/// commit so a push never needs a merge.
async fn checkout(state: &AppState) -> anyhow::Result<PathBuf> {
/// 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<String>)> {
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<PathBuf> {
}
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<PathBuf> {
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<Option<String>> {
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<Option<St
git(&dir, state, &["add", "-A", "--", &path, "README.md"]).await?;
let staged = git(&dir, state, &["diff", "--cached", "--name-only"]).await?;
if staged.is_empty() {
return Ok(None);
// Nothing new — but a commit the remote never received still goes out: a
// first push refused (a key without write access), or a new, empty
// repository after a change of remote.
let Ok(head) = git(&dir, state, &["rev-parse", "--verify", "-q", "HEAD"]).await else { return Ok(None) };
if remote_head.as_deref() == Some(head.as_str()) {
return Ok(None);
}
git(&dir, state, &["push", "-q", "origin", &format!("HEAD:{branch}")]).await?;
return Ok(Some(head));
}
let reasons: Vec<String> =
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<Option<St
}
}
git(&dir, state, &["commit", "-q", "-m", &message]).await?;
let branch = state.cfg.publish.as_ref().map(|p| p.branch.clone()).unwrap_or_else(|| "main".into());
git(&dir, state, &["push", "-q", "origin", &format!("HEAD:{branch}")]).await?;
Ok(Some(git(&dir, state, &["rev-parse", "HEAD"]).await?))
}
@@ -225,11 +235,13 @@ async fn last_change(state: &AppState, doc_id: &str) -> anyhow::Result<String> {
/// 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<serde_json::Value> {
let pending: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM outbox WHERE done_at IS NULL").fetch_one(&state.db).await?;
let error: Option<String> = 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<String>)> = 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<Vec<(String, String)>> {
let titles = util::parse_lang(&st.doc.title);
+79
View File
@@ -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);
}
+5 -1
View File
@@ -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.
+8
View File
@@ -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',
+8
View File
@@ -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',
+8
View File
@@ -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',
+25 -1
View File
@@ -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 @@
<div class="card block">
{#if data.value.publication.remote}
<p class="small">{t('access.publication.remote', { remote: data.value.publication.remote })}</p>
{#if data.value.publication.status}
{@const st = data.value.publication.status}
<p class="small">
{#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}
</p>
{#if st.pending && st.lastError}<p class="small faint err">{t('access.publication.error', { error: st.lastError })}</p>{/if}
{#if st.pending}<button class="btn small" type="button" onclick={publishNow}>{t('access.publication.now')}</button>{/if}
{/if}
{#if data.value.publication.publicKey}
<p class="small muted">{t('access.publication.key')}</p>
<code class="key">{data.value.publication.publicKey}</code>
@@ -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;
}