Key insight

A pin file is small, declarative, and version-controlled. It survives the failure modes that destroy pipelines: people leaving, tools changing, credentials rotating, registries moving. Treat it as the canonical statement of what a release is — not as a side-effect of CI.

Why a pin file exists

In a signed-image delivery model, the customer’s install script needs three pieces of information that change per release:

  1. The exact image digest to install (chapter 2 explained why a digest, not a tag).
  2. The signing identity to verify against (chapter 3 explained why this matters).
  3. The location of the SBOM and any other release artefacts the customer is expected to keep.

The naive approach is to bake these values into the install script and update the script per release. This works for the first few releases and breaks the moment a customer is one version behind. A customer running last quarter’s install script does not have last quarter’s correct digest, and finding out which digest they should have is detective work.

The pin file separates the script’s logic (which changes rarely) from the release’s identity (which changes every release). The install script reads the pin file; the pin file is updated atomically per release; the script itself does not need to be re-deployed.

Anatomy of a pin file

A minimal pin file is a single JSON document. The fields below are the working set across well-designed pipelines.

{
  "$schema": "https://json-schema.org/draft-07/schema",
  "agent": {
    "version": "1.2.3",
    "tag": "v1.2.3",
    "digest": "registry.example.com/vendor/agent@sha256:e8a4f01…",
    "registry": "registry.example.com",
    "signed_by": {
      "certificate_identity_regexp": "^https://github.com/vendor/agent/.github/workflows/release.yml@refs/tags/v.*$",
      "certificate_oidc_issuer": "https://token.actions.githubusercontent.com"
    },
    "released_utc": "2026-06-02T14:32:00Z",
    "build_provenance": "registry.example.com/vendor/agent:v1.2.3.intoto.jsonl",
    "sbom_url": "https://…/releases/download/v1.2.3/agent-sbom-cyclonedx.json",
    "sbom_sha256": "ab12…",
    "release_notes_url": "https://…/releases/tag/v1.2.3"
  }
}

Every field carries weight. The version is human-readable; the digest is canonical. The two signing fields together let the install script verify the signature against an exact build-pipeline identity, not against the vendor’s name in general (chapter 3 covered why this matters). The build_provenance URL points at the SLSA attestation, which the customer’s audit team will pull at install or annually. The sbom_url and sbom_sha256 let the customer download the SBOM and verify integrity in one step.

The $schema reference at the top is small but useful. Schema-aware editors highlight typos as you write the file; CI can validate it before publishing. A pin file with a structural mistake is one of the few release-day errors that is genuinely cheap to catch ahead of time.

Where the pin file lives

The pin file lives in the bootstrap repository (chapter 5) at the repository root. It is committed alongside the install script. Each release is one commit on the bootstrap repository’s main branch that updates the pin file (and, typically, the release-notes file) and creates a matching tag.

The pin file does not live in the application source repository. Keeping it in the bootstrap repository — the small public-facing companion repo — means that customers can subscribe to bootstrap-repo notifications and see only the events that affect them. They are not paged for development commits or feature branches that have nothing to do with what they install.

How the pin file is updated

The pin file is updated by the build pipeline at the end of every successful signed release. The mechanics vary by host, but the shape is consistent:

  1. The pipeline builds the image, pushes it, signs it, generates the build-provenance attestation, generates the SBOM.
  2. The pipeline computes the new pin file content from the values it just produced.
  3. The pipeline opens a pull request against the bootstrap repository, replacing the existing pin file. The pull request also updates the release-notes file.
  4. A human review approves and merges the pull request. The merge becomes the official release moment.
  5. The pipeline tags the bootstrap repository with the matching version.

The human-review step is small but important. It is the gate at which somebody confirms “yes, this is the release we meant to publish” before customers can install it. It also gives the operator a last chance to update the release-notes content from a one-line auto-generated summary to a usable customer-facing description.

How the install script consumes the pin file

The install script does four things with the pin file:

  1. Reads the digest. Calls the registry to verify the digest can be pulled.
  2. Reads the two signing fields. Runs the signature-verification tool with those values. Aborts on failure.
  3. Reads the SBOM URL and SHA. Downloads the SBOM. Verifies the SHA. Stores the file alongside the install log for the customer’s audit team.
  4. Pulls and installs the image at the verified digest.

The script never substitutes its own values for the pin file’s values. If the pin file says digest X, the script installs digest X or fails closed. There are no override flags, no “just this once” bypasses, no environment-variable shortcuts. If the customer needs to install a different version, they change the pin file (typically by checking out an older tag of the bootstrap repository) and re-run the script.

Why a JSON file beats every alternative

The temptation is to use something fancier. People reach for environment variables, command-line flags, Helm values, Kubernetes ConfigMaps, vendor-specific manifest formats. Each works; none is as good as a small versioned JSON file.

JSON has four properties that the alternatives lack. It is diffable in code review. It is schema-validatable in CI. It is portable — any language, any runtime, any tool can read it. And it has no privileged tooling — the customer does not need to install anything special to read or audit it.

The pin file is, in essence, the public API of the release pipeline. Treating it as a small, stable, documented data structure gives every other tool in the chain something firm to compose against.

Versioning the pin file itself

The pin file’s contents change every release. Its schema should change rarely — ideally never after the first few releases. When the schema does need to evolve, the right pattern is a versioned schema field at the top of the document:

{
  "pin_format_version": "1",
  "agent": { … }
}

The install script reads the version first and refuses unknown values. When a future release needs schema version 2, every customer install script needs to be updated first — and the install script can verify, on day one, that it is reading a pin file it understands.

A small example of what the pin file replaces

Without a pin file, the install script ends up containing release-specific values:

# install.sh, fragment, the old way
IMAGE_DIGEST="registry.example.com/vendor/agent@sha256:e8a4f01…"
COSIGN_IDENTITY="^https://github.com/vendor/agent/.github/workflows/release.yml@refs/tags/v.*$"
COSIGN_ISSUER="https://token.actions.githubusercontent.com"
SBOM_URL="https://…/releases/download/v1.2.3/agent-sbom-cyclonedx.json"
# …and now you need to update install.sh every release

With a pin file, the script reads the values:

# install.sh, fragment, the better way
PIN=$(cat pin.json)
IMAGE_DIGEST=$(echo "$PIN" | jq -r '.agent.digest')
COSIGN_IDENTITY=$(echo "$PIN" | jq -r '.agent.signed_by.certificate_identity_regexp')
# …the script no longer changes per release

The change looks trivial. It is. That is the point of the pin file: a small, almost-uninteresting JSON document that removes a class of operational pain that you would otherwise pay for every release for the lifetime of the product.

References & further reading