From fcd092e3976edd37622d5c9820c3f4b4651ee4b0 Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Wed, 23 Sep 2026 22:20:40 +0200 Subject: [PATCH 1/8] docs(recovery): rename release titles that differ from the tag Add a recovery section for releases titled " vX.Y.Z" instead of the bare tag. guard-gh-release.py blocks every title change for the agent, `gh release edit --title` and PATCH on the releases API alike, with no override, so the section describes handing the maintainer a script: dry run by default, rename only exact matches on --apply, read each title back, and a final check that no title differs from its tag. Measured on CybotTM/wow-quickroute on 2026-09-23: 20 releases renamed this way, all read back without a failure. Both jq filters checked on sample data: an exact " " title is listed, a bare tag or a title with a suffix is not. Assisted-by: claude-code:claude-opus-5-5 Agent-Session: https://claude.ai/code/session_01Ajsba9TsThQzrPKu1RxL5W Agent-Host: 0493f0 Signed-off-by: Sebastian Mendel --- .../references/recovery-procedures.md | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/skills/github-release/references/recovery-procedures.md b/skills/github-release/references/recovery-procedures.md index 103f1e5..3d68d6a 100644 --- a/skills/github-release/references/recovery-procedures.md +++ b/skills/github-release/references/recovery-procedures.md @@ -325,6 +325,42 @@ See `ter-republish.md` for the TYPO3-specific pattern using a release notes, re-run only downstream publishers, never the full release workflow." +## Release Titles Differ From the Tag + +**Symptom**: Releases carry a title such as `QuickRoute v1.21.0` while the +convention here is the bare tag (`gh release create … --title "vX.Y.Z"`), +and the maintainer wants the existing ones renamed. + +**Cause**: The release workflow sets its own `--title`. The pattern +`--title " $TAG"` tends to arrive with a hand-written packaging +step and is copied from release to release without a reason. + +**Prevention**: Set `--title "$TAG"` in the workflow. A store upload that +lists files outside the repository (a CurseForge `displayName`, for +example) may keep the project name; the GitHub release page shows the +repository name already. + +**Recovery** (existing releases): the agent cannot do it. `guard-gh-release.py` +blocks `gh release edit --title` and every mutating `gh api` call on a +releases endpoint, with no override, and that is intended. Hand the +maintainer a script instead, and let them run it with the `!` prefix: + +1. List the releases whose title is exactly ` `: + `gh api "repos/$R/releases?per_page=100" --paginate --jq '.[] | select(.name == " " + .tag_name) | "\(.id)\t\(.tag_name)"'`. +2. Dry run by default: print each planned rename and change nothing. + Rename only on `--apply`, and only titles that match exactly. +3. Per release: `gh api -X PATCH "repos/$R/releases/$ID" -f name="$TAG"`, + then read `.name` back and count a mismatch as a failure. Only the title + changes; tag, notes and assets stay. +4. Run the dry run yourself first — it only reads, so the guard allows it + — and show its output with the `--apply` command. + +Measured on CybotTM/wow-quickroute (2026-09-23): 20 releases, none marked +`immutable`, all renamed and read back without a failure. Afterwards check +that no release has a title different from its tag: +`gh api "repos/$R/releases?per_page=100" --paginate --jq '.[] | select(.name != .tag_name) | .tag_name'` +must print nothing. + ## Mis-Tagged SemVer Release (Scope Larger Than Version Bump Implies) **Symptom**: A release was tagged (and published, and consumed by TER / From 4e795753964d9b3976e6649929f043d0192bd0fa Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Thu, 24 Sep 2026 06:43:41 +0200 Subject: [PATCH 2/8] fix(guard): catch a quoted release endpoint path in gh api calls From a review of this branch. The path pattern for `gh api` required the endpoint to start right after the flags, so a quoted path, which is how a script with variables writes it (`"repos/$R/releases/$ID"`), skipped the check entirely: `gh api -X PATCH "repos/o/r/releases/1" -f name=v1` returned 0. The same held for a single-quoted path, `--method=PATCH` before a quoted path, and a quoted method (`-X "DELETE"`). The path may now carry an optional quote, flags may take their value after `=`, and the method may be quoted. Five new cases pin the blocking forms; each failed before the change. A quoted read-only call (`--paginate --jq`) stays allowed. All eight script suites pass. The recovery section added on this branch also changes: - the dry-run step no longer says the guard allows it because it only reads: the guard sees only the script call, so leaving `--apply` to the maintainer is the agent's part, not something the guard enforces; - "20 releases" reads "20 of 30 releases carried the prefixed title"; - the final check notes that a release with an empty name shows up too. Assisted-by: claude-code:claude-opus-5-5 Agent-Session: https://claude.ai/code/session_01Ajsba9TsThQzrPKu1RxL5W Agent-Host: 0493f0 Signed-off-by: Sebastian Mendel --- .../references/recovery-procedures.md | 18 +++++++++++------- .../github-release/scripts/guard-gh-release.py | 9 ++++++--- .../tests/guard-gh-release-invocations.test.sh | 9 +++++++++ 3 files changed, 26 insertions(+), 10 deletions(-) diff --git a/skills/github-release/references/recovery-procedures.md b/skills/github-release/references/recovery-procedures.md index 3d68d6a..0c16864 100644 --- a/skills/github-release/references/recovery-procedures.md +++ b/skills/github-release/references/recovery-procedures.md @@ -352,14 +352,18 @@ maintainer a script instead, and let them run it with the `!` prefix: 3. Per release: `gh api -X PATCH "repos/$R/releases/$ID" -f name="$TAG"`, then read `.name` back and count a mismatch as a failure. Only the title changes; tag, notes and assets stay. -4. Run the dry run yourself first — it only reads, so the guard allows it - — and show its output with the `--apply` command. - -Measured on CybotTM/wow-quickroute (2026-09-23): 20 releases, none marked -`immutable`, all renamed and read back without a failure. Afterwards check -that no release has a title different from its tag: +4. Run the dry run yourself first and show its output with the `--apply` + command. The guard sees only the script call, not the `gh` calls inside + it, so it would let `--apply` through as well: leaving `--apply` to the + maintainer is your part, not something the guard enforces. + +Measured on CybotTM/wow-quickroute (2026-09-23): 20 of 30 releases carried +the prefixed title, none marked `immutable`; all 20 were renamed and read +back without a failure. Afterwards check that no release has a title +different from its tag: `gh api "repos/$R/releases?per_page=100" --paginate --jq '.[] | select(.name != .tag_name) | .tag_name'` -must print nothing. +must print nothing. A release with an empty `name` shows up here too; +GitHub displays its tag as the title, so it needs no rename. ## Mis-Tagged SemVer Release (Scope Larger Than Version Bump Implies) diff --git a/skills/github-release/scripts/guard-gh-release.py b/skills/github-release/scripts/guard-gh-release.py index 8ff05ad..c3dea11 100755 --- a/skills/github-release/scripts/guard-gh-release.py +++ b/skills/github-release/scripts/guard-gh-release.py @@ -91,19 +91,22 @@ def block(reason: str, suggestion: str) -> None: # gh api repos/owner/repo/releases -X POST # gh api /repos/owner/repo/releases --method DELETE # gh api repos/owner/repo/releases/123 -X PATCH +# gh api -X PATCH "repos/$R/releases/$ID" -f name=v1 +# The path may be quoted: a script with variables writes it that way, and +# requiring it bare let every quoted form skip the check below. GH_API_RELEASE_RE = re.compile( INVOCATION_PREFIX + r""" gh\s+api\s+ # gh api - (?:(?:-\w+|--\w[\w-]*)(?:\s+(?:"[^"]*"|'[^']*'|\S+))?\s+)* # optional flags (e.g. -X POST, -H "...") - /?repos/[^\s]+/releases # release endpoint path + (?:(?:-\w+|--\w[\w-]*)(?:[\s=]+(?:"[^"]*"|'[^']*'|\S+))?\s+)* # optional flags (e.g. -X POST, --method=PATCH, -H "...") + ["']?/?repos/[^\s"']+/releases # release endpoint path, bare or quoted """, re.VERBOSE, ) MUTATING_METHOD_RE = re.compile( r""" - (?:-X|--method)\s*(POST|PUT|PATCH|DELETE) + (?:-X|--method)[\s=]*["']?(POST|PUT|PATCH|DELETE) """, re.VERBOSE | re.IGNORECASE, ) diff --git a/skills/github-release/scripts/tests/guard-gh-release-invocations.test.sh b/skills/github-release/scripts/tests/guard-gh-release-invocations.test.sh index a17342b..8a15769 100755 --- a/skills/github-release/scripts/tests/guard-gh-release-invocations.test.sh +++ b/skills/github-release/scripts/tests/guard-gh-release-invocations.test.sh @@ -183,6 +183,15 @@ check 2 'api DELETE on its own line' \ 'echo hi gh api repos/o/r/releases/1 -X DELETE' check 0 'api GET on releases' 'gh api repos/o/r/releases' +# A quoted endpoint path is how a script with variables writes it +# ("repos/$R/releases/$ID"), and the path pattern used to require the path to +# start right after the flags, so every quoted form skipped the check. +check 2 'api PATCH, path in double quotes' 'gh api -X PATCH "repos/o/r/releases/1" -f name=v1' +check 2 'api PATCH, path in single quotes' "gh api 'repos/o/r/releases/1' -X PATCH -f name=v1" +check 2 'api data flag only, quoted path' 'gh api "repos/o/r/releases/1" -f name=v1' +check 2 'api --method=PATCH, quoted path' 'gh api --method=PATCH "repos/o/r/releases/1"' +check 2 'api method in quotes' 'gh api repos/o/r/releases/1 -X "DELETE"' +check 0 'api GET, quoted path' 'gh api "repos/o/r/releases?per_page=100" --paginate --jq .' if [[ "$fail" == 0 ]]; then printf '\nAll gh-release invocation tests passed\n' From 8445dbccc2c832f37eb1a968857284d19ef37fb3 Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Thu, 24 Sep 2026 06:56:05 +0200 Subject: [PATCH 3/8] fix(guard): read a gh api call as argv, not as text From the second review of this branch. The two regexes that judged a `gh api` call had more ways through than the quoted path fixed in the previous commit: a flag value with a space in it (`-f body="new notes"`) ended the flag list before the endpoint, `--raw-field` and `--field=x` were not recognised as data flags, and a full URL was not an endpoint. They also produced false positives: `-X GET` with query fields, jq text mentioning "-X POST", a repository named like `releases-app`. And the flags group backtracked exponentially: twenty dash-words took over 20 s against a 2-second hook timeout. The call is now split with shlex, the way the shell hands it to gh. Flags that take a value (from `gh api --help`, gh 2.101.0) consume it, in attached forms too. A word containing repos///releases is a release endpoint, bare, quoted or as a URL. A call mutates on an explicit POST, PUT, PATCH or DELETE, on a method the guard cannot read ("$M"), or on data flags without GET or HEAD. An unbalanced quote around a release path blocks rather than guesses. Tests: 14 new cases. Against the previous guard, 10 of them fail: the three gaps, the full URL, the method from a variable, and the three false positives. All eight script suites pass. 200 dash-words now take about 15 ms. A command wrapped in `bash -c` or fed to a shell through a heredoc is still unseen by every check of the guard; that is issue #157. The recovery section now says the guard's silence there is no permission. Assisted-by: claude-code:claude-opus-5-5 Agent-Session: https://claude.ai/code/session_01Ajsba9TsThQzrPKu1RxL5W Agent-Host: 0493f0 Signed-off-by: Sebastian Mendel --- .../references/recovery-procedures.md | 10 +- .../scripts/guard-gh-release.py | 124 +++++++++++++----- .../guard-gh-release-invocations.test.sh | 17 +++ 3 files changed, 113 insertions(+), 38 deletions(-) diff --git a/skills/github-release/references/recovery-procedures.md b/skills/github-release/references/recovery-procedures.md index 0c16864..14888af 100644 --- a/skills/github-release/references/recovery-procedures.md +++ b/skills/github-release/references/recovery-procedures.md @@ -340,10 +340,12 @@ lists files outside the repository (a CurseForge `displayName`, for example) may keep the project name; the GitHub release page shows the repository name already. -**Recovery** (existing releases): the agent cannot do it. `guard-gh-release.py` -blocks `gh release edit --title` and every mutating `gh api` call on a -releases endpoint, with no override, and that is intended. Hand the -maintainer a script instead, and let them run it with the `!` prefix: +**Recovery** (existing releases): this is the maintainer's step, not the +agent's. `guard-gh-release.py` blocks `gh release edit --title` and a +mutating `gh api` call on a releases endpoint, with no override, and that is +intended. It reads the command it is shown; a call wrapped in `bash -c "…"` +or in a script is beyond it, so do not read its silence there as permission. +Hand the maintainer a script instead, and let them run it with the `!` prefix: 1. List the releases whose title is exactly ` `: `gh api "repos/$R/releases?per_page=100" --paginate --jq '.[] | select(.name == " " + .tag_name) | "\(.id)\t\(.tag_name)"'`. diff --git a/skills/github-release/scripts/guard-gh-release.py b/skills/github-release/scripts/guard-gh-release.py index c3dea11..409f83b 100755 --- a/skills/github-release/scripts/guard-gh-release.py +++ b/skills/github-release/scripts/guard-gh-release.py @@ -24,6 +24,7 @@ import json import re +import shlex import sys # Both guards share one invocation parser; sys.path[0] is this directory when @@ -87,29 +88,90 @@ def block(reason: str, suggestion: str) -> None: ALLOWED_RELEASE_SUBCOMMANDS = {"view", "list", "download"} # gh api calls to release endpoints with mutating methods. -# Matches patterns like: -# gh api repos/owner/repo/releases -X POST -# gh api /repos/owner/repo/releases --method DELETE -# gh api repos/owner/repo/releases/123 -X PATCH -# gh api -X PATCH "repos/$R/releases/$ID" -f name=v1 -# The path may be quoted: a script with variables writes it that way, and -# requiring it bare let every quoted form skip the check below. -GH_API_RELEASE_RE = re.compile( - INVOCATION_PREFIX - + r""" - gh\s+api\s+ # gh api - (?:(?:-\w+|--\w[\w-]*)(?:[\s=]+(?:"[^"]*"|'[^']*'|\S+))?\s+)* # optional flags (e.g. -X POST, --method=PATCH, -H "...") - ["']?/?repos/[^\s"']+/releases # release endpoint path, bare or quoted - """, - re.VERBOSE, -) - -MUTATING_METHOD_RE = re.compile( - r""" - (?:-X|--method)[\s=]*["']?(POST|PUT|PATCH|DELETE) - """, - re.VERBOSE | re.IGNORECASE, -) +# +# The call is read as the argv the shell will hand to gh, not matched as text. +# Two regexes did this before, and each shape they did not foresee was a way +# through: a quoted endpoint path ("repos/$R/releases/$ID"), a flag value with +# a space in it (-f body="new notes"), the long form --raw-field, --field=x. +# Their flags group also backtracked exponentially on a run of dash-words, +# which a 2-second hook timeout turns into a question of what the harness does +# on timeout. shlex splits the words the way the shell does, in linear time. +GH_API_RE = re.compile(INVOCATION_PREFIX + r"gh\s+api(?=\s|$)") + +# A release endpoint anywhere in a word: a bare or leading-slash path, a full +# URL, or a path whose quoting shlex could not resolve ($'...'). +_RELEASE_PATH_RE = re.compile(r"repos/[^/\s]+/[^/\s]+/releases(?:[/?#]|$)") + +# gh api flags that take a value, from `gh api --help` (gh 2.101.0). The value +# is consumed so it cannot be read as the endpoint. +_VALUE_FLAGS = { + "-X": "method", + "--method": "method", + "-f": "data", + "--raw-field": "data", + "-F": "data", + "--field": "data", + "--input": "data", + "-H": None, + "--header": None, + "-q": None, + "--jq": None, + "-t": None, + "--template": None, + "--hostname": None, + "--cache": None, + "-p": None, + "--preview": None, +} +_MUTATING_METHODS = {"POST", "PUT", "PATCH", "DELETE"} +_SAFE_METHODS = {"GET", "HEAD"} + + +def _gh_api_mutates_release(args: str): + """Return the method name if a gh api call mutates a release, else None. + + `args` is the text after `gh api`. gh sends POST when a field or an input + body is present and no method is given, so data alone is a mutation unless + the method is GET or HEAD (then the fields become query parameters). + """ + try: + words = shlex.split(args, comments=True) + except ValueError: + # Unbalanced quotes: the shell will not run this as written, but a + # release path in it is reason enough not to guess. + return "UNPARSEABLE" if _RELEASE_PATH_RE.search(args) else None + method = None + has_data = False + positionals = [] + i = 0 + while i < len(words): + word = words[i] + name, value = word, None + if word.startswith("--") and "=" in word: + name, value = word.split("=", 1) + elif len(word) > 2 and word[:2] in ("-X", "-f", "-F", "-H", "-q", "-t", "-p"): + name, value = word[:2], word[2:] + if name in _VALUE_FLAGS: + if value is None: + i += 1 + value = words[i] if i < len(words) else "" + kind = _VALUE_FLAGS[name] + if kind == "method": + method = value + elif kind == "data": + has_data = True + elif not word.startswith("-") or word == "-": + positionals.append(word) + i += 1 + if not any(_RELEASE_PATH_RE.search(w) for w in positionals): + return None + if method is not None: + upper = method.upper() + if upper in _SAFE_METHODS: + return None + # A mutating method, or one the guard cannot read ("$M"). + return upper if upper in _MUTATING_METHODS else method + return "POST" if has_data else None # Flags for gh release edit that modify metadata other than notes. @@ -280,18 +342,12 @@ def _check_invocation(cmd: str) -> None: ) # --- Check gh api calls to release endpoints --- - if GH_API_RELEASE_RE.match(cmd): - # If no explicit method flag, gh api defaults to GET for bare calls, - # but POST when -f/--field or --input is present. We block if a - # mutating method is specified OR if data-sending flags are present. - has_mutating_method = MUTATING_METHOD_RE.search(cmd) - has_data_flags = re.search(r"\s(-f|--field|-F|--json-field|--input)\s", cmd) - if has_mutating_method or has_data_flags: - method = "" - if has_mutating_method: - method = has_mutating_method.group(1).upper() + api = GH_API_RE.match(cmd) + if api: + method = _gh_api_mutates_release(cmd[api.end() :]) + if method: block( - f"Direct API call to release endpoint{' with ' + method + ' method' if method else ''} " + f"Direct API call to release endpoint with {method} method " f"bypasses the CI release pipeline. All mutating operations on " f"releases must go through CI.", "Use the CI release workflow to create or modify releases. " diff --git a/skills/github-release/scripts/tests/guard-gh-release-invocations.test.sh b/skills/github-release/scripts/tests/guard-gh-release-invocations.test.sh index 8a15769..e95cd1c 100755 --- a/skills/github-release/scripts/tests/guard-gh-release-invocations.test.sh +++ b/skills/github-release/scripts/tests/guard-gh-release-invocations.test.sh @@ -192,6 +192,23 @@ check 2 'api data flag only, quoted path' 'gh api "repos/o/r/releases/1" -f name check 2 'api --method=PATCH, quoted path' 'gh api --method=PATCH "repos/o/r/releases/1"' check 2 'api method in quotes' 'gh api repos/o/r/releases/1 -X "DELETE"' check 0 'api GET, quoted path' 'gh api "repos/o/r/releases?per_page=100" --paginate --jq .' +# The call is read as argv. Before that, a flag value with a space in it ended +# the flag list early, and the long data flags were not recognised. +check 2 'api data value with a space before the path' 'gh api -f body="new notes" -X PATCH "repos/o/r/releases/1"' +check 2 'api data value with a space, no method' 'gh api -f body="new notes" "repos/o/r/releases/1"' +check 2 'api --raw-field creates a release' 'gh api repos/o/r/releases --raw-field tag_name=v1' +check 2 'api --field=value' 'gh api "repos/o/r/releases/1" --field=name=v1' +check 2 'api attached -f value' 'gh api "repos/o/r/releases/1" -fname=v1' +check 2 'api attached -X value' 'gh api -XPATCH "repos/o/r/releases/1"' +check 2 'api full URL' 'gh api -X PATCH https://api.github.com/repos/o/r/releases/1' +# shellcheck disable=SC2016 # the guard must see "$M" unexpanded +check 2 'api method from a variable' 'gh api -X "$M" repos/o/r/releases/1' +check 2 'api unbalanced quote around a release path' 'gh api -X PATCH "repos/o/r/releases/1' +check 0 'api GET with query fields' 'gh api -X GET "repos/o/r/releases" -f per_page=100' +check 0 'api jq text that mentions a method' "gh api \"repos/o/r/releases\" --jq '.[] | select(.name==\"-X POST\")'" +check 0 'api PATCH on a pull request' 'gh api -X PATCH "repos/o/r/pulls/1" -f title=x' +check 0 'api PATCH on a repo named like releases' 'gh api -X PATCH "repos/o/releases-app/pulls/1" -f title=x' +check 0 'api GET on the latest release' 'gh api repos/o/r/releases/latest' if [[ "$fail" == 0 ]]; then printf '\nAll gh-release invocation tests passed\n' From b60cd37b3e35861feb31073be9c55e7be2912c8e Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Thu, 24 Sep 2026 07:01:42 +0200 Subject: [PATCH 4/8] fix(guard): owner/repo in one variable, "#" in a word, shorthand groups, {owner} From the third review of this branch, which found a regression in the previous commit and three older gaps in the same check. - The release path pattern required owner and repository as two path segments, so `gh api -X PATCH "repos/$R/releases/$ID"` passed: the very command the recovery section prints. The previous regex caught it. Owner and repository may now sit in one segment. A repository literally named "releases" is read as a release path too, which errs toward blocking. - shlex with comments=True treated a "#" inside a word as a comment, which bash does not: `-H X-A:a#b -X DELETE` lost its method. comments is dropped; a real trailing comment can only make the guard stricter. - Shorthand groups are read as pflag reads them: boolean letters are skipped and the first letter that takes a value ends the group, so `-iX DELETE`, `-iXDELETE` and `-if tag_name=v1` are seen. - The shared splitter cut at every "{" and "}", so `repos/{owner}/{repo}`, the form gh documents, never reached the check. A brace now separates only as a word of its own, as the shell's reserved word does; the brace group test of the tag guard still passes. Tests: 11 new cases, the recovery-doc command among them verbatim. Against the previous commit, all 10 new blocking cases fail. All eight script suites pass; ruff is clean. Assisted-by: claude-code:claude-opus-5-5 Agent-Session: https://claude.ai/code/session_01Ajsba9TsThQzrPKu1RxL5W Agent-Host: 0493f0 Signed-off-by: Sebastian Mendel --- skills/github-release/scripts/_invocations.py | 6 ++- .../scripts/guard-gh-release.py | 38 ++++++++++++++----- .../guard-gh-release-invocations.test.sh | 19 ++++++++++ 3 files changed, 52 insertions(+), 11 deletions(-) diff --git a/skills/github-release/scripts/_invocations.py b/skills/github-release/scripts/_invocations.py index c5e892e..99257bd 100644 --- a/skills/github-release/scripts/_invocations.py +++ b/skills/github-release/scripts/_invocations.py @@ -17,6 +17,10 @@ # splitting it: a commit message holding a ";" is one invocation, not two. The # separator set carries the grouping constructs, so an invocation inside a # subshell, a brace group or a command substitution is still seen (issue #112). +# A brace separates only as a word of its own, as the shell's reserved word +# does: "{" or "}" with a blank or another separator on both sides. Inside a +# word it is text -- "repos/{owner}/{repo}/releases", "${R}", "{a,b}" -- and +# splitting there cut a gh api path in pieces the checks never saw. # # The escapes are not decoration. A double-quoted argument may contain \", and # reading that as the closing quote shifts every quote after it by one, which @@ -24,7 +28,7 @@ # separator outside quotes is likewise literal text, not a separator: the shell # passes it to the command rather than ending it. QUOTED_SPAN_OR_SEPARATOR = re.compile( - r"""\"(?:\\.|[^"\\])*\"|'[^']*'|\\.|(?P[;&|\n(){}]+)""" + r"""\"(?:\\.|[^"\\])*\"|'[^']*'|\\.|(?P[;&|\n()]+|(? None: GH_API_RE = re.compile(INVOCATION_PREFIX + r"gh\s+api(?=\s|$)") # A release endpoint anywhere in a word: a bare or leading-slash path, a full -# URL, or a path whose quoting shlex could not resolve ($'...'). -_RELEASE_PATH_RE = re.compile(r"repos/[^/\s]+/[^/\s]+/releases(?:[/?#]|$)") +# URL, or a path whose quoting shlex could not resolve ($'...'). Owner and +# repository may sit in one segment, because a variable often holds both: +# "repos/$R/releases/$ID", "repos/$GITHUB_REPOSITORY/releases". A repository +# literally named "releases" is therefore read as a release path too, which +# errs toward blocking. +_RELEASE_PATH_RE = re.compile(r"repos/(?:[^/\s]+/){1,2}releases(?:[/?#]|$)") # gh api flags that take a value, from `gh api --help` (gh 2.101.0). The value # is consumed so it cannot be read as the endpoint. @@ -135,7 +139,11 @@ def _gh_api_mutates_release(args: str): the method is GET or HEAD (then the fields become query parameters). """ try: - words = shlex.split(args, comments=True) + # No comments=True: shlex would then treat a "#" inside a word as a + # comment, which bash does not ("-H X-A:a#b -X DELETE" lost its + # method). A real trailing comment is kept as words and can only make + # the guard stricter. + words = shlex.split(args) except ValueError: # Unbalanced quotes: the shell will not run this as written, but a # release path in it is reason enough not to guess. @@ -146,11 +154,23 @@ def _gh_api_mutates_release(args: str): i = 0 while i < len(words): word = words[i] - name, value = word, None - if word.startswith("--") and "=" in word: - name, value = word.split("=", 1) - elif len(word) > 2 and word[:2] in ("-X", "-f", "-F", "-H", "-q", "-t", "-p"): - name, value = word[:2], word[2:] + name, value = None, None + if word.startswith("--"): + name, _, attached = word.partition("=") + value = attached if "=" in word else None + elif word.startswith("-") and len(word) > 1: + # A shorthand group, read as pflag reads it: boolean letters + # ("-i") are skipped, and the first letter that takes a value ends + # the group -- the rest of the word is its value ("-iXDELETE", + # "-if tag_name=v1"). + for j in range(1, len(word)): + if "-" + word[j] in _VALUE_FLAGS: + name = "-" + word[j] + rest = word[j + 1 :] + value = rest.removeprefix("=") or None + break + else: + positionals.append(word) if name in _VALUE_FLAGS: if value is None: i += 1 @@ -160,8 +180,6 @@ def _gh_api_mutates_release(args: str): method = value elif kind == "data": has_data = True - elif not word.startswith("-") or word == "-": - positionals.append(word) i += 1 if not any(_RELEASE_PATH_RE.search(w) for w in positionals): return None diff --git a/skills/github-release/scripts/tests/guard-gh-release-invocations.test.sh b/skills/github-release/scripts/tests/guard-gh-release-invocations.test.sh index e95cd1c..8a3c68c 100755 --- a/skills/github-release/scripts/tests/guard-gh-release-invocations.test.sh +++ b/skills/github-release/scripts/tests/guard-gh-release-invocations.test.sh @@ -209,6 +209,25 @@ check 0 'api jq text that mentions a method' "gh api \"repos/o/r/releases\" --jq check 0 'api PATCH on a pull request' 'gh api -X PATCH "repos/o/r/pulls/1" -f title=x' check 0 'api PATCH on a repo named like releases' 'gh api -X PATCH "repos/o/releases-app/pulls/1" -f title=x' check 0 'api GET on the latest release' 'gh api repos/o/r/releases/latest' +# Owner and repository in one variable, as scripts and workflows write them. +# The first case is the command references/recovery-procedures.md prints. +# shellcheck disable=SC2016 # the guard must see the variables unexpanded +check 2 'api PATCH, owner/repo in one variable (recovery doc)' 'gh api -X PATCH "repos/$R/releases/$ID" -f name="$TAG"' +# shellcheck disable=SC2016 +check 2 'api PATCH, $GITHUB_REPOSITORY' 'gh api -X PATCH repos/$GITHUB_REPOSITORY/releases/$ID -f name=x' +# shellcheck disable=SC2016 +check 2 'api data, ${R} in braces' 'gh api "repos/${R}/releases" -f tag_name=v1' +# gh fills in {owner}/{repo} itself; the braces are part of the word. +check 2 'api DELETE, {owner}/{repo} placeholders' 'gh api -X DELETE repos/{owner}/{repo}/releases/1' +check 2 'api data, {owner}/{repo} placeholders' 'gh api repos/{owner}/{repo}/releases -f tag_name=v1' +# A "#" inside a word is not a comment in bash. +check 2 'api "#" inside a header value' 'gh api -H X-A:a#b -X DELETE repos/o/r/releases/1' +check 2 'api "#" at the end of the path' 'gh api repos/o/r/releases/1#x -X DELETE' +# Shorthand groups, read the way pflag reads them. +check 2 'api -iX DELETE' 'gh api -iX DELETE repos/o/r/releases/1' +check 2 'api -iXDELETE' 'gh api -iXDELETE repos/o/r/releases/1' +check 2 'api -if creates a release' 'gh api -if tag_name=v1 repos/o/r/releases' +check 0 'api -i on a release read' 'gh api -i repos/o/r/releases/latest' if [[ "$fail" == 0 ]]; then printf '\nAll gh-release invocation tests passed\n' From c357d78cb3aac6c3eaa08d5d9873b2cfe876f2d7 Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Thu, 24 Sep 2026 07:10:30 +0200 Subject: [PATCH 5/8] fix(guard): cut comments as bash does, keep ${...} prefixes and ${ cmd; } From the fourth review of this branch, which found three regressions in the previous commit and one older gap. - Dropping comments=True kept a trailing comment's words, and the method is read last-wins: `-X DELETE # -X GET` passed, and a release path inside a comment blocked an unrelated POST. The arguments are now cut at the first unquoted "#" that begins a word, before shlex splits them. A "#" inside a word stays part of it. - Braces inside a word no longer split, and the assignment pattern in INVOCATION_PREFIX stopped at the blank in `a=${X:-foo bar}`, so the command after it was not seen by either guard. The value may now hold ${...}; the alternatives do not overlap, so it cannot backtrack. - bash 5.3's `${ cmd; }` command substitution was no longer split: "${" followed by a blank now separates. - The numeric route `repositories//releases` is a release path. - A method flag with no value no longer reads as an allowed method. Tests: seven new cases in the gh-release suite and six in the tag suite. Against the previous commit, all ten new blocking cases fail; the control case (braces inside words) passes on both. All eight script suites pass; ruff is clean. Assisted-by: claude-code:claude-opus-5-5 Agent-Session: https://claude.ai/code/session_01Ajsba9TsThQzrPKu1RxL5W Agent-Host: 0493f0 Signed-off-by: Sebastian Mendel --- skills/github-release/scripts/_invocations.py | 12 +++++-- .../scripts/guard-gh-release.py | 33 +++++++++++++++---- .../guard-gh-release-invocations.test.sh | 10 ++++++ .../tests/guard-tag-invocations.test.sh | 10 ++++++ 4 files changed, 55 insertions(+), 10 deletions(-) diff --git a/skills/github-release/scripts/_invocations.py b/skills/github-release/scripts/_invocations.py index 99257bd..43f5e1c 100644 --- a/skills/github-release/scripts/_invocations.py +++ b/skills/github-release/scripts/_invocations.py @@ -20,7 +20,9 @@ # A brace separates only as a word of its own, as the shell's reserved word # does: "{" or "}" with a blank or another separator on both sides. Inside a # word it is text -- "repos/{owner}/{repo}/releases", "${R}", "{a,b}" -- and -# splitting there cut a gh api path in pieces the checks never saw. +# splitting there cut a gh api path in pieces the checks never saw. "${" with +# a blank after it opens bash 5.3's ${ cmd; } command substitution, so it +# separates as well. # # The escapes are not decoration. A double-quoted argument may contain \", and # reading that as the closing quote shifts every quote after it by one, which @@ -28,7 +30,7 @@ # separator outside quotes is likewise literal text, not a separator: the shell # passes it to the command rather than ending it. QUOTED_SPAN_OR_SEPARATOR = re.compile( - r"""\"(?:\\.|[^"\\])*\"|'[^']*'|\\.|(?P[;&|\n()]+|(?[;&|\n()]+|\$\{(?=\s)|(? None: # "repos/$R/releases/$ID", "repos/$GITHUB_REPOSITORY/releases". A repository # literally named "releases" is therefore read as a release path too, which # errs toward blocking. -_RELEASE_PATH_RE = re.compile(r"repos/(?:[^/\s]+/){1,2}releases(?:[/?#]|$)") +# The numeric route "repositories//releases" reaches the same releases. +_RELEASE_PATH_RE = re.compile( + r"(?:repos/(?:[^/\s]+/){1,2}|repositories/[^/\s]+/)releases(?:[/?#]|$)" +) + +# A shell comment: an unquoted "#" that begins a word. Everything after it is +# text the shell never passes on. A "#" inside a word ("a#b", "releases/1#x") +# is part of that word. +_COMMENT_START = re.compile( + r"""\"(?:\\.|[^"\\])*\"|'[^']*'|\\.|(?P(?:^|(?<=\s))#)""" +) + + +def _without_comment(args: str) -> str: + """Cut a command's arguments at a real shell comment.""" + for match in _COMMENT_START.finditer(args): + if match.group("comment") is not None: + return args[: match.start()] + return args + # gh api flags that take a value, from `gh api --help` (gh 2.101.0). The value # is consumed so it cannot be read as the endpoint. @@ -139,11 +158,11 @@ def _gh_api_mutates_release(args: str): the method is GET or HEAD (then the fields become query parameters). """ try: - # No comments=True: shlex would then treat a "#" inside a word as a - # comment, which bash does not ("-H X-A:a#b -X DELETE" lost its - # method). A real trailing comment is kept as words and can only make - # the guard stricter. - words = shlex.split(args) + # The comment is cut the way bash cuts it, before splitting. shlex's + # own comments=True treats a "#" inside a word as a comment ("-H + # X-A:a#b -X DELETE" lost its method), and keeping a comment's words + # let "# -X GET" override the real method. + words = shlex.split(_without_comment(args)) except ValueError: # Unbalanced quotes: the shell will not run this as written, but a # release path in it is reason enough not to guess. @@ -188,7 +207,7 @@ def _gh_api_mutates_release(args: str): if upper in _SAFE_METHODS: return None # A mutating method, or one the guard cannot read ("$M"). - return upper if upper in _MUTATING_METHODS else method + return upper if upper in _MUTATING_METHODS else (method or "UNREADABLE") return "POST" if has_data else None diff --git a/skills/github-release/scripts/tests/guard-gh-release-invocations.test.sh b/skills/github-release/scripts/tests/guard-gh-release-invocations.test.sh index 8a3c68c..889b5fd 100755 --- a/skills/github-release/scripts/tests/guard-gh-release-invocations.test.sh +++ b/skills/github-release/scripts/tests/guard-gh-release-invocations.test.sh @@ -228,6 +228,16 @@ check 2 'api -iX DELETE' 'gh api -iX DELETE repos/o/r/releases/1' check 2 'api -iXDELETE' 'gh api -iXDELETE repos/o/r/releases/1' check 2 'api -if creates a release' 'gh api -if tag_name=v1 repos/o/r/releases' check 0 'api -i on a release read' 'gh api -i repos/o/r/releases/latest' +# A shell comment is cut the way bash cuts it: words after it never reach gh. +check 2 'api DELETE with a comment naming GET' 'gh api repos/o/r/releases/1 -X DELETE # -X GET' +check 2 'api data with a comment naming GET' 'gh api repos/o/r/releases -f tag_name=v1 # -X GET' +check 0 'api POST elsewhere, release path only in a comment' 'gh api repos/o/r/issues -X POST # repos/o/r/releases' +check 2 'api method flag without a value' 'gh api repos/o/r/releases/1 -X DELETE -X' +check 2 'api numeric repositories route' 'gh api repositories/123/releases/1 -X DELETE' +# shellcheck disable=SC2016 # the guard must see the expansion unexpanded +check 2 'create after a ${...} assignment with a blank' 'a=${X:-foo bar} gh release create v1.2.3' +# shellcheck disable=SC2016 +check 2 'create in a ${ cmd; } substitution' 'echo ${ gh release create v1.2.3; }' if [[ "$fail" == 0 ]]; then printf '\nAll gh-release invocation tests passed\n' diff --git a/skills/github-release/scripts/tests/guard-tag-invocations.test.sh b/skills/github-release/scripts/tests/guard-tag-invocations.test.sh index 795e8ed..dbce9a1 100755 --- a/skills/github-release/scripts/tests/guard-tag-invocations.test.sh +++ b/skills/github-release/scripts/tests/guard-tag-invocations.test.sh @@ -160,6 +160,16 @@ check 0 'the command name inside a commit message' 'git commit -m "git tag v1.2. check 2 'lightweight tag in a subshell' '(git tag v1.2.3)' check 2 'lightweight tag in a subshell after a listing' 'git tag -l && (git tag v1.2.3)' check 2 'lightweight tag in a brace group' '{ git tag v1.2.3; }' +check 2 'lightweight tag in a brace group, no blank before }' '{ git tag v1.2.3;}' +# A brace inside a word is text. An assignment whose ${...} holds a blank +# still counts as a prefix, and bash 5.3's ${ cmd; } still opens a command. +# shellcheck disable=SC2016 # the guard must see the expansions unexpanded +check 2 'lightweight tag after a ${...} assignment with a blank' 'a=${X:-foo bar} git tag v1.2.3' +# shellcheck disable=SC2016 +check 2 'lightweight tag after a ${//} assignment' 'a=${X// /_} git tag v1.2.3' +# shellcheck disable=SC2016 +check 2 'lightweight tag in a ${ cmd; } substitution' 'echo ${ git tag v1.2.3; }' +check 0 'braces inside words do not make a command' 'echo {a,b} x}{ git-tag' # The single quotes are the point: the guard has to receive the substitution # as literal text, exactly as the harness would hand it over. # shellcheck disable=SC2016 From 9fd71631b3400e613d92bc09a008dc15511dedf6 Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Thu, 24 Sep 2026 07:16:01 +0200 Subject: [PATCH 6/8] fix(guard): judge a gh api call with and without its comment cut off From the fifth review of this branch. The comment cutter added in the previous commit is a text scan, and it read a "#" as a comment where bash does not: inside `${V/ #/}`, and after a `$'...'` string with an escaped quote, which shifted its quote tracking. Either way it cut off a real `-X DELETE`, and the call passed as a GET. A gh api call is now judged twice, with and without the cut, and blocks if either reading does. Cutting is still needed, because kept, a comment's words override the real method (`-X DELETE # -X GET`); judging the uncut text as well means a mistake in the cutter can only block. The cost: a release path or method that appears only in a comment blocks again, as it did before the cutter; that test's expectation changes from 0 to 2, with the reason next to it. Tests: the two reviewer commands. Against the previous commit, both and the changed case fail. Keeping only the cut reading fails the three; keeping only the uncut reading fails the two `# -X GET` cases. All eight script suites pass; ruff and shellcheck are clean. Assisted-by: claude-code:claude-opus-5-5 Agent-Session: https://claude.ai/code/session_01Ajsba9TsThQzrPKu1RxL5W Agent-Host: 0493f0 Signed-off-by: Sebastian Mendel --- .../scripts/guard-gh-release.py | 30 ++++++++++++++----- .../guard-gh-release-invocations.test.sh | 9 +++++- 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/skills/github-release/scripts/guard-gh-release.py b/skills/github-release/scripts/guard-gh-release.py index f93f1c2..32f341c 100755 --- a/skills/github-release/scripts/guard-gh-release.py +++ b/skills/github-release/scripts/guard-gh-release.py @@ -153,16 +153,30 @@ def _without_comment(args: str) -> str: def _gh_api_mutates_release(args: str): """Return the method name if a gh api call mutates a release, else None. - `args` is the text after `gh api`. gh sends POST when a field or an input - body is present and no method is given, so data alone is a mutation unless - the method is GET or HEAD (then the fields become query parameters). + `args` is the text after `gh api`. The call is judged twice, with and + without a trailing shell comment cut off, and it mutates if either reading + says so. Cutting is needed: kept, a comment's words override the real + method ("-X DELETE # -X GET"). Cutting alone is not safe: the cutter is a + text scan, and a "#" bash does not treat as a comment -- inside ${V/ #/}, + or after a $'...' string with an escaped quote -- made it drop a real + "-X DELETE". Judging both ways, a mistake in either reading can only block. + A method or path that appears only in a comment therefore blocks too, which + is the safe direction. + """ + return _judge_gh_api(_without_comment(args)) or _judge_gh_api(args) + + +def _judge_gh_api(args: str): + """One reading of a gh api call; see _gh_api_mutates_release. + + gh sends POST when a field or an input body is present and no method is + given, so data alone is a mutation unless the method is GET or HEAD (then + the fields become query parameters). """ try: - # The comment is cut the way bash cuts it, before splitting. shlex's - # own comments=True treats a "#" inside a word as a comment ("-H - # X-A:a#b -X DELETE" lost its method), and keeping a comment's words - # let "# -X GET" override the real method. - words = shlex.split(_without_comment(args)) + # No comments=True: shlex would treat a "#" inside a word as a + # comment, which bash does not ("-H X-A:a#b -X DELETE"). + words = shlex.split(args) except ValueError: # Unbalanced quotes: the shell will not run this as written, but a # release path in it is reason enough not to guess. diff --git a/skills/github-release/scripts/tests/guard-gh-release-invocations.test.sh b/skills/github-release/scripts/tests/guard-gh-release-invocations.test.sh index 889b5fd..a8a0122 100755 --- a/skills/github-release/scripts/tests/guard-gh-release-invocations.test.sh +++ b/skills/github-release/scripts/tests/guard-gh-release-invocations.test.sh @@ -231,7 +231,14 @@ check 0 'api -i on a release read' 'gh api -i repos/o/r/releases/latest' # A shell comment is cut the way bash cuts it: words after it never reach gh. check 2 'api DELETE with a comment naming GET' 'gh api repos/o/r/releases/1 -X DELETE # -X GET' check 2 'api data with a comment naming GET' 'gh api repos/o/r/releases -f tag_name=v1 # -X GET' -check 0 'api POST elsewhere, release path only in a comment' 'gh api repos/o/r/issues -X POST # repos/o/r/releases' +# The call is also judged without the comment cut, so a release path that +# appears only in a comment blocks. That is the safe direction: the cutter is a +# text scan, and a "#" bash does not read as a comment must never cost a real +# method (the two cases below). +check 2 'api POST elsewhere, release path only in a comment' 'gh api repos/o/r/issues -X POST # repos/o/r/releases' +# shellcheck disable=SC2016 # the guard must see the expansion unexpanded +check 2 'api "#" inside a ${...} expansion' 'gh api repos/o/r/releases/1 -H X-A:${V/ #/} -X DELETE' +check 2 "api \"#\" after a \$'...' string with an escaped quote" "gh api repos/o/r/releases/1 -H \$'X-A: a\\'b' -H 'X-B: #c' -X DELETE" check 2 'api method flag without a value' 'gh api repos/o/r/releases/1 -X DELETE -X' check 2 'api numeric repositories route' 'gh api repositories/123/releases/1 -X DELETE' # shellcheck disable=SC2016 # the guard must see the expansion unexpanded From 8b5ad7af9d1c57e30f8b9ed4539eb5064d9f0ea0 Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Thu, 24 Sep 2026 07:25:20 +0200 Subject: [PATCH 7/8] docs(guard): say why an apostrophe in a trailing comment blocks a read From the sixth review of this branch, which found no command blocked before and allowed now (115 fixed cases, 750,000 random inputs). It noted that a release read with an apostrophe in its trailing comment ("# don't mutate") is now blocked: the uncut reading sees an unclosed quote. The reviewer's narrowing of that was measured to reopen a 2 -> 0 path in a variant, so the block stays and the docstring says so. Comment only. Assisted-by: claude-code:claude-opus-5-5 Agent-Session: https://claude.ai/code/session_01Ajsba9TsThQzrPKu1RxL5W Agent-Host: 0493f0 Signed-off-by: Sebastian Mendel --- skills/github-release/scripts/guard-gh-release.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/skills/github-release/scripts/guard-gh-release.py b/skills/github-release/scripts/guard-gh-release.py index 32f341c..6106e78 100755 --- a/skills/github-release/scripts/guard-gh-release.py +++ b/skills/github-release/scripts/guard-gh-release.py @@ -161,7 +161,10 @@ def _gh_api_mutates_release(args: str): or after a $'...' string with an escaped quote -- made it drop a real "-X DELETE". Judging both ways, a mistake in either reading can only block. A method or path that appears only in a comment therefore blocks too, which - is the safe direction. + is the safe direction. So does a release read whose trailing comment holds + an apostrophe ("# don't mutate"): the uncut reading sees an unclosed quote + and cannot parse it. Narrowing that was measured to reopen a 2 -> 0 path, + so the block stays; drop the apostrophe or the comment. """ return _judge_gh_api(_without_comment(args)) or _judge_gh_api(args) From 22b0c58c93a8ad6d3f00bece57ed43ef9b7966c3 Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Thu, 24 Sep 2026 07:36:18 +0200 Subject: [PATCH 8/8] fix(guard): read quoted and escaped blanks in an assignment prefix An assignment before the command ended at the first blank, so a value with a quoted or escaped blank hid the command from both guards: A="x y" gh release create v1.2.3, A='x y' ..., A=x\ y ... and the same before git tag were all allowed. The value may now hold a double-quoted span, a single-quoted span and an escaped character. Each alternative starts with a different character, so the pattern stays linear. Found by CodeRabbit on this branch. The seven new blocking cases fail against the previous prefix; one allowed case per suite pins that a harmless command after such a prefix still passes. Assisted-by: claude-code:claude-opus-5-5 Agent-Session: https://claude.ai/code/session_01Ajsba9TsThQzrPKu1RxL5W Agent-Host: 0493f0 Signed-off-by: Sebastian Mendel --- skills/github-release/scripts/_invocations.py | 9 ++++++--- .../scripts/tests/guard-gh-release-invocations.test.sh | 5 +++++ .../scripts/tests/guard-tag-invocations.test.sh | 4 ++++ 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/skills/github-release/scripts/_invocations.py b/skills/github-release/scripts/_invocations.py index 43f5e1c..a939013 100644 --- a/skills/github-release/scripts/_invocations.py +++ b/skills/github-release/scripts/_invocations.py @@ -45,9 +45,12 @@ r"(?:(?:then|else|elif|do|if|while|until|sudo|command|time|exec|env|nohup)\s+" # The value of an assignment may hold a ${...} with blanks inside # ("a=${X:-foo bar} git tag v1"); \S* stopped at the blank and the prefix - # failed, hiding the command. The alternatives do not overlap, so the - # pattern cannot backtrack. - r"|[A-Za-z_][A-Za-z0-9_]*=(?:\$\{[^}]*\}|[^\s$]|\$(?!\{))*\s+)*" + # failed, hiding the command. Quoted spans and escaped characters carry + # blanks the same way ('A="x y" gh release create', "A=x\ y git tag"). + # Each alternative starts with a different character, so the pattern + # cannot backtrack. + r"|[A-Za-z_][A-Za-z0-9_]*=" + r"""(?:"(?:\\.|[^"\\])*"|'[^']*'|\$\{[^}]*\}|\\.|[^\s$"'\\]|\$(?!\{))*\s+)*""" ) diff --git a/skills/github-release/scripts/tests/guard-gh-release-invocations.test.sh b/skills/github-release/scripts/tests/guard-gh-release-invocations.test.sh index a8a0122..90c4771 100755 --- a/skills/github-release/scripts/tests/guard-gh-release-invocations.test.sh +++ b/skills/github-release/scripts/tests/guard-gh-release-invocations.test.sh @@ -243,6 +243,11 @@ check 2 'api method flag without a value' 'gh api repos/o/r/releases/1 -X DELETE check 2 'api numeric repositories route' 'gh api repositories/123/releases/1 -X DELETE' # shellcheck disable=SC2016 # the guard must see the expansion unexpanded check 2 'create after a ${...} assignment with a blank' 'a=${X:-foo bar} gh release create v1.2.3' +check 2 'create after a double-quoted assignment with a blank' 'A="x y" gh release create v1.2.3' +check 2 'create after a single-quoted assignment with a blank' "A='x y' gh release create v1.2.3" +check 2 'create after an escaped blank in an assignment' 'A=x\ y gh release create v1.2.3' +check 2 'api DELETE after a quoted assignment with a blank' 'A="x y" gh api -X DELETE repos/o/r/releases/1' +check 0 'view after a quoted assignment with a blank' 'A="x y" gh release view v1.2.3' # shellcheck disable=SC2016 check 2 'create in a ${ cmd; } substitution' 'echo ${ gh release create v1.2.3; }' diff --git a/skills/github-release/scripts/tests/guard-tag-invocations.test.sh b/skills/github-release/scripts/tests/guard-tag-invocations.test.sh index dbce9a1..57697da 100755 --- a/skills/github-release/scripts/tests/guard-tag-invocations.test.sh +++ b/skills/github-release/scripts/tests/guard-tag-invocations.test.sh @@ -167,6 +167,10 @@ check 2 'lightweight tag in a brace group, no blank before }' '{ git tag v1.2.3; check 2 'lightweight tag after a ${...} assignment with a blank' 'a=${X:-foo bar} git tag v1.2.3' # shellcheck disable=SC2016 check 2 'lightweight tag after a ${//} assignment' 'a=${X// /_} git tag v1.2.3' +check 2 'lightweight tag after a double-quoted assignment with a blank' 'a="x y" git tag v1.2.3' +check 2 'lightweight tag after a single-quoted assignment with a blank' "a='x y' git tag v1.2.3" +check 2 'lightweight tag after an escaped blank in an assignment' 'a=x\ y git tag v1.2.3' +check 0 'signed tag after a quoted assignment with a blank' 'a="x y" git tag -s v1.2.3 -m v1.2.3' # shellcheck disable=SC2016 check 2 'lightweight tag in a ${ cmd; } substitution' 'echo ${ git tag v1.2.3; }' check 0 'braces inside words do not make a command' 'echo {a,b} x}{ git-tag'