feat: add v0.2 GitOps lane
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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."
|
||||
}));
|
||||
}
|
||||
|
||||
+639
-124
File diff suppressed because it is too large
Load Diff
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user