Skip to content

Artifacts

Artifacts is Cloudflare’s Git, as a service: real repositories, which git clone and git push reach, made and read from a Worker. You declare a namespace; its repos are data, made at runtime, as many as you like: one per user, per site, per agent session.

example/git/resources.ts
import { Artifacts } from '@forge-effect/infra';
export class Code extends Artifacts.Namespace<Code>()('code', {
committer: { name: 'git app', email: 'git-app@example.com' },
}) {}

It’s CODE in the Worker’s env, and git-code in the account (for the app git). Add jurisdiction: 'eu' to keep its repos’ data in the EU; that can’t change once the namespace is made.

This page walks through example/git, a little Git host: browse repos, edit files in the browser, clone, and see each push as it happens.

There’s no clone to make. A commit names its branch, its changes, and the commit they were made on:

const code = yield* Code;
const { info, repo } = yield* code.create('site', { description: 'my site' });
const first = yield* repo.commit({
branch: info.defaultBranch,
message: 'Start',
changes: [Change.put('README.md', '# site\n')],
});
yield* repo.commit({
branch: 'main',
parent: first.id,
message: 'Move the README',
changes: [Change.move('README.md', 'docs/README.md'), Change.put('index.html', '<h1>Hi</h1>')],
});

Change is put (text or bytes, with a mode: a file, an executable or a symlink), delete, deleteTree and move. Only the trees on the changed paths are read and only the new objects are pushed, so a move uploads nothing. Each commit gets a short-lived write token, revoked after.

The parent is the guard. If someone committed to main since first, the commit fails with BranchMoved, saying where main is now. Nothing is overwritten. The Git app uses this for its editor: the page holds the commit it was read at, so a save from a stale page is refused, and the person’s text is kept to save again. When you’d rather just try again, repo.update reads a fresh snapshot, makes the changes, and runs again if someone got there first:

yield* repo.update({ branch: 'main', message: 'Count' }, (snap) =>
Effect.map(snap.text('count.txt'), (n) => [Change.put('count.txt', String(Number(Option.getOrElse(n, () => '0')) + 1))]),
);

repo.at(ref) pins a branch, tag or commit (the default branch, by default) to one commit, however the branch moves on after:

const snap = yield* repo.at('main');
yield* snap.text('README.md'); // Option<string>: None if it isn't there
yield* snap.bytes('logo.png'); // Option<File>: bytes, size, mode, hash, binary
yield* snap.json('site.json', Site); // decoded by a schema
yield* snap.entries('docs'); // one directory
yield* snap.diff(yield* repo.at('v1')); // the paths that changed

repo.log gives a page of history and repo.history a Stream of all of it. repo.refs lists branches and tags. repo.branches and repo.tags make and delete them, each guarded like a commit. repo.fork copies a repo.

The errors are the ones a web app answers with: RepoNotFound, RefNotFound, NotAFile (a directory read as a file), NotADirectory, AlreadyExists, BranchMoved, NoChanges, ChangeInvalid (a path with .., a file deleted that isn’t there) and TooLarge (over 32 MB).

For a person, a container or a CI job, remote gives a URL and a token:

const remote = yield* repo.remote({ access: 'read', ttl: '1 hour' });
// git ${Redacted.value(remote.gitArgs).join(' ')} clone ${remote.url}

The URL holds no secret. The token goes in a header (gitArgs is -c http.extraHeader=Authorization: Bearer …), so it isn’t written into the clone’s config or your shell history by accident. It lives for its TTL, or until remote.revoke.

A workflow whose payload is a namespace’s events starts on each one, with the event as its payload:

example/git/resources.ts
export class Recorder extends Workflow.Make<Recorder>()('record', {
payload: Code.events(['pushed', 'created', 'deleted']),
success: Schema.String,
}) {}
example/git/activity.ts
export const record = Recorder.implement((event) =>
Workflow.run('record', Effect.gen(function* () {
const line = summary(event); // "pushed to main: Fix the header"
yield* (yield* Activity).run('INSERT OR IGNORE INTO events (id, at, kind, repo, summary) VALUES (?, ?, ?, ?, ?)', event.id, /* … */);
return line;
}).pipe(Effect.orDie)),
);

A push has the ref, before and after (Options: none for a branch made or deleted), and the commits it brought. The other types are created, deleted, forked, imported, cloned, fetched, token.created and token.revoked. Code.events('pushed', { repo: 'site' }) is one repo’s. The event’s id is the instance’s, so each event starts one instance, and INSERT OR IGNORE by it records each once.

Events are decoded leniently. One of a type you didn’t ask for, or one Cloudflare has changed the shape of, arrives as Other, with what came, rather than failing the instance.

The Worker hosting the workflow binds the namespace, and ship puts the triggers after the workflow is registered.

Artifacts.memory(Code) is a namespace in memory that behaves as Cloudflare’s does: its commits are pushed to a Git server in memory, tokens checked, so BranchMoved and the rest happen as they would live. The Git app’s test runs every page with it, and D1 on SQLite:

test/git-example.test.ts
const runtime = ManagedRuntime.make(Layer.mergeAll(Artifacts.memory(Code), sqlite(Activity)));

A workflow’s body runs in memory too, with an event decoded from what Cloudflare sends: Workflow.test(record, event).

Cloudflare has no local Artifacts (Wrangler’s dev uses the real service). infra dev runs a stand-in Worker in its place: the same code as memory, keeping repos in a Durable Object with the rest of dev’s state. It serves them over Git at http://127.0.0.1:8787/__artifacts/git/<namespace>/<repo>.git, so git clone and git push work against dev, with a remote()’s gitArgs. Its events start your workflows as Cloudflare’s do: push to dev, and the Git app’s activity shows it a moment later.

A ship makes the namespace, in its jurisdiction (a ship that finds it in another stops), and binds it. Its repos are data: prune and destroy delete them, then the namespace, only when you allow data loss, as for a bucket.

  • Why a workflow, not a queue, for events: a queue’s Artifacts subscription is for one repo, or for the whole account’s lifecycle events (one per account), so a namespace’s pushes can’t reach one. A workflow’s triggers filter by namespace.
  • A commit makes three events: the push, and the token it was pushed with being made and revoked. A new branch’s push lists every commit it reaches, not only the new ones. A fork made no event of its own when measured, only its deletion did.
  • A namespace just deleted refuses new repos for a while (“Namespace is not active”). Use a fresh name rather than making it again at once.
  • Tokens look like art_v2_x_…?expires=… now (they were art_v1_…). The client takes either out of its error messages.