Build Tarball and Git-Repo Packs
fit-pack stage writes a single pack into a checked-out
repository's working tree. You may instead want distributable
artifacts. Examples are 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. The build
is deterministic. An unchanged input produces a byte-identical
output. So artifacts are reproducible and safe to cache.
This guide covers how to build 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. It is not a working
tree. It holds the objects/ and refs/ of a
repository with a single tagged commit. Serve it over plain HTTP. A
consumer can then git clone it, or
apm install it, and you run no 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 }. It holds the
name and description of each pack it wrote. You can list what
build() produced, or feed it into release notes.
Why the output is deterministic
A pack you build twice from the same input is byte-identical. So the artifacts are cacheable. A re-release shows a real diff. It does not show churn. Two mechanisms enforce it:
-
Reset timestamps. Before it archives the files,
PackBuildersets every file's modification time 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.
PackBuilderarchives files in sorted order, and the git repository's commit uses a fixed author, committer, and date. So the commit hash depends only on the content. It does not depend on when or where you built it.
PackBuilder uses gzip -n, so the
compressed stream omits the original filename and timestamp. That
keeps 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. A static file host is
enough for a normal clone. Dumb HTTP cannot negotiate a shallow
clone. So PackBuilder also writes a small
smart-http/ directory of pre-computed responses. Tools
that clone with --depth=1 (APM does) then 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 |
Serve any other path 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. -
A re-run of
build()with the same combinations and version produces byte-identical tarballs. -
A
git cloneofpacks/apm/<name>/checks out the pack at the tagged version. This includes--depth=1against a static host with the threesmart-httproutes.
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 turns a source tree into 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. You get a per-pack index plus a deduplicated index across every pack.