Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8bc0ff58d |
@@ -374,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) => {
|
||||||
@@ -396,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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -566,6 +593,7 @@ 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));
|
||||||
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 });
|
||||||
@@ -577,46 +605,11 @@ function buildArtifact({ artifact, registry, sha, workspace, dockerEnv, validati
|
|||||||
return { artifact, taggedImage, digest, exactImage: `${registry}/${artifact.image}@${digest}` };
|
return { artifact, taggedImage, digest, exactImage: `${registry}/${artifact.image}@${digest}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
const PROMOTION_PR_CANDIDATE_PULL_ATTEMPTS = 40;
|
function pullCandidateArtifact({ artifact, registry, sourceSha, workspace, dockerEnv }) {
|
||||||
const PROMOTION_PR_CANDIDATE_PULL_RETRY_MS = 10_000;
|
|
||||||
|
|
||||||
export async function pullCandidateArtifact({
|
|
||||||
artifact,
|
|
||||||
registry,
|
|
||||||
sourceSha,
|
|
||||||
workspace,
|
|
||||||
dockerEnv,
|
|
||||||
pullAttempts = 1,
|
|
||||||
pullRetryMs = 0,
|
|
||||||
runCommand = run,
|
|
||||||
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
||||||
}) {
|
|
||||||
const taggedImage = `${registry}/${artifact.image}:sha-${sourceSha}`;
|
const taggedImage = `${registry}/${artifact.image}:sha-${sourceSha}`;
|
||||||
if (!Number.isInteger(pullAttempts) || pullAttempts < 1) {
|
run("docker", ["pull", taggedImage], { cwd: workspace, env: dockerEnv });
|
||||||
throw new Error("Candidate pull attempts must be a positive integer.");
|
|
||||||
}
|
|
||||||
if (!Number.isFinite(pullRetryMs) || pullRetryMs < 0) {
|
|
||||||
throw new Error("Candidate pull retry delay must be a non-negative number.");
|
|
||||||
}
|
|
||||||
for (let attempt = 1; attempt <= pullAttempts; attempt += 1) {
|
|
||||||
try {
|
|
||||||
runCommand("docker", ["pull", taggedImage], { cwd: workspace, env: dockerEnv });
|
|
||||||
break;
|
|
||||||
} catch (error) {
|
|
||||||
if (attempt === pullAttempts) {
|
|
||||||
throw new Error(
|
|
||||||
`Candidate ${taggedImage} did not become available after ${pullAttempts} attempt(s).`,
|
|
||||||
{ cause: error },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
console.log(
|
|
||||||
`Candidate ${taggedImage} is not available yet (${attempt}/${pullAttempts}); retrying in ${pullRetryMs}ms.`,
|
|
||||||
);
|
|
||||||
await sleep(pullRetryMs);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
verifyRequiredContainerFiles(taggedImage, artifact.requiredFiles, { cwd: workspace, env: dockerEnv });
|
verifyRequiredContainerFiles(taggedImage, artifact.requiredFiles, { cwd: workspace, env: dockerEnv });
|
||||||
const repoDigests = runCommand(
|
const repoDigests = run(
|
||||||
"docker",
|
"docker",
|
||||||
["image", "inspect", "--format", "{{range .RepoDigests}}{{println .}}{{end}}", taggedImage],
|
["image", "inspect", "--format", "{{range .RepoDigests}}{{println .}}{{end}}", taggedImage],
|
||||||
{ cwd: workspace, env: dockerEnv, capture: true },
|
{ cwd: workspace, env: dockerEnv, capture: true },
|
||||||
@@ -736,14 +729,12 @@ async function executeVersion2({ config, pipeline, eventName, sha, workspace })
|
|||||||
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) {
|
||||||
artifacts.set(artifact.name, await pullCandidateArtifact({
|
artifacts.set(artifact.name, pullCandidateArtifact({
|
||||||
artifact,
|
artifact,
|
||||||
registry,
|
registry,
|
||||||
sourceSha: sha,
|
sourceSha: sha,
|
||||||
workspace,
|
workspace,
|
||||||
dockerEnv,
|
dockerEnv,
|
||||||
pullAttempts: PROMOTION_PR_CANDIDATE_PULL_ATTEMPTS,
|
|
||||||
pullRetryMs: PROMOTION_PR_CANDIDATE_PULL_RETRY_MS,
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
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 ${sha}; no rebuild or deployment performed.`);
|
||||||
@@ -755,7 +746,7 @@ async function executeVersion2({ config, pipeline, eventName, sha, workspace })
|
|||||||
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) {
|
||||||
artifacts.set(artifact.name, await pullCandidateArtifact({
|
artifacts.set(artifact.name, pullCandidateArtifact({
|
||||||
artifact,
|
artifact,
|
||||||
registry,
|
registry,
|
||||||
sourceSha,
|
sourceSha,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
deployExactImage,
|
deployExactImage,
|
||||||
expandTokens,
|
expandTokens,
|
||||||
mergeEnvironment,
|
mergeEnvironment,
|
||||||
pullCandidateArtifact,
|
resolveBuildSecretArguments,
|
||||||
resolveSecretEnvironment,
|
resolveSecretEnvironment,
|
||||||
orderApplications,
|
orderApplications,
|
||||||
resolveDeploymentBranch,
|
resolveDeploymentBranch,
|
||||||
@@ -100,46 +100,6 @@ test("production pull requests validate tested candidates without rebuilding", (
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("production pull requests wait for a concurrently published candidate", async () => {
|
|
||||||
const registry = "gitea.nuvisphere.de";
|
|
||||||
const artifact = {
|
|
||||||
name: "webapp",
|
|
||||||
image: "innomieter/webapp",
|
|
||||||
requiredFiles: [],
|
|
||||||
};
|
|
||||||
const sourceSha = "1234567890abcdef1234567890abcdef12345678";
|
|
||||||
const digest = `sha256:${"a".repeat(64)}`;
|
|
||||||
const sleeps = [];
|
|
||||||
let pullCalls = 0;
|
|
||||||
const runCommand = (_command, args) => {
|
|
||||||
if (args[0] === "pull") {
|
|
||||||
pullCalls += 1;
|
|
||||||
if (pullCalls < 3) throw new Error("manifest unknown");
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
if (args[0] === "image") {
|
|
||||||
return `${registry}/${artifact.image}@${digest}\n`;
|
|
||||||
}
|
|
||||||
throw new Error(`Unexpected Docker command: ${args.join(" ")}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await pullCandidateArtifact({
|
|
||||||
artifact,
|
|
||||||
registry,
|
|
||||||
sourceSha,
|
|
||||||
workspace: "/workspace",
|
|
||||||
dockerEnv: {},
|
|
||||||
pullAttempts: 3,
|
|
||||||
pullRetryMs: 25,
|
|
||||||
runCommand,
|
|
||||||
sleep: async (ms) => sleeps.push(ms),
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(pullCalls, 3);
|
|
||||||
assert.deepEqual(sleeps, [25, 25]);
|
|
||||||
assert.equal(result.exactImage, `${registry}/${artifact.image}@${digest}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
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/);
|
||||||
@@ -192,6 +152,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"] }]),
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ concurrency:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build_and_deploy:
|
build_and_deploy:
|
||||||
name: ${{ gitea.event_name == 'pull_request' && 'Validate existing candidate without deployment' || gitea.event_name == 'push' && 'Build, publish and deploy exact candidate' || 'Validate or redeploy declared pipeline' }}
|
name: Build once and deploy exact digest
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|||||||
@@ -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,9 +90,10 @@ Use manifest version 2 when one image serves multiple applications or Production
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
`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 ...`.
|
||||||
|
|
||||||
When a promotion pull request and its source-branch push start concurrently, the
|
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.
|
||||||
pull-request check waits for the immutable candidate tag to become available.
|
|
||||||
This bounded retry applies only to promotion pull requests; an actual promotion
|
|
||||||
still fails immediately when its tested candidate is missing.
|
|
||||||
|
|||||||
Reference in New Issue
Block a user