Skip to content

Repository files navigation

hashpinner

Check, pin and bump SHA-pinned GitHub/Forgejo Actions references.

An unpinned reference such as uses: actions/checkout@v4 names a mutable tag, so whoever controls the upstream repository controls what runs in your CI, with your secrets. Pinning to a commit fixes that, but a bare 40-character hex string is unreviewable, so the commit is annotated with what it actually is:

uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1, 2025-12-02

hashpinner maintains that form, from the command line or as a CI action: it lists references, checks them, pins the unpinned ones, bumps the pinned ones, and verifies that the version in the comment is correct.

Installation

nix run github:sshine/hashpinner
nix profile install github:sshine/hashpinner
cargo install hashpinner

You can download prebuilt static binaries for x86_64 and aarch64 Linux.

git must be on PATH; hashpinner drives it to resolve tags.

The Nix package wraps the binary so this is already taken care of.

NixOS

The flake exports an overlay, so a system configuration can take hashpinner as an ordinary attribute of pkgs:

{
  inputs = {
    nixpkgs.url = "https://nixos.org/channels/nixpkgs-unstable/nixexprs.tar.xz";

    hashpinner = {
      url = "github:sshine/hashpinner";
      inputs.nixpkgs.follows = "nixpkgs";
    };
  };

  outputs =
    { nixpkgs, hashpinner, ... }:
    {
      nixosConfigurations.example = nixpkgs.lib.nixosSystem {
        modules = [
          (
            { pkgs, ... }:
            {
              nixpkgs.overlays = [ hashpinner.overlays.default ];
              environment.systemPackages = [ pkgs.hashpinner ];
            }
          )
        ];
      };
    };
}

The overlay builds against your nixpkgs rather than this flake's, so follows is about lock file size, and overrideAttrs and cross-compilation work as they do for anything else in the set. Where none of that matters, hashpinner.packages.${system}.default is the same package without the overlay.

CLI

The hashpinner command: list, check, pin and bump Actions references.

Modes

One mode, optionally combined with --deep. The default is --list.

hashpinner                       list every reference and what it points at
hashpinner --check               fail if anything is unpinned      (offline)
hashpinner --check --bump        ...and fail if any pin is stale
hashpinner --check --deep        ...and verify pins and comments
hashpinner --pin                 pin the unpinned, repair comments
hashpinner --bump                move pins onto their latest release
hashpinner --pin --bump          both

--check never writes. --pin and --bump do, unless --dry-run is given.

With no path, hashpinner scans whichever of .forgejo/workflows, .gitea/workflows and .github/workflows exist, plus a root action.yml. Otherwise it scans the files and directories named.

From there it follows every uses: ./path to the file it names and scans that too, repeating until nothing new turns up. This reaches files no directory walk would: a local action may live at any path, and on Forgejo so may a local reusable workflow. Relative paths resolve against the repository root, which is the working directory, and one that climbs out of it with .. fails.

A file that cannot be read or parsed does not stop the run, but it does fail it. The other files are still scanned, so one malformed workflow does not hide the problems in the rest; the malformed one is then reported as a failure of its own. hashpinner takes no view on why the YAML is broken — that is a linter's job — but it cannot report a file it never read as pinned.

What each level costs

The three levels nest, and each is worth what it costs:

network catches
--check none unpinned refs, mutable docker:// tags
--check --bump tags, shallow stale pins
--check --deep full commit graph nonexistent pins, fork-injected pins, lying comments

--deep checks reachability rather than existence, because on GitHub a fork shares its object store with the upstream repository: a commit pushed to any public fork can be fetched from the upstream URL even though it was never merged. Existence therefore proves nothing. A commit reachable from no ref at all is what a fork-injected pin looks like, and --deep fails on it.

--deep also compares each comment against the tag the commit really carries. Reviewers read # v6.0.1, not the hex beside it, so a pin whose comment misdescribes it passes every syntactic check and sails through review.

The allowlist

