Skip to content

ci(deploy): deploy releases to production from GHCR - #211

Merged
AntoineJT merged 4 commits into
masterfrom
ci/deploy-from-ghcr
Sep 16, 2026
Merged

AntoineJT merged 4 commits into
masterfrom
ci/deploy-from-ghcr

Conversation

@AntoineJT

@AntoineJT AntoineJT commented Sep 16, 2026

Copy link
Copy Markdown
Member

🇬🇧 English

🎯 Summary

A release now reaches production on its own: both images are published, and the Portainer stack redeploys itself.

🤔 Motivation

The chain stopped at the registry. Publishing an image changed nothing on the host until someone connected to it and pulled by hand — and the migrator was never published at all, so there was no image to apply the migration history with.

📦 What's included

  • 📦 The migrator ships as its own GHCR package, omnibot-migrator, under the same tags as the bot — a deployment pulls the pair for one version, so the migration history cannot drift from the code that expects it.
  • 🐳 compose.prod.yaml names published images and builds nothing. A host needs Docker, that file and the environment variables; what runs is exactly what CI tested, not a rebuild from a checkout that may have drifted.
  • 🚀 A deploy job calls the Portainer stack webhook once the images are published. That is the whole reason it is a single curl and not an SSH session: the secret grants no shell on the host and can do nothing but redeploy this one stack.
  • 🔒 CLAUDE.md stops being a symlink to AGENTS.md — Portainer refuses to clone a repository containing one, which blocked the git-backed stack outright. It now imports AGENTS.md instead of copying it, so there is still a single source. Verified by running a session with file tools disabled: the import resolves.

🧭 Notes

  • ⚠️ Requires a PORTAINER_WEBHOOK_URL secret, and a stack with Re-pull image enabled — without it a redeploy reuses the image already cached under latest.
  • ⚠️ None of this has been exercised end to end: no webhook exists yet, and neither package has a latest tag, since only omnibot:0.1.0-rc1 is published. actionlint and docker compose config cover syntax, nothing more. The first non-prerelease tag is the real test.
  • 🔭 The deploy job skips prerelease tags, because latest does not move for them and redeploying would restart the stack onto the version already running — succeeding while changing nothing.

🇫🇷 Français

🎯 Résumé

Une release atteint désormais la production toute seule : les deux images sont publiées, et la stack Portainer se redéploie.

🤔 Motivation

La chaîne s'arrêtait au registre. Publier une image ne changeait rien sur l'hôte tant que personne ne s'y connectait pour tirer à la main — et le migrator n'était pas publié du tout, donc aucune image ne permettait d'appliquer l'historique des migrations.

📦 Contenu

  • 📦 Le migrator devient son propre package GHCR, omnibot-migrator, sous les mêmes tags que le bot — un déploiement tire la paire d'une version, donc l'historique des migrations ne peut pas diverger du code qui l'attend.
  • 🐳 compose.prod.yaml nomme des images publiées et ne construit plus rien. Un hôte n'a besoin que de Docker, de ce fichier et des variables ; ce qui tourne est exactement ce que la CI a testé, pas une reconstruction depuis un checkout qui a pu dériver.
  • 🚀 Un job deploy appelle le webhook de la stack Portainer une fois les images publiées. C'est toute la raison pour laquelle c'est un simple curl et non une session SSH : le secret ne donne aucun shell sur l'hôte et ne sait rien faire d'autre que redéployer cette stack.
  • 🔒 CLAUDE.md cesse d'être un symlink vers AGENTS.md — Portainer refuse de cloner un dépôt qui en contient un, ce qui bloquait purement et simplement la stack adossée à git. Il importe désormais AGENTS.md au lieu de le copier, donc il n'y a toujours qu'une source. Vérifié en lançant une session sans outils de lecture : l'import se résout bien.

