Skip to content

git-pkgs/pin

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

79 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

pin

pin vendors browser assets without npm: a single static binary that fetches files from published packages, anchors their integrity to the registry tarball, commits them to your repo, and writes a lockfile that is also a valid CycloneDX SBOM.

If your server-rendered app needs htmx, a CSS kit, and an icon set, that's three dependencies. Running npm install for them gives you a node_modules with hundreds of transitive packages, a lockfile format you don't otherwise use, a Node runtime in CI, and arbitrary code execution on every install via lifecycle hooks. pin fetches the files you name at the versions you pin, hashes them against what npm published, and writes them to disk without running install scripts, hooks, or plugin loaders.

Install

Homebrew:

brew tap git-pkgs/git-pkgs
brew install pin

Go:

go install github.com/git-pkgs/pin/cmd/pin@latest

Or grab a binary from the releases page once tagged.

Quickstart

Write pin.yaml:

out: "internal/web/static/vendor"

assets:
  - name: "htmx.org"
    version: "^2.0"
    files: ["dist/htmx.min.js"]

  - name: "@tailwindcss/browser"
    version: "4.1.13"

  - name: "lucide"
    version: "^0.545"
    files: ["dist/umd/lucide.min.js"]

  - name: "highlight.js"
    version: "11.11.1"
    source: "github:highlightjs/cdn-release"
    files:
      - "build/highlight.min.js"
      - "build/styles/github.min.css"

Run pin sync to get:

internal/web/static/vendor/
  htmx.org/htmx.min.js
  tailwindcss__browser/index.global.js
  lucide/lucide.min.js
  highlightjs__cdn-release/highlight.min.js
  highlightjs__cdn-release/github.min.css
pin.lock

The version field accepts exact pins (2.0.6), semver ranges (^2.0, ~0.3.11), or npm dist-tags (latest, next). Once a version is locked, it stays locked: pin sync re-uses the locked version as long as the manifest constraint still allows it, and pin update bumps within a range. When files: is omitted for an npm source, pin reads the package's package.json and picks the entry point from jsdelivr || unpkg || browser || module || main.

Source kinds

- name: "htmx.org"                       # npm (default)
  version: "^2.0"

- name: "highlight.js"                   # GitHub release
  version: "11.11.1"
  source: "github:highlightjs/cdn-release"
  files: ["build/highlight.min.js"]

- name: "my-asset"                       # Raw URL (TOFU)
  version: "1.0.0"
  source: "url:https://example.com/dist/asset.js"

github: sources resolve the tag to a commit SHA, fetch via jsdelivr's /gh/ mirror, and record the SHA in the lockfile as the integrity anchor. url: sources hash the bytes on first fetch and verify against the recorded hash on every subsequent sync. Both go through the same source.Resolver interface, so adding gitlab/codeberg/bitbucket later is a single new file.

--registry (or SyncOptions.RegistryURL) overrides the npm registry for the whole sync. For a single entry, add registry_url: to that asset:

assets:
  - name: "private-pkg"
    version: "1.0.0"
    files: ["dist/x.js"]
    registry_url: "https://npm.private.example/"

pin records the override on the asset's purl as a repository_url qualifier in pin.lock, so the lockfile round-trips faithfully. ~/.npmrc and registry-auth tokens are not read; private registries pin reaches today are ones that don't require credentials or whose credentials live in the URL.

Commands

pin sync                       resolve manifest, fetch assets, write lockfile (alias: pin install)
pin sync --frozen              fail before any network if manifest and lockfile disagree (CI)
pin sync --no-fetch            --frozen plus re-hash on-disk files against the lockfile; no network, no writes
pin sync --concurrency=N       cap parallel resolves (default 8)
pin sync --dry-run [--json]    resolve and report, write nothing
pin update [NAME...]           re-resolve to highest satisfying version, ignoring the lock
pin verify [--strict] [--json] re-hash files on disk against the lockfile (exit 4 on drift)
pin outdated [--json]          compare locked versions against the registry's latest
pin add NAME[@SPEC] [FILE...]  append to the manifest at alphabetic position and sync
pin rm NAME...                 remove entries from the manifest and sync
pin list [--json]              print the lockfile contents
pin path NAME                  print the on-disk paths for a locked package
pin init                       write a starter pin.yaml in the current directory
pin sbom [-f spdx|cyclonedx-xml] [-o FILE]  emit the lockfile as an SBOM

pin sync prints a one-line stderr nudge when it detects a CI environment (CI, GITHUB_ACTIONS, GITLAB_CI, BUILDKITE, CIRCLECI, JENKINS_URL) and --frozen is not set.

Safe defaults

The cooldown window (min_release_age) is on by default at 48 hours. Most malicious npm versions are caught within 24 to 48 hours, and the window blocks the majority of fresh-publish supply-chain attacks. Ranges fall back to the next-highest satisfying version outside the window; dist-tags fail with a clear error if latest is too fresh; exact pins bypass the window because you named the version explicitly. Opt out with min_release_age: 0 at the manifest top level or per entry.

