Repository: chainguard-dev/apko
Pull request #1708 (2025-06-09 15:22:04)

build(deps): bump github.com/go-git/go-git/v5 from 5.16.0 to 5.16.2
Bumps [github.com/go-git/go-git/v5](https://github.com/go-git/go-git) from 5.16.0 to 5.16.2.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a href="https://github.com/go-git/go-git/releases">github.com/go-git/go-git/v5's releases</a>.</em></p>
<blockquote>
<h2>v5.16.2</h2>
<h2>What's Changed</h2>
<ul>
<li>utils: fix diff so subpaths work for sparse checkouts, fixes 1455 to releases/v5.x by <a href="https://github.com/kane8n"><code>@​kane8n</code></a> in <a href="https://redirect.github.com/go-git/go-git/pull/1567">go-git/go-git#1567</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a href="https://github.com/go-git/go-git/compare/v5.16.1...v5.16.2">https://github.com/go-git/go-git/compare/v5.16.1...v5.16.2</a></p>
<h2>v5.16.1</h2>
<h2>What's Changed</h2>
<ul>
<li>utils: merkletrie, Fix diff on sparse-checkout index. Fixes <a href="https://redirect.github.com/go-git/go-git/issues/1406">#1406</a> to releases/v5.x by <a href="https://github.com/kane8n"><code>@​kane8n</code></a> in <a href="https://redirect.github.com/go-git/go-git/pull/1561">go-git/go-git#1561</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/kane8n"><code>@​kane8n</code></a> made their first contribution in <a href="https://redirect.github.com/go-git/go-git/pull/1561">go-git/go-git#1561</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a href="https://github.com/go-git/go-git/compare/v5.16.0...v5.16.1">https://github.com/go-git/go-git/compare/v5.16.0...v5.16.1</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a href="https://github.com/go-git/go-git/commit/ed8216c4a2c3fdb7e4e31ce242bf7847356623c9"><code>ed8216c</code></a> Merge pull request <a href="https://redirect.github.com/go-git/go-git/issues/1567">#1567</a> from kane8n/backport-to-v5-patricsss/fix-1455</li>
<li><a href="https://github.com/go-git/go-git/commit/4f35ebab20f1034df1df943044903e7044e6cdf4"><code>4f35eba</code></a> Merge pull request <a href="https://redirect.github.com/go-git/go-git/issues/1484">#1484</a> from patricsss/patricsss/fix-1455</li>
<li><a href="https://github.com/go-git/go-git/commit/fd1a83688236df0299d5cd677f04dccad073500c"><code>fd1a836</code></a> Merge pull request <a href="https://redirect.github.com/go-git/go-git/issues/1561">#1561</a> from kane8n/backport-to-v5-fix-sparse-checkout-status</li>
<li><a href="https://github.com/go-git/go-git/commit/c3c841090facce33faa2c80ca475ee673442b4a5"><code>c3c8410</code></a> Merge pull request <a href="https://redirect.github.com/go-git/go-git/issues/1492">#1492</a> from onee-only/fix-sparse-checkout-status</li>
<li>See full diff in <a href="https://github.com/go-git/go-git/compare/v5.16.0...v5.16.2">compare view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=github.com/go-git/go-git/v5&package-manager=go_modules&previous-version=5.16.0&new-version=5.16.2)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)


</details>


Patch:
diff --git a/.github/workflows/build-samples.yml b/.github/workflows/build-samples.yml
index c2ec3a3..a9f96be 100644
--- a/.github/workflows/build-samples.yml
+++ b/.github/workflows/build-samples.yml
@@ -24,7 +24,7 @@ jobs:
         arch: [x86_64, aarch64]
 
     steps:
-      - uses: step-security/harden-runner@c6295a65d1254861815972266d5933fd6e532bdf # v2.11.1
+      - uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
         with:
           egress-policy: audit
       - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
@@ -68,7 +68,7 @@ jobs:
     runs-on: ${{ matrix.platform }}
 
     steps:
-      - uses: step-security/harden-runner@c6295a65d1254861815972266d5933fd6e532bdf # v2.11.1
+      - uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
         with:
           egress-policy: audit
       - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
@@ -101,7 +101,7 @@ jobs:
       contents: read
 
     steps:
-      - uses: step-security/harden-runner@c6295a65d1254861815972266d5933fd6e532bdf # v2.11.1
+      - uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
         with:
           egress-policy: audit
       - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
@@ -112,7 +112,7 @@ jobs:
         with:
           go-version-file: 'go.mod'
           check-latest: true
-      - uses: chainguard-dev/actions/setup-registry@ce51233d303aed2394a9976e7f5642fd2158f693 # v1.1.1
+      - uses: chainguard-dev/actions/setup-registry@42c3aae0357e6493d93319b80c78fabada336849 # v1.4.1
         with:
           port: 5000
 
@@ -143,7 +143,7 @@ jobs:
       contents: read
 
     steps:
-      - uses: step-security/harden-runner@c6295a65d1254861815972266d5933fd6e532bdf # v2.11.1
+      - uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
         with:
           egress-policy: audit
       - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
@@ -154,7 +154,7 @@ jobs:
         with:
           go-version-file: 'go.mod'
           check-latest: true
-      - uses: chainguard-dev/actions/setup-registry@ce51233d303aed2394a9976e7f5642fd2158f693 # v1.1.1
+      - uses: chainguard-dev/actions/setup-registry@42c3aae0357e6493d93319b80c78fabada336849 # v1.4.1
         with:
           port: 5000
 
@@ -185,7 +185,7 @@ jobs:
       contents: read
 
     steps:
-      - uses: step-security/harden-runner@c6295a65d1254861815972266d5933fd6e532bdf # v2.11.1
+      - uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
         with:
           egress-policy: audit
       - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
@@ -197,7 +197,7 @@ jobs:
           go-version-file: 'go.mod'
           check-latest: true
       - uses: imjasonh/setup-crane@31b88efe9de28ae0ffa220711af4b60be9435f6e # v0.4
-      - uses: chainguard-dev/actions/setup-registry@ce51233d303aed2394a9976e7f5642fd2158f693 # v1.1.1
+      - uses: chainguard-dev/actions/setup-registry@42c3aae0357e6493d93319b80c78fabada336849 # v1.4.1
         with:
           port: 5000
       - run: |
diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml
index fb4d27b..48f966c 100644
--- a/.github/workflows/build.yaml
+++ b/.github/workflows/build.yaml
@@ -17,7 +17,7 @@ jobs:
       contents: read
 
     steps:
-      - uses: step-security/harden-runner@c6295a65d1254861815972266d5933fd6e532bdf # v2.11.1
+      - uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
         with:
           egress-policy: audit
 
@@ -36,7 +36,7 @@ jobs:
           ./apko version
 
       - name: Setup cosign (needed in CI tests)
-        uses: sigstore/cosign-installer@3454372f43399081ed03b604cb2d021dabca52bb # v3.8.2
+        uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3.9.1
 
       - name: Setup crane (needed in CI tests)
         uses: imjasonh/setup-crane@31b88efe9de28ae0ffa220711af4b60be9435f6e # v0.4
diff --git a/.github/workflows/codeql.yaml b/.github/workflows/codeql.yaml
index 80f2677..39805d8 100644
--- a/.github/workflows/codeql.yaml
+++ b/.github/workflows/codeql.yaml
@@ -20,7 +20,7 @@ jobs:
       security-events: write
 
     steps:
-    - uses: step-security/harden-runner@c6295a65d1254861815972266d5933fd6e532bdf # v2.11.1
+    - uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
       with:
         egress-policy: audit
 
@@ -34,7 +34,7 @@ jobs:
         check-latest: true
 
     - name: Initialize CodeQL
