diff --git a/.github/workflows/release-stable.yml b/.github/workflows/release-stable.yml index f5269a30..cce140ba 100644 --- a/.github/workflows/release-stable.yml +++ b/.github/workflows/release-stable.yml @@ -112,7 +112,7 @@ jobs: git config user.name 'github-actions[bot]'; git config user.email 'github-actions[bot]@users.noreply.github.com' EXPECTED_PATHS=$(mktemp); printf '%s\n' CHANGELOG.md packages/hatchet/package.json packages/node/better-auth/package.json packages/prisma/package.json packages/react/query/package.json packages/react/router/package.json packages/react/router-better-auth/package.json packages/solid/query/package.json | sort > "$EXPECTED_PATHS" RECORDS=$(mktemp); printf '%s\n' '@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' > "$RECORDS" - while IFS='|' read -r NAME PATH OLD NEW; do node -e 'const fs=require("node:fs");const [path,name,version]=process.argv.slice(1);const value=JSON.parse(fs.readFileSync(path,"utf8"));if(!value||typeof value!=="object"||Array.isArray(value)||typeof value.name!=="string"||typeof value.version!=="string"||value.name!==name||value.version!==version)process.exit(1)' "$PATH" "$NAME" "$OLD" || { echo "::error::unauthorized source $NAME"; exit 1; }; done < "$RECORDS" + 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 @@ -120,7 +120,7 @@ jobs: 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" cmp -s "$EXPECTED_PATHS" "$ACTUAL" || { echo '::error::unexpected PREPARE paths'; diff -u "$EXPECTED_PATHS" "$ACTUAL" || true; exit 1; } - while IFS='|' read -r NAME PATH OLD NEW; do node -e 'const fs=require("node:fs");const [path,name,version]=process.argv.slice(1);const value=JSON.parse(fs.readFileSync(path,"utf8"));if(!value||typeof value!=="object"||Array.isArray(value)||typeof value.name!=="string"||typeof value.version!=="string"||value.name!==name||value.version!==version)process.exit(1)' "$PATH" "$NAME" "$NEW" || { echo "::error::wrong target $NAME"; exit 1; }; done < "$RECORDS" + 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" "$NEW"); then :; else STATUS=$?; if [ "$STATUS" = 1 ]; then echo "::error::target manifest identity mismatch for $NAME: $DETAIL"; else echo "::error::target manifest execution or parse failed for $NAME"; fi; exit 1; fi; done < "$RECORDS" git add --pathspec-from-file="$EXPECTED_PATHS" git diff --cached --name-only --no-renames | sort > /tmp/stable-staged cmp -s "$EXPECTED_PATHS" /tmp/stable-staged || { echo '::error::staged path contamination'; exit 1; } @@ -147,8 +147,8 @@ jobs: test "$(git rev-parse HEAD)" = "$EXPECTED_SHA"; test "$(git rev-parse origin/master)" = "$EXPECTED_SHA" RECORDS=$(mktemp); printf '%s\n' '@effectify/hatchet|packages/hatchet/package.json|0.1.0' '@effectify/node-better-auth|packages/node/better-auth/package.json|0.5.12' '@effectify/prisma|packages/prisma/package.json|1.1.13' '@effectify/react-query|packages/react/query/package.json|1.0.0' '@effectify/react-router|packages/react/router/package.json|0.6.0' '@effectify/react-router-better-auth|packages/react/router-better-auth/package.json|0.5.12' '@effectify/solid-query|packages/solid/query/package.json|0.5.13' > "$RECORDS" : > /tmp/missing-projects; : > /tmp/missing-tags; : > /tmp/missing-releases - while IFS='|' read -r NAME PATH VERSION; do - node -e 'const fs=require("node:fs");const [path,name,version]=process.argv.slice(1);const value=JSON.parse(fs.readFileSync(path,"utf8"));if(!value||typeof value!=="object"||Array.isArray(value)||typeof value.name!=="string"||typeof value.version!=="string"||value.name!==name||value.version!==version)process.exit(1)' "$PATH" "$NAME" "$VERSION" || { echo '::error::merged stable matrix mismatch'; exit 1; } + while IFS='|' read -r NAME MANIFEST_PATH VERSION; 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" "$VERSION"); then :; else STATUS=$?; if [ "$STATUS" = 1 ]; then echo "::error::merged manifest identity mismatch for $NAME: $DETAIL"; else echo "::error::merged manifest execution or parse failed for $NAME"; fi; exit 1; fi TAG="$NAME@$VERSION"; VERSIONS=$(npm view "$NAME" versions --json); printf '%s' "$VERSIONS" | node -e 'const fs=require("node:fs");const value=JSON.parse(fs.readFileSync(0,"utf8"));if(!(typeof value==="string"||Array.isArray(value)&&value.every(item=>typeof item==="string")))process.exit(1)' LATEST_JSON=$(npm view "$NAME" dist-tags.latest --json); LATEST=$(printf '%s' "$LATEST_JSON" | node -e 'const fs=require("node:fs");const value=JSON.parse(fs.readFileSync(0,"utf8"));if(typeof value!=="string")process.exit(1);process.stdout.write(value)') if printf '%s' "$VERSIONS" | node -e 'const fs=require("node:fs");const version=process.argv[1];const value=JSON.parse(fs.readFileSync(0,"utf8"));if(!(typeof value==="string"||Array.isArray(value)&&value.every(item=>typeof item==="string")))process.exit(1);process.exit((Array.isArray(value)?value.includes(version):value===version)?0:1)' "$VERSION"; then test "$LATEST" = "$VERSION" || { echo '::error::existing stable has divergent latest'; exit 1; }; else printf '%s\n' "${NAME#@effectify/}" >> /tmp/missing-projects; fi @@ -162,7 +162,7 @@ jobs: while IFS= read -r TAG; do [ -n "$TAG" ] && gh release create "$TAG" --verify-tag --generate-notes; done < /tmp/missing-releases MISSING=$(paste -sd, /tmp/missing-projects); if [ -n "$MISSING" ]; then PROJECTS="$MISSING"; pnpm nx release publish "--projects=$PROJECTS"; fi MAX_NPM_READS=6; for ATTEMPT in $(seq 1 "$MAX_NPM_READS"); do - REMAINING=0; while IFS='|' read -r NAME PATH VERSION; do V=$(npm view "$NAME" versions --json) || { REMAINING=$((REMAINING+1)); continue; }; L_JSON=$(npm view "$NAME" dist-tags.latest --json) || { REMAINING=$((REMAINING+1)); continue; }; L=$(printf '%s' "$L_JSON" | node -e 'const fs=require("node:fs");const value=JSON.parse(fs.readFileSync(0,"utf8"));if(typeof value!=="string")process.exit(1);process.stdout.write(value)') || { REMAINING=$((REMAINING+1)); continue; }; printf '%s' "$V" | node -e 'const fs=require("node:fs");const version=process.argv[1];const value=JSON.parse(fs.readFileSync(0,"utf8"));if(!(typeof value==="string"||Array.isArray(value)&&value.every(item=>typeof item==="string")))process.exit(1);process.exit((Array.isArray(value)?value.includes(version):value===version)?0:1)' "$VERSION" && [ "$L" = "$VERSION" ] || REMAINING=$((REMAINING+1)); done < "$RECORDS" + REMAINING=0; while IFS='|' read -r NAME MANIFEST_PATH VERSION; do V=$(npm view "$NAME" versions --json) || { REMAINING=$((REMAINING+1)); continue; }; L_JSON=$(npm view "$NAME" dist-tags.latest --json) || { REMAINING=$((REMAINING+1)); continue; }; L=$(printf '%s' "$L_JSON" | node -e 'const fs=require("node:fs");const value=JSON.parse(fs.readFileSync(0,"utf8"));if(typeof value!=="string")process.exit(1);process.stdout.write(value)') || { REMAINING=$((REMAINING+1)); continue; }; printf '%s' "$V" | node -e 'const fs=require("node:fs");const version=process.argv[1];const value=JSON.parse(fs.readFileSync(0,"utf8"));if(!(typeof value==="string"||Array.isArray(value)&&value.every(item=>typeof item==="string")))process.exit(1);process.exit((Array.isArray(value)?value.includes(version):value===version)?0:1)' "$VERSION" && [ "$L" = "$VERSION" ] || REMAINING=$((REMAINING+1)); done < "$RECORDS" [ "$REMAINING" = 0 ] && break; [ "$ATTEMPT" = "$MAX_NPM_READS" ] && { echo "::error::npm did not converge: $REMAINING"; exit 1; }; sleep 10 done - name: 📊 Stable summary diff --git a/scripts/release-policy-contract.test.mjs b/scripts/release-policy-contract.test.mjs index c782779f..04ddd105 100644 --- a/scripts/release-policy-contract.test.mjs +++ b/scripts/release-policy-contract.test.mjs @@ -639,6 +639,7 @@ const stableViolations = (source) => { "manifest exact identity", ].includes(name), ) + const manifestCommand = /node -e '[^\n]*fs\.readFileSync\(path,"utf8"\)[^\n]*' "\$MANIFEST_PATH" "\$NAME"/ const releaseValidationCommands = commandEntries(finalizeBody) .map(({ command }) => command) .filter((command) => /printf '%s' "\$RELEASE" \| node -e /.test(command)) @@ -653,6 +654,23 @@ const stableViolations = (source) => { pattern.lastIndex = 0 if (!pattern.test(body)) violations.push(`stable ${phase} ${name}`) } + if (/\bread\s+-r\s+[^\n;]*\bPATH\b/.test(body)) violations.push(`stable ${phase} reserved PATH shadowing`) + const manifestCommands = commandEntries(body) + .map(({ command }) => command) + .filter((command) => /fs\.readFileSync\(path,"utf8"\)/.test(command)) + if (manifestCommands.length === 0 || manifestCommands.some((command) => !manifestCommand.test(command))) { + violations.push(`stable ${phase} MANIFEST_PATH manifest command`) + } + if (!/process\.exit\(2\)/.test(body) || !/manifest execution or parse failed/.test(body)) { + violations.push(`stable ${phase} manifest execution diagnostic`) + } + if (!/manifest identity mismatch/.test(body)) violations.push(`stable ${phase} manifest identity diagnostic`) + if (!/actual=\$\{JSON\.stringify\(actual\)\}/.test(body)) { + violations.push(`stable ${phase} manifest actual identity detail`) + } + if (!/expected=\$\{JSON\.stringify\(\{name,version\}\)\}/.test(body)) { + violations.push(`stable ${phase} manifest expected identity detail`) + } } if (!prepare || !/mode == 'prepare'/.test(prepare.condition)) violations.push("stable PREPARE isolation") if ( @@ -924,6 +942,24 @@ test("protected stable PREPARE and FINALIZE reject independent safety mutations" ]) { const changed = mutateStep(policy.stable, stepName, /JSON\.parse/g, "JSON.parseSafe") assert.ok(stableViolations(changed).includes(`stable ${phase} Node JSON type validation`)) + const withoutActual = mutateStep(policy.stable, stepName, /actual=\$\{JSON\.stringify\(actual\)\} /g, "") + assert.ok(stableViolations(withoutActual).includes(`stable ${phase} manifest actual identity detail`)) + const withoutExpected = mutateStep( + policy.stable, + stepName, + /expected=\$\{JSON\.stringify\(\{name,version\}\)\}/g, + "", + ) + assert.ok(stableViolations(withoutExpected).includes(`stable ${phase} manifest expected identity detail`)) + } + for (const [phase, stepName] of [ + ["PREPARE", "PREPARE protected stable"], + ["FINALIZE", "FINALIZE exact stable artifacts"], + ]) { + const shadowed = mutateStep(policy.stable, stepName, /read -r NAME MANIFEST_PATH/, "read -r NAME PATH") + assert.ok(stableViolations(shadowed).includes(`stable ${phase} reserved PATH shadowing`)) + const wrongArgument = mutateStep(policy.stable, stepName, /"\$MANIFEST_PATH" "\$NAME"/, '"$PATH" "$NAME"') + assert.ok(stableViolations(wrongArgument).includes(`stable ${phase} MANIFEST_PATH manifest command`)) } const literalRelease = mutateStep( policy.stable,