Part of my job is knowing what we ship. Not in general terms, but on the afternoon a CVE lands: which repositories and which container images carry the affected package, and at which versions. Every scanner we use can produce an SBOM, so in theory the answer exists. In practice it is spread across a few vendors’ export formats, one file per repository, in a bucket nobody queries.

So I wrote ClickBOM. It is a GitHub Action, written in Go, that collects the SBOMs you already have or generates new ones, normalizes them, and puts the components where you can run SQL on them. It is Apache 2.0 and lives in the ClickHouse org.

What It Does

The smallest useful job is two steps, plus id-token: write and contents: read permissions:

- uses: aws-actions/configure-aws-credentials@v6
  with:
    role-to-assume: arn:aws:iam::123456789012:role/GitHubOIDCRole
    aws-region: us-east-1

- uses: ClickHouse/ClickBOM@v2.3.0
  with:
    github-token: ${{ secrets.GITHUB_TOKEN }}
    repository: ${{ github.repository }}
    s3-bucket: my-sbom-bucket
    s3-key: ${{ github.event.repository.name }}.json
    clickhouse-url: ${{ secrets.CLICKHOUSE_URL }}
    clickhouse-password: ${{ secrets.CLICKHOUSE_PASSWORD }}

That gives you a CycloneDX SBOM in S3 and a table in ClickHouse on every push. The rest is a matter of which inputs you set.

GitHub, Mend, or Wiz as the source. GitHub’s dependency graph is the default. ClickBOM asks GitHub to generate the export, polls until it is ready, and downloads it. Mend and Wiz work the same way with their own credentials.

Trivy for container images. Set sbom-source: trivy and an image reference, and Trivy scans the image where it sits in the registry. Nothing is pulled through Docker. For ECR it can assume a role in the account that owns the repository, so one job can cover images in several accounts.

One format out. GitHub exports SPDX, Mend and Wiz export CycloneDX, and you probably want one of them downstream. sbom-format picks it, and the conversion only runs when the input is in the other format.

Merging across repositories. Run the action once per repository from a matrix, each writing its own object, then once more with merge: true. It downloads every SBOM in the bucket, filters with include and exclude globs, tags each component with the document it came from, and drops duplicates on name, version, purl and source. You end up with one CycloneDX document covering everything.

Rows in ClickHouse. This is where the name comes from. With a clickhouse-url, every component becomes a row in a table per source: name, version, license, source and inserted_at, in a MergeTree ordered by name, version and license, loaded as a single TSV insert over HTTP. A mapping file with 947 entries fills in licenses the sources leave blank, by component name. The question I started with becomes:

SELECT source, version, license
FROM default.clickbom_merged
WHERE name = 'log4j-core'
ORDER BY source, version

A Slack message per run. Set slack-webhook-url and each run posts one message, green or red, saying what ran, what it wrote and how it ended, with a link to the job. slack-notify-on: failure keeps the channel quiet on success. Nothing the README marks sensitive reaches Slack, an error is cut to its first line with tokens, auth headers and query strings scrubbed, and a failed delivery never fails the job.

What Runs in Your Job

A Docker action is rebuilt inside the consumer’s workflow, on a runner that holds that job’s credentials, so I was careful about what goes in the image. It is a static Go binary plus two tools, cyclonedx for conversion and trivy for scanning, on a distroless base. Both tools are pinned by version and SHA-256, so if a release asset is ever re-pointed the build fails instead of running. There is no AWS CLI in it. Every S3 call goes through the SDK, which is also how it works out a bucket’s home region on its own and talks to S3-compatible stores through AWS_ENDPOINT_URL.

There is one deadline to know about. GitHub is removing its synchronous SBOM endpoint on 2026-11-13. That endpoint generated the document inline under a server-side timeout, and one of our larger repositories, 25,233 packages in 52 MB of SPDX, failed five times out of five after about 3.5 seconds (#119). Since v2.0.1 ClickBOM uses the asynchronous export instead: request a report, poll with Retry-After, then download the pre-signed URL without the GitHub token, which the blob store would reject. The same repository now takes 19 seconds and three polls, and converts to a 27 MB CycloneDX document in about 1.4 seconds. If you are pinned to v2.0.0 or older, GitHub as a source stops working on that date.

The v1.0.x tags are the original bash version: 2,691 lines across an entrypoint and nine library files, 463 of them just sanitizing inputs. The Go rewrite landed in May and shipped as v2.0.0 in September. It is 5,157 lines plus 5,785 lines of tests. CI runs them under the race detector, runs an integration job against real ClickHouse and S3-compatible containers, builds the image and does a conversion inside it, and fails on any reachable standard-library CVE through govulncheck.

Getting Started

Pin a release tag; ClickHouse/ClickBOM@v2.3.0 is current. Never pin a branch; they get deleted after merge and the workflow fails with Unable to resolve action. The README has a worked example for every source and for merging.

If you have SBOMs piling up in a bucket because a compliance checklist asked for them, ClickBOM is a cheap way to turn them into something you can actually query.