diff --git a/README.md b/README.md index f87d91a8..b0d3a957 100644 --- a/README.md +++ b/README.md @@ -676,7 +676,8 @@ fetch still failed — naming which source it used, meaning that credential cannot read one of the LEG repositories, never the root, which no longer depends on it — checked via job-level `env: SHAPE_LEGS_APP_SET` / `SHAPE_LEGS_TOKEN_SET`, not `secrets` in the step `if:` (disallowed there; -MedxEHR PR #8, MedxGlass PR #1: zero-job push runs). +MedxEHR PR #8, MedxGlass PR #1: zero-job push runs). A `.gitmodules` that git +refuses fails the run too, whatever the credential, naming the key or line. ### A descendant form is a claim; a claim needs a referent diff --git a/docs/handbook.html b/docs/handbook.html index 76b5ad83..8df78127 100644 --- a/docs/handbook.html +++ b/docs/handbook.html @@ -474,7 +474,7 @@ - + @@ -1126,7 +1126,7 @@

If it fails part-way

- +

Setup

Reading private legs in CI

@@ -1179,7 +1179,8 @@

Reading private legs in CI

naming and manifest checks and skips validate-pins.py with a warning explaining why. It fails outright only if a credential is configured and the fetch still failed — meaning that credential cannot read one of the LEG repositories, never - the root. + the root. A .gitmodules that git refuses fails the run too, whatever the + credential, naming the key or line. Full text — private legs in CI @@ -1188,7 +1189,7 @@

Reading private legs in CI

- +

Reference

The three legs, and naming

diff --git a/templates/assembly-root/.github/workflows/validate.yml b/templates/assembly-root/.github/workflows/validate.yml index 8ad2ee05..731464c0 100644 --- a/templates/assembly-root/.github/workflows/validate.yml +++ b/templates/assembly-root/.github/workflows/validate.yml @@ -67,6 +67,21 @@ jobs: # in all three: it could print a workflow command of its own, and git # cannot clone it. And each repository is named once, however many # sections or paths give it. + # + # And only what git reads (#786), by the rules the doctor and `siblings.py` + # read the same file by (#733): only a `submodule..url` key counts; + # a section whose name is empty or holds a `..` component is ignored, as is + # one with no `path`; a value that begins with `-` is ignored, that value + # alone, so an earlier one stands; nothing is read while `.gitmodules` is + # unmerged; and a file git refuses whole (one it cannot parse, a `path`, + # `url`, `ignore`, `update` or `branch` with no value, an `update` other + # than none, checkout, rebase or merge, a `shallow` that is no boolean, a + # `fetchRecurseSubmodules` that is neither a boolean nor on-demand) fails + # the step with its cause and the key or line, since git reads none of it + # and a repository named from it would be one git never fetches. The two + # steps below that print the urls read by the same key rules; the fetch + # step refuses a file git refuses, and the failing step is never reached + # with one. - name: leg repository names from .gitmodules id: leg-names if: env.SHAPE_LEGS_APP_SET == 'true' @@ -75,9 +90,49 @@ jobs: run: | set -e legs="" - if [ -f .gitmodules ]; then + if [ -f .gitmodules ] && [ -z "$(git ls-files -u -- .gitmodules)" ]; then + # git reads none of a file it refuses, and neither does this (#786). Whatever + # of the file is printed is workflow-command data: % and CR are escaped. + esc() { sed -e 's/%/%25/g' -e "s/$(printf '\r')/%0D/g"; } + if ! err=$(git config --file .gitmodules --list 2>&1 >/dev/null); then + err=$(printf '%s\n' "$err" | sed -n '$s/^fatal: //p' | esc) + printf '%s\n' "::error::.gitmodules holds a line git cannot parse${err:+ ($err)}, so git refuses the whole file: it reads none of it, and no repository is named from it. git submodule status names the line git stops at." + exit 1 + fi + cause() { printf '%s (%s)\n' "$1" "$(printf '%s' "$key" | esc)"; } + us=$(printf '\001') + refusal=$(git config -z --file .gitmodules --list | tr '\000\n' '\n\001' | while IFS= read -r rec; do + case $rec in *"$us"*) value=${rec#*"$us"}; bare= ;; *) value=; bare=y ;; esac + key=${rec%%"$us"*} + section=${key%.*} + case $section in submodule.?*) ;; *) continue ;; esac + case /${section#submodule.}/ in *[/\\]..[/\\]*) continue ;; esac + field=${key##*.} + case $field in + path|url|ignore|update|branch) + [ -z "$bare" ] || { case $field in ignore|update) a=an ;; *) a=a ;; esac; cause "$a $field key with no value"; break; } ;; + esac + case $field in + update) + case $value in none|checkout|rebase|merge) ;; *) cause "an update that is not none, checkout, rebase or merge"; break ;; esac ;; + shallow) + git -c "x.y=$value" config --type=bool --get x.y >/dev/null 2>&1 || { cause "a shallow that is not a boolean"; break; } ;; + fetchrecursesubmodules) + [ "$value" = on-demand ] || git -c "x.y=$value" config --type=bool --get x.y >/dev/null 2>&1 || { cause "a fetchRecurseSubmodules that is neither a boolean nor on-demand"; break; } ;; + esac + done || true) + if [ -n "$refusal" ]; then + printf '%s\n' "::error::.gitmodules holds $refusal, so git refuses the whole file: it reads none of it, and no repository is named from it. git submodule status names the key and line git stops at." + exit 1 + fi while IFS= read -r key; do - url=$(git config --file .gitmodules --get -- "$key") || continue + case $key in submodule.?*.url) ;; *) continue ;; esac + name=${key#submodule.} + name=${name%.url} + case /$name/ in *[/\\]..[/\\]*) continue ;; esac + # A value beginning with - is ignored, that value alone: git keeps an earlier one. + url=$(git config --file .gitmodules --get -- "$key" '^([^-]|$)') || continue + git config --file .gitmodules --get -- "submodule.$name.path" '^([^-]|$)' >/dev/null || continue # A url holding a line break could print a workflow command of its own. case $url in *' '*|*"$(printf '\r')"*) continue ;; esac @@ -151,7 +206,9 @@ jobs: # carry: `https://github.com/...` and SSH `git@github.com:...`. With no # credential configured this falls back to a plain, unauthenticated # `git submodule update --init --recursive` — the same command that - # already ran here, unchanged, for a project whose legs are public. + # already ran here, unchanged, for a project whose legs are public. A fetch + # that fails over a `.gitmodules` git refuses is no such degrade: no + # credential cures it, so the step names the cause and fails (#786). - name: fetch the legs (submodules) id: submodules env: @@ -185,14 +242,60 @@ jobs: echo "legs_available=true" >> "$GITHUB_OUTPUT" else echo "legs_available=false" >> "$GITHUB_OUTPUT" + readable= + if [ -f .gitmodules ] && [ -z "$(git ls-files -u -- .gitmodules)" ]; then readable=y; fi + if [ -n "$readable" ]; then + # git reads none of a file it refuses, and neither does this (#786). Whatever + # of the file is printed is workflow-command data: % and CR are escaped. + esc() { sed -e 's/%/%25/g' -e "s/$(printf '\r')/%0D/g"; } + if ! err=$(git config --file .gitmodules --list 2>&1 >/dev/null); then + err=$(printf '%s\n' "$err" | sed -n '$s/^fatal: //p' | esc) + printf '%s\n' "::error::.gitmodules holds a line git cannot parse${err:+ ($err)}, so git refuses the whole file: it reads none of it, and no repository is named from it. git submodule status names the line git stops at." + exit 1 + fi + cause() { printf '%s (%s)\n' "$1" "$(printf '%s' "$key" | esc)"; } + us=$(printf '\001') + refusal=$(git config -z --file .gitmodules --list | tr '\000\n' '\n\001' | while IFS= read -r rec; do + case $rec in *"$us"*) value=${rec#*"$us"}; bare= ;; *) value=; bare=y ;; esac + key=${rec%%"$us"*} + section=${key%.*} + case $section in submodule.?*) ;; *) continue ;; esac + case /${section#submodule.}/ in *[/\\]..[/\\]*) continue ;; esac + field=${key##*.} + case $field in + path|url|ignore|update|branch) + [ -z "$bare" ] || { case $field in ignore|update) a=an ;; *) a=a ;; esac; cause "$a $field key with no value"; break; } ;; + esac + case $field in + update) + case $value in none|checkout|rebase|merge) ;; *) cause "an update that is not none, checkout, rebase or merge"; break ;; esac ;; + shallow) + git -c "x.y=$value" config --type=bool --get x.y >/dev/null 2>&1 || { cause "a shallow that is not a boolean"; break; } ;; + fetchrecursesubmodules) + [ "$value" = on-demand ] || git -c "x.y=$value" config --type=bool --get x.y >/dev/null 2>&1 || { cause "a fetchRecurseSubmodules that is neither a boolean nor on-demand"; break; } ;; + esac + done || true) + if [ -n "$refusal" ]; then + printf '%s\n' "::error::.gitmodules holds $refusal, so git refuses the whole file: it reads none of it, and no repository is named from it. git submodule status names the key and line git stops at." + exit 1 + fi + fi echo "::warning::submodule checkout failed (git exit $status) using credential source '$legs_credential'. If the legs are PRIVATE or internal sibling repositories, the default GITHUB_TOKEN cannot read them. Configure a GitHub App (SHAPE_LEGS_APP_ID + SHAPE_LEGS_APP_PRIVATE_KEY, preferred) or set SHAPE_LEGS_TOKEN — a fine-grained PAT — with contents:read on the legs, as a repository or organisation secret. Leg URLs:" - git config --file .gitmodules --name-only --get-regexp '\.url$' | while IFS= read -r key; do - url=$(git config --file .gitmodules --get -- "$key") || continue - # A url holding a line break could print a workflow command of its own. - case $url in *' + if [ -n "$readable" ]; then + git config --file .gitmodules --name-only --get-regexp '\.url$' | awk '!seen[$0]++' | while IFS= read -r key; do + case $key in submodule.?*.url) ;; *) continue ;; esac + name=${key#submodule.} + name=${name%.url} + case /$name/ in *[/\\]..[/\\]*) continue ;; esac + # A value beginning with - is ignored, that value alone: git keeps an earlier one. + url=$(git config --file .gitmodules --get -- "$key" '^([^-]|$)') || continue + git config --file .gitmodules --get -- "submodule.$name.path" '^([^-]|$)' >/dev/null || continue + # A url holding a line break could print a workflow command of its own. + case $url in *' '*|*"$(printf '\r')"*) continue ;; esac - echo "::warning:: $url" - done + echo "::warning:: $url" + done + fi fi - uses: actions/setup-python@v5 @@ -229,19 +332,30 @@ jobs: # a credential that IS configured (App or PAT) that still could not # check out the legs (wrong scope, missing contents:read grant, an # incomplete App installation, a leg URL problem) is a finding a human - # must act on, so this fails the job — the only case that does. The - # root checkout above never depended on either credential, so a - # failure here can only mean the credential named below cannot read one - # of the LEG repositories. + # must act on, so this fails the job — the only case that does, but for a + # `.gitmodules` git refuses, which the fetch step above fails whatever the + # credential, since none cures it. The root checkout above never depended + # on either credential, so a failure here can only mean the credential + # named below cannot read one of the LEG repositories. - name: fail — a leg credential is configured but the legs still could not be fetched if: steps.submodules.outputs.legs_available != 'true' && (env.SHAPE_LEGS_TOKEN_SET == 'true' || env.SHAPE_LEGS_APP_SET == 'true') run: | echo "::error::A leg credential is configured (source: ${{ steps.submodules.outputs.legs_credential }}) but 'git submodule update --init --recursive' still failed. The root checkout does not use this token — it reads with the workflow's default token — so this means the '${{ steps.submodules.outputs.legs_credential }}' credential cannot read one or more of the following LEG repositories (wrong token/App scope, a missing contents:read grant, an incomplete App installation, or a leg URL problem):" - git config --file .gitmodules --name-only --get-regexp '\.url$' | while IFS= read -r key; do - url=$(git config --file .gitmodules --get -- "$key") || continue - # A url holding a line break could print a workflow command of its own. - case $url in *' + if [ -f .gitmodules ] && [ -z "$(git ls-files -u -- .gitmodules)" ]; then + # No refusal here: the fetch step above exits 1 on a file git refuses, so + # this step is never reached with one. + git config --file .gitmodules --name-only --get-regexp '\.url$' | awk '!seen[$0]++' | while IFS= read -r key; do + case $key in submodule.?*.url) ;; *) continue ;; esac + name=${key#submodule.} + name=${name%.url} + case /$name/ in *[/\\]..[/\\]*) continue ;; esac + # A value beginning with - is ignored, that value alone: git keeps an earlier one. + url=$(git config --file .gitmodules --get -- "$key" '^([^-]|$)') || continue + git config --file .gitmodules --get -- "submodule.$name.path" '^([^-]|$)' >/dev/null || continue + # A url holding a line break could print a workflow command of its own. + case $url in *' '*|*"$(printf '\r')"*) continue ;; esac - echo "::error:: $url" - done + echo "::error:: $url" + done + fi exit 1 diff --git a/templates/assembly-root/README.md b/templates/assembly-root/README.md index 1ae66d50..c7d3f2e3 100644 --- a/templates/assembly-root/README.md +++ b/templates/assembly-root/README.md @@ -110,7 +110,9 @@ fetch still failed — naming which source it used. That presence check reads job-level `env: SHAPE_LEGS_APP_SET` / `SHAPE_LEGS_TOKEN_SET` booleans rather than the `secrets` context directly in the step's `if:` — the `secrets` context is not allowed in a step-level `if:` expression, and using it there -makes GitHub reject the whole workflow file instead of just that step. +makes GitHub reject the whole workflow file instead of just that step. A +`.gitmodules` that git refuses fails the run too, whatever the credential, +naming the key or line. ## The lockstep invariant diff --git a/templates/family-root/.github/workflows/validate.yml b/templates/family-root/.github/workflows/validate.yml index 56c5dbef..5da6fc2c 100644 --- a/templates/family-root/.github/workflows/validate.yml +++ b/templates/family-root/.github/workflows/validate.yml @@ -63,6 +63,21 @@ jobs: # skipped in all three: it could print a workflow command of its own, # and git cannot clone it. And each member is named once, however many # sections or paths give it. + # + # And only what git reads (#786), by the rules the doctor and `siblings.py` + # read the same file by (#733): only a `submodule..url` key counts; + # a section whose name is empty or holds a `..` component is ignored, as is + # one with no `path`; a value that begins with `-` is ignored, that value + # alone, so an earlier one stands; nothing is read while `.gitmodules` is + # unmerged; and a file git refuses whole (one it cannot parse, a `path`, + # `url`, `ignore`, `update` or `branch` with no value, an `update` other + # than none, checkout, rebase or merge, a `shallow` that is no boolean, a + # `fetchRecurseSubmodules` that is neither a boolean nor on-demand) fails + # the step with its cause and the key or line, since git reads none of it + # and a repository named from it would be one git never fetches. The two + # steps below that print the urls read by the same key rules; the fetch + # step refuses a file git refuses, and the failing step is never reached + # with one. - name: member repository names from .gitmodules id: member-names if: env.SHAPE_LEGS_APP_SET == 'true' @@ -71,9 +86,49 @@ jobs: run: | set -e members="" - if [ -f .gitmodules ]; then + if [ -f .gitmodules ] && [ -z "$(git ls-files -u -- .gitmodules)" ]; then + # git reads none of a file it refuses, and neither does this (#786). Whatever + # of the file is printed is workflow-command data: % and CR are escaped. + esc() { sed -e 's/%/%25/g' -e "s/$(printf '\r')/%0D/g"; } + if ! err=$(git config --file .gitmodules --list 2>&1 >/dev/null); then + err=$(printf '%s\n' "$err" | sed -n '$s/^fatal: //p' | esc) + printf '%s\n' "::error::.gitmodules holds a line git cannot parse${err:+ ($err)}, so git refuses the whole file: it reads none of it, and no repository is named from it. git submodule status names the line git stops at." + exit 1 + fi + cause() { printf '%s (%s)\n' "$1" "$(printf '%s' "$key" | esc)"; } + us=$(printf '\001') + refusal=$(git config -z --file .gitmodules --list | tr '\000\n' '\n\001' | while IFS= read -r rec; do + case $rec in *"$us"*) value=${rec#*"$us"}; bare= ;; *) value=; bare=y ;; esac + key=${rec%%"$us"*} + section=${key%.*} + case $section in submodule.?*) ;; *) continue ;; esac + case /${section#submodule.}/ in *[/\\]..[/\\]*) continue ;; esac + field=${key##*.} + case $field in + path|url|ignore|update|branch) + [ -z "$bare" ] || { case $field in ignore|update) a=an ;; *) a=a ;; esac; cause "$a $field key with no value"; break; } ;; + esac + case $field in + update) + case $value in none|checkout|rebase|merge) ;; *) cause "an update that is not none, checkout, rebase or merge"; break ;; esac ;; + shallow) + git -c "x.y=$value" config --type=bool --get x.y >/dev/null 2>&1 || { cause "a shallow that is not a boolean"; break; } ;; + fetchrecursesubmodules) + [ "$value" = on-demand ] || git -c "x.y=$value" config --type=bool --get x.y >/dev/null 2>&1 || { cause "a fetchRecurseSubmodules that is neither a boolean nor on-demand"; break; } ;; + esac + done || true) + if [ -n "$refusal" ]; then + printf '%s\n' "::error::.gitmodules holds $refusal, so git refuses the whole file: it reads none of it, and no repository is named from it. git submodule status names the key and line git stops at." + exit 1 + fi while IFS= read -r key; do - url=$(git config --file .gitmodules --get -- "$key") || continue + case $key in submodule.?*.url) ;; *) continue ;; esac + name=${key#submodule.} + name=${name%.url} + case /$name/ in *[/\\]..[/\\]*) continue ;; esac + # A value beginning with - is ignored, that value alone: git keeps an earlier one. + url=$(git config --file .gitmodules --get -- "$key" '^([^-]|$)') || continue + git config --file .gitmodules --get -- "submodule.$name.path" '^([^-]|$)' >/dev/null || continue # A url holding a line break could print a workflow command of its own. case $url in *' '*|*"$(printf '\r')"*) continue ;; esac @@ -152,7 +207,9 @@ jobs: # members AND their legs, which is why the App token above is scoped to # both. Whichever credential resolves is scoped to THIS STEP alone via # `env:` and used only through a `git -c url.<...>.insteadOf=<...>` - # rewrite, never persisted and never printed. + # rewrite, never persisted and never printed. A fetch that fails over a + # `.gitmodules` git refuses is no such degrade: no credential cures it, so + # the step names the cause and fails (#786). - name: fetch the members (submodules) id: submodules env: @@ -186,14 +243,60 @@ jobs: echo "members_available=true" >> "$GITHUB_OUTPUT" else echo "members_available=false" >> "$GITHUB_OUTPUT" + readable= + if [ -f .gitmodules ] && [ -z "$(git ls-files -u -- .gitmodules)" ]; then readable=y; fi + if [ -n "$readable" ]; then + # git reads none of a file it refuses, and neither does this (#786). Whatever + # of the file is printed is workflow-command data: % and CR are escaped. + esc() { sed -e 's/%/%25/g' -e "s/$(printf '\r')/%0D/g"; } + if ! err=$(git config --file .gitmodules --list 2>&1 >/dev/null); then + err=$(printf '%s\n' "$err" | sed -n '$s/^fatal: //p' | esc) + printf '%s\n' "::error::.gitmodules holds a line git cannot parse${err:+ ($err)}, so git refuses the whole file: it reads none of it, and no repository is named from it. git submodule status names the line git stops at." + exit 1 + fi + cause() { printf '%s (%s)\n' "$1" "$(printf '%s' "$key" | esc)"; } + us=$(printf '\001') + refusal=$(git config -z --file .gitmodules --list | tr '\000\n' '\n\001' | while IFS= read -r rec; do + case $rec in *"$us"*) value=${rec#*"$us"}; bare= ;; *) value=; bare=y ;; esac + key=${rec%%"$us"*} + section=${key%.*} + case $section in submodule.?*) ;; *) continue ;; esac + case /${section#submodule.}/ in *[/\\]..[/\\]*) continue ;; esac + field=${key##*.} + case $field in + path|url|ignore|update|branch) + [ -z "$bare" ] || { case $field in ignore|update) a=an ;; *) a=a ;; esac; cause "$a $field key with no value"; break; } ;; + esac + case $field in + update) + case $value in none|checkout|rebase|merge) ;; *) cause "an update that is not none, checkout, rebase or merge"; break ;; esac ;; + shallow) + git -c "x.y=$value" config --type=bool --get x.y >/dev/null 2>&1 || { cause "a shallow that is not a boolean"; break; } ;; + fetchrecursesubmodules) + [ "$value" = on-demand ] || git -c "x.y=$value" config --type=bool --get x.y >/dev/null 2>&1 || { cause "a fetchRecurseSubmodules that is neither a boolean nor on-demand"; break; } ;; + esac + done || true) + if [ -n "$refusal" ]; then + printf '%s\n' "::error::.gitmodules holds $refusal, so git refuses the whole file: it reads none of it, and no repository is named from it. git submodule status names the key and line git stops at." + exit 1 + fi + fi echo "::warning::submodule checkout failed (git exit $status) using credential source '$members_credential'. If the members are PRIVATE or internal sibling repositories, the default GITHUB_TOKEN cannot read them. Configure a GitHub App (SHAPE_LEGS_APP_ID + SHAPE_LEGS_APP_PRIVATE_KEY, preferred) or set SHAPE_LEGS_TOKEN — a fine-grained PAT — with contents:read on the members and their legs. Member URLs:" - git config --file .gitmodules --name-only --get-regexp '\.url$' | while IFS= read -r key; do - url=$(git config --file .gitmodules --get -- "$key") || continue - # A url holding a line break could print a workflow command of its own. - case $url in *' + if [ -n "$readable" ]; then + git config --file .gitmodules --name-only --get-regexp '\.url$' | awk '!seen[$0]++' | while IFS= read -r key; do + case $key in submodule.?*.url) ;; *) continue ;; esac + name=${key#submodule.} + name=${name%.url} + case /$name/ in *[/\\]..[/\\]*) continue ;; esac + # A value beginning with - is ignored, that value alone: git keeps an earlier one. + url=$(git config --file .gitmodules --get -- "$key" '^([^-]|$)') || continue + git config --file .gitmodules --get -- "submodule.$name.path" '^([^-]|$)' >/dev/null || continue + # A url holding a line break could print a workflow command of its own. + case $url in *' '*|*"$(printf '\r')"*) continue ;; esac - echo "::warning:: $url" - done + echo "::warning:: $url" + done + fi fi - uses: actions/setup-python@v5 @@ -224,18 +327,30 @@ jobs: # Misconfiguration must not be silent. Neither credential being set # degrading a family with private members is expected and reported # above; a credential that IS configured and still could not fetch is a - # finding a human must act on, so this fails the job — the only case - # that does. The root checkout never depended on it, so a failure here - # can only mean that credential cannot read a MEMBER or one of its legs. + # finding a human must act on, so this fails the job — the only case that + # does, but for a `.gitmodules` git refuses, which the fetch step above + # fails whatever the credential, since none cures it. The root checkout + # never depended on it, so a failure here can only mean that credential + # cannot read a MEMBER or one of its legs. - name: fail — a credential is configured but the members still could not be fetched if: steps.submodules.outputs.members_available != 'true' && (env.SHAPE_LEGS_TOKEN_SET == 'true' || env.SHAPE_LEGS_APP_SET == 'true') run: | echo "::error::A member credential is configured (source: ${{ steps.submodules.outputs.members_credential }}) but 'git submodule update --init --recursive' still failed. The root checkout does not use this token — it reads with the workflow's default token — so this means the '${{ steps.submodules.outputs.members_credential }}' credential cannot read one or more of the following MEMBER repositories, or a leg of one (wrong token/App scope, a missing contents:read grant, an incomplete App installation, or a URL problem):" - git config --file .gitmodules --name-only --get-regexp '\.url$' | while IFS= read -r key; do - url=$(git config --file .gitmodules --get -- "$key") || continue - # A url holding a line break could print a workflow command of its own. - case $url in *' + if [ -f .gitmodules ] && [ -z "$(git ls-files -u -- .gitmodules)" ]; then + # No refusal here: the fetch step above exits 1 on a file git refuses, so + # this step is never reached with one. + git config --file .gitmodules --name-only --get-regexp '\.url$' | awk '!seen[$0]++' | while IFS= read -r key; do + case $key in submodule.?*.url) ;; *) continue ;; esac + name=${key#submodule.} + name=${name%.url} + case /$name/ in *[/\\]..[/\\]*) continue ;; esac + # A value beginning with - is ignored, that value alone: git keeps an earlier one. + url=$(git config --file .gitmodules --get -- "$key" '^([^-]|$)') || continue + git config --file .gitmodules --get -- "submodule.$name.path" '^([^-]|$)' >/dev/null || continue + # A url holding a line break could print a workflow command of its own. + case $url in *' '*|*"$(printf '\r')"*) continue ;; esac - echo "::error:: $url" - done + echo "::error:: $url" + done + fi exit 1 diff --git a/templates/family-root/README.md b/templates/family-root/README.md index 9bfa6aa9..7e85df96 100644 --- a/templates/family-root/README.md +++ b/templates/family-root/README.md @@ -140,7 +140,8 @@ The same credentials the assembly roots use apply here, and are tried in the same order: a GitHub App (`SHAPE_LEGS_APP_ID` + `SHAPE_LEGS_APP_PRIVATE_KEY`, preferred, minted per run) then a fine-grained `SHAPE_LEGS_TOKEN` PAT with `contents:read`. With neither, the workflow degrades: it still runs the checks -that do not need the members checked out. +that do not need the members checked out. A `.gitmodules` that git refuses +fails the run whatever the credential, naming the key or line. **On the GitHub Free plan, set these as REPOSITORY secrets.** GitHub delivers an ORGANISATION secret only to PUBLIC repositories on Free, so on a private diff --git a/tests/readme-cap.d/786.txt b/tests/readme-cap.d/786.txt new file mode 100644 index 00000000..4e7f1721 --- /dev/null +++ b/tests/readme-cap.d/786.txt @@ -0,0 +1,7 @@ ++1 +2026-10-17 +One line for one sentence #790 (#786) leaves false in the README once it lands: +"fails outright only if a credential is configured" said a failed fetch with no +credential never fails the run. A `.gitmodules` git refuses now fails the fetch +step whatever the credential, since none cures it and a green run over it hid +it, so the paragraph says so in the words the pull request's own body gives. diff --git a/tests/test_validate_workflow_gitmodules_urls.py b/tests/test_validate_workflow_gitmodules_urls.py index 5dfb96d1..0b23ffcb 100644 --- a/tests/test_validate_workflow_gitmodules_urls.py +++ b/tests/test_validate_workflow_gitmodules_urls.py @@ -23,6 +23,14 @@ printed it could be a workflow command of its own; each repository is named once however many sections or paths give it; and a url key outside a `submodule` section, even `[-x] url`, is read without git's usage dump. + +And the rules the doctor and `siblings.py` read the same file by (#786, from +#733): `RULES` holds one row per rule, each a `.gitmodules` and the urls git +registers for it. Three things check a row. `git submodule init` itself says +what git registers, a `siblings.submodule_urls` that has #775's rules agrees +(it skips until they are in main), and each of the three steps in both +templates names or lists exactly that, or fails with the cause where git +refuses the whole file. """ from __future__ import annotations @@ -31,11 +39,13 @@ import re import shutil import subprocess +import sys +from dataclasses import dataclass from pathlib import Path import pytest -from conftest import REPO, WINDOWS_SKIP, git +from conftest import REPO, WINDOWS_SKIP, git, load_without_bytecode from test_scaffold_e2e import _step_run_script #: What each template calls its steps. The names step's output is `legs=` in @@ -56,16 +66,17 @@ } EACH_TEMPLATE = pytest.mark.parametrize("template", sorted(TEMPLATES)) -#: Section name -> url, in the order the file lists them. The first three are -#: names git allows and `--get-regexp`'s `key value` line cannot hold: a -#: space, a tab, and a leading `-` with `#` and `;`. The fourth is a url, not -#: a name, with the same bytes in it (a leading `-`, a space, a tab, `#`, `;` -#: and a trailing space); it names no repository, so the names step skips it. +#: Section name -> url, in the order the file lists them; each section mounts +#: at `mounts/`. The first three are names git allows and +#: `--get-regexp`'s `key value` line cannot hold: a space, a tab, and a +#: leading `-` with `#` and `;`. The fourth is a url, not a name, with the +#: same bytes in it (a space, a tab, `#`, `;` and a trailing space); it names +#: no repository, so the names step skips it. SECTIONS = { "members/My IRRS": "https://github.com/acme/IRRS.git", "tab\there": "git@github.com:acme/Tabbed.git", "-lead #hash;semi": "https://github.com/acme/Odd.git", - "plain": "-odd url\twith #hash;semi ", + "plain": "odd url\twith #hash;semi ", } THE_LEGS = "IRRS,Tabbed,Odd" #: Urls the steps skip, given by git's `\n` escape and a raw carriage return: @@ -79,21 +90,31 @@ EACH_STEP = pytest.mark.parametrize("step", ["names", "fetch", "fail"]) -def acme(name: str, scheme: str = "https://github.com/") -> str: - return f"url = {scheme}acme/{name}.git\n" +def acme(name: str) -> str: + return f"https://github.com/acme/{name}.git" + + +def section(name: str, *keys: str) -> str: + """A `[submodule "name"]` with each of `keys` on a line of its own.""" + return f'[submodule "{name}"]\n' + "".join(f"\t{key}\n" for key in keys) + + +def mounted(name: str, url: str, *keys: str, at: str = "") -> str: + """`section`, mounted at `at`, or at a path named for it, giving `url`.""" + return section(name, f"path = {at or name}", f"url = {url}", *keys) #: Each case a repository could be named twice, as a `.gitmodules` text and #: the repositories the names step names for it: once each, first seen first. DUPLICATES = { "a url given twice in one section": ( - '[submodule "m"]\n\t' + acme("Stale") + "\t" + acme("A"), "A"), + mounted("m", acme("Stale"), f"url = {acme('A')}"), "A"), "a section reopened further down": ( - '[submodule "m"]\n\t' + acme("Stale") + '[submodule "n"]\n\t' - + acme("B") + '[submodule "m"]\n\t' + acme("A"), "A,B"), + mounted("m", acme("Stale")) + mounted("n", acme("B")) + + section("m", f"url = {acme('A')}"), "A,B"), "two paths mounting one repository": ( - '[submodule "m"]\n\t' + acme("A") + '[submodule "n"]\n\t' - + acme("A", "git@github.com:"), "A"), + mounted("m", acme("A")) + mounted("n", "git@github.com:acme/A.git"), + "A"), } @@ -105,24 +126,184 @@ def workflow_text(template: str) -> str: @pytest.fixture def holder(tmp_path: Path) -> Path: - """A folder whose `.gitmodules` is `SECTIONS` and then `LINE_BREAKS`, + """A repository whose `.gitmodules` is `SECTIONS` and then `LINE_BREAKS`, written by git so that its quoting is git's.""" work = tmp_path / "holder" work.mkdir() + git("init", "-q", cwd=work) for name, url in {**SECTIONS, **LINE_BREAKS}.items(): + git("config", "--file", ".gitmodules", f"submodule.{name}.path", + f"mounts/{name}", cwd=work) git("config", "--file", ".gitmodules", f"submodule.{name}.url", url, cwd=work) return work -def holder_of(tmp_path: Path, gitmodules: str) -> Path: - """A folder whose `.gitmodules` is `gitmodules`, as written.""" - work = tmp_path / "holder" +@dataclass(frozen=True) +class Rule: + """One row of `RULES`: a `.gitmodules`, the paths `git submodule init` is + asked about, and what git registers for it. + + `urls` are the urls git registers, in the order of the file; none where it + reads none of the file. `refused` is the cause the steps name where git + refuses the whole file, and `named` what that message names of the file: + the key git stops at as the step prints it (`%` escaped), or its line. + `unmerged` leaves `.gitmodules` unmerged in the index with the file in the + working tree as it is, and a `gitmodules` of None writes no file at all. + """ + gitmodules: str | None + paths: tuple + urls: tuple + refused: str = "" + named: str = "" + unmerged: bool = False + + +GITLINK = "1234567890123456789012345678901234567890" + + +def holder_of(tmp_path: Path, gitmodules: str, paths=(), unmerged=False, + name: str = "holder") -> Path: + """A repository whose `.gitmodules` is `gitmodules`, as written, with a + gitlink at each of `paths` and, for `unmerged`, `.gitmodules` in + conflict in the index.""" + work = tmp_path / name work.mkdir() - (work / ".gitmodules").write_bytes(gitmodules.encode("utf-8")) + git("init", "-q", cwd=work) + if gitmodules is not None: + (work / ".gitmodules").write_bytes(gitmodules.encode("utf-8")) + for path in paths: + git("update-index", "--add", "--cacheinfo", f"160000,{GITLINK},{path}", + cwd=work) + if unmerged: + stages = "" + for stage, body in ((1, b"base\n"), (2, b"ours\n"), (3, b"theirs\n")): + blob = subprocess.run( + ["git", "hash-object", "-w", "--stdin"], cwd=work, input=body, + capture_output=True, check=True).stdout.decode().strip() + stages += f"100644 {blob} {stage}\t.gitmodules\n" + subprocess.run(["git", "update-index", "--index-info"], cwd=work, + input=stages.encode(), check=True) return work +OK_URL = acme("Ok") +OK = mounted("ok", OK_URL) + + +def accepted(*keys: str) -> Rule: + """A mounted section for each of `keys`, which it holds, and `ok`: git + reads every one of them.""" + names = [f"s{n}" for n in range(len(keys))] + text = "".join(mounted(name, acme(name.upper()), key) + for name, key in zip(names, keys)) + return Rule(text + OK, (*names, "ok"), + (*(acme(name.upper()) for name in names), OK_URL)) + + +def refusing(cause: str, *keys: str, named: str, name: str = "q", + at: str = "") -> Rule: + """`ok`, then a section named `name` and mounted at `at`, or at a path + named for it, holding `keys`: git refuses the whole file for them, `ok` + and all, and the steps say `cause`, naming `named`.""" + return Rule(OK + mounted(name, acme("Q"), *keys, at=at), + ("ok", at or name), (), cause, named) + + +NO_VALUE = " key with no value" +UPDATE = "an update that is not none, checkout, rebase or merge" +SHALLOW = "a shallow that is not a boolean" +FETCH = "a fetchRecurseSubmodules that is neither a boolean nor on-demand" + +#: One row per rule, each measured against `git submodule init` by +#: `test_git_registers_what_each_row_says`. Every row but the refusals keeps +#: `ok`, so a rule that drops too much shows. +RULES = { + "a url key outside a submodule section": Rule( + '[remote "origin"]\n\turl = ' + acme("Other") + "\n[-x]\n\turl = " + + acme("Dash") + "\n[submodule]\n\turl = " + acme("Bare") + "\n" + OK, + ("ok",), (OK_URL,)), + "an empty section name": Rule( + mounted("", acme("Empty"), at="e") + OK, ("e", "ok"), (OK_URL,)), + "a name holding a .. component": Rule( + mounted("../a", acme("DotA"), at="d1") + + mounted("a/../b", acme("DotB"), at="d2") + + mounted("..\\\\c", acme("DotC"), at="d3") + + mounted("..", acme("DotD"), at="d4") + + mounted("a/..", acme("DotE"), at="d5") + + mounted("a..b", acme("Dots"), at="d6") + OK, + ("d1", "d2", "d3", "d4", "d5", "d6", "ok"), (acme("Dots"), OK_URL)), + "a url beginning with a dash": Rule( + mounted("d", "-oProxyCommand=x") + OK, ("d", "ok"), (OK_URL,)), + "a path beginning with a dash": Rule( + section("p", "path = -p", f"url = {acme('P')}") + OK, ("-p", "ok"), + (OK_URL,)), + "a section with no path": Rule( + section("np", f"url = {acme('Nowhere')}") + OK, ("np", "ok"), + (OK_URL,)), + "a url given twice, the later beginning with a dash": Rule( + mounted("later", acme("Early"), "url = -x") + OK, ("later", "ok"), + (acme("Early"), OK_URL)), + "a path given twice, the later beginning with a dash": Rule( + section("lp", "path = lp", f"url = {acme('LP')}", "path = -p") + OK, + ("lp", "ok"), (acme("LP"), OK_URL)), + "no gitmodules at all": Rule(None, (), ()), + "a gitmodules left unmerged": Rule(OK, ("ok",), (), unmerged=True), + "a bare ignore in a section named with a backslash": Rule( + section("..\\\\x", "ignore") + OK, ("ok",), (OK_URL,)), + "a bare key where git ignores the section": Rule( + section("", "url") + section("../x", "path") + '[remote "r"]\n\turl\n' + + OK, ("ok",), (OK_URL,)), + "a file git cannot parse": Rule( + OK + '[submodule "x"\n', ("ok",), (), "a line git cannot parse", + "bad config line 5 in file .gitmodules"), + "a path with no value": refusing( + "a path" + NO_VALUE, "path", named="submodule.q.path"), + "a url with no value": refusing( + "a url" + NO_VALUE, "url", named="submodule.q.url"), + "an ignore with no value": refusing( + "an ignore" + NO_VALUE, "ignore", named="submodule.q.ignore"), + "an update with no value": refusing( + "an update" + NO_VALUE, "update", named="submodule.q.update"), + "a branch with no value": refusing( + "a branch" + NO_VALUE, "branch", named="submodule.q.branch"), + "an update git dies on": refusing( + UPDATE, "update = bogus", named="submodule.q.update"), + "an update that is a command": refusing( + UPDATE, "update = !echo hi", named="submodule.q.update"), + "an update in a section named with a percent sign": refusing( + UPDATE, "update = bogus", name="p%q", named="submodule.p%25q.update"), + "an update in a section named with a carriage return": refusing( + UPDATE, "update = bogus", name="q\rx", at="q", + named="submodule.q%0Dx.update"), + "each update git accepts": accepted( + "update = checkout", "update = merge", "update = rebase", + "update = none"), + "a shallow git dies on": refusing( + SHALLOW, "shallow = maybe", named="submodule.q.shallow"), + "each shallow git accepts": accepted( + "shallow = true", "shallow = no", "shallow = 1", "shallow = 0x10", + "shallow = ", "shallow"), + "a fetchRecurseSubmodules git dies on": refusing( + FETCH, "fetchRecurseSubmodules = sometimes", + named="submodule.q.fetchrecursesubmodules"), + "a fetchRecurseSubmodules spelt On-Demand": refusing( + FETCH, "fetchRecurseSubmodules = On-Demand", + named="submodule.q.fetchrecursesubmodules"), + "each fetchRecurseSubmodules git accepts": accepted( + "fetchRecurseSubmodules = on-demand", "fetchRecurseSubmodules = true", + "fetchRecurseSubmodules = 2", "fetchRecurseSubmodules"), +} +EACH_RULE = pytest.mark.parametrize("rule_id", sorted(RULES)) +#: Each step with each row, but for the failing step with a file git refuses: +#: the fetch step exits 1 on one, so the failing step is never reached with +#: it, and it carries no refusal of its own. +EACH_STEP_AND_RULE = pytest.mark.parametrize("step, rule_id", [ + (step, rule_id) for rule_id in sorted(RULES) + for step in ("names", "fetch", "fail") + if not (step == "fail" and RULES[rule_id].refused)]) + + def run_step(template: str, step: str, holder: Path, tmp_path: Path): """`(completed process, the step's $GITHUB_OUTPUT)` for one step's `run:` body in `holder`, under `bash -e` as the runner starts it. @@ -225,15 +406,135 @@ def test_the_names_step_names_a_repository_once(template, case, tmp_path): assert output.splitlines() == [f"{TEMPLATES[template]['output']}={names}"] +def registered(work: Path, paths) -> list: + """The urls `git submodule init` registers, asked of each of `paths` in + turn, since it stops at the first it can name no url for. It says so on + stderr.""" + urls = [] + for path in paths: + proc = subprocess.run( + ["git", "submodule", "init", "--", path], cwd=work, + capture_output=True, text=True, encoding="utf-8", check=False) + urls += re.findall(r"^Submodule '.*' \((.*)\) registered for path ", + proc.stdout + proc.stderr, re.MULTILINE) + return urls + + +@EACH_RULE +def test_git_registers_what_each_row_says(rule_id, tmp_path): + rule = RULES[rule_id] + work = holder_of(tmp_path, rule.gitmodules, rule.paths, rule.unmerged) + assert registered(work, rule.paths) == list(rule.urls) + + +@pytest.fixture +def siblings(monkeypatch): + """The TEMPLATE's `siblings.py`, once it has #775's rules; skipped until + they are in main. Loaded as `test_family_siblings_placement.py` loads it.""" + scripts = REPO / "templates" / "family-root" / "scripts" + monkeypatch.setattr(sys, "path", list(sys.path)) + monkeypatch.setitem(sys.modules, "bootstrap", load_without_bytecode( + scripts / "bootstrap.py", "ci_rules_family_bootstrap")) + module = load_without_bytecode(scripts / "siblings.py", + "ci_rules_family_siblings") + if not hasattr(module, "DOTDOT_COMPONENT"): + pytest.skip("siblings.py has no #733 rules until #775 is in main") + return module + + +@pytest.mark.parametrize("rule_id", sorted( + rule_id for rule_id, rule in RULES.items() + if not rule.unmerged)) +def test_siblings_reads_what_each_row_says(rule_id, siblings, tmp_path): + """`siblings.submodule_urls` reads the file in the working tree, so a row + that leaves it unmerged in the index is not asked. A row with a carriage + return in a section name is: `siblings.py` has read `git config -z + --list` since #805 (#690), so a CR stays inside its key, and the refused + `update` is read as `update`.""" + rule = RULES[rule_id] + work = holder_of(tmp_path, rule.gitmodules, rule.paths) + assert sorted(siblings.submodule_urls(work).values()) == sorted(rule.urls) + + +def read_by(template: str, step: str, result, output: str) -> list: + """What one step named (its repositories) or listed (its urls).""" + if step == "names": + key = TEMPLATES[template]["output"] + [named] = [line[len(key) + 1:] for line in output.splitlines() + if line.startswith(f"{key}=")] or [""] + return named.split(",") if named else [] + prefix = {"fetch": "::warning:: ", "fail": "::error:: "}[step] + return [line[len(prefix):] for line in result.stdout.split("\n") + if line.startswith(prefix)] + + +def expected_of(step: str, rule: Rule) -> list: + """What git's urls come to in one step: repository names in the names step, + the urls themselves in the two that list them.""" + if step != "names": + return list(rule.urls) + return [url.rsplit("/", 1)[-1].removesuffix(".git") for url in rule.urls] + + @WINDOWS_SKIP # it runs the step's `run:` body as the REAL bash it is -@EACH_STEP +@EACH_STEP_AND_RULE +@EACH_TEMPLATE +def test_each_step_reads_what_git_reads(template, step, rule_id, tmp_path): + rule = RULES[rule_id] + work = holder_of(tmp_path, rule.gitmodules, rule.paths, rule.unmerged) + result, output = run_step(template, step, work, tmp_path) + assert read_by(template, step, result, output) == expected_of(step, rule) + + +@WINDOWS_SKIP # it runs the step's `run:` body as the REAL bash it is +@EACH_STEP_AND_RULE @EACH_TEMPLATE -def test_a_url_key_outside_a_submodule_section_is_read_without_a_usage_dump( - template, step, tmp_path): - """`--get-regexp '\\.url$'` lists any section's url, and `git config - --get -x.url` reads `-x` as a switch: a usage dump on stderr, exit 129, - and the url swallowed by the `|| continue` after it.""" - work = holder_of(tmp_path, '[remote "origin"]\n\t' + acme("Remote") - + "[-x]\n\t" + acme("Dash")) +def test_each_step_exits_as_expected_and_prints_nothing_on_stderr( + template, step, rule_id, tmp_path): + """Only a file git refuses whole fails a step that would not fail anyway: + the failing step fails always, and nothing reaches stderr.""" + rule = RULES[rule_id] + work = holder_of(tmp_path, rule.gitmodules, rule.paths, rule.unmerged) result, _ = run_step(template, step, work, tmp_path) + failed = bool(rule.refused) or step == "fail" + assert result.returncode == int(failed) assert result.stderr == "" + + +@WINDOWS_SKIP # it runs the step's `run:` body as the REAL bash it is +@pytest.mark.parametrize("rule_id", sorted( + rule_id for rule_id, rule in RULES.items() if rule.refused)) +@pytest.mark.parametrize("step", ["names", "fetch"]) +@EACH_TEMPLATE +def test_a_file_git_refuses_whole_is_named_with_its_cause_and_its_key( + template, step, rule_id, tmp_path): + rule = RULES[rule_id] + work = holder_of(tmp_path, rule.gitmodules, rule.paths) + result, _ = run_step(template, step, work, tmp_path) + causes = [line for line in result.stdout.split("\n") + if line.startswith("::error::") and rule.refused in line] + assert len(causes) == 1 + assert rule.named in causes[0] + + +@WINDOWS_SKIP # it runs the step's `run:` body as the REAL bash it is +@EACH_TEMPLATE +def test_the_fetch_step_gives_no_credential_advice_for_a_file_git_refuses( + template, tmp_path): + rule = RULES["a url with no value"] + work = holder_of(tmp_path, rule.gitmodules, rule.paths) + result, _ = run_step(template, "fetch", work, tmp_path) + assert "submodule checkout failed" not in result.stdout + + +@WINDOWS_SKIP # it runs the step's `run:` body as the REAL bash it is +@EACH_TEMPLATE +def test_the_fetch_step_gives_its_credential_advice_before_it_lists_the_urls( + template, holder, tmp_path): + result, _ = run_step(template, "fetch", holder, tmp_path) + lines = result.stdout.split("\n") + advice = next(n for n, line in enumerate(lines) + if line.startswith("::warning::submodule checkout failed")) + first = next(n for n, line in enumerate(lines) + if line.startswith("::warning:: ")) + assert advice < first