Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
0558e7a
test(layout): execute the worked examples as a corpus
sunib Aug 31, 2026
982a24b
feat(api): add spec.suspend and status.placement, and resolve a folde…
sunib Aug 31, 2026
39de316
feat(git): publish the resolved layout from every scan, and honour su…
sunib Aug 31, 2026
8c250c1
feat(gittarget): project status.placement, LayoutResolved, and the re…
sunib Aug 31, 2026
6f4e52a
docs: describe suspend, status.placement, and the one write-behavior …
sunib Aug 31, 2026
2a4fc5f
test(layout): pin the refusal fixtures to the refusal the writer actu…
sunib Aug 31, 2026
aedcfd9
test(layout): name corpus subtests by what they expect
sunib Aug 31, 2026
aad437d
test(layout): write the two PR 2 refusals as fixtures, and skip them
sunib Aug 31, 2026
0d75eeb
test(e2e): assert the scan's revision once the branch has one
sunib Aug 31, 2026
3ee629b
docs(layout): point each shape at the refusal fixture it now has
sunib Aug 31, 2026
7c0324e
docs(git): record what a suspended resync reports downstream
sunib Aug 31, 2026
4b0cafb
docs(api): say which documents status.placement.serializeNamespace de…
sunib Aug 31, 2026
83f5e79
fix(watch): let status.placement.observedRevision fill in
sunib Aug 31, 2026
8efe124
style(manifestanalyzer): keep the classification table's alignment
sunib Aug 31, 2026
b580518
fix(git): say what "." is in the ambiguous-layout refusal
sunib Aug 31, 2026
368742a
fix(gittarget): make the refusal fixtures fully asserted, and state s…
sunib Aug 31, 2026
369978b
build(ci): stop the image gate failing on a CVE the SOPS binary carries
sunib Sep 1, 2026
eec1b7d
build(ci): run the image scan from the CI container, as `task scan-im…
sunib Sep 1, 2026
a0985a4
build(ci): give the release scan its registry credentials, and docume…
sunib Sep 1, 2026
2c7d029
docs(ci): say why the Trivy DB is not baked into the image
sunib Sep 1, 2026
7d63de9
refactor(api): report how a folder is written, not what the spec alre…
sunib Sep 1, 2026
4937776
fix(gittarget): publish no retention for a suspended target, and call…
sunib Sep 1, 2026
807719e
test(layout): drop the withdrawn supplier rule's scenario, and unsusp…
sunib Sep 1, 2026
5c8222c
docs(layout): say how a target is previewed, and what status is for
sunib Sep 1, 2026
83a9f54
fix(git): stage no bootstrap files for a suspended target
sunib Sep 1, 2026
993122e
fix(layout): send the ambiguous-layout refusal to the platform operator
sunib Sep 1, 2026
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
21 changes: 20 additions & 1 deletion .devcontainer/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ RUN apt-get update \
# NODE_MAJOR -> https://github.com/nodejs/node/releases (track a current LTS line)
# MARKDOWNLINT_CLI2_VERSION -> https://www.npmjs.com/package/markdownlint-cli2?activeTab=versions
# VALE_VERSION -> https://github.com/vale-cli/vale/releases
# TRIVY_VERSION -> https://github.com/aquasecurity/trivy/releases

ENV PATH="/go/bin:/usr/local/go/bin:${PATH}" \
KUBECTL_VERSION=v1.36.4 \
Expand All @@ -81,7 +82,8 @@ ENV PATH="/go/bin:/usr/local/go/bin:${PATH}" \
ORAS_VERSION=1.3.3 \
NODE_MAJOR=24 \
MARKDOWNLINT_CLI2_VERSION=0.23.2 \
VALE_VERSION=3.18.0
VALE_VERSION=3.18.0 \
TRIVY_VERSION=0.70.0

# Fail early on unsupported architectures instead of producing a partial image.
RUN test "$(dpkg --print-architecture)" = "amd64" \
Expand Down Expand Up @@ -149,6 +151,23 @@ RUN asset="actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \
&& install -m 0755 actionlint /usr/local/bin/actionlint \
&& rm -rf "${tmpdir}"

# Install trivy (container image vulnerability scanner). It backs `task scan-image`,
# which is the same command CI runs to gate the built image — so a maintainer can
# reproduce a scan failure locally instead of pushing to find out what it said.
#
# The vulnerability DB is deliberately NOT pre-seeded here: it unpacks to 1.3GB, goes
# stale in 24h, and ten CI jobs pull this image while only two scan. CI caches it instead.
RUN asset="trivy_${TRIVY_VERSION}_Linux-64bit.tar.gz" \
&& base="https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}" \
&& tmpdir="$(mktemp -d)" \
&& curl -fsSL "${base}/${asset}" -o "${tmpdir}/${asset}" \
&& curl -fsSL "${base}/trivy_${TRIVY_VERSION}_checksums.txt" -o "${tmpdir}/checksums.txt" \
&& cd "${tmpdir}" \
&& grep " ${asset}$" checksums.txt | sha256sum -c - \
&& tar -xzf "${asset}" trivy \
&& install -m 0755 trivy /usr/local/bin/trivy \
&& rm -rf "${tmpdir}"

# Install hadolint (static linter for Dockerfiles). Since 2.15.0 the release no
# longer ships a per-asset .sha256 file, only one combined checksums.sha256 in
# `sha256sum *filename` format (space-asterisk, not the two-space format the
Expand Down
145 changes: 100 additions & 45 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -648,7 +648,7 @@ jobs:
name: Scan project image
runs-on: ubuntu-latest
timeout-minutes: 20
needs: build
needs: [build, ci-container]
# PRs only: scans the instrumented image the PR built (delivered as an
# artifact). On main the shipped bytes are the release-grade digests, which
# image-scan-release scans instead — so this scan reports to the job log
Expand All @@ -659,6 +659,11 @@ jobs:
packages: read
env:
PROJECT_IMAGE: ${{ needs.build.outputs.image }}
CI_CONTAINER: ${{ needs.ci-container.outputs.image }}
# Inside the container this is a workspace path, so the runner-side cache step
# below can persist it between runs. Without a cache Trivy re-downloads its whole
# vulnerability DB on every job, which is slow and rate-limited at the source.
TRIVY_CACHE_DIR: ${{ github.workspace }}/.stamps/trivy-cache
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
Expand All @@ -671,27 +676,46 @@ jobs:
name: project-image
path: .

- name: Load project image
run: docker load -i project-image.tar && rm -f project-image.tar

- name: Scan image with Trivy (report)
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
- name: Download CI container image
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
image-ref: ${{ env.PROJECT_IMAGE }}
format: table
severity: CRITICAL,HIGH,MEDIUM
exit-code: "0"

- name: Gate on critical fixable vulnerabilities
# Fails the job only for CRITICAL vulnerabilities that have a fix
# available — actionable signal, not noise from unfixed CVEs.
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
name: ci-container-image
path: .

- name: Load CI container image
# rm after load: the tarball is multi-GB and otherwise occupies the runner
# for the rest of the job.
run: docker load -i ci-container-image.tar && rm -f ci-container-image.tar

- name: Restore Trivy vulnerability DB
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
image-ref: ${{ env.PROJECT_IMAGE }}
format: table
severity: CRITICAL
ignore-unfixed: true
exit-code: "1"
path: .stamps/trivy-cache
key: trivy-db-${{ github.run_id }}
restore-keys: trivy-db-

- name: Scan project image
# The project image is scanned straight from its artifact tarball, so this
# needs neither a `docker load` of it nor a registry pull. What the task does
# — report everything, then gate on CRITICAL-with-a-fix — is documented where
# it is defined; keeping it there is what lets a maintainer reproduce this
# exact gate with `task scan-image SCAN_ARCHIVE=project-image.tar`.
run: |
docker run --rm \
-v "${GITHUB_WORKSPACE}:${{ env.CI_WORKDIR }}" \
-w "${{ env.CI_WORKDIR }}" \
-e TRIVY_CACHE_DIR="${{ env.CI_WORKDIR }}/.stamps/trivy-cache" \
"${CI_CONTAINER}" \
bash -lc '
set -e
git config --global --add safe.directory "$PWD"
task scan-image SCAN_ARCHIVE=project-image.tar
'

- name: Make the Trivy cache readable by the runner
# The container writes it as root; actions/cache runs as the runner user.
if: always()
run: sudo chown -R "$(id -u):$(id -g)" .stamps/trivy-cache || true

image-scan-release:
name: Scan release image (${{ matrix.arch }})
Expand All @@ -701,11 +725,14 @@ jobs:
# dispatch) this skips transitively — no skip-tolerant `if:` needed. The
# release ships amd64 *and* arm64, so both digests are scanned; Trivy pulls
# each by digest and analyzes the filesystem cross-arch.
needs: [build-release-amd64, build-release-arm64]
needs: [ci-container, build-release-amd64, build-release-arm64]
permissions:
contents: read
packages: read
security-events: write
env:
CI_CONTAINER: ${{ needs.ci-container.outputs.image }}
TRIVY_CACHE_DIR: ${{ github.workspace }}/.stamps/trivy-cache
strategy:
fail-fast: false
matrix:
Expand All @@ -715,6 +742,13 @@ jobs:
- arch: arm64
digest: ${{ needs.build-release-arm64.outputs.digest }}
steps:
- name: Checkout code
# This job scans a REMOTE image by digest and needs nothing from the repository
# except the Taskfile and .trivyignore.yaml the scan below reads. Trivy treats a
# missing --ignorefile as a fatal error, so a lost checkout fails loudly here
# rather than quietly scanning without the suppressions.
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Log in to registry
# Each build-release-* job pushes by digest, and buildx wraps the
# single-platform image in an OCI *index* (it attaches a provenance
Expand All @@ -727,40 +761,61 @@ jobs:
run: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ${{ env.REGISTRY }} -u ${{ github.actor }} --password-stdin

- name: Scan release image with Trivy (report)
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
env:
# The pushed digest is an OCI index; pin the child to scan (see login step).
TRIVY_PLATFORM: linux/${{ matrix.arch }}
- name: Restore Trivy vulnerability DB
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ matrix.digest }}
format: sarif
output: trivy-image.sarif
severity: CRITICAL,HIGH,MEDIUM
exit-code: "0"
path: .stamps/trivy-cache
key: trivy-db-${{ github.run_id }}
restore-keys: trivy-db-

- name: Scan release image
# The same `task scan-image` the PR gate runs, so the release and the PR are
# held to one definition of what blocks. It writes SARIF for code scanning and
# then gates; the upload below runs even when the gate fails, so a blocking
# finding is still reported rather than only logged.
#
# Trivy pulls the image with its OWN registry client rather than through the
# docker daemon, so the runner's `docker login` above does not reach it: the
# credentials live in the runner's home, and this container sees only the
# workspace. Mounting that config read-only and pointing DOCKER_CONFIG at it is
# what keeps this working if the package ever stops being public. Read-only, and
# outside the workspace, so the credential is never somewhere an artifact upload
# could pick it up. DOCKER_CONFIG rather than a mount onto ~/.docker because it
# does not care which user the image runs as.
run: |
docker run --rm \
-v "${GITHUB_WORKSPACE}:${{ env.CI_WORKDIR }}" \
-v "${HOME}/.docker:/tmp/docker-config:ro" \
-w "${{ env.CI_WORKDIR }}" \
-e DOCKER_CONFIG=/tmp/docker-config \
-e TRIVY_CACHE_DIR="${{ env.CI_WORKDIR }}/.stamps/trivy-cache" \
-e TRIVY_PLATFORM="linux/${{ matrix.arch }}" \
"${CI_CONTAINER}" \
bash -lc '
set -e
git config --global --add safe.directory "$PWD"
task scan-image \
SCAN_IMAGE=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ matrix.digest }} \
SCAN_SARIF=trivy-image.sarif
'

- name: Make scan output readable by the runner
# The container writes both as root; the upload action and actions/cache run
# as the runner user.
if: always()
run: sudo chown -R "$(id -u):$(id -g)" .stamps/trivy-cache trivy-image.sarif || true

- name: Upload scan results to GitHub code scanning
# always(): the SARIF is written by the report pass BEFORE the gate runs, so a
# gate failure must not swallow the report that explains it.
if: always()
uses: github/codeql-action/upload-sarif@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8
with:
sarif_file: trivy-image.sarif
# Distinct category so the amd64 and arm64 legs don't overwrite each
# other's code-scanning results.
category: trivy-release-${{ matrix.arch }}

- name: Gate on critical fixable vulnerabilities
# Fails the job only for CRITICAL vulnerabilities that have a fix
# available — actionable signal, not noise from unfixed CVEs.
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
env:
# The pushed digest is an OCI index; pin the child to scan (see login step).
TRIVY_PLATFORM: linux/${{ matrix.arch }}
with:
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ matrix.digest }}
format: table
severity: CRITICAL
ignore-unfixed: true
exit-code: "1"

e2e:
name: E2E (${{ matrix.name }})
runs-on: ubuntu-latest
Expand Down
31 changes: 31 additions & 0 deletions .trivyignore.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Vulnerabilities the image scan may skip, each with the reason it is not reachable here and a
# date it must be reconsidered. An entry without both is a suppression nobody can audit.
#
# The gate this feeds is `task scan-image`, which fails on CRITICAL vulnerabilities that HAVE a
# fix — so everything listed here is by definition something a reader would expect to be fixed by
# upgrading. Say why we have not. Reproduce the gate locally with that task before editing this.
vulnerabilities:
- id: CVE-2026-56854
# golang.org/x/crypto/ssh: authentication bypass because a server does not enforce the
# source-address restrictions in an authorized_keys `from=` criterion.
#
# It reaches the image through the SOPS release binary, which getsops builds against
# x/crypto v0.54.0. Our own manager is unaffected and Trivy reports it clean: this repository
# requires x/crypto v0.55.0, which carries the fix.
#
# The vulnerable code is an SSH SERVER path (ssh.ServerConfig). SOPS is a CLI we exec to
# encrypt and decrypt files with age; it listens on nothing and accepts no SSH connection, so
# the affected path cannot be entered in this image. Suppressed rather than fixed because
# v3.13.3 is the latest SOPS release: there is no rebuilt binary to bump to, and building SOPS
# ourselves would trade an upstream signed release for one of ours, which is a bigger change
# to the supply chain than the finding warrants.
#
# Revisit when getsops ships a release built against x/crypto >= 0.55.0, and delete this entry
# then. The expiry is what forces that check to happen even if nobody is watching upstream.
paths:
- "usr/local/bin/sops"
statement: >-
Not reachable: the vulnerable path is x/crypto/ssh's SERVER-side authorized_keys handling,
and the SOPS CLI in this image runs no SSH server. Our own binary already uses the fixed
x/crypto v0.55.0. No rebuilt SOPS release exists yet (v3.13.3 is latest).
expired_at: 2026-12-01
13 changes: 13 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,19 @@ workflow or Dockerfile change is covered by the normal lint gate; you can also r
`task lint-actions` or `task lint-dockerfiles` directly. `actionlint`, `hadolint`, and
`golangci-lint` all ship in the devcontainer image.

`trivy` ships there too, behind `task scan-image` — the same command CI runs to gate the
built image, so a scan failure can be reproduced locally instead of by pushing:

```bash
task scan-image SCAN_ARCHIVE=project-image.tar # a docker-archive tarball
task scan-image SCAN_IMAGE=ghcr.io/example/img:tag # or an image reference
```

It is not part of `task lint`: it needs an image to scan, which a lint run has no reason
to build. Suppressions live in [`.trivyignore.yaml`](./.trivyignore.yaml), each with a
justification and an expiry date. A devcontainer built before `trivy` was added to the
image does not have it — rebuild the container if the task reports it missing.

It also runs the documentation checks via `task lint-docs`, which is three tasks:
`lint-doc-links` (`hack/doccheck`, every tracked file), `lint-markdown` (markdownlint-cli2,
every tracked file), and `lint-prose` (Vale, against
Expand Down
39 changes: 39 additions & 0 deletions Taskfile-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,45 @@ tasks:
cmds:
- actionlint

scan-image:
desc: Scan a container image for vulnerabilities — full report, then the CI gate
# This task IS the gate CI runs. Both live here rather than in the workflow so the
# severities, the ignore file and the exit codes have one definition: they were
# duplicated across four workflow steps in two jobs, which is four places to keep in
# agreement and no way to run any of them before pushing.
#
# It scans in two passes on purpose. The first reports everything a human should see
# and never fails. The second is the gate, and it fails ONLY on CRITICAL findings that
# have a fix — actionable signal, not noise from unfixed CVEs. Only the gate reads
# .trivyignore.yaml, so a suppressed finding still appears in the report above it: it
# stops blocking without disappearing. Trivy does not read that file from the working
# directory on its own, which is why --ignorefile is passed explicitly.
#
# A missing .trivyignore.yaml is a FATAL error rather than a silent no-op, which is
# the behaviour to want from a security control: the gate cannot quietly stop
# honouring its own suppressions. It is a relative path, so run this from the
# repository root (CI mounts the workspace and sets -w).
#
# Point it at either a docker-archive tarball or an image reference:
# task scan-image SCAN_ARCHIVE=project-image.tar
# task scan-image SCAN_IMAGE=ghcr.io/configbutler/gitops-reverser@sha256:...
# A tarball needs no docker daemon and no registry pull, so CI prefers it for the
# image it just built. SCAN_SARIF=<path> makes the report pass write SARIF for GitHub
# code scanning instead of a table; the gate is unaffected either way.
vars:
SCAN_ARCHIVE: '{{.SCAN_ARCHIVE | default ""}}'
SCAN_IMAGE: '{{.SCAN_IMAGE | default ""}}'
SCAN_SARIF: '{{.SCAN_SARIF | default ""}}'
SCAN_TARGET: '{{if .SCAN_ARCHIVE}}--input {{.SCAN_ARCHIVE}}{{else}}{{.SCAN_IMAGE}}{{end}}'
SCAN_REPORT_FORMAT: '{{if .SCAN_SARIF}}--format sarif --output {{.SCAN_SARIF}}{{else}}--format table{{end}}'
preconditions:
- sh: '[ -n "{{.SCAN_ARCHIVE}}{{.SCAN_IMAGE}}" ]'
msg: "set SCAN_ARCHIVE=<tarball> or SCAN_IMAGE=<ref> — there is nothing to scan otherwise"
cmds:
- trivy image {{.SCAN_TARGET}} {{.SCAN_REPORT_FORMAT}} --severity CRITICAL,HIGH,MEDIUM --exit-code 0
- trivy image {{.SCAN_TARGET}} --format table --severity CRITICAL --ignore-unfixed
--ignorefile .trivyignore.yaml --exit-code 1

lint-dockerfiles:
desc: Lint the project Dockerfiles with hadolint
# Only re-lint when a Dockerfile or the hadolint config changes; Task
Expand Down
Loading
Loading