feat: add v0.2 GitOps lane

This commit is contained in:
Codex
2026-05-28 16:56:19 +08:00
parent 453a87987f
commit d6a02cb14c
5 changed files with 936 additions and 169 deletions
+3
View File
@@ -39,3 +39,6 @@ yarn-error.log*
# G14 generated GitOps desired state is stored on the G14-gitops branch.
/deploy/gitops/g14/
# v0.2 generated artifact catalog is stored only on the v0.2-gitops branch.
/deploy/artifact-catalog.v02.json
+25
View File
@@ -184,6 +184,31 @@
"notes": "Reserved placeholder only. PROD deployment and acceptance are explicitly out of scope."
}
},
"lanes": {
"v02": {
"name": "v0.2",
"sourceBranch": "v0.2",
"gitopsBranch": "v0.2-gitops",
"namespace": "hwlab-v02",
"endpoint": "http://74.48.78.17:19667",
"publicEndpoints": {
"frontend": "http://74.48.78.17:19666",
"api": "http://74.48.78.17:19667"
},
"artifactCatalog": "deploy/artifact-catalog.v02.json",
"runtimePath": "deploy/gitops/g14/runtime-v02",
"imageTagMode": "full",
"services": [
{
"serviceId": "hwlab-cloud-api",
"env": {
"HWLAB_CLOUD_DB_URL": "secretRef:hwlab-cloud-api-v02-db/database-url",
"HWLAB_CLOUD_DB_CONTRACT": "v02-redacted-presence-only"
}
}
]
}
},
"services": [
{
"serviceId": "hwlab-cloud-api",
+139 -23
View File
@@ -26,6 +26,7 @@ import { ensureNotRepoReportsPath, tempReportPath } from "./src/report-paths.mjs
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const cliEntrypoint = process.env.HWLAB_ARTIFACT_PUBLISH_ENTRYPOINT || "scripts/artifact-publish.mjs";
const defaultLane = process.env.HWLAB_ARTIFACT_LANE || process.env.HWLAB_GITOPS_LANE || "g14";
const defaultRegistryPrefix =
process.env.HWLAB_DEV_REGISTRY_PREFIX || process.env.HWLAB_DEV_REGISTRY || "127.0.0.1:5000/hwlab";
const defaultBaseImage = process.env.HWLAB_DEV_BASE_IMAGE || null;
@@ -33,8 +34,8 @@ const defaultReportPath = tempReportPath("dev-artifacts.json");
const buildBackend = "buildkit";
const defaultBuildkitCommand = process.env.HWLAB_BUILDKIT_BUILDCTL || "buildctl-daemonless.sh";
const defaultBuildkitAddr = process.env.HWLAB_BUILDKIT_ADDR || null;
const catalogPath = "deploy/artifact-catalog.dev.json";
const deployPath = "deploy/deploy.json";
const defaultCatalogPath = process.env.HWLAB_ARTIFACT_CATALOG_PATH || "deploy/artifact-catalog.dev.json";
const defaultDeployPath = process.env.HWLAB_DEPLOY_MANIFEST_PATH || "deploy/deploy.json";
const mutableTags = new Set(["latest", "dev", "main", "master", "prod", "production"]);
const shaDigestPattern = /^sha256:[a-f0-9]{64}$/u;
@@ -76,6 +77,10 @@ const servicePorts = new Map([
function parseArgs(argv) {
const args = {
mode: "preflight",
lane: defaultLane,
catalogPath: defaultCatalogPath,
deployPath: defaultDeployPath,
imageTagMode: process.env.HWLAB_ARTIFACT_IMAGE_TAG_MODE || "short",
registryPrefix: defaultRegistryPrefix,
baseImage: defaultBaseImage,
reportPath: defaultReportPath,
@@ -98,6 +103,10 @@ function parseArgs(argv) {
if (arg === "--preflight") args.mode = "preflight";
else if (arg === "--build") args.mode = "build";
else if (arg === "--publish") args.mode = "publish";
else if (arg === "--lane") args.lane = readOption(argv, ++index, arg);
else if (arg === "--catalog-path") args.catalogPath = readOption(argv, ++index, arg);
else if (arg === "--deploy-json") args.deployPath = readOption(argv, ++index, arg);
else if (arg === "--image-tag-mode") args.imageTagMode = readOption(argv, ++index, arg);
else if (arg === "--registry-prefix") args.registryPrefix = readOption(argv, ++index, arg);
else if (arg === "--base-image") args.baseImage = readOption(argv, ++index, arg);
else if (arg === "--report") args.reportPath = readOption(argv, ++index, arg);
@@ -118,6 +127,9 @@ function parseArgs(argv) {
else throw new Error(`unknown argument ${arg}`);
}
assert.ok(["g14", "v02"].includes(args.lane), `unknown artifact lane ${args.lane}`);
assert.ok(["short", "full"].includes(args.imageTagMode), `unknown image tag mode ${args.imageTagMode}`);
return args;
}
@@ -191,6 +203,10 @@ function printHelp() {
"Commit-pinned artifact build/publish helper for controlled CI execution.",
"",
"options:",
" --lane LANE g14 or v02; default: g14",
` --catalog-path PATH default: ${defaultCatalogPath}`,
` --deploy-json PATH default: ${defaultDeployPath}`,
" --image-tag-mode MODE short or full; v02 uses full source commit IDs",
" --registry-prefix PREFIX default: 127.0.0.1:5000/hwlab",
" --base-image IMAGE override HWLAB_DEV_BASE_IMAGE for this run; must already be local",
" --services LIST comma-separated service IDs; default: all frozen DEV services",
@@ -211,6 +227,15 @@ async function readJson(relativePath) {
return JSON.parse(await readFile(path.join(repoRoot, relativePath), "utf8"));
}
async function readJsonIfPresent(relativePath, fallback = null) {
try {
return await readJson(relativePath);
} catch (error) {
if (error?.code === "ENOENT") return fallback;
throw error;
}
}
function emit(event, payload = {}) {
process.stderr.write(`${JSON.stringify({ event, at: new Date().toISOString(), ...payload })}\n`);
}
@@ -514,6 +539,82 @@ function assertDevOnlyContracts(catalog, deployManifest) {
}
}
function laneDeployConfig(deployManifest, lane) {
if (lane === "v02") return deployManifest?.lanes?.v02 ?? null;
return null;
}
function assertV02Contracts(catalog, deployManifest) {
const lane = laneDeployConfig(deployManifest, "v02");
assert.ok(lane && typeof lane === "object", "deploy.lanes.v02 must exist for v02 artifact publish");
assert.equal(lane.namespace, "hwlab-v02", "deploy.lanes.v02.namespace must be hwlab-v02");
assert.equal(lane.endpoint, "http://74.48.78.17:19667", "deploy.lanes.v02.endpoint must be the v02 API endpoint");
assert.equal(catalog.environment, "v02", "catalog.environment must be v02");
assert.equal(catalog.profile, "v02", "catalog.profile must be v02");
assert.equal(catalog.namespace, "hwlab-v02", "catalog.namespace must be hwlab-v02");
assert.deepEqual(catalog.allowedProfiles, ["v02"], "catalog.allowedProfiles must only allow v02");
assert.deepEqual(catalog.forbiddenProfiles, ["dev", "prod"], "catalog.forbiddenProfiles must forbid dev/prod");
const catalogIds = (catalog.services ?? []).map((service) => service.serviceId);
assert.deepEqual(catalogIds, SERVICE_IDS, "v02 catalog services must match frozen service IDs");
for (const service of catalog.services ?? []) {
assert.equal(service.profile, "v02", `${service.serviceId}.profile must be v02`);
assert.equal(service.namespace, "hwlab-v02", `${service.serviceId}.namespace must be hwlab-v02`);
assert.ok(!String(service.image).includes("prod"), `${service.serviceId}.image must not target prod`);
}
}
function assertArtifactContracts(args, catalog, deployManifest) {
if (args.lane === "v02") return assertV02Contracts(catalog, deployManifest);
return assertDevOnlyContracts(catalog, deployManifest);
}
function imageTagForCommit(args, commitId) {
return args.imageTagMode === "full" ? commitId : commitId.slice(0, 7);
}
function artifactEnvironment(args) {
return args.lane === "v02" ? "v02" : ENVIRONMENT_DEV;
}
function artifactCatalogSkeleton({ args, commitId }) {
const tag = imageTagForCommit(args, commitId);
return {
catalogVersion: "v1",
kind: "hwlab-artifact-catalog",
environment: artifactEnvironment(args),
profile: artifactEnvironment(args),
namespace: args.lane === "v02" ? "hwlab-v02" : "hwlab-dev",
endpoint: args.lane === "v02" ? "http://74.48.78.17:19667" : "http://74.48.78.17:16667",
commitId: tag,
artifactState: "contract-skeleton",
publish: {
ciPublished: false,
registryVerified: false,
provenance: "not_available_until_publish",
note: `${artifactEnvironment(args)} artifact catalog skeleton initialized without DEV/G14 fallback.`
},
allowedProfiles: [artifactEnvironment(args)],
forbiddenProfiles: args.lane === "v02" ? ["dev", "prod"] : ["prod"],
services: SERVICE_IDS.map((serviceId) => ({
serviceId,
commitId: tag,
sourceCommitId: commitId,
image: imageRef(args.registryPrefix, serviceId, tag),
imageTag: tag,
digest: "not_published",
publishState: "skeleton-only",
profile: artifactEnvironment(args),
namespace: args.lane === "v02" ? "hwlab-v02" : "hwlab-dev",
healthPath: "/health/live",
sourceState: "source-present",
publishEnabled: true,
artifactRequired: true,
artifactScope: "required",
notPublishedReason: "publish_not_run"
}))
};
}
function parseRegistryPrefix(prefix) {
const normalized = prefix.replace(/\/+$/u, "");
const [hostPort, ...pathParts] = normalized.split("/");
@@ -1146,14 +1247,14 @@ async function preflight({ args, services, catalog, deployManifest, baseImagePre
}
try {
assertDevOnlyContracts(catalog, deployManifest);
assertArtifactContracts(args, catalog, deployManifest);
} catch (error) {
blockers.push(
blocker({
type: "contract_blocker",
scope: "dev-contract",
scope: `${args.lane}-contract`,
summary: error.message,
next: "Fix deploy/artifact-catalog.dev.json and deploy/deploy.json so both remain DEV-only and cover the frozen service IDs."
next: `Fix ${args.catalogPath} and ${args.deployPath} so the selected artifact lane covers the frozen service IDs without crossing namespaces.`
})
);
}
@@ -1854,18 +1955,20 @@ function createReport({ args, repo, commitId, shortCommit, mode, services, artif
const publishedCount = requiredArtifacts.filter((artifact) => artifact.status === "published").length;
const reusedCount = requiredArtifacts.filter((artifact) => artifact.status === "reused").length;
const builtCount = requiredArtifacts.filter((artifact) => buildReadyStatuses.has(artifact.status)).length;
const environment = artifactEnvironment(args);
return {
$schema: "https://hwlab.pikastech.local/schemas/artifact-publish.schema.json",
$id: "https://hwlab.pikastech.local/g14-cicd/artifacts.json",
$id: `https://hwlab.pikastech.local/${args.lane}-cicd/artifacts.json`,
reportVersion: "v1",
issue: "pikasTech/HWLAB#35",
taskId: "g14-artifact-publish",
issue: args.lane === "v02" ? "pikasTech/HWLAB#530" : "pikasTech/HWLAB#35",
taskId: `${args.lane}-artifact-publish`,
commitId: shortCommit,
acceptanceLevel: "dev_artifact_publish",
devOnly: true,
acceptanceLevel: `${environment}_artifact_publish`,
lane: args.lane,
devOnly: args.lane !== "v02",
prodDisabled: true,
reportLifecycle: activeReportLifecycle("Current DEV artifact publish report; it is not a DEV-LIVE runtime substitute."),
reportLifecycle: activeReportLifecycle(`Current ${environment} artifact publish report; it is not runtime acceptance evidence.`),
sourceContract: {
status: fatalBlocked ? "blocked" : "pass",
documents: [
@@ -1875,7 +1978,7 @@ function createReport({ args, repo, commitId, shortCommit, mode, services, artif
"docs/reference/g14-gitops-cicd.md",
"docs/dev-base-image-preflight.md"
],
summary: "DEV artifact publish evidence for pikasTech/HWLAB#35, stored in the DEV gate report envelope."
summary: `${environment} artifact publish evidence; runtime acceptance stays separate from artifact publication.`
},
validationCommands: [
"node --check scripts/artifact-publish.mjs",
@@ -1916,19 +2019,22 @@ function createReport({ args, repo, commitId, shortCommit, mode, services, artif
requirements: [
"Registry prefix is localhost or private/internal only.",
"DEV builder base image preflight returns ready before build or publish.",
"Environment remains dev and namespace remains hwlab-dev.",
`Environment remains ${environment} and namespace remains ${args.lane === "v02" ? "hwlab-v02" : "hwlab-dev"}.`,
"PROD profile remains disabled.",
"No secret or token material is required by this workflow."
],
summary: fatalBlocked
? "Fatal preconditions blocked a full DEV artifact publish."
: "Fatal DEV-only artifact preconditions passed."
? `Fatal preconditions blocked a full ${environment} artifact publish.`
: `Fatal ${environment} artifact preconditions passed.`
},
blockers: devGateBlockers(blockers),
artifactPublish: {
issue: "pikasTech/HWLAB#35",
issue: args.lane === "v02" ? "pikasTech/HWLAB#530" : "pikasTech/HWLAB#35",
status,
mode,
lane: args.lane,
catalogPath: args.catalogPath,
imageTagMode: args.imageTagMode,
repo,
sourceCommitId: commitId,
registryPrefix: args.registryPrefix,
@@ -1945,7 +2051,7 @@ function createReport({ args, repo, commitId, shortCommit, mode, services, artif
quietBuild: args.quietBuild,
concurrency: args.concurrency,
baseImagePreflight: summarizeBaseImagePreflight(baseImagePreflight),
environment: ENVIRONMENT_DEV,
environment,
buildCreatedAt: artifacts.find((artifact) => artifact.buildCreatedAt)?.buildCreatedAt ?? null,
serviceInventory,
ciPlan: ciPlanReportSummary(ciPlan),
@@ -2003,7 +2109,7 @@ function createReport({ args, repo, commitId, shortCommit, mode, services, artif
})),
blockers
},
notes: "DEV-only artifact report. Do not treat runtime placeholder images as real service implementations."
notes: `${environment} artifact report. Do not treat runtime placeholder images as real service implementations.`
};
}
@@ -2041,19 +2147,29 @@ async function main() {
if (baseImagePreflight.publishUsable) {
args.baseImage = baseImagePreflight.localTag;
}
const [catalog, deployManifest, commitId, remoteUrl] = await Promise.all([
readJson(catalogPath),
readJson(deployPath),
const [deployManifest, commitId, remoteUrl] = await Promise.all([
readJson(args.deployPath),
gitValue(["rev-parse", "HEAD"]),
gitValue(["remote", "get-url", "origin"]).catch(() => "git@github.com:pikasTech/HWLAB.git")
]);
const shortCommit = commitId.slice(0, 7);
let catalog = await readJsonIfPresent(args.catalogPath, null);
const catalogWasMissing = !catalog;
if (!catalog && args.lane === "v02") catalog = artifactCatalogSkeleton({ args, commitId });
assert.ok(catalog, `${args.catalogPath} is required for ${args.lane} artifact publish`);
const shortCommit = imageTagForCommit(args, commitId);
let effectiveCatalogPath = args.catalogPath;
if (catalogWasMissing && args.lane === "v02") {
effectiveCatalogPath = path.join(os.tmpdir(), `hwlab-v02-artifact-catalog-${process.pid}.json`);
await writeFile(effectiveCatalogPath, `${JSON.stringify(catalog, null, 2)}\n`);
}
const buildCreatedAt = new Date().toISOString();
const repo = repoLabelFromRemote(remoteUrl);
const producer = g14ArtifactProducer(process.env);
const ciPlan = await createG14CiPlan({
repoRoot,
targetRef: "HEAD",
deployJsonPath: args.deployPath,
artifactCatalogPath: effectiveCatalogPath,
registryPrefix: args.registryPrefix,
baseImage: args.baseImage ?? undefined,
...(args.servicesExplicit ? { services: args.services } : {})
@@ -2122,7 +2238,7 @@ async function main() {
blockers.push(blocker({
type: "environment_blocker",
scope: service.serviceId,
summary: `${service.serviceId} cannot be reused because deploy/artifact-catalog.dev.json has no verified digest`,
summary: `${service.serviceId} cannot be reused because ${args.catalogPath} has no verified digest`,
next: "Refresh the service catalog with a verified sha256 digest for this service, or create a service-scoped source change so the planner publishes it through the per-service BuildKit task."
}));
}
File diff suppressed because it is too large Load Diff
+128 -20
View File
@@ -15,8 +15,9 @@ import { tempReportPath } from "./src/report-paths.mjs";
const execFileAsync = promisify(execFile);
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const catalogPath = "deploy/artifact-catalog.dev.json";
const deployPath = "deploy/deploy.json";
const defaultLane = process.env.HWLAB_ARTIFACT_LANE || process.env.HWLAB_GITOPS_LANE || "g14";
const defaultCatalogPath = process.env.HWLAB_ARTIFACT_CATALOG_PATH || "deploy/artifact-catalog.dev.json";
const defaultDeployPath = process.env.HWLAB_DEPLOY_MANIFEST_PATH || "deploy/deploy.json";
const defaultPublishReportPath = tempReportPath("dev-artifacts.json");
const defaultRegistryPrefix = process.env.HWLAB_DEV_REGISTRY_PREFIX || process.env.HWLAB_DEV_REGISTRY || "127.0.0.1:5000/hwlab";
const digestPattern = /^sha256:[a-f0-9]{64}$/;
@@ -26,6 +27,10 @@ const publishReadyStatuses = new Set(["published", "reused"]);
function parseArgs(argv) {
const args = {
lane: defaultLane,
catalogPath: defaultCatalogPath,
deployPath: defaultDeployPath,
imageTagMode: process.env.HWLAB_ARTIFACT_IMAGE_TAG_MODE || "short",
targetRef: "HEAD",
publishReportPath: null,
blocked: false,
@@ -36,6 +41,14 @@ function parseArgs(argv) {
const arg = argv[index];
if (arg === "--target-ref") {
args.targetRef = readOption(argv, ++index, arg);
} else if (arg === "--lane") {
args.lane = readOption(argv, ++index, arg);
} else if (arg === "--catalog-path") {
args.catalogPath = readOption(argv, ++index, arg);
} else if (arg === "--deploy-json") {
args.deployPath = readOption(argv, ++index, arg);
} else if (arg === "--image-tag-mode") {
args.imageTagMode = readOption(argv, ++index, arg);
} else if (arg === "--publish-report") {
args.publishReportPath = readOption(argv, ++index, arg);
} else if (arg === "--blocked") {
@@ -52,6 +65,8 @@ function parseArgs(argv) {
}
if (!args.help) {
assert.ok(["g14", "v02"].includes(args.lane), `unknown artifact lane ${args.lane}`);
assert.ok(["short", "full"].includes(args.imageTagMode), `unknown image tag mode ${args.imageTagMode}`);
assert.notEqual(args.blocked && Boolean(args.publishReportPath), true, "--blocked and --publish-report are mutually exclusive");
assert.ok(args.blocked || args.publishReportPath, `choose --blocked or --publish-report ${defaultPublishReportPath}`);
assert.ok(!args.write || args.publishReportPath, "--write requires --publish-report; blocked skeleton refresh must stay preview-only");
@@ -78,7 +93,7 @@ function usage() {
"examples:",
" node scripts/refresh-artifact-catalog.mjs --target-ref HEAD --blocked --no-write",
` node scripts/refresh-artifact-catalog.mjs --target-ref HEAD --publish-report ${defaultPublishReportPath} --no-write`,
` node scripts/refresh-artifact-catalog.mjs --target-ref HEAD --publish-report ${defaultPublishReportPath} --write`
` node scripts/refresh-artifact-catalog.mjs --lane v02 --catalog-path deploy/artifact-catalog.v02.json --image-tag-mode full --target-ref HEAD --publish-report ${defaultPublishReportPath} --write`
].join("\n");
}
@@ -87,6 +102,15 @@ async function readJson(relativePath) {
return JSON.parse(await readFile(filePath, "utf8"));
}
async function readJsonIfPresent(relativePath, fallback = null) {
try {
return await readJson(relativePath);
} catch (error) {
if (error?.code === "ENOENT") return fallback;
throw error;
}
}
async function writeJson(relativePath, value) {
await writeFile(path.join(repoRoot, relativePath), `${JSON.stringify(value, null, 2)}\n`);
}
@@ -100,11 +124,11 @@ async function gitValue(args) {
return result.stdout.trim();
}
async function resolveTarget(ref) {
async function resolveTarget(ref, imageTagMode = "short") {
const commitId = await gitValue(["rev-parse", ref]);
const shortCommitId = await gitValue(["rev-parse", "--short=7", ref]);
assert.match(commitId, /^[a-f0-9]{40}$/, `target ref ${ref} must resolve to a full SHA`);
return { ref, commitId, shortCommitId };
return { ref, commitId, shortCommitId, imageTag: imageTagMode === "full" ? commitId : shortCommitId };
}
function parseTaggedImage(image, context) {
@@ -124,15 +148,73 @@ function targetImage(serviceId, shortCommitId, registryPrefix = defaultRegistryP
return `${registryPrefix.replace(/\/+$/u, "")}/${serviceId}:${shortCommitId}`;
}
function artifactEnvironment(args) {
return args.lane === "v02" ? "v02" : ENVIRONMENT_DEV;
}
function artifactNamespace(args) {
return args.lane === "v02" ? "hwlab-v02" : "hwlab-dev";
}
function artifactEndpoint(args) {
return args.lane === "v02" ? "http://74.48.78.17:19667" : DEV_ENDPOINT;
}
function artifactCatalogSkeleton({ args, target, registryPrefix = defaultRegistryPrefix }) {
const environment = artifactEnvironment(args);
const namespace = artifactNamespace(args);
return {
catalogVersion: "v1",
kind: "hwlab-artifact-catalog",
environment,
profile: environment,
namespace,
endpoint: artifactEndpoint(args),
commitId: target.imageTag,
artifactState: "contract-skeleton",
publish: {
ciPublished: false,
registryVerified: false,
provenance: "not_available_until_publish",
note: `${environment} artifact catalog skeleton initialized without DEV/G14 fallback.`
},
healthContract: {
method: "GET",
path: "/health/live",
responseFormat: "json",
requiredFields: ["serviceId", "environment", "status"]
},
allowedProfiles: [environment],
forbiddenProfiles: args.lane === "v02" ? ["dev", "prod"] : ["prod"],
services: SERVICE_IDS.map((serviceId) => ({
serviceId,
commitId: target.imageTag,
sourceCommitId: target.commitId,
image: targetImage(serviceId, target.imageTag, registryPrefix),
imageTag: target.imageTag,
digest: "not_published",
publishState: "skeleton-only",
profile: environment,
namespace,
healthPath: "/health/live",
sourceState: "source-present",
publishEnabled: true,
artifactRequired: true,
artifactScope: "required",
notPublishedReason: "publish_not_run"
}))
};
}
function commitMatchesTarget(value, target) {
return value === target.commitId || value === target.shortCommitId;
}
function shortCommitForRecord(record, target) {
const value = record?.commitId ?? record?.imageTag ?? null;
if (typeof value === "string" && commitPattern.test(value)) return value.slice(0, 7);
if (typeof record?.sourceCommitId === "string" && commitPattern.test(record.sourceCommitId)) return record.sourceCommitId.slice(0, 7);
return target.shortCommitId;
if (typeof value === "string" && commitPattern.test(value)) return target.imageTag.length === 40 ? value : value.slice(0, 7);
if (typeof record?.sourceCommitId === "string" && commitPattern.test(record.sourceCommitId)) return target.imageTag.length === 40 ? record.sourceCommitId : record.sourceCommitId.slice(0, 7);
return target.imageTag;
}
function revisionForRecord(record, target) {
@@ -158,6 +240,27 @@ function assertDevOnlyDeployAndCatalog(deploy, catalog) {
assert.deepEqual((catalog.services ?? []).map((service) => service.serviceId), SERVICE_IDS, "catalog services must cover frozen service IDs in order");
}
function assertV02DeployAndCatalog(deploy, catalog) {
const lane = deploy?.lanes?.v02;
assert.ok(lane && typeof lane === "object", "deploy.lanes.v02 must exist for v02 catalog refresh");
assert.equal(lane.namespace, "hwlab-v02", "deploy.lanes.v02.namespace must be hwlab-v02");
assert.equal(lane.endpoint, "http://74.48.78.17:19667", "deploy.lanes.v02.endpoint must be the v02 API endpoint");
assert.deepEqual((deploy.services ?? []).map((service) => service.serviceId), SERVICE_IDS, "deploy services must cover frozen service IDs in order");
assert.equal(catalog.environment, "v02", "catalog environment must be v02");
assert.equal(catalog.profile, "v02", "catalog profile must be v02");
assert.equal(catalog.namespace, "hwlab-v02", "catalog namespace must be hwlab-v02");
assert.equal(catalog.endpoint, "http://74.48.78.17:19667", "catalog endpoint must be the v02 API endpoint");
assert.deepEqual(catalog.allowedProfiles, ["v02"], "catalog allowedProfiles must only allow v02");
assert.deepEqual(catalog.forbiddenProfiles, ["dev", "prod"], "catalog forbiddenProfiles must forbid dev/prod");
assert.deepEqual((catalog.services ?? []).map((service) => service.serviceId), SERVICE_IDS, "catalog services must cover frozen service IDs in order");
}
function assertLaneDeployAndCatalog(args, deploy, catalog) {
if (args.lane === "v02") return assertV02DeployAndCatalog(deploy, catalog);
return assertDevOnlyDeployAndCatalog(deploy, catalog);
}
function publishRecordsFromReport(report, target) {
const artifactPublish = report.artifactPublish;
assert.ok(artifactPublish && typeof artifactPublish === "object", "publish report missing artifactPublish");
@@ -198,7 +301,7 @@ function publishRecordsFromReport(report, target) {
assert.equal(publishReadyStatuses.has(service.status), true, `${service.serviceId} status must be published or reused`);
assert.equal(service.artifactRequired, true, `${service.serviceId} required service must state artifactRequired=true`);
if (service.status === "published") {
assert.equal(image.tag, target.shortCommitId, `${service.serviceId} newly published image tag must match target short commit`);
assert.equal(image.tag, target.imageTag, `${service.serviceId} newly published image tag must match target image tag`);
}
assert.match(service.digest, digestPattern, `${service.serviceId} digest must be a sha256 registry digest`);
records.set(service.serviceId, {
@@ -238,7 +341,7 @@ function refreshDocuments({ deploy, catalog, target, publishRecords, serviceInve
const inventoryByService = new Map(serviceInventory.services.map((service) => [service.serviceId, service]));
const refreshedServices = [];
catalog.commitId = target.shortCommitId;
catalog.commitId = target.imageTag;
catalog.artifactState = published ? "published" : "contract-skeleton";
catalog.serviceInventory = serviceInventory;
catalog.publish = {
@@ -260,8 +363,8 @@ function refreshDocuments({ deploy, catalog, target, publishRecords, serviceInve
const inventory = inventoryByService.get(serviceId);
const required = requiredIds.has(serviceId);
const publishRecord = records?.get(serviceId) ?? null;
const image = publishRecord?.image ?? targetImage(serviceId, target.shortCommitId, registryPrefix);
const imageTag = publishRecord?.imageTag ?? target.shortCommitId;
const image = publishRecord?.image ?? targetImage(serviceId, target.imageTag, registryPrefix);
const imageTag = publishRecord?.imageTag ?? target.imageTag;
const serviceCommitId = shortCommitForRecord(publishRecord, target);
catalogService.commitId = serviceCommitId;
@@ -321,13 +424,15 @@ async function main() {
return;
}
const target = await resolveTarget(args.targetRef);
const [deploy, catalog, publishReport] = await Promise.all([
readJson(deployPath),
readJson(catalogPath),
const target = await resolveTarget(args.targetRef, args.imageTagMode);
const [deploy, publishReport] = await Promise.all([
readJson(args.deployPath),
args.publishReportPath ? readJson(args.publishReportPath) : Promise.resolve(null)
]);
assertDevOnlyDeployAndCatalog(deploy, catalog);
let catalog = await readJsonIfPresent(args.catalogPath, null);
if (!catalog && args.lane === "v02") catalog = artifactCatalogSkeleton({ args, target, registryPrefix: publishReport?.artifactPublish?.registryPrefix ?? defaultRegistryPrefix });
assert.ok(catalog, `${args.catalogPath} is required for ${args.lane} catalog refresh`);
assertLaneDeployAndCatalog(args, deploy, catalog);
const publishRecords = publishReport ? publishRecordsFromReport(publishReport, target) : null;
const serviceInventory = publishReport?.artifactPublish?.serviceInventory ?? serviceInventoryFromServices(
@@ -344,15 +449,18 @@ async function main() {
});
if (args.write) {
await writeJson(catalogPath, catalog);
await writeJson(args.catalogPath, catalog);
}
console.log(JSON.stringify({
status: publishRecords ? "published" : "blocked",
targetRef: target.ref,
sourceCommitId: target.commitId,
artifactCommitId: target.shortCommitId,
wrote: args.write ? [catalogPath] : [],
artifactCommitId: target.imageTag,
lane: args.lane,
catalogPath: args.catalogPath,
imageTagMode: args.imageTagMode,
wrote: args.write ? [args.catalogPath] : [],
deployTruthPolicy: "deploy.json is human-authored runtime config; artifact promotion must not rewrite it",
ciPublished: Boolean(publishRecords),
registryVerified: Boolean(publishRecords),