Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ on:
pull_request:
merge_group:
workflow_dispatch:
inputs:
profile_baseline:
description: 'Снять зарегистрированный размерный baseline PROFILE-01'
type: boolean
default: false

permissions:
contents: read
Expand All @@ -21,6 +26,12 @@ env:
COREPACK_DEFAULT_TO_LATEST: "0"

jobs:
# Только явный baseline-запрос: обычный кандидат может менять runtime и
# не обязан совпадать с зарегистрированным старым источником PROFILE-01.
profile-baseline:
if: ${{ github.event_name == 'workflow_dispatch' && inputs.profile_baseline }}
uses: ./.github/workflows/profile-01.yml

verify:
name: typecheck · build · size · package
runs-on: ubuntu-latest
Expand Down
121 changes: 121 additions & 0 deletions .github/workflows/profile-01.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
name: PROFILE-01 — проверка размерного протокола

# Размерный вектор вызывается вручную через ci.yml в той же среде, что обычный CI
# публичного репозитория. Время/GPU/энергия этой машины не являются device proof.
on:
workflow_call:

permissions:
contents: read

env:
COREPACK_ENABLE_DOWNLOAD_PROMPT: '0'
COREPACK_DEFAULT_TO_LATEST: '0'
PROFILE_SOURCE_SHA: ${{ github.event.pull_request.head.sha || github.sha }}

jobs:
probe:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- name: Получить точный источник измерителя
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
persist-credentials: false
fetch-depth: 0

- name: Настроить Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v6
with:
node-version: '24'

- name: Активировать закреплённый pnpm
run: |
corepack enable
corepack install --global pnpm@11.11.0

- name: Проверить точную версию протокола
shell: bash
run: |
set -euo pipefail
test "$(git rev-parse HEAD)" = "$PROFILE_SOURCE_SHA"
node --input-type=module <<'NODE'
import { PROFILE_01, preregistrationDigest, verifyPreregistration } from './bench/profile/profile-01-preregistration.mjs';
verifyPreregistration(PROFILE_01);
if (PROFILE_01.candidateSamplesObservedAtRegistration !== false) {
throw new Error('preregistration обязана предшествовать samples');
}
console.log(`preregistration digest: ${preregistrationDigest(PROFILE_01)}`);
NODE

- name: Установить зависимости
run: pnpm install --frozen-lockfile

- name: Измерить прежний размерный вектор
id: probe
shell: bash
run: |
set -euo pipefail
mkdir -p "$RUNNER_TEMP/profile-01-raw"
node bench/profile/probe-profile-01.mjs \
--mode old-vector \
--cells all \
--out "$RUNNER_TEMP/profile-01-raw"

