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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user