--frozen is the CI safety flag: it bails before any network if the manifest and lockfile disagree. --no-fetch adds a re-hash of every vendored file against the lockfile's recorded integrity on top of --frozen, for CI jobs that vendored at image-build time and want to assert nothing was tampered with after git checkout without doing any network or any writes.

pin sync rewrites the lockfile only when the manifest changed; identical bytes skip the write. pin runs no code from a fetched package, which puts stages 5 and 6 of The Stages of Package Installation out of scope.

Provenance and trusted publishing

For npm and GitHub forge sources, when the publisher used trusted publishing, pin sync records the SLSA Provenance v1 attestation in the lockfile: builder_id (the CI workflow URI), source_repository, source_revision, signer_identity (the OIDC SAN), and the bundle URL.

Three opt-in flags layer the trust assertion:

pin sync --strict-provenance
   fail if any entry resolves to a version with no attestation.

pin sync --require-publisher-matches-repository
   fail if an attestation's source repository differs from the package's declared
   repository.url. Catches leaked-token attacks: a stolen publish token can sign
   a valid bundle from the attacker's CI, but the source_repository field then
   won't match the legitimate package's repo.

pin sync --verify-provenance
   cryptographically verify the sigstore bundle against the live Sigstore TUF
   trust root: Fulcio cert chain, Rekor inclusion proof, DSSE signature,
   subject digest matches the fetched artifact. Composes with the other two.
   Trust root is cached at $XDG_CACHE_HOME/pin/sigstore-tuf/ after first use.

pin sync --signature-mode {warn|enforce|off}
   verify npm dist.signatures (ECDSA P-256 over {name}@{version}:{integrity},
   keys fetched from /-/npm/v1/keys). warn (default) fails on bad sigs but
   tolerates absent ones; enforce additionally fails on absent.

The persistent form of these per-invocation flags is a manifest trust: block, set top-level or per-entry:

trust:
  require_provenance: true
  require_publisher_matches_repository: true
  trusted_workflows:
    - https://github.com/builder-org/builder/.github/workflows/release.yml

assets:
  - name: monorepo-pkg
    version: ^1.0.0
    trust:
      require_publisher_matches_repository: false   # entry-level override

trusted_workflows is the escape hatch for monorepo packages whose legitimate build workflow lives on a different repo than the package's declared repository.url. CLI flags always win over manifest entries: --strict-provenance forces the check even on an entry that opted out.

pin outdated flags a provenance-downgrade severity (above deprecated, below yanked) when the locked version had an attestation and the latest doesn't, which surfaces the case where the maintainer (or whoever now controls the publish token) disabled trusted publishing.

Lockfile

pin.lock is a valid CycloneDX 1.6 SBOM. Each package becomes a library component with the registry tarball hash; each vendored file becomes a nested file component with its own SHA-384, the CDN URL, and pin-specific metadata under a pin: property namespace. Any CycloneDX consumer (Dependency-Track, GUAC, OSV-scanner, git-pkgs sbom) reads it directly. serialNumber and metadata.timestamp are deliberately omitted so re-runs are byte-stable and parallel branches don't conflict on the file.

The schema is in docs/SPEC.md, the defences in docs/SECURITY.md, and the adversary-by-asset model in docs/THREAT_MODEL.md.

Integrity

On first sync of an npm package version, pin fetches the registry metadata, downloads the published tarball, verifies it against npm's dist.integrity, extracts the requested files, and computes a SHA-384 over each one. Subsequent syncs of the same version verify against the recorded hash, so the CDN URLs in the lockfile are a transport hint rather than the integrity anchor.

GitHub sources anchor on the commit SHA (recorded as a SHA-1 hash on the library component plus a vcs_revision qualifier on the purl); url sources anchor on the per-file SHA-384, established Trust-On-First-Use.

Format sniffing

For each vendored script, pin detects the module format (esm, umd, iife, cjs, amd, system, or unknown) by scanning the bytes with a comment- and string-aware regex pass. The result lands in the lockfile's pin:format property so importmap consumers can filter to ESM entries. Override per-entry with format: in the manifest.

What doesn't work

pin is for self-contained distributables: UMD bundles, IIFE builds, ESM modules with no bare-specifier imports, CSS files. It does not work for packages that expect a module graph at runtime, and it does not run install scripts. If a package's real payload arrives via a postinstall hook (a platform binary downloaded after the tarball lands) pin will vendor only the stub. Point files: at the package's pre-bundled CDN distribution if it ships one; if it doesn't ship one and depends on a bundler or postinstall to assemble itself, it's out of scope.

As a Go library

For one-shot scripts, the package-level functions take the same options the CLI flags wrap (the CLI is itself a thin shim over them):

import "github.com/git-pkgs/pin"

res, err := pin.Sync(ctx, pin.SyncOptions{Dir: "."})

For long-lived processes (a Rails gem, a CI service, a custom integrator) the pin.Client pattern lets one instance reuse its HTTP connection pool and source resolvers across calls:

c := pin.New(pin.ClientOptions{RegistryURL: "https://registry.npmjs.org"})

