All posts

11 min read Nathan Broadbent

Shiplist: Strict verification for the files that ship in your Docker images and packages

Shiplist is a new open source CLI that fails CI until every file in your repository is in exactly one list, then checks the Docker image or package you built. We built it after a list of files to remove kept letting new ones into our Enterprise image.

Blank file cards sorted through a three-column ledger: two columns drop into bins, and the third rides a conveyor into a shipping container tagged shiplist.

We've open sourced shiplist, a small Rust CLI that puts every file in a repository in exactly one list: removed before the tests, removed before the production image, or kept. CI fails when a file is in no list, so every new file gets a decision about whether it ships. Then it checks what you actually built, whether that's a Docker image or a package such as an npm tarball, a gem, a Python package or a crate.

It started as a Ruby script in our own CI. DocSpring is a Rails app, and most of our customers use it as a hosted service. Some of them run it on their own servers instead: we build a Docker image for each Enterprise release, and they pull it from a private registry. That image should hold what the app needs to run, and nothing else.

For years, the only thing that kept a file out of our image was a list of the files to remove. If you added a new script, a note or a test fixture, it shipped unless you remembered to add it to that list, and we would often forget. In February 2025 I turned it around, and shiplist is that idea rewritten as a tool anyone can use.

Our Ruby version ran on every CI build and every image build from February 2025. shiplist is new (version 0.1.1, for Linux and macOS), and it replaced the Ruby version in November 2026. It gives the same lists: it keeps 3,114 of the 12,300 or so tracked files, and the check takes about a tenth of a second. It now prunes our CI builds and checks our Docker images, the package of each API client before its release workflow publishes it, and our public mirrors before we push to them.

Three denylists that drifted apart

Before the change, three lists decided what stayed out of our images, and each one had been written at a different time:

  • a .dockerignore, which the CI job deleted before every build, because the image copied its files from a fresh clone of the repository
  • an rm -rf of about 25 paths, which the CI job ran on that clone
  • an rsync command with 65 --exclude paths, in a script for building the image outside CI

They disagreed with each other, and all three were denylists. A denylist only removes what someone thought of when they wrote it, so its default for anything new is to ship it. A setup script with a test API key in it goes into the image until someone notices, and so does a folder of internal notes.

You can write a .dockerignore as an allowlist instead, with * on the first line and a ! line for each folder the image needs. Then anything new at the top level stays out until someone adds it. It doesn't help the tests, though: they still run with every file present, so they can't tell you when the image is missing a file the code reads. And a .dockerignore only applies when Docker builds from that folder, which ours had stopped doing.

Every file in exactly one list

shiplist reads one YAML file at the root of the repository, .shiplist.yml. You name the stages of your build in order, and each stage gets a list of the files to remove before it. A file removed before the tests is gone from the image too. A keep list holds what ships. Here's a small, made-up example:

version: 1
stages: [test, production]

remove_before:
  test:
    include:
      - '.*'
      - docs/
      - bin/seed_dev_data
      - assets/                    # precompiled into public/ by the build
    exclude:
      - .shiplist.yml              # production removes it

  production:
    include:
      - .shiplist.yml
      - test/
      - bin/
    exclude:
      - bin/server                 # the app runs it: keep claims it
      - bin/seed_dev_data          # the tests don't need it either

keep:
  include:
    - app/
    - bin/server
    - Procfile

forbid:
  - '**/*.map'
  - '**/.env*'

build_output:
  - public/

