@foss.global/forgefixtures
Disposable, digest-pinned forge instances for integration tests. @foss.global/forgefixtures starts a real Gitea or GitLab Community Edition from its pinned upstream image behind a verified loopback TLS endpoint, bootstraps an administrator token through the forge's supported tooling, seeds realistic data through the documented REST APIs, and returns a ground-truth manifest read back from the forge itself. Every Docker resource carries ownership labels, so resources left behind by a crashed test process are removed by the next run.
Issue Reporting and Security
For reporting bugs, issues, or security vulnerabilities, please visit community.foss.global/. This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a code.foss.global/ account to submit Pull Requests directly.
Runtime Requirements
- Linux (ownership checks read
/proc) - Node.js 24.18 or newer
- a reachable Docker Engine; for Gitea the socket is taken from the
dockerSocketPathoption, otherwiseDOCKER_HOST, otherwise/var/run/docker.sock - for GitLab, a rootless Docker Engine: the omnibus image runs as root, and
@apiclient.xyz/dockerpermits a root container user only after the daemon proves rootless mode.GitlabFixturedefaults to the current user's rootless socket ($XDG_RUNTIME_DIR/docker.sock, otherwise/run/user/<uid>/docker.sock); a rootful daemon is refused - network access to Docker Hub for the first pull of a pinned image
Installation
pnpm add --save-dev @foss.global/forgefixtures
Usage
import { GiteaFixture, GiteaSeedBuilder, createDefaultGiteaSeedSpec } from '@foss.global/forgefixtures';
const fixture = new GiteaFixture();
try {
const runtime = await fixture.start();
// runtime.baseUrl https://127.0.0.1:<port>
// runtime.caCertificatePem the only CA that issued the endpoint certificate
// runtime.admin.token administrator token with scope `all`
// runtime.version '1.27.3', verified against the image pin
const manifest = await new GiteaSeedBuilder(fixture).apply(createDefaultGiteaSeedSpec());
const repository = manifest.repositories.find((repositoryArg) => repositoryArg.fullName === 'team-with-hyphens/public-repo');
console.log(repository?.issues.map((issueArg) => issueArg.number)); // [1, 3]; #2 is a pull request
const aliceToken = await fixture.createAccessToken({
username: 'alice',
tokenName: 'inventory',
scopes: ['read:repository', 'read:user'],
});
} finally {
await fixture.stop();
}
start() returns only after Gitea answers /api/healthz, reports the pinned version through /api/v1/version, and the administrator and its token exist. A fixture is single-use: after stop() create a new instance. stop() may be called while start() is still running: the start is cancelled at its next checkpoint (an image pull is aborted), start() rejects with ForgeFixtureStoppedError, and stop() returns only after every resource that start created is removed. stop() is idempotent; if a removal fails, the remaining resources stay recorded, the failures are thrown together, and a later stop() retries them.
GitLab
import { GitlabFixture, GitlabSeedBuilder, createDefaultGitlabSeedSpec } from '@foss.global/forgefixtures';
const fixture = new GitlabFixture(); // rootless daemon of the current user
try {
const runtime = await fixture.start(); // several minutes: omnibus runs gitlab-ctl reconfigure on first boot
// runtime.admin.username 'root'
// runtime.admin.token personal access token with scopes `api` and `sudo`
// runtime.version '19.4.1', verified against the image pin
const manifest = await new GitlabSeedBuilder(fixture).apply(createDefaultGitlabSeedSpec());
const app = manifest.projects.find((projectArg) => projectArg.pathWithNamespace === 'parent-group/sub-group/app');
console.log(app?.issues.map((issueArg) => issueArg.iid)); // [1, 2, 3, 4]; merge requests count separately
const token = await fixture.createAccessToken({ username: 'carol', tokenName: 'inventory', scopes: ['read_api'] });
} finally {
await fixture.stop();
}
GitLab is configured through GITLAB_OMNIBUS_CONFIG with GitLab's memory-constrained settings (Puma in single mode, Sidekiq concurrency 10, Prometheus monitoring off) and external_url set to the fixture's HTTPS origin, with nginx listening on plain HTTP behind the TLS terminator and Let's Encrypt off. start() returns once all of these hold:
- the image's post-reconfigure hook (
GITLAB_POST_RECONFIGURE_SCRIPT, run by the image's/assets/init-container) has run, sogitlab-ctl reconfigurefinished - Docker reports the container healthy through the image's own
gitlab-healthcheck, gated on that hook - an unauthenticated
/api/v4/versionanswers 401 through the proxy, so nginx, Workhorse and Puma serve the API - an administrator token exists and
/api/v4/versionreports the pinned version
Tokens are created with GitLab's documented programmatic method, gitlab-rails runner; the script is passed on stdin (runner -), so it never appears in process arguments. The default startup deadline is 12 minutes after the container starts; the image pull has its own deadline. Stop-during-start behaves exactly as for Gitea.
Talking to the fixture
The endpoint certificate is issued by a certificate authority that exists only in the memory of the lifecycle. Nothing changes process-global TLS state, so a client must trust runtime.caCertificatePem explicitly. fixture.http is such a scoped client: it trusts only that CA, refuses URLs outside the fixture origin before sending credentials, never follows redirects, and bounds time and response size.
const user = await fixture.http.requestJson({
method: 'GET',
url: '/api/v1/user',
headers: { authorization: `token ${runtime.admin.token}` },
expectedStatus: [200],
});
The endpoint is served by an in-process TLS-terminating reverse proxy that listens before the container starts. Each forge is therefore configured with its exact external origin (Gitea ROOT_URL, GitLab external_url), and the proxy forwards requests with the client's Host header and X-Forwarded-Proto: https, as a correctly configured production proxy does. Links the forge generates, including pagination Link headers (GitLab keyset pagination included), Git LFS action URLs and GitLab web_url values, use the fixture origin.
Seed specs and manifests
GiteaSeedBuilder.apply(spec) validates the whole spec before the first request, then creates:
- users (with random, discarded passwords) and organizations with
public,limitedorprivatevisibility - repositories owned by users or organizations, auto-initialised on
main, plus collaborators - files on
main, branches created by one commit each, labels, milestones and tags - issues and pull requests in the given order, authored through the administrator's
Sudoheader; labels, milestones, assignees and closing are applied by the administrator so they are not silently dropped for authors without write access - releases, and Git LFS objects uploaded through the batch API with their pointers and
.gitattributescommitted - deletion of the users listed in
deleteUsers, last
The returned IGiteaSeedManifest is read back from Gitea after all mutations: stable IDs, numbers, states, authors (after deletion), label and milestone associations, branch and tag commits, pull request head commits, release IDs and LFS object IDs.
createDefaultGiteaSeedSpec() covers behaviours an inventory reader must handle. These are observed facts of Gitea 1.27.3, asserted by the opt-in test:
- issues and pull requests share one number sequence per repository
- a deleted author's issues and comments are reattributed to the ghost user with ID
-1 internal: truemarks a public repository of a private owner; a public repository of alimitedorganization reportsinternal: false- private repositories are invisible (404) to anonymous callers, and a non-administrator token is refused on admin endpoints (403)
GitLab seed specs and manifests
GitlabSeedBuilder.apply(spec) validates the whole spec before the first request, then creates, as the root administrator unless noted:
- users (with random, discarded passwords), groups and nested subgroups with
public,internalorprivatevisibility, and group members - projects in groups or personal namespaces, initialised with a README on
main, plus project members - files on
main, branches created by one commit each, labels, milestones and tags - issues (including confidential issues and incidents) and merge requests, authored through the administrator's
Sudoheader; labels, milestones, assignees and closing are applied by the administrator, and the seed fails if GitLab does not apply them - releases, and closing of milestones marked closed
- deletion of the users listed in
deleteUsers, last, waiting until GitLab finished moving their contributions to its ghost user
The returned IGitlabSeedManifest is read back after all mutations: group and project IDs, namespaces, visibility, web_url and clone URLs, branch and tag commits, labels, milestones, issue and merge request IDs and IIDs, states, types, confidentiality, authors (after deletion), assignees, notes and releases.
createDefaultGitlabSeedSpec() creates 25 projects, more than one default page of 20. These are observed facts of GitLab CE 19.4.1, asserted by the opt-in test or handled by the seed:
- issues and merge requests have separate IID sequences per project
- a deleted user's issues and notes are reattributed to the
ghostuser; the deletion runs in the background and took about 50 s - a closed milestone assigned through the API is silently ignored (the milestone finder defaults to active milestones), so the seed closes milestones after assigning them
- group members get access to a project created in the group from a background job, so a member can see 404 for a few moments; the seed waits for access before acting as that member
- keyset pagination
Linkheaders carry the fixture origin - internal projects are 404 to anonymous callers and visible to any signed-in user; private projects and confidential issues are 404 to non-members; a non-administrator token is refused on admin endpoints (403)
Ownership and Cleanup
Every container, network and volume of a lifecycle carries labels under global.foss.forgefixtures.*: the lifecycle ID, fixture kind, machine ID (/etc/machine-id; a process whose system has none, as in many container images, uses a random ID fixed for its lifetime), host name, kernel boot ID, owner PID namespace (pid:[<inode>]), owner PID, owner process start time, creation time and expiry. Before starting, each lifecycle runs the reaper, which removes a lifecycle's resources when:
- its
maxLifetimeMs(default two hours) has passed (expired) - its owner ran on this machine, and the machine rebooted since (
owner-rebooted) - its owner ran on this machine in the reaper's own PID namespace, and that PID no longer exists or belongs to a process with a different start time (
owner-exited)
A PID identifies a process only inside its PID namespace. Processes that share the Docker socket can share host name and boot ID without sharing PIDs, for example a runner in a host-network container, and machines sharing a remote daemon can share a host name. Owners with another machine ID or host name, or in another PID namespace, are therefore judged by expiry only. A process without a machine ID therefore judges every other owner by expiry only, and every other process judges its owners the same way. Resources whose labels cannot be interpreted, including labels without a machine ID or PID namespace, are reported and never removed. The reaper can also be run on its own:
import { ForgeFixtureReaper } from '@foss.global/forgefixtures';
const report = await new ForgeFixtureReaper().reap();
console.log(report.reapedLifecycles, report.unrecognizedResources);
Pinned Images and Resources
| Fixture | Image | Limits |
|---|---|---|
| Gitea | gitea/gitea:1.27.3-rootless@sha256:1c17ecaead42eb3b5391553d8708103a4beb0e86edf5b9ebc1eb269c318845f2 (multi-architecture index) |
512 MiB memory without swap, 2 CPUs, 512 PIDs |
| GitLab CE | gitlab/gitlab-ce:19.4.1-ce.0@sha256:9b33b45b9f42d176bada85ee5ecb81ddab7e506c435f44cd582206e284b2809c (multi-architecture index) |
5 GiB memory without swap, 4 CPUs, 4096 PIDs, 256 MiB /dev/shm |
Images are pulled by digest and the pulled repository digest is verified; a moved tag cannot change what runs. Gitea runs as uid 1000 with SQLite, and its data directory, configuration and Git hooks live in one owned named volume that is removed with the lifecycle; hooks must be executable, which Docker's noexec tmpfs mounts would prevent. Only the plain-HTTP port is published, on 127.0.0.1, on a port the lifecycle picks from the kernel's free loopback ports (a rootless daemon allocates ephemeral ports blind to host listeners); SSH is not published.
GitLab runs as root inside the container, which a rootless daemon maps to the invoking user. Its configuration, data and logs live in three owned named volumes that are removed with the lifecycle. Run one GitLab fixture at a time and check the host's available memory first: the opt-in test refuses to boot GitLab with less than 8 GiB available.
Measured on a 32-CPU host with the image already present:
- Gitea: start to ready in 7–8 s, the default seed in about 7 s, about 170 MiB of memory while idle after seeding, and a 3.6 MiB data volume.
- GitLab CE (four runs): start to ready in 253–269 s, the default seed in 63–138 s (about 50 s of it waiting for the user deletion), a token created through
gitlab-rails runnerin up to a minute. Anonymous memory is about 2.8 GiB while idle after seeding and peaked at 3.6 GiB while a token runner shared the container with Puma; a 4 GiB limit was reached through page cache (reclaimed by the kernel, no OOM kills), so the default limit is 5 GiB to leave headroom for anonymous memory peaks. After seeding the data volume holds 486 MB, the log volume 11 MB and the configuration volume 0.2 MB. The image is 3.8 GB.
Tests
pnpm test runs the Docker-free tests only. The Docker tests are opt-in:
FORGEFIXTURES=gitea pnpm exec tstest test/test.gitea.node.ts --verbose --logfile
FORGEFIXTURES=gitea pnpm exec tstest test/test.gitea.crash.node.ts --verbose --logfile
FORGEFIXTURES=gitlab pnpm exec tstest test/test.gitlab.node.ts --verbose --logfile
FORGEFIXTURES takes a comma list (gitea,gitlab) or all. All GitLab assertions live in one file, so GitLab boots fully once per run; that file also stops three GitLab starts at different phases, one at a time.
test.gitea.crash.node.ts starts a fixture in a child process that kills itself with SIGKILL, then proves the reaper removes the container, network and volume that outlived it.
License and Legal Information
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the license.md file.
Please note: The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
Trademarks
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
Company Information
Task Venture Capital GmbH
Registered at District Court Bremen HRB 35230 HB, Germany
For any legal inquiries or further information, please contact us via email at hello@task.vc.
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.