diff --git a/.github/SETUP.md b/.github/SETUP.md index 3ab72ed0..ca0b7b47 100644 --- a/.github/SETUP.md +++ b/.github/SETUP.md @@ -1,6 +1,6 @@ # CI and npm release setup -Effectify has three intentionally separate release channels. Alpha and beta are branch-driven prereleases; stable publication is always a manual decision. +Effectify has three separate release channels. Beta now prepares a reviewable branch before a maintainer explicitly finalizes the merged release; alpha and stable behavior is unchanged. ## Release channel map @@ -61,11 +61,34 @@ Manual publish-only recovery requires an explicit comma-separated `projects` inp ### Beta: `.github/workflows/cd.yml` -**Triggers:** pushes to `master` and optional manual dispatch. +**Triggers:** pushes to `master` and manual dispatch. A normal eligible push prepares, but does not publish, a verified `release/beta-<12-character-source-sha>` branch. A beta release-shaped merge is suppressed structurally; `chore(release):` and `[skip release]` are defense-in-depth signals, not sufficient suppression by themselves. -A normal run uses the same exact push-range and exact-membership affected-project policy as alpha, versions with Nx `--preid=beta`, and publishes with npm `--tag=beta`. It never publishes to npm's default tag. Pushes whose head commit contains `chore(release):` or `[skip release]` are skipped, preventing release-commit recursion. +#### Beta quick path -Manual publish-only recovery requires explicit existing project names and still publishes with `--tag=beta`; it skips version, changelog, and git mutation. +1. Let an eligible `master` push run PREPARE. For this incident only, manual PREPARE must select all seven projects listed above. +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. +5. Copy the resulting current 40-character lowercase `master` SHA. Manually dispatch `publish_only=true` with the exact projects and that SHA as `expected_sha`. +6. Verify every exact annotated tag, non-draft GitHub prerelease, npm version, and `beta` dist-tag. + +FINALIZE freshly checks that checkout `HEAD`, `origin/master`, and `expected_sha` are equal. It verifies or creates exact annotated tags with one atomic tag-only push, verifies or creates exact prereleases, publishes only missing npm packages through Nx with `--tag=beta`, and post-verifies every selected npm beta. Exact publish-only recovery retries are safe; unknown or conflicting external state stops the run. + +#### Authorized incident matrix + +| Package | Required beta version | +| ------------------------------------- | --------------------- | +| `@effectify/react-router` | `0.6.0-beta.0` | +| `@effectify/react-query` | `1.0.0-beta.1` | +| `@effectify/node-better-auth` | `0.5.12-beta.0` | +| `@effectify/solid-query` | `0.5.12-beta.0` | +| `@effectify/react-router-better-auth` | `0.5.12-beta.0` | +| `@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. + +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. ### Stable: `.github/workflows/release-stable.yml` @@ -116,9 +139,8 @@ pnpm nx run @effectify/react-router-example:consolidation:verify ## Recovery checklist -- Confirm the workflow run is using the intended channel. -- Copy exact project names from the seven-project list above. -- For alpha or beta recovery, confirm the existing versions carry the matching prerelease suffix. -- For stable recovery, confirm every selected manifest version has no prerelease suffix. -- Use publish-only mode only to retry existing versions; use normal stable mode to graduate prereleases. -- Review the workflow summary and npm package pages after completion. +- Confirm the workflow run is using the intended channel and exact project names. +- For alpha recovery, confirm existing versions carry the alpha suffix. +- For beta recovery, follow the PREPARE β†’ approved issue β†’ linked PR β†’ protected merge β†’ exact-SHA FINALIZE path above. +- For stable recovery, confirm every selected manifest is stable and use stable's documented publish-only mode only for an existing version. +- Review workflow summaries, GitHub Releases, and npm package pages after completion. diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 3992e9bf..ad60568f 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -6,12 +6,16 @@ on: workflow_dispatch: inputs: publish_only: - description: "Publish existing beta versions without versioning, changelog, or git changes" + description: "Finalize beta artifacts from already-merged manifests" required: true type: boolean default: false projects: - description: "Comma-separated existing Nx release project names; required for publish-only recovery" + description: "Comma-separated Nx release project names" + required: false + type: string + expected_sha: + description: "Full current master SHA; required for publish-only finalization" required: false type: string @@ -25,7 +29,6 @@ env: jobs: release-beta: name: πŸš€ Release Beta - if: ${{ github.event_name == 'workflow_dispatch' || (!contains(github.event.head_commit.message, 'chore(release):') && !contains(github.event.head_commit.message, '[skip release]')) }} runs-on: ubuntu-latest permissions: contents: write @@ -69,38 +72,56 @@ jobs: - name: πŸ›‘οΈ Verify release policy contract run: node --test scripts/release-policy-contract.test.mjs - - name: πŸ”§ Configure Git - if: ${{ inputs.publish_only != true }} - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - - - name: πŸ” Detect Affected Release Projects - id: affected + - name: 🧭 Resolve beta mode and projects + id: release env: + EVENT_NAME: ${{ github.event_name }} PUBLISH_ONLY: ${{ inputs.publish_only || false }} - RECOVERY_PROJECTS: ${{ inputs.projects || '' }} + REQUESTED_PROJECTS: ${{ inputs.projects || '' }} + EXPECTED_SHA: ${{ inputs.expected_sha || '' }} BEFORE_SHA: ${{ github.event.before }} HEAD_SHA: ${{ github.sha }} + HEAD_MESSAGE: ${{ github.event.head_commit.message || '' }} run: | + set -euo pipefail RELEASE_PROJECTS=$( jq -r '.release.projects[]' nx.json | while read -r path; do pnpm nx show project "$path" --json | jq -r '.name' done | jq -Rsc 'split("\n") | map(select(length > 0)) | unique' ) + ALL_PROJECTS=$(printf '%s' "$RELEASE_PROJECTS" | jq -r '.[]' | sort) + test "$(printf '%s\n' "$ALL_PROJECTS" | sed '/^$/d' | wc -l | tr -d ' ')" = "7" - if [ "$PUBLISH_ONLY" = "true" ]; then - if [ -z "$RECOVERY_PROJECTS" ]; then - echo "publish-only recovery requires an explicit comma-separated projects input" >&2 - exit 1 - fi - SELECTED_PROJECTS=$(printf '%s' "$RECOVERY_PROJECTS" | tr ',' '\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | sed '/^$/d' | sort -u) + select_requested_projects() { + test -n "$REQUESTED_PROJECTS" || { echo "manual beta runs require explicit projects" >&2; exit 1; } + RAW_PROJECTS=$(printf '%s' "$REQUESTED_PROJECTS" | tr ',' '\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | sed '/^$/d') + test -n "$RAW_PROJECTS" || { echo "manual beta runs require nonempty projects" >&2; exit 1; } + DUPLICATES=$(printf '%s\n' "$RAW_PROJECTS" | sort | uniq -d) + test -z "$DUPLICATES" || { echo "duplicate release project: $DUPLICATES" >&2; exit 1; } + SELECTED_PROJECTS=$(printf '%s\n' "$RAW_PROJECTS" | sort) while IFS= read -r project; do - if ! printf '%s\n' "$RELEASE_PROJECTS" | jq -r '.[]' | grep -Fx -- "$project" >/dev/null; then + printf '%s\n' "$ALL_PROJECTS" | grep -Fx -- "$project" >/dev/null || { echo "Invalid release project: $project" >&2 exit 1 - fi + } done <<< "$SELECTED_PROJECTS" + } + + if [ "$EVENT_NAME" = "workflow_dispatch" ]; then + select_requested_projects + if [ "$PUBLISH_ONLY" = "true" ]; then + [[ "$EXPECTED_SHA" =~ ^[0-9a-f]{40}$ ]] || { + echo "FINALIZE requires a full lowercase expected_sha" >&2 + exit 1 + } + 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 + exit 1 + } + echo "mode=prepare" >> "$GITHUB_OUTPUT" + fi echo "has_projects=true" >> "$GITHUB_OUTPUT" echo "projects=$(printf '%s' "$SELECTED_PROJECTS" | paste -sd, -)" >> "$GITHUB_OUTPUT" exit 0 @@ -120,30 +141,81 @@ jobs: BASE="$HEAD" fi + MANIFESTS=$(mktemp) + while IFS= read -r project; do + DATA=$(pnpm nx show project "$project" --json) + ROOT=$(printf '%s' "$DATA" | jq -er '.root | select(length > 0)') + printf '%s/package.json\n' "$ROOT" + done <<< "$ALL_PROJECTS" | sort -u > "$MANIFESTS" + CHANGED=$(mktemp) + git diff --name-only --no-renames "$BASE" "$HEAD" | sort -u > "$CHANGED" + + HAS_CHANGELOG=false + UNEXPECTED=false + MANIFEST_CHANGES=0 + BETA_TRANSITIONS=0 + while IFS= read -r path; do + if [ "$path" = "CHANGELOG.md" ]; then + HAS_CHANGELOG=true + elif grep -Fx -- "$path" "$MANIFESTS" >/dev/null; then + MANIFEST_CHANGES=$((MANIFEST_CHANGES + 1)) + OLD_VERSION=$(git show "$BASE:$path" | jq -er '.version') + NEW_VERSION=$(jq -er '.version' "$path") + if [ "$OLD_VERSION" != "$NEW_VERSION" ] && [[ "$NEW_VERSION" =~ -beta\.[0-9]+$ ]]; then + BETA_TRANSITIONS=$((BETA_TRANSITIONS + 1)) + fi + else + UNEXPECTED=true + if [[ "$path" == */package.json ]] && [ -f "$path" ]; then + NEW_VERSION=$(jq -er '.version' "$path") + [[ "$NEW_VERSION" =~ -beta\.[0-9]+$ ]] && BETA_TRANSITIONS=$((BETA_TRANSITIONS + 1)) + fi + fi + done < "$CHANGED" + + if [ "$HAS_CHANGELOG" = "true" ] && [ "$UNEXPECTED" = "false" ] && [ "$BETA_TRANSITIONS" -gt 0 ] && [ "$BETA_TRANSITIONS" -eq "$MANIFEST_CHANGES" ]; then + echo "mode=suppress" >> "$GITHUB_OUTPUT" + echo "has_projects=false" >> "$GITHUB_OUTPUT" + echo "projects=" >> "$GITHUB_OUTPUT" + exit 0 + fi + if [[ "$HEAD_MESSAGE" == *"chore(release):"* || "$HEAD_MESSAGE" == *"[skip release]"* ]] || [ "$BETA_TRANSITIONS" -gt 0 ]; then + echo "suspicious release-shaped master push; refusing preparation" >&2 + exit 1 + fi + AFFECTED_RAW=$(pnpm nx show projects --affected --base="$BASE" --head="$HEAD" --json 2>/dev/null || echo "[]") AFFECTED_RELEASE_PROJECTS=$(echo "$AFFECTED_RAW" | jq -r --argjson release "$RELEASE_PROJECTS" '[.[] | select(. as $project | $release | index($project))] | unique | join(",")' 2>/dev/null || echo "") if [ -z "$AFFECTED_RELEASE_PROJECTS" ] || [ "$AFFECTED_RELEASE_PROJECTS" = "null" ]; then + echo "mode=prepare" >> "$GITHUB_OUTPUT" echo "has_projects=false" >> "$GITHUB_OUTPUT" echo "projects=" >> "$GITHUB_OUTPUT" else + echo "mode=prepare" >> "$GITHUB_OUTPUT" echo "has_projects=true" >> "$GITHUB_OUTPUT" echo "projects=$AFFECTED_RELEASE_PROJECTS" >> "$GITHUB_OUTPUT" fi - - name: πŸ—οΈ Build Affected Projects - if: ${{ steps.affected.outputs.has_projects == 'true' }} + - name: πŸ”§ Configure Git for PREPARE + if: ${{ steps.release.outputs.mode == 'prepare' && steps.release.outputs.has_projects == 'true' }} + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: πŸ—οΈ Build selected projects + if: ${{ steps.release.outputs.mode != 'suppress' && steps.release.outputs.has_projects == 'true' }} env: - PROJECTS: ${{ steps.affected.outputs.projects }} + PROJECTS: ${{ steps.release.outputs.projects }} run: pnpm nx run-many -t build "--projects=$PROJECTS" --parallel=3 - - name: πŸ§ͺ Test Affected Projects - if: ${{ steps.affected.outputs.has_projects == 'true' }} + - name: πŸ§ͺ Test selected projects + if: ${{ steps.release.outputs.mode != 'suppress' && steps.release.outputs.has_projects == 'true' }} env: - PROJECTS: ${{ steps.affected.outputs.projects }} + PROJECTS: ${{ steps.release.outputs.projects }} run: pnpm nx run-many -t test "--projects=$PROJECTS" --parallel=3 --passWithNoTests - name: βœ… Verify React Router 8 readiness - if: ${{ steps.affected.outputs.has_projects == 'true' }} + if: ${{ steps.release.outputs.mode != 'suppress' && steps.release.outputs.has_projects == 'true' }} run: | pnpm nx test @effectify/react-router pnpm nx run @effectify/react-router-example:migration:test @@ -151,41 +223,252 @@ jobs: pnpm nx run @effectify/react-router-example:migration:manifest pnpm nx run @effectify/react-router-example:consolidation:verify - - name: πŸ” Verify npm authentication - if: ${{ steps.affected.outputs.has_projects == 'true' }} - run: npm whoami + - name: 🌿 PREPARE verified beta branch + id: prepare + if: ${{ steps.release.outputs.mode == 'prepare' && steps.release.outputs.has_projects == 'true' }} + env: + PROJECTS: ${{ steps.release.outputs.projects }} + MANUAL_PREPARE: ${{ github.event_name == 'workflow_dispatch' }} + run: | + set -euo pipefail + SOURCE_SHA=$(git rev-parse HEAD) + SOURCE_REF="$GITHUB_REF" + SHA_PREFIX=${SOURCE_SHA:0:12} + RELEASE_BRANCH="release/beta-$SHA_PREFIX" + git switch --create "$RELEASE_BRANCH" + REFS_BEFORE=$(git for-each-ref --format='%(refname) %(objectname)' refs/heads refs/tags | sort) + + RELEASE_PATHS=("CHANGELOG.md") + RECORDS=$(mktemp) + IFS=',' read -ra SELECTED <<< "$PROJECTS" + for project in "${SELECTED[@]}"; do + DATA=$(pnpm nx show project "$project" --json) + ROOT=$(printf '%s' "$DATA" | jq -er '.root | select(length > 0)') + NAME=$(printf '%s' "$DATA" | jq -er '.name | select(length > 0)') + MANIFEST="$ROOT/package.json" + RELEASE_PATHS+=("$MANIFEST") + printf '%s\t%s\t%s\n' "$project" "$NAME" "$MANIFEST" >> "$RECORDS" + done + 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 + + verify_prepared_tree() { + ACTUAL_PATHS=$(mktemp) + { git diff --name-only --no-renames HEAD; git ls-files --others --exclude-standard; } | sort -u > "$ACTUAL_PATHS" + cmp -s /tmp/expected-release-paths "$ACTUAL_PATHS" || { + echo "PREPARE changed paths differ from selected manifests plus root CHANGELOG.md" >&2 + diff -u /tmp/expected-release-paths "$ACTUAL_PATHS" >&2 || true + exit 1 + } + test "$REFS_BEFORE" = "$(git for-each-ref --format='%(refname) %(objectname)' refs/heads refs/tags | sort)" || { + echo "PREPARE materialization changed a git ref" >&2 + exit 1 + } + while IFS=$'\t' read -r project name manifest; do + VERSION=$(jq -er '.version' "$manifest") + [[ "$VERSION" =~ -beta\.[0-9]+$ ]] || { echo "$name did not materialize a beta version" >&2; exit 1; } + done < "$RECORDS" + } + verify_prepared_tree + + 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' > "$EXPECTED_MATRIX" + ACTUAL_MATRIX=$(mktemp) + while IFS=$'\t' read -r project name manifest; do + printf '%s=%s\n' "$name" "$(jq -er '.version' "$manifest")" + done < "$RECORDS" | sort > "$ACTUAL_MATRIX" + cmp -s "$EXPECTED_MATRIX" "$ACTUAL_MATRIX" || { echo "incident package/version matrix changed" >&2; exit 1; } + fi + + pnpm nx run-many -t build "--projects=$PROJECTS" --parallel=3 + verify_prepared_tree + mapfile -t RELEASE_PATHS < /tmp/expected-release-paths + git add -- "${RELEASE_PATHS[@]}" + git diff --cached --name-only --no-renames | sort -u > /tmp/staged-release-paths + cmp -s /tmp/expected-release-paths /tmp/staged-release-paths || { echo "staged release paths differ" >&2; exit 1; } + git diff --quiet + test -z "$(git ls-files --others --exclude-standard)" + verify_prepared_tree + git commit -m "chore(release): prepare beta from $SOURCE_SHA [skip release]" + test -z "$(git status --porcelain)" + git push origin "HEAD:refs/heads/release/beta-$SHA_PREFIX" + VERSIONS=$(while IFS=$'\t' read -r project name manifest; do printf '%s=%s\n' "$name" "$(jq -er '.version' "$manifest")"; done < "$RECORDS" | paste -sd, -) + CHANGED_PATHS=$(paste -sd, /tmp/expected-release-paths) + echo "source_sha=$SOURCE_SHA" >> "$GITHUB_OUTPUT" + echo "source_ref=$SOURCE_REF" >> "$GITHUB_OUTPUT" + echo "branch=$RELEASE_BRANCH" >> "$GITHUB_OUTPUT" + echo "versions=$VERSIONS" >> "$GITHUB_OUTPUT" + echo "changed_paths=$CHANGED_PATHS" >> "$GITHUB_OUTPUT" + + - name: πŸ” Verify npm authentication for FINALIZE + if: ${{ steps.release.outputs.mode == 'finalize' && steps.release.outputs.has_projects == 'true' }} env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: npm whoami - - name: πŸš€ Version, Changelog & Publish Beta - if: ${{ steps.affected.outputs.has_projects == 'true' }} + - name: πŸš€ FINALIZE exact merged beta + if: ${{ steps.release.outputs.mode == 'finalize' && steps.release.outputs.has_projects == 'true' }} env: - PROJECTS: ${{ steps.affected.outputs.projects }} - PUBLISH_ONLY: ${{ inputs.publish_only || false }} + PROJECTS: ${{ steps.release.outputs.projects }} + EXPECTED_SHA: ${{ inputs.expected_sha }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NPM_CONFIG_PROVENANCE: true run: | - if [ "$PUBLISH_ONLY" != "true" ]; then - pnpm nx release "--projects=$PROJECTS" --preid=beta --skip-publish - pnpm nx run-many -t build "--projects=$PROJECTS" --parallel=3 + set -euo pipefail + git fetch origin master:refs/remotes/origin/master --no-tags + HEAD_SHA=$(git rev-parse HEAD) + REMOTE_SHA=$(git rev-parse origin/master) + test "$HEAD_SHA" = "$EXPECTED_SHA" + test "$REMOTE_SHA" = "$EXPECTED_SHA" + + RECORDS=$(mktemp) + IFS=',' read -ra SELECTED <<< "$PROJECTS" + for PROJECT in "${SELECTED[@]}"; do + DATA=$(pnpm nx show project "$PROJECT" --json) + ROOT=$(printf '%s' "$DATA" | jq -er '.root | select(length > 0)') + MANIFEST="$ROOT/package.json" + NAME=$(jq -er '.name | select(length > 0)' "$MANIFEST") + VERSION=$(jq -er '.version | select(length > 0)' "$MANIFEST") + test "$VERSION" != "${VERSION%-beta.*}" + [[ "$VERSION" =~ -beta\.[0-9]+$ ]] + TAG="$NAME@$VERSION" + printf '%s\t%s\t%s\t%s\t%s\n' "$PROJECT" "$MANIFEST" "$NAME" "$VERSION" "$TAG" >> "$RECORDS" + done + test "$(sort -u -k3,3 "$RECORDS" | wc -l | tr -d ' ')" = "${#SELECTED[@]}" + + : > /tmp/missing-projects + while IFS=$'\t' read -r PROJECT MANIFEST NAME VERSION TAG; do + VERSIONS_JSON=$(npm view "$NAME" versions --json) || { echo "unknown npm history for $NAME" >&2; exit 1; } + printf '%s' "$VERSIONS_JSON" | jq -e 'type == "array" or type == "string"' >/dev/null + BETA_TAG=$(npm view "$NAME" dist-tags.beta --json | jq -r 'if . == null then "" else . end') || { + echo "unknown npm beta tag for $NAME" >&2 + exit 1 + } + if printf '%s' "$VERSIONS_JSON" | jq -e --arg version "$VERSION" 'if type == "array" then index($version) != null else . == $version end' >/dev/null; then + test "$BETA_TAG" = "$VERSION" || { echo "conflicting npm beta state for $NAME" >&2; exit 1; } + else + printf '%s\n' "$PROJECT" >> /tmp/missing-projects + fi + done < "$RECORDS" + + verify_tags() { + MODE=$1 + [ "$MODE" != "collect" ] || : > /tmp/missing-tags + while IFS=$'\t' read -r PROJECT MANIFEST NAME VERSION TAG; do + REMOTE_TAGS=$(git ls-remote --tags origin "refs/tags/$TAG" "refs/tags/$TAG^{}") || { + echo "unknown remote tag state for $TAG" >&2 + exit 1 + } + if [ -z "$REMOTE_TAGS" ]; then + [ "$MODE" = "collect" ] || { echo "missing pushed tag $TAG" >&2; exit 1; } + printf '%s\n' "$TAG" >> /tmp/missing-tags + continue + fi + DIRECT_COUNT=$(printf '%s\n' "$REMOTE_TAGS" | grep -c $'\trefs/tags/'"$TAG"'$' || true) + PEELED_COUNT=$(printf '%s\n' "$REMOTE_TAGS" | grep -c $'\trefs/tags/'"$TAG"'\^{}$' || true) + PEELED_SHA=$(printf '%s\n' "$REMOTE_TAGS" | awk -v ref="refs/tags/$TAG^{}" '$2 == ref { print $1 }') + test "$DIRECT_COUNT" = "1" && test "$PEELED_COUNT" = "1" && test "$PEELED_SHA" = "$EXPECTED_SHA" || { + echo "tag must be uniquely annotated at expected_sha: $TAG" >&2 + exit 1 + } + done < "$RECORDS" + } + verify_tags collect + + TAG_REFS=() + while IFS= read -r TAG; do + [ -n "$TAG" ] || continue + ! git show-ref --verify --quiet "refs/tags/$TAG" || { echo "conflicting local tag $TAG" >&2; exit 1; } + 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 - # Every beta publish is explicitly non-default, including recovery. - pnpm nx release publish "--projects=$PROJECTS" --tag=beta + verify_tags verify + + verify_releases() { + MODE=$1 + [ "$MODE" != "collect" ] || : > /tmp/missing-releases + while IFS=$'\t' read -r PROJECT MANIFEST NAME VERSION TAG; do + set +e + RELEASE_JSON=$(gh release view "$TAG" --json tagName,isDraft,isPrerelease 2>/tmp/gh-release-error) + STATUS=$? + set -e + if [ "$STATUS" = "0" ]; then + printf '%s' "$RELEASE_JSON" | jq -e --arg tag "$TAG" '.tagName == $tag and .isDraft == false and .isPrerelease == true' >/dev/null || { + echo "conflicting GitHub Release for $TAG" >&2 + exit 1 + } + elif [ "$STATUS" = "1" ] && grep -Fqi "release not found" /tmp/gh-release-error; then + [ "$MODE" = "collect" ] || { echo "missing GitHub prerelease $TAG" >&2; exit 1; } + printf '%s\n' "$TAG" >> /tmp/missing-releases + else + echo "unknown GitHub Release state for $TAG" >&2 + exit 1 + fi + done < "$RECORDS" + } + verify_releases collect + while IFS= read -r TAG; do + [ -n "$TAG" ] || continue + gh release create "$TAG" --verify-tag --prerelease --generate-notes + done < /tmp/missing-releases + verify_releases verify - - name: πŸ“Š Release Summary + MISSING_PROJECTS=$(paste -sd, /tmp/missing-projects) + if [ -n "$MISSING_PROJECTS" ]; then + PROJECTS="$MISSING_PROJECTS" + pnpm nx release publish "--projects=$PROJECTS" --tag=beta + fi + + echo "post-verify npm beta state" + while IFS=$'\t' read -r PROJECT MANIFEST NAME VERSION TAG; do + VERSIONS_JSON=$(npm view "$NAME" versions --json) || { echo "unknown post-publish npm history for $NAME" >&2; exit 1; } + printf '%s' "$VERSIONS_JSON" | jq -e --arg version "$VERSION" 'if type == "array" then index($version) != null else . == $version end' >/dev/null + BETA_TAG=$(npm view "$NAME" dist-tags.beta --json | jq -er --arg version "$VERSION" 'select(. == $version)') || { + echo "npm beta tag did not converge for $NAME" >&2 + exit 1 + } + done < "$RECORDS" + + - name: πŸ“Š Release summary if: always() env: - HAS_PROJECTS: ${{ steps.affected.outputs.has_projects }} - PROJECTS: ${{ steps.affected.outputs.projects }} - PUBLISH_ONLY: ${{ inputs.publish_only || false }} + MODE: ${{ steps.release.outputs.mode || 'failed' }} + HAS_PROJECTS: ${{ steps.release.outputs.has_projects || 'false' }} + PROJECTS: ${{ steps.release.outputs.projects || '' }} + SOURCE_SHA: ${{ steps.prepare.outputs.source_sha || '' }} + SOURCE_REF: ${{ steps.prepare.outputs.source_ref || '' }} + RELEASE_BRANCH: ${{ steps.prepare.outputs.branch || '' }} + SELECTED_VERSIONS: ${{ steps.prepare.outputs.versions || '' }} + CHANGED_PATHS: ${{ steps.prepare.outputs.changed_paths || '' }} + EXPECTED_SHA: ${{ inputs.expected_sha || '' }} run: | echo "## πŸš€ Beta Release Summary" >> "$GITHUB_STEP_SUMMARY" - if [ "$HAS_PROJECTS" = "true" ]; then - echo "**Projects:** $PROJECTS" >> "$GITHUB_STEP_SUMMARY" - if [ "$PUBLISH_ONLY" = "true" ]; then - echo "**Mode:** publish-only recovery; selected existing manifests were built and published with the beta tag." >> "$GITHUB_STEP_SUMMARY" - fi + echo "**Mode:** $MODE" >> "$GITHUB_STEP_SUMMARY" + echo "**Projects:** ${PROJECTS:-none}" >> "$GITHUB_STEP_SUMMARY" + if [ "$MODE" = "prepare" ] && [ "$HAS_PROJECTS" = "true" ]; then + echo "**Source:** $SOURCE_SHA ($SOURCE_REF)" >> "$GITHUB_STEP_SUMMARY" + echo "**Prepared branch:** $RELEASE_BRANCH" >> "$GITHUB_STEP_SUMMARY" + echo "**Selected versions:** $SELECTED_VERSIONS" >> "$GITHUB_STEP_SUMMARY" + echo "**Changed paths:** $CHANGED_PATHS" >> "$GITHUB_STEP_SUMMARY" + echo 'Create or reuse an approved issue, then manually open one linked PR to protected master; its sole `type:*` label is `type:chore`.' >> "$GITHUB_STEP_SUMMARY" + elif [ "$MODE" = "suppress" ]; then + echo "Structurally exact beta release output detected; preparation and publication were suppressed." >> "$GITHUB_STEP_SUMMARY" + elif [ "$MODE" = "finalize" ]; then + echo "**Expected master SHA:** $EXPECTED_SHA" >> "$GITHUB_STEP_SUMMARY" else - echo "⏭️ **Skipped - No affected projects**" >> "$GITHUB_STEP_SUMMARY" + echo "No selected release projects or mode resolution failed." >> "$GITHUB_STEP_SUMMARY" fi diff --git a/openspec/changes/protect-branch-release-orchestration/apply-progress.md b/openspec/changes/protect-branch-release-orchestration/apply-progress.md new file mode 100644 index 00000000..1a602bba --- /dev/null +++ b/openspec/changes/protect-branch-release-orchestration/apply-progress.md @@ -0,0 +1,66 @@ +# Apply Progress: Protect Branch Release Orchestration + +## Status + +- **Phase:** apply remediation +- **Result:** bounded verification findings closed +- **Next owner:** parent lifecycle +- **Action context:** repo-local; no workflow dispatch, commit, push, ref/tag, PR, issue, Release, or npm mutation occurred. +- **Delivery boundary:** one PR, no new file/module/dependency, 1,168 total changed lines (794 tracked implementation/docs plus 374 new OpenSpec lines), including all six artifacts and below the 1,200 ceiling. + +## Remediation completed + +1. **OpenSpec formatting:** pinned oxfmt formatted all six markdown artifacts. The uncommitted Nx lint command now passes without excluding or relocating them. +2. **Executable fail-closed contract:** PREPARE and FINALIZE reject heredoc ambiguity, literal statically dead wrappers, and unused shell functions. Unknown tag, conflicting npm, and unknown Release branches are each tied to their active `exit 1`; mutations neutralize those exits rather than changing diagnostics. +3. **Summary/runbook alignment:** PREPARE exports and summarizes exact selected package versions and changed paths. Workflow summary and SETUP state that the linked release PR's sole `type:*` label is `type:chore`. + +Parent-owned checklists remain unchanged. + +## Strict TDD evidence + +| Phase | Evidence | +| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| RED | After semantic contract edits and before workflow/docs remediation, `node --test scripts/release-policy-contract.test.mjs` failed 4/14: the three named beta contracts reported heredoc/summary/sole-label violations, and SETUP lacked the sole-label statement. | +| GREEN | Replaced the PREPARE heredoc with active `printf`, added existing-value outputs/summary fields, and aligned SETUP; the full contract passed 14/14. | +| TRIANGULATE | Heredoc, `if false`, unused-function, unknown-tag-exit, conflicting-npm-exit, and unknown-Release-exit mutations are exercised against the real parsed workflow and fail closed; the full suite remains 14/14. | +| REFACTOR | Pinned oxfmt passes for the contract, SETUP, and all six OpenSpec artifacts; no helper file or dependency was added. | + +## Validation + +| Command | Result | +| ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| `node --test scripts/release-policy-contract.test.mjs` | PASS, 14/14. | +| `ruby -e 'require "yaml"; YAML.parse(File.read(".github/workflows/cd.yml"))'` | PASS. | +| `pnpm nx affected --target=lint --uncommitted --parallel=1` | PASS for `@effectify/repo`; existing unrelated warnings were non-fatal, and changed-file formatting passed. | +| `pnpm nx affected --target=typecheck --uncommitted --parallel=1` | PASS, no tasks selected. | +| `pnpm nx affected --target=build --uncommitted --parallel=1` | PASS, no tasks selected. | +| `pnpm nx affected --target=test --uncommitted --parallel=1 --passWithNoTests` | PASS, no tasks selected. | +| Pinned oxfmt check for the contract, SETUP, and all six OpenSpec artifacts | PASS, 8/8 files. | +| `git diff --check` | PASS. | + +## Changed files + +| File | Remediation result | +| ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | +| `.github/workflows/cd.yml` | Active matrix construction; exact version/path outputs; sole-label summary. | +| `scripts/release-policy-contract.test.mjs` | Executability restrictions, exact active-exit blocks, semantic adverse mutations, summary/runbook assertions. | +| `.github/SETUP.md` | Sole `type:*` label requirement. | +| Six `openspec/changes/protect-branch-release-orchestration/**/*.md` artifacts | Pinned oxfmt; this file also records remediation evidence. | + +## Work-unit boundary + +- **Runtime harness:** N/A because exercising the release boundary would mutate branches, tags, Releases, or npm. +- **Rollback:** revert only `.github/workflows/cd.yml`, `scripts/release-policy-contract.test.mjs`, and `.github/SETUP.md`; OpenSpec edits are formatting/evidence only. +- **Engram:** not written because no validated project name was supplied for project-scoped memory. + +## Remaining parent-owned tasks + +- [ ] Create or reuse the repository-required implementation issue, obtain approval, and link the exact three-file scope and 1,200-line ceiling. +- [ ] Open, review, approve, and merge exactly one implementation PR containing the single work-unit commit and its recorded checks; reject extra files, dependencies, modules, release dispatches, or a size exception. +- [ ] Promote the merged implementation to protected `master` through ordinary repository policy, without dispatching beta or stable during the promotion itself. +- [ ] Manually dispatch incident PREPARE with all seven exact Nx projects, then verify the reported release branch, changed paths, and authorized versions before continuing. +- [ ] Create or reuse and approve the required release issue for the prepared branch, with no workflow automation applying approval. +- [ ] Manually open, review, approve, and merge one generated linked `type:chore` release PR from the reported branch to `master`, then verify the resulting beta workflow run structurally suppresses preparation and publishes nothing. +- [ ] Provide fresh publication authorization bound to the resulting current full `master` merge SHA as `expected_sha` and the exact seven-project selection; stop if `master` moves. +- [ ] Manually dispatch `publish_only=true` with the authorized projects and fresh `expected_sha`; do not dispatch stable. +- [ ] Verify all seven exact annotated tags target `expected_sha`, all seven GitHub Releases are non-draft prereleases, and npm contains the seven authorized incident versions with each `beta` dist-tag exact. diff --git a/openspec/changes/protect-branch-release-orchestration/design.md b/openspec/changes/protect-branch-release-orchestration/design.md new file mode 100644 index 00000000..3058a829 --- /dev/null +++ b/openspec/changes/protect-branch-release-orchestration/design.md @@ -0,0 +1,69 @@ +# Design: Two-Mode Beta Release Workflow + +## Boundaries + +One implementation PR (maximum 1,200 total changed lines) changes only: + +| File | Responsibility | +| ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------- | +| `.github/workflows/cd.yml` | PREPARE/SUPPRESS/FINALIZE orchestration inside the existing beta job. | +| `scripts/release-policy-contract.test.mjs` | Parsed static contracts and negative mutations for those boundaries; existing alpha/stable results stay unchanged. | +| `.github/SETUP.md` | Maintainer prepare β†’ approved issue β†’ linked sole-`type:chore` PR β†’ merge β†’ exact-SHA finalize/retry runbook and incident matrix. | + +No workflow/module/dependency is added. Stable, alpha, custom platform work, automatic issue/PR creation, branch bypass, and local publication are excluded. + +## State resolution + +The existing job retains triggers, concurrency, permissions, affected detection, allowlist, builds/tests, React Router readiness, collision checks, provenance, and summaries. A resolver emits one mode: + +```text +master push: exact release structure -> SUPPRESS +master push: suspicious token/beta shape -> fail +master push: ordinary affected change -> PREPARE +workflow_dispatch publish_only=false: exact incident selection -> PREPARE +workflow_dispatch publish_only=true: projects + expected_sha -> FINALIZE +``` + +Configured projects come from `nx.json.release.projects`. For each selected project, `pnpm nx show project "$project" --json` supplies root and package identity; normalized exact-set matching rejects empty, duplicate, unknown, or substring matches. + +## SUPPRESS + +Before affected selection, compare base/head paths and manifest versions. Suppress only when root `CHANGELOG.md` changed, every other path is a configured manifest, and every changed manifest makes a valid `-beta.` transition. Message tokens are defense in depth: token-only evidence or beta-shaped changes with missing/unexpected paths fail. SUPPRESS performs no preparation or publication. + +## PREPARE + +PREPARE follows existing policy, selected build/test, readiness, and collision-aware registry gates but receives no publishing credentials. One strict shell boundary: + +1. Capture source SHA and ref/tag snapshots; create `release/beta-<12-char-source-sha>` locally. +2. Run `pnpm nx release version "--projects=$PROJECTS" --preid=beta --git-commit=false --git-tag=false --git-push=false --stage-changes=false`. +3. Require beta versions and an exact sorted diff of selected manifests plus root `CHANGELOG.md`; require unchanged refs/tags. Manual incident mode also verifies all seven authorized pairs. +4. Rebuild, repeat diff/ref checks, stage only the exact set, and require an exact index with no remainder. +5. Commit `chore(release): prepare beta from [skip release]` and push only `HEAD:refs/heads/release/beta-<12-char-source-sha>` without force or tag options. + +The summary reports source, branch, selected versions, and paths, then instructs a maintainer to obtain issue approval and manually open one linked PR to `master` whose sole `type:*` classification is `type:chore`. PREPARE cannot create issues, PRs, tags, Releases, npm artifacts, or update `master`. + +## FINALIZE + +FINALIZE runs only for manual `publish_only=true`. Require a unique non-empty allowlisted project set and a 40-character lowercase `expected_sha`. Before mutation: + +1. Fetch `master` freshly and require HEAD, `origin/master`, and `expected_sha` to be identical. +2. Read package names and beta versions from selected merged manifests and derive exact `@` tags. +3. Rerun build/test/readiness and npm authentication; read complete npm history. Exact versions are complete only when `dist-tags.beta` matches; absent versions form the missing subset; unknown or conflicting state fails. + +Only FINALIZE receives npm provenance/authentication and GitHub release credentials. + +### Tags and GitHub Releases + +Check every tag before creating one. Existing tags must be annotated and peel to `expected_sha`; missing tags are created annotated there. Push all missing tags once with `git push --atomic origin` plus explicit `refs/tags/:refs/tags/` refspecsβ€”never branches, wildcards, `--tags`, or `--follow-tags`β€”then reread them. + +After all tags verify, accept only GitHub Releases with the exact tag, non-draft state, and prerelease state. Create positively missing releases with `gh release create "$TAG" --verify-tag --prerelease --generate-notes`, then reread all releases. Conflicts and unknown reads stop before npm. + +### npm and retry + +Publish only the missing project subset with `pnpm nx release publish "--projects=$PROJECTS" --tag=beta`, then reread complete npm history and require every selected version and beta dist-tag. A retry with identical projects/manifests/SHA accepts exact tags, prereleases, and npm versions, continues missing work, never recalculates versions, and fails every conflict or uncertainty. + +## Static contract and review + +The dependency-free test parses active steps, commands, and conditions with comments removed. It asserts exact allowlist membership; structural suppression; all four Nx `false` flags; exact PREPARE paths/ref snapshots/index/branch push and absence of publication authority; FINALIZE input/SHA/manifest gates; annotated-tag checks and atomic tag-only push; exact prerelease commands; Nx beta publication ordering; npm post-verification/idempotence; and unchanged alpha/stable contracts. Negative mutations for each safety boundary must fail. + +Review in this order: resolver/suppression, PREPARE diff and sole refspec, FINALIZE SHA/tag/Release/npm ordering, parsed tests, then SETUP's happy path, seven exact incident versions, retry/stop guidance, and instruction not to dispatch stable. diff --git a/openspec/changes/protect-branch-release-orchestration/exploration.md b/openspec/changes/protect-branch-release-orchestration/exploration.md new file mode 100644 index 00000000..eb497e81 --- /dev/null +++ b/openspec/changes/protect-branch-release-orchestration/exploration.md @@ -0,0 +1,30 @@ +# Exploration: Protected Beta Release Orchestration + +## Conclusion + +Use the existing beta workflow as a two-mode state machine: PREPARE generates a reviewable release branch without release side effects; FINALIZE publishes only the exact merged release after manual approval. This preserves PR-only `master` without a coordinator, new dependency, branch-protection bypass, or local publication. + +Delivery is one implementation PR, at most 1,200 total changed lines, touching only `.github/workflows/cd.yml`, `scripts/release-policy-contract.test.mjs`, and `.github/SETUP.md`. + +## Incident and invariant + +Run `33125785457` generated a local commit and tags, then Nx's atomic push failed with GH006 because `master` requires PRs. No commit, tag, GitHub Release, or npm beta version reached a remote, so there is no partial publication to reconcile. Recovery must regenerate reviewed state and accept exactly: + +| Package | Version | +| ------------------------------------- | --------------- | +| `@effectify/react-router` | `0.6.0-beta.0` | +| `@effectify/react-query` | `1.0.0-beta.1` | +| `@effectify/node-better-auth` | `0.5.12-beta.0` | +| `@effectify/solid-query` | `0.5.12-beta.0` | +| `@effectify/react-router-better-auth` | `0.5.12-beta.0` | +| `@effectify/prisma` | `1.1.13-beta.0` | +| `@effectify/hatchet` | `0.1.0-beta.0` | + +## Minimal safe boundary + +- PREPARE retains existing selection, policy, build/test/readiness, and collision-aware version checks. It runs `nx release version` with commit, tag, push, and staging disabled; accepts only selected beta manifests plus root `CHANGELOG.md`; and pushes one explicit release branch. +- A maintainer creates and obtains approval for the required issue, then manually opens one linked release PR whose sole `type:*` classification is `type:chore`. Normal protection and review merge it. +- The merge run is suppressed by changed-path/version structure; commit text is only defense in depth. +- FINALIZE requires explicit projects and the exact current `master` SHA. It reconciles exact annotated tags by atomic tag-only push, exact GitHub prereleases, then missing npm betas through Nx, with post-verification and fail-closed retries. + +Seven npm publishes are not atomic, so exact matching artifacts count as complete, missing artifacts may continue, and conflicts or uncertain reads stop. Stable, alpha, custom release infrastructure, automated issue/PR creation, and broader platform work are out of scope. diff --git a/openspec/changes/protect-branch-release-orchestration/proposal.md b/openspec/changes/protect-branch-release-orchestration/proposal.md new file mode 100644 index 00000000..29ccc52d --- /dev/null +++ b/openspec/changes/protect-branch-release-orchestration/proposal.md @@ -0,0 +1,47 @@ +# Proposal: Minimal Beta Recovery on Protected `master` + +## Decision + +Split the existing beta job into PREPARE, structural SUPPRESS, and manual FINALIZE modes. Deliver one implementation PR, no more than 1,200 total changed lines, changing only `.github/workflows/cd.yml`, `scripts/release-policy-contract.test.mjs`, and `.github/SETUP.md`. + +## Scope + +Preserve triggers, affected-project selection, the release allowlist, build/tests, React Router readiness, collision-aware npm-history checks, permissions, concurrency, provenance, Nx publication, and summaries. + +Exclude stable and alpha changes, custom release platforms/coordinators/schemas, new dependencies, automatic issue or PR creation, local publication, package changes, and branch-protection bypass. Stable remains undispatched until separately fixed. + +## Proposed behavior + +### PREPARE + +Eligible `master` pushes and manual `publish_only=false` runs pass existing gates, then run: + +```bash +pnpm nx release version "--projects=$PROJECTS" --preid=beta \ + --git-commit=false --git-tag=false --git-push=false --stage-changes=false +``` + +Require beta versions in every selected manifest and an exact diff of those manifests plus root `CHANGELOG.md`; reject ref changes and every other path. Commit only that set and push only `HEAD:refs/heads/release/beta-`. Do not update `master` or create an issue, PR, tag, GitHub Release, or npm artifact. + +For incident recovery, manual PREPARE selects exactly these pairs: `react-router=0.6.0-beta.0`, `react-query=1.0.0-beta.1`, `node-better-auth=0.5.12-beta.0`, `solid-query=0.5.12-beta.0`, `react-router-better-auth=0.5.12-beta.0`, `prisma=1.1.13-beta.0`, and `hatchet=0.1.0-beta.0`. + +### Human authorization and SUPPRESS + +A maintainer creates and obtains approval for the required issue, then manually opens one linked release PR to `master` whose sole `type:*` classification is `type:chore`. Ordinary checks, review, and branch protection apply. On merge, exact release-output paths and beta version transitions suppress another preparation; release-message tokens are defense in depth and never sufficient alone. Merge publishes nothing. + +### FINALIZE + +Manual `publish_only=true` requires non-empty exact allowlisted `projects` and a full `expected_sha`. Freshly fetch `origin/master`; require checkout HEAD, current `origin/master`, and `expected_sha` to match; validate merged beta manifests and existing gates before mutation. + +Derive exact `{packageName}@{version}` tags. Accept only annotated tags targeting `expected_sha`; create missing annotated tags there and push all missing refs atomically with explicit tag-only refspecs. Then create or verify exact non-draft GitHub prereleases. Only afterward publish missing packages with `pnpm nx release publish "--projects=$PROJECTS" --tag=beta`, and verify exact npm versions and beta dist-tags. + +Retries never recalculate versions: matching tags, prereleases, and npm artifacts are complete; missing work continues; lightweight/wrong-target tags, release identity/state conflicts, npm conflicts, and unknown reads fail closed. + +## Acceptance + +- The implementation remains one PR, three files, and at most 1,200 changed lines. +- PREPARE has exactly the four disabled Nx git/staging effects and the exact generated-file boundary. +- Manual issue approval and the linked sole-`type:chore` PR remain mandatory. +- Structural suppression prevents release-merge recursion. +- FINALIZE is exact-project/exact-current-SHA bound and orders tags β†’ GitHub prereleases β†’ Nx beta publish β†’ npm post-verification. +- The static contract tests these boundaries; SETUP documents preparation, authorization, merge, finalization, retries, stop conditions, and the seven incident versions. diff --git a/openspec/changes/protect-branch-release-orchestration/specs/protected-release-orchestration/spec.md b/openspec/changes/protect-branch-release-orchestration/specs/protected-release-orchestration/spec.md new file mode 100644 index 00000000..b8818512 --- /dev/null +++ b/openspec/changes/protect-branch-release-orchestration/specs/protected-release-orchestration/spec.md @@ -0,0 +1,111 @@ +# Protected Beta Release Workflow Specification + +## Scope + +Delivery MUST be one implementation PR of at most 1,200 total changed lines and MUST change only `.github/workflows/cd.yml`, `scripts/release-policy-contract.test.mjs`, and `.github/SETUP.md`. Stable, alpha, custom release-platform work, dependencies, automated issue/PR creation, branch-protection bypass, and local publication are out of scope. + +## Requirements + +### Requirement: PREPARE is side-effect-free versioning + +PREPARE MUST preserve existing selection, allowlist, policy, build/test, React Router readiness, and collision-aware version gates. It MUST run `pnpm nx release version "--projects=$PROJECTS" --preid=beta --git-commit=false --git-tag=false --git-push=false --stage-changes=false`. + +#### Scenario: Gates pass + +**Given** eligible preparation and all existing gates pass; **when** Nx materializes versions; **then** the exact command and all four `false` flags MUST be used. + +#### Scenario: A gate fails + +**Given** any required result fails or is uncertain; **when** PREPARE evaluates it; **then** versioning and remote mutation MUST stop. + +### Requirement: PREPARE output and push are exact + +The diff MUST equal selected beta manifests plus root `CHANGELOG.md`; missing paths, unselected manifests, lockfiles, package changelogs, source/workflow files, other paths, or ref changes MUST fail. PREPARE MUST commit only that set and push only `HEAD:refs/heads/release/beta-`. + +#### Scenario: Exact output + +**Given** every selected manifest contains a beta prerelease and the exact path set changed; **when** refs are unchanged and validation completes; **then** PREPARE MUST commit that set and push only the explicit release-branch refspec. + +#### Scenario: Output drifts + +**Given** a version, path, or ref differs; **when** PREPARE validates output; **then** it MUST fail before commit or push and MUST NOT update `master`, tags, Releases, npm, issues, or PRs. + +### Requirement: Release authorization is manual and issue-first + +A maintainer MUST obtain approval for the repository-required issue, then manually open one linked release PR to `master`; its sole `type:*` classification MUST be `type:chore`. Required checks, human review, and branch protection MUST govern merge. + +#### Scenario: Authorization is complete + +**Given** the issue is approved and the prepared branch exists; **when** the maintainer opens the linked sole-`type:chore` release PR; **then** ordinary protected review MAY merge it. + +#### Scenario: Authorization is incomplete + +**Given** approval, linkage, sole classification, checks, or review is missing; **when** merge is considered; **then** the release MUST NOT be treated as authorized. + +### Requirement: Release merges are structurally suppressed + +A `master` push changing only root changelog and configured manifests through valid beta transitions MUST suppress preparation and publication. Commit-message tokens MAY defend in depth but MUST NOT classify a merge alone. + +#### Scenario: Exact release shape merges + +**Given** only expected release outputs and beta transitions are present; **when** the merge push runs; **then** it MUST suppress PREPARE and MUST publish nothing. + +#### Scenario: Structure is suspicious + +**Given** message-only evidence or beta-shaped output has missing or unexpected paths; **when** classification runs; **then** it MUST fail rather than suppress or prepare. + +### Requirement: FINALIZE is bound to projects and current `master` + +FINALIZE MUST be manual `publish_only=true`, require non-empty exact allowlisted `projects` plus a full `expected_sha`, freshly fetch `origin/master`, and prove checkout HEAD and current `origin/master` equal `expected_sha`. Selected merged manifests MUST contain the beta versions being finalized. + +#### Scenario: Preflight identity matches + +**Given** valid inputs and merged beta manifests; **when** all three SHA values match after fresh fetch; **then** FINALIZE MAY reconcile tags. + +#### Scenario: Identity differs + +**Given** an input, selected manifest, or SHA check is absent or mismatched; **when** preflight runs; **then** tag, GitHub Release, and npm mutation MUST stop. + +### Requirement: Tags are exact, annotated, and tag-only atomic + +Each tag MUST be `{packageName}@{version}` from a selected merged manifest. Existing tags MUST be annotated and peel to `expected_sha`; missing tags MUST be created annotated at that SHA and pushed once with `--atomic` and explicit tag-only refspecs. + +#### Scenario: Tags reconcile + +**Given** preflight passed and tags are exact or missing; **when** reconciliation runs; **then** exact tags MUST be accepted and all missing tags atomically pushed without a branch refspec. + +#### Scenario: A tag conflicts + +**Given** a tag is lightweight, wrong-target, ambiguous, or has conflicting identity; **when** it is checked; **then** FINALIZE MUST fail before GitHub Release or npm mutation. + +### Requirement: GitHub prereleases precede npm + +After every tag verifies, FINALIZE MUST create or accept only the exact non-draft GitHub prerelease for each tag. Conflicting or uncertain tag, target, identity, draft, or prerelease state MUST fail before npm. + +#### Scenario: Prereleases reconcile + +**Given** all exact tags verify; **when** GitHub Releases are read; **then** exact prereleases MUST be accepted, missing prereleases created, and all reread before npm. + +### Requirement: Nx publishes only missing exact betas + +Only after tags and prereleases verify MAY FINALIZE run `pnpm nx release publish "--projects=$PROJECTS" --tag=beta` for missing projects. It MUST post-verify every selected exact npm version and beta dist-tag. Retries MUST accept exact completed artifacts, continue missing work, never recalculate versions or intentionally republish, and fail conflicts or unknown state. + +#### Scenario: Retry follows partial publication + +**Given** some exact artifacts exist and others are missing; **when** the same projects and `expected_sha` are retried; **then** only missing npm projects MAY publish and all selected results MUST be post-verified. + +### Requirement: Incident versions are immutable + +Incident PREPARE MUST select all and only: `@effectify/react-router@0.6.0-beta.0`, `@effectify/react-query@1.0.0-beta.1`, `@effectify/node-better-auth@0.5.12-beta.0`, `@effectify/solid-query@0.5.12-beta.0`, `@effectify/react-router-better-auth@0.5.12-beta.0`, `@effectify/prisma@1.1.13-beta.0`, and `@effectify/hatchet@0.1.0-beta.0`. + +#### Scenario: Incident matrix differs + +**Given** any pair is missing, additional, or different; **when** incident output is validated; **then** PREPARE MUST stop before branch push and stable MUST remain undispatched. + +### Requirement: Static contracts enforce mode boundaries + +The policy test MUST parse active workflow structure and reject PREPARE access to protected-branch push or publication, weakened suppression/SHA/tag/order/post-verification checks, and alpha/stable drift. + +#### Scenario: A protected boundary regresses + +**Given** workflow structure violates a required PREPARE, SUPPRESS, or FINALIZE boundary; **when** `scripts/release-policy-contract.test.mjs` runs; **then** the contract test MUST fail. diff --git a/openspec/changes/protect-branch-release-orchestration/tasks.md b/openspec/changes/protect-branch-release-orchestration/tasks.md new file mode 100644 index 00000000..7efbf0ee --- /dev/null +++ b/openspec/changes/protect-branch-release-orchestration/tasks.md @@ -0,0 +1,51 @@ +# Tasks: Minimal Beta Recovery on Protected `master` + +Deliver one strict-TDD work unit in one implementation PR. Change exactly `.github/workflows/cd.yml`, `scripts/release-policy-contract.test.mjs`, and `.github/SETUP.md`; add no dependency, file, or module. Apply is static and local only: it must not dispatch a workflow, push a release ref, merge a PR, create a GitHub Release, or publish npm. + +## Review Workload Forecast + +| Field | Value | +| ----------------------- | --------------------------------------------------------------- | +| Estimated changed lines | 125–250 authored; hard ceiling 1,200 additions plus deletions | +| 400-line budget risk | Low | +| Chained PRs recommended | No | +| Suggested split | Single PR containing the workflow, policy contract, and runbook | +| Delivery strategy | single-pr | +| Chain strategy | pending (not applicable while the single-PR ceiling is met) | + +Decision needed before apply: No +Chained PRs recommended: No +Chain strategy: pending +400-line budget risk: Low + +No size exception is authorized or needed. `pending` is only the canonical guard value; no chain decision is required because this plan permits exactly one implementation PR. If the three-file diff exceeds 1,200 changed lines, stop and reduce scope rather than chain or request a size exception. + +## One implementation work unit + +| Boundary | Definition | +| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Start | `cd.yml` directly versions/publishes beta from `master`, its static contract assumes direct beta publication, and `SETUP.md` documents publish-only recovery without exact-SHA finalization. | +| Finish | PREPARE pushes only a verified release branch; release-shaped merges suppress recursion; FINALIZE reconciles exact-SHA tags, prereleases, and missing npm betas; the policy contract and runbook cover the same behavior. | +| Verification | Focused Node RED/GREEN evidence, then Node, YAML syntax, Nx affected lint/typecheck/build/test, formatting, diff, file-scope, and changed-line checks. Runtime mutation evidence is `N/A` because apply performs no release dispatch or external mutation. | +| Rollback | Revert the single work-unit commit across only the three scoped files; before publication, separately delete only the unprotected prepared branch or close/revert the generated release PR through normal policy. | + +### Strict-TDD implementation + +- [x] RED β€” In `scripts/release-policy-contract.test.mjs`, first add named failing parsed-step/command and mutation contracts for PREPARE versus FINALIZE: the exact `pnpm nx release version "--projects=$PROJECTS" --preid=beta --git-commit=false --git-tag=false --git-push=false --stage-changes=false` flags; exact `HEAD:refs/heads/release/beta-<12-char-source-sha>` as PREPARE’s sole push; no PREPARE tag, `gh release`, npm authentication/provenance, publish, issue, PR, stable dispatch, or `master` update; exact generated-path/ref/index/clean-tree checks; structural beta-release merge suppression with the message guard only as defense in depth; nonempty full `expected_sha`; fresh `origin/master` fetch and checkout/remote/input equality; merged beta-manifest tag derivation; annotated exact-target tags; one atomic explicit tag-only push; exact non-draft prerelease view/create/reverify ordering; retained `pnpm nx release publish "--projects=$PROJECTS" --tag=beta`; exact retry/missing-subset behavior; conflict/unknown-state failures; and all seven authorized incident package/version pairs. Preserve existing alpha and stable assertions. Run `node --test scripts/release-policy-contract.test.mjs` and record failures from the new named beta contracts rather than syntax or fixture errors. +- [x] GREEN β€” Make the smallest PREPARE change in `.github/workflows/cd.yml`: retain the existing trigger, concurrency, exact-range affected detection, Nx allowlist, build/test, React Router readiness, collision-aware version action, permissions, and summaries; resolve `prepare|finalize|suppress`; classify exact root-`CHANGELOG.md` plus configured-manifest beta transitions structurally; reject suspicious release messages or beta-shaped drift; for PREPARE, derive selected manifests from Nx, require the exact incident matrix on manual preparation, snapshot refs/tags, run the side-effect-free version command, verify beta versions and exact changed paths before and after rebuild, stage/commit only those paths, prove the index/tree is clean, and push once only to `release/beta-<12-char-source-sha>`. Run `node --test --test-name-pattern='PREPARE|suppression|incident' scripts/release-policy-contract.test.mjs` to green while making no dispatch, push, merge, Release, or publication call locally. +- [x] TRIANGULATE β€” Complete FINALIZE in `.github/workflows/cd.yml` and adverse contracts in `scripts/release-policy-contract.test.mjs`: require manual `publish_only=true`, unique nonempty exact-allowlisted projects, and a 40-lowercase-hex `expected_sha`; freshly fetch `master`; require checkout `HEAD`, `origin/master`, and input equality before mutation; reread exact package names/beta versions from merged manifests; reuse existing build/test/readiness/authentication gates; classify complete npm history and exact `dist-tags.beta` state before tag mutation; accept only matching annotated tags at `expected_sha`, create missing annotated tags, and push all missing tags once with `git push --atomic origin` plus explicit tag refspecs only; verify all tags before creating/verifying exact non-draft GitHub prereleases; publish only missing projects with the retained `--tag=beta` command; post-verify all npm versions/dist-tags; accept exact retries and fail lightweight/wrong-SHA tags, conflicting Releases/npm state, ambiguous reads, partial identity drift, and suspicious message-only or malformed release-shape merges. Run `node --test scripts/release-policy-contract.test.mjs` and require every mutation case to fail closed. +- [x] REFACTOR/VALIDATE β€” Simplify only duplicated inline shell/test parsing without broadening scope, then update `.github/SETUP.md` with the short prepare β†’ approved issue β†’ manual linked `type:chore` PR β†’ protected merge β†’ exact-SHA `publish_only` path, seven-version incident matrix, retry/stop rules, stable-dispatch prohibition, and rollback limits. Before Nx checks, snapshot generated directories with `GENERATED_BEFORE="$(mktemp)"; find packages apps -type d \( -name dist -o -name build -o -name .output -o -name coverage \) -prune -print | sort > "$GENERATED_BEFORE"`; run `node --test scripts/release-policy-contract.test.mjs`; run YAML syntax validation with `ruby -e 'require "yaml"; YAML.parse(File.read(".github/workflows/cd.yml"))'`; set `BASE_SHA="$(git merge-base HEAD origin/master)"`; run `pnpm nx affected --target=lint --base="$BASE_SHA" --head=HEAD --parallel=1`, `pnpm nx affected --target=typecheck --base="$BASE_SHA" --head=HEAD --parallel=1`, `pnpm nx affected --target=build --base="$BASE_SHA" --head=HEAD --parallel=1`, and `pnpm nx affected --target=test --base="$BASE_SHA" --head=HEAD --parallel=1 --passWithNoTests`; run `pnpm exec oxfmt --check scripts/release-policy-contract.test.mjs .github/SETUP.md`, `pnpm nx run @effectify/repo:format:check --args="--base=$BASE_SHA --head=HEAD"`, and `git diff --check "$BASE_SHA"...HEAD`. Remove only newly generated directories with `GENERATED_AFTER="$(mktemp)"; find packages apps -type d \( -name dist -o -name build -o -name .output -o -name coverage \) -prune -print | sort > "$GENERATED_AFTER"; comm -13 "$GENERATED_BEFORE" "$GENERATED_AFTER" | while IFS= read -r path; do rm -rf -- "$path"; done; rm -f "$GENERATED_BEFORE" "$GENERATED_AFTER"`; require `git diff --name-only "$BASE_SHA"...HEAD` to equal the three scoped paths and use `git diff --numstat "$BASE_SHA"...HEAD -- .github/workflows/cd.yml scripts/release-policy-contract.test.mjs .github/SETUP.md | awk '{ add += $1; del += $2 } END { print add + del }'` to prove at most 1,200 changed lines; finish with `git status --short` and record exact command results, runtime mutation `N/A`, and the three-file rollback boundary in the one implementation PR. + +## Deferred parent-owned lifecycle gates + +These happen only after apply completes; they are not implementation commands and remain subject to ordinary repository policy. + +- [x] Create or reuse the repository-required implementation issue, obtain approval, and link the exact three-file scope and 1,200-line ceiling. Issue: `#230`. +- [ ] Open, review, approve, and merge exactly one implementation PR containing the single work-unit commit and its recorded checks; reject extra files, dependencies, modules, release dispatches, or a size exception. +- [ ] Promote the merged implementation to protected `master` through ordinary repository policy, without dispatching beta or stable during the promotion itself. +- [ ] Manually dispatch incident PREPARE with all seven exact Nx projects, then verify the reported release branch, changed paths, and authorized versions before continuing. +- [ ] Create or reuse and approve the required release issue for the prepared branch, with no workflow automation applying approval. +- [ ] Manually open, review, approve, and merge one generated linked `type:chore` release PR from the reported branch to `master`, then verify the resulting beta workflow run structurally suppresses preparation and publishes nothing. +- [ ] Provide fresh publication authorization bound to the resulting current full `master` merge SHA as `expected_sha` and the exact seven-project selection; stop if `master` moves. +- [ ] Manually dispatch `publish_only=true` with the authorized projects and fresh `expected_sha`; do not dispatch stable. +- [ ] Verify all seven exact annotated tags target `expected_sha`, all seven GitHub Releases are non-draft prereleases, and npm contains `@effectify/react-router@0.6.0-beta.0`, `@effectify/react-query@1.0.0-beta.1`, `@effectify/node-better-auth@0.5.12-beta.0`, `@effectify/solid-query@0.5.12-beta.0`, `@effectify/react-router-better-auth@0.5.12-beta.0`, `@effectify/prisma@1.1.13-beta.0`, and `@effectify/hatchet@0.1.0-beta.0` with each `beta` dist-tag exact. diff --git a/scripts/release-policy-contract.test.mjs b/scripts/release-policy-contract.test.mjs index 1345c7c4..3e51c23c 100644 --- a/scripts/release-policy-contract.test.mjs +++ b/scripts/release-policy-contract.test.mjs @@ -65,13 +65,14 @@ const extractSteps = (source) => { if (!match || /^\s*#/.test(lines[index])) continue const stepIndent = match[1].length - const step = { name: match[2], condition: "", commands: [], uses: "", packageManagerCache: "" } + const step = { name: match[2], condition: "", commands: [], uses: "", packageManagerCache: "", source: "" } for (index += 1; index < lines.length; index += 1) { const line = lines[index] if (line.trim() && indentation(line) <= stepIndent) { index -= 1 break } + step.source += `${line}\n` if (/^\s*#/.test(line)) continue const condition = line.match(/^\s*if:\s*(.+?)\s*$/) @@ -94,6 +95,7 @@ const extractSteps = (source) => { for (index += 1; index < lines.length; index += 1) { const command = lines[index] + step.source += `${command}\n` if (command.trim() && indentation(command) <= runIndent) { index -= 1 break @@ -133,10 +135,46 @@ const requireCommandOrder = (violations, source, patterns, violation) => { } } +const hasCommandSequence = (commands, patterns) => + commands.some((_, start) => patterns.every((pattern, offset) => pattern.test(commands[start + offset] ?? ""))) + +const sensitiveShellViolations = (step, label) => { + const violations = [] + if (step.commands.some((command) => /<<-?\s*['"]?[A-Za-z_][A-Za-z0-9_]*['"]?/.test(command))) { + violations.push(`${label} heredoc ambiguity`) + } + if (step.commands.some((command) => /^if\s+(?:false|!\s+true)\s*;?\s*then$/.test(command))) { + violations.push(`${label} statically dead wrapper`) + } + + const functions = [] + for (let index = 0; index < step.commands.length; index += 1) { + const declaration = step.commands[index].match(/^([A-Za-z_][A-Za-z0-9_]*)\(\)\s*\{$/) + if (!declaration) continue + let depth = 1 + let end = index + while (++end < step.commands.length && depth > 0) { + if (/(?:\|\||&&)\s*\{\s*$/.test(step.commands[end])) depth += 1 + if (step.commands[end] === "}") depth -= 1 + } + functions.push({ name: declaration[1], start: index, end }) + index = end - 1 + } + for (const fn of functions) { + const invoked = step.commands.some( + (command, index) => (index < fn.start || index >= fn.end) && new RegExp(`^${fn.name}(?:\\s|$)`).test(command), + ) + if (!invoked) violations.push(`${label} unused shell function ${fn.name}`) + } + return violations +} + const channelVersionCommand = (channel) => new RegExp(`^pnpm nx release "--projects=\\$PROJECTS" --preid=${channel} --skip-publish$`) 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$/ const buildCommand = /^pnpm nx run-many -t build "--projects=\$PROJECTS" --parallel=3$/ const testCommand = /^pnpm nx run-many -t test "--projects=\$PROJECTS" --parallel=3 --passWithNoTests$/ const contractCommand = /^node --test scripts\/release-policy-contract\.test\.mjs$/ @@ -197,6 +235,193 @@ const channelViolations = (channel, source) => { return violations } +const betaViolations = (source) => { + const violations = [] + const active = withoutComments(source) + const steps = extractSteps(source) + const resolve = steps.find((step) => step.commands.some((command) => /mode=prepare/.test(command))) + const prepare = steps.find((step) => step.commands.some((command) => betaVersionCommand.test(command))) + const finalize = steps.find((step) => step.commands.some((command) => channelPublishCommand("beta").test(command))) + + if (!/push:\s*\n\s*branches: \[master\]/.test(active)) violations.push("beta trigger") + if (!/expected_sha:\s*\n\s*description:[^\n]*\n\s*required: false/.test(active)) { + violations.push("beta expected SHA input") + } + if (!resolve) { + violations.push("beta mode resolver") + } else { + const commands = resolve.commands.join("\n") + for (const [pattern, name] of [ + [/mode=prepare/, "prepare mode"], + [/mode=finalize/, "finalize mode"], + [/mode=suppress/, "suppress mode"], + [/\^\[0-9a-f\]\{40\}\$/, "full expected SHA"], + [/git diff --name-only --no-renames/, "structural changed paths"], + [/CHANGELOG\.md/, "root changelog shape"], + [/-beta\\\.\[0-9\]/, "beta manifest transition"], + [/chore\(release\):/, "release message defense"], + [/\[skip release\]/, "skip message defense"], + [/grep -Fx -- "\$project"/, "exact allowlist membership"], + [/sort \| uniq -d/, "duplicate selection rejection"], + [/manual PREPARE requires all seven release projects/, "incident project set"], + ]) { + if (!pattern.test(commands)) violations.push(`beta ${name}`) + } + for (const [project, version] of [ + ["@effectify/react-router", "0.6.0-beta.0"], + ["@effectify/react-query", "1.0.0-beta.1"], + ["@effectify/node-better-auth", "0.5.12-beta.0"], + ["@effectify/solid-query", "0.5.12-beta.0"], + ["@effectify/react-router-better-auth", "0.5.12-beta.0"], + ["@effectify/prisma", "1.1.13-beta.0"], + ["@effectify/hatchet", "0.1.0-beta.0"], + ]) { + if (!active.includes(`${project}=${version}`)) violations.push(`beta incident ${project}`) + } + } + + if (!prepare) { + violations.push("beta PREPARE step") + } else { + const commands = prepare.commands.join("\n") + const pushes = prepare.commands.filter((command) => /^git push\b/.test(command)) + if (pushes.length !== 1 || !/^git push origin "HEAD:refs\/heads\/release\/beta-\$SHA_PREFIX"$/.test(pushes[0])) { + violations.push("beta PREPARE sole branch push") + } + for (const [pattern, name] of [ + [betaVersionCommand, "side-effect-free versioning"], + [/^git switch --create "\$RELEASE_BRANCH"$/, "release branch creation"], + [/^REFS_BEFORE=/, "ref snapshot"], + [/verify_prepared_tree/, "prepared tree verification"], + [/cmp -s \/tmp\/expected-release-paths "\$ACTUAL_PATHS"/, "exact path equality"], + [/test "\$REFS_BEFORE" = "\$\(git for-each-ref/, "unchanged refs"], + [/git diff --cached --name-only --no-renames/, "exact index"], + [/git diff --quiet/, "clean tracked tree"], + [/git ls-files --others --exclude-standard/, "clean untracked tree"], + [/^git add -- "\$\{RELEASE_PATHS\[@\]\}"$/, "explicit staging"], + [/^git commit -m "chore\(release\): prepare beta from \$SOURCE_SHA \[skip release\]"$/, "release commit"], + ]) { + if (!prepare.commands.some((command) => pattern.test(command))) violations.push(`beta PREPARE ${name}`) + } + if ((commands.match(/verify_prepared_tree/g) ?? []).length < 3) + violations.push("beta PREPARE repeated verification") + if ( + /nx release publish|npm (?:publish|whoami)|gh (?:release|issue|pr)|git tag|workflow run|release-stable|refs\/heads\/master|NODE_AUTH_TOKEN|NPM_CONFIG_PROVENANCE/.test( + prepare.source, + ) + ) { + violations.push("beta PREPARE mutation isolation") + } + violations.push(...sensitiveShellViolations(prepare, "beta PREPARE")) + } + + if (!finalize) { + violations.push("beta FINALIZE step") + } else { + const commands = finalize.commands.join("\n") + const requiredOrder = [ + /^git fetch origin master:refs\/remotes\/origin\/master --no-tags$/, + /^test "\$HEAD_SHA" = "\$EXPECTED_SHA"$/, + /^test "\$REMOTE_SHA" = "\$EXPECTED_SHA"$/, + /test "\$VERSION"/, + /npm view "\$NAME" versions --json/, + /^verify_tags collect$/, + /^git tag -a "\$TAG" "\$EXPECTED_SHA" -m "\$TAG"$/, + /^git push --atomic origin "\$\{TAG_REFS\[@\]\}"$/, + /^verify_tags verify$/, + /gh release view "\$TAG" --json tagName,isDraft,isPrerelease/, + /^gh release create "\$TAG" --verify-tag --prerelease --generate-notes$/, + /^verify_releases verify$/, + channelPublishCommand("beta"), + /post-verify npm beta state/, + ] + for (const pattern of requiredOrder) { + if (!finalize.commands.some((command) => pattern.test(command))) { + violations.push(`beta FINALIZE ${String(pattern)}`) + } + } + requireCommandOrder(violations, source, requiredOrder, "beta FINALIZE mutation ordering") + const pushes = finalize.commands.filter((command) => /^git push\b/.test(command)) + if ( + !/refs\/tags\/\$TAG:refs\/tags\/\$TAG/.test(commands) || + pushes.length !== 1 || + !/^git push --atomic origin "\$\{TAG_REFS\[@\]\}"$/.test(pushes[0]) || + /refs\/heads\/|--tags|--follow-tags/.test(pushes[0]) + ) { + violations.push("beta FINALIZE explicit tag refspecs") + } + if (!/PROJECTS="\$MISSING_PROJECTS"/.test(commands) || (commands.match(/dist-tags\.beta/g) ?? []).length < 2) { + violations.push("beta FINALIZE exact retry subset") + } + for (const [pattern, name] of [ + [/DIRECT_COUNT/, "tag identity"], + [/PEELED_COUNT/, "annotated tag type"], + [/PEELED_SHA/, "tag target"], + [ + /test "\$DIRECT_COUNT" = "1" && test "\$PEELED_COUNT" = "1" && test "\$PEELED_SHA" = "\$EXPECTED_SHA"/, + "annotated exact-target acceptance", + ], + [/unknown remote tag state/, "unknown tag failure"], + [/conflicting npm beta state/, "npm conflict failure"], + [/unknown GitHub Release state/, "unknown release failure"], + ]) { + if (!pattern.test(commands)) violations.push(`beta FINALIZE ${name}`) + } + for (const [patterns, name] of [ + [ + [ + /^REMOTE_TAGS=\$\(git ls-remote --tags origin "refs\/tags\/\$TAG" "refs\/tags\/\$TAG\^\{}"\) \|\| \{$/, + /^echo "unknown remote tag state for \$TAG" >&2$/, + /^exit 1$/, + /^\}$/, + ], + "unknown tag fail-closed block", + ], + [ + [/^test "\$BETA_TAG" = "\$VERSION" \|\| \{ echo "conflicting npm beta state for \$NAME" >&2; exit 1; \}$/], + "conflicting npm fail-closed block", + ], + [ + [/^else$/, /^echo "unknown GitHub Release state for \$TAG" >&2$/, /^exit 1$/, /^fi$/], + "unknown Release fail-closed block", + ], + ]) { + if (!hasCommandSequence(finalize.commands, patterns)) violations.push(`beta FINALIZE ${name}`) + } + violations.push(...sensitiveShellViolations(finalize, "beta FINALIZE")) + if (!/NODE_AUTH_TOKEN/.test(active) || !/NPM_CONFIG_PROVENANCE:\s*true/.test(active)) { + violations.push("beta FINALIZE publication credentials") + } + } + + if ( + !/if \[ "\$HAS_CHANGELOG" = "true" \] && \[ "\$UNEXPECTED" = "false" \] && \[ "\$BETA_TRANSITIONS" -gt 0 \] && \[ "\$BETA_TRANSITIONS" -eq "\$MANIFEST_CHANGES" \]; then/.test( + active, + ) + ) { + violations.push("beta exact suppression shape") + } + if (!/echo "suspicious release-shaped master push; refusing preparation" >&2\s*\n\s*exit 1/.test(active)) { + violations.push("beta suspicious shape rejection") + } + if (/workflow run[^\n]*stable|release-stable/.test(active)) violations.push("beta stable dispatch") + if ( + !/echo "versions=\$VERSIONS" >> "\$GITHUB_OUTPUT"/.test(active) || + !/echo "changed_paths=\$CHANGED_PATHS" >> "\$GITHUB_OUTPUT"/.test(active) || + !/\*\*Selected versions:\*\* \$SELECTED_VERSIONS/.test(active) || + !/\*\*Changed paths:\*\* \$CHANGED_PATHS/.test(active) + ) { + violations.push("beta PREPARE summary versions and paths") + } + if (!/sole `type:\*` label is `type:chore`/.test(active)) violations.push("beta sole type label summary") + if ( + commandEntries(source).some(({ command }) => /nx release publish/.test(command) && !command.endsWith("--tag=beta")) + ) { + violations.push("beta default publication") + } + return violations +} + const stableViolations = (source) => { const violations = [] const active = withoutComments(source) @@ -265,11 +490,7 @@ const releasePolicyBootstrapViolations = (source) => { } const policyViolations = ({ alpha, beta, stable, docs }) => { - const violations = [ - ...channelViolations("alpha", alpha), - ...channelViolations("beta", beta), - ...stableViolations(stable), - ] + const violations = [...channelViolations("alpha", alpha), ...betaViolations(beta), ...stableViolations(stable)] if (!/\|\s*Beta\s*\|[^\n]*`master`[^\n]*`beta`/.test(withoutComments(docs))) { violations.push("documented mapping") } @@ -291,10 +512,16 @@ test("dev pushes retain exact-range conditional alpha publication", () => { assert.deepEqual(channelViolations("alpha", workflows.alpha), []) }) -test("master pushes publish beta only across the exact pushed range", () => { - assert.deepEqual(channelViolations("beta", workflows.beta), []) - assert.match(withoutComments(workflows.beta), /!contains\(github\.event\.head_commit\.message, 'chore\(release\):'\)/) - assert.doesNotMatch(withoutComments(workflows.beta), /--tag=(?:latest|stable)/) +test("beta PREPARE is branch-only, exact-path, and incident-bound", () => { + assert.deepEqual(betaViolations(workflows.beta), []) +}) + +test("beta release-merge suppression is structural and fail-closed", () => { + assert.deepEqual(betaViolations(workflows.beta), []) +}) + +test("beta FINALIZE is exact-SHA, tag-only, prerelease-first, and retryable", () => { + assert.deepEqual(betaViolations(workflows.beta), []) }) test("stable validates current master and selected projects before every release mutation", () => { @@ -356,6 +583,128 @@ test("setup lists all seven Nx release projects", () => { for (const project of releaseProjects) { assert.match(setup, new RegExp(project.replaceAll("/", "\\/"))) } + assert.match(setup, /sole `type:\*` label is `type:chore`/) +}) + +test("beta PREPARE and suppression mutations fail closed", () => { + const policy = { ...workflows, docs: readme } + + for (const [name, before, after] of [ + ["enable Nx git commit", "--git-commit=false", "--git-commit=true"], + [ + "push PREPARE to protected master", + 'git push origin "HEAD:refs/heads/release/beta-$SHA_PREFIX"', + 'git push origin "HEAD:refs/heads/master"', + ], + [ + "publish from PREPARE", + 'pnpm nx release version "--projects=$PROJECTS" --preid=beta --git-commit=false --git-tag=false --git-push=false --stage-changes=false', + 'pnpm nx release version "--projects=$PROJECTS" --preid=beta --git-commit=false --git-tag=false --git-push=false --stage-changes=false\n pnpm nx release publish "--projects=$PROJECTS" --tag=beta', + ], + ["weaken exact generated paths", 'cmp -s /tmp/expected-release-paths "$ACTUAL_PATHS"', 'test -s "$ACTUAL_PATHS"'], + [ + "allow Nx ref mutation", + 'test "$REFS_BEFORE" = "$(git for-each-ref', + 'test "$REFS_BEFORE" != "$(git for-each-ref', + ], + ["stage every path", 'git add -- "${RELEASE_PATHS[@]}"', "git add -A"], + ["change an incident version", "@effectify/hatchet=0.1.0-beta.0", "@effectify/hatchet=0.1.0-beta.1"], + [ + "expose npm credentials to PREPARE", + " MANUAL_PREPARE: ${{ github.event_name == 'workflow_dispatch' }}", + " MANUAL_PREPARE: ${{ github.event_name == 'workflow_dispatch' }}\n NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}", + ], + [ + "weaken suppression changed-path shape", + '[ "$HAS_CHANGELOG" = "true" ] && [ "$UNEXPECTED" = "false" ]', + '[ "$HAS_CHANGELOG" = "true" ] && [ "$UNEXPECTED" = "true" ]', + ], + [ + "trust release message without structure", + '[[ "$HEAD_MESSAGE" == *"chore(release):"* || "$HEAD_MESSAGE" == *"[skip release]"* ]] || [ "$BETA_TRANSITIONS" -gt 0 ]', + '[ "$BETA_TRANSITIONS" -gt 0 ]', + ], + [ + "suppress a suspicious shape", + 'echo "suspicious release-shaped master push; refusing preparation" >&2\n exit 1', + 'echo "suspicious release-shaped master push; refusing preparation" >&2\n echo "mode=suppress" >> "$GITHUB_OUTPUT"', + ], + [ + "hide PREPARE versioning in a heredoc", + 'pnpm nx release version "--projects=$PROJECTS" --preid=beta --git-commit=false --git-tag=false --git-push=false --stage-changes=false', + "cat <<'DEAD_VERSION'\n pnpm nx release version \"--projects=$PROJECTS\" --preid=beta --git-commit=false --git-tag=false --git-push=false --stage-changes=false\n DEAD_VERSION", + ], + [ + "hide PREPARE versioning behind false", + 'pnpm nx release version "--projects=$PROJECTS" --preid=beta --git-commit=false --git-tag=false --git-push=false --stage-changes=false', + 'if false; then\n pnpm nx release version "--projects=$PROJECTS" --preid=beta --git-commit=false --git-tag=false --git-push=false --stage-changes=false\n fi', + ], + [ + "hide PREPARE versioning in an unused function", + 'pnpm nx release version "--projects=$PROJECTS" --preid=beta --git-commit=false --git-tag=false --git-push=false --stage-changes=false', + 'unused_version() {\n pnpm nx release version "--projects=$PROJECTS" --preid=beta --git-commit=false --git-tag=false --git-push=false --stage-changes=false\n }', + ], + ]) { + assertMutationFails(name, policy, (candidate) => ({ + ...candidate, + beta: mutate(candidate.beta, before, after), + })) + } +}) + +test("beta FINALIZE conflict and ordering mutations fail closed", () => { + const policy = { ...workflows, docs: readme } + + for (const [name, before, after] of [ + ["accept short expected SHA", "^[0-9a-f]{40}$", "^[0-9a-f]{7,40}$"], + ["weaken checkout equality", 'test "$HEAD_SHA" = "$EXPECTED_SHA"', 'test "$HEAD_SHA" != "$EXPECTED_SHA"'], + ["weaken remote equality", 'test "$REMOTE_SHA" = "$EXPECTED_SHA"', 'test "$REMOTE_SHA" != "$EXPECTED_SHA"'], + ["create lightweight tags", 'git tag -a "$TAG" "$EXPECTED_SHA" -m "$TAG"', 'git tag "$TAG" "$EXPECTED_SHA"'], + ["target tags at moving HEAD", 'git tag -a "$TAG" "$EXPECTED_SHA" -m "$TAG"', 'git tag -a "$TAG" HEAD -m "$TAG"'], + ["remove atomic tag push", 'git push --atomic origin "${TAG_REFS[@]}"', 'git push origin "${TAG_REFS[@]}"'], + ["push wildcard tags", 'TAG_REFS+=("refs/tags/$TAG:refs/tags/$TAG")', 'TAG_REFS+=("refs/tags/*:refs/tags/*")'], + ["accept lightweight remote tags", 'test "$PEELED_COUNT" = "1"', 'test "$DIRECT_COUNT" = "1"'], + [ + "drop GitHub prerelease identity", + 'gh release create "$TAG" --verify-tag --prerelease --generate-notes', + 'gh release create "$TAG" --generate-notes', + ], + [ + "publish every project on retry", + 'PROJECTS="$MISSING_PROJECTS"', + 'PROJECTS="${{ steps.release.outputs.projects }}"', + ], + ["drop exact npm beta tag reads", "dist-tags.beta", "dist-tags.latest"], + [ + "accept unknown tag reads", + 'echo "unknown remote tag state for $TAG" >&2\n exit 1', + 'echo "unknown remote tag state for $TAG" >&2\n :', + ], + [ + "accept conflicting npm state", + 'test "$BETA_TAG" = "$VERSION" || { echo "conflicting npm beta state for $NAME" >&2; exit 1; }', + 'test "$BETA_TAG" = "$VERSION" || { echo "conflicting npm beta state for $NAME" >&2; true; }', + ], + [ + "accept unknown Release reads", + 'echo "unknown GitHub Release state for $TAG" >&2\n exit 1', + 'echo "unknown GitHub Release state for $TAG" >&2\n :', + ], + ]) { + assertMutationFails(name, policy, (candidate) => ({ + ...candidate, + beta: mutate(candidate.beta, before, after), + })) + } + + assertMutationFails("publish before every prerelease is verified", policy, (candidate) => ({ + ...candidate, + beta: mutate( + candidate.beta, + " verify_releases verify\n\n MISSING_PROJECTS=", + ' pnpm nx release publish "--projects=$PROJECTS" --tag=beta\n verify_releases verify\n\n MISSING_PROJECTS=', + ), + })) }) test("stable safety mutations fail closed, including commented-out policy text", () => { @@ -430,7 +779,7 @@ test("stable safety mutations fail closed, including commented-out policy text", test("alpha and beta exact-range and membership mutations fail closed", () => { const policy = { ...workflows, docs: readme } - for (const channel of ["alpha", "beta"]) { + for (const channel of ["alpha"]) { assertMutationFails(`${channel} ignores push before SHA`, policy, (candidate) => ({ ...candidate, [channel]: mutate(candidate[channel], 'BEFORE="$BEFORE_SHA"', 'BEFORE="origin/branch~1"'),