Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9bc520ce64 | ||
|
|
7625cab0d3 | ||
|
|
c8bc0ff58d | ||
|
|
b6a550e9a3 | ||
|
|
3a9bd879e6 | ||
|
|
efc0421ac1 | ||
|
|
70b3375617 |
@@ -5,6 +5,14 @@ inputs:
|
|||||||
description: Path to the repository deployment manifest.
|
description: Path to the repository deployment manifest.
|
||||||
required: false
|
required: false
|
||||||
default: .quickstack/deploy.json
|
default: .quickstack/deploy.json
|
||||||
|
mode:
|
||||||
|
description: Execute the complete action, one artifact build slot or only the deployment coordinator.
|
||||||
|
required: false
|
||||||
|
default: all
|
||||||
|
artifact-index:
|
||||||
|
description: Zero-based artifact index used when mode is build.
|
||||||
|
required: false
|
||||||
|
default: "0"
|
||||||
runs:
|
runs:
|
||||||
using: composite
|
using: composite
|
||||||
steps:
|
steps:
|
||||||
@@ -12,4 +20,6 @@ runs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
QUICKSTACK_DEPLOY_CONFIG: ${{ inputs.config-path }}
|
QUICKSTACK_DEPLOY_CONFIG: ${{ inputs.config-path }}
|
||||||
|
QUICKSTACK_ACTION_MODE: ${{ inputs.mode }}
|
||||||
|
QUICKSTACK_ARTIFACT_INDEX: ${{ inputs.artifact-index }}
|
||||||
run: node "$GITHUB_ACTION_PATH/deploy.mjs"
|
run: node "$GITHUB_ACTION_PATH/deploy.mjs"
|
||||||
|
|||||||
@@ -87,6 +87,33 @@ 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 resolveSecretEnvironment(secretEnvironment = {}, environment = process.env) {
|
||||||
|
if (
|
||||||
|
secretEnvironment === null ||
|
||||||
|
typeof secretEnvironment !== "object" ||
|
||||||
|
Array.isArray(secretEnvironment)
|
||||||
|
) {
|
||||||
|
throw new Error("secretEnvironment must be an object.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolved = {};
|
||||||
|
for (const [runtimeKey, rawSecretName] of Object.entries(secretEnvironment)) {
|
||||||
|
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(runtimeKey)) {
|
||||||
|
throw new Error(`Invalid secret environment key ${runtimeKey}.`);
|
||||||
|
}
|
||||||
|
const secretName = String(rawSecretName);
|
||||||
|
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(secretName)) {
|
||||||
|
throw new Error(`Invalid Actions secret name for ${runtimeKey}.`);
|
||||||
|
}
|
||||||
|
const value = environment[secretName];
|
||||||
|
if (value === undefined || String(value).length === 0) {
|
||||||
|
throw new Error(`Missing Actions secret ${secretName} for runtime environment ${runtimeKey}.`);
|
||||||
|
}
|
||||||
|
resolved[runtimeKey] = String(value);
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
async function readBody(response) {
|
async function readBody(response) {
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
if (!text) return null;
|
if (!text) return null;
|
||||||
@@ -347,6 +374,32 @@ function validateImagePath(value, name) {
|
|||||||
return image;
|
return image;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeBuildSecrets(buildSecrets = {}) {
|
||||||
|
if (buildSecrets === null || typeof buildSecrets !== "object" || Array.isArray(buildSecrets)) {
|
||||||
|
throw new Error("buildSecrets must be an object.");
|
||||||
|
}
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(buildSecrets).map(([id, rawEnvironmentName]) => {
|
||||||
|
if (!/^[A-Za-z0-9_.-]+$/.test(id)) throw new Error(`Invalid BuildKit secret ID ${id}.`);
|
||||||
|
const environmentName = String(rawEnvironmentName);
|
||||||
|
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(environmentName)) {
|
||||||
|
throw new Error(`Invalid Actions secret environment name for BuildKit secret ${id}.`);
|
||||||
|
}
|
||||||
|
return [id, environmentName];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveBuildSecretArguments(buildSecrets = {}, environment = process.env) {
|
||||||
|
return Object.entries(normalizeBuildSecrets(buildSecrets)).flatMap(([id, environmentName]) => {
|
||||||
|
const value = environment[environmentName];
|
||||||
|
if (value === undefined || String(value).length === 0) {
|
||||||
|
throw new Error(`Missing Actions secret ${environmentName} for BuildKit secret ${id}.`);
|
||||||
|
}
|
||||||
|
return ["--secret", `id=${id},env=${environmentName}`];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function validateArtifact(artifact) {
|
export function validateArtifact(artifact) {
|
||||||
const name = requiredString(artifact.name, "Artifact name");
|
const name = requiredString(artifact.name, "Artifact name");
|
||||||
const requiredFiles = (artifact.requiredFiles ?? []).map((file) => {
|
const requiredFiles = (artifact.requiredFiles ?? []).map((file) => {
|
||||||
@@ -369,6 +422,7 @@ export function validateArtifact(artifact) {
|
|||||||
dockerfile: safeRelative(artifact.dockerfile ?? "Dockerfile", `Dockerfile for ${name}`),
|
dockerfile: safeRelative(artifact.dockerfile ?? "Dockerfile", `Dockerfile for ${name}`),
|
||||||
context: safeRelative(artifact.context ?? ".", `Build context for ${name}`),
|
context: safeRelative(artifact.context ?? ".", `Build context for ${name}`),
|
||||||
buildArgs,
|
buildArgs,
|
||||||
|
buildSecrets: normalizeBuildSecrets(artifact.buildSecrets),
|
||||||
requiredFiles,
|
requiredFiles,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -487,6 +541,27 @@ export function resolvePullRequestHeadBranch({ environment = process.env, eventP
|
|||||||
return requiredString(payload?.pull_request?.head?.ref, "Pull request head branch");
|
return requiredString(payload?.pull_request?.head?.ref, "Pull request head branch");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isSameRepositoryPullRequest({ eventName, environment = process.env, eventPayload } = {}) {
|
||||||
|
if (eventName !== "pull_request") return false;
|
||||||
|
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const baseRepository = String(
|
||||||
|
environment.GITHUB_REPOSITORY ||
|
||||||
|
environment.GITEA_REPOSITORY ||
|
||||||
|
payload?.repository?.full_name ||
|
||||||
|
payload?.pull_request?.base?.repo?.full_name ||
|
||||||
|
"",
|
||||||
|
).trim();
|
||||||
|
const headRepository = String(payload?.pull_request?.head?.repo?.full_name || "").trim();
|
||||||
|
return Boolean(baseRepository && headRepository && baseRepository === headRepository);
|
||||||
|
}
|
||||||
|
|
||||||
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";
|
||||||
@@ -494,6 +569,22 @@ export function classifyVersion2Execution(pipeline, eventName) {
|
|||||||
return pipeline.strategy === "candidate" ? "build-deploy" : "promote";
|
return pipeline.strategy === "candidate" ? "build-deploy" : "promote";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveActionMode(value = "all") {
|
||||||
|
const mode = String(value || "all").trim().toLowerCase();
|
||||||
|
if (!new Set(["all", "build", "coordinate"]).has(mode)) {
|
||||||
|
throw new Error(`Unsupported QuickStack action mode ${mode}.`);
|
||||||
|
}
|
||||||
|
return mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveArtifactIndex(value = "0") {
|
||||||
|
const index = Number(value);
|
||||||
|
if (!Number.isInteger(index) || index < 0) {
|
||||||
|
throw new Error("QuickStack artifact index must be a non-negative integer.");
|
||||||
|
}
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
export function validatePromotionPullRequestSource(pipeline, headBranch) {
|
export function validatePromotionPullRequestSource(pipeline, headBranch) {
|
||||||
const sourceBranch = requiredString(pipeline.source?.branch, "Promotion source branch");
|
const sourceBranch = requiredString(pipeline.source?.branch, "Promotion source branch");
|
||||||
const actualHeadBranch = requiredString(headBranch, "Pull request head branch");
|
const actualHeadBranch = requiredString(headBranch, "Pull request head branch");
|
||||||
@@ -516,6 +607,24 @@ function dockerLogin(registry, dockerEnv) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ensureBuildxBuilder(dockerEnv) {
|
||||||
|
const builder = "quickstack-registry-cache";
|
||||||
|
const existing = spawnSync(
|
||||||
|
"docker",
|
||||||
|
["buildx", "inspect", builder],
|
||||||
|
{ env: dockerEnv, encoding: "utf8", stdio: "ignore" },
|
||||||
|
);
|
||||||
|
if (existing.status !== 0) {
|
||||||
|
run(
|
||||||
|
"docker",
|
||||||
|
["buildx", "create", "--name", builder, "--driver", "docker-container"],
|
||||||
|
{ env: dockerEnv },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
run("docker", ["buildx", "inspect", "--bootstrap", builder], { env: dockerEnv });
|
||||||
|
return builder;
|
||||||
|
}
|
||||||
|
|
||||||
function exactDigestFromPush(pushOutput, taggedImage) {
|
function exactDigestFromPush(pushOutput, taggedImage) {
|
||||||
const digest = [...pushOutput.matchAll(/digest:\s*(sha256:[0-9a-f]{64})/g)].at(-1)?.[1];
|
const digest = [...pushOutput.matchAll(/digest:\s*(sha256:[0-9a-f]{64})/g)].at(-1)?.[1];
|
||||||
if (!digest) throw new Error(`Registry did not return a digest for ${taggedImage}.`);
|
if (!digest) throw new Error(`Registry did not return a digest for ${taggedImage}.`);
|
||||||
@@ -528,10 +637,20 @@ function verifyRequiredContainerFiles(taggedImage, requiredFiles, options) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildArtifact({ artifact, registry, sha, workspace, dockerEnv, validationOnly }) {
|
function buildArtifact({
|
||||||
|
artifact,
|
||||||
|
registry,
|
||||||
|
sha,
|
||||||
|
workspace,
|
||||||
|
dockerEnv,
|
||||||
|
validationOnly,
|
||||||
|
useRegistryCache = !validationOnly,
|
||||||
|
}) {
|
||||||
const taggedImage = `${registry}/${artifact.image}:sha-${sha}`;
|
const taggedImage = `${registry}/${artifact.image}:sha-${sha}`;
|
||||||
|
const cacheImage = `${registry}/${artifact.image}:buildcache`;
|
||||||
|
const builder = ensureBuildxBuilder(dockerEnv);
|
||||||
const buildArgs = [
|
const buildArgs = [
|
||||||
"build", "--progress=plain", "--file", artifact.dockerfile,
|
"buildx", "build", "--builder", builder, "--load", "--progress=plain", "--file", artifact.dockerfile,
|
||||||
"--label", `org.opencontainers.image.source=${process.env.GITHUB_SERVER_URL ?? process.env.GITEA_SERVER_URL}/${process.env.GITHUB_REPOSITORY ?? process.env.GITEA_REPOSITORY}`,
|
"--label", `org.opencontainers.image.source=${process.env.GITHUB_SERVER_URL ?? process.env.GITEA_SERVER_URL}/${process.env.GITHUB_REPOSITORY ?? process.env.GITEA_REPOSITORY}`,
|
||||||
"--label", `org.opencontainers.image.revision=${sha}`,
|
"--label", `org.opencontainers.image.revision=${sha}`,
|
||||||
"--tag", taggedImage,
|
"--tag", taggedImage,
|
||||||
@@ -539,6 +658,18 @@ function buildArtifact({ artifact, registry, sha, workspace, dockerEnv, validati
|
|||||||
for (const [key, value] of Object.entries(artifact.buildArgs)) {
|
for (const [key, value] of Object.entries(artifact.buildArgs)) {
|
||||||
buildArgs.push("--build-arg", `${key}=${expandTokens(value, { sha })}`);
|
buildArgs.push("--build-arg", `${key}=${expandTokens(value, { sha })}`);
|
||||||
}
|
}
|
||||||
|
buildArgs.push(...resolveBuildSecretArguments(artifact.buildSecrets, dockerEnv));
|
||||||
|
if (useRegistryCache) {
|
||||||
|
buildArgs.push("--cache-from", `type=registry,ref=${cacheImage}`);
|
||||||
|
if (!validationOnly) {
|
||||||
|
buildArgs.push(
|
||||||
|
"--cache-to", `type=registry,ref=${cacheImage},mode=max,ignore-error=true`,
|
||||||
|
);
|
||||||
|
console.log(`Using read-write max-mode registry build cache ${cacheImage}.`);
|
||||||
|
} else {
|
||||||
|
console.log(`Using read-only registry build cache ${cacheImage}.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
buildArgs.push(artifact.context);
|
buildArgs.push(artifact.context);
|
||||||
console.log(`Building ${artifact.name} from ${artifact.dockerfile} as ${taggedImage}.`);
|
console.log(`Building ${artifact.name} from ${artifact.dockerfile} as ${taggedImage}.`);
|
||||||
run("docker", buildArgs, { cwd: workspace, env: dockerEnv });
|
run("docker", buildArgs, { cwd: workspace, env: dockerEnv });
|
||||||
@@ -603,30 +734,64 @@ function promoteArtifactAliases(artifacts, releaseTag, options) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function deployApplications({ applications, artifacts, sha, client }) {
|
async function deployApplications({ applications, artifacts, sha, client }) {
|
||||||
for (const application of applications) {
|
const pending = new Map(applications.map((application) => [application.name, application]));
|
||||||
const artifact = artifacts.get(application.artifact);
|
const completed = new Set();
|
||||||
const environment = Object.fromEntries(
|
|
||||||
Object.entries(application.environment ?? {}).map(([key, value]) => [key, expandTokens(value, { sha })]),
|
while (pending.size > 0) {
|
||||||
|
const wave = applications.filter(
|
||||||
|
(application) =>
|
||||||
|
pending.has(application.name) &&
|
||||||
|
application.dependsOn.every((dependency) => completed.has(dependency)),
|
||||||
);
|
);
|
||||||
const result = await deployExactImage({
|
if (wave.length === 0) {
|
||||||
client,
|
throw new Error(
|
||||||
appId: application.appId,
|
`Unable to resolve deployment dependencies for: ${[...pending.keys()].join(", ")}`,
|
||||||
image: artifact.exactImage,
|
);
|
||||||
registryUsername: process.env.REGISTRY_USERNAME,
|
}
|
||||||
registryToken: process.env.REGISTRY_TOKEN,
|
|
||||||
environment,
|
console.log(`Deploying application wave: ${wave.map((application) => application.name).join(", ")}.`);
|
||||||
healthCheckTcpPort: application.healthCheckTcpPort,
|
await Promise.all(
|
||||||
postflight: application.postflight,
|
wave.map(async (application) => {
|
||||||
deploymentAttempts: application.deploymentAttempts,
|
const artifact = artifacts.get(application.artifact);
|
||||||
deploymentRetrySeconds: application.deploymentRetrySeconds,
|
const environment = {
|
||||||
sha,
|
...Object.fromEntries(
|
||||||
});
|
Object.entries(application.environment ?? {}).map(([key, value]) => [key, expandTokens(value, { sha })]),
|
||||||
console.log(`Deployed ${application.name}: ${result.image}`);
|
),
|
||||||
appendSummary(`- ${application.name}: \`${result.image}\` (${result.status})`);
|
...resolveSecretEnvironment(application.secretEnvironment),
|
||||||
|
};
|
||||||
|
const result = await deployExactImage({
|
||||||
|
client,
|
||||||
|
appId: application.appId,
|
||||||
|
image: artifact.exactImage,
|
||||||
|
registryUsername: process.env.REGISTRY_USERNAME,
|
||||||
|
registryToken: process.env.REGISTRY_TOKEN,
|
||||||
|
environment,
|
||||||
|
healthCheckTcpPort: application.healthCheckTcpPort,
|
||||||
|
postflight: application.postflight,
|
||||||
|
deploymentAttempts: application.deploymentAttempts,
|
||||||
|
deploymentRetrySeconds: application.deploymentRetrySeconds,
|
||||||
|
sha,
|
||||||
|
});
|
||||||
|
console.log(`Deployed ${application.name}: ${result.image}`);
|
||||||
|
appendSummary(`- ${application.name}: \`${result.image}\` (${result.status})`);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
for (const application of wave) {
|
||||||
|
pending.delete(application.name);
|
||||||
|
completed.add(application.name);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function executeVersion2({ config, pipeline, eventName, sha, workspace }) {
|
async function executeVersion2({
|
||||||
|
config,
|
||||||
|
pipeline,
|
||||||
|
eventName,
|
||||||
|
sha,
|
||||||
|
workspace,
|
||||||
|
actionMode = "all",
|
||||||
|
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 validationOnly = eventName === "pull_request";
|
||||||
const skipMarker = pipeline.strategy === "promote"
|
const skipMarker = pipeline.strategy === "promote"
|
||||||
@@ -638,10 +803,68 @@ async function executeVersion2({ config, pipeline, eventName, sha, workspace })
|
|||||||
}
|
}
|
||||||
const registry = requiredString(config.registry ?? "gitea.nuvisphere.de", "OCI registry").replace(/\/$/, "");
|
const registry = requiredString(config.registry ?? "gitea.nuvisphere.de", "OCI registry").replace(/\/$/, "");
|
||||||
const dockerConfig = fs.mkdtempSync(path.join(os.tmpdir(), "quickstack-docker-"));
|
const dockerConfig = fs.mkdtempSync(path.join(os.tmpdir(), "quickstack-docker-"));
|
||||||
const dockerEnv = { ...process.env, DOCKER_CONFIG: dockerConfig };
|
const dockerEnv = { ...process.env, DOCKER_CONFIG: dockerConfig, DOCKER_BUILDKIT: "1" };
|
||||||
const execution = classifyVersion2Execution(pipeline, eventName);
|
const execution = classifyVersion2Execution(pipeline, eventName);
|
||||||
|
const useRegistryCache =
|
||||||
|
execution !== "build-validation" ||
|
||||||
|
isSameRepositoryPullRequest({ eventName, environment: process.env });
|
||||||
try {
|
try {
|
||||||
if (execution !== "build-validation") dockerLogin(registry, dockerEnv);
|
const needsRegistryLogin =
|
||||||
|
!(actionMode === "coordinate" && execution === "build-validation") &&
|
||||||
|
(execution !== "build-validation" || useRegistryCache);
|
||||||
|
if (needsRegistryLogin) dockerLogin(registry, dockerEnv);
|
||||||
|
|
||||||
|
if (actionMode === "build") {
|
||||||
|
if (execution !== "build-validation" && execution !== "build-deploy") {
|
||||||
|
console.log(`Artifact build slot ${artifactIndex} is not needed for ${execution}.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const artifact = pipeline.artifacts[artifactIndex];
|
||||||
|
if (!artifact) {
|
||||||
|
console.log(`Artifact build slot ${artifactIndex} is unused for ${pipeline.name ?? pipeline.branch}.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const built = buildArtifact({
|
||||||
|
artifact,
|
||||||
|
registry,
|
||||||
|
sha,
|
||||||
|
workspace,
|
||||||
|
dockerEnv,
|
||||||
|
validationOnly: execution === "build-validation",
|
||||||
|
useRegistryCache,
|
||||||
|
});
|
||||||
|
appendSummary(
|
||||||
|
execution === "build-validation"
|
||||||
|
? `Validated OCI artifact \`${artifact.name}\` for \`${pipeline.branch}\`.`
|
||||||
|
: `Built OCI artifact \`${artifact.name}\`: \`${built.exactImage}\`.`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actionMode === "coordinate" && execution === "build-validation") {
|
||||||
|
appendSummary(`Validated ${pipeline.artifacts.length} immutable OCI artifact build(s) for \`${pipeline.branch}\`.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actionMode === "coordinate" && execution === "build-deploy") {
|
||||||
|
const artifacts = new Map();
|
||||||
|
for (const artifact of pipeline.artifacts) {
|
||||||
|
artifacts.set(artifact.name, pullCandidateArtifact({
|
||||||
|
artifact,
|
||||||
|
registry,
|
||||||
|
sourceSha: sha,
|
||||||
|
workspace,
|
||||||
|
dockerEnv,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
const client = createQuickStackClient({
|
||||||
|
baseUrl: process.env.QUICKSTACK_BASE_URL,
|
||||||
|
token: process.env.QUICKSTACK_API_TOKEN,
|
||||||
|
});
|
||||||
|
await deployApplications({ applications: pipeline.applications, artifacts, sha, client });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (execution === "build-validation" || execution === "build-deploy") {
|
if (execution === "build-validation" || execution === "build-deploy") {
|
||||||
const artifacts = new Map();
|
const artifacts = new Map();
|
||||||
for (const artifact of pipeline.artifacts) {
|
for (const artifact of pipeline.artifacts) {
|
||||||
@@ -652,6 +875,7 @@ async function executeVersion2({ config, pipeline, eventName, sha, workspace })
|
|||||||
workspace,
|
workspace,
|
||||||
dockerEnv,
|
dockerEnv,
|
||||||
validationOnly: execution === "build-validation",
|
validationOnly: execution === "build-validation",
|
||||||
|
useRegistryCache,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
if (execution === "build-validation") {
|
if (execution === "build-validation") {
|
||||||
@@ -731,6 +955,8 @@ async function main() {
|
|||||||
const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
||||||
const eventName = process.env.GITHUB_EVENT_NAME ?? process.env.GITEA_EVENT_NAME ?? "";
|
const eventName = process.env.GITHUB_EVENT_NAME ?? process.env.GITEA_EVENT_NAME ?? "";
|
||||||
const branch = resolveDeploymentBranch({ eventName });
|
const branch = resolveDeploymentBranch({ eventName });
|
||||||
|
const actionMode = resolveActionMode(process.env.QUICKSTACK_ACTION_MODE);
|
||||||
|
const artifactIndex = resolveArtifactIndex(process.env.QUICKSTACK_ARTIFACT_INDEX);
|
||||||
if (config.version === 2) {
|
if (config.version === 2) {
|
||||||
const pipeline = validateVersion2Pipeline(config, branch);
|
const pipeline = validateVersion2Pipeline(config, branch);
|
||||||
if (!pipeline) {
|
if (!pipeline) {
|
||||||
@@ -739,7 +965,19 @@ async function main() {
|
|||||||
}
|
}
|
||||||
const sha = requiredString(process.env.GITHUB_SHA ?? process.env.GITEA_SHA, "Git SHA").toLowerCase();
|
const sha = requiredString(process.env.GITHUB_SHA ?? process.env.GITEA_SHA, "Git SHA").toLowerCase();
|
||||||
if (!/^[0-9a-f]{40,64}$/.test(sha)) throw new Error("Git SHA must be a full hexadecimal commit ID.");
|
if (!/^[0-9a-f]{40,64}$/.test(sha)) throw new Error("Git SHA must be a full hexadecimal commit ID.");
|
||||||
await executeVersion2({ config, pipeline, eventName, sha, workspace });
|
await executeVersion2({
|
||||||
|
config,
|
||||||
|
pipeline,
|
||||||
|
eventName,
|
||||||
|
sha,
|
||||||
|
workspace,
|
||||||
|
actionMode,
|
||||||
|
artifactIndex,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (actionMode === "build") {
|
||||||
|
console.log("Manifest version 1 remains on the coordinator's serial compatibility path.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const deployment = selectDeployment(config, branch);
|
const deployment = selectDeployment(config, branch);
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import {
|
|||||||
deployExactImage,
|
deployExactImage,
|
||||||
expandTokens,
|
expandTokens,
|
||||||
mergeEnvironment,
|
mergeEnvironment,
|
||||||
|
resolveActionMode,
|
||||||
|
resolveArtifactIndex,
|
||||||
|
resolveBuildSecretArguments,
|
||||||
|
resolveSecretEnvironment,
|
||||||
orderApplications,
|
orderApplications,
|
||||||
resolveDeploymentBranch,
|
resolveDeploymentBranch,
|
||||||
resolvePullRequestHeadBranch,
|
resolvePullRequestHeadBranch,
|
||||||
@@ -98,6 +102,16 @@ test("production pull requests validate tested candidates without rebuilding", (
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("validates split action modes and artifact build slots", () => {
|
||||||
|
assert.equal(resolveActionMode(undefined), "all");
|
||||||
|
assert.equal(resolveActionMode("BUILD"), "build");
|
||||||
|
assert.equal(resolveActionMode("coordinate"), "coordinate");
|
||||||
|
assert.throws(() => resolveActionMode("parallel"), /Unsupported QuickStack action mode/);
|
||||||
|
assert.equal(resolveArtifactIndex("2"), 2);
|
||||||
|
assert.throws(() => resolveArtifactIndex("-1"), /non-negative integer/);
|
||||||
|
assert.throws(() => resolveArtifactIndex("1.5"), /non-negative integer/);
|
||||||
|
});
|
||||||
|
|
||||||
test("validates OCI paths and repository-local build paths", () => {
|
test("validates OCI paths and repository-local build paths", () => {
|
||||||
assert.equal(validateTarget({ name: "Web", image: "Owner/Web", appId: "app-1" }).image, "owner/web");
|
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/);
|
assert.throws(() => validateTarget({ name: "Web", image: "owner/web", appId: "app-1", context: "../secret" }), /inside/);
|
||||||
@@ -150,6 +164,34 @@ test("version 2 validates promotion contracts and token expansion", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("passes declared Actions secrets to Docker only through BuildKit secret mounts", () => {
|
||||||
|
const secretValue = "must-not-appear-in-docker-arguments";
|
||||||
|
const artifact = validateArtifact({
|
||||||
|
name: "web",
|
||||||
|
image: "owner/web",
|
||||||
|
buildSecrets: {
|
||||||
|
"next-server-actions-encryption-key": "NEXT_SERVER_ACTIONS_ENCRYPTION_KEY",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const args = resolveBuildSecretArguments(artifact.buildSecrets, {
|
||||||
|
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: secretValue,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(args, [
|
||||||
|
"--secret",
|
||||||
|
"id=next-server-actions-encryption-key,env=NEXT_SERVER_ACTIONS_ENCRYPTION_KEY",
|
||||||
|
]);
|
||||||
|
assert.doesNotMatch(JSON.stringify(args), new RegExp(secretValue));
|
||||||
|
assert.throws(
|
||||||
|
() => resolveBuildSecretArguments(artifact.buildSecrets, {}),
|
||||||
|
/Missing Actions secret NEXT_SERVER_ACTIONS_ENCRYPTION_KEY/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => validateArtifact({ name: "web", image: "owner/web", buildSecrets: { "../invalid": "SECRET" } }),
|
||||||
|
/Invalid BuildKit secret ID/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("application dependencies reject missing nodes and cycles", () => {
|
test("application dependencies reject missing nodes and cycles", () => {
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() => orderApplications([{ name: "web", dependsOn: ["missing"] }]),
|
() => orderApplications([{ name: "web", dependsOn: ["missing"] }]),
|
||||||
@@ -170,6 +212,24 @@ test("preserves response-only fields and existing environment secrets safely", (
|
|||||||
assert.equal(mergeEnvironment(app().envVars, { ENVIRONMENT: "new" }), "SECRET=preserved\nENVIRONMENT=new");
|
assert.equal(mergeEnvironment(app().envVars, { ENVIRONMENT: "new" }), "SECRET=preserved\nENVIRONMENT=new");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("maps only explicitly declared Actions secrets into runtime environment", () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
resolveSecretEnvironment(
|
||||||
|
{ MINIO_ENDPOINT: "MINIO_ENDPOINT", MINIO_REGION: "MINIO_REGION" },
|
||||||
|
{ MINIO_ENDPOINT: "https://minio.example.test", MINIO_REGION: "us-east-1" },
|
||||||
|
),
|
||||||
|
{ MINIO_ENDPOINT: "https://minio.example.test", MINIO_REGION: "us-east-1" },
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => resolveSecretEnvironment({ MINIO_ENDPOINT: "MINIO_ENDPOINT" }, {}),
|
||||||
|
/Missing Actions secret MINIO_ENDPOINT/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => resolveSecretEnvironment({ "INVALID-KEY": "MINIO_ENDPOINT" }, { MINIO_ENDPOINT: "value" }),
|
||||||
|
/Invalid secret environment key/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("postflight waits for the exact expected identity", async () => {
|
test("postflight waits for the exact expected identity", async () => {
|
||||||
let attempt = 0;
|
let attempt = 0;
|
||||||
const result = await verifyEndpoint(
|
const result = await verifyEndpoint(
|
||||||
|
|||||||
@@ -34,3 +34,19 @@ jobs:
|
|||||||
QUICKSTACK_API_TOKEN: ${{ secrets.QUICKSTACK_API_TOKEN }}
|
QUICKSTACK_API_TOKEN: ${{ secrets.QUICKSTACK_API_TOKEN }}
|
||||||
QUICKSTACK_BASE_URL: https://server.nuvisphere.de
|
QUICKSTACK_BASE_URL: https://server.nuvisphere.de
|
||||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
MINIO_ENDPOINT: ${{ secrets.MINIO_ENDPOINT }}
|
||||||
|
NEXT_PUBLIC_MINIO_ENDPOINT: ${{ secrets.NEXT_PUBLIC_MINIO_ENDPOINT }}
|
||||||
|
MINIO_REGION: ${{ secrets.MINIO_REGION }}
|
||||||
|
MINIO_ROOT_USER: ${{ secrets.MINIO_ROOT_USER }}
|
||||||
|
MINIO_ROOT_PASSWORD: ${{ secrets.MINIO_ROOT_PASSWORD }}
|
||||||
|
MINIO_TLS_REJECT_UNAUTHORIZED: ${{ secrets.MINIO_TLS_REJECT_UNAUTHORIZED }}
|
||||||
|
MINIO_AVATAR_BUCKET: ${{ secrets.MINIO_AVATAR_BUCKET }}
|
||||||
|
REDIS_URL: ${{ secrets.REDIS_URL }}
|
||||||
|
WEB_PUSH_PUBLIC_KEY: ${{ secrets.WEB_PUSH_PUBLIC_KEY }}
|
||||||
|
WEB_PUSH_PRIVATE_KEY: ${{ secrets.WEB_PUSH_PRIVATE_KEY }}
|
||||||
|
WEB_PUSH_SUBJECT: ${{ secrets.WEB_PUSH_SUBJECT }}
|
||||||
|
SMTP_HOST: ${{ secrets.SMTP_HOST }}
|
||||||
|
SMTP_PORT: ${{ secrets.SMTP_PORT }}
|
||||||
|
SMTP_SECURE: ${{ secrets.SMTP_SECURE }}
|
||||||
|
SMTP_USER: ${{ secrets.SMTP_USER }}
|
||||||
|
SMTP_PASS: ${{ secrets.SMTP_PASS }}
|
||||||
|
|||||||
@@ -50,6 +50,9 @@ Use manifest version 2 when one image serves multiple applications or Production
|
|||||||
"image": "example/web",
|
"image": "example/web",
|
||||||
"dockerfile": "Dockerfile",
|
"dockerfile": "Dockerfile",
|
||||||
"buildArgs": { "BUILD_SHA": "$sha12" },
|
"buildArgs": { "BUILD_SHA": "$sha12" },
|
||||||
|
"buildSecrets": {
|
||||||
|
"framework-build-key": "FRAMEWORK_BUILD_KEY"
|
||||||
|
},
|
||||||
"requiredFiles": ["/app/server.js"]
|
"requiredFiles": ["/app/server.js"]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -87,4 +90,18 @@ Use manifest version 2 when one image serves multiple applications or Production
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`buildSecrets` is optional and maps a BuildKit secret mount ID to the name of
|
||||||
|
an Actions secret exposed to the workflow environment. Values are passed to
|
||||||
|
`docker build` through `--secret id=...,env=...`; they are never included in
|
||||||
|
the command line as build arguments. The Dockerfile consumes them with
|
||||||
|
`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.
|
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.
|
||||||
|
|
||||||
|
Scoped workflows can distribute version 2 artifact builds across independent
|
||||||
|
runner jobs by invoking the action with `mode: build` and a zero-based
|
||||||
|
`artifact-index`. A final job invokes `mode: coordinate` after all build jobs.
|
||||||
|
Candidate tags are the synchronization boundary, so exact digests never depend
|
||||||
|
on matrix output merging. Published candidates also maintain a per-image
|
||||||
|
`buildcache` tag with inline BuildKit metadata for ephemeral runners. Version 1
|
||||||
|
manifests remain on the serial coordinator compatibility path.
|
||||||
|
|||||||
Reference in New Issue
Block a user