From a9385bbc22b7f4b3a06132ebaf8094bf7ba7cb1a Mon Sep 17 00:00:00 2001 From: deveshctl Date: Mon, 6 Jul 2026 17:10:35 +0530 Subject: [PATCH 1/3] ci: publish multi-arch container image to GHCR on release Every tagged release now publishes a linux/amd64 + linux/arm64 image to ghcr.io/deveshctl/layerx alongside the existing archives, deb/rpm packages, Homebrew tap, and Scoop bucket. The image reuses the exact binary produced by GoReleaser so `layerx --version` stays identical across every install path. - Dockerfile: multi-stage build on gcr.io/distroless/static-debian12 :nonroot. Runs as uid 65532, WORKDIR /home/nonroot, exec-form ENTRYPOINT so signals reach the TUI cleanly. Per-arch binary picked from dist/layerx_linux_*/layerx via buildx TARGETARCH. - release.yml: new ghcr job (needs: goreleaser) that downloads the goreleaser-built binaries as a workflow artifact, sets up QEMU + Buildx, logs in to ghcr.io with GITHUB_TOKEN, computes tags/labels via docker/metadata-action, and pushes via docker/build-push-action with GHA cache. Tag scheme: latest, vX.Y.Z, vX.Y, vX (pre-release tags skip latest + the major/minor rollups). workflow_dispatch trigger runs the full build with push: false for dry-run testing. - README: new 'Container image' install section with Docker, Podman, and archive-mode examples, plus the --group-add note for reaching Docker's root:docker socket from the nonroot user inside a distroless image (which has no /etc/group to resolve the name). - docs/releasing.md: new file documenting the release flow and the one-time public-visibility flip required after the first GHCR publish. --- .github/workflows/release.yml | 150 +++++++++++++++++++++++++++++++++- CHANGELOG.md | 25 ++++-- Dockerfile | 75 +++++++++++++++++ README.md | 58 +++++++++++++ docs/releasing.md | 68 +++++++++++++++ 5 files changed, 367 insertions(+), 9 deletions(-) create mode 100644 Dockerfile create mode 100644 docs/releasing.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5fc70b3..27ede3b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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. @@ -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: @@ -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 @@ -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 @@ -86,3 +127,110 @@ 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. See docs/releasing.md for the exact UI path. + 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b542fc..3f6261d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ae3cdb3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,75 @@ +# 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__[_|_]/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. +WORKDIR /home/nonroot +USER 65532:65532 +ENTRYPOINT ["/usr/local/bin/layerx"] diff --git a/README.md b/README.md index 304b1c0..1eec910 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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. After the first successful push, the maintainer flips the package to public visibility from the package page (see [docs/releasing.md](docs/releasing.md#first-time-ghcr-setup)). Once public, `docker pull` needs no authentication. + ### Build from source Requires Go 1.26+: @@ -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*). diff --git a/docs/releasing.md b/docs/releasing.md new file mode 100644 index 0000000..23704a8 --- /dev/null +++ b/docs/releasing.md @@ -0,0 +1,68 @@ +# Releasing LayerX + +The release pipeline is driven from a single git tag. Everything downstream +— archives, deb/rpm packages, Homebrew tap, Scoop bucket, cosign signatures, +SBOMs, SLSA provenance, and the GHCR container image — is produced by +`.github/workflows/release.yml` when the tag lands. + +## Cutting a release + +1. Move the `[Unreleased]` section in `CHANGELOG.md` to a dated + `[vX.Y.Z] - YYYY-MM-DD` heading. Update the README version badge if the + feature set changed. +2. Open a `chore/release-vX.Y.Z` branch, PR it, review, squash-merge to + `main`. +3. Tag the squash-merge commit on `main`: + ```bash + git tag -a vX.Y.Z -m "vX.Y.Z" + git push origin vX.Y.Z + ``` + The tag must exactly match the most recent `[vX.Y.Z]` heading in + `CHANGELOG.md`. + +Pushing the tag triggers the release workflow. `goreleaser` runs first; +once it completes, `provenance` and `ghcr` fan out in parallel: + +- **`goreleaser`** — cross-compiles Linux / macOS / Windows binaries + (amd64 + arm64), builds `.tar.gz` / `.zip` archives + `.deb` / `.rpm` + packages, writes the Homebrew formula and Scoop manifest to their + respective repos, signs `checksums.txt` with cosign (keyless, GitHub + OIDC), attaches an SPDX SBOM per archive, and cuts the GitHub release. +- **`provenance`** — the SLSA reusable workflow issues a Build Level 3 + provenance attestation over the `checksums.txt` subjects and attaches + it to the release. +- **`ghcr`** — packages the same Linux binaries (downloaded as a workflow + artifact from `goreleaser`) into a multi-arch container image and + pushes it to `ghcr.io/deveshctl/layerx`. Tags: `latest`, `vX.Y.Z`, + `vX.Y`, `vX`. Pre-release tags (`vX.Y.Z-rc.N`) get only the concrete + version tag — never `latest`. + +## First-time GHCR setup + +GHCR packages default to **private**. On the very first push to +`ghcr.io/deveshctl/layerx`, flip it to public so `docker pull` needs no +authentication: + +1. Open the package page at + `https://github.com/deveshctl?tab=packages` (or click the **Packages** + widget on the repository's landing page). GHCR packages are surfaced + on the user / organization profile, not under the repository's + **Settings** tab. +2. Click the `layerx` package, then **Package settings** in the right + sidebar of the package page. +3. Scroll to **Danger Zone** → **Change package visibility** → **Public**. + +This is a one-time step. Subsequent releases inherit the visibility. + +## Dry-running the container image build + +Push a branch and trigger the release workflow manually via +`workflow_dispatch` in the Actions UI. GoReleaser runs in `--snapshot +--skip=publish` mode (no release cut, no signatures), and the `ghcr` job +builds the multi-platform image with `push: false`. Any Dockerfile or +buildx configuration issue surfaces without publishing anything. + +## Verifying releases + +See [SECURITY.md](../SECURITY.md) for the cosign / slsa-verifier commands +users run to verify a downloaded archive or checksum file. From 35c317d87e8270a41bf3eaf3ed6e77929fd9030d Mon Sep 17 00:00:00 2001 From: deveshctl Date: Mon, 6 Jul 2026 18:21:47 +0530 Subject: [PATCH 2/3] docs: drop maintainer runbook from public tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/releasing.md is a maintainer-only runbook — its contents (cutting a tag, flipping GHCR visibility, dry-running the workflow) are only actionable by someone with push access to this repo, so shipping it in the public tree and linking to it from the user-facing README sends readers to a page that is not for them. Remove the file and the README link. Keep the one-sentence user-visible note about the first-release private-GHCR behaviour in the README, but drop the dead link to the removed doc. Update the workflow comment that pointed at the deleted file to reference the gh CLI command directly. CONTRIBUTING.md still contains a reference to docs/releasing.md; that link predates this PR and points at a file that also did not exist on main before this change, so it is a pre-existing broken link and left untouched here. --- .github/workflows/release.yml | 3 +- README.md | 2 +- docs/releasing.md | 68 ----------------------------------- 3 files changed, 3 insertions(+), 70 deletions(-) delete mode 100644 docs/releasing.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 27ede3b..c35f70f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -135,7 +135,8 @@ jobs: # `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. See docs/releasing.md for the exact UI path. + # 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: diff --git a/README.md b/README.md index 1eec910..fe7fe5c 100644 --- a/README.md +++ b/README.md @@ -207,7 +207,7 @@ docker run --rm -it \ 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. After the first successful push, the maintainer flips the package to public visibility from the package page (see [docs/releasing.md](docs/releasing.md#first-time-ghcr-setup)). Once public, `docker pull` needs no authentication. +> **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 diff --git a/docs/releasing.md b/docs/releasing.md deleted file mode 100644 index 23704a8..0000000 --- a/docs/releasing.md +++ /dev/null @@ -1,68 +0,0 @@ -# Releasing LayerX - -The release pipeline is driven from a single git tag. Everything downstream -— archives, deb/rpm packages, Homebrew tap, Scoop bucket, cosign signatures, -SBOMs, SLSA provenance, and the GHCR container image — is produced by -`.github/workflows/release.yml` when the tag lands. - -## Cutting a release - -1. Move the `[Unreleased]` section in `CHANGELOG.md` to a dated - `[vX.Y.Z] - YYYY-MM-DD` heading. Update the README version badge if the - feature set changed. -2. Open a `chore/release-vX.Y.Z` branch, PR it, review, squash-merge to - `main`. -3. Tag the squash-merge commit on `main`: - ```bash - git tag -a vX.Y.Z -m "vX.Y.Z" - git push origin vX.Y.Z - ``` - The tag must exactly match the most recent `[vX.Y.Z]` heading in - `CHANGELOG.md`. - -Pushing the tag triggers the release workflow. `goreleaser` runs first; -once it completes, `provenance` and `ghcr` fan out in parallel: - -- **`goreleaser`** — cross-compiles Linux / macOS / Windows binaries - (amd64 + arm64), builds `.tar.gz` / `.zip` archives + `.deb` / `.rpm` - packages, writes the Homebrew formula and Scoop manifest to their - respective repos, signs `checksums.txt` with cosign (keyless, GitHub - OIDC), attaches an SPDX SBOM per archive, and cuts the GitHub release. -- **`provenance`** — the SLSA reusable workflow issues a Build Level 3 - provenance attestation over the `checksums.txt` subjects and attaches - it to the release. -- **`ghcr`** — packages the same Linux binaries (downloaded as a workflow - artifact from `goreleaser`) into a multi-arch container image and - pushes it to `ghcr.io/deveshctl/layerx`. Tags: `latest`, `vX.Y.Z`, - `vX.Y`, `vX`. Pre-release tags (`vX.Y.Z-rc.N`) get only the concrete - version tag — never `latest`. - -## First-time GHCR setup - -GHCR packages default to **private**. On the very first push to -`ghcr.io/deveshctl/layerx`, flip it to public so `docker pull` needs no -authentication: - -1. Open the package page at - `https://github.com/deveshctl?tab=packages` (or click the **Packages** - widget on the repository's landing page). GHCR packages are surfaced - on the user / organization profile, not under the repository's - **Settings** tab. -2. Click the `layerx` package, then **Package settings** in the right - sidebar of the package page. -3. Scroll to **Danger Zone** → **Change package visibility** → **Public**. - -This is a one-time step. Subsequent releases inherit the visibility. - -## Dry-running the container image build - -Push a branch and trigger the release workflow manually via -`workflow_dispatch` in the Actions UI. GoReleaser runs in `--snapshot ---skip=publish` mode (no release cut, no signatures), and the `ghcr` job -builds the multi-platform image with `push: false`. Any Dockerfile or -buildx configuration issue surfaces without publishing anything. - -## Verifying releases - -See [SECURITY.md](../SECURITY.md) for the cosign / slsa-verifier commands -users run to verify a downloaded archive or checksum file. From 24c96fb6c2c063208d96b2affecbe4aaaf4658d4 Mon Sep 17 00:00:00 2001 From: deveshctl Date: Tue, 7 Jul 2026 17:28:04 +0530 Subject: [PATCH 3/3] fix(docker): set TERM and COLORTERM for TUI colours in distroless image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit distroless/static ships no terminfo database. Without TERM set, termenv (used by lipgloss v2) sees a dumb terminal and disables all colour output. COLORTERM=truecolor is required to activate 24-bit hex colour codes — without it, the palette defined in tui/styles.go gets quantized or dropped. --- Dockerfile | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Dockerfile b/Dockerfile index ae3cdb3..965bf6c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -70,6 +70,12 @@ COPY --from=stage /out/layerx /usr/local/bin/layerx # 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"]