Patterns are anchored at the root of the repository. A pattern that ends in / is a directory, * never crosses a /, and ** has to be a whole path component. shiplist rejects a pattern such as .**/*, which many glob libraries quietly treat as one directory level deep.

shiplist check takes its file list from git ls-files, so it sees every file the repository tracks and nothing else. When someone adds a file that no pattern covers, CI stops:

$ shiplist check
Files in no list (add each to exactly one list):
  config/partner_rates.yml

The fix takes a few seconds: add the file to one of the lists. Someone still has to decide whether it belongs in the image, and they decide while the file is new and they know what it's for.

Our real config has about 200 patterns. Most new files land in a directory that's already covered (a new model goes under app/, a new spec under spec/), so the check fails only for files in new places, and those are the ones worth a second look.

Exceptions need a second entry

The lists work like double-entry bookkeeping. An exclude only takes a file out of its own list, so some other list has to claim it, or the file is in no list and the check fails. In the example, bin/ goes before production, bin/server is excluded there because the app runs it, and keep includes it. shiplist explain shows how a path ends up where it is:

$ shiplist explain bin/server
bin/server
  remove_before.production: include bin/, but exclude bin/server
  keep: include bin/server
  => keep: kept at every stage

A file that two lists claim fails the check as well. If someone widens keep from bin/server to all of bin/, the dev script is suddenly headed for the image, and CI says so:

$ shiplist check
Files in more than one list (each file belongs to exactly one):
  bin/seed_dev_data
    remove_before.test (include: bin/seed_dev_data)
    keep (include: bin/)

So no change to a pattern can move a file into or out of the image without a check failing and a person deciding. I considered making the most specific pattern win, which would have made the config shorter, but then a new pattern could quietly move a file from one list to another. I'd rather write some files down twice.

Test runs should be as close to production as possible

The second idea took us longer to get right. If CI runs the tests on the full repository, the tests can pass while production is missing a file the code needs. So CI runs shiplist prune test before the test suite, and code that reads one of those files fails there.

Here's a minimal version. The app reads its logo's width from the SVG source when it starts, and the SVG lives in assets/, which the build precompiles and the image leaves out:

module Logo
  def self.width
    File.read(File.expand_path('../assets/logo.svg', __dir__))[/width="(\d+)"/, 1].to_i
  end
end

The test passes on a laptop. After CI prunes the files, it fails the way production would:

$ ruby test/logo_test.rb
1 runs, 1 assertions, 0 failures, 0 errors, 0 skips

$ shiplist prune test
removed assets/
removed docs/
removed .env.development
removed bin/seed_dev_data
removed 2 directories and 2 files for stage test

$ ruby test/logo_test.rb
Errno::ENOENT: No such file or directory @ rb_sysopen - .../assets/logo.svg
1 runs, 0 assertions, 0 failures, 1 errors, 0 skips

prune deletes files, so it runs only when CI=true, or with --yes on a working tree with no uncommitted changes. Our Ruby script had the same guard, because running it on a laptop deletes your docs and dev tools.

From February 2025 our asset sources were removed before production, so the images left them out but the tests still had them. In October 2026 a new marketing page read each partner logo's size from its source file, the tests passed, and the page returned a 500 on staging. We moved the asset sources to the list that goes before the tests, which made the same mistake fail in CI, and changed the page to read the sizes from the compiled assets.

So put a file in the first stage's list whenever the tests can run without it. Then the tests prove that production doesn't need it.

Some files have to stay for the tests and still can't ship, so the tests can't catch everything. The day before the logo bug, a staging image went out without a helper script that runs as a child process for every HTML and Word template render. Our bin/ folder is removed before production, with a short list of exceptions, and this script wasn't on it. The tests had bin/, so they passed, and every HTML and Word render on staging failed. Now the image's smoke test, which boots the app inside the built image, also renders a Liquid tag through that script, so an image without it fails its own build.

Patterns that match nothing fail too

shiplist check also fails when a pattern matches no tracked file. Delete bin/seed_dev_data from the example, and both of its entries go stale:

$ shiplist check
Patterns that match no tracked file (delete them, or mark one `untracked: true` if git never has it):
  remove_before.test.include: bin/seed_dev_data
  remove_before.production.exclude: bin/seed_dev_data

Without this, the config fills up with paths from files that were renamed or deleted years ago, and nobody can tell which patterns still matter. A few folders that git never tracks, such as node_modules/, are removed on purpose, so a pattern can say untracked: true. An exclude that no longer takes anything out of its own list fails the check too.

Files that git doesn't track

Because the check only sees what git tracks, it can't see anything the build creates. We found that out at the start of November 2026, in an audit of what our images actually contained.

Our webpack build writes source maps. Our Ruby config had a pattern for them, but it had never removed anything: the maps don't exist until the build runs, and they're never in git. The source maps now go only to our error tracker at deploy time, and the image build deletes every map it finds before building the image. The audit also moved about 180 more dev-only files out of the images.

That gap is why shiplist has a second command. shiplist verify checks the build itself, either a directory or an image saved with docker save, against the config: it fails on a file the stage shouldn't have, on anything that matches forbid, and on a file that's in no list and isn't in build_output. Here's the example after a build that wrote a source map:

$ shiplist prune production --dir build/src
removed assets/
removed docs/
removed test/
removed .env.development
removed .shiplist.yml
removed bin/seed_dev_data
removed 3 directories and 3 files for stage production

$ shiplist verify production --dir build/src
Forbidden files:
  public/app.js.map  (forbid: **/*.map)

To check the image you're about to push, run docker save my-app:latest -o my-app.tar, then shiplist verify production --image my-app.tar --root /app. shiplist reads the tarball directly, so it doesn't need Docker.

Using shiplist for package archives

Docker images aren't the only thing we ship. We publish API clients in 10 languages and a CLI, and a package has the same problem as an image: the packaging tool decides what goes in, often from a list of files to leave out, and nobody looks inside afterwards.

When I added package support to shiplist, I tried it on our own Ruby client first. Version 4.5.0 on RubyGems has 603 files, and 396 of them are its specs, its generated docs and the files for building and testing it:

