From eb6e3e4709c94c9a06dff47591f0bcf763af533d Mon Sep 17 00:00:00 2001 From: kattsushi Date: Thu, 27 Aug 2026 11:57:48 -0600 Subject: [PATCH 1/2] ci(release): separate alpha beta and stable channels --- .github/SETUP.md | 300 ++++------------ .github/workflows/cd.yml | 174 ++++++--- .github/workflows/ci.yml | 19 +- .github/workflows/release-alpha.yml | 41 ++- .github/workflows/release-stable.yml | 184 ++++++++++ README.md | 103 +++--- scripts/release-policy-contract.test.mjs | 426 +++++++++++++++++++++++ 7 files changed, 894 insertions(+), 353 deletions(-) create mode 100644 .github/workflows/release-stable.yml create mode 100644 scripts/release-policy-contract.test.mjs diff --git a/.github/SETUP.md b/.github/SETUP.md index e6729172..3ab72ed0 100644 --- a/.github/SETUP.md +++ b/.github/SETUP.md @@ -1,272 +1,124 @@ -# πŸš€ CI/CD Setup Guide +# CI and npm release setup -This guide explains how to set up the automated CI/CD pipeline for publishing packages to NPM and JSR (Deno). +Effectify has three intentionally separate release channels. Alpha and beta are branch-driven prereleases; stable publication is always a manual decision. -## πŸ“‹ Prerequisites +## Release channel map -1. **NPM Account**: You need an NPM account with publish permissions -2. **JSR Account**: You need a JSR account for Deno packages (optional) -3. **GitHub Repository**: With admin access to configure secrets +| Channel | Trigger | npm tag | Workflow | +| ------- | ---------------------------------------- | ------------------ | -------------------------------------- | +| Alpha | Push to `dev` | `alpha` | `.github/workflows/release-alpha.yml` | +| Beta | Push to `master` | `beta` | `.github/workflows/cd.yml` | +| Stable | Manual workflow against current `master` | default (`latest`) | `.github/workflows/release-stable.yml` | -## πŸ” Required Secrets +A `chore(release):` commit pushed by a release workflow does not start another beta publication. Stable has no push trigger and cannot be reached by a normal branch push. -Configure these secrets in your GitHub repository settings (`Settings > Secrets and variables > Actions`): +## Required repository setup -### NPM Token +Use Node.js 24.19.0 and pnpm 10.14.0 locally when reproducing workflow checks. -- **Name**: `NPM_TOKEN` -- **Description**: NPM authentication token for publishing packages -- **How to get**: - 1. Go to [npmjs.com](https://www.npmjs.com/) and log in - 2. Go to `Account Settings > Access Tokens` - 3. Click `Generate New Token` - 4. Select `Automation` type (for CI/CD) - 5. Copy the token and add it as `NPM_TOKEN` secret +Configure these GitHub Actions secrets under **Settings > Secrets and variables > Actions**: -### JSR OIDC Configuration (Recommended) +| Secret | Purpose | +| --------------- | ----------------------------------------------------------------------------------------- | +| `NPM_TOKEN` | npm authentication and provenance publication | +| `RELEASE_TOKEN` | Optional checkout token for stable release git operations; `GITHUB_TOKEN` is the fallback | -- **Method**: OIDC (OpenID Connect) - more secure than personal tokens -- **Setup**: Link your package to your GitHub repository in JSR -- **How to configure**: - 1. Go to your package `@effectify/solid-query` on [jsr.io](https://jsr.io/) - 2. Go to the **"Settings"** tab - 3. In **"GitHub repository"** field, enter your repository name (e.g., `your-username/effectify`) - 4. Click **"Link"** to connect the package to your repository - 5. No secrets needed in GitHub - OIDC handles authentication automatically -- **Reference**: [JSR Publishing from GitHub Actions](https://jsr.io/docs/publishing-packages#publishing-from-github-actions) +The release jobs request `contents: write` for Nx release commits, tags, and GitHub releases, and `id-token: write` for npm provenance. -## πŸ—οΈ Workflow Overview +## Nx release projects -### CI Workflow (`.github/workflows/ci.yml`) +All release workflows derive their allowlist from `nx.json`. The seven current Nx project names are: -- **Triggers**: Push to `master`, `main`, `develop` branches and PRs -- **Purpose**: Development and PR validation -- **Jobs**: - - πŸ” **Lint & Format**: Checks code style and formatting - - πŸ” **Type Check**: Validates TypeScript types - - πŸ—οΈ **Build**: Builds affected projects and uploads artifacts - - πŸ§ͺ **Test**: Runs tests for affected projects - - πŸ“Š **Summary**: CI results dashboard +1. `@effectify/react-router` +2. `@effectify/react-query` +3. `@effectify/node-better-auth` +4. `@effectify/solid-query` +5. `@effectify/react-router-better-auth` +6. `@effectify/prisma` +7. `@effectify/hatchet` -### Release Workflow (`.github/workflows/release.yml`) +Use these project namesβ€”not filesystem pathsβ€”in manual workflow inputs. -- **Triggers**: Push to `master` branch only -- **Purpose**: Production release and publishing -- **Optimized**: Tries to reuse build artifacts from CI -- **Jobs**: - - πŸ” **Detect Changes**: Determines if release is needed - - πŸš€ **Release & Publish**: Handles versioning, changelog, and publishing - - πŸ“’ **Notify**: Provides status notifications +## Exact workflow behavior -## 🎯 How It Works +### CI: `.github/workflows/ci.yml` -### 1. Change Detection +**Triggers:** pull requests that are opened, synchronized, reopened, or marked ready for review, plus pushes to `dev`. -The workflow automatically detects if any of the configured release projects have changes: +For non-draft pull requests, CI runs the static release-policy contract, affected lint and format checks, affected type checks, affected builds, and affected tests. The release-policy contract is dependency-free and runs with Node.js 24.19.0: -- `packages/react/router` β€” maintained RR8 integration -- `packages/node/better-auth` -- `packages/solid/query` - -### 2. Affected Projects - -Uses Nx's native `affected` commands to: - -- Build only changed projects: `nx affected --target=build` -- Test only changed projects: `nx affected --target=test` -- Lint only changed projects: `nx affected --target=lint` - -### 3. Release Process - -When changes are detected on master: - -1. **Try to reuse** build artifacts from CI (if available) -2. **Build** affected projects (only if artifacts not found) -3. **Test** affected projects -4. **Version** packages using Nx Release -5. **Generate** changelogs automatically -6. **Publish** to NPM and JSR -7. **Create** GitHub release - -## πŸ”§ Configuration Files - -### `.npmrc` - -```ini -registry=https://registry.npmjs.org/ -always-auth=true -@jsr:registry=https://npm.jsr.io/ -``` - -### `nx.json` (Release Configuration) - -```json -{ - "release": { - "projects": [ - "packages/react/router", - "packages/node/better-auth", - "packages/solid/query" - ], - "changelog": { - "projectChangelogs": { - "renderOptions": { - "authors": true, - "commitReferences": false, - "versionTitleDate": true, - "applyUsernameToAuthors": true - } - } - }, - "releaseTagPattern": "release/{version}", - "version": { - "preVersionCommand": "pnpm nx build @effectify/react-router" - } - } -} +```bash +node --test scripts/release-policy-contract.test.mjs ``` -## πŸš€ Usage +### Alpha: `.github/workflows/release-alpha.yml` -### Automatic Release +**Triggers:** pushes to `dev` and optional manual dispatch. -- Push changes to `master` branch -- The workflow automatically detects affected packages -- If changes are found, it triggers the release process +A normal run calculates projects affected across the GitHub push event's exact `before`-to-`github.sha` range, then intersects those exact project names with the seven-project release allowlist. Invalid or zero `before` SHAs safely fall back to the current commit's parent. If the intersection is empty, publication is skipped. Otherwise the workflow builds, tests, versions with Nx `--preid=alpha`, rebuilds the versioned packages, and publishes with npm `--tag=alpha`. -### Manual Release +Manual publish-only recovery requires an explicit comma-separated `projects` input. It publishes the selected existing manifests with `--tag=alpha` and skips version, changelog, and git mutation. -- Go to `Actions` tab in GitHub -- Select `πŸš€ Release & Publish` workflow -- Click `Run workflow` button +### Beta: `.github/workflows/cd.yml` -### Check Status +**Triggers:** pushes to `master` and optional manual dispatch. -- Go to `Actions` tab to see workflow status -- Check the `πŸ“Š CI Summary` for detailed results -- Review published packages in NPM and JSR +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. -## πŸ› οΈ Troubleshooting +Manual publish-only recovery requires explicit existing project names and still publishes with `--tag=beta`; it skips version, changelog, and git mutation. -### Common Issues +### Stable: `.github/workflows/release-stable.yml` -1. **NPM Token Issues** +**Trigger:** manual dispatch only. The workflow has no push trigger. - - Ensure token has `Automation` type - - Check token permissions include publish access - - Verify token is not expired +The workflow always checks out `master`, fetches `origin/master`, and fails unless the checkout is the current remote commit. The `projects` input is required and is validated against all seven Nx release projects. -2. **JSR Token Issues** +#### Normal stable graduation - - Ensure JSR account has publish permissions - - Check if package name conflicts exist - - Verify JSR token is valid +1. Select one or more existing prerelease projects in the comma-separated `projects` input. +2. Leave `publish_only` disabled. +3. The workflow verifies the release-policy contract, exact checked-out HEAD equality with fetched `origin/master`, the selected-project allowlist, and npm authentication. +4. It builds and tests the selected projects, then runs React Router 8 tests, consolidation, readiness, and manifest verification. +5. Only after validation passes, Nx applies the relative `patch` specifier to the selected prereleases, producing their stable versions and release metadata. +6. Nx publishes only the selected projects without a prerelease dist-tag, so npm uses the stable default tag. -3. **Build Failures** +The workflow rejects a selected normal-mode project whose local manifest is already stable. This keeps graduation explicit and prevents an accidental extra patch release. - - Check if all dependencies are installed - - Verify TypeScript compilation - - Review test failures +#### Publish-only stable recovery -4. **No Release Triggered** - - Ensure changes are in release-configured projects - - Check if changes are in configuration files - - Verify branch is `master` +Use this only when selected stable versions already exist in the checked-out manifests but need publication retried: -## πŸ§ͺ Testing Workflows Locally +1. Enter the exact existing stable project names in `projects`. +2. Enable `publish_only`. +3. The workflow rejects missing versions, prerelease versions, unknown projects, and empty selections. +4. It builds, tests, and verifies before publishing the selected manifests. +5. It performs no version, changelog, tag, release commit, or git push mutation and supplies no prerelease npm dist-tag. -### Using Act (GitHub Actions Local Runner) +## Release safety checks -We've set up **act** to test GitHub Actions workflows locally before pushing to GitHub. - -#### Prerequisites +Before any Nx version or publish command, every release workflow runs: ```bash -# Install act (if not already installed) -brew install act - -# Install Docker (required for act) -# Download from https://www.docker.com/products/docker-desktop +node --test scripts/release-policy-contract.test.mjs ``` -#### Quick Testing +The contract rejects explicitly modeled structural regressions: a stable push trigger, missing beta or alpha prerelease flags, weakened project or current-`master` checks, known version/publish commands moving ahead of required validation, and channel documentation drifting from the workflows. -```bash -# Test all workflows -./scripts/test-workflows.sh - -# Test specific workflow -./scripts/test-workflows.sh ci -./scripts/test-workflows.sh release - -# List available workflows -./scripts/test-workflows.sh list - -# Get help -./scripts/test-workflows.sh help -``` - -#### Manual Testing with Act +React Router publication readiness is verified with the maintained React Router 8 project and example targets: ```bash -# List jobs in a workflow -act -W .github/workflows/ci.yml --list -act -W .github/workflows/release.yml --list - -# Run specific job -act -W .github/workflows/ci.yml -j build -act -W .github/workflows/ci.yml -j test - -# Run with M1/M2 Mac compatibility -act -W .github/workflows/ci.yml --container-architecture linux/amd64 - -# Run with local secrets -act -W .github/workflows/ci.yml --secret-file .secrets +pnpm nx test @effectify/react-router +pnpm nx run @effectify/react-router-example:migration:test +pnpm nx run @effectify/react-router-example:migration:verify +pnpm nx run @effectify/react-router-example:migration:manifest +pnpm nx run @effectify/react-router-example:consolidation:verify ``` -### Testing Nx Commands Locally - -```bash -# Check affected projects locally -pnpm nx show projects --affected --base=origin/master~1 --head=HEAD - -# Test release process locally -pnpm nx release --dry-run - -# Build affected projects locally -pnpm nx affected --target=build --base=origin/master~1 --head=HEAD - -# Test JSR publish locally (dry run) -cd packages/solid/query -pnpm dlx jsr publish --dry-run - -# Test JSR publish locally (real publish) -cd packages/solid/query -pnpm dlx jsr publish -``` - -### Local Testing Files - -- **`.actrc`**: Act configuration file -- **`.secrets`**: Local secrets for testing (not committed to git) -- **`scripts/test-workflows.sh`**: Helper script for testing workflows - -## πŸ“š Additional Resources - -- [Nx Release Documentation](https://nx.dev/nx-api/nx/documents/release) -- [Nx Affected Commands](https://nx.dev/nx-api/nx/documents/affected) -- [GitHub Actions Documentation](https://docs.github.com/en/actions) -- [NPM Publishing Guide](https://docs.npmjs.com/packages-and-modules/contributing-packages-to-the-registry) -- [JSR Publishing Guide](https://jsr.io/docs/publishing) - -## πŸŽ‰ Benefits +## Recovery checklist -- βœ… **Automated**: No manual publishing required -- βœ… **Optimized**: Reuses build artifacts when possible -- βœ… **Efficient**: Only builds and tests affected projects -- βœ… **Reliable**: Comprehensive testing before release -- βœ… **Transparent**: Clear changelogs and release notes -- βœ… **Multi-platform**: Supports both NPM and JSR (Deno) -- βœ… **Scalable**: Easy to add new packages to release process -- βœ… **Separated**: Clear separation between CI and Release concerns -- βœ… **Fast**: Parallel execution and smart artifact reuse +- 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. diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 6cf5abdc..3992e9bf 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -1,4 +1,4 @@ -name: πŸš€ CD +name: πŸš€ Release Beta on: push: @@ -6,7 +6,7 @@ on: workflow_dispatch: inputs: publish_only: - description: "Publish existing stable versions without versioning, changelog, or git changes" + description: "Publish existing beta versions without versioning, changelog, or git changes" required: true type: boolean default: false @@ -16,24 +16,40 @@ on: type: string concurrency: - group: cd-release + group: release-beta cancel-in-progress: false -permissions: - contents: write # Needed for creating commits and tags - id-token: write # Needed for npm provenance authentication +env: + DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/effectify" jobs: - release: - name: πŸš€ Release + 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 - if: ${{ !contains(github.event.head_commit.message, 'chore(release):') && !contains(github.event.head_commit.message, '[skip release]') }} + permissions: + contents: write + id-token: write + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: effectify + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 steps: - name: πŸ“₯ Checkout uses: actions/checkout@v5 with: - fetch-depth: 0 # Important for Nx to analyze git history - token: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }} + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} - name: πŸ“¦ Install pnpm uses: pnpm/action-setup@v6 @@ -44,82 +60,132 @@ jobs: uses: actions/setup-node@v5 with: node-version: "24.19.0" - registry-url: "https://registry.npmjs.org" cache: "pnpm" + registry-url: "https://registry.npmjs.org/" - name: πŸ“¦ Install dependencies run: pnpm install --frozen-lockfile - - name: βš™οΈ Git Configuration + - 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: πŸ” Validate Publish-Only Recovery Projects - id: recovery - if: ${{ inputs.publish_only == true }} + - name: πŸ” Detect Affected Release Projects + id: affected env: + PUBLISH_ONLY: ${{ inputs.publish_only || false }} RECOVERY_PROJECTS: ${{ inputs.projects || '' }} + BEFORE_SHA: ${{ github.event.before }} + HEAD_SHA: ${{ github.sha }} run: | - if [ -z "$RECOVERY_PROJECTS" ]; then - echo "publish-only recovery requires an explicit comma-separated projects input" >&2 - exit 1 - fi - RELEASE_PROJECTS=$(jq -r '.release.projects[]' nx.json | while read -r path; do - pnpm nx show project "$path" --json | jq -r '.name' - done | sort -u) - SELECTED_PROJECTS=$(printf '%s' "$RECOVERY_PROJECTS" | tr ',' '\n' | sed '/^$/d' | sort -u) - while IFS= read -r project; do - if ! printf '%s\n' "$RELEASE_PROJECTS" | grep -Fx -- "$project" >/dev/null; then - echo "Invalid release project: $project" >&2 + 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' + ) + + 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 - done <<< "$SELECTED_PROJECTS" - echo "projects=$(printf '%s' "$SELECTED_PROJECTS" | paste -sd, -)" >> "$GITHUB_OUTPUT" + SELECTED_PROJECTS=$(printf '%s' "$RECOVERY_PROJECTS" | tr ',' '\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | sed '/^$/d' | sort -u) + while IFS= read -r project; do + if ! printf '%s\n' "$RELEASE_PROJECTS" | jq -r '.[]' | grep -Fx -- "$project" >/dev/null; then + echo "Invalid release project: $project" >&2 + exit 1 + fi + done <<< "$SELECTED_PROJECTS" + echo "has_projects=true" >> "$GITHUB_OUTPUT" + echo "projects=$(printf '%s' "$SELECTED_PROJECTS" | paste -sd, -)" >> "$GITHUB_OUTPUT" + exit 0 + fi - - name: πŸ” Verify npm authentication - run: npm whoami + ZERO_SHA="0000000000000000000000000000000000000000" + BEFORE="$BEFORE_SHA" + HEAD="$HEAD_SHA" + if ! git cat-file -e "${HEAD}^{commit}" 2>/dev/null; then + HEAD=$(git rev-parse HEAD) + fi + if [ -n "$BEFORE" ] && [ "$BEFORE" != "$ZERO_SHA" ] && git cat-file -e "${BEFORE}^{commit}" 2>/dev/null; then + BASE="$BEFORE" + elif git rev-parse --verify HEAD^ >/dev/null 2>&1; then + BASE="HEAD^" + else + BASE="$HEAD" + 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 "has_projects=false" >> "$GITHUB_OUTPUT" + echo "projects=" >> "$GITHUB_OUTPUT" + else + 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' }} env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + PROJECTS: ${{ steps.affected.outputs.projects }} + run: pnpm nx run-many -t build "--projects=$PROJECTS" --parallel=3 - - name: βœ… Verify React Router migration + - name: πŸ§ͺ Test Affected Projects + if: ${{ steps.affected.outputs.has_projects == 'true' }} + env: + PROJECTS: ${{ steps.affected.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' }} run: | pnpm nx test @effectify/react-router pnpm nx run @effectify/react-router-example:migration:test pnpm nx run @effectify/react-router-example:migration:verify pnpm nx run @effectify/react-router-example:migration:manifest + pnpm nx run @effectify/react-router-example:consolidation:verify - - name: πŸ”– Version & Changelog - if: ${{ inputs.publish_only != true }} - # Nx Release is the sole owner of changelogs, the release commit, and tags. + - name: πŸ” Verify npm authentication + if: ${{ steps.affected.outputs.has_projects == 'true' }} + run: npm whoami env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: pnpm nx release --skip-publish + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - - name: πŸ—οΈ Build + - name: πŸš€ Version, Changelog & Publish Beta + if: ${{ steps.affected.outputs.has_projects == 'true' }} env: + PROJECTS: ${{ steps.affected.outputs.projects }} PUBLISH_ONLY: ${{ inputs.publish_only || false }} - PROJECTS: ${{ steps.recovery.outputs.projects }} + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NPM_CONFIG_PROVENANCE: true run: | - if [ "$PUBLISH_ONLY" = "true" ]; then - pnpm nx run-many -t build "--projects=$PROJECTS" - else - pnpm nx run-many -t build --all + if [ "$PUBLISH_ONLY" != "true" ]; then + pnpm nx release "--projects=$PROJECTS" --preid=beta --skip-publish + pnpm nx run-many -t build "--projects=$PROJECTS" --parallel=3 fi + # Every beta publish is explicitly non-default, including recovery. + pnpm nx release publish "--projects=$PROJECTS" --tag=beta - - name: πŸš€ Publish + - name: πŸ“Š Release Summary + if: always() env: + HAS_PROJECTS: ${{ steps.affected.outputs.has_projects }} + PROJECTS: ${{ steps.affected.outputs.projects }} PUBLISH_ONLY: ${{ inputs.publish_only || false }} - PROJECTS: ${{ steps.recovery.outputs.projects }} - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed for creating GitHub Releases - NPM_CONFIG_PROVENANCE: true - # Publish-only recovery uses selected existing manifests; Nx checks registry state. run: | - if [ "$PUBLISH_ONLY" = "true" ]; then - pnpm nx release publish "--projects=$PROJECTS" + 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 else - pnpm nx release publish + echo "⏭️ **Skipped - No affected projects**" >> "$GITHUB_STEP_SUMMARY" fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e5527493..15f7e472 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,22 @@ env: NODE_OPTIONS: "--max-old-space-size=4096" jobs: + release-policy: + name: πŸ›‘οΈ Release Policy Contract + if: github.event_name != 'pull_request' || github.event.pull_request.draft == false + runs-on: ubuntu-latest + steps: + - name: πŸ“₯ Checkout + uses: actions/checkout@v5 + + - name: πŸ—οΈ Setup Node.js + uses: actions/setup-node@v5 + with: + node-version: "24.19.0" + + - name: πŸ›‘οΈ Verify release policy contract + run: node --test scripts/release-policy-contract.test.mjs + # Lint and format check lint: name: πŸ” Lint & Format @@ -260,7 +276,7 @@ jobs: ci-summary: name: πŸ“Š CI Summary runs-on: ubuntu-latest - needs: [lint, typecheck, build, test] + needs: [release-policy, lint, typecheck, build, test] if: always() && (github.event_name != 'pull_request' || github.event.pull_request.draft == false) steps: - name: πŸ“Š CI Summary @@ -268,6 +284,7 @@ jobs: echo "## πŸ§ͺ CI Results Summary" >> $GITHUB_STEP_SUMMARY echo "| Job | Status | Details |" >> $GITHUB_STEP_SUMMARY echo "|-----|--------|---------|" >> $GITHUB_STEP_SUMMARY + echo "| πŸ›‘οΈ Release Policy | ${{ needs.release-policy.result == 'success' && 'βœ… Success' || '❌ Failed' }} | Enforces alpha, beta, and stable separation |" >> $GITHUB_STEP_SUMMARY echo "| πŸ” Lint & Format | ${{ needs.lint.result == 'success' && 'βœ… Success' || '❌ Failed' }} | Uses oxlint + oxfmt |" >> $GITHUB_STEP_SUMMARY echo "| πŸ” Type Check | ${{ needs.typecheck.result == 'success' && 'βœ… Success' || '❌ Failed' }} | TypeScript compiler checks |" >> $GITHUB_STEP_SUMMARY echo "| πŸ—οΈ Build | ${{ needs.build.result == 'success' && 'βœ… Success' || '❌ Failed' }} | Nx affected build |" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/release-alpha.yml b/.github/workflows/release-alpha.yml index 16c584fb..f8e656ec 100644 --- a/.github/workflows/release-alpha.yml +++ b/.github/workflows/release-alpha.yml @@ -65,6 +65,9 @@ jobs: - name: πŸ“¦ Install dependencies run: pnpm install --frozen-lockfile + - name: πŸ›‘οΈ Verify release policy contract + run: node --test scripts/release-policy-contract.test.mjs + - name: πŸ”§ Configure Git if: ${{ inputs.publish_only != true }} run: | @@ -76,25 +79,23 @@ jobs: env: PUBLISH_ONLY: ${{ inputs.publish_only || false }} RECOVERY_PROJECTS: ${{ inputs.projects || '' }} + BEFORE_SHA: ${{ github.event.before }} + HEAD_SHA: ${{ github.sha }} run: | - RELEASE_PATHS=$(jq -r '.release.projects[]' nx.json | sort | uniq) - RELEASE_PROJECTS="" - for path in $RELEASE_PATHS; do - PROJECT_NAME=$(pnpm nx show project "$path" --json 2>/dev/null | jq -r '.name' 2>/dev/null) - if [ -n "$PROJECT_NAME" ] && [ "$PROJECT_NAME" != "null" ]; then - RELEASE_PROJECTS="$RELEASE_PROJECTS $PROJECT_NAME" - fi - done - RELEASE_PROJECTS=$(echo "$RELEASE_PROJECTS" | tr ' ' '\n' | sort | uniq) + 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' + ) 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 '/^$/d' | sort -u) + SELECTED_PROJECTS=$(printf '%s' "$RECOVERY_PROJECTS" | tr ',' '\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | sed '/^$/d' | sort -u) while IFS= read -r project; do - if ! printf '%s\n' "$RELEASE_PROJECTS" | grep -Fx -- "$project" >/dev/null; then + if ! printf '%s\n' "$RELEASE_PROJECTS" | jq -r '.[]' | grep -Fx -- "$project" >/dev/null; then echo "Invalid release project: $project" >&2 exit 1 fi @@ -105,8 +106,22 @@ jobs: exit 0 fi - AFFECTED_RAW=$(pnpm nx show projects --affected --base=origin/dev~1 --head=HEAD --json 2>/dev/null || echo "[]") - AFFECTED_RELEASE_PROJECTS=$(echo "$AFFECTED_RAW" | jq -r --arg release "$RELEASE_PROJECTS" '[.[] | select(. as $p | $release | contains($p))] | join(",")' 2>/dev/null || echo "") + ZERO_SHA="0000000000000000000000000000000000000000" + BEFORE="$BEFORE_SHA" + HEAD="$HEAD_SHA" + if ! git cat-file -e "${HEAD}^{commit}" 2>/dev/null; then + HEAD=$(git rev-parse HEAD) + fi + if [ -n "$BEFORE" ] && [ "$BEFORE" != "$ZERO_SHA" ] && git cat-file -e "${BEFORE}^{commit}" 2>/dev/null; then + BASE="$BEFORE" + elif git rev-parse --verify HEAD^ >/dev/null 2>&1; then + BASE="HEAD^" + else + BASE="$HEAD" + 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 "has_projects=false" >> "$GITHUB_OUTPUT" echo "projects=" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/release-stable.yml b/.github/workflows/release-stable.yml new file mode 100644 index 00000000..d40a3b14 --- /dev/null +++ b/.github/workflows/release-stable.yml @@ -0,0 +1,184 @@ +name: πŸš€ Release Stable + +on: + workflow_dispatch: + inputs: + projects: + description: "Comma-separated Nx release project names to graduate or recover" + required: true + type: string + publish_only: + description: "Publish selected existing stable versions without version, tag, changelog, or git mutation" + required: true + type: boolean + default: false + +concurrency: + group: release-stable + cancel-in-progress: false + +env: + DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/effectify" + +jobs: + release-stable: + name: πŸš€ Release Stable + runs-on: ubuntu-latest + permissions: + contents: write + id-token: write + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: effectify + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: + - name: πŸ“₯ Checkout current master + uses: actions/checkout@v5 + with: + ref: master + fetch-depth: 0 + token: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }} + + - name: πŸ”’ Confirm current master + run: | + git fetch origin master --no-tags + test "$(git rev-parse HEAD)" = "$(git rev-parse origin/master)" || { + echo "Stable release checkout is not current origin/master" >&2 + exit 1 + } + + - name: πŸ“¦ Install pnpm + uses: pnpm/action-setup@v6 + with: + version: 10.14.0 + + - name: πŸ—οΈ Setup Node.js + uses: actions/setup-node@v5 + with: + node-version: "24.19.0" + cache: "pnpm" + registry-url: "https://registry.npmjs.org/" + + - name: πŸ“¦ Install dependencies + run: pnpm install --frozen-lockfile + + - name: πŸ›‘οΈ Verify release policy contract + run: node --test scripts/release-policy-contract.test.mjs + + - name: πŸ” Validate Explicit Stable Projects + id: selected + env: + REQUESTED_PROJECTS: ${{ inputs.projects }} + PUBLISH_ONLY: ${{ inputs.publish_only }} + run: | + if [ -z "$REQUESTED_PROJECTS" ]; then + echo "Stable release requires explicit selected projects" >&2 + exit 1 + fi + + RELEASE_PROJECTS=$(jq -r '.release.projects[]' nx.json | while read -r path; do + pnpm nx show project "$path" --json | jq -r '.name' + done | sort -u) + SELECTED_PROJECTS=$(printf '%s' "$REQUESTED_PROJECTS" | tr ',' '\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | sed '/^$/d' | sort -u) + + if [ -z "$SELECTED_PROJECTS" ]; then + echo "Stable release requires at least one selected project" >&2 + exit 1 + fi + + while IFS= read -r project; do + if ! printf '%s\n' "$RELEASE_PROJECTS" | grep -Fx -- "$project" >/dev/null; then + echo "Invalid release project: $project" >&2 + exit 1 + fi + + PROJECT_ROOT=$(pnpm nx show project "$project" --json | jq -r '.root') + VERSION=$(jq -r '.version // empty' "$PROJECT_ROOT/package.json") + if [ -z "$VERSION" ]; then + echo "Release project has no manifest version: $project" >&2 + exit 1 + fi + + if [ "$PUBLISH_ONLY" = "true" ]; then + # publish-only recovery requires explicit selected existing stable projects. + if [[ "$VERSION" == *-* ]]; then + echo "Publish-only stable recovery rejects prerelease version $project@$VERSION" >&2 + exit 1 + fi + elif [[ "$VERSION" != *-* ]]; then + echo "Normal stable release only graduates selected prereleases: $project@$VERSION" >&2 + exit 1 + fi + done <<< "$SELECTED_PROJECTS" + + echo "projects=$(printf '%s' "$SELECTED_PROJECTS" | paste -sd, -)" >> "$GITHUB_OUTPUT" + + - name: πŸ”§ Configure Git for graduation + 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: πŸ” Verify npm authentication + run: npm whoami + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: πŸ—οΈ Build Selected Projects + env: + PROJECTS: ${{ steps.selected.outputs.projects }} + run: pnpm nx run-many -t build "--projects=$PROJECTS" --parallel=3 + + - name: πŸ§ͺ Test Selected Projects + env: + PROJECTS: ${{ steps.selected.outputs.projects }} + run: pnpm nx run-many -t test "--projects=$PROJECTS" --parallel=3 --passWithNoTests + + - name: βœ… Verify React Router 8 readiness + run: | + pnpm nx test @effectify/react-router + pnpm nx run @effectify/react-router-example:migration:test + pnpm nx run @effectify/react-router-example:migration:verify + pnpm nx run @effectify/react-router-example:migration:manifest + pnpm nx run @effectify/react-router-example:consolidation:verify + + - name: πŸ”– Graduate Selected Prereleases + if: ${{ inputs.publish_only != true }} + env: + PROJECTS: ${{ steps.selected.outputs.projects }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Relative patch removes the prerelease suffix without selecting unrequested projects. + run: pnpm nx release patch "--projects=$PROJECTS" --skip-publish + + - name: πŸš€ Publish Stable + env: + PROJECTS: ${{ steps.selected.outputs.projects }} + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NPM_CONFIG_PROVENANCE: true + # No prerelease dist-tag is supplied: npm's default stable tag is intentional. + run: pnpm nx release publish "--projects=$PROJECTS" + + - name: πŸ“Š Release Summary + if: always() + env: + PROJECTS: ${{ steps.selected.outputs.projects }} + PUBLISH_ONLY: ${{ inputs.publish_only }} + run: | + echo "## πŸš€ Stable Release Summary" >> "$GITHUB_STEP_SUMMARY" + echo "**Projects:** $PROJECTS" >> "$GITHUB_STEP_SUMMARY" + if [ "$PUBLISH_ONLY" = "true" ]; then + echo "**Mode:** publish-only recovery of selected existing stable versions; no version, tag, changelog, or git mutation was requested." >> "$GITHUB_STEP_SUMMARY" + else + echo "**Mode:** selected prereleases graduated with Nx relative patch." >> "$GITHUB_STEP_SUMMARY" + fi diff --git a/README.md b/README.md index 8983f1f9..1c22a615 100644 --- a/README.md +++ b/README.md @@ -1,72 +1,58 @@ # Effectify -[![Alpha Release](https://img.shields.io/badge/alpha-v4%20alpha-blue)](https://www.npmjs.com/search?q=%40effectify) +[![Alpha Release](https://img.shields.io/badge/channel-alpha-blue)](https://www.npmjs.com/search?q=%40effectify) [![Documentation](https://img.shields.io/badge/docs-effectify.dev-00C853)](https://devx-op.github.io/effectify/) -Monorepo of utilities for integrating [Effect](https://effect.website/) with different frameworks and libraries. +Effectify provides Effect integrations for React, Solid, authentication, Prisma, and Hatchet. -> **πŸš€ Effect v4 Alpha Support**: We are currently migrating packages to support Effect v4 beta. Alpha versions are available on npm with the `@alpha` tag. +> **Effect v4 RC:** The current workspace targets the Effect v4 release candidate (`effect@4.0.0-rc.111`). Prerelease packages are published on explicit npm tags and never replace the stable default by accident. -## Packages +## Choose a release channel + +| Channel | Trigger | npm tag | Use it for | +| ------- | ---------------------- | ------------------ | --------------------------------------- | +| Alpha | Push to `dev` | `alpha` | Earliest integration builds | +| Beta | Push to `master` | `beta` | Master-qualified prereleases | +| Stable | Manual stable workflow | default (`latest`) | Explicitly selected production releases | -| Package | Version | Documentation | Description | -| -------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- | ---------------------------------------------------------------------------- | -| [@effectify/solid-query](https://www.npmjs.com/package/@effectify/solid-query) | [![npm version](https://img.shields.io/npm/v/@effectify/solid-query.svg)](https://www.npmjs.com/package/@effectify/solid-query) | [Docs](./packages/solid/query/README.md) | Integration of Effect with TanStack Query for Solid.js | -| [@effectify/react-query](https://www.npmjs.com/package/@effectify/react-query) | [![npm version](https://img.shields.io/npm/v/@effectify/react-query.svg)](https://www.npmjs.com/package/@effectify/react-query) | [Docs](./packages/react/query/README.md) | Integration of Effect with TanStack Query for React | -| [@effectify/react-router](https://www.npmjs.com/package/@effectify/react-router) | [![npm version](https://img.shields.io/npm/v/@effectify/react-router.svg)](https://www.npmjs.com/package/@effectify/react-router) | [Docs](./packages/react/router/README.md) | Integration of React Router with Effect for React applications | -| [@effectify/node-better-auth](https://www.npmjs.com/package/@effectify/node-better-auth) | [![npm version](https://img.shields.io/npm/v/@effectify/node-better-auth.svg)](https://www.npmjs.com/package/@effectify/node-better-auth) | [Docs](./packages/node/better-auth/README.md) | Integration of better-auth with Effect for Node.js applications | -| [@effectify/react-router-better-auth](https://www.npmjs.com/package/@effectify/react-router-better-auth) | [![npm version](https://img.shields.io/npm/v/@effectify/react-router-better-auth.svg)](https://www.npmjs.com/package/@effectify/react-router-better-auth) | [Docs](./packages/react/router-better-auth/) | Integration of React Router + better-auth with Effect for React applications | -| [@effectify/prisma](https://www.npmjs.com/package/@effectify/prisma) | [![npm version](https://img.shields.io/npm/v/@effectify/prisma.svg)](https://www.npmjs.com/package/@effectify/prisma) | [Docs](./packages/prisma/README.md) | Prisma generator and runtime utilities for Effect | +Install an explicit channel; do not rely on npm's default tag for prereleases. -## Solid Atom Integration +## Packages -The Solid example uses Effect v4's core `Atom` and `AtomRef` modules with the official [`@effect/atom-solid`](https://www.npmjs.com/package/@effect/atom-solid) bindings. See the [Solid example](./apps/solid-example/) for the current provider and hook integration. +| Package | Documentation | Scope | +| ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ------------------------------------------ | +| [`@effectify/react-router`](https://www.npmjs.com/package/@effectify/react-router) | [Docs](./packages/react/router/README.md) | Maintained React Router 8 integration | +| [`@effectify/react-query`](https://www.npmjs.com/package/@effectify/react-query) | [Docs](./packages/react/query/README.md) | TanStack Query integration for React | +| [`@effectify/node-better-auth`](https://www.npmjs.com/package/@effectify/node-better-auth) | [Docs](./packages/node/better-auth/README.md) | better-auth integration for Node.js | +| [`@effectify/solid-query`](https://www.npmjs.com/package/@effectify/solid-query) | [Docs](./packages/solid/query/README.md) | TanStack Query integration for Solid | +| [`@effectify/react-router-better-auth`](https://www.npmjs.com/package/@effectify/react-router-better-auth) | [Usage reference](./packages/react/router-better-auth/tests/auth-guard.test.ts) | React Router 8 and better-auth integration | +| [`@effectify/prisma`](https://www.npmjs.com/package/@effectify/prisma) | [Docs](./packages/prisma/README.md) | Prisma generator and runtime utilities | +| [`@effectify/hatchet`](https://www.npmjs.com/package/@effectify/hatchet) | [Package](./packages/hatchet/) | Hatchet workflow integration | -## Alpha Installation (Effect v4) +The supported router surface is React Router 8 only. The Solid example uses Effect v4's `Atom` and `AtomRef` modules with the official [`@effect/atom-solid`](https://www.npmjs.com/package/@effect/atom-solid) bindings. -We are actively migrating packages to support Effect v4 beta. You can install alpha versions using the `@alpha` npm tag: +## Install alpha packages + +Every Nx release package is available through the explicit alpha channel when an alpha version has been published: ```bash -# npm -npm install @effectify/react-query@alpha -npm install @effectify/solid-query@alpha npm install @effectify/react-router@alpha +npm install @effectify/react-query@alpha npm install @effectify/node-better-auth@alpha +npm install @effectify/solid-query@alpha +npm install @effectify/react-router-better-auth@alpha npm install @effectify/prisma@alpha - -# pnpm -pnpm add @effectify/react-query@alpha - -# yarn -yarn add @effectify/react-query@alpha +npm install @effectify/hatchet@alpha ``` -### Migration Status - -| Package | v4 Alpha Status | Stable Version | -| --------------------------- | --------------- | -------------- | -| @effectify/react-query | 🚧 In Progress | βœ… v3 | -| @effectify/solid-query | 🚧 In Progress | βœ… v3 | -| @effectify/react-router | 🚧 In Progress | βœ… v3 | -| @effectify/node-better-auth | 🚧 In Progress | βœ… v3 | -| @effectify/prisma | 🚧 In Progress | βœ… v3 | - -**Legend**: βœ… Available | 🚧 Migrating | ⏳ Pending - -### Effect v3 vs v4 - -- **Stable releases** (v3.x) continue to work with Effect v3.19.x -- **Alpha releases** (v4.x) require Effect v4 beta -- Both versions maintain the same API where possible - -For migration details, see the [Effect v4 Migration Guide](https://effect.website/docs/migration/v4). +Use the same package names with `pnpm add` or `yarn add` if those are your package managers. Alpha and beta releases require the current Effect v4 RC. Stable compatibility is documented by each package release. ## Development ### Requirements -- [pnpm](https://pnpm.io/) -- [Node.js](https://nodejs.org/) +- Node.js 24.19.0 +- pnpm 10.14.0 ### Commands @@ -74,32 +60,27 @@ For migration details, see the [Effect v4 Migration Guide](https://effect.websit # Install dependencies pnpm install -# Run example application -pnpm nx dev tanstack-solid-app +# Run the maintained Solid example +pnpm nx dev @effectify/solid-example -# Build all packages +# Build affected packages pnpm nx affected -t build -# Check or apply pinned oxfmt to changed files +# Check or apply pinned formatting to changed files pnpm format:check pnpm format -# Audit the full repository before the dedicated formatting follow-up -pnpm format:all:check - -# Clean project -pnpm clean +# Verify React Router 8 consolidation and readiness +pnpm nx run @effectify/react-router-example:consolidation:verify +pnpm nx run @effectify/react-router-example:migration:manifest +pnpm nx run @effectify/react-router-example:migration:verify ``` -The workspace pins Oxfmt 0.60.0 and Oxlint 1.75.0, whose published Node engine range is `^20.19.0 || >=22.12.0`. Oxfmt provides full-document formatting through the OXC editor extension, its LSP, and the contextual Node API used by the Prisma generator. Changed-file enforcement covers TypeScript, JavaScript, JSON, Markdown, CSS, SCSS, and HTML. Oxfmt 0.60.0 does not support Eta filepath parsing, so changed Eta files fail the formatter check with an explicit manual-formatting requirement until upstream support is available. LSP range formatting is also unavailable, so formatting a selection remains an upstream follow-up rather than claimed parity. - -### Release Management - -To skip a release for documentation updates or other non-release changes, include `[skip release]` in your commit message. +See [`.github/SETUP.md`](./.github/SETUP.md) for exact CI triggers, release behavior, and stable recovery. ## Credits & Inspiration -This project was inspired by the excellent educational content from [Lucas Barake](https://www.youtube.com/@lucas-barake), particularly his [video on Effect and TanStack Query](https://www.youtube.com/watch?v=zl4w3BQAoJM&t=1011s) which provides great insights into these technologies. +This project was inspired by the educational content from [Lucas Barake](https://www.youtube.com/@lucas-barake), particularly his [Effect and TanStack Query video](https://www.youtube.com/watch?v=zl4w3BQAoJM&t=1011s). ## License diff --git a/scripts/release-policy-contract.test.mjs b/scripts/release-policy-contract.test.mjs new file mode 100644 index 00000000..567687c6 --- /dev/null +++ b/scripts/release-policy-contract.test.mjs @@ -0,0 +1,426 @@ +import assert from "node:assert/strict" +import { readFileSync } from "node:fs" +import test from "node:test" + +const read = (path) => { + try { + return readFileSync(new URL(`../${path}`, import.meta.url), "utf8") + } catch { + return "" + } +} + +const workflows = { + alpha: read(".github/workflows/release-alpha.yml"), + beta: read(".github/workflows/cd.yml"), + ci: read(".github/workflows/ci.yml"), + stable: read(".github/workflows/release-stable.yml"), +} +const readme = read("README.md") +const setup = read(".github/SETUP.md") + +const releaseProjects = [ + "@effectify/react-router", + "@effectify/react-query", + "@effectify/node-better-auth", + "@effectify/solid-query", + "@effectify/react-router-better-auth", + "@effectify/prisma", + "@effectify/hatchet", +] + +const indentation = (line) => line.match(/^\s*/)[0].length +const stripComment = (line) => { + let singleQuoted = false + let doubleQuoted = false + for (let index = 0; index < line.length; index += 1) { + const character = line[index] + if (character === "'" && !doubleQuoted) singleQuoted = !singleQuoted + if (character === '"' && !singleQuoted && line[index - 1] !== "\\") doubleQuoted = !doubleQuoted + if (character === "#" && !singleQuoted && !doubleQuoted && (index === 0 || /\s/.test(line[index - 1]))) { + return line.slice(0, index).trimEnd() + } + } + return line +} +const withoutComments = (source) => source.split("\n").map(stripComment).join("\n") + +const extractSteps = (source) => { + const lines = source.split("\n") + const steps = [] + + for (let index = 0; index < lines.length; index += 1) { + const match = lines[index].match(/^(\s*)- name:\s*(.+?)\s*$/) + if (!match || /^\s*#/.test(lines[index])) continue + + const stepIndent = match[1].length + const step = { name: match[2], condition: "", commands: [] } + for (index += 1; index < lines.length; index += 1) { + const line = lines[index] + if (line.trim() && indentation(line) <= stepIndent) { + index -= 1 + break + } + if (/^\s*#/.test(line)) continue + + const condition = line.match(/^\s*if:\s*(.+?)\s*$/) + if (condition) step.condition = condition[1] + + const run = line.match(/^(\s*)run:\s*(.*)$/) + if (!run) continue + + const runIndent = run[1].length + if (run[2] && !/^[|>]$/.test(run[2])) { + step.commands.push(run[2].trim()) + continue + } + + for (index += 1; index < lines.length; index += 1) { + const command = lines[index] + if (command.trim() && indentation(command) <= runIndent) { + index -= 1 + break + } + const trimmed = command.trim() + if (trimmed && !trimmed.startsWith("#")) step.commands.push(trimmed) + } + } + steps.push(step) + } + + return steps +} + +const commandEntries = (source) => + extractSteps(source).flatMap((step, stepIndex) => + step.commands.map((command, commandIndex) => ({ command, commandIndex, step, stepIndex })), + ) + +const commandPosition = (source, pattern) => { + const entry = commandEntries(source).find(({ command }) => pattern.test(command)) + return entry ? entry.stepIndex * 1000 + entry.commandIndex : -1 +} + +const requireCommand = (violations, source, pattern, violation) => { + if (commandPosition(source, pattern) === -1) violations.push(violation) +} + +const requireCommandOrder = (violations, source, patterns, violation) => { + const positions = patterns.map((pattern) => commandPosition(source, pattern)) + if (positions.some((position) => position === -1)) { + violations.push(`${violation} (missing command)`) + return + } + if (positions.some((position, index) => index > 0 && position <= positions[index - 1])) { + violations.push(`${violation} (wrong order)`) + } +} + +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 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$/ +const rr8Commands = [ + /^pnpm nx test @effectify\/react-router$/, + /^pnpm nx run @effectify\/react-router-example:migration:test$/, + /^pnpm nx run @effectify\/react-router-example:migration:verify$/, + /^pnpm nx run @effectify\/react-router-example:migration:manifest$/, + /^pnpm nx run @effectify\/react-router-example:consolidation:verify$/, +] + +const channelViolations = (channel, source) => { + const violations = [] + const active = withoutComments(source) + const branch = channel === "alpha" ? "dev" : "master" + + if (!new RegExp(`push:\\s*\\n\\s*branches: \\[${branch}\\]`).test(active)) { + violations.push(`${channel} trigger`) + } + requireCommand(violations, source, channelVersionCommand(channel), `${channel} version mapping`) + requireCommand(violations, source, channelPublishCommand(channel), `${channel} publish mapping`) + requireCommand(violations, source, contractCommand, `${channel} policy contract`) + requireCommandOrder( + violations, + source, + [contractCommand, buildCommand, testCommand, channelVersionCommand(channel), channelPublishCommand(channel)], + `${channel} release ordering`, + ) + + if (!/BEFORE_SHA:\s*\$\{\{ github\.event\.before \}\}/.test(active)) { + violations.push(`${channel} push base input`) + } + if (!/HEAD_SHA:\s*\$\{\{ github\.sha \}\}/.test(active)) { + violations.push(`${channel} push head input`) + } + + for (const [pattern, name] of [ + [/^ZERO_SHA="0{40}"$/, "zero SHA fallback"], + [/^BEFORE="\$BEFORE_SHA"$/, "before assignment"], + [/^HEAD="\$HEAD_SHA"$/, "head assignment"], + [/git cat-file -e "\$\{BEFORE\}\^\{commit\}"/, "before validation"], + [/git cat-file -e "\$\{HEAD\}\^\{commit\}"/, "head validation"], + [/^BASE="HEAD\^"$/, "manual fallback"], + [/--base="\$BASE" --head="\$HEAD" --json/, "exact affected range"], + [/grep -Fx -- "\$project"/, "exact recovery membership"], + [/\$release \| index\(\$project\)/, "exact affected membership"], + ]) { + requireCommand(violations, source, pattern, `${channel} ${name}`) + } + + if ( + commandEntries(source).some( + ({ command }) => /nx release publish/.test(command) && !new RegExp(`--tag=${channel}$`).test(command), + ) + ) { + violations.push(`${channel} default publication`) + } + return violations +} + +const stableViolations = (source) => { + const violations = [] + const active = withoutComments(source) + if (/^\s*push:/m.test(active) || !/^\s*workflow_dispatch:/m.test(active)) { + violations.push("stable trigger") + } + if (!/projects:\s*\n\s*description:[^\n]*\n\s*required: true/.test(active) || !/ref: master/.test(active)) { + violations.push("stable selection") + } + + const stableVersion = /^pnpm nx release patch "--projects=\$PROJECTS" --skip-publish$/ + const stablePublish = /^pnpm nx release publish "--projects=\$PROJECTS"$/ + const requiredOrder = [ + /^git fetch origin master --no-tags$/, + /^test "\$\(git rev-parse HEAD\)" = "\$\(git rev-parse origin\/master\)" \|\| \{$/, + contractCommand, + /grep -Fx -- "\$project"/, + buildCommand, + testCommand, + ...rr8Commands, + stableVersion, + stablePublish, + ] + + requireCommandOrder(violations, source, requiredOrder, "stable release safety ordering") + requireCommand(violations, source, stableVersion, "stable relative patch") + requireCommand(violations, source, stablePublish, "stable publish") + requireCommand(violations, source, /grep -Fx -- "\$project"/, "stable exact allowlist membership") + requireCommand(violations, source, /^if \[\[ "\$VERSION" == \*-\* \]\]; then$/, "stable recovery version guard") + + const stableSteps = extractSteps(source) + const versionStep = stableSteps.find((step) => step.commands.some((command) => stableVersion.test(command))) + if (!versionStep || !/inputs\.publish_only != true/.test(versionStep.condition)) { + violations.push("stable publish-only version isolation") + } + for (const [pattern, name] of [ + [buildCommand, "build"], + [testCommand, "test"], + ]) { + const step = stableSteps.find((candidate) => candidate.commands.some((command) => pattern.test(command))) + if (!step || /publish_only != true/.test(step.condition)) { + violations.push(`stable publish-only ${name}`) + } + } + for (const step of stableSteps.filter((candidate) => + candidate.commands.some((command) => /^git (?:commit|tag|push)\b/.test(command)), + )) { + if (!/inputs\.publish_only != true/.test(step.condition)) { + violations.push("stable publish-only git mutation isolation") + } + } + if (commandEntries(source).some(({ command }) => /--preid=|--tag=(?:alpha|beta|latest|stable)/.test(command))) { + violations.push("stable prerelease mapping") + } + return violations +} + +const policyViolations = ({ alpha, beta, stable, docs }) => { + const violations = [ + ...channelViolations("alpha", alpha), + ...channelViolations("beta", beta), + ...stableViolations(stable), + ] + if (!/\|\s*Beta\s*\|[^\n]*`master`[^\n]*`beta`/.test(withoutComments(docs))) { + violations.push("documented mapping") + } + return violations +} + +const mutate = (source, before, after) => { + const mutated = source.replace(before, after) + assert.notEqual(mutated, source, `mutation fixture not found: ${String(before)}`) + return mutated +} + +const assertMutationFails = (name, policy, mutation) => { + const changed = mutation(policy) + assert.notDeepEqual(policyViolations(changed), [], name) +} + +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("stable validates current master and selected projects before every release mutation", () => { + assert.deepEqual(stableViolations(workflows.stable), []) +}) + +test("the release policy contract runs in PR CI", () => { + assert.match(withoutComments(workflows.ci), /pull_request:/) + requireCommand([], workflows.ci, contractCommand, "CI policy contract") + assert.notEqual(commandPosition(workflows.ci, contractCommand), -1) +}) + +test("release documentation leads with the three-channel mapping", () => { + for (const document of [readme, setup]) { + const active = withoutComments(document) + assert.match(active, /\|\s*Channel\s*\|\s*Trigger\s*\|\s*npm tag\s*\|/) + assert.match(active, /\|\s*Alpha\s*\|[^\n]*`dev`[^\n]*`alpha`[^\n]*\|/) + assert.match(active, /\|\s*Beta\s*\|[^\n]*`master`[^\n]*`beta`[^\n]*\|/) + assert.match(active, /\|\s*Stable\s*\|[^\n]*[Mm]anual[^\n]*(?:default|latest)[^\n]*\|/) + } + + assert.match(setup, /Node\.js 24\.19\.0/) + assert.match(setup, /publish-only recovery/i) + assert.doesNotMatch(setup, /JSR|develop branch|release\.yml/) +}) + +test("README describes every release package and links a real router-auth reference", () => { + for (const project of releaseProjects) { + assert.match(readme, new RegExp(project.replaceAll("/", "\\/"))) + assert.match( + readme, + new RegExp(`(?:npm install|pnpm add|yarn add) ${project.replaceAll("/", "\\/")}@alpha`), + `missing alpha install for ${project}`, + ) + } + + assert.match(readme, /packages\/react\/router-better-auth\/tests\/auth-guard\.test\.ts/) + assert.match(readme, /Effect v4 RC/) + assert.match(readme, /React Router 8/) + assert.match(readme, /pnpm nx dev @effectify\/solid-example/) + assert.doesNotMatch(readme, /Effect v4 beta|React Remix|@effectify\/react-remix/) +}) + +test("setup lists all seven Nx release projects", () => { + for (const project of releaseProjects) { + assert.match(setup, new RegExp(project.replaceAll("/", "\\/"))) + } +}) + +test("stable safety mutations fail closed, including commented-out policy text", () => { + const policy = { ...workflows, docs: readme } + + assertMutationFails("corrupt exact HEAD equality", policy, (candidate) => ({ + ...candidate, + stable: mutate( + candidate.stable, + 'test "$(git rev-parse HEAD)" = "$(git rev-parse origin/master)" || {', + 'test "$(git rev-parse HEAD)" != "$(git rev-parse origin/master)" || {', + ), + })) + assertMutationFails("remove exact HEAD equality but leave it in a comment", policy, (candidate) => ({ + ...candidate, + stable: mutate( + candidate.stable, + 'test "$(git rev-parse HEAD)" = "$(git rev-parse origin/master)" || {', + '# test "$(git rev-parse HEAD)" = "$(git rev-parse origin/master)" || {', + ), + })) + assertMutationFails("move stable build after version", policy, (candidate) => ({ + ...candidate, + stable: mutate(candidate.stable, "pnpm nx release patch", "pnpm nx TEMP patch") + .replace("pnpm nx run-many -t build", "pnpm nx release patch") + .replace("pnpm nx TEMP patch", "pnpm nx run-many -t build"), + })) + assertMutationFails("move stable test after publish", policy, (candidate) => ({ + ...candidate, + stable: mutate(candidate.stable, "pnpm nx release publish", "pnpm nx TEMP publish") + .replace("pnpm nx run-many -t test", "pnpm nx release publish") + .replace("pnpm nx TEMP publish", "pnpm nx run-many -t test"), + })) + assertMutationFails("remove stable build", policy, (candidate) => ({ + ...candidate, + stable: mutate(candidate.stable, "pnpm nx run-many -t build", "echo build removed"), + })) + assertMutationFails("remove stable test", policy, (candidate) => ({ + ...candidate, + stable: mutate(candidate.stable, "pnpm nx run-many -t test", "echo test removed"), + })) + assertMutationFails("weaken stable allowlist membership", policy, (candidate) => ({ + ...candidate, + stable: mutate(candidate.stable, "grep -Fx --", "grep -F --"), + })) + assertMutationFails("allow stable versioning during publish-only recovery", policy, (candidate) => ({ + ...candidate, + stable: mutate( + candidate.stable, + " - name: πŸ”– Graduate Selected Prereleases\n if: ${{ inputs.publish_only != true }}", + " - name: πŸ”– Graduate Selected Prereleases", + ), + })) + assertMutationFails("skip stable build during publish-only recovery", policy, (candidate) => ({ + ...candidate, + stable: mutate( + candidate.stable, + " - name: πŸ—οΈ Build Selected Projects\n env:", + " - name: πŸ—οΈ Build Selected Projects\n if: ${{ inputs.publish_only != true }}\n env:", + ), + })) + assertMutationFails("comment out the policy contract", policy, (candidate) => ({ + ...candidate, + stable: mutate( + candidate.stable, + "run: node --test scripts/release-policy-contract.test.mjs", + "run: echo contract removed\n # node --test scripts/release-policy-contract.test.mjs", + ), + })) +}) + +test("alpha and beta exact-range and membership mutations fail closed", () => { + const policy = { ...workflows, docs: readme } + + for (const channel of ["alpha", "beta"]) { + assertMutationFails(`${channel} ignores push before SHA`, policy, (candidate) => ({ + ...candidate, + [channel]: mutate(candidate[channel], 'BEFORE="$BEFORE_SHA"', 'BEFORE="origin/branch~1"'), + })) + assertMutationFails(`${channel} keeps before SHA only in an inline comment`, policy, (candidate) => ({ + ...candidate, + [channel]: mutate( + candidate[channel], + "BEFORE_SHA: ${{ github.event.before }}", + "UNUSED_BEFORE: true # BEFORE_SHA: ${{ github.event.before }}", + ), + })) + assertMutationFails(`${channel} ignores github.sha`, policy, (candidate) => ({ + ...candidate, + [channel]: mutate(candidate[channel], 'HEAD="$HEAD_SHA"', 'HEAD="HEAD"'), + })) + assertMutationFails(`${channel} uses substring membership`, policy, (candidate) => ({ + ...candidate, + [channel]: mutate(candidate[channel], "$release | index($project)", "$release | contains($project)"), + })) + assertMutationFails(`${channel} weakens recovery allowlist`, policy, (candidate) => ({ + ...candidate, + [channel]: mutate(candidate[channel], "grep -Fx --", "grep -F --"), + })) + assertMutationFails(`${channel} comments out exact affected range`, policy, (candidate) => ({ + ...candidate, + [channel]: mutate( + candidate[channel], + 'AFFECTED_RAW=$(pnpm nx show projects --affected --base="$BASE" --head="$HEAD" --json 2>/dev/null || echo "[]")', + 'AFFECTED_RAW="[]"\n # AFFECTED_RAW=$(pnpm nx show projects --affected --base="$BASE" --head="$HEAD" --json)', + ), + })) + } +}) From ae2ba8b9adecc5b696c1fdf09de224366f4d0c4a Mon Sep 17 00:00:00 2001 From: kattsushi Date: Thu, 27 Aug 2026 12:02:19 -0600 Subject: [PATCH 2/2] fix(ci): bootstrap release policy contract --- .github/workflows/ci.yml | 1 + scripts/release-policy-contract.test.mjs | 43 +++++++++++++++++++++++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 15f7e472..b0c44f85 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,7 @@ jobs: uses: actions/setup-node@v5 with: node-version: "24.19.0" + package-manager-cache: false - name: πŸ›‘οΈ Verify release policy contract run: node --test scripts/release-policy-contract.test.mjs diff --git a/scripts/release-policy-contract.test.mjs b/scripts/release-policy-contract.test.mjs index 567687c6..1345c7c4 100644 --- a/scripts/release-policy-contract.test.mjs +++ b/scripts/release-policy-contract.test.mjs @@ -45,6 +45,17 @@ const stripComment = (line) => { } const withoutComments = (source) => source.split("\n").map(stripComment).join("\n") +const extractJob = (source, jobName) => { + const lines = source.split("\n") + const start = lines.findIndex((line) => new RegExp(`^(\\s*)${jobName}:\\s*$`).test(line)) + if (start === -1) return "" + + const jobIndent = indentation(lines[start]) + let end = start + 1 + while (end < lines.length && (!lines[end].trim() || indentation(lines[end]) > jobIndent)) end += 1 + return lines.slice(start, end).join("\n") +} + const extractSteps = (source) => { const lines = source.split("\n") const steps = [] @@ -54,7 +65,7 @@ const extractSteps = (source) => { if (!match || /^\s*#/.test(lines[index])) continue const stepIndent = match[1].length - const step = { name: match[2], condition: "", commands: [] } + const step = { name: match[2], condition: "", commands: [], uses: "", packageManagerCache: "" } for (index += 1; index < lines.length; index += 1) { const line = lines[index] if (line.trim() && indentation(line) <= stepIndent) { @@ -66,6 +77,12 @@ const extractSteps = (source) => { const condition = line.match(/^\s*if:\s*(.+?)\s*$/) if (condition) step.condition = condition[1] + const uses = line.match(/^\s*uses:\s*(.+?)\s*$/) + if (uses) step.uses = uses[1] + + const packageManagerCache = line.match(/^\s*package-manager-cache:\s*(.+?)\s*$/) + if (packageManagerCache) step.packageManagerCache = packageManagerCache[1] + const run = line.match(/^(\s*)run:\s*(.*)$/) if (!run) continue @@ -237,6 +254,16 @@ const stableViolations = (source) => { return violations } +const releasePolicyBootstrapViolations = (source) => { + const steps = extractSteps(extractJob(source, "release-policy")) + const setupNodeIndex = steps.findIndex((step) => /^actions\/setup-node@/.test(step.uses)) + if (setupNodeIndex === -1) return ["release-policy setup-node"] + + const pnpmIndex = steps.findIndex((step) => /^pnpm\/action-setup@/.test(step.uses)) + const cacheDisabled = steps[setupNodeIndex].packageManagerCache === "false" + return pnpmIndex !== -1 && pnpmIndex < setupNodeIndex ? [] : cacheDisabled ? [] : ["release-policy setup-node cache"] +} + const policyViolations = ({ alpha, beta, stable, docs }) => { const violations = [ ...channelViolations("alpha", alpha), @@ -280,6 +307,20 @@ test("the release policy contract runs in PR CI", () => { assert.notEqual(commandPosition(workflows.ci, contractCommand), -1) }) +test("the Node-only release policy job can bootstrap setup-node without pnpm", () => { + assert.deepEqual(releasePolicyBootstrapViolations(workflows.ci), []) + + const cacheEnabled = mutate(workflows.ci, "package-manager-cache: false", "package-manager-cache: true") + assert.notDeepEqual(releasePolicyBootstrapViolations(cacheEnabled), []) + + const pnpmFirst = mutate( + cacheEnabled, + " - name: πŸ—οΈ Setup Node.js", + " - name: πŸ“¦ Install pnpm\n uses: pnpm/action-setup@v6\n\n - name: πŸ—οΈ Setup Node.js", + ) + assert.deepEqual(releasePolicyBootstrapViolations(pnpmFirst), []) +}) + test("release documentation leads with the three-channel mapping", () => { for (const document of [readme, setup]) { const active = withoutComments(document)