- name: Независимо перепроверить raw-артефакт
if: ${{ !cancelled() && steps.probe.outcome != 'skipped' }}
shell: bash
run: |
set -euo pipefail
for raw in "$RUNNER_TEMP"/profile-01-raw/*.json; do
test -f "$raw"
node bench/profile/validate-profile-01.mjs --raw "$raw"
done

- name: Проверить отказ внешнего validator на подменах настоящего результата
if: ${{ !cancelled() && steps.probe.outcome == 'success' }}
shell: bash
run: |
set -euo pipefail
node --input-type=module <<'NODE'
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const rawDirectory = join(process.env.RUNNER_TEMP, 'profile-01-raw');
const files = readdirSync(rawDirectory).filter(name => name.endsWith('.json'));
assert.equal(files.length, 1);
const original = JSON.parse(readFileSync(join(rawDirectory, files[0]), 'utf8'));
const directory = mkdtempSync(join(tmpdir(), 'profile-replay-mutations-'));
try {
const cases = [
['число', value => { value.costVector.scenarios[0].gzBytes += 1; }, 'scenarios не воспроизводится'],
['identity', value => { value.head = '0'.repeat(40); }, 'baseProof не покрывает'],
['клетки', value => { value.cellsUnproven = []; }, 'неизмеренные клетки потеряны'],
];
for (const [name, mutate, rejection] of cases) {
const value = structuredClone(original);
mutate(value);
const path = join(directory, 'mutated.json');
writeFileSync(path, JSON.stringify(value));
const run = spawnSync(process.execPath, ['bench/profile/validate-profile-01.mjs', '--raw', path],
{ encoding: 'utf8', timeout: 180_000, maxBuffer: 8 * 1024 * 1024 });
assert.equal(run.status, 1, `${name}: ${run.error ?? run.stderr}`);
assert.ok(run.stderr.includes(rejection), `${name}: неверная причина отказа: ${run.stderr}`);
console.log(`${name}: ожидаемый предметный отказ подтверждён`);
}
} finally {
rmSync(directory, { recursive: true, force: true });
}
NODE

- name: Опубликовать raw-артефакт
# Отрицательный результат сохраняется даже после отказа измерителя.
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: profile-01-raw-${{ env.PROFILE_SOURCE_SHA }}
path: ${{ runner.temp }}/profile-01-raw/
retention-days: 90
140 changes: 140 additions & 0 deletions bench/profile/probe-profile-01.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
// PROFILE-01 probe: исполняемый измерительный стенд.
// Измерение размера использует обычную среду CI публичного репозитория.
// Оно не доказывает задержку, частоту кадров или свойства физического устройства.
// Недействительная калибровка или расхождение с протоколом запрещают допуск.
// Использование: node bench/profile/probe-profile-01.mjs --mode old-vector|aa|ab --cells desktop|all --out <dir>

import { createHash } from 'node:crypto';
import { mkdirSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
PROFILE_01,
preregistrationDigest,
verifyPreregistration,
unmeasuredCells,
} from './profile-01-preregistration.mjs';
import { PREREG_OWN_PATHS, makeGit } from './profile-git-proof.mjs';
import { measureOldVector } from './profile-measurement.mjs';

function fail(message) {
throw new Error(`PROFILE-01 probe (fail-closed): ${message}`);
}

function arg(name) {
const index = process.argv.indexOf(name);
return index === -1 ? undefined : process.argv[index + 1];
}

const git = makeGit(fail);

function writeArtifact(outDir, artifact, head, digest) {
const rawPath = join(outDir, `profile-01-${artifact.mode}-${head.slice(0, 12)}.json`);
const rawBytes = `${JSON.stringify(artifact, null, 2)}\n`;
writeFileSync(rawPath, rawBytes);
const rawDigest = createHash('sha256').update(rawBytes).digest('hex');
// eslint-disable-next-line no-console
console.log(JSON.stringify({ rawPath, rawDigest, preregistrationDigest: digest, admission: artifact.admission }));
}

// Отказ после измерения сохраняет артефакт с причиной и хешем.
// Отказ до измерения ещё не создаёт данных для сохранения.
function persistAndFail(outDir, artifact, head, digest, reason) {
artifact.finishedAtUtc = new Date().toISOString();
artifact.admission = 'NOT-GRANTED';
artifact.rejection = reason;
writeArtifact(outDir, artifact, head, digest);
fail(reason);
}

async function main() {
const repoRoot = fileURLToPath(new URL('../../', import.meta.url));
const mode = arg('--mode') ?? fail('требуется --mode old-vector|aa|ab');
if (!['old-vector', 'aa', 'ab'].includes(mode)) fail(`неизвестный --mode ${mode}`);
const cells = arg('--cells') ?? 'desktop';
if (!['desktop', 'all'].includes(cells)) fail(`неизвестный --cells ${cells}`);
const outDir = resolve(arg('--out') ?? join(tmpdir(), 'profile-01-raw'));
mkdirSync(outDir, { recursive: true });

// Протокол проверяется до любых измерений.
verifyPreregistration(PROFILE_01);
const digest = preregistrationDigest(PROFILE_01);

// 2. Точный base/provenance.
const head = git.head(repoRoot);
const artifact = {
node: 'PROFILE-01',
revision: 'r11',
mode,
cells,
preregistrationDigest: digest,
candidateSamplesObservedAtRegistration: false,
head,
sizeGateBlob: null,
baseProof: null,
seed: 20260929,
startedAtUtc: new Date().toISOString(),
cellsMeasured: [],
cellsUnproven: unmeasuredCells(cells),
rawControls: {},
calibration: {},
costVector: null,
admission: 'NOT-GRANTED',
rejection: null,
};

if (mode === 'old-vector') {
const base = PROFILE_01.productBase.sourceSha;
if (!git.ancestor(repoRoot, base)) fail(`old-vector требует HEAD, выросший из PRODUCT_BASE ${base}`);
const sizeGateBlob = git.blob(repoRoot, 'HEAD', 'scripts/size-gate.mjs');
if (sizeGateBlob !== PROFILE_01.productBase.sizeGateBlob) {
fail(`size-gate provenance drifted: ${sizeGateBlob}`);
}
// Рабочая копия обязана совпадать с коммитом: иначе измеритель
// исполнит непроверенный файл, а baseProof этого не покажет.
const workingSizeGateBlob = git.workingBlob(repoRoot, 'scripts/size-gate.mjs');
if (workingSizeGateBlob !== sizeGateBlob) {
fail(`рабочая копия size-gate.mjs отличается от коммита: working ${workingSizeGateBlob}, committed ${sizeGateBlob}`);
}
const diffPaths = head === base ? [] : git.diffNames(repoRoot, base, head);
const foreign = diffPaths.filter((path) => !PREREG_OWN_PATHS.includes(path));
if (foreign.length > 0) fail(`измеряемое дерево отличается от PRODUCT_BASE вне prereg-пакета: ${foreign.join(', ')}`);
artifact.sizeGateBlob = sizeGateBlob;
artifact.baseProof = { productBase: base, head, diffPaths };
try {
artifact.costVector = await measureOldVector(repoRoot);
} catch (error) {
persistAndFail(outDir, artifact, head, digest, `незавершённое измерение: ${error.message}`);
}
if (artifact.costVector.exitCode !== 0) {
persistAndFail(outDir, artifact, head, digest, `старый cost vector не зелёный на PRODUCT_BASE (exit ${artifact.costVector.exitCode})`);
}
for (const name of Object.keys(PROFILE_01.oldCostVectorGzipBytes.scenarios)) {
if (!artifact.costVector.scenarios.some((row) => row.name === name)) {
persistAndFail(outDir, artifact, head, digest, `size-gate не содержит сценарий ${name}`);
}
}
artifact.cellsMeasured.push('desktop-size-vector');
}

// 4. Калибровка: A/A и deliberate 2×work обязаны быть явными.
// Детализация timing-калибровки — в отдельной browser-фазе;
// без пройденной калибровки admission не выдаётся (см. ниже).
artifact.calibration = { aa: 'PENDING', positive2x: 'PENDING' };

// Без калибровки aa/ab сохраняют отказ до завершения процесса.
if (mode !== 'old-vector') {
persistAndFail(outDir, artifact, head, digest, 'aa/ab режимы требуют отдельной зелёной browser-калибровки');
}
const ready = mode === 'old-vector' && artifact.costVector !== null;
artifact.finishedAtUtc = new Date().toISOString();
artifact.admission = ready ? 'OLD-VECTOR-ONLY' : 'NOT-GRANTED';
if (!ready) {
persistAndFail(outDir, artifact, head, digest, 'old-vector не готов: costVector отсутствует');
}

writeArtifact(outDir, artifact, head, digest);
}

await main();
Loading
Loading