Sync GitHub ↔ OneDev, API Rust et dashboard Astro : une journée de homelab


Quand on self-host, on finit toujours par avoir des trucs qui vivent dans des coins différents. Les repos Git en sont l’exemple parfait : une forge locale (OneDev, dans mon cas) pour le contrôle et la CI/CD, GitHub pour la visibilité publique et les mirrors. Sauf que maintenir ça à la main, c’est la garantie d’avoir des dépôts qui divergent en silence pendant des semaines.

La solution évidente : automatiser la synchronisation. Moins évident : faire ça proprement.

La forge locale : OneDev

OneDev est une forge Git auto-hébergée — Git + CI/CD + issue tracker dans un seul container Docker. Pas de marketplace de plugins à payer, pas de quotas absurdes, ça tourne sur un vieux NAS Unraid sans se plaindre.

Le problème avec GitHub en parallèle, c’est que les deux dépôts évoluent indépendamment. Un commit fait sur GitHub depuis l’interface web, un push fait depuis OneDev — et les deux forks divergent. Il faut un arbitre.

Le script de sync bidirectionnel

La logique est simple : pour chaque repo, on compare les timestamps du dernier commit sur chaque plateforme, et on pousse dans le sens qui va vers le moins récent. Si un repo n’existe que d’un côté, on le crée de l’autre et on le pousse.

onedev_date=$(git -C "$dir/onedev" log -1 --format="%ct" 2>/dev/null || echo 0)
github_date=$(git -C "$dir/github" log -1 --format="%ct" 2>/dev/null || echo 0)

if [ "$onedev_date" -ge "$github_date" ]; then
  push_all "$dir/onedev" "$github_git"
else
  push_all "$dir/github" "$onedev_git"
fi

push_all pousse chaque branche et chaque tag individuellement avec --force. On évite --mirror qui écrase les remote refs et crée des situations bizarres sur des repos existants.

Les deux APIs sont interrogées au démarrage :

onedev_repos=$(curl -s -u "$ONEDEV_CREDS" \
  "$ONEDEV_URL/~api/projects?offset=0&count=200" | \
  python3 -c "import sys,json; [print(r['name']) for r in json.load(sys.stdin)]")

github_repos=$(curl -s -H "Authorization: Bearer $GITHUB_TOKEN" \
  "https://api.github.com/user/repos?per_page=100&type=owner" | \
  python3 -c "import sys,json; [print(r['name']) for r in json.load(sys.stdin)]")

On calcule l’union des deux listes, on trie, et on boucle. Pour la création côté GitHub, l’API REST suffit. Côté OneDev, pareil mais avec POST /~api/projects.

Quelques pièges :

  • Cataclysm-DDA : un fork de 12 Go. Sans liste d’exclusion, le script essaie de cloner 12 Go dans /tmp et sature le disque. Solution : variable EXCLUDE_LIST.
  • Instances parallèles : le cron lance le script toutes les heures, mais si le précédent tourne encore, on se retrouve avec deux instances qui clonent les mêmes repos. Solution : lockfile avec vérification PID.
LOCKFILE="/tmp/git-sync.lock"
if [ -f "$LOCKFILE" ] && kill -0 $(cat "$LOCKFILE") 2>/dev/null; then
  exit 0
fi
echo $$ > "$LOCKFILE"
trap "rm -f $LOCKFILE" EXIT

Au final : 55 repos synchronisés des deux côtés, cron horaire, zéro intervention manuelle.

zone-api : une API de gestion en Rust

Le site sevan.zone affiche une page Projets — une liste de trucs que j’ai faits ou que j’utilise. Plutôt que de hardcoder ça dans le HTML (qui demande un rebuild Astro à chaque modif), j’ai une API qui sert la liste depuis un fichier JSON.

L’API est écrite en Rust avec Axum. Pas parce que c’est nécessaire — un fichier JSON servi par nginx ferait le job. Mais parce que j’avais envie d’un endpoint HMAC pour l’auth, d’un toggle publish/hide en temps réel, et que Rust + Axum pour ce genre de microservice, c’est plaisant à écrire.

L’authentification utilise HMAC-SHA256 :

fn auth_token() -> String {
    let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC ok");
    mac.update(b"projets_auth_v1");
    hex::encode(mac.finalize().into_bytes())
}

Le token est stocké dans un cookie HttpOnly; Secure; SameSite=Lax avec une durée de 24h. Pas de JWT, pas de sessions côté serveur, pas de base de données. Juste un HMAC dérivé d’un secret.

Les routes :

