From d406e08df1554d05b0b66c8b3f6702b0eb334be6 Mon Sep 17 00:00:00 2001 From: Weekendsuperhero <4048475+WeekendSuperhero@users.noreply.github.com> Date: Mon, 13 Jul 2026 19:09:05 -0700 Subject: [PATCH 1/2] feat(pr-description): Linear-aware PR bodies + node24 hygiene - create-github-app-token v2 -> v3 with client-id (kills the node20 deprecation and the deprecated app-id input); checkout v4 -> v6. - New runs-on input (default ubuntu-latest) so consumers can move the job to WarpBuild runners. - Deterministic Linear pipeline: IDs are regex-extracted from branch name, commits, and the current PR body (never by the LLM); the model only picks verbs (Closes/Fixes/Resolves/Completes vs Part of/Refs) per extracted ID via a new {title, body, linear[]} contract; the workflow renders the ## Linear section itself with a hidden linear-managed marker, preserves human-edited verbs, and skips the write when nothing changed (no edited-event loops). - Optional LINEAR_API_KEY secret enriches IDs with issue titles and prunes definitive not-founds; cosmetic, non-fatal, skipped if absent. - All inputs additive with back-compat defaults. --- .github/workflows/reusable-pr-description.yml | 253 +++++++++++++++++- 1 file changed, 242 insertions(+), 11 deletions(-) diff --git a/.github/workflows/reusable-pr-description.yml b/.github/workflows/reusable-pr-description.yml index 6a0be19..2bea273 100644 --- a/.github/workflows/reusable-pr-description.yml +++ b/.github/workflows/reusable-pr-description.yml @@ -12,11 +12,24 @@ # update-pr: # permissions: # contents: read +# pull-requests: write # uses: //.github/workflows/reusable-pr-description.yml@main # with: # trigger-phrase: "@agent pr-title" # optional +# runs-on: warp-ubuntu-latest-x64-4x # optional (defaults to ubuntu-latest) # secrets: inherit # ``` +# +# Linear integration: +# A deterministic pre-step extracts Linear issue IDs from the branch name, +# commit messages, and the current PR body. The LLM only picks the verb +# (Closes/Fixes/Resolves/Completes vs Part of/Refs) per already-extracted ID; +# the workflow renders the final `## Linear` section itself, so IDs can never +# be hallucinated or silently dropped. When a LINEAR_API_KEY secret is +# available, IDs are enriched with issue titles (cosmetic, non-fatal) and +# definitive not-founds are pruned. Linear's GitHub integration reads the +# magic words in the PR body: closing verbs move the issue when the PR +# merges; linking verbs only relate it. name: Reusable PR Description @@ -28,6 +41,11 @@ on: type: string required: false default: "@agent pr-title" + runs-on: + description: "Runner label for the job (e.g. warp-ubuntu-latest-x64-4x)" + type: string + required: false + default: "ubuntu-latest" base-branch: description: "The branch to diff against (defaults to the PR base branch)" type: string @@ -69,6 +87,16 @@ on: type: string required: false default: "" + linear-enabled: + description: "Detect Linear issue IDs and render a `## Linear` section in the PR body" + type: boolean + required: false + default: true + linear-id-pattern: + description: "Regex (POSIX ERE, matched case-insensitively) for Linear issue identifiers" + type: string + required: false + default: "[A-Za-z][A-Za-z0-9]+-[0-9]+" secrets: JULES_API_KEY: required: true @@ -76,14 +104,18 @@ on: required: false JULES_PR_PRIVATE_KEY: required: false + LINEAR_API_KEY: + description: "Optional Linear API key — enriches ticket IDs with titles and prunes false-positive IDs" + required: false jobs: update-pr: name: Update PR Title & Description - runs-on: ubuntu-latest + runs-on: ${{ inputs.runs-on }} permissions: contents: read + pull-requests: write steps: - name: Check trigger phrase @@ -128,15 +160,15 @@ jobs: - name: Generate GitHub App token if: steps.app-auth-config.outputs.enabled == 'true' id: app-token - uses: actions/create-github-app-token@v2 + uses: actions/create-github-app-token@v3 with: - app-id: ${{ secrets.JULES_PR_CLIENT_ID }} + client-id: ${{ secrets.JULES_PR_CLIENT_ID }} private-key: ${{ secrets.JULES_PR_PRIVATE_KEY }} owner: ${{ github.repository_owner }} - name: Checkout PR head if: steps.check.outputs.skip != 'true' || github.event_name != 'pull_request' - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }} token: ${{ steps.app-token.outputs.token || github.token }} @@ -237,6 +269,120 @@ jobs: fi done < /tmp/changed_files.txt + # ── Linear: deterministic ID extraction ───────────────────────────── + # Identity work is never delegated to the LLM: IDs come from the branch + # name (primary ticket), commit messages, and the current PR body. The + # LLM later only chooses verbs for these IDs. + - name: Extract Linear issue references + if: inputs.linear-enabled && (steps.check.outputs.skip != 'true' || github.event_name != 'pull_request') + id: linear + shell: bash + env: + HEAD_REF: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.ref || github.ref_name }} + PR_BODY: ${{ github.event.pull_request.body }} + ID_PATTERN: ${{ inputs.linear-id-pattern }} + run: | + set -euo pipefail + + # Acronym-style false positives that match the TEAM-123 shape but are + # never tickets. Branch-derived IDs are exempt (deliberate naming). + FALSE_POSITIVES='^(UTF|SHA|ISO|RFC|CVE|TLS|AES|HTTP|SSH|GPG|PGP|MD|CRC|X)-' + + extract() { grep -oiE "$ID_PATTERN" 2>/dev/null | tr '[:lower:]' '[:upper:]' || true; } + + printf '%s\n' "$HEAD_REF" | extract > /tmp/linear_branch_ids.txt + { + cat /tmp/linear_branch_ids.txt + [ -f /tmp/commits.txt ] && extract < /tmp/commits.txt + printf '%s\n' "$PR_BODY" | extract + } | awk '!seen[$0]++' > /tmp/linear_all_ids.txt + + BRANCH_PRIMARY=$(head -1 /tmp/linear_branch_ids.txt || true) + + # candidates: [{id, verb, source}] — verb starts as the default + # (branch's own ticket → Closes, everything else → Part of). + jq -R -n --arg primary "$BRANCH_PRIMARY" --arg fp "$FALSE_POSITIVES" ' + [inputs | select(length > 0) + | select((test($fp) | not) or . == $primary) + | {id: ., + verb: (if . == $primary then "Closes" else "Part of" end), + source: (if . == $primary then "branch" else "commits/body" end)} + ]' /tmp/linear_all_ids.txt > /tmp/linear_candidates.json + + COUNT=$(jq 'length' /tmp/linear_candidates.json) + echo "Found $COUNT Linear issue reference(s): $(jq -r '[.[].id] | join(", ")' /tmp/linear_candidates.json)" + echo "count=$COUNT" >> "$GITHUB_OUTPUT" + + # Preserve human intent: if the current PR body already has a + # `## Linear` section whose verbs differ from the marker we last + # wrote (or has no marker at all), those verbs were human-edited — + # they win over both defaults and the LLM. + printf '%s\n' "$PR_BODY" | awk '/^## Linear[[:space:]]*$/{f=1; next} /^## /{f=0} f' > /tmp/linear_existing_section.txt + MARKER=$(printf '%s\n' "$PR_BODY" | grep -oE '' | head -1 || true) + + : > /tmp/linear_human_verbs.txt + if [ -s /tmp/linear_existing_section.txt ]; then + grep -oiE "(Closes|Fixes|Resolves|Completes|Part of|Refs|References|Related to) ${ID_PATTERN}" \ + /tmp/linear_existing_section.txt > /tmp/linear_current_verbs.txt || true + while IFS= read -r line; do + [ -z "$line" ] && continue + ID=$(printf '%s' "$line" | grep -oiE "$ID_PATTERN" | tail -1 | tr '[:lower:]' '[:upper:]') + VERB=$(printf '%s' "$line" | sed -E 's/[[:space:]][A-Za-z][A-Za-z0-9]+-[0-9]+$//') + if [ -z "$MARKER" ] || ! printf '%s' "$MARKER" | grep -qF "${ID}=${VERB}"; then + printf '%s\t%s\n' "$ID" "$VERB" >> /tmp/linear_human_verbs.txt + fi + done < /tmp/linear_current_verbs.txt + fi + + # Cosmetic enrichment only — titles make the section readable, and a + # definitive "issue not found" prunes acronym false positives. Any + # API failure degrades to bare IDs; it never fails the job. + - name: Enrich Linear issues (titles) + if: inputs.linear-enabled && steps.linear.outputs.count != '0' && steps.linear.outputs.count != '' + shell: bash + env: + LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }} + run: | + set -uo pipefail + if [ -z "${LINEAR_API_KEY:-}" ]; then + echo "LINEAR_API_KEY not set — skipping enrichment" + exit 0 + fi + + ENRICHED='[]' + while IFS= read -r ID; do + RESP=$(curl -sS --max-time 15 https://api.linear.app/graphql \ + -H "Content-Type: application/json" \ + -H "Authorization: ${LINEAR_API_KEY}" \ + -d "$(jq -n --arg id "$ID" '{query: "query($id: String!) { issue(id: $id) { identifier title state { name } } }", variables: {id: $id}}')" \ + 2>/dev/null) || RESP="" + + TITLE=$(printf '%s' "$RESP" | jq -r '.data.issue.title // empty' 2>/dev/null || true) + # "data.issue == null" with a well-formed response is a definitive + # not-found; transport/auth errors are indeterminate → keep the id. + NOT_FOUND=false + if [ -n "$RESP" ] && [ "$(printf '%s' "$RESP" | jq -r 'if (.data? | type == "object") and (.data | has("issue")) and .data.issue == null then "yes" else "no" end' 2>/dev/null)" = "yes" ]; then + NOT_FOUND=true + fi + ENRICHED=$(printf '%s' "$ENRICHED" | jq --arg id "$ID" --arg title "$TITLE" --argjson nf "$NOT_FOUND" \ + '. + [{id: $id, title: $title, not_found: $nf}]') + done < <(jq -r '.[].id' /tmp/linear_candidates.json) + + printf '%s' "$ENRICHED" > /tmp/linear_enriched.json + + # Prune definitive not-founds, except the branch-derived primary + # (deliberate naming beats a possibly-permission-limited lookup). + if jq --slurpfile e /tmp/linear_enriched.json ' + [.[] | . as $c + | ([ $e[0][] | select(.id == $c.id) ] | .[0]) as $match + | select($match == null or ($match.not_found | not) or $c.source == "branch") + | . + {title: ($match.title // "")}] + ' /tmp/linear_candidates.json > /tmp/linear_candidates_enriched.json 2>/dev/null; then + mv /tmp/linear_candidates_enriched.json /tmp/linear_candidates.json + fi + + echo "Enriched candidates: $(jq -c '[.[] | {id, title}]' /tmp/linear_candidates.json)" + - name: Build prompt if: steps.check.outputs.skip != 'true' || github.event_name != 'pull_request' shell: bash @@ -249,7 +395,43 @@ jobs: EOF echo "$CUSTOM_PROMPT" >> /tmp/prompt.txt else - cat > /tmp/prompt.txt << 'HEREDOC_END' + HAS_LINEAR=false + if [ -s /tmp/linear_candidates.json ] && [ "$(jq 'length' /tmp/linear_candidates.json 2>/dev/null || echo 0)" != "0" ]; then + HAS_LINEAR=true + fi + + if [ "$HAS_LINEAR" = "true" ]; then + cat > /tmp/prompt.txt << 'HEREDOC_END' + You are writing a pull request title and description for a Tauri v2 desktop app. + + Generate a JSON object with exactly three fields: + - "title": concise PR title under 70 chars, conventional commit format (feat:, fix:, refactor:, etc.) + - "body": markdown with these sections: + ## Summary (3-5 bullet points) + ## Changes (grouped by area) + ## Test plan (bulleted checklist) + - "linear": an array with one entry per issue listed under === LINEAR ISSUES === below, + each {"id": "", "verb": ""}. + Pick the verb from the change's intent as evidenced by the commits/diff: + - Use a CLOSING verb ("Closes", "Fixes", "Resolves", "Completes") when this PR + finishes the work that issue describes. Prefer "Fixes" for bug fixes and + "Closes" otherwise. + - Use a LINKING verb ("Part of", "Refs") when the PR only advances or relates + to the issue without finishing it. + - Use "None" ONLY for an entry that is clearly not a project ticket + (e.g. an acronym like UTF-8 that slipped through extraction). + Never invent new ids, never omit a listed id, and do NOT include any + Linear/ticket section inside "body" — the workflow renders that separately. + + Output ONLY the raw JSON object. No markdown fences. No explanation. + HEREDOC_END + + echo "" >> /tmp/prompt.txt + echo "=== LINEAR ISSUES ===" >> /tmp/prompt.txt + jq -r '.[] | "- \(.id)\(if (.title // "") != "" then " — \(.title)" else "" end) (default verb: \(.verb)\(if .source == "branch" then ", from branch name" else "" end))"' \ + /tmp/linear_candidates.json >> /tmp/prompt.txt + else + cat > /tmp/prompt.txt << 'HEREDOC_END' You are writing a pull request title and description for a Tauri v2 desktop app. Generate a JSON object with exactly two fields: @@ -261,6 +443,7 @@ jobs: Output ONLY the raw JSON object. No markdown fences. No explanation. HEREDOC_END + fi fi echo "" >> /tmp/prompt.txt @@ -289,12 +472,6 @@ jobs: cat /tmp/diff.txt >> /tmp/prompt.txt fi - # Store full prompt as output for the composite action - PROMPT_CONTENT=$(cat /tmp/prompt.txt) - echo "prompt<> "$GITHUB_OUTPUT" - echo "$PROMPT_CONTENT" >> "$GITHUB_OUTPUT" - echo "PROMPT_EOF" >> "$GITHUB_OUTPUT" - - name: Generate PR title and description with Jules if: steps.check.outputs.skip != 'true' || github.event_name != 'pull_request' id: jules @@ -310,6 +487,7 @@ jobs: GH_TOKEN: ${{ steps.app-token.outputs.token || github.token }} PR_NUMBER: ${{ github.event.pull_request.number }} CURRENT_TITLE: ${{ github.event.pull_request.title }} + CURRENT_BODY: ${{ github.event.pull_request.body }} JULES_OUTPUT: ${{ steps.jules.outputs.response }} run: | # Try parsing as JSON first @@ -317,11 +495,57 @@ jobs: TITLE=$(echo "$OUTPUT" | jq -r '.title // empty' 2>/dev/null) BODY=$(echo "$OUTPUT" | jq -r '.body // empty' 2>/dev/null) + LLM_LINEAR=$(echo "$OUTPUT" | jq -c '.linear // []' 2>/dev/null || echo '[]') + [ -z "$LLM_LINEAR" ] && LLM_LINEAR='[]' if [ -z "$TITLE" ] || [ -z "$BODY" ]; then echo "Jules output is not JSON — using raw output as description" TITLE="${CURRENT_TITLE}" BODY="$JULES_OUTPUT" + LLM_LINEAR='[]' + fi + + # ── Deterministic `## Linear` section ────────────────────────── + # The workflow owns the section: LLM verbs are accepted only for + # extracted IDs and only from the allowlist; anything the model + # dropped comes back with its default verb; human-edited verbs + # (detected in the extract step) override everything. A hidden + # marker records what we wrote so the next run can tell machine + # output from human edits. + if [ -s /tmp/linear_candidates.json ] && [ "$(jq 'length' /tmp/linear_candidates.json 2>/dev/null || echo 0)" != "0" ]; then + # Strip any Linear/ticket section the model put in the body anyway. + BODY=$(printf '%s\n' "$BODY" | awk '/^## Linear/{drop=1; next} /^## /{drop=0} !drop') + + HUMAN_JSON='[]' + if [ -s /tmp/linear_human_verbs.txt ]; then + HUMAN_JSON=$(jq -R -n '[inputs | select(length>0) | split("\t") | {id: .[0], verb: .[1]}]' /tmp/linear_human_verbs.txt) + fi + + FINAL=$(jq -n \ + --slurpfile cand /tmp/linear_candidates.json \ + --argjson llm "$LLM_LINEAR" \ + --argjson human "$HUMAN_JSON" ' + def allowed: ["Closes","Fixes","Resolves","Completes","Part of","Refs","References","Related to"]; + [ $cand[0][] | . as $c + | ([ $llm[]? | select(type == "object" and .id == $c.id) ] | .[0]) as $l + | ([ $human[]? | select(.id == $c.id) ] | .[0]) as $h + | (if ($h.verb? // "") != "" then $h.verb + elif ($l.verb? // "") == "None" and $c.source != "branch" then "DROP" + elif (($l.verb? // "") != "") and ([$l.verb] | inside(allowed)) then $l.verb + else $c.verb end) as $verb + | select($verb != "DROP") + | {id: $c.id, verb: $verb, title: ($c.title? // "")} ]') + + if [ "$(printf '%s' "$FINAL" | jq 'length')" != "0" ]; then + SECTION=$(printf '%s' "$FINAL" | jq -r '.[] | "\(.verb) \(.id)\(if .title != "" then " — \(.title)" else "" end)"') + MARKER=$(printf '%s' "$FINAL" | jq -r '[.[] | "\(.id)=\(.verb)"] | join(";")') + BODY="## Linear + + + ${SECTION} + + ${BODY}" + fi fi # Append attribution @@ -330,5 +554,12 @@ jobs: --- *PR description generated by [Agent](https://weekendsuperhero.io)*" + # No-op guard: if nothing changed, skip the write — an `edited` + # retrigger of this workflow then converges instead of looping. + if [ "$TITLE" = "$CURRENT_TITLE" ] && [ "$BODY" = "$CURRENT_BODY" ]; then + echo "Generated title/body identical to current PR — skipping update" + exit 0 + fi + gh pr edit "$PR_NUMBER" --title "$TITLE" --body "$BODY" echo "PR #${PR_NUMBER} updated successfully" From ea0a95b80555fde7908500413590e17c44b30f7b Mon Sep 17 00:00:00 2001 From: Weekendsuperhero <4048475+WeekendSuperhero@users.noreply.github.com> Date: Mon, 13 Jul 2026 19:09:05 -0700 Subject: [PATCH 2/2] feat(changelog): user-facing vs internal split + action bumps - New internal-changelog-path input: when set, one Jules call classifies every commit as USER-FACING (ships verbatim as release notes / updater notes / TestFlight) vs INTERNAL (refactors, CI, deps, plumbing) via a JSON contract, and both files are patched under ## [Unreleased] in a single PR. Default '' keeps the exact single-file behavior for existing consumers. - Prompt bans ticket IDs/crate names/commit-speak in user-facing bullets; internal bullets keep full technical detail. - create-github-app-token v2 -> v3 (client-id), checkout v4 -> v6, create-pull-request v6 -> v8 (node20 -> node24); runs-on input. - awk inserts now pass entries via ENVIRON (BSD awk on macOS runners rejects newlines in -v values). --- .github/workflows/reusable-changelog.yml | 243 ++++++++++++++++++++--- 1 file changed, 217 insertions(+), 26 deletions(-) diff --git a/.github/workflows/reusable-changelog.yml b/.github/workflows/reusable-changelog.yml index 0bd5e1a..0713d91 100644 --- a/.github/workflows/reusable-changelog.yml +++ b/.github/workflows/reusable-changelog.yml @@ -15,10 +15,20 @@ # contents: write # uses: //.github/workflows/reusable-changelog.yml@main # with: -# changelog-path: CHANGELOG.md # optional -# pr-branch-prefix: "chore/changelog-" # optional +# changelog-path: CHANGELOG.md # optional +# internal-changelog-path: CHANGELOG_INTERNAL.md # optional — enables the split +# pr-branch-prefix: "chore/changelog-" # optional +# runs-on: warp-ubuntu-latest-x64-4x # optional # secrets: inherit # ``` +# +# Split mode (internal-changelog-path set): +# One Jules call classifies every commit into USER-FACING (what a user of the +# app can perceive — ships verbatim as release notes / updater notes / +# TestFlight "What to Test") vs INTERNAL (refactors, CI, deps, plumbing — +# recorded but never shown to users), and both files are patched under their +# `## [Unreleased]` headings in a single PR. With the input unset the +# workflow behaves exactly as before: one file, one markdown blob. name: Reusable Changelog @@ -26,10 +36,20 @@ on: workflow_call: inputs: changelog-path: - description: "Path to the CHANGELOG file" + description: "Path to the user-facing CHANGELOG file" type: string required: false default: "CHANGELOG.md" + internal-changelog-path: + description: "Path to the internal changelog. Empty (default) keeps the single-file behavior; setting it enables the user-facing/internal split." + type: string + required: false + default: "" + runs-on: + description: "Runner label for the job (e.g. warp-ubuntu-latest-x64-4x)" + type: string + required: false + default: "ubuntu-latest" pr-branch-prefix: description: "Prefix for the branch created by this workflow" type: string @@ -94,7 +114,7 @@ on: jobs: changelog: name: Generate Changelog PR - runs-on: ubuntu-latest + runs-on: ${{ inputs.runs-on }} permissions: contents: write @@ -123,9 +143,9 @@ jobs: - name: Generate GitHub App token if: steps.app-auth-config.outputs.enabled == 'true' id: app-token - uses: actions/create-github-app-token@v2 + uses: actions/create-github-app-token@v3 with: - app-id: ${{ secrets.JULES_PR_CLIENT_ID }} + client-id: ${{ secrets.JULES_PR_CLIENT_ID }} private-key: ${{ secrets.JULES_PR_PRIVATE_KEY }} owner: ${{ github.repository_owner }} @@ -148,7 +168,7 @@ jobs: echo "identity=${BOT_NAME} <${BOT_EMAIL}>" >> "$GITHUB_OUTPUT" - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: token: ${{ steps.app-token.outputs.token || github.token }} fetch-depth: 0 @@ -157,18 +177,16 @@ jobs: shell: bash env: CHANGELOG_PATH: ${{ inputs.changelog-path }} + INTERNAL_PATH: ${{ inputs.internal-changelog-path }} UNRELEASED: ${{ inputs.unreleased-heading }} run: | set -euo pipefail - if [ -f "$CHANGELOG_PATH" ]; then - exit 0 - fi + if [ ! -f "$CHANGELOG_PATH" ]; then + echo "Changelog file not found. Creating $CHANGELOG_PATH" + mkdir -p "$(dirname "$CHANGELOG_PATH")" - echo "Changelog file not found. Creating $CHANGELOG_PATH" - mkdir -p "$(dirname "$CHANGELOG_PATH")" - - cat > "$CHANGELOG_PATH" < "$CHANGELOG_PATH" < "$INTERNAL_PATH" <> "$GITHUB_OUTPUT" + EXCLUDE_PATHS=(":!$CHANGELOG_PATH") + [ -n "$INTERNAL_PATH" ] && EXCLUDE_PATHS+=(":!$INTERNAL_PATH") + git log -n "$MAX_COMMITS" "$RANGE_SPEC" \ --no-merges \ --date=iso-strict \ --format='=== COMMIT ===%nHash: %H%nAuthor: %an <%ae>%nDate: %ad%nSubject: %s%nBody:%n%b%n' \ - -- ":!$CHANGELOG_PATH" \ + -- "${EXCLUDE_PATHS[@]}" \ > /tmp/commits.txt COMMIT_COUNT=$(grep -c '^=== COMMIT ===$' /tmp/commits.txt || true) @@ -230,6 +270,7 @@ jobs: shell: bash env: CHANGELOG_PATH: ${{ inputs.changelog-path }} + INTERNAL_PATH: ${{ inputs.internal-changelog-path }} CUSTOM_PROMPT: ${{ inputs.custom-prompt }} run: | set -euo pipefail @@ -238,7 +279,59 @@ jobs: cat > /tmp/prompt.txt << 'EOF' EOF echo "$CUSTOM_PROMPT" >> /tmp/prompt.txt + elif [ -n "$INTERNAL_PATH" ]; then + # ── Split mode: one call classifies user-facing vs internal ── + head -60 "$CHANGELOG_PATH" > /tmp/current_changelog.txt + + cat > /tmp/prompt.txt << 'HEREDOC_END' + You are updating TWO changelogs for a desktop app, both following Keep a Changelog + (https://keepachangelog.com/en/1.1.0/): + + 1. USER-FACING — shipped verbatim to end users as release notes (GitHub Release + body, in-app updater notes, TestFlight "What to Test"). Written for a + non-developer user of the app. + 2. INTERNAL — an engineering record of mechanics. Never shown to users. + + Classify every change from the commits below: + - USER-FACING: anything a user can perceive — visible behavior/UX, new + capabilities, perceptible performance, crash/reliability fixes, security + fixes with user impact, platform support. + - INTERNAL: refactors with no behavior change, CI/CD and build tooling, + dependency bumps, tests, developer docs, protocol/wire plumbing, + schema/migration mechanics, logging/tracing, cleanup. + - A change with BOTH a user-visible face and mechanics appears in BOTH lists + with different wording (user words vs mechanics). + - Unsure whether users would notice? Put it in INTERNAL — keep the user + changelog clean. Prefer omission over a vague user-facing bullet. + - A dependency bump that fixes a user-visible bug IS user-facing. + + Style for USER-FACING bullets: + - `- **Feature name** — user-visible outcome.` The name is a product noun, + never a code symbol. + - Product voice, present tense, benefit-first. No commit-speak (bump, wip, + refactor, plumb, wire), no ticket IDs (e.g. WEE-123), no crate/module/file + names, no PR numbers. + - Merge all commits about one feature into ONE bullet. Never one bullet per + commit. + + Style for INTERNAL bullets: + - Same `- **Name** — description.` shape, but ticket IDs, crate names, and + technical rationale are welcome. May be more granular. + + Output a single JSON object, no markdown fences, no explanation: + { + "user_facing": {"Added": ["- **...** — ..."], "Changed": [], "Fixed": [], "Removed": [], "Security": [], "Deprecated": []}, + "internal": {"Added": [], "Changed": [], "Fixed": [], "Removed": [], "Security": [], "Deprecated": []} + } + Every array element is one complete markdown bullet. Omit or leave empty any + category with no entries. + + === CURRENT USER-FACING CHANGELOG FORMAT (for reference) === + HEREDOC_END + + cat /tmp/current_changelog.txt >> /tmp/prompt.txt else + # ── Legacy single-file mode (unchanged behavior) ── head -60 "$CHANGELOG_PATH" > /tmp/current_changelog.txt cat > /tmp/prompt.txt << 'HEREDOC_END' @@ -264,11 +357,6 @@ jobs: echo "=== FULL COMMITS ===" >> /tmp/prompt.txt cat /tmp/commits.txt >> /tmp/prompt.txt - PROMPT_CONTENT=$(cat /tmp/prompt.txt) - echo "prompt<> "$GITHUB_OUTPUT" - echo "$PROMPT_CONTENT" >> "$GITHUB_OUTPUT" - echo "PROMPT_EOF" >> "$GITHUB_OUTPUT" - - name: Generate changelog entry with Jules if: steps.changes.outputs.skip != 'true' id: jules @@ -277,8 +365,8 @@ jobs: title: "Changelog Update" jules_api_key: ${{ secrets.JULES_API_KEY }} - - name: Patch changelog - if: steps.changes.outputs.skip != 'true' + - name: Patch changelog (single-file mode) + if: steps.changes.outputs.skip != 'true' && inputs.internal-changelog-path == '' shell: bash env: CHANGELOG_PATH: ${{ inputs.changelog-path }} @@ -301,11 +389,13 @@ jobs: mv /tmp/changelog_with_heading.md "$CHANGELOG_PATH" fi - awk -v entries="$NEW_ENTRIES" -v heading="$UNRELEASED" ' + # entries via ENVIRON, not -v: BSD awk (macOS runners) rejects + # newlines in -v values, and ENVIRON skips escape processing. + entries="$NEW_ENTRIES" awk -v heading="$UNRELEASED" ' $0 == heading { print $0 print "" - print entries + print ENVIRON["entries"] next } { print } @@ -316,9 +406,110 @@ jobs: echo "=== Updated changelog (first 40 lines) ===" head -40 "$CHANGELOG_PATH" + - name: Patch changelogs (split mode) + if: steps.changes.outputs.skip != 'true' && inputs.internal-changelog-path != '' + shell: bash + env: + CHANGELOG_PATH: ${{ inputs.changelog-path }} + INTERNAL_PATH: ${{ inputs.internal-changelog-path }} + UNRELEASED: ${{ inputs.unreleased-heading }} + NEW_ENTRIES: ${{ steps.jules.outputs.response }} + run: | + set -euo pipefail + + if [ -z "$NEW_ENTRIES" ]; then + echo "::error::No output from Jules" + exit 1 + fi + + # Strip optional code fences and validate the JSON envelope. + printf '%s' "$NEW_ENTRIES" | sed 's/^```json//;s/^```//;s/```$//' | tr -d '\r' > /tmp/changelog_parts.txt + if ! jq -e 'type == "object"' /tmp/changelog_parts.txt > /dev/null 2>&1; then + echo "::error::Jules output is not the expected JSON object" + head -40 /tmp/changelog_parts.txt + exit 1 + fi + + # Render one part ("user_facing" | "internal") to Keep-a-Changelog + # markdown: ### headings in canonical order, only non-empty ones. + render_part() { + jq -r --arg part "$1" ' + (.[$part] // {}) as $p + | ["Added","Changed","Fixed","Removed","Security","Deprecated"] + | map(. as $cat + | ($p[$cat] // []) + | if length > 0 then "### \($cat)\n\n\(join("\n"))\n" else empty end) + | join("\n")' /tmp/changelog_parts.txt + } + + insert_under_unreleased() { + local file="$1" entries="$2" + + if ! grep -Fxq "$UNRELEASED" "$file"; then + echo "Unreleased heading missing in $file. Prepending '$UNRELEASED'." + { + printf '%s\n\n' "$UNRELEASED" + cat "$file" + } > /tmp/changelog_with_heading.md + mv /tmp/changelog_with_heading.md "$file" + fi + + # entries via ENVIRON, not -v: BSD awk (macOS runners) rejects + # newlines in -v values, and ENVIRON skips escape processing. + entries="$entries" awk -v heading="$UNRELEASED" ' + $0 == heading { + print $0 + print "" + print ENVIRON["entries"] + next + } + { print } + ' "$file" > /tmp/changelog_new.md + mv /tmp/changelog_new.md "$file" + } + + USER_MD=$(render_part user_facing) + INTERNAL_MD=$(render_part internal) + + if [ -z "$USER_MD" ] && [ -z "$INTERNAL_MD" ]; then + echo "::error::Jules returned no entries in either part" + exit 1 + fi + + if [ -n "$USER_MD" ]; then + insert_under_unreleased "$CHANGELOG_PATH" "$USER_MD" + echo "=== Updated $CHANGELOG_PATH (first 40 lines) ===" + head -40 "$CHANGELOG_PATH" + else + echo "No user-facing entries this round — $CHANGELOG_PATH untouched" + fi + + if [ -n "$INTERNAL_MD" ]; then + insert_under_unreleased "$INTERNAL_PATH" "$INTERNAL_MD" + echo "=== Updated $INTERNAL_PATH (first 40 lines) ===" + head -40 "$INTERNAL_PATH" + else + echo "No internal entries this round — $INTERNAL_PATH untouched" + fi + + - name: Compute PR paths + if: steps.changes.outputs.skip != 'true' + id: paths + shell: bash + env: + CHANGELOG_PATH: ${{ inputs.changelog-path }} + INTERNAL_PATH: ${{ inputs.internal-changelog-path }} + run: | + { + echo "add_paths<> "$GITHUB_OUTPUT" + - name: Create Pull Request if: steps.changes.outputs.skip != 'true' - uses: peter-evans/create-pull-request@v6 + uses: peter-evans/create-pull-request@v8 with: token: ${{ steps.app-token.outputs.token || github.token }} author: ${{ steps.app-bot.outputs.identity || 'github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>' }} @@ -327,4 +518,4 @@ jobs: title: ${{ inputs.pr-title }} body: ${{ format(inputs.pr-body-template, steps.changes.outputs.commit_count) }} branch: "${{ inputs.pr-branch-prefix }}${{ github.run_id }}" - add-paths: ${{ inputs.changelog-path }} + add-paths: ${{ steps.paths.outputs.add_paths }}