From d94132e60979ab2f94f1c2a4458ff91b8214030a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 27 Oct 2026 07:39:56 +0000 Subject: [PATCH] ci: a release PR runs a fast release check instead of the heavy suites (#6524) * ci: a release PR runs a fast release check, not the heavy suites * ci: keep the release check's header within the comment limit * ci: shorten the release check's usage line * ci: trim the release check's comments * ci: the release check's classifier hands the manifest test to a helper --- .github/actions/release-only-diff/action.yml | 19 ++ .github/workflows/ci.yml | 36 +++- .github/workflows/lambda-arm64-image.yml | 17 ++ .github/workflows/player-perf.yml | 5 +- .github/workflows/player-safari.yml | 17 ++ .github/workflows/preview-regression.yml | 5 +- .github/workflows/regression.yml | 5 +- .github/workflows/windows-render.yml | 5 +- package.json | 2 +- scripts/ci/release-only-diff.mjs | 106 ++++++++++ scripts/ci/release-only-diff.test.mjs | 195 +++++++++++++++++++ 11 files changed, 398 insertions(+), 14 deletions(-) create mode 100644 .github/actions/release-only-diff/action.yml create mode 100644 scripts/ci/release-only-diff.mjs create mode 100644 scripts/ci/release-only-diff.test.mjs diff --git a/.github/actions/release-only-diff/action.yml b/.github/actions/release-only-diff/action.yml new file mode 100644 index 0000000000..34109d418a --- /dev/null +++ b/.github/actions/release-only-diff/action.yml @@ -0,0 +1,19 @@ +name: Release-only diff +description: >- + Says whether a pull request changes only package and plugin versions, release notes and the changelog, so heavy suites + can skip it. Main and the merge queue never skip. Needs a checkout with full history. +outputs: + release_only: + description: "'true' when every changed file is a version bump, release note or changelog entry." + value: ${{ steps.diff.outputs.release_only }} +runs: + using: composite + steps: + - id: diff + shell: bash + run: | + if [ "${{ github.event_name }}" = pull_request ]; then + node scripts/ci/release-only-diff.mjs + else + echo "release_only=false" >> "$GITHUB_OUTPUT" + fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3f70dfe179..749fea1c93 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -79,16 +79,17 @@ jobs: contents: read pull-requests: read outputs: - code: ${{ (github.event_name == 'workflow_dispatch' && inputs.catalog_publish) && 'true' || steps.filter.outputs.code }} + release_only: ${{ steps.release.outputs.release_only }} + code: ${{ (github.event_name == 'workflow_dispatch' && inputs.catalog_publish) && 'true' || steps.release.outputs.release_only == 'true' && 'false' || steps.filter.outputs.code }} docs_catalog: ${{ steps.filter.outputs.docs_catalog }} - cli: ${{ steps.filter.outputs.cli }} - cli_bundle: ${{ steps.filter.outputs.cli_bundle }} - skills: ${{ steps.filter.outputs.skills }} - catalog_index: ${{ steps.filter.outputs.catalog_index }} + cli: ${{ steps.release.outputs.release_only == 'true' && 'false' || steps.filter.outputs.cli }} + cli_bundle: ${{ steps.release.outputs.release_only == 'true' && 'false' || steps.filter.outputs.cli_bundle }} + skills: ${{ steps.release.outputs.release_only == 'true' && 'false' || steps.filter.outputs.skills }} + catalog_index: ${{ steps.release.outputs.release_only == 'true' && 'false' || steps.filter.outputs.catalog_index }} codex_plugin: ${{ steps.filter.outputs.codex_plugin }} - gcp_beginframe: ${{ steps.filter.outputs.gcp_beginframe }} - studio: ${{ steps.filter.outputs.studio }} - edit_accuracy: ${{ steps.edit_accuracy_filter.outputs.edit_accuracy }} + gcp_beginframe: ${{ steps.release.outputs.release_only == 'true' && 'false' || steps.filter.outputs.gcp_beginframe }} + studio: ${{ steps.release.outputs.release_only == 'true' && 'false' || steps.filter.outputs.studio }} + edit_accuracy: ${{ steps.release.outputs.release_only == 'true' && 'false' || steps.edit_accuracy_filter.outputs.edit_accuracy }} steps: # Force git-based change detection instead of the pull_request REST API. # The API path can fail the whole workflow on transient listFiles @@ -125,6 +126,9 @@ jobs: env: BASE_SHA: ${{ steps.base.outputs.sha }} run: sh scripts/check-large-files.sh --range "$BASE_SHA" HEAD + - name: Find a release-only diff + id: release + uses: ./.github/actions/release-only-diff - uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4 id: filter if: steps.edit.outputs.passed != 'true' @@ -272,6 +276,20 @@ jobs: - "!**/{__tests__,__fixtures__}/**" - "!**/*.md" + # A release PR changes versions and notes only (scripts/ci/release-only-diff.mjs), so heavy suites skip it. + # Build still packs the manifests and keeps the tarballs; this job checks the release is consistent. + release-checks: + name: Release checks + needs: changes + if: ${{ needs.changes.outputs.release_only == 'true' }} + runs-on: ubuntu-latest + timeout-minutes: 4 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + fetch-depth: 0 + - run: node scripts/ci/release-only-diff.mjs --verify + gcp-beginframe-contract: name: GCP BeginFrame image contract needs: changes @@ -306,7 +324,7 @@ jobs: name: Build needs: changes # A skipped required check passes, so a broken change detection must fail it instead. - if: ${{ !cancelled() && (needs.changes.result != 'success' || needs.changes.outputs.code == 'true' || needs.changes.outputs.cli_bundle == 'true' || needs.changes.outputs.edit_accuracy == 'true') }} + if: ${{ !cancelled() && (needs.changes.result != 'success' || needs.changes.outputs.code == 'true' || needs.changes.outputs.cli_bundle == 'true' || needs.changes.outputs.edit_accuracy == 'true' || needs.changes.outputs.release_only == 'true') }} runs-on: ubuntu-latest timeout-minutes: 10 steps: diff --git a/.github/workflows/lambda-arm64-image.yml b/.github/workflows/lambda-arm64-image.yml index 8ae3b2365b..3fe7405a0d 100644 --- a/.github/workflows/lambda-arm64-image.yml +++ b/.github/workflows/lambda-arm64-image.yml @@ -26,8 +26,25 @@ concurrency: cancel-in-progress: true jobs: + changes: + name: Detect changes + runs-on: ubuntu-latest + timeout-minutes: 4 + outputs: + release_only: ${{ steps.release.outputs.release_only }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + fetch-depth: 0 + - name: Find a release-only diff + id: release + uses: ./.github/actions/release-only-diff + ffmpeg: name: ffmpeg ${{ matrix.copy }} + needs: changes + # A release PR changes versions and notes only; a failed change detection must not skip the build. + if: ${{ !cancelled() && needs.changes.outputs.release_only != 'true' }} runs-on: ubuntu-24.04-arm timeout-minutes: 90 strategy: diff --git a/.github/workflows/player-perf.yml b/.github/workflows/player-perf.yml index 8bfea63ae7..3590458aa7 100644 --- a/.github/workflows/player-perf.yml +++ b/.github/workflows/player-perf.yml @@ -23,7 +23,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 4 outputs: - perf: ${{ steps.filter.outputs.perf }} + perf: ${{ steps.release.outputs.release_only == 'true' && 'false' || steps.filter.outputs.perf }} steps: # Force git-based change detection instead of the pull_request REST API. # The API path can fail the perf workflow on transient listFiles timeouts. @@ -48,6 +48,9 @@ jobs: git rev-parse --verify -q HEAD^2 > /dev/null || { echo "::error::HEAD is not the pull request merge commit"; exit 1; } sha="$(git rev-parse --verify HEAD^1)" echo "sha=$sha" >> "$GITHUB_OUTPUT" + - name: Find a release-only diff + id: release + uses: ./.github/actions/release-only-diff - uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4 id: filter with: diff --git a/.github/workflows/player-safari.yml b/.github/workflows/player-safari.yml index 0c77701229..057ea172c6 100644 --- a/.github/workflows/player-safari.yml +++ b/.github/workflows/player-safari.yml @@ -21,8 +21,25 @@ concurrency: cancel-in-progress: true jobs: + changes: + name: Detect changes + runs-on: ubuntu-latest + timeout-minutes: 4 + outputs: + release_only: ${{ steps.release.outputs.release_only }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + fetch-depth: 0 + - name: Find a release-only diff + id: release + uses: ./.github/actions/release-only-diff + heavy-films: name: "Heavy films: Safari vs Chrome" + needs: changes + # A release PR changes versions and notes only; a failed change detection must not skip the films. + if: ${{ !cancelled() && needs.changes.outputs.release_only != 'true' }} runs-on: macos-latest timeout-minutes: 45 steps: diff --git a/.github/workflows/preview-regression.yml b/.github/workflows/preview-regression.yml index ee66a154ea..c8a77280f1 100644 --- a/.github/workflows/preview-regression.yml +++ b/.github/workflows/preview-regression.yml @@ -24,7 +24,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 outputs: - preview: ${{ steps.filter.outputs.preview }} + preview: ${{ steps.release.outputs.release_only == 'true' && 'false' || steps.filter.outputs.preview }} steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: @@ -38,6 +38,9 @@ jobs: git rev-parse --verify -q HEAD^2 > /dev/null || { echo "::error::HEAD is not the pull request merge commit"; exit 1; } sha="$(git rev-parse --verify HEAD^1)" echo "sha=$sha" >> "$GITHUB_OUTPUT" + - name: Find a release-only diff + id: release + uses: ./.github/actions/release-only-diff - uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4 id: filter with: diff --git a/.github/workflows/regression.yml b/.github/workflows/regression.yml index 9d030f126e..3b7b25697a 100644 --- a/.github/workflows/regression.yml +++ b/.github/workflows/regression.yml @@ -44,7 +44,7 @@ jobs: actions: read contents: read outputs: - code: ${{ (github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && inputs.catalog_publish)) && 'true' || steps.edit.outputs.passed == 'true' && 'false' || steps.filter.outputs.code }} + code: ${{ (github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && inputs.catalog_publish)) && 'true' || steps.edit.outputs.passed == 'true' && 'false' || steps.release.outputs.release_only == 'true' && 'false' || steps.filter.outputs.code }} matrix: ${{ steps.shards.outputs.matrix }} steps: # Force git-based change detection instead of the pull_request REST API. @@ -74,6 +74,9 @@ jobs: git rev-parse --verify -q HEAD^2 > /dev/null || { echo "::error::HEAD is not the pull request merge commit"; exit 1; } sha="$(git rev-parse --verify HEAD^1)" echo "sha=$sha" >> "$GITHUB_OUTPUT" + - name: Find a release-only diff + id: release + uses: ./.github/actions/release-only-diff - uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4 id: filter if: github.event_name != 'schedule' && steps.edit.outputs.passed != 'true' diff --git a/.github/workflows/windows-render.yml b/.github/workflows/windows-render.yml index b91fa48102..027b8c255f 100644 --- a/.github/workflows/windows-render.yml +++ b/.github/workflows/windows-render.yml @@ -48,7 +48,7 @@ jobs: actions: read contents: read outputs: - code: ${{ steps.filter.outputs.code }} + code: ${{ steps.release.outputs.release_only == 'true' && 'false' || steps.filter.outputs.code }} steps: # Force git-based change detection instead of the pull_request REST API. # The API path can fail the workflow on transient listFiles timeouts @@ -77,6 +77,9 @@ jobs: git rev-parse --verify -q HEAD^2 > /dev/null || { echo "::error::HEAD is not the pull request merge commit"; exit 1; } sha="$(git rev-parse --verify HEAD^1)" echo "sha=$sha" >> "$GITHUB_OUTPUT" + - name: Find a release-only diff + id: release + uses: ./.github/actions/release-only-diff - uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4 id: filter if: steps.edit.outputs.passed != 'true' diff --git a/package.json b/package.json index 8b9a103c25..7142ee0f85 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ "player:perf": "bun run --filter @hyperframes/player perf", "format:check": "oxfmt --check .", "knip": "knip", - "test:scripts": "node --import tsx --test scripts/animejs-v4-guidance.test.mjs scripts/check-tracked-artifacts.test.mjs scripts/check-registry-set-delta.test.mjs scripts/check-no-main-deletions.test.mjs scripts/check-pr-captures.test.mjs scripts/check-comment-citations.test.mjs scripts/comments-workflow.test.mjs scripts/comment-ratchet.test.mjs scripts/check-docs-snippet-motion.test.mjs scripts/registry-target-paths.test.mjs scripts/check-workspace-contracts.test.mjs scripts/check-media-use-copy-parity.test.mjs scripts/check-svg-sanitize-parity.test.mjs scripts/check-media-use-svg-sanitize-generated.test.mjs scripts/check-package-cycles.test.mjs scripts/check-cli-process-ownership.test.mjs scripts/check-cli-lazy-deps.test.mjs scripts/file-url-paths.test.mjs scripts/check-studio-copy-dashes.test.mjs scripts/check-large-files.test.mjs scripts/package-subpaths.test.mjs scripts/validate-release-channel.test.mjs scripts/clawhub-sync.test.mjs scripts/clawhub-unlist-retired.test.mjs scripts/publish-workflow.test.mjs scripts/pr-edit-concurrency.test.mjs scripts/merge-queue-workflows.test.mjs scripts/install-workspace-dependencies.test.mjs scripts/check-edit-accuracy-filter.test.mjs scripts/edit-accuracy-retry.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/catalog-payload-assets.test.ts scripts/host-registry-assets.test.ts scripts/catalog-preview-temp.test.ts scripts/backfill-block-previews.test.ts scripts/catalog-hosted-files.test.ts scripts/player-cdn-pin.test.ts scripts/studio-runtime-smoke.test.mjs scripts/verify-packed-manifests.test.mjs scripts/rewrite-relative-import-extensions.test.mjs scripts/lint-skills.test.mjs scripts/check-core-skills.test.mjs scripts/creator-editing-recipes.test.mjs packages/gcp-cloud-run/check-dockerfile-workspaces.test.mjs packages/core/scripts/writeGeneratedFile.test.ts scripts/catalog-publication.test.mjs scripts/ci/resolve-workflow-pr.test.mjs scripts/lefthook-order.test.mjs scripts/inline-gsap.test.mjs scripts/ci/flake-gate.test.mjs scripts/ci/base-file.test.mjs scripts/check-catalog-source-pr.test.mjs scripts/generate-registry-items.test.ts scripts/catalog-drift.test.ts scripts/catalog-fetch-mirror.test.ts scripts/catalog-script-inlining.test.ts scripts/catalog-detail.test.ts scripts/generate-catalog-pages.test.ts scripts/verify-catalog-payloads.test.ts scripts/registry-theme-fallbacks.test.ts scripts/registry-skill-files.test.ts scripts/creator-editing-capabilities.test.mjs scripts/generate-catalog-previews.test.ts scripts/registry-primitive-payloads.test.ts registry/components/pan-stations/pan-stations.test.mjs registry/components/filmed-screen/filmed-screen.test.mjs registry/components/echo-smear/echo-smear.test.mjs && vitest run scripts/catalog/ scripts/contrastRatchet.test.ts scripts/generate-catalog-payloads.test.ts", + "test:scripts": "node --import tsx --test scripts/animejs-v4-guidance.test.mjs scripts/check-tracked-artifacts.test.mjs scripts/check-registry-set-delta.test.mjs scripts/check-no-main-deletions.test.mjs scripts/check-pr-captures.test.mjs scripts/check-comment-citations.test.mjs scripts/comments-workflow.test.mjs scripts/comment-ratchet.test.mjs scripts/check-docs-snippet-motion.test.mjs scripts/registry-target-paths.test.mjs scripts/check-workspace-contracts.test.mjs scripts/check-media-use-copy-parity.test.mjs scripts/check-svg-sanitize-parity.test.mjs scripts/check-media-use-svg-sanitize-generated.test.mjs scripts/check-package-cycles.test.mjs scripts/check-cli-process-ownership.test.mjs scripts/check-cli-lazy-deps.test.mjs scripts/file-url-paths.test.mjs scripts/check-studio-copy-dashes.test.mjs scripts/check-large-files.test.mjs scripts/package-subpaths.test.mjs scripts/validate-release-channel.test.mjs scripts/clawhub-sync.test.mjs scripts/clawhub-unlist-retired.test.mjs scripts/publish-workflow.test.mjs scripts/pr-edit-concurrency.test.mjs scripts/merge-queue-workflows.test.mjs scripts/install-workspace-dependencies.test.mjs scripts/check-edit-accuracy-filter.test.mjs scripts/edit-accuracy-retry.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/catalog-payload-assets.test.ts scripts/host-registry-assets.test.ts scripts/catalog-preview-temp.test.ts scripts/backfill-block-previews.test.ts scripts/catalog-hosted-files.test.ts scripts/player-cdn-pin.test.ts scripts/studio-runtime-smoke.test.mjs scripts/verify-packed-manifests.test.mjs scripts/rewrite-relative-import-extensions.test.mjs scripts/lint-skills.test.mjs scripts/check-core-skills.test.mjs scripts/creator-editing-recipes.test.mjs packages/gcp-cloud-run/check-dockerfile-workspaces.test.mjs packages/core/scripts/writeGeneratedFile.test.ts scripts/catalog-publication.test.mjs scripts/ci/resolve-workflow-pr.test.mjs scripts/lefthook-order.test.mjs scripts/inline-gsap.test.mjs scripts/ci/flake-gate.test.mjs scripts/ci/base-file.test.mjs scripts/ci/release-only-diff.test.mjs scripts/check-catalog-source-pr.test.mjs scripts/generate-registry-items.test.ts scripts/catalog-drift.test.ts scripts/catalog-fetch-mirror.test.ts scripts/catalog-script-inlining.test.ts scripts/catalog-detail.test.ts scripts/generate-catalog-pages.test.ts scripts/verify-catalog-payloads.test.ts scripts/registry-theme-fallbacks.test.ts scripts/registry-skill-files.test.ts scripts/creator-editing-capabilities.test.mjs scripts/generate-catalog-previews.test.ts scripts/registry-primitive-payloads.test.ts registry/components/pan-stations/pan-stations.test.mjs registry/components/filmed-screen/filmed-screen.test.mjs registry/components/echo-smear/echo-smear.test.mjs && vitest run scripts/catalog/ scripts/contrastRatchet.test.ts scripts/generate-catalog-payloads.test.ts", "typecheck:scripts": "tsc --noEmit -p scripts/tsconfig.json", "test:skills": "HYPERFRAMES_MEDIA_HOME_REQUIRED=1 node --import ./packages/cli/scripts/test-home.mjs --test 'skills/**/*.test.mjs' 'packages/cli/src/media-use/**/*.test.mjs'", "generate:previews": "tsx scripts/generate-template-previews.ts", diff --git a/scripts/ci/release-only-diff.mjs b/scripts/ci/release-only-diff.mjs new file mode 100644 index 0000000000..d3d96b9176 --- /dev/null +++ b/scripts/ci/release-only-diff.mjs @@ -0,0 +1,106 @@ +#!/usr/bin/env node +// Says whether a pull request changes only a release's own files: manifest versions, release notes, the changelog. +// node scripts/ci/release-only-diff.mjs [--base ] [--verify]; writes release_only= to $GITHUB_OUTPUT +// --verify also exits 1 on mixed versions, missing notes, an unreviewed TODO or a missing changelog entry. +import { execFileSync } from "node:child_process"; +import { appendFileSync } from "node:fs"; +import { basename } from "node:path"; +import { pathToFileURL } from "node:url"; + +const MANIFESTS = new Set(["package.json", "plugin.json", "gemini-extension.json"]); +const RELEASE_NOTES = /^releases\/v\d+\.\d+\.\d+[^/]*\.md$/; +const CHANGELOG = "docs/changelog.mdx"; +const UNREVIEWED = ["", ""), + ); + }); + assert.match(run(unreviewed, "--verify").stderr, /still has the generated TODO/); + + const noEntry = repo((write) => { + bump("0.8.2")(write); + write("docs/changelog.mdx", CHANGELOG("0.8.1")); + }); + assert.match(run(noEntry, "--verify").stderr, /no entry for v0\.8\.2/); +}); + +test("classify works on plain data", () => { + const files = { "a/package.json": manifest("1"), "plugin.json": manifest("1") }; + const after = { "a/package.json": manifest("2"), "plugin.json": manifest("2") }; + const result = classify( + Object.keys(files), + (p) => files[p] ?? null, + (p) => after[p] ?? null, + ); + assert.equal(result.releaseOnly, true); + assert.deepEqual([...result.versions.values()], ["2", "2"]); +}); + +test("every heavy workflow asks the release-only action, and the decision is used where the heavy jobs read it", () => { + const read = (name) => + readFileSync(new URL(`../../.github/workflows/${name}.yml`, import.meta.url), "utf8"); + for (const name of [ + "ci", + "regression", + "windows-render", + "preview-regression", + "player-perf", + "player-safari", + "lambda-arm64-image", + ]) + assert.match(read(name), /uses: \.\/\.github\/actions\/release-only-diff/, name); + const ci = read("ci"); + for (const key of [ + "code", + "cli", + "cli_bundle", + "skills", + "catalog_index", + "gcp_beginframe", + "studio", + "edit_accuracy", + ]) + assert.match( + ci, + new RegExp(`${key}: \\$\\{\\{[^\\n]*release_only == 'true' && 'false' \\|\\|`), + `ci ${key}`, + ); + assert.match( + ci, + /needs\.changes\.outputs\.release_only == 'true' \}\}\n\s+runs-on/, + "build still runs on a release", + ); + assert.match(ci, /release-checks:[\s\S]*release-only-diff\.mjs --verify/); + for (const [name, key] of [ + ["regression", "code"], + ["windows-render", "code"], + ["preview-regression", "preview"], + ["player-perf", "perf"], + ]) + assert.match( + read(name), + new RegExp(`${key}: \\$\\{\\{[^\\n]*release_only == 'true' && 'false' \\|\\|`), + name, + ); +});