From 9e8fe22b883852dbd4764326252b7b1c99d293df Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Tue, 27 Oct 2026 15:24:37 -0700 Subject: [PATCH 1/7] Validate single-line changeset bodies and fix changelog formatting --- .changeset/README.md | 6 +- .changeset/formatEntry.d.mts | 1 + .changeset/formatEntry.mjs | 25 +++--- .changeset/single-line-summaries.md | 5 ++ .github/workflows/changelog.yml | 29 +++---- CHANGELOG.md | 1 + scripts/src/changesets/formatEntry.test.ts | 10 +-- scripts/src/changesets/validation.test.ts | 90 ++++++++++++++++++++++ scripts/src/changesets/validation.ts | 39 ++++++++++ scripts/src/validateChangesets.ts | 35 ++------- 10 files changed, 178 insertions(+), 63 deletions(-) create mode 100644 .changeset/single-line-summaries.md create mode 100644 scripts/src/changesets/validation.test.ts create mode 100644 scripts/src/changesets/validation.ts diff --git a/.changeset/README.md b/.changeset/README.md index 2915d0fec1..839c49ed8e 100644 --- a/.changeset/README.md +++ b/.changeset/README.md @@ -1,6 +1,6 @@ # Changesets -Every PR that changes a package adds a changeset: a Markdown file in this directory that names the changed packages, the bump type, and a one-line summary for the changelog. Do not edit `CHANGELOG.md` files. The release generates them from the changesets. +Every PR that changes a package adds a changeset: a Markdown file in this directory that names the changed packages, the bump type, and a one-line summary for the changelog. Do not edit generated `CHANGELOG.md` text. The release generates it from the changesets; blank-line repairs are allowed. ## Add a changeset @@ -19,7 +19,7 @@ Select the changed packages and the bump type, then write the summary. Commit th Add the `--foo` flag to `eas build`. ``` -Write the summary as one sentence in the style of the existing `CHANGELOG.md` entries. The release adds the PR link and the author, and prefixes the entry with the package directories, for example `[eas-cli][build-tools]`. +The changeset body must contain exactly one nonempty line. Do not wrap it or add paragraphs or lists. Surrounding blank lines are allowed. Write one short sentence in the style of the existing `CHANGELOG.md` entries. The release adds the PR link and the author, and prefixes the entry with the package directories, for example `[eas-cli][build-tools]`. ## Bump types and changelog sections @@ -34,6 +34,6 @@ All packages share one version. The highest bump in all changesets sets the next `yarn changeset` does not offer `none`. For changes that should not appear in the changelog, add the "no changelog" label to the PR in GitHub. -The "no changelog" label skips the changeset check, but not the `CHANGELOG.md` check. A PR that edits a `CHANGELOG.md` file always fails. Dependabot PRs skip the changeset check. +The "no changelog" label and Dependabot PRs waive the requirement to add a changeset. Any supplied changesets are still validated. Generated `CHANGELOG.md` text is protected; only blank-line repairs are allowed. See [RELEASING.md](../RELEASING.md) for how a release uses the changesets. diff --git a/.changeset/formatEntry.d.mts b/.changeset/formatEntry.d.mts index 10558791c6..bc4cf6e205 100644 --- a/.changeset/formatEntry.d.mts +++ b/.changeset/formatEntry.d.mts @@ -1 +1,2 @@ export declare function formatEntry(summary: string, attribution: string): string; +export declare function validateSummary(summary: string): string; diff --git a/.changeset/formatEntry.mjs b/.changeset/formatEntry.mjs index 0dd03d4359..98bbd707da 100644 --- a/.changeset/formatEntry.mjs +++ b/.changeset/formatEntry.mjs @@ -1,23 +1,26 @@ /** * Formats a changeset summary as the text of a CHANGELOG list item. * - * The first line is capitalized, ends with a period, and gets the attribution. Later lines are - * indented by two spaces, so they stay inside the list item. + * The summary must be one nonempty line. It is capitalized, ends with a period, and gets attribution. * * @param {string} summary The changeset summary. * @param {string} attribution For example `([#123](https://...) by [@user](https://...))`. * @returns {string} The entry text, without the leading `- `. */ export function formatEntry(summary, attribution) { - const [firstLine, ...otherLines] = summary - .trim() - .split('\n') - .map(line => line.trimEnd()); - if (!firstLine) { - throw new Error('The changeset summary is empty. Describe the change below the front matter.'); - } + const firstLine = validateSummary(summary); const capitalizedLine = `${firstLine[0].toUpperCase()}${firstLine.slice(1)}`; const sentence = capitalizedLine.endsWith('.') ? capitalizedLine : `${capitalizedLine}.`; - const indentedLines = otherLines.map(line => (line ? ` ${line}` : '')); - return [`${sentence} ${attribution}`, ...indentedLines].join('\n'); + return `${sentence} ${attribution}`; +} + +/** @param {string} summary @returns {string} The trimmed, single-line summary. */ +export function validateSummary(summary) { + const trimmed = summary.trim(); + if (!trimmed || /[\r\n]/.test(trimmed)) { + throw new Error( + 'The changeset body must contain exactly one nonempty line. Combine the summary into one line below the front matter.' + ); + } + return trimmed; } diff --git a/.changeset/single-line-summaries.md b/.changeset/single-line-summaries.md new file mode 100644 index 0000000000..d2a11f7403 --- /dev/null +++ b/.changeset/single-line-summaries.md @@ -0,0 +1,5 @@ +--- +'eas-cli': none +--- + +Reject changeset bodies with multiple lines in PR checks and before release generation. diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml index 66daf52944..bd462a8dbb 100644 --- a/.github/workflows/changelog.yml +++ b/.github/workflows/changelog.yml @@ -6,6 +6,8 @@ on: types: - opened - synchronize + - reopened + - edited - labeled - unlabeled @@ -22,7 +24,7 @@ jobs: name: Check for a changeset runs-on: ubuntu-latest env: - # Dependabot PRs only update dependencies, so they need no changeset. + # These PRs need no changeset, but any supplied changesets must still be valid. SKIP_CHECK: >- ${{ contains(github.event.pull_request.labels.*.name, 'no changelog') || @@ -34,25 +36,18 @@ jobs: with: fetch-depth: 0 persist-credentials: false - # The "no changelog" label does not skip this step. - - name: Fail if a CHANGELOG.md file was edited - run: | - edited="$(git diff --name-only "$BASE_REF...HEAD" | grep -E '(^|/)CHANGELOG\.md$' || true)" - if [ -n "$edited" ]; then - echo "::error::Do not edit CHANGELOG.md files. The release generates them from changesets. Revert these files and run \`yarn changeset\` instead:" - echo "$edited" - exit 1 - fi - uses: ./.github/actions/setup-mise - if: env.SKIP_CHECK != 'true' - name: Install script dependencies - if: env.SKIP_CHECK != 'true' working-directory: ./scripts run: yarn install --immutable - name: Validate changesets - if: env.SKIP_CHECK != 'true' working-directory: ./scripts - run: yarn validate-changesets "$BASE_REF" + run: | + if [ "$SKIP_CHECK" = 'true' ]; then + yarn validate-changesets "$BASE_REF" --allow-no-changeset + else + yarn validate-changesets "$BASE_REF" + fi - uses: mshick/add-pr-comment@b8f338c590a895d50bcbfa6c5859251edc8952fc # v2.8.2 if: >- always() && @@ -61,7 +56,7 @@ jobs: with: message-id: changelog-entry-check refresh-message-position: true - message: ${{ env.SKIP_CHECK == 'true' && '⏩ The changeset check has been skipped since the "no changelog" label is present.' || '✅ Thank you for adding a changeset!' }} + message: ${{ env.SKIP_CHECK == 'true' && '✅ Changeset validation passed. No new changeset is required.' || '✅ Thank you for adding a changeset!' }} message-failure: | - ❌ This PR needs a valid changeset. Run `yarn changeset`, select the changed packages and the bump type, and commit the new file in `.changeset/`. Do not edit `CHANGELOG.md`: the release generates it. The "Validate changesets" step log lists the problems. - ⏩ If this PR doesn't require a changelog entry, such as if it's an internal change that doesn't affect the user experience, you can add the "no changelog" label to the PR. The label does not allow edits to `CHANGELOG.md` files. + ❌ Changeset validation failed. Each changeset body must contain exactly one nonempty line. Run `yarn changeset` to add an entry. Do not edit generated changelog text; only blank-line repairs are allowed. The "Validate changesets" step log lists the problems. + ⏩ If this PR doesn't require a changelog entry, add the "no changelog" label. Supplied changesets and generated changelog text are still checked. diff --git a/CHANGELOG.md b/CHANGELOG.md index 9306656dce..1d9903d60b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ This is the log of notable changes to EAS CLI and related packages. - [eas-cli] Require explicit `host:` simulator-host upload paths and report missing local files. ([#4683](https://github.com/expo/eas-cli/pull/4683) by [@gwdp](https://github.com/gwdp)) Missing local absolute paths now require the `host:` prefix. Actions that require upload IDs accept local files and reject host paths with an explanation. + - [build-tools] Handle a device session stopped during startup as normal cleanup instead of reporting a startup error. ([#4716](https://github.com/expo/eas-cli/pull/4716) by [@gwdp](https://github.com/gwdp)) - [build-tools] Support idle timeouts for iOS web-preview-only sessions, resetting the timer when preview input arrives. ([#4731](https://github.com/expo/eas-cli/pull/4731) by [@gwdp](https://github.com/gwdp)) diff --git a/scripts/src/changesets/formatEntry.test.ts b/scripts/src/changesets/formatEntry.test.ts index b84ef3f4b1..33f40c62ce 100644 --- a/scripts/src/changesets/formatEntry.test.ts +++ b/scripts/src/changesets/formatEntry.test.ts @@ -19,14 +19,14 @@ describe(formatEntry.name, () => { assert.equal(formatEntry('\n Add a flag \n\n', ATTRIBUTION), `Add a flag. ${ATTRIBUTION}`); }); - it('indents later lines and keeps blank lines', () => { - assert.equal( - formatEntry('Add a flag\n\nDetails here.\n- item', ATTRIBUTION), - `Add a flag. ${ATTRIBUTION}\n\n Details here.\n - item` + it('rejects multiline summaries before generating an entry', () => { + assert.throws( + () => formatEntry('Add a flag\n\nDetails here.\n- item', ATTRIBUTION), + /exactly one nonempty line/ ); }); it('throws on an empty summary', () => { - assert.throws(() => formatEntry(' \n ', ATTRIBUTION), /summary is empty/); + assert.throws(() => formatEntry(' \n ', ATTRIBUTION), /exactly one nonempty line/); }); }); diff --git a/scripts/src/changesets/validation.test.ts b/scripts/src/changesets/validation.test.ts new file mode 100644 index 0000000000..e14c796d04 --- /dev/null +++ b/scripts/src/changesets/validation.test.ts @@ -0,0 +1,90 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { describe, it } from 'node:test'; + +import { hasChangelogContentChanges, validateChangeset } from './validation.js'; + +const FILE = '.changeset/example.md'; +const PACKAGES = new Set(['eas-cli']); +const FRONT_MATTER = "---\n'eas-cli': patch\n---\n\n"; + +describe(validateChangeset.name, () => { + for (const summary of ['Fix a bug.', '\n Fix a bug. \n\n', 'Fix a bug.\r\n']) { + it(`accepts one line with surrounding whitespace: ${JSON.stringify(summary)}`, () => { + assert.deepEqual(validateChangeset(FILE, FRONT_MATTER + summary, PACKAGES), []); + }); + } + + for (const summary of [ + '', + ' \n ', + 'Fix a bug.\nDetails.', + 'Fix a bug.\n\nDetails.', + 'Fix a bug.\r\nDetails.', + 'Fix a bug.\n- Another change.', + ]) { + it(`rejects an empty or multiline body: ${JSON.stringify(summary)}`, () => { + assert.deepEqual(validateChangeset(FILE, FRONT_MATTER + summary, PACKAGES), [ + `${FILE}: The changeset body must contain exactly one nonempty line. Combine the summary into one line below the front matter.`, + ]); + }); + } + + it('accepts a full CRLF changeset', () => { + assert.deepEqual( + validateChangeset(FILE, `${FRONT_MATTER}Fix a bug.\n`.replace(/\n/g, '\r\n'), PACKAGES), + [] + ); + }); + + it('still rejects unknown packages', () => { + assert.match( + validateChangeset(FILE, FRONT_MATTER + 'Fix a bug.', new Set())[0], + /not a package/ + ); + }); + + it('still rejects invalid front matter', () => { + assert.match( + validateChangeset(FILE, '---\ninvalid: [\n---\nFix a bug.', PACKAGES)[0], + /cannot parse/ + ); + }); +}); + +describe(hasChangelogContentChanges.name, () => { + for (const [name, contents, expected] of [ + ['blank-line repair', '# Changelog\n\n- Fix.\n\n- Another fix.\n', false], + ['text edit', '# Changelog\n\n- Different fix.\n- Another fix.\n', true], + [ + 'text edit with blank-line repair', + '# Changelog\n\n- Different fix.\n\n- Another fix.\n', + true, + ], + ] as const) { + it(name, () => { + const cwd = mkdtempSync(path.join(os.tmpdir(), 'changeset-validation-')); + const git = (...args: string[]): string => + execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); + try { + git('init'); + git('config', 'user.name', 'Test'); + git('config', 'user.email', 'test@example.com'); + const file = path.join(cwd, 'CHANGELOG.md'); + writeFileSync(file, '# Changelog\n\n- Fix.\n- Another fix.\n'); + git('add', '.'); + git('commit', '-m', 'Base', '--no-verify'); + const base = git('rev-parse', 'HEAD').trim(); + writeFileSync(file, contents); + git('add', '.'); + git('commit', '-m', 'Change', '--no-verify'); + assert.equal(hasChangelogContentChanges(base, 'CHANGELOG.md', cwd), expected); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }); + } +}); diff --git a/scripts/src/changesets/validation.ts b/scripts/src/changesets/validation.ts new file mode 100644 index 0000000000..49e3e82390 --- /dev/null +++ b/scripts/src/changesets/validation.ts @@ -0,0 +1,39 @@ +import { parseChangesetFile } from '@changesets/parse'; +import { execFileSync } from 'node:child_process'; + +import { validateSummary } from '../../../.changeset/formatEntry.mjs'; + +export function validateChangeset( + file: string, + contents: string, + packageNames: ReadonlySet +): string[] { + let changeset: ReturnType; + try { + changeset = parseChangesetFile(contents); + } catch (error) { + return [`${file}: cannot parse the changeset. ${(error as Error).message}`]; + } + const errors: string[] = []; + try { + validateSummary(changeset.summary); + } catch (error) { + errors.push(`${file}: ${(error as Error).message}`); + } + for (const release of changeset.releases) { + if (!packageNames.has(release.name)) { + errors.push(`${file}: "${release.name}" is not a package in packages/.`); + } + } + return errors; +} + +export function hasChangelogContentChanges(baseRef: string, file: string, cwd: string): boolean { + // Permit blank-line repairs, but keep generated changelog text protected. + return ( + execFileSync('git', ['diff', '--ignore-blank-lines', `${baseRef}...HEAD`, '--', file], { + cwd, + encoding: 'utf8', + }).length > 0 + ); +} diff --git a/scripts/src/validateChangesets.ts b/scripts/src/validateChangesets.ts index f11fd6bb41..d3160831b6 100644 --- a/scripts/src/validateChangesets.ts +++ b/scripts/src/validateChangesets.ts @@ -1,11 +1,11 @@ /* eslint-disable no-console */ -import { parseChangesetFile } from '@changesets/parse'; import { execFileSync } from 'node:child_process'; import { readFile } from 'node:fs/promises'; import path from 'node:path'; import { ROOT_DIR, readPackageDirsByNameAsync } from './changesets/packages.js'; +import { hasChangelogContentChanges, validateChangeset } from './changesets/validation.js'; const CHANGESET_FILE = /^\.changeset\/[^/]+\.md$/; @@ -18,51 +18,32 @@ function getChangedFiles(baseRef: string): string[] { return output.split('\n').filter(Boolean); } -async function validateChangesetAsync( - file: string, - packageNames: ReadonlySet -): Promise { - let changeset: ReturnType; - try { - changeset = parseChangesetFile(await readFile(path.join(ROOT_DIR, file), 'utf8')); - } catch (error) { - return [`${file}: cannot parse the changeset. ${(error as Error).message}`]; - } - const errors: string[] = []; - if (!changeset.summary.trim()) { - errors.push(`${file}: the summary is empty. Describe the change below the front matter.`); - } - for (const release of changeset.releases) { - if (!packageNames.has(release.name)) { - errors.push(`${file}: "${release.name}" is not a package in packages/.`); - } - } - return errors; -} - async function mainAsync(): Promise { const baseRef = process.argv[2]; if (!baseRef) { - throw new Error('Usage: validate-changesets '); + throw new Error('Usage: validate-changesets [--allow-no-changeset]'); } const changedFiles = getChangedFiles(baseRef); const errors: string[] = changedFiles .filter(file => path.basename(file) === 'CHANGELOG.md') + .filter(file => hasChangelogContentChanges(baseRef, file, ROOT_DIR)) .map( file => - `${file}: do not edit CHANGELOG.md files. The release generates them from changesets. Run \`yarn changeset\` instead.` + `${file}: do not edit generated changelog text. Only blank-line repairs are allowed. Run \`yarn changeset\` instead.` ); const changesetFiles = changedFiles.filter( file => CHANGESET_FILE.test(file) && file !== '.changeset/README.md' ); - if (changesetFiles.length === 0) { + if (changesetFiles.length === 0 && !process.argv.includes('--allow-no-changeset')) { errors.push('This PR has no changeset. Run `yarn changeset` and commit the new file.'); } const packageNames = new Set((await readPackageDirsByNameAsync()).keys()); for (const file of changesetFiles) { - errors.push(...(await validateChangesetAsync(file, packageNames))); + errors.push( + ...validateChangeset(file, await readFile(path.join(ROOT_DIR, file), 'utf8'), packageNames) + ); } if (errors.length > 0) { From 5231ea1dec1c0435a064f1be9246a81ef784ef98 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Tue, 27 Oct 2026 15:28:00 -0700 Subject: [PATCH 2/7] Keep changeset validation limited to the PR check --- .changeset/README.md | 6 +- .changeset/formatEntry.d.mts | 1 - .changeset/formatEntry.mjs | 25 +++--- .changeset/single-line-summaries.md | 2 +- .github/workflows/changelog.yml | 29 +++--- CHANGELOG.md | 1 - scripts/src/changesets/formatEntry.test.ts | 10 +-- .../src/changesets/validateChangesets.test.ts | 34 +++++++ scripts/src/changesets/validation.test.ts | 90 ------------------- scripts/src/changesets/validation.ts | 39 -------- scripts/src/validateChangesets.ts | 48 +++++++--- 11 files changed, 107 insertions(+), 178 deletions(-) create mode 100644 scripts/src/changesets/validateChangesets.test.ts delete mode 100644 scripts/src/changesets/validation.test.ts delete mode 100644 scripts/src/changesets/validation.ts diff --git a/.changeset/README.md b/.changeset/README.md index 839c49ed8e..2915d0fec1 100644 --- a/.changeset/README.md +++ b/.changeset/README.md @@ -1,6 +1,6 @@ # Changesets -Every PR that changes a package adds a changeset: a Markdown file in this directory that names the changed packages, the bump type, and a one-line summary for the changelog. Do not edit generated `CHANGELOG.md` text. The release generates it from the changesets; blank-line repairs are allowed. +Every PR that changes a package adds a changeset: a Markdown file in this directory that names the changed packages, the bump type, and a one-line summary for the changelog. Do not edit `CHANGELOG.md` files. The release generates them from the changesets. ## Add a changeset @@ -19,7 +19,7 @@ Select the changed packages and the bump type, then write the summary. Commit th Add the `--foo` flag to `eas build`. ``` -The changeset body must contain exactly one nonempty line. Do not wrap it or add paragraphs or lists. Surrounding blank lines are allowed. Write one short sentence in the style of the existing `CHANGELOG.md` entries. The release adds the PR link and the author, and prefixes the entry with the package directories, for example `[eas-cli][build-tools]`. +Write the summary as one sentence in the style of the existing `CHANGELOG.md` entries. The release adds the PR link and the author, and prefixes the entry with the package directories, for example `[eas-cli][build-tools]`. ## Bump types and changelog sections @@ -34,6 +34,6 @@ All packages share one version. The highest bump in all changesets sets the next `yarn changeset` does not offer `none`. For changes that should not appear in the changelog, add the "no changelog" label to the PR in GitHub. -The "no changelog" label and Dependabot PRs waive the requirement to add a changeset. Any supplied changesets are still validated. Generated `CHANGELOG.md` text is protected; only blank-line repairs are allowed. +The "no changelog" label skips the changeset check, but not the `CHANGELOG.md` check. A PR that edits a `CHANGELOG.md` file always fails. Dependabot PRs skip the changeset check. See [RELEASING.md](../RELEASING.md) for how a release uses the changesets. diff --git a/.changeset/formatEntry.d.mts b/.changeset/formatEntry.d.mts index bc4cf6e205..10558791c6 100644 --- a/.changeset/formatEntry.d.mts +++ b/.changeset/formatEntry.d.mts @@ -1,2 +1 @@ export declare function formatEntry(summary: string, attribution: string): string; -export declare function validateSummary(summary: string): string; diff --git a/.changeset/formatEntry.mjs b/.changeset/formatEntry.mjs index 98bbd707da..0dd03d4359 100644 --- a/.changeset/formatEntry.mjs +++ b/.changeset/formatEntry.mjs @@ -1,26 +1,23 @@ /** * Formats a changeset summary as the text of a CHANGELOG list item. * - * The summary must be one nonempty line. It is capitalized, ends with a period, and gets attribution. + * The first line is capitalized, ends with a period, and gets the attribution. Later lines are + * indented by two spaces, so they stay inside the list item. * * @param {string} summary The changeset summary. * @param {string} attribution For example `([#123](https://...) by [@user](https://...))`. * @returns {string} The entry text, without the leading `- `. */ export function formatEntry(summary, attribution) { - const firstLine = validateSummary(summary); + const [firstLine, ...otherLines] = summary + .trim() + .split('\n') + .map(line => line.trimEnd()); + if (!firstLine) { + throw new Error('The changeset summary is empty. Describe the change below the front matter.'); + } const capitalizedLine = `${firstLine[0].toUpperCase()}${firstLine.slice(1)}`; const sentence = capitalizedLine.endsWith('.') ? capitalizedLine : `${capitalizedLine}.`; - return `${sentence} ${attribution}`; -} - -/** @param {string} summary @returns {string} The trimmed, single-line summary. */ -export function validateSummary(summary) { - const trimmed = summary.trim(); - if (!trimmed || /[\r\n]/.test(trimmed)) { - throw new Error( - 'The changeset body must contain exactly one nonempty line. Combine the summary into one line below the front matter.' - ); - } - return trimmed; + const indentedLines = otherLines.map(line => (line ? ` ${line}` : '')); + return [`${sentence} ${attribution}`, ...indentedLines].join('\n'); } diff --git a/.changeset/single-line-summaries.md b/.changeset/single-line-summaries.md index d2a11f7403..9e128ef9a2 100644 --- a/.changeset/single-line-summaries.md +++ b/.changeset/single-line-summaries.md @@ -2,4 +2,4 @@ 'eas-cli': none --- -Reject changeset bodies with multiple lines in PR checks and before release generation. +Reject changeset bodies with multiple lines in PR checks. diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml index bd462a8dbb..66daf52944 100644 --- a/.github/workflows/changelog.yml +++ b/.github/workflows/changelog.yml @@ -6,8 +6,6 @@ on: types: - opened - synchronize - - reopened - - edited - labeled - unlabeled @@ -24,7 +22,7 @@ jobs: name: Check for a changeset runs-on: ubuntu-latest env: - # These PRs need no changeset, but any supplied changesets must still be valid. + # Dependabot PRs only update dependencies, so they need no changeset. SKIP_CHECK: >- ${{ contains(github.event.pull_request.labels.*.name, 'no changelog') || @@ -36,18 +34,25 @@ jobs: with: fetch-depth: 0 persist-credentials: false + # The "no changelog" label does not skip this step. + - name: Fail if a CHANGELOG.md file was edited + run: | + edited="$(git diff --name-only "$BASE_REF...HEAD" | grep -E '(^|/)CHANGELOG\.md$' || true)" + if [ -n "$edited" ]; then + echo "::error::Do not edit CHANGELOG.md files. The release generates them from changesets. Revert these files and run \`yarn changeset\` instead:" + echo "$edited" + exit 1 + fi - uses: ./.github/actions/setup-mise + if: env.SKIP_CHECK != 'true' - name: Install script dependencies + if: env.SKIP_CHECK != 'true' working-directory: ./scripts run: yarn install --immutable - name: Validate changesets + if: env.SKIP_CHECK != 'true' working-directory: ./scripts - run: | - if [ "$SKIP_CHECK" = 'true' ]; then - yarn validate-changesets "$BASE_REF" --allow-no-changeset - else - yarn validate-changesets "$BASE_REF" - fi + run: yarn validate-changesets "$BASE_REF" - uses: mshick/add-pr-comment@b8f338c590a895d50bcbfa6c5859251edc8952fc # v2.8.2 if: >- always() && @@ -56,7 +61,7 @@ jobs: with: message-id: changelog-entry-check refresh-message-position: true - message: ${{ env.SKIP_CHECK == 'true' && '✅ Changeset validation passed. No new changeset is required.' || '✅ Thank you for adding a changeset!' }} + message: ${{ env.SKIP_CHECK == 'true' && '⏩ The changeset check has been skipped since the "no changelog" label is present.' || '✅ Thank you for adding a changeset!' }} message-failure: | - ❌ Changeset validation failed. Each changeset body must contain exactly one nonempty line. Run `yarn changeset` to add an entry. Do not edit generated changelog text; only blank-line repairs are allowed. The "Validate changesets" step log lists the problems. - ⏩ If this PR doesn't require a changelog entry, add the "no changelog" label. Supplied changesets and generated changelog text are still checked. + ❌ This PR needs a valid changeset. Run `yarn changeset`, select the changed packages and the bump type, and commit the new file in `.changeset/`. Do not edit `CHANGELOG.md`: the release generates it. The "Validate changesets" step log lists the problems. + ⏩ If this PR doesn't require a changelog entry, such as if it's an internal change that doesn't affect the user experience, you can add the "no changelog" label to the PR. The label does not allow edits to `CHANGELOG.md` files. diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d9903d60b..9306656dce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,7 +26,6 @@ This is the log of notable changes to EAS CLI and related packages. - [eas-cli] Require explicit `host:` simulator-host upload paths and report missing local files. ([#4683](https://github.com/expo/eas-cli/pull/4683) by [@gwdp](https://github.com/gwdp)) Missing local absolute paths now require the `host:` prefix. Actions that require upload IDs accept local files and reject host paths with an explanation. - - [build-tools] Handle a device session stopped during startup as normal cleanup instead of reporting a startup error. ([#4716](https://github.com/expo/eas-cli/pull/4716) by [@gwdp](https://github.com/gwdp)) - [build-tools] Support idle timeouts for iOS web-preview-only sessions, resetting the timer when preview input arrives. ([#4731](https://github.com/expo/eas-cli/pull/4731) by [@gwdp](https://github.com/gwdp)) diff --git a/scripts/src/changesets/formatEntry.test.ts b/scripts/src/changesets/formatEntry.test.ts index 33f40c62ce..b84ef3f4b1 100644 --- a/scripts/src/changesets/formatEntry.test.ts +++ b/scripts/src/changesets/formatEntry.test.ts @@ -19,14 +19,14 @@ describe(formatEntry.name, () => { assert.equal(formatEntry('\n Add a flag \n\n', ATTRIBUTION), `Add a flag. ${ATTRIBUTION}`); }); - it('rejects multiline summaries before generating an entry', () => { - assert.throws( - () => formatEntry('Add a flag\n\nDetails here.\n- item', ATTRIBUTION), - /exactly one nonempty line/ + it('indents later lines and keeps blank lines', () => { + assert.equal( + formatEntry('Add a flag\n\nDetails here.\n- item', ATTRIBUTION), + `Add a flag. ${ATTRIBUTION}\n\n Details here.\n - item` ); }); it('throws on an empty summary', () => { - assert.throws(() => formatEntry(' \n ', ATTRIBUTION), /exactly one nonempty line/); + assert.throws(() => formatEntry(' \n ', ATTRIBUTION), /summary is empty/); }); }); diff --git a/scripts/src/changesets/validateChangesets.test.ts b/scripts/src/changesets/validateChangesets.test.ts new file mode 100644 index 0000000000..3154cd3ec3 --- /dev/null +++ b/scripts/src/changesets/validateChangesets.test.ts @@ -0,0 +1,34 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { it } from 'node:test'; + +import { validateChangesetAsync } from '../validateChangesets.js'; +import { ROOT_DIR } from './packages.js'; + +it('requires one nonempty summary line while allowing surrounding whitespace', async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), 'changeset-summary-')); + const file = path.relative(ROOT_DIR, path.join(dir, 'summary.md')); + try { + for (const [summary, valid] of [ + ['Fix a bug.', true], + ['\nFix a bug.\r\n\n', true], + ['', false], + ['Fix a bug.\nDetails.', false], + ['Fix a bug.\n\nDetails.', false], + ['Fix a bug.\r\nDetails.', false], + ] as const) { + await writeFile(path.join(ROOT_DIR, file), `---\n'eas-cli': patch\n---\n\n${summary}`); + const errors = await validateChangesetAsync(file, new Set(['eas-cli'])); + if (valid) { + assert.deepEqual(errors, []); + } else { + assert.equal(errors.length, 1); + assert.match(errors[0], summary ? /exactly one nonempty line/ : /summary is empty/); + } + } + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/scripts/src/changesets/validation.test.ts b/scripts/src/changesets/validation.test.ts deleted file mode 100644 index e14c796d04..0000000000 --- a/scripts/src/changesets/validation.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import assert from 'node:assert/strict'; -import { execFileSync } from 'node:child_process'; -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { describe, it } from 'node:test'; - -import { hasChangelogContentChanges, validateChangeset } from './validation.js'; - -const FILE = '.changeset/example.md'; -const PACKAGES = new Set(['eas-cli']); -const FRONT_MATTER = "---\n'eas-cli': patch\n---\n\n"; - -describe(validateChangeset.name, () => { - for (const summary of ['Fix a bug.', '\n Fix a bug. \n\n', 'Fix a bug.\r\n']) { - it(`accepts one line with surrounding whitespace: ${JSON.stringify(summary)}`, () => { - assert.deepEqual(validateChangeset(FILE, FRONT_MATTER + summary, PACKAGES), []); - }); - } - - for (const summary of [ - '', - ' \n ', - 'Fix a bug.\nDetails.', - 'Fix a bug.\n\nDetails.', - 'Fix a bug.\r\nDetails.', - 'Fix a bug.\n- Another change.', - ]) { - it(`rejects an empty or multiline body: ${JSON.stringify(summary)}`, () => { - assert.deepEqual(validateChangeset(FILE, FRONT_MATTER + summary, PACKAGES), [ - `${FILE}: The changeset body must contain exactly one nonempty line. Combine the summary into one line below the front matter.`, - ]); - }); - } - - it('accepts a full CRLF changeset', () => { - assert.deepEqual( - validateChangeset(FILE, `${FRONT_MATTER}Fix a bug.\n`.replace(/\n/g, '\r\n'), PACKAGES), - [] - ); - }); - - it('still rejects unknown packages', () => { - assert.match( - validateChangeset(FILE, FRONT_MATTER + 'Fix a bug.', new Set())[0], - /not a package/ - ); - }); - - it('still rejects invalid front matter', () => { - assert.match( - validateChangeset(FILE, '---\ninvalid: [\n---\nFix a bug.', PACKAGES)[0], - /cannot parse/ - ); - }); -}); - -describe(hasChangelogContentChanges.name, () => { - for (const [name, contents, expected] of [ - ['blank-line repair', '# Changelog\n\n- Fix.\n\n- Another fix.\n', false], - ['text edit', '# Changelog\n\n- Different fix.\n- Another fix.\n', true], - [ - 'text edit with blank-line repair', - '# Changelog\n\n- Different fix.\n\n- Another fix.\n', - true, - ], - ] as const) { - it(name, () => { - const cwd = mkdtempSync(path.join(os.tmpdir(), 'changeset-validation-')); - const git = (...args: string[]): string => - execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); - try { - git('init'); - git('config', 'user.name', 'Test'); - git('config', 'user.email', 'test@example.com'); - const file = path.join(cwd, 'CHANGELOG.md'); - writeFileSync(file, '# Changelog\n\n- Fix.\n- Another fix.\n'); - git('add', '.'); - git('commit', '-m', 'Base', '--no-verify'); - const base = git('rev-parse', 'HEAD').trim(); - writeFileSync(file, contents); - git('add', '.'); - git('commit', '-m', 'Change', '--no-verify'); - assert.equal(hasChangelogContentChanges(base, 'CHANGELOG.md', cwd), expected); - } finally { - rmSync(cwd, { recursive: true, force: true }); - } - }); - } -}); diff --git a/scripts/src/changesets/validation.ts b/scripts/src/changesets/validation.ts deleted file mode 100644 index 49e3e82390..0000000000 --- a/scripts/src/changesets/validation.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { parseChangesetFile } from '@changesets/parse'; -import { execFileSync } from 'node:child_process'; - -import { validateSummary } from '../../../.changeset/formatEntry.mjs'; - -export function validateChangeset( - file: string, - contents: string, - packageNames: ReadonlySet -): string[] { - let changeset: ReturnType; - try { - changeset = parseChangesetFile(contents); - } catch (error) { - return [`${file}: cannot parse the changeset. ${(error as Error).message}`]; - } - const errors: string[] = []; - try { - validateSummary(changeset.summary); - } catch (error) { - errors.push(`${file}: ${(error as Error).message}`); - } - for (const release of changeset.releases) { - if (!packageNames.has(release.name)) { - errors.push(`${file}: "${release.name}" is not a package in packages/.`); - } - } - return errors; -} - -export function hasChangelogContentChanges(baseRef: string, file: string, cwd: string): boolean { - // Permit blank-line repairs, but keep generated changelog text protected. - return ( - execFileSync('git', ['diff', '--ignore-blank-lines', `${baseRef}...HEAD`, '--', file], { - cwd, - encoding: 'utf8', - }).length > 0 - ); -} diff --git a/scripts/src/validateChangesets.ts b/scripts/src/validateChangesets.ts index d3160831b6..738f46a6a4 100644 --- a/scripts/src/validateChangesets.ts +++ b/scripts/src/validateChangesets.ts @@ -1,11 +1,12 @@ /* eslint-disable no-console */ +import { parseChangesetFile } from '@changesets/parse'; import { execFileSync } from 'node:child_process'; import { readFile } from 'node:fs/promises'; import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { ROOT_DIR, readPackageDirsByNameAsync } from './changesets/packages.js'; -import { hasChangelogContentChanges, validateChangeset } from './changesets/validation.js'; const CHANGESET_FILE = /^\.changeset\/[^/]+\.md$/; @@ -18,32 +19,53 @@ function getChangedFiles(baseRef: string): string[] { return output.split('\n').filter(Boolean); } +export async function validateChangesetAsync( + file: string, + packageNames: ReadonlySet +): Promise { + let changeset: ReturnType; + try { + changeset = parseChangesetFile(await readFile(path.join(ROOT_DIR, file), 'utf8')); + } catch (error) { + return [`${file}: cannot parse the changeset. ${(error as Error).message}`]; + } + const errors: string[] = []; + if (!changeset.summary.trim()) { + errors.push(`${file}: the summary is empty. Describe the change below the front matter.`); + } else if (/[\r\n]/.test(changeset.summary.trim())) { + errors.push(`${file}: the changeset body must contain exactly one nonempty line.`); + } + for (const release of changeset.releases) { + if (!packageNames.has(release.name)) { + errors.push(`${file}: "${release.name}" is not a package in packages/.`); + } + } + return errors; +} + async function mainAsync(): Promise { const baseRef = process.argv[2]; if (!baseRef) { - throw new Error('Usage: validate-changesets [--allow-no-changeset]'); + throw new Error('Usage: validate-changesets '); } const changedFiles = getChangedFiles(baseRef); const errors: string[] = changedFiles .filter(file => path.basename(file) === 'CHANGELOG.md') - .filter(file => hasChangelogContentChanges(baseRef, file, ROOT_DIR)) .map( file => - `${file}: do not edit generated changelog text. Only blank-line repairs are allowed. Run \`yarn changeset\` instead.` + `${file}: do not edit CHANGELOG.md files. The release generates them from changesets. Run \`yarn changeset\` instead.` ); const changesetFiles = changedFiles.filter( file => CHANGESET_FILE.test(file) && file !== '.changeset/README.md' ); - if (changesetFiles.length === 0 && !process.argv.includes('--allow-no-changeset')) { + if (changesetFiles.length === 0) { errors.push('This PR has no changeset. Run `yarn changeset` and commit the new file.'); } const packageNames = new Set((await readPackageDirsByNameAsync()).keys()); for (const file of changesetFiles) { - errors.push( - ...validateChangeset(file, await readFile(path.join(ROOT_DIR, file), 'utf8'), packageNames) - ); + errors.push(...(await validateChangesetAsync(file, packageNames))); } if (errors.length > 0) { @@ -55,7 +77,9 @@ async function mainAsync(): Promise { console.log(`✅ Found ${changesetFiles.length} valid changeset(s): ${changesetFiles.join(', ')}`); } -mainAsync().catch(error => { - console.error(error); - process.exit(1); -}); +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + mainAsync().catch(error => { + console.error(error); + process.exit(1); + }); +} From c4e529576bad851537fe5da746bb4c5df87d3a68 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Tue, 27 Oct 2026 15:30:10 -0700 Subject: [PATCH 3/7] Limit the change to rejecting multiline summaries --- .changeset/single-line-summaries.md | 5 --- .../src/changesets/validateChangesets.test.ts | 34 ------------------- scripts/src/validateChangesets.ts | 13 +++---- 3 files changed, 5 insertions(+), 47 deletions(-) delete mode 100644 .changeset/single-line-summaries.md delete mode 100644 scripts/src/changesets/validateChangesets.test.ts diff --git a/.changeset/single-line-summaries.md b/.changeset/single-line-summaries.md deleted file mode 100644 index 9e128ef9a2..0000000000 --- a/.changeset/single-line-summaries.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'eas-cli': none ---- - -Reject changeset bodies with multiple lines in PR checks. diff --git a/scripts/src/changesets/validateChangesets.test.ts b/scripts/src/changesets/validateChangesets.test.ts deleted file mode 100644 index 3154cd3ec3..0000000000 --- a/scripts/src/changesets/validateChangesets.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import assert from 'node:assert/strict'; -import { mkdtemp, rm, writeFile } from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { it } from 'node:test'; - -import { validateChangesetAsync } from '../validateChangesets.js'; -import { ROOT_DIR } from './packages.js'; - -it('requires one nonempty summary line while allowing surrounding whitespace', async () => { - const dir = await mkdtemp(path.join(os.tmpdir(), 'changeset-summary-')); - const file = path.relative(ROOT_DIR, path.join(dir, 'summary.md')); - try { - for (const [summary, valid] of [ - ['Fix a bug.', true], - ['\nFix a bug.\r\n\n', true], - ['', false], - ['Fix a bug.\nDetails.', false], - ['Fix a bug.\n\nDetails.', false], - ['Fix a bug.\r\nDetails.', false], - ] as const) { - await writeFile(path.join(ROOT_DIR, file), `---\n'eas-cli': patch\n---\n\n${summary}`); - const errors = await validateChangesetAsync(file, new Set(['eas-cli'])); - if (valid) { - assert.deepEqual(errors, []); - } else { - assert.equal(errors.length, 1); - assert.match(errors[0], summary ? /exactly one nonempty line/ : /summary is empty/); - } - } - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); diff --git a/scripts/src/validateChangesets.ts b/scripts/src/validateChangesets.ts index 738f46a6a4..3ec44248ec 100644 --- a/scripts/src/validateChangesets.ts +++ b/scripts/src/validateChangesets.ts @@ -4,7 +4,6 @@ import { parseChangesetFile } from '@changesets/parse'; import { execFileSync } from 'node:child_process'; import { readFile } from 'node:fs/promises'; import path from 'node:path'; -import { fileURLToPath } from 'node:url'; import { ROOT_DIR, readPackageDirsByNameAsync } from './changesets/packages.js'; @@ -19,7 +18,7 @@ function getChangedFiles(baseRef: string): string[] { return output.split('\n').filter(Boolean); } -export async function validateChangesetAsync( +async function validateChangesetAsync( file: string, packageNames: ReadonlySet ): Promise { @@ -77,9 +76,7 @@ async function mainAsync(): Promise { console.log(`✅ Found ${changesetFiles.length} valid changeset(s): ${changesetFiles.join(', ')}`); } -if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { - mainAsync().catch(error => { - console.error(error); - process.exit(1); - }); -} +mainAsync().catch(error => { + console.error(error); + process.exit(1); +}); From 2f30c21f1dd029cdedab2d17f11c3563092cf707 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Tue, 27 Oct 2026 15:30:50 -0700 Subject: [PATCH 4/7] Remove the extra changelog paragraph --- CHANGELOG.md | 2 -- packages/eas-cli/CHANGELOG.md | 2 -- 2 files changed, 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9306656dce..0d1f63fcbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,8 +24,6 @@ This is the log of notable changes to EAS CLI and related packages. - [build-tools] Run apsd setup in the background without delaying iOS app launch or preview readiness. ([#4706](https://github.com/expo/eas-cli/pull/4706) by [@gwdp](https://github.com/gwdp)) - [eas-cli] Fix simulator control action help routing and show help when a flag is incomplete. ([#4681](https://github.com/expo/eas-cli/pull/4681) by [@gwdp](https://github.com/gwdp)) - [eas-cli] Require explicit `host:` simulator-host upload paths and report missing local files. ([#4683](https://github.com/expo/eas-cli/pull/4683) by [@gwdp](https://github.com/gwdp)) - - Missing local absolute paths now require the `host:` prefix. Actions that require upload IDs accept local files and reject host paths with an explanation. - [build-tools] Handle a device session stopped during startup as normal cleanup instead of reporting a startup error. ([#4716](https://github.com/expo/eas-cli/pull/4716) by [@gwdp](https://github.com/gwdp)) - [build-tools] Support idle timeouts for iOS web-preview-only sessions, resetting the timer when preview input arrives. ([#4731](https://github.com/expo/eas-cli/pull/4731) by [@gwdp](https://github.com/gwdp)) diff --git a/packages/eas-cli/CHANGELOG.md b/packages/eas-cli/CHANGELOG.md index 777c4974d6..9249f26616 100644 --- a/packages/eas-cli/CHANGELOG.md +++ b/packages/eas-cli/CHANGELOG.md @@ -19,8 +19,6 @@ - Fix simulator control action help routing and show help when a flag is incomplete. ([#4681](https://github.com/expo/eas-cli/pull/4681) by [@gwdp](https://github.com/gwdp)) - Require explicit `host:` simulator-host upload paths and report missing local files. ([#4683](https://github.com/expo/eas-cli/pull/4683) by [@gwdp](https://github.com/gwdp)) - Missing local absolute paths now require the `host:` prefix. Actions that require upload IDs accept local files and reject host paths with an explanation. - ## 26.0.0 ### Patch Changes From 8bc9e02f9993c28a13fd19160b29b1f9490806ff Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Tue, 27 Oct 2026 15:32:00 -0700 Subject: [PATCH 5/7] Add a patch changeset for the changelog repair --- .changeset/fix-simulator-paths-changelog.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fix-simulator-paths-changelog.md diff --git a/.changeset/fix-simulator-paths-changelog.md b/.changeset/fix-simulator-paths-changelog.md new file mode 100644 index 0000000000..20846c3208 --- /dev/null +++ b/.changeset/fix-simulator-paths-changelog.md @@ -0,0 +1,5 @@ +--- +'eas-cli': patch +--- + +Fix changelog formatting for simulator control upload paths. From d9fdc24eaef7e618b2dd28c13d069800ead443c0 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Tue, 27 Oct 2026 16:34:55 -0700 Subject: [PATCH 6/7] Format the generated root changelog before release --- .changeset/fix-simulator-paths-changelog.md | 2 +- CHANGELOG.md | 2 ++ packages/eas-cli/CHANGELOG.md | 2 ++ scripts/src/validateChangesets.ts | 2 -- scripts/src/versionPackages.ts | 1 + 5 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.changeset/fix-simulator-paths-changelog.md b/.changeset/fix-simulator-paths-changelog.md index 20846c3208..2008f92aee 100644 --- a/.changeset/fix-simulator-paths-changelog.md +++ b/.changeset/fix-simulator-paths-changelog.md @@ -2,4 +2,4 @@ 'eas-cli': patch --- -Fix changelog formatting for simulator control upload paths. +Format the generated root changelog before committing a release. diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d1f63fcbe..9306656dce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,8 @@ This is the log of notable changes to EAS CLI and related packages. - [build-tools] Run apsd setup in the background without delaying iOS app launch or preview readiness. ([#4706](https://github.com/expo/eas-cli/pull/4706) by [@gwdp](https://github.com/gwdp)) - [eas-cli] Fix simulator control action help routing and show help when a flag is incomplete. ([#4681](https://github.com/expo/eas-cli/pull/4681) by [@gwdp](https://github.com/gwdp)) - [eas-cli] Require explicit `host:` simulator-host upload paths and report missing local files. ([#4683](https://github.com/expo/eas-cli/pull/4683) by [@gwdp](https://github.com/gwdp)) + + Missing local absolute paths now require the `host:` prefix. Actions that require upload IDs accept local files and reject host paths with an explanation. - [build-tools] Handle a device session stopped during startup as normal cleanup instead of reporting a startup error. ([#4716](https://github.com/expo/eas-cli/pull/4716) by [@gwdp](https://github.com/gwdp)) - [build-tools] Support idle timeouts for iOS web-preview-only sessions, resetting the timer when preview input arrives. ([#4731](https://github.com/expo/eas-cli/pull/4731) by [@gwdp](https://github.com/gwdp)) diff --git a/packages/eas-cli/CHANGELOG.md b/packages/eas-cli/CHANGELOG.md index 9249f26616..777c4974d6 100644 --- a/packages/eas-cli/CHANGELOG.md +++ b/packages/eas-cli/CHANGELOG.md @@ -19,6 +19,8 @@ - Fix simulator control action help routing and show help when a flag is incomplete. ([#4681](https://github.com/expo/eas-cli/pull/4681) by [@gwdp](https://github.com/gwdp)) - Require explicit `host:` simulator-host upload paths and report missing local files. ([#4683](https://github.com/expo/eas-cli/pull/4683) by [@gwdp](https://github.com/gwdp)) + Missing local absolute paths now require the `host:` prefix. Actions that require upload IDs accept local files and reject host paths with an explanation. + ## 26.0.0 ### Patch Changes diff --git a/scripts/src/validateChangesets.ts b/scripts/src/validateChangesets.ts index 3ec44248ec..f11fd6bb41 100644 --- a/scripts/src/validateChangesets.ts +++ b/scripts/src/validateChangesets.ts @@ -31,8 +31,6 @@ async function validateChangesetAsync( const errors: string[] = []; if (!changeset.summary.trim()) { errors.push(`${file}: the summary is empty. Describe the change below the front matter.`); - } else if (/[\r\n]/.test(changeset.summary.trim())) { - errors.push(`${file}: the changeset body must contain exactly one nonempty line.`); } for (const release of changeset.releases) { if (!packageNames.has(release.name)) { diff --git a/scripts/src/versionPackages.ts b/scripts/src/versionPackages.ts index 72d10ac480..20b8462fd1 100644 --- a/scripts/src/versionPackages.ts +++ b/scripts/src/versionPackages.ts @@ -85,6 +85,7 @@ async function mainAsync(): Promise { runWithOutput('yarn', ['install', '--mode=update-lockfile']); runWithOutput('yarn', ['workspace', 'eas-cli', 'run', 'version']); await updateRootChangelogAsync(section); + runWithOutput('yarn', ['exec', 'oxfmt', 'CHANGELOG.md']); const tag = `v${version}`; if (!isPublishingRun()) { From 6c2342b4a69643a6cba3c0791ac8463964528ee4 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Tue, 27 Oct 2026 16:40:02 -0700 Subject: [PATCH 7/7] Fix existing root changelog formatting --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9306656dce..1d9903d60b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ This is the log of notable changes to EAS CLI and related packages. - [eas-cli] Require explicit `host:` simulator-host upload paths and report missing local files. ([#4683](https://github.com/expo/eas-cli/pull/4683) by [@gwdp](https://github.com/gwdp)) Missing local absolute paths now require the `host:` prefix. Actions that require upload IDs accept local files and reject host paths with an explanation. + - [build-tools] Handle a device session stopped during startup as normal cleanup instead of reporting a startup error. ([#4716](https://github.com/expo/eas-cli/pull/4716) by [@gwdp](https://github.com/gwdp)) - [build-tools] Support idle timeouts for iOS web-preview-only sessions, resetting the timer when preview input arrives. ([#4731](https://github.com/expo/eas-cli/pull/4731) by [@gwdp](https://github.com/gwdp))