feat(deploy): commit-status reporting to Git providers

Report deploy status back to the Git provider as a commit status
(pending/success/failure) for git-sourced workloads (static + dockerfile).

- GitProvider.SetCommitStatus on gitea/github/gitlab over the existing
  SSRF-safe client; fixed "tinyforge" context so redeploys update one row.
  postJSON returns status-code-only errors (never echoes the upstream body,
  which a hostile provider could use to reflect the auth token into the
  best-effort log line).
- Best-effort deploy hook: pending on deploy start, success/failure on
  outcome, gated on a per-workload report_commit_status flag. Never fails or
  blocks a deploy; emits nothing on the unchanged-SHA short-circuit.
- UI ToggleSwitch (create + edit) + reportCommitStatus in sourceForms.ts
  + en/ru i18n.
- Tests: per-provider state mapping + request shape; reporter gating
  (enabled/disabled/empty-SHA/nil/error-swallow).

Reviewed via go-reviewer + security-reviewer (0 CRITICAL/HIGH; one MEDIUM
body-echo log-leak fixed).
This commit is contained in:
2026-05-29 11:37:56 +03:00
parent 410a131cec
commit 3071cda512
17 changed files with 1051 additions and 10 deletions
+331
View File
@@ -0,0 +1,331 @@
package staticsite
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// ── State mapping (pure) ────────────────────────────────────────────
//
// Each provider maps the provider-agnostic CommitStatus onto its own API
// vocabulary. Gitea/GitHub accept the same four words; GitLab collapses
// failure+error into "failed".
func TestGiteaState_Mapping(t *testing.T) {
cases := map[CommitStatus]string{
CommitStatusPending: "pending",
CommitStatusSuccess: "success",
CommitStatusFailure: "failure",
CommitStatusError: "error",
CommitStatus("???"): "pending", // unknown -> pending fallback
}
for in, want := range cases {
if got := giteaState(in); got != want {
t.Errorf("giteaState(%q) = %q, want %q", in, got, want)
}
}
}
func TestGitHubState_Mapping(t *testing.T) {
cases := map[CommitStatus]string{
CommitStatusPending: "pending",
CommitStatusSuccess: "success",
CommitStatusFailure: "failure",
CommitStatusError: "error",
CommitStatus("???"): "pending",
}
for in, want := range cases {
if got := githubState(in); got != want {
t.Errorf("githubState(%q) = %q, want %q", in, got, want)
}
}
}
func TestGitLabState_Mapping(t *testing.T) {
cases := map[CommitStatus]string{
CommitStatusPending: "pending",
CommitStatusSuccess: "success",
CommitStatusFailure: "failed", // GitLab has no "failure"
CommitStatusError: "failed", // error also collapses to "failed"
CommitStatus("???"): "pending",
}
for in, want := range cases {
if got := gitlabState(in); got != want {
t.Errorf("gitlabState(%q) = %q, want %q", in, got, want)
}
}
}
func TestTruncateDescription(t *testing.T) {
short := "Tinyforge: deploying"
if got := truncateDescription(short); got != short {
t.Errorf("short description mutated: %q", got)
}
long := strings.Repeat("x", 200)
got := truncateDescription(long)
if len([]rune(got)) > maxCommitStatusDescription {
t.Errorf("truncated length = %d runes, want <= %d", len([]rune(got)), maxCommitStatusDescription)
}
if !strings.HasSuffix(got, "…") {
t.Errorf("missing ellipsis on truncation: %q", got)
}
}
// ── Endpoint + body construction (httptest) ─────────────────────────
//
// The SSRF-safe client refuses loopback, so for these tests we swap the
// provider's httpClient for a plain one pointed at httptest. This still
// exercises the real URL/body construction inside each SetCommitStatus.
type capturedRequest struct {
method string
path string // r.URL.EscapedPath() — preserves %2F so GitLab's encoded project path is observable
rawQ string
body map[string]string
auth string
token string // PRIVATE-TOKEN (GitLab)
}
func newCaptureServer(t *testing.T, capture *capturedRequest) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
capture.method = r.Method
capture.path = r.URL.EscapedPath()
capture.rawQ = r.URL.RawQuery
capture.auth = r.Header.Get("Authorization")
capture.token = r.Header.Get("PRIVATE-TOKEN")
raw, _ := io.ReadAll(r.Body)
if len(raw) > 0 {
_ = json.Unmarshal(raw, &capture.body)
}
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{}`))
}))
}
func TestGitea_SetCommitStatus_Request(t *testing.T) {
var cap capturedRequest
srv := newCaptureServer(t, &cap)
defer srv.Close()
f := NewGiteaContentFetcher(srv.URL, "tok123")
f.httpClient = srv.Client() // bypass SSRF guard for loopback test server
err := f.SetCommitStatus(context.Background(), "owner", "repo", "abc123",
CommitStatusSuccess, "https://app.example.com", "deployed")
if err != nil {
t.Fatalf("SetCommitStatus: %v", err)
}
if cap.method != http.MethodPost {
t.Errorf("method = %q, want POST", cap.method)
}
if want := "/api/v1/repos/owner/repo/statuses/abc123"; cap.path != want {
t.Errorf("path = %q, want %q", cap.path, want)
}
if cap.body["state"] != "success" {
t.Errorf("state = %q, want success", cap.body["state"])
}
if cap.body["context"] != "tinyforge" {
t.Errorf("context = %q, want tinyforge", cap.body["context"])
}
if cap.body["target_url"] != "https://app.example.com" {
t.Errorf("target_url = %q", cap.body["target_url"])
}
if cap.body["description"] != "deployed" {
t.Errorf("description = %q, want deployed", cap.body["description"])
}
if cap.auth != "token tok123" {
t.Errorf("auth = %q, want 'token tok123'", cap.auth)
}
}
func TestGitHub_SetCommitStatus_Request(t *testing.T) {
var cap capturedRequest
srv := newCaptureServer(t, &cap)
defer srv.Close()
// Force GHE-style apiBase so we hit the server's path; the github.com
// branch hard-codes api.github.com which the SSRF client would block.
g := NewGitHubProvider(srv.URL, "ghp_tok")
g.apiBase = srv.URL + "/api/v3"
g.httpClient = srv.Client()
err := g.SetCommitStatus(context.Background(), "octo", "cat", "deadbeef",
CommitStatusFailure, "", "failed")
if err != nil {
t.Fatalf("SetCommitStatus: %v", err)
}
if want := "/api/v3/repos/octo/cat/statuses/deadbeef"; cap.path != want {
t.Errorf("path = %q, want %q", cap.path, want)
}
if cap.body["state"] != "failure" {
t.Errorf("state = %q, want failure", cap.body["state"])
}
if cap.body["context"] != "tinyforge" {
t.Errorf("context = %q, want tinyforge", cap.body["context"])
}
if cap.auth != "Bearer ghp_tok" {
t.Errorf("auth = %q, want 'Bearer ghp_tok'", cap.auth)
}
}
func TestGitLab_SetCommitStatus_Request(t *testing.T) {
var cap capturedRequest
srv := newCaptureServer(t, &cap)
defer srv.Close()
g := NewGitLabProvider(srv.URL, "glpat-xyz")
g.httpClient = srv.Client()
err := g.SetCommitStatus(context.Background(), "grp", "proj", "cafe01",
CommitStatusError, "https://app.example.com", "boom")
if err != nil {
t.Fatalf("SetCommitStatus: %v", err)
}
// GitLab uses the URL-encoded project path + sha in the path, and the
// status metadata as query params.
if want := "/api/v4/projects/grp%2Fproj/statuses/cafe01"; cap.path != want {
t.Errorf("path = %q, want %q", cap.path, want)
}
q, err := parseQuery(cap.rawQ)
if err != nil {
t.Fatalf("parse query %q: %v", cap.rawQ, err)
}
if q["state"] != "failed" { // error -> failed
t.Errorf("state = %q, want failed", q["state"])
}
if q["name"] != "tinyforge" {
t.Errorf("name = %q, want tinyforge", q["name"])
}
if q["target_url"] != "https://app.example.com" {
t.Errorf("target_url = %q", q["target_url"])
}
if q["description"] != "boom" {
t.Errorf("description = %q, want boom", q["description"])
}
if cap.token != "glpat-xyz" {
t.Errorf("PRIVATE-TOKEN = %q, want glpat-xyz", cap.token)
}
}
// parseQuery is a tiny wrapper so the test reads the first value of each
// query key without dragging net/url into every assertion.
func parseQuery(raw string) (map[string]string, error) {
out := map[string]string{}
if raw == "" {
return out, nil
}
for _, pair := range strings.Split(raw, "&") {
kv := strings.SplitN(pair, "=", 2)
k := urlDecode(kv[0])
v := ""
if len(kv) == 2 {
v = urlDecode(kv[1])
}
if _, ok := out[k]; !ok {
out[k] = v
}
}
return out, nil
}
func urlDecode(s string) string {
dec, err := decodeQueryComponent(s)
if err != nil {
return s
}
return dec
}
// decodeQueryComponent decodes one application/x-www-form-urlencoded
// component (handles %XX and '+'-as-space) without importing net/url here.
func decodeQueryComponent(s string) (string, error) {
var b strings.Builder
for i := 0; i < len(s); i++ {
switch s[i] {
case '+':
b.WriteByte(' ')
case '%':
if i+2 >= len(s) {
return s, errPercent
}
hi, lo := fromHex(s[i+1]), fromHex(s[i+2])
if hi < 0 || lo < 0 {
return s, errPercent
}
b.WriteByte(byte(hi<<4 | lo))
i += 2
default:
b.WriteByte(s[i])
}
}
return b.String(), nil
}
var errPercent = &decodeErr{}
type decodeErr struct{}
func (*decodeErr) Error() string { return "bad percent-encoding" }
func fromHex(c byte) int {
switch {
case c >= '0' && c <= '9':
return int(c - '0')
case c >= 'a' && c <= 'f':
return int(c-'a') + 10
case c >= 'A' && c <= 'F':
return int(c-'A') + 10
}
return -1
}
// TestSetCommitStatus_NonOK_ReturnsError verifies a non-2xx provider
// response surfaces as an error (the deploy hook logs + swallows it, but
// the provider method itself must report it).
func TestSetCommitStatus_NonOK_ReturnsError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"message":"bad token"}`))
}))
defer srv.Close()
f := NewGiteaContentFetcher(srv.URL, "tok")
f.httpClient = srv.Client()
err := f.SetCommitStatus(context.Background(), "o", "r", "sha", CommitStatusPending, "", "x")
if err == nil {
t.Fatal("expected error on 401, got nil")
}
if !strings.Contains(err.Error(), "401") {
t.Errorf("error missing status code: %v", err)
}
}
// TestSetCommitStatus_RespectsContext ensures the call honours context
// cancellation (defensive — the deploy hook passes the deploy ctx).
func TestSetCommitStatus_RespectsContext(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(200 * time.Millisecond)
w.WriteHeader(http.StatusCreated)
}))
defer srv.Close()
f := NewGiteaContentFetcher(srv.URL, "")
f.httpClient = srv.Client()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
defer cancel()
if err := f.SetCommitStatus(ctx, "o", "r", "sha", CommitStatusPending, "", "x"); err == nil {
t.Fatal("expected context-deadline error, got nil")
}
}
+49
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
@@ -304,6 +305,54 @@ func (f *GiteaContentFetcher) TestConnection(ctx context.Context, owner, repo st
return nil
}
// SetCommitStatus reports a deploy status on a commit via Gitea's commit-
// status API (also serves Forgejo/Gogs). The "context" field is fixed to
// "tinyforge" so repeated deploys update one status row.
func (f *GiteaContentFetcher) SetCommitStatus(ctx context.Context, owner, repo, sha string, status CommitStatus, targetURL, description string) error {
state := giteaState(status)
body, err := json.Marshal(map[string]string{
"state": state,
"target_url": targetURL,
"description": truncateDescription(description),
"context": commitStatusContext,
})
if err != nil {
return fmt.Errorf("marshal status: %w", err)
}
// Path-escape each identifier so the URL shape matches the other
// provider methods and a hostile owner/repo/sha can't break out of
// the intended path. The SSRF-safe client guards the host.
apiURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/statuses/%s",
f.baseURL, url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(sha))
if err := postJSON(ctx, f.httpClient, apiURL, body, f.setAuth); err != nil {
return fmt.Errorf("set commit status: %w", err)
}
return nil
}
// setAuth applies the Gitea token header (no-op when the token is empty).
func (f *GiteaContentFetcher) setAuth(req *http.Request) {
if f.token != "" {
req.Header.Set("Authorization", "token "+f.token)
}
}
// giteaState maps a provider-agnostic CommitStatus onto Gitea's API
// vocabulary. Gitea accepts the same four words Tinyforge uses, so this is
// a 1:1 mapping with a "pending" fallback for any unknown value.
func giteaState(status CommitStatus) string {
switch status {
case CommitStatusSuccess:
return "success"
case CommitStatusFailure:
return "failure"
case CommitStatusError:
return "error"
default:
return "pending"
}
}
// doGet performs an authenticated GET request and returns the response body.
func (f *GiteaContentFetcher) doGet(ctx context.Context, url string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
+38
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
@@ -115,6 +116,43 @@ func (g *GitHubProvider) TestConnection(ctx context.Context, owner, repo string)
return err
}
// SetCommitStatus reports a deploy status on a commit via GitHub's commit-
// status API (works for github.com and GitHub Enterprise — apiBase already
// carries the /api/v3 suffix for GHE). The "context" field is fixed to
// "tinyforge" so repeated deploys update one status row.
func (g *GitHubProvider) SetCommitStatus(ctx context.Context, owner, repo, sha string, status CommitStatus, targetURL, description string) error {
body, err := json.Marshal(map[string]string{
"state": githubState(status),
"target_url": targetURL,
"description": truncateDescription(description),
"context": commitStatusContext,
})
if err != nil {
return fmt.Errorf("marshal status: %w", err)
}
apiURL := fmt.Sprintf("%s/repos/%s/%s/statuses/%s",
g.apiBase, url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(sha))
if err := postJSON(ctx, g.httpClient, apiURL, body, g.setAuth); err != nil {
return fmt.Errorf("set commit status: %w", err)
}
return nil
}
// githubState maps a provider-agnostic CommitStatus onto GitHub's API
// vocabulary. GitHub accepts the same four words Tinyforge uses.
func githubState(status CommitStatus) string {
switch status {
case CommitStatusSuccess:
return "success"
case CommitStatusFailure:
return "failure"
case CommitStatusError:
return "error"
default:
return "pending"
}
}
func (g *GitHubProvider) ListBranches(ctx context.Context, owner, repo string) ([]string, error) {
var allBranches []string
page := 1
+39
View File
@@ -95,6 +95,45 @@ func (g *GitLabProvider) TestConnection(ctx context.Context, owner, repo string)
return err
}
// SetCommitStatus reports a deploy status on a commit via GitLab's commit-
// status API. GitLab's state vocabulary differs (pending/running/success/
// failed/canceled), so failure AND error both map to "failed". The status
// metadata (name/target_url/description) is passed as query parameters,
// which is how GitLab's POST .../statuses/{sha} endpoint accepts them.
func (g *GitLabProvider) SetCommitStatus(ctx context.Context, owner, repo, sha string, status CommitStatus, targetURL, description string) error {
q := url.Values{}
q.Set("state", gitlabState(status))
q.Set("name", commitStatusContext)
if targetURL != "" {
q.Set("target_url", targetURL)
}
if description != "" {
q.Set("description", truncateDescription(description))
}
apiURL := fmt.Sprintf("%s/projects/%s/statuses/%s?%s",
g.apiBase, projectPath(owner, repo), url.PathEscape(sha), q.Encode())
// No JSON body — all fields ride as query params. Reuse postJSON for
// the SSRF-safe POST + 2xx handling; an empty body is valid here.
if err := postJSON(ctx, g.httpClient, apiURL, nil, g.setAuth); err != nil {
return fmt.Errorf("set commit status: %w", err)
}
return nil
}
// gitlabState maps a provider-agnostic CommitStatus onto GitLab's API
// vocabulary. GitLab has no "failure"/"error" split — both map to
// "failed".
func gitlabState(status CommitStatus) string {
switch status {
case CommitStatusSuccess:
return "success"
case CommitStatusFailure, CommitStatusError:
return "failed"
default:
return "pending"
}
}
func (g *GitLabProvider) ListBranches(ctx context.Context, owner, repo string) ([]string, error) {
var allBranches []string
page := 1
+71
View File
@@ -1,6 +1,7 @@
package staticsite
import (
"bytes"
"context"
"fmt"
"io"
@@ -21,6 +22,40 @@ type RepoInfo struct {
HTMLURL string `json:"html_url"`
}
// CommitStatus is the deploy outcome reported back to the git provider as
// a commit status. The values are provider-agnostic; each implementation
// maps them onto its own API vocabulary (Gitea/GitHub use the same four
// words, GitLab collapses failure/error into "failed").
type CommitStatus string
const (
CommitStatusPending CommitStatus = "pending"
CommitStatusSuccess CommitStatus = "success"
CommitStatusFailure CommitStatus = "failure"
CommitStatusError CommitStatus = "error"
)
// commitStatusContext is the status "context"/"name" key reported to every
// provider so repeated deploys update the same status row rather than
// piling up new ones.
const commitStatusContext = "tinyforge"
// maxCommitStatusDescription caps the human-readable description so a
// provider can't reject the request for an over-long field.
const maxCommitStatusDescription = 140
// truncateDescription clamps a status description to the provider-safe
// length, appending an ellipsis when it had to cut.
func truncateDescription(s string) string {
if len(s) <= maxCommitStatusDescription {
return s
}
// Reserve room for the ellipsis rune; cut on a byte boundary that
// stays under the cap. Descriptions are short ASCII strings in
// practice, so a simple byte cut is fine here.
return s[:maxCommitStatusDescription-1] + "…"
}
// GitProvider abstracts Git hosting API operations.
// Implementations exist for Gitea/Forgejo/Gogs, GitHub, and GitLab.
type GitProvider interface {
@@ -45,6 +80,12 @@ type GitProvider interface {
// DownloadFolder downloads all files from a folder path to a local directory.
DownloadFolder(ctx context.Context, owner, repo, branch, folderPath, destDir string) error
// SetCommitStatus reports a deploy status on a commit. Best-effort;
// callers ignore errors beyond logging. targetURL and description are
// optional (pass "" to omit); description is truncated to a provider-
// safe length by the implementation.
SetCommitStatus(ctx context.Context, owner, repo, sha string, status CommitStatus, targetURL, description string) error
}
// ProviderType identifies a Git hosting provider.
@@ -135,6 +176,36 @@ func httpGet(ctx context.Context, client *http.Client, url string) (int, error)
return resp.StatusCode, nil
}
// postJSON is a shared helper for POSTing a JSON body to a provider API
// endpoint with the caller's auth applied. It accepts any 2xx as success
// (status APIs return 201 Created on Gitea/GitHub, 200/201 on GitLab) and
// returns a status-code-only error on non-2xx — it must NOT echo the
// response body: the deploy hook logs this error best-effort, and a
// hostile/misconfigured provider could reflect the request's auth token
// back in its body. The body bytes must already be marshalled by the caller.
func postJSON(ctx context.Context, client *http.Client, url string, body []byte, authHeader func(r *http.Request)) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("create request: %w", err)
}
if authHeader != nil {
authHeader(req)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("execute request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("unexpected status %d", resp.StatusCode)
}
return nil
}
// downloadFileHTTP is a shared helper for downloading a file from a URL.
func downloadFileHTTP(ctx context.Context, client *http.Client, url, localPath string, authHeader func(r *http.Request)) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
@@ -0,0 +1,125 @@
package dockerfile
import (
"context"
"errors"
"testing"
"github.com/alexei/tinyforge/internal/staticsite"
"github.com/alexei/tinyforge/internal/workload/plugin"
)
// fakeProvider is a stub staticsite.GitProvider that records SetCommitStatus
// calls. Unused interface methods panic so a mis-wired test is loud.
type fakeProvider struct {
calls []statusCall
failErr error
}
type statusCall struct {
owner, repo, sha string
status staticsite.CommitStatus
targetURL, descr string
}
func (f *fakeProvider) SetCommitStatus(_ context.Context, owner, repo, sha string, status staticsite.CommitStatus, targetURL, description string) error {
f.calls = append(f.calls, statusCall{owner, repo, sha, status, targetURL, description})
return f.failErr
}
func (*fakeProvider) Name() string { return "fake" }
func (*fakeProvider) TestConnection(context.Context, string, string) error { panic("unused") }
func (*fakeProvider) ListRepos(context.Context, string) ([]staticsite.RepoInfo, error) {
panic("unused")
}
func (*fakeProvider) ListBranches(context.Context, string, string) ([]string, error) {
panic("unused")
}
func (*fakeProvider) GetLatestCommitSHA(context.Context, string, string, string) (string, error) {
panic("unused")
}
func (*fakeProvider) ListTree(context.Context, string, string, string) ([]staticsite.FolderEntry, error) {
panic("unused")
}
func (*fakeProvider) DownloadFolder(context.Context, string, string, string, string, string) error {
panic("unused")
}
func sampleCfg() Config {
return Config{RepoOwner: "owner", RepoName: "svc", Branch: "main", Port: 8080}
}
// Enabled: forwards to the provider with the captured identifiers + target.
func TestReporter_Enabled_Calls(t *testing.T) {
fp := &fakeProvider{}
cfg := sampleCfg()
cfg.ReportCommitStatus = true
r := newCommitStatusReporter(fp, cfg, "deadbeef", statusTargetURL("svc.example.com"))
w := plugin.Workload{Name: "svc"}
r.report(context.Background(), w, staticsite.CommitStatusPending, "Tinyforge: deploying")
r.report(context.Background(), w, staticsite.CommitStatusSuccess, "Tinyforge: deployed")
if len(fp.calls) != 2 {
t.Fatalf("calls = %d, want 2", len(fp.calls))
}
c := fp.calls[0]
if c.owner != "owner" || c.repo != "svc" || c.sha != "deadbeef" {
t.Errorf("identifiers wrong: %+v", c)
}
if c.targetURL != "https://svc.example.com" {
t.Errorf("targetURL = %q", c.targetURL)
}
if fp.calls[1].status != staticsite.CommitStatusSuccess {
t.Errorf("second status = %q, want success", fp.calls[1].status)
}
}
// Disabled: inert.
func TestReporter_Disabled_NoCalls(t *testing.T) {
fp := &fakeProvider{}
cfg := sampleCfg() // ReportCommitStatus false
r := newCommitStatusReporter(fp, cfg, "deadbeef", "")
r.report(context.Background(), plugin.Workload{Name: "svc"}, staticsite.CommitStatusFailure, "x")
if len(fp.calls) != 0 {
t.Fatalf("expected no calls when disabled, got %d", len(fp.calls))
}
}
// Empty SHA: no call even when enabled.
func TestReporter_EmptySHA_NoCalls(t *testing.T) {
fp := &fakeProvider{}
cfg := sampleCfg()
cfg.ReportCommitStatus = true
r := newCommitStatusReporter(fp, cfg, "", "")
r.report(context.Background(), plugin.Workload{Name: "svc"}, staticsite.CommitStatusPending, "x")
if len(fp.calls) != 0 {
t.Fatalf("expected no calls with empty SHA, got %d", len(fp.calls))
}
}
// Provider error is swallowed (best-effort).
func TestReporter_ProviderError_Swallowed(t *testing.T) {
fp := &fakeProvider{failErr: errors.New("boom")}
cfg := sampleCfg()
cfg.ReportCommitStatus = true
r := newCommitStatusReporter(fp, cfg, "deadbeef", "")
r.report(context.Background(), plugin.Workload{Name: "svc"}, staticsite.CommitStatusFailure, "Tinyforge: build failed")
if len(fp.calls) != 1 {
t.Fatalf("expected the failing call to still be recorded, got %d", len(fp.calls))
}
}
func TestReporter_NilSafe(t *testing.T) {
var r *commitStatusReporter
r.report(context.Background(), plugin.Workload{Name: "svc"}, staticsite.CommitStatusSuccess, "x")
}
func TestStatusTargetURL(t *testing.T) {
if got := statusTargetURL(""); got != "" {
t.Errorf("empty domain -> %q, want \"\"", got)
}
if got := statusTargetURL("x.example.com"); got != "https://x.example.com" {
t.Errorf("got %q", got)
}
}
@@ -43,7 +43,7 @@ const healthCheckDelay = 3 * time.Second
// Each step writes its own status update so the dashboard's runtime-
// state panel can show a useful intermediate state when the deploy
// stalls on the slow step (almost always the build).
func deploy(ctx context.Context, deps plugin.Deps, w plugin.Workload, intent plugin.DeploymentIntent) error {
func deploy(ctx context.Context, deps plugin.Deps, w plugin.Workload, intent plugin.DeploymentIntent) (retErr error) {
cfg, err := plugin.SourceConfigOf[Config](w)
if err != nil {
return fmt.Errorf("dockerfile source: decode config: %w", err)
@@ -90,6 +90,25 @@ func deploy(ctx context.Context, deps plugin.Deps, w plugin.Workload, intent plu
domain := primaryDomain(deps, w)
// Commit-status reporter (best-effort; gated on cfg.ReportCommitStatus).
// The deferred terminal report fires Success/Failure based on the
// deploy's outcome, but ONLY once an actual build/deploy began
// (deployStarted). The unchanged-SHA short-circuit below returns via
// healUnchanged before that flips, so no status is reported when
// nothing was built. retErr is the named return the defer inspects.
reporter := newCommitStatusReporter(provider, cfg, latestSHA, statusTargetURL(domain))
deployStarted := false
defer func() {
if !deployStarted {
return
}
if retErr != nil {
reporter.report(ctx, w, staticsite.CommitStatusFailure, "Tinyforge: build failed")
} else {
reporter.report(ctx, w, staticsite.CommitStatusSuccess, "Tinyforge: deployed")
}
}()
prevContainerID := ""
prevProxyRouteID := ""
if prevContainer != nil {
@@ -129,8 +148,13 @@ func deploy(ctx context.Context, deps plugin.Deps, w plugin.Workload, intent plu
}
}
// From here on a deploy is genuinely underway, so the deferred terminal
// status report should fire. Push a "pending" commit status (best-
// effort) and arm the deferred Success/Failure report.
updateStatus(deps, w, "syncing", prev.LastCommitSHA, "")
publishEvent(deps, w, "syncing")
deployStarted = true
reporter.report(ctx, w, staticsite.CommitStatusPending, "Tinyforge: deploying")
// Clone the repo into a temp dir. We always download the entire
// repo tree (folderPath = ""); a ContextPath subset is applied
@@ -371,6 +395,55 @@ func deploy(ctx context.Context, deps plugin.Deps, w plugin.Workload, intent plu
return nil
}
// commitStatusReporter pushes deploy outcomes back to the git provider as
// a commit status, gated on the per-workload report_commit_status flag.
// Strictly best-effort: every call is wrapped so a reporting failure is
// logged at Warn and NEVER propagates to fail or block the deploy. Mirrors
// the static plugin's reporter of the same name.
type commitStatusReporter struct {
provider staticsite.GitProvider
owner string
repo string
sha string
targetURL string
enabled bool
}
// newCommitStatusReporter builds a reporter from the decoded config. When
// report_commit_status is off (or the SHA is empty) the returned reporter's
// report method is inert.
func newCommitStatusReporter(provider staticsite.GitProvider, cfg Config, sha, targetURL string) *commitStatusReporter {
return &commitStatusReporter{
provider: provider,
owner: cfg.RepoOwner,
repo: cfg.RepoName,
sha: sha,
targetURL: targetURL,
enabled: cfg.ReportCommitStatus,
}
}
// report sends one commit status, swallowing (and logging) any error. Safe
// to call on a disabled reporter or with a nil provider/empty SHA.
func (r *commitStatusReporter) report(ctx context.Context, w plugin.Workload, status staticsite.CommitStatus, description string) {
if r == nil || !r.enabled || r.provider == nil || r.sha == "" {
return
}
if err := r.provider.SetCommitStatus(ctx, r.owner, r.repo, r.sha, status, r.targetURL, description); err != nil {
slog.Warn("dockerfile: commit-status report failed (ignored)",
"workload", w.Name, "status", string(status), "error", err)
}
}
// statusTargetURL derives the https URL the commit status links back to —
// the workload's primary public face, or "" when it has none.
func statusTargetURL(domain string) string {
if domain == "" {
return ""
}
return "https://" + domain
}
// updateStatus writes the runtime-state status/error/commit and (on
// terminal states) fires the side effects the static plugin's helper
// does: failures land in the event log, and a "deployed" or "failed"
@@ -59,6 +59,11 @@ type Config struct {
Port int `json:"port"`
Healthcheck string `json:"healthcheck,omitempty"`
// ReportCommitStatus, when true, pushes the deploy outcome back to the
// git provider as a commit status (pending/success/failure) on the
// built SHA. Best-effort — a reporting failure never fails a deploy.
ReportCommitStatus bool `json:"report_commit_status"`
}
type source struct{}
@@ -0,0 +1,140 @@
package static
import (
"context"
"errors"
"testing"
"github.com/alexei/tinyforge/internal/staticsite"
"github.com/alexei/tinyforge/internal/workload/plugin"
)
// fakeProvider is a stub staticsite.GitProvider that records SetCommitStatus
// calls. Only the methods the reporter exercises are meaningful; the rest
// satisfy the interface and panic if ever hit so a mis-wired test is loud.
type fakeProvider struct {
calls []statusCall
failErr error // when set, SetCommitStatus returns it (best-effort path)
}
type statusCall struct {
owner, repo, sha string
status staticsite.CommitStatus
targetURL, descr string
}
func (f *fakeProvider) SetCommitStatus(_ context.Context, owner, repo, sha string, status staticsite.CommitStatus, targetURL, description string) error {
f.calls = append(f.calls, statusCall{owner, repo, sha, status, targetURL, description})
return f.failErr
}
func (*fakeProvider) Name() string { return "fake" }
func (*fakeProvider) TestConnection(context.Context, string, string) error {
panic("unused")
}
func (*fakeProvider) ListRepos(context.Context, string) ([]staticsite.RepoInfo, error) {
panic("unused")
}
func (*fakeProvider) ListBranches(context.Context, string, string) ([]string, error) {
panic("unused")
}
func (*fakeProvider) GetLatestCommitSHA(context.Context, string, string, string) (string, error) {
panic("unused")
}
func (*fakeProvider) ListTree(context.Context, string, string, string) ([]staticsite.FolderEntry, error) {
panic("unused")
}
func (*fakeProvider) DownloadFolder(context.Context, string, string, string, string, string) error {
panic("unused")
}
func sampleCfg() Config {
return Config{RepoOwner: "owner", RepoName: "pages", Branch: "main"}
}
// When report_commit_status is enabled, the reporter forwards to the
// provider with the owner/repo/sha + target URL it was built with.
func TestReporter_Enabled_Calls(t *testing.T) {
fp := &fakeProvider{}
cfg := sampleCfg()
cfg.ReportCommitStatus = true
r := newCommitStatusReporter(fp, cfg, "abc123", statusTargetURL("app.example.com"))
w := plugin.Workload{Name: "site"}
r.report(context.Background(), w, staticsite.CommitStatusPending, "Tinyforge: deploying")
r.report(context.Background(), w, staticsite.CommitStatusSuccess, "Tinyforge: deployed")
if len(fp.calls) != 2 {
t.Fatalf("calls = %d, want 2", len(fp.calls))
}
first := fp.calls[0]
if first.owner != "owner" || first.repo != "pages" || first.sha != "abc123" {
t.Errorf("identifiers wrong: %+v", first)
}
if first.status != staticsite.CommitStatusPending {
t.Errorf("first status = %q, want pending", first.status)
}
if first.targetURL != "https://app.example.com" {
t.Errorf("targetURL = %q", first.targetURL)
}
if fp.calls[1].status != staticsite.CommitStatusSuccess {
t.Errorf("second status = %q, want success", fp.calls[1].status)
}
}
// When report_commit_status is disabled, the reporter is inert.
func TestReporter_Disabled_NoCalls(t *testing.T) {
fp := &fakeProvider{}
cfg := sampleCfg() // ReportCommitStatus defaults to false
r := newCommitStatusReporter(fp, cfg, "abc123", "")
r.report(context.Background(), plugin.Workload{Name: "site"}, staticsite.CommitStatusSuccess, "x")
if len(fp.calls) != 0 {
t.Fatalf("expected no calls when disabled, got %d", len(fp.calls))
}
}
// An empty SHA (e.g. a provider that couldn't resolve the branch) must not
// produce a status call even when reporting is enabled.
func TestReporter_EmptySHA_NoCalls(t *testing.T) {
fp := &fakeProvider{}
cfg := sampleCfg()
cfg.ReportCommitStatus = true
r := newCommitStatusReporter(fp, cfg, "", "")
r.report(context.Background(), plugin.Workload{Name: "site"}, staticsite.CommitStatusPending, "x")
if len(fp.calls) != 0 {
t.Fatalf("expected no calls with empty SHA, got %d", len(fp.calls))
}
}
// A provider error must be swallowed (best-effort) — report never panics or
// propagates. We assert it returns normally after a failing provider call.
func TestReporter_ProviderError_Swallowed(t *testing.T) {
fp := &fakeProvider{failErr: errors.New("boom")}
cfg := sampleCfg()
cfg.ReportCommitStatus = true
r := newCommitStatusReporter(fp, cfg, "abc123", "")
// Should not panic / propagate.
r.report(context.Background(), plugin.Workload{Name: "site"}, staticsite.CommitStatusFailure, "Tinyforge: deploy failed")
if len(fp.calls) != 1 {
t.Fatalf("expected the failing call to still be recorded, got %d", len(fp.calls))
}
}
// A nil reporter (constructed only when needed in some call paths) is safe.
func TestReporter_NilSafe(t *testing.T) {
var r *commitStatusReporter
// Must not panic.
r.report(context.Background(), plugin.Workload{Name: "site"}, staticsite.CommitStatusSuccess, "x")
}
func TestStatusTargetURL(t *testing.T) {
if got := statusTargetURL(""); got != "" {
t.Errorf("empty domain -> %q, want \"\"", got)
}
if got := statusTargetURL("x.example.com"); got != "https://x.example.com" {
t.Errorf("got %q", got)
}
}
@@ -40,7 +40,7 @@ const healthCheckDelay = 3 * time.Second
// log-line format ("Static site \"%s\": %s") and event payload shapes
// are preserved so log scrapers and SSE clients keep working through
// the cutover.
func deploy(ctx context.Context, deps plugin.Deps, w plugin.Workload, intent plugin.DeploymentIntent) error {
func deploy(ctx context.Context, deps plugin.Deps, w plugin.Workload, intent plugin.DeploymentIntent) (retErr error) {
cfg, err := plugin.SourceConfigOf[Config](w)
if err != nil {
return fmt.Errorf("static source: decode config: %w", err)
@@ -88,6 +88,26 @@ func deploy(ctx context.Context, deps plugin.Deps, w plugin.Workload, intent plu
// proxy registration sees the same FQDN it did before.
domain := primaryDomain(deps, w)
// Commit-status reporter (best-effort; gated on cfg.ReportCommitStatus).
// Built here once latestSHA + domain are known. A deferred terminal
// report fires Success/Failure based on the deploy's outcome, but ONLY
// once an actual deploy began (deployStarted) — the unchanged-SHA
// short-circuit below returns before that flips, so no status is
// reported when nothing was deployed. retErr is the named return the
// defer inspects.
reporter := newCommitStatusReporter(provider, cfg, latestSHA, statusTargetURL(domain))
deployStarted := false
defer func() {
if !deployStarted {
return
}
if retErr != nil {
reporter.report(ctx, w, staticsite.CommitStatusFailure, "Tinyforge: deploy failed")
} else {
reporter.report(ctx, w, staticsite.CommitStatusSuccess, "Tinyforge: deployed")
}
}()
// Skip redeploy when nothing changed AND we have a live container +
// (if applicable) live proxy route. Manual deploys always force.
prevContainerID := ""
@@ -116,9 +136,13 @@ func deploy(ctx context.Context, deps plugin.Deps, w plugin.Workload, intent plu
}
}
// Mark syncing.
// Mark syncing. From here on a deploy is genuinely underway, so the
// deferred terminal status report should fire. Push a "pending" commit
// status (best-effort) and arm the deferred Success/Failure report.
updateStatus(deps, w, "syncing", prev.LastCommitSHA, "")
publishEvent(deps, w, "syncing")
deployStarted = true
reporter.report(ctx, w, staticsite.CommitStatusPending, "Tinyforge: deploying")
// Build context — temp dir cleaned up on every exit path.
buildDir, err := os.MkdirTemp("", "dw-site-"+idShort(w)+"-*")
@@ -402,6 +426,59 @@ func deploy(ctx context.Context, deps plugin.Deps, w plugin.Workload, intent plu
return nil
}
// commitStatusReporter pushes deploy outcomes back to the git provider as
// a commit status, gated on the per-workload report_commit_status flag.
// It is strictly best-effort: every call is wrapped so a reporting failure
// is logged at Warn and NEVER propagates to fail or block the deploy.
//
// The provider + identifiers are captured once at deploy start so the hot
// transition points (pending/success/failure) read as one-liners. A nil
// receiver (reporting disabled) makes report a no-op, so callers don't have
// to guard each site.
type commitStatusReporter struct {
provider staticsite.GitProvider
owner string
repo string
sha string
targetURL string
enabled bool
}
// newCommitStatusReporter builds a reporter from the decoded config. When
// report_commit_status is off (or the SHA is empty) the returned reporter's
// report method is inert.
func newCommitStatusReporter(provider staticsite.GitProvider, cfg Config, sha, targetURL string) *commitStatusReporter {
return &commitStatusReporter{
provider: provider,
owner: cfg.RepoOwner,
repo: cfg.RepoName,
sha: sha,
targetURL: targetURL,
enabled: cfg.ReportCommitStatus,
}
}
// report sends one commit status, swallowing (and logging) any error. Safe
// to call on a disabled reporter or with a nil provider/empty SHA.
func (r *commitStatusReporter) report(ctx context.Context, w plugin.Workload, status staticsite.CommitStatus, description string) {
if r == nil || !r.enabled || r.provider == nil || r.sha == "" {
return
}
if err := r.provider.SetCommitStatus(ctx, r.owner, r.repo, r.sha, status, r.targetURL, description); err != nil {
slog.Warn("static site: commit-status report failed (ignored)",
"site", w.Name, "status", string(status), "error", err)
}
}
// statusTargetURL derives the https URL the commit status links back to —
// the workload's primary public face, or "" when it has none.
func statusTargetURL(domain string) string {
if domain == "" {
return ""
}
return "https://" + domain
}
// updateStatus writes the runtime state's status/error/commit fields
// and fires the side effects the legacy Manager.updateStatus did:
// failures land in the event log, and terminal transitions trigger an
@@ -37,6 +37,10 @@ type Config struct {
RenderMarkdown bool `json:"render_markdown"`
StorageEnabled bool `json:"storage_enabled"`
StorageLimitMB int `json:"storage_limit_mb"`
// ReportCommitStatus, when true, pushes the deploy outcome back to the
// git provider as a commit status (pending/success/failure) on the
// deployed SHA. Best-effort — a reporting failure never fails a deploy.
ReportCommitStatus bool `json:"report_commit_status"`
}
type source struct{}
@@ -14,6 +14,7 @@
<script lang="ts">
import type { DockerfileFormState } from '$lib/workload/sourceForms';
import StaticDiscoveryWizard from '$lib/components/workload/StaticDiscoveryWizard.svelte';
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
import { IconX } from '$lib/components/icons';
import { t } from '$lib/i18n';
@@ -136,6 +137,16 @@
<span>{$t('apps.new.dockerfilePortRequired')}</span>
</div>
{/if}
<label class="toggle-row">
<ToggleSwitch
bind:checked={form.reportCommitStatus}
label={$t('apps.new.sourceReportCommitStatus')}
/>
<span>
<strong>{$t('apps.new.sourceReportCommitStatus')}</strong>
{@html $t('apps.new.sourceReportCommitStatusDesc')}
</span>
</label>
<p class="hint image-form-foot">{$t('apps.new.dockerfileFoot')}</p>
</div>
@@ -165,6 +176,22 @@
margin: 0;
line-height: 1.45;
}
/* ── Commit-status toggle row (mirrors the static source form) ── */
.toggle-row {
display: flex;
align-items: flex-start;
gap: 0.55rem;
padding: 0.35rem 0;
font-size: 0.88rem;
color: var(--text-secondary);
cursor: pointer;
}
.toggle-row strong {
color: var(--text-primary);
}
.toggle-row :global(.toggle-switch) {
margin-top: 0.1rem;
}
.row {
display: grid;
grid-template-columns: 1fr 1fr;
@@ -108,6 +108,16 @@
<strong>{$t('apps.new.staticRenderMarkdown')}</strong> {@html $t('apps.new.staticRenderMarkdownDesc')}
</span>
</label>
<label class="toggle-row">
<ToggleSwitch
bind:checked={form.reportCommitStatus}
label={$t('apps.new.sourceReportCommitStatus')}
/>
<span>
<strong>{$t('apps.new.sourceReportCommitStatus')}</strong>
{@html $t('apps.new.sourceReportCommitStatusDesc')}
</span>
</label>
<p class="hint image-form-foot">{$t('apps.new.staticFoot')}</p>
</div>
+2
View File
@@ -1298,6 +1298,8 @@
"staticModeDenoDesc": "— Deno runtime container with optional dynamic routing.",
"staticRenderMarkdown": "Render markdown",
"staticRenderMarkdownDesc": "— auto-render <code>.md</code> files as HTML pages.",
"sourceReportCommitStatus": "Report commit status",
"sourceReportCommitStatusDesc": "— report deploy status back to the Git provider as a commit status on the deployed commit.",
"staticFoot": "The webhook secret for git push triggers lives on the workload's Webhook panel after creation.",
"staticDetectProvider": "Detect",
"staticDetectedOk": "Detected: {provider}",
+2
View File
@@ -1298,6 +1298,8 @@
"staticModeDenoDesc": "— Deno-рантайм с опциональной динамической маршрутизацией.",
"staticRenderMarkdown": "Рендерить markdown",
"staticRenderMarkdownDesc": "— автоматически отдавать <code>.md</code> файлы как HTML-страницы.",
"sourceReportCommitStatus": "Отправлять статус коммита",
"sourceReportCommitStatusDesc": "— отправлять статус деплоя обратно в Git-провайдер как статус коммита для развёрнутого коммита.",
"staticFoot": "Секрет вебхука для git push-триггеров появляется в панели вебхука нагрузки после создания.",
"staticDetectProvider": "Определить",
"staticDetectedOk": "Определено: {provider}",
+22 -2
View File
@@ -146,9 +146,19 @@ describe('static source', () => {
'folder_path',
'access_token',
'mode',
'render_markdown'
'render_markdown',
'report_commit_status'
]);
expect(config.branch).toBe('main');
expect(config.report_commit_status).toBe(false);
});
it('seeds and serializes report_commit_status', () => {
expect(seedStaticState(JSON.stringify({ report_commit_status: true })).reportCommitStatus).toBe(
true
);
const cfg = staticToConfig({ ...emptyStaticState(), reportCommitStatus: true }, '{}');
expect(cfg.report_commit_status).toBe(true);
});
it('preserves storage_* keys only when present', () => {
@@ -216,11 +226,21 @@ describe('dockerfile source', () => {
'access_token',
'context_path',
'dockerfile_path',
'port'
'port',
'report_commit_status'
]);
expect(config.dockerfile_path).toBe('Dockerfile');
expect(config.branch).toBe('main');
expect(config.port).toBe(0);
expect(config.report_commit_status).toBe(false);
});
it('seeds and serializes report_commit_status', () => {
expect(
seedDockerfileState(JSON.stringify({ report_commit_status: true })).reportCommitStatus
).toBe(true);
const cfg = dockerfileToConfig({ ...emptyDockerfileState(), reportCommitStatus: true }, '{}');
expect(cfg.report_commit_status).toBe(true);
});
it('preserves unknown keys but scrubs static-only keys', () => {
+33 -5
View File
@@ -59,6 +59,8 @@ export interface StaticFormState extends GitSourceState {
folderPath: string;
mode: 'static' | 'deno';
renderMarkdown: boolean;
/** Report deploy outcome back to the git provider as a commit status. */
reportCommitStatus: boolean;
}
/** Dockerfile source: build an image from a Dockerfile in a repo. */
@@ -66,6 +68,8 @@ export interface DockerfileFormState extends GitSourceState {
contextPath: string;
dockerfilePath: string;
port: number;
/** Report deploy outcome back to the git provider as a commit status. */
reportCommitStatus: boolean;
}
// ── Defaults ────────────────────────────────────────────────────────
@@ -99,11 +103,23 @@ function emptyGitSourceState(): GitSourceState {
}
export function emptyStaticState(): StaticFormState {
return { ...emptyGitSourceState(), folderPath: '', mode: 'static', renderMarkdown: false };
return {
...emptyGitSourceState(),
folderPath: '',
mode: 'static',
renderMarkdown: false,
reportCommitStatus: false
};
}
export function emptyDockerfileState(): DockerfileFormState {
return { ...emptyGitSourceState(), contextPath: '', dockerfilePath: 'Dockerfile', port: 0 };
return {
...emptyGitSourceState(),
contextPath: '',
dockerfilePath: 'Dockerfile',
port: 0,
reportCommitStatus: false
};
}
// ── Parse helpers ───────────────────────────────────────────────────
@@ -186,7 +202,9 @@ export function seedStaticState(jsonText: string): StaticFormState {
accessToken: strOr(o.access_token, ''),
folderPath: strOr(o.folder_path, ''),
mode: o.mode === 'deno' ? 'deno' : 'static',
renderMarkdown: typeof o.render_markdown === 'boolean' ? o.render_markdown : false
renderMarkdown: typeof o.render_markdown === 'boolean' ? o.render_markdown : false,
reportCommitStatus:
typeof o.report_commit_status === 'boolean' ? o.report_commit_status : false
};
}
@@ -201,7 +219,9 @@ export function seedDockerfileState(jsonText: string): DockerfileFormState {
accessToken: strOr(o.access_token, ''),
contextPath: strOr(o.context_path, ''),
dockerfilePath: strOrTruthy(o.dockerfile_path, 'Dockerfile'),
port: numOr(o.port, 0)
port: numOr(o.port, 0),
reportCommitStatus:
typeof o.report_commit_status === 'boolean' ? o.report_commit_status : false
};
}
@@ -260,7 +280,11 @@ export function staticToConfig(s: StaticFormState, existingJson: string): Record
folder_path: s.folderPath,
access_token: s.accessToken,
mode: s.mode,
render_markdown: s.renderMarkdown
render_markdown: s.renderMarkdown,
// New key appended at the END so existing byte-shape assertions for
// the other keys are minimally affected. Storage_* keys (added below
// only when present in the existing config) trail this on edit.
report_commit_status: s.reportCommitStatus
};
// Preserve storage_* keys set via the raw JSON editor (not yet surfaced
// as form controls) so a form round-trip doesn't silently drop them.
@@ -289,6 +313,7 @@ const DOCKERFILE_OWNED_KEYS: ReadonlySet<string> = new Set([
'context_path',
'dockerfile_path',
'port',
'report_commit_status',
'folder_path',
'mode',
'render_markdown',
@@ -317,6 +342,9 @@ export function dockerfileToConfig(
context_path: s.contextPath,
dockerfile_path: s.dockerfilePath || 'Dockerfile',
port: s.port || 0,
// New owned key appended at the END of the owned block (before any
// preserved unknown keys) so existing byte-shape assertions hold.
report_commit_status: s.reportCommitStatus,
...preserved
};
}