diff --git a/ai-release-notes/README.md b/ai-release-notes/README.md index 4724851..de47543 100644 --- a/ai-release-notes/README.md +++ b/ai-release-notes/README.md @@ -2,9 +2,16 @@ Generate polished, AI-powered release notes from GitHub's auto-generated changelog and optionally create a draft GitHub release. -The action compares the current tag with the previous one, fetches the raw changelog via the GitHub API, rewrites it using an OpenAI-compatible chat completions API, and creates a draft release with the result. +The action compares the current tag with the previous one, fetches the raw changelog via the GitHub API, rewrites it with an AI model, and creates a draft release with the result. -> **Note:** This action previously used the GitHub Models API, which was [fully retired on July 30, 2026](https://github.blog/changelog/2026-07-30-github-models-is-now-retired/). It now calls a configurable OpenAI-compatible endpoint instead and requires an `api-key`. Without an `api-key`, the AI rewrite is skipped and the raw GitHub changelog is used — the release is still created. +Two providers are supported via the `provider` input: + +- **`openai`** (default) — any OpenAI-compatible chat completions endpoint, authenticated with a static `api-key`. +- **`bedrock`** — Claude on Amazon Bedrock, authenticated with the ambient AWS credentials. Combined with `aws-actions/configure-aws-credentials` and an OIDC role this needs **no stored secret at all** — only short-lived STS credentials derived from the workflow's OIDC token. The request goes through the `aws` CLI preinstalled on GitHub-hosted runners, so the action stays dependency-free. + +> **Note:** This action previously used the GitHub Models API, which was [fully retired on July 30, 2026](https://github.blog/changelog/2026-07-30-github-models-is-now-retired/). + +In all cases the AI rewrite degrades gracefully: when no credentials are configured or the AI call fails, the raw GitHub changelog is used and the release is still created. ## Prerequisites @@ -12,7 +19,7 @@ The calling workflow must: 1. **Check out the repository** with full history (`fetch-depth: 0`) so previous tags can be detected. 2. **Grant permissions** for `contents: write` (to create releases). -3. **Provide an API key** for an OpenAI-compatible provider via the `api-key` input (typically an org/repo secret). Without it, the release falls back to the raw GitHub changelog. +3. **Provide credentials** — either an `api-key` for an OpenAI-compatible provider, or (for `provider: bedrock`) AWS credentials via `aws-actions/configure-aws-credentials`, which additionally needs `id-token: write` permission. Without credentials, the release falls back to the raw GitHub changelog. ## Usage @@ -43,7 +50,42 @@ jobs: api-key: ${{ secrets.OPENAI_API_KEY }} ``` -### Using a Different Provider +### Claude on Amazon Bedrock (no stored secret) + +Authenticates with short-lived STS credentials from the workflow's OIDC token. `BEDROCK_ROLE_ARN` and `BEDROCK_AWS_REGION` are non-secret repository **variables**; the IAM role needs `bedrock:InvokeModel*` on the inference profile ARN **and** on the underlying foundation-model ARNs in every region the profile routes to. + +```yaml +name: AI Release Notes +on: + push: + tags: ["v*"] + +permissions: + contents: write + id-token: write + +jobs: + release-notes: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: aws-actions/configure-aws-credentials@v6 + with: + role-to-assume: ${{ vars.BEDROCK_ROLE_ARN }} + aws-region: ${{ vars.BEDROCK_AWS_REGION }} + + - uses: shopware/github-actions/ai-release-notes@main + with: + product-name: "My Product" + provider: "bedrock" +``` + +The default Bedrock model is the `eu.anthropic.claude-sonnet-5` cross-region inference profile — a bare model id is rejected for on-demand throughput in `eu-central-1`, and the `eu.` prefix keeps routing inside EU regions. `temperature` is not sent on this path (Claude Sonnet 5 and Opus 5 reject sampling parameters). + +### Using a Different OpenAI-Compatible Provider Any OpenAI-compatible chat completions endpoint works — for example the Anthropic API: @@ -103,10 +145,12 @@ Use the action as a pure notes generator — for example to post to Slack or app | `collapse-types` | no | `"dependency,translation,CI"` | Change types collapsed under the last section | | `additional-rules` | no | `""` | Extra rules appended to the default prompt | | `custom-prompt` | no | `""` | Completely override the system prompt (ignores all formatting inputs) | -| `api-endpoint` | no | `"https://api.openai.com/v1/chat/completions"` | OpenAI-compatible chat completions endpoint URL | -| `api-key` | no | `""` | API key for the AI provider — when empty, the AI rewrite is skipped and the raw changelog is used | -| `model` | no | `"gpt-4o"` | Model name understood by the configured endpoint | -| `temperature` | no | `"0.4"` | AI temperature (`0.0` = deterministic, `1.0` = creative) | +| `provider` | no | `"openai"` | AI provider: `"openai"` or `"bedrock"` | +| `api-endpoint` | no | `"https://api.openai.com/v1/chat/completions"` | OpenAI-compatible chat completions endpoint URL (`provider: openai` only) | +| `api-key` | no | `""` | API key (`provider: openai` only) — when empty, the AI rewrite is skipped and the raw changelog is used | +| `model` | no | per provider | `"gpt-4o"` for `openai`, `"eu.anthropic.claude-sonnet-5"` for `bedrock` | +| `temperature` | no | `"0.4"` | AI temperature — only sent on the `openai` path | +| `max-output-tokens` | no | `"8192"` | Output token budget (`provider: bedrock` only) — Claude's adaptive thinking draws from the same budget | | `tag-pattern` | no | `"^v"` | Regex pattern to match tags when detecting the previous release | | `release-name` | no | `"Release {tag}"` | Release name template — use `{tag}` as placeholder for the tag name | | `draft` | no | `"true"` | Create the release as a draft | diff --git a/ai-release-notes/action.yml b/ai-release-notes/action.yml index f8acc50..a344f1b 100644 --- a/ai-release-notes/action.yml +++ b/ai-release-notes/action.yml @@ -41,22 +41,30 @@ inputs: description: "Completely override the default system prompt (ignores all formatting inputs)" required: false default: "" + provider: + description: "AI provider: 'openai' (OpenAI-compatible endpoint authenticated with api-key) or 'bedrock' (Claude on Amazon Bedrock, authenticated with the ambient AWS credentials — run aws-actions/configure-aws-credentials before this action)" + required: false + default: "openai" api-endpoint: - description: "OpenAI-compatible chat completions endpoint URL" + description: "OpenAI-compatible chat completions endpoint URL (provider: openai only)" required: false default: "https://api.openai.com/v1/chat/completions" api-key: - description: "API key for the AI provider. When empty, the AI rewrite is skipped and the raw GitHub changelog is used as release notes." + description: "API key for the AI provider (provider: openai only). When empty, the AI rewrite is skipped and the raw GitHub changelog is used as release notes." required: false default: "" model: - description: "Model name understood by the configured endpoint" + description: "Model name understood by the configured provider. Defaults per provider: 'gpt-4o' for openai, 'eu.anthropic.claude-sonnet-5' (cross-region inference profile) for bedrock." required: false - default: "gpt-4o" + default: "" temperature: - description: "AI model temperature (0.0 = deterministic, 1.0 = creative)" + description: "AI model temperature (0.0 = deterministic, 1.0 = creative). Only sent on the openai path — Claude Sonnet 5 and Opus 5 reject sampling parameters." required: false default: "0.4" + max-output-tokens: + description: "Output token budget for the bedrock path. Claude's adaptive thinking draws from the same budget, so keep it generous." + required: false + default: "8192" tag-pattern: description: "Regex pattern to match tags when detecting the previous release (e.g. '^v', '^release-')" required: false @@ -148,8 +156,10 @@ runs: id: ai-notes uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: + INPUT_PROVIDER: "${{ inputs.provider }}" INPUT_API_ENDPOINT: "${{ inputs.api-endpoint }}" INPUT_API_KEY: "${{ inputs.api-key }}" + INPUT_MAX_OUTPUT_TOKENS: "${{ inputs.max-output-tokens }}" RAW_NOTES: "${{ steps.raw-notes.outputs.body }}" INPUT_PRODUCT_NAME: "${{ inputs.product-name }}" INPUT_PRODUCT_DESCRIPTION: "${{ inputs.product-description }}" @@ -167,14 +177,26 @@ runs: const currentTag = process.env.GITHUB_REF_NAME; const rawNotes = process.env.RAW_NOTES; const customPrompt = process.env.INPUT_CUSTOM_PROMPT; + const provider = process.env.INPUT_PROVIDER; const apiKey = process.env.INPUT_API_KEY; - if (!apiKey) { + if (provider !== 'openai' && provider !== 'bedrock') { + core.setFailed(`Unknown provider "${provider}" — expected "openai" or "bedrock".`); + return; + } + + if (provider === 'openai' && !apiKey) { core.warning('No api-key configured — skipping AI rewrite and using the raw GitHub changelog as release notes.'); core.setOutput('notes', rawNotes); return; } + if (provider === 'bedrock' && !process.env.AWS_ACCESS_KEY_ID) { + core.warning('No AWS credentials in the environment — run aws-actions/configure-aws-credentials before this action. Skipping AI rewrite and using the raw GitHub changelog as release notes.'); + core.setOutput('notes', rawNotes); + return; + } + let systemPrompt; if (customPrompt) { @@ -232,39 +254,138 @@ runs: systemPrompt = lines.join('\n'); } - const response = await fetch(process.env.INPUT_API_ENDPOINT, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${apiKey}`, - }, - body: JSON.stringify({ - model: process.env.INPUT_MODEL, - temperature: parseFloat(process.env.INPUT_TEMPERATURE), - messages: [ - { role: 'system', content: systemPrompt }, - { role: 'user', content: `Rewrite these auto-generated release notes:\n\n${rawNotes}` }, - ], - }), - }); + const userPrompt = `Rewrite these auto-generated release notes:\n\n${rawNotes}`; + const model = process.env.INPUT_MODEL + || (provider === 'bedrock' ? 'eu.anthropic.claude-sonnet-5' : 'gpt-4o'); - if (!response.ok) { - const errorBody = await response.text(); - core.warning(`AI API error (${response.status}): ${errorBody} — falling back to the raw GitHub changelog.`); + let aiNotes; + try { + aiNotes = provider === 'bedrock' + ? await requestBedrock({ model, systemPrompt, userPrompt }) + : await requestOpenAi({ model, systemPrompt, userPrompt }); + } catch (error) { + core.warning(`${error.message} — falling back to the raw GitHub changelog.`); core.setOutput('notes', rawNotes); return; } - const result = await response.json(); + core.setOutput('notes', aiNotes); - if (!result.choices?.length) { - core.warning(`Unexpected AI API response: ${JSON.stringify(result)} — falling back to the raw GitHub changelog.`); - core.setOutput('notes', rawNotes); - return; + async function requestOpenAi({ model, systemPrompt, userPrompt }) { + const response = await fetch(process.env.INPUT_API_ENDPOINT, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model, + temperature: parseFloat(process.env.INPUT_TEMPERATURE), + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userPrompt }, + ], + }), + }); + + if (!response.ok) { + const errorBody = await response.text(); + throw new Error(`AI API error (${response.status}): ${errorBody}`); + } + + const result = await response.json(); + + if (!result.choices?.length) { + throw new Error(`Unexpected AI API response: ${JSON.stringify(result)}`); + } + + return result.choices[0].message.content; } - const aiNotes = result.choices[0].message.content; - core.setOutput('notes', aiNotes); + // Claude on Amazon Bedrock via the `aws` CLI preinstalled on GitHub-hosted + // runners — the CLI does the SigV4 signing that would otherwise require an + // SDK dependency. Credentials are the short-lived STS credentials that + // aws-actions/configure-aws-credentials derives from the workflow's OIDC + // token; nothing long-lived is stored. + async function requestBedrock({ model, systemPrompt, userPrompt }) { + const { execFileSync } = require('node:child_process'); + const fs = require('node:fs'); + const os = require('node:os'); + const path = require('node:path'); + + // configure-aws-credentials exports AWS_REGION from its `aws-region` input. + const awsRegion = process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION; + if (!awsRegion) { + throw new Error('Missing AWS_REGION — pass `aws-region` to aws-actions/configure-aws-credentials'); + } + + const body = { + // Identifies the request body schema for Bedrock's InvokeModel API; + // unrelated to the model version. + anthropic_version: 'bedrock-2023-05-31', + // Claude's adaptive thinking draws from the same output budget. + max_tokens: parseInt(process.env.INPUT_MAX_OUTPUT_TOKENS, 10), + // No `temperature`: Claude Sonnet 5 and Opus 5 reject sampling parameters. + system: systemPrompt, + messages: [{ role: 'user', content: userPrompt }], + }; + + // The request body goes to the CLI as a file rather than an argument — + // release-notes prompts can run to tens of kilobytes. + const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-release-notes-')); + const requestPath = path.join(scratch, 'request.json'); + const responsePath = path.join(scratch, 'response.json'); + + let message; + try { + fs.writeFileSync(requestPath, JSON.stringify(body), 'utf8'); + + try { + execFileSync( + 'aws', + [ + 'bedrock-runtime', 'invoke-model', + '--region', awsRegion, + '--model-id', model, + '--content-type', 'application/json', + '--body', `fileb://${requestPath}`, + '--cli-read-timeout', '300', + '--cli-connect-timeout', '30', + responsePath, + ], + { stdio: ['ignore', 'ignore', 'pipe'], encoding: 'utf8' }, + ); + } catch (error) { + const stderr = (error.stderr || '').trim(); + throw new Error(`aws bedrock-runtime invoke-model failed: ${stderr || error.message}`); + } + + message = JSON.parse(fs.readFileSync(responsePath, 'utf8')); + } finally { + fs.rmSync(scratch, { force: true, recursive: true }); + } + + if (message.stop_reason === 'refusal') { + throw new Error(`Claude declined the request (refusal category: ${message.stop_details?.category ?? 'unknown'})`); + } + + if (message.stop_reason === 'max_tokens') { + throw new Error(`Claude hit the ${body.max_tokens}-token output cap (max-output-tokens) before finishing`); + } + + // Thinking blocks precede the text block, so filter by type rather than + // indexing content[0]. + const text = message.content?.find((block) => block.type === 'text')?.text; + if (typeof text !== 'string' || text.trim() === '') { + throw new Error(`Claude returned no text content (stop_reason: ${message.stop_reason})`); + } + + if (message.usage) { + core.info(`${model} — input ${message.usage.input_tokens} / output ${message.usage.output_tokens} tokens.`); + } + + return text; + } - name: Create GitHub release id: create-release