diff --git a/.github/workflows/release-stable.yml b/.github/workflows/release-stable.yml index ac496a03..f5269a30 100644 --- a/.github/workflows/release-stable.yml +++ b/.github/workflows/release-stable.yml @@ -29,7 +29,12 @@ jobs: services: postgres: image: postgres:16-alpine - env: { POSTGRES_USER: postgres, POSTGRES_PASSWORD: postgres, POSTGRES_DB: effectify } + env: + { + POSTGRES_USER: postgres, + POSTGRES_PASSWORD: postgres, + POSTGRES_DB: effectify, + } ports: ["5432:5432"] options: >- --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 @@ -107,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 test "$(jq -er .name "$PATH")" = "$NAME"; test "$(jq -er .version "$PATH")" = "$OLD" || { echo "::error::unauthorized source $NAME"; exit 1; }; done < "$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" 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 @@ -115,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 test "$(jq -er .version "$PATH")" = "$NEW" || { echo "::error::wrong target $NAME"; exit 1; }; done < "$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" "$NEW" || { echo "::error::wrong target $NAME"; exit 1; }; 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; } @@ -143,21 +148,21 @@ jobs: 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 - test "$(jq -er .name "$PATH")" = "$NAME"; test "$(jq -er .version "$PATH")" = "$VERSION" || { echo '::error::merged stable matrix mismatch'; exit 1; } - TAG="$NAME@$VERSION"; VERSIONS=$(npm view "$NAME" versions --json); printf '%s' "$VERSIONS" | jq -e 'type=="array" or type=="string"' >/dev/null - LATEST=$(npm view "$NAME" dist-tags.latest --json | jq -er 'select(type=="string")') - if printf '%s' "$VERSIONS" | jq -e --arg v "$VERSION" 'if type=="array" then index($v)!=null else .==$v end' >/dev/null; then test "$LATEST" = "$VERSION" || { echo '::error::existing stable has divergent latest'; exit 1; }; else printf '%s\n' "${NAME#@effectify/}" >> /tmp/missing-projects; fi + 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; } + 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 REMOTE=$(git ls-remote --tags origin "refs/tags/$TAG" "refs/tags/$TAG^{}") if [ -z "$REMOTE" ]; then printf '%s\n' "$TAG" >> /tmp/missing-tags; else test "$(printf '%s\n' "$REMOTE" | grep -c $'\trefs/tags/'"$TAG"'$')" = 1; test "$(printf '%s\n' "$REMOTE" | grep -c $'\trefs/tags/'"$TAG"'\^{}$')" = 1; test "$(printf '%s\n' "$REMOTE" | awk -v r="refs/tags/$TAG^{}" '$2==r{print $1}')" = "$EXPECTED_SHA"; fi set +e; RELEASE=$(gh release view "$TAG" --json tagName,isDraft,isPrerelease 2>/tmp/stable-gh-error); STATUS=$?; set -e - if [ "$STATUS" = 0 ]; then printf '%s' "$RELEASE" | jq -e --arg t "$TAG" '.tagName==$t and .isDraft==false and .isPrerelease==false' >/dev/null; elif [ "$STATUS" = 1 ] && grep -Fqi 'release not found' /tmp/stable-gh-error; then printf '%s\n' "$TAG" >> /tmp/missing-releases; else echo '::error::unknown GitHub Release state'; exit 1; fi + if [ "$STATUS" = 0 ]; then printf '%s' "$RELEASE" | node -e 'const fs=require("node:fs");const tag=process.argv[1];const value=JSON.parse(fs.readFileSync(0,"utf8"));if(!value||typeof value!=="object"||Array.isArray(value)||typeof value.tagName!=="string"||typeof value.isDraft!=="boolean"||typeof value.isPrerelease!=="boolean"||value.tagName!==tag||value.isDraft||value.isPrerelease)process.exit(1)' "$TAG"; elif [ "$STATUS" = 1 ] && grep -Fqi 'release not found' /tmp/stable-gh-error; then printf '%s\n' "$TAG" >> /tmp/missing-releases; else echo '::error::unknown GitHub Release state'; exit 1; fi done < "$RECORDS" TAG_REFS=(); while IFS= read -r TAG; do [ -n "$TAG" ] || continue; ! git show-ref --verify --quiet "refs/tags/$TAG"; git tag -a "$TAG" "$EXPECTED_SHA" -m "$TAG"; TAG_REFS+=("refs/tags/$TAG:refs/tags/$TAG"); done < /tmp/missing-tags if [ ${#TAG_REFS[@]} -gt 0 ]; then git push --atomic origin "${TAG_REFS[@]}"; fi 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=$(npm view "$NAME" dist-tags.latest --json | jq -r .); printf '%s' "$V" | jq -e --arg v "$VERSION" 'if type=="array" then index($v)!=null else .==$v end' >/dev/null && [ "$L" = "$VERSION" ] || REMAINING=$((REMAINING+1)); done < "$RECORDS" + 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 ] && 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 986e61a9..c782779f 100644 --- a/scripts/release-policy-contract.test.mjs +++ b/scripts/release-policy-contract.test.mjs @@ -514,9 +514,18 @@ const betaViolations = (source) => { return violations } +const isStableReleaseValidationCommand = (command) => + /printf '%s' "\$RELEASE" \| node -e /.test(command) && + /const value=JSON\.parse\(fs\.readFileSync\(0,"utf8"\)\)/.test(command) && + /typeof value\.tagName!=="string"/.test(command) && + /typeof value\.isDraft!=="boolean"/.test(command) && + /typeof value\.isPrerelease!=="boolean"/.test(command) && + /value\.tagName!==tag\|\|value\.isDraft\|\|value\.isPrerelease/.test(command) + const stableViolations = (source) => { const violations = [] const active = withoutComments(source) + if (/\bjq\b/.test(active)) violations.push("stable jq dependency") const required = [ ["dispatch", /^\s*workflow_dispatch:/m], ["duplicates", /sort \| uniq -d/], @@ -545,17 +554,22 @@ const stableViolations = (source) => { ["commit", /git commit -m "chore\(release\): prepare stable from \$SOURCE_SHA \[skip release\]"/], ["clean output", /::error::post-commit tree dirty/], ["branch refspec", /git push origin "HEAD:refs\/heads\/release\/stable-\$SHA_PREFIX"/], - ["manifest name", /jq -er \.name "\$PATH"/], - ["manifest version", /jq -er \.version "\$PATH"/], + ["Node manifest validation", /node -e/], + ["Node JSON type validation", /JSON\.parse\(/], + ["manifest object type", /!value\|\|typeof value!=="object"\|\|Array\.isArray\(value\)/], + ["manifest name type", /typeof value\.name!=="string"/], + ["manifest version type", /typeof value\.version!=="string"/], + ["manifest exact identity", /value\.name!==name\|\|value\.version!==version/], ["npm histories", /npm view "\$NAME" versions --json/], + ["npm versions type", /typeof value==="string"\|\|Array\.isArray\(value\)&&value\.every\(item=>typeof item==="string"\)/], ["npm latest", /npm view "\$NAME" dist-tags\.latest --json/], + ["npm latest type", /typeof value!=="string"/], ["latest conflict", /existing stable has divergent latest/], ["tag refs", /git ls-remote --tags origin "refs\/tags\/\$TAG" "refs\/tags\/\$TAG\^\{\}"/], ["direct unique", /grep -c \$'\\trefs\/tags\/'"\$TAG"'\$'/], ["peeled unique", /grep -c \$'\\trefs\/tags\/'"\$TAG"'\\\^\{\}\$'/], ["tag target", /awk -v r="refs\/tags\/\$TAG\^\{\}"[^\n]*"\$EXPECTED_SHA"/], ["release read", /gh release view "\$TAG" --json tagName,isDraft,isPrerelease/], - ["release identity", /\.tagName==\$t and \.isDraft==false and \.isPrerelease==false/], ["annotated tag", /git tag -a "\$TAG" "\$EXPECTED_SHA" -m "\$TAG"/], ["tag refspec", /TAG_REFS\+=\("refs\/tags\/\$TAG:refs\/tags\/\$TAG"\)/], ["atomic push", /git push --atomic origin "\$\{TAG_REFS\[@\]\}"/], @@ -565,17 +579,81 @@ const stableViolations = (source) => { ["delay", /sleep 10/], ["exhaustion", /npm did not converge/], ] - const commands = commandEntries(source).map(({ command }) => command) + const steps = extractSteps(source) + const prepare = steps.find((step) => step.name.includes("PREPARE protected stable")) + const finalize = steps.find((step) => step.name.includes("FINALIZE exact stable artifacts")) + const prepareBody = prepare ? `- name: PREPARE\n${prepare.source}` : "" + const finalizeBody = finalize ? `- name: FINALIZE\n${finalize.source}` : "" + const prepareContracts = new Set([ + "clean input", + "ref snapshot", + "Nx flags", + "refs unchanged", + "no Nx staging", + "all paths", + "path equality", + "pathspec", + "index equality", + "commit", + "clean output", + "branch refspec", + ]) + const finalizeContracts = new Set([ + "npm histories", + "npm versions type", + "npm latest", + "npm latest type", + "latest conflict", + "tag refs", + "direct unique", + "peeled unique", + "tag target", + "release read", + "annotated tag", + "tag refspec", + "atomic push", + "release create", + "missing subset", + "six reads", + "delay", + "exhaustion", + ]) for (const [name, pattern] of required) { + const body = prepareContracts.has(name) ? prepareBody : finalizeContracts.has(name) ? finalizeBody : active + const commands = commandEntries(body).map(({ command }) => command) pattern.lastIndex = 0 - const inSource = pattern.test(active) + const inSource = pattern.test(body) const inCommands = commands.some((command) => { pattern.lastIndex = 0 return pattern.test(command) }) if (!inSource && !inCommands) violations.push(`stable ${name}`) } - const prepare = extractSteps(source).find((step) => step.name.includes("PREPARE protected stable")) + const sharedPhaseContracts = required.filter(([name]) => + [ + "Node manifest validation", + "Node JSON type validation", + "manifest object type", + "manifest name type", + "manifest version type", + "manifest exact identity", + ].includes(name), + ) + const releaseValidationCommands = commandEntries(finalizeBody) + .map(({ command }) => command) + .filter((command) => /printf '%s' "\$RELEASE" \| node -e /.test(command)) + if (releaseValidationCommands.length !== 1 || !isStableReleaseValidationCommand(releaseValidationCommands[0])) { + violations.push("stable FINALIZE exact Release validation command") + } + for (const [phase, body] of [ + ["PREPARE", prepareBody], + ["FINALIZE", finalizeBody], + ]) { + for (const [name, pattern] of sharedPhaseContracts) { + pattern.lastIndex = 0 + if (!pattern.test(body)) violations.push(`stable ${phase} ${name}`) + } + } if (!prepare || !/mode == 'prepare'/.test(prepare.condition)) violations.push("stable PREPARE isolation") if ( prepare && @@ -593,7 +671,7 @@ const stableViolations = (source) => { /git push --atomic/, /gh release create/, /nx release publish/, - ].map((p) => active.search(p)) + ].map((p) => finalizeBody.search(p)) if (order.some((p) => p < 0) || order.some((p, i) => i && p <= order[i - 1])) violations.push("stable ordering") return violations } @@ -627,6 +705,12 @@ const assertMutationFails = (name, policy, mutation) => { assert.notDeepEqual(policyViolations(changed), [], name) } +const mutateStep = (source, stepName, before, after) => { + const step = extractSteps(source).find((candidate) => candidate.name.includes(stepName)) + assert.ok(step, `step fixture not found: ${stepName}`) + return source.replace(step.source, mutate(step.source, before, after)) +} + test("dev pushes retain exact-range conditional alpha publication", () => { assert.deepEqual(channelViolations("alpha", workflows.alpha), []) }) @@ -833,6 +917,22 @@ test("beta FINALIZE conflict and ordering mutations fail closed", () => { test("protected stable PREPARE and FINALIZE reject independent safety mutations", () => { const policy = { ...workflows, docs: readme } assert.deepEqual(stableViolations(policy.stable), []) + + for (const [phase, stepName] of [ + ["PREPARE", "PREPARE protected stable"], + ["FINALIZE", "FINALIZE exact stable artifacts"], + ]) { + const changed = mutateStep(policy.stable, stepName, /JSON\.parse/g, "JSON.parseSafe") + assert.ok(stableViolations(changed).includes(`stable ${phase} Node JSON type validation`)) + } + const literalRelease = mutateStep( + policy.stable, + "FINALIZE exact stable artifacts", + 'const value=JSON.parse(fs.readFileSync(0,"utf8"));if(!value||typeof value!=="object"||Array.isArray(value)||typeof value.tagName', + 'const value={tagName:tag,isDraft:false,isPrerelease:false};if(!value||typeof value!=="object"||Array.isArray(value)||typeof value.tagName', + ) + assert.ok(stableViolations(literalRelease).includes("stable FINALIZE exact Release validation command")) + for (const [name, before, after] of [ ["allow abbreviated SHA", "^[0-9a-f]{40}$", "^[0-9a-f]{7,40}$"], ["fetch tags", "--no-tags", "--tags"], @@ -845,6 +945,23 @@ test("protected stable PREPARE and FINALIZE reject independent safety mutations" ["weaken path comparison", 'cmp -s "$EXPECTED_PATHS" "$ACTUAL"', 'test -s "$ACTUAL"'], ["stage broad tree", 'git add --pathspec-from-file="$EXPECTED_PATHS"', "git add -A"], ["push master", "HEAD:refs/heads/release/stable-$SHA_PREFIX", "HEAD:refs/heads/master"], + ["restore jq", "node -e", "jq -e"], + ["weaken JSON parse", "JSON.parse", "JSON.parseSafe"], + ["accept scalar manifest", /!value\|\|typeof value!=="object"\|\|Array\.isArray\(value\)/g, "!value"], + ["accept non-string manifest name", /typeof value\.name!=="string"\|\|/g, ""], + ["accept non-string manifest version", /typeof value\.version!=="string"\|\|/g, ""], + ["accept inexact manifest identity", /value\.name!==name\|\|value\.version!==version/g, "false"], + [ + "accept non-string npm versions", + /typeof value==="string"\|\|Array\.isArray\(value\)&&value\.every\(item=>typeof item==="string"\)/g, + "Array.isArray(value)", + ], + ["accept non-string npm latest", /typeof value!=="string"/g, "value==null"], + ["accept non-string release tag", 'typeof value.tagName!=="string"||', ""], + ["accept non-boolean release draft", 'typeof value.isDraft!=="boolean"||', ""], + ["accept non-boolean release prerelease", 'typeof value.isPrerelease!=="boolean"||', ""], + ["accept draft release", "||value.isDraft||value.isPrerelease", "||value.isPrerelease"], + ["accept prerelease release", "||value.isDraft||value.isPrerelease", "||value.isDraft"], ["read latest as beta", "dist-tags.latest", "dist-tags.beta"], ["accept divergent latest", "existing stable has divergent latest", "existing stable accepted"], ["omit peeled tag ref", ' "refs/tags/$TAG^{}"', ""],