README
¶
Shapin
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
- Supported files
- Installation
- Usage
- Upgrading pinned refs
- Flags
- Output formats
- Config file
- Providers
- When do you need a token?
- Rate limiting
- What it can't do
- Dependencies
- Support
- Architecture
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-alpineFROM 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/.yamlfile inside.github/workflows/(and subdirectories) - GitLab CI:
.gitlab-ci.yml/.gitlab-ci.yaml/.gitlab-ci-*.ymlat any depth (supports monorepos where each subdirectory is its own project)- Any
.yml/.yamlfile 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/.yamlfile inside.forgejo/workflows/(and subdirectories) - Woodpecker CI:
.woodpecker.yml/.woodpecker.yamlat the root- Any
.yml/.yamlfile inside.woodpecker/and its subdirectories
- Dockerfiles:
Dockerfile,Dockerfile.*,*.dockerfile,*.Dockerfile(at any depth) — pinsFROM image:taglines - 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!referencetemplate 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
@mainresolves 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_ROOTor similar custom variables cannot be resolved parallel: matrix:image arrays — matrix values are CI variables resolved at runtime; Shapin cannot pin array entries likeIMAGE: [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.
Tracking — Dependabot 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.