Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 150 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
# SBOM per archive.
# 2. The SLSA reusable workflow generates a non-forgeable provenance
# attestation over the checksums file and uploads it to the release.
# 3. The ghcr job packages the same goreleaser-built Linux binaries into
# a multi-arch OCI image and pushes it to ghcr.io/deveshctl/layerx.
# Reusing the release-built binary keeps `layerx --version` identical
# across brew / scoop / deb / rpm / direct-download / container.
#
# Verification commands live in SECURITY.md.

Expand All @@ -13,6 +17,11 @@ on:
push:
tags:
- "v*"
# Manual dispatch runs the container image build without publishing, so
# the Dockerfile and buildx wiring can be validated on a feature branch
# before cutting a real tag. The `push` flag on the build step below is
# gated on `event_name == 'push'`, so a dispatch run is always a dry run.
workflow_dispatch:

# Workflow-default is read-only. Each job opts into the writes it needs.
permissions:
Expand Down Expand Up @@ -53,12 +62,19 @@ jobs:
with:
distribution: goreleaser
version: "~> v2"
args: release --clean
# `--skip=publish` on workflow_dispatch: we still build every archive
# (so the ghcr job has real Linux binaries to package) but don't cut
# a GitHub release. Tag runs use the full publish flow.
args: >-
release
--clean
${{ github.event_name == 'workflow_dispatch' && '--snapshot --skip=publish --skip=sign' || '' }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAP_GITHUB_TOKEN: ${{ secrets.TAP_GITHUB_TOKEN }}

- name: Export checksums for SLSA provenance
if: github.event_name == 'push'
id: hash
run: |
set -euo pipefail
Expand All @@ -73,8 +89,33 @@ jobs:
fi
echo "hashes=$(base64 -w0 < "$checksum_path")" >> "$GITHUB_OUTPUT"

# Ship the Linux binaries downstream to the ghcr job. We only need the
# linux_* build outputs; the archives, deb/rpm, and checksums stay in
# dist/ for goreleaser's own publish step and are not required for the
# image. Retention is short — this artifact is scratch state, not a
# release asset.
#
# upload-artifact@v4 strips the longest common leading directory when
# matching multiple globs — here `dist/` is stripped, so the artifact
# contains `layerx_linux_amd64_v1/layerx` etc. The ghcr job's
# `download-artifact` step re-adds the prefix via `path: dist/`, which
# is what the Dockerfile's `COPY dist/ /src/dist/` expects. If you
# ever change the download path, update the Dockerfile in lockstep.
- name: Upload Linux binaries for image build
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: layerx-linux-binaries
path: |
dist/layerx_linux_amd64*/layerx
dist/layerx_linux_arm64*/layerx
if-no-files-found: error
retention-days: 1

provenance:
needs: [goreleaser]
# SLSA generator only makes sense for a real release. Skip on manual
# dispatch (no artifacts to attest, no release to upload to).
if: github.event_name == 'push'
permissions:
# Required by the SLSA generator: mint OIDC token, write the release.
id-token: write
Expand All @@ -86,3 +127,111 @@ jobs:
with:
base64-subjects: ${{ needs.goreleaser.outputs.hashes }}
upload-assets: true

ghcr:
needs: [goreleaser]
runs-on: ubuntu-latest
permissions:
# `packages: write` is what lets GITHUB_TOKEN push to ghcr.io. Note that
# the resulting package must be manually flipped to public visibility
# after the first push — GHCR defaults to private, and there is no API
# to flip this on publish. Flip via the package settings UI on GitHub,
# or `gh api --method PATCH /user/packages/container/layerx -f visibility=public`.
packages: write
contents: read
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0

- name: Download release binaries
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: layerx-linux-binaries
path: dist/

- name: Show binary layout (debug)
run: |
set -eux
find dist -maxdepth 3 -type f -printf '%p %s bytes\n'
# Binaries lose the +x bit through the artifact upload/download
# round-trip; the Dockerfile's `install -m 0755` restores it, but
# we also chmod here so anything that reads dist/ before the image
# build sees a usable binary.
find dist -type f -name layerx -exec chmod 0755 {} +

- name: Set up QEMU (arm64 emulation)
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0

- name: Log in to GHCR
# Login is only needed when we actually push. workflow_dispatch runs
# are dry runs (build-only), so skip auth to avoid unnecessary token
# usage.
if: github.event_name == 'push'
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Compute lowercase image name
id: image
# GHCR rejects any mixed-case path segment; `github.repository_owner`
# preserves the login's original casing. Force lowercase so the
# workflow keeps working if this repo is ever transferred to an org
# or user with mixed-case in their name.
run: |
set -eu
owner="${GITHUB_REPOSITORY_OWNER,,}"
echo "name=ghcr.io/${owner}/layerx" >> "$GITHUB_OUTPUT"

- name: Extract image metadata (tags, labels)
id: meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: ${{ steps.image.outputs.name }}
# Preserve the `v` prefix in image tags so they match the git tag
# scheme exactly (`v1.5.1`, `v1.5`, `v1`). The default `{{version}}`
# helper strips the leading `v`; use `v{{version}}` etc. to keep it.
# `latest` is gated to non-prerelease tag pushes.
# Pre-release tags (v1.5.0-rc.1) skip the major/minor rollups and
# `latest` automatically via the semver helper + the enable clause.
tags: |
type=semver,pattern=v{{version}}
type=semver,pattern=v{{major}}.{{minor}}
type=semver,pattern=v{{major}}
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') && !contains(github.ref, '-') }}
type=raw,value=dispatch-${{ github.run_id }},enable=${{ github.event_name == 'workflow_dispatch' }}
# metadata-action fills in `source`, `revision`, `version`, and
# `created` from workflow context; the static labels below just make
# the intent explicit and stop `docker inspect` from picking up
# whatever GitHub's repo description happens to be that day.
labels: |
org.opencontainers.image.title=layerx
org.opencontainers.image.description=Terminal container image layer explorer — inspect Docker, Podman, and OCI images.
org.opencontainers.image.licenses=MIT
org.opencontainers.image.vendor=deveshctl

- name: Build and push image
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: Dockerfile
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name == 'push' }}
tags: ${{ steps.meta.outputs.tags }}
# metadata-action emits every OCI label we care about (title,
# description, source, revision, version, created, licenses,
# vendor). These override the LABEL defaults in the Dockerfile,
# so `docker inspect ghcr.io/...` shows the git tag and commit
# rather than the Dockerfile's `dev`/`unknown` placeholders.
labels: ${{ steps.meta.outputs.labels }}
# GitHub Actions cache backend for buildx. `mode=max` caches every
# intermediate layer (not just the final one), so repeated releases
# short-circuit on the runtime base image + the unchanged staging
# step, and only the binary-copy layer rebuilds.
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: false
sbom: false
25 changes: 17 additions & 8 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- Multi-arch container image published to
`ghcr.io/deveshctl/layerx` on every tagged release (linux/amd64 +
linux/arm64). The image reuses the exact binary produced by GoReleaser,
so `layerx --version` matches across brew, scoop, deb, rpm,
direct-download, and the container image. Runs on Docker, Podman, or
any OCI runtime. See the "Container image" section of the README for
socket-mount examples and the `--group-add` note for Docker on Linux.

