diff --git a/.github/workflows/pnpr-release-to-npm.yml b/.github/workflows/pnpr-release-to-npm.yml index 27b1eff0d1..822e0b4b09 100644 --- a/.github/workflows/pnpr-release-to-npm.yml +++ b/.github/workflows/pnpr-release-to-npm.yml @@ -2,8 +2,10 @@ name: Release @pnpm/pnpr # Manual-trigger only. Type the version to publish — the workflow patches # pnpr/npm/pnpr/package.json with it before generating per-platform -# packages and publishing. No git tag is created and no GitHub release -# asset is uploaded; npm is the authoritative artifact store. +# packages and publishing. It then builds and pushes a multi-arch Docker +# image (ghcr.io/pnpm/pnpr) from the same static musl binaries. No git tag +# is created and no GitHub release asset is uploaded; npm and GHCR are the +# authoritative artifact stores. on: workflow_dispatch: inputs: @@ -125,6 +127,9 @@ jobs: BIN_NAME=pnpr-${{ matrix.code-target }} mv target/${{ matrix.target }}/release/pnpr $BIN_NAME tar czf $BIN_NAME.tar.gz $BIN_NAME + # Pin the binary's checksum at build time so the Docker job can + # verify the artifact it stages into the image context. + shasum -a 256 $BIN_NAME > $BIN_NAME.sha256 - name: Attest build provenance uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 @@ -140,6 +145,7 @@ jobs: path: | *.zip *.tar.gz + *.sha256 publish: name: Publish @@ -212,3 +218,99 @@ jobs: for package in pnpr/npm/pnpr*; do pnpm publish "$package/" --tag latest --access public --provenance --no-git-checks done + + docker: + name: Docker + runs-on: ubuntu-latest + needs: + - build + - publish + permissions: + contents: read + packages: write + env: + IMAGE: ghcr.io/${{ github.repository_owner }}/pnpr + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Compute image tags + id: tags + # A version containing a hyphen (e.g. 0.2.3-rc.1) is a prerelease and + # must not move the mutable `latest` tag. + env: + VERSION: ${{ inputs.version }} + run: | + set -eu + if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then + echo "::error::Invalid version: '$VERSION' (expected semver like 0.2.3 or 0.2.3-rc.1)" + exit 1 + fi + tags="${IMAGE}:${VERSION}" + case "$VERSION" in + *-*) ;; + *) tags="${tags},${IMAGE}:latest" ;; + esac + echo "tags=$tags" >> "$GITHUB_OUTPUT" + + - name: Download Linux musl binaries + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: binaries-linux-*-musl + merge-multiple: true + + - name: Stage binaries for the build context + id: stage + # The static musl binaries match what was just published to npm. The + # Dockerfile selects one via the build's TARGETARCH (amd64 / arm64) and + # re-verifies it against the build-time checksum surfaced here. + # + # Extract into a throwaway directory and move only the expected, + # checksum-verified regular files into the build context, so a malformed + # archive cannot escape it (path traversal, symlinks) and overwrite the + # Dockerfile or the staged binaries before they are pushed to GHCR. + run: | + set -eu + rm -rf extracted + mkdir extracted + for f in pnpr-linux-*-musl.tar.gz; do + tar -xzf "$f" -C extracted --no-same-owner + done + ( cd extracted && sha256sum -c ../pnpr-linux-x64-musl.sha256 ../pnpr-linux-arm64-musl.sha256 ) + for pair in x64:amd64 arm64:arm64; do + bin="extracted/pnpr-linux-${pair%:*}-musl" + test -f "$bin" && test ! -L "$bin" + mv "$bin" "pnpr/docker/pnpr-${pair#*:}" + done + { + echo "sha_amd64=$(awk '{print $1}' pnpr-linux-x64-musl.sha256)" + echo "sha_arm64=$(awk '{print $1}' pnpr-linux-arm64-musl.sha256)" + } >> "$GITHUB_OUTPUT" + + - name: Set up QEMU + uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + + - name: Login to GHCR + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + with: + context: ./pnpr/docker + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.tags.outputs.tags }} + build-args: | + PNPR_VERSION=${{ inputs.version }} + PNPR_SHA256_AMD64=${{ steps.stage.outputs.sha_amd64 }} + PNPR_SHA256_ARM64=${{ steps.stage.outputs.sha_arm64 }} + provenance: mode=max + sbom: true diff --git a/pnpr/docker/Dockerfile b/pnpr/docker/Dockerfile new file mode 100644 index 0000000000..33873c1ab4 --- /dev/null +++ b/pnpr/docker/Dockerfile @@ -0,0 +1,56 @@ +# syntax=docker/dockerfile:1.7 + +# Refresh periodically: resolve with +# docker buildx imagetools inspect debian:stable-slim --format '{{.Manifest.Digest}}' +FROM debian:stable-slim@sha256:e51bfcd2226c480a5416730e0fa2c40df28b0da5ff562fc465202feeef2f1116 + +ARG PNPR_VERSION +ARG TARGETARCH +ARG PNPR_SHA256_AMD64 +ARG PNPR_SHA256_ARM64 + +# pnpr reaches upstream registries over HTTPS via rustls' platform +# verifier, which reads the system CA bundle, so ca-certificates is required. +RUN set -eu; \ + apt-get update; \ + apt-get install -y --no-install-recommends ca-certificates; \ + rm -rf /var/lib/apt/lists/*; \ + useradd --system --create-home --home-dir /home/pnpr --shell /usr/sbin/nologin pnpr + +# The build context stages the prebuilt static musl binary for each +# architecture as pnpr- (pnpr-amd64, pnpr-arm64). +COPY pnpr-${TARGETARCH} /usr/local/bin/pnpr + +# Verify the staged binary against the checksum pinned by the release job +# before trusting it. `pnpr --version` only confirms reported metadata, so +# it cannot stand in for an integrity check on the binary itself. +RUN set -eu; \ + test -n "$PNPR_VERSION"; \ + case "$TARGETARCH" in \ + amd64) expected_sha="$PNPR_SHA256_AMD64" ;; \ + arm64) expected_sha="$PNPR_SHA256_ARM64" ;; \ + *) echo "unsupported architecture: $TARGETARCH" >&2; exit 1 ;; \ + esac; \ + test -n "$expected_sha" || { echo "missing PNPR_SHA256_* build-arg for $TARGETARCH" >&2; exit 1; }; \ + echo "$expected_sha /usr/local/bin/pnpr" | sha256sum -c -; \ + chmod 0755 /usr/local/bin/pnpr; \ + installed="$(pnpr --version | awk '{print $NF}')"; \ + test "$installed" = "$PNPR_VERSION" || { \ + echo "pnpr version mismatch: expected $PNPR_VERSION, got $installed" >&2; \ + exit 1; \ + }; \ + mkdir -p /pnpr/storage /pnpr/cache; \ + chown -R pnpr:pnpr /pnpr + +USER pnpr +WORKDIR /pnpr + +# Persist published packages and the disposable upstream mirror. +VOLUME ["/pnpr/storage", "/pnpr/cache"] + +# pnpr's default port. Bind to 0.0.0.0 so the server is reachable from +# outside the container (the binary defaults to 127.0.0.1). +EXPOSE 7677 + +ENTRYPOINT ["pnpr"] +CMD ["--listen", "0.0.0.0:7677", "--storage", "/pnpr/storage", "--cache", "/pnpr/cache"] diff --git a/pnpr/docker/README.md b/pnpr/docker/README.md new file mode 100644 index 0000000000..e6c14a8d5d --- /dev/null +++ b/pnpr/docker/README.md @@ -0,0 +1,78 @@ +# pnpr Docker image + +Official image for [`pnpr`](../), the pnpm-compatible npm registry server, +published to GitHub Container Registry. + +```text +ghcr.io/pnpm/pnpr +``` + +Based on `debian:stable-slim` with the standalone `pnpr` binary (static musl +build, the same artifact published to npm). The container runs as a +non-root `pnpr` user and listens on port `7677`. + +## Tags + +| Tag | Meaning | +| ----------- | ------------------------------------------------------------ | +| `` | Exact, immutable (e.g. `0.2.3`). Includes prereleases. | +| `latest` | Most recent stable release. Not updated for prereleases. | + +## Supported platforms + +`linux/amd64`, `linux/arm64`. + +## Usage + +```sh +docker run --rm -p 7677:7677 \ + -v pnpr-storage:/pnpr/storage \ + ghcr.io/pnpm/pnpr:latest +``` + +The default command binds to `0.0.0.0:7677` and stores published packages in +`/pnpr/storage` and the disposable upstream mirror in `/pnpr/cache` (both +declared as volumes). To use a custom config, mount it and point `pnpr` at it: + +```sh +docker run --rm -p 7677:7677 \ + -v "$PWD/config.yaml:/pnpr/config.yaml:ro" \ + -v pnpr-storage:/pnpr/storage \ + ghcr.io/pnpm/pnpr:latest --listen 0.0.0.0:7677 --config /pnpr/config.yaml +``` + +Then point pnpm at it: + +```sh +pnpm config set registry http://localhost:7677 +``` + +## Build locally + +The build context expects the binary for each target architecture, staged as +`pnpr-amd64` / `pnpr-arm64`. Build one with `cross` (or `cargo` for the host +arch) and drop it in: + +The build verifies the binary against a SHA256 checksum before trusting it, +so pass the checksum for the architecture you're building: + +```sh +VERSION=0.2.3 +cross build -p pnpr --bin pnpr --release --target x86_64-unknown-linux-musl +cp target/x86_64-unknown-linux-musl/release/pnpr pnpr/docker/pnpr-amd64 +docker buildx build \ + --build-arg PNPR_VERSION=${VERSION} \ + --build-arg PNPR_SHA256_AMD64=$(shasum -a 256 pnpr/docker/pnpr-amd64 | awk '{print $1}') \ + --platform linux/amd64 \ + --load \ + -t pnpr-test ./pnpr/docker +docker run --rm pnpr-test --version +``` + +## Release + +Images are built and pushed by the `docker` job in +[`.github/workflows/pnpr-release-to-npm.yml`](../../.github/workflows/pnpr-release-to-npm.yml), +which runs after the npm packages are published. The build verifies each +staged binary against the SHA256 checksum pinned by the release job and fails +if `pnpr --version` in the image doesn't match the `PNPR_VERSION` build-arg.