diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml deleted file mode 100644 index 8b2ecb7a..00000000 --- a/.github/actionlint.yaml +++ /dev/null @@ -1,7 +0,0 @@ -# actionlint configuration. https://github.com/rhysd/actionlint/blob/main/docs/config.md -paths: - .github/workflows/**/*.{yml,yaml}: - ignore: - # `copilot-requests` is the permission that lets GITHUB_TOKEN call - # GitHub Copilot. actionlint 1.7.12 does not know the scope yet. - - 'unknown permission scope "copilot-requests"' diff --git a/.github/actions/setup-copilot-cli/action.yml b/.github/actions/setup-copilot-cli/action.yml deleted file mode 100644 index f51e9426..00000000 --- a/.github/actions/setup-copilot-cli/action.yml +++ /dev/null @@ -1,51 +0,0 @@ -name: Set up Copilot CLI -description: >- - Installs a pinned GitHub Copilot CLI and `copilot-locked`, a launcher that - runs it with everything that lets it act switched off. - -inputs: - version: - description: The @github/copilot npm version to install. - required: false - default: "1.0.88" - -outputs: - launcher: - description: Absolute path of `copilot-locked`. Pass it as `copilot-cli-path` to actions/ai-inference. - value: ${{ steps.install.outputs.launcher }} - -runs: - using: composite - steps: - - name: Install Copilot CLI - id: install - shell: bash - env: - COPILOT_CLI_VERSION: ${{ inputs.version }} - run: | - # Pinned: the AI workflows run with write tokens next to text anyone - # can write, so a new CLI release must not change what they execute - # until someone bumps this on purpose. The package ships prebuilt - # binaries as optional dependencies and needs no install scripts. - npm install --global --no-audit --no-fund --ignore-scripts "@github/copilot@${COPILOT_CLI_VERSION}" - - # The prompts carry issue bodies, pull request text and translations. - # Without repository instructions (AGENTS.md would be read as orders), - # built-in MCP servers (the GitHub server holds the token) or temp-dir - # access, and in prompt mode, which denies every tool that needs - # approval, the model can answer but cannot act. - launcher_dir="$RUNNER_TEMP/copilot-launcher" - mkdir -p "$launcher_dir" - cat > "$launcher_dir/copilot-locked" <<'EOF' - #!/usr/bin/env bash - exec copilot "$@" \ - --no-custom-instructions \ - --disable-builtin-mcps \ - --no-auto-update \ - --disallow-temp-dir - EOF - chmod +x "$launcher_dir/copilot-locked" - echo "$launcher_dir" >> "$GITHUB_PATH" - echo "launcher=$launcher_dir/copilot-locked" >> "$GITHUB_OUTPUT" - - copilot --version diff --git a/.github/prompts/release-notes.prompt.yml b/.github/prompts/release-notes.prompt.yml index 1eb38b2c..d4928cd3 100644 --- a/.github/prompts/release-notes.prompt.yml +++ b/.github/prompts/release-notes.prompt.yml @@ -30,9 +30,3 @@ messages: ### Documentation ### Dependencies ### Developer Experience -# `auto` lets the Copilot CLI pick. Deliberately not a pinned model id: it is -# the only value valid on every Copilot plan (Free and Student get auto -# selection only), and it cannot go stale the way `openai/gpt-4.1` did when -# GitHub Models was retired on 2026-07-30 and took both of these workflows -# down with it. -model: auto diff --git a/.github/prompts/telegram-release-summary.prompt.yml b/.github/prompts/telegram-release-summary.prompt.yml index 8295ec13..8567f51d 100644 --- a/.github/prompts/telegram-release-summary.prompt.yml +++ b/.github/prompts/telegram-release-summary.prompt.yml @@ -20,9 +20,3 @@ messages: {{commits}} Format: one short opening sentence, a blank line, then bullets starting with "- " (one per line). Nothing else. -# `auto` lets the Copilot CLI pick. Deliberately not a pinned model id: it is -# the only value valid on every Copilot plan (Free and Student get auto -# selection only), and it cannot go stale the way `openai/gpt-4.1` did when -# GitHub Models was retired on 2026-07-30 and took both of these workflows -# down with it. -model: auto diff --git a/.github/scripts/ai-infer.sh b/.github/scripts/ai-infer.sh new file mode 100644 index 00000000..b2d66e5d --- /dev/null +++ b/.github/scripts/ai-infer.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +# Sends one prompt to a free OpenRouter model and writes the answer to a file. +# +# bash .github/scripts/ai-infer.sh SYSTEM_PROMPT_FILE USER_PROMPT_FILE OUTPUT_FILE +# +# Needs the OPENROUTER_API_KEY secret in AI_API_KEY. GitHub Models was retired +# on 2026-07-30, and Copilot through the workflow token needs an org policy +# that bills the org. +# +# Free models allow 20 requests a minute and 1000 a day (the account has +# bought credits), and the free list changes: when a model goes, pick another +# `:free` id from https://openrouter.ai/models (i18n-review.yml has the same +# list). A free model is often rate-limited upstream, so OpenRouter tries the +# fallbacks in order. AI_MODEL and AI_FALLBACK_MODELS override them. +# +# The user prompt is cut to AI_PROMPT_MAX_BYTES, so put the least important +# context at its end. +# +# Exits 0 with a non-empty answer in OUTPUT_FILE, 1 otherwise. The caller +# decides how to degrade. Nothing the model or the prompt says is printed to +# the log without `::stop-commands::` around it, because a log line that +# starts with `::` is a workflow command. +set -euo pipefail + +if [ "$#" -ne 3 ]; then + echo "usage: $0 SYSTEM_PROMPT_FILE USER_PROMPT_FILE OUTPUT_FILE" >&2 + exit 2 +fi + +system_file=$1 +user_file=$2 +out_file=$3 +api_url=${AI_API_URL:-https://openrouter.ai/api/v1/chat/completions} +model=${AI_MODEL:-nvidia/nemotron-3-super-120b-a12b:free} +fallbacks=${AI_FALLBACK_MODELS:-minimax/minimax-m3.1-flash:free,google/gemma-4-31b-it:free} +max_bytes=${AI_PROMPT_MAX_BYTES:-120000} +timeout_seconds=${AI_TIMEOUT_SECONDS:-300} + +: > "$out_file" + +if [ -z "${AI_API_KEY:-}" ]; then + echo "::error::AI_API_KEY is not set (the OPENROUTER_API_KEY secret)" + exit 1 +fi + +scratch=$(mktemp -d) +trap 'rm -rf "$scratch"' EXIT + +# Prints untrusted text without letting a line act as a workflow command. +quote_log() { + local stop_token + stop_token="ai-infer-$(od -An -N12 -tx1 /dev/urandom | tr -d ' \n')" + echo "::stop-commands::$stop_token" + printf '%s\n' "$1" + echo "::$stop_token::" +} + +user_bytes=$(wc -c < "$user_file") +if [ "$user_bytes" -gt "$max_bytes" ]; then + echo "::warning::User prompt cut from $user_bytes to $max_bytes bytes" +fi +# A cut can split a UTF-8 sequence; iconv -c drops the broken tail (and exits +# 1 when it does). +head -c "$max_bytes" "$user_file" | tr -d '\000' | + { iconv -f UTF-8 -t UTF-8 -c 2> /dev/null || true; } > "$scratch/user.txt" + +jq -n \ + --rawfile system "$system_file" \ + --rawfile user "$scratch/user.txt" \ + --arg model "$model" \ + --arg fallbacks "$fallbacks" \ + '{ + model: $model, + models: ([$model] + ($fallbacks | split(",") | map(gsub("^\\s+|\\s+$"; "")) | map(select(length > 0)))), + messages: [ + {role: "system", content: $system}, + {role: "user", content: $user} + ], + temperature: 0, + max_tokens: 8192 + }' > "$scratch/request.json" + +# The key goes to curl on stdin, so it is not in the process list. curl +# retries 429 and 5xx answers and honours Retry-After. +status=0 +http_code=$( + printf 'header = "Authorization: Bearer %s"\n' "$AI_API_KEY" | + curl -sS --config - \ + --max-time "$timeout_seconds" \ + --retry 2 --retry-max-time 120 \ + -H "Content-Type: application/json" \ + --data-binary "@$scratch/request.json" \ + -o "$scratch/response.json" \ + -w '%{http_code}' \ + "$api_url" +) || status=$? + +if [ "$status" -ne 0 ]; then + if [ "$status" -eq 28 ]; then + echo "::error::The model API did not answer in ${timeout_seconds}s" + else + echo "::error::The model API could not be reached (curl exit $status)" + fi + exit 1 +fi + +error=$(jq -r '.error.message // empty' "$scratch/response.json" 2> /dev/null || true) +if [ "$http_code" != "200" ] || [ -n "$error" ]; then + echo "::error::The model API answered HTTP $http_code" + if [ -n "$error" ]; then + quote_log "${error:0:500}" + fi + exit 1 +fi + +if ! jq -j '.choices[0].message.content // empty' "$scratch/response.json" > "$out_file" 2> /dev/null; then + echo "::error::The model API returned a body that is not JSON" + exit 1 +fi + +if [ -z "$(tr -d '[:space:]' < "$out_file")" ]; then + echo "::error::The model returned an empty answer" + exit 1 +fi + +answered_by=$(jq -r '.model // "unknown"' "$scratch/response.json") +echo "Model $answered_by answered with $(wc -c < "$out_file") bytes" diff --git a/.github/scripts/copilot-infer.sh b/.github/scripts/copilot-infer.sh deleted file mode 100644 index 5a1b2e46..00000000 --- a/.github/scripts/copilot-infer.sh +++ /dev/null @@ -1,104 +0,0 @@ -#!/usr/bin/env bash -# Sends one prompt to GitHub Copilot and writes the answer to a file. -# -# bash .github/scripts/copilot-infer.sh SYSTEM_PROMPT_FILE USER_PROMPT_FILE OUTPUT_FILE -# -# Needs .github/actions/setup-copilot-cli (for `copilot-locked`), the job -# permission `copilot-requests: write`, and the workflow token in -# COPILOT_GITHUB_TOKEN. COPILOT_MODEL picks the model (default `auto`, the -# only value every Copilot plan accepts). -# -# GitHub Models (models.github.ai) was retired on 2026-07-30. It answered 410 -# after that, and later 200 with a body that is not JSON, so every workflow -# that still called it degraded on every run. Copilot is what the workflow -# token can reach now. -# -# The prompt goes to the CLI as one argument, and Linux caps one argument at -# 128 KiB. The user prompt is cut to fit COPILOT_PROMPT_MAX_BYTES, so put the -# least important context at its end. -# -# Exits 0 with a non-empty answer in OUTPUT_FILE, 1 otherwise. The caller -# decides how to degrade. Nothing the model or the prompt says is printed to -# the log without `::stop-commands::` around it, because a log line that -# starts with `::` is a workflow command. -set -euo pipefail - -if [ "$#" -ne 3 ]; then - echo "usage: $0 SYSTEM_PROMPT_FILE USER_PROMPT_FILE OUTPUT_FILE" >&2 - exit 2 -fi - -system_file=$1 -user_file=$2 -out_file=$3 -model=${COPILOT_MODEL:-auto} -max_bytes=${COPILOT_PROMPT_MAX_BYTES:-120000} -timeout_seconds=${COPILOT_TIMEOUT_SECONDS:-300} - -: > "$out_file" - -if [ -z "${COPILOT_GITHUB_TOKEN:-}" ]; then - echo "::error::COPILOT_GITHUB_TOKEN is not set" - exit 1 -fi -if ! command -v copilot-locked > /dev/null 2>&1; then - echo "::error::copilot-locked is not on PATH; run .github/actions/setup-copilot-cli first" - exit 1 -fi - -system_bytes=$(wc -c < "$system_file") -budget=$((max_bytes - system_bytes - 2)) -if [ "$budget" -lt 1024 ]; then - echo "::error::The system prompt ($system_bytes bytes) leaves no room for the user prompt" - exit 1 -fi -user_bytes=$(wc -c < "$user_file") -if [ "$user_bytes" -gt "$budget" ]; then - echo "::warning::User prompt cut from $user_bytes to $budget bytes to fit one argument" -fi - -scratch=$(mktemp -d) -workdir=$(mktemp -d) -trap 'rm -rf "$scratch" "$workdir"' EXIT - -# NUL bytes cannot travel in an argument, and a cut can split a UTF-8 -# sequence; iconv -c drops the broken tail (and exits 1 when it does). -{ - cat "$system_file" - printf '\n\n' - head -c "$budget" "$user_file" | tr -d '\000' | { iconv -f UTF-8 -t UTF-8 -c 2> /dev/null || true; } -} > "$scratch/prompt.txt" -prompt=$(cat "$scratch/prompt.txt") - -# An empty working directory: the file tools that need no approval have -# nothing to read. -status=0 -( - cd "$workdir" - timeout --kill-after=15 "$timeout_seconds" \ - copilot-locked -p "$prompt" -s --no-ask-user --model "$model" -) > "$out_file" 2> "$scratch/stderr.txt" || status=$? - -if [ "$status" -ne 0 ]; then - if [ "$status" -eq 124 ]; then - echo "::error::Copilot CLI timed out after ${timeout_seconds}s" - else - echo "::error::Copilot CLI exited with status $status" - fi - if [ -s "$scratch/stderr.txt" ]; then - stop_token="copilot-stderr-$(od -An -N12 -tx1 /dev/urandom | tr -d ' \n')" - echo "::stop-commands::$stop_token" - echo "Copilot CLI stderr (last 2000 bytes):" - tail -c 2000 "$scratch/stderr.txt" - echo - echo "::$stop_token::" - fi - exit 1 -fi - -if [ -z "$(tr -d '[:space:]' < "$out_file")" ]; then - echo "::error::Copilot CLI returned an empty answer" - exit 1 -fi - -echo "Copilot answered with $(wc -c < "$out_file") bytes" diff --git a/.github/scripts/extract-json.py b/.github/scripts/extract-json.py index 5ec70c0f..b22592e8 100644 --- a/.github/scripts/extract-json.py +++ b/.github/scripts/extract-json.py @@ -3,8 +3,8 @@ python3 .github/scripts/extract-json.py ANSWER_FILE OUTPUT_FILE -Copilot answers in text. Even when told to return only JSON, a model can wrap -it in a markdown fence or put a sentence before it. This skips anything around +A model answers in text. Even when told to return only JSON, it can wrap the +object in a markdown fence or put a sentence before it. This skips anything around the first object that parses. Exits 0 when it wrote one, 1 when there is none. """ diff --git a/.github/scripts/render-prompt.sh b/.github/scripts/render-prompt.sh new file mode 100644 index 00000000..5e9cacf8 --- /dev/null +++ b/.github/scripts/render-prompt.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Turns a .github/prompts/*.prompt.yml file into the two prompt files +# ai-infer.sh takes, with {{version}} and {{commits}} filled in. +# +# bash .github/scripts/render-prompt.sh PROMPT_FILE VERSION COMMITS_FILE SYSTEM_OUT USER_OUT +set -euo pipefail + +if [ "$#" -ne 5 ]; then + echo "usage: $0 PROMPT_FILE VERSION COMMITS_FILE SYSTEM_OUT USER_OUT" >&2 + exit 2 +fi + +prompt_file=$1 +version=$2 +commits_file=$3 +system_out=$4 +user_out=$5 + +yq '.messages[] | select(.role == "system") | .content' "$prompt_file" > "$system_out" +# Python, not bash ${//}: commit subjects may hold `&`, which bash 5.2 +# replaces with the matched text. +yq '.messages[] | select(.role == "user") | .content' "$prompt_file" | + python3 -c ' +import sys +version = sys.argv[1] +with open(sys.argv[2], encoding="utf-8", errors="replace") as fh: + commits = fh.read() +sys.stdout.write(sys.stdin.read().replace("{{version}}", version).replace("{{commits}}", commits)) +' "$version" "$commits_file" > "$user_out" diff --git a/.github/workflows/issue-validation.yml b/.github/workflows/issue-validation.yml index 466c27d5..20fa4e5b 100644 --- a/.github/workflows/issue-validation.yml +++ b/.github/workflows/issue-validation.yml @@ -13,14 +13,6 @@ on: permissions: contents: read -env: - # GitHub Models (models.github.ai) was retired on 2026-07-30, and every call - # to it here had degraded since. Both AI jobs now ask GitHub Copilot through - # the workflow token (`copilot-requests: write`). `auto` lets Copilot pick: - # it is the only value every Copilot plan accepts and it cannot go stale the - # way the pinned `openai/gpt-4.1` did. - COPILOT_MODEL: auto - jobs: # Decides what happens to a new issue before triage answers it. Minor # problems never close an issue: a feature filed as a bug (or the reverse) @@ -39,7 +31,6 @@ jobs: permissions: contents: read issues: write - copilot-requests: write outputs: action: ${{ steps.decide.outputs.action }} steps: @@ -97,27 +88,21 @@ jobs: cat /tmp/issue-body.txt } > /tmp/user.txt - # continue-on-error: an install failure is an outage like any other. - # The Copilot step records it and the run turns red at the end. - - name: Set up Copilot CLI - continue-on-error: true - uses: ./.github/actions/setup-copilot-cli - - - name: Ask Copilot + - name: Ask the model env: - COPILOT_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + AI_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} run: | # Fail-open: an outage or an answer that is not JSON must never close # or delete a contributor's issue. It is recorded and turns the run red below. - if ! bash .github/scripts/copilot-infer.sh /tmp/system.txt /tmp/user.txt /tmp/raw.txt; then - printf '%s\n' "the Copilot call failed" >> /tmp/ai-degraded + if ! bash .github/scripts/ai-infer.sh /tmp/system.txt /tmp/user.txt /tmp/raw.txt; then + printf '%s\n' "the model call failed" >> /tmp/ai-degraded echo '{"action": "keep", "kind": "unclear"}' > /tmp/result.json exit 0 fi if ! python3 .github/scripts/extract-json.py /tmp/raw.txt /tmp/result.json; then - echo "::error::Copilot returned no JSON object; keeping the issue" - printf '%s\n' "Copilot returned output that was not JSON" >> /tmp/ai-degraded + echo "::error::The model returned no JSON object; keeping the issue" + printf '%s\n' "the model returned output that was not JSON" >> /tmp/ai-degraded echo '{"action": "keep", "kind": "unclear"}' > /tmp/result.json fi @@ -136,7 +121,7 @@ jobs: action = r.get('action') if action not in ('keep', 'close', 'delete'): with open('/tmp/ai-degraded', 'a') as fh: - fh.write('Copilot returned no known action; the issue was kept open\n') + fh.write('The model returned no known action; the issue was kept open\n') action = 'keep' kind = r.get('kind') reasons = [' '.join(str(x).split())[:300] for x in (r.get('reasons') or []) if str(x).strip()][:10] @@ -147,7 +132,7 @@ jobs: issue_text = normalize(open('/tmp/issue-title.txt').read() + '\n' + open('/tmp/issue-body.txt').read()) if len(quote) < 2 or quote not in issue_text: with open('/tmp/ai-degraded', 'a') as fh: - fh.write('Copilot asked to delete the issue without quoting it; it was kept open, check it by hand\n') + fh.write('The model asked to delete the issue without quoting it; it was kept open, check it by hand\n') action = 'keep' else: open('/tmp/insult-quote.txt', 'w').write(quote) @@ -256,19 +241,12 @@ jobs: permissions: contents: read issues: write - copilot-requests: write steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1 with: persist-credentials: false - # continue-on-error: an install failure is an outage like any other. - # The Copilot steps record it and the run turns red at the end. - - name: Set up Copilot CLI - continue-on-error: true - uses: ./.github/actions/setup-copilot-cli - - name: Check if first-time contributor id: check-first-time env: @@ -460,7 +438,7 @@ jobs: - name: Stage 1 (triage and file selection) env: - COPILOT_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + AI_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} run: | # The triage call returns ONLY JSON. It classifies the issue and picks a # short list of source files for the composer to read. @@ -477,14 +455,14 @@ jobs: # An outage must not fail the run here: fall through to the safe # classification below and let the last step report it. - if bash .github/scripts/copilot-infer.sh /tmp/triage-system.txt /tmp/triage-user.txt /tmp/triage-raw.txt; then + if bash .github/scripts/ai-infer.sh /tmp/triage-system.txt /tmp/triage-user.txt /tmp/triage-raw.txt; then if ! python3 .github/scripts/extract-json.py /tmp/triage-raw.txt /tmp/triage.json; then echo "::error::Triage returned no JSON object; using fallback classification" printf '%s\n' "triage returned output that was not JSON" >> /tmp/ai-degraded rm -f /tmp/triage.json fi else - printf '%s\n' "the triage Copilot call failed" >> /tmp/ai-degraded + printf '%s\n' "the triage model call failed" >> /tmp/ai-degraded fi if [ ! -s /tmp/triage.json ]; then @@ -608,7 +586,7 @@ jobs: - name: Stage 2 (compose response) id: compose env: - COPILOT_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + AI_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} ISSUE_AUTHOR: ${{ github.event.issue.user.login }} IS_FIRST_TIME: ${{ steps.check-first-time.outputs.is_first_time }} run: | @@ -631,9 +609,9 @@ jobs: # Same as triage: an outage must not fail the run. When no comment can # be composed the remaining steps are skipped instead. - if ! bash .github/scripts/copilot-infer.sh /tmp/composer-system.txt /tmp/composer-user.txt /tmp/ai-comment.txt; then + if ! bash .github/scripts/ai-infer.sh /tmp/composer-system.txt /tmp/composer-user.txt /tmp/ai-comment.txt; then echo "::error::Skipping the triage comment" - printf '%s\n' "the composer Copilot call failed" >> /tmp/ai-degraded + printf '%s\n' "the composer model call failed" >> /tmp/ai-degraded echo "has_comment=false" >> "$GITHUB_OUTPUT" exit 0 fi @@ -691,7 +669,6 @@ jobs: permissions: contents: read pull-requests: write - copilot-requests: write steps: # pull_request_target runs in the base repository's context: this is the # trusted base branch. The pull request is only ever read as text. @@ -700,12 +677,6 @@ jobs: with: persist-credentials: false - # continue-on-error: an install failure is an outage like any other. - # The Copilot step records it and the run turns red at the end. - - name: Set up Copilot CLI - continue-on-error: true - uses: ./.github/actions/setup-copilot-cli - - name: Check if first-time contributor id: check-first-time env: @@ -771,7 +742,7 @@ jobs: - name: Analyze PR with AI id: analyze env: - COPILOT_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + AI_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} PR_TITLE: ${{ github.event.pull_request.title }} PR_BODY: ${{ github.event.pull_request.body }} PR_AUTHOR: ${{ github.event.pull_request.user.login }} @@ -835,9 +806,9 @@ jobs: # An outage must not red-cross an otherwise healthy pull request here: # skip the review comment; the last step reports the degradation. - if ! bash .github/scripts/copilot-infer.sh /tmp/pr-system.txt /tmp/pr-user.txt /tmp/ai-comment.txt; then + if ! bash .github/scripts/ai-infer.sh /tmp/pr-system.txt /tmp/pr-user.txt /tmp/ai-comment.txt; then echo "::error::Skipping the review comment" - printf '%s\n' "the PR review Copilot call failed" >> /tmp/ai-degraded + printf '%s\n' "the PR review model call failed" >> /tmp/ai-degraded echo "has_comment=false" >> "$GITHUB_OUTPUT" exit 0 fi diff --git a/.github/workflows/notify-telegram.yml b/.github/workflows/notify-telegram.yml index 531b2600..943ea4be 100644 --- a/.github/workflows/notify-telegram.yml +++ b/.github/workflows/notify-telegram.yml @@ -22,7 +22,6 @@ on: permissions: contents: read - copilot-requests: write jobs: notify: @@ -126,31 +125,15 @@ jobs: echo "previous-tag=${PREV_TAG}" >> "$GITHUB_OUTPUT" echo "Collected $(wc -l < commits.txt) commits between ${PREV_TAG} and ${TAG}." - # The Copilot CLI is not preinstalled on GitHub-hosted runners, and - # ai-inference v3 shells out to it. - - name: Set up Copilot CLI - id: copilot - if: steps.gate.outputs.skip != 'true' - uses: ./.github/actions/setup-copilot-cli - - name: Generate summary with AI - id: ai if: steps.gate.outputs.skip != 'true' - uses: actions/ai-inference@2c43c91ae16266ca159d311430343c67a5ffa222 # v3 - with: - prompt-file: .github/prompts/telegram-release-summary.prompt.yml - input: | - version: ${{ steps.tag.outputs.tag }} - file_input: | - commits: ./commits.txt - # Runs the CLI without AGENTS.md as instructions, without the GitHub - # MCP server and without auto-update; see setup-copilot-cli. - copilot-cli-path: ${{ steps.copilot.outputs.launcher }} env: - # The Copilot CLI reads its credential from the environment; the - # workflow token carries it under the `copilot-requests` permission - # granted above, so no PAT is needed. - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + AI_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} + TAG: ${{ steps.tag.outputs.tag }} + run: | + bash .github/scripts/render-prompt.sh .github/prompts/telegram-release-summary.prompt.yml \ + "$TAG" commits.txt "$RUNNER_TEMP/system.txt" "$RUNNER_TEMP/user.txt" + bash .github/scripts/ai-infer.sh "$RUNNER_TEMP/system.txt" "$RUNNER_TEMP/user.txt" "$RUNNER_TEMP/summary.txt" - name: Post release announcement to Telegram if: steps.gate.outputs.skip != 'true' @@ -159,20 +142,13 @@ jobs: TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }} TAG: ${{ steps.tag.outputs.tag }} REPO: ${{ github.repository }} - AI_RESPONSE_FILE: ${{ steps.ai.outputs.response-file }} - AI_RESPONSE: ${{ steps.ai.outputs.response }} run: | if [ -z "$TELEGRAM_BOT_TOKEN" ] || [ -z "$TELEGRAM_CHAT_ID" ]; then echo "::warning::TELEGRAM_BOT_TOKEN or TELEGRAM_CHAT_ID is not set — skipping Telegram notification." exit 0 fi - # Prefer the file output — `response` can be truncated for longer summaries. - if [ -n "$AI_RESPONSE_FILE" ] && [ -f "$AI_RESPONSE_FILE" ]; then - SUMMARY=$(cat "$AI_RESPONSE_FILE") - else - SUMMARY="$AI_RESPONSE" - fi + SUMMARY=$(cat "$RUNNER_TEMP/summary.txt") if [ -z "${SUMMARY//[[:space:]]/}" ]; then echo "::error::AI summary is empty" diff --git a/.github/workflows/pr-ai-policy.yml b/.github/workflows/pr-ai-policy.yml index c9fc66f4..9476e0b2 100644 --- a/.github/workflows/pr-ai-policy.yml +++ b/.github/workflows/pr-ai-policy.yml @@ -7,9 +7,6 @@ on: permissions: contents: read pull-requests: write - # GitHub Models (models.github.ai) was retired on 2026-07-30; the workflow - # token reaches GitHub Copilot through this permission instead. - copilot-requests: write # `edited` and `synchronize` can arrive back to back. Queue rather than cancel, # so a run is never stopped between commenting and closing. @@ -17,11 +14,6 @@ concurrency: group: pr-ai-policy-${{ github.event.pull_request.number }} cancel-in-progress: false -env: - # `auto` lets Copilot pick. It is the only value every Copilot plan accepts - # and it cannot go stale the way the pinned `openai/gpt-4.1` did. - COPILOT_MODEL: auto - jobs: check-ai-policy: # Nobody is exempt: maintainers are checked like everyone else. The one @@ -105,15 +97,9 @@ jobs: {"compliant": true, "violations": []} PROMPT - # continue-on-error: a failed install must not stop the deterministic - # trailer scan from acting. The Copilot step then records the outage. - - name: Set up Copilot CLI - continue-on-error: true - uses: ./.github/actions/setup-copilot-cli - - - name: Ask Copilot + - name: Ask the model env: - COPILOT_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + AI_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} TRAILER_HIT: ${{ steps.trailers.outputs.hit }} run: | { @@ -128,15 +114,15 @@ jobs: # The model half of this check is fail-open: an outage or an answer # that is not JSON must never close a pull request. The deterministic # trailer scan still stands on its own below. - if ! bash .github/scripts/copilot-infer.sh /tmp/system.txt /tmp/user.txt /tmp/raw.txt; then - printf '%s\n' "the Copilot call failed" >> /tmp/ai-degraded + if ! bash .github/scripts/ai-infer.sh /tmp/system.txt /tmp/user.txt /tmp/raw.txt; then + printf '%s\n' "the model call failed" >> /tmp/ai-degraded echo '{"compliant": true, "violations": []}' > /tmp/result.json exit 0 fi if ! python3 .github/scripts/extract-json.py /tmp/raw.txt /tmp/result.json; then - echo "::error::Copilot returned no JSON object; treating as compliant" - printf '%s\n' "Copilot returned output that was not JSON" >> /tmp/ai-degraded + echo "::error::The model returned no JSON object; treating as compliant" + printf '%s\n' "the model returned output that was not JSON" >> /tmp/ai-degraded echo '{"compliant": true, "violations": []}' > /tmp/result.json fi echo "Policy response validated" diff --git a/.github/workflows/release-notes-generator.yml b/.github/workflows/release-notes-generator.yml index 26d3800c..095828e8 100644 --- a/.github/workflows/release-notes-generator.yml +++ b/.github/workflows/release-notes-generator.yml @@ -8,7 +8,6 @@ on: permissions: contents: write - copilot-requests: write jobs: generate-release-notes: @@ -79,44 +78,24 @@ jobs: git log --pretty=format:"- %s (%h by %an)" "$PREVIOUS_TAG".."$CURRENT_TAG" --no-merges > commits.txt echo "Collected $(wc -l < commits.txt) commits" - - name: Set up Copilot CLI - id: copilot - if: steps.get-release.outputs.is-prerelease == 'false' - uses: ./.github/actions/setup-copilot-cli - - name: Generate release notes with AI id: generate-notes if: steps.get-release.outputs.is-prerelease == 'false' - uses: actions/ai-inference@2c43c91ae16266ca159d311430343c67a5ffa222 # v3 - with: - prompt-file: .github/prompts/release-notes.prompt.yml - input: | - version: ${{ steps.get-previous-tag.outputs.current-tag }} - file_input: | - commits: ./commits.txt - # Runs the CLI without AGENTS.md as instructions, without the GitHub - # MCP server and without auto-update; see setup-copilot-cli. - copilot-cli-path: ${{ steps.copilot.outputs.launcher }} env: - # The Copilot CLI reads its credential from the environment; the - # workflow token carries it under the `copilot-requests` permission - # granted above, so no PAT is needed. - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + AI_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} + VERSION: ${{ steps.get-previous-tag.outputs.current-tag }} + run: | + bash .github/scripts/render-prompt.sh .github/prompts/release-notes.prompt.yml \ + "$VERSION" commits.txt "$RUNNER_TEMP/system.txt" "$RUNNER_TEMP/user.txt" + bash .github/scripts/ai-infer.sh "$RUNNER_TEMP/system.txt" "$RUNNER_TEMP/user.txt" "$RUNNER_TEMP/notes.txt" - name: Update release with generated notes if: steps.get-release.outputs.is-prerelease == 'false' env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RESPONSE_FILE: ${{ steps.generate-notes.outputs.response-file }} - RESPONSE_OUTPUT: ${{ steps.generate-notes.outputs.response }} RELEASE_ID: ${{ steps.get-release.outputs.release-id }} run: | - # Prefer reading from the response file to avoid output truncation - if [ -n "$RESPONSE_FILE" ] && [ -f "$RESPONSE_FILE" ]; then - RELEASE_NOTES=$(cat "$RESPONSE_FILE") - else - RELEASE_NOTES="$RESPONSE_OUTPUT" - fi + RELEASE_NOTES=$(cat "$RUNNER_TEMP/notes.txt") # An empty answer would PATCH the release body to nothing and wipe # the notes the release already has.