docs(s004): document required TF_VAR_alert_email (DO-verified) for mo⦠#226
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Auto-Create PR to Main | ||
| # Authors PRs as the ecosystem-wide `weown-bot` service account so that | ||
| # GitHub Copilot code review is auto-triggered (Copilot only reviews PRs | ||
| # authored by human-type accounts, not GitHub Apps). | ||
| # | ||
| # NOTE: For auto-created PRs (this workflow's pattern), commits are pushed to | ||
| # the branch BEFORE the PR is created. Because there is no new push delta at | ||
| # PR-creation time, Copilot's `review_on_push: true` does not fire on the | ||
| # initial PR. Any follow-up push to the SAME open PR triggers Copilot review | ||
| # automatically. This is expected and documented β see ADR-004 | ||
| # Β§ Empirical Validation Results + Forward-looking posture for full analysis. | ||
| # For manually-created PRs where the PR is opened before commits are pushed, | ||
| # Copilot fires at PR-creation time as described in ADR-004. | ||
| # | ||
| # See: | ||
| # - .github/workflows/README.md (authoritative ops reference) | ||
| # - .github/ADR-001-service-account-pat.md | ||
| # - .github/ADR-002-infisical-github-sync.md | ||
| # - .github/copilot-instructions.md (phase-aware compliance directives) | ||
| on: | ||
| push: | ||
| branches: | ||
| - 'feature/*' | ||
| - 'fix/*' | ||
| - 'docs/*' | ||
| - 'hotfix/*' | ||
| # Manual trigger for debugging + PR-body refresh without needing an empty | ||
| # commit. Uses the same defense-in-depth branch-name regex guard in step 1, | ||
| # so dispatching on `main` or an unconventional branch exits safely. | ||
| workflow_dispatch: | ||
| inputs: | ||
| base: | ||
| description: "Target branch for the new PR (default main)" | ||
| required: false | ||
| default: "main" | ||
| type: string | ||
| # Serialize runs per-branch so rapid pushes don't race each other on | ||
| # `gh pr edit`. `cancel-in-progress: true` supersedes an older in-flight run | ||
| # when a newer push arrives β the PR body will always reflect the most | ||
| # recent push. Only cancels runs on the same branch; other branches are | ||
| # unaffected. | ||
| concurrency: | ||
| group: auto-pr-${{ github.ref }} | ||
| cancel-in-progress: true | ||
| # GITHUB_TOKEN permissions β intentionally minimal. | ||
| # Every `gh` command in this workflow uses WEOWN_BOT_PAT via GH_TOKEN (so PRs | ||
| # are authored by `weown-bot`, which is what makes Copilot auto-review trigger). | ||
| # actions/checkout also receives the PAT via its `token:` input. | ||
| # GITHUB_TOKEN is therefore effectively unused, but we grant `contents: read` | ||
| # as a defensive fallback in case a future step (e.g. a linter action) needs it. | ||
| # No `pull-requests: write` β PR ops go through the PAT, not GITHUB_TOKEN. | ||
| # Rationale: NIST PR.AC-3 (least privilege), CIS 5.4 (reduce blast radius). | ||
| permissions: | ||
| contents: read | ||
| jobs: | ||
| create-pr: | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - name: Checkout repository | ||
| uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 | ||
| with: | ||
| fetch-depth: 0 | ||
| token: ${{ secrets.WEOWN_BOT_PAT }} | ||
| - name: Create or Update Pull Request | ||
| id: create-pr | ||
| env: | ||
| GH_TOKEN: ${{ secrets.WEOWN_BOT_PAT }} | ||
| GITHUB_SERVER_URL: ${{ github.server_url }} | ||
| GITHUB_REPOSITORY: ${{ github.repository }} | ||
| # Last-pusher attribution: triggering_actor handles workflow_dispatch | ||
| # + re-runs accurately (it's the user who clicked Run / Re-run); | ||
| # github.actor is the fallback for push events. For a plain push, | ||
| # both resolve to the pusher. `Opened by:` attribution is computed | ||
| # separately inside the run (step 6) from the FIRST commit on the | ||
| # branch β it is idempotent across pushes, unlike this env var. | ||
| LAST_PUSHED_BY: ${{ github.triggering_actor || github.actor }} | ||
| EVENT_NAME: ${{ github.event_name }} | ||
| DISPATCH_BASE: ${{ github.event.inputs.base }} | ||
| run: | | ||
| set -euo pipefail | ||
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | ||
| # 1. Branch identification + defense-in-depth name validation | ||
| # | ||
| # The same regex as .github/workflows/branch-name-check.yml is | ||
| # re-applied here so this workflow's git plumbing (which expands | ||
| # BRANCH_NAME into `git log` / `git rev-list` arguments) can | ||
| # never be fed an unusual ref-like string. branch-name-check.yml | ||
| # is the authoritative gate; this is a belt-and-suspenders guard. | ||
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | ||
| BRANCH_NAME="${{ github.ref_name }}" | ||
| # On workflow_dispatch the dispatcher picks the base via inputs.base | ||
| # (default "main"). On push the workflow only refreshes existing PRs, | ||
| # so TARGET_BRANCH is informational and pinned to main. | ||
| if [ "$EVENT_NAME" = "workflow_dispatch" ]; then | ||
| TARGET_BRANCH="${DISPATCH_BASE:-main}" | ||
| else | ||
| TARGET_BRANCH="main" | ||
| fi | ||
| # Validate TARGET_BRANCH β the dispatch input is user-controlled and | ||
| # flows into git refs (`refs/remotes/origin/$TARGET_BRANCH`), URLs | ||
| # (BLOB_BASE) and `gh pr create --base`. `git check-ref-format` | ||
| # rejects whitespace, `..`, leading dashes, and other ref-syntax | ||
| # hazards. Fail fast with a clear message rather than producing | ||
| # opaque downstream failures. | ||
| if ! git check-ref-format --branch "$TARGET_BRANCH" >/dev/null 2>&1; then | ||
| echo "::error::Invalid target branch name '$TARGET_BRANCH'. Must be a valid git branch ref (no whitespace, '..', leading '-', etc.)." | ||
| exit 1 | ||
| fi | ||
| # Regex MUST stay in sync with .github/workflows/branch-name-check.yml. | ||
| # Description segment requires 3+ alphanumeric chars before any hyphen | ||
| # suffix (prevents meaningless names like `feature/ab-a`). | ||
| BRANCH_NAME_REGEX='^(feature|fix|docs|hotfix)/[a-z0-9]{2,}-[a-z0-9]{3,}(-[a-z0-9]+)*$' | ||
| if ! [[ "$BRANCH_NAME" =~ $BRANCH_NAME_REGEX ]]; then | ||
| echo "::warning::Branch '$BRANCH_NAME' does not match required pattern ($BRANCH_NAME_REGEX). Skipping auto-PR β branch-name-check.yml will enforce the convention." | ||
| exit 0 | ||
| fi | ||
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | ||
| # 2. Fully-qualified refs + argument arrays | ||
| # | ||
| # Using refs/heads/... and refs/remotes/origin/... makes these | ||
| # unambiguously branch references (never git options or other | ||
| # revision aliases). All downstream git commands expand the | ||
| # array with quoted "${GIT_RANGE[@]}" syntax. | ||
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | ||
| BRANCH_REF="refs/heads/${BRANCH_NAME}" | ||
| TARGET_REF="refs/remotes/origin/${TARGET_BRANCH}" | ||
| if git rev-parse --verify "$TARGET_REF" >/dev/null 2>&1; then | ||
| GIT_RANGE=("$BRANCH_REF" "^$TARGET_REF") | ||
| else | ||
| GIT_RANGE=("$BRANCH_REF") | ||
| fi | ||
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | ||
| # 3. Absolute-URL base for links in the PR body | ||
| # | ||
| # GitHub does not resolve `../docs/...` relative links from a | ||
| # PR description reliably (it sometimes resolves to the diff | ||
| # view). Absolute https:// URLs always work. | ||
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | ||
| BLOB_BASE="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/blob/${TARGET_BRANCH}" | ||
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | ||
| # 4. Safe temp files | ||
| # | ||
| # WeOwn guidance: never hardcode /tmp filenames; always use | ||
| # `mktemp` (which generates unpredictable names with mode 0600) | ||
| # with an explicit trap-based cleanup. We route mktemp through | ||
| # $RUNNER_TEMP β the GitHub-runner-scoped temp directory that is | ||
| # isolated from the shared /tmp and automatically cleaned at | ||
| # job end. Defense in depth: the trap also removes each file | ||
| # synchronously before the shell exits. | ||
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | ||
| # Fail fast if RUNNER_TEMP is unset rather than silently | ||
| # falling back to the shared /tmp β keeps runtime behavior | ||
| # aligned with the documented isolation policy. GitHub-hosted | ||
| # runners always set RUNNER_TEMP; an unset value means the | ||
| # workflow is being executed in an unsupported environment | ||
| # (e.g., act, local emulation without env shimming) where the | ||
| # isolation guarantee cannot be honored. | ||
| if [ -z "${RUNNER_TEMP:-}" ]; then | ||
| echo "::error::RUNNER_TEMP is unset; refusing to fall back to /tmp. Run this workflow on a GitHub-hosted runner (which sets RUNNER_TEMP automatically) or set RUNNER_TEMP explicitly in the calling environment." >&2 | ||
| exit 1 | ||
| fi | ||
| TEMP_DIR="$RUNNER_TEMP" | ||
| PR_BODY="$(mktemp -p "$TEMP_DIR")" | ||
| PR_TITLE="$(mktemp -p "$TEMP_DIR")" | ||
| CONTRIBUTORS_FILE="$(mktemp -p "$TEMP_DIR")" | ||
| # CONTRIB_RAW is created later in step 7. We declare it as an | ||
| # empty placeholder here so a SINGLE trap can cover all temp | ||
| # files β the trap body uses single quotes, so $VAR expansion | ||
| # happens at fire time (not set time). Optional temp files | ||
| # (those that may still be empty placeholders when the trap | ||
| # fires β e.g., the script aborts before step 7 runs) are | ||
| # guarded with an `[ -n "$VAR" ]` check before the `rm -f`. | ||
| # `rm -f` does NOT silence the empty-operand error on GNU | ||
| # coreutils (the runner is Ubuntu, so coreutils applies): | ||
| # `rm -f ""` emits `rm: cannot remove '': No such file or | ||
| # directory` and exits 1, which would alter the trap's exit | ||
| # status and could mask real failures from the main script. | ||
| # Regression-safe: any future optional temp file should be | ||
| # added to the conditional cleanup branch (not the | ||
| # unconditional list) when its mktemp assignment happens | ||
| # after this trap is set. | ||
| CONTRIB_RAW="" | ||
| trap 'rm -f "$PR_BODY" "$PR_TITLE" "$CONTRIBUTORS_FILE"; if [ -n "$CONTRIB_RAW" ]; then rm -f "$CONTRIB_RAW"; fi' EXIT | ||
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | ||
| # 5. Title: "Auto-PR: <subject of LATEST commit in the range>" | ||
| # | ||
| # `git log -1 <range>` returns the MOST RECENT commit reachable | ||
| # from BRANCH_REF but not from TARGET_REF. This is intentional: | ||
| # the latest work typically describes the PR best. If you ever | ||
| # want the OLDEST commit (first one added to the branch), use: | ||
| # git log --reverse --format=%s "${GIT_RANGE[@]}" | head -n1 | ||
| # | ||
| # The title is only set on PR CREATION; for existing PRs we keep | ||
| # the user-curated title so manual edits are respected on repush. | ||
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | ||
| LATEST_COMMIT_SUBJECT=$(git log --format=%s -1 "${GIT_RANGE[@]}" 2>/dev/null || echo "") | ||
| if [ -z "$LATEST_COMMIT_SUBJECT" ]; then | ||
| COMMIT_COUNT=$(git rev-list --count "${GIT_RANGE[@]}" 2>/dev/null || echo "0") | ||
| LATEST_SUBJECT=$(git log --format=%s -1 "$BRANCH_REF" 2>/dev/null || echo "") | ||
| if [ -n "$LATEST_SUBJECT" ]; then | ||
| LATEST_COMMIT_SUBJECT="Merge $BRANCH_NAME into $TARGET_BRANCH - $LATEST_SUBJECT" | ||
| elif [ "$COMMIT_COUNT" != "0" ]; then | ||
| LATEST_COMMIT_SUBJECT="Merge $BRANCH_NAME into $TARGET_BRANCH ($COMMIT_COUNT commits)" | ||
| else | ||
| LATEST_COMMIT_SUBJECT="Merge $BRANCH_NAME into $TARGET_BRANCH" | ||
| fi | ||
| fi | ||
| echo "Auto-PR: $LATEST_COMMIT_SUBJECT" > "$PR_TITLE" | ||
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | ||
| # 6. Three-tier developer attribution from GitHub context. | ||
| # | ||
| # Terminology: | ||
| # - Opened by = GitHub login of the FIRST commit's author on | ||
| # this branch. Stable across pushes because | ||
| # the "Copilot auto-review" ruleset (id | ||
| # 12131972, see ADR-004) enforces | ||
| # `non_fast_forward` on `~ALL` branches in | ||
| # this repo, blocking the rebase / force-push | ||
| # that would change the first-commit identity. | ||
| # Resolved via `gh api /repos/.../commits/ | ||
| # {first-sha}` so it's a real GitHub @handle, | ||
| # not a name+email. | ||
| # - Last pushed by = GitHub login of whoever pushed / dispatched | ||
| # THIS run. Updates every push. | ||
| # - Contributors = all GitHub logins that authored commits on | ||
| # this branch, with commit counts (step 7). | ||
| # | ||
| # No branch-name parsing, no case-statement mapping. The platform | ||
| # already knows who authored each commit. The <dev> segment in | ||
| # branch names is preserved for human readability only (see | ||
| # CONTRIBUTING.md Β§4). Zero-maintenance. | ||
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | ||
| LAST_PUSHED_BY_RESOLVED="${LAST_PUSHED_BY:-unknown}" | ||
| FIRST_SHA=$(git rev-list --reverse "${GIT_RANGE[@]}" 2>/dev/null | head -n 1 || true) | ||
| OPENED_BY="" | ||
| if [ -n "$FIRST_SHA" ]; then | ||
| # Single API call with jq fallback chain: prefer author.login, | ||
| # fall back to committer.login (some commits have a null | ||
| # author.login if the email isn't GitHub-verified but the | ||
| # committer is). Halves API requests per PR build vs. two | ||
| # separate calls, reducing rate-limit exposure. | ||
| OPENED_BY=$(gh api "repos/$GITHUB_REPOSITORY/commits/$FIRST_SHA" --jq '.author.login // .committer.login // ""' 2>/dev/null || true) | ||
| fi | ||
| # Fallback 2: last-pusher (consistent with prior behavior for | ||
| # single-commit PRs where first == last) | ||
| OPENED_BY="${OPENED_BY:-$LAST_PUSHED_BY_RESOLVED}" | ||
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | ||
| # 7. Contributors list β commits aggregated by GitHub login. | ||
| # | ||
| # For each commit in the branch range, resolve the author to a | ||
| # GitHub @handle via the commits API. Falls back to commit author | ||
| # NAME ONLY (no email) for unlinked emails (e.g. one-off external | ||
| # contributors) β emails are PII and intentionally not surfaced | ||
| # in the public PR body. Group and count; render sorted descending | ||
| # by commit count. | ||
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | ||
| : > "$CONTRIBUTORS_FILE" | ||
| # Populate CONTRIB_RAW (declared empty in step 4); the existing | ||
| # trap will pick up the new path automatically because the trap | ||
| # body single-quotes the variable, expanding at fire time. | ||
| CONTRIB_RAW="$(mktemp -p "$TEMP_DIR")" | ||
| # Email-keyed cache: GitHub's commits API resolves the same | ||
| # author email to the same login deterministically, so on a | ||
| # typical PR (1β5 unique authors over 5β50 commits) we can | ||
| # avoid 50β95% of the per-commit `gh api` calls by caching | ||
| # the email β login mapping. Each unique author hits the API | ||
| # exactly once; subsequent commits by the same author reuse | ||
| # the cached value. Cache scope is the workflow run. | ||
| declare -A EMAIL_LOGIN_CACHE | ||
| while IFS= read -r sha; do | ||
| [ -z "$sha" ] && continue | ||
| email=$(git log -1 --format='%ae' "$sha" 2>/dev/null || echo "") | ||
| if [ -n "$email" ] && [ -n "${EMAIL_LOGIN_CACHE[$email]+set}" ]; then | ||
| # Cache hit β no API call. | ||
| login="${EMAIL_LOGIN_CACHE[$email]}" | ||
| else | ||
| # Cache miss β single API call, then memoize the result | ||
| # (including the empty-string "unresolved" outcome so we | ||
| # don't retry the same email twice within one run). | ||
| login=$(gh api "repos/$GITHUB_REPOSITORY/commits/$sha" --jq '.author.login // .committer.login // ""' 2>/dev/null || true) | ||
| [ -n "$email" ] && EMAIL_LOGIN_CACHE[$email]="$login" | ||
| fi | ||
| if [ -n "$login" ]; then | ||
| echo "@$login" | ||
| else | ||
| # Fallback to commit-author NAME ONLY (no email) β emails are PII | ||
| # and intentionally not surfaced in the public PR body. | ||
| git log -1 --format='%an' "$sha" 2>/dev/null || echo "unknown" | ||
| fi | ||
| done < <(git log --format='%H' "${GIT_RANGE[@]}" 2>/dev/null || true) > "$CONTRIB_RAW" | ||
| # Group + count. `sort | uniq -c | sort -rn` is POSIX-stable. | ||
| # NOTE: use awk (not `read -r count handle`) to preserve multi-word | ||
| # names in the fallback path β e.g. `Jane Doe` must render as | ||
| # `- Jane Doe (3 commits)`, not `- Jane (3 commits)` (truncation | ||
| # would happen with `read`-into-two-vars because IFS whitespace | ||
| # splits on every space). awk keeps the entire remainder of the | ||
| # line after $1 (the count) intact. | ||
| if [ -s "$CONTRIB_RAW" ]; then | ||
| sort "$CONTRIB_RAW" | uniq -c | sort -rn | awk '{ | ||
| count = $1 | ||
| $1 = "" | ||
| sub(/^[[:space:]]+/, "", $0) | ||
| plural = (count == 1) ? "" : "s" | ||
| print "- " $0 " (" count " commit" plural ")" | ||
| }' > "$CONTRIBUTORS_FILE" | ||
| fi | ||
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | ||
| # 8. Build the PR body | ||
| # | ||
| # Key upgrade vs. prior version: show FULL commit bodies (%b), | ||
| # author NAME (%an, no email β emails are PII and not surfaced | ||
| # in the public PR body), and date (%ad) so Copilot AI review | ||
| # sees the full rationale, not just subject lines. All links | ||
| # use absolute https:// URLs so they resolve from the PR | ||
| # description reliably. | ||
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | ||
| # Render helper: prefix "@" only when the value is a real | ||
| # GitHub login (non-empty AND not the sentinel "unknown"). | ||
| # Prevents misleading "@unknown" output in the PR body when | ||
| # resolution fell through every fallback chain. | ||
| render_handle() { | ||
| case "$1" in | ||
| ""|unknown) echo "unknown" ;; | ||
| *) echo "@$1" ;; | ||
| esac | ||
| } | ||
| { | ||
| echo "π€ Automated Pull Request β authored by \`weown-bot\` (ecosystem service account)" | ||
| echo "" | ||
| echo "**Opened by:** $(render_handle "$OPENED_BY")" | ||
| echo "**Last pushed by:** $(render_handle "$LAST_PUSHED_BY_RESOLVED")" | ||
| echo "**Branch:** \`${BRANCH_NAME}\` β \`${TARGET_BRANCH}\`" | ||
| echo "" | ||
| echo "**Contributors on this branch:**" | ||
| echo "" | ||
| if [ -s "$CONTRIBUTORS_FILE" ]; then | ||
| cat "$CONTRIBUTORS_FILE" | ||
| else | ||
| echo "- _(none detected β possibly no commits vs. target)_" | ||
| fi | ||
| echo "" | ||
| echo "---" | ||
| echo "" | ||
| echo "## π Human Review Checklist β NIST CSF 2.0 Functions" | ||
| echo "" | ||
| echo "Review per the 6 NIST CSF Functions. Frameworks referenced: NIST CSF 2.0, CIS Controls v8 IG1, CSA CCM v4, ISO/IEC 27001:2022, SOC 2, ISO/IEC 42001:2023. See [\`docs/COMPLIANCE_ROADMAP.md\`](${BLOB_BASE}/docs/COMPLIANCE_ROADMAP.md)." | ||
| echo "" | ||
| echo "### ποΈ Govern (GV)" | ||
| echo "- [ ] CODEOWNERS correct for affected paths (\`.github/CODEOWNERS\`)" | ||
| echo "- [ ] ADR required/updated if an architectural decision is introduced" | ||
| echo "- [ ] Policy impact considered and documented" | ||
| echo "- [ ] All Copilot AI review comments addressed or explicitly deferred with rationale" | ||
| echo "" | ||
| echo "### π Identify (ID)" | ||
| echo "- [ ] New assets inventoried (Helm values, container images, dependencies)" | ||
| echo "- [ ] SBOM regenerated if dependencies changed" | ||
| echo "- [ ] Risk register / threat model touched if threat surface changed (\`.github/SECURITY_ASSESSMENT.md\`)" | ||
| echo "" | ||
| echo "### π‘οΈ Protect (PR)" | ||
| echo "- [ ] Least privilege: RBAC, ServiceAccounts, scoped PATs (NIST PR.AC, CIS 5/6, ISO A.5.15-A.5.18)" | ||
| echo "- [ ] Secrets managed via Infisical (never \`--from-literal\`, never \`/tmp\`, always \`\$(mktemp)\` β ISO A.8.24)" | ||
| echo "- [ ] NetworkPolicy present for new deployments (NIST PR.AC-5, CIS 12, CSA IVS)" | ||
| echo "- [ ] TLS 1.3 with strong cipher suites where applicable (NIST PR.DS-1, CIS 3)" | ||
| echo "- [ ] Container security: non-root UID 1000+, Pod Security \`restricted\` (NIST PR.IP, CIS 4)" | ||
| echo "" | ||
| echo "### π΅οΈ Detect (DE)" | ||
| echo "- [ ] Logs / metrics added for new components (NIST DE.CM, CIS 8/13)" | ||
| echo "- [ ] Alert rules updated if thresholds change" | ||
| echo "- [ ] Health checks (\`livenessProbe\` + \`readinessProbe\`) configured" | ||
| echo "" | ||
| echo "### π¨ Respond (RS)" | ||
| echo "- [ ] Runbook updated if operational behavior changes (\`.github/INCIDENT_RESPONSE.md\`)" | ||
| echo "- [ ] Incident response impact considered (escalation paths, on-call)" | ||
| echo "" | ||
| echo "### β»οΈ Recover (RC)" | ||
| echo "- [ ] Backup strategy covers new persistent data (NIST RC.RP, CIS 11, ISO A.8.13)" | ||
| echo "- [ ] Rollback procedure tested or documented" | ||
| echo "- [ ] DR impact assessed for new critical components" | ||
| echo "" | ||
| echo "### π Documentation & Versioning" | ||
| echo "- [ ] Relevant \`CHANGELOG.md\` updated (per-directory or repo-level \`/CHANGELOG.md\`)" | ||
| echo "- [ ] \`#WeOwnVer\` version bumped per [\`docs/VERSIONING_WEOWNVER.md\`](${BLOB_BASE}/docs/VERSIONING_WEOWNVER.md)" | ||
| echo "- [ ] READMEs / ADRs / inline comments updated" | ||
| echo "" | ||
| echo "---" | ||
| echo "" | ||
| echo "## π Recent Commits (full bodies for Copilot context)" | ||
| echo "" | ||
| # Full commit details for Copilot: hash, subject, author, date, body | ||
| git log --format='### %h %s%n%n**Author:** %an %n**Date:** %ad%n%n%b%n---%n' "${GIT_RANGE[@]}" 2>/dev/null | head -c 60000 || true | ||
| echo "" | ||
| echo "---" | ||
| echo "" | ||
| echo "**π Copilot AI Review**: Copilot is configured to auto-request review for bot-authored PRs. If an auto-created PR opens without an initial Copilot review, push a follow-up commit to the same open PR (\`review_on_push: true\`) to trigger review automatically." | ||
| echo "" | ||
| echo "**π₯ Required Reviewers**: 1 human approval enforced by branch protection. `@ncimino` requested automatically." | ||
| echo "" | ||
| echo "**π Review Guidelines**: [\`.github/copilot-instructions.md\`](${BLOB_BASE}/.github/copilot-instructions.md) (phase-aware compliance directives)" | ||
| echo "" | ||
| echo "**π οΈ Workflow Operations**: [\`.github/workflows/README.md\`](${BLOB_BASE}/.github/workflows/README.md)" | ||
| echo "" | ||
| echo "**Auto-generated by** [\`.github/workflows/auto-pr-to-main.yml\`](${BLOB_BASE}/.github/workflows/auto-pr-to-main.yml)" | ||
| } > "$PR_BODY" | ||
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | ||
| # 9. Create OR Update the PR | ||
| # | ||
| # Existing PR: update BODY + (re)request reviewers. Title is | ||
| # preserved so that any manual title edit by the author is | ||
| # respected across subsequent pushes. | ||
| # New PR: set title, body, and request reviewers. | ||
| # | ||
| # Both paths keep the checklist + full commit log visible to | ||
| # Copilot and human reviewers after every push. | ||
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | ||
| # Look up ALL open PRs with this branch as head. GitHub allows | ||
| # multiple β e.g. stacked PRs targeting different bases, or a | ||
| # legacy duplicate from before this workflow's skip-on-mismatch | ||
| # guard. Picking `.[0]` would non-deterministically hit one of | ||
| # them depending on list ordering, which is the exact failure | ||
| # mode this PR is trying to prevent. Instead, prefer the PR | ||
| # whose `baseRefName` matches $TARGET_BRANCH, then fall back to | ||
| # the first remaining PR. | ||
| # `jq` is pre-installed on ubuntu-latest runners; the same | ||
| # binary backs `gh ... --jq`. Using a single `jq` filter that | ||
| # extracts both fields keeps this self-contained. | ||
| all_prs_json=$(gh pr list --head "$BRANCH_NAME" --state open --json number,baseRefName 2>/dev/null || echo "[]") | ||
| pr_count=$(printf '%s' "$all_prs_json" | jq 'length') | ||
| existing_line=$(printf '%s' "$all_prs_json" \ | ||
| | jq -r --arg base "$TARGET_BRANCH" \ | ||
| '(map(select(.baseRefName == $base)) + .) | .[0] // empty | "\(.number) \(.baseRefName)"') | ||
| existing_pr_number="" | ||
| existing_pr_base="" | ||
| if [ -n "$existing_line" ]; then | ||
| existing_pr_number="${existing_line% *}" | ||
| existing_pr_base="${existing_line#* }" | ||
| fi | ||
| if [ "${pr_count:-0}" -gt 1 ]; then | ||
| echo "::warning::Found $pr_count open PRs with head=$BRANCH_NAME. Acting on #$existing_pr_number (base=$existing_pr_base) β preferred match for target=$TARGET_BRANCH. Close stale duplicates manually." | ||
| fi | ||
| if [ -n "$existing_pr_number" ] && [ "$existing_pr_base" != "$TARGET_BRANCH" ]; then | ||
| echo "::notice::PR #$existing_pr_number on $BRANCH_NAME targets $existing_pr_base, not $TARGET_BRANCH. Skipping auto-PR creation to avoid a duplicate." | ||
| echo "pr_number=$existing_pr_number" >> "$GITHUB_OUTPUT" | ||
| exit 0 | ||
| fi | ||
| if [ -n "$existing_pr_number" ]; then | ||
| echo "PR #$existing_pr_number already exists on $BRANCH_NAME; updating body + reviewers." | ||
| gh pr edit "$existing_pr_number" --body-file "$PR_BODY" \ | ||
| || echo "::warning::Failed to update body on PR #$existing_pr_number." | ||
| gh pr edit "$existing_pr_number" --add-reviewer ncimino \ | ||
| || echo "::warning::Failed to (re)assign reviewers on PR #$existing_pr_number (they may already be assigned)." | ||
| echo "pr_number=$existing_pr_number" >> "$GITHUB_OUTPUT" | ||
| echo "Updated PR #$existing_pr_number" | ||
| elif [ "$EVENT_NAME" = "workflow_dispatch" ]; then | ||
| pr_url=$(gh pr create \ | ||
| --base "$TARGET_BRANCH" \ | ||
| --head "$BRANCH_NAME" \ | ||
| --title "$(cat "$PR_TITLE")" \ | ||
| --body-file "$PR_BODY") | ||
| pr_number=$(echo "$pr_url" | grep -oE '[0-9]+$') | ||
| echo "pr_number=$pr_number" >> "$GITHUB_OUTPUT" | ||
| echo "Created PR #$pr_number: $pr_url" | ||
| gh pr edit "$pr_number" --add-reviewer ncimino \ | ||
| || echo "::warning::Failed to auto-assign reviewers on PR #$pr_number; they can be added manually." | ||
| else | ||
| # Push event with no existing PR β intentional under the new | ||
| # split: push refreshes existing PRs but does NOT create new | ||
| # ones (prevents surprise PRs on every push). Operators who | ||
| # want a PR run the workflow manually: | ||
| # gh workflow run auto-pr-to-main.yml --ref "$BRANCH_NAME" | ||
| echo "::notice::Push event on $BRANCH_NAME has no existing PR. No PR will be created β run 'gh workflow run auto-pr-to-main.yml --ref $BRANCH_NAME' (or the 'Run workflow' UI button) to explicitly open one." | ||
| fi | ||
| echo "Note: Copilot auto-review will trigger because PR was authored by weown-bot (human-type account)." | ||