Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
2 changes: 1 addition & 1 deletion .coverage-baseline
Original file line number Diff line number Diff line change
@@ -1 +1 @@
77.8
78.0
110 changes: 109 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,14 @@ jobs:
```
**Platforms:** linux/amd64, linux/arm64
**Digest:** `${{ steps.digest.outputs.digest }}`
## Standalone analyzer
`manifest-analyzer` answers "which folders in this repository could become
GitTargets?" without a cluster. Binaries are attached for linux/amd64,
linux/arm64 and darwin/arm64, with a `manifest-analyzer.sha256sums` file:
```bash
curl -fsSLO https://github.com/ConfigButler/gitops-reverser/releases/download/${{ needs.release-please.outputs.tag_name }}/manifest-analyzer_linux_amd64
curl -fsSL https://github.com/ConfigButler/gitops-reverser/releases/download/${{ needs.release-please.outputs.tag_name }}/manifest-analyzer.sha256sums | sha256sum -c --ignore-missing
```
## Verify this release
Images are signed with cosign (keyless) and carry SLSA build provenance
and an SPDX SBOM as registry attestations:
Expand Down Expand Up @@ -364,6 +372,106 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

# Build, checksum, sign and attest the manifest-analyzer binary, and attach it to the
# draft release. A tool that execs the analyzer to decide which folders it may offer a
# tenant is pinning us by binary, and `go install` from source is the one unverifiable
# link in a devcontainer that pins every other tool from a release asset.
#
# Both verification mechanisms ship, because the two consumers asking for this do not
# agree on one: `gh attestation verify` is the stronger claim, and a sha256sums file is
# what a `curl | sha256sum -c` line in a Dockerfile can consume without a GitHub token.
publish-analyzer:
name: Publish manifest-analyzer
runs-on: ubuntu-latest
timeout-minutes: 20
needs: [ci, release-please]
if: needs.release-please.outputs.release_created == 'true'
permissions:
contents: write # upload release assets
id-token: write # cosign keyless signing + attestation OIDC
attestations: write
container:
image: ${{ needs.ci.outputs.ci-image }}
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Configure Git safe directory
run: git config --global --add safe.directory /__w/gitops-reverser/gitops-reverser

# linux/amd64 + linux/arm64 cover the devcontainers that consume this; darwin/arm64
# covers a developer running the same scan locally. The version is stamped into the
# library, not into main, so the string the binary prints is the same one every
# report it emits carries.
- name: Build the analyzer for every published platform
env:
VERSION: ${{ needs.release-please.outputs.tag_name }}
run: |
set -euo pipefail
mkdir -p dist/analyzer
for target in linux/amd64 linux/arm64 darwin/arm64; do
os="${target%/*}"
arch="${target#*/}"
CGO_ENABLED=0 GOOS="${os}" GOARCH="${arch}" go build \
-trimpath \
-ldflags "-s -w -X github.com/ConfigButler/gitops-reverser/pkg/manifestanalyzer.version=${VERSION}" \
-o "dist/analyzer/manifest-analyzer_${os}_${arch}" \
./cmd/manifest-analyzer
done
ls -l dist/analyzer

- name: Verify the built binary reports the release it was built from
env:
VERSION: ${{ needs.release-please.outputs.tag_name }}
run: |
set -euo pipefail
printed="$(./dist/analyzer/manifest-analyzer_linux_amd64 --version)"
echo "${printed}"
echo "${printed}" | grep -qF "${VERSION}" || {
echo "the binary does not report ${VERSION}; the ldflags path is broken" >&2
exit 1
}

- name: Write the checksums file
working-directory: dist/analyzer
run: sha256sum manifest-analyzer_* > manifest-analyzer.sha256sums

- name: Sign the checksums file (cosign keyless)
working-directory: dist/analyzer
run: |
cosign sign-blob --bundle manifest-analyzer.sha256sums.sigstore.json --yes manifest-analyzer.sha256sums

- name: Attest SLSA provenance for the analyzer binaries
id: attest-analyzer
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
with:
subject-path: dist/analyzer/manifest-analyzer_*

- name: Rename provenance bundle to the Scorecard-recognized suffix
run: cp "${{ steps.attest-analyzer.outputs.bundle-path }}" dist/analyzer/manifest-analyzer.intoto.jsonl

- name: Upload the analyzer as release assets
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
with:
tag_name: ${{ needs.release-please.outputs.tag_name }}
# Attach to the still-draft release; publish-release publishes it once every
# asset is in place (immutable releases reject post-publish uploads).
draft: true
files: |
dist/analyzer/manifest-analyzer_linux_amd64
dist/analyzer/manifest-analyzer_linux_arm64
dist/analyzer/manifest-analyzer_darwin_arm64
dist/analyzer/manifest-analyzer.sha256sums
dist/analyzer/manifest-analyzer.sha256sums.sigstore.json
dist/analyzer/manifest-analyzer.intoto.jsonl
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

# Publish the release only after every asset is attached. release-please
# creates the GitHub release as a draft (see release-please-config.json);
# GitHub's immutable-releases enforcement freezes a release the moment it is
Expand All @@ -375,7 +483,7 @@ jobs:
name: Publish GitHub Release
runs-on: ubuntu-latest
timeout-minutes: 10
needs: [release-please, publish-manifest, publish-helm]
needs: [release-please, publish-manifest, publish-helm, publish-analyzer]
if: needs.release-please.outputs.release_created == 'true'
permissions:
contents: write # publish the draft release (creates the tag)
Expand Down
9 changes: 9 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,15 @@ See [Documentation checks](./CONTRIBUTING.md#documentation-checks).
- Update API documentation if modifying webhook behavior
- Update API documentation if modifying CRDs

### `docs/UPGRADING.md` is read AFTER the release ships

Nobody reads an upgrade guide before the version exists. Write every entry in the present
tense, describing the release the reader is upgrading to as something that already is:
"`schemaVersion` is gone; `apiVersion` replaces it", never "will be removed" or "now
gains". Do not mark an entry `Unreleased` or `(next minor)` — by the time it is read, both
are false, and a heading that says "Unreleased" about a change shipped six versions ago is
worse than no heading. Name what changed, not when it is going to.

## VALIDATION SEQUENCE

For markdown/docs-only edits, skip this full sequence unless the documentation change depends on or
Expand Down
68 changes: 58 additions & 10 deletions cmd/manifest-analyzer/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,11 @@
// applied (the repo-level refuse gate is
// deferred)
// discovery: raw Kubernetes API discovery dump
// --format text|json output format (default text)
// --format text|json|yaml
// output format (default text). yaml is the KRM report modes only
// (scan-folder, scan-repo): it is the same document as json, in the
// serialization a human can read and commit
// --version print the release and the report apiVersion, then exit
// --policy report|refuse
// report: always exit 0 (analysis only)
// refuse: exit 1 when the folder would be refused
Expand Down Expand Up @@ -91,16 +95,18 @@ func runWithDiscoveryClientFactory(
fs := flag.NewFlagSet("manifest-analyzer", flag.ContinueOnError)
fs.SetOutput(stderr)
mode := fs.String("mode", "analyze", "what to produce: analyze|scan-folder|scan-repo|discovery")
format := fs.String("format", "text", "output format: text|json")
format := fs.String("format", "text", "output format: text|json|yaml (yaml: scan-folder/scan-repo only)")
policy := fs.String("policy", "report", "adoption policy: report|refuse (scan-repo is report-only)")
kubeconfig := fs.String(
"kubeconfig",
"",
"kubeconfig path for --mode discovery (default: standard loading rules)",
)
contextName := fs.String("context", "", "kubeconfig context for --mode discovery")
showVersion := fs.Bool("version", false, "print the release and the report apiVersion, then exit")
fs.Usage = func() {
fmt.Fprintln(stderr, "usage: manifest-analyzer [flags] <dir>")
fmt.Fprintln(stderr, " manifest-analyzer --version")
fmt.Fprintln(stderr, " manifest-analyzer --mode scan-repo [flags] <repo-root>")
fmt.Fprintln(stderr, " manifest-analyzer --mode discovery [flags]")
fs.PrintDefaults()
Expand All @@ -109,6 +115,10 @@ func runWithDiscoveryClientFactory(
if err := fs.Parse(args); err != nil {
return exitUsage
}
if *showVersion {
printVersion(stdout)
return exitOK
}
if !validChoices(*mode, *format, *policy, stderr) {
return exitUsage
}
Expand All @@ -128,14 +138,31 @@ func runWithDiscoveryClientFactory(
return runDirMode(*mode, fs.Arg(0), *format, *policy, stdout, stderr)
}

// printVersion answers both version questions in one exec: which release this binary is,
// and which contract the documents it prints conform to. A tool that pins us by release
// and reads our JSON needs both, and a second exec to get the second answer would be an
// assumption that it ran the same binary.
//
// The version is resolved by the library, not by a main-package ldflags variable, so it is
// the same string the reports themselves carry — including for a `go install ...@vX.Y.Z`
// build, where no ldflags are applied at all.
func printVersion(stdout io.Writer) {
fmt.Fprintf(stdout, "%s %s\n", publicanalyzer.GeneratorName, publicanalyzer.Version())
fmt.Fprintf(stdout, "report apiVersion: %s\n", publicanalyzer.APIVersion)
}

// validChoices validates the mode/format/policy enum flags, reporting the first bad one
// to stderr. Splitting it out of run keeps the top-level dispatch simple.
func validChoices(mode, format, policy string, stderr io.Writer) bool {
switch {
case mode != "analyze" && mode != "scan-folder" && mode != "scan-repo" && mode != "discovery":
case !knownMode(mode):
fmt.Fprintf(stderr, "error: unknown mode %q (want analyze|scan-folder|scan-repo|discovery)\n", mode)
case format != "text" && format != "json":
fmt.Fprintf(stderr, "error: unknown format %q (want text|json)\n", format)
case format != "text" && format != "json" && format != "yaml":
fmt.Fprintf(stderr, "error: unknown format %q (want text|json|yaml)\n", format)
case format == "yaml" && !reportsKRM(mode):
// yaml serializes the KRM report. The other modes render the engine's own
// structural report, which is not that document and not a published contract.
fmt.Fprintf(stderr, "error: --format yaml is only available for --mode scan-folder or scan-repo\n")
case policy != "report" && policy != "refuse":
fmt.Fprintf(stderr, "error: unknown policy %q (want report|refuse)\n", policy)
default:
Expand All @@ -144,6 +171,17 @@ func validChoices(mode, format, policy string, stderr io.Writer) bool {
return false
}

// knownMode reports whether mode is one the CLI implements.
func knownMode(mode string) bool {
return mode == "analyze" || mode == "scan-folder" || mode == "scan-repo" || mode == "discovery"
}

// reportsKRM reports whether mode emits the published KRM document (the two scan modes)
// rather than the engine's own structural report.
func reportsKRM(mode string) bool {
return mode == "scan-folder" || mode == "scan-repo"
}

// runDirMode dispatches the directory-argument modes (everything but discovery).
func runDirMode(mode, dir, format, policy string, stdout, stderr io.Writer) int {
switch mode {
Expand Down Expand Up @@ -245,17 +283,17 @@ func failedGroupVersions(failed map[schema.GroupVersion]error) map[string]string
// and the published Go contract are the same document and cannot drift. Text output stays
// on the internal renderer, which can show the plan the public report deliberately omits.
func runScanFolder(dir, format, policy string, stdout, stderr io.Writer) int {
if format == "json" {
if format == "json" || format == "yaml" {
report, err := publicanalyzer.ScanFolder(context.Background(), dir)
if err != nil {
fmt.Fprintf(stderr, "error: %v\n", err)
return exitUsage
}
if err := report.WriteJSON(stdout); err != nil {
if err := writeReport(stdout, format, report.WriteJSON, report.WriteYAML); err != nil {
fmt.Fprintf(stderr, "error: %v\n", err)
return exitUsage
}
return scanExitCode(policy, report.Accepted)
return scanExitCode(policy, report.Status.Accepted)
}

scanPolicy := manifestanalyzer.ScanPolicy{
Expand All @@ -271,6 +309,16 @@ func runScanFolder(dir, format, policy string, stdout, stderr io.Writer) int {
return scanExitCode(policy, result.Acceptance.Accepted)
}

// writeReport renders the report in the requested machine-readable format. The two
// writers are the report's own, so the CLI cannot serialize a document the published Go
// contract would not.
func writeReport(stdout io.Writer, format string, writeJSON, writeYAML func(io.Writer) error) error {
if format == "yaml" {
return writeYAML(stdout)
}
return writeJSON(stdout)
}

func scanExitCode(policy string, accepted bool) int {
if policy == "refuse" && !accepted {
return exitRefused
Expand All @@ -284,13 +332,13 @@ func scanExitCode(policy string, accepted bool) int {
// (exitOK, or exitUsage on an I/O error); the repo-level --policy refuse gate is
// deferred per the design doc.
func runScanRepo(root, format string, stdout, stderr io.Writer) int {
if format == "json" {
if format == "json" || format == "yaml" {
report, err := publicanalyzer.ScanRepo(context.Background(), root)
if err != nil {
fmt.Fprintf(stderr, "error: %v\n", err)
return exitUsage
}
if err := report.WriteJSON(stdout); err != nil {
if err := writeReport(stdout, format, report.WriteJSON, report.WriteYAML); err != nil {
fmt.Fprintf(stderr, "error: %v\n", err)
return exitUsage
}
Expand Down
Loading