Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions .github/scripts/fork-promotion-decision.cjs
Original file line number Diff line number Diff line change
@@ -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 <nul-changed-files> <base-package.json> <head-package.json>");
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 };
25 changes: 23 additions & 2 deletions .github/workflows/promote-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand All @@ -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 }}
Expand Down
35 changes: 33 additions & 2 deletions tests/fork/dev-promotion-workflow.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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", () => {
Expand All @@ -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 }}",
Expand Down
Loading