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:
@@ -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>
|
||||
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -1298,6 +1298,8 @@
|
||||
"staticModeDenoDesc": "— Deno-рантайм с опциональной динамической маршрутизацией.",
|
||||
"staticRenderMarkdown": "Рендерить markdown",
|
||||
"staticRenderMarkdownDesc": "— автоматически отдавать <code>.md</code> файлы как HTML-страницы.",
|
||||
"sourceReportCommitStatus": "Отправлять статус коммита",
|
||||
"sourceReportCommitStatusDesc": "— отправлять статус деплоя обратно в Git-провайдер как статус коммита для развёрнутого коммита.",
|
||||
"staticFoot": "Секрет вебхука для git push-триггеров появляется в панели вебхука нагрузки после создания.",
|
||||
"staticDetectProvider": "Определить",
|
||||
"staticDetectedOk": "Определено: {provider}",
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user