🧭 À noter

  • ⚠️ Nécessite un secret PORTAINER_WEBHOOK_URL et une stack avec Re-pull image activé — sans lui, un redéploiement réutilise l'image déjà en cache sous latest.
  • ⚠️ Rien de tout ceci n'a été éprouvé de bout en bout : aucun webhook n'existe encore, et aucun des deux packages n'a de tag latest puisque seul omnibot:0.1.0-rc1 est publié. actionlint et docker compose config couvrent la syntaxe, rien de plus. Le premier tag non-préversion sera le vrai test.
  • 🔭 Le job de déploiement saute les préversions : latest ne bouge pas pour elles, et redéployer relancerait la stack sur la version déjà en place — un succès qui ne change rien.

📋 Checklist

  • 🇬🇧 Create the PORTAINER_WEBHOOK_URL secret and the Portainer stack (Repository mode, Re-pull image, the four env vars) / 🇫🇷 Créer le secret PORTAINER_WEBHOOK_URL et la stack Portainer (mode Repository, Re-pull image, les quatre variables)
  • 🇬🇧 Cut a non-prerelease tag and check that both packages get latest, that the migration runs, and that the bot comes up / 🇫🇷 Poser un tag non-préversion et vérifier que les deux packages reçoivent latest, que la migration s'applique et que le bot démarre

AntoineJT and others added 4 commits September 16, 2026 01:37
Only the runtime image was pushed. The migrator was built on every pull
request and then thrown away, so a deployment had no published image to
apply the migration history with — it had to build one from a source
checkout on the host.

It now ships as its own GHCR package under the same tags as the bot, so a
deployment pulls the pair for one version and the migration history can
never drift from the code that expects it. The release cache gets its own
scope, and reads the CI migrator scope as a second source.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`compose.prod.yaml` carried `build:` sections, so a production host had to
hold a source checkout and rebuild both images itself — running something
subtly different from what CI tested, and rebuilding it on every machine.

It now names published images and builds nothing. A host needs Docker,
this file and an env file, and deploying or updating is the same two
commands: `pull`, then `up -d`.

Both images track `latest` rather than a version variable. A pinned
version would have to be edited on the host for every release, and an
automated `pull && up -d` would otherwise redeploy the very same image and
report success while changing nothing. `latest` only ever moves to a
non-prerelease tag, so cutting an `-rc` publishes it without deploying it.

Building locally is still `docker build --target runtime .`, and CI builds
both targets on every pull request, so the compose file no longer has to
serve both purposes.

Note that this file only becomes usable once the migrator package exists
and a non-prerelease tag has moved `latest` — as of writing, only
`omnibot:0.1.0-rc1` is published.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The chain stopped at the registry: publishing an image changed nothing on
the host until someone connected to it and pulled by hand.

A `deploy` job now calls the Portainer stack webhook, which re-pulls
`latest` and recreates what changed. The webhook is the whole reason this
is a single `curl` rather than an SSH session: the secret it needs grants
no shell on the host and can do nothing but redeploy this one stack.

It skips prerelease tags. `latest` does not move for those, so redeploying
would restart the stack onto the version already running and report
success having changed nothing.

`curl -f` matters more than it looks: without it a revoked or renamed
webhook answers 404, curl exits 0, and a dead deployment reads as a green
release for as long as nobody checks the bot.

Requires the `PORTAINER_WEBHOOK_URL` secret, and a stack configured with
Re-pull image — the README says so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Portainer refuses to clone a repository that contains one:

    Failed to download git repository: unable to clone git repository:
    repository contains a symlink, which is not allowed for security
    reasons

`CLAUDE.md` pointed at `AGENTS.md`, and it was the only symlink tracked
here, so a git-backed stack could not deploy at all.

It becomes a regular file that imports `AGENTS.md` rather than copying it,
so there is still one source to keep up to date.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@AntoineJT
AntoineJT merged commit 7a55e06 into master Sep 16, 2026
8 checks passed
@AntoineJT
AntoineJT deleted the ci/deploy-from-ghcr branch September 16, 2026 00:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant