Shapin

module
v1.6.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Apr 15, 2026 License: MIT

README

Shapin

Go Reference Go Report Card OpenSSF Baseline

Pin floating tags in CI workflow files to immutable SHAs, making your pipelines reproducible and immune to tag mutation attacks.

Table of contents

What it does

Reference type Before After
GitHub Action uses: actions/checkout@v4 uses: actions/checkout@abc1234... # v4
Forgejo Action uses: actions/checkout@v1 uses: actions/checkout@abc1234... # v1
Docker image (image:) image: maildev/maildev:2.2.1 image: maildev/maildev@sha256:180ef5... # maildev/maildev:2.2.1
Docker image (image: name:) image:
  name: maildev/maildev:2.2.1
image:
  name: maildev/maildev@sha256:180ef5... # maildev/maildev:2.2.1
Dockerfile FROM FROM golang:1.24-alpine AS builder # golang:1.24-alpine
FROM golang@sha256:8bee19... AS builder
GitLab component ref component: gitlab.com/group/proj/name@v1.0.0 component: gitlab.com/group/proj/name@abc1234... # v1.0.0
GitLab image:tag variable TRIVY_TAG: aquasec/trivy:0.69.3 TRIVY_TAG: aquasec/trivy@sha256:eafae... # aquasec/trivy:0.69.3
GitLab bare version variable TF_VERSION: "1.14.8" TF_DIGEST: "sha256:6bbb82... # hashicorp/terraform:1.14.8"
GitLab trigger input TF_VERSION: "1.14.8" (under inputs:) TF_DIGEST: "sha256:6bbb82... # hashicorp/terraform:1.14.8"
GitLab dependency proxy image: ${CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX}/node:24.13.0 image: node@sha256:cd6fb7... # node:24.13.0
GitLab services: (bare) - postgres:15 - postgres@sha256:abc123... # postgres:15
GitLab services: (map) - name: redis:7 - name: redis@sha256:def456... # redis:7

Already-pinned refs and digests are left untouched. Every provider checks pinned SHAs against their current tag — a warning is printed if the tag has been moved to a different commit (drift detection). Using latest as a tag also prints a warning.

Supported files

The tool scans recursively under --path, skipping node_modules, .git, vendor, and dist.

  • GitHub Actions: any .yml/.yaml file inside .github/workflows/ (and subdirectories)
  • GitLab CI:
    • .gitlab-ci.yml / .gitlab-ci.yaml / .gitlab-ci-*.yml at any depth (supports monorepos where each subdirectory is its own project)
    • Any .yml/.yaml file inside .gitlab/ and its subdirectories, at any depth
  • CircleCI: .circleci/config.yml / .circleci/config.yaml
  • Bitbucket Pipelines: bitbucket-pipelines.yml / bitbucket-pipelines.yaml
  • Forgejo Actions: any .yml/.yaml file inside .forgejo/workflows/ (and subdirectories)
  • Woodpecker CI:
    • .woodpecker.yml / .woodpecker.yaml at the root
    • Any .yml/.yaml file inside .woodpecker/ and its subdirectories
  • Dockerfiles: Dockerfile, Dockerfile.*, *.dockerfile, *.Dockerfile (at any depth) — pins FROM image:tag lines
  • Docker Compose: docker-compose.yml, docker-compose.yaml, docker-compose.*.yml, compose.yml, compose.yaml

Installation

One-liner (Linux / macOS)
curl -fsSL https://raw.githubusercontent.com/Kirskov/Shapin/df97d9b9fd31e5e9ac80b2257d3eae7d7628509d/install.sh | sh

The script URL is pinned to a commit SHA so the install script itself cannot be tampered with. Supports Ubuntu, Debian, Kali, Arch, Alpine, Red Hat, Fedora, and macOS. The script will automatically detect your OS and architecture, download the correct binary, and install it to /usr/local/bin.

If you hit GitHub API rate limits (common on shared corporate networks), pass a personal access token with the public_repo scope:

curl -fsSL https://raw.githubusercontent.com/Kirskov/Shapin/df97d9b9fd31e5e9ac80b2257d3eae7d7628509d/install.sh | GITHUB_TOKEN=ghp_xxx sh

To install a specific version, use the Manual method below.

Manual

All releases are immutable — the Git tag, commit SHA, and release assets are locked and cannot be modified or deleted after publication.

Download the binary for your platform from the releases page, verify the release attestation, and move it to your PATH:

# Example for Linux amd64
curl -fsSL https://github.com/Kirskov/Shapin/releases/download/v1.4.0/shapin-v1.4.0-linux-amd64 -o shapin
gh attestation verify shapin --repo Kirskov/Shapin
chmod +x shapin
sudo mv shapin /usr/local/bin/
Docker

Images are published to GHCR and available for linux/amd64 and linux/arm64. Always reference by digest, not tag:

docker run --rm -v $(pwd):/repo ghcr.io/kirskov/shapin@sha256:ceb8dd7ec84b7478a2488beae5af92a6735620acb826106c8b20920023a9041a # v1.4.0 --path /repo

Apply changes (disable dry-run):

docker run --rm -v $(pwd):/repo ghcr.io/kirskov/shapin@sha256:ceb8dd7ec84b7478a2488beae5af92a6735620acb826106c8b20920023a9041a # v1.4.0 --path /repo --dry-run=false

With API tokens:

docker run --rm \
  -v $(pwd):/repo \
  -e GITHUB_TOKEN=ghp_xxx \
  -e GITLAB_TOKEN=glpat_xxx \
  ghcr.io/kirskov/shapin@sha256:ceb8dd7ec84b7478a2488beae5af92a6735620acb826106c8b20920023a9041a # v1.4.0 --path /repo

The digest for each release is listed on the releases page. Update the digest when upgrading to a new version.

Verify the image signature

Images are signed with cosign keyless signing via GitHub Actions OIDC. Verify before running:

cosign verify \
  --certificate-identity "https://github.com/Kirskov/Shapin/.github/workflows/release.yml@refs/tags/v1.4.0" \
  --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
  ghcr.io/kirskov/shapin@sha256:ceb8dd7ec84b7478a2488beae5af92a6735620acb826106c8b20920023a9041a # v1.4.0
Verify release integrity

Every release asset can be verified using three independent mechanisms:

1. Checksum verification — a checksums.txt SHA-256 manifest is included in every release:

# Download the binary and checksum file
curl -fsSL https://github.com/Kirskov/Shapin/releases/download/v1.4.0/shapin-v1.4.0-linux-amd64 -o shapin
curl -fsSL https://github.com/Kirskov/Shapin/releases/download/v1.4.0/checksums.txt -o checksums.txt

# Verify (expected output: "shapin-v1.4.0-linux-amd64: OK")
sha256sum --ignore-missing -c checksums.txt

2. cosign bundle signature — each binary is signed with cosign keyless signing via the Sigstore transparency log:

curl -fsSL https://github.com/Kirskov/Shapin/releases/download/v1.4.0/shapin-v1.4.0-linux-amd64.sigstore.json -o shapin.sigstore.json
cosign verify-blob shapin \
  --bundle shapin.sigstore.json \
  --certificate-identity "https://github.com/Kirskov/Shapin/.github/workflows/release.yml@refs/tags/v1.4.0" \
  --certificate-oidc-issuer "https://token.actions.githubusercontent.com"
# Expected output: Verified OK

3. SLSA provenance attestation — build provenance is attested via GitHub's attestation framework:

gh attestation verify shapin --repo Kirskov/Shapin
# Expected output: Attestation verification was successful
Build from source
git clone https://github.com/Kirskov/Shapin.git
cd Shapin
go build -o shapin ./cmd/shapin

Usage

# Dry run — show what would change, write nothing (default)
shapin --path ./myproject

# Apply changes
shapin --path ./myproject --dry-run=false

# Only pin Docker images, leave refs alone
shapin --path ./myproject --pin-refs=false

# Only pin CI refs, leave images alone
shapin --path ./myproject --pin-images=false

# Exclude specific files (comma-separated globs)
shapin --path ./myproject --exclude ".github/workflows/generated.yml,*.skip.yml"

# Use a config file
shapin --config .shapin.json

# With API tokens (required to resolve unpinned action refs)
shapin --path ./myproject --github-token ghp_xxx --gitlab-token glpat_xxx

# Self-hosted GitLab instance
shapin --path ./myproject --gitlab-host https://gitlab.mycompany.com --gitlab-token glpat_xxx

Upgrading pinned refs

To upgrade a pinned ref to a newer version, update it and rerun shapin.

Action / component refs — change the SHA back to the new tag:

# before (pinned)
- uses: actions/checkout@abc1234... # v4
# edit to
- uses: actions/checkout@v5

Version variables — just set the new version directly in the _DIGEST key:

# before (pinned)
TF_DIGEST: "sha256:6bbb82... # hashicorp/terraform:1.14.8"
# edit to
TF_DIGEST: "1.15.0"

Then rerun shapin --path . to resolve the new digest.

Flags

Flag Default Description
--path . Path to the project to scan
--dry-run true Show diff without writing files
--pin-refs true Pin uses: and component: refs to SHAs
--pin-images true Pin Docker image: tags to digests
--exclude Comma-separated glob patterns of files to skip
--config .shapin.json Path to config file
--github-token $GITHUB_TOKEN GitHub API token
--gitlab-token $GITLAB_TOKEN GitLab API token
--gitlab-host https://gitlab.com GitLab instance URL
--forgejo-host https://codeberg.org Forgejo instance URL
--forgejo-token $FORGEJO_TOKEN Forgejo API token
--output Write output to a file instead of stdout
--format text Output format: text, json, or sarif

Tokens can also be set via environment variables GITHUB_TOKEN and GITLAB_TOKEN.

Warnings (drift, branch refs, resolution failures) are always written to stderr, so they never pollute --output or piped output.

Output formats

JSON
shapin --path ./myproject --format json --output results.json

Outputs a JSON array of file changes, each with the file path and a list of old/new line pairs with their line numbers.

SARIF
shapin --path ./myproject --format sarif --output results.sarif

Outputs SARIF 2.1.0 for upload to GitHub Code Scanning. Each result includes the file path and exact line number, so annotations appear inline in pull requests.

Upload example:

- name: Run Shapin
  run: shapin --path . --format sarif --output shapin.sarif --dry-run=false

- name: Upload SARIF
  uses: github/codeql-action/upload-sarif@v3
  with:
    sarif_file: shapin.sarif

Config file

All flags can be set in a .shapin.json file at the root of your project. CLI flags always take precedence over the config file.

{
  "dry-run": false,
  "pin-refs": true,
  "pin-images": false,
  "github-token": "ghp_...",
  "gitlab-host": "https://gitlab.mycompany.com",
  "exclude": [
    ".github/workflows/generated.yml",
    ".gitlab/auto-*.yml"
  ]
}

Providers

GitHub Actions

Pins uses: owner/repo@tag refs to their commit SHA. Requires --github-token to call the GitHub API.

- uses: actions/checkout@v4
# → - uses: actions/checkout@abc1234... # v4

Already-pinned refs (@sha # tag) are checked for drift — a warning is printed if the tag has been moved to a different commit.

Branch ref warning: If a ref points to a well-known branch name (main, master, develop, development) or a common branch prefix (feat/, fix/, bug/, hotfix/, feature/, bugfix/, release/), a red warning is printed — the pinned SHA will become stale as the branch moves forward. Use a tag instead.


GitLab CI

Scans .gitlab-ci.yml, .gitlab-ci.yaml, .gitlab-ci-*.yml, and any .yml/.yaml inside .gitlab/ — at any directory depth, supporting monorepos where each subdirectory is its own project.

Component refs

Component refs (component: path@tag) are pinned to their commit SHA using the GitLab tags API — no token required for public components.

include:
  - component: gitlab.com/my-group/my-catalogue/deploy@2.1.4
    # → component: gitlab.com/my-group/my-catalogue/deploy@abc1234... # 2.1.4

The predefined variables $CI_SERVER_FQDN and $CI_SERVER_HOST are automatically substituted with --gitlab-host (default: gitlab.com):

component: $CI_SERVER_FQDN/components/sast/sast@3.4.0
# → component: $CI_SERVER_FQDN/components/sast/sast@0a29cf... # 3.4.0

Other $VARIABLE prefixes (e.g. $SPLIT_GLOBAL_COMPONENT_ROOT) cannot be resolved and are left untouched.

For private components, pass --gitlab-token. Without it a warning is printed:

warn: GitLab component .../private-comp@v1.0.0: HTTP 404 — try --gitlab-token if this is a private component

Branch ref warning: Same as GitHub Actions — if the component ref is a well-known branch name, a red warning is printed.

Version inputs

Two patterns are detected at any nesting level across the entire file:

1. image:tag values — keys containing TAG with a full image:tag value:

SCANNER_TAG: myregistry.com/custom-scanner:1.2.3
# → SCANNER_TAG: myregistry.com/custom-scanner@sha256:... # myregistry.com/custom-scanner:1.2.3

2. Bare version values — keys ending or starting with _VERSION, _TAG, or _DIGEST whose stem matches a built-in or user-supplied image mapping. The key is renamed to use _DIGEST:

TF_VERSION: '1.13.5'     # → TF_DIGEST: 'sha256:...' # hashicorp/terraform:1.13.5
VERSION_TF: '1.13.5'     # → DIGEST_TF: 'sha256:...' # hashicorp/terraform:1.13.5

Values starting with $ (CI variable interpolation) or already containing a digest are left untouched.

Built-in stem mappings

The stem is the key name with _VERSION, _TAG, or _DIGEST stripped (prefix or suffix):

Stem(s) Docker image
TF, TERRAFORM hashicorp/terraform
NODE, NODEJS node
TRIVY aquasec/trivy
JAVA eclipse-temurin
ALPINE alpine
PYTHON python
GO, GOLANG golang
RUBY ruby
RUST rust
DOTNET mcr.microsoft.com/dotnet/sdk
KUBECTL bitnami/kubectl
HELM alpine/helm
POSTGRES postgres
MYSQL mysql
REDIS redis
NGINX nginx
SONAR, SONARQUBE sonarsource/sonar-scanner-cli
AWS_CLI, AWSCLI amazon/aws-cli
CURL curlimages/curl
GIT_CLIFF orhunp/git-cliff
DOCKER, DIND docker
KANIKO gcr.io/kaniko-project/executor
GRADLE gradle
MAVEN, MVN maven
PHP php
ELASTICSEARCH, ES elasticsearch
MONGO, MONGODB mongo
RABBITMQ rabbitmq
GRYPE anchore/grype
SEMGREP semgrep/semgrep
COSIGN cgr.dev/chainguard/cosign
ANSIBLE cytopia/ansible
PACKER hashicorp/packer
VAULT hashicorp/vault
GOLANGCI, GOLANGCI_LINT golangci/golangci-lint
OPENTOFU, TOFU ghcr.io/opentofu/opentofu
VALKEY valkey/valkey
GRAFANA grafana/grafana
PROMETHEUS prom/prometheus
ALERTMANAGER prom/alertmanager
TRAEFIK traefik
CADDY caddy
TELEGRAF telegraf
BASH bash
SELENIUM selenium/standalone-chrome
SYFT anchore/syft

For images not in this list, add a tag-mappings entry to .shapin.json:

{
  "tag-mappings": {
    "MYAPP": "registry.internal/myapp",
    "TF": "myregistry.internal/mirror/terraform"
  }
}

User-supplied mappings override the built-ins.

Dependency proxy

Images pulled through the GitLab Dependency Proxy use a CI variable as their registry prefix:

image: ${CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX}/node:24.13.0-alpine3.23
image: ${CI_DEPENDENCY_PROXY_DIRECT_GROUP_IMAGE_PREFIX}/alpine:3.20

Shapin automatically strips the proxy prefix and resolves the underlying Docker Hub image to a digest:

image: node@sha256:cd6fb7... # node:24.13.0-alpine3.23
image: alpine@sha256:... # alpine:3.20

Both ${VAR}/ and $VAR/ syntaxes are supported for CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX and CI_DEPENDENCY_PROXY_DIRECT_GROUP_IMAGE_PREFIX.

Note: The GitLab Dependency Proxy only supports Docker Hub images. Shapin resolves the stripped image name against Docker Hub, which matches what the proxy itself does.

The image: name: map form is also supported:

image:
  name: maildev/maildev:2.2.1
  entrypoint: [""]
# →
image:
  name: maildev/maildev@sha256:180ef5... # maildev/maildev:2.2.1
  entrypoint: [""]
Services

Docker images in services: blocks are pinned, both the bare form and the name: map form:

services:
  - postgres:15
  - name: redis:7
    alias: cache
# →
services:
  - postgres@sha256:abc123... # postgres:15
  - name: redis@sha256:def456... # redis:7
    alias: cache

latest warning: Using latest as a tag is not pinnable to a meaningful digest — a warning is printed on stderr and the image is left untouched:

warn: docker image postgres:latest: avoid 'latest' — pin to an explicit tag

Limitations:

  • extends: and !reference template includes are not followed

Forgejo Actions

Pins uses: owner/repo@tag refs to their commit SHA. Falls back to code.forgejo.org for community actions.

- uses: actions/checkout@v1
# → - uses: actions/checkout@abc1234... # v1

Branch ref warning: Same as GitHub Actions — a red warning is printed if the ref is a well-known branch name.


CircleCI

Pins Docker image: tags inside .circleci/config.yml and .circleci/config.yaml to digests.

Limitations:

  • CircleCI orbs use semver versioning with no SHA pinning API — only image: tags are pinned

Bitbucket Pipelines

Pins Docker image: tags inside bitbucket-pipelines.yml and bitbucket-pipelines.yaml to digests.

Limitations:

  • Bitbucket Pipes use semver versioning with no SHA pinning API — only image: tags are pinned

Woodpecker CI

Pins Docker image: tags inside .woodpecker.yml, .woodpecker.yaml, and any .yml/.yaml inside .woodpecker/.

Limitations:

  • Woodpecker plugin steps are pinned by Docker image digest, but there is no SHA pinning API for the plugin registry itself

Dockerfile

Pins FROM image:tag lines to digests at any depth. The AS alias is preserved. The original tag is recorded on the line above as a comment — Docker does not allow inline comments on FROM lines.

FROM golang:1.24-alpine AS builder
# →
# golang:1.24-alpine
FROM golang@sha256:... AS builder

FROM scratch is left untouched.

GitLab CI spec: inputs: defaults

When a GitLab CI file declares its inputs using the spec: inputs: preamble with a nested default: version, Shapin pins the default value and updates the description: field:

# before
spec:
  inputs:
    TF_IMAGE_DIGEST:
      default: "1.14.8"
      description: "SHA256 digest of hashicorp/terraform"

# after
spec:
  inputs:
    TF_IMAGE_DIGEST:
      default: "sha256:42ecfb..."
      description: "SHA256 digest of hashicorp/terraform:1.14.8"

The $[[ inputs.TF_IMAGE_DIGEST ]] forwarding references in include: component inputs are left untouched.


Docker Compose

Pins image: tags in docker-compose.yml, docker-compose.yaml, docker-compose.*.yml, compose.yml, and compose.yaml files at any depth.


When do you need a token?

Operation Token needed?
Pinning Docker images No — uses the public registry API
Pinning GitHub Actions uses: Yes — --github-token
Pinning GitLab components (public) No — uses the public GitLab API
Pinning GitLab components (private) Yes — --gitlab-token
Pinning Forgejo actions No for public, --forgejo-token for private
Scanning already-pinned files No — skipped immediately

Rate limiting

API calls are automatically retried on HTTP 429 (rate limited) or 503 responses. The retry delay is read from the Retry-After or X-RateLimit-Reset headers, falling back to 60 seconds. Up to 3 retries are attempted before giving up.

What it can't do

  • Private Docker registries — only public registries (Docker Hub, GHCR, Quay.io, etc.) are supported
  • Branch refs — pinning @main resolves to the current HEAD SHA, which will become stale — use tags when possible
  • Unknown GitLab CI variable prefixes — component paths starting with $SPLIT_GLOBAL_COMPONENT_ROOT or similar custom variables cannot be resolved
  • parallel: matrix: image arrays — matrix values are CI variables resolved at runtime; Shapin cannot pin array entries like IMAGE: [alpine:3.20, debian:12]

Dependencies

Shapin has minimal runtime dependencies, all managed via Go modules.

Selection — dependencies are chosen to be small, well-maintained, and auditable. The full dependency list with pinned versions and checksums is declared in go.mod and go.sum.

Obtaining — dependencies are fetched by the Go toolchain (go mod download) during development and CI builds. All checksums are verified against go.sum and the Go checksum database on every build.

TrackingDependabot is configured to open weekly pull requests for outdated Go module and GitHub Actions dependencies. Security advisories are tracked via GitHub's dependency graph and the Vulnerabilities OpenSSF Scorecard check.

Support

Only the latest release is actively supported. When a new version is published, the previous release is no longer maintained.

Type Included
Security vulnerability fixes Yes — latest release only
Bug fixes Yes — latest release only
Backports to older releases No

A release stops receiving security updates as soon as a newer version is published. Users should upgrade to the latest release to remain protected.

For bug reports open a GitHub Issue. For security vulnerabilities follow the private disclosure process. There is no formal LTS program — upgrading is straightforward as Shapin is a single self-contained binary.

Directories

Path Synopsis
cmd
shapin command
internal
contract
Package contract defines the extension point for CI/CD file formats.
Package contract defines the extension point for CI/CD file formats.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL