Un article par jour sans CMS : quand une date future sert de planificateur
Ou : Comment confier son calendrier éditorial à une option dont le travail consiste à ne rien faire
Depuis le 4 octobre 2026, ce blog publie un article par jour, vers 14 heures, heure de Paris. Il n’y a pourtant ni CMS, ni file d’attente, ni bouton « programmer la publication ». Il y a une date dans le futur, écrite dans l’en-tête de chaque article, et un serveur qui reconstruit le site toutes les dix minutes. Hugo, le générateur de site statique qui fabrique ces pages, ignore par défaut tout contenu daté dans le futur. L’article attend donc dans les sources, invisible, jusqu’à ce que sa date passe, et la reconstruction suivante le met en ligne. Celui que tu lis a été écrit le 16 octobre et daté du 21 à 13 h 38.
Le mécanisme tient en une option qu’on ne règle pas. Ses pièges, eux, tiennent dans le fuseau horaire, dans la date de dernière modification, dans les liens entre articles, et dans tout ce qui se passe au moment du déploiement plutôt qu’au moment de la publication. Tous ceux qui se laissaient reproduire, je les ai reproduits sur un site jetable, avec Hugo 0.165, avant de les raconter.
Programmer la publication d’un article avec Hugo : dates, fuseau horaire et reconstruction
Le planificateur qui n’existe pas
La documentation de Hugo règle la question en une phrase : avant sa date de publication, une page « will not be rendered unless you pass the --buildFuture flag »1. L’option buildFuture vaut false par défaut, et le fichier de configuration de ce site ne la mentionne pas. Voilà tout le planificateur.
Pour le vérifier sans attendre mercredi, Hugo propose une option précieuse, --clock, qui lui fait croire qu’il est une autre heure. Sur le site de test, un article est daté du 21 octobre à 13 h 38 :
| |
Deux minutes d’horloge, une page de plus. Et pas seulement une page : la liste du blog, le flux RSS et le sitemap sont recalculés dans le même build, avec l’article dedans.
Ce qui frappe, c’est le silence. Le build qui exclut l’article ne l’annonce nulle part : aucun avertissement, aucune ligne dans le tableau récapitulatif, code de sortie 0. Hugo a l’habitude de ces comportements qui ne disent rien : la coloration syntaxique qui a fait sauter AMP en était un autre. Pour voir la file d’attente, il faut la demander :
| |
La commande existe aussi en version drafts, expired et published. Pour un site sans CMS, c’est la seule vue d’ensemble du calendrier éditorial, et elle tient dans un fichier CSV.
Quatre dates pour une seule page
Hugo connaît quatre dates par page, et chacune se remplit, à défaut, avec une autre. La configuration par défaut fixe l’ordre de lecture des champs2 :
| Date de la page | Champs lus, dans l’ordre |
|---|---|
Date | date, publishdate, pubdate, published, lastmod, modified |
PublishDate | publishdate, pubdate, published, date |
ExpiryDate | expirydate, unpublishdate |
Lastmod | :git, lastmod, modified, date, publishdate, pubdate, published |
La deuxième ligne explique pourquoi un seul champ date suffit : sans publishDate, la date de publication reprend date, et c’est elle que Hugo compare à l’horloge. La troisième ouvre la porte inverse : à partir de son expiryDate, une page n’est plus générée, sauf avec --buildExpired1. La quatrième cache un piège sur lequel je reviendrai.
Séparer les deux premières paraît élégant : date pour le jour où l’article a été écrit, publishDate pour celui où il sort. Je l’ai testé avec un article écrit le 12 octobre et publié le 22. Il sort bien le 22, mais Hugo trie les listes sur Date. Voici la liste du blog de test, construite au 26 octobre :
| Rang | Article | date | publishDate |
|---|---|---|---|
| 1 | Publié le 25 | 25 octobre | reprend date |
| 2 | Publié le 21 | 21 octobre | reprend date |
| 3 | Déjà publié | 14 octobre | reprend date |
| 4 | Écrit le 12, publié le 22 | 12 octobre | 22 octobre |
L’article du 22 sort enterré sous celui du 14. Même chose dans le flux RSS, où sa balise pubDate affiche pourtant le 22 : la date est juste, la place ne l’est pas. Les articles de ce site n’ont qu’un champ date, et ce test donne une bonne raison de s’y tenir.
L’heure qui recule le dernier dimanche d’octobre
La date d’un article programmé ressemble à ceci : 2026-10-21T13:38:00+02:00. Les six derniers caractères comptent autant que les autres. Ils disent que ces 13 h 38 s’entendent avec deux heures d’avance sur le temps universel, autrement dit à l’heure d’été de Paris. L’article du dimanche 25 octobre devra porter +01:00, parce que l’heure d’été s’arrête « à 1 heure du matin, temps universel, le dernier dimanche d’octobre », selon la directive européenne qui fixe ce calendrier depuis 20013.
Et si on oublie le décalage ? Hugo ne devine rien. Une date sans décalage est lue en Etc/UTC, sauf si l’option timeZone en décide autrement4. Deux articles de test, datés sans décalage, l’un en heure d’été, l’autre en heure d’hiver :
| |
Sans l’option, 14 h 23 devient 14 h 23 à Greenwich, soit 15 h 23 à Paris en hiver et 16 h 23 en été : l’article sort avec une ou deux heures de retard, sans que rien ne le signale. Avec timeZone = 'Europe/Paris', Hugo applique le bon décalage de part et d’autre du changement d’heure, sans qu’on y pense.
Ce site fait l’inverse : pas de timeZone, et chaque date porte son décalage en toutes lettres. L’information vit dans le fichier plutôt que dans la configuration, et un article copié ailleurs garde son heure. Le prix à payer, c’est de penser à remplacer +02:00 par +01:00 le bon dimanche.
Reste l’heure elle-même. Elle n’est jamais 14 h pile : elle est tirée au hasard, à trente minutes près, pour ne pas publier à heure fixe. C’est typiquement le genre de calcul que je préfère confier à un programme qui connaît le calendrier mieux que moi :
| |
Une exécution donne ceci, et le dimanche change de décalage tout seul :
| |
Le commentaire sur AddDate n’est pas décoratif. Ajouter 24 * time.Hour au samedi 24 octobre à 14 h donne 2026-10-25T13:00:00+01:00 : vingt-quatre heures plus tard, il n’est que 13 h, puisque la nuit a duré une heure de plus. AddDate(0, 0, 1) donne bien 14 h, parce qu’il compte en jours de calendrier dans le fuseau de la date5. Le fichier de test vérifie les deux bords, le samedi à +02:00 et le dimanche à +01:00, ainsi qu’aucune heure ne sorte de la fenêtre de 13 h 30 à 14 h 30. Avec Go 1.27.1, go vet ne signale rien et go test -race passe.
Dix minutes de latence, 144 reconstructions par jour
Hugo ne publie rien. Il fabrique des fichiers. Pour qu’un article sorte le 21 à 13 h 38, il faut qu’un Hugo tourne après 13 h 38. Sur ce site, c’est le serveur qui s’en charge : une tâche planifiée y reconstruit le site toutes les dix minutes, que les sources aient changé ou non. L’article daté de 13 h 38 apparaît à la première reconstruction qui suit, donc au plus tard une dizaine de minutes après l’heure prévue. Pour un blog, c’est une précision largement suffisante.
Reconstruire 144 fois par jour un site qui ne change qu’une fois paraît dispendieux. Ça ne l’est guère : sur ma machine, Hugo construit les 1 002 pages qu’il compte pour ce site en un peu plus de quatre secondes. Chaque reconstruction refait tout ce qui dépend de la liste des pages : la liste du blog, l’encadré des derniers articles sur l’accueil, le flux RSS, le sitemap, et même le fichier llms.txt du site. Un article programmé n’est pas « ajouté » au site. Le site est recalculé autour de lui.
La conséquence la plus utile est ailleurs : un seul déploiement couvre une semaine entière. Les articles sont rédigés en lot, datés d’avance, envoyés ensemble, et le serveur les sort un par un. La première semaine du plan est sortie ainsi, toute seule. La contrepartie se paie au moment des corrections : une coquille repérée dans l’article de jeudi exige un nouveau déploiement avant son heure de sortie, sinon c’est la version fautive qui sort.
Sans serveur à soi, il faut un autre réveil. Sur un hébergement statique comme GitHub Pages, la solution classique est un workflow planifié de GitHub Actions. Sa documentation prévient de trois choses : l’intervalle minimal est de cinq minutes, l’exécution peut être retardée aux heures de forte charge, voire abandonnée, et dans un dépôt public, la planification se désactive après 60 jours sans activité6. Pour un blog dont l’auteur part deux mois en vacances avec ses articles programmés, le dernier point mérite d’être lu deux fois.
Le ping qui arrive avant l’article
Les moteurs de recherche, eux, ne lisent pas mon calendrier. Pour leur signaler une page nouvelle, il existe IndexNow, un protocole par lequel un site prévient Bing et une poignée d’autres moteurs qu’une URL a changé. Google ne figure pas dans la liste des participants7. Le protocole mérite un article à lui seul, je m’en tiens ici à la question du moment.
Mon script de déploiement envoie les fichiers au serveur, puis programme un envoi IndexNow douze minutes plus tard, le temps que le serveur ait reconstruit le site. Les URL soumises sont celles des pages modifiées dans l’historique git depuis le déploiement précédent. Le raisonnement tient pour une correction. Il s’effondre pour un article programmé : au moment du déploiement, sa date n’est pas passée, Hugo ne l’a pas construit, et l’URL annoncée répond 404. Et s’il n’a pas encore été commité, il n’est pas annoncé du tout, ni ce jour-là ni plus tard.
Un 404 annoncé n’a rien de neutre. La FAQ d’IndexNow demande justement de soumettre les pages qui répondent 404 ou 410, pour signaler leur disparition8. Annoncer un article quatre jours trop tôt, c’est envoyer le robot constater qu’il n’existe pas.
Le bon moment pour prévenir, c’est juste après la reconstruction qui publie. Et le meilleur témoin de ce qui vient d’être publié, c’est le sitemap que Hugo vient d’écrire, le même fichier que lit mon crawler SEO maison. Il suffit de le comparer au précédent :
| |
Sur le site de test, entre une reconstruction le 21 octobre à 13 h 30 et la suivante à 13 h 40 :
| |
L’accueil sort avec l’article, parce que sa date de modification est celle de la page la plus récente, et c’est exact : il a changé. Deux sitemaps identiques ne produisent aucune ligne. Entre le 21 et le 25, le script trouve les deux articles sortis entre-temps, et seulement eux. Surtout, il ne voit une page qu’une fois qu’elle existe. Branché après chaque reconstruction, avec une copie du sitemap gardée d’une fois sur l’autre, il pourrait alimenter IndexNow au bon moment. Sur ce site, ce n’est encore qu’une proposition : testée, mais pas installée.
Le lastmod qui date d’avant la naissance
Le sitemap porte, pour chaque page, une date de dernière modification, lastmod. Google a expliqué en 2023 qu’il s’en sert pour planifier ses visites, à condition qu’elle colle à la réalité. Si elle ment trop souvent, prévient le billet, « eventually we’re not going to believe you anymore »9.
Or la configuration par défaut de Hugo place :git en tête de lastmod, c’est-à-dire la date du dernier commit qui a touché le fichier2. Pour un article programmé, ce commit précède la publication de plusieurs jours. Sur le site de test, tout le lot d’articles tient dans un commit daté du 16 octobre à 10 h 12, et le site est construit au 26 octobre. Voici deux lignes du sitemap :
| |
L’article publié le 25 annonce une dernière modification le 16 : modifié neuf jours avant d’exister. Le second s’en sort grâce à deux précautions cumulées. Il porte un champ lastmod explicite, et la configuration de ce site lit ce champ avant git :
| |
Les deux sont nécessaires. Avec l’ordre par défaut, j’ai vérifié que même le lastmod explicite perd contre git : le sitemap affiche alors le 16 octobre pour l’article du 21. Les articles programmés de ce blog portent tous un lastmod égal à leur date. Une page modifiée avant sa naissance ne ruine rien à elle seule, mais c’est exactement le genre d’écart qui, répété chaque semaine, apprend à Google à ne plus croire tes dates.
Le piège inverse : la page qu’on croyait en ligne
Tout ce mécanisme repose sur une page qui ne s’affiche pas, sans erreur. C’est une qualité quand la date future est voulue, et un défaut le jour où elle ne l’est pas : une faute de frappe dans l’année, un guide daté de sa mise en ligne prévue alors qu’on voulait le publier tout de suite. La page est déployée, le build est vert, et elle n’existe pas. C’est arrivé sur ce site, au point que la première règle sur les dates était « jamais de date future ». La règle a changé quand la date future est devenue le mécanisme. Le risque, lui, est resté.
Le même silence touche les liens. Un article publié qui pointe vers celui de jeudi, encore à venir, envoie le lecteur sur un 404 jusqu’à jeudi, et Hugo ne dit rien. Avec le raccourci relref, qui résout le lien à la construction, il dit au contraire beaucoup :
| |
Le build échoue avec un code de sortie 1. Sur un poste de travail, c’est rassurant. Sur un serveur qui reconstruit seul, cette erreur se répète toutes les dix minutes jusqu’à la date de la page visée, et personne ne la lit. D’où une règle simple : un article ne pointe que vers des pages publiées avant lui.
expiryDate a son propre piège. À l’expiration, la page quitte les listes et le sitemap, mais Hugo n’efface pas les fichiers écrits par les builds précédents. Une offre limitée, construite le 10 octobre puis expirée le 15 :
| |
La page n’apparaît plus ni dans le sitemap ni dans la liste du blog, mais son HTML est toujours sur le disque, servi à quiconque connaît l’adresse. Une page orpheline, absente de partout sauf du serveur. Avec --cleanDestinationDir, Hugo vide le dossier de sortie avant d’écrire, et elle disparaît vraiment.
DevOps Dave : J’ai programmé la semaine. Sept articles, sept dates, un déploiement. Je pars en week-end.
Security Sarah : Tu as lancé hugo list future avant de partir ?
DevOps Dave : Pour quoi faire ? Le build est passé.
Security Sarah : Le build passe aussi quand l’article de lundi est daté de 2027.
Un planificateur qui ne fait rien ne se plaint jamais. C’est sa force, et c’est pour ça qu’il faut aller lui demander, de temps en temps, ce qu’il compte faire.
Ce blog n’a pas de planificateur : il a une horloge, et un générateur qui sait la lire. Tout le reste, le décalage horaire, le lastmod, le ping et les liens, consiste à vérifier que le reste du monde lit la même heure que lui. Cet article en est la démonstration, ou pas. Il a été écrit le 16 octobre, et si tu le lis, c’est que l’horloge a tourné.
La documentation de Hugo sur le frontmatter : pour
publishDate, « Before the publication date, the page will not be rendered unless you pass the--buildFutureflag to thehugocommand » ; pourexpiryDate, « On or after the expiration date, the page will not be rendered unless you pass the--buildExpiredflag ». La référence de configuration confirme quebuildFutureetbuildExpiredvalentfalsepar défaut. Tous les tests de cet article ont tourné avec Hugo 0.165.0, sur un site jetable, avec l’option--clockpour simuler les dates. ↩︎ ↩︎La page de configuration des dates du frontmatter donne l’ordre par défaut de chaque date et définit le jeton
:gitcomme « The Git author date for the file’s last revision », disponible quandenableGitInfoest activé. ↩︎ ↩︎La directive 2000/84/CE, adoptée le 19 janvier 2001, fixe le début de l’heure d’été au dernier dimanche de mars et sa fin au dernier dimanche d’octobre, à 1 heure du matin, temps universel. La base de fuseaux horaires du système et celle de Go placent le changement de 2026 le 25 octobre, à 1 heure UTC, soit 3 heures du matin à Paris, qui deviennent 2 heures. La Commission européenne a proposé en 2018 de supprimer ces changements d’heure, mais le texte reste bloqué au Conseil, faute d’accord entre États membres sur l’heure à garder : la directive s’applique toujours en 2026. ↩︎
Toujours la documentation du frontmatter, section des dates : les dates sans décalage « are not fully qualified, and default to the
Etc/UTCtime zone », sauf si l’optiontimeZonedésigne un autre fuseau de la base IANA, par exempleEurope/Paris. ↩︎La documentation de
time.Time.AddDatele précise : « AddDate uses the Location of the Time value to determine these durations ». Un jour de calendrier n’a pas de durée fixe, et le 25 octobre 2026 dure 25 heures à Paris. ↩︎La documentation des événements de GitHub Actions, section
schedule: « The shortest interval you can run scheduled workflows is once every 5 minutes », l’événement « can be delayed during periods of high loads », « some queued jobs may be dropped », et « In a public repository, scheduled workflows are automatically disabled when no repository activity has occurred in 60 days ». ↩︎La FAQ d’IndexNow liste les points d’entrée des moteurs participants : Amazon, Bing, Naver, Seznam.cz, Yandex et Yep. Google n’en fait pas partie. ↩︎
Même FAQ d’IndexNow : « You should submit redirected URLs and pages that return HTTP 404 or HTTP 410 status codes ». ↩︎
Le billet « Sitemaps ping endpoint is going away » du blog Google Search Central, publié le 26 juin 2023, annonce la fin du ping de sitemap et précise l’usage de
lastmod: la date doit « consistently match reality », faute de quoi Google finit par l’ignorer. ↩︎