diff --git a/.github/workflows/upstream-release-docs.yml b/.github/workflows/upstream-release-docs.yml index f00545cb..c581b852 100644 --- a/.github/workflows/upstream-release-docs.yml +++ b/.github/workflows/upstream-release-docs.yml @@ -509,8 +509,10 @@ jobs: # `renovate[bot]`, while `gh pr view`/`gh release view` # return `app/toolhive-release-app` for the same class of # identity. + # GitHub logins are case-insensitive; preserve their spelling + # in outputs while comparing bot names without case sensitivity. is_bot() { - printf '%s\n' "$1" | grep -qE '(\[bot\]$|^app/|^github-actions|^stacklokbot$|^dependabot|^renovate|^copilot)' + printf '%s\n' "$1" | grep -qiE '(\[bot\]$|^app/|^github-actions|^stacklokbot$|^dependabot|^renovate|^copilot)' } # ---------- compare the release range ---------- @@ -557,8 +559,15 @@ jobs: COMMIT_MESSAGE=$(printf '%s' "$COMMIT_JSON" | jq -r '.commit.message // ""' 2>/dev/null || true) # Squash merges can separate this trailer from Co-authored-by # with a blank line, so inspect the complete commit message. - INITIATOR=$(printf '%s\n' "$COMMIT_MESSAGE" \ - | sed -n 's/^Release-Triggered-By:[[:blank:]]*//p') + # Count before extraction: command substitution strips empty + # trailing values, which could hide an empty duplicate trailer. + INITIATOR_COUNT=$(printf '%s\n' "$COMMIT_MESSAGE" \ + | grep -c '^Release-Triggered-By:' || true) + INITIATOR="" + if [ "$INITIATOR_COUNT" -eq 1 ]; then + INITIATOR=$(printf '%s\n' "$COMMIT_MESSAGE" \ + | sed -n 's/^Release-Triggered-By:[[:blank:]]*//p') + fi # Accept a single GitHub login. Missing, malformed, duplicate, # or bot trailers fall through to the existing owner sources. if [[ "$INITIATOR" =~ ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$ ]] && ! is_bot "$INITIATOR"; then diff --git a/scripts/upstream-release/workflow.test.mjs b/scripts/upstream-release/workflow.test.mjs index 6f17a36a..f6a53a5a 100644 --- a/scripts/upstream-release/workflow.test.mjs +++ b/scripts/upstream-release/workflow.test.mjs @@ -197,10 +197,15 @@ for (const annotated of [true, false]) { for (const message of [ 'Release v0.60.0', 'Release v0.60.0\n\nRelease-Triggered-By: renovate', + 'Release v0.60.0\n\nRelease-Triggered-By: Renovate', + 'Release v0.60.0\n\nRelease-Triggered-By: StacklokBot', 'Release v0.60.0\n\nRelease-Triggered-By: toolhive-release-app[bot]', 'Release v0.60.0\n\nRelease-Triggered-By: app/toolhive-release-app', 'Release v0.60.0\n\nRelease-Triggered-By: invalid login', 'Release v0.60.0\n\nRelease-Triggered-By: alice\nRelease-Triggered-By: bob', + 'Release v0.60.0\n\nRelease-Triggered-By: alice\nRelease-Triggered-By:', + 'Release v0.60.0\n\nRelease-Triggered-By:\nRelease-Triggered-By: alice', + 'Release v0.60.0\n\nRelease-Triggered-By: alice\nRelease-Triggered-By: \t', 'Release v0.60.0\n\nDocumentation mentions Release-Triggered-By: alice', ]) { test(`falls back to the release PR merger for unusable metadata: ${JSON.stringify(message)}`, (t) => { @@ -244,3 +249,23 @@ test('leaves the owner unresolved when all sources are bots', (t) => { assert.equal(meta.owner, null); assert.equal(meta.owner_source, null); }); + +for (const source of ['releaseAuthor', 'merger', 'commitAuthor']) { + test(`rejects mixed-case bots in the ${source} fallback`, (t) => { + const meta = resolveOwner(t, { + message: '', + releaseAuthor: 'app/release-bot', + merger: 'app/release-bot', + commitAuthor: 'app/release-bot', + [source]: 'Renovate', + }); + assert.equal(meta.owner, null); + }); +} + +test('preserves the case of a human initiator login', (t) => { + const meta = resolveOwner(t, { + message: 'Release v0.60.0\n\nRelease-Triggered-By: ChrisJBurns', + }); + assert.equal(meta.owner, 'ChrisJBurns'); +});