From 0c39ea2b1024f813d2fb02e9f2be40c1115e6fdd Mon Sep 17 00:00:00 2001 From: jon8787 <112368577+jon8787@users.noreply.github.com> Date: Mon, 6 Jul 2026 22:14:49 +1000 Subject: [PATCH 1/6] UID2-7426: add shared-check-jira-key reusable workflow (commit-based Jira-key gate) IABTechLab is a GitHub team plan, where commit-metadata ruleset rules (commit_message_pattern) are Enterprise-gated and silently inert, so the native require-jira-key rule used on UnifiedID2/EUID (UID2-7312) does not enforce there. This reusable workflow reproduces the same rule as a required status check, the only team-plan-compatible mechanism. It reconstructs the squash commit GitHub will actually land (per each repo's squash_merge_commit_title/message) and requires a UID2- key or a reasoned [no-jira - reason: ] opt-out, mirroring the native ruleset and the commit_pr_and_merge marker (UID2-7400) so CI release PRs pass with no CI change. Commit-based, not PR-title, because in-scope repos use COMMIT_OR_PR_TITLE, where a single-commit PR lands the commit message rather than the PR title. Reconstruction logic validated against 12 cases (single/multi-commit, CI marker, android-sdk PR_TITLE/PR_BODY, blank opt-out, FOOUID2-1). Co-Authored-By: Claude Opus 4.8 --- .github/workflows/shared-check-jira-key.yaml | 114 +++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 .github/workflows/shared-check-jira-key.yaml diff --git a/.github/workflows/shared-check-jira-key.yaml b/.github/workflows/shared-check-jira-key.yaml new file mode 100644 index 00000000..691a09f6 --- /dev/null +++ b/.github/workflows/shared-check-jira-key.yaml @@ -0,0 +1,114 @@ +name: Shared Check Jira Key +# Required-status-check for IABTechLab uid2* repos (UID2-7426). +# +# WHY THIS EXISTS: the UnifiedID2 / European-Unified-ID orgs enforce the Jira-key +# rule natively with a GitHub `commit_message_pattern` ruleset (UID2-7312). +# IABTechLab is a GitHub *team* plan, where commit-metadata ruleset rules are +# Enterprise-gated and silently never evaluated — so the native rule is inert there. +# This reusable workflow reproduces the same rule as a required status check, the +# only team-plan-compatible mechanism. +# +# WHAT IT CHECKS: the message that will actually land in the immutable default-branch +# history — i.e. the squash commit GitHub will produce — must contain either a +# `UID2-` Jira key or a reasoned `[no-jira - reason: ]` opt-out. Because +# in-scope repos are squash-only, we reconstruct that squash commit from the PR's +# commits + title + body according to this repo's squash settings (see below), rather +# than trusting the PR title alone (which is NOT what lands for a single-commit PR on +# a repo whose squash title source is COMMIT_OR_PR_TITLE). +on: + workflow_call: + +permissions: + contents: read + pull-requests: read + +jobs: + check-jira-key: + runs-on: ubuntu-latest + steps: + - name: Require a UID2 Jira key or reasoned opt-out in the landed squash commit + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_TITLE: ${{ github.event.pull_request.title }} + PR_BODY: ${{ github.event.pull_request.body }} + shell: bash + run: | + set -euo pipefail + + # SINGLE SOURCE OF TRUTH regex. This MUST stay identical to + # `local.jira_key_pattern` in uid2-okta-configuration (github_repo_rulesets / + # github_rulesets) and the self-assertion in actions/commit_pr_and_merge, so the + # rule can't diverge between the native ruleset (UID2/EUID) and this Action (IAB). + # - key: \bUID2-[0-9]+ (uppercase, \b-anchored so FOOUID2-1 does NOT match) + # - opt-out: [no-jira - reason: ] (lowercase; \S after the colon means a + # blank/whitespace-only reason fails, so an opt-out is never silent) + PATTERN='\bUID2-[0-9]+|\[no-jira - reason:\s*\S[^\]]*\]' + + if [ -z "${PR_NUMBER:-}" ] || [ "${PR_NUMBER}" = "null" ]; then + echo "::error::shared-check-jira-key must be called from a pull_request-triggered workflow (no PR number in context)." + exit 1 + fi + + # This repo's squash-merge composition settings decide what the squash commit + # message will be (GitHub defaults: title=COMMIT_OR_PR_TITLE, body=COMMIT_MESSAGES). + CFG=$(gh api "repos/${REPO}" --jq '{t: (.squash_merge_commit_title // "COMMIT_OR_PR_TITLE"), m: (.squash_merge_commit_message // "COMMIT_MESSAGES")}') + TMODE=$(printf '%s' "$CFG" | jq -r '.t') + MMODE=$(printf '%s' "$CFG" | jq -r '.m') + + # PR commits, first page of 100 (a PR with >100 commits is pathological and out of + # scope; not using --paginate keeps this a single valid JSON array to parse). + # Each element's .commit.message is the full message. + COMMITS=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}/commits?per_page=100") + N=$(printf '%s' "$COMMITS" | jq 'length') + N=${N:-0} + ALL_MSGS=$(printf '%s' "$COMMITS" | jq -r '.[].commit.message') + FIRST_MSG=$(printf '%s' "$COMMITS" | jq -r '.[0].commit.message // ""') + + # Reconstruct the squash commit exactly as GitHub will compose it: + # subject: PR_TITLE -> always the PR title + # COMMIT_OR_PR_TITLE -> the commit message for a single-commit PR, + # else the PR title (multi-commit) + # body: COMMIT_MESSAGES -> every commit message, concatenated + # PR_BODY -> the PR description + # BLANK -> empty + if [ "$TMODE" = "PR_TITLE" ]; then + SUBJECT="$PR_TITLE" + elif [ "$N" -eq 1 ]; then + SUBJECT="$FIRST_MSG" + else + SUBJECT="$PR_TITLE" + fi + case "$MMODE" in + COMMIT_MESSAGES) BODY="$ALL_MSGS" ;; + PR_BODY) BODY="$PR_BODY" ;; + *) BODY="" ;; + esac + + # Search subject and body INDEPENDENTLY — never concatenate them. For a + # single-commit COMMIT_OR_PR_TITLE+COMMIT_MESSAGES repo the subject and body are + # the same commit message; concatenating would create a spurious cross-boundary + # match (e.g. a blank "[no-jira - reason: ]" would falsely pass by pairing its own + # "]" with the "]" of the duplicate copy). + if printf '%s' "$SUBJECT" | grep -Pq "$PATTERN" || printf '%s' "$BODY" | grep -Pq "$PATTERN"; then + echo "✓ A UID2- key or reasoned [no-jira - reason: …] opt-out is present in the commit that will land on ${GITHUB_REF_NAME:-the default branch}." + exit 0 + fi + + # Fail-closed with actionable guidance. squash title/body sources are printed so a + # confused author can see exactly which text was evaluated for THIS repo. + echo "::error title=Missing Jira key::The squash commit that will merge to the default branch has no UID2- Jira key and no reasoned opt-out." + { + echo "### ❌ Jira-key check failed" + echo "" + echo "Every change merged to the default branch must carry a Jira key **in the commit that lands** — either:" + echo "- a **\`UID2-\`** key (uppercase), or" + echo "- a reasoned opt-out **\`[no-jira - reason: ]\`** (lowercase, reason mandatory)." + echo "" + echo "This repo squashes with **title=\`${TMODE}\`**, **body=\`${MMODE}\`**, and your PR has **${N}** commit(s), so what lands is driven by your **commit message(s)**" + echo "(for a single-commit PR, the **PR title is not used** — put the key in the commit)." + echo "" + echo "Fixes: reword a commit (e.g. \`git commit --amend\`) to include \`UID2-1234\`, or add \`[no-jira - reason: ]\` for a legitimately ticketless change." + } >> "$GITHUB_STEP_SUMMARY" + exit 1 From b8afe8a7db67a0f2046a0a71d653783b59cbcad4 Mon Sep 17 00:00:00 2001 From: jon8787 <112368577+jon8787@users.noreply.github.com> Date: Mon, 6 Jul 2026 23:01:41 +1000 Subject: [PATCH 2/6] =?UTF-8?q?UID2-7426:=20address=20review=20=E2=80=94?= =?UTF-8?q?=20fail-loud=20on=20unreadable=20squash=20settings,=20live=20ti?= =?UTF-8?q?tle/body,=20first-line=20subject,=20locale,=20conditional=20gui?= =?UTF-8?q?dance;=20align=20SSOT=20regex?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Read squash settings and FAIL CLOSED (never default) when null. A token without administration:read gets null and would silently mis-evaluate PR_TITLE/PR_BODY repos (e.g. uid2-android-sdk: false-block a key-in-title PR, false-pass a key-in-commit one). Add administration:read to permissions; callers must grant it. - Fetch PR title/body LIVE (not the frozen github.event payload) so a manual re-run after a title edit re-evaluates the current title/body. - Use the commit's FIRST LINE (not the whole message) as the squash subject — a key only in a commit body does not land on BLANK / PR_BODY body modes. - Pin LC_ALL=C.UTF-8 for grep -P (matches commit_pr_and_merge; PCRE errors on non-UTF-8 locale). - Branch the failure guidance on squash title mode / commit count so authors aren't told to amend a commit when the PR title is what lands (or vice-versa). - Document rollout prerequisites in the header: require-squash-merge must be active, and commit_pr_and_merge needs a wait-for-checks (or the release actor a bypass) before this is flipped to a required check (its immediate merge API call would otherwise 405). - Align the SSOT: add \b to the commit_pr_and_merge self-assertion regex so the two in-repo copies are byte-identical (previously it lacked \b). Reconstruction logic re-verified against 16 cases (incl. first-line/body-mode and android-sdk). Co-Authored-By: Claude Opus 4.8 --- .github/workflows/shared-check-jira-key.yaml | 133 +++++++++++-------- actions/commit_pr_and_merge/action.yaml | 2 +- 2 files changed, 77 insertions(+), 58 deletions(-) diff --git a/.github/workflows/shared-check-jira-key.yaml b/.github/workflows/shared-check-jira-key.yaml index 691a09f6..bb835699 100644 --- a/.github/workflows/shared-check-jira-key.yaml +++ b/.github/workflows/shared-check-jira-key.yaml @@ -1,26 +1,39 @@ name: Shared Check Jira Key # Required-status-check for IABTechLab uid2* repos (UID2-7426). # -# WHY THIS EXISTS: the UnifiedID2 / European-Unified-ID orgs enforce the Jira-key -# rule natively with a GitHub `commit_message_pattern` ruleset (UID2-7312). -# IABTechLab is a GitHub *team* plan, where commit-metadata ruleset rules are -# Enterprise-gated and silently never evaluated — so the native rule is inert there. -# This reusable workflow reproduces the same rule as a required status check, the -# only team-plan-compatible mechanism. +# WHY THIS EXISTS: the UnifiedID2 / European-Unified-ID orgs enforce the Jira-key rule natively +# with a GitHub `commit_message_pattern` ruleset (UID2-7312). IABTechLab is a GitHub *team* plan, +# where commit-metadata ruleset rules are Enterprise-gated and silently never evaluated — so the +# native rule is inert there. This reusable workflow reproduces the same rule as a required status +# check, the only team-plan-compatible mechanism. # -# WHAT IT CHECKS: the message that will actually land in the immutable default-branch -# history — i.e. the squash commit GitHub will produce — must contain either a -# `UID2-` Jira key or a reasoned `[no-jira - reason: ]` opt-out. Because -# in-scope repos are squash-only, we reconstruct that squash commit from the PR's -# commits + title + body according to this repo's squash settings (see below), rather -# than trusting the PR title alone (which is NOT what lands for a single-commit PR on -# a repo whose squash title source is COMMIT_OR_PR_TITLE). +# WHAT IT CHECKS: the message that will actually land in the immutable default-branch history — +# the squash commit GitHub will produce — must contain a `UID2-` key or a reasoned +# `[no-jira - reason: ]` opt-out. It reconstructs that squash commit from the PR's commits +# + title + body according to this repo's squash settings, rather than trusting the PR title alone +# (which is NOT what lands for a single-commit PR on a COMMIT_OR_PR_TITLE repo). +# +# PREREQUISITES before this is wired as a REQUIRED check (UID2-7426 rollout — do not skip): +# 1. require-squash-merge must be ACTIVE on the repo. In-scope repos are NOT squash-only yet +# (require-squash-merge is still in evaluate, and merge-commit/rebase remain enabled), so until +# that flips a "Rebase and merge" can land raw keyless commits even with this check green — the +# check reconstructs the *squash* commit. Squash-only is what makes this check mean what it says. +# 2. The CI release flow (actions/commit_pr_and_merge, UID2-7400) merges via an immediate merge +# API call that does NOT wait for checks — a required check makes that PUT return 405 +# ("required status check is expected"). Before flipping to required, either add a +# wait-for-checks/retry to commit_pr_and_merge, or bypass the release actor on the +# required_status_checks rule (compliance-safe: its commits already carry the marker, asserted +# at compose time in commit_pr_and_merge). on: workflow_call: permissions: contents: read pull-requests: read + # administration: read is required so the token can read this repo's squash-merge settings + # (squash_merge_commit_title / _message). Those fields come back null to a token that lacks it, + # and the step below fails closed rather than guessing. Callers MUST also grant this permission. + administration: read jobs: check-jira-key: @@ -31,19 +44,15 @@ jobs: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} PR_NUMBER: ${{ github.event.pull_request.number }} - PR_TITLE: ${{ github.event.pull_request.title }} - PR_BODY: ${{ github.event.pull_request.body }} shell: bash run: | set -euo pipefail + export LC_ALL=C.UTF-8 # grep -P (PCRE) errors under a non-UTF-8 locale (matches commit_pr_and_merge) - # SINGLE SOURCE OF TRUTH regex. This MUST stay identical to - # `local.jira_key_pattern` in uid2-okta-configuration (github_repo_rulesets / - # github_rulesets) and the self-assertion in actions/commit_pr_and_merge, so the - # rule can't diverge between the native ruleset (UID2/EUID) and this Action (IAB). - # - key: \bUID2-[0-9]+ (uppercase, \b-anchored so FOOUID2-1 does NOT match) - # - opt-out: [no-jira - reason: ] (lowercase; \S after the colon means a - # blank/whitespace-only reason fails, so an opt-out is never silent) + # SINGLE SOURCE OF TRUTH regex. Keep in sync with local.jira_key_pattern in + # uid2-okta-configuration (github_repo_rulesets / github_rulesets) and the self-assertion + # in actions/commit_pr_and_merge. The \b prevents FOOUID2-1 from matching; a blank + # [no-jira - reason: ] fails because \S requires a non-space reason. PATTERN='\bUID2-[0-9]+|\[no-jira - reason:\s*\S[^\]]*\]' if [ -z "${PR_NUMBER:-}" ] || [ "${PR_NUMBER}" = "null" ]; then @@ -51,32 +60,36 @@ jobs: exit 1 fi - # This repo's squash-merge composition settings decide what the squash commit - # message will be (GitHub defaults: title=COMMIT_OR_PR_TITLE, body=COMMIT_MESSAGES). - CFG=$(gh api "repos/${REPO}" --jq '{t: (.squash_merge_commit_title // "COMMIT_OR_PR_TITLE"), m: (.squash_merge_commit_message // "COMMIT_MESSAGES")}') - TMODE=$(printf '%s' "$CFG" | jq -r '.t') - MMODE=$(printf '%s' "$CFG" | jq -r '.m') + # Title/body fetched LIVE (not from the frozen github.event payload) so a manual re-run + # after a title edit re-evaluates the CURRENT title/body. Callers should also trigger on + # types: [opened, edited, synchronize, reopened] so title edits auto-re-check. + PR_JSON=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}") + PR_TITLE=$(printf '%s' "$PR_JSON" | jq -r '.title // ""') + PR_BODY=$(printf '%s' "$PR_JSON" | jq -r '.body // ""') + + # This repo's squash composition decides what the squash commit message will be. These + # fields are permission-gated: a token without administration:read gets null even on a + # configured repo. Do NOT default — defaulting would silently evaluate the wrong + # composition (e.g. false-pass a PR_TITLE/PR_BODY repo like uid2-android-sdk). Fail closed. + read -r TMODE MMODE < <(gh api "repos/${REPO}" --jq '[(.squash_merge_commit_title // "null"), (.squash_merge_commit_message // "null")] | @tsv') + if [ "$TMODE" = "null" ] || [ "$MMODE" = "null" ]; then + echo "::error::Could not read ${REPO} squash-merge settings (got null) — refusing to guess the squash composition. Grant the workflow token 'administration: read' in the caller's permissions." + exit 1 + fi - # PR commits, first page of 100 (a PR with >100 commits is pathological and out of - # scope; not using --paginate keeps this a single valid JSON array to parse). - # Each element's .commit.message is the full message. COMMITS=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}/commits?per_page=100") N=$(printf '%s' "$COMMITS" | jq 'length') - N=${N:-0} ALL_MSGS=$(printf '%s' "$COMMITS" | jq -r '.[].commit.message') - FIRST_MSG=$(printf '%s' "$COMMITS" | jq -r '.[0].commit.message // ""') + FIRST_LINE=$(printf '%s' "$COMMITS" | jq -r '(.[0].commit.message // "") | split("\n")[0]') - # Reconstruct the squash commit exactly as GitHub will compose it: - # subject: PR_TITLE -> always the PR title - # COMMIT_OR_PR_TITLE -> the commit message for a single-commit PR, - # else the PR title (multi-commit) - # body: COMMIT_MESSAGES -> every commit message, concatenated - # PR_BODY -> the PR description - # BLANK -> empty - if [ "$TMODE" = "PR_TITLE" ]; then - SUBJECT="$PR_TITLE" - elif [ "$N" -eq 1 ]; then - SUBJECT="$FIRST_MSG" + # Reconstruct the squash commit GitHub will compose for this repo: + # subject: PR_TITLE mode -> the PR title. COMMIT_OR_PR_TITLE -> the single commit's + # FIRST LINE for a 1-commit PR (GitHub uses only the first line as the squash + # title, not the whole message), else the PR title (multi-commit). + # body: COMMIT_MESSAGES -> every commit message. PR_BODY -> the PR description. + # BLANK -> empty. + if [ "$TMODE" = "COMMIT_OR_PR_TITLE" ] && [ "$N" -eq 1 ]; then + SUBJECT="$FIRST_LINE" else SUBJECT="$PR_TITLE" fi @@ -86,29 +99,35 @@ jobs: *) BODY="" ;; esac - # Search subject and body INDEPENDENTLY — never concatenate them. For a - # single-commit COMMIT_OR_PR_TITLE+COMMIT_MESSAGES repo the subject and body are - # the same commit message; concatenating would create a spurious cross-boundary - # match (e.g. a blank "[no-jira - reason: ]" would falsely pass by pairing its own - # "]" with the "]" of the duplicate copy). + # Search subject and body INDEPENDENTLY — never concatenate. For a single-commit + # COMMIT_OR_PR_TITLE+COMMIT_MESSAGES repo subject and body are the same message; + # concatenating would create a spurious cross-boundary match (e.g. a blank + # "[no-jira - reason: ]" pairing its own "]" with the "]" of the duplicate copy). if printf '%s' "$SUBJECT" | grep -Pq "$PATTERN" || printf '%s' "$BODY" | grep -Pq "$PATTERN"; then echo "✓ A UID2- key or reasoned [no-jira - reason: …] opt-out is present in the commit that will land on ${GITHUB_REF_NAME:-the default branch}." exit 0 fi - # Fail-closed with actionable guidance. squash title/body sources are printed so a - # confused author can see exactly which text was evaluated for THIS repo. - echo "::error title=Missing Jira key::The squash commit that will merge to the default branch has no UID2- Jira key and no reasoned opt-out." + # Fail-closed with guidance SPECIFIC to what actually lands for this repo/PR (TMODE/N), + # so an author isn't told to amend a commit when the PR title is what lands (or vice-versa). + if [ "$TMODE" = "PR_TITLE" ]; then + WHATLANDS="the **PR title** (and description)" + FIX="edit the PR title (or description) to include it" + elif [ "$N" -eq 1 ]; then + WHATLANDS="your **single commit's message**" + FIX="reword the commit (e.g. \`git commit --amend\`) to include it" + else + WHATLANDS="the **PR title** plus your **commit messages**" + FIX="put it in the PR title or one of your commit messages" + fi + echo "::error title=Missing Jira key::The squash commit that will merge to the default branch has no UID2- key and no reasoned opt-out." { echo "### ❌ Jira-key check failed" echo "" - echo "Every change merged to the default branch must carry a Jira key **in the commit that lands** — either:" - echo "- a **\`UID2-\`** key (uppercase), or" - echo "- a reasoned opt-out **\`[no-jira - reason: ]\`** (lowercase, reason mandatory)." + echo "Every change merged to the default branch must carry, **in the commit that lands**, either a \`UID2-\` key (uppercase) or a reasoned \`[no-jira - reason: ]\` opt-out (lowercase, reason mandatory)." echo "" - echo "This repo squashes with **title=\`${TMODE}\`**, **body=\`${MMODE}\`**, and your PR has **${N}** commit(s), so what lands is driven by your **commit message(s)**" - echo "(for a single-commit PR, the **PR title is not used** — put the key in the commit)." + echo "This repo squashes with **title=\`${TMODE}\`**, **body=\`${MMODE}\`**, and your PR has **${N}** commit(s), so what lands is ${WHATLANDS}." echo "" - echo "Fixes: reword a commit (e.g. \`git commit --amend\`) to include \`UID2-1234\`, or add \`[no-jira - reason: ]\` for a legitimately ticketless change." + echo "Fix: ${FIX}." } >> "$GITHUB_STEP_SUMMARY" exit 1 diff --git a/actions/commit_pr_and_merge/action.yaml b/actions/commit_pr_and_merge/action.yaml index aa5c5eae..f65d372b 100644 --- a/actions/commit_pr_and_merge/action.yaml +++ b/actions/commit_pr_and_merge/action.yaml @@ -104,7 +104,7 @@ runs: # merge rejection across every @v3 consumer ("all releases blocked"). # Assert against the exact pattern so drift fails here instead. Pin a # UTF-8 locale: grep -P (PCRE) errors under a non-UTF-8 locale. - if ! printf '%s' "$message" | LC_ALL=C.UTF-8 grep -Pq 'UID2-[0-9]+|\[no-jira - reason:\s*\S[^\]]*\]'; then + if ! printf '%s' "$message" | LC_ALL=C.UTF-8 grep -Pq '\bUID2-[0-9]+|\[no-jira - reason:\s*\S[^\]]*\]'; then echo "::error::composed commit message does not satisfy the require_jira_key ruleset: ${message}" exit 1 fi From 0b676e59fcdb958628bd110de3a8ea399d782a9a Mon Sep 17 00:00:00 2001 From: jon8787 <112368577+jon8787@users.noreply.github.com> Date: Mon, 6 Jul 2026 23:26:19 +1000 Subject: [PATCH 3/6] UID2-7426: drop invalid administration:read permission (not a valid workflow permissions key); reword fail-closed message [no-jira - reason: review fix] --- .github/workflows/shared-check-jira-key.yaml | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/.github/workflows/shared-check-jira-key.yaml b/.github/workflows/shared-check-jira-key.yaml index bb835699..006e96c2 100644 --- a/.github/workflows/shared-check-jira-key.yaml +++ b/.github/workflows/shared-check-jira-key.yaml @@ -30,10 +30,6 @@ on: permissions: contents: read pull-requests: read - # administration: read is required so the token can read this repo's squash-merge settings - # (squash_merge_commit_title / _message). Those fields come back null to a token that lacks it, - # and the step below fails closed rather than guessing. Callers MUST also grant this permission. - administration: read jobs: check-jira-key: @@ -68,12 +64,12 @@ jobs: PR_BODY=$(printf '%s' "$PR_JSON" | jq -r '.body // ""') # This repo's squash composition decides what the squash commit message will be. These - # fields are permission-gated: a token without administration:read gets null even on a - # configured repo. Do NOT default — defaulting would silently evaluate the wrong + # fields can be permission-gated (a token without repo-settings access gets null even on a + # configured repo). Do NOT default — defaulting would silently evaluate the wrong # composition (e.g. false-pass a PR_TITLE/PR_BODY repo like uid2-android-sdk). Fail closed. read -r TMODE MMODE < <(gh api "repos/${REPO}" --jq '[(.squash_merge_commit_title // "null"), (.squash_merge_commit_message // "null")] | @tsv') if [ "$TMODE" = "null" ] || [ "$MMODE" = "null" ]; then - echo "::error::Could not read ${REPO} squash-merge settings (got null) — refusing to guess the squash composition. Grant the workflow token 'administration: read' in the caller's permissions." + echo "::error::Could not read ${REPO} squash-merge settings (squash_merge_commit_title/_message came back null) — refusing to guess the squash composition. Either normalize the repo to COMMIT_OR_PR_TITLE + COMMIT_MESSAGES so the check needn't read them, or run with a token that can read repo merge settings." exit 1 fi From 170f85dee87bb8816902f8192c554a142fcc0cc5 Mon Sep 17 00:00:00 2001 From: jon8787 <112368577+jon8787@users.noreply.github.com> Date: Mon, 6 Jul 2026 23:36:37 +1000 Subject: [PATCH 4/6] UID2-7426: assume uniform squash config instead of reading it (workflow token can't read squash_merge_commit_*); simplify reconstruction [no-jira - reason: review fix] --- .github/workflows/shared-check-jira-key.yaml | 98 ++++++++------------ 1 file changed, 40 insertions(+), 58 deletions(-) diff --git a/.github/workflows/shared-check-jira-key.yaml b/.github/workflows/shared-check-jira-key.yaml index 006e96c2..143e4af2 100644 --- a/.github/workflows/shared-check-jira-key.yaml +++ b/.github/workflows/shared-check-jira-key.yaml @@ -7,23 +7,31 @@ name: Shared Check Jira Key # native rule is inert there. This reusable workflow reproduces the same rule as a required status # check, the only team-plan-compatible mechanism. # -# WHAT IT CHECKS: the message that will actually land in the immutable default-branch history — -# the squash commit GitHub will produce — must contain a `UID2-` key or a reasoned +# WHAT IT CHECKS: the message that will actually land in the immutable default-branch history — the +# squash commit GitHub will produce — must contain a `UID2-` key or a reasoned # `[no-jira - reason: ]` opt-out. It reconstructs that squash commit from the PR's commits -# + title + body according to this repo's squash settings, rather than trusting the PR title alone -# (which is NOT what lands for a single-commit PR on a COMMIT_OR_PR_TITLE repo). +# and title, rather than trusting the PR title alone (which is NOT what lands for a single-commit PR). +# +# SQUASH-COMPOSITION ASSUMPTION (important): every in-scope repo is configured +# squash_merge_commit_title = COMMIT_OR_PR_TITLE and squash_merge_commit_message = COMMIT_MESSAGES +# (GitHub's default, and what all UID2/EUID gated repos use). The check ASSUMES that config rather +# than reading it, because the workflow GITHUB_TOKEN cannot read squash_merge_commit_* — those +# fields return null without admin access, and `administration` is not a grantable workflow +# `permissions:` key. Under that config the squash commit is: subject = the single commit's first +# line (1-commit PR) or the PR title (multi-commit); body = all commit messages concatenated. # # PREREQUISITES before this is wired as a REQUIRED check (UID2-7426 rollout — do not skip): -# 1. require-squash-merge must be ACTIVE on the repo. In-scope repos are NOT squash-only yet -# (require-squash-merge is still in evaluate, and merge-commit/rebase remain enabled), so until -# that flips a "Rebase and merge" can land raw keyless commits even with this check green — the -# check reconstructs the *squash* commit. Squash-only is what makes this check mean what it says. -# 2. The CI release flow (actions/commit_pr_and_merge, UID2-7400) merges via an immediate merge -# API call that does NOT wait for checks — a required check makes that PUT return 405 -# ("required status check is expected"). Before flipping to required, either add a -# wait-for-checks/retry to commit_pr_and_merge, or bypass the release actor on the -# required_status_checks rule (compliance-safe: its commits already carry the marker, asserted -# at compose time in commit_pr_and_merge). +# 1. Uniform squash config: every gated repo must be COMMIT_OR_PR_TITLE + COMMIT_MESSAGES, else +# the assumption above is wrong for that repo. uid2-android-sdk is currently PR_TITLE/PR_BODY — +# normalize it (or exclude it) before wiring its caller. Enforce the setting (terraform / +# go-live merge-method audit) so a repo can't silently drift out of the assumption. +# 2. require-squash-merge must be ACTIVE. In-scope repos are NOT squash-only yet (it is still in +# evaluate, and merge-commit/rebase remain enabled), so until it flips a "Rebase and merge" can +# land raw keyless commits even with this check green. Squash-only makes the check mean what it says. +# 3. The CI release flow (actions/commit_pr_and_merge, UID2-7400) merges via an immediate merge API +# call that does NOT wait for checks — a required check makes that PUT return 405. Before +# flipping to required, add a wait-for-checks/retry to commit_pr_and_merge, or bypass the release +# actor on the required_status_checks rule (compliance-safe: its commits already carry the marker). on: workflow_call: @@ -56,65 +64,39 @@ jobs: exit 1 fi - # Title/body fetched LIVE (not from the frozen github.event payload) so a manual re-run - # after a title edit re-evaluates the CURRENT title/body. Callers should also trigger on + # Title fetched LIVE (not from the frozen github.event payload) so a manual re-run after a + # title edit re-evaluates the CURRENT title. Callers should also trigger on # types: [opened, edited, synchronize, reopened] so title edits auto-re-check. - PR_JSON=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}") - PR_TITLE=$(printf '%s' "$PR_JSON" | jq -r '.title // ""') - PR_BODY=$(printf '%s' "$PR_JSON" | jq -r '.body // ""') - - # This repo's squash composition decides what the squash commit message will be. These - # fields can be permission-gated (a token without repo-settings access gets null even on a - # configured repo). Do NOT default — defaulting would silently evaluate the wrong - # composition (e.g. false-pass a PR_TITLE/PR_BODY repo like uid2-android-sdk). Fail closed. - read -r TMODE MMODE < <(gh api "repos/${REPO}" --jq '[(.squash_merge_commit_title // "null"), (.squash_merge_commit_message // "null")] | @tsv') - if [ "$TMODE" = "null" ] || [ "$MMODE" = "null" ]; then - echo "::error::Could not read ${REPO} squash-merge settings (squash_merge_commit_title/_message came back null) — refusing to guess the squash composition. Either normalize the repo to COMMIT_OR_PR_TITLE + COMMIT_MESSAGES so the check needn't read them, or run with a token that can read repo merge settings." - exit 1 - fi + PR_TITLE=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}" --jq '.title // ""') COMMITS=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}/commits?per_page=100") N=$(printf '%s' "$COMMITS" | jq 'length') ALL_MSGS=$(printf '%s' "$COMMITS" | jq -r '.[].commit.message') + # GitHub's squash title for a 1-commit PR is the commit's FIRST LINE, not the whole message. FIRST_LINE=$(printf '%s' "$COMMITS" | jq -r '(.[0].commit.message // "") | split("\n")[0]') - # Reconstruct the squash commit GitHub will compose for this repo: - # subject: PR_TITLE mode -> the PR title. COMMIT_OR_PR_TITLE -> the single commit's - # FIRST LINE for a 1-commit PR (GitHub uses only the first line as the squash - # title, not the whole message), else the PR title (multi-commit). - # body: COMMIT_MESSAGES -> every commit message. PR_BODY -> the PR description. - # BLANK -> empty. - if [ "$TMODE" = "COMMIT_OR_PR_TITLE" ] && [ "$N" -eq 1 ]; then - SUBJECT="$FIRST_LINE" - else - SUBJECT="$PR_TITLE" - fi - case "$MMODE" in - COMMIT_MESSAGES) BODY="$ALL_MSGS" ;; - PR_BODY) BODY="$PR_BODY" ;; - *) BODY="" ;; - esac + # Reconstruct the squash commit under the assumed config (see header): + # subject: 1-commit PR -> that commit's first line; multi-commit -> the PR title. + # body: all commit messages (COMMIT_MESSAGES). + if [ "$N" -eq 1 ]; then SUBJECT="$FIRST_LINE"; else SUBJECT="$PR_TITLE"; fi + BODY="$ALL_MSGS" - # Search subject and body INDEPENDENTLY — never concatenate. For a single-commit - # COMMIT_OR_PR_TITLE+COMMIT_MESSAGES repo subject and body are the same message; - # concatenating would create a spurious cross-boundary match (e.g. a blank - # "[no-jira - reason: ]" pairing its own "]" with the "]" of the duplicate copy). + # Search subject and body INDEPENDENTLY — never concatenate. For a single-commit PR the + # subject (first line) is contained in the body (its full message); concatenating would + # create a spurious cross-boundary match (e.g. a blank "[no-jira - reason: ]" pairing its + # own "]" with the "]" of the body copy). if printf '%s' "$SUBJECT" | grep -Pq "$PATTERN" || printf '%s' "$BODY" | grep -Pq "$PATTERN"; then echo "✓ A UID2- key or reasoned [no-jira - reason: …] opt-out is present in the commit that will land on ${GITHUB_REF_NAME:-the default branch}." exit 0 fi - # Fail-closed with guidance SPECIFIC to what actually lands for this repo/PR (TMODE/N), - # so an author isn't told to amend a commit when the PR title is what lands (or vice-versa). - if [ "$TMODE" = "PR_TITLE" ]; then - WHATLANDS="the **PR title** (and description)" - FIX="edit the PR title (or description) to include it" - elif [ "$N" -eq 1 ]; then + # Fail-closed with guidance specific to what lands (single- vs multi-commit). + if [ "$N" -eq 1 ]; then WHATLANDS="your **single commit's message**" FIX="reword the commit (e.g. \`git commit --amend\`) to include it" else - WHATLANDS="the **PR title** plus your **commit messages**" - FIX="put it in the PR title or one of your commit messages" + WHATLANDS="the **PR title** or one of your **commit messages**" + FIX="put it in the PR title or a commit message" fi echo "::error title=Missing Jira key::The squash commit that will merge to the default branch has no UID2- key and no reasoned opt-out." { @@ -122,7 +104,7 @@ jobs: echo "" echo "Every change merged to the default branch must carry, **in the commit that lands**, either a \`UID2-\` key (uppercase) or a reasoned \`[no-jira - reason: ]\` opt-out (lowercase, reason mandatory)." echo "" - echo "This repo squashes with **title=\`${TMODE}\`**, **body=\`${MMODE}\`**, and your PR has **${N}** commit(s), so what lands is ${WHATLANDS}." + echo "Your PR has **${N}** commit(s), so what lands is ${WHATLANDS}." echo "" echo "Fix: ${FIX}." } >> "$GITHUB_STEP_SUMMARY" From 77ee11c6cef4e9ba60a602f91f44414027952766 Mon Sep 17 00:00:00 2001 From: jon8787 <112368577+jon8787@users.noreply.github.com> Date: Tue, 7 Jul 2026 00:06:01 +1000 Subject: [PATCH 5/6] UID2-7426: convert check to a composite action with a unit-tested pure matcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the shared-check-jira-key reusable workflow with actions/check_jira_key (composite action), so the shipped logic lives in a script that is genuinely unit-tested rather than mirrored: - match.sh: pure matcher (inputs via env, no I/O) — the reconstruction + SSOT regex. - action.yaml: gathers inputs (gh api) + a runtime squash-config TRIPWIRE, then calls match.sh. - tests/match.test.sh: 11 cases against the real match.sh (auto-run by test-scripts.yaml). - tests/regex_identity.test.sh: asserts the SSOT regex is byte-identical to the commit_pr_and_merge self-assertion (reads the real files, so it can't drift). Also: - Runtime tripwire: attempt to read squash settings; fail loudly ONLY if they are readable AND differ from the assumed COMMIT_OR_PR_TITLE/COMMIT_MESSAGES (null = unreadable = trust the audit). Turns a silent UI settings-flip into an immediate red check at zero cost on the normal path. - Fix cosmetic success message (dropped the misleading github.ref_name "/merge"). - .gitattributes: *.sh eol=lf so shebangs work on Linux runners. - Callers pin job name `check_jira_key` -> one status-check context for the terraform required check. Co-Authored-By: Claude Opus 4.8 --- .gitattributes | 3 + .github/workflows/shared-check-jira-key.yaml | 111 ------------------ actions/check_jira_key/action.yaml | 66 +++++++++++ actions/check_jira_key/match.sh | 56 +++++++++ actions/check_jira_key/tests/match.test.sh | 32 +++++ .../tests/regex_identity.test.sh | 26 ++++ 6 files changed, 183 insertions(+), 111 deletions(-) create mode 100644 .gitattributes delete mode 100644 .github/workflows/shared-check-jira-key.yaml create mode 100644 actions/check_jira_key/action.yaml create mode 100644 actions/check_jira_key/match.sh create mode 100644 actions/check_jira_key/tests/match.test.sh create mode 100644 actions/check_jira_key/tests/regex_identity.test.sh diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..0b01ea0f --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# Shell scripts must be LF so their shebang and heredocs work on Linux runners +# (Windows checkouts would otherwise commit CRLF and break `#!/usr/bin/env bash`). +*.sh text eol=lf diff --git a/.github/workflows/shared-check-jira-key.yaml b/.github/workflows/shared-check-jira-key.yaml deleted file mode 100644 index 143e4af2..00000000 --- a/.github/workflows/shared-check-jira-key.yaml +++ /dev/null @@ -1,111 +0,0 @@ -name: Shared Check Jira Key -# Required-status-check for IABTechLab uid2* repos (UID2-7426). -# -# WHY THIS EXISTS: the UnifiedID2 / European-Unified-ID orgs enforce the Jira-key rule natively -# with a GitHub `commit_message_pattern` ruleset (UID2-7312). IABTechLab is a GitHub *team* plan, -# where commit-metadata ruleset rules are Enterprise-gated and silently never evaluated — so the -# native rule is inert there. This reusable workflow reproduces the same rule as a required status -# check, the only team-plan-compatible mechanism. -# -# WHAT IT CHECKS: the message that will actually land in the immutable default-branch history — the -# squash commit GitHub will produce — must contain a `UID2-` key or a reasoned -# `[no-jira - reason: ]` opt-out. It reconstructs that squash commit from the PR's commits -# and title, rather than trusting the PR title alone (which is NOT what lands for a single-commit PR). -# -# SQUASH-COMPOSITION ASSUMPTION (important): every in-scope repo is configured -# squash_merge_commit_title = COMMIT_OR_PR_TITLE and squash_merge_commit_message = COMMIT_MESSAGES -# (GitHub's default, and what all UID2/EUID gated repos use). The check ASSUMES that config rather -# than reading it, because the workflow GITHUB_TOKEN cannot read squash_merge_commit_* — those -# fields return null without admin access, and `administration` is not a grantable workflow -# `permissions:` key. Under that config the squash commit is: subject = the single commit's first -# line (1-commit PR) or the PR title (multi-commit); body = all commit messages concatenated. -# -# PREREQUISITES before this is wired as a REQUIRED check (UID2-7426 rollout — do not skip): -# 1. Uniform squash config: every gated repo must be COMMIT_OR_PR_TITLE + COMMIT_MESSAGES, else -# the assumption above is wrong for that repo. uid2-android-sdk is currently PR_TITLE/PR_BODY — -# normalize it (or exclude it) before wiring its caller. Enforce the setting (terraform / -# go-live merge-method audit) so a repo can't silently drift out of the assumption. -# 2. require-squash-merge must be ACTIVE. In-scope repos are NOT squash-only yet (it is still in -# evaluate, and merge-commit/rebase remain enabled), so until it flips a "Rebase and merge" can -# land raw keyless commits even with this check green. Squash-only makes the check mean what it says. -# 3. The CI release flow (actions/commit_pr_and_merge, UID2-7400) merges via an immediate merge API -# call that does NOT wait for checks — a required check makes that PUT return 405. Before -# flipping to required, add a wait-for-checks/retry to commit_pr_and_merge, or bypass the release -# actor on the required_status_checks rule (compliance-safe: its commits already carry the marker). -on: - workflow_call: - -permissions: - contents: read - pull-requests: read - -jobs: - check-jira-key: - runs-on: ubuntu-latest - steps: - - name: Require a UID2 Jira key or reasoned opt-out in the landed squash commit - env: - GH_TOKEN: ${{ github.token }} - REPO: ${{ github.repository }} - PR_NUMBER: ${{ github.event.pull_request.number }} - shell: bash - run: | - set -euo pipefail - export LC_ALL=C.UTF-8 # grep -P (PCRE) errors under a non-UTF-8 locale (matches commit_pr_and_merge) - - # SINGLE SOURCE OF TRUTH regex. Keep in sync with local.jira_key_pattern in - # uid2-okta-configuration (github_repo_rulesets / github_rulesets) and the self-assertion - # in actions/commit_pr_and_merge. The \b prevents FOOUID2-1 from matching; a blank - # [no-jira - reason: ] fails because \S requires a non-space reason. - PATTERN='\bUID2-[0-9]+|\[no-jira - reason:\s*\S[^\]]*\]' - - if [ -z "${PR_NUMBER:-}" ] || [ "${PR_NUMBER}" = "null" ]; then - echo "::error::shared-check-jira-key must be called from a pull_request-triggered workflow (no PR number in context)." - exit 1 - fi - - # Title fetched LIVE (not from the frozen github.event payload) so a manual re-run after a - # title edit re-evaluates the CURRENT title. Callers should also trigger on - # types: [opened, edited, synchronize, reopened] so title edits auto-re-check. - PR_TITLE=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}" --jq '.title // ""') - - COMMITS=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}/commits?per_page=100") - N=$(printf '%s' "$COMMITS" | jq 'length') - ALL_MSGS=$(printf '%s' "$COMMITS" | jq -r '.[].commit.message') - # GitHub's squash title for a 1-commit PR is the commit's FIRST LINE, not the whole message. - FIRST_LINE=$(printf '%s' "$COMMITS" | jq -r '(.[0].commit.message // "") | split("\n")[0]') - - # Reconstruct the squash commit under the assumed config (see header): - # subject: 1-commit PR -> that commit's first line; multi-commit -> the PR title. - # body: all commit messages (COMMIT_MESSAGES). - if [ "$N" -eq 1 ]; then SUBJECT="$FIRST_LINE"; else SUBJECT="$PR_TITLE"; fi - BODY="$ALL_MSGS" - - # Search subject and body INDEPENDENTLY — never concatenate. For a single-commit PR the - # subject (first line) is contained in the body (its full message); concatenating would - # create a spurious cross-boundary match (e.g. a blank "[no-jira - reason: ]" pairing its - # own "]" with the "]" of the body copy). - if printf '%s' "$SUBJECT" | grep -Pq "$PATTERN" || printf '%s' "$BODY" | grep -Pq "$PATTERN"; then - echo "✓ A UID2- key or reasoned [no-jira - reason: …] opt-out is present in the commit that will land on ${GITHUB_REF_NAME:-the default branch}." - exit 0 - fi - - # Fail-closed with guidance specific to what lands (single- vs multi-commit). - if [ "$N" -eq 1 ]; then - WHATLANDS="your **single commit's message**" - FIX="reword the commit (e.g. \`git commit --amend\`) to include it" - else - WHATLANDS="the **PR title** or one of your **commit messages**" - FIX="put it in the PR title or a commit message" - fi - echo "::error title=Missing Jira key::The squash commit that will merge to the default branch has no UID2- key and no reasoned opt-out." - { - echo "### ❌ Jira-key check failed" - echo "" - echo "Every change merged to the default branch must carry, **in the commit that lands**, either a \`UID2-\` key (uppercase) or a reasoned \`[no-jira - reason: ]\` opt-out (lowercase, reason mandatory)." - echo "" - echo "Your PR has **${N}** commit(s), so what lands is ${WHATLANDS}." - echo "" - echo "Fix: ${FIX}." - } >> "$GITHUB_STEP_SUMMARY" - exit 1 diff --git a/actions/check_jira_key/action.yaml b/actions/check_jira_key/action.yaml new file mode 100644 index 00000000..2eed8d4e --- /dev/null +++ b/actions/check_jira_key/action.yaml @@ -0,0 +1,66 @@ +name: Check Jira Key +description: >- + Required-status-check enforcement of the UID2 Jira-key rule for IABTechLab uid2* repos + (UID2-7426). Fails a PR unless the squash commit that will land carries a UID2- key or a + reasoned [no-jira - reason: ] opt-out. +# +# WHY AN ACTION (not a native ruleset): IABTechLab is a GitHub team plan, where commit-metadata +# ruleset rules (commit_message_pattern) are Enterprise-gated and never evaluated. UnifiedID2 / EUID +# enforce the same rule natively (UID2-7312); IABTechLab uses this Action + a required status check. +# +# SQUASH-CONFIG ASSUMPTION: assumes squash_merge_commit_title=COMMIT_OR_PR_TITLE and +# squash_merge_commit_message=COMMIT_MESSAGES (the config all gated repos use). The workflow token +# CANNOT read squash_merge_commit_* (they return null without admin, and `administration` is not a +# grantable workflow permission), so the config is assumed, not read — with a runtime tripwire below +# that fails loudly if the settings ARE readable and differ from the assumption. +# +# ROLLOUT PREREQUISITES before wiring as a REQUIRED check (UID2-7426 — do not skip): +# 1. Every gated repo is COMMIT_OR_PR_TITLE + COMMIT_MESSAGES (enforced via terraform so it can't +# drift). uid2-android-sdk (was PR_TITLE/PR_BODY) is normalized to match. +# 2. require-squash-merge is active (else a rebase-merge can land raw keyless commits). +# 3. commit_pr_and_merge waits for checks, or the release actor is bypassed on the required-check +# rule (its immediate merge PUT would otherwise 405). +# +# Callers pin the job name `check_jira_key` so all repos produce one status-check context +# ("check_jira_key") for a single terraform required_status_checks entry. +runs: + using: composite + steps: + - name: Require a UID2 Jira key or reasoned opt-out in the landed squash commit + shell: bash + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + ACTION_PATH: ${{ github.action_path }} + run: | + set -euo pipefail + + if [ -z "${PR_NUMBER:-}" ] || [ "${PR_NUMBER}" = "null" ]; then + echo "::error::check_jira_key must run on a pull_request event (no PR number in context)." + exit 1 + fi + + # Runtime tripwire (zero cost on the normal path). The check ASSUMES COMMIT_OR_PR_TITLE + + # COMMIT_MESSAGES; the token usually can't read these (null -> unreadable -> trust the + # terraform/audit enforcement and proceed). But if they ARE readable and DIFFER, fail loudly + # — that turns "an admin flips the setting in the UI and the check silently evaluates the + # wrong text for months" into an immediate red check. `|| true` so a read failure is treated + # as unreadable (proceed), not a crash. + TMODE=""; MMODE="" + read -r TMODE MMODE < <(gh api "repos/${REPO}" --jq '[(.squash_merge_commit_title // "null"), (.squash_merge_commit_message // "null")] | @tsv' 2>/dev/null) || true + if [ -n "$TMODE" ] && [ "$TMODE" != "null" ] && { [ "$TMODE" != "COMMIT_OR_PR_TITLE" ] || [ "$MMODE" != "COMMIT_MESSAGES" ]; }; then + echo "::error::${REPO} squash settings are title=${TMODE}, message=${MMODE}, but this check assumes COMMIT_OR_PR_TITLE / COMMIT_MESSAGES. Restore them (UID2-7426 requires a uniform squash config) or update the check." + exit 1 + fi + + # Gather inputs for the pure matcher. Title fetched LIVE (not the frozen github.event payload) + # so a manual re-run after a title edit re-evaluates the current title. + PR_TITLE=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}" --jq '.title // ""') + COMMITS=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}/commits?per_page=100") + COMMIT_COUNT=$(printf '%s' "$COMMITS" | jq 'length') + ALL_MSGS=$(printf '%s' "$COMMITS" | jq -r '.[].commit.message') + FIRST_LINE=$(printf '%s' "$COMMITS" | jq -r '(.[0].commit.message // "") | split("\n")[0]') + + export PR_TITLE COMMIT_COUNT ALL_MSGS FIRST_LINE + bash "${ACTION_PATH}/match.sh" diff --git a/actions/check_jira_key/match.sh b/actions/check_jira_key/match.sh new file mode 100644 index 00000000..7278c2bf --- /dev/null +++ b/actions/check_jira_key/match.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# Pure matcher for the check_jira_key composite action (UID2-7426). Reads its inputs from the +# environment and does NO network I/O, so it is unit-testable (tests/match.test.sh). action.yaml +# gathers the inputs (gh api) + the squash-config tripwire, then calls this. +# +# Assumes the repo squashes with squash_merge_commit_title=COMMIT_OR_PR_TITLE and +# squash_merge_commit_message=COMMIT_MESSAGES (see action.yaml). Under that config the squash commit +# GitHub lands is: subject = the single commit's first line (1-commit PR) or the PR title +# (multi-commit); body = all commit messages concatenated. +# +# Inputs (env): PR_TITLE, COMMIT_COUNT, ALL_MSGS, FIRST_LINE. +set -uo pipefail +export LC_ALL=C.UTF-8 # grep -P (PCRE) errors under a non-UTF-8 locale + +# SINGLE SOURCE OF TRUTH regex — kept byte-identical to the self-assertion in +# actions/commit_pr_and_merge/action.yaml (enforced by tests/regex_identity.test.sh) and to +# local.jira_key_pattern in uid2-okta-configuration. The \b blocks FOOUID2-1; \S blocks a blank reason. +PATTERN='\bUID2-[0-9]+|\[no-jira - reason:\s*\S[^\]]*\]' + +PR_TITLE="${PR_TITLE:-}" +COMMIT_COUNT="${COMMIT_COUNT:-0}" +ALL_MSGS="${ALL_MSGS:-}" +FIRST_LINE="${FIRST_LINE:-}" + +# Reconstruct the squash commit under the assumed config. +if [ "$COMMIT_COUNT" -eq 1 ]; then SUBJECT="$FIRST_LINE"; else SUBJECT="$PR_TITLE"; fi +BODY="$ALL_MSGS" + +# Search subject and body INDEPENDENTLY — never concatenate. For a single-commit PR the subject +# (first line) is contained in the body (its full message); concatenating could create a spurious +# cross-boundary match (e.g. a blank "[no-jira - reason: ]" pairing its own "]" with the body copy). +if printf '%s' "$SUBJECT" | grep -Pq "$PATTERN" || printf '%s' "$BODY" | grep -Pq "$PATTERN"; then + echo "✓ A UID2- key or reasoned [no-jira - reason: …] opt-out is present in the commit that will land." + exit 0 +fi + +if [ "$COMMIT_COUNT" -eq 1 ]; then + WHATLANDS="your **single commit's message**" + FIX="reword the commit (e.g. \`git commit --amend\`) to include it" +else + WHATLANDS="the **PR title** or one of your **commit messages**" + FIX="put it in the PR title or a commit message" +fi +echo "::error title=Missing Jira key::The squash commit that will merge to the default branch has no UID2- key and no reasoned opt-out." +if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then + { + echo "### ❌ Jira-key check failed" + echo "" + echo "Every change merged to the default branch must carry, **in the commit that lands**, either a \`UID2-\` key (uppercase) or a reasoned \`[no-jira - reason: ]\` opt-out (lowercase, reason mandatory)." + echo "" + echo "Your PR has **${COMMIT_COUNT}** commit(s), so what lands is ${WHATLANDS}." + echo "" + echo "Fix: ${FIX}." + } >> "$GITHUB_STEP_SUMMARY" +fi +exit 1 diff --git a/actions/check_jira_key/tests/match.test.sh b/actions/check_jira_key/tests/match.test.sh new file mode 100644 index 00000000..17ca0add --- /dev/null +++ b/actions/check_jira_key/tests/match.test.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Unit tests for ../match.sh — run: bash actions/check_jira_key/tests/match.test.sh +# Auto-run in CI by .github/workflows/test-scripts.yaml (actions/**/tests/*.sh). +set -uo pipefail +SCRIPT="$(cd "$(dirname "$0")/.." && pwd)/match.sh" +fail=0 + +# check: desc expected_rc PR_TITLE COMMIT_COUNT ALL_MSGS FIRST_LINE +# expected_rc 0 = pass (key/opt-out present in what lands), 1 = fail (missing) +check() { + local desc="$1" exp="$2" title="$3" count="$4" allmsgs="$5" first="$6" rc + PR_TITLE="$title" COMMIT_COUNT="$count" ALL_MSGS="$allmsgs" FIRST_LINE="$first" \ + bash "$SCRIPT" >/dev/null 2>&1 + rc=$? + if [ "$rc" -eq "$exp" ]; then echo "ok - $desc"; else echo "FAIL - $desc: expected rc=$exp got $rc"; fail=1; fi +} + +check "single-commit, key in commit" 0 "misc" 1 "UID2-1234 fix" "UID2-1234 fix" +check "single-commit, key ONLY in PR title" 1 "UID2-1234 fix" 1 "fix thing" "fix thing" +check "CI release marker in commit" 0 "[CI Pipeline] Released" 1 "[CI Pipeline] Released 5.70.207 [no-jira - reason: automated release v5.70.207]" "[CI Pipeline] Released 5.70.207 [no-jira - reason: automated release v5.70.207]" +check "multi-commit, key in PR title" 0 "UID2-7056 deploy" 2 "$(printf 'fix a\nfix b')" "fix a" +check "multi-commit, key in one commit" 0 "chore" 2 "$(printf 'bump x\nUID2-9 tweak')" "bump x" +check "single-commit, key in commit body" 0 "fix" 1 "$(printf 'fix thing\n\nUID2-99 in body')" "fix thing" +check "no key anywhere" 1 "update stuff" 2 "$(printf 'update stuff\nmore')" "update stuff" +check "blank opt-out reason fails" 1 "x" 1 "cleanup [no-jira - reason: ]" "cleanup [no-jira - reason: ]" +check "reasoned opt-out in commit" 0 "x" 1 "cleanup [no-jira - reason: automated dep bump]" "cleanup [no-jira - reason: automated dep bump]" +check "FOOUID2-1 must not satisfy" 1 "x" 1 "FOOUID2-1 sneaky" "FOOUID2-1 sneaky" +check "tf-modules bump marker" 0 "Update tf" 1 "chore: update terraform_modules_version to v1.453 [no-jira - reason: automated tf-modules bump v1.453]" "chore: update terraform_modules_version to v1.453 [no-jira - reason: automated tf-modules bump v1.453]" + +echo "" +if [ "$fail" -eq 0 ]; then echo "match.test.sh: ALL PASS"; else echo "match.test.sh: FAILURES"; fi +exit "$fail" diff --git a/actions/check_jira_key/tests/regex_identity.test.sh b/actions/check_jira_key/tests/regex_identity.test.sh new file mode 100644 index 00000000..df0aab0f --- /dev/null +++ b/actions/check_jira_key/tests/regex_identity.test.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Drift guard (UID2-7426): the SSOT Jira-key regex must be byte-identical across its in-repo copies +# — the check_jira_key matcher and the commit_pr_and_merge self-assertion. This test READS the real +# files (not a copy), so it cannot itself drift. Auto-run by .github/workflows/test-scripts.yaml. +set -uo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +MATCH="$HERE/../match.sh" +CPAM="$HERE/../../commit_pr_and_merge/action.yaml" +fail=0 + +# Extract the single-quoted regex string that contains "UID2-" from a file (ERE, no PCRE needed). +extract() { grep -oE "'[^']*UID2-[^']*'" "$1" | head -1 | sed "s/^'//; s/'\$//"; } + +A="$(extract "$MATCH")" +B="$(extract "$CPAM")" +echo "check_jira_key/match.sh : $A" +echo "commit_pr_and_merge/action: $B" + +if [ -z "$A" ]; then echo "FAIL - could not extract regex from match.sh"; fail=1; fi +if [ -z "$B" ]; then echo "FAIL - could not extract regex from commit_pr_and_merge/action.yaml"; fail=1; fi +if [ -n "$A" ] && [ "$A" = "$B" ]; then + echo "ok - SSOT regex is byte-identical across both copies" +else + echo "FAIL - SSOT regex drift between the two in-repo copies"; fail=1 +fi +exit "$fail" From 54e1080c8c080689afb790d5c3126cf1dd6f3f1e Mon Sep 17 00:00:00 2001 From: jon8787 <112368577+jon8787@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:59:44 +1000 Subject: [PATCH 6/6] UID2-7426: paginate PR commits (all pages, not just first 100) + surface the 250-commit cap in the failure message [no-jira - reason: review fix] --- actions/check_jira_key/action.yaml | 6 +++++- actions/check_jira_key/match.sh | 8 +++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/actions/check_jira_key/action.yaml b/actions/check_jira_key/action.yaml index 2eed8d4e..95262c21 100644 --- a/actions/check_jira_key/action.yaml +++ b/actions/check_jira_key/action.yaml @@ -57,7 +57,11 @@ runs: # Gather inputs for the pure matcher. Title fetched LIVE (not the frozen github.event payload) # so a manual re-run after a title edit re-evaluates the current title. PR_TITLE=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}" --jq '.title // ""') - COMMITS=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}/commits?per_page=100") + # --paginate + `jq -s '[.[][]]'` flattens ALL pages: the squash body concatenates every + # commit, so fetching only the first page (per_page=100) could false-fail a >100-commit PR + # whose key is only in a later commit and not the PR title. GitHub caps PR commits at 250; + # match.sh surfaces that cap in the failure message for the (pathological) >250 case. + COMMITS=$(gh api --paginate "repos/${REPO}/pulls/${PR_NUMBER}/commits?per_page=100" | jq -s '[.[][]]') COMMIT_COUNT=$(printf '%s' "$COMMITS" | jq 'length') ALL_MSGS=$(printf '%s' "$COMMITS" | jq -r '.[].commit.message') FIRST_LINE=$(printf '%s' "$COMMITS" | jq -r '(.[0].commit.message // "") | split("\n")[0]') diff --git a/actions/check_jira_key/match.sh b/actions/check_jira_key/match.sh index 7278c2bf..ab097cf9 100644 --- a/actions/check_jira_key/match.sh +++ b/actions/check_jira_key/match.sh @@ -42,13 +42,19 @@ else FIX="put it in the PR title or a commit message" fi echo "::error title=Missing Jira key::The squash commit that will merge to the default branch has no UID2- key and no reasoned opt-out." +# GitHub returns at most 250 commits per PR, so a >250-commit PR may not have every commit message +# evaluated. Surface that only when we're at the cap, so a rare large-PR false-fail is diagnosable. +CAP_NOTE="" +if [ "$COMMIT_COUNT" -ge 250 ]; then + CAP_NOTE=" GitHub returns at most 250 commits per PR; if this PR has more, later commit messages were not evaluated — put the key in the PR title." +fi if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then { echo "### ❌ Jira-key check failed" echo "" echo "Every change merged to the default branch must carry, **in the commit that lands**, either a \`UID2-\` key (uppercase) or a reasoned \`[no-jira - reason: ]\` opt-out (lowercase, reason mandatory)." echo "" - echo "Your PR has **${COMMIT_COUNT}** commit(s), so what lands is ${WHATLANDS}." + echo "Your PR has **${COMMIT_COUNT}** commit(s), so what lands is ${WHATLANDS}.${CAP_NOTE}" echo "" echo "Fix: ${FIX}." } >> "$GITHUB_STEP_SUMMARY"