diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml index 70f688971c..438843d816 100644 --- a/.github/workflows/documentation.yml +++ b/.github/workflows/documentation.yml @@ -221,18 +221,35 @@ jobs: # meta.agent.js always rewrites .github/metrics/meta-metrics.json # (fresh timestamp), even when no content changed. Only proceed # when at least one file other than the metrics file changed. - if git status --porcelain -- . ':(exclude).github/metrics/meta-metrics.json' | grep -q .; then + # + # `.github/workflows/` is excluded for the same reason the create-pull-request + # steps below exclude it from `add-paths`: GitHub treats a file directly in + # that directory as a workflow definition, so the App token (least privilege, + # no `workflows` permission) cannot push a change to it. Excluding it here + # keeps the "did anything change?" decision consistent with what the commit + # can actually contain, so a push whose only touched file was a workflow does + # not report has_changes=true on the strength of a file the push could not + # have made. + if git status --porcelain -- . ':(exclude).github/metrics/meta-metrics.json' ':(exclude).github/workflows' ':(exclude).github/workflows/**' | grep -q .; then echo "has_changes=true" >> "$GITHUB_OUTPUT" else git checkout -- .github/metrics/meta-metrics.json 2>/dev/null || true echo "has_changes=false" >> "$GITHUB_OUTPUT" - echo "No content changes (metrics only); skipping README PR." + echo "No content changes (metrics only, or .github/workflows/ only); skipping README PR." fi - name: Generate GitHub App token # App token (not GITHUB_TOKEN) so the regeneration PR triggers CI and # can satisfy develop's required checks. Requires repo/org secrets: # BOT_PR_APP_CLIENT_ID and BOT_PR_APP_PRIVATE_KEY. + # + # `permission-workflows` is intentionally absent. The App stays at least + # privilege, and `.github/workflows/` is kept out of the bot's reach + # instead: resolve-readme-files.cjs never returns a README under that + # directory, and both create-pull-request steps below exclude it from + # `add-paths`. Do not add `permission-workflows` to work around a + # regeneration failure -- a failure means a workflow file leaked into + # the commit, which is the bug, not the missing permission. id: app-token if: github.event_name != 'pull_request' && steps.readmes.outputs.files != '' && steps.content.outputs.has_changes == 'true' uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 @@ -251,6 +268,23 @@ jobs: branch: chore/readme-regeneration base: develop commit-message: 'chore(docs): regenerate impacted README files' + # Defence in depth. resolve-readme-files.cjs already refuses to emit a README + # under `.github/workflows/`, but this step is the last gate before the push: + # GitHub classifies a file directly in that directory as a workflow -- even a + # plain Markdown one -- and rejects the push unless the token carries + # `workflows` permission, which this App deliberately does not have. + # `add-paths` restricts staging to the repository minus that directory, so + # nothing under it can reach the commit even if the resolver regresses or + # meta.agent.js rewrites a file some other way. + # + # The pathspecs are unquoted on purpose. The action passes each comma/newline + # separated token straight to git as an argv element (no shell), so quotes + # would become part of the pathspec and git fails with + # "pathspec '':(exclude)...'' did not match any files". Verified against + # peter-evans/create-pull-request@5f6978f. + add-paths: | + . + :(exclude).github/workflows title: 'chore(docs): regenerate impacted README files' body: | Automated README regeneration, Dependabot-style. @@ -540,12 +574,32 @@ jobs: - name: Check for changes id: changes - run: test "$(git status --porcelain | wc -l)" -gt 0 && echo "has_changes=true" >> "$GITHUB_OUTPUT" || echo "has_changes=false" >> "$GITHUB_OUTPUT" + # `.github/workflows/` is excluded for the same reason the regenerate job + # excludes it: GitHub treats a file directly in that directory as a + # workflow and the App token cannot push one. Note this job always + # rewrites the tracked .github/reports/mermaid-audit/maintenance-report.md + # with a fresh timestamp, so in practice this reads true regardless; the + # load-bearing guard against a workflow file reaching the commit is the + # `add-paths` on the create-pull-request step below. + run: | + if git status --porcelain -- . ':(exclude).github/workflows' ':(exclude).github/workflows/**' | grep -q .; then + echo "has_changes=true" >> "$GITHUB_OUTPUT" + else + echo "has_changes=false" >> "$GITHUB_OUTPUT" + fi - name: Generate GitHub App token # App token (not GITHUB_TOKEN) so the maintenance PR triggers CI and # can satisfy develop's required checks. Requires repo/org secrets: # BOT_PR_APP_CLIENT_ID and BOT_PR_APP_PRIVATE_KEY. + # + # `permission-workflows` is intentionally absent, for the same reason as in + # the regenerate job above: the App stays at least privilege and + # `.github/workflows/` is excluded from the commit instead. This job runs + # repository-wide fixers (Mermaid, staleness) and inherited the identical + # latent flaw, so it carries the same guard. Do not add + # `permission-workflows`; a push failure here means a workflow file leaked + # into the commit. id: app-token if: ${{ steps.changes.outputs.has_changes == 'true' && env.DRY_RUN == 'false' }} uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 @@ -564,6 +618,14 @@ jobs: branch: chore/docs-maintenance base: develop commit-message: 'docs: maintenance updates (Mermaid + staleness)' + # Same last-gate guard as the regenerate job: keep anything under + # `.github/workflows/` out of the commit, because this App token has no + # `workflows` permission and GitHub would refuse the push. Unquoted + # pathspecs are required -- the action passes each token to git as an + # argv element with no shell involved. See the regenerate job above. + add-paths: | + . + :(exclude).github/workflows title: 'docs: maintenance updates (Mermaid + staleness)' body: | Automated documentation maintenance, Dependabot-style. diff --git a/CHANGELOG.md b/CHANGELOG.md index 09e63a8e92..35264dca1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,6 +72,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Docs Bot Skips Workflows Directory** — Regeneration skips `.github/workflows/`, so its pushes stop failing for want of App token `workflows` permission. (#3687) - **Merged Advisory and Spec Defects Corrected** — The advisories newsletter form survives a failed request, tells a blocked address from a fault and counts its rate limit atomically; spec 018 defers auto-approved branch deletion. (#1396) - **Docs-Bot Changelog Exemption** — The gate now matches the docs bot in either login shape, so its exemption applies. Bot pull requests with no user-facing content no longer need a changelog entry. (#3603, #3448) - **Semantic-Version Release Branches** — Branch checks in CI and the npm script now accept documented release names such as `release/v1.2.3` and `release/v1.2.3-rc1`. ([#3555](https://github.com/lightspeedwp/.github/issues/3555)) diff --git a/scripts/workflows/__tests__/resolve-readme-files.test.js b/scripts/workflows/__tests__/resolve-readme-files.test.js new file mode 100644 index 0000000000..0890a9d3e0 --- /dev/null +++ b/scripts/workflows/__tests__/resolve-readme-files.test.js @@ -0,0 +1,316 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const resolver = require("../resolve-readme-files.cjs"); + +const { + resolveReadmeFiles, +} = resolver; + +/** + * Regression coverage for the README regeneration bot staging + * `.github/workflows/README.md`. + * + * GitHub treats ANY path under `.github/workflows/` -- including a plain + * Markdown file -- as a workflow definition, and refuses the push unless the + * pushing token carries the `workflows` permission: + * + * refusing to allow a GitHub App to create or update workflow + * '.github/workflows/README.md' without 'workflows' permission + * + * The App token in documentation.yml is deliberately minted with least + * privilege (`permission-contents: write`, `permission-pull-requests: write`), + * so every regeneration run whose changed-file list touched a workflow file + * failed at the push. Two develop pushes reproduced it: run 36567389709 + * (d7e98f3, #3682 merge) and run 36519808147 (759dbb8, #3683 merge). + * + * The resolver must therefore never emit a README that lives under + * `.github/workflows/`, whatever shape the changed-file list takes. + */ + +const WORKFLOWS_README = ".github/workflows/README.md"; + +// Fixture roots and the cwd they displaced, drained by the afterEach below so +// a test that chdirs cannot leak its working directory into the next one. +const fixtureDirs = []; +const cwdStack = []; + +/** + * Build a throwaway repository fixture and chdir into it. + * + * `resolveReadmeFiles` probes the filesystem with `fs.existsSync` against + * relative paths, so the fixture root has to be the process cwd for the test + * to observe a realistic tree. Each fixture is torn down by `afterEach`, which + * restores the cwd before the next test builds its own. + * + * @param {Record} files Relative path to file contents; missing + * parent directories are created, and an omitted value becomes a placeholder. + * @returns {string} Absolute path to the fixture root, which is also the cwd. + */ +function withFixture(files) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "resolve-readme-")); + const previousCwd = process.cwd(); + + for (const [relative, contents] of Object.entries(files)) { + const target = path.join(dir, relative); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, contents ?? "# placeholder\n"); + } + + fixtureDirs.push(dir); + cwdStack.push(previousCwd); + process.chdir(dir); + + return dir; +} + +afterEach(() => { + while (cwdStack.length > 0) { + process.chdir(cwdStack.pop()); + } + while (fixtureDirs.length > 0) { + fs.rmSync(fixtureDirs.pop(), { recursive: true, force: true }); + } +}); + +describe("resolveReadmeFiles", () => { + describe("excludes .github/workflows/ (the push-refusal regression)", () => { + it("does not return the workflows README for a changed workflow file", () => { + withFixture({ + "README.md": "# root\n", + "docs/README.md": "# docs\n", + ".github/workflows/README.md": "# workflows\n", + ".github/workflows/documentation.yml": "name: test\n", + }); + + const result = resolveReadmeFiles([".github/workflows/documentation.yml"]); + + expect(result).not.toContain(WORKFLOWS_README); + }); + + it("reproduces the real failing input from run 36567389709 (d7e98f3)", () => { + // `git diff --name-only d7e98f3^ d7e98f3`. Before the fix this list + // contained '.github/workflows/README.md', which is the file the push was + // refused on. The fixture mirrors every directory that diff touched, so + // the expected survivors below are the real ones, not a convenient subset. + withFixture({ + "README.md": "# root\n", + "docs/README.md": "# docs\n", + "scripts/README.md": "# scripts\n", + "scripts/agents/includes/README.md": "# includes\n", + "scripts/agents/includes/__tests__/README.md": "# includes tests\n", + ".github/workflows/README.md": "# workflows\n", + ".github/workflows/documentation.yml": "name: test\n", + }); + + const result = resolveReadmeFiles([ + ".github/workflows/documentation.yml", + "CHANGELOG.md", + "docs/FOOTER_REMEDIATION_GUIDE.md", + "package.json", + "scripts/__tests__/dedupe-footers.test.js", + "scripts/__tests__/measure-footer-shape.test.js", + "scripts/agents/includes/__tests__/footer-shape.test.js", + "scripts/agents/includes/footer-shape.js", + "scripts/dedupe-footers.js", + "scripts/measure-footer-shape.js", + ]); + + expect(result).not.toContain(WORKFLOWS_README); + // Everything else must survive, or the bot silently stops regenerating. + expect(result).toEqual([ + "README.md", + "docs/README.md", + "scripts/README.md", + "scripts/agents/includes/README.md", + "scripts/agents/includes/__tests__/README.md", + ]); + }); + + it("reproduces the real failing input from run 36519808147 (759dbb8)", () => { + withFixture({ + "README.md": "# root\n", + ".github/workflows/README.md": "# workflows\n", + ".github/workflows/changelog-unified.yml": "name: test\n", + }); + + const result = resolveReadmeFiles([ + ".github/workflows/changelog-unified.yml", + "CHANGELOG.md", + "tests/js/bot-author-exemptions.test.js", + ]); + + expect(result).not.toContain(WORKFLOWS_README); + expect(result).toContain("README.md"); + }); + + it("still returns the workflows README's siblings from a mixed list", () => { + withFixture({ + "README.md": "# root\n", + "docs/README.md": "# docs\n", + "agents/README.md": "# agents\n", + ".github/workflows/README.md": "# workflows\n", + ".github/workflows/changelog-unified.yml": "name: test\n", + "docs/INDEX.md": "# index\n", + }); + + const result = resolveReadmeFiles([ + ".github/workflows/changelog-unified.yml", + "docs/INDEX.md", + "agents/foo.js", + ]); + + expect(result).not.toContain(WORKFLOWS_README); + expect(result).toEqual(["README.md", "agents/README.md", "docs/README.md"]); + }); + + it("excludes a README in a nested subdirectory of .github/workflows/", () => { + withFixture({ + "README.md": "# root\n", + ".github/workflows/README.md": "# workflows\n", + ".github/workflows/nested/README.md": "# nested\n", + ".github/workflows/nested/thing.yml": "name: nested\n", + }); + + const result = resolveReadmeFiles([ + ".github/workflows/nested/thing.yml", + ]); + + expect(result).not.toContain(WORKFLOWS_README); + expect(result).not.toContain(".github/workflows/nested/README.md"); + expect(result).toEqual(["README.md"]); + }); + + it("excludes when the changed path carries a ./ prefix", () => { + withFixture({ + "README.md": "# root\n", + ".github/workflows/README.md": "# workflows\n", + ".github/workflows/documentation.yml": "name: test\n", + }); + + const result = resolveReadmeFiles(["./.github/workflows/documentation.yml"]); + + expect(result).not.toContain(WORKFLOWS_README); + }); + + it("excludes when the changed path uses Windows separators", () => { + withFixture({ + "README.md": "# root\n", + ".github/workflows/README.md": "# workflows\n", + ".github/workflows/documentation.yml": "name: test\n", + }); + + // A Windows-style separator must not smuggle the workflows README back in. + // This asserts the exact list rather than "does not contain", because the + // pre-fix resolver also produced ["README.md"] here -- `path.dirname` treats + // a backslash as an ordinary character, so the whole string looked like a + // root-level filename. Pinning the exact value is what makes the test + // meaningful instead of passing on unfixed code for the wrong reason. + const result = resolveReadmeFiles([ + ".github\\workflows\\documentation.yml", + ]); + + expect(result).toEqual(["README.md"]); + }); + + it("does not treat a similarly named sibling directory as excluded", () => { + // `.github/workflows-old/` is not under `.github/workflows/`, so a README + // there is a legitimate regeneration target. + withFixture({ + "README.md": "# root\n", + ".github/workflows/README.md": "# workflows\n", + ".github/workflows-old/README.md": "# old\n", + ".github/workflows-old/thing.yml": "name: old\n", + }); + + const result = resolveReadmeFiles([".github/workflows-old/thing.yml"]); + + expect(result).toContain(".github/workflows-old/README.md"); + expect(result).not.toContain(WORKFLOWS_README); + }); + }); + + describe("emits platform-independent POSIX output", () => { + it("emits forward-slash paths, matching the git paths it derives from", () => { + // The output is written to GITHUB_OUTPUT and consumed by meta.agent.js, so + // it must be spelled the same way on every platform. `path.join` produces + // backslashes on Windows, and a backslash would be the one place the list + // stopped matching the forward-slash paths `git diff --name-only` reports. + // + // The exact-list assertion below is what catches this on Windows; the + // backslash assertion is belt-and-braces. Neither can fail on Linux, + // because `path.join` is already POSIX there -- this test protects + // Windows runs, it does not add Linux CI coverage. + withFixture({ + "README.md": "# root\n", + "docs/README.md": "# docs\n", + "scripts/agents/includes/README.md": "# includes\n", + ".github/workflows/README.md": "# workflows\n", + ".github/workflows/documentation.yml": "name: test\n", + }); + + const result = resolveReadmeFiles([ + ".github/workflows/documentation.yml", + "docs/INDEX.md", + "scripts/agents/includes/footer-shape.js", + ]); + + expect(result).toEqual([ + "README.md", + "docs/README.md", + "scripts/agents/includes/README.md", + ]); + // A backslash anywhere in the output is the bug, whatever the platform. + expect(result.some((file) => file.includes("\\"))).toBe(false); + }); + }); + + describe("leaves every other directory untouched", () => { + it("returns the root README when a subdirectory changes", () => { + withFixture({ "README.md": "# root\n", "docs/README.md": "# docs\n" }); + + expect(resolveReadmeFiles(["docs/INDEX.md"])).toEqual([ + "README.md", + "docs/README.md", + ]); + }); + + it("omits a directory's README when the directory has none", () => { + withFixture({ "README.md": "# root\n" }); + + expect(resolveReadmeFiles(["docs/INDEX.md"])).toEqual(["README.md"]); + }); + + it("still returns the root README for a root-level change", () => { + // Baseline behaviour, pinned deliberately: `path.dirname("CHANGELOG.md")` + // is ".", so the root README is a legitimate regeneration target and the + // root-README gate is not the thing that must change here. + withFixture({ "README.md": "# root\n" }); + + expect(resolveReadmeFiles(["CHANGELOG.md"])).toEqual(["README.md"]); + }); + + it("deduplicates and sorts the result", () => { + withFixture({ + "README.md": "# root\n", + "docs/README.md": "# docs\n", + "agents/README.md": "# agents\n", + }); + + const result = resolveReadmeFiles([ + "docs/one.md", + "agents/two.md", + "docs/three.md", + ]); + + expect(result).toEqual(["README.md", "agents/README.md", "docs/README.md"]); + }); + + it("returns an empty list for no changed files", () => { + withFixture({ "README.md": "# root\n" }); + + expect(resolveReadmeFiles([])).toEqual([]); + }); + }); +}); diff --git a/scripts/workflows/resolve-readme-files.cjs b/scripts/workflows/resolve-readme-files.cjs index f16116e2d8..15585ab75f 100644 --- a/scripts/workflows/resolve-readme-files.cjs +++ b/scripts/workflows/resolve-readme-files.cjs @@ -6,14 +6,90 @@ * This matches the format expected by GitHub Actions workflow variable interpolation * when passing values to shell commands via ${{ steps.readmes.outputs.files }}. * - * Example output: - * files=README.md,.github/projects/README.md,docs/README.md + * Example output (sorted, so '.' sorts before letters): + * files=.github/projects/README.md,README.md,docs/README.md */ const { execFileSync } = require("child_process"); const fs = require("fs"); const path = require("path"); +/** + * Directories the regeneration bot must never write into. + * + * GitHub treats a file directly in `.github/workflows/` as a workflow + * definition -- including a plain Markdown file such as + * `.github/workflows/README.md` -- and refuses a push that touches one unless + * the pushing token carries the `workflows` permission: + * + * refusing to allow a GitHub App to create or update workflow + * '.github/workflows/README.md' without 'workflows' permission + * + * The App token minted in documentation.yml is deliberately least-privilege + * (`permission-contents: write` and `permission-pull-requests: write` only), so + * any run that regenerated that README failed at the push. + * + * The whole subtree is excluded, not just the top level. GitHub only reads + * workflow files placed directly in `.github/workflows/`, so the nested + * directories (`archived/`, `__tests__/`) are not themselves a risk; excluding + * them costs nothing and removes the need to reason about which is which. The + * bot is kept away from the directory outright rather than the App's + * permissions being widened. + */ +const EXCLUDED_README_DIRS = [".github/workflows"]; + +/** + * Normalise a git-reported path to a POSIX-style relative path. + * + * `git diff --name-only` emits forward slashes, but a path reaching this + * function from any other caller (or a Windows-style input) may carry backslashes + * or a leading `./`. Comparing raw strings would let `.github\workflows\x.yml` + * slip past the exclusion, so both spellings are folded to one form first. + * + * Folds every backslash to a forward slash and strips a single leading `./`. + * It does not collapse `..` or strip drive letters: nothing that can reach it + * from `getChangedFiles` contains either, and inventing normalisation beyond + * that would be a behaviour change rather than a portability fix. + * + * @param {string} file Path as reported, with any platform's separators. + * @returns {string} The same path spelled with forward slashes and no leading `./`. + */ +function toPosixPath(file) { + return file.replace(/\\/g, "/").replace(/^\.\//, ""); +} + +/** + * True when `readmePath` lives inside one of the excluded directories. + * + * The check is on a path-segment boundary, so `.github/workflows-old/README.md` + * is NOT excluded -- only `.github/workflows` and its descendants are. The + * value is normalised first, so a backslash spelling is judged identically to + * a forward-slash one. + * + * @param {string} readmePath Candidate README path, in any platform's spelling. + * @returns {boolean} True when the path is one of the {@link EXCLUDED_README_DIRS} + * entries or sits inside one of them. + */ +function isExcludedReadme(readmePath) { + const posix = toPosixPath(readmePath); + return EXCLUDED_README_DIRS.some( + (dir) => posix === dir || posix.startsWith(`${dir}/`), + ); +} + +/** + * List the files that differ between two commits. + * + * `execFileSync` is used rather than a shell so no path is ever interpreted as + * a command. A git failure (an unresolvable range, a missing object on a shallow + * clone) resolves to an empty list rather than throwing: an empty list makes the + * caller emit no README files, which the workflow already treats as "nothing to + * do", whereas propagating the error would fail the whole regeneration job. + * + * @param {string} baseSha Commit to compare from. + * @param {string} headSha Commit to compare to. + * @returns {string[]} Repository-relative paths, in the order git reported them; empty on failure. + */ function getChangedFiles(baseSha, headSha) { try { const output = execFileSync( @@ -33,17 +109,47 @@ function getChangedFiles(baseSha, headSha) { } } +/** + * Pick the README files a change set makes worth regenerating. + * + * A changed file nominates the `README.md` sitting beside it, because that is + * the file whose index, badges and footer can go stale when the directory + * changes. The root `README.md` is added when anything below it changed, since + * it indexes the tree. + * + * Two invariants matter to the caller: + * + * - **Nothing under `.github/workflows/` is ever returned.** See + * {@link EXCLUDED_README_DIRS} for why; briefly, the App token that pushes + * the regeneration branch has no `workflows` permission, so a README there + * cannot be committed at all. + * - **Every returned path is POSIX.** The result is compared against, and + * consumed alongside, the forward-slash paths git reports, and it is written + * to `GITHUB_OUTPUT` for `meta.agent.js`; a backslash would be the one place + * the output disagreed with its own input. On POSIX the normalisation is a + * no-op. + * + * Existence is checked on the filesystem rather than assumed, so a directory + * with no README contributes nothing. + * + * @param {string[]} changedFiles Repository-relative paths that changed. + * @returns {string[]} Deduplicated, lexicographically sorted, forward-slash + * README paths that exist and are not under an excluded directory. + */ function resolveReadmeFiles(changedFiles) { const readmes = new Set(); let hasSubdirChanges = false; changedFiles.forEach((file) => { - const dir = path.dirname(file); + const dir = path.dirname(toPosixPath(file)); // Check for README.md in the changed file's directory const readmeInDir = path.join(dir, "README.md"); - if (fs.existsSync(readmeInDir)) { - readmes.add(readmeInDir); + if (fs.existsSync(readmeInDir) && !isExcludedReadme(readmeInDir)) { + // Emit the POSIX spelling even when `path.join` produced backslashes. The + // probe above deliberately keeps the joined form, because that is the + // path the filesystem understands on the host running this. + readmes.add(toPosixPath(readmeInDir)); } // Track if changes are in subdirectories (not root) @@ -60,6 +166,25 @@ function resolveReadmeFiles(changedFiles) { return Array.from(readmes).sort(); } +/** + * Resolve the impacted READMEs for the current event and write them to the + * step output. + * + * The base commit depends on the trigger: a pull request uses `PR_BASE`, a push + * uses the event's `before`, and anything else falls back to `HEAD~1`. The head + * is `PUSH_SHA` when set, else `PR_HEAD`. Note that `documentation.yml` exports + * `PUSH_SHA` as `${{ github.sha }}` unconditionally, so on a pull_request it is + * already the merge commit's SHA and `PR_HEAD` is not consulted. The chosen + * range is handed to {@link getChangedFiles} and the result to + * {@link resolveReadmeFiles}. + * + * Output is appended to `$GITHUB_OUTPUT` as `files=a,b,c`, which is the format + * `documentation.yml` interpolates into `meta.agent.js --files`. Writing is + * skipped when `GITHUB_OUTPUT` is unset or its directory does not exist, so the + * module can be imported for its exports without writing a step output file. + * + * @returns {void} Nothing; the resolved list is written to `$GITHUB_OUTPUT`. + */ function main() { const eventName = process.env.EVENT_NAME; let baseSha; @@ -88,4 +213,8 @@ function main() { } } -main(); +module.exports = { getChangedFiles, resolveReadmeFiles, main }; + +if (require.main === module) { + main(); +} diff --git a/scripts/workflows/resolve-readme-files.sh b/scripts/workflows/resolve-readme-files.sh index f27bdcc422..5ae5716974 100644 --- a/scripts/workflows/resolve-readme-files.sh +++ b/scripts/workflows/resolve-readme-files.sh @@ -23,13 +23,25 @@ trap 'rm -f "$TMP"' EXIT HAS_SUBDIR_CHANGES=0 +# Kept in step with EXCLUDED_README_DIRS in resolve-readme-files.cjs: GitHub +# treats a file directly in .github/workflows/ as a workflow definition and the +# App token that pushes the regeneration branch has no `workflows` permission, +# so a README there can never be pushed. See issue #3687. +EXCLUDED_DIR=".github/workflows" + while IFS= read -r file; do [ -z "$file" ] && continue dir=$(dirname "$file") - if [ -f "$dir/README.md" ]; then - echo "$dir/README.md" >> "$TMP" - fi + # Segment-boundary match, so .github/workflows-old/ is NOT excluded. + case "$dir/README.md" in + "$EXCLUDED_DIR"/*) ;; + *) + if [ -f "$dir/README.md" ]; then + echo "$dir/README.md" >> "$TMP" + fi + ;; + esac if [ "$dir" != "." ]; then HAS_SUBDIR_CHANGES=1