From b1096471f85c5922b6e72f47c89ea5884f83919c Mon Sep 17 00:00:00 2001 From: "melodic-automation-lanes[bot]" <337595936+melodic-automation-lanes[bot]@users.noreply.github.com> Date: Mon, 26 Oct 2026 04:01:40 +0000 Subject: [PATCH 1/2] Resolve issue #8956 Lane-Activity: work-resolve-issue/resolve-issue --- .../feat-8956-docs-patch-row-3e7a91c4.md | 7 ++ .../actions/resolve-config/patch-rules.json | 10 ++ .../resolve-config/patch-rules.schema.json | 13 +- .../actions/resolve-config/resolve.test.mjs | 3 +- .github/scripts/apply-patch/apply-patch.mjs | 18 ++- .../scripts/apply-patch/apply-patch.test.mjs | 114 +++++++++++++++++- .github/scripts/apply-patch/predicates.mjs | 2 + docs/conventions/pr-pipeline/README.md | 13 +- 8 files changed, 164 insertions(+), 16 deletions(-) create mode 100644 .changes/pr-pipeline/feat-8956-docs-patch-row-3e7a91c4.md diff --git a/.changes/pr-pipeline/feat-8956-docs-patch-row-3e7a91c4.md b/.changes/pr-pipeline/feat-8956-docs-patch-row-3e7a91c4.md new file mode 100644 index 00000000000..23817a120a6 --- /dev/null +++ b/.changes/pr-pipeline/feat-8956-docs-patch-row-3e7a91c4.md @@ -0,0 +1,7 @@ +--- +bump: minor +--- + +### Added + +- `patch-rules.json` has a `docs` row for a markdown-only patch. It allows only a path ending in `.md` (the new `markdown` allow rule). Besides the universal set it refuses a path under a `skills/` directory at any depth and a `SKILL.md` anywhere (the new `skills` and `skill-md` refusals, which apply only to a row that names them), each escalating `patch-protected-path`. Its cap, commit, moved-head path and reasons match `default`. No push job selects it yet: fix-docs's `apply-docs-patch` job keeps the `default` row until `pr-refine.yml` passes `patch-prepare: docs`. diff --git a/.github/actions/resolve-config/patch-rules.json b/.github/actions/resolve-config/patch-rules.json index d6ba29b1398..1a453b3c65b 100644 --- a/.github/actions/resolve-config/patch-rules.json +++ b/.github/actions/resolve-config/patch-rules.json @@ -50,6 +50,16 @@ "conflict-patch-protected-path" ] }, + "docs": { + "refuse": ["github-workflows", "codeowners", "gitmodules", "settings", "symlink", "gitlink", "rename-into-refused", "skills", "skill-md"], + "allow": ["markdown"], + "max-files": 300, + "requires": [], + "commit": { "parents": ["head"], "message": "Apply {activity} on {ref}", "trailer": "Lane-Activity: {lane}/{activity}" }, + "reads-report": null, + "skip-moved-head": false, + "escalations": ["patch-empty", "patch-invalid", "patch-not-applied", "patch-protected-path"] + }, "default": { "refuse": ["github-workflows", "codeowners", "gitmodules", "settings", "symlink", "gitlink", "rename-into-refused"], "allow": ["any"], diff --git a/.github/actions/resolve-config/patch-rules.schema.json b/.github/actions/resolve-config/patch-rules.schema.json index 4fffa1596e1..d6ec1f2c9d3 100644 --- a/.github/actions/resolve-config/patch-rules.schema.json +++ b/.github/actions/resolve-config/patch-rules.schema.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://github.com/melodic-software/claude-code-plugins/.github/actions/resolve-config/patch-rules.schema.json", "title": "Patch rules", - "description": "What the apply-patch script is to admit from a producer's patch: one row per prepare mode a patch-producing skill uses, `default` for one with no prepare mode, and `issue` for a patch that goes to a new issue-lane branch. Contract: docs/conventions/pr-pipeline/README.md, Patch rules.", + "description": "What the apply-patch script is to admit from a producer's patch: one row per prepare mode a patch-producing skill uses, `docs` for a markdown-only patch, `default` for one with no prepare mode, and `issue` for a patch that goes to a new issue-lane branch. Contract: docs/conventions/pr-pipeline/README.md, Patch rules.", "type": "object", "additionalProperties": false, "required": ["default", "issue"], @@ -11,6 +11,7 @@ "review-threads": { "$ref": "#/$defs/row" }, "dependency-review": { "$ref": "#/$defs/row" }, "merge-base": { "$ref": "#/$defs/row" }, + "docs": { "$ref": "#/$defs/row" }, "default": { "$ref": "#/$defs/row" }, "issue": { "$ref": "#/$defs/row" } }, @@ -32,17 +33,19 @@ "settings", "symlink", "gitlink", - "rename-into-refused" + "rename-into-refused", + "skills", + "skill-md" ] }, - "description": "Named refusals; any match refuses the whole patch. github-workflows: .github/workflows/** in any case and through any .. segment; codeowners: CODEOWNERS at the root, in .github/ or in docs/; gitmodules: .gitmodules; settings: **/settings*.json; symlink: mode 120000; gitlink: mode 160000; rename-into-refused: a rename whose new path another refusal matches." + "description": "Named refusals; any match refuses the whole patch. github-workflows: .github/workflows/** in any case and through any .. segment; codeowners: CODEOWNERS at the root, in .github/ or in docs/; gitmodules: .gitmodules; settings: **/settings*.json; symlink: mode 120000; gitlink: mode 160000; rename-into-refused: a rename whose new path github-workflows, codeowners, gitmodules or settings matches, or that holds a backslash. Optional, refused only where a row names them, in any case and through any .. segment: skills: a path under a skills/ directory at any depth; skill-md: a SKILL.md at any depth." }, "allow": { "type": "array", "minItems": 1, "uniqueItems": true, - "items": { "enum": ["any", "kept-threads", "dependency-manifests", "merge-result"] }, - "description": "What a path the refusals pass must also meet. any: nothing more; kept-threads: the path of a thread the run kept; dependency-manifests: an npm or pip manifest or lock file outside .github/; merge-result: a path the base merge left conflicted, which the patch must leave with no conflict markers; the merge's own changes from the base side pass through without facing the rules." + "items": { "enum": ["any", "markdown", "kept-threads", "dependency-manifests", "merge-result"] }, + "description": "What a path the refusals pass must also meet. any: nothing more; markdown: a path ending in .md, in that case; kept-threads: the path of a thread the run kept; dependency-manifests: an npm or pip manifest or lock file outside .github/; merge-result: a path the base merge left conflicted, which the patch must leave with no conflict markers; the merge's own changes from the base side pass through without facing the rules." }, "max-files": { "oneOf": [{ "type": "integer", "minimum": 1 }, { "type": "null" }], diff --git a/.github/actions/resolve-config/resolve.test.mjs b/.github/actions/resolve-config/resolve.test.mjs index 4c1bc6789c1..5ef3718b616 100644 --- a/.github/actions/resolve-config/resolve.test.mjs +++ b/.github/actions/resolve-config/resolve.test.mjs @@ -1799,12 +1799,13 @@ const PATCH_RULES = JSON.parse(read("./patch-rules.json")); const PATCH_RULES_SCHEMA = JSON.parse(read("./patch-rules.schema.json")); const patchRules = (name) => JSON.parse(fixture(name)); -test("patch rules: the shipped registry passes, with a row for each prepare mode and the default and issue targets", () => { +test("patch rules: the shipped registry passes, with a row for each prepare mode, the docs row and the default and issue targets", () => { assert.equal(checkPatchRules(PATCH_RULES, PATCH_RULES_SCHEMA), PATCH_RULES); assert.deepEqual(Object.keys(PATCH_RULES).sort(), [ "ci-context", "default", "dependency-review", + "docs", "issue", "merge-base", "review-threads", diff --git a/.github/scripts/apply-patch/apply-patch.mjs b/.github/scripts/apply-patch/apply-patch.mjs index 2f6c3b769df..0a02234d2f4 100755 --- a/.github/scripts/apply-patch/apply-patch.mjs +++ b/.github/scripts/apply-patch/apply-patch.mjs @@ -111,9 +111,19 @@ const PATH_RULES = { const pathRule = (name) => (entry) => PATH_RULES[name](segments(entry.path)); const pathRefused = (path) => Object.values(PATH_RULES).some((rule) => rule(segments(path))) || path.includes("\\"); +// Path refusals a row may add to the universal set, in any case and through +// any .. segment; rename-into-refused does not read them. +const ROW_PATH_RULES = { + // A path under a skills/ directory at any depth. + skills: (s) => s.slice(0, -1).includes("skills"), + "skill-md": (s) => s.at(-1) === "skill.md", +}; +const rowPathRule = (name) => (entry) => ROW_PATH_RULES[name](segments(entry.path)); + // The refusal table. Every row must name every refusal but those in ALWAYS, -// which apply to every row unnamed; any match refuses the whole patch, and -// `kind` picks the row's escalation reason. +// which apply to every row unnamed, and the `optional` ones, which apply only +// to a row that names them; any match refuses the whole patch, and `kind` +// picks the row's escalation reason. export const ALWAYS = ["backslash"]; export const REFUSALS = { // A backslash reads as a directory separator on Windows checkouts. @@ -125,6 +135,8 @@ export const REFUSALS = { symlink: { kind: "invalid", test: (entry) => entry.oldMode === SYMLINK || entry.newMode === SYMLINK }, gitlink: { kind: "invalid", test: (entry) => entry.oldMode === GITLINK || entry.newMode === GITLINK }, "rename-into-refused": { kind: "protected-path", test: (entry) => entry.renamedFrom !== undefined && pathRefused(entry.path) }, + skills: { kind: "protected-path", optional: true, test: rowPathRule("skills") }, + "skill-md": { kind: "protected-path", optional: true, test: rowPathRule("skill-md") }, }; // The names of the refusals that match one changed entry; by default all. @@ -293,7 +305,7 @@ export function checkRow(row) { const fail = (message) => { throw new Error(`patch-rules.json row \`${row.name}\` ${message}`); }; - const missing = Object.keys(REFUSALS).filter((name) => !ALWAYS.includes(name) && !row.refuse?.includes(name)); + const missing = Object.keys(REFUSALS).filter((name) => !ALWAYS.includes(name) && !REFUSALS[name].optional && !row.refuse?.includes(name)); if (missing.length > 0) { fail(`does not refuse ${missing.join(", ")}`); } diff --git a/.github/scripts/apply-patch/apply-patch.test.mjs b/.github/scripts/apply-patch/apply-patch.test.mjs index 469108331e3..eb937c95a9a 100644 --- a/.github/scripts/apply-patch/apply-patch.test.mjs +++ b/.github/scripts/apply-patch/apply-patch.test.mjs @@ -308,8 +308,8 @@ test("a patch over max-files is refused", async (t) => { }); // Agent instructions, plugin hooks and manifests, MCP config and .claude -// reach review on every row; settings files, submodules and code owners stay -// refused on every row. +// reach review on every row past its optional refusals; settings files, +// submodules and code owners stay refused on every row. const INSTRUCTION_PATHS = [ ["CLAUDE.md", []], ["AGENTS.md", []], @@ -333,11 +333,119 @@ const INSTRUCTION_PATHS = [ test("every row refuses the same instruction paths: settings, submodules and code owners only", () => { for (const [path, refusals] of INSTRUCTION_PATHS) { for (const name of Object.keys(RULES)) { - assert.deepEqual(refusalsOf(entry(path), RULES[name].refuse), refusals, `${name} ${path}`); + const universal = RULES[name].refuse.filter((refusal) => !REFUSALS[refusal].optional); + assert.deepEqual(refusalsOf(entry(path), universal), refusals, `${name} ${path}`); } } }); +// --- the docs row: markdown only, nothing under skills/ and no SKILL.md --- + +test("refuses a path under a skills/ directory at any depth, in any case, and a SKILL.md anywhere, only where a row names them", () => { + for (const [path, refusals] of [ + ["plugins/p/skills/s/SKILL.md", ["skills", "skill-md"]], + ["plugins/p/skills/s/reference/r.md", ["skills"]], + ["skills/x.md", ["skills"]], + ["plugins/p/Skills/s/notes.md", ["skills"]], + ["a/../skills/x.md", ["skills"]], + ["docs/skill.md", ["skill-md"]], + ["SKILL.md", ["skill-md"]], + ["docs/skills.md", []], + ["plugins/p/skillset/x.md", []], + ]) { + assert.deepEqual(refusalsOf(entry(path), ["skills", "skill-md"]), refusals, path); + assert.deepEqual(refusalsOf(entry(path), RULES.default.refuse), [], `default ${path}`); + } +}); + +test("only the docs row names the skills and skill-md refusals, and no row has to", () => { + for (const name of Object.keys(RULES)) { + const named = RULES[name].refuse.filter((refusal) => REFUSALS[refusal].optional); + assert.deepEqual(named, name === "docs" ? ["skills", "skill-md"] : [], name); + } + const refuse = RULES.docs.refuse.filter((name) => !REFUSALS[name].optional); + assert.doesNotThrow(() => checkRow(row("docs", { refuse }))); + assert.throws(() => checkRow(row("docs", { refuse: refuse.filter((name) => name !== "codeowners") })), /does not refuse codeowners/); +}); + +test("the docs row allows markdown alone, refuses the universal set, and keeps the default row's cap, commit, moved-head path and reasons", () => { + const docs = row("docs"); + const fallback = row("default"); + assert.deepEqual(docs.allow, ["markdown"]); + assert.deepEqual( + docs.refuse.filter((name) => !REFUSALS[name].optional), + fallback.refuse, + ); + for (const key of ["max-files", "requires", "commit", "reads-report", "skip-moved-head", "escalations"]) { + assert.deepEqual(docs[key], fallback[key], key); + } +}); + +const docsPush = (fix, api, extra = {}) => pushWith(fix, api, { row: row("docs"), lane: "pr-refine", activity: "fix-docs", ...extra }); + +test("under the docs row a patch that edits only markdown outside skills/ pushes", async (t) => { + const fix = repoWithPatch(t, (repo) => { + writeFileSync(join(repo, "README.md"), "# fixed\n"); + mkdirSync(join(repo, "docs")); + writeFileSync(join(repo, "docs/AGENTS.md"), "x\n"); + }); + const api = fakeApi(fix.repo, fix.head); + assert.deepEqual(await docsPush(fix, api), { outcome: "pushed", commit: "c".repeat(40), branch: "feat/x" }); + const { createCommit } = api.calls.find((call) => call.createCommit); + assert.equal(createCommit.message, "Apply fix-docs on feat/x\n\nLane-Activity: pr-refine/fix-docs\n"); +}); + +for (const [path, edit] of [ + ["scripts/x.sh", (repo) => writeFileSync(join(repo, "scripts/x.sh"), "#!/bin/sh\nfoo=2\n")], + ["config.json", (repo) => writeFileSync(join(repo, "config.json"), "{}\n")], + ["lib/x.mjs", (repo) => (mkdirSync(join(repo, "lib")), writeFileSync(join(repo, "lib/x.mjs"), "export {};\n"))], + ["GUIDE.MD", (repo) => writeFileSync(join(repo, "GUIDE.MD"), "x\n")], +]) { + test(`under the docs row a patch that also edits the non-markdown ${path} is refused patch-protected-path before any API call`, async (t) => { + const fix = repoWithPatch(t, (repo) => { + writeFileSync(join(repo, "README.md"), "# fixed\n"); + edit(repo); + }); + const api = fakeApi(fix.repo, fix.head); + const lines = []; + assert.deepEqual(await docsPush(fix, api, { log: (line) => lines.push(line) }), { outcome: "escalate", escalationReason: "patch-protected-path" }); + assert.deepEqual(lines, [`not allowed by markdown: ${path}`]); + assert.deepEqual(api.calls, []); + }); +} + +for (const [path, refusal] of [ + ["plugins/p/skills/s/SKILL.md", "skills"], + ["plugins/p/skills/s/reference/r.md", "skills"], + ["docs/SKILL.md", "skill-md"], +]) { + test(`under the docs row a patch that touches ${path} is refused patch-protected-path before any API call`, async (t) => { + const fix = repoWithPatch(t, (repo) => { + writeFileSync(join(repo, "README.md"), "# fixed\n"); + mkdirSync(dirname(join(repo, path)), { recursive: true }); + writeFileSync(join(repo, path), "# changed\n"); + }); + const api = fakeApi(fix.repo, fix.head); + const lines = []; + assert.deepEqual(await docsPush(fix, api, { log: (line) => lines.push(line) }), { outcome: "escalate", escalationReason: "patch-protected-path" }); + assert.deepEqual(lines, [`refused (${refusal}): ${path}`]); + assert.deepEqual(api.calls, []); + }); +} + +test("under the docs row deleting a SKILL.md is refused patch-protected-path before any API call", async (t) => { + const path = "plugins/p/skills/s/SKILL.md"; + const fix = repoWithPatch(t, (repo) => rmSync(join(repo, path)), { + head: (repo) => { + mkdirSync(dirname(join(repo, path)), { recursive: true }); + writeFileSync(join(repo, path), "# skill\n"); + }, + }); + const api = fakeApi(fix.repo, fix.head); + assert.deepEqual(await docsPush(fix, api), { outcome: "escalate", escalationReason: "patch-protected-path" }); + assert.deepEqual(api.calls, []); +}); + // fix-docs hands on its patch with no prepare mode, so the default row holds // it: an edit to an agent instruction or a plugin hook pushes, and one to a // code owners file is refused whole before any API call. diff --git a/.github/scripts/apply-patch/predicates.mjs b/.github/scripts/apply-patch/predicates.mjs index 020c5fde5eb..572601b2d15 100644 --- a/.github/scripts/apply-patch/predicates.mjs +++ b/.github/scripts/apply-patch/predicates.mjs @@ -8,6 +8,8 @@ import { inThreadScope } from "../pr-unblock/thread-scope.mjs"; export const PREDICATES = { any: () => () => true, + // A markdown file: a path ending in .md, in that case. + markdown: () => (path) => path.endsWith(".md"), // A kept thread's path, or a file thread-scope.mjs places in its scope, // its named_paths and the PR's own files the patch does not add included: // `keptThreads` is { paths, threads, prPaths }. diff --git a/docs/conventions/pr-pipeline/README.md b/docs/conventions/pr-pipeline/README.md index db5816514f2..b0d6aab6b91 100644 --- a/docs/conventions/pr-pipeline/README.md +++ b/docs/conventions/pr-pipeline/README.md @@ -555,7 +555,8 @@ label back. [`patch-rules.json`](../../../.github/actions/resolve-config/patch-rules.json) is the registry of what one `prepare: apply-patch` script is to admit from a producer's patch, read from the trusted base: one row per prepare mode a patch-producing skill uses (`ci-context`, -`review-threads`, `dependency-review`, `merge-base`), `default` for one with no prepare mode, and +`review-threads`, `dependency-review`, `merge-base`), `docs` for a push job that passes +`patch-prepare: docs` to hold a patch to markdown, `default` for one with no prepare mode, and `issue` for a patch that goes to a new issue-lane branch made at the run's base or resume tip. [`apply-patch.mjs`](../../../.github/scripts/apply-patch/apply-patch.mjs) reads it for fix-ci's, fix-docs's, resolve-conflicts's, address-feedback's, review-dependency's and resolve-issue's patches ([`prepare: apply-patch`](pr-run-activity.md#prepare-apply-patch)). @@ -565,7 +566,7 @@ each row to these keys: | Key | Holds | |---|---| | `refuse` | Named refusals; any match refuses the whole patch. | -| `allow` | What every other changed path must also meet: `any`, `kept-threads` (a path in the scope of a thread the select job kept, its `named_paths` included, or a file the pull request already changes that the patch does not add, its `pr_paths`, read from that job's thread file), `dependency-manifests` (an npm or pip manifest or lock file outside `.github/` in a directory the base `.github/dependabot.yml` lists for its ecosystem) or `merge-result` (a path the base merge left conflicted; under a merge row only the patch's own changes, measured from the conflict tree, face the rules, every conflicted path left in the tree must hold no conflict markers, and the merge's base-side changes pass through). | +| `allow` | What every other changed path must also meet: `any`, `markdown` (a path ending in `.md`, in that case), `kept-threads` (a path in the scope of a thread the select job kept, its `named_paths` included, or a file the pull request already changes that the patch does not add, its `pr_paths`, read from that job's thread file), `dependency-manifests` (an npm or pip manifest or lock file outside `.github/` in a directory the base `.github/dependabot.yml` lists for its ecosystem) or `merge-result` (a path the base merge left conflicted; under a merge row only the patch's own changes, measured from the conflict tree, face the rules, every conflicted path left in the tree must hold no conflict markers, and the merge's base-side changes pass through). | | `max-files` | The most paths one patch may change, or `null`. | | `max-blobs` | Merge rows only: the most blobs one merge commit may upload, each one neither the head nor the base holds at its path; over it nothing is written. | | `requires` | What the producer's output must say before anything applies: `dependency-verdict: fix`, the scanned verdict's field. | @@ -573,7 +574,7 @@ each row to these keys: | `commit` | `parents` (`[head]`, or `[head, base]` for a merge), `message` and `trailer`, with `{ref}`, `{base}`, `{lane}`, `{activity}` and `{issue}` filled in. | | `reads-report` | The producer report the apply reads (`ci-escalations`: the checks the model escalated), or `null`. | | `empty-at-push` | Optional; the kind a patch that applies but changes nothing escalates at push time: `empty` (the default), `invalid` (`ci-context`, as the producer said it wrote a change) or `conflict` (`merge-base`: its `unresolved-conflict` reason, since the conflicts stay as git left them). | -| `skip-moved-head` | Optional, default `false`; `true` (`ci-context`, `review-threads`, `dependency-review`, `merge-base`) skips `superseded-sha` on any PR head that moved since the producer ran, where other rows take the moved tip with `--3way` when the moved commits touch no patched path. `default` sets `false`: fix-docs runs once per pull request, so a skipped fix-docs patch would never run again. | +| `skip-moved-head` | Optional, default `false`; `true` (`ci-context`, `review-threads`, `dependency-review`, `merge-base`) skips `superseded-sha` on any PR head that moved since the producer ran, where other rows take the moved tip with `--3way` when the moved commits touch no patched path. `default` and `docs` set `false`: fix-docs runs once per pull request, so a skipped fix-docs patch would never run again. | | `script-exec-bit` | Optional, default `false`; `true` (`issue`) makes each new or changed file that ends in `.sh` or starts with `#!` executable after every refusal passes, since the model's tools set no file mode. | | `escalations` | The escalation reasons the apply may give for the row. | @@ -584,7 +585,11 @@ The rest of `.github/`, `.claude/`, `.mcp.json`, plugin hooks, `.claude-plugin/` `scripts/`, `CLAUDE.md`, `AGENTS.md` and an executable bit reach review, and merge on it: the [HIGH path floor](#merge-authority) holds for a human merge only the takeover set: the trusted-actor list, the trusted-trigger check, `CODEOWNERS`, `.gitmodules`, and `resolve-config`, -whose code the token broker runs from main. The `ci-context`, `default`, `issue` and `merge-base` rows cap a patch at 300 paths, and the +whose code the token broker runs from main. The `docs` row also refuses, in any case and through +any `..` segment, a path under a `skills/` directory at any depth (`skills`) and a `SKILL.md` +anywhere (`skill-md`), and allows only `markdown`, so a non-markdown path escalates +`patch-protected-path`; it otherwise matches `default`. No other row names those two refusals, +and none has to. The `ci-context`, `docs`, `default`, `issue` and `merge-base` rows cap a patch at 300 paths, and the `merge-base` row a merge commit at 300 new blobs. `checkPatchRules` in [`patch-rules.mjs`](../../../.github/actions/resolve-config/patch-rules.mjs) rejects a row without that From bfb898851442358aa903d6f54f045274d318909f Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 26 Oct 2026 12:53:19 -0400 Subject: [PATCH 2/2] fix(pr-pipeline): leave optional refusals out of refusalsOf's default refusalsOf with no names applied every REFUSALS key, so the new optional skills rule refused a settings file under plugins/