c.Sync(ctx, pin.SyncOptions{Dir: "./app-a"})
c.Sync(ctx, pin.SyncOptions{Dir: "./app-b"})
c.Verify(pin.VerifyOptions{Dir: "./app-a"})

Source resolvers are pluggable by purl type. Register a new resolver for any prefix (pkg:ipfs/..., an internal artifact registry, etc.) and Sync will dispatch manifest entries with that purl to it:

c.RegisterResolver("ipfs", myIPFSResolver{})

The full Client surface: Sync, Verify, Outdated, Add, Remove, plus the package-level List, Path, Init, SBOM, EncodeLock. The manifest, lock, pinfs, integrity, cdn, sniff, source (with source/npm, source/forge, source/rawurl), and assets sub-packages are all public.

SyncOptions.FS redirects pin's outputs (vendored files + pin.lock) into anything that implements pinfs.Writer. The default writes to local paths under SyncOptions.Dir; pinfs.NewMemory() keeps everything in process, and a custom implementation can pipe writes into a tarball, an archive, or an in-memory build artefact.

Provenance handling lives in two sibling modules: github.com/git-pkgs/attestation (stdlib-only SLSA Provenance v1 bundle parser) and github.com/git-pkgs/sigstore (sigstore-go wrapper that verifies any (digestAlg, digest) pair against the Sigstore TUF trust root). Both can be imported independently of pin.

The assets package is the runtime helper a Go web app uses to consume pin's output: parse the lockfile, serve the vendored files via fs.FS, and emit HTML tags with integrity and crossorigin attributes from a template.

Failure modes surface as wrapped sentinel errors: errors.Is(err, pin.ErrFrozenDrift), pin.ErrVerifyFailed, pin.ErrProvenanceMissing, pin.ErrPublisherMismatch, pin.ErrPathEscape, pin.ErrPathCollision, pin.ErrNoLockfile.

Framework integration

The assets package imports only lock and the standard library, so any Go web framework that takes an fs.FS (or a directory) and any template engine that accepts template.HTML works without a framework-specific adapter.

Framework Serve Tag emission
net/http http.FileServer(http.FS(afs)) assets.Tag / Tags in html/template
Chi r.Handle("/vendor/*", http.FileServer(...)) same
Gin r.StaticFS("/vendor", http.FS(afs)) template helper that returns template.HTML
Echo e.StaticFS("/vendor", afs) renderer that accepts template.HTML
Fiber app.Use("/vendor", filesystem.New(...)) engine-specific Raw helper
Templ http.FileServer(http.FS(afs)) @templ.Raw(assets.Tag(lock, name, opts)[0])
Wails bundle alongside the embedded UI inline in the embedded HTML

Common shape regardless of framework:

import (
    "bytes"
    "embed"

    "github.com/git-pkgs/pin/assets"
)

//go:embed static/vendor pin.lock
var vendored embed.FS

lockBytes, _ := vendored.ReadFile("pin.lock")
lock, _ := assets.Parse(bytes.NewReader(lockBytes))
afs, _ := assets.FS(vendored, lock)

// afs implements fs.FS — pass to http.FileServer(http.FS(afs)) or any
// framework's static-file handler. Render tags from your template with
// assets.Tag(lock, "htmx.org", assets.Options{Prefix: "/vendor/"}).

Embedding vendored bytes in the binary

For single-binary distribution, point pin sync at a directory inside your module and //go:embed it alongside the lockfile:

# pin.yaml
out: "internal/web/static/vendor"
//go:embed internal/web/static/vendor pin.lock
var vendored embed.FS

assets.Parse + assets.FS read both from the same embed.FS, so the binary has no runtime filesystem dependency and no separate static/vendor directory to ship. pin verify --no-fetch runs against the on-disk copy before the build to confirm the embedded bytes are what the lockfile claims.

Stability

The Go API at github.com/git-pkgs/pin covers the functions Sync, Add, Outdated, Verify, Remove, List, Path, Init, SBOM, EncodeLock, and New, plus the Client reusable-client pattern and the option, result, and error types they take. The lock, manifest, pinfs, and assets sub-packages are covered too, along with the sentinel errors. Removing or renaming any of these requires a new major version (/v2, /v3). New fields on option structs are additive and don't bump the major version.

pin.lock carries a pin:lockfile_version property under the CycloneDX metadata. A binary refuses any lockfile whose version it doesn't recognise. New fields land as additive properties under the pin: namespace and don't bump the version. A version bump only happens on incompatible schema changes and arrives as a separate release with migration notes.

pin.yaml follows the same convention: new fields are additive, existing fields keep their meaning across releases, and removals or semantic changes ship under a new major version with explicit migration steps.

The pinfs.Writer interface and the source.Resolver interface are stable in shape: adding a method to either is a breaking change. New behaviour goes on a parallel interface or option struct instead.

api_stability_test.go references every public symbol so a removed or renamed export breaks go build immediately; pkg.go.dev is the live record.

License

MIT

About

Browser asset vendoring without npm.

Topics

Resources

License

Code of conduct

Contributing

Security policy

Stars

Watchers

Forks

Contributors

Languages