diff --git a/docs/dev-gate-report.md b/docs/dev-gate-report.md index 3f6631b3..31141cad 100644 --- a/docs/dev-gate-report.md +++ b/docs/dev-gate-report.md @@ -10,6 +10,26 @@ blockers stay in one place. This contract is report-only. It does not authorize a real DEV or PROD deployment. +## Report Lifecycle + +Each committed DEV gate report must carry `reportLifecycle`: + +- `state: "active"` means the report belongs to the current DEV gate surface. + Its `activeEndpoint` must be `http://74.48.78.17:16667`, its + `activeBrowserEndpoint` must be `http://74.48.78.17:16666`, and + `deprecatedEndpoint` must be `null`. +- `state: "historical"` means the report is retained only for evidence + provenance. It may name a deprecated endpoint such as + `http://74.48.78.17:6667`, but it must stay blocked/non-green and cannot be + consumed as active acceptance evidence. +- Active reports may preserve a legacy public endpoint only inside an explicit + `runtimeSmoke.legacyPublicEndpoint` object with `status: "deprecated"` and + `activeGreenEligible: false`. +- M3 DEV-LIVE remains narrower than report freshness: it requires + `res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1` in real DEV + plus operation, audit, and evidence identifiers. SOURCE, LOCAL, DRY-RUN, + fixture, and historical report evidence are support/diagnostics only. + ## Files - `reports/dev-gate/dev-gate-report.example.json`: machine-readable example. @@ -29,6 +49,7 @@ Every report must include: - `acceptanceLevel` - `devOnly` - `prodDisabled` +- `reportLifecycle` - `sourceContract` - `validationCommands` - `localSmoke` diff --git a/internal/dev-report-lifecycle.mjs b/internal/dev-report-lifecycle.mjs new file mode 100644 index 00000000..3955b22c --- /dev/null +++ b/internal/dev-report-lifecycle.mjs @@ -0,0 +1,63 @@ +export const ACTIVE_DEV_PUBLIC_ENDPOINT = "http://74.48.78.17:16667"; +export const ACTIVE_DEV_BROWSER_ENDPOINT = "http://74.48.78.17:16666"; +export const DEPRECATED_DEV_PUBLIC_ENDPOINT = "http://74.48.78.17:6667"; +export const DEPRECATED_RESERVED_PROD_ENDPOINT = "http://74.48.78.17:6666"; +export const REPORT_LIFECYCLE_VERSION = "v1"; + +export const deprecatedPublicEndpoints = Object.freeze([ + DEPRECATED_DEV_PUBLIC_ENDPOINT, + DEPRECATED_RESERVED_PROD_ENDPOINT +]); + +const deprecatedPublicEndpointPattern = /http:\/\/74\.48\.78\.17:666[67]\b/gu; + +export function findDeprecatedPublicEndpoints(text) { + return [...new Set(String(text).match(deprecatedPublicEndpointPattern) ?? [])].sort(); +} + +export function isDeprecatedEndpointAllowedInActiveReport(report, endpoint) { + const legacyEndpoint = report?.runtimeSmoke?.legacyPublicEndpoint; + return Boolean( + legacyEndpoint && + legacyEndpoint.endpoint === endpoint && + legacyEndpoint.status === "deprecated" && + legacyEndpoint.activeGreenEligible === false + ); +} + +export function findForbiddenActiveDeprecatedEndpoints(report, raw) { + if (reportIsHistorical(report)) return []; + return findDeprecatedPublicEndpoints(raw).filter( + (endpoint) => !isDeprecatedEndpointAllowedInActiveReport(report, endpoint) + ); +} + +export function reportLifecycleState(report) { + return report?.reportLifecycle?.state ?? "active"; +} + +export function reportIsHistorical(report) { + return reportLifecycleState(report) === "historical"; +} + +export function activeReportLifecycle(summary) { + return { + version: REPORT_LIFECYCLE_VERSION, + state: "active", + activeEndpoint: ACTIVE_DEV_PUBLIC_ENDPOINT, + activeBrowserEndpoint: ACTIVE_DEV_BROWSER_ENDPOINT, + deprecatedEndpoint: null, + summary + }; +} + +export function historicalReportLifecycle({ activeEndpoint = ACTIVE_DEV_PUBLIC_ENDPOINT, deprecatedEndpoint, summary }) { + return { + version: REPORT_LIFECYCLE_VERSION, + state: "historical", + activeEndpoint, + activeBrowserEndpoint: ACTIVE_DEV_BROWSER_ENDPOINT, + deprecatedEndpoint, + summary + }; +} diff --git a/reports/dev-gate/dev-artifacts.json b/reports/dev-gate/dev-artifacts.json index 32368d42..9f8d81c2 100644 --- a/reports/dev-gate/dev-artifacts.json +++ b/reports/dev-gate/dev-artifacts.json @@ -1114,5 +1114,13 @@ ], "blockers": [] }, - "notes": "DEV-only artifact report. Do not treat runtime placeholder images as real service implementations." + "notes": "DEV-only artifact report. Do not treat runtime placeholder images as real service implementations.", + "reportLifecycle": { + "version": "v1", + "state": "active", + "activeEndpoint": "http://74.48.78.17:16667", + "activeBrowserEndpoint": "http://74.48.78.17:16666", + "deprecatedEndpoint": null, + "summary": "Current DEV artifact publish report; it is not a DEV-LIVE runtime substitute." + } } diff --git a/reports/dev-gate/dev-deploy-report.json b/reports/dev-gate/dev-deploy-report.json index 15addb57..ab7582e4 100644 --- a/reports/dev-gate/dev-deploy-report.json +++ b/reports/dev-gate/dev-deploy-report.json @@ -1124,5 +1124,13 @@ } }, "blockers": [], - "notes": "DEV-only result. No PROD resources, secret values, force push, heavy e2e, browser e2e, or UniDesk runtime substitute were used." + "notes": "DEV-only result. No PROD resources, secret values, force push, heavy e2e, browser e2e, or UniDesk runtime substitute were used.", + "reportLifecycle": { + "version": "v1", + "state": "active", + "activeEndpoint": "http://74.48.78.17:16667", + "activeBrowserEndpoint": "http://74.48.78.17:16666", + "deprecatedEndpoint": null, + "summary": "Current DEV deploy apply report; active public API/health endpoint is 16667 and browser endpoint is 16666." + } } diff --git a/reports/dev-gate/dev-edge-health.json b/reports/dev-gate/dev-edge-health.json index c6dff262..9396ac60 100644 --- a/reports/dev-gate/dev-edge-health.json +++ b/reports/dev-gate/dev-edge-health.json @@ -752,5 +752,13 @@ ], "nextTask": "Deploy cloud-api DB runtime readiness probe and/or repair DEV DB connectivity, then rerun the read-only health smoke without reading or printing the DB secret value." } + }, + "reportLifecycle": { + "version": "v1", + "state": "active", + "activeEndpoint": "http://74.48.78.17:16667", + "activeBrowserEndpoint": "http://74.48.78.17:16666", + "deprecatedEndpoint": null, + "summary": "Current DEV edge-health report; deprecated public 6666/6667 endpoints are not valid active evidence." } } diff --git a/reports/dev-gate/dev-gate-report.example.json b/reports/dev-gate/dev-gate-report.example.json index 993c8a02..65bf6c86 100644 --- a/reports/dev-gate/dev-gate-report.example.json +++ b/reports/dev-gate/dev-gate-report.example.json @@ -69,5 +69,13 @@ "summary": "No live DEV precondition evidence is attached." } ], - "notes": "Contract-only example. Do not treat this file as a real DEV gate report." + "notes": "Contract-only example. Do not treat this file as a real DEV gate report.", + "reportLifecycle": { + "version": "v1", + "state": "active", + "activeEndpoint": "http://74.48.78.17:16667", + "activeBrowserEndpoint": "http://74.48.78.17:16666", + "deprecatedEndpoint": null, + "summary": "Active example report for the current DEV gate contract." + } } diff --git a/reports/dev-gate/dev-m2-deploy-smoke-active.json b/reports/dev-gate/dev-m2-deploy-smoke-active.json index 1156c3fa..c06c3019 100644 --- a/reports/dev-gate/dev-m2-deploy-smoke-active.json +++ b/reports/dev-gate/dev-m2-deploy-smoke-active.json @@ -252,5 +252,13 @@ "summary": "Legacy public :6667 observations are retained only as historical blockers and are not accepted as active green evidence." } }, - "notes": "M2 active report uses read-only GET probes only; PROD, service restarts, deployments, and script-driven apply flows were not touched." + "notes": "M2 active report uses read-only GET probes only; PROD, service restarts, deployments, and script-driven apply flows were not touched.", + "reportLifecycle": { + "version": "v1", + "state": "active", + "activeEndpoint": "http://74.48.78.17:16667", + "activeBrowserEndpoint": "http://74.48.78.17:16666", + "deprecatedEndpoint": null, + "summary": "Current M2 DEV deploy smoke report; legacy public endpoints are deprecated and not active green evidence." + } } diff --git a/reports/dev-gate/dev-m3-hardware-loop.json b/reports/dev-gate/dev-m3-hardware-loop.json index 30ad366f..27ec8f93 100644 --- a/reports/dev-gate/dev-m3-hardware-loop.json +++ b/reports/dev-gate/dev-m3-hardware-loop.json @@ -195,5 +195,13 @@ "classification": "hardware loop", "observedAt": "2026-05-22T04:30:50.820Z", "result": "Live DEV M3 runtime was reachable but did not satisfy the M3 hardware-loop contract." + }, + "reportLifecycle": { + "version": "v1", + "state": "active", + "activeEndpoint": "http://74.48.78.17:16667", + "activeBrowserEndpoint": "http://74.48.78.17:16666", + "deprecatedEndpoint": null, + "summary": "Current M3 DEV hardware trusted-loop report; DEV-LIVE requires res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1 with operation, audit, and evidence ids." } } diff --git a/reports/dev-gate/dev-m4-agent-loop.json b/reports/dev-gate/dev-m4-agent-loop.json index d0f094b4..dc65ba0f 100644 --- a/reports/dev-gate/dev-m4-agent-loop.json +++ b/reports/dev-gate/dev-m4-agent-loop.json @@ -75,5 +75,13 @@ ], "summary": "Blocked at DB live readiness before scheduling a DEV agent task." }, - "notes": "DEV M4 agent loop live preflight report. No secret material is read." + "notes": "DEV M4 agent loop live preflight report. No secret material is read.", + "reportLifecycle": { + "version": "v1", + "state": "active", + "activeEndpoint": "http://74.48.78.17:16667", + "activeBrowserEndpoint": "http://74.48.78.17:16666", + "deprecatedEndpoint": null, + "summary": "Current DEV M4 agent-loop preflight report; it does not substitute for M3 DEV-LIVE acceptance." + } } diff --git a/reports/dev-gate/dev-m5-gate-aggregator-v2.json b/reports/dev-gate/dev-m5-gate-aggregator-v2.json index e588c976..8d021645 100644 --- a/reports/dev-gate/dev-m5-gate-aggregator-v2.json +++ b/reports/dev-gate/dev-m5-gate-aggregator-v2.json @@ -1109,5 +1109,13 @@ "node scripts/dev-evidence-blocker-aggregator.mjs --markdown", "node --check scripts/validate-dev-gate-report.mjs", "node scripts/validate-dev-gate-report.mjs" - ] + ], + "reportLifecycle": { + "version": "v1", + "state": "active", + "activeEndpoint": "http://74.48.78.17:16667", + "activeBrowserEndpoint": "http://74.48.78.17:16666", + "deprecatedEndpoint": null, + "summary": "Current M5 evidence aggregator report; source, local, and dry-run evidence are not DEV-LIVE acceptance." + } } diff --git a/reports/dev-gate/dev-mvp-gate-report.json b/reports/dev-gate/dev-mvp-gate-report.json index 3cb12cea..44775d3b 100644 --- a/reports/dev-gate/dev-mvp-gate-report.json +++ b/reports/dev-gate/dev-mvp-gate-report.json @@ -256,5 +256,13 @@ "status": "not_observed", "evidence": "No additional blocker class observed in the short DEV-only retest" } - ] + ], + "reportLifecycle": { + "version": "v1", + "state": "active", + "activeEndpoint": "http://74.48.78.17:16667", + "activeBrowserEndpoint": "http://74.48.78.17:16666", + "deprecatedEndpoint": null, + "summary": "Current MVP gate report; M3 DEV-LIVE remains blocked until the patch-panel trusted loop is proven." + } } diff --git a/reports/dev-gate/dev-preflight-report.json b/reports/dev-gate/dev-preflight-report.json index 2cd425e0..914a2ef2 100644 --- a/reports/dev-gate/dev-preflight-report.json +++ b/reports/dev-gate/dev-preflight-report.json @@ -1553,5 +1553,13 @@ "nextTask": "Deploy cloud-api DB runtime readiness probe and/or repair DEV DB connectivity, then rerun the read-only health smoke without reading or printing the DB secret value." } ], - "notes": "No PROD action, secret read, UniDesk runtime substitution, heavy e2e, browser e2e, runtime restart, or force push was performed." + "notes": "No PROD action, secret read, UniDesk runtime substitution, heavy e2e, browser e2e, runtime restart, or force push was performed.", + "reportLifecycle": { + "version": "v1", + "state": "active", + "activeEndpoint": "http://74.48.78.17:16667", + "activeBrowserEndpoint": "http://74.48.78.17:16666", + "deprecatedEndpoint": null, + "summary": "Current read-only DEV preflight report; source, local, and dry-run checks are not DEV-LIVE acceptance." + } } diff --git a/reports/dev-gate/historical/dev-m3-hardware-loop-legacy-6667.json b/reports/dev-gate/historical/dev-m3-hardware-loop-legacy-6667.json new file mode 100644 index 00000000..50383d22 --- /dev/null +++ b/reports/dev-gate/historical/dev-m3-hardware-loop-legacy-6667.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://hwlab.pikastech.local/schemas/dev-m3-hardware-loop-report.schema.json", + "$id": "https://hwlab.pikastech.local/reports/dev-gate/historical/dev-m3-hardware-loop-legacy-6667.json", + "reportVersion": "v1", + "issue": "pikasTech/HWLAB#38", + "taskId": "dev-m3-hardware-loop", + "commitId": "historical", + "acceptanceLevel": "dev_m3_hardware_loop", + "devOnly": true, + "prodDisabled": true, + "reportLifecycle": { + "version": "v1", + "state": "historical", + "activeEndpoint": "http://74.48.78.17:16667", + "activeBrowserEndpoint": "http://74.48.78.17:16666", + "deprecatedEndpoint": "http://74.48.78.17:6667", + "summary": "Historical deprecated endpoint snapshot retained only for evidence provenance; not active DEV-LIVE evidence." + }, + "status": "blocked", + "generatedAt": "2026-05-22T00:00:00.000Z", + "runtimeTarget": { + "environment": "dev", + "namespace": "hwlab-dev", + "endpoint": "http://74.48.78.17:6667", + "requiredBoxSimulators": 2, + "requiredGatewaySimulators": 2, + "requiredPatchPanels": 1 + }, + "blockers": [ + { + "type": "observability_blocker", + "scope": "deprecated-public-endpoint", + "status": "open", + "summary": "Legacy public :6667 evidence is historical/deprecated and cannot support current M3 DEV-LIVE acceptance." + } + ] +} diff --git a/scripts/dev-artifact-publish.mjs b/scripts/dev-artifact-publish.mjs index 2b8077da..a329d2a3 100644 --- a/scripts/dev-artifact-publish.mjs +++ b/scripts/dev-artifact-publish.mjs @@ -9,6 +9,7 @@ import { import path from "node:path"; import { fileURLToPath } from "node:url"; +import { activeReportLifecycle } from "../internal/dev-report-lifecycle.mjs"; import { ENVIRONMENT_DEV, SERVICE_IDS } from "../internal/protocol/index.mjs"; import { BUILDABLE_IMPLEMENTATION_STATES, @@ -824,6 +825,7 @@ function createReport({ args, repo, commitId, shortCommit, mode, services, artif acceptanceLevel: "dev_artifact_publish", devOnly: true, prodDisabled: true, + reportLifecycle: activeReportLifecycle("Current DEV artifact publish report; it is not a DEV-LIVE runtime substitute."), sourceContract: { status: fatalBlocked ? "blocked" : "pass", documents: [ diff --git a/scripts/dev-m3-hardware-loop-smoke.mjs b/scripts/dev-m3-hardware-loop-smoke.mjs index 97efaf23..71ede2e3 100644 --- a/scripts/dev-m3-hardware-loop-smoke.mjs +++ b/scripts/dev-m3-hardware-loop-smoke.mjs @@ -8,6 +8,7 @@ import { request as httpsRequest } from "node:https"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { activeReportLifecycle } from "../internal/dev-report-lifecycle.mjs"; import { DEV_ENDPOINT, ENVIRONMENT_DEV } from "../internal/protocol/index.mjs"; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); @@ -566,6 +567,7 @@ function baseReport({ commitId, observedAt }) { acceptanceLevel: "dev_m3_hardware_loop", devOnly: true, prodDisabled: true, + reportLifecycle: activeReportLifecycle("Current M3 DEV hardware trusted-loop report; DEV-LIVE requires res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1 with operation, audit, and evidence ids."), sourceContract: { status: "pass", documents: [ diff --git a/scripts/dev-m4-agent-loop-smoke.mjs b/scripts/dev-m4-agent-loop-smoke.mjs index 7f91a620..84437891 100644 --- a/scripts/dev-m4-agent-loop-smoke.mjs +++ b/scripts/dev-m4-agent-loop-smoke.mjs @@ -7,6 +7,7 @@ import { readFile, writeFile, mkdir } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { activeReportLifecycle } from "../internal/dev-report-lifecycle.mjs"; import { DEV_ENDPOINT, ENVIRONMENT_DEV } from "../internal/protocol/index.mjs"; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); @@ -151,6 +152,7 @@ function buildReport(fixture, evidence, result) { acceptanceLevel: "dev_m4_agent_loop", devOnly: true, prodDisabled: true, + reportLifecycle: activeReportLifecycle("Current DEV M4 agent-loop preflight report; it does not substitute for M3 DEV-LIVE acceptance."), sourceContract, validationCommands, localSmoke: { diff --git a/scripts/src/dev-deploy-apply.mjs b/scripts/src/dev-deploy-apply.mjs index dba4da02..8f137702 100755 --- a/scripts/src/dev-deploy-apply.mjs +++ b/scripts/src/dev-deploy-apply.mjs @@ -10,6 +10,7 @@ import { DEV_DB_ENV_CONTRACT, summarizeDbContract } from "../../internal/cloud/db-contract.mjs"; +import { activeReportLifecycle } from "../../internal/dev-report-lifecycle.mjs"; import { DEV_ENDPOINT, ENVIRONMENT_DEV, SERVICE_IDS } from "../../internal/protocol/index.mjs"; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); @@ -970,6 +971,7 @@ export async function runDevDeployApply(argv, io = {}) { acceptanceLevel: "dev_deploy_apply", devOnly: true, prodDisabled: true, + reportLifecycle: activeReportLifecycle("Current DEV deploy apply report; active public API/health endpoint is 16667 and browser endpoint is 16666."), status, generatedAt: new Date().toISOString(), namespace, diff --git a/scripts/src/dev-edge-health-smoke-lib.mjs b/scripts/src/dev-edge-health-smoke-lib.mjs index b0c862da..5c8b1748 100644 --- a/scripts/src/dev-edge-health-smoke-lib.mjs +++ b/scripts/src/dev-edge-health-smoke-lib.mjs @@ -6,6 +6,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { DEV_DB_ENV_CONTRACT, summarizeDbContract } from "../../internal/cloud/db-contract.mjs"; +import { activeReportLifecycle } from "../../internal/dev-report-lifecycle.mjs"; import { DEV_ENDPOINT, ENVIRONMENT_DEV } from "../../internal/protocol/index.mjs"; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); @@ -118,6 +119,7 @@ async function createDevGateReport(edgeHealth) { acceptanceLevel: "dev_edge_health", devOnly: true, prodDisabled: true, + reportLifecycle: activeReportLifecycle("Current DEV edge-health report; deprecated public 6666/6667 endpoints are not valid active evidence."), status, generatedAt: edgeHealth.generatedAt, namespace, diff --git a/scripts/src/dev-evidence-blocker-aggregator.mjs b/scripts/src/dev-evidence-blocker-aggregator.mjs index d92c7462..181c5e98 100644 --- a/scripts/src/dev-evidence-blocker-aggregator.mjs +++ b/scripts/src/dev-evidence-blocker-aggregator.mjs @@ -4,6 +4,7 @@ import { readFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { activeReportLifecycle, reportIsHistorical, reportLifecycleState } from "../../internal/dev-report-lifecycle.mjs"; import { DEV_ENDPOINT, ENVIRONMENT_DEV, SERVICE_IDS } from "../../internal/protocol/index.mjs"; export const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); @@ -33,6 +34,8 @@ const blockerTypes = new Set([ "observability_blocker", "safety_blocker" ]); +const historicalLifecycleSummary = + "Historical report retained for evidence provenance; it is not active green DEV evidence."; const reportPaths = Object.freeze({ devPreflight: "reports/dev-gate/dev-preflight-report.json", @@ -275,6 +278,7 @@ function sourceEntry(entry) { level: entry.level, status: entry.status, category: entry.category, + lifecycleState: entry.lifecycleState ?? "active", sources: entry.sources, commands: entry.commands, summary: entry.summary @@ -282,18 +286,21 @@ function sourceEntry(entry) { } function reportEvidence({ milestone, level, report, status, category, summary, evidence = [], commands = [] }) { + const lifecycleState = reportLifecycleState(report); + const historical = lifecycleState === "historical"; return { milestone, issue: report.issue, taskId: report.taskId ?? report.reportKind, reportPath: report.path, commitId: short(report.commitId ?? report.target?.commitId), - level, - status, + lifecycleState, + level: historical ? "BLOCKED" : level, + status: historical && status === "pass" ? "blocked" : status, category, commands, evidence, - summary + summary: historical ? `${summary} ${historicalLifecycleSummary}` : summary }; } @@ -485,6 +492,19 @@ function collectM5Evidence(reports) { function collectBlockers(reports) { const blockers = []; for (const report of Object.values(reports)) { + if (reportIsHistorical(report)) { + blockers.push({ + id: `${report.taskId ?? report.reportKind ?? "report"}-historical:${report.path}`, + priority: "P2", + type: "observability_blocker", + scope: `${report.taskId ?? report.reportKind ?? "report"}-historical`, + status: "open", + source: report.path, + sourceIssue: report.issue, + summary: `${report.taskId ?? report.reportKind ?? "Report"} is historical/deprecated and must be refreshed before it can support active DEV evidence. ${historicalLifecycleSummary}`, + nextTask: "Regenerate the report as active DEV evidence or keep the historical snapshot in an archival path." + }); + } for (const blocker of report.blockers ?? []) { if (isStaleLegacyIngressBlocker(blocker, report, reports)) { continue; @@ -593,6 +613,7 @@ function groupByLevel(evidence, blockers) { milestone: item.milestone, issue: item.issue, taskId: item.taskId, + lifecycleState: item.lifecycleState, status: item.status, category: item.category, reportPath: item.reportPath, @@ -780,6 +801,9 @@ export async function buildReport() { endpoint: DEV_ENDPOINT, devOnly: true, prodDisabled: true, + reportLifecycle: activeReportLifecycle( + "Current M5 evidence aggregator report; source, local, and dry-run evidence are not DEV-LIVE acceptance." + ), safety: { reportOnly: true, noDeploy: true, @@ -797,6 +821,7 @@ export async function buildReport() { path: report.path, issue: report.issue, taskId: report.taskId ?? report.reportKind, + lifecycleState: reportLifecycleState(report), status: primaryStatus(report), commitId: short(report.commitId ?? report.target?.commitId) } diff --git a/scripts/src/dev-gate-preflight.mjs b/scripts/src/dev-gate-preflight.mjs index 4d28bf5e..904d1350 100644 --- a/scripts/src/dev-gate-preflight.mjs +++ b/scripts/src/dev-gate-preflight.mjs @@ -10,6 +10,7 @@ import { buildDbHealthContract, summarizeDbContract } from "../../internal/cloud/db-contract.mjs"; +import { activeReportLifecycle } from "../../internal/dev-report-lifecycle.mjs"; import { DEV_ENDPOINT, ENVIRONMENT_DEV, SERVICE_IDS } from "../../internal/protocol/index.mjs"; import { probeRegistryCapabilities } from "./registry-capabilities.mjs"; @@ -1095,6 +1096,7 @@ function makeReport(args, targetCommit, targetShortCommit, reporter, artifactIde mode: "read-only", devOnly: true, prodDisabled: true, + reportLifecycle: activeReportLifecycle("Current read-only DEV preflight report; source, local, and dry-run checks are not DEV-LIVE acceptance."), forbiddenActions, validationCommands: [ "node --check scripts/dev-gate-preflight.mjs", diff --git a/scripts/validate-dev-gate-report.mjs b/scripts/validate-dev-gate-report.mjs index 3af01168..ef2d0772 100644 --- a/scripts/validate-dev-gate-report.mjs +++ b/scripts/validate-dev-gate-report.mjs @@ -5,6 +5,16 @@ import { constants as fsConstants } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { + ACTIVE_DEV_BROWSER_ENDPOINT, + ACTIVE_DEV_PUBLIC_ENDPOINT, + REPORT_LIFECYCLE_VERSION, + deprecatedPublicEndpoints, + findDeprecatedPublicEndpoints, + findForbiddenActiveDeprecatedEndpoints, + reportIsHistorical +} from "../internal/dev-report-lifecycle.mjs"; + const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const reportsDir = path.join(repoRoot, "reports/dev-gate"); @@ -330,24 +340,101 @@ function assertUnique(values, label) { assert.equal(new Set(values).size, values.length, `${label} must be unique`); } -async function readJsonFile(relativePath) { +function assertReportLifecycle(report, raw, label) { + assertObject(report.reportLifecycle, `${label}.reportLifecycle`); + const lifecycle = report.reportLifecycle; + assert.equal(lifecycle.version, REPORT_LIFECYCLE_VERSION, `${label}.reportLifecycle.version`); + assert.ok(["active", "historical"].includes(lifecycle.state), `${label}.reportLifecycle.state`); + assert.equal(lifecycle.activeEndpoint, ACTIVE_DEV_PUBLIC_ENDPOINT, `${label}.reportLifecycle.activeEndpoint`); + assert.equal(lifecycle.activeBrowserEndpoint, ACTIVE_DEV_BROWSER_ENDPOINT, `${label}.reportLifecycle.activeBrowserEndpoint`); + assertString(lifecycle.summary, `${label}.reportLifecycle.summary`); + + if (lifecycle.state === "active") { + assert.equal(lifecycle.deprecatedEndpoint, null, `${label}.reportLifecycle.deprecatedEndpoint`); + assert.deepEqual( + findForbiddenActiveDeprecatedEndpoints(report, raw), + [], + `${label} active report must not use deprecated public endpoints as active evidence` + ); + return; + } + + assert.ok( + deprecatedPublicEndpoints.includes(lifecycle.deprecatedEndpoint), + `${label}.reportLifecycle.deprecatedEndpoint must name a deprecated public endpoint` + ); + assert.ok( + lifecycle.summary.toLowerCase().includes("historical") || + lifecycle.summary.toLowerCase().includes("deprecated"), + `${label}.reportLifecycle.summary must explain the historical/deprecated state` + ); + const deprecatedMatches = findDeprecatedPublicEndpoints(raw); + assert.ok( + deprecatedMatches.length === 0 || deprecatedMatches.includes(lifecycle.deprecatedEndpoint), + `${label}.reportLifecycle.deprecatedEndpoint must cover deprecated public endpoint evidence` + ); +} + +function primaryReportStatus(report) { + return report.status ?? + report.gateStatus ?? + report.conclusion ?? + report.artifactPublish?.status ?? + report.summary?.status ?? + (Array.isArray(report.blockers) && report.blockers.length > 0 ? "blocked" : "not_run"); +} + +function validateHistoricalReport(report, label) { + assert.equal(report.devOnly, true, `${label}.devOnly`); + assert.equal(report.prodDisabled, true, `${label}.prodDisabled`); + assertString(report.issue, `${label}.issue`); + assert.ok(allowedIssues.has(report.issue), `${label}.issue must be a known HWLAB DEV gate issue`); + assertString(report.taskId ?? report.reportKind, `${label}.taskId`); + assert.ok( + ["blocked", "not_run", "not_applicable", "failed", "degraded"].includes(primaryReportStatus(report)), + `${label} historical report must not present active green status` + ); + assert.ok( + Array.isArray(report.blockers) && report.blockers.length >= 1, + `${label}.blockers must explain why the report is historical` + ); + assertBlockers(report.blockers, `${label}.blockers`); +} + +async function readJsonReport(relativePath) { const raw = await readFile(path.join(repoRoot, relativePath), "utf8"); - return JSON.parse(raw); + return { report: JSON.parse(raw), raw }; } async function collectReportFiles() { if (process.argv.length > 2) { return process.argv.slice(2).map((entry) => path.relative(repoRoot, path.resolve(process.cwd(), entry))); } - const entries = await readdir(reportsDir, { withFileTypes: true }); - return entries - .filter((entry) => entry.isFile() && entry.name.endsWith(".json")) - .map((entry) => path.join("reports/dev-gate", entry.name)) - .sort(); + return collectReportJsonFiles(reportsDir, "reports/dev-gate"); +} + +async function collectReportJsonFiles(directory, relativeDirectory) { + const entries = await readdir(directory, { withFileTypes: true }); + const nested = await Promise.all(entries.map(async (entry) => { + const relativePath = path.join(relativeDirectory, entry.name); + if (entry.isDirectory()) { + return collectReportJsonFiles(path.join(directory, entry.name), relativePath); + } + return entry.isFile() && entry.name.endsWith(".json") ? [relativePath] : []; + })); + return nested.flat().sort(); } async function validateReport(relativePath) { - const report = await readJsonFile(relativePath); + const { report, raw } = await readJsonReport(relativePath); + const label = relativePath; + assertObject(report, label); + assertReportLifecycle(report, raw, label); + if (reportIsHistorical(report)) { + validateHistoricalReport(report, label); + return; + } + if (report.reportKind === "dev-gate-preflight") { await validatePreflightReport(relativePath, report); return; @@ -357,8 +444,6 @@ async function validateReport(relativePath) { return; } - const label = relativePath; - assertObject(report, label); if (report.issue === requiredDevM3Issue || report.taskId === "dev-m3-hardware-loop") { await validateDevM3Report(report, label); return;