Pin floating tags in CI workflow files to immutable SHAs, making your pipelines reproducible and immune to tag mutation attacks.
# Install
curl -fsSL https://raw.githubusercontent.com/Kirskov/Shapin/df97d9b9fd31e5e9ac80b2257d3eae7d7628509d/install.sh | sh
# Preview what would be pinned (dry run, no files written)
shapin --path ./myproject
# Apply the changes
shapin --path ./myproject --dry-run=falseFor GitHub Actions, a token is required:
shapin --path ./myproject --github-token ghp_xxx --dry-run=false- Quick start
- 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
- Contributing
- Security
- Governance
| 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.26.2-alpine AS builder |
# golang:1.26.2-alpineFROM golang@sha256:f85330... 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.
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
curl -fsSL https://raw.githubusercontent.com/Kirskov/Shapin/df97d9b9fd31e5e9ac80b2257d3eae7d7628509d/install.sh | shThe 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 shTo install a specific version, use the Manual method below.
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 signature, and move it to your PATH:
# Example for Linux amd64
curl -fsSL https://github.com/Kirskov/Shapin/releases/download/v1.7.0/shapin-v1.7.0-linux-amd64 -o shapin
curl -fsSL https://github.com/Kirskov/Shapin/releases/download/v1.7.0/shapin-v1.7.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.7.0" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com"
chmod +x shapin
sudo mv shapin /usr/local/bin/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:e5edcf349d8ba89b1199f9db448591461d3b4ccebf663b82854a128448aa67ff # v1.7.0 --path /repoApply changes (disable dry-run):
docker run --rm -v $(pwd):/repo ghcr.io/kirskov/shapin@sha256:e5edcf349d8ba89b1199f9db448591461d3b4ccebf663b82854a128448aa67ff # v1.7.0 --path /repo --dry-run=falseWith API tokens:
docker run --rm \
-v $(pwd):/repo \
-e GITHUB_TOKEN=ghp_xxx \
-e GITLAB_TOKEN=glpat_xxx \
ghcr.io/kirskov/shapin@sha256:e5edcf349d8ba89b1199f9db448591461d3b4ccebf663b82854a128448aa67ff # v1.7.0 --path /repoThe digest for each release is listed on the releases page. Update the digest when upgrading to a new version.
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.7.0" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
ghcr.io/kirskov/shapin@sha256:e5edcf349d8ba89b1199f9db448591461d3b4ccebf663b82854a128448aa67ff # v1.7.0Every 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.7.0/shapin-v1.7.0-linux-amd64 -o shapin-v1.7.0-linux-amd64
curl -fsSL https://github.com/Kirskov/Shapin/releases/download/v1.7.0/checksums.txt -o checksums.txt
# Verify (expected output: "shapin-v1.7.0-linux-amd64: OK")
sha256sum --ignore-missing -c checksums.txt2. 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.7.0/shapin-v1.7.0-linux-amd64 -o shapin
curl -fsSL https://github.com/Kirskov/Shapin/releases/download/v1.7.0/shapin-v1.7.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.7.0" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com"
# Expected output: Verified OK3. SLSA provenance attestation — build provenance is attested via the SLSA L3 generator and included in every release as multiple.intoto.jsonl:
curl -fsSL https://github.com/Kirskov/Shapin/releases/download/v1.7.0/multiple.intoto.jsonl -o multiple.intoto.jsonl
slsa-verifier verify-artifact shapin \
--provenance-path multiple.intoto.jsonl \
--source-uri github.com/Kirskov/Shapin \
--source-tag v1.7.0
# Expected output: PASSED: SLSA verification passedgit clone https://github.com/Kirskov/Shapin.git
cd Shapin
go build -o shapin ./cmd/shapin# 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_xxxTo 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@v5Version 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.
| 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.
shapin --path ./myproject --format json --output results.jsonOutputs a JSON array of file changes, each with the file path and a list of old/new line pairs with their line numbers.
shapin --path ./myproject --format sarif --output results.sarifOutputs 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.sarifAll 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"
]
}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... # v4Already-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.
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: 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.4The 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.0Other $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.
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.32. 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.5Values starting with $ (CI variable interpolation) or already containing a digest are left untouched.
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 |
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.
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.20Shapin 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.20Both ${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: [""]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: cachelatest 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
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... # v1Branch ref warning: Same as GitHub Actions — a red warning is printed if the ref is a well-known branch name.
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
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
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
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.26.2-alpine AS builder
# →
# golang:1.26.2-alpine
FROM golang@sha256:... AS builderFROM scratch is left untouched.
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.
Pins image: tags in docker-compose.yml, docker-compose.yaml, docker-compose.*.yml, compose.yml, and compose.yaml files at any depth.
| 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 |
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.
- 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]
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.
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.