diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 753ff0ccb..936352333 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -122,9 +122,59 @@ jobs: if-no-files-found: error retention-days: 7 - publish: + notes: needs: [plan, build] + if: needs.plan.outputs.release == 'true' && needs.build.result == 'success' + runs-on: ubuntu-latest + timeout-minutes: 5 + continue-on-error: true + permissions: + contents: read + pull-requests: none + outputs: + available: ${{ steps.notes.outputs.available }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.plan.outputs.commit }} + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Summarize the validated source with the configured AI API + id: notes + env: + OPENAI_API_KEY: ${{ secrets.DS_API_KEY }} + OPENAI_MODEL: ${{ vars.OPENAI_RELEASE_NOTES_MODEL || 'deepseek-flash' }} + OPENAI_BASE_URL: ${{ vars.OPENAI_BASE_URL || 'https://api.deepseek.com' }} + GH_REPO: ${{ github.repository }} + RELEASE_TAG: ${{ needs.plan.outputs.tag }} + RELEASE_COMMIT: ${{ needs.plan.outputs.commit }} + PREVIOUS_TAG: ${{ needs.plan.outputs.previous_tag }} + shell: bash + run: | + python tools/generate_release_notes.py --tag "$RELEASE_TAG" \ + --commit "$RELEASE_COMMIT" --previous-tag "$PREVIOUS_TAG" \ + --model "$OPENAI_MODEL" --base-url "$OPENAI_BASE_URL" \ + --output "$RUNNER_TEMP/release-notes/ai-notes.md" + if [[ -s "$RUNNER_TEMP/release-notes/ai-notes.md" ]]; then + echo "available=true" >> "$GITHUB_OUTPUT" + else + echo "available=false" >> "$GITHUB_OUTPUT" + fi + - uses: actions/upload-artifact@v4 + if: steps.notes.outputs.available == 'true' + with: + name: release-notes + path: ${{ runner.temp }}/release-notes/ai-notes.md + if-no-files-found: error + retention-days: 7 + + publish: + needs: [plan, build, notes] if: >- + always() && !cancelled() && needs.plan.outputs.release == 'true' && needs.build.result == 'success' && (github.event_name == 'schedule' || !inputs.dry_run) runs-on: ubuntu-latest @@ -156,6 +206,13 @@ jobs: with: name: release-distributions path: dist + - uses: actions/download-artifact@v4 + id: download_notes + if: needs.notes.outputs.available == 'true' + continue-on-error: true + with: + name: release-notes + path: notes - name: Verify downloaded checksums working-directory: dist run: sha256sum --check SHA256SUMS @@ -177,16 +234,27 @@ jobs: [[ "$latest" == "$PREVIOUS_TAG" ]] - name: Create the remote tag, upload packages, then publish shell: bash + env: + AI_NOTES_DOWNLOADED: ${{ steps.download_notes.outcome }} run: | gh api --method POST "repos/$GH_REPO/git/refs" \ -f ref="refs/tags/$RELEASE_TAG" -f sha="$RELEASE_COMMIT" > /dev/null - notes="Source: https://github.com/$GH_REPO/tree/$RELEASE_COMMIT" - notes+=$'\n\nThese notes describe software changes. Release builds and scaffold checks do not establish campaign PASS, model quality or service performance. Any such claim requires its acceptance issue and complete frozen evidence.' + notes_file="$RUNNER_TEMP/release-body.md" + { + printf 'Source: https://github.com/%s/tree/%s\n' "$GH_REPO" "$RELEASE_COMMIT" + printf '\nThese notes describe software changes. Release builds and scaffold checks do not establish campaign PASS, model quality or service performance. Any such claim requires its acceptance issue and complete frozen evidence.\n' + if [[ "${AI_NOTES_DOWNLOADED:-}" == success && -f notes/ai-notes.md && ! -L notes/ai-notes.md ]] && + [[ "$(wc -c < notes/ai-notes.md)" -gt 0 && "$(wc -c < notes/ai-notes.md)" -le 20000 ]]; then + printf '\n## AI-generated summary\n\n' + cat notes/ai-notes.md + printf '\n' + fi + } > "$notes_file" options=() if [[ -n "$PREVIOUS_TAG" ]]; then options+=(--notes-start-tag "$PREVIOUS_TAG"); fi gh release create "$RELEASE_TAG" dist/*.whl dist/*.tar.gz dist/*.zip dist/SHA256SUMS \ --verify-tag --draft --title "InferRSI $RELEASE_TAG" \ - --generate-notes --notes "$notes" "${options[@]}" + --generate-notes --notes-file "$notes_file" "${options[@]}" assets=$(gh release view "$RELEASE_TAG" --json assets --jq '.assets[].name') for file in dist/*.whl dist/*.tar.gz dist/*.zip dist/SHA256SUMS; do grep -Fxq "$(basename "$file")" <<< "$assets" diff --git a/docs/releases.md b/docs/releases.md index d40b9f407..c4cec8438 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -26,7 +26,7 @@ Release. It must not create code commits, edit repository files, create/update/ delete code branches, create/approve/merge PRs or enable auto-merge. Configuration development and review are contributor work; they are not bot capabilities. -Plan/build jobs have Contents read access. Only the publisher receives Contents +Plan/build/notes jobs have Contents read access. Only the publisher receives Contents write; Pull requests permission is explicitly none. The publisher validates its tag/SHA inputs and never checks out or executes repository code. Regression tests execute its actual shell commands against a recording GitHub mock that @@ -64,7 +64,12 @@ configuration does not silently change their access or existing branch rules. Its generated plugin metadata, bundled runtime and tag must have the same version. Verify every bundled runtime hash and exercise the extracted launcher/knowledge reads outside the checkout with isolated Python. -4. A separate write job verifies the artifacts, source ancestry and release +4. After a successful build, an optional read-only notes job calls the AI API + with bounded change metadata for exactly the planned revision range. Its + Markdown summary is transferred as a separate artifact. Missing credentials, + API/validation errors and unavailable notes artifacts fall back to the + existing GitHub-generated notes. +5. A separate write job verifies the artifacts, source ancestry and release state again. It creates the remote tag only after validation, creates an internal draft, uploads wheel/sdist/plugin ZIP/checksums and automatically publishes after confirming the attachments and tag identity. It does not execute @@ -79,6 +84,69 @@ software changes; CPU/package checks do not establish campaign `PASS`, model quality or service performance. Such claims retain the acceptance and evidence requirements in [contributing](contributing.md#evidence-and-claims). +## AI release summaries + +To enable AI summaries, add the Actions repository secret `DS_API_KEY` under +**Settings → Secrets and variables → Actions**. The key is injected only into +the notes step as the helper's internal `OPENAI_API_KEY` environment variable. +The workflow does not read or fall back to the old `OPENAI_API_KEY` repository +secret. Do not put the key in workflow source, command arguments, PR text or +logs. API access and usage are billed by the selected provider for that key; +GitHub Copilot access is not required. + +The default API model is `deepseek-flash`, documented by DeepSeek as +[DeepSeek-V4.1-Flash-1017](https://api-docs.deepseek.com/quick_start/pricing/) +when this configuration was added. This is a provider-managed alias, not a +version pin; DeepSeek may upgrade its target. Set the optional Actions repository variable +`OPENAI_RELEASE_NOTES_MODEL` to another model available to your API account and +compatible with the Responses API. Set `OPENAI_BASE_URL` to select the approved +HTTPS provider: `https://api.deepseek.com` (the fallback), +`https://api.openai.com/v1` or `https://yibuapi.com/v1`. This repository uses +DeepSeek's official API and sends the configured key only to its `/responses` +endpoint. The existing `OPENAI_*` setting names also apply to DeepSeek; use a key +issued by the selected provider. The configured API model ID is sent unchanged; +the helper does not substitute a model when it is unavailable. Trailing slashes +are normalized; other hosts, +HTTP, userinfo, query strings and fragments are rejected before sending a key. +Redirects remain disabled. Additional providers require a reviewed allowlist +change. Without the secret, the workflow skips AI generation and continues ordinary +publication. + +The [notes helper](../tools/generate_release_notes.py) sends bounded commit +subjects, source links and file-change statistics from the previous successful +tag through the planned commit. It omits author metadata, commit bodies and raw +patches. Recognizable email/IP literals, URLs, personal paths and common +credential forms in selected metadata are replaced with labeled placeholders +before transmission; generated canonical commit links remain available. This +targeted redaction supplements the repository's privacy rules for source +metadata. Inputs are treated as source data, not executable instructions. +The prompt asks for a concise English overview of software features, fixes and +compatibility changes, with at most six bullets, 250 words of prose and one +supplied commit link per bullet. It must not invent performance/quality or campaign PASS +claims. The response is labeled **AI-generated summary** and prepended to the +original GitHub changelog, preserving its PR links and source/evidence notice. +When source metadata is truncated, the helper appends a fixed coverage notice +from the source flags. The model is asked to leave coverage statements to the helper. +Model output is read as a file and never executed as shell code. +Explicit URLs and Markdown links in model output must be simple inline links to +canonical commit URLs present in the supplied source. Images, reference links, +bare URL/email autolinks and unsupported or unverified link forms cause fallback. +This gate validates generated link syntax; GitHub's own reference and custom +autolink rendering remains repository policy. + +The helper makes one request using the +[Responses API](https://api-docs.deepseek.com/guides/responses_api/), with +bounded input/output (at most 3,200 output tokens), a timeout, no tools and +`store: false`. DeepSeek requests +set reasoning effort to `none` for the short summary. DeepSeek ignores `store` +and documents stateless Responses compatibility; this setting does +not establish zero data retention. Missing credentials, failed/incomplete/refused +or unusable responses leave no summary. The optional notes job or artifact may +fail without skipping an otherwise valid publication; failed package checks and +workflow cancellation still prevent publication. The AI job has no repository +write permission, and the publisher receives no API key. There is no AI code +commit, merge, account installation or update to an existing public Release. + Each Release contains a Python wheel, source distribution, the versioned Codex plugin ZIP and `SHA256SUMS` covering all three packages. Extract the ZIP and follow the [Codex plugin installation guide](codex-plugin.md#install-from-the-release-zip). @@ -92,13 +160,17 @@ wrapper is packaged; the downloaded ZIP is the only Codex plugin release asset. Merge the configuration into the default branch. GitHub Actions and the listed actions must be allowed by organization policy, and the publisher needs -`contents: write`. No extra secret, PAT, GitHub App or repository-wide write -default is needed. The workflow guards the canonical repository and default -branch; copies must deliberately update that repository guard. +`contents: write`. Ordinary releases need no extra secret, PAT, GitHub App or +repository-wide write default. AI summaries additionally use the optional +`DS_API_KEY` secret described above. The workflow guards the canonical +repository and default branch; copies must deliberately update that repository +guard. Under **Actions → Release bot → Run workflow**, select the default branch and leave **dry_run** checked. It plans and builds the next release without creating -a remote tag or Release; unchanged trees skip here too. Clearing **dry_run** +a remote tag or Release; unchanged trees skip here too. With `DS_API_KEY` +configured, a changed-source dry run also makes the AI request and uploads the +summary artifact for inspection, consuming API usage. Clearing **dry_run** runs the same automatic publication immediately. It takes no version/tag input. ```bash diff --git a/tests/test_release_notes.py b/tests/test_release_notes.py new file mode 100644 index 000000000..1a891ad9e --- /dev/null +++ b/tests/test_release_notes.py @@ -0,0 +1,570 @@ +"""Release overviews use mocked HTTP and synthetic local source histories only.""" + +import io +import json +import subprocess +import sys +import urllib.error +from pathlib import Path + +import pytest + +from tools import generate_release_notes as notes + +KEY = "sk-test-no-access-credential" +SHA = "1" * 40 +COMMIT_URL = f"https://github.com/example/release/commit/{SHA}" + + +def completed(text="## Features\n- Preserve source identity."): + return {"status": "completed", "output": [ + {"type": "reasoning", "summary": []}, + {"type": "message", "role": "assistant", "status": "completed", + "content": [{"type": "output_text", "text": text}]}, + ]} + + +@pytest.fixture +def source_repo(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + + def git(*args): + return subprocess.check_output(["git", *args], text=True, stderr=subprocess.PIPE).strip() + + git("init", "-b", "main") + git("config", "user.name", "Release Test") + git("config", "user.email", "release@example.invalid") + (tmp_path / "initial.txt").write_text("initial\n", encoding="utf-8") + git("add", ".") + git("commit", "-m", "initial subject") + git("tag", "v0.1.0") + (tmp_path / "feature.txt").write_text("feature\n", encoding="utf-8") + git("add", ".") + git("commit", "-m", "feat: retain identity (#42)\n\n" + "PRIVATE_BODY_MUST_NOT_BE_SENT\nSigned-off-by: Release Test ") + return git, git("rev-parse", "HEAD") + + +def test_exact_source_range_contains_subjects_links_and_statistics_only(source_repo, monkeypatch): + _, sha = source_repo + monkeypatch.setenv("OPENAI_API_KEY", KEY) + monkeypatch.setenv("GH_TOKEN", "fake-github-token") + monkeypatch.setenv("GITHUB_TOKEN", "fake-actions-token") + original = subprocess.Popen + calls = [] + + def checked(*args, **kwargs): + env = kwargs["env"] + assert "OPENAI_API_KEY" not in env and "GH_TOKEN" not in env and "GITHUB_TOKEN" not in env + calls.append(args[0]) + return original(*args, **kwargs) + + monkeypatch.setattr(subprocess, "Popen", checked) + result = notes.source_snapshot("v0.1.1", sha, "v0.1.0", "example/release") + value = json.loads(result) + assert len(value["commits"]) == 1 + assert value["commits"][0] == { + "sha": sha, "subject": "feat: retain identity (#42)", + "url": f"https://github.com/example/release/commit/{sha}", + } + assert "feature.txt" in value["file_change_statistics"] + assert "initial subject" not in result + assert "PRIVATE_BODY" not in result and "release@example.invalid" not in result + assert "Signed-off-by" not in result and KEY not in result + assert calls and not any("--patch" in call or "-p" in call for call in calls) + + +def test_first_release_uses_complete_history_and_empty_tree_statistics(source_repo): + _, sha = source_repo + value = json.loads(notes.source_snapshot("v0.1.1", sha, "", "example/release")) + assert len(value["commits"]) == 2 + assert "initial.txt" in value["file_change_statistics"] + assert "feature.txt" in value["file_change_statistics"] + + +@pytest.mark.parametrize("tag,commit,previous,repository", [ + ("main", SHA, "", "example/release"), ("v01.1.0", SHA, "", "example/release"), + ("v0.1.1", "main", "", "example/release"), + ("v0.1.1", SHA, "refs/heads/main", "example/release"), + ("v0.1.1", SHA, "", "https://example.invalid/repo"), +]) +def test_invalid_source_identity_never_runs_git(monkeypatch, tag, commit, previous, repository): + monkeypatch.setattr(notes, "git", lambda *args: pytest.fail("Git must not run")) + with pytest.raises(ValueError): + notes.source_snapshot(tag, commit, previous, repository) + + +def test_source_checkout_must_match_planned_commit(source_repo): + with pytest.raises(ValueError, match="does not match"): + notes.source_snapshot("v0.1.1", SHA, "", "example/release") + + +def test_previous_release_must_be_an_ancestor(source_repo): + git, sha = source_repo + git("checkout", "-b", "unmerged", "v0.1.0") + git("commit", "--allow-empty", "-m", "unmerged branch") + git("tag", "v0.9.0") + git("checkout", "main") + with pytest.raises(ValueError, match="Source inspection failed"): + notes.source_snapshot("v0.1.1", sha, "v0.9.0", "example/release") + + +def test_source_budget_limits_subjects_commits_and_total_utf8_bytes(monkeypatch): + def git(args, limit=256): + if args[0] == "rev-parse": + return SHA, False + if args[0] == "hash-object": + return "2" * 40, False + if args[0] == "log": + return "".join(f"{number:040x}\t" + "知" * 500 + "\n" for number in range(101)), True + assert args[0] == "diff" and "--numstat" in args + return "1\t0\t" + "file" * 2_500, True + + monkeypatch.setattr(notes, "git", git) + result = notes.source_snapshot("v0.1.1", SHA, "", "example/" + "r" * 150) + value = json.loads(result) + assert len(result.encode("utf-8")) <= notes.MAX_SOURCE_BYTES + assert len(value["commits"]) <= 100 + assert all(len(item["subject"].encode("utf-8")) <= 240 for item in value["commits"]) + assert value["commits_truncated"] and value["file_statistics_truncated"] + + +def test_git_output_is_bounded_before_capture(monkeypatch): + original = subprocess.Popen + + def output_process(command, **kwargs): + assert "OPENAI_API_KEY" not in kwargs["env"] + return original([sys.executable, "-c", "import sys; sys.stdout.write('x' * 20000)"], + **kwargs) + + monkeypatch.setattr(subprocess, "Popen", output_process) + value, truncated = notes.git(["ignored"], 1024) + assert len(value) == 1024 and truncated + + +PRIVATE_METADATA = [ + "operator@example.invalid", "operator@[2001:db8::7]", + "ssh://operator:fake-password@example.invalid/repo?token=fake-no-access", + "https://example.invalid/notes?api_key=fake-no-access", + "192.0.2.7", "192.0.2.10:8080", "192.0.2.10.", + "[2001:db8::7]", "2001:db8::7", "2001:db8::7.", "[2001:db8::7]:8080", + r"C:\Users\\configuration", r"\\example.invalid\share\configuration", + "/home//.config", "/Users//.config", "~/.config/private", + "sk-test-no-access-value", "ghp_test_no_access_value", "github_pat_test_no_access_value", + "OPENAI_API_KEY=fake-no-access", "password: fake-no-access", "Bearer fake-no-access", + "Bearer=fake-no-access", '{"api_key": "fake-no-access"}', "api key = fake-no-access", + "example.invalid.internal", "example.invalid.local", + "example.invalid.corp", "example.invalid.lan", +] + + +@pytest.mark.parametrize("private", PRIVATE_METADATA, ids=[ + "email", "user-at-host", "credential-url", "query-url", "ipv4", "ipv4-port", "ipv4-period", + "ipv6-brackets", "ipv6", "ipv6-period", "ipv6-port", + "windows-home", "unc", "posix-home", "macos-home", "tilde-home", "openai-token", + "github-token", "github-pat", "api-key-assignment", "password-assignment", "bearer", + "bearer-assignment", "json-key-assignment", "spaced-key-assignment", + "internal-host", "local-host", "corp-host", "lan-host", +]) +def test_recognizable_private_metadata_is_omitted_in_full(private): + text, redacted = notes.metadata_field(f"fix: metadata contains {private}") + assert redacted and text == "[REDACTED: private metadata]" + assert private not in text + + +def test_plain_versions_and_public_change_descriptions_are_retained(): + text = "fix: compatibility with v1.2.3 and Python 3.10" + assert notes.metadata_field(text) == (text, False) + + +def test_mocked_api_receives_redacted_subjects_and_statistics_with_commit_provenance( + monkeypatch, capsys, +): + def git(args, limit=256): + if args[0] == "rev-parse": + return SHA, False + if args[0] == "hash-object": + return "2" * 40, False + if args[0] == "log": + subject = "fix: operator@example.invalid at 192.0.2.7 token=fake-no-access" + return f"{SHA}\t{subject}\n", False + assert args[0] == "diff" + return ("2\t1\tdocs/public.md\n1\t0\t/home//configuration\n" + "1\t0\t[2001:db8::7]\n"), False + + captured = [] + + class Opener: + def open(self, request, timeout): + captured.append(json.loads(request.data)) + response = io.BytesIO(json.dumps(completed()).encode()) + response.status = 200 + return response + + monkeypatch.setattr(notes, "git", git) + monkeypatch.setattr(notes.urllib.request, "build_opener", lambda *args: Opener()) + source = notes.source_snapshot("v0.1.1", SHA, "", "example/release") + notes.generate(source, notes.DEFAULT_MODEL, KEY) + sent = captured[0]["input"] + for private in ("operator@example.invalid", "192.0.2.7", "fake-no-access", + "/home//configuration", "2001:db8::7"): + assert private not in sent + value = json.loads(sent) + assert value["metadata_redacted"] is True + assert value["commits"][0]["sha"] == SHA + assert value["commits"][0]["url"] == f"https://github.com/example/release/commit/{SHA}" + assert "docs/public.md" in value["file_change_statistics"] + assert capsys.readouterr().out == "" + + +@pytest.fixture +def invocation(tmp_path, monkeypatch): + output = tmp_path / "overview.md" + monkeypatch.setenv("OPENAI_API_KEY", KEY) + monkeypatch.setenv("GH_REPO", "example/release") + monkeypatch.setattr(notes, "source_snapshot", lambda *args: '{"commits": []}') + arguments = ["--tag", "v0.1.1", "--commit", SHA, "--previous-tag", "", "--output", str(output)] + + def run(body=None, *, error=None, status=200): + calls = [] + + class Response(io.BytesIO): + pass + + def open_request(request, timeout): + calls.append((request, timeout)) + if error: + raise error + response = Response(body if isinstance(body, bytes) else json.dumps(body).encode()) + response.status = status + return response + + class Opener: + open = staticmethod(open_request) + + def build_opener(handler): + assert isinstance(handler, notes.NoRedirect) + return Opener() + + monkeypatch.setattr(notes.urllib.request, "build_opener", build_opener) + return notes.main(arguments), output, calls + + return run, output, arguments + + +def test_completed_mixed_output_items_make_one_bounded_markdown_file(invocation, capsys): + run, _, _ = invocation + response = completed() + response["output"].append({ + "type": "message", "role": "assistant", "status": "completed", + "content": [{"type": "output_text", "text": "## Compatibility\n"}, + {"type": "output_text", "text": "- Existing interfaces remain available."}], + }) + code, output, calls = run(response) + assert code == 0 and output.is_file() + text = output.read_text(encoding="utf-8") + assert "## Features" in text and "## Compatibility" in text + request, timeout = calls[0] + assert len(calls) == 1 and timeout == 45 + assert request.full_url == notes.ENDPOINT and request.method == "POST" + assert request.get_header("Authorization") == f"Bearer {KEY}" + assert request.get_header("Accept") == "application/json" + assert request.get_header("User-agent") == "inferrsi-release-notes/1" + payload = json.loads(request.data) + assert request.full_url == "https://api.deepseek.com/responses" + assert payload["model"] == "deepseek-flash" and payload["store"] is False + assert payload["reasoning"] == {"effort": "none"} + assert payload["max_output_tokens"] == 3200 + assert "temperature" not in payload and "tools" not in payload + assert KEY not in request.data.decode() + assert "untrusted" in payload["instructions"] + captured = capsys.readouterr() + assert captured.out == "AI release overview generated.\n" and captured.err == "" + + +def test_supplied_canonical_inline_links_preserve_prose_colons_and_inline_code( + invocation, monkeypatch, +): + run, output, _ = invocation + context = {"repository": "example/release", "commits": [{"sha": SHA, "url": COMMIT_URL}]} + monkeypatch.setattr(notes, "source_snapshot", lambda *args: json.dumps(context)) + text = f"## Features\n- Indexer: opt-in ranking for `platform_packs` ([change]({COMMIT_URL}))." + code, _, _ = run(completed(text)) + assert code == 0 and output.read_text(encoding="utf-8") == text + "\n" + + +def test_long_plain_ascii_overview_at_byte_limit_is_accepted(invocation): + run, output, _ = invocation + prefix = "## Features\n" + text = prefix + "x" * (notes.MAX_NOTES_BYTES - len(prefix) - 1) + code, _, _ = run(completed(text)) + assert code == 0 and output.read_bytes() == (text + "\n").encode() + assert len(output.read_bytes()) == notes.MAX_NOTES_BYTES + + +@pytest.mark.parametrize("fragment", [ + "![tracking](https://example.invalid/pixel)", f"![tracking]({COMMIT_URL})", + f"![image][ref]\n[ref]: {COMMIT_URL}", "[external](https://example.invalid/path)", + f"[unknown](https://github.com/example/release/commit/{'2' * 40})", + f"[other](https://github.com/other/release/commit/{SHA})", + f'[title]({COMMIT_URL} "title")', f"[angle](<{COMMIT_URL}>)", + f"[reference][ref]\n[ref]: {COMMIT_URL}", f"<{COMMIT_URL}>", COMMIT_URL, + "https://example.invalid/raw", "www.example.invalid", "operator@example.invalid", + "[mail](mailto:operator@example.invalid)", "[relative](../README.md)", "[anchor](#features)", + f"[escape](https://github\\.com/example/release/commit/{SHA})", + f"[entity](https://github.com/example/release/commit/{SHA})", + f"[encoded](https%3A%2F%2Fgithub.com/example/release/commit/{SHA})", + f"[userinfo](https://github.com@example.invalid/example/release/commit/{SHA})", + f"[suffix](https://github.com.example.invalid/example/release/commit/{SHA})", + f"[case](https://GITHUB.com/example/release/commit/{SHA})", + f"[fragment]({COMMIT_URL}#details)", f"[query]({COMMIT_URL}?redirect=example)", + f"[nested [label]]({COMMIT_URL})", f"[safe]({COMMIT_URL})https://example.invalid", + f"[https://example.invalid]({COMMIT_URL})", "@operator", "#123", + "<https://example.invalid>", "ftp://example.invalid", "[script](javascript:alert(1))", + "123https://example.invalid/embedded", "123www.example.invalid", +], ids=[ + "image", "canonical-image", "reference-image", "external-inline", "unknown-commit", + "other-repository", "title", "angle-target", "reference", "autolink", "bare-canonical", + "bare-external", "www", "email", "mailto", "relative", "anchor", "escaped-host", + "entity-protocol", "encoded-protocol", "userinfo", "host-suffix", "host-case", "fragment", + "query", "nested-label", "trailing-url", "url-label", "mention", "issue-reference", + "encoded-autolink", "ftp", "script", "embedded-url", "embedded-www", +]) +def test_unsafe_or_unsupported_model_links_use_fallback_without_output( + invocation, monkeypatch, capsys, fragment, +): + run, output, _ = invocation + context = {"repository": "example/release", "commits": [{"sha": SHA, "url": COMMIT_URL}]} + monkeypatch.setattr(notes, "source_snapshot", lambda *args: json.dumps(context)) + output.write_text("stale", encoding="utf-8") + code, _, _ = run(completed("## Features\n- " + fragment)) + assert code == 0 and not output.exists() + assert capsys.readouterr().out == notes.FALLBACK + "\n" + + +@pytest.mark.parametrize("context", [ + {"repository": "example/release", "commits": [{"sha": SHA, + "url": "https://example.invalid/fake"}]}, + {"repository": "example/../release", "commits": [{"sha": SHA, "url": COMMIT_URL}]}, + {"repository": "example/release", "commits": [{"sha": "1" * 39, "url": COMMIT_URL}]}, + {"repository": "example/release", "commits": [{"sha": "A" * 40, "url": COMMIT_URL}]}, + {"repository": "example/release", "commits": [{"sha": "2" * 40, "url": COMMIT_URL}]}, + {"commits": [{"sha": SHA, "url": COMMIT_URL}]}, + {"repository": "example/release", "commits": "not-a-list"}, + {"repository": "example/release", "commits": ["not-an-object"]}, +], ids=["external-source", "invalid-repository", "short-sha", "uppercase-sha", "url-sha-mismatch", + "missing-repository", "invalid-commits", "invalid-commit"]) +def test_forged_source_metadata_cannot_authorize_links(context): + with pytest.raises(ValueError): + notes.validate_overview_links(f"## Features\n- [change]({COMMIT_URL})\n", context) + + +@pytest.mark.parametrize("body", [ + b"not JSON", [], {"status": "incomplete", "output": []}, + {"status": "completed", "output": [], "error": {"message": KEY}}, + completed(""), completed("## Features\n" + "知" * 8_000), + completed("## Features\n\x1b[31munsafe"), completed("## Features\n\u202eunsafe"), + completed("```markdown\n## Features\n```"), completed("## Features\n