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/actions/check_jira_key/action.yaml b/actions/check_jira_key/action.yaml new file mode 100644 index 00000000..95262c21 --- /dev/null +++ b/actions/check_jira_key/action.yaml @@ -0,0 +1,70 @@ +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 // ""') + # --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]') + + 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..ab097cf9 --- /dev/null +++ b/actions/check_jira_key/match.sh @@ -0,0 +1,62 @@ +#!/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." +# 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}.${CAP_NOTE}" + 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" 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