Skip to content

Commit 65abf54

Browse files
sunibclaude
andauthored
feat(commit): let a GitTarget frame a save request's message (#364)
* feat(commit): let a GitTarget frame a save request's message A CommitRequest's message REPLACED the commit template rather than feeding it, so supplying one cost the resource body liveTemplate would have produced. "Why I saved" and "what was saved" were mutually exclusive: the commit said the reason and stopped naming the resources. The obvious fix is to template `CommitRequest.spec.message`, and it is the wrong one. That spec is immutable, its bytes are committed verbatim, and nothing parses them, which together are what make a request auditable against the commit it produced. Templating it would also cross a trust boundary: a GitTarget is operator-owned, while a CommitRequest is created by whoever holds create on it in a namespace. Decisively, it could not be validated. Commit templates run with missingkey=error, and ValidateCommitConfig exists to render every one of them against sample data at GitTarget admission precisely because "failing at admission is the difference between a rejected GitTarget and a commit that dies mid-window months later". A CommitRequest gets no such protection: a runtime fault surfaces at finalize, where it costs the whole window (other authors' retained events included), on an immutable object nobody can repair. So it is inverted. The operator's template sees the request's message instead. `GitTarget.spec.commit.message.requestTemplate` renders for a window a request attached to, with the message arriving as `.RequestMessage` and committed unaltered. Omit it and behaviour is byte-for-byte what it was. Three properties make it safe rather than merely convenient: - The request is still never parsed. Nothing a requester writes is executed; they own the content, the operator owns the wording around it. - A requestTemplate that never renders `.RequestMessage` is REJECTED. Without this the feature reintroduces the exact fault it was designed around — a target could frame the reason away entirely and the commit would still count as request-sourced. The check renders the template and looks for a probe in the OUTPUT rather than scanning the source, so `{{.RequestMessage}}`, a pipeline, and a variable all pass for the right reason: the message reached the commit. - Both live templates are now validated against ONE shared set of window shapes. Validating requestTemplate against a single sample was not enough and the tests caught it: the labelled sample carries `team`, so a template reading that label passed admission and would have fallen back at runtime for every resource without it. They face identical windows in production; checking one more thoroughly than the other only moves which template fails late. A requestTemplate that fails to render at finalize commits the request's message verbatim rather than losing the window. That deviates from liveTemplate, where a render failure fails the write, and the deviation is the point: here a correct, non-lossy answer is always in hand because the literal already passed admission, so failing would discard the requester's save and everyone else's retained events to punish a formatting mistake. framedRequestMessage returns no error at all, and cannot — "this failed" is not an outcome it can report. That fallback is a SUCCESSFUL commit, so nothing is refused and no condition moves, which would leave "the template works" and "the template silently stopped applying" indistinguishable. git_commits_total therefore gains `commit_request_framed` and `commit_request_fallback`; `commit_request` keeps its meaning, so existing dashboards are unaffected. The source is stamped onto the write at commit time the way CommitSHA already is, because publishCommitsForPush runs after the push and recomputing it from the write cannot know a render failed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(e2e): retry a seed push that loses to the controller, and tighten framing Review follow-ups, plus the flake the first e2e run exposed. seedRenderedFolderIntoRepo pushed straight into a branch the controller is already mirroring, with no retry. It reads the remote tip, builds a commit on it and pushes under the remote's compare-and-swap, so a controller commit landing in that window rejects it: ! [remote rejected] HEAD -> main (incorrect old value provided) That is the test's own push failing, not the controller, and losing the race says nothing about the behaviour under test. Each attempt now rebuilds the commit on the new tip. The helper is shared by six spec files, so one unretried push was a flake source for all of them; it surfaced as the unsupported-folder spec failing locally while the same spec passed on CI's full-manager leg for the same commit. The attempt returns errors rather than asserting: an Expect inside Eventually would fail the spec on the first lost race, which is exactly what the retry is there to survive. A retry landing on a tip that already carries the identical content stages nothing, and `git commit` with nothing staged is an error — that case is success, because the fixture is in Git, which is all the caller asked. Three review points on the framing change itself: - The requestTemplate arm now validates the literal message too, instead of going straight to the render. The literal reaches Git on BOTH outcomes of that arm (framed on success, bare on the fallback), so framing must not become a route that smuggles a message past the check the verbatim arm enforces. The controller validates earlier, so this is not reachable through the normal flow today — it is the kind of invariant that stops holding the moment a second producer of PendingWrite appears. Tested on both arms together, so framing cannot change what is accepted. - The CRD field description is back to the contract: when it renders, what fields it gets, that the message is literal, and what omitting or dropping it does. The rationale moves to a block above the doc comment, separated by the blank line that keeps controller-gen from putting it in the schema (AGENTS.md). Verified the way that section prescribes: regenerated to a scratch dir and compared with every description stripped — zero structural diffs, so no kubebuilder marker was displaced. - docs/configuration.md listed RequestMessage among liveTemplate's fields, which is misleading: it is always empty there, because liveTemplate only runs for windows that have no request message. Moved to requestTemplate, with the reason stated so nobody reaches for {{if .RequestMessage}} and waits for it to fire. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(commit): bind framing to the grouped kind, and mint a fresh probe Two holes in the requestTemplate work, both reported by CodeRabbit on #364. The validation probe was a package constant, so the check could be satisfied without doing the thing it verifies: a template emitting that literal string would pass while never referencing .RequestMessage. Nobody writes that on purpose, but the whole argument for rejecting a message-dropping template was that the guarantee must hold by construction rather than by good intentions, and a spoofable sentinel does not carry that. The probe is now minted per validation from crypto/rand, so rendering the message is the only way to pass. Framing was bound to "carries a message" rather than to the kind. A CommitRequest attaches its message to an open live window, which finalizes as PendingWriteCommit — but an atomic snapshot can carry a message too (WriteRequest.CommitMessage), and it was being framed with a live context written for save requests, rerouting the documented atomic path through the wrong template. Not reachable in production today, since the only WriteRequest producer hardcodes an empty message and EnqueueRequest has no production callers; that is exactly the kind of invariant that stops holding the moment a second producer appears, which is the same reason the literal validator was added to this arm earlier. An atomic write with a message now keeps the behaviour it had before requestTemplate existed: committed verbatim. Tests cover both: a template faking either probe shape is rejected, successive probes differ, and atomic/resync writes resolve exactly as they did before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent d0601a5 commit 65abf54

17 files changed

Lines changed: 735 additions & 62 deletions

‎api/v1alpha3/gitprovider_types.go‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,28 @@ type CommitMessageSpec struct {
184184
// +optional
185185
LiveTemplate string `json:"liveTemplate,omitempty"`
186186

187+
// Rationale, kept out of the doc comment (and so out of the CRD schema) by the blank line
188+
// below: a CommitRequest is created by whoever holds create on it in a namespace, so its
189+
// author supplies the message but must not set commit-message policy. Framing the message
190+
// rather than parsing it keeps that split — the operator owns the wording, the requester owns
191+
// the words, and nothing a requester writes is ever executed. Rejecting a template that never
192+
// renders RequestMessage is what keeps the split honest in the other direction: silently
193+
// discarding a requester's stated reason would be worse than having no template at all.
194+
195+
// RequestTemplate frames a CommitRequest's message instead of committing it verbatim. It
196+
// renders only for a commit window whose attached request supplied a message; every other
197+
// window uses liveTemplate or reconcileTemplate.
198+
//
199+
// It receives the same fields as liveTemplate, plus RequestMessage carrying the request's
200+
// message unaltered. That message is never parsed as a template, so template syntax inside it
201+
// stays literal.
202+
//
203+
// Omitted, a request's message is committed exactly as supplied. A template that never renders
204+
// RequestMessage is rejected. One that fails to render at commit time commits the message
205+
// verbatim instead, counted as message_source="commit_request_fallback".
206+
// +optional
207+
RequestTemplate string `json:"requestTemplate,omitempty"`
208+
187209
// ReconcileTemplate formats atomic snapshots and resyncs.
188210
// Fields: Count, GitTarget, Group, Version, Resource, APIVersion, Namespace, Revision.
189211
// Type and Namespace fields are empty for whole-target snapshots. Revision can be empty.

‎config/crd/bases/configbutler.ai_gittargets.yaml‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,20 @@ spec:
172172
Type and Namespace fields are empty for whole-target snapshots. Revision can be empty.
173173
Guard optional fields so the message remains meaningful for every snapshot scope.
174174
type: string
175+
requestTemplate:
176+
description: |-
177+
RequestTemplate frames a CommitRequest's message instead of committing it verbatim. It
178+
renders only for a commit window whose attached request supplied a message; every other
179+
window uses liveTemplate or reconcileTemplate.
180+
181+
It receives the same fields as liveTemplate, plus RequestMessage carrying the request's
182+
message unaltered. That message is never parsed as a template, so template syntax inside it
183+
stays literal.
184+
185+
Omitted, a request's message is committed exactly as supplied. A template that never renders
186+
RequestMessage is rejected. One that fails to render at commit time commits the message
187+
verbatim instead, counted as message_source="commit_request_fallback".
188+
type: string
175189
type: object
176190
x-kubernetes-validations:
177191
- message: eventTemplate is retired; migrate to liveTemplate

‎docs/UPGRADING.md‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,35 @@ guidance that the changelog's breaking-change entries link to.
77
We are pre-1.0, so breaking changes bump the **minor** version (release-please is configured with
88
`bump-minor-pre-major`) rather than the major. Read the relevant entry before upgrading across it.
99

10+
## Save messages can be framed by the GitTarget
11+
12+
**Not breaking.** `GitTarget.spec.commit.message.requestTemplate` is new and optional; omit it and
13+
`CommitRequest.spec.message` is committed verbatim exactly as before.
14+
15+
Until now a save request's message REPLACED the commit template, so supplying one lost the resource
16+
body `liveTemplate` would have produced. `requestTemplate` composes them:
17+
18+
```yaml
19+
spec:
20+
commit:
21+
message:
22+
requestTemplate: |-
23+
{{.RequestMessage}}
24+
25+
{{range .Resources -}}
26+
- [{{.Operation}}] {{.APIVersion}}/{{.Resource}}/{{.Namespace}}/{{.Name}}
27+
{{end -}}
28+
```
29+
30+
`CommitRequest.spec.message` stays literal and is still never parsed as a template — it arrives as
31+
`.RequestMessage` and is committed unaltered. A `requestTemplate` that never renders it is rejected
32+
with `Validated=False`.
33+
34+
`git_commits_total` gains two `message_source` values, `commit_request_framed` and
35+
`commit_request_fallback`. The existing `commit_request` keeps its meaning (a verbatim message with
36+
no `requestTemplate` configured), so dashboards reading it are unaffected. Alert on the fallback
37+
rate: it is a successful commit, so it is the only signal that a template has stopped applying.
38+
1039
## Watch reconnects no longer report as failures
1140

1241
**Not breaking, but two observable surfaces move.** Neither needs a manifest change; both may need

‎docs/configuration.md‎

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -575,10 +575,13 @@ write rather than stopping the mirror.
575575
`reconcileTemplate` formats atomic snapshots and resyncs. Invalid templates report
576576
`Validated=False` with reason `InvalidConfig`; validation exercises singleton and mixed-operation
577577
windows, empty authors, and scoped and whole-target snapshots through the production renderer.
578-
Sample execution cannot prove every possible conditional branch valid.
578+
Sample execution cannot prove every possible conditional branch valid. That applies to
579+
`requestTemplate`'s [message check](#framing-a-save-message) too: a template that drops the save
580+
message only on some branch passes validation and falls back at commit time.
579581
580582
| Input, in precedence order | Message source |
581583
|---|---|
584+
| Attached [save request](#commitrequest) **and** `requestTemplate` set | `requestTemplate`, with the request's message as `.RequestMessage` |
582585
| Non-empty literal override, including an attached [save request](#commitrequest) | Exact supplied text |
583586
| Live window of any size | `liveTemplate` |
584587
| Atomic snapshot or resync | `reconcileTemplate` |
@@ -601,6 +604,7 @@ spec:
601604
| Template | Fields |
602605
|---|---|
603606
| `liveTemplate` | `Author`, `GitTarget`, `Count`, `Operations`, `Resources`, and the `LabelValues` / `LabelValue` accessors |
607+
| `requestTemplate` | the same fields, plus `RequestMessage` |
604608
| Each `Resources` entry | `Operation`, `Group`, `Version`, `Resource`, `Kind`, `Namespace`, `Name`, `APIVersion`, `Labels`, and the `Label` accessor |
605609
| `reconcileTemplate` | `Count`, `GitTarget`, `Group`, `Version`, `Resource`, `APIVersion`, `Namespace`, `Revision` |
606610

@@ -611,10 +615,54 @@ first-seen order. An entry already matching Git still counts, and several entrie
611615
The count can exceed the number of changed resources. A no-op creates no commit, even with a literal
612616
message. Printing a resource entry directly keeps its `group/version/resource[/namespace]/name` form.
613617

618+
`RequestMessage` belongs to `requestTemplate`. It exists on the live context too, but is always
619+
empty there: a window carrying a save request's message renders `requestTemplate` when one is
620+
configured and the literal message when one is not, so `liveTemplate` only ever runs for windows
621+
that have no request message. Do not reach for `{{if .RequestMessage}}` inside `liveTemplate`;
622+
it never fires.
623+
614624
`Author` is the raw window username and is empty when no actor is named. It does not use an OIDC
615625
display name or the `attribution-unresolved` Git author sentinel. The sentinel appears only in the
616626
Git author header when attribution ran without resolving an actor. Messages never change authorship.
617627

628+
##### Framing a save message
629+
630+
By default a [save request](#commitrequest)'s message **replaces** the template, so supplying one
631+
costs you the resource body `liveTemplate` would have produced: the commit says why, but no longer
632+
says what. `requestTemplate` composes the two.
633+
634+
```yaml
635+
spec:
636+
commit:
637+
message:
638+
requestTemplate: |-
639+
{{.RequestMessage}}
640+
641+
{{range .Resources -}}
642+
- [{{.Operation}}] {{.APIVersion}}/{{.Resource}}/{{.Namespace}}/{{.Name}}
643+
{{end -}}
644+
```
645+
646+
It renders only for a window a save request attached to; every other window is unaffected. Omit it
647+
and request messages are committed verbatim, exactly as before.
648+
649+
The request is **never** parsed as a template. Its message arrives as `.RequestMessage` and is
650+
committed unaltered, so a save-button user supplies the content while the operator owns the
651+
wording around it. Nothing a requester writes is ever executed, and braces inside a request message
652+
stay literal in every case.
653+
654+
A `requestTemplate` that never renders `.RequestMessage` is **rejected** with `Validated=False`,
655+
because dropping the requester's stated reason is the one thing this field must not do. Every
656+
spelling that puts the message in the commit is accepted (`{{.RequestMessage}}`, a pipeline, or a
657+
variable), because the check renders the template and looks for the message in the output rather
658+
than scanning the template's text.
659+
660+
`requestTemplate` is validated against the same window shapes as `liveTemplate`, so a template
661+
reading a label some resources do not carry fails at admission rather than at commit time. If one
662+
does fail to render in production, the request's message is committed verbatim instead of the
663+
window being lost, and the commit is counted under `message_source="commit_request_fallback"`.
664+
Alert on that rate: the commit itself succeeds and no condition moves, so it is the only signal.
665+
618666
##### Kind, scope, and labels
619667

620668
`Kind` is the commit-message spelling of the `{kind}` [placement variable](#template-variables),
@@ -1614,7 +1662,9 @@ The entire spec is immutable. Create a new `CommitRequest` for each save attempt
16141662
A present message accepts 1–1024 Unicode characters, including newline. All other ASCII control
16151663
characters, including tab, carriage return, and DEL, are rejected, as is whitespace-only text.
16161664
Accepted surrounding spaces are preserved. Braces such as `{{.Author}}` remain literal; omission
1617-
uses [the live template](#commit-message-templates). A rejected request leaves automatic mirroring
1665+
uses [the live template](#commit-message-templates). To frame a save message with what was saved,
1666+
configure [`requestTemplate`](#framing-a-save-message) on the GitTarget. The request is still never
1667+
parsed as a template. A rejected request leaves automatic mirroring
16181668
available. The submitter chooses any semantic prefix; free-form messages are accepted.
16191669

16201670
A request attaches to at most one matching open window. Normal flush triggers may close it early;

‎docs/interpreting-metrics.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,23 @@ serve several GitTargets sharing a provider+branch, coalescing their writes into
353353
the worker is the honest attribution unit. `author_kind` is `user`, `serviceaccount`, `committer`, or `unresolved`;
354354
reconcile/resync commits and configured-author mode use `committer`.
355355

356+
`message_source` is `live`, `reconcile`, or one of three request values: `commit_request` (a save
357+
request's message committed verbatim, no `requestTemplate` configured), `commit_request_framed`
358+
(`requestTemplate` rendered), and `commit_request_fallback` (`requestTemplate` failed to render, so
359+
the message was committed verbatim instead).
360+
361+
The fallback is the one to alert on. It is a **successful** commit — nothing is refused and no
362+
condition moves — so without this counter a `requestTemplate` that has quietly stopped applying
363+
looks exactly like one that was never configured:
364+
365+
```promql
366+
sum by (provider_namespace, provider_name) (
367+
rate(gitopsreverser_git_commits_total{message_source="commit_request_fallback"}[15m])
368+
) > 0
369+
```
370+
371+
`framed / (framed + fallback)` is the health ratio for the feature.
372+
356373
**`unresolved` is the one to watch.** It means attribution RAN and did not name an actor, so
357374
the commit carries the `unknown (attribution unresolved)` author instead of a person. It is
358375
deliberately not folded into `user` (which would make a lost actor look like a named one, so a

‎docs/spec/commitrequest-design.md‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,13 @@ at most one window and cannot rename a finalized commit, including one waiting f
3131
provides no ordering guarantee; use a non-zero window for custom save messages. The delay does not
3232
reserve a transaction. Competing requests keep the earliest-finalize-deadline selection policy.
3333

34-
`spec.message` is literal, including template-like text and surrounding spaces. Omission uses
35-
`GitTarget.spec.commit.message.liveTemplate`. A present value accepts 1–1024 Unicode characters;
34+
`spec.message` is literal, including template-like text and surrounding spaces. It is never parsed
35+
as a template, so a request author cannot execute one. Omission uses
36+
`GitTarget.spec.commit.message.liveTemplate`. A target may set
37+
`GitTarget.spec.commit.message.requestTemplate` to frame the message with the window's resources;
38+
the message still arrives unaltered, as `.RequestMessage`, and a template that does not render it
39+
is rejected at admission, so the request's bytes always reach the commit. A `requestTemplate` that
40+
fails to render commits the message verbatim rather than losing the window. A present value accepts 1–1024 Unicode characters;
3641
newline is allowed, other ASCII controls and whitespace-only text are rejected. Validation never
3742
truncates accepted text. A no-op still creates no commit. The message does not change Git identities.
3843

‎internal/git/commit.go‎

Lines changed: 101 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ package git
44

55
import (
66
"bytes"
7+
"crypto/rand"
8+
"encoding/hex"
9+
"errors"
710
"fmt"
811
"regexp"
912
"strings"
@@ -68,11 +71,20 @@ func renderReconcileCommitMessage(
6871
}
6972

7073
func renderLiveCommitMessage(pendingWrite PendingWrite, config CommitConfig) (string, error) {
71-
return renderCommitTemplate(
72-
"live",
73-
config.Message.LiveTemplate,
74-
buildLiveCommitMessageData(pendingWrite.Author(), pendingWrite.Target().Name, pendingWrite.Events),
75-
)
74+
return renderCommitTemplate("live", config.Message.LiveTemplate, pendingWrite.liveMessageData())
75+
}
76+
77+
// renderRequestCommitMessage frames an attached CommitRequest's message with the target's
78+
// requestTemplate. The literal rides in as .RequestMessage rather than being parsed, so nothing a
79+
// requester wrote is ever executed.
80+
func renderRequestCommitMessage(pendingWrite PendingWrite, config CommitConfig) (string, error) {
81+
return renderCommitTemplate("request", config.Message.RequestTemplate, pendingWrite.liveMessageData())
82+
}
83+
84+
// liveMessageData is the template context both live renders share. One builder, because a framed
85+
// request commit is a live window that happens to carry a message — not a different kind of commit.
86+
func (p PendingWrite) liveMessageData() LiveCommitMessageData {
87+
return buildLiveCommitMessageData(p.Author(), p.Target().Name, p.CommitMessage, p.Events)
7688
}
7789

7890
func renderCommitTemplate(name, text string, data any) (string, error) {
@@ -139,6 +151,30 @@ func ValidateCommitConfig(config CommitConfig) error {
139151
// not carry "team", and failing at admission is the difference between a rejected GitTarget
140152
// and a commit that dies mid-window months later. "{{.Label \"team\"}}" renders empty and
141153
// passes both.
154+
for _, events := range liveValidationSamples(sampleEvent) {
155+
if _, err := renderLiveCommitMessage(PendingWrite{
156+
Kind: PendingWriteCommit, Events: events,
157+
}, config); err != nil {
158+
return err
159+
}
160+
if err := validateRequestTemplate(config, events); err != nil {
161+
return err
162+
}
163+
}
164+
165+
return nil
166+
}
167+
168+
// liveValidationSamples is the set of window shapes both live-message templates are validated
169+
// against: growing windows, mixed operations, an empty author, and — the important half — a
170+
// resource that carries an object with labels beside ones that carry neither, because these
171+
// templates run with missingkey=error and it is the render WITHOUT the label that fails.
172+
//
173+
// It is shared so liveTemplate and requestTemplate cannot drift into being checked against
174+
// different worlds. They face identical windows at runtime; checking one more thoroughly than the
175+
// other just moves which template fails months later instead of at admission.
176+
func liveValidationSamples(sampleEvent Event) [][]Event {
177+
var samples [][]Event
142178
for _, author := range []string{"template-validator", ""} {
143179
var events []Event
144180
for _, operation := range []string{"CREATE", "UPDATE", "DELETE"} {
@@ -150,14 +186,69 @@ func ValidateCommitConfig(config CommitConfig) error {
150186
event.Object = sampleLabeledObject()
151187
}
152188
events = append(events, event)
153-
if _, err := renderLiveCommitMessage(PendingWrite{
154-
Kind: PendingWriteCommit, Events: events,
155-
}, config); err != nil {
156-
return err
157-
}
189+
samples = append(samples, append([]Event(nil), events...))
158190
}
159191
}
192+
return samples
193+
}
194+
195+
// newRequestTemplateProbe mints the sentinel that requestTemplate validation renders as the
196+
// request's message.
197+
//
198+
// Fresh per call, and unpredictable, because the check asks "did the message reach the commit?" by
199+
// looking for this string in the rendered output. A FIXED sentinel answers a weaker question: a
200+
// template that emits the constant itself would pass while never referencing .RequestMessage at
201+
// all. Nobody would write that on purpose, but a check that can be satisfied without doing the
202+
// thing it verifies is not a check.
203+
func newRequestTemplateProbe() (string, error) {
204+
var raw [16]byte
205+
if _, err := rand.Read(raw[:]); err != nil {
206+
return "", fmt.Errorf("generate requestTemplate probe: %w", err)
207+
}
208+
return "gitops-reverser-request-probe-" + hex.EncodeToString(raw[:]), nil
209+
}
160210

211+
// validateRequestTemplate checks that a configured requestTemplate renders, AND that it actually
212+
// puts the request's message in the commit.
213+
//
214+
// The second half is the point. Without it the feature has a hole exactly as bad as the one it was
215+
// designed to avoid: a target could set `requestTemplate: "chore: sync {{.Count}} resources"` and
216+
// every save message would silently vanish — the requester writes a reason, the commit never
217+
// carries it, and the commit is still counted as request-sourced. Framing the message is the whole
218+
// purpose of the field, so a template that drops it is a mistake, not a configuration choice.
219+
//
220+
// It probes the RENDERED OUTPUT rather than scanning the template source. A scan for the literal
221+
// "{{.RequestMessage}}" would reject `{{.RequestMessage | printf "%s"}}`, a template that assigns
222+
// it to a variable first, and every other legitimate spelling — while the probe accepts all of them
223+
// for the right reason: the message reached the commit.
224+
//
225+
// Sample execution cannot prove every branch: a template that drops the message only under, say,
226+
// {{if eq .Count 1}} still passes. That is the same caveat docs/configuration.md already states for
227+
// the other templates, not a new one.
228+
func validateRequestTemplate(config CommitConfig, events []Event) error {
229+
if config.Message.RequestTemplate == "" {
230+
return nil
231+
}
232+
233+
probe, err := newRequestTemplateProbe()
234+
if err != nil {
235+
return err
236+
}
237+
rendered, err := renderRequestCommitMessage(PendingWrite{
238+
Kind: PendingWriteCommit,
239+
CommitMessage: probe,
240+
Events: events,
241+
}, config)
242+
if err != nil {
243+
return err
244+
}
245+
// EVERY sample must carry the message through, not merely one: the contract is that a
246+
// requester's reason reaches the commit whatever the window happened to contain.
247+
if !strings.Contains(rendered, probe) {
248+
return errors.New("requestTemplate must render {{.RequestMessage}}: as written it would " +
249+
"drop the CommitRequest's message from the commit. Omit requestTemplate to commit that " +
250+
"message verbatim")
251+
}
161252
return nil
162253
}
163254

0 commit comments

Comments
 (0)