736 lines
75 KiB
JavaScript
736 lines
75 KiB
JavaScript
function escapeHtml(value) {
|
||
return String(value)
|
||
.replaceAll('&', '&')
|
||
.replaceAll('<', '<')
|
||
.replaceAll('>', '>')
|
||
.replaceAll('"', '"')
|
||
.replaceAll("'", ''');
|
||
}
|
||
|
||
const p = (text) => '<p>' + text + '</p>';
|
||
const h2 = (text) => '<h2>' + text + '</h2>';
|
||
const code = (text) => '<pre><code>' + escapeHtml(text) + '</code></pre>';
|
||
const ol = (items) => '<ol>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ol>';
|
||
const figure = (src, alt, caption) => '<figure><img src="' + src + '" alt="' + alt + '" loading="lazy" /><figcaption>' + caption + '</figcaption></figure>';
|
||
const table = (caption, headers, rows) => '<div class="table-scroll"><table><caption>' + caption + '</caption><thead><tr>' + headers.map((item) => '<th scope="col">' + item + '</th>').join('') + '</tr></thead><tbody>' + rows.map((row) => '<tr>' + row.map((item) => '<td>' + item + '</td>').join('') + '</tr>').join('') + '</tbody></table></div>';
|
||
|
||
function plainText(content) {
|
||
return content
|
||
.replace(/<[^>]+>/g, ' ')
|
||
.replaceAll(' ', ' ')
|
||
.replaceAll('"', '"')
|
||
.replaceAll(''', "'")
|
||
.replaceAll('<', '<')
|
||
.replaceAll('>', '>')
|
||
.replaceAll('&', '&')
|
||
.replace(/\s+/g, ' ')
|
||
.trim();
|
||
}
|
||
|
||
function bodyText(content) {
|
||
return plainText(content.replace(/<h2>Проверяемые источники<\/h2>[\s\S]*?(?=<h2>|$)/, ''));
|
||
}
|
||
|
||
const sources = [
|
||
{
|
||
title: 'GitHub Changelog: Artifact Attestations is generally available, 25 июня 2024',
|
||
url: 'https://github.blog/changelog/2024-06-25-artifact-attestations-is-generally-available/',
|
||
note: 'Первичный датированный источник GitHub: в GitHub Actions можно создать attestation для собранного artifact и затем проверить его. Здесь это основание различать artifact и проверяемое утверждение о нём; источник не является policy, журналом или доказательством чужого выпуска.',
|
||
},
|
||
{
|
||
title: 'SLSA: SLSA v1.0 is now final!, 19 апреля 2023',
|
||
url: 'https://slsa.dev/blog/2023/04/slsa-v1-final',
|
||
note: 'Первичная публикация о первом stable SLSA v1.0. Из неё взята узкая идея versioned provenance и проверки ожиданий, а не claim, что любой набор полей делает конкретную сборку безопасной или готовой к deploy.',
|
||
},
|
||
{
|
||
title: 'Kubernetes documentation: Deployments, snapshot 14.03.2024',
|
||
url: 'https://github.com/kubernetes/website/blob/c889d9b2510bba78dbb527a79b8e2099b16f3d67/content/en/docs/concepts/workloads/controllers/deployment.md',
|
||
note: 'Первичный неизменяемый снимок ветки release-1.30, доступный до июля 2024: revision Deployment связана с Pod template, а rollback возвращает прежнюю revision шаблона. Это не отменяет data migration и не переносится автоматически на другой deploy-инструмент.',
|
||
},
|
||
];
|
||
|
||
function sourceList() {
|
||
return '<ul>' + sources.map((item) => '<li><a href="' + item.url + '" target="_blank" rel="noopener noreferrer">' + item.title + '</a> — ' + item.note + '</li>').join('') + '</ul>';
|
||
}
|
||
|
||
function revision(meta, parts) {
|
||
const contentHtml = parts.join('\n') + '\n' + h2('Проверяемые источники') + '\n' + sourceList();
|
||
const proseLength = bodyText(contentHtml).length;
|
||
if (proseLength < 5000 || proseLength > 15000) {
|
||
throw new Error(meta.slug + ': основной текст вне диапазона 5 000–15 000 знаков: ' + proseLength);
|
||
}
|
||
return Object.freeze({ ...meta, contentHtml, proseLength });
|
||
}
|
||
|
||
const FIXTURE_SCOPE = 'synthetic-release-evidence-2024-07';
|
||
const FIXTURE_MODE = 'fixed-memory-only';
|
||
const FIXTURE_LIMIT = 'fixed-versioned-synthetic-js-objects-in-memory-no-git-no-artifact-registry-no-ci-no-deploy-no-cloud-no-production-no-delivery-logs-no-files-no-network';
|
||
|
||
function freezeRecord(record) {
|
||
return Object.freeze({
|
||
...record,
|
||
commit: Object.freeze({ ...record.commit }),
|
||
artifact: Object.freeze({ ...record.artifact }),
|
||
migration: Object.freeze({ ...record.migration }),
|
||
rollout: Object.freeze({ ...record.rollout }),
|
||
rollback: Object.freeze({ ...record.rollback }),
|
||
});
|
||
}
|
||
|
||
const fixedRecords = Object.freeze({
|
||
'fixed-aligned-release': freezeRecord({
|
||
recordVersion: 'synthetic-release-record/v1',
|
||
label: 'all four versioned teaching objects bind to one release and one compatible return point',
|
||
releaseVersion: '2024.07.0',
|
||
commit: {
|
||
kind: 'synthetic-commit/v1',
|
||
version: '2024.07.0',
|
||
id: 'synthetic-commit-7f4a0c1',
|
||
},
|
||
artifact: {
|
||
kind: 'synthetic-artifact/v1',
|
||
version: '2024.07.0',
|
||
digest: 'sha256:synthetic-2024-07-0-a1',
|
||
sourceCommitId: 'synthetic-commit-7f4a0c1',
|
||
evidenceVersion: 'synthetic-evidence/v1',
|
||
},
|
||
migration: {
|
||
kind: 'synthetic-migration-plan/v1',
|
||
version: '2024.07.0',
|
||
targetReleaseVersion: '2024.07.0',
|
||
compatibility: 'expand-compatible-with-2024.06.3',
|
||
action: 'not-executed',
|
||
},
|
||
rollout: {
|
||
kind: 'synthetic-rollout-card/v1',
|
||
version: '2024.07.0',
|
||
requestedReleaseVersion: '2024.07.0',
|
||
requestedArtifactDigest: 'sha256:synthetic-2024-07-0-a1',
|
||
requestedMigrationVersion: '2024.07.0',
|
||
state: 'proposed-not-executed',
|
||
},
|
||
rollback: {
|
||
kind: 'synthetic-rollback-candidate/v1',
|
||
version: '2024.07.0',
|
||
returnReleaseVersion: '2024.06.3',
|
||
returnArtifactDigest: 'sha256:synthetic-2024-06-3-z9',
|
||
migrationBoundary: 'does-not-reverse-data-and-requires-separate-human-review',
|
||
state: 'draft-not-executed',
|
||
},
|
||
}),
|
||
'fixed-artifact-commit-mismatch': freezeRecord({
|
||
recordVersion: 'synthetic-release-record/v1',
|
||
label: 'artifact version looks familiar but its declared source commit is different',
|
||
releaseVersion: '2024.07.0',
|
||
commit: {
|
||
kind: 'synthetic-commit/v1',
|
||
version: '2024.07.0',
|
||
id: 'synthetic-commit-7f4a0c1',
|
||
},
|
||
artifact: {
|
||
kind: 'synthetic-artifact/v1',
|
||
version: '2024.07.0',
|
||
digest: 'sha256:synthetic-2024-07-0-b2',
|
||
sourceCommitId: 'synthetic-commit-other-91',
|
||
evidenceVersion: 'synthetic-evidence/v1',
|
||
},
|
||
migration: {
|
||
kind: 'synthetic-migration-plan/v1',
|
||
version: '2024.07.0',
|
||
targetReleaseVersion: '2024.07.0',
|
||
compatibility: 'expand-compatible-with-2024.06.3',
|
||
action: 'not-executed',
|
||
},
|
||
rollout: {
|
||
kind: 'synthetic-rollout-card/v1',
|
||
version: '2024.07.0',
|
||
requestedReleaseVersion: '2024.07.0',
|
||
requestedArtifactDigest: 'sha256:synthetic-2024-07-0-b2',
|
||
requestedMigrationVersion: '2024.07.0',
|
||
state: 'proposed-not-executed',
|
||
},
|
||
rollback: {
|
||
kind: 'synthetic-rollback-candidate/v1',
|
||
version: '2024.07.0',
|
||
returnReleaseVersion: '2024.06.3',
|
||
returnArtifactDigest: 'sha256:synthetic-2024-06-3-z9',
|
||
migrationBoundary: 'does-not-reverse-data-and-requires-separate-human-review',
|
||
state: 'draft-not-executed',
|
||
},
|
||
}),
|
||
'fixed-migration-version-mismatch': freezeRecord({
|
||
recordVersion: 'synthetic-release-record/v1',
|
||
label: 'migration points at a later release while the artifact and rollout card name the earlier one',
|
||
releaseVersion: '2024.07.0',
|
||
commit: {
|
||
kind: 'synthetic-commit/v1',
|
||
version: '2024.07.0',
|
||
id: 'synthetic-commit-7f4a0c1',
|
||
},
|
||
artifact: {
|
||
kind: 'synthetic-artifact/v1',
|
||
version: '2024.07.0',
|
||
digest: 'sha256:synthetic-2024-07-0-c3',
|
||
sourceCommitId: 'synthetic-commit-7f4a0c1',
|
||
evidenceVersion: 'synthetic-evidence/v1',
|
||
},
|
||
migration: {
|
||
kind: 'synthetic-migration-plan/v1',
|
||
version: '2024.07.1',
|
||
targetReleaseVersion: '2024.07.1',
|
||
compatibility: 'not-yet-declared-for-2024.07.0',
|
||
action: 'not-executed',
|
||
},
|
||
rollout: {
|
||
kind: 'synthetic-rollout-card/v1',
|
||
version: '2024.07.0',
|
||
requestedReleaseVersion: '2024.07.0',
|
||
requestedArtifactDigest: 'sha256:synthetic-2024-07-0-c3',
|
||
requestedMigrationVersion: '2024.07.0',
|
||
state: 'proposed-not-executed',
|
||
},
|
||
rollback: {
|
||
kind: 'synthetic-rollback-candidate/v1',
|
||
version: '2024.07.0',
|
||
returnReleaseVersion: '2024.06.3',
|
||
returnArtifactDigest: 'sha256:synthetic-2024-06-3-z9',
|
||
migrationBoundary: 'does-not-reverse-data-and-requires-separate-human-review',
|
||
state: 'draft-not-executed',
|
||
},
|
||
}),
|
||
'fixed-rollout-digest-mismatch': freezeRecord({
|
||
recordVersion: 'synthetic-release-record/v1',
|
||
label: 'rollout card requests a different digest than the recorded artifact',
|
||
releaseVersion: '2024.07.0',
|
||
commit: {
|
||
kind: 'synthetic-commit/v1',
|
||
version: '2024.07.0',
|
||
id: 'synthetic-commit-7f4a0c1',
|
||
},
|
||
artifact: {
|
||
kind: 'synthetic-artifact/v1',
|
||
version: '2024.07.0',
|
||
digest: 'sha256:synthetic-2024-07-0-d4',
|
||
sourceCommitId: 'synthetic-commit-7f4a0c1',
|
||
evidenceVersion: 'synthetic-evidence/v1',
|
||
},
|
||
migration: {
|
||
kind: 'synthetic-migration-plan/v1',
|
||
version: '2024.07.0',
|
||
targetReleaseVersion: '2024.07.0',
|
||
compatibility: 'expand-compatible-with-2024.06.3',
|
||
action: 'not-executed',
|
||
},
|
||
rollout: {
|
||
kind: 'synthetic-rollout-card/v1',
|
||
version: '2024.07.0',
|
||
requestedReleaseVersion: '2024.07.0',
|
||
requestedArtifactDigest: 'sha256:synthetic-other-digest-e5',
|
||
requestedMigrationVersion: '2024.07.0',
|
||
state: 'proposed-not-executed',
|
||
},
|
||
rollback: {
|
||
kind: 'synthetic-rollback-candidate/v1',
|
||
version: '2024.07.0',
|
||
returnReleaseVersion: '2024.06.3',
|
||
returnArtifactDigest: 'sha256:synthetic-2024-06-3-z9',
|
||
migrationBoundary: 'does-not-reverse-data-and-requires-separate-human-review',
|
||
state: 'draft-not-executed',
|
||
},
|
||
}),
|
||
});
|
||
|
||
function hasOnlyKeys(object, allowed) {
|
||
return object && typeof object === 'object' && !Array.isArray(object)
|
||
&& Object.keys(object).every((key) => allowed.includes(key));
|
||
}
|
||
|
||
function rejectInspection(reason) {
|
||
return Object.freeze({
|
||
kind: 'synthetic-release-evidence-report/v1',
|
||
syntheticOnly: true,
|
||
inputAccepted: false,
|
||
reason,
|
||
modelLimit: FIXTURE_LIMIT,
|
||
realGit: 'not-read',
|
||
artifactRegistry: 'not-read',
|
||
ci: 'not-run',
|
||
deployment: 'not-run',
|
||
cloud: 'not-read',
|
||
production: 'not-read',
|
||
deliveryLogs: 'not-read',
|
||
filesystem: 'not-read',
|
||
network: 'not-used',
|
||
});
|
||
}
|
||
|
||
export function createFixedSyntheticReleaseInput(caseId) {
|
||
return Object.freeze({
|
||
kind: 'synthetic-release-evidence-input/v1',
|
||
synthetic: true,
|
||
scope: FIXTURE_SCOPE,
|
||
mode: FIXTURE_MODE,
|
||
caseId,
|
||
});
|
||
}
|
||
|
||
function inspectRecord(record) {
|
||
const checks = Object.freeze({
|
||
commitVersionMatchesRelease: record.commit.version === record.releaseVersion,
|
||
artifactVersionMatchesRelease: record.artifact.version === record.releaseVersion,
|
||
artifactBindsCommit: record.artifact.sourceCommitId === record.commit.id,
|
||
migrationVersionMatchesRelease: record.migration.version === record.releaseVersion,
|
||
migrationTargetsRelease: record.migration.targetReleaseVersion === record.releaseVersion,
|
||
migrationIsDeclaredCompatible: record.migration.compatibility === 'expand-compatible-with-2024.06.3',
|
||
rolloutVersionMatchesRelease: record.rollout.version === record.releaseVersion,
|
||
rolloutRequestsRelease: record.rollout.requestedReleaseVersion === record.releaseVersion,
|
||
rolloutRequestsArtifact: record.rollout.requestedArtifactDigest === record.artifact.digest,
|
||
rolloutRequestsMigration: record.rollout.requestedMigrationVersion === record.migration.version,
|
||
rollbackCarriesKnownReturnVersion: record.rollback.returnReleaseVersion === '2024.06.3',
|
||
rollbackCarriesKnownReturnArtifact: record.rollback.returnArtifactDigest === 'sha256:synthetic-2024-06-3-z9',
|
||
rollbackDoesNotPromiseDataUndo: record.rollback.migrationBoundary === 'does-not-reverse-data-and-requires-separate-human-review',
|
||
});
|
||
const findings = [];
|
||
if (!checks.commitVersionMatchesRelease) findings.push('commit-version-mismatch');
|
||
if (!checks.artifactVersionMatchesRelease) findings.push('artifact-version-mismatch');
|
||
if (!checks.artifactBindsCommit) findings.push('artifact-does-not-bind-commit');
|
||
if (!checks.migrationVersionMatchesRelease || !checks.migrationTargetsRelease) findings.push('migration-does-not-target-release');
|
||
if (!checks.migrationIsDeclaredCompatible) findings.push('migration-compatibility-not-declared');
|
||
if (!checks.rolloutVersionMatchesRelease || !checks.rolloutRequestsRelease) findings.push('rollout-version-mismatch');
|
||
if (!checks.rolloutRequestsArtifact) findings.push('rollout-does-not-request-recorded-artifact');
|
||
if (!checks.rolloutRequestsMigration) findings.push('rollout-does-not-request-recorded-migration');
|
||
if (!checks.rollbackCarriesKnownReturnVersion || !checks.rollbackCarriesKnownReturnArtifact) findings.push('rollback-return-record-incomplete');
|
||
if (!checks.rollbackDoesNotPromiseDataUndo) findings.push('rollback-overstates-migration-reversal');
|
||
return Object.freeze({ checks, findings: Object.freeze(findings) });
|
||
}
|
||
|
||
/**
|
||
* Inspects one embedded release-evidence record. It never reads a commit,
|
||
* artifact registry, CI run, deploy target, cloud account, production state,
|
||
* delivery log, file, environment variable, clock or network endpoint.
|
||
*/
|
||
export function inspectSyntheticReleaseEvidence(input) {
|
||
if (!input || input.kind !== 'synthetic-release-evidence-input/v1' || input.synthetic !== true) {
|
||
return rejectInspection('synthetic-input-required');
|
||
}
|
||
const allowed = ['kind', 'synthetic', 'scope', 'mode', 'caseId'];
|
||
if (!hasOnlyKeys(input, allowed)) return rejectInspection('unexpected-input-field');
|
||
if (input.scope !== FIXTURE_SCOPE) return rejectInspection('unexpected-synthetic-scope');
|
||
if (input.mode !== FIXTURE_MODE) return rejectInspection('fixed-memory-mode-required');
|
||
if (!Object.hasOwn(fixedRecords, input.caseId)) return rejectInspection('unknown-fixed-synthetic-case');
|
||
|
||
const record = fixedRecords[input.caseId];
|
||
const inspected = inspectRecord(record);
|
||
const decision = inspected.findings.length === 0
|
||
? 'continue-to-human-deploy-review'
|
||
: 'stop-and-reconcile-records';
|
||
return Object.freeze({
|
||
kind: 'synthetic-release-evidence-report/v1',
|
||
syntheticOnly: true,
|
||
inputAccepted: true,
|
||
reason: 'fixed-versioned-synthetic-record-inspected',
|
||
modelLimit: FIXTURE_LIMIT,
|
||
scope: FIXTURE_SCOPE,
|
||
caseId: input.caseId,
|
||
caseLabel: record.label,
|
||
record,
|
||
checks: inspected.checks,
|
||
findings: inspected.findings,
|
||
decision,
|
||
nextHumanQuestion: decision === 'continue-to-human-deploy-review'
|
||
? 'can an authorized reviewer compare the real commit, artifact, migration and deploy intent using the organisation’s approved evidence?'
|
||
: 'which versioned record is wrong, and can the discrepancy be corrected before any real deploy review begins?',
|
||
evidenceBoundary: Object.freeze({
|
||
source: 'embedded-fixed-synthetic-js-objects-only',
|
||
realCommit: 'not-read',
|
||
artifactRegistry: 'not-read',
|
||
ci: 'not-run',
|
||
deployment: 'not-run',
|
||
cloud: 'not-read',
|
||
production: 'not-read',
|
||
deliveryLogs: 'not-read',
|
||
filesystem: 'not-read',
|
||
network: 'not-used',
|
||
}),
|
||
productionEffect: 'not-attempted',
|
||
});
|
||
}
|
||
|
||
function canonicalReportFor(caseId) {
|
||
return inspectSyntheticReleaseEvidence(createFixedSyntheticReleaseInput(caseId));
|
||
}
|
||
|
||
/**
|
||
* Produces a teaching decision draft only after comparing the complete report
|
||
* with the fixed record again. It does not start CI, publish an artifact, or
|
||
* create, approve, change, observe or roll back a deployment.
|
||
*/
|
||
export function planSyntheticReleaseDecision(report) {
|
||
if (!report || report.kind !== 'synthetic-release-evidence-report/v1' || report.syntheticOnly !== true || report.inputAccepted !== true) {
|
||
return Object.freeze({ accepted: false, syntheticOnly: true, reason: 'accepted-synthetic-report-required', modelLimit: FIXTURE_LIMIT });
|
||
}
|
||
const allowed = [
|
||
'kind', 'syntheticOnly', 'inputAccepted', 'reason', 'modelLimit', 'scope', 'caseId', 'caseLabel',
|
||
'record', 'checks', 'findings', 'decision', 'nextHumanQuestion', 'evidenceBoundary', 'productionEffect',
|
||
];
|
||
if (!hasOnlyKeys(report, allowed)) {
|
||
return Object.freeze({ accepted: false, syntheticOnly: true, reason: 'unexpected-report-field', modelLimit: FIXTURE_LIMIT });
|
||
}
|
||
const canonical = canonicalReportFor(report.caseId);
|
||
if (JSON.stringify(canonical) !== JSON.stringify(report)) {
|
||
return Object.freeze({ accepted: false, syntheticOnly: true, reason: 'report-does-not-match-fixed-record', modelLimit: FIXTURE_LIMIT });
|
||
}
|
||
|
||
const actions = canonical.decision === 'continue-to-human-deploy-review'
|
||
? Object.freeze([
|
||
'request-authorized-human-comparison-of-real-evidence',
|
||
'keep-commit-artifact-migration-and-rollout-identifiers-together',
|
||
'prepare-only-a-versioned-rollback-draft-with-data-boundary',
|
||
])
|
||
: Object.freeze([
|
||
'stop-before-real-deploy-review',
|
||
'reconcile-only-the-named-fixed-record-discrepancy',
|
||
'repeat-the-evidence-comparison-after-a-new-authorized-record-exists',
|
||
]);
|
||
return Object.freeze({
|
||
kind: 'synthetic-release-decision-draft/v1',
|
||
accepted: true,
|
||
syntheticOnly: true,
|
||
reason: 'canonical-fixed-synthetic-release-decision-draft',
|
||
modelLimit: FIXTURE_LIMIT,
|
||
sourceReport: canonical,
|
||
decision: canonical.decision,
|
||
actions,
|
||
rollbackCandidate: canonical.record.rollback,
|
||
realGit: 'not-read-or-changed',
|
||
artifactRegistry: 'not-read-or-written',
|
||
ci: 'not-run',
|
||
deployment: 'not-created-or-run',
|
||
cloud: 'not-read-or-changed',
|
||
production: 'not-read-or-changed',
|
||
deliveryLogs: 'not-read-or-written',
|
||
filesystem: 'not-read-or-written',
|
||
network: 'not-used',
|
||
productionEffect: 'not-attempted',
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Returns a versioned synthetic rollback draft only for the aligned canonical
|
||
* decision. The returned record is not a deploy instruction and never changes
|
||
* code, artifacts, data, traffic, cloud state or a real rollout.
|
||
*/
|
||
export function draftSyntheticControlledRollback(plan) {
|
||
if (!plan || plan.kind !== 'synthetic-release-decision-draft/v1' || plan.accepted !== true || plan.syntheticOnly !== true || !plan.sourceReport) {
|
||
return Object.freeze({ drafted: false, syntheticOnly: true, reason: 'accepted-synthetic-decision-draft-required' });
|
||
}
|
||
const canonicalPlan = planSyntheticReleaseDecision(plan.sourceReport);
|
||
if (canonicalPlan.accepted !== true || JSON.stringify(canonicalPlan) !== JSON.stringify(plan)) {
|
||
return Object.freeze({ drafted: false, syntheticOnly: true, reason: 'decision-draft-does-not-match-fixed-record' });
|
||
}
|
||
if (canonicalPlan.decision !== 'continue-to-human-deploy-review') {
|
||
return Object.freeze({ drafted: false, syntheticOnly: true, reason: 'reconcile-records-before-rollback-draft' });
|
||
}
|
||
const current = canonicalPlan.sourceReport.record;
|
||
return Object.freeze({
|
||
kind: 'synthetic-controlled-rollback-draft/v1',
|
||
drafted: true,
|
||
syntheticOnly: true,
|
||
reason: 'versioned-return-point-recorded-in-memory',
|
||
fromReleaseVersion: current.releaseVersion,
|
||
fromArtifactDigest: current.artifact.digest,
|
||
toReleaseVersion: current.rollback.returnReleaseVersion,
|
||
toArtifactDigest: current.rollback.returnArtifactDigest,
|
||
migrationBoundary: current.rollback.migrationBoundary,
|
||
realGit: 'not-read-or-changed',
|
||
artifactRegistry: 'not-read-or-written',
|
||
ci: 'not-run',
|
||
deployment: 'not-created-or-run',
|
||
cloud: 'not-read-or-changed',
|
||
production: 'not-read-or-changed',
|
||
deliveryLogs: 'not-read-or-written',
|
||
filesystem: 'not-read-or-written',
|
||
network: 'not-used',
|
||
productionEffect: 'not-attempted',
|
||
});
|
||
}
|
||
|
||
export function runReleaseEvidenceFixture() {
|
||
const aligned = inspectSyntheticReleaseEvidence(createFixedSyntheticReleaseInput('fixed-aligned-release'));
|
||
const artifactMismatch = inspectSyntheticReleaseEvidence(createFixedSyntheticReleaseInput('fixed-artifact-commit-mismatch'));
|
||
const migrationMismatch = inspectSyntheticReleaseEvidence(createFixedSyntheticReleaseInput('fixed-migration-version-mismatch'));
|
||
const rolloutMismatch = inspectSyntheticReleaseEvidence(createFixedSyntheticReleaseInput('fixed-rollout-digest-mismatch'));
|
||
const nonSynthetic = inspectSyntheticReleaseEvidence({ ...createFixedSyntheticReleaseInput('fixed-aligned-release'), synthetic: false });
|
||
const extraCiLikeField = inspectSyntheticReleaseEvidence({ ...createFixedSyntheticReleaseInput('fixed-aligned-release'), ciLog: 'not-read' });
|
||
const extraDeployLikeField = inspectSyntheticReleaseEvidence({ ...createFixedSyntheticReleaseInput('fixed-aligned-release'), deployTarget: 'not-read' });
|
||
const wrongScope = inspectSyntheticReleaseEvidence({ ...createFixedSyntheticReleaseInput('fixed-aligned-release'), scope: 'another-scope' });
|
||
const wrongMode = inspectSyntheticReleaseEvidence({ ...createFixedSyntheticReleaseInput('fixed-aligned-release'), mode: 'read-real-registry' });
|
||
const unknownCase = inspectSyntheticReleaseEvidence(createFixedSyntheticReleaseInput('invented-release'));
|
||
const alignedPlan = planSyntheticReleaseDecision(aligned);
|
||
const mismatchPlan = planSyntheticReleaseDecision(artifactMismatch);
|
||
const forgedDecision = planSyntheticReleaseDecision({ ...aligned, decision: 'stop-and-reconcile-records' });
|
||
const forgedRecord = planSyntheticReleaseDecision({ ...aligned, record: { ...aligned.record, releaseVersion: '2024.99.0' } });
|
||
const extraReportField = planSyntheticReleaseDecision({ ...aligned, realDeploymentId: 'not-read' });
|
||
const rollbackDraft = draftSyntheticControlledRollback(alignedPlan);
|
||
const forgedRollback = draftSyntheticControlledRollback({ ...alignedPlan, actions: ['run-real-deploy'] });
|
||
const blockedRollback = draftSyntheticControlledRollback(mismatchPlan);
|
||
const reportRollback = draftSyntheticControlledRollback(aligned);
|
||
|
||
return Object.freeze({
|
||
assertions: Object.freeze({
|
||
acceptsAlignedFixedRecord: aligned.inputAccepted === true && aligned.decision === 'continue-to-human-deploy-review',
|
||
keepsAllTeachingObjectsVersioned: aligned.record.commit.kind === 'synthetic-commit/v1' && aligned.record.artifact.kind === 'synthetic-artifact/v1' && aligned.record.migration.kind === 'synthetic-migration-plan/v1' && aligned.record.rollout.kind === 'synthetic-rollout-card/v1',
|
||
bindsCommitToArtifact: aligned.checks.artifactBindsCommit === true && aligned.record.artifact.sourceCommitId === aligned.record.commit.id,
|
||
bindsArtifactAndMigrationToRolloutCard: aligned.checks.rolloutRequestsArtifact === true && aligned.checks.rolloutRequestsMigration === true,
|
||
requiresMigrationCompatibility: aligned.checks.migrationIsDeclaredCompatible === true && aligned.record.migration.action === 'not-executed',
|
||
keepsReturnPointVersioned: aligned.checks.rollbackCarriesKnownReturnVersion === true && aligned.checks.rollbackCarriesKnownReturnArtifact === true,
|
||
doesNotClaimMigrationUndo: aligned.checks.rollbackDoesNotPromiseDataUndo === true && aligned.record.rollback.migrationBoundary.includes('does-not-reverse-data'),
|
||
stopsArtifactCommitMismatch: artifactMismatch.decision === 'stop-and-reconcile-records' && artifactMismatch.findings.includes('artifact-does-not-bind-commit'),
|
||
stopsMigrationMismatch: migrationMismatch.decision === 'stop-and-reconcile-records' && migrationMismatch.findings.includes('migration-does-not-target-release'),
|
||
stopsUndeclaredMigrationCompatibility: migrationMismatch.findings.includes('migration-compatibility-not-declared'),
|
||
stopsRolloutDigestMismatch: rolloutMismatch.decision === 'stop-and-reconcile-records' && rolloutMismatch.findings.includes('rollout-does-not-request-recorded-artifact'),
|
||
rejectsNonSyntheticInput: nonSynthetic.inputAccepted === false && nonSynthetic.reason === 'synthetic-input-required',
|
||
rejectsCiLikeInput: extraCiLikeField.inputAccepted === false && extraCiLikeField.reason === 'unexpected-input-field',
|
||
rejectsDeployLikeInput: extraDeployLikeField.inputAccepted === false && extraDeployLikeField.reason === 'unexpected-input-field',
|
||
rejectsDifferentScope: wrongScope.inputAccepted === false && wrongScope.reason === 'unexpected-synthetic-scope',
|
||
rejectsRegistryReadMode: wrongMode.inputAccepted === false && wrongMode.reason === 'fixed-memory-mode-required',
|
||
rejectsUnknownFixedCase: unknownCase.inputAccepted === false && unknownCase.reason === 'unknown-fixed-synthetic-case',
|
||
createsOnlySyntheticDecisionDraft: alignedPlan.accepted === true && alignedPlan.deployment === 'not-created-or-run' && alignedPlan.ci === 'not-run',
|
||
preservesStopDecisionInDraft: mismatchPlan.accepted === true && mismatchPlan.decision === 'stop-and-reconcile-records' && mismatchPlan.actions.includes('stop-before-real-deploy-review'),
|
||
rejectsForgedDecision: forgedDecision.accepted === false && forgedDecision.reason === 'report-does-not-match-fixed-record',
|
||
rejectsForgedRecord: forgedRecord.accepted === false && forgedRecord.reason === 'report-does-not-match-fixed-record',
|
||
rejectsUnexpectedReportField: extraReportField.accepted === false && extraReportField.reason === 'unexpected-report-field',
|
||
draftsControlledReturnOnlyForAlignedRecord: rollbackDraft.drafted === true && rollbackDraft.fromReleaseVersion === '2024.07.0' && rollbackDraft.toReleaseVersion === '2024.06.3',
|
||
keepsRollbackOutsideRealSystems: rollbackDraft.deployment === 'not-created-or-run' && rollbackDraft.production === 'not-read-or-changed' && rollbackDraft.deliveryLogs === 'not-read-or-written',
|
||
rejectsForgedRollbackDraft: forgedRollback.drafted === false && forgedRollback.reason === 'decision-draft-does-not-match-fixed-record',
|
||
blocksRollbackDraftForMismatch: blockedRollback.drafted === false && blockedRollback.reason === 'reconcile-records-before-rollback-draft',
|
||
refusesReportAsRollbackDraft: reportRollback.drafted === false && reportRollback.reason === 'accepted-synthetic-decision-draft-required',
|
||
}),
|
||
samples: Object.freeze({
|
||
aligned,
|
||
artifactMismatch,
|
||
migrationMismatch,
|
||
rolloutMismatch,
|
||
nonSynthetic,
|
||
extraCiLikeField,
|
||
extraDeployLikeField,
|
||
wrongScope,
|
||
wrongMode,
|
||
unknownCase,
|
||
alignedPlan,
|
||
mismatchPlan,
|
||
forgedDecision,
|
||
forgedRecord,
|
||
extraReportField,
|
||
rollbackDraft,
|
||
forgedRollback,
|
||
blockedRollback,
|
||
reportRollback,
|
||
}),
|
||
});
|
||
}
|
||
|
||
const practiceExample = [
|
||
"import { createFixedSyntheticReleaseInput, inspectSyntheticReleaseEvidence } from './upgrade-2024-07.mjs';",
|
||
'',
|
||
"const report = inspectSyntheticReleaseEvidence(createFixedSyntheticReleaseInput('fixed-aligned-release'));",
|
||
'',
|
||
"if (report.decision !== 'continue-to-human-deploy-review') {",
|
||
" throw new Error('fixed synthetic chain is not aligned');",
|
||
'}',
|
||
'',
|
||
'console.log(report.record.commit.id);',
|
||
'console.log(report.record.artifact.digest);',
|
||
'console.log(report.record.rollout.state); // proposed-not-executed',
|
||
'',
|
||
'// The fixture reads only embedded objects. It is not CI, a registry, or a rollout.',
|
||
'',
|
||
'node web/scripts/upgrade-2024-07.mjs --verify-fixture',
|
||
].join('\n');
|
||
|
||
const mechanismExample = [
|
||
"import { createFixedSyntheticReleaseInput, inspectSyntheticReleaseEvidence, planSyntheticReleaseDecision, draftSyntheticControlledRollback } from './upgrade-2024-07.mjs';",
|
||
'',
|
||
"const report = inspectSyntheticReleaseEvidence(createFixedSyntheticReleaseInput('fixed-aligned-release'));",
|
||
'const plan = planSyntheticReleaseDecision(report);',
|
||
'const rollback = draftSyntheticControlledRollback(plan);',
|
||
'',
|
||
"if (rollback.drafted !== true || rollback.migrationBoundary !== 'does-not-reverse-data-and-requires-separate-human-review') {",
|
||
" throw new Error('fixed synthetic rollback boundary changed');",
|
||
'}',
|
||
'',
|
||
'console.log(rollback.toReleaseVersion); // 2024.06.3',
|
||
'console.log(rollback.deployment); // not-created-or-run',
|
||
'',
|
||
'// This creates no CI run, deploy, registry entry, cloud change, or production effect.',
|
||
].join('\n');
|
||
|
||
const fieldExample = [
|
||
"import { runReleaseEvidenceFixture } from './upgrade-2024-07.mjs';",
|
||
'',
|
||
'const fixture = runReleaseEvidenceFixture();',
|
||
'const failed = Object.entries(fixture.assertions)',
|
||
' .filter(([, value]) => value !== true)',
|
||
' .map(([name]) => name);',
|
||
'',
|
||
'if (failed.length > 0) throw new Error(failed.join(", "));',
|
||
"console.log('fixed synthetic evidence checks passed');",
|
||
'',
|
||
'// The four cases are versioned JS records held in memory, not delivery evidence.',
|
||
'node web/scripts/upgrade-2024-07.mjs --verify-fixture',
|
||
].join('\n');
|
||
|
||
const practice = revision({
|
||
slug: 'editorial-2024-07-practice-release-engineering',
|
||
title: 'Инженерия релиза: цепочка доказательств между commit и deploy',
|
||
excerpt: 'Практический разбор: как связать версию, artifact, migration и намерение rollout так, чтобы расхождение остановило выпуск до действия и оставило проверяемый rollback.',
|
||
cover: '/assets/editorial/2024/release-engineering-2024-delivery-chain.svg',
|
||
readingMinutes: 10,
|
||
}, [
|
||
p('В заявке на выпуск написано <code>2024.07.0</code>. Рядом лежат четыре похожих подписи: commit <code>synthetic-commit-7f4a0c1</code>, artifact с digest <code>sha256:synthetic-2024-07-0-a1</code>, migration plan <code>2024.07.0</code> и rollout card с тем же номером. Это конкретная учебная ситуация, а не журнал поставки: все записи заранее вшиты в fixture. Цена понятна и без реальной среды: если хотя бы одна подпись относится к другой версии, команда может обсуждать ошибку не того кода, ожидать не ту схему и готовить возврат к несуществующей точке.') ,
|
||
p('Чаще всего симптом выглядит безобидно: на каждой стадии есть знакомый tag, но нельзя показать одну непрерывную связь от commit до запрошенного artifact. Причина — версия используется как украшение, а не как ключ сравнения; migration и rollout появляются отдельными карточками. Проверка короткая: сравнить parent-ссылку artifact на commit, target migration, digest в rollout и return point. Действие ещё короче: при первом несовпадении остановить review и исправить запись до любого реального deploy. Так рассуждают о контракте релиза, не о любимой CI-системе.'),
|
||
h2('Что именно должно совпасть'),
|
||
p('Минимальная цепочка не требует огромной платформы. Достаточно назвать четыре объекта и запретить им молча расходиться. Commit фиксирует исходный revision. Artifact хранит собственную версию, digest и declared source commit. Migration plan хранит target release и отдельный compatibility statement. Rollout card повторяет версию, digest и migration version, но остаётся намерением: она ещё не доказывает, что кто-либо получил доступ к среде или что операция была выполнена. Отдельно хранится rollback candidate с прошлой версией и digest, но без обещания вернуть данные назад.'),
|
||
p('Слово «доказательство» здесь практическое. Оно не означает криптографическое свойство любого поля и не подменяет attestation. Оно означает, что следующий человек может сравнить одну пару значений с другой и получить конкретное «совпало» либо «стоп». GitHub в июне 2024 уже описывал artifact attestations как отдельный объект, который создаётся для build artifact и проверяется позже. Это полезное разделение ролей: label artifact, statement о происхождении и approval на deploy не должны сливаться в одну строку <code>release=green</code>.'),
|
||
figure('/assets/editorial/2024/release-engineering-2024-delivery-chain.svg', 'Четыре versioned synthetic объекта: commit, artifact, migration plan и rollout card. Стрелки требуют точного совпадения commit id, версии, digest и migration version; отдельная нижняя карточка показывает rollback draft без реального deploy и без обещания отменить данные.', 'Цепочка показывает только учебный contract fixed records в памяти. Это не pipeline, artifact registry, трасса CI, облачный экран или результат rollout.'),
|
||
h2('Рабочая таблица связей'),
|
||
table('Какая связь нужна до human review', ['Переход', 'Нужное равенство', 'Что проверить', 'Что делать при расхождении'], [
|
||
['commit → artifact', '<code>artifact.sourceCommitId = commit.id</code>', 'Не только tag, но exact declared commit id.', 'Остановить: artifact нельзя связать с исходным revision.'],
|
||
['artifact → migration', '<code>migration.targetReleaseVersion = releaseVersion</code>', 'Версия и declared compatibility относятся к одному release.', 'Остановить: не угадывать, для какого кода написана migration.'],
|
||
['artifact → rollout card', '<code>requestedArtifactDigest = artifact.digest</code>', 'Card называет именно записанный immutable digest.', 'Остановить: версия может совпасть, а содержимое — нет.'],
|
||
['release → rollback draft', 'return version + return digest известны', 'Есть ли точка возврата и граница для migration.', 'Не обещать data undo; вынести его в отдельный review.'],
|
||
]),
|
||
p('Таблица полезна тем, что не требует доверять одному центральному полю. Например, tag <code>2024.07.0</code> может стоять и у source, и у artifact, но source commit всё равно обязан совпасть. Аналогично, rollout card не становится надёжной от того, что её version выглядит красиво: digest должен быть тем же digest, который зафиксирован рядом с artifact. Каждая строка даёт проверяемый ответ и отдельный stop condition. Такой stop condition дешевле позднего расследования, потому что меняет только запись, а не среду.'),
|
||
h2('Минимальная fixture вместо ложной автоматизации'),
|
||
p('Ниже — исполнимая fixture sidecar-пакета. В ней четыре versioned synthetic JS objects заранее записаны в модуле. Функция не принимает URL, имя registry, путь к manifest, credentials, журнал CI или target deploy. Она возвращает teaching report: aligned case ведёт лишь к human deploy review, а mismatch case — к stop. Команда <code>--verify-fixture</code> проверяет invariants этих объектов, но не создаёт artifact, не вызывает runner и не читает ни одного внешнего состояния.'),
|
||
code(practiceExample),
|
||
p('Именно ограничение input делает пример проверяемым. Если передать похожее поле <code>ciLog</code> или <code>deployTarget</code>, fixture отвергнет input как extra field. Это не защита настоящей системы и не проверка CI. Это защита смысла статьи: учебный объект не должен незаметно начать потреблять реальное evidence, а затем выдавать его за подтверждённый rollout. Даже положительный результат fixture означает лишь равенство fixed records, которые автор уже поместил в память.'),
|
||
h2('Маршрут перед настоящим выпуском'),
|
||
ol([
|
||
'<strong>Назовите release version и owner проверки.</strong> Version без ответственного за сравнение остаётся label. Owner не обязан делать deploy, но обязан знать, какая запись считается исходной.',
|
||
'<strong>Свяжите artifact с commit exact идентификатором.</strong> Не заменяйте id названием ветки, последним merge или человеческим описанием. Сначала проверьте declared link, затем решайте, достаточно ли он надёжен для вашей среды.',
|
||
'<strong>Опишите migration отдельно.</strong> Укажите target version, compatibility с предыдущей версией и то, что data effect требует отдельного review. Не прячьте это в комментарии к artifact.',
|
||
'<strong>Соберите rollout intent без действия.</strong> Card должна повторить release, digest и migration version. Её задача — сделать будущую проверку однозначной, не запустить операцию.',
|
||
'<strong>Сравните четыре перехода и остановитесь на первом mismatch.</strong> Не лечите расхождение новой попыткой. Исправьте record, повторите сравнение и только затем передавайте вопрос авторизованному reviewer.',
|
||
'<strong>Запишите return point до approval.</strong> Версия и digest для возврата нужны до реального deploy; для migration отдельно зафиксируйте, что code rollback не означает data rollback.',
|
||
]),
|
||
h2('Почему migration нельзя приклеить в самом конце'),
|
||
p('Migration обычно ломает простую картину «вернули прошлый artifact — всё вернулось». В Kubernetes Deployment revision связана с Pod template: rollback возвращает прежний template, а не отменяет любые изменения за его пределами. Это не недостаток Kubernetes; это важная граница, которую стоит назвать в любом delivery design. Если migration уже изменила данные или contract, прежний code может потребовать compatibility window, отдельную reverse procedure либо сознательный отказ от автоматического возврата. Поэтому migration version и её declared compatibility — часть evidence chain, а не последний пункт runbook.'),
|
||
p('Практика не требует однажды выбрать универсальный способ миграций. Она требует отделить два вопроса. Первый: какая версия artifact допустима для target data contract? Второй: какое действие по данным разрешено и как проверяется его обратимость? Пока второй вопрос не описан, rollback draft должен говорить ровно правду: «return artifact указан; data undo не обещан». Такая формулировка неприятнее зелёной галочки, зато она не заставляет инженера угадывать после первого симптома.'),
|
||
h2('Ограничения и следующий проверяемый шаг'),
|
||
p('Эта схема не проверяет реальный Git history, build provenance, signature, runtime configuration, secret, доступ к cluster, состояние database, качество monitoring или факт доставки. SLSA v1.0 даёт vocabulary для provenance и verification, но не отменяет local policy и не превращает произвольный JSON в доказательство. GitHub Artifact Attestations и Kubernetes Deployment documentation описывают конкретные возможности своих систем; из них нельзя вывести, что каждая организация должна применять идентичные поля или один и тот же rollback.'),
|
||
p('Следующий проверяемый шаг — взять <strong>один</strong> разрешённый к анализу release record и, не выполняя deploy, заполнить ту же таблицу четырьмя значениями: exact commit id, artifact digest, migration target/compatibility и return version/digest. Затем второй reviewer должен найти либо подтвердить одно равенство в каждой строке. Если у строки нет owner-а, источника или stop action, не расширяйте pipeline: сначала исправьте contract. Это маленький тест, который оставляет понятный результат независимо от выбранного CI/CD инструмента.'),
|
||
h2('Историческая граница июля 2024'),
|
||
p('Материал использует только первичные официальные источники, реально доступные к июлю 2024: SLSA v1.0 был объявлен stable в апреле 2023, GitHub объявил Artifact Attestations generally available 25 июня 2024, а Kubernetes release-1.30 documentation уже содержала versioned Deployment guidance. Технические факты в тексте намеренно узкие: provenance нужно проверять относительно ожиданий, attestation не равна approval, а Deployment rollback не равен откату данных. Все ids, digest, versions и outcomes выше — fixed synthetic teaching records, не наблюдения о чьей-либо доставке.'),
|
||
]);
|
||
|
||
const mechanism = revision({
|
||
slug: 'editorial-2024-07-mechanism-release-engineering',
|
||
title: 'Инженерия релиза: механизм evidence gate и контролируемого rollback',
|
||
excerpt: 'Модель release evidence: почему одинаковая версия не доказывает одинаковое содержимое, как закрыть вход fixture и почему rollback возвращает только явно названную точку.',
|
||
cover: '/assets/editorial/2024/release-engineering-2024-rollback-table.svg',
|
||
readingMinutes: 11,
|
||
}, [
|
||
p('Четыре карточки могут честно носить версию <code>2024.07.0</code> и всё равно описывать разные вещи. В учебном case artifact говорит, что он получен из <code>synthetic-commit-other-91</code>, migration уже нацелена на <code>2024.07.1</code>, а rollout card просит знакомый номер. Это не лог CI и не снимок кластера, а специально зафиксированная модель расхождения. Цена такого расхождения в настоящем процессе — потеря точки, где ещё можно безопасно остановиться: команда начинает спорить о rollout, хотя предметом спора должен быть parent artifact или contract migration.'),
|
||
p('Симптом — «везде написана одна версия». Причина — system считает tag достаточным идентификатором и допускает свободные поля между стадиями. Проверка — представить каждую связь как exact predicate, а не как описание: commit id у artifact равен commit id source; rollout digest равен artifact digest; migration target равен release version. Действие — закрыть gate: любой false predicate возвращает <code>stop-and-reconcile-records</code>, а не очередную попытку deploy. Так механизм делает несовпадение видимым до действия и не прячет его за успешным названием job.'),
|
||
h2('Не четыре текста, а ориентированные связи'),
|
||
p('У цепочки есть направление. Commit не подтверждает artifact автоматически; artifact должен явно указать, на какой commit он ссылается. Migration не должна «следовать последнему main»; ей нужен target release и known compatibility statement. Rollout card не является artifact; она лишь говорит, какой immutable digest и какой migration version намерены рассматривать дальше. Rollback candidate не является кнопкой возврата; он описывает предыдущую versioned point и собственную data boundary. Если поменять направление, легко решить, что rollout status доказывает source, хотя он мог смотреть на совсем другой artifact.'),
|
||
p('Эта модель близка к тому, как SLSA отделяет artifact от provenance и verification от простого наличия metadata. Но она намеренно меньше спецификации: не пытается моделировать builder, signature, identity, dependency graph или policy engine. Внутри sidecar нужны только считываемые relations. Такая скромность важна: чем больше real-like полей проглатывает fixture, тем выше риск неявно превратить teaching example в плохую imitation pipeline. Поэтому input состоит из marker, scope, fixed-memory-only mode и case id; сами commit, artifact, migration и rollout нельзя передать извне.'),
|
||
figure('/assets/editorial/2024/release-engineering-2024-rollback-table.svg', 'Матрица controlled rollback: code and artifact могут иметь versioned return point, migration требует отдельной compatibility and data review, rollout card не становится операцией, а evidence record остаётся тем, что нужно сравнить до действия.', 'Таблица показывает границы решения: вернуть artifact можно планировать, но fixture не выполняет deploy и не объявляет отмену migration или данных.'),
|
||
h2('Таблица predicate и stop condition'),
|
||
table('Проверки механизма и их смысл', ['Predicate', 'Когда true', 'Когда gate закрыт', 'Разрешённое действие'], [
|
||
['<code>artifact.sourceCommitId === commit.id</code>', 'Artifact declared the same source revision.', 'Нельзя понять, чей source представляет artifact.', 'Стоп и сравнение source record; не менять rollout.'],
|
||
['<code>migration.targetReleaseVersion === releaseVersion</code>', 'Plan написан именно для этого release contract.', 'Migration относится к другой версии или неизвестной совместимости.', 'Стоп и отдельный migration review.'],
|
||
['<code>rollout.requestedArtifactDigest === artifact.digest</code>', 'Intent называет exact recorded content.', 'Card может указать другой immutable artifact.', 'Стоп и правка intent record.'],
|
||
['return version + digest известны', 'Есть названная code/artifact point для возврата.', 'Rollback — лишь слово без target.', 'Не давать approval; написать return draft.'],
|
||
['data boundary названа', 'Никто не обещает undo того, что не моделировалось.', 'Code rollback маскируется как data recovery.', 'Разделить review code и data.'],
|
||
]),
|
||
p('В этой таблице нет строки «все jobs зелёные», потому что она недостаточна для исходной проблемы. Зелёный status может быть полезным evidence в реальном разрешённом процессе, но он не отвечает, какой artifact проверяли, для какого release была migration и что именно вернётся назад. Обратное тоже верно: совпадение полей не доказывает успешный deploy. Gate обязан различать semantic consistency records и execution result. Иначе положительный unit test или status check случайно получит смысл release approval, на который он не рассчитан.'),
|
||
h2('Почему closed input — техническая, а не декоративная граница'),
|
||
p('Функция <code>createFixedSyntheticReleaseInput()</code> принимает case id, а <code>inspectSyntheticReleaseEvidence()</code> сверяет его с object, созданным в source file. Дополнительные <code>ciLog</code>, <code>deployTarget</code>, другой scope, режим чтения registry или unknown case отвергаются. Это звучит жёстко, но именно так видно, что пример не перепутал модель с интеграцией. В нём нельзя случайно положить реальный digest, URL или credentials, дождаться вывода и назвать его evidence. Все возвращаемые поля прямо говорят <code>not-read</code>, <code>not-run</code> или <code>not-attempted</code>.'),
|
||
p('После inspection отдельная функция plan повторно строит canonical report из fixed case и сравнивает весь object через serialised representation. Если подменить decision на continue, заменить releaseVersion либо добавить поле, похожее на deployment id, plan откажет. Это не универсальная защита от подделки в production; в fixture нет identity, signature и storage. Это маленькая проверка model invariant: decision не должен зависеть от изменённого object, который caller собрал после inspection. Даже на уровне обучения это полезнее, чем функция, которая верит любому красивому report.'),
|
||
h2('Исполнимый пример: plan и return draft'),
|
||
p('Этот пример можно запустить как модуль рядом с sidecar. Он сначала получает report только из embedded case, затем делает decision draft и запрашивает return point. Последняя проверка специально требует фразу о migration boundary. Если позже кто-то заменит её на обещание data undo или добавит действие над deploy, fixture должен изменить verdict и тест остановится. Код не является конфигурацией GitHub Actions, Kubernetes manifest или инструкцией registry; он не содержит client, filesystem access или network call.'),
|
||
code(mechanismExample),
|
||
p('Контролируемый rollback начинается не с глагола «откатить», а с ответа на два разных вопроса. К какому artifact version можно вернуться? И какие effects migration это не покрывает? Для aligned case fixture возвращает <code>2024.06.3</code> и точный synthetic digest, а строка <code>does-not-reverse-data-and-requires-separate-human-review</code> остаётся частью output. Это ограничение избегает одного из самых дорогих ложных обещаний: что возврат template или binary автоматически превращает данные в прежнюю форму.'),
|
||
h2('Маршрут проектирования evidence gate'),
|
||
ol([
|
||
'<strong>Запишите entity types.</strong> Commit, artifact, migration plan, rollout intent и rollback candidate должны быть различимы даже если используют одну release version.',
|
||
'<strong>Выберите immutable relation для каждой стрелки.</strong> Для artifact это exact source commit и digest; для migration — target version и compatibility; для intent — повторение exact digest и migration version.',
|
||
'<strong>Назовите отрицательные outcomes.</strong> «Неизвестный artifact», «migration другой версии» и «digest в card иной» — разные stop reasons. Не сливайте их в один retry.',
|
||
'<strong>Разведите consistency и execution.</strong> Gate может разрешить human review, но не говорит, что deploy состоялся. Result реальной операции должен жить в другой, разрешённой модели evidence.',
|
||
'<strong>Сделайте return point versioned.</strong> Нужны previous release и previous digest, а также понятная граница migration. Отсутствие одного поля — повод не выдавать approval.',
|
||
'<strong>Проверьте contract отрицательными cases.</strong> Подмените commit, target migration, requested digest и добавьте real-like input. Каждая ветка обязана останавливаться без side effect.',
|
||
]),
|
||
h2('Ограничения и граница реальных инструментов'),
|
||
p('GitHub Changelog от 25 июня 2024 важен здесь не как рекомендация внедрить конкретный action, а как исторически точный пример того, что artifact attestation и её verification — отдельные операции. SLSA v1.0 важна как stable terminology, где output artifact, provenance и проверка ожиданий не смешиваются. Kubernetes v1.30 важен как напоминание, что Deployment revision и её rollback имеют конкретную область: Pod template. Эти источники не диктуют schema полей sidecar и не дают обещаний о CI, registry, cloud или production данной работы.'),
|
||
p('Нельзя честно перенести этот fixture в real release path одной строкой. Для этого потребуются авторизация, threat model, источник истины для commit, правила подписей или digest, review migration, target-specific deploy policy, monitoring и заранее разрешённые данные. Ничего из перечисленного fixture не читает и не заменяет. Положительный его output означает только «в этой закрытой коллекции JS objects predicates совпали». Отрицательный означает «в этой коллекции named relation нарушена». Это хороший unit test мысли, но не change record.'),
|
||
h2('Следующий проверяемый шаг'),
|
||
p('Выберите одну существующую release form и проведите paper review без доступа к deploy: найдите, где в ней должны жить exact commit id, artifact digest, migration target/compatibility, requested digest и return digest. Для каждого поля добавьте owner и question, который проверяющий способен закрыть. Затем специально составьте три ложные карточки: одна с другим commit, одна с migration следующей версии, одна с другим digest. Если process не умеет назвать три разных stop actions, сначала улучшите форму. Если умеет — только после отдельного разрешения исследуйте автоматизацию.'),
|
||
h2('Историческая граница июля 2024'),
|
||
p('Все внешние ссылки в этой ревизии — первичные официальные материалы, доступные до конца июля 2024. Они подтверждают лишь узкие исторические свойства: SLSA v1.0 существовала как stable release, GitHub Artifact Attestations стали generally available в июне 2024, Kubernetes v1.30 описывала Deployment revisions и rollback Pod template. Внутренние record names, predicates, negative cases и controlled rollback draft — авторская synthetic модель. Она не измеряет delivery, не получает attestation, не вызывает CI и не утверждает состояние production.'),
|
||
]);
|
||
|
||
const field = revision({
|
||
slug: 'editorial-2024-07-field-release-engineering',
|
||
title: 'Инженерия релиза: полевой разбор расхождений версии, artifact и migration',
|
||
excerpt: 'Разбор четырёх synthetic cases: как отличить mismatch source, migration и rollout intent, когда stop сильнее retry и как оставить return point без ложного обещания data rollback.',
|
||
cover: '/assets/editorial/2024/release-engineering-2024-evidence-gate.svg',
|
||
readingMinutes: 10,
|
||
}, [
|
||
p('Представим один field-разбор без реальной среды. Карточка A связывает <code>2024.07.0</code>, <code>synthetic-commit-7f4a0c1</code>, digest <code>sha256:synthetic-2024-07-0-a1</code> и compatible migration. Карточка B держит тот же release number, но artifact ссылается на другой commit. Карточка C держит правильный artifact, зато migration уже помечена <code>2024.07.1</code>. Карточка D просит в rollout card другой digest. Все четыре — fixed versioned objects из memory fixture, не delivery records. Их цена как учебного упражнения в том, что похожие названия перестают успокаивать и заставляют назвать точку расхождения.'),
|
||
p('Симптом полевого разбора: участники видят «версия одна», а спорят о том, стоит ли повторить deploy или открыть incident. Причина глубже: разные классы несовпадений ведут к одному общему действию — retry. Проверка должна идти по направлению цепочки и останавливаться на первой broken relation. Действие тогда конкретно: mismatch commit требует исправить artifact evidence, mismatch migration — проверить contract и compatibility, mismatch digest — переписать rollout intent. Никакая из этих веток не даёт fixture права вызвать CI, registry или target environment.'),
|
||
h2('Четыре fixed cases как тренировочный материал'),
|
||
p('Aligned case полезен не потому, что он «зелёный». Он показывает минимальный набор facts, который можно сравнить: artifact declares source commit, migration target совпадает с release, migration объявлена compatible с return version, а rollout card повторяет exact digest и migration version. Это приводит лишь к <code>continue-to-human-deploy-review</code>. Слово <em>human</em> важно: fixture не даёт approval и не запускает операцию, даже когда все собственные predicates true. Она отделяет согласованность description от факта исполнения.'),
|
||
p('Artifact-commit mismatch говорит другую вещь. Версия и даже prefix digest могут выглядеть ожидаемо, но <code>artifact.sourceCommitId</code> не равен source commit. Это не повод угадать, какой из двух объектов «вернее». Правильный outcome — <code>stop-and-reconcile-records</code> с причиной <code>artifact-does-not-bind-commit</code>. Полевой reviewer получает точный вопрос: кто исправляет source relation и какое authorised evidence потом сравнит её снова? Сравнение не заменяется новым build и не маскируется повтором deploy, потому что исходное несоответствие ещё не объяснено.'),
|
||
figure('/assets/editorial/2024/release-engineering-2024-evidence-gate.svg', 'Decision gate для fixed synthetic records: совпадают ли version, commit-to-artifact link, migration target and compatibility, rollout digest and migration version. Любое нет ведёт в stop and reconcile; да ведёт только к human review. Нижняя полоса отделяет versioned rollback draft от data undo и реального deploy.', 'Схема — маршрут проверки учебных records. Она не показывает работающий pipeline, registry, CI run, облако, production rollout или delivery log.'),
|
||
h2('Рабочая таблица разбора'),
|
||
table('Как отличить четыре synthetic outcomes', ['Case', 'Первый broken relation', 'Verdict fixture', 'Следующий проверяемый вопрос'], [
|
||
['Aligned <code>2024.07.0</code>', 'Нет: все fixed predicates true.', '<code>continue-to-human-deploy-review</code>', 'Может ли authorised reviewer сравнить реальное evidence отдельно от fixture?'],
|
||
['Artifact mismatch', '<code>artifact.sourceCommitId ≠ commit.id</code>', '<code>stop-and-reconcile-records</code>', 'Какой commit действительно должен быть parent artifact?'],
|
||
['Migration mismatch', '<code>migration.targetReleaseVersion ≠ releaseVersion</code>', '<code>stop-and-reconcile-records</code>', 'Для какого contract написан plan и есть ли declared compatibility?'],
|
||
['Rollout digest mismatch', '<code>requestedArtifactDigest ≠ artifact.digest</code>', '<code>stop-and-reconcile-records</code>', 'Почему intent ссылается на другой immutable content?'],
|
||
]),
|
||
p('Таблица нужна полевому разбору, чтобы не превратить verdict в эмоцию. У всех stop cases одинаковая дисциплина — ничего не исполнять из fixture, — но причина и владелец следующего вопроса различаются. Artifact mismatch чаще всего начинается у source relation; migration mismatch — у compatibility boundary; digest mismatch — у intent. Когда эти случаи складываются в одну категорию «release failed», потеряется информация, которую надо принести в review. А когда у каждого есть короткое название, переписать или уточнить можно до того, как начнётся дорогое действие.'),
|
||
h2('Исполнимая проверка негативных веток'),
|
||
p('Функция <code>runReleaseEvidenceFixture()</code> возвращает полный набор fixed samples и map assertions. Она проверяет positive case, три вида расхождений, лишние CI-like и deploy-like input fields, подменённый report, подменённый rollback draft и попытку получить rollback из stop decision. Это важнее одной happy path: release discipline ломается именно там, где один объект слегка изменили, а следующий шаг всё равно продолжился. Команда ниже выполняет только Node code из данного файла; у неё нет client library, credentials, endpoint или файлового input.'),
|
||
code(fieldExample),
|
||
p('Позитивная assertion здесь не является релизным подтверждением. Она доказывает лишь, что fixed record, который автор положил в source code, согласован сам с собой по написанным predicates. Отрицательная assertion также не доказывает ошибку в реальном процессе. Она доказывает, что модель не пропускает известный bad shape. Это правильный масштаб автоматического примера: тестировать не то, что он не может видеть, а границу, на которой он обязан сказать «я не знаю; остановись и проверь разрешённый источник».'),
|
||
h2('Маршрут field triage'),
|
||
ol([
|
||
'<strong>Зафиксируйте точку чтения.</strong> Сначала выберите один record и явно пометьте, что это input для review, а не execution command. Не смешивайте with current environment state.',
|
||
'<strong>Сравните commit и artifact.</strong> Проверяйте exact source relation и digest, не только semantic version. При mismatch запишите named reason и owner исходной записи.',
|
||
'<strong>Сравните migration и release.</strong> Target version и compatibility должны быть описаны отдельно. Если migration unknown или относится к будущей версии, маршрут заканчивается stop.',
|
||
'<strong>Сравните rollout intent с artifact и migration.</strong> Intent обязан повторить immutable digest и exact migration version. Если он иной, не исправляйте его устно.',
|
||
'<strong>Проверьте return point.</strong> До approval назовите return version/digest и ограничение: code/artifact return не равен автоматическому data undo.',
|
||
'<strong>Передайте только открытый вопрос.</strong> Aligned card передаётся authorised human reviewer; stop card — владельцу discrepancy. Ни одна ветка fixture не передаётся deployment client.',
|
||
]),
|
||
h2('Rollback без героической легенды'),
|
||
p('Контролируемый rollback не обязан быть мгновенным, чтобы быть полезным. Он обязан быть точным. В fixture для aligned case есть return version <code>2024.06.3</code> и return digest <code>sha256:synthetic-2024-06-3-z9</code>. Функция <code>draftSyntheticControlledRollback()</code> создаёт teaching draft только после canonical plan: она снова сравнивает report с fixed record и отказывает при изменённом action. Draft называет, куда <em>мог бы</em> вернуться code/artifact review, но у него поля <code>deployment: not-created-or-run</code> и <code>productionEffect: not-attempted</code>. Это принципиально не rollout.'),
|
||
p('Migration boundary остаётся наиболее важной строкой return draft. В ней прямо сказано <code>does-not-reverse-data-and-requires-separate-human-review</code>. Так field reviewer не теряет вопрос о данных в момент, когда все устали и хотят «просто вернуть вчерашний образ». Kubernetes documentation помогает поставить правильную границу: rollback Deployment связан с его revision Pod template. В вашей системе migration может иметь свои механизмы, lock, compatibility period или запрет на reverse. Именно поэтому field triage обязан отправить data question отдельному owner-у, а не присвоить ему успех вслед за возвратом binary.'),
|
||
h2('Ограничения разбора'),
|
||
p('Ревизия не анализирует настоящие commit, container image, registry metadata, provenance signature, CI configuration, environment protection rules, database schema, cloud account, traffic, telemetry или production response. Она также не выдаёт SLSA level, не проверяет GitHub attestation и не запускает Kubernetes API. Официальные источники в конце помогают удержать terminology и историческую границу, но не являются доказательством того, что конкретная команда использует эти возможности. Ещё меньше они позволяют заключить, что любой record с digest можно безопасно deploy или rollback.'),
|
||
p('У field pattern есть сознательное ограничение масштаба: он хорошо ловит disagreement между named records, но не отвечает, почему values были записаны именно такими. Это следующий слой — authorised evidence collection, trust model, signature verification, migration rehearsal и target-specific policy. Если начать тянуть эти задачи в один fixture, он станет непрозрачной копией pipeline и всё равно не получит реального доступа. Лучше оставить маленький deterministic test рядом с формой и вести реальные процедуры в отдельном, разрешённом контуре.'),
|
||
h2('Следующий проверяемый шаг'),
|
||
p('Проведите tabletop review на бумаге: один author заполняет пять полей для вымышленного release, второй получает только таблицу связей и должен найти один из трёх заранее внесённых mismatch. Никакие реальные name, URL, digest, migration или deploy targets не нужны. Успех измеряется не скоростью и не зелёным status, а тем, что reviewer называет precise broken relation, stop action, owner следующего вопроса и границу rollback. После этого можно сравнить форму с одним разрешённым рабочим процессом, но не заменять tabletop result production claim.'),
|
||
h2('Историческая граница июля 2024'),
|
||
p('К июлю 2024 уже были доступны три первичных опоры, на которые ссылается текст: stable SLSA v1.0, GitHub Artifact Attestations generally available с 25 июня 2024 и versioned Kubernetes 1.30 documentation о Deployments. Они не используются для прогнозов, рекомендаций конкретного cloud или утверждений о существующей delivery system. Все case ids, commit ids, digest, migration plans, rollout cards, decisions и return drafts в этой ревизии — versioned fixed synthetic JS objects in memory. Их purpose — проверить reasoning, не имитировать pipeline.'),
|
||
]);
|
||
|
||
export const revisions = [practice, mechanism, field].map(({ proseLength, ...item }) => item);
|
||
|
||
function verifyFixture() {
|
||
const report = runReleaseEvidenceFixture();
|
||
const failed = Object.entries(report.assertions).filter(([, value]) => value !== true).map(([key]) => key);
|
||
if (failed.length > 0) {
|
||
process.stderr.write('FAIL fixture: ' + failed.join(', ') + '\n');
|
||
process.exitCode = 1;
|
||
return;
|
||
}
|
||
const count = Object.keys(report.assertions).length;
|
||
process.stdout.write('PASS fixture: ' + count + '/' + count + ' assertions\n');
|
||
}
|
||
|
||
if (process.argv.includes('--verify-fixture')) verifyFixture();
|
||
if (process.argv.includes('--print-revisions')) process.stdout.write(JSON.stringify(revisions) + '\n');
|