EN FR

Chapter 15Ship

GitLab, end to end

Actual support status, what already works, and the implementation still to write — with the six traps that set it apart from GitHub. Plus CI and deployment.

6 min read10 sectionsChapter 15 / 22

Read this before promising GitLab to a client

The GitLab provider is not implemented. Setting GIT_PROVIDER=gitlab today makes every write fail with an explicit message. What exists is the signature and the list of things to know in order to write it — about half a day of work, plus a test to make pass.

Separating two questions

QuestionStatus
Hosting the code on GitLab, running CI there, deploying from GitLab works today — nothing in the project depends on it
Writing content through the GitLab API from /api/save and /api/upload to be writtencreateGitLabProvider throws

A perfectly valid setup today: the code lives on GitLab, CI is GitLab CI, deployment starts from GitLab — and GIT_REPO points at a GitHub repository for content. It is unusual, but nothing forbids it: the two roles are independent.

Hosting and deploying from GitLab

GitLab Pages does not fit

GitLab Pages serves static files and does not run the functions in /functions. The site would display, /api/* would answer 404, and editing would be impossible. You need a host that runs functions — the repository can stay on GitLab.

Option A — connect GitLab to the host

Static hosts with functions generally offer a direct GitLab connection, just as for GitHub: every push triggers a build. The configuration is identical to the one described in Deployment — command npm run build, folder dist, variables as runtime secrets, RATE_LIMIT binding. Check in the host's interface that GitLab is among the offered sources.

Option B — deploy from GitLab CI

The finest control, and the most portable: your pipeline decides.

.gitlab-ci.yml yaml
# The same commands as locally, in the same order.
image: node:20

stages:
  - checks
  - deploy

cache:
  key:
    files:
      - package-lock.json
  paths:
    - .npm/

.node: &node
  before_script:
    - npm ci --cache .npm --prefer-offline

checks:
  <<: *node
  stage: checks
  script:
    - npm test          # builds the site along the way
    - npm run check     # raw HTML, languages, logs, secrets

# Does a site created from scratch still build? (reference repository only)
scaffold:
  <<: *node
  stage: checks
  script:
    - npm run test:scaffold
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

deploy:
  <<: *node
  stage: deploy
  needs: [checks]
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
  script:
    - npm run build
    - npx wrangler pages deploy dist --project-name=martin-bakery
  # CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID are declared as project
  # variables, masked and protected. Never in this file.
No content secret in CI

GIT_TOKEN, EDITOR_KEY_HASH and SESSION_SECRET have no business in the pipeline: they belong to function runtime, on the host. CI only builds and inspects.

The token, GitLab side

For content writing — that is, the day the implementation exists — the token is created at project level, not for a person:

  1. Project → Settings → Access tokens

    A project access token: tied to the project, not to a human account.

  2. Role: Developer

    Enough to write to an unprotected branch. If the publishing branch is protected, you must either explicitly allow that token to push to it, or use a Maintainer role — but then measure what you are opening.

  3. Scope: api

    This is the surprising part. write_repository covers Git over HTTPS, not the files API. Writing through /projects/:id/repository/files/… requires the api scope.

  4. Expiry

    The shortest your operations can live with, and a reminder a week ahead.

The six points that set GitLab apart from GitHub

They are noted in the code, at the exact place where the implementation will have to be written. Rediscovering them costs half a day; reading them costs two minutes.

  1. The file path is fully encoded, separators included

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

    Encoding each segment separately, as for GitHub, does not work.

  2. The version is not the blob SHA

    It is the last_commit_id returned by the read, to be passed back as-is on write. GitLab compares the last commit that touched the file, where GitHub compares the content. The lock is equivalent, but the value is not interchangeable.

  3. The project identifier

    Numeric (12345678) or encoded path (group%2Fproject). GIT_REPO must be encoded before being inserted into the URL.

  4. Authentication

    Header PRIVATE-TOKEN, not Authorization: Bearer.

  5. The conflict

    GitLab answers 400 with a message, where GitHub answers 409. To be translated into GitError('conflict') so the caller stays unchanged.

  6. Commit attribution

    author_email and author_name go in the request body, not in a committer object.

A seventh, found while writing it

Creating a file and updating one are not the same request. PUT updates an existing file; a new file — the case of every uploaded image — is created with POST. The interface accounts for it: an empty version means “new file”.

The implementation skeleton

To be written in packages/inline-core/src/server/gitlab.ts. The interface is already in place, and createGitProvider already routes on GIT_PROVIDER=gitlab: nothing else in the code has to change.

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);

  // The whole path is a single URL segment: separators encoded too.
  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 reports the lock with a 400 plus a 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();
      // The version is the last commit that touched the file, not the 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,
        // Empty version = new file: no lock to assert.
        ...(version ? { last_commit_id: version } : {}),
      };

      const response = await fetch(endpoint(path), {
        // POST creates, PUT updates. Getting it wrong returns a terse 400.
        method: version ? 'PUT' : 'POST',
        headers: { ...headers(), 'content-type': 'application/json' },
        body: JSON.stringify(payload),
      });
      if (!response.ok) await fail(response);

      // The response does not carry the new version: read it back.
      const { version: next } = await this.readFile(path);
      return { version: next };
    },
  };
}

Testing the implementation

bash bash
# Offline: the mechanics shared by providers
npm run test:git

# Online, against a real GitLab project
GIT_PROVIDER=gitlab \
GIT_REPO=group/project \
GIT_TOKEN=glpat-… \
  node scripts/test-git-provider.mjs --online --write

What the test must demonstrate, in order:

  • Reading an existing file returns its content and a non-empty version.
  • Writing with the right version succeeds and returns a different version.
  • Writing again with the old version returns conflict, not unavailable.
  • Writing a new file (empty version) succeeds.
  • A missing path returns not_found.
  • An invalid token returns unauthorized.
  • No error message copies the token.

Migrating a site from GitHub to GitLab

  1. Mirror the repository, history included

    bash bash
    git clone --mirror git@github.com:agency/martin-bakery.git
    cd martin-bakery.git
    git push --mirror git@gitlab.com:agency/martin-bakery.git
  2. Create the project token

    Developer role, api scope.

  3. Change three variables on the host

    GIT_PROVIDER=gitlab, GIT_REPO=group/project, GIT_TOKEN=glpat-…. Then redeploy.

  4. Warn about drafts in progress

    The nature of the version changes: a draft opened before the switch and published after would produce a false conflict. Harmless — the client reloads and publishes again — but do the switch at a quiet hour.

  5. Reconnect CI and deployment

    Translate .github/workflows/ci.yml into .gitlab-ci.yml — see above.

Other forges

Gitea, Forgejo, Bitbucket, a self-hosted instance: the reasoning is the same. There is one file to write, two methods to implement, and a GIT_API_BASE variable to point at an instance that is not the public service. Nothing else in the code knows which forge it is talking to — that is the whole point of the abstraction.