diff --git a/.changeset/README.md b/.changeset/README.md index 94906cd43..bfac0ea65 100644 --- a/.changeset/README.md +++ b/.changeset/README.md @@ -41,7 +41,7 @@ corepack pnpm changeset version # strips the -alpha suffix ## Publishing (npm trusted publishing / OIDC) -The [`.github/workflows/release-npm.yml`](../.github/workflows/release-npm.yml) workflow runs the [changesets GitHub Action](https://github.com/changesets/action). Pushing changesets to `main` opens a "Version Packages" PR aggregating all pending changesets; merging that PR bumps versions, updates `CHANGELOG.md` files, and publishes to npm (under the `alpha` dist-tag while in prerelease mode). +The `release-alpha-train` job in [`.github/workflows/ci.yml`](../.github/workflows/ci.yml) runs the [changesets GitHub Action](https://github.com/changesets/action). Pushing changesets to `main` opens a "Version Packages" PR aggregating all pending changesets; merging that PR bumps versions, updates `CHANGELOG.md` files, waits for the CI aggregate gate, and publishes to npm (under the `alpha` dist-tag while in prerelease mode). Publishing authenticates with **npm trusted publishing (OIDC)** — there is **no `NPM_TOKEN`** secret. Before the first automated publish, a maintainer must, once per public package: @@ -50,7 +50,7 @@ Publishing authenticates with **npm trusted publishing (OIDC)** — there is **n - Provider: **GitHub Actions** - Organization/owner: `zitadel` - Repository: `nextgen` - - Workflow filename: `release-npm.yml` (exact, case-sensitive) + - Workflow filename: `ci.yml` (exact, case-sensitive) 3. Optionally, under **Publishing access**, require 2FA and disallow tokens so only this workflow can publish. While this repository is private, the workflow keeps npm provenance disabled @@ -59,11 +59,12 @@ short-lived OIDC credentials, but npm only accepts public provenance attestations from public source repositories. Re-enable provenance when `zitadel/nextgen` is public. -Changesets does not build the Go server binary. During alpha, `release-npm.yml` -uses the lockstep npm version as the release train version, creates `v`, -and then runs GoReleaser so the server image and binaries publish into the same -GitHub Release. The manual [`release.yml`](../.github/workflows/release.yml) -workflow remains a server snapshot/fallback path. See +Changesets does not build the Go server binary. During alpha, the `release-alpha-train` +job uses the lockstep npm version as the release train version, creates +`v`, and then runs GoReleaser so the server image and binaries publish +into the same GitHub Release. The manual +[`release.yml`](../.github/workflows/release.yml) workflow remains a server +snapshot/fallback path. See [docs/adrs/002-multi-package-release-strategy.md](../docs/adrs/002-multi-package-release-strategy.md) and [docs/adrs/023-lockstep-alpha-release-train.md](../docs/adrs/023-lockstep-alpha-release-train.md). diff --git a/.changeset/release-main-ci-wait.md b/.changeset/release-main-ci-wait.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/release-main-ci-wait.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c2a9b0470..7bbd4fbaa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,11 +9,62 @@ permissions: contents: read # Cancel an in-flight run when a newer commit is pushed to the same PR/branch. +# Main runs may publish releases, so do not cancel them once started. concurrency: group: ci-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: + detect-alpha-release: + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + runs-on: depot-ubuntu-24.04-4 + timeout-minutes: 5 + outputs: + should_release: ${{ steps.detect.outputs.should_release }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Detect release-relevant changes + id: detect + env: + BEFORE: ${{ github.event.before }} + run: | + set -euo pipefail + base="$BEFORE" + if [ -z "$base" ] || [[ "$base" =~ ^0+$ ]] || ! git cat-file -e "$base^{commit}" 2>/dev/null; then + base="$(git rev-parse HEAD^ 2>/dev/null || true)" + fi + if [ -n "$base" ]; then + git diff --name-only "$base" HEAD > "$RUNNER_TEMP/release-changed-files" + else + git diff-tree --no-commit-id --name-only -r HEAD > "$RUNNER_TEMP/release-changed-files" + fi + + node --input-type=module <<'NODE' + import { appendFileSync, readFileSync } from "node:fs"; + import { PUBLIC_PACKAGE_MANIFESTS } from "./scripts/release-alpha-train.mjs"; + + const changed = readFileSync(`${process.env.RUNNER_TEMP}/release-changed-files`, "utf8") + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + const publicPackageReleasePaths = new Set( + PUBLIC_PACKAGE_MANIFESTS.flatMap((manifestPath) => [ + manifestPath, + manifestPath.replace(/package\.json$/, "CHANGELOG.md"), + ]), + ); + const releaseRelevant = changed.filter( + (path) => path.startsWith(".changeset/") || publicPackageReleasePaths.has(path), + ); + const shouldRelease = releaseRelevant.length > 0; + + console.log(`release-relevant changes: ${releaseRelevant.join(", ") || "none"}`); + appendFileSync(process.env.GITHUB_OUTPUT, `should_release=${String(shouldRelease)}\n`); + NODE + openapi-lint: runs-on: depot-ubuntu-24.04-4 timeout-minutes: 5 @@ -260,7 +311,7 @@ jobs: # changeset, so no consumer-visible change merges without a release note. # Private packages (apps, demos, mocks, lint, design-tokens, ui-react, ...) # are never published and so do not require a changeset. Publishing itself - # runs on pushes to main in release-npm.yml. + # runs on pushes to main in the release-alpha-train job below. # # The check lives in scripts/check-changeset-required.mjs so the publishable # package list and rules are testable and shared, rather than duplicated in @@ -701,3 +752,195 @@ jobs: ) || true fi if [ -n "${VERDACCIO_PID:-}" ]; then kill "$VERDACCIO_PID" 2>/dev/null || true; fi + + ci-success: + if: always() + runs-on: depot-ubuntu-24.04-4 + timeout-minutes: 5 + needs: + - openapi-lint + - go-unit-test + - go-integration-test-postgres + - go-integration-test-spanner + - node-check + - changeset-check + - node-e2e + - goreleaser-snapshot + - npm-pack-smoke + - go-smoke-test-embedded-postgres + - quickstart-smoke + - consumer-journey-e2e + steps: + - name: Check CI gate results + env: + NEEDS_JSON: ${{ toJson(needs) }} + run: | + node <<'NODE' + const needs = JSON.parse(process.env.NEEDS_JSON); + const allowedSkipped = new Set(["changeset-check"]); + const failed = Object.entries(needs).filter(([name, job]) => { + if (job.result === "success") return false; + if (job.result === "skipped" && allowedSkipped.has(name)) return false; + return true; + }); + + if (failed.length > 0) { + for (const [name, job] of failed) { + console.error(`${name}: ${job.result}`); + } + process.exit(1); + } + + console.log("CI gate passed"); + NODE + + release-alpha-train: + if: github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.detect-alpha-release.outputs.should_release == 'true' + runs-on: depot-ubuntu-24.04-8 + needs: [detect-alpha-release, ci-success] + permissions: + contents: write + pull-requests: write + packages: write + id-token: write + steps: + # Mint a short-lived installation token for the release GitHub App so the + # changesets PR/commits trigger CI (GITHUB_TOKEN-authored ones do not). + - name: Generate a token for the release app + id: app-token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + token: ${{ steps.app-token.outputs.token }} + + - name: Set up pnpm + uses: pnpm/action-setup@v5 + with: + run_install: false + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + registry-url: "https://registry.npmjs.org" + cache: pnpm + + # Trusted publishing requires npm >= 11.5.1; the pinned pnpm delegates + # the actual publish to the npm CLI, so the runner's npm must be current. + - name: Update npm for trusted publishing (OIDC) + run: npm install -g npm@latest + + - name: Install dependencies + run: corepack pnpm install --frozen-lockfile + + - name: Build packages + run: corepack pnpm nx run-many -t build + + - name: Create release PR or publish to npm + id: changesets + uses: changesets/action@v1 + with: + version: corepack pnpm changeset version + publish: corepack pnpm changeset publish + title: "chore: version packages" + commit: "chore: version packages" + createGithubReleases: false + env: + # App token (not GITHUB_TOKEN) so the Version Packages PR triggers CI. + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} + # Empty so the action does not pass an undefined token; OIDC handles auth. + NPM_TOKEN: "" + # npm provenance is only supported for public source repositories. + # Keep trusted publishing via OIDC, but disable provenance until this + # repository is public. + NPM_CONFIG_PROVENANCE: "false" + + - name: Inspect alpha release train candidate + id: alpha-status + env: + PUBLISHED: ${{ steps.changesets.outputs.published }} + run: | + set -euo pipefail + alpha_env="$RUNNER_TEMP/alpha-release-status.env" + node scripts/release-alpha-train.mjs status --published "$PUBLISHED" --remote false | tee "$alpha_env" + cat "$alpha_env" >> "$GITHUB_OUTPUT" + + - name: Login to GHCR + if: ${{ steps.alpha-status.outputs.should_complete == 'true' }} + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Prepare alpha release train + if: ${{ steps.alpha-status.outputs.should_complete == 'true' }} + id: alpha + env: + PUBLISHED: ${{ steps.changesets.outputs.published }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + alpha_env="$RUNNER_TEMP/alpha-release.env" + node scripts/release-alpha-train.mjs prepare --published "$PUBLISHED" --out-dir "$RUNNER_TEMP/alpha-release" | tee "$alpha_env" + cat "$alpha_env" >> "$GITHUB_OUTPUT" + + - name: Set up QEMU + if: ${{ steps.alpha.outputs.run_goreleaser == 'true' }} + uses: docker/setup-qemu-action@v4 + + - name: Set up Docker Buildx + if: ${{ steps.alpha.outputs.run_goreleaser == 'true' }} + uses: docker/setup-buildx-action@v4 + + - name: Create and push Go release tag + if: ${{ steps.alpha.outputs.create_tag == 'true' }} + env: + TAG: ${{ steps.alpha.outputs.tag }} + TITLE: ${{ steps.alpha.outputs.title }} + run: | + set -euo pipefail + git config user.name "zitadel-release" + git config user.email "noreply@zitadel.com" + git tag -a "$TAG" -m "$TITLE" + git push origin "$TAG" + + - name: Prune npm package tags for GoReleaser + if: ${{ steps.alpha.outputs.run_goreleaser == 'true' }} + run: | + set -euo pipefail + git tag -l '@zitadel/*' | while read -r tag; do + git tag -d "$tag" + done + + - name: Run GoReleaser + if: ${{ steps.alpha.outputs.run_goreleaser == 'true' }} + uses: goreleaser/goreleaser-action@v7 + with: + distribution: goreleaser + version: "~> v2" + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Update GitHub Release notes + if: ${{ steps.alpha.outputs.update_release == 'true' }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ steps.alpha.outputs.tag }} + TITLE: ${{ steps.alpha.outputs.title }} + NOTES_PATH: ${{ steps.alpha.outputs.notes_path }} + run: | + set -euo pipefail + gh release edit "$TAG" \ + --draft \ + --prerelease \ + --latest=false \ + --title "$TITLE" \ + --notes-file "$NOTES_PATH" diff --git a/.github/workflows/release-npm.yml b/.github/workflows/release-npm.yml deleted file mode 100644 index fcfdb686f..000000000 --- a/.github/workflows/release-npm.yml +++ /dev/null @@ -1,193 +0,0 @@ -name: release-npm - -# Publishes the public alpha train: npm packages (@zitadel/cli, @zitadel/api, -# @zitadel/components, @zitadel/sdk-core, @zitadel/sdk-next, @zitadel/sdk-nuxt, -# @zitadel/sdk-react, @zitadel/sdk-vue, @zitadel/sdk-angular) -# via Changesets, then the matching Go server image/binaries via GoReleaser. -# Pushing changesets to main opens a "Version Packages" PR; merging that PR -# publishes to npm, creates v, and publishes the single GitHub Release. -# -# The repo is in changesets PRERELEASE mode (.changeset/pre.json, tag "alpha"), -# so versions are cut as X.Y.Z-alpha.N and published under the `alpha` npm -# dist-tag — `npm install @zitadel/cli` keeps resolving the last stable -# `latest`; consumers opt into prereleases with `@zitadel/cli@alpha`. Run -# `pnpm changeset pre exit` to leave alpha and cut a stable `latest` release. -# -# Authentication uses npm trusted publishing (OIDC) — there is no NPM_TOKEN. -# A maintainer must configure the trusted publisher for each public package -# once on npmjs.com (provider: GitHub Actions, repo: zitadel/nextgen, workflow -# filename: release-npm.yml). See .changeset/README.md. - -on: - push: - branches: [main] - -# id-token: write is required for OIDC trusted publishing. -# contents/pull-requests: write let the changesets action open the -# "Version Packages" PR and push version-bump commits. -# -# The changesets action authenticates as a GitHub App (see the -# "Generate a token" step) rather than the default GITHUB_TOKEN. GitHub -# deliberately does NOT trigger workflows for events caused by GITHUB_TOKEN, -# so a GITHUB_TOKEN-authored "Version Packages" PR never runs the required -# CI checks and stays blocked. A GitHub App is a distinct identity, so its -# PRs/pushes DO trigger CI — the release PR then has to pass the same -# required checks as any other PR. This keeps branch protection fully -# enforced (nothing bypasses checks); it only stops CI being silently -# skipped on the bot's PR. Requires repo secrets RELEASE_APP_ID and -# RELEASE_APP_PRIVATE_KEY (a GitHub App with Contents: R/W and -# Pull requests: R/W, installed on this repo). -permissions: - contents: write - pull-requests: write - packages: write - id-token: write - -concurrency: - group: release-npm-${{ github.ref }} - cancel-in-progress: false - -jobs: - release: - runs-on: depot-ubuntu-24.04-8 - steps: - # Mint a short-lived installation token for the release GitHub App so the - # changesets PR/commits trigger CI (GITHUB_TOKEN-authored ones do not). - - name: Generate a token for the release app - id: app-token - uses: actions/create-github-app-token@v2 - with: - app-id: ${{ secrets.RELEASE_APP_ID }} - private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} - - - name: Checkout - uses: actions/checkout@v6 - with: - fetch-depth: 0 - token: ${{ steps.app-token.outputs.token }} - - - name: Set up pnpm - uses: pnpm/action-setup@v5 - with: - run_install: false - - - name: Set up Node.js - uses: actions/setup-node@v6 - with: - node-version-file: .nvmrc - registry-url: "https://registry.npmjs.org" - cache: pnpm - - # Trusted publishing requires npm >= 11.5.1; the pinned pnpm delegates - # the actual publish to the npm CLI, so the runner's npm must be current. - - name: Update npm for trusted publishing (OIDC) - run: npm install -g npm@latest - - - name: Install dependencies - run: corepack pnpm install --frozen-lockfile - - - name: Build packages - run: corepack pnpm nx run-many -t build - - - name: Create release PR or publish to npm - id: changesets - uses: changesets/action@v1 - with: - version: corepack pnpm changeset version - publish: corepack pnpm changeset publish - title: "chore: version packages" - commit: "chore: version packages" - createGithubReleases: false - env: - # App token (not GITHUB_TOKEN) so the Version Packages PR triggers CI. - GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} - # Empty so the action does not pass an undefined token; OIDC handles auth. - NPM_TOKEN: "" - # npm provenance is only supported for public source repositories. - # Keep trusted publishing via OIDC, but disable provenance until this - # repository is public. - NPM_CONFIG_PROVENANCE: "false" - - - name: Inspect alpha release train candidate - id: alpha-status - env: - PUBLISHED: ${{ steps.changesets.outputs.published }} - run: | - set -euo pipefail - alpha_env="$RUNNER_TEMP/alpha-release-status.env" - node scripts/release-alpha-train.mjs status --published "$PUBLISHED" --remote false | tee "$alpha_env" - cat "$alpha_env" >> "$GITHUB_OUTPUT" - - - name: Login to GHCR - if: ${{ steps.alpha-status.outputs.should_complete == 'true' }} - uses: docker/login-action@v4 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Prepare alpha release train - if: ${{ steps.alpha-status.outputs.should_complete == 'true' }} - id: alpha - env: - PUBLISHED: ${{ steps.changesets.outputs.published }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - set -euo pipefail - alpha_env="$RUNNER_TEMP/alpha-release.env" - node scripts/release-alpha-train.mjs prepare --published "$PUBLISHED" --out-dir "$RUNNER_TEMP/alpha-release" | tee "$alpha_env" - cat "$alpha_env" >> "$GITHUB_OUTPUT" - - - name: Set up QEMU - if: ${{ steps.alpha.outputs.run_goreleaser == 'true' }} - uses: docker/setup-qemu-action@v4 - - - name: Set up Docker Buildx - if: ${{ steps.alpha.outputs.run_goreleaser == 'true' }} - uses: docker/setup-buildx-action@v4 - - - name: Create and push Go release tag - if: ${{ steps.alpha.outputs.create_tag == 'true' }} - env: - TAG: ${{ steps.alpha.outputs.tag }} - TITLE: ${{ steps.alpha.outputs.title }} - run: | - set -euo pipefail - git config user.name "zitadel-release" - git config user.email "noreply@zitadel.com" - git tag -a "$TAG" -m "$TITLE" - git push origin "$TAG" - - - name: Prune npm package tags for GoReleaser - if: ${{ steps.alpha.outputs.run_goreleaser == 'true' }} - run: | - set -euo pipefail - git tag -l '@zitadel/*' | while read -r tag; do - git tag -d "$tag" - done - - - name: Run GoReleaser - if: ${{ steps.alpha.outputs.run_goreleaser == 'true' }} - uses: goreleaser/goreleaser-action@v7 - with: - distribution: goreleaser - version: "~> v2" - args: release --clean - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Update GitHub Release notes - if: ${{ steps.alpha.outputs.update_release == 'true' }} - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TAG: ${{ steps.alpha.outputs.tag }} - TITLE: ${{ steps.alpha.outputs.title }} - NOTES_PATH: ${{ steps.alpha.outputs.notes_path }} - run: | - set -euo pipefail - gh release edit "$TAG" \ - --draft \ - --prerelease \ - --latest=false \ - --title "$TITLE" \ - --notes-file "$NOTES_PATH" diff --git a/apps/cli/tests/unit/scripts/check-alpha-release-plan.test.ts b/apps/cli/tests/unit/scripts/check-alpha-release-plan.test.ts index 6e83f7ed0..3f0816323 100644 --- a/apps/cli/tests/unit/scripts/check-alpha-release-plan.test.ts +++ b/apps/cli/tests/unit/scripts/check-alpha-release-plan.test.ts @@ -91,17 +91,10 @@ describe("check-alpha-release-plan script", () => { it("rejects a workflow that lets Changesets create GitHub Releases", async () => { const { cwd, statusPath } = await fixtureRepo({ - releaseWorkflow: [ - "jobs:", - " release:", - " steps:", - " - uses: changesets/action@v1", - " with:", - " createGithubReleases: true", - " - run: node scripts/release-alpha-train.mjs prepare", - " - run: gh release edit \"$TAG\" --prerelease --latest=false", - "", - ].join("\n"), + ciWorkflow: validCiWorkflow().replace( + "createGithubReleases: false", + "createGithubReleases: true", + ), }); await expect( @@ -111,25 +104,10 @@ describe("check-alpha-release-plan script", () => { it("rejects alpha release notes generated inside the checkout", async () => { const { cwd, statusPath } = await fixtureRepo({ - releaseWorkflow: [ - "jobs:", - " release:", - " steps:", - " - uses: changesets/action@v1", - " with:", - " createGithubReleases: false", - " - run: |", - " node scripts/release-alpha-train.mjs status --published \"$PUBLISHED\" --remote false", - " - if: ${{ steps.alpha-status.outputs.should_complete == 'true' }}", - " run: |", - " node scripts/release-alpha-train.mjs prepare --published \"$PUBLISHED\" --out-dir dist/alpha-release | tee dist/alpha-release.env", - " - if: ${{ steps.alpha.outputs.create_tag == 'true' }}", - " run: git tag \"$TAG\"", - " - if: ${{ steps.alpha.outputs.run_goreleaser == 'true' }}", - " run: goreleaser release --clean", - " - run: gh release edit \"$TAG\" --prerelease --latest=false", - "", - ].join("\n"), + ciWorkflow: validCiWorkflow().replace( + '--out-dir "$RUNNER_TEMP/alpha-release" | tee "$alpha_env"', + "--out-dir dist/alpha-release | tee dist/alpha-release.env", + ), }); await expect( @@ -139,42 +117,126 @@ describe("check-alpha-release-plan script", () => { it("rejects workflow steps gated only on the current Changesets publish result", async () => { const { cwd, statusPath } = await fixtureRepo({ - releaseWorkflow: [ - "jobs:", - " release:", - " steps:", - " - uses: changesets/action@v1", - " with:", - " createGithubReleases: false", - " - run: |", - " alpha_env=\"$RUNNER_TEMP/alpha-release.env\"", - " node scripts/release-alpha-train.mjs status --published \"$PUBLISHED\" --remote false", - " - if: ${{ steps.alpha-status.outputs.should_complete == 'true' }}", - " run: |", - " node scripts/release-alpha-train.mjs prepare --published \"$PUBLISHED\" --out-dir \"$RUNNER_TEMP/alpha-release\" | tee \"$alpha_env\"", - " cat \"$alpha_env\" >> \"$GITHUB_OUTPUT\"", - " - if: ${{ steps.alpha.outputs.create_tag == 'true' }}", - " run: git tag \"$TAG\"", - " - if: ${{ steps.alpha.outputs.run_goreleaser == 'true' }}", - " run: goreleaser release --clean", + ciWorkflow: validCiWorkflow().replace( + " - if: ${{ steps.alpha.outputs.update_release == 'true' }}", " - if: ${{ steps.changesets.outputs.published == 'true' }}", - " run: gh release edit \"$TAG\" --prerelease --latest=false", - "", - ].join("\n"), + ), }); await expect( checkAlphaReleasePlanModule.checkAlphaReleasePlan({ cwd, statusPath }), ).rejects.toThrow("post-npm alpha train steps must not be gated only on Changesets publishing"); }); + + it("rejects a legacy standalone release workflow", async () => { + const { cwd, statusPath } = await fixtureRepo({ + legacyReleaseWorkflow: "name: release-npm\n", + }); + + await expect( + checkAlphaReleasePlanModule.checkAlphaReleasePlan({ cwd, statusPath }), + ).rejects.toThrow("release publishing must live in ci.yml"); + }); + + it("rejects CI path filters that can skip release relevance detection", async () => { + const { cwd, statusPath } = await fixtureRepo({ + ciWorkflow: validCiWorkflow().replace( + " push:\n branches: [main]", + ' push:\n branches: [main]\n paths:\n - ".changeset/**"', + ), + }); + + await expect( + checkAlphaReleasePlanModule.checkAlphaReleasePlan({ cwd, statusPath }), + ).rejects.toThrow("must not path-filter main pushes"); + }); + + it("rejects a workflow missing release job publish permissions", async () => { + const { cwd, statusPath } = await fixtureRepo({ + ciWorkflow: validCiWorkflow().replace(" packages: write\n", ""), + }); + + await expect( + checkAlphaReleasePlanModule.checkAlphaReleasePlan({ cwd, statusPath }), + ).rejects.toThrow("must scope publish permissions"); + }); + + it("rejects a workflow that can publish before main CI succeeds", async () => { + const { cwd, statusPath } = await fixtureRepo({ + ciWorkflow: validCiWorkflow().replace( + " needs: [detect-alpha-release, ci-success]", + " needs: [detect-alpha-release]", + ), + }); + + await expect( + checkAlphaReleasePlanModule.checkAlphaReleasePlan({ cwd, statusPath }), + ).rejects.toThrow("must wait for release relevance detection and the aggregate CI gate"); + }); }); +function validCiWorkflow(): string { + return [ + "on:", + " pull_request:", + " push:", + " branches: [main]", + "permissions:", + " contents: read", + "concurrency:", + " group: ci-${{ github.workflow }}-${{ github.ref }}", + " cancel-in-progress: ${{ github.event_name == 'pull_request' }}", + "jobs:", + " detect-alpha-release:", + " outputs:", + " should_release: ${{ steps.detect.outputs.should_release }}", + " steps:", + " - id: detect", + " run: |", + ' import { PUBLIC_PACKAGE_MANIFESTS } from "./scripts/release-alpha-train.mjs";', + ' path.startsWith(".changeset/") || publicPackageReleasePaths.has(path)', + " ci-success:", + " steps:", + " - run: |", + ' const allowedSkipped = new Set(["changeset-check"]);', + " release-alpha-train:", + " if: github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.detect-alpha-release.outputs.should_release == 'true'", + " needs: [detect-alpha-release, ci-success]", + " permissions:", + " contents: write", + " pull-requests: write", + " packages: write", + " id-token: write", + " steps:", + " - uses: changesets/action@v1", + " with:", + " createGithubReleases: false", + " - id: alpha-status", + " run: |", + " node scripts/release-alpha-train.mjs status --published \"$PUBLISHED\" --remote false", + " - if: ${{ steps.alpha-status.outputs.should_complete == 'true' }}", + " id: alpha", + " run: |", + " alpha_env=\"$RUNNER_TEMP/alpha-release.env\"", + " node scripts/release-alpha-train.mjs prepare --published \"$PUBLISHED\" --out-dir \"$RUNNER_TEMP/alpha-release\" | tee \"$alpha_env\"", + " cat \"$alpha_env\" >> \"$GITHUB_OUTPUT\"", + " - if: ${{ steps.alpha.outputs.create_tag == 'true' }}", + " run: git tag \"$TAG\"", + " - if: ${{ steps.alpha.outputs.run_goreleaser == 'true' }}", + " run: goreleaser release --clean", + " - if: ${{ steps.alpha.outputs.update_release == 'true' }}", + " run: gh release edit \"$TAG\" --prerelease --latest=false", + "", + ].join("\n"); +} + async function fixtureRepo( options: { releases?: Array<{ name: string; type: string; newVersion: string }>; versionOverrides?: Record; goreleaser?: string; - releaseWorkflow?: string; + ciWorkflow?: string; + legacyReleaseWorkflow?: string; } = {}, ): Promise<{ cwd: string; statusPath: string }> { const cwd = await mkdtemp(join(tmpdir(), "zitadel-alpha-check-")); @@ -200,32 +262,15 @@ async function fixtureRepo( ); await mkdir(join(cwd, ".github/workflows"), { recursive: true }); await writeFile( - join(cwd, ".github/workflows/release-npm.yml"), - options.releaseWorkflow ?? - [ - "jobs:", - " release:", - " steps:", - " - uses: changesets/action@v1", - " with:", - " createGithubReleases: false", - " - id: alpha-status", - " run: |", - " node scripts/release-alpha-train.mjs status --published \"$PUBLISHED\" --remote false", - " - if: ${{ steps.alpha-status.outputs.should_complete == 'true' }}", - " id: alpha", - " run: |", - " alpha_env=\"$RUNNER_TEMP/alpha-release.env\"", - " node scripts/release-alpha-train.mjs prepare --published \"$PUBLISHED\" --out-dir \"$RUNNER_TEMP/alpha-release\" | tee \"$alpha_env\"", - " cat \"$alpha_env\" >> \"$GITHUB_OUTPUT\"", - " - if: ${{ steps.alpha.outputs.create_tag == 'true' }}", - " run: git tag \"$TAG\"", - " - if: ${{ steps.alpha.outputs.run_goreleaser == 'true' }}", - " run: goreleaser release --clean", - " - run: gh release edit \"$TAG\" --prerelease --latest=false", - "", - ].join("\n"), + join(cwd, ".github/workflows/ci.yml"), + options.ciWorkflow ?? validCiWorkflow(), ); + if (options.legacyReleaseWorkflow !== undefined) { + await writeFile( + join(cwd, ".github/workflows/release-npm.yml"), + options.legacyReleaseWorkflow, + ); + } const statusPath = join(cwd, "changeset-status.json"); await writeJson(statusPath, { diff --git a/docs/adrs/002-multi-package-release-strategy.md b/docs/adrs/002-multi-package-release-strategy.md index 4fc6bb354..607a5304d 100644 --- a/docs/adrs/002-multi-package-release-strategy.md +++ b/docs/adrs/002-multi-package-release-strategy.md @@ -8,8 +8,8 @@ Release the three artifact families produced by this monorepo with two complementary tools. Component releases can use independent cadences, while the public alpha train temporarily uses one lockstep version as defined in [ADR 023](023-lockstep-alpha-release-train.md): -1. **Go server binary + embedded React console**: released by `goreleaser` against an explicit release tag. While this repository is pre-release, the publish-capable workflow is normally driven by the alpha train in [`release-npm.yml`](../../.github/workflows/release-npm.yml); the manual [`release.yml`](../../.github/workflows/release.yml) workflow remains available for snapshots and fallback server releases. The release produces multi-arch archives (linux/darwin/windows × amd64/arm64, minus windows/arm64), per-arch Docker images, and a `ghcr.io/zitadel/nextgen` manifest list. The console SPA is built by Vite during goreleaser's `before.hooks` and embedded into the binary via `//go:embed`. GoReleaser jobs prune Changesets-created npm package tags (`@zitadel/*`) from the local checkout before running so scoped package tags are never interpreted as server release tags. -2. **TypeScript packages**: released by `changesets` via the [`release-npm.yml`](../../.github/workflows/release-npm.yml) workflow. The public packages are `@zitadel/cli` (`apps/cli/`), `@zitadel/api`, `@zitadel/components`, `@zitadel/sdk-core`, `@zitadel/sdk-next`, `@zitadel/sdk-nuxt`, `@zitadel/sdk-react`, `@zitadel/sdk-vue`, and `@zitadel/sdk-angular`. Every other workspace package is marked `"private": true` and never publishes. Each PR adds a `.changeset/*.md` describing the bump; pushing changesets to `main` opens a "Version Packages" PR aggregating pending changes, and merging that PR versions the packages and publishes them to npm. Authentication uses **npm trusted publishing (OIDC)** — there is no `NPM_TOKEN`. npm provenance is disabled while the repository is private because npm only accepts provenance attestations from public source repositories; re-enable it when `zitadel/nextgen` is public. The repo is in changesets **prerelease mode** (`.changeset/pre.json`, tag `alpha`): versions are cut as `X.Y.Z-alpha.N` and published under the `alpha` dist-tag until `changeset pre exit` cuts a stable `latest` release. +1. **Go server binary + embedded React console**: released by `goreleaser` against an explicit release tag. While this repository is pre-release, the publish-capable workflow is normally driven by the alpha train's `release-alpha-train` job in [`ci.yml`](../../.github/workflows/ci.yml); the manual [`release.yml`](../../.github/workflows/release.yml) workflow remains available for snapshots and fallback server releases. The release produces multi-arch archives (linux/darwin/windows × amd64/arm64, minus windows/arm64), per-arch Docker images, and a `ghcr.io/zitadel/nextgen` manifest list. The console SPA is built by Vite during goreleaser's `before.hooks` and embedded into the binary via `//go:embed`. GoReleaser jobs prune Changesets-created npm package tags (`@zitadel/*`) from the local checkout before running so scoped package tags are never interpreted as server release tags. +2. **TypeScript packages**: released by `changesets` via the `release-alpha-train` job in [`ci.yml`](../../.github/workflows/ci.yml). The public packages are `@zitadel/cli` (`apps/cli/`), `@zitadel/api`, `@zitadel/components`, `@zitadel/sdk-core`, `@zitadel/sdk-next`, `@zitadel/sdk-nuxt`, `@zitadel/sdk-react`, `@zitadel/sdk-vue`, and `@zitadel/sdk-angular`. Every other workspace package is marked `"private": true` and never publishes. Each PR adds a `.changeset/*.md` describing the bump; pushing changesets to `main` opens a "Version Packages" PR aggregating pending changes, and merging that PR waits for the matching main CI aggregate gate before versioned packages publish to npm. Authentication uses **npm trusted publishing (OIDC)** — there is no `NPM_TOKEN`. npm provenance is disabled while the repository is private because npm only accepts provenance attestations from public source repositories; re-enable it when `zitadel/nextgen` is public. The repo is in changesets **prerelease mode** (`.changeset/pre.json`, tag `alpha`): versions are cut as `X.Y.Z-alpha.N` and published under the `alpha` dist-tag until `changeset pre exit` cuts a stable `latest` release. 3. **The console SPA is intentionally not a separately versioned npm package.** It is the Go server's UI; it ships embedded in the server binary at the server's version. If a future use case calls for a standalone console library, it becomes a new entry under `packages/` managed by changesets, and the Go server pins a specific version. Cross-package coordination is handled via changeset notes and peer-dep ranges @@ -63,8 +63,8 @@ Trade-offs: ## Follow-up - ✅ The `@zitadel` npm scope is owned (the main `zitadel` repo already publishes `@zitadel/client` and `@zitadel/proto` under it). -- ✅ The changesets publishing workflow ([`release-npm.yml`](../../.github/workflows/release-npm.yml)) is enabled, using npm trusted publishing (OIDC) — no `NPM_TOKEN` secret. Provenance is disabled while this source repository is private. -- One-time bootstrap per public package: a maintainer must do the first manual publish (the names do not exist on npm yet) and then add the GitHub Actions trusted publisher on npmjs.com (repo `zitadel/nextgen`, workflow `release-npm.yml`). See [`.changeset/README.md`](../../.changeset/README.md). +- ✅ The changesets publishing job (`release-alpha-train` in [`ci.yml`](../../.github/workflows/ci.yml)) is enabled, using npm trusted publishing (OIDC) — no `NPM_TOKEN` secret. Provenance is disabled while this source repository is private. +- One-time bootstrap per public package: a maintainer must do the first manual publish (the names do not exist on npm yet) and then add the GitHub Actions trusted publisher on npmjs.com (repo `zitadel/nextgen`, workflow `ci.yml`). See [`.changeset/README.md`](../../.changeset/README.md). - Decide when to switch the Go release workflow from the alpha train/manual fallback to automatic stable `v*` tag releases. - Draft a `CONTRIBUTING.md` section pointing contributors at `pnpm changeset` for npm changes and conventional commit prefixes for the goreleaser changelog. diff --git a/docs/adrs/023-lockstep-alpha-release-train.md b/docs/adrs/023-lockstep-alpha-release-train.md index 8d6761e48..190e2bf35 100644 --- a/docs/adrs/023-lockstep-alpha-release-train.md +++ b/docs/adrs/023-lockstep-alpha-release-train.md @@ -17,10 +17,10 @@ During the alpha period, this intentionally overrides the independent component cadence described in [ADR 002](002-multi-package-release-strategy.md). Changesets remains the version and npm changelog tool, but all public npm packages are in one fixed group. When the Changesets "Version Packages" PR is merged, -`release-npm.yml` publishes npm first, validates that every public package has -the same alpha version, creates the matching `v` tag, runs GoReleaser -from that tag, and updates one draft GitHub Release named -`ZITADEL Alpha `. +the `release-alpha-train` job in `ci.yml` runs after the matching main-branch CI gate +passes, publishes npm first, validates that every public package has the same +alpha version, creates the matching `v` tag, runs GoReleaser from that +tag, and updates one draft GitHub Release named `ZITADEL Alpha `. The user-facing release is the GitHub Release for `v`. It contains tester commands, an npm package table, and GoReleaser server artifacts. Alpha diff --git a/docs/runbooks/release-alpha-train.md b/docs/runbooks/release-alpha-train.md index b6ef3a973..929eeacb6 100644 --- a/docs/runbooks/release-alpha-train.md +++ b/docs/runbooks/release-alpha-train.md @@ -7,18 +7,20 @@ artifacts, for example `0.1.0-alpha.7`. ## Steps 1. Merge feature and fix PRs with changesets as usual. -2. Wait for [`release-npm.yml`](../../.github/workflows/release-npm.yml) to open - or update the `chore: version packages` PR. +2. Wait for the `release-alpha-train` job in [`ci.yml`](../../.github/workflows/ci.yml) + to open or update the `chore: version packages` PR. 3. Review that every public `@zitadel/*` package in the fixed group has the same `0.1.0-alpha.N` version. 4. Before merging, run the release process check: `corepack pnpm run check -- --only release`. 5. Merge the Version Packages PR after CI is green. -6. Let `release-npm.yml` publish npm first. The same workflow then creates - `v`, runs GoReleaser, publishes `ghcr.io/zitadel/nextgen:`, - and updates one draft GitHub prerelease named `ZITADEL Alpha `. - Alpha trains do not move `ghcr.io/zitadel/nextgen:latest`. -7. Review the draft prerelease notes and publish the GitHub Release. +6. Let the main-branch `ci.yml` run finish. Its `release-alpha-train` job starts only + after the aggregate CI gate passes for that exact commit. +7. After CI is green, the `release-alpha-train` job creates `v`, runs + GoReleaser, publishes `ghcr.io/zitadel/nextgen:`, and updates one + draft GitHub prerelease named `ZITADEL Alpha `. Alpha trains do not + move `ghcr.io/zitadel/nextgen:latest`. +8. Review the draft prerelease notes and publish the GitHub Release. ## Local Check diff --git a/scripts/check-alpha-release-plan.mjs b/scripts/check-alpha-release-plan.mjs index 0a9f6a784..57a053d51 100644 --- a/scripts/check-alpha-release-plan.mjs +++ b/scripts/check-alpha-release-plan.mjs @@ -91,59 +91,138 @@ export async function validateReleaseTooling(cwd, readFileFn = readFileDefault) "GoReleaser prereleases must not become GitHub latest", ); - const releaseWorkflow = await readFileFn(join(cwd, ".github/workflows/release-npm.yml"), "utf8"); + const legacyReleaseWorkflow = await readOptionalFile( + join(cwd, ".github/workflows/release-npm.yml"), + readFileFn, + ); + if (legacyReleaseWorkflow !== undefined) { + throw new Error("release publishing must live in ci.yml, not release-npm.yml"); + } + + const ciWorkflow = await readFileFn(join(cwd, ".github/workflows/ci.yml"), "utf8"); + assertContains( + ciWorkflow, + "cancel-in-progress: ${{ github.event_name == 'pull_request' }}", + "ci.yml must not cancel main release runs", + ); + assertNotContains( + ciWorkflow, + " paths:", + "ci.yml must not path-filter main pushes before release relevance is computed", + ); + assertContains( + ciWorkflow, + "detect-alpha-release:", + "ci.yml must compute release relevance before publishing", + ); assertContains( - releaseWorkflow, + ciWorkflow, + "should_release: ${{ steps.detect.outputs.should_release }}", + "detect-alpha-release must expose a should_release output", + ); + assertContains( + ciWorkflow, + 'import { PUBLIC_PACKAGE_MANIFESTS } from "./scripts/release-alpha-train.mjs";', + "detect-alpha-release must derive public package release paths from release-alpha-train.mjs", + ); + assertContains( + ciWorkflow, + 'path.startsWith(".changeset/") || publicPackageReleasePaths.has(path)', + "detect-alpha-release must detect Changesets and public package release files", + ); + assertContains( + ciWorkflow, + "ci-success:", + "ci.yml must include an aggregate CI success gate", + ); + assertContains( + ciWorkflow, + 'const allowedSkipped = new Set(["changeset-check"]);', + "ci-success must only allow intentionally skipped jobs", + ); + assertContains( + ciWorkflow, + "release-alpha-train:", + "ci.yml must contain the alpha release train job", + ); + assertContains( + ciWorkflow, + "if: github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.detect-alpha-release.outputs.should_release == 'true'", + "release-alpha-train must only run on release-relevant main pushes", + ); + assertContains( + ciWorkflow, + "needs: [detect-alpha-release, ci-success]", + "release-alpha-train must wait for release relevance detection and the aggregate CI gate", + ); + assertContains( + ciWorkflow, + [ + " permissions:", + " contents: write", + " pull-requests: write", + " packages: write", + " id-token: write", + ].join("\n"), + "release-alpha-train must scope publish permissions to the release job", + ); + assertNotContains( + ciWorkflow, + "gh run list --workflow ci.yml", + "release-alpha-train must rely on ci.yml needs instead of polling GitHub Actions", + ); + assertContains( + ciWorkflow, "createGithubReleases: false", "Changesets must not create package-shaped GitHub Releases", ); assertContains( - releaseWorkflow, + ciWorkflow, "node scripts/release-alpha-train.mjs status --published \"$PUBLISHED\" --remote false", - "release-npm.yml must inspect alpha train recovery status without remote checks", + "ci.yml must inspect alpha train recovery status without remote checks", ); assertContains( - releaseWorkflow, + ciWorkflow, "steps.alpha-status.outputs.should_complete == 'true'", - "release-npm.yml must complete recoverable alpha trains even when npm publish is not rerun", + "ci.yml must complete recoverable alpha trains even when npm publish is not rerun", ); assertContains( - releaseWorkflow, + ciWorkflow, "node scripts/release-alpha-train.mjs prepare --published \"$PUBLISHED\"", - "release-npm.yml must prepare the alpha train before GoReleaser", + "ci.yml must prepare the alpha train before GoReleaser", ); assertContains( - releaseWorkflow, + ciWorkflow, '--out-dir "$RUNNER_TEMP/alpha-release"', - "release-npm.yml must write alpha release notes outside the checkout before GoReleaser --clean", + "ci.yml must write alpha release notes outside the checkout before GoReleaser --clean", ); assertContains( - releaseWorkflow, + ciWorkflow, 'alpha_env="$RUNNER_TEMP/alpha-release.env"', - "release-npm.yml must write alpha release outputs outside the checkout before GoReleaser --clean", + "ci.yml must write alpha release outputs outside the checkout before GoReleaser --clean", ); assertContains( - releaseWorkflow, + ciWorkflow, "steps.alpha.outputs.create_tag == 'true'", - "release-npm.yml must create the Go tag only when the alpha train needs it", + "ci.yml must create the Go tag only when the alpha train needs it", ); assertContains( - releaseWorkflow, + ciWorkflow, "steps.alpha.outputs.run_goreleaser == 'true'", - "release-npm.yml must skip GoReleaser when the alpha release and image already exist", + "ci.yml must skip GoReleaser when the alpha release and image already exist", ); assertNotContains( - releaseWorkflow, + ciWorkflow, "if: ${{ steps.changesets.outputs.published == 'true' }}", "post-npm alpha train steps must not be gated only on Changesets publishing in the current rerun", ); assertContains( - releaseWorkflow, + ciWorkflow, "--prerelease", "alpha GitHub Releases must be marked as prereleases", ); assertContains( - releaseWorkflow, + ciWorkflow, "--latest=false", "alpha GitHub Releases must not become GitHub latest", ); @@ -167,6 +246,17 @@ function assertPattern(input, pattern, message) { } } +async function readOptionalFile(path, readFileFn) { + try { + return await readFileFn(path, "utf8"); + } catch (error) { + if (error && typeof error === "object" && error.code === "ENOENT") { + return undefined; + } + throw error; + } +} + function requiredString(value, name) { if (typeof value !== "string" || value.trim().length === 0) { throw new Error(`${name} is required`);