fix: require Cloudflare deployment commit proof

This commit is contained in:
kuekhaoyang
2026-08-01 02:46:16 +08:00
parent 33277526e1
commit 05761075cf
4 changed files with 52 additions and 10 deletions
+21 -4
View File
@@ -1,3 +1,4 @@
import fs from 'node:fs';
import path from 'node:path';
import { runCommand } from '../core/command.mjs';
import { finding } from '../core/finding.mjs';
@@ -8,6 +9,13 @@ function digest(output) {
return output?.match(/^Digest:\s+(sha256:[a-f0-9]+)/m)?.[1] || null;
}
export function latestDeployment(output) {
try {
const rows = JSON.parse(output);
return Array.isArray(rows) ? rows[0] || null : null;
} catch { return null; }
}
export async function checkDeployment(ctx) {
if (ctx.config.offline) return finding(ctx, {
id: 'deploy.consistency', category: 'deployment', title: 'Local, GitHub, Cloudflare, and Docker release consistency', status: 'SKIP', severity: 'critical',
@@ -21,15 +29,24 @@ export async function checkDeployment(ctx) {
const latest = await runCommand(ctx, 'dockerhub-latest', 'docker', ['buildx', 'imagetools', 'inspect', 'kuekhaoyang/kvideo:latest'], { timeoutMs: 120_000 });
const versioned = await runCommand(ctx, 'dockerhub-version', 'docker', ['buildx', 'imagetools', 'inspect', `kuekhaoyang/kvideo:${ctx.state.version}`], { timeoutMs: 120_000 });
const wrangler = path.join(ctx.config.verifyDir, 'node_modules', '.bin', 'wrangler');
const pages = await runCommand(ctx, 'cloudflare-deployments', wrangler, ['pages', 'deployment', 'list', '--project-name', 'kvideo'], { timeoutMs: 120_000 });
const pages = await runCommand(ctx, 'cloudflare-deployments', wrangler, ['pages', 'deployment', 'list', '--project-name', 'kvideo', '--environment', 'production', '--json'], { timeoutMs: 120_000 });
const deployment = latestDeployment(fs.readFileSync(pages.outputPath, 'utf8'));
const deployedRelease = deployment?.Deployment ? await request(`${deployment.Deployment}/api/app-update`) : null;
const githubVersion = jsonBody(githubPackage)?.version;
const cloudVersion = jsonBody(cloudflare)?.currentVersion;
const deployedVersion = jsonBody(deployedRelease)?.currentVersion;
const latestDigest = digest(latest.tail);
const versionDigest = digest(versioned.tail);
const facts = { localSha, remoteSha, localVersion: ctx.state.version, githubVersion, cloudVersion, latestDigest, versionDigest, pagesMentionsSha: pages.tail.includes(localSha.slice(0, 7)) };
const deploymentSha = deployment?.Source || null;
const deploymentUrl = deployment?.Deployment || null;
const facts = { localSha, remoteSha, localVersion: ctx.state.version, githubVersion, cloudVersion, deployedVersion,
deploymentSha, deploymentUrl, latestDigest, versionDigest };
const target = path.join(ctx.dirs.raw, 'deployment-consistency.json');
writeJson(target, { facts, githubPackage, cloudflare, evidence: { latest: latest.outputPath, versioned: versioned.outputPath, pages: pages.outputPath } });
const ok = localSha === remoteSha && githubVersion === ctx.state.version && cloudVersion === ctx.state.version && latestDigest && latestDigest === versionDigest;
writeJson(target, { facts, githubPackage, cloudflare, deployedRelease, deployment,
evidence: { latest: latest.outputPath, versioned: versioned.outputPath, pages: pages.outputPath } });
const ok = localSha === remoteSha && githubVersion === ctx.state.version && cloudVersion === ctx.state.version
&& deploymentSha === localSha.slice(0, 7) && deployedVersion === ctx.state.version
&& latestDigest && latestDigest === versionDigest;
finding(ctx, {
id: 'deploy.consistency', category: 'deployment', title: 'Local, GitHub main, Cloudflare, and both Docker tags agree',
status: ok ? 'PASS' : 'FAIL', severity: 'critical', expected: 'Same Git commit/version; Docker latest and version tags share one digest', actual: JSON.stringify(facts),
+9 -6
View File
@@ -25,6 +25,13 @@ export function runCommand(ctx, name, command, args = [], options = {}) {
});
let tail = '';
let timedOut = false;
let settled = false;
const finish = (result) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
stream.end(() => resolve(result));
};
const capture = (chunk) => {
const text = chunk.toString();
stream.write(text);
@@ -39,14 +46,10 @@ export function runCommand(ctx, name, command, args = [], options = {}) {
setTimeout(() => terminate(child, 'SIGKILL'), 3000).unref();
}, options.timeoutMs || ctx.config.commandTimeoutMs);
child.on('error', (error) => {
clearTimeout(timeout);
stream.end();
resolve({ code: 127, error: error.message, tail, outputPath, timedOut, durationMs: Date.now() - started });
finish({ code: 127, error: error.message, tail, outputPath, timedOut, durationMs: Date.now() - started });
});
child.on('exit', (code, signal) => {
clearTimeout(timeout);
stream.end();
resolve({ code: code ?? 1, signal, tail, outputPath, timedOut, durationMs: Date.now() - started });
finish({ code: code ?? 1, signal, tail, outputPath, timedOut, durationMs: Date.now() - started });
});
});
}
+10
View File
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import { numericCandidate, prepareActionState } from '../src/browser/action-state.mjs';
import { getConfig } from '../src/config.mjs';
import { latestDeployment } from '../src/checks/deployment.mjs';
import { redact, redactText } from '../src/core/redact.mjs';
import { escapeXml } from '../src/core/xml.mjs';
@@ -41,3 +42,12 @@ test('accepts explicit full-run action budgets', () => {
assert.equal(config.maxActionStates, 1234);
assert.equal(config.maxActionDepth, 7);
});
test('selects the newest Cloudflare production deployment', () => {
const output = JSON.stringify([
{ Environment: 'Production', Source: 'abcdef1', Deployment: 'https://new.pages.dev' },
{ Environment: 'Production', Source: '1234567', Deployment: 'https://old.pages.dev' },
]);
assert.equal(latestDeployment(output)?.Source, 'abcdef1');
assert.equal(latestDeployment('not json'), null);
});
+12
View File
@@ -3,6 +3,7 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { runCommand } from '../src/core/command.mjs';
import { lineCount, readJson, relative, walk, writeJson } from '../src/core/files.mjs';
test('file helpers inventory and serialize deterministic fixtures', () => {
@@ -17,3 +18,14 @@ test('file helpers inventory and serialize deterministic fixtures', () => {
assert.equal(walk(root, (file) => file.endsWith('.txt')).length, 1);
fs.rmSync(root, { recursive: true, force: true });
});
test('command logs are flushed before command completion resolves', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'kvideo-command-'));
const raw = path.join(root, 'raw');
fs.mkdirSync(raw);
const ctx = { config: { root, commandTimeoutMs: 5000 }, dirs: { raw } };
const result = await runCommand(ctx, 'flush', process.execPath, ['-e', "process.stdout.write('x'.repeat(50000))"]);
assert.equal(result.code, 0);
assert.equal(fs.readFileSync(result.outputPath, 'utf8').length, 50000);
fs.rmSync(root, { recursive: true, force: true });
});