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
This commit is contained in:
Miguel Ángel
2026-10-27 07:39:56 +00:00
committed by GitHub
parent 019b17b4b7
commit d94132e609
11 changed files with 398 additions and 14 deletions
@@ -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
+27 -9
View File
@@ -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:
+17
View File
@@ -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:
+4 -1
View File
@@ -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:
+17
View File
@@ -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:
+4 -1
View File
@@ -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:
+4 -1
View File
@@ -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'
+4 -1
View File
@@ -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'
+1 -1
View File
@@ -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",
+106
View File
@@ -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 <ref>] [--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 = ["<!-- TODO: write a 1-2 sentence release summary here. -->", "<!-- Style bar:"];
const sameJson = (a, b) => JSON.stringify(a) === JSON.stringify(b);
/** The manifest with its version taken out, or null when it is not JSON. */
function withoutVersion(text) {
try {
const { version, ...rest } = JSON.parse(text);
return { version, rest };
} catch {
return null;
}
}
/** The new version when the only difference between two manifest texts is `version`, else null. */
function versionBump(was, now) {
const a = was === null ? null : withoutVersion(was);
const b = now === null ? null : withoutVersion(now);
return a && b && sameJson(a.rest, b.rest) && a.version !== b.version ? b.version : null;
}
/** Sorts changed paths into the release's own and the rest; before and after read a path at each end, or null. */
export function classify(paths, before, after) {
const other = [];
const versions = new Map();
for (const path of paths) {
if (RELEASE_NOTES.test(path) || path === CHANGELOG) continue;
const bumped = MANIFESTS.has(basename(path)) ? versionBump(before(path), after(path)) : null;
if (bumped === null) other.push(path);
else versions.set(path, bumped);
}
return { releaseOnly: paths.length > 0 && other.length === 0, other, versions };
}
/** Problems with a release that is otherwise only versions and notes; empty when it is consistent. */
export function verifyRelease({ versions }, after) {
const problems = [];
const distinct = new Set(versions.values());
if (distinct.size !== 1) {
problems.push(
`manifests move to different versions: ${[...distinct].join(", ") || "none changed"}`,
);
return problems;
}
const [version] = distinct;
const notes = after(`releases/v${version}.md`);
if (notes === null) problems.push(`releases/v${version}.md is missing`);
else if (UNREVIEWED.some((marker) => notes.includes(marker)))
problems.push(`releases/v${version}.md still has the generated TODO or style note`);
const changelog = after(CHANGELOG);
if (changelog === null || !changelog.includes(`label="HyperFrames v${version}"`))
problems.push(`${CHANGELOG} has no entry for v${version}`);
else if (UNREVIEWED.some((marker) => changelog.includes(marker)))
problems.push(`${CHANGELOG} still has the generated TODO or style note`);
return problems;
}
const git = (...args) => execFileSync("git", args, { encoding: "utf8", maxBuffer: 1 << 26 });
const show = (ref) => (path) => {
try {
return git("show", `${ref}:${path}`);
} catch {
return null;
}
};
function main(argv) {
const at = argv.indexOf("--base");
const base = at >= 0 ? argv[at + 1] : "HEAD^1";
const paths = git("-c", "core.quotePath=false", "diff", "--name-only", `${base}..HEAD`)
.split("\n")
.filter(Boolean);
const result = classify(paths, show(base), show("HEAD"));
if (process.env.GITHUB_OUTPUT)
appendFileSync(process.env.GITHUB_OUTPUT, `release_only=${result.releaseOnly}\n`);
console.log(
result.releaseOnly
? `release only: ${paths.length} files, versions and notes`
: `not release only: ${result.other.slice(0, 5).join(", ") || "no changed files"}`,
);
if (!argv.includes("--verify")) return 0;
if (!result.releaseOnly) {
console.error(
"--verify applies to a release diff; this one carries more than versions and notes.",
);
return 1;
}
const problems = verifyRelease(result, show("HEAD"));
for (const problem of problems) console.error(`::error::${problem}`);
return problems.length ? 1 : 0;
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
process.exit(main(process.argv.slice(2)));
+195
View File
@@ -0,0 +1,195 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { test } from "node:test";
import { classify } from "./release-only-diff.mjs";
const SCRIPT = new URL("./release-only-diff.mjs", import.meta.url).pathname;
const manifest = (version, extra = {}) =>
`${JSON.stringify({ name: "p", version, dependencies: { a: "1" }, ...extra }, null, 2)}\n`;
const NOTES = (v, body = "Faster renders.") => `# HyperFrames v${v}\n\n${body}\n\n## Fixes\n`;
const CHANGELOG = (v, body = "Faster renders.") =>
`<Update\n label="HyperFrames v${v}"\n>\n${body}\n</Update>\n`;
/** A repo whose HEAD~1 is the base: `change` edits the worktree, then it is committed as the "release". */
function repo(change) {
const root = mkdtempSync(join(tmpdir(), "release-only-"));
const git = (...args) => execFileSync("git", args, { cwd: root, encoding: "utf8" });
const write = (path, text) => {
mkdirSync(dirname(join(root, path)), { recursive: true });
writeFileSync(join(root, path), text);
};
git("init", "-q", "-b", "main");
git("config", "user.email", "t@example.com");
git("config", "user.name", "t");
write("packages/core/package.json", manifest("0.8.1"));
write("packages/cli/package.json", manifest("0.8.1"));
write("plugin.json", manifest("0.8.1"));
write("packages/core/src/index.ts", "export const a = 1;\n");
write("docs/changelog.mdx", CHANGELOG("0.8.1"));
git("add", "-A");
git("commit", "-qm", "base");
change(write);
git("add", "-A");
git("commit", "-qm", "release", "--allow-empty");
return root;
}
const run = (root, ...args) => {
const out = join(root, "..", `out-${Math.random().toString(36).slice(2)}`);
writeFileSync(out, "");
try {
const stdout = execFileSync("node", [SCRIPT, ...args], {
cwd: root,
encoding: "utf8",
env: { ...process.env, GITHUB_OUTPUT: out },
stdio: ["ignore", "pipe", "pipe"],
});
return { code: 0, stdout, output: readFileSync(out, "utf8") };
} catch (error) {
return {
code: error.status,
stdout: error.stdout,
stderr: error.stderr,
output: readFileSync(out, "utf8"),
};
}
};
const bump = (v) => (write) => {
write("packages/core/package.json", manifest(v));
write("packages/cli/package.json", manifest(v));
write("plugin.json", manifest(v));
write(`releases/v${v}.md`, NOTES(v));
write("docs/changelog.mdx", CHANGELOG(v) + CHANGELOG("0.8.1"));
};
test("a diff of version bumps, release notes and the changelog is release only, and consistent", () => {
const root = repo(bump("0.8.2"));
assert.match(run(root).output, /release_only=true/);
assert.equal(run(root, "--verify").code, 0);
});
test("a release that also changes code is not release only, so it keeps the full suite", () => {
const root = repo((write) => {
bump("0.8.2")(write);
write("packages/core/src/index.ts", "export const a = 2;\n");
});
const result = run(root);
assert.match(result.output, /release_only=false/);
assert.match(result.stdout, /packages\/core\/src\/index\.ts/);
});
test("a manifest that changes more than its version is not a version bump", () => {
const root = repo((write) => {
bump("0.8.2")(write);
write("packages/core/package.json", manifest("0.8.2", { dependencies: { a: "2" } }));
});
assert.match(run(root).output, /release_only=false/);
});
test("a branch named release/ with code in it is judged by its files, not its name", () => {
const root = repo((write) => write("packages/core/src/index.ts", "export const a = 3;\n"));
execFileSync("git", ["checkout", "-qb", "release/v0.8.2"], { cwd: root });
assert.match(run(root).output, /release_only=false/);
});
test("an ordinary code pull request and an empty diff are not release only", () => {
assert.match(
run(repo((write) => write("packages/cli/src/a.ts", "x\n"))).output,
/release_only=false/,
);
assert.match(run(repo(() => {})).output, /release_only=false/);
});
test("--verify refuses mixed versions, a missing note, an unreviewed note and a missing changelog entry", () => {
const mixed = repo((write) => {
bump("0.8.2")(write);
write("plugin.json", manifest("0.8.3"));
});
assert.match(run(mixed, "--verify").stderr, /different versions/);
const missing = repo((write) => {
for (const path of ["packages/core/package.json", "packages/cli/package.json", "plugin.json"])
write(path, manifest("0.8.2"));
write("docs/changelog.mdx", CHANGELOG("0.8.2"));
});
assert.match(run(missing, "--verify").stderr, /releases\/v0\.8\.2\.md is missing/);
const unreviewed = repo((write) => {
bump("0.8.2")(write);
write(
"releases/v0.8.2.md",
NOTES("0.8.2", "<!-- TODO: write a 1-2 sentence release summary here. -->"),
);
});
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,
);
});