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.
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.
Make a repo, and commit to it
Section titled “Make a repo, and commit to it”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))]),);Read a snapshot
Section titled “Read a snapshot”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 thereyield* snap.bytes('logo.png'); // Option<File>: bytes, size, mode, hash, binaryyield* snap.json('site.json', Site); // decoded by a schemayield* snap.entries('docs'); // one directoryyield* snap.diff(yield* repo.at('v1')); // the paths that changedrepo.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).
Hand out a clone URL
Section titled “Hand out a clone URL”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.
Start a workflow on every push
Section titled “Start a workflow on every push”A workflow whose payload is a namespace’s events starts on each one, with the event as its payload:
export class Recorder extends Workflow.Make<Recorder>()('record', { payload: Code.events(['pushed', 'created', 'deleted']), success: Schema.String,}) {}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.
Test it
Section titled “Test it”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:
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).
Try it locally
Section titled “Try it locally”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.
Deploying
Section titled “Deploying”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.
Good to know
Section titled “Good to know”- 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 wereart_v1_…). The client takes either out of its error messages.
- Workflows: steps, sleeps and replays, for what a push starts.
- Plan and ship: ship it, behind Access.