From 01bcbd08c04ebe725b971fe7c1b57e4235dd3b09 Mon Sep 17 00:00:00 2001 From: Jeremy Harrington Date: Sun, 19 Jul 2026 18:10:26 -0700 Subject: [PATCH] docs(gitops): plan-on-PR workflow template (I1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add examples/gitops-pr-plan/ — a reusable GitHub Actions workflow that runs `openctl plan` (K7) on every PR touching manifests and posts the apply order + $ref dependency graph as a sticky PR comment, so a reviewer sees what will happen, in what order, and what waits on what before merge. The PR-gated front half of the DriftlessAF loop (the controller reconciles on merge/webhook via B1-B3). Kept under examples/ (NOT .github/workflows/) so it doesn't run on openctl's own repo — it's a template to copy into a user's infra repo. Offline (no controller), self-contained (only `gh` + pull-requests:write), sticky comment (update-in-place via gh api), and a dependency cycle makes `openctl plan` exit non-zero → the check fails and blocks merge. README covers adoption + scope (per-resource diffs are server-only, out of scope). --- ROADMAP.md | 14 +++-- examples/gitops-pr-plan/README.md | 64 +++++++++++++++++++++++ examples/gitops-pr-plan/plan.yml | 87 +++++++++++++++++++++++++++++++ 3 files changed, 162 insertions(+), 3 deletions(-) create mode 100644 examples/gitops-pr-plan/README.md create mode 100644 examples/gitops-pr-plan/plan.yml diff --git a/ROADMAP.md b/ROADMAP.md index a732115..9638cba 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -221,9 +221,17 @@ prerequisite (Longhorn); the rest are convenience + robustness. is an OIDC IdP; point `auth.oidc` at it (OIDC backend shipped #110). ### I. GitOps -- [ ] **I1 — plan-on-PR (optional).** Surface a dry-run diff on a pull request - before merge, for a PR-gated Driftless workflow (openctl reconciles on - merge/webhook today — B1–B3; PR-preview was deferred to Argo). Enhancement. +- [x] **I1 — plan-on-PR.** A reusable GitHub Actions workflow template + (`examples/gitops-pr-plan/`, kept out of `.github/workflows/` so it doesn't + self-activate on openctl's own repo) that runs `openctl plan` (K7) on every + PR touching manifests and posts the apply order + `$ref` dependency graph + as a **sticky** PR comment — the PR-gated front half of the DriftlessAF + loop (openctl reconciles on merge/webhook via B1–B3). Offline (no + controller), self-contained (only `gh` + `pull-requests: write`), and a + dependency **cycle** makes `openctl plan` exit non-zero → the check fails + and blocks merge. README covers adoption. Per-resource dry-run **diffs** + stay server-only (K7) and are intentionally out of scope for this offline + preview. ### J. App catalog / examples - [x] **J1 — example manifests** under `examples/homelab/` — the full stack diff --git a/examples/gitops-pr-plan/README.md b/examples/gitops-pr-plan/README.md new file mode 100644 index 0000000..7b15fee --- /dev/null +++ b/examples/gitops-pr-plan/README.md @@ -0,0 +1,64 @@ +# plan-on-PR (I1) + +A reusable GitHub Actions workflow that runs [`openctl plan`](../../internal/cli/plan.go) +on every pull request touching your manifests and posts the apply order + `$ref` +dependency graph as a **sticky PR comment** — so a reviewer sees *what will +happen, in what order, and what waits on what* before merge. + +This is the PR-gated front half of the DriftlessAF loop: the PR shows the plan; +the controller reconciles on merge (via `manifests.gitops` pull/webhook — see +[../../docs/gitops-modes.md](../../docs/gitops-modes.md)). + +## Adopt it + +1. Copy [`plan.yml`](plan.yml) into your **infra** repo at + `.github/workflows/openctl-plan.yml`. +2. Set `MANIFESTS` (in the workflow `env`) to where your manifests live + (default `manifests`). It accepts a directory (walked recursively) or + space-separated `-f` paths. +3. Adjust the `on.pull_request.paths` globs to match your layout. +4. Optionally pin `go install …/openctl@vX.Y.Z` to a release for reproducible + checks. + +That's it — no controller connection, no secrets. The workflow needs only +`pull-requests: write` (already declared) to post the comment. + +## What the comment shows + +``` +### openctl plan — ✅ plan computed + +Plan: 11 resource(s), 2 wave(s) + +Apply order: + wave 1: + Cluster/home + Tunnel/home + wave 2: + DNSRecord/chat ← Tunnel/home + HelmRelease/ollama ← Cluster/home + Platform/home ← Cluster/home + ... + +External references (must already exist — not in this set): + HelmRelease/ollama → Cluster/home +``` + +- **Waves** are topological levels — resources in one wave have no + inter-dependency and apply concurrently. +- **`← Kind/Name`** is what a resource waits on (a `$ref` into another resource + in the set). +- **External references** are `$ref` targets *not* in the changed set — they + must already exist in the cluster. + +## Gating + +`openctl plan` exits non-zero on a **dependency cycle**, which fails the check +and blocks the merge (the comment shows the offending resources). + +## Scope + +This is the **offline** preview: ordering + the dependency graph. Per-resource +dry-run **diffs** (spec drift vs the live cluster) are server-only — they need a +running controller and its `DryRunApply` — and are intentionally not part of +this workflow. See the K7 notes in [../../ROADMAP.md](../../ROADMAP.md). diff --git a/examples/gitops-pr-plan/plan.yml b/examples/gitops-pr-plan/plan.yml new file mode 100644 index 0000000..da9e13c --- /dev/null +++ b/examples/gitops-pr-plan/plan.yml @@ -0,0 +1,87 @@ +# openctl plan-on-PR — a reusable GitHub Actions workflow TEMPLATE. +# +# Copy this into YOUR infra repo at .github/workflows/openctl-plan.yml. It runs +# `openctl plan` on every pull request that touches your manifests and posts the +# apply order + $ref dependency graph as a sticky PR comment, so a reviewer sees +# "what will happen, in what order, and what waits on what" before merge — the +# PR-gated half of the DriftlessAF loop (openctl reconciles on merge/webhook; +# see manifests.gitops in the controller config). +# +# It is offline: `openctl plan` needs no controller connection. Per-resource +# dry-run diffs are server-only and are NOT included (see docs/gitops-modes.md +# and the K7 plan-preview notes). A dependency cycle makes `openctl plan` exit +# non-zero, which fails the check and blocks the merge. +# +# It lives under examples/ in the openctl repo on purpose — it must NOT run on +# openctl's own repo; it's a template for yours. +name: openctl plan + +on: + pull_request: + # Adjust to where your manifests live. + paths: + - "manifests/**" + - "**/*.cue" + - "**/*.yaml" + - "**/*.yml" + +permissions: + contents: read + pull-requests: write # to post/update the sticky comment + +jobs: + plan: + runs-on: ubuntu-latest + env: + # Directory (or space-separated -f args) to plan. Override as needed. + MANIFESTS: manifests + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.25" + + - name: Install openctl + # Pin @vX.Y.Z for reproducible checks once you track a release. + run: go install github.com/openctl/openctl/cmd/openctl@latest + + - name: Run openctl plan + id: plan + run: | + set +e + out="$(openctl plan -f "$MANIFESTS" 2>&1)" + code=$? + set -e + echo "$out" + # Stash the output for the comment step (multiline-safe). + { + echo 'output<> "$GITHUB_OUTPUT" + echo "code=$code" >> "$GITHUB_OUTPUT" + + - name: Post sticky PR comment + if: always() + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + PR: ${{ github.event.pull_request.number }} + OUT: ${{ steps.plan.outputs.output }} + CODE: ${{ steps.plan.outputs.code }} + run: | + marker='' + if [ "$CODE" = "0" ]; then status='✅ plan computed'; else status='❌ plan failed (see below — a dependency cycle blocks merge)'; fi + body="$(printf '%s\n### openctl plan — %s\n\n```\n%s\n```\n' "$marker" "$status" "$OUT")" + # Sticky: update an existing openctl-plan comment if present, else create. + cid="$(gh api "repos/$REPO/issues/$PR/comments" --jq ".[] | select(.body | contains(\"$marker\")) | .id" | head -n1)" + if [ -n "$cid" ]; then + gh api -X PATCH "repos/$REPO/issues/comments/$cid" -f body="$body" >/dev/null + else + gh api -X POST "repos/$REPO/issues/$PR/comments" -f body="$body" >/dev/null + fi + + - name: Fail the check on a plan error + if: always() + run: exit ${{ steps.plan.outputs.code }}