Chapitre 15Mettre en ligne
GitLab, de bout en bout
État réel du support, ce qui marche déjà, et l'implémentation à écrire — avec les six pièges qui la distinguent de GitHub. Plus la CI et le déploiement.
Le fournisseur GitLab n'est pas implémenté. Poser
GIT_PROVIDER=gitlab aujourd'hui fait échouer toute écriture avec un message
explicite. Ce qui existe est la signature et la liste des points à connaître pour
l'écrire — soit une demi-journée de travail, et un test à faire passer.
Séparer deux questions
| Question | État |
|---|---|
| Héberger le code sur GitLab, y faire tourner la CI, déployer depuis GitLab | fonctionne aujourd'hui — rien dans le projet n'en dépend |
Écrire le contenu par l'API GitLab depuis /api/save et /api/upload |
à écrire — createGitLabProvider lève une erreur |
Un montage parfaitement valide aujourd'hui : le code vit sur GitLab, la CI est GitLab CI, le
déploiement part de GitLab — et GIT_REPO pointe vers un dépôt GitHub pour
le contenu. C'est inhabituel, mais rien ne l'interdit : les deux rôles sont indépendants.
Héberger et déployer depuis GitLab
GitLab Pages sert des fichiers statiques et n'exécute pas les fonctions de
/functions. Le site s'afficherait, /api/* répondrait 404, et
l'édition serait impossible. Il faut un hébergeur qui exécute des fonctions — le dépôt peut
rester sur GitLab.
Option A — connecter GitLab à l'hébergeur
Les hébergeurs de sites statiques avec fonctions proposent en général une connexion directe à
GitLab, au même titre qu'à GitHub : chaque poussée déclenche un build. La configuration est
identique à celle décrite dans Déploiement — commande
npm run build, dossier dist, variables en secrets d'exécution,
liaison RATE_LIMIT. Vérifiez dans l'interface de l'hébergeur que GitLab figure
bien parmi les sources proposées.
Option B — déployer depuis GitLab CI
Le contrôle le plus fin, et le plus portable : c'est votre pipeline qui décide.
# Les mêmes commandes qu'en local, dans le même ordre.
image: node:20
stages:
- controles
- deploiement
cache:
key:
files:
- package-lock.json
paths:
- .npm/
.node: &node
before_script:
- npm ci --cache .npm --prefer-offline
controles:
<<: *node
stage: controles
script:
- npm test # construit le site au passage
- npm run check # HTML brut, langues, journaux, secrets
# Un site créé de zéro construit-il encore ? (dépôt de référence uniquement)
echafaudage:
<<: *node
stage: controles
script:
- npm run test:scaffold
rules:
- if: $CI_COMMIT_BRANCH == "main"
deploiement:
<<: *node
stage: deploiement
needs: [controles]
rules:
- if: $CI_COMMIT_BRANCH == "main"
script:
- npm run build
- npx wrangler pages deploy dist --project-name=boulangerie-martin
# CLOUDFLARE_API_TOKEN et CLOUDFLARE_ACCOUNT_ID sont déclarés en variables
# de projet, masquées et protégées. Jamais dans ce fichier.
GIT_TOKEN, EDITOR_KEY_HASH et SESSION_SECRET n'ont
rien à faire dans le pipeline : ils appartiennent à l'exécution des fonctions, chez
l'hébergeur. La CI ne fait que construire et inspecter.
Le jeton, côté GitLab
Pour l'écriture de contenu — donc le jour où l'implémentation existe — le jeton se crée au niveau du projet, pas d'une personne :
-
Projet → Settings → Access tokens
Un project access token : il est lié au projet, pas à un compte humain.
-
Rôle : Developer
Suffisant pour écrire sur une branche non protégée. Si la branche de publication est protégée, il faut soit autoriser explicitement ce jeton à y pousser, soit un rôle Maintainer — mais alors, mesurez ce que vous ouvrez.
-
Portée :
apiC'est le point qui surprend.
write_repositorycouvre Git en HTTPS, pas l'API des fichiers. L'écriture par/projects/:id/repository/files/…exige la portéeapi. -
Expiration
La plus courte compatible avec votre exploitation, et un rappel une semaine avant.
Les six points qui distinguent GitLab de GitHub
Ils sont notés dans le code, à l'endroit exact où l'implémentation devra être écrite. Les redécouvrir coûte une demi-journée ; les lire coûte deux minutes.
-
Le chemin de fichier est entièrement encodé, séparateurs compris
text text src/content/pages/fr/home.json → src%2Fcontent%2Fpages%2Ffr%2Fhome.jsonEncoder chaque segment séparément, comme pour GitHub, ne fonctionne pas.
-
La version n'est pas le SHA du blob
C'est le
last_commit_idrenvoyé par la lecture, à repasser tel quel à l'écriture. GitLab compare le dernier commit ayant touché le fichier, là où GitHub compare le contenu. Le verrou reste équivalent, mais la valeur n'est pas interchangeable. -
L'identifiant de projet
Numérique (
12345678) ou chemin encodé (groupe%2Fprojet).GIT_REPOdoit être encodé avant insertion dans l'URL. -
L'authentification
En-tête
PRIVATE-TOKEN, et nonAuthorization: Bearer. -
Le conflit
GitLab répond
400avec un message, là où GitHub répond409. À traduire enGitError('conflict')pour que l'appelant reste inchangé. -
L'attribution du commit
author_emailetauthor_namepassent dans le corps de la requête, pas dans un objetcommitter.
Créer un fichier et le modifier ne sont pas la même requête.
PUT met à jour un fichier existant ; un fichier neuf — le cas de chaque image
téléversée — se crée en POST. L'interface le prévoit : une version vide signifie
« fichier neuf ».
Le squelette de l'implémentation
À écrire dans packages/inline-core/src/server/gitlab.ts. L'interface est déjà
posée, et createGitProvider aiguille déjà sur GIT_PROVIDER=gitlab :
rien d'autre dans le code n'a à changer.
import {
GitError, fromBase64, bytesToBase64, toBase64,
type GitAuthor, type GitConfig, type GitProvider, type ReadResult,
} from './git-provider';
const DEFAULT_API = 'https://gitlab.com/api/v4';
export function createGitLabProvider(config: GitConfig): GitProvider {
const api = (config.apiBase ?? DEFAULT_API).replace(/\/$/, '');
const project = encodeURIComponent(config.repo);
// Le chemin entier est un segment d'URL : séparateurs encodés compris.
const endpoint = (path: string) =>
`${api}/projects/${project}/repository/files/${encodeURIComponent(path)}`;
const headers = () => ({ 'private-token': config.token });
async function fail(response: Response): Promise<never> {
const body = await response.text().catch(() => '');
const detail = body.slice(0, 300);
if (response.status === 404) throw new GitError('not_found', detail);
if (response.status === 401 || response.status === 403) {
throw new GitError('unauthorized', detail);
}
// GitLab signale le verrou par un 400 accompagné d'un message.
if (response.status === 400 && /changed since you started editing/i.test(body)) {
throw new GitError('conflict', detail);
}
throw new GitError('unavailable', `${response.status} ${detail}`);
}
return {
async readFile(path): Promise<ReadResult> {
const url = `${endpoint(path)}?ref=${encodeURIComponent(config.branch)}`;
const response = await fetch(url, { headers: headers() });
if (!response.ok) await fail(response);
const body = await response.json();
// La version est le dernier commit ayant touché le fichier, pas le blob.
return { content: fromBase64(body.content), version: body.last_commit_id };
},
async writeFile(path, content, version, message, author: GitAuthor) {
const payload = {
branch: config.branch,
content: typeof content === 'string' ? toBase64(content) : bytesToBase64(content),
encoding: 'base64',
commit_message: message,
author_name: author.name,
author_email: author.email,
// Une version vide = fichier neuf : pas de verrou à faire valoir.
...(version ? { last_commit_id: version } : {}),
};
const response = await fetch(endpoint(path), {
// POST crée, PUT met à jour. Se tromper renvoie un 400 peu bavard.
method: version ? 'PUT' : 'POST',
headers: { ...headers(), 'content-type': 'application/json' },
body: JSON.stringify(payload),
});
if (!response.ok) await fail(response);
// La réponse ne porte pas la nouvelle version : on relit pour l'obtenir.
const { version: next } = await this.readFile(path);
return { version: next };
},
};
}Éprouver l'implémentation
# Hors ligne : la mécanique commune aux fournisseurs
npm run test:git
# En ligne, sur un vrai projet GitLab
GIT_PROVIDER=gitlab \
GIT_REPO=groupe/projet \
GIT_TOKEN=glpat-… \
node scripts/test-git-provider.mjs --online --writeCe que le test doit démontrer, dans l'ordre :
- Lire un fichier existant renvoie son contenu et une version non vide.
- Écrire avec la bonne version réussit et renvoie une version différente.
- Réécrire avec l'ancienne version renvoie un
conflict, pas ununavailable. - Écrire un fichier neuf (version vide) réussit.
- Un chemin inexistant renvoie
not_found. - Un jeton invalide renvoie
unauthorized. - Aucun message d'erreur ne recopie le jeton.
Migrer un site de GitHub vers GitLab
-
Miroir du dépôt, historique compris
bash bash git clone --mirror git@github.com:agence/boulangerie-martin.git cd boulangerie-martin.git git push --mirror git@gitlab.com:agence/boulangerie-martin.git -
Créer le jeton de projet
Rôle Developer, portée
api. -
Changer trois variables chez l'hébergeur
GIT_PROVIDER=gitlab,GIT_REPO=groupe/projet,GIT_TOKEN=glpat-…. Puis redéployer. -
Prévenir des brouillons en cours
La nature de la version change : un brouillon ouvert avant la bascule et publié après produirait un faux conflit. Sans gravité — le client recharge et republie — mais autant faire la bascule à une heure creuse.
-
Reconnecter la CI et le déploiement
Traduire
.github/workflows/ci.ymlen.gitlab-ci.yml— voir plus haut.
Autres forges
Gitea, Forgejo, Bitbucket, une instance auto-hébergée : le raisonnement est le même. Il y a
un seul fichier à écrire, deux méthodes à implémenter, et une variable
GIT_API_BASE pour pointer vers une instance qui n'est pas le service public. Rien
d'autre dans le code ne sait à quelle forge il parle — c'est tout l'intérêt de l'abstraction.