From f4ffb28018d2bee69e079eae0451e355a6dcd577 Mon Sep 17 00:00:00 2001 From: Vadime Date: Thu, 20 Aug 2026 16:30:23 +0200 Subject: [PATCH] ci: reuse candidates for production pull requests --- .gitea/actions/quickstack-oci/deploy.mjs | 57 +++++++++++++++++-- .gitea/actions/quickstack-oci/deploy.test.mjs | 36 ++++++++++++ README.md | 4 +- 3 files changed, 91 insertions(+), 6 deletions(-) diff --git a/.gitea/actions/quickstack-oci/deploy.mjs b/.gitea/actions/quickstack-oci/deploy.mjs index 2d29dfc..0725130 100644 --- a/.gitea/actions/quickstack-oci/deploy.mjs +++ b/.gitea/actions/quickstack-oci/deploy.mjs @@ -473,6 +473,36 @@ export function resolveDeploymentBranch({ eventName, environment = process.env, ); } +export function resolvePullRequestHeadBranch({ environment = process.env, eventPayload } = {}) { + const direct = String(environment.GITHUB_HEAD_REF || environment.GITEA_HEAD_REF || "").trim(); + if (direct) return direct; + + let payload = eventPayload; + if (payload === undefined) { + const eventPath = String(environment.GITHUB_EVENT_PATH || environment.GITEA_EVENT_PATH || "").trim(); + if (eventPath) { + payload = JSON.parse(fs.readFileSync(eventPath, "utf8")); + } + } + return requiredString(payload?.pull_request?.head?.ref, "Pull request head branch"); +} + +export function classifyVersion2Execution(pipeline, eventName) { + if (eventName === "pull_request") { + return pipeline.strategy === "promote" ? "validate-candidate" : "build-validation"; + } + return pipeline.strategy === "candidate" ? "build-deploy" : "promote"; +} + +export function validatePromotionPullRequestSource(pipeline, headBranch) { + const sourceBranch = requiredString(pipeline.source?.branch, "Promotion source branch"); + const actualHeadBranch = requiredString(headBranch, "Pull request head branch"); + if (actualHeadBranch !== sourceBranch) { + throw new Error(`Production promotion pull requests must originate from ${sourceBranch}, not ${actualHeadBranch}.`); + } + return sourceBranch; +} + function appendSummary(text) { const summary = process.env.GITHUB_STEP_SUMMARY ?? process.env.GITEA_STEP_SUMMARY; if (summary) fs.appendFileSync(summary, `${text}\n`); @@ -609,9 +639,10 @@ async function executeVersion2({ config, pipeline, eventName, sha, workspace }) const registry = requiredString(config.registry ?? "gitea.nuvisphere.de", "OCI registry").replace(/\/$/, ""); const dockerConfig = fs.mkdtempSync(path.join(os.tmpdir(), "quickstack-docker-")); const dockerEnv = { ...process.env, DOCKER_CONFIG: dockerConfig }; + const execution = classifyVersion2Execution(pipeline, eventName); try { - if (!validationOnly) dockerLogin(registry, dockerEnv); - if (validationOnly || pipeline.strategy === "candidate") { + if (execution !== "build-validation") dockerLogin(registry, dockerEnv); + if (execution === "build-validation" || execution === "build-deploy") { const artifacts = new Map(); for (const artifact of pipeline.artifacts) { artifacts.set(artifact.name, buildArtifact({ @@ -620,10 +651,10 @@ async function executeVersion2({ config, pipeline, eventName, sha, workspace }) sha, workspace, dockerEnv, - validationOnly, + validationOnly: execution === "build-validation", })); } - if (validationOnly) { + if (execution === "build-validation") { appendSummary(`Validated ${artifacts.size} immutable OCI artifact(s) for ${pipeline.branch}.`); return; } @@ -635,6 +666,24 @@ async function executeVersion2({ config, pipeline, eventName, sha, workspace }) return; } + if (execution === "validate-candidate") { + const sourceBranch = validatePromotionPullRequestSource(pipeline, resolvePullRequestHeadBranch()); + const release = loadRelease(workspace, pipeline.release); + const artifacts = new Map(); + for (const artifact of pipeline.artifacts) { + artifacts.set(artifact.name, pullCandidateArtifact({ + artifact, + registry, + sourceSha: sha, + workspace, + dockerEnv, + })); + } + console.log(`Validated ${release.tag} against ${artifacts.size} tested candidate artifact(s) from ${sourceBranch} at ${sha}; no rebuild or deployment performed.`); + appendSummary(`Validated release ${release.tag} against tested candidate \`${sha}\` from \`${sourceBranch}\` without rebuilding.`); + return; + } + const sourceSha = resolvePromotionSource(workspace, pipeline.source); const release = loadRelease(workspace, pipeline.release); const artifacts = new Map(); diff --git a/.gitea/actions/quickstack-oci/deploy.test.mjs b/.gitea/actions/quickstack-oci/deploy.test.mjs index 8d5adc5..77cacb4 100644 --- a/.gitea/actions/quickstack-oci/deploy.test.mjs +++ b/.gitea/actions/quickstack-oci/deploy.test.mjs @@ -2,16 +2,19 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + classifyVersion2Execution, deployExactImage, expandTokens, mergeEnvironment, orderApplications, resolveDeploymentBranch, + resolvePullRequestHeadBranch, selectDeployment, selectPipeline, toSavePayload, validateArtifact, validateApplication, + validatePromotionPullRequestSource, validateTarget, validateVersion2Pipeline, verifyEndpoint, @@ -62,6 +65,39 @@ test("resolves push and pull request deployment branches without accepting empty ); }); +test("resolves the pull request head branch from direct and payload context", () => { + assert.equal( + resolvePullRequestHeadBranch({ environment: { GITHUB_HEAD_REF: "staging" } }), + "staging", + ); + assert.equal( + resolvePullRequestHeadBranch({ + environment: {}, + eventPayload: { pull_request: { head: { ref: "release-candidate" } } }, + }), + "release-candidate", + ); + assert.throws( + () => resolvePullRequestHeadBranch({ environment: {}, eventPayload: {} }), + /Pull request head branch/, + ); +}); + +test("production pull requests validate tested candidates without rebuilding", () => { + assert.equal(classifyVersion2Execution({ strategy: "candidate" }, "pull_request"), "build-validation"); + assert.equal(classifyVersion2Execution({ strategy: "candidate" }, "push"), "build-deploy"); + assert.equal(classifyVersion2Execution({ strategy: "promote" }, "pull_request"), "validate-candidate"); + assert.equal(classifyVersion2Execution({ strategy: "promote" }, "push"), "promote"); + assert.equal( + validatePromotionPullRequestSource({ source: { branch: "staging" } }, "staging"), + "staging", + ); + assert.throws( + () => validatePromotionPullRequestSource({ source: { branch: "staging" } }, "feature"), + /must originate from staging, not feature/, + ); +}); + test("validates OCI paths and repository-local build paths", () => { assert.equal(validateTarget({ name: "Web", image: "Owner/Web", appId: "app-1" }).image, "owner/web"); assert.throws(() => validateTarget({ name: "Web", image: "owner/web", appId: "app-1", context: "../secret" }), /inside/); diff --git a/README.md b/README.md index ce8f8f5..e88d561 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ Use manifest version 2 when one image serves multiple applications or Production "name": "production", "branch": "prod", "strategy": "promote", - "source": { "mergeParent": 2, "requireTreeMatch": true }, + "source": { "branch": "staging", "mergeParent": 2, "requireTreeMatch": true }, "release": { "versionFile": "content/releases/latest.json", "notesFile": "content/releases/$version.md", @@ -87,4 +87,4 @@ Use manifest version 2 when one image serves multiple applications or Production } ``` -Candidate pipelines build every artifact once, push `sha-`, resolve the registry digest and deploy applications in dependency order. Promotion pipelines 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-`, 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-` 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.