perf(ci): reuse tree-equivalent merge candidates
This commit is contained in:
@@ -369,6 +369,16 @@ function run(command, args, options = {}) {
|
||||
return `${result.stdout ?? ""}${result.stderr ?? ""}`;
|
||||
}
|
||||
|
||||
function tryGit(workspace, args) {
|
||||
const result = spawnSync("git", args, {
|
||||
cwd: workspace,
|
||||
encoding: "utf8",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
if (result.error || result.status !== 0) return null;
|
||||
return String(result.stdout ?? "").trim();
|
||||
}
|
||||
|
||||
function safeRelative(value, name) {
|
||||
const normalized = requiredString(value, name).replace(/^\.\//, "");
|
||||
if (path.isAbsolute(normalized) || normalized.split(/[\\/]/).includes("..")) {
|
||||
@@ -660,6 +670,28 @@ export function validatePromotionPullRequestSource(pipeline, headBranch) {
|
||||
return sourceBranch;
|
||||
}
|
||||
|
||||
export function resolveTreeEquivalentCandidateSha(workspace, sha, source = {}) {
|
||||
if (source.requireTreeMatch === false) return sha;
|
||||
const mergeParent = Number(source.mergeParent ?? 2);
|
||||
if (!Number.isInteger(mergeParent) || mergeParent < 1) {
|
||||
throw new Error("Candidate mergeParent must be a positive integer.");
|
||||
}
|
||||
|
||||
let currentSha = sha;
|
||||
const visited = new Set();
|
||||
while (!visited.has(currentSha)) {
|
||||
visited.add(currentSha);
|
||||
const parentSha = tryGit(workspace, ["rev-parse", `${currentSha}^${mergeParent}`]);
|
||||
if (!parentSha) return currentSha;
|
||||
|
||||
const commitTree = run("git", ["rev-parse", `${currentSha}^{tree}`], { cwd: workspace, capture: true }).trim();
|
||||
const parentTree = run("git", ["rev-parse", `${parentSha}^{tree}`], { cwd: workspace, capture: true }).trim();
|
||||
if (commitTree !== parentTree) return currentSha;
|
||||
currentSha = parentSha;
|
||||
}
|
||||
return currentSha;
|
||||
}
|
||||
|
||||
function appendSummary(text) {
|
||||
const summary = process.env.GITHUB_STEP_SUMMARY ?? process.env.GITEA_STEP_SUMMARY;
|
||||
if (summary) fs.appendFileSync(summary, `${text}\n`);
|
||||
@@ -912,6 +944,12 @@ async function executeVersion2({
|
||||
const useRegistryCache =
|
||||
execution !== "build-validation" ||
|
||||
isSameRepositoryPullRequest({ eventName, environment: process.env });
|
||||
const candidateSha = execution === "build-deploy"
|
||||
? resolveTreeEquivalentCandidateSha(workspace, sha, pipeline.source)
|
||||
: sha;
|
||||
if (candidateSha !== sha) {
|
||||
console.log(`Branch commit ${sha} has the same tree as tested candidate parent ${candidateSha}; reusing candidate artifacts.`);
|
||||
}
|
||||
try {
|
||||
const needsRegistryLogin =
|
||||
!(actionMode === "coordinate" && execution === "build-validation") &&
|
||||
@@ -931,7 +969,7 @@ async function executeVersion2({
|
||||
const built = buildArtifact({
|
||||
artifact,
|
||||
registry,
|
||||
sha,
|
||||
sha: candidateSha,
|
||||
workspace,
|
||||
dockerEnv,
|
||||
validationOnly,
|
||||
@@ -959,7 +997,7 @@ async function executeVersion2({
|
||||
artifacts.set(artifact.name, pullCandidateArtifact({
|
||||
artifact,
|
||||
registry,
|
||||
sourceSha: sha,
|
||||
sourceSha: candidateSha,
|
||||
workspace,
|
||||
dockerEnv,
|
||||
}));
|
||||
@@ -968,7 +1006,7 @@ async function executeVersion2({
|
||||
baseUrl: process.env.QUICKSTACK_BASE_URL,
|
||||
token: process.env.QUICKSTACK_API_TOKEN,
|
||||
});
|
||||
await deployApplications({ applications: pipeline.applications, artifacts, sha, client });
|
||||
await deployApplications({ applications: pipeline.applications, artifacts, sha: candidateSha, client });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -978,7 +1016,7 @@ async function executeVersion2({
|
||||
artifacts.set(artifact.name, buildArtifact({
|
||||
artifact,
|
||||
registry,
|
||||
sha,
|
||||
sha: candidateSha,
|
||||
workspace,
|
||||
dockerEnv,
|
||||
validationOnly,
|
||||
@@ -998,29 +1036,37 @@ async function executeVersion2({
|
||||
baseUrl: process.env.QUICKSTACK_BASE_URL,
|
||||
token: process.env.QUICKSTACK_API_TOKEN,
|
||||
});
|
||||
await deployApplications({ applications: pipeline.applications, artifacts, sha, client });
|
||||
await deployApplications({ applications: pipeline.applications, artifacts, sha: candidateSha, client });
|
||||
return;
|
||||
}
|
||||
|
||||
if (execution === "validate-candidate") {
|
||||
const sourceBranch = validatePromotionPullRequestSource(pipeline, resolvePullRequestHeadBranch());
|
||||
const release = loadRelease(workspace, pipeline.release);
|
||||
const sourceSha = resolveTreeEquivalentCandidateSha(workspace, sha, pipeline.source);
|
||||
if (sourceSha !== sha) {
|
||||
console.log(`Pull request head ${sha} has the same tree as tested candidate parent ${sourceSha}; validating parent candidate artifacts.`);
|
||||
}
|
||||
const artifacts = new Map();
|
||||
for (const artifact of pipeline.artifacts) {
|
||||
artifacts.set(artifact.name, pullCandidateArtifact({
|
||||
artifact,
|
||||
registry,
|
||||
sourceSha: sha,
|
||||
sourceSha,
|
||||
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.`);
|
||||
console.log(`Validated ${release.tag} against ${artifacts.size} tested candidate artifact(s) from ${sourceBranch} at ${sourceSha}; no rebuild or deployment performed.`);
|
||||
appendSummary(`Validated release ${release.tag} against tested candidate \`${sourceSha}\` from \`${sourceBranch}\` without rebuilding.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceSha = resolvePromotionSource(workspace, pipeline.source);
|
||||
const mergeSourceSha = resolvePromotionSource(workspace, pipeline.source);
|
||||
const sourceSha = resolveTreeEquivalentCandidateSha(workspace, mergeSourceSha, pipeline.source);
|
||||
if (sourceSha !== mergeSourceSha) {
|
||||
console.log(`Promotion source ${mergeSourceSha} has the same tree as tested candidate parent ${sourceSha}; promoting parent candidate artifacts.`);
|
||||
}
|
||||
const release = loadRelease(workspace, pipeline.release);
|
||||
const artifacts = new Map();
|
||||
for (const artifact of pipeline.artifacts) {
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
@@ -17,6 +21,7 @@ import {
|
||||
orderApplications,
|
||||
resolveDeploymentBranch,
|
||||
resolvePullRequestHeadBranch,
|
||||
resolveTreeEquivalentCandidateSha,
|
||||
selectDeployment,
|
||||
selectPipeline,
|
||||
toSavePayload,
|
||||
@@ -130,6 +135,45 @@ test("same-repository pull requests publish reusable candidates while forks stay
|
||||
);
|
||||
});
|
||||
|
||||
test("tree-equivalent merge commits resolve to their tested candidate parent", () => {
|
||||
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "quickstack-merge-candidate-"));
|
||||
const git = (args) => execFileSync("git", args, {
|
||||
cwd: workspace,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
}).trim();
|
||||
try {
|
||||
git(["init"]);
|
||||
git(["checkout", "-b", "main"]);
|
||||
git(["config", "user.email", "ci@example.test"]);
|
||||
git(["config", "user.name", "CI"]);
|
||||
fs.writeFileSync(path.join(workspace, "app.txt"), "base\n");
|
||||
git(["add", "app.txt"]);
|
||||
git(["commit", "-m", "base"]);
|
||||
const baseSha = git(["rev-parse", "HEAD"]);
|
||||
git(["checkout", "-b", "feature"]);
|
||||
fs.writeFileSync(path.join(workspace, "app.txt"), "feature\n");
|
||||
git(["commit", "-am", "feature"]);
|
||||
const featureSha = git(["rev-parse", "HEAD"]);
|
||||
git(["checkout", "main"]);
|
||||
git(["merge", "--no-ff", "feature", "-m", "merge feature"]);
|
||||
const mergeSha = git(["rev-parse", "HEAD"]);
|
||||
git(["checkout", "-b", "prod", baseSha]);
|
||||
git(["merge", "--no-ff", "main", "-m", "merge staging"]);
|
||||
const prodMergeSha = git(["rev-parse", "HEAD"]);
|
||||
|
||||
assert.equal(resolveTreeEquivalentCandidateSha(workspace, mergeSha), featureSha);
|
||||
assert.equal(resolveTreeEquivalentCandidateSha(workspace, prodMergeSha), featureSha);
|
||||
assert.equal(resolveTreeEquivalentCandidateSha(workspace, featureSha), featureSha);
|
||||
assert.equal(
|
||||
resolveTreeEquivalentCandidateSha(workspace, mergeSha, { requireTreeMatch: false }),
|
||||
mergeSha,
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(workspace, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("validates split action modes and artifact build slots", () => {
|
||||
assert.equal(resolveActionMode(undefined), "all");
|
||||
assert.equal(resolveActionMode("BUILD"), "build");
|
||||
|
||||
@@ -106,7 +106,7 @@ volume ID or shared-volume ID to a safe absolute container path; it never
|
||||
creates or replaces a volume. QuickStack's deployment identity variable is
|
||||
never inherited from the source application.
|
||||
|
||||
Candidate pipelines build every artifact once, push `sha-<commit>`, resolve the registry digest and deploy applications in dependency order. Same-repository pull requests publish their verified immutable candidates immediately. A fast-forward merge keeps the tested commit SHA, so the subsequent branch push only pulls, verifies and deploys those candidates instead of building them again. Fork pull requests remain validation-only and never publish. A pull request into a promotion branch must originate from `source.branch`; it pulls and verifies the already tested `sha-<commit>` candidates without rebuilding or deploying them. Promotion pushes require a merge parent with an identical Git tree, pull the existing candidate, verify required container files, add the SemVer alias, deploy the exact digests, create the immutable tag and publish the canonical Gitea release. Tag creation does not trigger another scoped pipeline because the workflow listens only to branch pushes.
|
||||
Candidate pipelines build every artifact once, push `sha-<commit>`, resolve the registry digest and deploy applications in dependency order. Same-repository pull requests publish their verified immutable candidates immediately. A fast-forward merge keeps the tested commit SHA, so the subsequent branch push only pulls, verifies and deploys those candidates instead of building them again. A no-ff merge commit is also reused when its tree matches the configured merge parent, so Gitea's merge commit does not force a second identical build. Fork pull requests remain validation-only and never publish. A pull request into a promotion branch must originate from `source.branch`; it pulls and verifies the already tested `sha-<commit>` candidates without rebuilding or deploying them. Promotion pushes require a merge parent with an identical Git tree, trace tree-equivalent staging merge commits back to their tested candidate parent, pull the existing candidate, verify required container files, add the SemVer alias, deploy the exact digests, create the immutable tag and publish the canonical Gitea release. Tag creation does not trigger another scoped pipeline because the workflow listens only to branch pushes.
|
||||
|
||||
Scoped workflows can distribute version 2 artifact builds across independent
|
||||
runner jobs by invoking the action with `mode: build` and a zero-based
|
||||
|
||||
Reference in New Issue
Block a user