Build Tarball and Git-Repo Packs
fit-pack stage writes a single pack into a checked-out
repository's working tree. When you instead want to produce
distributable artifacts — tarballs to attach to a
release, or a static git repository a package manager can clone over
plain HTTP — @forwardimpact/libpack builds them
programmatically with PackBuilder. One call takes a
list of pack combinations and emits every format at once,
deterministically: an unchanged input produces a byte-identical
output, so artifacts are reproducible and safe to cache.
This guide covers building the tarball and bare-git-repo formats. For the discovery-index format that lets agents find skills over the web, see Publish a Skill Discovery Index. For the single-repository working-tree path, see Distribute Skill Packs.
Prerequisites
- Node.js 22+
-
git,tar, andgzipon the path —PackBuildershells out to all three to produce the git repo and the compressed tarballs. -
Pack combinations prepared in memory: each is a
{ name, description, content }object, wherecontentholds the skills, agents, and shared files to stage. You assemble these from your own source —PackBuilderconsumes them; it does not read a source directory itself.
What it builds
PackBuilder.build() writes three output trees under the
directory you give it, one entry per combination:
<out>/
packs/
raw/<name>.tar.gz # the full pack, flat layout, gzipped
apm/<name>.tar.gz # the same pack in APM's .apm/ layout, gzipped
apm/<name>/ # a static bare git repository of the APM layout
skills/<name>/ # the discovery index (see the discovery-index guide)
The two tarballs differ only in layout.
packs/raw/<name>.tar.gz carries the pack in its
full directory shape;
packs/apm/<name>.tar.gz carries the same content
rearranged into the .apm/skills/ and
.apm/agents/ convention a package manager reads. Ship
the raw tarball when a consumer wants the files as authored; ship
the APM tarball when they install through APM but prefer a
downloaded archive over a clone.
packs/apm/<name>/ is a
static bare git repository — not a working tree,
but the objects/ and refs/ of a repository
with a single tagged commit. Serve it over plain HTTP and a consumer
can git clone it, or apm install it,
without you running a git server.
Build the packs
Compose PackBuilder from the stager and the three
emitters, then call build() with your combinations:
import {
PackBuilder,
PackStager,
TarEmitter,
GitEmitter,
DiscEmitter,
} from "@forwardimpact/libpack";
import { createDefaultRuntime } from "@forwardimpact/libutil/runtime";
const runtime = createDefaultRuntime();
const builder = new PackBuilder({
runtime,
stager: new PackStager({ runtime }),
emitters: {
tar: new TarEmitter({ runtime }),
git: new GitEmitter({ runtime }),
disc: new DiscEmitter({ runtime }),
},
});
const { packs } = await builder.build({
combinations,
outputDir: "./dist",
version: "1.2.3",
});
build() returns { packs } — the name and
description of each pack it wrote, so you can list what was produced
or feed it into release notes.
Why the output is deterministic
A pack you build twice from the same input is byte-identical, which is what makes the artifacts cacheable and a re-release a real diff rather than churn. Two mechanisms enforce it:
- Reset timestamps. Before archiving, every file's modification time is set to the Unix epoch, so the tarball's headers do not carry the wall-clock time of the build.
- Sorted entries and stable git identity. Files are archived in sorted order, and the git repository's commit uses a fixed author, committer, and date. The commit hash therefore depends only on the content, not on when or where you built it.
gzip -n is used so the compressed stream omits the
original filename and timestamp, keeping even the
.tar.gz byte-stable.
Serve the git repo over HTTP
The bare repository under packs/apm/<name>/ is
laid out for dumb HTTP serving — a static file host
is enough for a normal clone. Because dumb HTTP cannot negotiate a
shallow clone, PackBuilder also writes a small
smart-http/ directory of pre-computed responses so that
tools cloning with --depth=1 (APM does) still succeed
against a static host. You route three paths to those files:
| Request | Serve |
|---|---|
GET …/info/refs?service=git-upload-pack |
smart-http/info-refs |
POST …/git-upload-pack (body has no
done)
|
smart-http/upload-pack-shallow |
POST …/git-upload-pack (body has
done)
|
smart-http/upload-pack-result |
Any other path is served straight from the repository directory as a static file. With those three routes in place, both a full clone and a shallow clone work without a live git backend.
Verify
You have reached the outcome of this guide when:
-
PackBuilder.build()writespacks/raw/<name>.tar.gz,packs/apm/<name>.tar.gz, andpacks/apm/<name>/for every combination. -
Re-running
build()with the same combinations and version produces byte-identical tarballs. -
A
git cloneofpacks/apm/<name>/— including--depth=1against a static host with the threesmart-httproutes — checks out the pack at the tagged version.
What's next
Distribute Skill Packs
Stage a skill pack into APM's .apm/ layout so a bare install pulls skills and agents together — one command from a source tree to an installable repository.
Publish a Skill Discovery Index
Emit a .well-known/skills/ discovery index so an agent can find and load skills over the web — a per-pack index plus a deduplicated index spanning every pack.