Compare commits
5
Commits
9bc520ce64
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed7a5c0ff8 | ||
|
|
f520255d61 | ||
|
|
0cc4f072e7 | ||
|
|
f39d803d7a | ||
|
|
b512035c07 |
@@ -87,6 +87,54 @@ export function mergeEnvironment(source, overrides = {}) {
|
|||||||
return rows.map((row) => row.raw ?? `${row.key}=${row.value}`).join("\n");
|
return rows.map((row) => row.raw ?? `${row.key}=${row.value}`).join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function parseEnvironment(source, { exclude = ["APP_DEPLOYMENT_ID"] } = {}) {
|
||||||
|
const excluded = new Set(exclude);
|
||||||
|
const environment = {};
|
||||||
|
for (const line of String(source ?? "").split(/\r?\n/)) {
|
||||||
|
const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
|
||||||
|
if (match && !excluded.has(match[1])) environment[match[1]] = match[2];
|
||||||
|
}
|
||||||
|
return environment;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeVolumeMountPaths(volumeMountPaths = {}) {
|
||||||
|
if (
|
||||||
|
volumeMountPaths === null ||
|
||||||
|
typeof volumeMountPaths !== "object" ||
|
||||||
|
Array.isArray(volumeMountPaths)
|
||||||
|
) {
|
||||||
|
throw new Error("volumeMountPaths must be an object.");
|
||||||
|
}
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(volumeMountPaths).map(([volumeId, rawPath]) => {
|
||||||
|
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(volumeId)) {
|
||||||
|
throw new Error(`Invalid QuickStack volume ID ${volumeId}.`);
|
||||||
|
}
|
||||||
|
const containerPath = String(rawPath).trim();
|
||||||
|
if (!/^\/[A-Za-z0-9._/-]+$/.test(containerPath) || containerPath.split("/").includes("..")) {
|
||||||
|
throw new Error(`Volume mount path for ${volumeId} must be a safe absolute path.`);
|
||||||
|
}
|
||||||
|
return [volumeId, containerPath];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyVolumeMountPaths(appVolumes = [], volumeMountPaths = {}) {
|
||||||
|
const normalized = normalizeVolumeMountPaths(volumeMountPaths);
|
||||||
|
const matched = new Set();
|
||||||
|
const volumes = appVolumes.map((volume) => {
|
||||||
|
const key = [volume.id, volume.sharedVolumeId].find((candidate) => candidate && normalized[candidate]);
|
||||||
|
if (!key) return volume;
|
||||||
|
matched.add(key);
|
||||||
|
return { ...volume, containerMountPath: normalized[key] };
|
||||||
|
});
|
||||||
|
const missing = Object.keys(normalized).filter((key) => !matched.has(key));
|
||||||
|
if (missing.length) {
|
||||||
|
throw new Error(`QuickStack volume not found: ${missing.join(", ")}.`);
|
||||||
|
}
|
||||||
|
return volumes;
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveSecretEnvironment(secretEnvironment = {}, environment = process.env) {
|
export function resolveSecretEnvironment(secretEnvironment = {}, environment = process.env) {
|
||||||
if (
|
if (
|
||||||
secretEnvironment === null ||
|
secretEnvironment === null ||
|
||||||
@@ -237,6 +285,7 @@ export async function verifyEndpoint(postflight, { sha, fetchImpl = fetch, sleep
|
|||||||
export async function deployExactImage({
|
export async function deployExactImage({
|
||||||
client, appId, image, registryUsername, registryToken, environment = {},
|
client, appId, image, registryUsername, registryToken, environment = {},
|
||||||
healthCheckTcpPort, postflight, sha, deploymentAttempts = 1, deploymentRetrySeconds = 15,
|
healthCheckTcpPort, postflight, sha, deploymentAttempts = 1, deploymentRetrySeconds = 15,
|
||||||
|
volumeMountPaths = {},
|
||||||
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
||||||
fetchImpl = fetch,
|
fetchImpl = fetch,
|
||||||
}) {
|
}) {
|
||||||
@@ -256,6 +305,7 @@ export async function deployExactImage({
|
|||||||
containerRegistryUsername: requiredString(registryUsername, "Registry username"),
|
containerRegistryUsername: requiredString(registryUsername, "Registry username"),
|
||||||
containerRegistryPassword: requiredString(registryToken, "Registry token"),
|
containerRegistryPassword: requiredString(registryToken, "Registry token"),
|
||||||
envVars: mergeEnvironment(previous.envVars, environment),
|
envVars: mergeEnvironment(previous.envVars, environment),
|
||||||
|
appVolumes: applyVolumeMountPaths(previous.appVolumes, volumeMountPaths),
|
||||||
...(healthCheckTcpPort === undefined ? {} : { healthCheckTcpPort: Number(healthCheckTcpPort) }),
|
...(healthCheckTcpPort === undefined ? {} : { healthCheckTcpPort: Number(healthCheckTcpPort) }),
|
||||||
};
|
};
|
||||||
await client.saveApp(next);
|
await client.saveApp(next);
|
||||||
@@ -319,6 +369,16 @@ function run(command, args, options = {}) {
|
|||||||
return `${result.stdout ?? ""}${result.stderr ?? ""}`;
|
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) {
|
function safeRelative(value, name) {
|
||||||
const normalized = requiredString(value, name).replace(/^\.\//, "");
|
const normalized = requiredString(value, name).replace(/^\.\//, "");
|
||||||
if (path.isAbsolute(normalized) || normalized.split(/[\\/]/).includes("..")) {
|
if (path.isAbsolute(normalized) || normalized.split(/[\\/]/).includes("..")) {
|
||||||
@@ -444,12 +504,21 @@ export function validateApplication(application, artifactNames) {
|
|||||||
if (deploymentRetrySeconds !== undefined && (!Number.isFinite(deploymentRetrySeconds) || deploymentRetrySeconds < 0)) {
|
if (deploymentRetrySeconds !== undefined && (!Number.isFinite(deploymentRetrySeconds) || deploymentRetrySeconds < 0)) {
|
||||||
throw new Error(`Deployment retry seconds for ${name} must be a non-negative number.`);
|
throw new Error(`Deployment retry seconds for ${name} must be a non-negative number.`);
|
||||||
}
|
}
|
||||||
|
const appId = requiredString(application.appId, `QuickStack app ID for ${name}`);
|
||||||
|
const environmentFromAppId = application.environmentFromAppId === undefined
|
||||||
|
? undefined
|
||||||
|
: requiredString(application.environmentFromAppId, `Environment source app ID for ${name}`);
|
||||||
|
if (environmentFromAppId === appId) {
|
||||||
|
throw new Error(`Application ${name} cannot inherit environment from itself.`);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
...application,
|
...application,
|
||||||
name,
|
name,
|
||||||
artifact,
|
artifact,
|
||||||
appId: requiredString(application.appId, `QuickStack app ID for ${name}`),
|
appId,
|
||||||
dependsOn,
|
dependsOn,
|
||||||
|
volumeMountPaths: normalizeVolumeMountPaths(application.volumeMountPaths),
|
||||||
|
...(environmentFromAppId === undefined ? {} : { environmentFromAppId }),
|
||||||
...(deploymentAttempts === undefined ? {} : { deploymentAttempts }),
|
...(deploymentAttempts === undefined ? {} : { deploymentAttempts }),
|
||||||
...(deploymentRetrySeconds === undefined ? {} : { deploymentRetrySeconds }),
|
...(deploymentRetrySeconds === undefined ? {} : { deploymentRetrySeconds }),
|
||||||
};
|
};
|
||||||
@@ -562,6 +631,13 @@ export function isSameRepositoryPullRequest({ eventName, environment = process.e
|
|||||||
return Boolean(baseRepository && headRepository && baseRepository === headRepository);
|
return Boolean(baseRepository && headRepository && baseRepository === headRepository);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isValidationOnlyCandidateBuild({
|
||||||
|
eventName,
|
||||||
|
sameRepositoryPullRequest,
|
||||||
|
} = {}) {
|
||||||
|
return eventName === "pull_request" && !sameRepositoryPullRequest;
|
||||||
|
}
|
||||||
|
|
||||||
export function classifyVersion2Execution(pipeline, eventName) {
|
export function classifyVersion2Execution(pipeline, eventName) {
|
||||||
if (eventName === "pull_request") {
|
if (eventName === "pull_request") {
|
||||||
return pipeline.strategy === "promote" ? "validate-candidate" : "build-validation";
|
return pipeline.strategy === "promote" ? "validate-candidate" : "build-validation";
|
||||||
@@ -594,6 +670,28 @@ export function validatePromotionPullRequestSource(pipeline, headBranch) {
|
|||||||
return sourceBranch;
|
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) {
|
function appendSummary(text) {
|
||||||
const summary = process.env.GITHUB_STEP_SUMMARY ?? process.env.GITEA_STEP_SUMMARY;
|
const summary = process.env.GITHUB_STEP_SUMMARY ?? process.env.GITEA_STEP_SUMMARY;
|
||||||
if (summary) fs.appendFileSync(summary, `${text}\n`);
|
if (summary) fs.appendFileSync(summary, `${text}\n`);
|
||||||
@@ -645,8 +743,26 @@ function buildArtifact({
|
|||||||
dockerEnv,
|
dockerEnv,
|
||||||
validationOnly,
|
validationOnly,
|
||||||
useRegistryCache = !validationOnly,
|
useRegistryCache = !validationOnly,
|
||||||
|
reuseExistingCandidate = false,
|
||||||
}) {
|
}) {
|
||||||
const taggedImage = `${registry}/${artifact.image}:sha-${sha}`;
|
const taggedImage = `${registry}/${artifact.image}:sha-${sha}`;
|
||||||
|
if (reuseExistingCandidate) {
|
||||||
|
try {
|
||||||
|
const existing = pullCandidateArtifact({
|
||||||
|
artifact,
|
||||||
|
registry,
|
||||||
|
sourceSha: sha,
|
||||||
|
workspace,
|
||||||
|
dockerEnv,
|
||||||
|
});
|
||||||
|
console.log(`Reusing verified candidate ${existing.exactImage}; no Docker build required.`);
|
||||||
|
return existing;
|
||||||
|
} catch (error) {
|
||||||
|
console.log(
|
||||||
|
`No reusable candidate found for ${taggedImage}; building it now (${error instanceof Error ? error.message : String(error)}).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
const cacheImage = `${registry}/${artifact.image}:buildcache`;
|
const cacheImage = `${registry}/${artifact.image}:buildcache`;
|
||||||
const builder = ensureBuildxBuilder(dockerEnv);
|
const builder = ensureBuildxBuilder(dockerEnv);
|
||||||
const buildArgs = [
|
const buildArgs = [
|
||||||
@@ -685,6 +801,14 @@ function pullCandidateArtifact({ artifact, registry, sourceSha, workspace, docke
|
|||||||
const taggedImage = `${registry}/${artifact.image}:sha-${sourceSha}`;
|
const taggedImage = `${registry}/${artifact.image}:sha-${sourceSha}`;
|
||||||
run("docker", ["pull", taggedImage], { cwd: workspace, env: dockerEnv });
|
run("docker", ["pull", taggedImage], { cwd: workspace, env: dockerEnv });
|
||||||
verifyRequiredContainerFiles(taggedImage, artifact.requiredFiles, { cwd: workspace, env: dockerEnv });
|
verifyRequiredContainerFiles(taggedImage, artifact.requiredFiles, { cwd: workspace, env: dockerEnv });
|
||||||
|
const revision = run(
|
||||||
|
"docker",
|
||||||
|
["image", "inspect", "--format", '{{ index .Config.Labels "org.opencontainers.image.revision" }}', taggedImage],
|
||||||
|
{ cwd: workspace, env: dockerEnv, capture: true },
|
||||||
|
).trim();
|
||||||
|
if (revision !== sourceSha) {
|
||||||
|
throw new Error(`Candidate ${taggedImage} has revision ${revision || "<missing>"}, expected ${sourceSha}.`);
|
||||||
|
}
|
||||||
const repoDigests = run(
|
const repoDigests = run(
|
||||||
"docker",
|
"docker",
|
||||||
["image", "inspect", "--format", "{{range .RepoDigests}}{{println .}}{{end}}", taggedImage],
|
["image", "inspect", "--format", "{{range .RepoDigests}}{{println .}}{{end}}", taggedImage],
|
||||||
@@ -753,7 +877,11 @@ async function deployApplications({ applications, artifacts, sha, client }) {
|
|||||||
await Promise.all(
|
await Promise.all(
|
||||||
wave.map(async (application) => {
|
wave.map(async (application) => {
|
||||||
const artifact = artifacts.get(application.artifact);
|
const artifact = artifacts.get(application.artifact);
|
||||||
|
const inheritedEnvironment = application.environmentFromAppId
|
||||||
|
? parseEnvironment((await client.getApp(application.environmentFromAppId)).envVars)
|
||||||
|
: {};
|
||||||
const environment = {
|
const environment = {
|
||||||
|
...inheritedEnvironment,
|
||||||
...Object.fromEntries(
|
...Object.fromEntries(
|
||||||
Object.entries(application.environment ?? {}).map(([key, value]) => [key, expandTokens(value, { sha })]),
|
Object.entries(application.environment ?? {}).map(([key, value]) => [key, expandTokens(value, { sha })]),
|
||||||
),
|
),
|
||||||
@@ -770,6 +898,7 @@ async function deployApplications({ applications, artifacts, sha, client }) {
|
|||||||
postflight: application.postflight,
|
postflight: application.postflight,
|
||||||
deploymentAttempts: application.deploymentAttempts,
|
deploymentAttempts: application.deploymentAttempts,
|
||||||
deploymentRetrySeconds: application.deploymentRetrySeconds,
|
deploymentRetrySeconds: application.deploymentRetrySeconds,
|
||||||
|
volumeMountPaths: application.volumeMountPaths,
|
||||||
sha,
|
sha,
|
||||||
});
|
});
|
||||||
console.log(`Deployed ${application.name}: ${result.image}`);
|
console.log(`Deployed ${application.name}: ${result.image}`);
|
||||||
@@ -793,7 +922,14 @@ async function executeVersion2({
|
|||||||
artifactIndex = 0,
|
artifactIndex = 0,
|
||||||
}) {
|
}) {
|
||||||
const commitMessage = run("git", ["log", "-1", "--pretty=%B"], { cwd: workspace, capture: true });
|
const commitMessage = run("git", ["log", "-1", "--pretty=%B"], { cwd: workspace, capture: true });
|
||||||
const validationOnly = eventName === "pull_request";
|
const sameRepositoryPullRequest = isSameRepositoryPullRequest({
|
||||||
|
eventName,
|
||||||
|
environment: process.env,
|
||||||
|
});
|
||||||
|
const validationOnly = isValidationOnlyCandidateBuild({
|
||||||
|
eventName,
|
||||||
|
sameRepositoryPullRequest,
|
||||||
|
});
|
||||||
const skipMarker = pipeline.strategy === "promote"
|
const skipMarker = pipeline.strategy === "promote"
|
||||||
? String(pipeline.release?.skipMarker ?? "[skip prod-release]")
|
? String(pipeline.release?.skipMarker ?? "[skip prod-release]")
|
||||||
: "[skip quickstack-deploy]";
|
: "[skip quickstack-deploy]";
|
||||||
@@ -808,6 +944,12 @@ async function executeVersion2({
|
|||||||
const useRegistryCache =
|
const useRegistryCache =
|
||||||
execution !== "build-validation" ||
|
execution !== "build-validation" ||
|
||||||
isSameRepositoryPullRequest({ eventName, environment: process.env });
|
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 {
|
try {
|
||||||
const needsRegistryLogin =
|
const needsRegistryLogin =
|
||||||
!(actionMode === "coordinate" && execution === "build-validation") &&
|
!(actionMode === "coordinate" && execution === "build-validation") &&
|
||||||
@@ -827,15 +969,18 @@ async function executeVersion2({
|
|||||||
const built = buildArtifact({
|
const built = buildArtifact({
|
||||||
artifact,
|
artifact,
|
||||||
registry,
|
registry,
|
||||||
sha,
|
sha: candidateSha,
|
||||||
workspace,
|
workspace,
|
||||||
dockerEnv,
|
dockerEnv,
|
||||||
validationOnly: execution === "build-validation",
|
validationOnly,
|
||||||
useRegistryCache,
|
useRegistryCache,
|
||||||
|
reuseExistingCandidate: !validationOnly,
|
||||||
});
|
});
|
||||||
appendSummary(
|
appendSummary(
|
||||||
execution === "build-validation"
|
validationOnly
|
||||||
? `Validated OCI artifact \`${artifact.name}\` for \`${pipeline.branch}\`.`
|
? `Validated OCI artifact \`${artifact.name}\` for \`${pipeline.branch}\`.`
|
||||||
|
: execution === "build-validation"
|
||||||
|
? `Built reusable pull-request candidate \`${artifact.name}\`: \`${built.exactImage}\`.`
|
||||||
: `Built OCI artifact \`${artifact.name}\`: \`${built.exactImage}\`.`,
|
: `Built OCI artifact \`${artifact.name}\`: \`${built.exactImage}\`.`,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -852,7 +997,7 @@ async function executeVersion2({
|
|||||||
artifacts.set(artifact.name, pullCandidateArtifact({
|
artifacts.set(artifact.name, pullCandidateArtifact({
|
||||||
artifact,
|
artifact,
|
||||||
registry,
|
registry,
|
||||||
sourceSha: sha,
|
sourceSha: candidateSha,
|
||||||
workspace,
|
workspace,
|
||||||
dockerEnv,
|
dockerEnv,
|
||||||
}));
|
}));
|
||||||
@@ -861,7 +1006,7 @@ async function executeVersion2({
|
|||||||
baseUrl: process.env.QUICKSTACK_BASE_URL,
|
baseUrl: process.env.QUICKSTACK_BASE_URL,
|
||||||
token: process.env.QUICKSTACK_API_TOKEN,
|
token: process.env.QUICKSTACK_API_TOKEN,
|
||||||
});
|
});
|
||||||
await deployApplications({ applications: pipeline.applications, artifacts, sha, client });
|
await deployApplications({ applications: pipeline.applications, artifacts, sha: candidateSha, client });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -871,44 +1016,57 @@ async function executeVersion2({
|
|||||||
artifacts.set(artifact.name, buildArtifact({
|
artifacts.set(artifact.name, buildArtifact({
|
||||||
artifact,
|
artifact,
|
||||||
registry,
|
registry,
|
||||||
sha,
|
sha: candidateSha,
|
||||||
workspace,
|
workspace,
|
||||||
dockerEnv,
|
dockerEnv,
|
||||||
validationOnly: execution === "build-validation",
|
validationOnly,
|
||||||
useRegistryCache,
|
useRegistryCache,
|
||||||
|
reuseExistingCandidate: !validationOnly,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
if (execution === "build-validation") {
|
if (execution === "build-validation") {
|
||||||
appendSummary(`Validated ${artifacts.size} immutable OCI artifact(s) for ${pipeline.branch}.`);
|
appendSummary(
|
||||||
|
validationOnly
|
||||||
|
? `Validated ${artifacts.size} immutable OCI artifact(s) for ${pipeline.branch}.`
|
||||||
|
: `Published ${artifacts.size} reusable pull-request candidate artifact(s) for ${pipeline.branch}.`,
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const client = createQuickStackClient({
|
const client = createQuickStackClient({
|
||||||
baseUrl: process.env.QUICKSTACK_BASE_URL,
|
baseUrl: process.env.QUICKSTACK_BASE_URL,
|
||||||
token: process.env.QUICKSTACK_API_TOKEN,
|
token: process.env.QUICKSTACK_API_TOKEN,
|
||||||
});
|
});
|
||||||
await deployApplications({ applications: pipeline.applications, artifacts, sha, client });
|
await deployApplications({ applications: pipeline.applications, artifacts, sha: candidateSha, client });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (execution === "validate-candidate") {
|
if (execution === "validate-candidate") {
|
||||||
const sourceBranch = validatePromotionPullRequestSource(pipeline, resolvePullRequestHeadBranch());
|
const sourceBranch = validatePromotionPullRequestSource(pipeline, resolvePullRequestHeadBranch());
|
||||||
const release = loadRelease(workspace, pipeline.release);
|
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();
|
const artifacts = new Map();
|
||||||
for (const artifact of pipeline.artifacts) {
|
for (const artifact of pipeline.artifacts) {
|
||||||
artifacts.set(artifact.name, pullCandidateArtifact({
|
artifacts.set(artifact.name, pullCandidateArtifact({
|
||||||
artifact,
|
artifact,
|
||||||
registry,
|
registry,
|
||||||
sourceSha: sha,
|
sourceSha,
|
||||||
workspace,
|
workspace,
|
||||||
dockerEnv,
|
dockerEnv,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
console.log(`Validated ${release.tag} against ${artifacts.size} tested candidate artifact(s) from ${sourceBranch} at ${sha}; no rebuild or deployment performed.`);
|
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 \`${sha}\` from \`${sourceBranch}\` without rebuilding.`);
|
appendSummary(`Validated release ${release.tag} against tested candidate \`${sourceSha}\` from \`${sourceBranch}\` without rebuilding.`);
|
||||||
return;
|
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 release = loadRelease(workspace, pipeline.release);
|
||||||
const artifacts = new Map();
|
const artifacts = new Map();
|
||||||
for (const artifact of pipeline.artifacts) {
|
for (const artifact of pipeline.artifacts) {
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
import assert from "node:assert/strict";
|
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 test from "node:test";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
applyVolumeMountPaths,
|
||||||
classifyVersion2Execution,
|
classifyVersion2Execution,
|
||||||
deployExactImage,
|
deployExactImage,
|
||||||
expandTokens,
|
expandTokens,
|
||||||
mergeEnvironment,
|
mergeEnvironment,
|
||||||
|
isValidationOnlyCandidateBuild,
|
||||||
|
normalizeVolumeMountPaths,
|
||||||
|
parseEnvironment,
|
||||||
resolveActionMode,
|
resolveActionMode,
|
||||||
resolveArtifactIndex,
|
resolveArtifactIndex,
|
||||||
resolveBuildSecretArguments,
|
resolveBuildSecretArguments,
|
||||||
@@ -13,6 +21,7 @@ import {
|
|||||||
orderApplications,
|
orderApplications,
|
||||||
resolveDeploymentBranch,
|
resolveDeploymentBranch,
|
||||||
resolvePullRequestHeadBranch,
|
resolvePullRequestHeadBranch,
|
||||||
|
resolveTreeEquivalentCandidateSha,
|
||||||
selectDeployment,
|
selectDeployment,
|
||||||
selectPipeline,
|
selectPipeline,
|
||||||
toSavePayload,
|
toSavePayload,
|
||||||
@@ -102,6 +111,69 @@ test("production pull requests validate tested candidates without rebuilding", (
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("same-repository pull requests publish reusable candidates while forks stay validation-only", () => {
|
||||||
|
assert.equal(
|
||||||
|
isValidationOnlyCandidateBuild({
|
||||||
|
eventName: "pull_request",
|
||||||
|
sameRepositoryPullRequest: true,
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
isValidationOnlyCandidateBuild({
|
||||||
|
eventName: "pull_request",
|
||||||
|
sameRepositoryPullRequest: false,
|
||||||
|
}),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
isValidationOnlyCandidateBuild({
|
||||||
|
eventName: "push",
|
||||||
|
sameRepositoryPullRequest: false,
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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", () => {
|
test("validates split action modes and artifact build slots", () => {
|
||||||
assert.equal(resolveActionMode(undefined), "all");
|
assert.equal(resolveActionMode(undefined), "all");
|
||||||
assert.equal(resolveActionMode("BUILD"), "build");
|
assert.equal(resolveActionMode("BUILD"), "build");
|
||||||
@@ -162,6 +234,15 @@ test("version 2 validates promotion contracts and token expansion", () => {
|
|||||||
() => validateApplication({ name: "web", artifact: "missing", appId: "app" }, new Set(["known"])),
|
() => validateApplication({ name: "web", artifact: "missing", appId: "app" }, new Set(["known"])),
|
||||||
/unknown artifact/,
|
/unknown artifact/,
|
||||||
);
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => validateApplication({
|
||||||
|
name: "worker",
|
||||||
|
artifact: "known",
|
||||||
|
appId: "app-worker",
|
||||||
|
environmentFromAppId: "app-worker",
|
||||||
|
}, new Set(["known"])),
|
||||||
|
/cannot inherit environment from itself/,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("passes declared Actions secrets to Docker only through BuildKit secret mounts", () => {
|
test("passes declared Actions secrets to Docker only through BuildKit secret mounts", () => {
|
||||||
@@ -210,6 +291,26 @@ test("preserves response-only fields and existing environment secrets safely", (
|
|||||||
const payload = toSavePayload(app());
|
const payload = toSavePayload(app());
|
||||||
assert.equal(payload.createdAt, undefined);
|
assert.equal(payload.createdAt, undefined);
|
||||||
assert.equal(mergeEnvironment(app().envVars, { ENVIRONMENT: "new" }), "SECRET=preserved\nENVIRONMENT=new");
|
assert.equal(mergeEnvironment(app().envVars, { ENVIRONMENT: "new" }), "SECRET=preserved\nENVIRONMENT=new");
|
||||||
|
assert.deepEqual(parseEnvironment("SECRET=preserved\nAPP_DEPLOYMENT_ID=source\nENVIRONMENT=new\ninvalid line"), {
|
||||||
|
SECRET: "preserved",
|
||||||
|
ENVIRONMENT: "new",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("overrides only declared existing QuickStack volume mount paths", () => {
|
||||||
|
const volumes = [
|
||||||
|
{ id: "volume-local", sharedVolumeId: "shared-volume", containerMountPath: "/old" },
|
||||||
|
{ id: "volume-untouched", containerMountPath: "/data" },
|
||||||
|
];
|
||||||
|
assert.deepEqual(
|
||||||
|
applyVolumeMountPaths(volumes, { "shared-volume": "/mnt/as4" }),
|
||||||
|
[
|
||||||
|
{ id: "volume-local", sharedVolumeId: "shared-volume", containerMountPath: "/mnt/as4" },
|
||||||
|
{ id: "volume-untouched", containerMountPath: "/data" },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert.throws(() => applyVolumeMountPaths(volumes, { missing: "/mnt/data" }), /not found/);
|
||||||
|
assert.throws(() => normalizeVolumeMountPaths({ volume: "../data" }), /safe absolute path/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("maps only explicitly declared Actions secrets into runtime environment", () => {
|
test("maps only explicitly declared Actions secrets into runtime environment", () => {
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
name: QuickStack runner job image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths:
|
||||||
|
- runner-images/quickstack-job/**
|
||||||
|
- .gitea/workflows/runner-image.yml
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths:
|
||||||
|
- runner-images/quickstack-job/**
|
||||||
|
- .gitea/workflows/runner-image.yml
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: gitea.nuvisphere.de
|
||||||
|
IMAGE: gitea.nuvisphere.de/nuvisphere/quickstack-job
|
||||||
|
STABLE_TAG: node20-docker27
|
||||||
|
DOCKERFILE: runner-images/quickstack-job/Dockerfile
|
||||||
|
CONTEXT: runner-images/quickstack-job
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build and publish runner image
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Build image
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
docker buildx create --name quickstack-runner-image --use || docker buildx use quickstack-runner-image
|
||||||
|
docker buildx build \
|
||||||
|
--load \
|
||||||
|
--progress=plain \
|
||||||
|
--file "$DOCKERFILE" \
|
||||||
|
--tag "$IMAGE:$STABLE_TAG" \
|
||||||
|
"$CONTEXT"
|
||||||
|
|
||||||
|
- name: Verify image tools
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
docker run --rm "$IMAGE:$STABLE_TAG" bash -lc '
|
||||||
|
node --version
|
||||||
|
git --version
|
||||||
|
docker --version
|
||||||
|
docker buildx version
|
||||||
|
jq --version
|
||||||
|
curl --version
|
||||||
|
zstd --version
|
||||||
|
'
|
||||||
|
|
||||||
|
- name: Login to Gitea registry
|
||||||
|
if: ${{ gitea.event_name != 'pull_request' }}
|
||||||
|
env:
|
||||||
|
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||||
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
test -n "$REGISTRY_USERNAME"
|
||||||
|
test -n "$REGISTRY_TOKEN"
|
||||||
|
echo "$REGISTRY_TOKEN" | docker login "$REGISTRY" --username "$REGISTRY_USERNAME" --password-stdin
|
||||||
|
|
||||||
|
- name: Publish image
|
||||||
|
if: ${{ gitea.event_name != 'pull_request' }}
|
||||||
|
env:
|
||||||
|
COMMIT_SHA: ${{ gitea.sha }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
docker tag "$IMAGE:$STABLE_TAG" "$IMAGE:sha-$COMMIT_SHA"
|
||||||
|
docker push "$IMAGE:$STABLE_TAG"
|
||||||
|
docker push "$IMAGE:sha-$COMMIT_SHA"
|
||||||
@@ -68,6 +68,7 @@ Use manifest version 2 when one image serves multiple applications or Production
|
|||||||
"artifact": "web",
|
"artifact": "web",
|
||||||
"appId": "app-worker-staging",
|
"appId": "app-worker-staging",
|
||||||
"dependsOn": ["web"],
|
"dependsOn": ["web"],
|
||||||
|
"environmentFromAppId": "app-staging",
|
||||||
"environment": { "PROCESS": "worker" }
|
"environment": { "PROCESS": "worker" }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -96,7 +97,16 @@ an Actions secret exposed to the workflow environment. Values are passed to
|
|||||||
the command line as build arguments. The Dockerfile consumes them with
|
the command line as build arguments. The Dockerfile consumes them with
|
||||||
`RUN --mount=type=secret,id=<id>,required=true ...`.
|
`RUN --mount=type=secret,id=<id>,required=true ...`.
|
||||||
|
|
||||||
Candidate pipelines build every artifact once, push `sha-<commit>`, resolve the registry digest and deploy applications in dependency order. 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.
|
`environmentFromAppId` is optional and copies the existing QuickStack runtime
|
||||||
|
environment from another application before applying the target application's
|
||||||
|
declared `environment` and `secretEnvironment` overrides. This is intended for
|
||||||
|
workers that share backend credentials with an API without duplicating secret
|
||||||
|
values in Git or Actions. `volumeMountPaths` can map an existing QuickStack
|
||||||
|
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. 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
|
Scoped workflows can distribute version 2 artifact builds across independent
|
||||||
runner jobs by invoking the action with `mode: build` and a zero-based
|
runner jobs by invoking the action with `mode: build` and a zero-based
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
*
|
||||||
|
!Dockerfile
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
FROM docker:27.5.1-cli AS docker-cli
|
||||||
|
|
||||||
|
FROM docker.gitea.com/runner-images:ubuntu-latest-slim
|
||||||
|
|
||||||
|
USER root
|
||||||
|
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
|
COPY --from=docker-cli /usr/local/bin/docker /usr/local/bin/docker
|
||||||
|
COPY --from=docker-cli /usr/local/libexec/docker/cli-plugins/docker-buildx /usr/local/libexec/docker/cli-plugins/docker-buildx
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates \
|
||||||
|
curl \
|
||||||
|
git \
|
||||||
|
jq \
|
||||||
|
openssh-client \
|
||||||
|
zstd \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN node --version \
|
||||||
|
&& git --version \
|
||||||
|
&& docker --version \
|
||||||
|
&& docker buildx version \
|
||||||
|
&& jq --version \
|
||||||
|
&& zstd --version
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# QuickStack Job Runner Image
|
||||||
|
|
||||||
|
This image is the small, source-controlled job container used by the Nuvisphere
|
||||||
|
Gitea runners for `ubuntu-latest`, `ubuntu-24.04` and `ubuntu-22.04` jobs.
|
||||||
|
|
||||||
|
It replaces `docker.gitea.com/runner-images:ubuntu-latest` for QuickStack
|
||||||
|
workloads. The full upstream image is large and has caused slow or stuck pulls
|
||||||
|
on production runners. This image starts from Gitea's slim Node runner image
|
||||||
|
and adds only the tools required by the shared QuickStack OCI workflow:
|
||||||
|
|
||||||
|
- Docker CLI and Buildx
|
||||||
|
- Git
|
||||||
|
- curl
|
||||||
|
- jq
|
||||||
|
- OpenSSH client
|
||||||
|
- CA certificates
|
||||||
|
- zstd
|
||||||
|
|
||||||
|
The stable production tag is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
gitea.nuvisphere.de/nuvisphere/quickstack-job:node20-docker27
|
||||||
|
```
|
||||||
|
|
||||||
|
The publish workflow also pushes an immutable commit tag:
|
||||||
|
|
||||||
|
```text
|
||||||
|
gitea.nuvisphere.de/nuvisphere/quickstack-job:sha-<commit>
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user