diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5b3887c4e..e4b7e35e6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,6 +34,8 @@ jobs: run: | if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then echo "should_run=true" >> "$GITHUB_OUTPUT" + echo "should_publish=true" >> "$GITHUB_OUTPUT" + echo "should_verify_release_gate_maintenance=false" >> "$GITHUB_OUTPUT" exit 0 fi @@ -43,13 +45,28 @@ jobs: fi release_work_pattern='^\.changeset/(pre\.json|[^/]+\.md)$|^packages/[^/]+/(package\.json|CHANGELOG\.md)$|^package\.json$|^pnpm-lock\.yaml$' + release_gate_maintenance_pattern='^\.github/workflows/release\.yml$|^scripts/(changeset-required-check\.mts|normalize-packages\.mjs|package-bin-smoke\.mts|package-entrypoint-smoke\.mts|package-manifest-contracts\.mjs|release-docs-check\.mts)$|^scripts/tests/(changeset-required-check|normalize-packages|package-bin-smoke|package-entrypoint-smoke|release-docs-check|release-workflow)\.spec\.ts$' changed_files="$(git diff --name-only "$base" HEAD)" + should_publish=false + should_verify_release_gate_maintenance=false + if echo "$changed_files" | grep -Eq "$release_work_pattern"; then + should_publish=true + fi + + if echo "$changed_files" | grep -Eq "$release_gate_maintenance_pattern"; then + should_verify_release_gate_maintenance=true + fi + + echo "should_publish=${should_publish}" >> "$GITHUB_OUTPUT" + echo "should_verify_release_gate_maintenance=${should_verify_release_gate_maintenance}" >> "$GITHUB_OUTPUT" + + if [ "$should_publish" = "true" ] || [ "$should_verify_release_gate_maintenance" = "true" ]; then echo "should_run=true" >> "$GITHUB_OUTPUT" else echo "should_run=false" >> "$GITHUB_OUTPUT" - echo "No changeset, prerelease state, manifest, changelog, or lockfile changes detected; skipping release pipeline." + echo "No changeset, prerelease state, manifest, changelog, lockfile, or release-gate maintenance changes detected; skipping release pipeline." fi - name: Setup pnpm @@ -68,41 +85,51 @@ jobs: if: steps.release_work.outputs.should_run == 'true' run: pnpm install --frozen-lockfile + - name: Release gate maintenance self-check + if: steps.release_work.outputs.should_verify_release_gate_maintenance == 'true' + run: | + pnpm build + pnpm exec vitest run scripts/tests/release-workflow.spec.ts scripts/tests/package-entrypoint-smoke.spec.ts scripts/tests/package-bin-smoke.spec.ts scripts/tests/release-docs-check.spec.ts scripts/tests/changeset-required-check.spec.ts scripts/tests/normalize-packages.spec.ts --config vitest.config.ts + pnpm package-manifests:check + pnpm release-docs:check + pnpm package-entrypoints:smoke + pnpm package-bins:smoke + # Release duplicates the publish-blocking CI quality gates that protect # npm publishing. PR-only checks, secret-scan reports, and docs/coverage # report artifact jobs stay in CI before release-triggering changes land. # audit:prod intentionally ignores GHSA-gv7w-rqvm-qjhr: it covers the # esbuild Deno API install path, while Croco release builds use Node/pnpm. - name: Production dependency audit - if: steps.release_work.outputs.should_run == 'true' + if: steps.release_work.outputs.should_publish == 'true' run: pnpm audit:prod - name: Lint, format, and repository policy checks - if: steps.release_work.outputs.should_run == 'true' + if: steps.release_work.outputs.should_publish == 'true' run: pnpm check - name: Build all packages - if: steps.release_work.outputs.should_run == 'true' + if: steps.release_work.outputs.should_publish == 'true' run: pnpm build - name: Package entrypoint smoke - if: steps.release_work.outputs.should_run == 'true' + if: steps.release_work.outputs.should_publish == 'true' run: pnpm package-entrypoints:smoke - name: Package binary smoke - if: steps.release_work.outputs.should_run == 'true' + if: steps.release_work.outputs.should_publish == 'true' run: pnpm package-bins:smoke - name: TypeScript check - if: steps.release_work.outputs.should_run == 'true' + if: steps.release_work.outputs.should_publish == 'true' run: pnpm typecheck - name: Test - if: steps.release_work.outputs.should_run == 'true' + if: steps.release_work.outputs.should_publish == 'true' run: pnpm test - name: Verify npm provenance configuration - if: steps.release_work.outputs.should_run == 'true' + if: steps.release_work.outputs.should_publish == 'true' shell: bash run: | npm_provenance="$(npm config get provenance)" @@ -115,11 +142,11 @@ jobs: fi - name: Dry-run publish gate - if: steps.release_work.outputs.should_run == 'true' + if: steps.release_work.outputs.should_publish == 'true' run: pnpm -r publish --dry-run --no-git-checks - name: Create Release Pull Request or Publish - if: steps.release_work.outputs.should_run == 'true' + if: steps.release_work.outputs.should_publish == 'true' uses: changesets/action@v1 with: publish: pnpm exec changeset publish diff --git a/scripts/tests/release-workflow.spec.ts b/scripts/tests/release-workflow.spec.ts index da52bcf5b..24d0ad33d 100644 --- a/scripts/tests/release-workflow.spec.ts +++ b/scripts/tests/release-workflow.spec.ts @@ -6,21 +6,32 @@ const releaseWorkflowPath = resolve(__dirname, "../../.github/workflows/release. const readReleaseWorkflow = () => readFileSync(releaseWorkflowPath, "utf-8"); -const getReleaseWorkPattern = () => { +const getWorkflowPattern = (variableName: string) => { const workflow = readReleaseWorkflow(); - const match = workflow.match(/release_work_pattern='([^']+)'/); + const match = workflow.match(new RegExp(`${variableName}='([^']+)'`)); - expect(match, "release_work_pattern should be present").not.toBeNull(); + expect(match, `${variableName} should be present`).not.toBeNull(); return new RegExp(match?.[1] ?? ""); }; +const getReleaseWorkPattern = () => getWorkflowPattern("release_work_pattern"); + +const getReleaseGateMaintenancePattern = () => + getWorkflowPattern("release_gate_maintenance_pattern"); + const shouldRunReleaseWork = (changedFiles: string[]) => { const releaseWorkPattern = getReleaseWorkPattern(); return changedFiles.some((file) => releaseWorkPattern.test(file)); }; +const shouldRunReleaseGateMaintenance = (changedFiles: string[]) => { + const releaseGateMaintenancePattern = getReleaseGateMaintenancePattern(); + + return changedFiles.some((file) => releaseGateMaintenancePattern.test(file)); +}; + describe("release workflow quality gates", () => { it("runs publish-blocking quality gates before dry-run publish and Changesets", () => { const workflow = readReleaseWorkflow(); @@ -78,6 +89,7 @@ describe("release workflow quality gates", () => { it("runs for Changesets prerelease state changes", () => { expect(shouldRunReleaseWork([".changeset/pre.json"])).toBe(true); + expect(shouldRunReleaseGateMaintenance([".changeset/pre.json"])).toBe(false); }); it("keeps existing release-work file triggers", () => { @@ -88,9 +100,53 @@ describe("release workflow quality gates", () => { expect(shouldRunReleaseWork(["pnpm-lock.yaml"])).toBe(true); }); + it("runs focused self-checks for release-gate maintenance changes", () => { + const workflow = readReleaseWorkflow(); + const releaseGateFiles = [ + ".github/workflows/release.yml", + "scripts/changeset-required-check.mts", + "scripts/normalize-packages.mjs", + "scripts/package-bin-smoke.mts", + "scripts/package-entrypoint-smoke.mts", + "scripts/package-manifest-contracts.mjs", + "scripts/release-docs-check.mts", + "scripts/tests/changeset-required-check.spec.ts", + "scripts/tests/normalize-packages.spec.ts", + "scripts/tests/package-bin-smoke.spec.ts", + "scripts/tests/package-entrypoint-smoke.spec.ts", + "scripts/tests/release-docs-check.spec.ts", + "scripts/tests/release-workflow.spec.ts", + ]; + + for (const file of releaseGateFiles) { + expect( + shouldRunReleaseGateMaintenance([file]), + `${file} should trigger release gate maintenance`, + ).toBe(true); + expect(shouldRunReleaseWork([file]), `${file} should not be release metadata`).toBe(false); + } + + expect(workflow).toContain("- name: Release gate maintenance self-check"); + expect(workflow).toContain( + "if: steps.release_work.outputs.should_verify_release_gate_maintenance == 'true'", + ); + expect(workflow).toContain("pnpm exec vitest run scripts/tests/release-workflow.spec.ts"); + expect(workflow).toContain("scripts/tests/normalize-packages.spec.ts"); + expect(workflow).toContain("pnpm package-manifests:check"); + expect(workflow).toContain("pnpm release-docs:check"); + expect(workflow).toContain("pnpm package-entrypoints:smoke"); + expect(workflow).toContain("pnpm package-bins:smoke"); + expect(workflow).toContain("if: steps.release_work.outputs.should_publish == 'true'"); + }); + it("skips non-release-only changes", () => { expect(shouldRunReleaseWork(["RELEASING.md"])).toBe(false); expect(shouldRunReleaseWork(["packages/framework-context/src/index.ts"])).toBe(false); expect(shouldRunReleaseWork([".changeset/config.json"])).toBe(false); + expect(shouldRunReleaseGateMaintenance(["RELEASING.md"])).toBe(false); + expect(shouldRunReleaseGateMaintenance(["packages/framework-context/src/index.ts"])).toBe( + false, + ); + expect(shouldRunReleaseGateMaintenance([".changeset/config.json"])).toBe(false); }); });