-      uses: github/codeql-action/init@60168efe1c415ce0f5521ea06d5c2062adbeed1b
+      uses: github/codeql-action/init@ce28f5bb42b7a9f2c824e633a3f6ee835bab6858
       with:
         languages: go
 
@@ -42,4 +42,4 @@ jobs:
       run: make apko
 
     - name: Perform CodeQL Analysis
-      uses: github/codeql-action/analyze@60168efe1c415ce0f5521ea06d5c2062adbeed1b
+      uses: github/codeql-action/analyze@ce28f5bb42b7a9f2c824e633a3f6ee835bab6858
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index c3c66c0..e8092d2 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -45,7 +45,7 @@ jobs:
 
     steps:
     - name: Harden the runner (Audit all outbound calls)
-      uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
+      uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
       with:
         egress-policy: audit
 
@@ -55,12 +55,12 @@ jobs:
         persist-credentials: false
 
     - name: Initialize CodeQL
-      uses: github/codeql-action/init@60168efe1c415ce0f5521ea06d5c2062adbeed1b # v3.28.17
+      uses: github/codeql-action/init@ce28f5bb42b7a9f2c824e633a3f6ee835bab6858 # v3.29.0
       with:
         languages: ${{ matrix.language }}
         build-mode: ${{ matrix.build-mode }}
 
     - name: Perform CodeQL Analysis
-      uses: github/codeql-action/analyze@60168efe1c415ce0f5521ea06d5c2062adbeed1b # v3.28.17
+      uses: github/codeql-action/analyze@ce28f5bb42b7a9f2c824e633a3f6ee835bab6858 # v3.29.0
       with:
         category: "/language:${{matrix.language}}"
diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml
index 673f5fc..0623820 100644
--- a/.github/workflows/release.yaml
+++ b/.github/workflows/release.yaml
@@ -22,7 +22,7 @@ jobs:
       contents: write
 
     steps:
-    - uses: step-security/harden-runner@c6295a65d1254861815972266d5933fd6e532bdf # v2.11.1
+    - uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
       with:
         egress-policy: audit
     - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
@@ -31,7 +31,7 @@ jobs:
         go-version-file: 'go.mod'
         check-latest: true
 
-    - uses: sigstore/cosign-installer@3454372f43399081ed03b604cb2d021dabca52bb # v3.8.2
+    - uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3.9.1
 
     - uses: goreleaser/goreleaser-action@9c156ee8a17a598857849441385a2041ef570552 # v6.3.0
       with:
diff --git a/.github/workflows/verify.yaml b/.github/workflows/verify.yaml
index 98c3fd5..183df27 100644
--- a/.github/workflows/verify.yaml
+++ b/.github/workflows/verify.yaml
@@ -18,7 +18,7 @@ jobs:
 
     steps:
       - name: Harden the runner (Audit all outbound calls)
-        uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
+        uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
         with:
           egress-policy: audit
 
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 7844bff..484d004 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -60,7 +60,7 @@ about your experience in the development shell and any ideas you may have!
 
 Before submitting a pull request, make sure tests and lints do not complain. 
 Make sure you have go 1.18 and