MéthodeRouteAuth
GET/projets/publicNon
GET/projetsOui
POST/projetsOui
PATCH/projets/:idOui
DELETE/projets/:idOui
GET/projets/suggestionsOui
POST/projets/:id/toggleOui

Le endpoint /projets/suggestions est le plus intéressant : il appelle en parallèle les APIs GitHub et OneDev avec tokio::join!, compare la liste résultante avec les projets existants, et retourne ce qui n’est pas encore dans la liste. C’est ce qui alimente la section “Suggestions” du dashboard admin.

let (github, onedev) = tokio::join!(fetch_github_repos(), fetch_onedev_repos());

La persistence : un fichier projets.json. Pas de SQLite, pas de Postgres. Pour 50 entrées modifiées à la main quelques fois par semaine, une sérialisation JSON avec serde_json::to_string_pretty suffit largement.

Le dashboard admin en Astro

La page /projets-admin est protégée par le cookie HMAC. Si tu n’es pas connecté, redirection vers /projets-login. Rien de compliqué.

Ce qui est plus intéressant, c’est le dashboard en lui-même. Astro génère du HTML statique, mais la page admin est entièrement client-side : on fetch l’API, on rend le tableau, on bind les events. Astro joue le rôle d’un simple shell HTML avec styles.

Le tableau affiche :

  • Toggle switch CSS pur (pas de lib JS) pour publier/masquer en temps réel via PATCH
  • Badge de déploiement : vert si le projet a une URL configurée, gris sinon
  • Édition d’URL inline : clic sur le crayon → input → PATCH /projets/:id avec {href: "https://..."}
  • Suppression : icône poubelle visible au survol → DELETE /projets/:id

Le toggle switch, c’est 20 lignes de CSS sans JavaScript :

.slider::before {
    content: '';
    position: absolute;
    width: 16px; height: 16px;
    left: 3px; top: 3px;
    background: #fff;
    border-radius: 50%;
    transition: transform 0.2s;
}
input:checked + .slider { background: var(--green); }
input:checked + .slider::before { transform: translateX(16px); }

Pour les stats en haut (total / publiés / déployés / masqués), c’est du JS vanilla qui lit le tableau en mémoire. Pas de state management, pas de reactive framework. allProjets.filter(p => p.published).length. C’est tout.

L’affichage des projets : overlay vs pages statiques

Premier réflexe : créer une page /projets/[slug].astro avec getStaticPaths() qui fetch l’API au build. Ça marche, mais ça crée un problème : si on publie un projet après le build, la page n’existe pas. L’utilisateur tombe sur une 404 déguisée en “Contenu archivé”.

La solution retenue : une overlay client-side. Les cartes de la grille sont des <button> qui, au clic, ouvrent une overlay plein écran avec un iframe. L’URL est mise à jour via history.pushState pour rester partageable (/projets/?p=linetime). Le bouton retour fonctionne naturellement via popstate.

function openProjet(slug: string) {
    const p = projets.find(x => x.slug === slug);
    if (!p) return;
    const body = document.getElementById('overlay-body')!;
    if (p.href) {
        body.innerHTML = `<iframe src="${p.href}" title="${p.name}"></iframe>`;
    } else {
        body.innerHTML = `<div class="no-url"><span>🚧</span><h2>${p.name}</h2></div>`;
    }
    overlay.classList.add('open');
    history.pushState({ slug }, '', `/projets/?p=${slug}`);
}

Avantages : zéro rebuild nécessaire, fonctionne instantanément après un toggle publish, URLs partageables. Le projet s’affiche dans l’iframe ou montre un message “pas encore hébergé” si pas d’URL configurée.

Ce que ça donne

  • 55 repos synchronisés entre OneDev et GitHub, cron horaire avec lockfile
  • zone-api v0.5.0 en Rust/Axum : CRUD complet sur les projets, auth HMAC, suggestions auto depuis les deux forges
  • Dashboard admin : stats temps réel, filtres, édition inline, toggle publish en un clic
  • sevan.zone : page Projets unifiée, Jeux absorbé dedans, nav allégée

La navigation du site est passée de 5 à 4 liens. Pas parce que c’est mieux en soi, mais parce qu’une page dédiée “Jeux” pour un seul jeu disponible, c’est du gaspillage d’espace mental.

Le reste — NoCarbono, Linetime, les APIs dans différents langages — c’est là, accessible depuis /projets, dans une overlay propre sans quitter le site.

Self-hosting, c’est rarement glamour. C’est surtout beaucoup de scripts shell, de lockfiles, et de fichiers JSON. Mais au moins c’est à soi.