diff --git a/.github/workflows/release-stable.yml b/.github/workflows/release-stable.yml index cce140ba..3ad3e36a 100644 --- a/.github/workflows/release-stable.yml +++ b/.github/workflows/release-stable.yml @@ -115,7 +115,9 @@ jobs: while IFS='|' read -r NAME MANIFEST_PATH OLD NEW; do if DETAIL=$(node -e 'const fs=require("node:fs");const [path,name,version]=process.argv.slice(1);let value;try{value=JSON.parse(fs.readFileSync(path,"utf8"))}catch{process.exit(2)}if(!value||typeof value!=="object"||Array.isArray(value)||typeof value.name!=="string"||typeof value.version!=="string"||value.name!==name||value.version!==version){const actual={name:typeof value?.name==="string"?value.name:null,version:typeof value?.version==="string"?value.version:null};process.stdout.write(`actual=${JSON.stringify(actual)} expected=${JSON.stringify({name,version})}`);process.exit(1)}' "$MANIFEST_PATH" "$NAME" "$OLD"); then :; else STATUS=$?; if [ "$STATUS" = 1 ]; then echo "::error::source manifest identity mismatch for $NAME: $DETAIL"; else echo "::error::source manifest execution or parse failed for $NAME"; fi; exit 1; fi; done < "$RECORDS" test -z "$(git status --porcelain)" || { echo '::error::PREPARE requires clean tree'; exit 1; } REFS_BEFORE=$(git for-each-ref --format='%(refname) %(objectname)' refs/heads refs/tags | sort) - pnpm nx release version patch "--projects=$PROJECTS" --git-commit=false --git-tag=false --git-push=false --stage-changes=false + while IFS='|' read -r NAME MANIFEST_PATH OLD NEW; do + pnpm nx release version "$NEW" "--projects=$NAME" --git-commit=false --git-tag=false --git-push=false --stage-changes=false + done < "$RECORDS" test "$REFS_BEFORE" = "$(git for-each-ref --format='%(refname) %(objectname)' refs/heads refs/tags | sort)" || { echo '::error::Nx moved refs'; exit 1; } test -z "$(git diff --cached --name-only)" || { echo '::error::Nx staged files'; exit 1; } ACTUAL=$(mktemp); { git diff --name-only --no-renames HEAD; git ls-files --others --exclude-standard; } | sort -u > "$ACTUAL" diff --git a/scripts/release-policy-contract.test.mjs b/scripts/release-policy-contract.test.mjs index 04ddd105..03c3d218 100644 --- a/scripts/release-policy-contract.test.mjs +++ b/scripts/release-policy-contract.test.mjs @@ -543,7 +543,7 @@ const stableViolations = (source) => { ["ref snapshot", /REFS_BEFORE=\$\(git for-each-ref/], [ "Nx flags", - /^pnpm nx release version patch "--projects=\$PROJECTS" --git-commit=false --git-tag=false --git-push=false --stage-changes=false$/m, + /^pnpm nx release version "\$NEW" "--projects=\$NAME" --git-commit=false --git-tag=false --git-push=false --stage-changes=false$/m, ], ["refs unchanged", /test "\$REFS_BEFORE" = "\$\(git for-each-ref/], ["no Nx staging", /test -z "\$\(git diff --cached --name-only\)"/], @@ -672,6 +672,22 @@ const stableViolations = (source) => { violations.push(`stable ${phase} manifest expected identity detail`) } } + if (prepare) { + const versionCommands = prepare.commands.filter((command) => /pnpm nx release version/.test(command)) + if (versionCommands.length !== 1 || /\bpatch\b|--projects=\$PROJECTS/.test(versionCommands[0])) violations.push("stable exact per-record version action") + if (/writeFileSync|fs\.writeFile|jq[^\n]*\.version|sed -i|npm pkg set/.test(prepare.source)) violations.push("stable direct manifest edit") + const records = [...prepare.source.matchAll(/'(@effectify\/[^|']+\|[^|']+\|[^|']+\|[^']+)'/g)].map(([, record]) => record) + const expectedRecords = [ + "@effectify/hatchet|packages/hatchet/package.json|0.1.0-beta.0|0.1.0", + "@effectify/node-better-auth|packages/node/better-auth/package.json|0.5.12-beta.0|0.5.12", + "@effectify/prisma|packages/prisma/package.json|1.1.13-beta.0|1.1.13", + "@effectify/react-query|packages/react/query/package.json|1.0.0-beta.1|1.0.0", + "@effectify/react-router|packages/react/router/package.json|0.6.0-beta.0|0.6.0", + "@effectify/react-router-better-auth|packages/react/router-better-auth/package.json|0.5.12-beta.0|0.5.12", + "@effectify/solid-query|packages/solid/query/package.json|0.5.13-beta.0|0.5.13", + ] + if (JSON.stringify(records) !== JSON.stringify(expectedRecords)) violations.push("stable exact PREPARE records") + } if (!prepare || !/mode == 'prepare'/.test(prepare.condition)) violations.push("stable PREPARE isolation") if ( prepare && @@ -1101,7 +1117,7 @@ test("protected stable promotion exposes exact PREPARE and FINALIZE contracts", assert.match(active, /MODE=finalize/) assert.match( active, - /pnpm nx release version patch "--projects=\$PROJECTS" --git-commit=false --git-tag=false --git-push=false --stage-changes=false/, + /pnpm nx release version "\$NEW" "--projects=\$NAME" --git-commit=false --git-tag=false --git-push=false --stage-changes=false/, ) assert.match(active, /HEAD:refs\/heads\/release\/stable-\$SHA_PREFIX/) assert.match(active, /git push --atomic origin "\$\{TAG_REFS\[@\]\}"/) diff --git a/tools/release-version-actions.test.cjs b/tools/release-version-actions.test.cjs index 809d9f6b..88f388f6 100644 --- a/tools/release-version-actions.test.cjs +++ b/tools/release-version-actions.test.cjs @@ -16,6 +16,77 @@ const releasedPackages = [ { name: "@effectify/prisma", version: "1.0.0" }, ] +const stableMatrix = [ + ["@effectify/hatchet", "0.1.0", []], + ["@effectify/node-better-auth", "0.5.12", []], + ["@effectify/prisma", "1.1.13", []], + ["@effectify/react-query", "1.0.0", []], + ["@effectify/react-router", "0.6.0", ["0.5.10"]], + ["@effectify/react-router-better-auth", "0.5.12", []], + ["@effectify/solid-query", "0.5.13", ["0.5.12"]], +] + +test("no-network stable actions accept the exact heterogeneous matrix and reject an exact target collision", async () => { + const delegated = [] + const delegatedUpdates = [] + class BaseVersionActions { + constructor(releaseGroup, projectGraphNode, finalConfigForProject) { + this.releaseGroup = releaseGroup + this.projectGraphNode = projectGraphNode + this.finalConfigForProject = finalConfigForProject + } + async init() {} + async calculateNewVersion() { + delegated.push(this.packageJson?.name ?? this.projectGraphNode.data.root) + return { newVersion: this.finalConfigForProject.candidate, logText: "Nx selected exact target" } + } + async updateProjectVersion(_tree, version) { + delegatedUpdates.push(version) + } + } + + const run = async ([name, candidate, publishedVersions]) => { + const VersionActions = createCollisionAwareVersionActions({ + BaseVersionActions, + resolveRegistry: async () => "https://registry.example.test/", + getPublishedVersions: async () => publishedVersions, + }) + const action = new VersionActions({}, { data: { root: name.slice("@effectify/".length) } }, { + candidate, + versionActionsOptions: {}, + }) + await action.init({ root: "/repo", read: () => Buffer.from(JSON.stringify({ name, version: "0.0.0" })) }) + return action.calculateNewVersion("0.0.0", candidate, "exact stable", {}, "") + } + + const accepted = [] + for (const record of stableMatrix) accepted.push(await run(record)) + assert.deepEqual(accepted.map(({ newVersion }) => newVersion), stableMatrix.map(([, version]) => version)) + assert.equal(delegated.length, 7) + + const beforeCollision = delegated.length + await assert.rejects( + () => run(["@effectify/solid-query", "0.5.13", ["0.5.12", "0.5.13"]]), + /stable candidate 0\.5\.13 is already published/, + ) + assert.equal(delegated.length, beforeCollision + 1) + assert.deepEqual(delegatedUpdates, []) +}) + +test("cumulative seven-package root changelog is idempotent and dry-run updates never write", () => { + const packages = stableMatrix.map(([name, version]) => ({ name, version })) + const date = new Date("2026-07-12T00:00:00Z") + const changelog = mergeRootChangelog(undefined, packages, date) + assert.equal((changelog.match(/^## @effectify\//gm) ?? []).length, 7) + assert.equal(mergeRootChangelog(changelog, packages, date), changelog) + assert.deepEqual(getRootChangelogUpdate("# stale\n", changelog, true), { + changed: true, + content: changelog, + shouldWrite: false, + shouldReportChangedFile: false, + }) +}) + test("creates the root release snapshot with UTC version dates and package names", () => { assert.equal( createRootChangelog(releasedPackages, new Date("2026-07-11T23:59:59-07:00")),