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
@@ -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
};
}