/skills/ and broke "admits a settings file anywhere under plugins/". Optional refusals apply only to a row that names them, as checkRow already treats them. Refs: #8956 Co-Authored-By: Claude Opus 5.5 --- .github/scripts/apply-patch/apply-patch.mjs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/scripts/apply-patch/apply-patch.mjs b/.github/scripts/apply-patch/apply-patch.mjs index a1f09f32d70..b26c4de5f3b 100755 --- a/.github/scripts/apply-patch/apply-patch.mjs +++ b/.github/scripts/apply-patch/apply-patch.mjs @@ -140,8 +140,9 @@ export const REFUSALS = { "skill-md": { kind: "protected-path", optional: true, test: rowPathRule("skill-md") }, }; -// The names of the refusals that match one changed entry; by default all. -export const refusalsOf = (entry, names = Object.keys(REFUSALS)) => names.filter((name) => REFUSALS[name].test(entry)); +// The names of the refusals that match one changed entry; by default all but +// the optional ones, which apply only to a row that names them. +export const refusalsOf = (entry, names = Object.keys(REFUSALS).filter((name) => !REFUSALS[name].optional)) => names.filter((name) => REFUSALS[name].test(entry)); // Credential shapes: any match in the patch or a patched blob refuses it all. // GitHub prefixes from