-[golangci-lint](https://golangci-lint.run/usage/install/) installed and try
+[golangci-lint](https://golangci-lint.run/welcome/install/) installed and try
 running the linter and tests:
 
 ```
diff --git a/docs/apko_file.md b/docs/apko_file.md
index 555e735..f62dd6c 100644
--- a/docs/apko_file.md
+++ b/docs/apko_file.md
@@ -91,6 +91,11 @@ archs:
 annotations:
   foo: bar
   bar: baz
+
+# optional layering strategy
+layering:
+  strategy: origin
+  budget: 10
 ```
 
 Details of each field can be found below.
@@ -241,3 +246,14 @@ Patches to improve the parsing to make it more flexible are welcome.
 ### Annotations
 
 `annotations` defines the set of annotations that should be applied to images and indexes.
+
+### Layering
+
+`layering` defines a strategy for splitting the filesystem contents into layers.
+
+It contains the following children:
+
+ - `strategy`: The strategy to employ (currently, only "origin" is valid).
+ - `budget`: The number of additional layers apko will use for layering.
+
+See [layering.md](layering.md) for more information.
diff --git a/docs/layering.md b/docs/layering.md
new file mode 100644
index 0000000..ac4b4c2
--- /dev/null
+++ b/docs/layering.md
@@ -0,0 +1,378 @@
+# Layering
+
+This document describes how apko's layering implementation works and some rationale explaining why it works that way.
+
+## Considerations
+
+Primarily, we wanted something simple that can just be enabled and wouldn't require config authors to manually arrange layers.
+While that amount of flexibility is powerful and makes sense for formats like `Dockerfile`, it wouldn't be a great fit for apko's simple model.
+We've come up with simple heuristics that produce results that should be good enough for most people.
+There is only one knob for tuning the number of layers, and we've left it open for extension, but the current layering config is very simple.
+
+Another consideration was [reproducibility](https://www.chainguard.dev/unchained/reproducing-chainguards-reproducible-image-builds).
+There are very interesting approaches like [nixery](https://nixery.dev/) that consider popularity of a package within the overall dependency graph, but we didn't want the layering strategy to rely on external data sources.
+We want the layering decisions to be stable for reproducibility, so consulting an external index that can change over time wouldn't work.
+
+Finally, we want the layering implementation to be effective.
+Chainguard has a [large corpus of images](https://images.chainguard.dev/), so we were able to simulate the effects of these layering changes before rolling them out and also observe the actual effects in production.
+Using our image corpus, we determined about 2/3 of our image data could be eliminated by sharing layers across images.
+Similarly, we expect incremental changes to be much smaller between updates of a single image (based on the given budget, see below).
+
+## Implementation
+
+Support for layered apko images was added in [v0.27.0](https://github.com/chainguard-dev/apko/releases/tag/v0.27.0).
+
+By default, apko will continue to lump everything into a single layer, but including the `layering` in an apko config will instruct apko to split filesystem contents across multiple layers.
+
+For example:
+
+```yaml
+layering:
+  strategy: origin
+  budget: 10
+```
+
+Currently, the only layering strategy that has been implemented is the `origin` strategy, but we may revisit more complicated strategies in the future.
+
+### Budget
+
+Why include a budget?
+
+Practically speaking, most container runtimes cannot support an infinite number of layers, so there is _some_ upper bound in how many layers we can use (today, containerd maxes out at 127).
+Furthermore, not all images are used in the same way.
+For "base" images, you probably don't want apko to use a huge number of layers because consumers of base images will append a bunch of their own layers, and at some point too many is too many.
+For "application" images that are intended to be run directly, you don't usually expect someone to append any (or many) layers on top of them, so you may want apko to take advantage of more layers.
+
+Thus, we shift the decision here to the user who will know more about how and where the image will be used.
+Emperically, we have found a budget of 10 layers to be a decent balance between deduplication benefit and small fixed costs associated with each layer, but your mileage may vary.
+
+### Strategy
+
+The main purpose of this doc is to describe the `origin` layering strategy.
+
+#### tl;dr
+
+1. Lazily install all packages in the usual way.
+1. Group packages together based on strategy and budget.
+1. Partition the filesystem into real layers based on package grouping.
+1. Any files not associated with a package are written to the "top" layer.
+
+#### Lazy Install
+
+One of the design goals of layering was to produce a layered rootfs that is identical to the non-layered rootfs.
+If we try to construct each layer independently, we end up with slightly different results.
+
+Why?
+
+One example is [usr merge](https://wiki.debian.org/UsrMerge).
+As part of merging everything under usr, we added a bunch of symlinked directories.
+Installing a package with files under e.g. `/bin` when `/bin` is a symlink to `/usr/bin` will result in those files being written to `/usr/bin`.
+One way to keep this behavior in a layered world is to start with "installing" every package in the usual way to a single filesystem.
+
+This might seem inefficient because we would need to write every file twice, right?
+In reality, this is fairly [efficient](https://github.com/chainguard-dev/apko/issues/781) because we don't actually write bytes to disk when we do this, we [just track file metadata](https://github.com/chainguard-dev/go-apk/pull/103) and [tar file offsets in memory](https://github.com/jonjohnsonjr/til/blob/main/post/tarfs.md).
+We don't actually access those bytes until later when we walk the virtual filesystem to convert it into a concrete layer artifact.
+
+Thanks to this lazy virtual filesystem approach, we actually have enough metadata about file ownership (i.e. which package a file belonged to) to retroactively perform layering while walking the filesystem.
+
+#### Package Grouping
+
+A naive strategy would be to lump every package into its own layer.
+While this would be good for overall deduplication across images, it doesn't make efficient use of our layer budget.
+We would like to apply some heuristics to combine related packages into layers so that we can stretch that layer budget further.
+
+How?
+
+We group packages together based on a small number of rules:
+
+1. Group by `origin`.
+1. Group by `replaces`.
+1. Group outside budget.
+
+##### By Origin
+
+The `origin` of package really just refers to the build that produced it.
+For wolfi, if a build is defined in `foo.yaml`, its origin is `foo`.
+
+The rationale for grouping by origin is that packages within the same origin change together.
+If there's a new version of `foo`, there will also be a new version of `foo-dev`, and you very likely would not want to mix and match packages across versions within an origin.
+By that logic, grouping any packages from the same origin helps us double-dip on our layer budget by colocating these packages.
+
+This heuristic works really well for a single image as it changes over time, but it has a minor failure mode when considering deduplication _across_ multiple images with similar packages.
+As an example, if `example.com/image:latest` pulls in a huge package, `giant-package`, and `example.com/image:latest-dev` pulls in both `giant-package` and `giant-package-dev`, we would not get deduplication between the `latest` and `latest-dev` tags.
+We have not found this to be common enough to warrant further effort.
+
+As an example, chainguard's `go` image contains several packages that are part of the same `gcc` origin.
+One easy way to determine which packages were grouped into a layer is to use their SBOM files as evidence.
+Since those packages embed their SBOMs under `var/lib/db/sbom`, we can just list those files in any layer:
+
+```
+$ crane blob cgr.dev/chainguard/go@sha256:9d385ce368f85ee0b9463eb4e8734b8583b201a32c3f501cbef4cd1ac2de06bb | tar -tvz var/lib/db/sbom
+drwxr-xr-x  0 root   root        0 Jun  2 10:31 var/lib/db/sbom
+-rw-rw-rw-  0 root   root     3287 Jun  2 10:31 var/lib/db/sbom/gcc-15.1.0-r1.spdx.json
+-rw-rw-rw-  0 root   root     3329 Jun  2 10:31 var/lib/db/sbom/libatomic-15.1.0-r1.spdx.json
+-rw-rw-rw-  0 root   root     3308 Jun  2 10:31 var/lib/db/sbom/libgcc-15.1.0-r1.spdx.json
+-rw-rw-rw-  0 root   root     3301 Jun  2 10:31 var/lib/db/sbom/libgo-15.1.0-r1.spdx.json
+-rw-rw-rw-  0 root   root     3315 Jun  2 10:31 var/lib/db/sbom/libgomp-15.1.0-r1.spdx.json
+-rw-rw-rw-  0 root   root     3343 Jun  2 10:31 var/lib/db/sbom/libquadmath-15.1.0-r1.spdx.json
+-rw-rw-rw-  0 root   root     3349 Jun  2 10:31 var/lib/db/sbom/libstdc++-15.1.0-r1.spdx.json
+-rw-rw-rw-  0 root   root     3377 Jun  2 10:31 var/lib/db/sbom/libstdc++-dev-15.1.0-r1.spdx.json
+```
+
+##### By Replaces
+
+Rarely, but sometimes, you may want to install two packages that have overlapping and conflicting files.
+By default, this will cause conflicts at install time, but APK has an escape hatch to deal with this: `replaces`.
+
+You can declare that one package `replaces` another such that it will overwrite any conflicting files.
+An example of this is [libxcrypt replacing old versions of libcrypt1](https://github.com/wolfi-dev/os/blob/4996d337875501f74f6a084fb7837d9e569a6dbf/libxcrypt.yaml#L13-L16).
+
+One way to deal with this replaces directive would be to ensure layers are ordered correctly such that the last layer "wins" when the container runtime overlays each as a filesystem diff.
+To continue with the above example, we would need to ensure that `libxcrypt` belonged to a layer that was "above" `libcrypt1`, so that the overlayed filesystem replaced any conflicts with `libcryptx` files.
+This presents an interesting implementation challenge that I chose instead to sidestep.
+
+Rather than trying to track these `replaces` relationships and sort layers in a corresponding order, we simply group both the replacer and the replacee packages into the same layer.
+A downside of this is that "replaced" package layers won't be deduplicated with "nonreplaced" package layers, but given other implementation decisions, that was going to be true anyway!
+
+All else being equal, grouping by `replaces` allows us to stretch that layer budget further, so we biased towards grouping.
+
+I don't have a great public example of this because it happens so rarely, but trust me that this is how we handle that situation.
+
+##### Overflow
+
+After grouping by origin and replaces, we just sort by approximate size by summing the installed sizes of all packages within a layer (this information exists in each package's .PKGINFO) and take the top `$budget - 1`.
+The penultimate layer group merges any remaining packages that didn't make the cut (given the budget).
+In practice, this means all the smaller packages end up being grouped together.
+
+Drawback?
+Very commonly used packages that are small don't see any deduplication.
+In practice, the potential savings you might gain from deduplicating very-commonly-used small packages together are dwarfed by the actual savings we see from prioritizing larger packages, so this has worked fine for us.
+There is inherent overhead to pulling each layer at the HTTP layer (pardon the pun), so trying to dedupe anything under a few kilobytes is not really worth doing anyway.
+
+Piggybacking on the previous example, we can look at packages in go's overflow layer:
+
+```
+$ crane blob cgr.dev/chainguard/go@sha256:3f284680a82c9bf0a2b8c8d856b9cce058a192fe04e64f606419756f2d138302 | tar -tvz var/lib/db/sbom
+
+drwxr-xr-x  0 root   root        0 May 29 13:20 var/lib/db/sbom
+-rw-rw-rw-  0 root   root     3148 May 29 13:20 var/lib/db/sbom/bash-5.2.37-r32.spdx.json
+-rw-r--r--  0 root   root     1362 May 23  2024 var/lib/db/sbom/build-base-1-r8.spdx.json
+-rw-rw-rw-  0 root   root     3003 May 28 06:35 var/lib/db/sbom/busybox-1.37.0-r41.spdx.json
+-rw-rw-rw-  0 root   root     3397 May 28 06:35 var/lib/db/sbom/ca-certificates-bundle-20241121-r42.spdx.json
+-rw-rw-rw-  0 root   root     3116 May 29 14:49 var/lib/db/sbom/cyrus-sasl-2.1.28-r42.spdx.json
+-rw-rw-rw-  0 root   root     3093 May 28 06:35 var/lib/db/sbom/gdbm-1.25-r1.spdx.json
+-rw-rw-rw-  0 root   root     3108 May 29 13:20 var/lib/db/sbom/gmp-6.3.0-r6.spdx.json
+-rw-rw-rw-  0 root   root     2897 May 28 06:35 var/lib/db/sbom/isl-0.27-r1.spdx.json
+-rw-rw-rw-  0 root   root     3319 May 28 06:35 var/lib/db/sbom/keyutils-libs-1.6.3-r32.spdx.json
+-rw-rw-rw-  0 root   root     2046 May 28 06:35 var/lib/db/sbom/krb5-conf-1.0-r6.spdx.json
+-rw-rw-rw-  0 root   root     3007 May 29 13:20 var/lib/db/sbom/krb5-libs-1.21.3-r42.spdx.json
+-rw-rw-rw-  0 root   root     3028 May 28 06:35 var/lib/db/sbom/libbrotlicommon1-1.1.0-r5.spdx.json
+-rw-rw-rw-  0 root   root     3007 May 28 06:35 var/lib/db/sbom/libbrotlidec1-1.1.0-r5.spdx.json
+-rw-rw-rw-  0 root   root     3145 May 28 06:35 var/lib/db/sbom/libcom_err-1.47.2-r22.spdx.json
+-rw-rw-rw-  0 root   root     3033 Jun  3 06:05 var/lib/db/sbom/libcurl-openssl4-8.14.0-r3.spdx.json
+-rw-rw-rw-  0 root   root     3158 May 29 13:20 var/lib/db/sbom/libedit-3.1-r10.spdx.json
+-rw-rw-rw-  0 root   root     3206 May 28 06:35 var/lib/db/sbom/libexpat1-2.7.1-r1.spdx.json
+-rw-rw-rw-  0 root   root     3183 May 28 06:35 var/lib/db/sbom/libidn2-2.3.8-r1.spdx.json
+-rw-rw-rw-  0 root   root     3269 May 29 02:02 var/lib/db/sbom/libldap-2.6.10-r2.spdx.json
+-rw-rw-rw-  0 root   root     3033 May 28 06:35 var/lib/db/sbom/libnghttp2-14-1.65.0-r1.spdx.json
+-rw-rw-rw-  0 root   root     3065 May 28 06:35 var/lib/db/sbom/libpcre2-8-0-10.45-r2.spdx.json
+-rw-rw-rw-  0 root   root     2989 May 28 06:35 var/lib/db/sbom/libpsl-0.21.5-r5.spdx.json
+-rw-rw-rw-  0 root   root     3253 May 28 06:35 var/lib/db/sbom/libunistring-1.3-r2.spdx.json
+-rw-rw-rw-  0 root   root     2990 May 28 06:35 var/lib/db/sbom/libverto-0.3.2-r5.spdx.json
+-rw-rw-rw-  0 root   root     3091 May 28 06:35 var/lib/db/sbom/libxcrypt-4.4.38-r2.spdx.json
+-rw-rw-rw-  0 root   root     3119 May 28 06:35 var/lib/db/sbom/libxcrypt-dev-4.4.38-r2.spdx.json
+-rw-rw-rw-  0 root   root     3010 May 28 06:35 var/lib/db/sbom/libzstd1-1.5.7-r2.spdx.json
+-rw-rw-rw-  0 root   root     3106 May 28 06:35 var/lib/db/sbom/make-4.4.1-r5.spdx.json
+-rw-rw-rw-  0 root   root     3074 May 28 06:35 var/lib/db/sbom/mpc-1.3.1-r6.spdx.json
+-rw-rw-rw-  0 root   root     3097 May 28 06:35 var/lib/db/sbom/mpfr-4.2.2-r1.spdx.json
+-rw-rw-rw-  0 root   root     3432 May 28 06:35 var/lib/db/sbom/ncurses-6.5_p20241228-r2.spdx.json
+-rw-rw-rw-  0 root   root     3530 May 28 06:35 var/lib/db/sbom/ncurses-terminfo-base-6.5_p20241228-r2.spdx.json
+-rw-rw-rw-  0 root   root     2238 May 28 06:35 var/lib/db/sbom/openssf-compiler-options-20240627-r20.spdx.json
+-rw-rw-rw-  0 root   root     3000 May 28 06:35 var/lib/db/sbom/pkgconf-2.4.3-r2.spdx.json
+-rw-rw-rw-  0 root   root     2114 May 28 06:35 var/lib/db/sbom/posix-cc-wrappers-1-r6.spdx.json
+-rw-rw-rw-  0 root   root     3195 May 28 06:35 var/lib/db/sbom/readline-8.2.13-r4.spdx.json
+-rw-rw-rw-  0 root   root     3196 May 29 09:22 var/lib/db/sbom/sqlite-libs-3.50.0-r0.spdx.json
+-rw-r--r--  0 root   root     2151 Mar 31 06:08 var/lib/db/sbom/wolfi-baselayout-20230201-r20.spdx.json
+-rw-rw-rw-  0 root   root     2950 May 28 06:35 var/lib/db/sbom/zlib-1.3.1-r7.spdx.json
+```
+
+Also, looking at the relative size of the overflow layer vs the layers that made the cut, it's not unreasonable:
+
+```
+$ crane manifest cgr.dev/chainguard/go@sha256:78036eb1e687715046b6f4850e9785355a58a88967ab2b853cfbfa46e245ee39 | jq '.layers[].size'
+153505114
+72330618
+25702646
+17118397
+9087291
+1916754
+2778029
+1413772
+1117322
+11134395
+296522
+```
+
+Our overflow layer is about 10x the size of smallest package-ful layer, but still much smaller than the largest layers.
+There are likely some small percentage improvement in deduplication we could attain here, but it's probably not worth the effort.
+
+#### Top Layer
+
+Finally, the top layer is any remaining files.
+Which files are those?
+Glad you asked.
+
+As mentioned earlier, we track metadata about every file we lazily "write" to our references-to-tar-offsets-backed filesystem.
+Part of that metadata is which package owned those file's bytes.
+We use that ownership information to paritition our single virtual filesystem into `$budget + 1` concrete layers.
+
+Any file that has actual contents gets split out into its own layer (including directories that contain said file (see next section for more details)).
+Any file _without_ package-owner metadata gets dumped into the top layer.
+
+Primarily, this is a bunch of directories and device files, but the actually interesting files are OS metadata like the installed database.
+
+Looking at `cgr.dev/chainguard/crane` and filtering out directores and files:
+
+```
+$ crane blob cgr.dev/chainguard/crane@sha256:5d26415966d404f314a45b7952a9b2e6f9a9fee1b535e8bebd123ff904111b74 | tar -tvz | grep -v -e '^d' | grep -v -e '^c'
+-rw-r--r--  0 root   root        7 Dec 31  1969 etc/apk/arch
+-rw-r--r--  0 root   root      800 Dec 31  1969 etc/apk/keys/wolfi-signing.rsa.pub
+-rw-r--r--  0 root   root       30 Dec 31  1969 etc/apk/repositories
+-rw-r--r--  0 root   root       82 Dec 31  1969 etc/apk/world
+-r--r--r--  0 root   root      870 Dec 31  1969 etc/apko.json
+lrwxrwxrwx  0 root    root        0 Mar 31 06:08 lib/apk -> ../usr/lib/apk
+-rw-r--r--  0 root    root     2609 Dec 31  1969 usr/lib/apk/db/installed
+-rw-------  0 root    root        0 Dec 31  1969 usr/lib/apk/db/lock
+-rw-r--r--  0 root    root     1024 Dec 31  1969 usr/lib/apk/db/scripts.tar
+-rw-r--r--  0 root    root        0 Dec 31  1969 usr/lib/apk/db/triggers
+```
+
+Again, these files were produced by the same code that apko has always used to generate single-layer images, so these should match what you'd expect.
+
+## Results
+
+Does this actually work in practice?
+
+### Across Space
+
+Let's look at two public images that should have a lot of overlap in their installed packages:
+
+```
+$ comm -12 \
+    <(crane manifest --platform linux/amd64 cgr.dev/chainguard/gradle | jq '.layers[].digest' -r) \
+    <(crane manifest --platform linux/amd64 cgr.dev/chainguard/jdk | jq '.layers[].digest' -r)
+sha256:751aa1731700531822dd165e9b94f0d3557e6d9a3d479c1c67e24d3784340a3f
+sha256:db70eed5a289b0f1d7a1ea36785a9dcbc0ef289a8663cc5a9ee8bc3320325e37
+sha256:afc58e7b271e3e989292dea108da612444653f86f6f0745ad1699e80eaec9a08
+sha256:2c87b77641e1f7762400a761f6190625a095011680971d9b86bd8f604046671e
+sha256:fbb06e7d45863e8607250738faadf58352992f5f417e6764aafb2f47a2bb8e14
+sha256:acaad7d4f229c940a3228d1170a02263374f7b640ccf9980f174b1ee8366164f
+sha256:f96a4e40e9f1eb0df7d4bef81780ce0b6899ae9e7f3f707b3dd9f11e4ba27f53
+sha256:e8f0a3361e4411faf5559c8f93113eeea0a7c6aa43bb01fb9172f5f744ab886c
+```
+
+The `jdk` and `gradle` images share most of their layers by count.
+We get pretty decent sharing of layers across similar images.
+
+## Across Time
+
+How do we do with the same image as it changes over time?
+
+Let's look at the counts of unique layers within the 10 most recent gradle images:
+
+```
+$ curl -s -H "$(crane auth token -H cgr.dev/chainguard/gradle)" https://cgr.dev/v2/chainguard/gradle/_chainguard/history/latest\?end\=3000-01-01T00%3A00%3A00.000Z \
+  | jq '.history[].digest' -r | head -n 10 | xargs -n1 -I{} crane manifest --platform linux/amd64 cgr.dev/chainguard/gradle@{} \
+  | jq '.layers[] | "\(.digest) \(.size)"' -r \
+  | sort | uniq -c | sort -n
+   1 sha256:0687e766052c4bb7acd662a60a48c5769e0673d83c14947007a6f3a63ca1fed1 2963776
+   1 sha256:0b75aa176560094069142b4483ee28dd4e2760966c66ab70a40ae734847eadab 65185
+   1 sha256:115facd2f2717ec3687b48c1e149bab61accb01c382438baa61569336f22a57c 64282
+   1 sha256:1316b82f723d185c160f7897675f0878cc1ed9c9420a617b481f6196ea4d4edd 67266
+   1 sha256:334129c24c3ee74a241c5b0610f43d0ef6ddde456425f8a05966c0eceeefce49 64272
+   1 sha256:6a6db57cf22068bb3d3a82e53e4288d11ba84bdba967d844821943b53c4c54b6 2970485
+   1 sha256:7b63427630312ac171847aa98e99690012cee264ff8a6a7256dd5665019e5432 65010
+   1 sha256:90b11aebd3149d5e18ea925177b3026686e3d35dc5d411574fdf688cdfe8457a 65144
+   1 sha256:9546f0ea3d8b5927a76ea1d7b83bdce40d2ed8750c6dbfa9b7e6ad25e997f111 64315
+   1 sha256:96bb84bf6b678d2a4fc80ee09bdd621b2e754b7e4aa65a79106da17718561f13 2970499
+   1 sha256:a722c03e5874bbc8ae7f7094785a3e07190d1a2ddce344ecb122e95d8c6e45d2 65149
+   1 sha256:b3d19f90e8e4914ea70985b6547072ef6d5225fcf2e40a571efcd4f13ffd04ad 2970434
+   1 sha256:b3e987c7cb25a7734de8b3f850dc0368f353428925a6305f04bcd9b363374d2c 65260
+   1 sha256:cbfef2d4b60a33a02894df1eb84c861f81d6a224e0f9d814f3fe957172aa0e20 65070
+   1 sha256:db70eed5a289b0f1d7a1ea36785a9dcbc0ef289a8663cc5a9ee8bc3320325e37 22293495
+   1 sha256:ecc1cd11c33f8f9cb91792c9166601ee645fb6dc572157b1bba496079842bd3e 139912260
+   2 sha256:0bd75a0ea92aa08986806061b19785c6c4d03794c09b7035dc4c9842d74d9b39 2965925
+   2 sha256:1ec80df8f6be39f65d7ff3a16838b8ce82359f184a0bc9359a03847f171ac6ae 2966323
+   2 sha256:6f57ef0b73246f856f3ecfd9442e1773bf185946765696cf9573f009dd2baf94 2966216
+   3 sha256:f578aeb87d759ebd015dd05e91f69f89d0877a5c1e8155245e8425d251bbd114 22276588
+   6 sha256:e572154e14b785d3aec83077dc343315da6fcd9f0d7c4950df94081025567a76 22260994
+   9 sha256:ab7657aec6dad1a95cc8bcebd4c89a0f657877b32e6e00f106912ac02645d06c 139918243
+  10 sha256:2c87b77641e1f7762400a761f6190625a095011680971d9b86bd8f604046671e 1838566
+  10 sha256:751aa1731700531822dd165e9b94f0d3557e6d9a3d479c1c67e24d3784340a3f 97254522
+  10 sha256:acaad7d4f229c940a3228d1170a02263374f7b640ccf9980f174b1ee8366164f 889941
+  10 sha256:afc58e7b271e3e989292dea108da612444653f86f6f0745ad1699e80eaec9a08 10269539
+  10 sha256:e8f0a3361e4411faf5559c8f93113eeea0a7c6aa43bb01fb9172f5f744ab886c 390729
+  10 sha256:f96a4e40e9f1eb0df7d4bef81780ce0b6899ae9e7f3f707b3dd9f11e4ba27f53 674902
+  10 sha256:fbb06e7d45863e8607250738faadf58352992f5f417e6764aafb2f47a2bb8e14 2286737
+```
+
+We see pretty decent overlap!
+For the most part, the layers we see only once are very small (~64K), though there are a few larger ones that seem to change more frequently.
+The layers we see with multiple times tend to be larger, which matches what our layering strategy is trying to achieve.
+
+## Caveats
+
+### Directory Timestamps
+
+The initial implementation here produced results that didn't live up to what we expected.
+After some investigation, we realized that our culprit was timestamps.
+
+As mentioned in the "replaces" section, packages tend to have non-overlapping files.
+This is true for regular files, but it doesn't apply to directories.
+Packages almost always have overlapping directories!
+And those directories almost always have different timestamps.
+
+What does this mean for layering?
+Cross-image deduplication won't work very well.
+
+Even if all the files with actual contents were identical between two layers in different images, often the timestamps in directories were slightly different because of the order we "installed" other packages that belonged to different layers because of overlapping directories (this is a downside of our "install things the usual way then partition into separate layers" strategy that solved several other problems for us).
+
+We really want that deduplication between images, so how could we solve this?
+
+#### Option 1: Drop timestamps
+
+If timestamps are a problem, just get rid of them!
+Indeed, we used to do exactly this, but we ran into a number of applications that will `stat` certain files and refuse to start if their modtime was too old.
+On top of that, timestamps are often useful for debugging things or for setting HTTP headers for caching, so we really wanted to keep them around.
+
+Onto the next option.
+
+#### Option 2: Drop directories
+
+We don't _really_ need these directories in each layer, right?
+We could just have those directories exist in the top layer and have orphaned files in our package-ful layers.
+
+This would work, and it's not a terrible idea, but it relies on [implementation-defined behavior](https://github.com/opencontainers/image-spec/pull/970) of implicit directories and makes browsing individual layers cumbersome.
+
+For now, we've rejected this option, but we might want to revisit it in the future.
+
+#### Option 3: Synthesize Timestamps
+
+Finally, we've come to the (current) hacky solution: https://github.com/chainguard-dev/apko/pull/1624
+
+Instead of dropping directory timestamps in our layers, we just have parent directories adopt whatever timestamps their child files have.
+These timestamps still get overwritten by the directory entry in the top layer, so semantically it's still identical to a single-layer image.
+Since the timestamps are synthesized from files that _only_ exist in each layer, we don't have the unfortunate inter-layer influence that was invalidating our deduplication from before.
+
+This has worked pretty well, but it may not be a permanent solution.
+
+### Path Mutations
+
+Another deduplication antagonist is apko's support for mutating [paths](apko_file.md#paths).
+In particular, modifying the ownership or permissions of a file will affect layer contents and impact layer deduplication.
+Unlike with directory timestamps, there's not much we can do here to fix it.
+
+Because layers are tarballs, there's no cheap mechanism to overwrite _just_ file metadata in the top layer.
+We'd have to overwrite the entire thing, which doesn't feel worth doing.
+
+Luckily, these kinds of path mutations aren't that common, just be aware that modifying permissions of a file in one image may mean it won't see any deduplication with similar layers in other images without said modifications.
diff --git a/go.mod b/go.mod
index cc8fe5a..6bc928a 100644
--- a/go.mod
+++ b/go.mod
@@ -6,7 +6,7 @@ require (
 	chainguard.dev/sdk v0.1.31
 	github.com/chainguard-dev/clog v1.7.0
 	github.com/charmbracelet/log v0.4.2
-	github.com/go-git/go-git/v5 v5.16.0
+	github.com/go-git/go-git/v5 v5.16.2
 	github.com/google/go-cmp v0.7.0
 	github.com/google/go-containerregistry v0.20.4-0.20250225234217-098045d5e61f
 	github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
@@ -16,20 +16,20 @@ require (
 	github.com/klauspost/compress v1.18.0
 	github.com/klauspost/pgzip v1.2.6
 	github.com/package-url/packageurl-go v0.1.3
-	github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966
+	github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
 	github.com/spf13/cobra v1.9.1
 	github.com/stretchr/testify v1.10.0
 	github.com/tmc/dot v0.0.0-20210901225022-f9bc17da75c0
 	github.com/u-root/u-root v0.14.0
 	go.lsp.dev/uri v0.3.0
-	go.opentelemetry.io/otel v1.35.0
-	go.opentelemetry.io/otel/trace v1.35.0
-	go.step.sm/crypto v0.60.0
+	go.opentelemetry.io/otel v1.36.0
+	go.opentelemetry.io/otel/trace v1.36.0
+	go.step.sm/crypto v0.67.0
 	golang.org/x/oauth2 v0.30.0
-	golang.org/x/sync v0.14.0
+	golang.org/x/sync v0.15.0
 	golang.org/x/sys v0.33.0
-	golang.org/x/time v0.11.0
-	google.golang.org/api v0.231.0
+	golang.org/x/time v0.12.0
+	google.golang.org/api v0.237.0
 	gopkg.in/ini.v1 v1.67.0
 	gopkg.in/yaml.v3 v3.0.1
 	k8s.io/apimachinery v0.32.3
@@ -38,9 +38,9 @@ require (
 
 require (
 	chainguard.dev/go-grpc-kit v0.17.7 // indirect
-	cloud.google.com/go/auth v0.16.1 // indirect
+	cloud.google.com/go/auth v0.16.2 // indirect
 	cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
-	cloud.google.com/go/compute/metadata v0.6.0 // indirect
+	cloud.google.com/go/compute/metadata v0.7.0 // indirect
 	dario.cat/mergo v1.0.1 // indirect
 	filippo.io/edwards25519 v1.1.0 // indirect
 	github.com/Microsoft/go-winio v0.6.2 // indirect
@@ -80,7 +80,7 @@ require (
 	github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
 	github.com/google/s2a-go v0.1.9 // indirect
 	github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
-	github.com/googleapis/gax-go/v2 v2.14.1 // indirect
+	github.com/googleapis/gax-go/v2 v2.14.2 // indirect
 	github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 // indirect
 	github.com/grpc-ecosystem/go-grpc-prometheus v1.2.1-0.20210315223345-82c243799c99 // indirect
 	github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0 // indirect
@@ -117,18 +117,18 @@ require (
 	github.com/xanzy/ssh-agent v0.3.3 // indirect
 	github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
 	go.opentelemetry.io/auto/sdk v1.1.0 // indirect
-	go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 // indirect
-	go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect
+	go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect
+	go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
 	go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 // indirect
-	go.opentelemetry.io/otel/metric v1.35.0 // indirect
+	go.opentelemetry.io/otel/metric v1.36.0 // indirect
 	go.opentelemetry.io/proto/otlp v1.4.0 // indirect
-	golang.org/x/crypto v0.37.0 // indirect
-	golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f // indirect
-	golang.org/x/net v0.39.0 // indirect
-	golang.org/x/text v0.24.0 // indirect
-	google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb // indirect
-	google.golang.org/genproto/googleapis/rpc v0.0.0-20250425173222-7b384671a197 // indirect
-	google.golang.org/grpc v1.72.0 // indirect
+	golang.org/x/crypto v0.39.0 // indirect
+	golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
+	golang.org/x/net v0.41.0 // indirect
+	golang.org/x/text v0.26.0 // indirect
+	google.golang.org/genproto/googleapis/api v0.0.0-20250505200425-f936aa4a68b2 // indirect
+	google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect
+	google.golang.org/grpc v1.73.0 // indirect
 	google.golang.org/protobuf v1.36.6 // indirect
 	gopkg.in/warnings.v0 v0.1.2 // indirect
 	gotest.tools/v3 v3.5.1 // indirect
diff --git a/go.sum b/go.sum
index 7e205e9..80461ca 100644
--- a/go.sum
+++ b/go.sum
@@ -3,12 +3,12 @@ chainguard.dev/go-grpc-kit v0.17.7/go.mod h1:JroMzTY9mdhKe/bvtyChgfECaNh80+bMZH3
 chainguard.dev/sdk v0.1.31 h1:Blvpa0Ji/tC1VVV8/l8UyQe022LoRxZLfgasyFE1EhQ=
 chainguard.dev/sdk v0.1.31/go.mod h1:/zqikqbDCBAAlhIDuBl8V4bR9nmB1qLEIn2w9FxzNwI=
 cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
-cloud.google.com/go/auth v0.16.1 h1:XrXauHMd30LhQYVRHLGvJiYeczweKQXZxsTbV9TiguU=
-cloud.google.com/go/auth v0.16.1/go.mod h1:1howDHJ5IETh/LwYs3ZxvlkXF48aSqqJUM+5o02dNOI=
+cloud.google.com/go/auth v0.16.2 h1:QvBAGFPLrDeoiNjyfVunhQ10HKNYuOwZ5noee0M5df4=
+cloud.google.com/go/auth v0.16.2/go.mod h1:sRBas2Y1fB1vZTdurouM0AzuYQBMZinrUYL8EufhtEA=
 cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
 cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
-cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I=
-cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg=
+cloud.google.com/go/compute/metadata v0.7.0 h1:PBWF+iiAerVNe8UCHxdOt6eHLVc3ydFeOCw78U8ytSU=
+cloud.google.com/go/compute/metadata v0.7.0/go.mod h1:j5MvL9PprKL39t166CoB1uVHfQMs4tFQZZcKwksXUjo=
 dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
 dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
 filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
@@ -107,8 +107,8 @@ github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UN
 github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU=
 github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
 github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
-github.com/go-git/go-git/v5 v5.16.0 h1:k3kuOEpkc0DeY7xlL6NaaNg39xdgQbtH5mwCafHO9AQ=
-github.com/go-git/go-git/v5 v5.16.0/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8=
+github.com/go-git/go-git/v5 v5.16.2 h1:fT6ZIOjE5iEnkzKyxTHK1W4HGAsPhqEqiSAssSO77hM=
+github.com/go-git/go-git/v5 v5.16.2/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8=
 github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY=
 github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=
 github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
@@ -147,8 +147,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4=
 github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
-github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q=
-github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA=
+github.com/googleapis/gax-go/v2 v2.14.2 h1:eBLnkZ9635krYIPD+ag1USrOAI0Nr0QYF3+/3GqO0k0=
+github.com/googleapis/gax-go/v2 v2.14.2/go.mod h1:ON64QhlJkhVtSqp4v1uaK92VyZ2gmvDQsweuyLV+8+w=
 github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI=
 github.com/grpc-ecosystem/go-grpc-middleware v1.4.0/go.mod h1:g5qyo/la0ALbONm6Vbp88Yd8NsDy6rZz+RcrMPxvld8=
 github.com/grpc-ecosystem/go-grpc-prometheus v1.2.1-0.20210315223345-82c243799c99 h1:JYghRBlGCZyCF2wNUJ8W0cwaQdtpcssJ4CgC406g+WU=
@@ -224,6 +224,8 @@ github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ
 github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
 github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4=
 github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A=
+github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
+github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
 github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
 github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
@@ -258,8 +260,6 @@ github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ
 github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
 github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8=
 github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY=
-github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA=
-github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
 github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
 github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
 github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
@@ -293,28 +293,28 @@ go.lsp.dev/uri v0.3.0 h1:KcZJmh6nFIBeJzTugn5JTU6OOyG0lDOo3R9KwTxTYbo=
 go.lsp.dev/uri v0.3.0/go.mod h1:P5sbO1IQR+qySTWOCnhnK7phBx+W3zbLqSMDJNTw88I=
 go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
 go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
-go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 h1:x7wzEgXfnzJcHDwStJT+mxOz4etr2EcexjqhBvmoakw=
-go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0/go.mod h1:rg+RlpR5dKwaS95IyyZqj5Wd4E13lk/msnTS0Xl9lJM=
-go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU=
-go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ=
-go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
-go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
+go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ=
+go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
+go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg=
+go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E=
 go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 h1:Vh5HayB/0HHfOQA7Ctx69E/Y/DcQSMPpKANYVMQ7fBA=
 go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0/go.mod h1:cpgtDBaqD/6ok/UG0jT15/uKjAY8mRA53diogHBg3UI=
 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.33.0 h1:wpMfgF8E1rkrT1Z6meFh1NDtownE9Ii3n3X2GJYjsaU=
 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.33.0/go.mod h1:wAy0T/dUbs468uOlkT31xjvqQgEVXv58BRFWEgn5v/0=
-go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M=
-go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
-go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY=
-go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg=
-go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o=
-go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w=
-go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
-go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
+go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE=
+go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs=
+go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs=
+go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY=
+go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis=
+go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4=
+go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w=
+go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA=
 go.opentelemetry.io/proto/otlp v1.4.0 h1:TA9WRvW6zMwP+Ssb6fLoUIuirti1gGbP28GcKG1jgeg=
 go.opentelemetry.io/proto/otlp v1.4.0/go.mod h1:PPBWZIP98o2ElSqI35IHfu7hIhSwvc5N38Jw8pXuGFY=
-go.step.sm/crypto v0.60.0 h1:UgSw8DFG5xUOGB3GUID17UA32G4j1iNQ4qoMhBmsVFw=
-go.step.sm/crypto v0.60.0/go.mod h1:Ep83Lv818L4gV0vhFTdPWRKnL6/5fRMpi8SaoP5ArSw=
+go.step.sm/crypto v0.67.0 h1:1km9LmxMKG/p+mKa1R4luPN04vlJYnRLlLQrWv7egGU=
+go.step.sm/crypto v0.67.0/go.mod h1:+AoDpB0mZxbW/PmOXuwkPSpXRgaUaoIK+/Wx/HGgtAU=
 go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
 go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A=
 go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
@@ -325,11 +325,11 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
 golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
 golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
-golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
-golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
+golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
+golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
 golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
-golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f h1:XdNn9LlyWAhLVp6P/i8QYBW+hlyhrhei9uErw2B5GJo=
-golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f/go.mod h1:D5SMRVC3C2/4+F/DB1wZsLRnSNimn2Sp/NPsCrsv8ak=
+golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
+golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
 golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
 golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
 golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
@@ -338,8 +338,8 @@ golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
 golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
-golang.org/x/mod v0.23.0 h1:Zb7khfcRGKk+kqfxFaP5tZqCnDZMjC5VtUBs87Hr6QM=
-golang.org/x/mod v0.23.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
+golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
+golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
 golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -354,8 +354,8 @@ golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qx
 golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
 golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
 golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
-golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
-golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
+golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
+golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
 golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
 golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
@@ -366,8 +366,8 @@ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJ
 golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
-golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
+golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
+golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
 golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -382,6 +382,7 @@ golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -393,8 +394,8 @@ golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuX
 golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
 golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
 golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
-golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
-golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
+golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
+golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@@ -402,10 +403,10 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
 golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
 golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
 golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
-golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
-golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
-golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
-golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
+golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
+golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
+golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
+golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
 golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -418,31 +419,31 @@ golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roY
 golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
 golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
 golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
-golang.org/x/tools v0.30.0 h1:BgcpHewrV5AUp2G9MebG4XPFI1E2W41zU1SaqVA9vJY=
-golang.org/x/tools v0.30.0/go.mod h1:c347cR/OJfw5TI+GfX7RUPNMdDRRbjvYTS0jPyvsVtY=
+golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
+golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-google.golang.org/api v0.231.0 h1:LbUD5FUl0C4qwia2bjXhCMH65yz1MLPzA/0OYEsYY7Q=
-google.golang.org/api v0.231.0/go.mod h1:H52180fPI/QQlUc0F4xWfGZILdv09GCWKt2bcsn164A=
+google.golang.org/api v0.237.0 h1:MP7XVsGZesOsx3Q8WVa4sUdbrsTvDSOERd3Vh4xj/wc=
+google.golang.org/api v0.237.0/go.mod h1:cOVEm2TpdAGHL2z+UwyS+kmlGr3bVWQQ6sYEqkKje50=
 google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
 google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
 google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
 google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
 google.golang.org/genproto v0.0.0-20200423170343-7949de9c1215/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
-google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb h1:p31xT4yrYrSM/G4Sn2+TNUkVhFCbG9y8itM2S6Th950=
-google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:jbe3Bkdp+Dh2IrslsFCklNhweNTBgSYanP1UXhJDhKg=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20250425173222-7b384671a197 h1:29cjnHVylHwTzH66WfFZqgSQgnxzvWE+jvBwpZCLRxY=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20250425173222-7b384671a197/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
+google.golang.org/genproto/googleapis/api v0.0.0-20250505200425-f936aa4a68b2 h1:vPV0tzlsK6EzEDHNNH5sa7Hs9bd7iXR7B1tSiPepkV0=
+google.golang.org/genproto/googleapis/api v0.0.0-20250505200425-f936aa4a68b2/go.mod h1:pKLAc5OolXC3ViWGI62vvC0n10CpwAtRcTNCFwTKBEw=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
 google.golang.org/grpc v1.18.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs=
 google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
 google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
 google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
 google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
 google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
-google.golang.org/grpc v1.72.0 h1:S7UkcVa60b5AAQTaO6ZKamFp1zMZSU0fGDK2WZLbBnM=
-google.golang.org/grpc v1.72.0/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM=
+google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok=
+google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc=
 google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
 google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
diff --git a/internal/cli/dot.go b/internal/cli/dot.go
index 94bcb6e..f18ae8c 100644
--- a/internal/cli/dot.go
+++ b/internal/cli/dot.go
@@ -27,7 +27,7 @@ import (
 	"strings"
 	"time"
 
-	"github.com/skratchdot/open-golang/open"
+	"github.com/pkg/browser"
 	"github.com/spf13/cobra"
 	"github.com/tmc/dot"
 	"golang.org/x/sync/errgroup"
@@ -354,7 +354,7 @@ func DotCmd(ctx context.Context, configFile string, archs []types.Architecture,
 		})
 
 		g.Go(func() error {
-			return open.Run(fmt.Sprintf("http://localhost:%d", l.Addr().(*net.TCPAddr).Port))
+			return browser.OpenURL(fmt.Sprintf("http://localhost:%d", l.Addr().(*net.TCPAddr).Port))
 		})
 
 		g.Go(func() error {
diff --git a/pkg/apko/version.go b/pkg/apko/version.go
new file mode 100644
index 0000000..56f2e96
--- /dev/null
+++ b/pkg/apko/version.go
@@ -0,0 +1,53 @@
+// Copyright 2025 Chainguard, Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package apko exposes high level functions like apko's module version information.
+package apko
+
+import (
+	"runtime/debug"
+	"sync"
+)
+
+var once sync.Once
+var apkoVersion = "unknown"
+
+const modulePath = "chainguard.dev/apko"
+
+// Version returns the version of the apko module used in the current build.
+func Version() string {
+	once.Do(func() {
+		bi, ok := debug.ReadBuildInfo()
+		if !ok {
+			return
+		}
+
+		// If apko itself (or its tests) calls version.Version, we should report the version of the module.
+		if bi.Main.Path == modulePath {
+			apkoVersion = bi.Main.Version
+		}
+
+		for _, d := range bi.Deps {
+			if apkoVersion == "unknown" && d.Path == modulePath {
+				apkoVersion = d.Version
+			}
+			// In case the module is replaced, we want to report the replaced version, not the original.
+			if d.Replace != nil && d.Replace.Path == modulePath {
+				apkoVersion = d.Replace.Version
+				break
+			}
+		}
+	})
+	return apkoVersion
+}
diff --git a/pkg/build/sbom.go b/pkg/build/sbom.go
index 0ea5c37..5f55079 100644
--- a/pkg/build/sbom.go
+++ b/pkg/build/sbom.go
@@ -19,6 +19,7 @@ import (
 	"context"
 	"errors"
 	"fmt"
+	"io"
 	"io/fs"
 	"log/slog"
 	"path/filepath"
@@ -99,7 +100,7 @@ func (bc *Context) GenerateImageSBOM(ctx context.Context, arch types.Architectur
 
 	s.ImageInfo.Layers = m.Layers
 
-	info, err := readReleaseData(bc.fs)
+	info, err := fetchFSReleaseData(bc.fs)
 	if err != nil {
 		return nil, fmt.Errorf("reading release data: %w", err)
 	}
@@ -153,10 +154,10 @@ type ReleaseData struct {
 	VersionID  string
 }
 
-// readReleaseData reads the information from /etc/os-release
+// fetchFSReleaseData is a helper that reads the information from /etc/os-release
 //
 // If no os-release file is found, it returns a Data struct with ID set to "unknown".
-func readReleaseData(fsys fs.FS) (*ReleaseData, error) {
+func fetchFSReleaseData(fsys fs.FS) (*ReleaseData, error) {
 	f, err := fsys.Open("/etc/os-release")
 	if errors.Is(err, fs.ErrNotExist) {
 		return &ReleaseData{
@@ -169,7 +170,12 @@ func readReleaseData(fsys fs.FS) (*ReleaseData, error) {
 	}
 	defer f.Close()
 
-	scanner := bufio.NewScanner(f)
+	return ParseReleaseData(f)
+}
+
+// ParseReleaseData reads the os-release data from the provided io.Reader
+func ParseReleaseData(osRelease io.Reader) (*ReleaseData, error) {
+	scanner := bufio.NewScanner(osRelease)
 
 	kv := map[string]string{}
 	for scanner.Scan() {


Test patch:
diff --git a/.github/workflows/go-tests.yaml b/.github/workflows/go-tests.yaml
index 75f6804..6282dc9 100644
--- a/.github/workflows/go-tests.yaml
+++ b/.github/workflows/go-tests.yaml
@@ -16,7 +16,7 @@ jobs:
       contents: read
 
     steps:
-      - uses: step-security/harden-runner@c6295a65d1254861815972266d5933fd6e532bdf # v2.11.1
+      - uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
         with:
           egress-policy: audit
 
diff --git a/pkg/build/sbom_test.go b/pkg/build/sbom_test.go
index cfca1d8..6a848d9 100644
--- a/pkg/build/sbom_test.go
+++ b/pkg/build/sbom_test.go
@@ -24,7 +24,7 @@ import (
 	apkfs "chainguard.dev/apko/pkg/apk/fs"
 )
 
-func TestReadReleaseData(t *testing.T) {
+func TestFetchFSReleaseData(t *testing.T) {
 	osinfoData := `# This is a comment that should be ignored.
 ID=wolfi
 NAME="Wolfi"
@@ -35,8 +35,7 @@ HOME_URL="https://wolfi.dev"
 	fsys := apkfs.NewMemFS()
 	require.NoError(t, fsys.MkdirAll(filepath.Dir("/etc/os-release"), os.FileMode(0o644)))
 	require.NoError(t, fsys.WriteFile("/etc/os-release", []byte(osinfoData), os.FileMode(0o644)))
-	// Non existent file, should err
-	info, err := readReleaseData(fsys)
+	info, err := fetchFSReleaseData(fsys)
 	require.NoError(t, err)
 	require.Equal(t, "wolfi", info.ID, "id")
 	require.Equal(t, "Wolfi", info.Name, "name")
@@ -44,9 +43,9 @@ HOME_URL="https://wolfi.dev"
 	require.Equal(t, "Wolfi", info.PrettyName, "pretty name")
 }
 
-func TestReadReleaseData_EmptyDefaults(t *testing.T) {
+func TestFetchFSReleaseData_EmptyDefaults(t *testing.T) {
 	fsys := apkfs.NewMemFS()
-	info, err := readReleaseData(fsys)
+	info, err := fetchFSReleaseData(fsys)
 	require.NoError(t, err)
 	require.Equal(t, "unknown", info.ID)
 	require.Equal(t, "apko-generated image", info.Name)
@@ -54,13 +53,13 @@ func TestReadReleaseData_EmptyDefaults(t *testing.T) {
 	require.Equal(t, "", info.PrettyName)
 }
 
-func TestBadReleaseData(t *testing.T) {
+func TestBadFSReleaseData(t *testing.T) {
 	osinfoData := `hello, world! this is not a valid os-release file
 `
 	fsys := apkfs.NewMemFS()
 	require.NoError(t, fsys.MkdirAll(filepath.Dir("/etc/os-release"), os.FileMode(0o644)))
 	require.NoError(t, fsys.WriteFile("/etc/os-release", []byte(osinfoData), os.FileMode(0o644)))
 	// Bad data in file should err.
-	_, err := readReleaseData(fsys)
+	_, err := fetchFSReleaseData(fsys)
 	require.Error(t, err)
 }