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