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
28 changes: 28 additions & 0 deletions .github/RELEASES.md
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,34 @@ Publishing it instead freezes a release whose own notes link to assets it does n
`last-release-sha` does not rescue this: release-please reads it only as a top-level key,
and this repo's copy sits under `packages["."]`, where it is ignored.

### A merged PR is missing from the changelog

**Symptom:** a `feat` or `fix` merged since the last release has no changelog line, and none
of its breaking changes appear. The Release Please job log says nothing at its default level.

**Cause:** release-please parses each commit with `@conventional-commits/parser` and, when the
parse throws, drops the whole commit with only a debug log. A body line that starts with a word
followed by nested parentheses, such as `` `^([0-9]+(\.[0-9]+)?`` or `f(a(b))`, reads as an
unclosed `type(scope` token and throws. The squash body carries every commit message on the
branch, so one such line in any commit is enough. This dropped #388 from 0.50.0.

**Prevention:** the "Squash message parses" check in `pr-title.yml` rebuilds the squash message
and parses it before merge. Fix a failure by indenting the named line, or starting it with other
text, in the commit that holds it. To check a branch locally:

```bash
(cd hack/release-notes && npm ci --ignore-scripts)
hack/release-notes/squash-message.sh origin/main HEAD "<PR title>" | node hack/release-notes/check.mjs
```

`squash-message.sh` builds the message the way GitHub does for this repository: a single-commit PR
squashes to that commit's own message, and the PR title is not used; several commits squash to the
PR title over the bulleted commit messages. `hack/release-notes/test.sh` covers both shapes.

**Repair after a release:** add the missing entries to that release's section of `CHANGELOG.md`
by hand (release-please only prepends, so it never rewrites them) and edit the GitHub Release
notes to match.

### Docker Build Failed

**Check:**
Expand Down
41 changes: 41 additions & 0 deletions .github/workflows/pr-title.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# Two checks on what a pull request will become once squash-merged. The first is the title.
#
# PR titles become the squash-merge commit subject (the repository squash-merges with
# COMMIT_OR_PR_TITLE), and release-please reads those subjects to decide the version bump and
# to write the changelog. A title that is not a Conventional Commit is therefore not a style
Expand Down Expand Up @@ -47,3 +49,42 @@ jobs:
echo '`fix(webhook)!: reject unsigned requests`. Edit the title and this check re-runs.'
} | tee -a "$GITHUB_STEP_SUMMARY"
exit 1

# The squash commit's BODY matters as much as its title. The repository squash-merges with
# COMMIT_MESSAGES, so the body is every commit message on the branch (or, for a single commit,
# that commit's whole message, title included), and release-please parses the whole of it. A body the parser cannot read drops the commit from the changelog and the
# version bump without a word; that is how #388 went missing from 0.50.0. This rebuilds the
# message GitHub will write and parses it with the parser release-please uses.
#
# Commit messages are untrusted text: they reach the parser through a file, never through an
# expression in the script.
squash-message:
name: Squash message parses
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Check out the branch
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
persist-credentials: false

- name: Install the parser
working-directory: hack/release-notes
run: npm ci --ignore-scripts --no-audit --no-fund

- name: Self-test the check
run: hack/release-notes/test.sh

- name: Parse the squash message
env:
PR_TITLE: ${{ github.event.pull_request.title }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
# squash-message.sh owns GitHub's rule: one commit keeps its own message, several get
# the PR title over the bulleted commit messages.
hack/release-notes/squash-message.sh "$BASE_SHA" "$HEAD_SHA" "$PR_TITLE" > "$RUNNER_TEMP/squash-message.txt"
node hack/release-notes/check.mjs < "$RUNNER_TEMP/squash-message.txt" | tee -a "$GITHUB_STEP_SUMMARY"
exit "${PIPESTATUS[0]}"
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,4 @@ loadtest
.agents/*
/external-sources/
/docs/local-only/*
hack/release-notes/node_modules
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* expire finished CommitRequests and name the placement fallback the built-in path ([#400](https://github.com/ConfigButler/gitops-reverser/issues/400))
* **api:** rules[].operations is removed. A rule that listed a subset now also mirrors updates and removals; set spec.prune.mode: Never on the GitTarget before upgrading to keep documents on removal. See UPGRADING.md.
* **api:** kubectl get -o wide no longer prints PROVIDERREADY, CLUSTERPROVIDERREADY (GitTarget) or GITTARGETREADY (WatchRule, ClusterWatchRule).
* `CommitRequest.spec.closeDelaySeconds` is renamed to `spec.closeDelay`, a Go duration string (`"2s"`, at most `"5m"`). The old field is pruned on write, not refused. See UPGRADING.md. ([#388](https://github.com/ConfigButler/gitops-reverser/issues/388))
* `GitTarget.spec.commit.window` is validated at admission as a Go duration string of at most `"24h"`; unit-less or out-of-range values are refused. ([#388](https://github.com/ConfigButler/gitops-reverser/issues/388))
* the `--base-trust-max-age` flag is removed; a Deployment that sets it by hand fails to start. The periodic Git refresh (`--git-refresh-interval`, default `10m`) replaces it. ([#388](https://github.com/ConfigButler/gitops-reverser/issues/388))

### Features

Expand All @@ -22,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* expire finished CommitRequests and name the placement fallback the built-in path ([#400](https://github.com/ConfigButler/gitops-reverser/issues/400)) ([cd0c94c](https://github.com/ConfigButler/gitops-reverser/commit/cd0c94c823721888642d0227ac011f0b3aedf2b5))
* key a branch worker to its repository, and give a repository its own status surface ([#396](https://github.com/ConfigButler/gitops-reverser/issues/396)) ([71eb515](https://github.com/ConfigButler/gitops-reverser/commit/71eb515273590f4d75d1118a7eb881fece673a0d))
* report a folder somebody else broke, instead of discovering it on the next write ([#391](https://github.com/ConfigButler/gitops-reverser/issues/391)) ([ae4c823](https://github.com/ConfigButler/gitops-reverser/commit/ae4c82391ce67c6ade1806fb1aaf4fdb08d1ca6c))
* refresh an idle target from Git, and make every API duration a Go duration string ([#388](https://github.com/ConfigButler/gitops-reverser/issues/388)) ([a087901](https://github.com/ConfigButler/gitops-reverser/commit/a0879014c8a036fe4a7843e0cee636496c45001d))

## [0.49.1](https://github.com/ConfigButler/gitops-reverser/compare/v0.49.0...v0.49.1) (2026-09-22)

Expand Down
10 changes: 6 additions & 4 deletions api/v1alpha3/commitrequest_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,12 @@ type CommitRequestSpec struct {
// The pattern, the CEL bound and the wider-than-Flux unit set are the shape every duration in
// this API takes, and GitTargetCommitSpec.Window carries the reasoning for all three.

// CloseDelay sets the finalize deadline from the worker's first receipt, as a Go duration
// string ("2s", "750ms", "1m"). Time waiting for a matching window consumes this delay;
// repeated receipt keeps the deadline. Normal flush triggers can close an attached window
// early, carrying its message. A request claims at most one open window and cannot rename a
// CloseDelay is how long the request waits for a matching window from the worker's first
// receipt, and how long a window it claims keeps collecting from the claim, as a Go duration
// string ("2s", "750ms", "1m"). Time spent waiting does not shorten the collection, so a
// request created before its writes still gets the whole delay after them; repeated receipt
// keeps the waiting deadline. Normal flush triggers can close an attached window early,
// carrying its message. A request claims at most one open window and cannot rename a
// finalized commit. At most "5m".
// Defaults to "2s", which covers the time a write spends waiting for its audit fact before
// the commit window opens; an explicit "0s" requests immediate finalization and will usually
Expand Down
10 changes: 6 additions & 4 deletions config/crd/bases/configbutler.ai_commitrequests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,12 @@ spec:
closeDelay:
default: 2s
description: |-
CloseDelay sets the finalize deadline from the worker's first receipt, as a Go duration
string ("2s", "750ms", "1m"). Time waiting for a matching window consumes this delay;
repeated receipt keeps the deadline. Normal flush triggers can close an attached window
early, carrying its message. A request claims at most one open window and cannot rename a
CloseDelay is how long the request waits for a matching window from the worker's first
receipt, and how long a window it claims keeps collecting from the claim, as a Go duration
string ("2s", "750ms", "1m"). Time spent waiting does not shorten the collection, so a
request created before its writes still gets the whole delay after them; repeated receipt
keeps the waiting deadline. Normal flush triggers can close an attached window early,
carrying its message. A request claims at most one open window and cannot rename a
finalized commit. At most "5m".
Defaults to "2s", which covers the time a write spends waiting for its audit fact before
the commit window opens; an explicit "0s" requests immediate finalization and will usually
Expand Down
3 changes: 2 additions & 1 deletion docs/INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ decided), **design, decided** (decision made, not built), **partly built**, **bu
**deferred** (parked, kept as a decision record). The label is the first thing in the page,
so you never have to read a proposal to find out it already shipped.

### Open — 19 pages
### Open — 23 pages

| Doc | Open question |
|---|---|
Expand All @@ -139,6 +139,7 @@ so you never have to read a proposal to find out it already shipped.
| [`label-selection-follow-ups.md`](design/label-selection-follow-ups.md) | **design, open.** What the label-selection review rounds left open, with the evidence for each: real deletions in a selected collection wait the full 3s attribution grace (the watch cannot tell them from a label exit, measured), the resync fence that stops overlapping collections from coalescing (kept on purpose), same-second selector-conflict ties, and one store rebuild per object coming back. Also lists what was fixed on the branch |
| [`attribution-removal-wait-options.md`](design/attribution-removal-wait-options.md) | a removal now waits for evidence about the DELETION rather than accepting the object's last write, which stopped it naming whoever last edited the object as the author of a deletion they did not perform. Enumerates the eight situations a resolution can be in and shows the cost is concentrated in exactly one: a removal for which no delete fact will ever arrive (a graceful pod delete, a status-only removal, a type the audit policy skips) spends the whole grace to return the answer it had at t=0, measured at ~3.1s against ~70ms when evidence is present. Prices five options against that, and recommends a per-route watermark — stop waiting once the fact stream has demonstrably moved past this event — over a second timeout flag whose right value lives in the API server's config rather than ours. Open: the decision, and how common the case is outside the e2e suite |
| [`commitrequest-save-wait-options.md`](design/commitrequest-save-wait-options.md) | **design, decided** — a five-phase plan, reported from a live cluster: a `CommitRequest` can expire before the write it exists to publish arrives, because the close delay defaulted to `0` while the write is still held head-of-line waiting for an audit fact, so the save resolves `Ready=True`/`NoWindowInGrace` and the edit commits seconds later under the target's `liveTemplate`. The decision is to repair the default (`spec.closeDelay`, now a duration string defaulting to `"2s"`, **shipped as phase 0**) and then let a request **name the write it is waiting for**, reusing the `(uid, resourceVersion)` identity the attribution join already keys on, including its settled `ExactCapable` rule. Records why the fact index cannot BE the wait (it sits upstream of the watch event, so a fact-keyed wait fires before the window opens) and what to take from it anyway — the register-then-check waiter discipline, and a best-effort retrospective oracle bounded by a TUNABLE TTL. Twelve situations, and the page is deliberate about the two it cannot fix cheaply: row 11 (a saturated queue is rejected in the WATCH path and never reaches the worker, so no worker-side wait can diagnose it) is an accepted, named gap rather than a promise, and row 10 is fixed only by a hook in the fidelity-drop branch that marks a wait terminally UNSATISFIABLE — satisfying before that gate would arm a finalize for a write that never commits. Carries a **preconditions chapter** for saves on today's build (audit policy first, then delay sizing, fidelity warm-up, save-button debounce, and assert on the commit rather than on `Ready`). `CommitterActivity` is deferred, not recommended: by its own arrival-not-existence rule it cannot satisfy the already-arrived case. Open: situation 12 (one actor, two saves, one window — blocks phase 2), whether the fact-index lookup earns its coupling, and how an unsatisfied wait is graded |
| [`commit-timing-surface.md`](design/commit-timing-surface.md) | **design.** One configuration surface for commit windows and saves, an in-place `v1alpha3` break with one tested upgrade procedure: only writes open a window; a `window` block (`idleTimeout`, `maxDuration`, target default `1m`) on both kinds, which a `CommitRequest` replaces whole for the window it attaches to; `attach: CurrentOrNext \| Next` with `attachTimeout`; opt-in `whenNothingToCommit: CommitEmpty` that keeps the cause in the reason. Precise clock and zero rules. Supersedes the save-wait timer model. Open: the target's `maxDuration` value |
| [`watch-and-catalog-architecture.md`](design/watch-and-catalog-architecture.md) | the target three-layer watch model — **needs a human call before building** |
| [`metrics-observability-plan.md`](design/metrics-observability-plan.md) | **built.** the canonical metrics document, rewritten 2026-09 and shipped in the same wave. Reads the product as two ingestion paths — object state through watch, authorship through audit — joining at the resolver, and gives each stage one counter with a bounded `outcome` so the flow and its loss paths are drawable. Records what three review rounds corrected, including two reversals worth not re-litigating: the fact-loss counters must NOT be merged (an eviction counts a fact, a trim gap an occurrence, a decode error a whole batch), and a cumulative counter cannot replace a current count, which is why `status.streams` and `status.retainedDocuments` stayed. The instrument inventory lives in [`interpreting-metrics.md`](interpreting-metrics.md), not here. Open: the Grafana JSON and alert rules |
| [`reconcile-triggering.md`](design/reconcile-triggering.md) | which controllers still fail to wake up |
Expand Down
18 changes: 17 additions & 1 deletion docs/UPGRADING.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,20 @@ guidance that the changelog's breaking-change entries link to.
We are pre-1.0, so breaking changes bump the **minor** version (release-please is configured with
`bump-minor-pre-major`) rather than the major. Read the relevant entry before upgrading across it.

## A `CommitRequest`'s `closeDelay` restarts when it claims a window

`closeDelay` used to be one deadline counted from when the worker received the request. Time spent
waiting for a matching window came out of it. It now bounds two phases: the request waits up to
`closeDelay` from receipt for a matching window, and a window it claims keeps collecting for
`closeDelay` from the claim. The field, its default and its bounds are unchanged.

A request whose window is already open when it arrives behaves as before. A request created before
the writes it is saving now gets the whole delay after its first write, instead of whatever was left.
Its commit can be finalized up to one `closeDelay` later than it was, and the worst case from receipt
to the finalize is twice the delay; the push follows as before. If you had raised `closeDelay` to make
room for a slow first write, it now only needs to cover the longer of that gap and the span of the
save's writes. See [sizing `closeDelay`](configuration.md#sizing-closedelay).

## Finished CommitRequests are deleted after 48 hours

A `CommitRequest` that finishes after the upgrade gets a `configbutler.ai/delete-after` annotation
Expand All @@ -18,7 +32,9 @@ before the upgrade have no annotation and are kept. To keep new ones as well, se

The manager's ClusterRole includes the `patch` and `delete` verbs on `commitrequests`. A deployment
that renders its own RBAC must add them. Without `patch` no annotation is written and requests
are kept. Without `delete`, requests stay after their time has passed.
are kept, and each such failure is logged. Without `delete`, requests stay after their time has
passed, and the controller logs a `Reconciler error` with a Forbidden cause for each one it tries to
delete.

## Placement `source="canonical"` is now `source="builtin"`

Expand Down
5 changes: 3 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -1363,8 +1363,9 @@ immediately. That is not a failure:
1. The controller stamps the in-progress conditions (`Reconciling=True`) and settles
`AuthorAttributed` synchronously from the admission author cache. There is no audit wait on this path.
2. The controller eagerly **attaches** the request to the worker (`AttachCommitRequest`), anchoring the
finalize at `receipt + closeDelay`. The worker binds it to an open window only when the author
state and GitTarget match. It **never finalizes another author's window**; a window carries at most one request.
wait for a window at `receipt + closeDelay`. The worker binds it to an open window only when the author
state and GitTarget match, and restarts the deadline at `claim + closeDelay`, so time spent waiting
never shortens the collection. It **never finalizes another author's window**; a window carries at most one request.
3. The window finalizes on the deadline (or when it closes for any other reason). If a finalize closes an
open window, the worker always schedules a push, so a window closed by an otherwise no-op resync is not
stranded.
Expand Down
Loading
Loading