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
+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);
}