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.
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
| Question | Status |
|---|---|
| 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 written — createGitLabProvider 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 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.
# 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.
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:
-
Project → Settings → Access tokens
A project access token: tied to the project, not to a human account.
-
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.
-
Scope:
apiThis is the surprising part.
write_repositorycovers Git over HTTPS, not the files API. Writing through/projects/:id/repository/files/…requires theapiscope. -
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.
-
The file path is fully encoded, separators included
text text src/content/pages/fr/home.json → src%2Fcontent%2Fpages%2Ffr%2Fhome.jsonEncoding each segment separately, as for GitHub, does not work.
-
The version is not the blob SHA
It is the
last_commit_idreturned 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. -
The project identifier
Numeric (
12345678) or encoded path (group%2Fproject).GIT_REPOmust be encoded before being inserted into the URL. -
Authentication
Header
PRIVATE-TOKEN, notAuthorization: Bearer. -
The conflict
GitLab answers
400with a message, where GitHub answers409. To be translated intoGitError('conflict')so the caller stays unchanged. -
Commit attribution
author_emailandauthor_namego in the request body, not in acommitterobject.
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.
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
# 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 --writeWhat 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, notunavailable. - 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
-
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 -
Create the project token
Developer role,
apiscope. -
Change three variables on the host
GIT_PROVIDER=gitlab,GIT_REPO=group/project,GIT_TOKEN=glpat-…. Then redeploy. -
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.
-
Reconnect CI and deployment
Translate
.github/workflows/ci.ymlinto.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.