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
9 changes: 9 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# An allowlist, not a denylist. The build context is exactly the release
# binaries the Dockerfile copies: no source, no fixtures, no .git.
#
# Written this way round on purpose: `dist/` is gitignored, so a
# .dockerignore modelled on .gitignore would exclude the one directory
# the build actually needs and the failure would read as a missing
# artifact rather than a mistake in this file.
*
!dist/piace-*
115 changes: 112 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@ name: CI

# Pull requests run the test job. A merge to main runs the same test job
# and then, only if it passed, builds the release artifacts and uploads
# them. A `v*` tag runs both and then publishes a GitHub Release from the
# artifacts the build job already verified.
# them. A `v*` tag runs both, publishes a GitHub Release from the
# artifacts the build job already verified, and then pushes the container
# image built from those same artifacts to Docker Hub.
#
# All three live in one workflow so `needs:` can gate each stage on the
# All four live in one workflow so `needs:` can gate each stage on the
# one before it — a cross-workflow dependency would need `workflow_run`,
# which reports its status against the wrong commit and is easy to
# misread. That gating is the point on a tag: a release is published only
Expand Down Expand Up @@ -283,3 +284,111 @@ jobs:
--notes-file release-notes.md \
"${flags[@]}" \
dist/piace-* dist/SHA256SUMS

image:
name: publish container image
# Both: `build` for the verified artifacts the image is assembled
# from, `release` so Docker Hub follows the GitHub Release rather than
# racing it. The GitHub Release stays the primary artifact: if the
# push here fails, a release is already published and re-running this
# job alone finishes the job.
needs: [build, release]
if: github.ref_type == 'tag'
runs-on: ubuntu-latest
steps:
# Only the Dockerfile and .dockerignore are needed here; the
# binaries come from the build job, downloaded next.
- uses: actions/checkout@v4

- name: Download the verified artifacts
uses: actions/download-artifact@v4
with:
name: piace-${{ needs.build.outputs.version }}
path: dist

- name: Re-verify the checksum manifest
working-directory: dist
run: sha256sum --check SHA256SUMS

# Nothing runs in the target architecture during the build (every
# image layer is a COPY of an already cross-compiled binary), so
# buildx alone covers linux/arm64 and no QEMU setup is needed.
- uses: docker/setup-buildx-action@v3

# DOCKERHUB_TOKEN is a Docker Hub access token scoped to
# read/write, not the account password. See docs/release.md.
#
# Before the first build, not just before the push: the Dockerfile's
# `# syntax=` line makes BuildKit pull its frontend image from
# Docker Hub, and an anonymous pull from a shared GitHub runner IP
# is the kind of thing that hits a rate limit and fails a release
# for reasons that have nothing to do with this repository.
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}

# Built and loaded locally first, so the assertion below runs
# against the image that is about to be pushed rather than after the
# fact. The multi-platform build that follows reuses this build's
# cache, so the amd64 half of it is near-free.
- name: Build the amd64 image
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64
load: true
push: false
build-args: VERSION=${{ needs.build.outputs.version }}
tags: piace:verify
provenance: false

# The same assertion the build job makes about the bare binary, made
# again about the packaged one: it catches a COPY that picked up the
# wrong artifact, and an image whose binary lost its executable bit
# in transit through actions/upload-artifact.
- name: Confirm the image reports the version it was built from
env:
VERSION: ${{ needs.build.outputs.version }}
run: |
reported="$(docker run --rm piace:verify version)"
echo "$reported"
if [ "$reported" != "piace ${VERSION}" ]; then
echo "::error::image reports '$reported', expected 'piace ${VERSION}'"
exit 1
fi

# `latest` moves only for a full release. A prerelease that took it
# would hand every `docker run example42/piace` a version nobody
# asked for. Composed here rather than inline in `tags:` so an empty
# line never reaches the action.
- name: Compose the image tags
id: tags
env:
VERSION: ${{ needs.build.outputs.version }}
PRERELEASE: ${{ needs.build.outputs.prerelease }}
run: |
{
echo 'tags<<TAGS'
echo "example42/piace:${VERSION}"
if [ "$PRERELEASE" = "false" ]; then
echo "example42/piace:latest"
fi
echo 'TAGS'
} >> "$GITHUB_OUTPUT"

# provenance: false keeps the pushed manifest list to the two
# platforms it actually carries. The default attaches a provenance
# attestation as a third manifest entry, which Docker Hub renders as
# an `unknown/unknown` architecture beside the real ones.
- name: Build and push the multi-platform image
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
build-args: VERSION=${{ needs.build.outputs.version }}
tags: ${{ steps.tags.outputs.tags }}
labels: org.opencontainers.image.revision=${{ github.sha }}
provenance: false
21 changes: 20 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,23 @@ All notable changes to PIACE are recorded here. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project
follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.2.1] - 2026-08-30

### Added

- **A published container image**: cutting a `v*` tag now also pushes
`example42/piace:<version>` to Docker Hub, as a `linux/amd64` +
`linux/arm64` manifest list; `:latest` moves with every non-prerelease. The
image is the release binary the workflow already verified, copied onto
`distroless/static`, so what a `docker pull` runs is the bytes `SHA256SUMS`
certifies. It runs as a non-root user out of `/work`: see the README's
Install section for the mount and `--user` flags.
- **[docs/ci.md](docs/ci.md) and [examples/ci/](examples/ci/)**: copy-ready
GitHub Actions and GitLab CI pipelines, where each configuration file belongs
in a control repository, and what changes when the runner is one you do not
control. Two jobs by design, so the catalog-reader identity and the inference
token are never held by the same job.

## [0.2.0] - 2026-08-29

### Added
Expand Down Expand Up @@ -156,5 +173,7 @@ than what changed.
The last two are recorded as skipped tests carrying their confirmation
procedures in `cmd/piace/acceptance_assumptions_test.go`.

[Unreleased]: https://github.com/example42/piace/compare/v0.1.0...HEAD
[Unreleased]: https://github.com/example42/piace/compare/v0.2.1...HEAD
[0.2.1]: https://github.com/example42/piace/releases/tag/v0.2.1
[0.2.0]: https://github.com/example42/piace/releases/tag/v0.2.0
[0.1.0]: https://github.com/example42/piace/releases/tag/v0.1.0
57 changes: 57 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# syntax=docker/dockerfile:1
#
# The PIACE container image: one statically linked binary on a base that
# carries nothing but a CA bundle, /etc/passwd and /tmp.
#
# This image is assembled from the artifacts `scripts/build-release.sh`
# already produced, not from a `golang` builder stage. The release job in
# .github/workflows/ci.yml publishes the bytes the build job verified
# rather than rebuilding, and the image holds to the same rule: what a
# `docker pull` runs is byte-for-byte what the GitHub Release publishes
# and what SHA256SUMS certifies. A builder stage would produce a second,
# unchecked binary that only looks identical: a different Go patch level
# in the base image is enough to make it differ.
#
# It also means nothing ever executes in the target architecture during
# the build: the arm64 image is a `COPY` of a cross-compiled binary, so
# multi-platform builds need buildx but no QEMU emulation.
#
# Build it by hand with:
#
# scripts/build-release.sh 1.0.0
# docker build --build-arg VERSION=1.0.0 -t piace:1.0.0 .

# distroless/static rather than scratch: `piace explain` reaches an
# OpenAI-compatible inference service over ordinary TLS (the compiler and
# PuppetDB transports carry their own CA bundle from the services file,
# but the inference client uses Go's default transport), so the image
# needs system root certificates or every `explain` run fails to verify.
# The `nonroot` variant runs as uid 65532; see docs/release.md for the
# `--user` flag that makes report output land in a bind mount.
FROM gcr.io/distroless/static-debian12:nonroot

# Both are consumed by the COPY below. TARGETARCH is a predefined build
# argument, but a stage sees it only after declaring it. Undeclared, it
# expands to the empty string and the COPY silently looks for the wrong
# file.
ARG VERSION
ARG TARGETARCH

# --chmod because the artifacts arrive in CI through actions/upload-artifact,
# which does not preserve the executable bit. Without it the image builds
# clean and fails at `docker run` with "permission denied".
COPY --chmod=0755 dist/piace-${VERSION}-linux-${TARGETARCH} /usr/local/bin/piace

# PIACE reads its targets, services and snapshot files from the working
# directory and writes its reports back to it, so the whole interface is
# one bind mount here.
WORKDIR /work

LABEL org.opencontainers.image.title="piace" \
org.opencontainers.image.description="Puppet Impact Assessment & Change Explorer" \
org.opencontainers.image.source="https://github.com/example42/piace" \
org.opencontainers.image.documentation="https://github.com/example42/piace/blob/main/README.md" \
org.opencontainers.image.vendor="example42" \
org.opencontainers.image.version="${VERSION}"

ENTRYPOINT ["/usr/local/bin/piace"]
23 changes: 22 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,23 @@ go build -o piace ./cmd/piace
Go 1.22+, no other dependency. Release artifacts, checksums and signature
verification: [docs/release.md](docs/release.md).

Or run the published image, which is the same release binary on a
distroless base. It runs as a non-root user and works out of `/work`, so
mount your workspace there and pass your own uid; without both, writing a
report into the mount fails with a permission error:

```sh
docker run --rm \
--user "$(id -u):$(id -g)" \
--volume "$PWD:/work" \
example42/piace:latest \
compare --targets targets.yaml --services services.yaml --html-out report.html
```

Every path in `targets.yaml` and `services.yaml` (CA bundle, client
certificate, key, snapshots, outputs) is resolved inside the container,
so keep them under the mount.

## Quick start

1. **Write `services.yaml`** — where your compiler and PuppetDB are, and the
Expand All @@ -48,7 +65,9 @@ piace compare --targets targets.yaml --services services.yaml \
```

The text report goes to stdout; the exit code tells CI what happened. See
[Exit codes](#exit-codes).
[Exit codes](#exit-codes), and [docs/ci.md](docs/ci.md) for the pipeline
around it: file layout, credential handling, and copy-ready GitHub Actions and
GitLab CI jobs.

---

Expand Down Expand Up @@ -636,4 +655,6 @@ intact, inside the fence.
- [docs/development.md](docs/development.md) — building, testing, CI, releases,
package layout, project status
- [examples/](examples/) — loadable sample configuration for every usage pattern
- [docs/ci.md](docs/ci.md): running PIACE in CI, pipeline shape, where each
file belongs, and credentials on a runner you do not control
- [docs/release.md](docs/release.md) — release artifacts and verification
Loading
Loading