### Changed
- Extend the PR-time fuzz smoke budget from 30s to 60s per target. The
30s window occasionally exceeded its deadline on shared CI runners
while a worker was still processing a slow input from the corpus,
surfacing as a spurious "context deadline exceeded" failure with no
crasher reproducer. The nightly fuzz workflow retains its longer
budgets and is unchanged.

### Fixed
- Bound the decompressed byte count walked by the per-layer tar reader in
`findFileInLayer`. Previously a crafted gzip stream (tiny compressed
Expand All @@ -18,14 +35,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
archive. Legitimate images are unaffected; malformed inputs return a
structured "reading layer tar" error instead of stalling.

### Changed
- Extend the PR-time fuzz smoke budget from 30s to 60s per target. The
30s window occasionally exceeded its deadline on shared CI runners
while a worker was still processing a slow input from the corpus,
surfacing as a spurious "context deadline exceeded" failure with no
crasher reproducer. The nightly fuzz workflow retains its longer
budgets and is unchanged.

## [v1.5.1] - 2026-07-06

Multi-engine polish: LayerX now honours the active Docker context and
Expand Down
81 changes: 81 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# syntax=docker/dockerfile:1.9
#
# Container image for layerx.
#
# layerx is a client to a running container engine's daemon socket, so a
# containerised layerx is only useful when that socket is bind-mounted in.
# See the "Install via container image" section of README.md for the exact
# `docker run` / `podman run` incantations, socket paths, and the group-access
# note for Docker on Linux.
#
# This Dockerfile does NOT build the Go binary. During a release, the binary
# produced by GoReleaser (bit-identical to the archives published on the
# GitHub release page) is copied in per-platform. This keeps the image and
# the native install paths on exactly the same commit / version / ldflags.
#
# Build context: the repository root, with a populated `dist/` directory from
# a prior `goreleaser release` (or `goreleaser build --snapshot`). Buildx sets
# TARGETOS and TARGETARCH per platform in the manifest list.

ARG BASE_IMAGE=gcr.io/distroless/static-debian12:nonroot
FROM --platform=$BUILDPLATFORM alpine:3.20 AS stage
ARG TARGETOS
ARG TARGETARCH
WORKDIR /src

# Copy the GoReleaser output for the target platform. The path pattern matches
# GoReleaser's default layout for `builds:` with no id override:
# dist/layerx_<goos>_<goarch>[_<goarm>|_<goamd64>]/layerx
# The `v1` (amd64 microarch) and `v8.0` (arm64 microarch) suffixes are the
# GoReleaser defaults; the wildcard tolerates future changes.
COPY dist/ /src/dist/
RUN set -eux; \
mkdir -p /out; \
src="$(find /src/dist -type d -name "layerx_${TARGETOS}_${TARGETARCH}*" -print -quit)"; \
if [ -z "$src" ] || [ ! -f "$src/layerx" ]; then \
echo "no layerx binary for ${TARGETOS}/${TARGETARCH} under /src/dist" >&2; \
find /src/dist -maxdepth 2 -type f >&2; \
exit 1; \
fi; \
install -m 0755 "$src/layerx" /out/layerx

FROM ${BASE_IMAGE}

# Default OCI image labels for a plain `docker build .` (local, no CI). During
# a release the workflow's docker/metadata-action supplies the full label set
# — including the concrete version, revision, and creation timestamp — and
# those take precedence over the defaults below. Left in the Dockerfile so
# `docker inspect` on a locally-built image still reports something useful.
ARG VERSION=dev
ARG REVISION=unknown
ARG CREATED=unknown
LABEL org.opencontainers.image.title="layerx" \
org.opencontainers.image.description="Terminal container image layer explorer — inspect Docker, Podman, and OCI images." \
org.opencontainers.image.url="https://github.com/deveshctl/layerx" \
org.opencontainers.image.source="https://github.com/deveshctl/layerx" \
org.opencontainers.image.documentation="https://github.com/deveshctl/layerx#readme" \
org.opencontainers.image.licenses="MIT" \
org.opencontainers.image.vendor="deveshctl" \
org.opencontainers.image.version="${VERSION}" \
org.opencontainers.image.revision="${REVISION}" \
org.opencontainers.image.created="${CREATED}"

COPY --from=stage /out/layerx /usr/local/bin/layerx

# distroless-static:nonroot ships a `nonroot` user (uid 65532) whose home is
# `/home/nonroot` and is writable by that uid. Set WORKDIR there so anything
# layerx writes with a relative path lands somewhere the process can actually
# write to — `/` (the base image default) is uid 0-only.
#
# No shell is present, so the exec-form ENTRYPOINT executes layerx directly
# — SIGINT / SIGTERM reach the TUI cleanly without a shell wrapper swallowing
# them.
# termenv (used by lipgloss) detects color support from these vars.
# distroless ships no terminfo database, so TERM alone is insufficient —
# COLORTERM=truecolor enables 24-bit hex colors defined in tui/styles.go.
ENV TERM=xterm-256color \
COLORTERM=truecolor

WORKDIR /home/nonroot
USER 65532:65532
ENTRYPOINT ["/usr/local/bin/layerx"]
58 changes: 58 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
- [Quick start](#quick-start)
- [Features](#features)
- [Install](#install)
- [Container image (Docker, Podman, any OCI runtime)](#container-image-docker-podman-any-oci-runtime)
- [Container engines — Docker, Podman, OCI archives](#container-engines)
- [Multi-platform images (`--platform`)](#multi-platform-images---platform)
- [CI mode & GitHub Actions](#ci-mode)
Expand Down Expand Up @@ -155,6 +156,59 @@ sudo rpm -i layerx_linux_arm64.rpm

Prebuilt binaries for Linux, macOS, and Windows (amd64 + arm64) on the [Releases page](https://github.com/deveshctl/layerx/releases). Replace `latest` with a specific tag (e.g. `v1.5.1`) in any URL above.

### Container image (Docker, Podman, any OCI runtime)

Every tagged release also publishes a multi-arch image (linux/amd64 + linux/arm64) to GitHub Container Registry:

```
ghcr.io/deveshctl/layerx:latest
ghcr.io/deveshctl/layerx:v1.5.1 # pin a specific version
```

LayerX is a client to a running container engine, so the image needs the host engine's socket bind-mounted in. It cannot inspect live images without one — but archive mode (bind-mount a `docker save` / OCI tar) works without any socket at all.

**Docker on Linux** — the socket is owned by `root:docker` on the host. The image runs as `nonroot` (uid 65532) and needs the host's numeric `docker` GID passed in as a supplementary group (the distroless image has no `/etc/group`, so `--group-add docker` by name won't resolve inside the container):

```bash
DOCKER_GID=$(getent group docker | cut -d: -f3 2>/dev/null || awk -F: '/^docker:/{print $3}' /etc/group)
docker run --rm -it \
--group-add "$DOCKER_GID" \
-v /var/run/docker.sock:/var/run/docker.sock \
ghcr.io/deveshctl/layerx:latest nginx:latest
```

(`getent` is glibc-only; the `awk` fallback covers Alpine and musl hosts.)

**Docker Desktop (macOS / Windows)** — the socket is proxied and no group is enforced, so the group flag is not needed:

```bash
docker run --rm -it \
-v /var/run/docker.sock:/var/run/docker.sock \
ghcr.io/deveshctl/layerx:latest nginx:latest
```

**Podman (rootless, Linux)** — mount the user's Podman socket at the container's Docker socket path. Podman's Docker-compat API means LayerX talks to it the same way as Docker:

```bash
podman run --rm -it \
-v "${XDG_RUNTIME_DIR}/podman/podman.sock:/var/run/docker.sock" \
ghcr.io/deveshctl/layerx:latest alpine:3
```

For rootful Podman on Linux the socket is at `/run/podman/podman.sock`; on macOS / Windows the Podman Machine socket path varies per install — resolve it with `podman info --format '{{.Host.RemoteSocket.Path}}'` and substitute it into the left side of the `-v` flag.

**Archive mode (no daemon)** — bind-mount the tarball read-only, no socket required:

```bash
docker run --rm -it \
-v "$PWD/alpine.tar:/tmp/alpine.tar:ro" \
ghcr.io/deveshctl/layerx:latest /tmp/alpine.tar
```

Trade-offs vs. the native binary: the image is a distroless base plus the layerx binary (a few MB of overhead — check the release page for the exact compressed size), it needs the `--group-add` step on Linux for Docker's `root:docker`-owned socket, and file-extraction (`x` key) writes into the container's filesystem — bind-mount an output directory if you want the file on the host. Native binaries are the smoother path for daily use; the container image shines in CI runners that already have an engine but no package manager, and for pinning the exact LayerX version alongside your other build tooling.

> **First release only:** GHCR packages default to private, so `docker pull` returns 401 until visibility is flipped to public — usually within a few hours of a new release landing.

### Build from source

Requires Go 1.26+:
Expand Down Expand Up @@ -478,6 +532,10 @@ Yes. `layerx ci IMAGE_OR_ARCHIVE` has three configurable thresholds (lowest effi

Yes — Windows binaries for amd64 and arm64 are published with every release, and Scoop is a supported install path. LayerX runs against Docker Desktop, Podman Desktop, or archive files directly. WSL is not required.

### Should I use the container image or the native binary?

Native binaries (Homebrew, Scoop, `.deb`, `.rpm`, direct download) are the smoother path for interactive daily use — they're smaller, launch instantly, and don't need any socket plumbing. The container image at `ghcr.io/deveshctl/layerx` is the right pick when you don't want to install anything on the host: CI runners that already have Docker, one-off inspections from a colleague's machine, or when you want to pin the exact LayerX version alongside your other build tooling. The container image still needs the engine's socket bind-mounted in (or a bind-mounted archive), so it's not usable in a fully-sandboxed environment where the daemon is off-limits.

### Is LayerX safe to run against untrusted images?

LayerX reads image tarballs, walks their file trees, and caps in-memory reads at 2 GiB per file to prevent a crafted tar entry from exhausting memory. It never executes anything from the image. That said, treat any container image as untrusted input: pair LayerX (which tells you *what's inside*) with a vulnerability scanner like Trivy or Grype (which tells you *whether what's inside has known CVEs*).
Expand Down
Loading