@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 dockerSocketPath option, otherwise DOCKER_HOST, otherwise /var/run/docker.sock
  • for GitLab, a rootless Docker Engine: the omnibus image runs as root, and @apiclient.xyz/docker permits a root container user only after the daemon proves rootless mode. GitlabFixture defaults 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, so gitlab-ctl reconfigure finished
  • Docker reports the container healthy through the image's own gitlab-healthcheck, gated on that hook
  • an unauthenticated /api/v4/version answers 401 through the proxy, so nginx, Workhorse and Puma serve the API
  • an administrator token exists and /api/v4/version reports 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.

Recording requests

new GiteaFixture({ recordRequests: true }) makes the fixture's TLS proxy record every request it forwards to Gitea, so a test can prove that a client wrote nothing to the source forge. The proxy is the same one that serves runtime.baseUrl, and it is the only route to Gitea: clients use runtime.baseUrl and runtime.caCertificatePem exactly as without recording, and URLs Gitea generates (clone URLs, LFS actions, pagination links) cannot route around the log. Requests and responses pass through unaltered and streamed.

A record holds only the method, the path without its query, the service query parameter (as git-upload-pack, git-receive-pack or unrecognized), and the operation of Git LFS batch requests (POST .../info/lfs/objects/batch). The batch body is read alongside the forwarded stream for that one field (up to 1 MiB; a larger, encoded, malformed or unfinished body records unrecognized). Headers, credentials, other query parameters and bodies are never kept.

const fixture = new GiteaFixture({ recordRequests: true });
const runtime = await fixture.start();
await new GiteaSeedBuilder(fixture).apply(createDefaultGiteaSeedSpec());
const mark = fixture.requestLog.mark(); // the fixture's own startup and seed requests are recorded too

// … run the client under test against runtime.baseUrl, trusting runtime.caCertificatePem …

fixture.requestLog.assertReadOnly({
  since: mark,
  requiredKinds: ['upload-pack-advertisement', 'upload-pack', 'lfs-batch-download', 'lfs-object-get'],
});
console.log(fixture.requestLog.records(mark)); // frozen snapshot

assertReadOnly() throws ForgeFixtureRequestLogError unless every record in the window is on the read-only allowlist and every required kind occurred. The allowlist is: any GET without a service query parameter (a push starts with info/refs?service=git-receive-pack); GET <owner>/<repo>/info/refs?service=git-upload-pack and POST <owner>/<repo>/git-upload-pack; Git LFS batch POST with operation download; and Git LFS object GET. The error lists the offending records (error.offending, method, path, service and operation only) and the missing kinds (error.missingKinds). classifyForgeFixtureRequest() exposes the classification of one record. The log stays readable after stop(). Without recordRequests, fixture.requestLog throws.

Seed specs and manifests

GiteaSeedBuilder.apply(spec) validates the whole spec before the first request, then creates:

  • users (with random, discarded passwords), optionally site administrators or deactivated, with generated Ed25519 SSH keys; organizations with public, limited or private visibility
  • repositories owned by users or organizations, auto-initialised on main, or created as a fork or a pull mirror of an earlier repository (mirrors clone from the instance's own loopback listener, which the fixture allows), plus collaborators
  • files on main (Actions workflows are files under .gitea/workflows/), branches created by one commit each, labels, milestones and tags
  • issues and pull requests in the given order, authored through the administrator's Sudo header; labels, milestones, assignees and closing are applied by the administrator so they are not silently dropped for authors without write access; reactions on issues and comments and COMMENT reviews with inline comments, authored through Sudo
  • releases, drafts and prereleases included, with uploaded assets given as text or bytes (Uint8Array); an asset's name travels in the name query parameter, so any name Gitea stores survives, and one release may hold the same name twice
  • Git LFS objects uploaded through the batch API and committed under a .gitattributes LFS filter, so the commit holds their pointers
  • wiki pages, inactive webhooks, deploy keys, Actions secrets (random values that never leave the builder) and variables; then the template flag
  • organization teams with per-unit access, members and repositories, inactive organization webhooks and organization Actions secrets and variables, once every repository exists
  • branch protections (a plain rule name disables pushing; a rule object takes every field Gitea reads back: priority, push, force-push, merge, bypass and approvals allowlists of users and teams, deploy-key flags, status checks, required approvals, review blocking, stale approvals, admin merge override, signed commits, protected and unprotected file patterns) and tag protections allowing users and teams. They come after every commit, because a protected branch refuses the API commits, and after the teams, because allowlists name teams and their members
  • generic packages uploaded through the package registry
  • archiving of repositories marked archived, then 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 commits, every tag the repository holds (published releases create their own tags) with its commit, pull request head commits, reaction and review counts, release IDs with draft and prerelease flags, LFS object IDs, account flags and SSH key fingerprints, teams with members and repositories, webhook and deploy key IDs, Actions secret and variable names (never values), workflow paths, collaborators, fork, mirror, template and archived flags, and package files with sizes and SHA-256 digests.

Release assets carry their ID, UUID, name, size and SHA-256 digest. The digest is computed from the bytes downloaded through /attachments/{uuid} with the administrator's token (a redirect is refused), and the builder throws unless every release holds exactly the uploaded assets: the same IDs, names, sizes and digests. Gitea itself exposes no asset digest.

Protections appear as names (branchProtections, tagProtections) and as full rules (branchProtectionRules with every field above, allowlists sorted, and tagProtectionRules with their user and team allowlists). Gitea answers 201 for a branch protection yet silently drops allowlisted users without write access (approvals: without read access), teams without access to the repository, every team of a personal repository, and force pushing while pushing is disabled. The builder therefore throws unless every declared field reads back as declared.

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: true marks a public repository of a private owner; a public repository of a limited organization reports internal: false
  • private repositories are invisible (404) to anonymous callers, and a non-administrator token is refused on admin endpoints (403)
  • a team with per-unit access reports permission: none; its access is in units_map. The Owners team reports owner
  • a repository without a wiki answers 404 on its wiki page list
  • a protected main refuses commits through the contents API (403), even for the administrator
  • release asset names are stored as given: spaces and parentheses, non-ASCII characters, a leading dot, and the same name twice in one release
  • a release creates its tag, a draft does not
  • release assets download from /attachments/{uuid} with the administrator's token, without a redirect
  • a branch protection keeps no allowlisted user without write access, and Gitea still answers 201

The default seed's v1.0.0 release carries a SHA256SUMS asset in sha256sum format (<hex> <name> per line) covering its other assets, among them a 5 MiB + 1 byte asset of deterministic bytes; the prerelease carries the odd and duplicate names, the draft carries its own SHA256SUMS beside draft.txt, and the private repository's release and the archived repository's release carry plain assets without a digest list. The release/* branch rule sets every field away from its default; the v* tag rule allows alice and the maintainers team.

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, internal or private visibility, 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 Sudo header; 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 commits, every tag the project holds (releases create their own tags) with its commit, 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 ghost user; 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 Link headers 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–10 s, the default seed in 9–14 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 runner in 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; the Gitea suite also clones and pushes with the host's git:

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.

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.

S
Description
No description provided
Readme
228 KiB
0 Stars 3 Watchers 0 Forks
Languages
TypeScript 100%