ci: reuse candidates for production pull requests
This commit is contained in:
@@ -473,6 +473,36 @@ export function resolveDeploymentBranch({ eventName, environment = process.env,
|
||||
);
|
||||
}
|
||||
|
||||
export function resolvePullRequestHeadBranch({ environment = process.env, eventPayload } = {}) {
|
||||
const direct = String(environment.GITHUB_HEAD_REF || environment.GITEA_HEAD_REF || "").trim();
|
||||
if (direct) return direct;
|
||||
|
||||
let payload = eventPayload;
|
||||
if (payload === undefined) {
|
||||
const eventPath = String(environment.GITHUB_EVENT_PATH || environment.GITEA_EVENT_PATH || "").trim();
|
||||
if (eventPath) {
|
||||
payload = JSON.parse(fs.readFileSync(eventPath, "utf8"));
|
||||
}
|
||||
}
|
||||
return requiredString(payload?.pull_request?.head?.ref, "Pull request head branch");
|
||||
}
|
||||
|
||||
export function classifyVersion2Execution(pipeline, eventName) {
|
||||
if (eventName === "pull_request") {
|
||||
return pipeline.strategy === "promote" ? "validate-candidate" : "build-validation";
|
||||
}
|
||||
return pipeline.strategy === "candidate" ? "build-deploy" : "promote";
|
||||
}
|
||||
|
||||
export function validatePromotionPullRequestSource(pipeline, headBranch) {
|
||||
const sourceBranch = requiredString(pipeline.source?.branch, "Promotion source branch");
|
||||
const actualHeadBranch = requiredString(headBranch, "Pull request head branch");
|
||||
if (actualHeadBranch !== sourceBranch) {
|
||||
throw new Error(`Production promotion pull requests must originate from ${sourceBranch}, not ${actualHeadBranch}.`);
|
||||
}
|
||||
return sourceBranch;
|
||||
}
|
||||
|
||||
function appendSummary(text) {
|
||||
const summary = process.env.GITHUB_STEP_SUMMARY ?? process.env.GITEA_STEP_SUMMARY;
|
||||
if (summary) fs.appendFileSync(summary, `${text}\n`);
|
||||
@@ -609,9 +639,10 @@ async function executeVersion2({ config, pipeline, eventName, sha, workspace })
|
||||
const registry = requiredString(config.registry ?? "gitea.nuvisphere.de", "OCI registry").replace(/\/$/, "");
|
||||
const dockerConfig = fs.mkdtempSync(path.join(os.tmpdir(), "quickstack-docker-"));
|
||||
const dockerEnv = { ...process.env, DOCKER_CONFIG: dockerConfig };
|
||||
const execution = classifyVersion2Execution(pipeline, eventName);
|
||||
try {
|
||||
if (!validationOnly) dockerLogin(registry, dockerEnv);
|
||||
if (validationOnly || pipeline.strategy === "candidate") {
|
||||
if (execution !== "build-validation") dockerLogin(registry, dockerEnv);
|
||||
if (execution === "build-validation" || execution === "build-deploy") {
|
||||
const artifacts = new Map();
|
||||
for (const artifact of pipeline.artifacts) {
|
||||
artifacts.set(artifact.name, buildArtifact({
|
||||
@@ -620,10 +651,10 @@ async function executeVersion2({ config, pipeline, eventName, sha, workspace })
|
||||
sha,
|
||||
workspace,
|
||||
dockerEnv,
|
||||
validationOnly,
|
||||
validationOnly: execution === "build-validation",
|
||||
}));
|
||||
}
|
||||
if (validationOnly) {
|
||||
if (execution === "build-validation") {
|
||||
appendSummary(`Validated ${artifacts.size} immutable OCI artifact(s) for ${pipeline.branch}.`);
|
||||
return;
|
||||
}
|
||||
@@ -635,6 +666,24 @@ async function executeVersion2({ config, pipeline, eventName, sha, workspace })
|
||||
return;
|
||||
}
|
||||
|
||||
if (execution === "validate-candidate") {
|
||||
const sourceBranch = validatePromotionPullRequestSource(pipeline, resolvePullRequestHeadBranch());
|
||||
const release = loadRelease(workspace, pipeline.release);
|
||||
const artifacts = new Map();
|
||||
for (const artifact of pipeline.artifacts) {
|
||||
artifacts.set(artifact.name, pullCandidateArtifact({
|
||||
artifact,
|
||||
registry,
|
||||
sourceSha: sha,
|
||||
workspace,
|
||||
dockerEnv,
|
||||
}));
|
||||
}
|
||||
console.log(`Validated ${release.tag} against ${artifacts.size} tested candidate artifact(s) from ${sourceBranch} at ${sha}; no rebuild or deployment performed.`);
|
||||
appendSummary(`Validated release ${release.tag} against tested candidate \`${sha}\` from \`${sourceBranch}\` without rebuilding.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceSha = resolvePromotionSource(workspace, pipeline.source);
|
||||
const release = loadRelease(workspace, pipeline.release);
|
||||
const artifacts = new Map();
|
||||
|
||||
@@ -2,16 +2,19 @@ import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
classifyVersion2Execution,
|
||||
deployExactImage,
|
||||
expandTokens,
|
||||
mergeEnvironment,
|
||||
orderApplications,
|
||||
resolveDeploymentBranch,
|
||||
resolvePullRequestHeadBranch,
|
||||
selectDeployment,
|
||||
selectPipeline,
|
||||
toSavePayload,
|
||||
validateArtifact,
|
||||
validateApplication,
|
||||
validatePromotionPullRequestSource,
|
||||
validateTarget,
|
||||
validateVersion2Pipeline,
|
||||
verifyEndpoint,
|
||||
@@ -62,6 +65,39 @@ test("resolves push and pull request deployment branches without accepting empty
|
||||
);
|
||||
});
|
||||
|
||||
test("resolves the pull request head branch from direct and payload context", () => {
|
||||
assert.equal(
|
||||
resolvePullRequestHeadBranch({ environment: { GITHUB_HEAD_REF: "staging" } }),
|
||||
"staging",
|
||||
);
|
||||
assert.equal(
|
||||
resolvePullRequestHeadBranch({
|
||||
environment: {},
|
||||
eventPayload: { pull_request: { head: { ref: "release-candidate" } } },
|
||||
}),
|
||||
"release-candidate",
|
||||
);
|
||||
assert.throws(
|
||||
() => resolvePullRequestHeadBranch({ environment: {}, eventPayload: {} }),
|
||||
/Pull request head branch/,
|
||||
);
|
||||
});
|
||||
|
||||
test("production pull requests validate tested candidates without rebuilding", () => {
|
||||
assert.equal(classifyVersion2Execution({ strategy: "candidate" }, "pull_request"), "build-validation");
|
||||
assert.equal(classifyVersion2Execution({ strategy: "candidate" }, "push"), "build-deploy");
|
||||
assert.equal(classifyVersion2Execution({ strategy: "promote" }, "pull_request"), "validate-candidate");
|
||||
assert.equal(classifyVersion2Execution({ strategy: "promote" }, "push"), "promote");
|
||||
assert.equal(
|
||||
validatePromotionPullRequestSource({ source: { branch: "staging" } }, "staging"),
|
||||
"staging",
|
||||
);
|
||||
assert.throws(
|
||||
() => validatePromotionPullRequestSource({ source: { branch: "staging" } }, "feature"),
|
||||
/must originate from staging, not feature/,
|
||||
);
|
||||
});
|
||||
|
||||
test("validates OCI paths and repository-local build paths", () => {
|
||||
assert.equal(validateTarget({ name: "Web", image: "Owner/Web", appId: "app-1" }).image, "owner/web");
|
||||
assert.throws(() => validateTarget({ name: "Web", image: "owner/web", appId: "app-1", context: "../secret" }), /inside/);
|
||||
|
||||
Reference in New Issue
Block a user