--allow marks actions that need not be pinned, defaulting to actions/*. It relaxes --check only: --pin still pins an allowlisted action and --bump still bumps it. --no-allow empties it, so every unpinned reference fails.

hashpinner --check --no-allow          strict: everything must be pinned
hashpinner --check --allow 'actions/*' --allow 'nix-community/*'

Triggers

pull_request_target and workflow_run fail --check. A workflow on either one runs against this repository, with its secrets and a write-scoped token, in response to something an outsider did. That is the same question pinning asks — who controls what runs in this repository's CI — arriving through the event rather than through a uses:, and no amount of pinning constrains it.

The trigger is flagged, not any particular use of it. Checking out the pull request's head is the well-known way to get hurt, but GITHUB_ENV writes, argument injection and cache poisoning all escalate without a checkout, so "we never run the fork's code" is not the safe harbour it sounds like.

Reading the event payload alone — labelling, commenting, assigning reviewers — is a legitimate use and a common one. Say so explicitly:

hashpinner --check --allow-trigger pull_request_target

This is the whole of hashpinner's interest in workflow security. For permissions:, template injection, credential persistence and the rest, use a dedicated analyser such as zizmor.

Forgejo

A bare owner/repo does not mean the same thing on both forges. Under .github/ it is github.com; under .forgejo/ it resolves against the instance's DEFAULT_ACTIONS_URL, which Forgejo defaults to https://data.forgejo.org — a different repository, with different commit ids. hashpinner takes the host from the directory the file is in; --forgejo-host overrides it.

One consequence is worth stating plainly: a repository mirrored to both forges cannot share a pinned workflow file, because the correct commit differs.

Forgejo also reads only the first of .forgejo/workflows, .gitea/workflows and .github/workflows that exists, silently ignoring the others. hashpinner scans all of them and warns when more than one is present.

What is not pinned

  • docker:// references are pinnable by digest but not by anything git knows. A mutable tag fails --check; image@sha256:... passes. Neither is ever rewritten.
  • Local actions (./path) are never pinned: they live in this repository and are covered by the same review as the rest of it. What makes that safe is that hashpinner follows them and pins what it finds inside, so a ./path is not a way to launder an unpinned third-party action past --check. A local reference that resolves to nothing, or out of the repository, fails.

Anchors, aliases and merge keys

A uses: does not have to be written where it is used:

x-shared: &co actions/checkout@v4
jobs:
  a:
    steps:
      - uses: *co
      - <<: *step-defaults

Both forms are resolved, offline, against the document they appear in. This is not a corner: GitHub gained anchor support in September 2025, so they are getting more common, and Forgejo has always accepted merge keys. Before they were resolved, an anchor under a key like x-shared: was a way past --check entirely, because nothing looked at it.

A value reached this way is reported on the line that asked for it and rewritten on the line that defines it, since the anchor is the only place an edit can go. Several aliases onto one anchor therefore produce one edit, not several.

Anchors do not cross a document boundary, and neither does the resolution; an alias naming an anchor from an earlier document is reported, not resolved.

The CI Action

The same checks, as a composite action that runs unchanged on GitHub-hosted runners and on Forgejo runners with either a docker or a host label:

- uses: sshine/hashpinner@<sha>                      # GitHub
- uses: https://github.com/sshine/hashpinner@<sha>   # Forgejo, absolute URL
  with:
    version: v0.1.0
    mode: check
    deep: "true"
input default meaning
version required Release tag to download and run.
mode check One of list, check, pin, bump.
deep false Add --deep.
path workflow dirs Files or directories to scan.
allow actions/* Allowlist patterns, whitespace-separated. Empty means strict.
base-url GitHub releases Where to fetch the release asset from.

version is required and takes an explicit tag because there is no "latest" URL that works on both forges: GitHub serves /releases/latest/download/<asset> and Forgejo 404s on it. The action downloads the static musl binary for the runner's architecture and verifies it against the .sha256 sidecar before running it.

Pin the action itself, of course. hashpinner will do it for you.

Self-hosting

To run this from your own Forgejo, mirror the repository and reference it by absolute URL, which Forgejo accepts and GitHub does not. Release binaries are still fetched from the GitHub release; on an instance with no route to github.com, mirror the assets and point base-url at them:

- uses: https://git.example.com/sshine/hashpinner@<sha>
  with:
    version: v0.1.0
    base-url: https://artifacts.example.com/hashpinner

The asset layout under base-url is <base-url>/<version>/<asset>, matching what both forges serve.

About

Check, pin and bump SHA-pinned GitHub/Forgejo Actions references

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages