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
182 changes: 169 additions & 13 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,32 @@ 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. Both live in one workflow so `needs:` can gate the build on the
# tests — a cross-workflow dependency would need `workflow_run`, which
# reports its status against the wrong commit and is easy to misread.
# them. A `v*` tag runs both and then publishes a GitHub Release from the
# artifacts the build job already verified.
#
# All three 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
# from a commit whose tests passed on every matrix leg.
on:
pull_request:
push:
branches: [main]
tags: ['v*']

# Least privilege. Nothing here writes to the repository; add
# `contents: write` only if a job is later taught to publish a Release.
# Least privilege by default. Only the release job writes to the
# repository, and it raises its own permission locally rather than
# granting it to every job here.
permissions:
contents: read

# A new push to the same branch supersedes the previous run. Runs on main
# are never cancelled, so every merged commit keeps its artifacts.
# A new push to the same branch supersedes the previous run. Only pull
# requests are cancelled: a merged commit keeps its artifacts, and
# cancelling a tag run would leave a published tag with no release.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
test:
Expand Down Expand Up @@ -80,6 +88,12 @@ jobs:
name: build release artifacts
needs: test
runs-on: ubuntu-latest
# Consumed by the release job, so it publishes the version this job
# actually stamped rather than re-deriving it from the ref and
# risking a disagreement between the two.
outputs:
version: ${{ steps.version.outputs.version }}
prerelease: ${{ steps.version.outputs.prerelease }}
steps:
- uses: actions/checkout@v4

Expand All @@ -88,12 +102,39 @@ jobs:
go-version-file: go.mod
cache-dependency-path: go.sum

# A tag builds the version it names; anything else builds a
# commit-stamped development version that can never be mistaken for
# a release. docs/release.md's artifact names carry no `v`, so the
# prefix is stripped here rather than taught to the build script,
# which stays a plain `VERSION` interface usable by hand.
- name: Resolve the version to stamp
id: version
run: |
if [ "$GITHUB_REF_TYPE" = "tag" ]; then
version="${GITHUB_REF_NAME#v}"
# A malformed tag has to fail here. Downstream it becomes an
# artifact filename and the -X main.toolVersion stamp, where
# `piace-1.0-linux-amd64` would be published looking every bit
# as deliberate as a correct one.
if ! printf '%s' "$version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$'; then
echo "::error::tag $GITHUB_REF_NAME is not vMAJOR.MINOR.PATCH[-prerelease]"
exit 1
fi
case "$version" in *-*) prerelease=true ;; *) prerelease=false ;; esac
else
version="main-${GITHUB_SHA::7}"
prerelease=false
fi
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "prerelease=$prerelease" >> "$GITHUB_OUTPUT"
echo "building version $version"

# Every artifact is CGO-free (requirements.md 12.1), so one Linux
# runner cross-compiles the whole supported matrix. Running this on
# pull requests too means a broken release script is found in review
# rather than at release time.
- name: Build artifacts and checksum manifest
run: bash scripts/build-release.sh "main-${GITHUB_SHA::7}"
run: bash scripts/build-release.sh "${{ steps.version.outputs.version }}"

# docs/release.md tells a consumer to verify the manifest before
# installing. Running that same check here proves the manifest CI
Expand All @@ -114,16 +155,131 @@ jobs:
|| { echo "::error::$binary is not statically linked"; exit 1; }
done

# Uploaded only for main: a pull request has already had the build
# Ties the artifact back to the tag. -X main.toolVersion is what
# `piace version` prints and what every result document records as
# its invocation metadata, so a mis-stamped binary would misreport
# itself in every report it ever produced — and the checksum
# manifest would happily certify it.
- name: Confirm the binary reports the version it was stamped with
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
reported="$(./dist/piace-${VERSION}-linux-amd64 version)"
echo "$reported"
if [ "$reported" != "piace ${VERSION}" ]; then
echo "::error::binary reports '$reported', expected 'piace ${VERSION}'"
exit 1
fi

# Uploaded for pushes only: a pull request has already had the build
# validated above, and does not need artifacts accumulating against
# the repository's storage.
# the repository's storage. On a tag this is also the handoff to the
# release job, which publishes these exact bytes rather than
# building its own.
- name: Upload artifacts
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
if: github.event_name == 'push'
uses: actions/upload-artifact@v4
with:
name: piace-main-${{ github.sha }}
name: piace-${{ steps.version.outputs.version }}
path: |
dist/piace-*
dist/SHA256SUMS
retention-days: 30
if-no-files-found: error

release:
name: publish release
needs: build
# github.ref_type is 'tag' only under the tags: ['v*'] trigger above,
# so a push to main builds and uploads as before and stops there.
if: github.ref_type == 'tag'
runs-on: ubuntu-latest
# The only job in this workflow that writes to the repository, and the
# only one holding the permission to. Granting it at the workflow
# level would hand it to the test job as well, which runs the code
# under review.
permissions:
contents: write
steps:
# The published bytes are the ones the build job already checked:
# manifest verified, static linking confirmed, version stamp
# asserted. Rebuilding here would publish artifacts that nothing had
# checked, and a compiler or toolchain difference between the two
# jobs would be invisible.
- name: Download the verified artifacts
uses: actions/download-artifact@v4
with:
name: piace-${{ needs.build.outputs.version }}
path: dist

# Re-verified after the round trip through artifact upload and
# download, so a truncated or corrupted transfer fails here rather
# than reaching a consumer who trusts the manifest.
- name: Re-verify the checksum manifest
working-directory: dist
run: sha256sum --check SHA256SUMS

# The notes state plainly what this release does and does not prove.
# A checksum manifest published beside its own artifacts attests to
# integrity, never to origin: anyone who could replace the binaries
# could replace SHA256SUMS with them. Only the detached signature
# closes that gap, and this workflow holds no signing key, so the
# release says so instead of leaving a consumer to follow a
# verification step that cannot yet succeed.
- name: Compose the release notes
env:
VERSION: ${{ needs.build.outputs.version }}
run: |
{
echo "Statically linked, CGO-free binaries for linux/amd64, linux/arm64,"
echo "darwin/amd64 and darwin/arm64."
echo
echo "## Verifying this download"
echo
echo '```sh'
echo "sha256sum --check --ignore-missing SHA256SUMS # shasum -a 256 on macOS"
echo "./piace-${VERSION}-<os>-<arch> version"
echo '```'
echo
echo "\`SHA256SUMS.asc\` — the detached OpenPGP signature over the manifest — is"
echo "signed and attached separately after publication; this workflow holds no"
echo "signing key. Until it appears, the checksums above show only that a download"
echo "is intact, not where it came from. See \`docs/release.md\` for the full"
echo "procedure and the signing key fingerprint."
echo
echo "## SHA256SUMS"
echo
echo '```'
cat dist/SHA256SUMS
echo '```'
} > release-notes.md
cat release-notes.md

# gh resolves the repository from GH_REPO, so this job needs no
# checkout: the only inputs are the downloaded artifacts and the
# notes composed above. The tag already exists — pushing it is what
# triggered the run — so gh attaches the release to it rather than
# creating one.
#
# Re-running this job after a release already exists fails, and is
# meant to. The alternative — falling back to `gh release upload
# --clobber` — would quietly overwrite the assets of a release
# people may already have downloaded, to rescue a case (a partial
# publish) that is rarer than the case it endangers. Delete the
# incomplete release and re-run if that happens.
- name: Publish the release
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
VERSION: ${{ needs.build.outputs.version }}
PRERELEASE: ${{ needs.build.outputs.prerelease }}
run: |
flags=()
if [ "$PRERELEASE" = "true" ]; then
flags+=(--prerelease)
fi
gh release create "$GITHUB_REF_NAME" \
--title "piace $VERSION" \
--notes-file release-notes.md \
"${flags[@]}" \
dist/piace-* dist/SHA256SUMS
32 changes: 32 additions & 0 deletions .kiro/specs/piace/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,38 @@ inlined CSS/JavaScript only; it makes target failure, v3 warning, exclusions,
and final outcome visible without network access. HTML, text, and JSON derive
from the same redacted projection, preventing format drift or secret exposure.

### 9.1 Display policy

The three formats show the same document at three levels of detail, decided
through shared helpers so they cannot drift apart in what a value says. JSON
encodes the complete document and takes no options. HTML is complete too and
uses disclosure rather than omission: resource changes, edge changes, aggregate
groups, and each estimate's PQL, request options and full certname list are on
the page inside closed `<details>`. Every list of rows is closed and every
summary carries its count, so the page a reader lands on is an index of the run
— outcome, reasons, tally, and one line per target with a counted chip per
section — and one click reaches any of it. What stays outside every disclosure
is anything requirement 8.5 requires visibly marked (retrieval and compilation
failures, the v3 warning) and requirement 9.3's estimate label and note. Only
the text report omits, because a CI log is a linear read with nothing to
expand — it drops edge changes and each estimate's query mechanics, and caps an
estimate's certname sample unless `--impact-nodes` is passed. Requirements 5.3,
6.5, 7.4, 8.2, and 9.4 are discharged by JSON, and visibly by HTML as well.

Two invariants keep this safe rather than lossy. Every section header counts
what it actually displays, not what the document holds. And a target whose only
differences are edges — `has_difference` true, no resource change — is never
rendered as unchanged: HTML shows the edges, and the text report prints an
explicit note, so no report reads as "nothing changed" on a run that exits
non-zero (requirements 10.2, 10.5).

Requirement 8.3's "no HTTP server, a CDN, network access, or sibling assets"
also rules out webfonts and image files, so the HTML report uses system font
stacks with declared fallbacks and draws its disclosure markers in CSS. It
commits to one light palette rather than following the reader's system theme:
a review artifact is shared, printed, and pasted into tickets, and a single
appearance is a single thing to verify.

## 10. Error taxonomy and outcomes

PIACE records all target-local problems with an operation, safe reason, and
Expand Down
118 changes: 118 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
# Changelog

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).

## [Unreleased]

## [0.1.0] - 2026-08-28

First release: the whole tool, so this entry describes what it does rather
than what changed.

### Comparison

- `piace compare` compares each target's **baseline catalog** (PuppetDB's
latest, or a local snapshot) against a **candidate catalog** compiled by an
existing Puppet Server or OpenVox compiler, and reports a per-node diff, a
cross-node aggregate view, and an optional PuppetDB-backed impact estimate.
- Deterministic semantic normalization: exact `Type[title]` identities with no
case folding, a canonical value domain with exact-decimal numbers, and
resources and edges sorted before comparison and serialization.
- Generated catalog noise is excluded before comparison — tags, source
file/line, `exported`, `aliases`, and the `alias` parameter the PuppetDB
terminus injects into a stored catalog.
- Four change kinds: resource added, resource removed, parameter changed, and
dependency-graph edge added/removed. A target whose only differences are
edges is still reported as changed.
- Managed `File` content evidence without content disclosure: inline `content`
digests, an authoritative compiled checksum, compiler retrieval of a
`source` reference, and the explicit `reference_changed` /
`content_indeterminate` states when bytes cannot be compared. No managed
file bytes reach any output, log, PQL query, or aggregate state.
- Configurable per-target exclusions (`Type[title]`, case-sensitive glob) and
redaction selectors, applied after equality so redaction cannot alter a
comparison.
- Impact estimates report only that a node's latest *stored* catalog contains
the exact `Type[title]`, bounded by a configured timeout and result limit,
and labelled as such in every format.

### Catalog APIs

- `catalog_api: v4` is the supported path. Every request carries
`persistence: {facts: false, catalog: false}`, so a comparison leaves the
target's stored factset and catalog untouched, and sends the target's own
trusted facts.
- `catalog_api: v3` is a degraded path, with a non-suppressible trusted-fact
warning in every output format, and an opt-in, never implicit, v4→v3
fallback.

### Snapshots

- `piace capture facts` and `piace capture catalog` write PIACE envelopes —
format version, target identity, source, capture timestamp, SHA-256 payload
checksum, and a catalog's requested environment, compiler API version and
input factset identity — atomically, at `0600`, never overwriting without
`--replace`. Every field is validated on reuse.

### Reports

- Three formats from one redacted result document, so they cannot disagree:
**text** for a CI log (the only format that omits anything), **JSON** as the
complete versioned record, and **HTML** as a complete, self-contained page
with no JavaScript, webfonts, or images.
- The HTML report is an index of the run: every list of rows sits in a closed
disclosure whose heading counts what it holds. Failures, the v3 warning and
the outcome badges never collapse.

### Outcomes

- Exit codes `0` (clean / differences allowed), `10`
(policy-disallowed difference), `20` (compilation failure) and `30`
(operational error), with documented precedence. A run is never `clean`
while any target has an unreported retrieval, compilation, normalization or
content-verification failure.

### Transport and secrecy

- Independent hardened mTLS clients per service, with bounded timeouts,
response size limits, and no redirect following.
- Private keys, certificate material and authorization headers never reach a
diagnostic, report or log.
- `--debug` prints request metadata only (safe for CI); `--debug-dump-dir`
writes verbatim, unredacted bodies to `0600` files in a `0700` directory,
never to stdout or stderr.

### Build and release

- Dependency-free and CGO-free; Go 1.22+ is the only build requirement.
- CI runs `gofmt`, `go vet`, `go build` and `go test -race` on Linux and
macOS, cross-compiles the full platform matrix on every pull request, and
publishes a GitHub Release with `SHA256SUMS` from a `v*` tag. The detached
signature over the manifest is attached by hand afterwards — CI holds no
signing key, and the release notes say so.

### Known limitations

- **`catalog_api: v3` with `baseline.source: puppetdb` is not rejected.**
requirements.md 1.8 requires a v3 target to use a file baseline, but config
validation does not yet enforce it. The configuration loads and the run
overwrites the baseline it just read; the symptom on the next run is a
baseline-environment mismatch naming the candidate environment. Set
`baseline.source: file` yourself. See the README's
"If you must use v3, compare against a captured file".
- **Two PuppetDB impact-endpoint behaviours are unconfirmed against a
deployment**: that design §8's PQL text is accepted at the root
`/pdb/query/v4`, and that `limit`/`order_by` are honoured there. If
`order_by` is not honoured, a *truncated* impact sample is not reproducible.
- **The Puppet `Sensitive` wire shape** (`{"__ptype":"Sensitive","__pvalue":…}`)
is derived from Puppet's Ruby serializer source rather than a captured
response. A compiler emitting a different encoding would pass the acceptance
suite with the value unredacted.

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
[0.1.0]: https://github.com/example42/piace/releases/tag/v0.1.0
Loading
Loading