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
+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)