mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
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:
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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)));
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user