diff --git a/.github/scripts/fork-promotion-decision.cjs b/.github/scripts/fork-promotion-decision.cjs new file mode 100644 index 00000000000..39b04fdb92a --- /dev/null +++ b/.github/scripts/fork-promotion-decision.cjs @@ -0,0 +1,63 @@ +const { readFileSync } = require("node:fs"); +const { isDeepStrictEqual } = require("node:util"); + +const STABLE_VERSION = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/; + +function parseStableVersion(value) { + if (typeof value !== "string") return null; + const match = STABLE_VERSION.exec(value); + if (!match) return null; + const parts = match.slice(1).map(Number); + if (parts.some((part) => !Number.isSafeInteger(part))) return null; + return parts; +} + +function packageWithoutVersion(raw) { + try { + const value = typeof raw === "string" ? JSON.parse(raw) : structuredClone(raw); + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const version = value.version; + delete value.version; + return { value, version }; + } catch { + return null; + } +} + +function isVersionOnlyStableSuccessor({ changedFiles, basePackage, headPackage }) { + if (!Array.isArray(changedFiles) || changedFiles.length !== 1 || changedFiles[0] !== "package.json") return false; + + const base = packageWithoutVersion(basePackage); + const head = packageWithoutVersion(headPackage); + if (!base || !head || !isDeepStrictEqual(base.value, head.value)) return false; + + const baseVersion = parseStableVersion(base.version); + const headVersion = parseStableVersion(head.version); + if (!baseVersion || !headVersion) return false; + + const [baseMajor, baseMinor, basePatch] = baseVersion; + const [headMajor, headMinor, headPatch] = headVersion; + return headMajor === baseMajor && headMinor === baseMinor && headPatch === basePatch + 1; +} + +function decidePromotion(state) { + return isVersionOnlyStableSuccessor(state) ? "wait" : "promote"; +} + +if (require.main === module) { + const [changedFilesPath, basePackagePath, headPackagePath] = process.argv.slice(2); + if (!changedFilesPath || !basePackagePath || !headPackagePath) { + console.error("usage: fork-promotion-decision.cjs "); + process.exit(1); + } + + const changedFiles = readFileSync(changedFilesPath) + .toString("utf8") + .split("\0") + .filter(Boolean); + const basePackage = readFileSync(basePackagePath, "utf8"); + const headPackage = readFileSync(headPackagePath, "utf8"); + process.stdout.write(decidePromotion({ changedFiles, basePackage, headPackage })); +} + +module.exports = { decidePromotion, isVersionOnlyStableSuccessor }; diff --git a/.github/workflows/promote-dev.yml b/.github/workflows/promote-dev.yml index 3cadc1cd345..78a7f3830cd 100644 --- a/.github/workflows/promote-dev.yml +++ b/.github/workflows/promote-dev.yml @@ -77,16 +77,37 @@ jobs: main_ancestor=false echo "Current main ancestry is missing from dev; waiting for protected dev reconciliation" fi + + promotion_ready=false + if [ "$trees_differ" = true ] && [ "$main_ancestor" = true ]; then + changed_files_path="$RUNNER_TEMP/promotion-changed-files" + base_package_path="$RUNNER_TEMP/promotion-base-package.json" + head_package_path="$RUNNER_TEMP/promotion-head-package.json" + git diff --name-only -z HEAD "$fetched_dev_sha" -- > "$changed_files_path" + git show "HEAD:package.json" > "$base_package_path" + git show "$fetched_dev_sha:package.json" > "$head_package_path" + promotion_action="$(node .github/scripts/fork-promotion-decision.cjs \ + "$changed_files_path" "$base_package_path" "$head_package_path")" + if [ "$promotion_action" = promote ]; then + promotion_ready=true + elif [ "$promotion_action" = wait ]; then + echo "Only the prepared successor version differs; waiting for release content" + else + echo "::error::promotion decision returned unexpected action: $promotion_action" + exit 1 + fi + fi echo "Promoting verified dev commit $expected_ci_sha" { echo "verified_sha=$expected_ci_sha" echo "trees_differ=$trees_differ" echo "main_ancestor=$main_ancestor" + echo "promotion_ready=$promotion_ready" } >> "$GITHUB_OUTPUT" - name: Create promotion App token id: promotion-app-token - if: steps.verify.outputs.trees_differ == 'true' && steps.verify.outputs.main_ancestor == 'true' + if: steps.verify.outputs.promotion_ready == 'true' && steps.verify.outputs.main_ancestor == 'true' uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: client-id: ${{ vars.PR_AUTOMATION_APP_ID }} @@ -97,7 +118,7 @@ jobs: permission-issues: write - name: Create or update the human promotion PR - if: steps.verify.outputs.trees_differ == 'true' && steps.verify.outputs.main_ancestor == 'true' + if: steps.verify.outputs.promotion_ready == 'true' && steps.verify.outputs.main_ancestor == 'true' env: GH_TOKEN: ${{ steps.promotion-app-token.outputs.token }} VERIFIED_CI_SHA: ${{ steps.verify.outputs.verified_sha }} diff --git a/tests/fork/dev-promotion-workflow.test.ts b/tests/fork/dev-promotion-workflow.test.ts index ed084fecee9..cbfa1c879d5 100644 --- a/tests/fork/dev-promotion-workflow.test.ts +++ b/tests/fork/dev-promotion-workflow.test.ts @@ -1,5 +1,11 @@ import { describe, expect, test } from "bun:test"; import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +const { decidePromotion } = require("../../.github/scripts/fork-promotion-decision.cjs") as { + decidePromotion(state: { changedFiles: string[]; basePackage: string; headPackage: string }): "promote" | "wait"; +}; const workflowText = readFileSync(new URL("../../.github/workflows/promote-dev.yml", import.meta.url), "utf8"); const workflow = Bun.YAML.parse(workflowText) as { @@ -79,7 +85,32 @@ describe("dev promotion workflow contract", () => { expect(workflowSource).toContain('git diff --quiet HEAD "$fetched_dev_sha" --'); expect(workflowSource).toContain("trees_differ=false"); expect(workflowSource).toContain("trees_differ=true"); - expect(workflowSource).toContain("if: steps.verify.outputs.trees_differ == 'true'"); + expect(workflowSource).toContain("promotion_ready=false"); + expect(workflowSource).toContain("if: steps.verify.outputs.promotion_ready == 'true'"); + }); + + test("waits when the generated stable successor version is the only change", () => { + const basePackage = JSON.stringify({ name: "@yansigit/opencodex", version: "2.39.5", scripts: { test: "bun test" } }); + const headPackage = JSON.stringify({ name: "@yansigit/opencodex", version: "2.39.6", scripts: { test: "bun test" } }); + + expect(decidePromotion({ changedFiles: ["package.json"], basePackage, headPackage })).toBe("wait"); + expect(decidePromotion({ changedFiles: ["package.json", "src/index.ts"], basePackage, headPackage })).toBe("promote"); + expect(decidePromotion({ + changedFiles: ["package.json"], + basePackage, + headPackage: JSON.stringify({ name: "@yansigit/opencodex", version: "2.39.6", scripts: { test: "bun test --timeout 30000" } }), + })).toBe("promote"); + expect(decidePromotion({ + changedFiles: ["package.json"], + basePackage, + headPackage: JSON.stringify({ name: "@yansigit/opencodex", version: "2.40.0", scripts: { test: "bun test" } }), + })).toBe("promote"); + + expect(workflowSource).toContain(".github/scripts/fork-promotion-decision.cjs"); + expect(workflowSource).toContain('git diff --name-only -z HEAD "$fetched_dev_sha"'); + expect(workflowSource).toContain("Only the prepared successor version differs; waiting for release content"); + expect(workflowSource).toContain("promotion_ready=$promotion_ready"); + expect(workflowSource).toContain("if: steps.verify.outputs.promotion_ready == 'true'"); }); test("uses least privilege and an immutable trusted checkout", () => { @@ -99,7 +130,7 @@ describe("dev promotion workflow contract", () => { const tokenUse = "actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1"; expect(promoteSteps.find((step) => step.id === "promotion-app-token")).toMatchObject({ - if: "steps.verify.outputs.trees_differ == 'true' && steps.verify.outputs.main_ancestor == 'true'", + if: "steps.verify.outputs.promotion_ready == 'true' && steps.verify.outputs.main_ancestor == 'true'", uses: tokenUse, with: { "client-id": "${{ vars.PR_AUTOMATION_APP_ID }}",