ci: free model for AI jobs

This commit is contained in:
zhom committed 2026-10-20 17:41:10 +04:00
1 parent e14e97963d
commit 4388f6ae74
12 files changed
+195 -301

No files matched your search

-7
View File
@@ -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"'
@@ -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
-6
View File
@@ -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
@@ -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
+127
View File
@@ -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"
-104
View File
@@ -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"
+2 -2
View File
@@ -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.
"""
+29
View File
@@ -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"
+17 -46
View File
@@ -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
+7 -31
View File
@@ -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"
+6 -20
View File
@@ -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"
+7 -28
View File
@@ -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.