FR EN

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.

6 min de lecture10 sectionsChapitre 15 / 22

À lire avant de promettre GitLab à un client

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 à écrirecreateGitLabProvider 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 ne convient pas

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.

.gitlab-ci.yml yaml
# 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.
Aucun secret de contenu dans la CI

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 :

  1. Projet → Settings → Access tokens

    Un project access token : il est lié au projet, pas à un compte humain.

  2. 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.

  3. Portée : api

    C'est le point qui surprend. write_repository couvre Git en HTTPS, pas l'API des fichiers. L'écriture par /projects/:id/repository/files/… exige la portée api.

  4. 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.

  1. Le chemin de fichier est entièrement encodé, séparateurs compris

    text text
    src/content/pages/fr/home.json
    → src%2Fcontent%2Fpages%2Ffr%2Fhome.json

    Encoder chaque segment séparément, comme pour GitHub, ne fonctionne pas.

  2. La version n'est pas le SHA du blob

    C'est le last_commit_id renvoyé 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.

  3. L'identifiant de projet

    Numérique (12345678) ou chemin encodé (groupe%2Fprojet). GIT_REPO doit être encodé avant insertion dans l'URL.

  4. L'authentification

    En-tête PRIVATE-TOKEN, et non Authorization: Bearer.

  5. Le conflit

    GitLab répond 400 avec un message, là où GitHub répond 409. À traduire en GitError('conflict') pour que l'appelant reste inchangé.

  6. L'attribution du commit

    author_email et author_name passent dans le corps de la requête, pas dans un objet committer.

Un septième, découvert à l'écriture

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.

packages/inline-core/src/server/gitlab.ts ts
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

bash bash
# 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 --write

Ce 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 un unavailable.
  • É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

  1. 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
  2. Créer le jeton de projet

    Rôle Developer, portée api.

  3. Changer trois variables chez l'hébergeur

    GIT_PROVIDER=gitlab, GIT_REPO=groupe/projet, GIT_TOKEN=glpat-…. Puis redéployer.

  4. 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.

  5. Reconnecter la CI et le déploiement

    Traduire .github/workflows/ci.yml en .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.