$ shiplist verify package --archive docspring-4.5.0.gem
Files that shouldn't be here at stage package, grouped by top-level directory (`--format json` lists each one):
  .github/workflows/release.yml  (remove_before.package)
  .gitignore  (remove_before.package)
  .openapi-generator/  (2 files, remove_before.package)
  .openapi-generator-ignore  (remove_before.package)
  .rspec  (remove_before.package)
  .rubocop.yml  (remove_before.package)
  AGENTS.md  (remove_before.package)
  Gemfile  (remove_before.package)
  Gemfile.lock  (remove_before.package)
  Rakefile  (remove_before.package)
  docs/  (192 files, remove_before.package)
  spec/  (193 files, remove_before.package)

Its gemspec takes every file git tracks. Nothing in those files is secret, but everyone who installs the gem downloads them. We've fixed that in the code generator, so the next release has 208 files. The release workflow of every client except Go now runs shiplist on the package before it publishes, so the extra files can't come back.

Other packaging tools have the same gaps. An npm package with no files list in its package.json includes a .env.development file: npm leaves out .git and a few other files by itself, but not .env files. A source package built with hatchling includes the tests/ folder, and helm package includes any folder that .helmignore doesn't list.

shiplist verify --archive reads a package the way it reads an image: npm and Python source packages, gems, wheels, crates, Helm charts and any other tar or zip file. Your packaging tool's own settings still decide what goes in (files in package.json, the gemspec's files, include in Cargo.toml), and shiplist checks the result before you publish it.

You can also use shiplist's lists as the source of truth. A gemspec can take its files straight from shiplist:

spec.files = IO.popen(%w[shiplist list package], &:read).split("\n")

For other tools, you can prune a clean copy of the repository and build the package there.

Here's some other ways you can use shiplist:

  • Public mirrors of private code. We publish parts of our private repository to public ones: our docs site's source and our Agent Skills. We now run shiplist verify on each mirror before the push, so an internal file can't leak by accident.
  • Helm charts. .helmignore is another denylist, and charts are .tgz files.
  • Lambda and serverless zips, and browser extensions. Source maps, .env files and test fixtures end up in these all the time.
  • Static sites and docs deploys. Check the build folder before it goes to S3 or GitHub Pages, so drafts and source maps stay out.
  • Source tarballs made with git archive, where export-ignore in .gitattributes is also a denylist.
  • Composer packages and Go modules are built from the repository itself, so for those shiplist checks the repository rather than an archive.

Secrets don't belong in the repository at all

shiplist decides which files ship, but it doesn't read what's in them. It won't notice an API key pasted into a file that's meant to ship, and a secret that's been committed is in the repository's history even after the file leaves the image. So a separate CI job runs TruffleHog on each branch's new commits, and on main every night. It fails only on secrets it has confirmed are live by checking them with the provider, so test keys and made-up examples don't break the build. Our public repositories, shiplist included, also have GitHub's secret scanning with push protection, which blocks a push that contains a token GitHub recognizes.

Try it

Install it with Homebrew, on macOS or Linux:

brew install docspring/tap/shiplist

Or from crates.io with Cargo:

cargo install shiplist --locked

There are also static Linux binaries (x86_64 and arm64) and macOS binaries on the releases page.

Then write a .shiplist.yml with your stages, run shiplist check, and work through the files it lists, one top-level directory at a time. In CI, check the config on every push and prune before the tests. On GitHub Actions, our action installs the release and checks the binary's build provenance before it runs:

- uses: DocSpring/shiplist@v0.1.1
# GitHub Actions sets CI=true
- run: shiplist check
- run: shiplist prune test
- run: bundle exec rspec

Build the image from a clean clone that you've run shiplist prune production on, and run shiplist verify on the result. For a package, add a package stage and run shiplist verify package --archive on the package before you publish it. The README has the full config reference and a Dockerfile example.

If you use a coding agent, npx skills add DocSpring/shiplist installs an Agent Skill that tells it how to fix a failing check without making things worse: how to decide which list a new file belongs in, why it shouldn't delete the broad pattern around a conflict, and why a test that fails only after prune needs a code fix rather than a config change. Every command also takes --format json.

shiplist is MIT or Apache-2.0, and everything it depends on is permissively licensed, so you can ship it in your own images. It matters most if you ship an image that runs outside your own infrastructure, as we do with on-premise installs. It helps a hosted app too: a production image with only the files the app needs is smaller so your deploys are faster, and it has nothing in it that nobody meant to ship.

More posts

  1. A level balance scale weighing a small stack of code cards against a checklist document, above a tag reading AGENTS.md.

    7 min read

    Ponytail vs a good AGENTS.md: What we found on our real codebase

    Ponytail makes coding agents write about half as much code. I had Claude Code benchmark it on our Rails monorepo and check it against five months of my messages. Here's what we kept, what we skipped, and the setup we've now open-sourced.

Try DocSpring with your own documents

Upload a PDF or a Word document, or create an HTML template, then send a test request. No credit card needed.

Blog posts and product news by email

New articles and product changes, once a month.