diff --git a/.github/SETUP.md b/.github/SETUP.md index 314fd04e..5d64225e 100644 --- a/.github/SETUP.md +++ b/.github/SETUP.md @@ -65,7 +65,9 @@ Manual publish-only recovery requires an explicit comma-separated `projects` inp #### Beta quick path -1. Let an eligible `master` push run PREPARE. For this incident only, manual PREPARE must select all seven projects listed above. +The published `@effectify/solid-query@0.5.12` collision cannot be repaired by moving `latest` back to the stale tarball. Recovery is strictly ordered: merge the implementation PR; run corrective beta PREPARE/PR/FINALIZE for only `@effectify/solid-query@0.5.13-beta.0`; then run the seven-project stable PREPARE/PR/FINALIZE ending at `@effectify/solid-query@0.5.13`. + +1. Let an eligible `master` push run PREPARE. The existing incident PREPARE still requires all seven projects. The one corrective exception selects only `@effectify/solid-query`; the workflow fixes positional `prepatch`, `--preid=beta`, and all disabled git/staging effects, and accepts only root `CHANGELOG.md` plus `packages/solid/query/package.json` at `0.5.13-beta.0`. 2. Verify the summary's source SHA, release branch, changed paths, and versions. PREPARE changes only root `CHANGELOG.md` and the selected manifests. 3. Create or reuse the required approved issue. Manually open one linked PR from the reported release branch to `master`; its sole `type:*` label is `type:chore`. 4. Use ordinary required checks, human review, and protected merge. Confirm the merge-triggered beta run reports `suppress` and publishes nothing. @@ -86,7 +88,7 @@ FINALIZE freshly checks that checkout `HEAD`, `origin/master`, and `expected_sha | `@effectify/prisma` | `1.1.13-beta.0` | | `@effectify/hatchet` | `0.1.0-beta.0` | -Do **not** dispatch stable during beta recovery. Stop on a newer `master`, an unexpected generated path or version, an ambiguous remote read, a lightweight or wrong-target tag, a conflicting Release, or inconsistent npm state. +Do **not** dispatch stable during either beta recovery. Complete corrective beta PREPARE, its protected PR, and exact-SHA FINALIZE before starting the seven-project stable PREPARE. Stop on a newer `master`, an unexpected generated path or version, an ambiguous remote read, a lightweight or wrong-target tag, a conflicting Release, or inconsistent npm state. Before publication, rollback is limited to deleting the unprotected prepared branch or closing/reverting the release PR through normal policy. Do not delete published tags, Releases, or npm artifacts as rollback; rerun the exact FINALIZE request or obtain authorization for a fix-forward release. @@ -102,7 +104,7 @@ Stable is a protected **PREPARE → manual authorization → FINALIZE** promotio | `@effectify/react-query` | `1.0.0-beta.1` | `1.0.0` | | `@effectify/react-router` | `0.6.0-beta.0` | `0.6.0` | | `@effectify/react-router-better-auth` | `0.5.12-beta.0` | `0.5.12` | -| `@effectify/solid-query` | `0.5.12-beta.0` | `0.5.12` | +| `@effectify/solid-query` | `0.5.13-beta.0` | `0.5.13` | #### Protected stable quick path diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 05c7d497..069d7dd4 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -116,10 +116,14 @@ jobs: } echo "mode=finalize" >> "$GITHUB_OUTPUT" else - cmp -s <(printf '%s\n' "$ALL_PROJECTS") <(printf '%s\n' "$SELECTED_PROJECTS") || { - echo "manual PREPARE requires all seven release projects" >&2 + if cmp -s <(printf '%s\n' "$ALL_PROJECTS") <(printf '%s\n' "$SELECTED_PROJECTS"); then + echo "version_specifier=" >> "$GITHUB_OUTPUT" + elif [ "$SELECTED_PROJECTS" = "@effectify/solid-query" ]; then + echo "version_specifier=prepatch" >> "$GITHUB_OUTPUT" + else + echo "manual PREPARE requires all seven release projects or the corrective solid-query singleton" >&2 exit 1 - } + fi echo "mode=prepare" >> "$GITHUB_OUTPUT" fi echo "has_projects=true" >> "$GITHUB_OUTPUT" @@ -151,8 +155,19 @@ jobs: '@effectify/react-query=1.0.0-beta.1=1.0.0|packages/react/query/package.json' \ '@effectify/react-router=0.6.0-beta.0=0.6.0|packages/react/router/package.json' \ '@effectify/react-router-better-auth=0.5.12-beta.0=0.5.12|packages/react/router-better-auth/package.json' \ - '@effectify/solid-query=0.5.12-beta.0=0.5.12|packages/solid/query/package.json' > "$STABLE_TRANSITIONS" + '@effectify/solid-query=0.5.13-beta.0=0.5.13|packages/solid/query/package.json' > "$STABLE_TRANSITIONS" CHANGED=$(mktemp); git diff --name-only --no-renames "$BASE" "$HEAD" | sort -u > "$CHANGED" + CORRECTIVE_PATHS=$(mktemp) + printf '%s\n' CHANGELOG.md packages/solid/query/package.json | sort > "$CORRECTIVE_PATHS" + CORRECTIVE_TRANSITION='@effectify/solid-query=0.5.12-beta.0=0.5.13-beta.0|packages/solid/query/package.json' + if cmp -s "$CORRECTIVE_PATHS" "$CHANGED"; then + OLD_NAME=$(git show "$BASE:packages/solid/query/package.json" | jq -er .name) || exit 1 + OLD_VERSION=$(git show "$BASE:packages/solid/query/package.json" | jq -er .version) || exit 1 + NEW_NAME=$(jq -er .name packages/solid/query/package.json) || exit 1 + NEW_VERSION=$(jq -er .version packages/solid/query/package.json) || exit 1 + [ "$OLD_NAME" = "@effectify/solid-query" ] && [ "$NEW_NAME" = "@effectify/solid-query" ] && [ "$OLD_VERSION" = "0.5.12-beta.0" ] && [ "$NEW_VERSION" = "0.5.13-beta.0" ] || { echo "corrective beta shape is malformed" >&2; exit 1; } + echo "mode=suppress" >> "$GITHUB_OUTPUT"; echo "has_projects=false" >> "$GITHUB_OUTPUT"; echo "projects=" >> "$GITHUB_OUTPUT"; exit 0 + fi STABLE_SHAPE=false if cmp -s "$EXPECTED_PATHS" "$CHANGED"; then STABLE_SHAPE=true @@ -218,6 +233,7 @@ jobs: if: ${{ steps.release.outputs.mode == 'prepare' && steps.release.outputs.has_projects == 'true' }} env: PROJECTS: ${{ steps.release.outputs.projects }} + VERSION_SPECIFIER: ${{ steps.release.outputs.version_specifier }} MANUAL_PREPARE: ${{ github.event_name == 'workflow_dispatch' }} run: | set -euo pipefail @@ -242,7 +258,7 @@ jobs: printf '%s\n' "${RELEASE_PATHS[@]}" | sort -u > /tmp/expected-release-paths test "$(wc -l < /tmp/expected-release-paths | tr -d ' ')" = "$(( ${#SELECTED[@]} + 1 ))" - pnpm nx release version "--projects=$PROJECTS" --preid=beta --git-commit=false --git-tag=false --git-push=false --stage-changes=false + pnpm nx release version $VERSION_SPECIFIER "--projects=$PROJECTS" --preid=beta --git-commit=false --git-tag=false --git-push=false --stage-changes=false verify_prepared_tree() { ACTUAL_PATHS=$(mktemp) @@ -265,14 +281,18 @@ jobs: if [ "$MANUAL_PREPARE" = "true" ]; then EXPECTED_MATRIX=$(mktemp) - printf '%s\n' \ - '@effectify/hatchet=0.1.0-beta.0' \ - '@effectify/node-better-auth=0.5.12-beta.0' \ - '@effectify/prisma=1.1.13-beta.0' \ - '@effectify/react-query=1.0.0-beta.1' \ - '@effectify/react-router=0.6.0-beta.0' \ - '@effectify/react-router-better-auth=0.5.12-beta.0' \ - '@effectify/solid-query=0.5.12-beta.0' | sort > "$EXPECTED_MATRIX" + if [ "$PROJECTS" = "@effectify/solid-query" ]; then + printf '%s\n' '@effectify/solid-query=0.5.13-beta.0' > "$EXPECTED_MATRIX" + else + printf '%s\n' \ + '@effectify/hatchet=0.1.0-beta.0' \ + '@effectify/node-better-auth=0.5.12-beta.0' \ + '@effectify/prisma=1.1.13-beta.0' \ + '@effectify/react-query=1.0.0-beta.1' \ + '@effectify/react-router=0.6.0-beta.0' \ + '@effectify/react-router-better-auth=0.5.12-beta.0' \ + '@effectify/solid-query=0.5.12-beta.0' | sort > "$EXPECTED_MATRIX" + fi ACTUAL_MATRIX=$(mktemp) while IFS=$'\t' read -r project name manifest; do printf '%s=%s\n' "$name" "$(jq -er '.version' "$manifest")" diff --git a/.github/workflows/release-stable.yml b/.github/workflows/release-stable.yml index f0803120..ac496a03 100644 --- a/.github/workflows/release-stable.yml +++ b/.github/workflows/release-stable.yml @@ -106,7 +106,7 @@ jobs: SOURCE_SHA=$(git rev-parse HEAD); SHA_PREFIX=${SOURCE_SHA:0:12}; BRANCH="release/stable-$SHA_PREFIX" 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.12-beta.0|0.5.12' > "$RECORDS" + 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" 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) @@ -140,7 +140,7 @@ jobs: set -euo pipefail git fetch origin master:refs/remotes/origin/master --no-tags 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.12' > "$RECORDS" + 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; } diff --git a/openspec/specs/protected-stable-promotion/spec.md b/openspec/specs/protected-stable-promotion/spec.md index 9c8b83f1..c0691d80 100644 --- a/openspec/specs/protected-stable-promotion/spec.md +++ b/openspec/specs/protected-stable-promotion/spec.md @@ -6,6 +6,26 @@ Define a protected, auditable promotion of the complete authorized beta matrix t ## Requirements +### Requirement: Collision correction precedes stable promotion + +The system MUST correct the stale `@effectify/solid-query@0.5.12` collision by authorizing exactly one manual beta PREPARE exception: the singleton project `@effectify/solid-query`, positional `prepatch`, `--preid=beta`, disabled commit/tag/push/staging effects, exact output `0.5.13-beta.0`, and exactly `CHANGELOG.md` plus `packages/solid/query/package.json`. No caller-selectable version specifier or other subset is authorized. Its protected merge MUST be suppressed only for the exact `0.5.12-beta.0` to `0.5.13-beta.0` transition and those two paths. + +The required order is implementation PR, corrective beta PREPARE/PR/FINALIZE, then seven-project stable PREPARE/PR/FINALIZE. Recovery MUST NOT move npm `latest` to the stale `0.5.12` tarball. + +#### Scenario: Corrective beta is exact + +- GIVEN the implementation PR has merged +- WHEN manual beta PREPARE selects only `@effectify/solid-query` +- THEN Nx uses positional `prepatch` and `--preid=beta` with all git and staging effects disabled +- AND the only generated version is `@effectify/solid-query@0.5.13-beta.0` +- AND the only generated paths are the root changelog and Solid manifest + +#### Scenario: Corrective suppression fails closed + +- GIVEN a merge has partial, mixed, message-only, wrong-version, or additional-path changes +- WHEN beta classifies the merge +- THEN it MUST NOT suppress the run as the corrective beta shape + ### Requirement: Exact authorized promotion matrix The system MUST accept the promotion only when the requested project set is complete, duplicate-free, and exactly matches this source-to-target matrix: @@ -18,7 +38,7 @@ The system MUST accept the promotion only when the requested project set is comp | `@effectify/react-query` | `1.0.0-beta.1` | `1.0.0` | | `@effectify/react-router` | `0.6.0-beta.0` | `0.6.0` | | `@effectify/react-router-better-auth` | `0.5.12-beta.0` | `0.5.12` | -| `@effectify/solid-query` | `0.5.12-beta.0` | `0.5.12` | +| `@effectify/solid-query` | `0.5.13-beta.0` | `0.5.13` | Every stable target MUST equal the semver core of its authorized beta source. The system MUST NOT recalculate, increment, substitute, or partially promote this matrix. diff --git a/scripts/release-policy-contract.test.mjs b/scripts/release-policy-contract.test.mjs index 92dc9e1d..986e61a9 100644 --- a/scripts/release-policy-contract.test.mjs +++ b/scripts/release-policy-contract.test.mjs @@ -187,7 +187,7 @@ const channelVersionCommand = (channel) => const channelPublishCommand = (channel) => new RegExp(`^pnpm nx release publish "--projects=\\$PROJECTS" --tag=${channel}$`) const betaVersionCommand = - /^pnpm nx release version "--projects=\$PROJECTS" --preid=beta --git-commit=false --git-tag=false --git-push=false --stage-changes=false$/ + /^pnpm nx release version(?: \$VERSION_SPECIFIER)? "--projects=\$PROJECTS" --preid=beta --git-commit=false --git-tag=false --git-push=false --stage-changes=false$/ const betaGitIdentityCondition = "${{ (steps.release.outputs.mode == 'prepare' || steps.release.outputs.mode == 'finalize') && steps.release.outputs.has_projects == 'true' }}" const gitIdentityCommands = [ @@ -476,7 +476,7 @@ const betaViolations = (source) => { "@effectify/react-query=1.0.0-beta.1=1.0.0|packages/react/query/package.json", "@effectify/react-router=0.6.0-beta.0=0.6.0|packages/react/router/package.json", "@effectify/react-router-better-auth=0.5.12-beta.0=0.5.12|packages/react/router-better-auth/package.json", - "@effectify/solid-query=0.5.12-beta.0=0.5.12|packages/solid/query/package.json", + "@effectify/solid-query=0.5.13-beta.0=0.5.13|packages/solid/query/package.json", ]) if (!active.includes(transition)) violations.push(`beta stable transition ${transition}`) for (const pattern of [ @@ -968,12 +968,38 @@ test("beta structurally suppresses only the exact stable matrix", () => { "@effectify/react-query=1.0.0-beta.1=1.0.0", "@effectify/react-router=0.6.0-beta.0=0.6.0", "@effectify/react-router-better-auth=0.5.12-beta.0=0.5.12", - "@effectify/solid-query=0.5.12-beta.0=0.5.12", + "@effectify/solid-query=0.5.13-beta.0=0.5.13", ]) assert.match(active, new RegExp(transition.replaceAll("/", "\\/"))) assert.match(active, /stable promotion shape is partial, mixed, or malformed/) }) +test("corrective solid-query beta and updated stable matrix are exact", () => { + const beta = withoutComments(workflows.beta) + const stable = withoutComments(workflows.stable) + assert.match(beta, /manual PREPARE requires all seven release projects or the corrective solid-query singleton/) + assert.match(beta, /echo "version_specifier=prepatch" >> "\$GITHUB_OUTPUT"/) + assert.match(beta, /pnpm nx release version \$VERSION_SPECIFIER "--projects=\$PROJECTS" --preid=beta --git-commit=false --git-tag=false --git-push=false --stage-changes=false/) + assert.match(beta, /0\.5\.12-beta\.0=0\.5\.13-beta\.0\|packages\/solid\/query\/package\.json/) + assert.match(beta, /CHANGELOG\.md packages\/solid\/query\/package\.json \| sort > "\$CORRECTIVE_PATHS"/) + assert.match(stable, /@effectify\/solid-query\|packages\/solid\/query\/package\.json\|0\.5\.13-beta\.0\|0\.5\.13/) + assert.doesNotMatch(stable, /@effectify\/solid-query\|packages\/solid\/query\/package\.json\|0\.5\.12-beta\.0\|0\.5\.12/) + + for (const [name, before, after, required] of [ + ["arbitrary singleton", 'elif [ "$SELECTED_PROJECTS" = "@effectify/solid-query" ]', 'elif [ "$SELECTED_PROJECTS" = "@effectify/react-query" ]', /SELECTED_PROJECTS" = "@effectify\/solid-query/], + ["prerelease specifier", "version_specifier=prepatch", "version_specifier=prerelease", /version_specifier=prepatch/], + ["wrong target", "CORRECTIVE_TRANSITION='@effectify/solid-query=0.5.12-beta.0=0.5.13-beta.0", "CORRECTIVE_TRANSITION='@effectify/solid-query=0.5.12-beta.0=0.5.14-beta.0", /CORRECTIVE_TRANSITION='@effectify\/solid-query=0\.5\.12-beta\.0=0\.5\.13-beta\.0/], + ["wrong counter", "CORRECTIVE_TRANSITION='@effectify/solid-query=0.5.12-beta.0=0.5.13-beta.0", "CORRECTIVE_TRANSITION='@effectify/solid-query=0.5.12-beta.0=0.5.13-beta.1", /CORRECTIVE_TRANSITION='@effectify\/solid-query=0\.5\.12-beta\.0=0\.5\.13-beta\.0/], + ["broad paths", "CHANGELOG.md packages/solid/query/package.json | sort", "CHANGELOG.md README.md packages/solid/query/package.json | sort", /CHANGELOG\.md packages\/solid\/query\/package\.json \| sort/], + ["message-only", 'if cmp -s "$CORRECTIVE_PATHS" "$CHANGED"; then', 'if [[ "$HEAD_MESSAGE" == *"[skip release]"* ]]; then', /cmp -s "\$CORRECTIVE_PATHS" "\$CHANGED"/], + ]) { + const mutated = mutate(beta, before, after) + assert.doesNotMatch(mutated, required, name) + } + const oldStable = mutate(stable, "0.5.13-beta.0|0.5.13", "0.5.12-beta.0|0.5.12") + assert.doesNotMatch(oldStable, /0\.5\.13-beta\.0\|0\.5\.13/) +}) + test("alpha and beta exact-range and membership mutations fail closed", () => { const policy = { ...workflows, docs: readme }