From 4221f63f49ff8eba7900a11f8d2cc057f39a1fe0 Mon Sep 17 00:00:00 2001 From: Johnathan Falk Date: Fri, 16 Oct 2026 09:46:10 -0400 Subject: [PATCH 1/5] fix(release): pass release notes by file, cap them under GitHub's body limit A ~125 KB changelog passed through 'body:' (an INPUT_* env var) exceeded Linux's 128 KiB per-string limit, so 'Create GitHub Release' failed with 'Argument list too long' after publishing, and changelog collection, draft cleanup and the packages publish were skipped. generate-changelog now writes a capped notes file; the release step uses body_path and the RC draft step uses -F body=@file / --notes-file. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012mtQZQ26prebPvRBbC5wdL --- .github/guid-index.json | 2 +- .github/workflows/reusable-release.yml | 96 +++++++----- .github/workflows/scripts/release_workflow.py | 148 ++++++++++++++---- .../20261016-release-notes-body-path.md | 17 ++ .../workflow_scripts/test_release_workflow.py | 65 +++++++- 5 files changed, 260 insertions(+), 68 deletions(-) create mode 100644 changelog.d/20261016-release-notes-body-path.md diff --git a/.github/guid-index.json b/.github/guid-index.json index 93b3eab2..7f6dc89d 100644 --- a/.github/guid-index.json +++ b/.github/guid-index.json @@ -1003,7 +1003,7 @@ "path": ".github/workflows/reusable-release.yml", "guid": "7b8c9d0e-1f2a-3b4c-5d6e-7f8a9b0c1d2e", "title": "Reusable Release Coordinator", - "version": "2.15.3", + "version": "2.16.0", "header_path": ".github/workflows/reusable-release.yml", "path_mismatch": false }, diff --git a/.github/workflows/reusable-release.yml b/.github/workflows/reusable-release.yml index e0ad3cc0..a37d8493 100644 --- a/.github/workflows/reusable-release.yml +++ b/.github/workflows/reusable-release.yml @@ -1,7 +1,7 @@ # file: .github/workflows/reusable-release.yml -# version: 2.15.3 +# version: 2.16.0 # guid: 7b8c9d0e-1f2a-3b4c-5d6e-7f8a9b0c1d2e -# last-edited: 2026-08-30 +# last-edited: 2026-10-16 name: Reusable Release Coordinator @@ -690,38 +690,60 @@ jobs: echo "Remaining artifacts:" find ./artifacts -type f 2>/dev/null | sort || true - - name: Create GitHub Release - id: release - uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3 + # The notes go to the release through body_path, never `body:`. An + # action input is passed as an INPUT_* env var, and Linux caps one env + # string at 128 KiB; a ~125 KB changelog failed the release step with + # "Argument list too long" (audiobook-organizer v0.222.2, 2026-10-16). + - name: Compose release notes + id: notes if: github.event_name != 'pull_request' - with: - tag_name: ${{ steps.version.outputs.version }} - name: ${{ steps.version.outputs.version }} - body: | - ${{ steps.changelog.outputs.changelog_content }} + env: + CHANGELOG_FILE: ${{ steps.changelog.outputs.changelog_file }} + COMMIT_SHA: ${{ github.sha }} + run: | + set -euo pipefail + NOTES_FILE="${RUNNER_TEMP}/release-notes/body.md" + mkdir -p "$(dirname "$NOTES_FILE")" + { + cat "$CHANGELOG_FILE" + echo + cat <<'FOOTER_EOF' + + ## Release Assets - ## Release Assets + This release includes organized packages for easy consumption: - This release includes organized packages for easy consumption: + ### Binaries + Pre-built binaries for multiple platforms with SHA256 checksums: + - **Linux**: `*-linux-amd64`, `*-linux-arm64` + - **macOS**: `*-darwin-amd64`, `*-darwin-arm64` - ### Binaries - Pre-built binaries for multiple platforms with SHA256 checksums: - - **Linux**: `*-linux-amd64`, `*-linux-arm64` - - **macOS**: `*-darwin-amd64`, `*-darwin-arm64` + Each binary includes a `.sha256` checksum file for verification. - Each binary includes a `.sha256` checksum file for verification. + ### SDKs + - **Go SDK**: `*-go-sdk.tar.gz` / `*-go-sdk.zip` + - **Python SDK**: `*-python-sdk.tar.gz` / `*-python-sdk.zip` - ### SDKs - - **Go SDK**: `*-go-sdk.tar.gz` / `*-go-sdk.zip` - - **Python SDK**: `*-python-sdk.tar.gz` / `*-python-sdk.zip` + ### Documentation + - **API Documentation**: `*-docs.tar.gz` / `*-docs.zip` - ### Documentation - - **API Documentation**: `*-docs.tar.gz` / `*-docs.zip` + See `MANIFEST.md` in the release assets for a complete list of all files and their sizes. - See `MANIFEST.md` in the release assets for a complete list of all files and their sizes. + --- + FOOTER_EOF + printf '*Generated automatically from commit %s*\n' "$COMMIT_SHA" + } > "$NOTES_FILE" + echo "body-file=$NOTES_FILE" >> "$GITHUB_OUTPUT" + echo "Release notes: $(wc -c < "$NOTES_FILE") bytes" - --- - *Generated automatically from commit ${{ github.sha }}* + - name: Create GitHub Release + id: release + uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3 + if: github.event_name != 'pull_request' + with: + tag_name: ${{ steps.version.outputs.version }} + name: ${{ steps.version.outputs.version }} + body_path: ${{ steps.notes.outputs.body-file }} draft: ${{ needs.detect-languages.outputs.auto-draft }} prerelease: ${{ needs.detect-languages.outputs.auto-prerelease }} files: | @@ -818,7 +840,7 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} FULL_VERSION: ${{ needs.detect-languages.outputs.full-version }} RC_TAG: ${{ steps.version.outputs.version }} - CHANGELOG: ${{ steps.changelog.outputs.changelog_content }} + CHANGELOG_FILE: ${{ steps.changelog.outputs.changelog_file }} run: | set -euo pipefail @@ -848,14 +870,16 @@ jobs: '[.[] | select(.tagName == $v) | .id] | .[]') EXISTING_ID=${MATCHING_IDS%%$'\n'*} - NOTES=$(cat < "$NOTES_FILE" if [ -n "$EXISTING_ID" ]; then echo "Updating existing draft release $FULL_VERSION (id=$EXISTING_ID)" @@ -863,7 +887,7 @@ jobs: -f tag_name="$FULL_VERSION" \ -f target_commitish="$GITHUB_SHA" \ -f name="$FULL_VERSION" \ - -f body="$NOTES" \ + -F body=@"$NOTES_FILE" \ -F draft=true >/dev/null # Any further drafts on this same version are duplicates minted by @@ -879,7 +903,7 @@ jobs: gh release create "$FULL_VERSION" \ --target "$GITHUB_SHA" \ --title "$FULL_VERSION" \ - --notes "$NOTES" \ + --notes-file "$NOTES_FILE" \ --draft fi diff --git a/.github/workflows/scripts/release_workflow.py b/.github/workflows/scripts/release_workflow.py index f2179ce3..17289c71 100755 --- a/.github/workflows/scripts/release_workflow.py +++ b/.github/workflows/scripts/release_workflow.py @@ -4,17 +4,16 @@ from __future__ import annotations import argparse +from collections.abc import Iterable +from datetime import datetime import json import os +from pathlib import Path import re import subprocess -from collections.abc import Iterable -from datetime import datetime -from pathlib import Path import requests from workflow_common import ( - append_to_file, config_path, write_output, ) @@ -32,10 +31,16 @@ def _normalize_override(value: str | None) -> str: def _build_target_set(build_target: str) -> set[str]: if not build_target or build_target == "all": return {"go", "python", "rust", "frontend", "docker", "protobuf"} - return {entry.strip().lower() for entry in build_target.split(",") if entry.strip()} + return { + entry.strip().lower() + for entry in build_target.split(",") + if entry.strip() + } -def _derive_flag(override: str, key: str, targets: set[str], default: bool) -> bool: +def _derive_flag( + override: str, key: str, targets: set[str], default: bool +) -> bool: if override == "true": return True if override == "false": @@ -56,7 +61,9 @@ def _any_proto_files(limit: int = 20) -> bool: return count > 0 -def _matrix_json(version_key: str, versions: Iterable[str], oses: Iterable[str]) -> str: +def _matrix_json( + version_key: str, versions: Iterable[str], oses: Iterable[str] +) -> str: matrix = { version_key: list(dict.fromkeys(versions)), "os": list(dict.fromkeys(oses)), @@ -70,7 +77,9 @@ def _docker_matrix_json(platforms: Iterable[str]) -> str: def detect_languages(_: argparse.Namespace) -> None: - skip_detection = os.environ.get("SKIP_LANGUAGE_DETECTION", "false").lower() == "true" + skip_detection = ( + os.environ.get("SKIP_LANGUAGE_DETECTION", "false").lower() == "true" + ) targets = _build_target_set(os.environ.get("BUILD_TARGET", "all").lower()) overrides = { @@ -86,9 +95,13 @@ def detect_languages(_: argparse.Namespace) -> None: has_go = _derive_flag(overrides["go"], "go", targets, False) has_python = _derive_flag(overrides["python"], "python", targets, False) has_rust = _derive_flag(overrides["rust"], "rust", targets, False) - has_frontend = _derive_flag(overrides["frontend"], "frontend", targets, False) + has_frontend = _derive_flag( + overrides["frontend"], "frontend", targets, False + ) has_docker = _derive_flag(overrides["docker"], "docker", targets, False) - protobuf_needed = _derive_flag(overrides["protobuf"], "protobuf", targets, False) + protobuf_needed = _derive_flag( + overrides["protobuf"], "protobuf", targets, False + ) else: has_go = ( Path("go.mod").is_file() @@ -118,7 +131,9 @@ def detect_languages(_: argparse.Namespace) -> None: or Path("docker-compose.yaml").is_file() ) protobuf_needed = ( - Path("buf.yaml").is_file() or Path("buf.gen.yaml").is_file() or _any_proto_files() + Path("buf.yaml").is_file() + or Path("buf.gen.yaml").is_file() + or _any_proto_files() ) for key, override in overrides.items(): @@ -180,14 +195,18 @@ def detect_languages(_: argparse.Namespace) -> None: python_versions = versions.get("python") or ["3.11", "3.12", "3.13"] rust_versions = versions.get("rust") or ["stable", "beta"] node_versions = versions.get("node") or ["18", "20", "22"] - docker_platforms = config_path(["linux/amd64", "linux/arm64"], "build", "docker", "platforms") + docker_platforms = config_path( + ["linux/amd64", "linux/arm64"], "build", "docker", "platforms" + ) write_output("go-matrix", _matrix_json("go-version", go_versions, os_list)) write_output( "python-matrix", _matrix_json("python-version", python_versions, os_list), ) - write_output("rust-matrix", _matrix_json("rust-version", rust_versions, os_list)) + write_output( + "rust-matrix", _matrix_json("rust-version", rust_versions, os_list) + ) write_output( "frontend-matrix", _matrix_json("node-version", node_versions, ["ubuntu-latest"]), @@ -197,7 +216,9 @@ def detect_languages(_: argparse.Namespace) -> None: def release_strategy(_: argparse.Namespace) -> None: branch = os.environ.get("BRANCH_NAME", "") - input_prerelease = os.environ.get("INPUT_PRERELEASE", "false").lower() == "true" + input_prerelease = ( + os.environ.get("INPUT_PRERELEASE", "false").lower() == "true" + ) input_draft = os.environ.get("INPUT_DRAFT", "false").lower() == "true" if branch == "main": @@ -223,8 +244,12 @@ def release_strategy(_: argparse.Namespace) -> None: print(f"πŸ“‹ Auto-draft: {auto_draft}") -def _run_git(args: list[str], check: bool = False) -> subprocess.CompletedProcess[str]: - return subprocess.run(["git"] + args, check=check, capture_output=True, text=True) +def _run_git( + args: list[str], check: bool = False +) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["git"] + args, check=check, capture_output=True, text=True + ) def _latest_tag_from_api() -> str: @@ -274,7 +299,9 @@ def _latest_tag_from_git() -> str: def generate_version(_: argparse.Namespace) -> None: release_type = os.environ.get("RELEASE_TYPE", "auto").lower() branch_name = os.environ.get("BRANCH_NAME", "") - auto_prerelease = os.environ.get("AUTO_PRERELEASE", "false").lower() == "true" + auto_prerelease = ( + os.environ.get("AUTO_PRERELEASE", "false").lower() == "true" + ) print("πŸ” Detecting latest version...") latest_tag = _latest_tag_from_api() @@ -305,7 +332,9 @@ def generate_version(_: argparse.Namespace) -> None: timestamp = datetime.utcnow().strftime("%Y%m%d%H%M") if auto_prerelease: suffix = "dev" if branch_name == "develop" else "alpha" - version_tag = f"v{new_major}.{new_minor}.{new_patch}-{suffix}.{timestamp}" + version_tag = ( + f"v{new_major}.{new_minor}.{new_patch}-{suffix}.{timestamp}" + ) else: version_tag = f"v{new_major}.{new_minor}.{new_patch}" @@ -342,18 +371,60 @@ def generate_version(_: argparse.Namespace) -> None: new_patch += 1 version_tag = f"v{new_major}.{new_minor}.{new_patch}" if new_patch - patch > 10: - version_tag = f"v{new_major}.{new_minor}.{new_patch}-build.{timestamp}" + version_tag = ( + f"v{new_major}.{new_minor}.{new_patch}-build.{timestamp}" + ) break print(f"βœ… Final version tag: {version_tag}") write_output("tag", version_tag) +# GitHub rejects a release body over 125,000 characters. The release and +# draft steps add their own header/footer (~1.5 KB) around these notes, so +# the commit list gets a budget well under that. +CHANGELOG_COMMITS_BUDGET = 110_000 + + +def _omitted_line(count: int) -> str: + return ( + f"- … and {count} older commit(s) not listed " + "(release notes are capped at GitHub's body limit)" + ) + + +def _cap_commit_lines(commits: list[str], budget: int) -> list[str]: + """Return "- commit" lines whose joined length fits budget characters. + + Commits are newest first, so the oldest are the ones dropped and a final + line says how many were left out. The budget counts characters (what + GitHub limits), including the newline between lines. A commit is kept + only if the omitted-count line for everything after it still fits, so + that line always has room when the list is cut. + """ + kept: list[str] = [] + used = 0 + for index, commit in enumerate(commits): + line = f"- {commit}" + after = len(commits) - index - 1 + need = len(line) + (1 if kept else 0) + if after: + need += 1 + len(_omitted_line(after)) + if used + need > budget: + kept.append(_omitted_line(len(commits) - index)) + return kept + kept.append(line) + used += len(line) + (1 if len(kept) > 1 else 0) + return kept + + def generate_changelog(_: argparse.Namespace) -> None: branch = os.environ.get("BRANCH_NAME", "") primary_language = os.environ.get("PRIMARY_LANGUAGE", "unknown") strategy = os.environ.get("RELEASE_STRATEGY", "stable") - auto_prerelease = os.environ.get("AUTO_PRERELEASE", "false").lower() == "true" + auto_prerelease = ( + os.environ.get("AUTO_PRERELEASE", "false").lower() == "true" + ) auto_draft = os.environ.get("AUTO_DRAFT", "false").lower() == "true" # Find the previous STABLE release tag for changelog diffing. @@ -379,7 +450,10 @@ def generate_changelog(_: argparse.Namespace) -> None: stable_tags = [ t for t in tag_list.stdout.strip().splitlines() - if t.strip() and "-rc" not in t and "-alpha" not in t and "-beta" not in t + if t.strip() + and "-rc" not in t + and "-alpha" not in t + and "-beta" not in t ] # The first entry is the tag we just created (or the latest stable); # the second is the "previous release" we want the diff from. @@ -397,12 +471,14 @@ def generate_changelog(_: argparse.Namespace) -> None: log_args = [] header = "### πŸ“‹ Initial Release Commits:\n" - commits = _run_git(["log"] + log_args + ["--pretty=%s (%h)"]).stdout.splitlines() + commits = _run_git( + ["log"] + log_args + ["--pretty=%s (%h)"] + ).stdout.splitlines() commits = [entry for entry in commits if entry.strip()] lines = ["## πŸš€ What's Changed", "", header] if commits: - lines.extend(f"- {commit}" for commit in commits) + lines.extend(_cap_commit_lines(commits, CHANGELOG_COMMITS_BUDGET)) else: lines.append("- No commits available") @@ -417,18 +493,32 @@ def generate_changelog(_: argparse.Namespace) -> None: ) if auto_prerelease: - lines.append("\n⚠️ **This is a pre-release version** - use for testing purposes.") + lines.append( + "\n⚠️ **This is a pre-release version** - use for testing purposes." + ) if auto_draft: - lines.append("\nπŸ“ **This is a draft release** - review before making public.") + lines.append( + "\nπŸ“ **This is a draft release** - review before making public." + ) changelog = "\n".join(lines) + "\n" - append_to_file("GITHUB_OUTPUT", "changelog_content< argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="Release workflow helper commands.") + parser = argparse.ArgumentParser( + description="Release workflow helper commands." + ) subparsers = parser.add_subparsers(dest="command", required=True) commands = { diff --git a/changelog.d/20261016-release-notes-body-path.md b/changelog.d/20261016-release-notes-body-path.md new file mode 100644 index 00000000..84223e07 --- /dev/null +++ b/changelog.d/20261016-release-notes-body-path.md @@ -0,0 +1,17 @@ +### Fixed + +#### `reusable-release.yml` β€” long release notes no longer fail the release with "Argument list too long" + +The changelog was passed to `softprops/action-gh-release` through `body:`, and to +the RC draft step through an env var and `gh api -f body=` / `gh release --notes`. +Each of those puts the whole notes into a single env/argv string, which Linux caps +at 128 KiB. A stable release with hundreds of commits since the last tag +(audiobook-organizer v0.222.2, about 125 KB of notes) died in "Create GitHub +Release" after the release was already published. Every later step then skipped: +changelog fragment collection, the draft cleanup and the GitHub Packages publish. + +`release_workflow.py generate-changelog` now writes the notes to a file +(`changelog_file` output) and caps the commit list at 110,000 characters, newest +first, ending with "… and N older commit(s) not listed". GitHub rejects release +bodies over 125,000 characters. The release step reads the notes with +`body_path:`, and the draft step uses `-F body=@file` / `--notes-file`. diff --git a/tests/workflow_scripts/test_release_workflow.py b/tests/workflow_scripts/test_release_workflow.py index bc46ecc7..7fa5c3ff 100644 --- a/tests/workflow_scripts/test_release_workflow.py +++ b/tests/workflow_scripts/test_release_workflow.py @@ -86,7 +86,9 @@ def test_generate_version_from_tag(monkeypatch, tmp_path): monkeypatch.setenv("GITHUB_EVENT_NAME", "push") monkeypatch.setattr(release_workflow, "_latest_tag_from_api", lambda: "") - monkeypatch.setattr(release_workflow, "_latest_tag_from_git", lambda: "v1.2.3") + monkeypatch.setattr( + release_workflow, "_latest_tag_from_git", lambda: "v1.2.3" + ) def fake_run(args, check=False): cmd = tuple(args) @@ -106,6 +108,7 @@ def fake_run(args, check=False): def test_generate_changelog(monkeypatch, tmp_path): output_file = tmp_path / "outputs.txt" monkeypatch.setenv("GITHUB_OUTPUT", str(output_file)) + monkeypatch.setenv("RUNNER_TEMP", str(tmp_path / "runner-temp")) monkeypatch.setenv("BRANCH_NAME", "main") monkeypatch.setenv("PRIMARY_LANGUAGE", "go") monkeypatch.setenv("RELEASE_STRATEGY", "stable") @@ -130,6 +133,64 @@ def fake_run(args, check=False): monkeypatch.setattr(release_workflow, "_run_git", fake_run) release_workflow.generate_changelog(argparse.Namespace()) - content = output_file.read_text() + outputs = _parse_outputs(output_file) + # The notes travel as a file path, never as inline output content. + assert "changelog_content" not in output_file.read_text() + content = Path(outputs["changelog_file"]).read_text(encoding="utf-8") assert "feat: add feature" in content assert "Release Type" in content + + +def test_cap_commit_lines_keeps_everything_under_budget(): + commits = ["feat: a (1)", "fix: b (2)"] + assert release_workflow._cap_commit_lines(commits, 1000) == [ + "- feat: a (1)", + "- fix: b (2)", + ] + + +def test_cap_commit_lines_drops_oldest_and_says_how_many(): + commits = [f"fix: change number {i} ({i:07x})" for i in range(5000)] + lines = release_workflow._cap_commit_lines( + commits, release_workflow.CHANGELOG_COMMITS_BUDGET + ) + body = "\n".join(lines) + assert len(body) <= release_workflow.CHANGELOG_COMMITS_BUDGET + # Newest first: the first commit survives, the oldest does not. + assert lines[0] == f"- {commits[0]}" + assert f"- {commits[-1]}" not in lines + kept = len(lines) - 1 + assert lines[-1].startswith(f"- … and {5000 - kept} older commit(s)") + + +def test_generate_changelog_long_history_fits_github_body_limit( + monkeypatch, tmp_path +): + output_file = tmp_path / "outputs.txt" + monkeypatch.setenv("GITHUB_OUTPUT", str(output_file)) + monkeypatch.setenv("RUNNER_TEMP", str(tmp_path / "runner-temp")) + monkeypatch.setenv("PREVIOUS_VERSION", "v0.1.0") + + import subprocess + + # Multibyte subjects: a ~125 KB body like this is what overflowed the + # 128 KiB env/argv string limit when passed as an action input. + log = "".join( + f"fix(ΓΌ): βœ“ subject number {i} ({i:09x})\n" for i in range(6000) + ) + + def fake_run(args, check=False): + if args[0] == "log": + return subprocess.CompletedProcess(args, 0, log, "") + return subprocess.CompletedProcess(args, 0, "", "") + + monkeypatch.setattr(release_workflow, "_run_git", fake_run) + + release_workflow.generate_changelog(argparse.Namespace()) + content = Path(_parse_outputs(output_file)["changelog_file"]).read_text( + encoding="utf-8" + ) + # GitHub's release body limit is 125,000 characters; leave room for the + # header/footer the workflow adds around these notes. + assert len(content) < 120_000 + assert "older commit(s) not listed" in content From 1b277ee636a430616c74f245876e45c001d8a844 Mon Sep 17 00:00:00 2001 From: Johnathan Falk Date: Fri, 16 Oct 2026 09:56:56 -0400 Subject: [PATCH 2/5] fix(release): survive script/workflow ref skew; keep a list that fits whole (#374 review) The scripts are checked out at the caller's ghcommon-ref, separately from the workflow, so a new workflow can run an old script (no changelog_file) and the reverse. Both new steps now fail with an error naming the ref to bump, and the script still emits changelog_content so an older workflow keeps its notes. _cap_commit_lines returns a list that fits whole unchanged, and the omitted line no longer calls the newest commit 'older'. Version headers on the script and its test; usage map lists the new output. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012mtQZQ26prebPvRBbC5wdL --- .github/guid-index.json | 18 ++++++++++- .github/workflows/reusable-release.yml | 8 +++++ .github/workflows/scripts/release_workflow.py | 30 +++++++++++++++---- WORKFLOW_SCRIPT_USAGE_MAP.md | 12 +++++--- .../20261016-release-notes-body-path.md | 8 +++++ docs/guid-index.md | 4 ++- .../workflow_scripts/test_release_workflow.py | 22 ++++++++++++-- 7 files changed, 88 insertions(+), 14 deletions(-) diff --git a/.github/guid-index.json b/.github/guid-index.json index 7f6dc89d..ebebb705 100644 --- a/.github/guid-index.json +++ b/.github/guid-index.json @@ -1167,6 +1167,14 @@ "header_path": ".github/workflows/scripts/release-strategy.sh", "path_mismatch": false }, + { + "path": ".github/workflows/scripts/release_workflow.py", + "guid": "dc14e86d-ff9a-4317-88de-23e0e48651d2", + "title": "release workflow", + "version": "2.2.0", + "header_path": ".github/workflows/scripts/release_workflow.py", + "path_mismatch": false + }, { "path": ".github/workflows/scripts/test-scripts.sh", "guid": "2e3f4a5b-6c7d-8e9f-0a1b-2c3d4e5f6a7b", @@ -1547,7 +1555,7 @@ "path": "WORKFLOW_SCRIPT_USAGE_MAP.md", "guid": "2b3c4d5e-6f7a-8b9c-0d1e-2f3a4b5c6d7e", "title": "Workflow Script Usage Mapping", - "version": "1.0.0", + "version": "1.0.1", "header_path": "WORKFLOW_SCRIPT_USAGE_MAP.md", "path_mismatch": false }, @@ -3687,6 +3695,14 @@ "header_path": "tests/workflow_scripts/test_publish_to_github_packages.py", "path_mismatch": false }, + { + "path": "tests/workflow_scripts/test_release_workflow.py", + "guid": "543b49c0-5c64-4cea-a025-76de25f6b324", + "title": "test release workflow", + "version": "1.1.0", + "header_path": "tests/workflow_scripts/test_release_workflow.py", + "path_mismatch": false + }, { "path": "tests/workflow_scripts/test_workflow_common.py", "guid": "4dcbebd7-74dd-4c5f-9442-7ad2c49ea5a6", diff --git a/.github/workflows/reusable-release.yml b/.github/workflows/reusable-release.yml index a37d8493..cf275c08 100644 --- a/.github/workflows/reusable-release.yml +++ b/.github/workflows/reusable-release.yml @@ -702,6 +702,10 @@ jobs: COMMIT_SHA: ${{ github.sha }} run: | set -euo pipefail + if [ ! -s "${CHANGELOG_FILE:-}" ]; then + echo "::error::generate-changelog produced no changelog_file. The ghcommon scripts ref (ghcommon-ref.txt or the fallback SHA) predates github-common#374; bump .github/ghcommon-ref.txt (or the fallback pin) to match this workflow." + exit 1 + fi NOTES_FILE="${RUNNER_TEMP}/release-notes/body.md" mkdir -p "$(dirname "$NOTES_FILE")" { @@ -873,6 +877,10 @@ jobs: # Notes go through a file (gh api -F body=@file, gh release # --notes-file), never argv: one argv string is capped at 128 KiB # and a long changelog exceeds it ("Argument list too long"). + if [ ! -s "${CHANGELOG_FILE:-}" ]; then + echo "::error::generate-changelog produced no changelog_file. The ghcommon scripts ref (ghcommon-ref.txt or the fallback SHA) predates github-common#374; bump .github/ghcommon-ref.txt (or the fallback pin) to match this workflow." + exit 1 + fi NOTES_FILE="${RUNNER_TEMP}/release-notes/draft-body.md" mkdir -p "$(dirname "$NOTES_FILE")" { diff --git a/.github/workflows/scripts/release_workflow.py b/.github/workflows/scripts/release_workflow.py index 17289c71..ec277ea5 100755 --- a/.github/workflows/scripts/release_workflow.py +++ b/.github/workflows/scripts/release_workflow.py @@ -1,4 +1,8 @@ #!/usr/bin/env python3 +# file: .github/workflows/scripts/release_workflow.py +# version: 2.2.0 +# guid: dc14e86d-ff9a-4317-88de-23e0e48651d2 +# last-edited: 2026-10-16 """Helper utilities for reusable release workflows.""" from __future__ import annotations @@ -14,6 +18,7 @@ import requests from workflow_common import ( + append_to_file, config_path, write_output, ) @@ -386,9 +391,11 @@ def generate_version(_: argparse.Namespace) -> None: CHANGELOG_COMMITS_BUDGET = 110_000 -def _omitted_line(count: int) -> str: +def _omitted_line(count: int, *, newest: bool = False) -> str: + """Return the closing line that says how many commits were left out.""" + which = "" if newest else " older" return ( - f"- … and {count} older commit(s) not listed " + f"- … and {count}{which} commit(s) not listed " "(release notes are capped at GitHub's body limit)" ) @@ -398,10 +405,15 @@ def _cap_commit_lines(commits: list[str], budget: int) -> list[str]: Commits are newest first, so the oldest are the ones dropped and a final line says how many were left out. The budget counts characters (what - GitHub limits), including the newline between lines. A commit is kept - only if the omitted-count line for everything after it still fits, so - that line always has room when the list is cut. + GitHub limits), including the newline between lines. A list that fits + whole is returned unchanged. Otherwise a commit is kept only if the + omitted-count line for everything after it still fits, so that line + always has room when the list is cut. A budget smaller than that line + itself (under ~90 characters) cannot be honoured; callers use 110,000. """ + lines = [f"- {commit}" for commit in commits] + if len("\n".join(lines)) <= budget: + return lines kept: list[str] = [] used = 0 for index, commit in enumerate(commits): @@ -411,7 +423,7 @@ def _cap_commit_lines(commits: list[str], budget: int) -> list[str]: if after: need += 1 + len(_omitted_line(after)) if used + need > budget: - kept.append(_omitted_line(len(commits) - index)) + kept.append(_omitted_line(len(commits) - index, newest=not kept)) return kept kept.append(line) used += len(line) + (1 if len(kept) > 1 else 0) @@ -513,6 +525,12 @@ def generate_changelog(_: argparse.Namespace) -> None: out_file = out_dir / "changelog.md" out_file.write_text(changelog, encoding="utf-8") write_output("changelog_file", str(out_file)) + # Kept for an older reusable-release.yml paired with this script (the + # scripts are checked out at the caller's ghcommon-ref, separately from + # the workflow). Without it that workflow publishes an empty body. + append_to_file("GITHUB_OUTPUT", "changelog_content< argparse.ArgumentParser: diff --git a/WORKFLOW_SCRIPT_USAGE_MAP.md b/WORKFLOW_SCRIPT_USAGE_MAP.md index b361ff9b..25940deb 100644 --- a/WORKFLOW_SCRIPT_USAGE_MAP.md +++ b/WORKFLOW_SCRIPT_USAGE_MAP.md @@ -1,7 +1,7 @@ - + - + # Workflow Script Usage Mapping @@ -353,8 +353,12 @@ branch" **Outputs:** -- `changelog` - Markdown changelog text -- `changelog-file` - Path to changelog file +- `changelog_file` - Path to the release-notes Markdown file (commit list capped + at 110,000 characters). Workflows read the notes from this file; never pass + them through an action input, env var or argv (128 KiB per-string limit). +- `changelog_content` - The same notes inline. Kept only for an older + `reusable-release.yml` paired with a newer script; new consumers use + `changelog_file`. **Dependencies:** diff --git a/changelog.d/20261016-release-notes-body-path.md b/changelog.d/20261016-release-notes-body-path.md index 84223e07..63ee1ee7 100644 --- a/changelog.d/20261016-release-notes-body-path.md +++ b/changelog.d/20261016-release-notes-body-path.md @@ -15,3 +15,11 @@ changelog fragment collection, the draft cleanup and the GitHub Packages publish first, ending with "… and N older commit(s) not listed". GitHub rejects release bodies over 125,000 characters. The release step reads the notes with `body_path:`, and the draft step uses `-F body=@file` / `--notes-file`. + +`create-release` fetches the workflow scripts at the caller's +`.github/ghcommon-ref.txt`, or at the fallback SHA, separately from the +workflow, so the two can be at different versions. The script still emits +`changelog_content`, so an older workflow paired with this script keeps +publishing notes. In the other direction, both new steps stop with an +`::error::` naming the ref to bump instead of a bare `cat` failure. Callers +that set `ghcommon-ref.txt` must bump it together with the `uses:` pin. diff --git a/docs/guid-index.md b/docs/guid-index.md index e0054e0a..146e6102 100644 --- a/docs/guid-index.md +++ b/docs/guid-index.md @@ -1,6 +1,6 @@ # GUID Index -Generated by `generate_guid_index.py` from 467 file headers. +Generated by `generate_guid_index.py` from 469 file headers. Do not edit by hand β€” regenerate instead. ## Duplicate GUIDs @@ -530,6 +530,7 @@ These are not valid UUIDs (likely leftover placeholder text). | rebase | `e542f3f2-f992-4f03-a755-a2f8b9fc2790` | scripts/rebase | | Release | `8b4d6c2a-1f5e-4a8b-9c3d-7e2f5a8b1c4d` | .github/workflows/release.yml | | release strategy | `9b0c1d2e-3f4a-5b6c-7d8e-9f0a1b2c3d4e` | .github/workflows/scripts/release-strategy.sh | +| release workflow | `dc14e86d-ff9a-4317-88de-23e0e48651d2` | .github/workflows/scripts/release_workflow.py | | Release Workflows Implementation Guide | `b2c3d4e5-f6a7-8b9c-0d1e-2f3a4b5c6d7e` | docs/refactors/workflows/v2/implementation/release-workflows.md | | repo audit | `a1b2c3d4-e5f6-7a8b-9c0d-1e2f3a4b5c6d` | scripts/repo-audit.py | | repo comparison chart | `6f263283-29c4-475e-b162-09dfdcd9ab9d` | repo-comparison-chart.md | @@ -740,6 +741,7 @@ These are not valid UUIDs (likely leftover placeholder text). | Test Markdown File | `e4f5a6b7-c8d9-0e1f-2a3b-4c5d6e7f8a9b` | .github/test-files/test.md | | test minimal | `b1c2d3e4-f5a6-7b8c-9d0e-1f2a3b4c5d6e` | .github/test-configs/test-minimal.env | | test publish to github packages | `6e77a1f8-5c76-41a8-9e3c-9f89cb237a9f` | tests/workflow_scripts/test_publish_to_github_packages.py | +| test release workflow | `543b49c0-5c64-4cea-a025-76de25f6b324` | tests/workflow_scripts/test_release_workflow.py | | test scripts | `2e3f4a5b-6c7d-8e9f-0a1b-2c3d4e5f6a7b` | .github/workflows/scripts/test-scripts.sh | | Test Super Linter Configuration | `test-super-linter-workflow-2025-05-24-initial` | .github/workflows/test-super-linter.yml | | test workflow common | `4dcbebd7-74dd-4c5f-9442-7ad2c49ea5a6` | tests/workflow_scripts/test_workflow_common.py | diff --git a/tests/workflow_scripts/test_release_workflow.py b/tests/workflow_scripts/test_release_workflow.py index 7fa5c3ff..fce7bd61 100644 --- a/tests/workflow_scripts/test_release_workflow.py +++ b/tests/workflow_scripts/test_release_workflow.py @@ -1,3 +1,8 @@ +# file: tests/workflow_scripts/test_release_workflow.py +# version: 1.1.0 +# guid: 543b49c0-5c64-4cea-a025-76de25f6b324 +# last-edited: 2026-10-16 + import argparse import json from pathlib import Path @@ -134,9 +139,10 @@ def fake_run(args, check=False): release_workflow.generate_changelog(argparse.Namespace()) outputs = _parse_outputs(output_file) - # The notes travel as a file path, never as inline output content. - assert "changelog_content" not in output_file.read_text() + # Workflows read the notes from the file; the inline copy stays only for + # an older reusable-release.yml paired with this script. content = Path(outputs["changelog_file"]).read_text(encoding="utf-8") + assert content in output_file.read_text(encoding="utf-8") assert "feat: add feature" in content assert "Release Type" in content @@ -194,3 +200,15 @@ def fake_run(args, check=False): # header/footer the workflow adds around these notes. assert len(content) < 120_000 assert "older commit(s) not listed" in content + + +def test_cap_commit_lines_exact_fit_is_not_cut(): + commits = ["abc", "de"] + # "- abc\n- de" is exactly 10 characters. + assert release_workflow._cap_commit_lines(commits, 10) == ["- abc", "- de"] + + +def test_cap_commit_lines_oversized_newest_is_not_called_older(): + lines = release_workflow._cap_commit_lines(["x" * 500, "y"], 200) + assert lines == [release_workflow._omitted_line(2, newest=True)] + assert "older" not in lines[0] From 1cd2dbe0287221e2048c1717d835a99c71c98e8c Mon Sep 17 00:00:00 2001 From: Johnathan Falk Date: Fri, 16 Oct 2026 10:04:39 -0400 Subject: [PATCH 3/5] fix(release): fall back to the inline changelog for an older scripts ref (#374 re-review) Callers without .github/ghcommon-ref.txt get scripts at the fallback SHA, which predates changelog_file, so the guard would have stopped every one of their releases. A separate step now writes changelog_content to a file only when changelog_file is absent (so current runs never put the notes in env), and the error fires only when neither exists. changelog_content uses write_output's uuid delimiter. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012mtQZQ26prebPvRBbC5wdL --- .github/workflows/reusable-release.yml | 30 ++++++++++++++++--- .github/workflows/scripts/release_workflow.py | 8 ++--- .../20261016-release-notes-body-path.md | 14 +++++---- 3 files changed, 39 insertions(+), 13 deletions(-) diff --git a/.github/workflows/reusable-release.yml b/.github/workflows/reusable-release.yml index cf275c08..3be9d9d8 100644 --- a/.github/workflows/reusable-release.yml +++ b/.github/workflows/reusable-release.yml @@ -646,6 +646,28 @@ jobs: run: | python3 "$GHCOMMON_SCRIPTS_DIR/release_workflow.py" "generate-changelog" + # The scripts are checked out at the caller's .github/ghcommon-ref.txt + # (or the fallback SHA above), separately from this workflow. A script + # from before github-common#374 emits only changelog_content, inline. + # Only then is it written to a file here. This is a separate step so + # that a current script's notes never pass through env at all (one env + # string is capped at 128 KiB, the original "Argument list too long"). + - name: Write legacy inline changelog to a file + id: legacy-notes + if: steps.changelog.outputs.changelog_file == '' + env: + CHANGELOG_CONTENT: ${{ steps.changelog.outputs.changelog_content }} + run: | + set -euo pipefail + if [ -z "${CHANGELOG_CONTENT}" ]; then + exit 0 # nothing to fall back to; the guard below reports it + fi + echo "::warning::The ghcommon scripts ref predates github-common#374 (no changelog_file). Using the inline changelog; set .github/ghcommon-ref.txt to a newer github-common SHA." + f="${RUNNER_TEMP}/release-notes/legacy-changelog.md" + mkdir -p "$(dirname "$f")" + printf '%s\n' "$CHANGELOG_CONTENT" > "$f" + echo "changelog_file=$f" >> "$GITHUB_OUTPUT" + - name: Download all build artifacts uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: @@ -698,12 +720,12 @@ jobs: id: notes if: github.event_name != 'pull_request' env: - CHANGELOG_FILE: ${{ steps.changelog.outputs.changelog_file }} + CHANGELOG_FILE: ${{ steps.changelog.outputs.changelog_file || steps.legacy-notes.outputs.changelog_file }} COMMIT_SHA: ${{ github.sha }} run: | set -euo pipefail if [ ! -s "${CHANGELOG_FILE:-}" ]; then - echo "::error::generate-changelog produced no changelog_file. The ghcommon scripts ref (ghcommon-ref.txt or the fallback SHA) predates github-common#374; bump .github/ghcommon-ref.txt (or the fallback pin) to match this workflow." + echo "::error::No release notes file: generate-changelog produced neither changelog_file nor changelog_content. Check the Generate changelog step; if the ghcommon scripts ref is very old, set .github/ghcommon-ref.txt to a current github-common SHA." exit 1 fi NOTES_FILE="${RUNNER_TEMP}/release-notes/body.md" @@ -844,7 +866,7 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} FULL_VERSION: ${{ needs.detect-languages.outputs.full-version }} RC_TAG: ${{ steps.version.outputs.version }} - CHANGELOG_FILE: ${{ steps.changelog.outputs.changelog_file }} + CHANGELOG_FILE: ${{ steps.changelog.outputs.changelog_file || steps.legacy-notes.outputs.changelog_file }} run: | set -euo pipefail @@ -878,7 +900,7 @@ jobs: # --notes-file), never argv: one argv string is capped at 128 KiB # and a long changelog exceeds it ("Argument list too long"). if [ ! -s "${CHANGELOG_FILE:-}" ]; then - echo "::error::generate-changelog produced no changelog_file. The ghcommon scripts ref (ghcommon-ref.txt or the fallback SHA) predates github-common#374; bump .github/ghcommon-ref.txt (or the fallback pin) to match this workflow." + echo "::error::No release notes file: generate-changelog produced neither changelog_file nor changelog_content. Check the Generate changelog step; if the ghcommon scripts ref is very old, set .github/ghcommon-ref.txt to a current github-common SHA." exit 1 fi NOTES_FILE="${RUNNER_TEMP}/release-notes/draft-body.md" diff --git a/.github/workflows/scripts/release_workflow.py b/.github/workflows/scripts/release_workflow.py index ec277ea5..8dc55408 100755 --- a/.github/workflows/scripts/release_workflow.py +++ b/.github/workflows/scripts/release_workflow.py @@ -18,7 +18,6 @@ import requests from workflow_common import ( - append_to_file, config_path, write_output, ) @@ -528,9 +527,10 @@ def generate_changelog(_: argparse.Namespace) -> None: # Kept for an older reusable-release.yml paired with this script (the # scripts are checked out at the caller's ghcommon-ref, separately from # the workflow). Without it that workflow publishes an empty body. - append_to_file("GITHUB_OUTPUT", "changelog_content< argparse.ArgumentParser: diff --git a/changelog.d/20261016-release-notes-body-path.md b/changelog.d/20261016-release-notes-body-path.md index 63ee1ee7..9d78f266 100644 --- a/changelog.d/20261016-release-notes-body-path.md +++ b/changelog.d/20261016-release-notes-body-path.md @@ -18,8 +18,12 @@ bodies over 125,000 characters. The release step reads the notes with `create-release` fetches the workflow scripts at the caller's `.github/ghcommon-ref.txt`, or at the fallback SHA, separately from the -workflow, so the two can be at different versions. The script still emits -`changelog_content`, so an older workflow paired with this script keeps -publishing notes. In the other direction, both new steps stop with an -`::error::` naming the ref to bump instead of a bare `cat` failure. Callers -that set `ghcommon-ref.txt` must bump it together with the `uses:` pin. +workflow, so the two can be at different versions. Both pairings keep working: +- **Old script, new workflow:** a new step, "Write legacy inline changelog to a + file", runs only when there is no `changelog_file`, and warns that the ref + is stale. +- **New script, old workflow:** the script still emits `changelog_content`, + now capped. + +If neither output is present, the release stops with an `::error::` before +anything is published. From e2585e8d2b21c3490c3207bcb03fbcb1f988ff8d Mon Sep 17 00:00:00 2001 From: Johnathan Falk Date: Fri, 16 Oct 2026 10:08:49 -0400 Subject: [PATCH 4/5] fix(release): accurate guard text; test the inline changelog round trip; fragment notes the legacy env limit (#374 round 3) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012mtQZQ26prebPvRBbC5wdL --- .github/workflows/reusable-release.yml | 6 +-- .../20261016-release-notes-body-path.md | 7 ++++ .../workflow_scripts/test_release_workflow.py | 38 +++++++++++++++++++ 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/.github/workflows/reusable-release.yml b/.github/workflows/reusable-release.yml index 3be9d9d8..420ea3a5 100644 --- a/.github/workflows/reusable-release.yml +++ b/.github/workflows/reusable-release.yml @@ -659,7 +659,7 @@ jobs: CHANGELOG_CONTENT: ${{ steps.changelog.outputs.changelog_content }} run: | set -euo pipefail - if [ -z "${CHANGELOG_CONTENT}" ]; then + if [ -z "${CHANGELOG_CONTENT:-}" ]; then exit 0 # nothing to fall back to; the guard below reports it fi echo "::warning::The ghcommon scripts ref predates github-common#374 (no changelog_file). Using the inline changelog; set .github/ghcommon-ref.txt to a newer github-common SHA." @@ -725,7 +725,7 @@ jobs: run: | set -euo pipefail if [ ! -s "${CHANGELOG_FILE:-}" ]; then - echo "::error::No release notes file: generate-changelog produced neither changelog_file nor changelog_content. Check the Generate changelog step; if the ghcommon scripts ref is very old, set .github/ghcommon-ref.txt to a current github-common SHA." + echo "::error::No non-empty release notes file (changelog_file, or the legacy changelog_content). Check the Generate changelog step; if the ghcommon scripts ref is old, set .github/ghcommon-ref.txt to a current github-common SHA." exit 1 fi NOTES_FILE="${RUNNER_TEMP}/release-notes/body.md" @@ -900,7 +900,7 @@ jobs: # --notes-file), never argv: one argv string is capped at 128 KiB # and a long changelog exceeds it ("Argument list too long"). if [ ! -s "${CHANGELOG_FILE:-}" ]; then - echo "::error::No release notes file: generate-changelog produced neither changelog_file nor changelog_content. Check the Generate changelog step; if the ghcommon scripts ref is very old, set .github/ghcommon-ref.txt to a current github-common SHA." + echo "::error::No non-empty release notes file (changelog_file, or the legacy changelog_content). Check the Generate changelog step; if the ghcommon scripts ref is old, set .github/ghcommon-ref.txt to a current github-common SHA." exit 1 fi NOTES_FILE="${RUNNER_TEMP}/release-notes/draft-body.md" diff --git a/changelog.d/20261016-release-notes-body-path.md b/changelog.d/20261016-release-notes-body-path.md index 9d78f266..7999b00e 100644 --- a/changelog.d/20261016-release-notes-body-path.md +++ b/changelog.d/20261016-release-notes-body-path.md @@ -27,3 +27,10 @@ workflow, so the two can be at different versions. Both pairings keep working: If neither output is present, the release stops with an `::error::` before anything is published. + +The legacy path still passes the notes through env, so the "Argument list +too long" fix only fully applies once the scripts are current too. An old, +uncapped script with notes near 128 KiB still fails, though now before +publishing. Callers that set `.github/ghcommon-ref.txt` must bump it together +with the `uses:` pin. The fallback scripts SHA in this workflow is bumped +right after this change merges. diff --git a/tests/workflow_scripts/test_release_workflow.py b/tests/workflow_scripts/test_release_workflow.py index fce7bd61..5af8647c 100644 --- a/tests/workflow_scripts/test_release_workflow.py +++ b/tests/workflow_scripts/test_release_workflow.py @@ -212,3 +212,41 @@ def test_cap_commit_lines_oversized_newest_is_not_called_older(): lines = release_workflow._cap_commit_lines(["x" * 500, "y"], 200) assert lines == [release_workflow._omitted_line(2, newest=True)] assert "older" not in lines[0] + + +def _parse_heredoc_output(path: Path, name: str) -> str: + """Return a ``name< Date: Fri, 16 Oct 2026 10:13:24 -0400 Subject: [PATCH 5/5] style(release): workflow scripts get the CLI lint exemptions; docstrings and unpacking in release_workflow.py ruff.toml's per-file ignores for workflow CLIs (stdout is the interface, git/gh by name, status emoji, semver counts) covered .github/scripts but not .github/workflows/scripts, where the reusable workflows' GHCOMMON_SCRIPTS_DIR points, so the changed-file Python lint on #374 failed on release_workflow.py's existing prints and subprocess calls. Same list, second path. The two findings it does not cover are fixed in code: public-function docstrings and RUF005. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012mtQZQ26prebPvRBbC5wdL --- .github/guid-index.json | 4 ++-- .github/workflows/scripts/release_workflow.py | 12 +++++++++--- ruff.toml | 7 +++++-- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/.github/guid-index.json b/.github/guid-index.json index ebebb705..588df797 100644 --- a/.github/guid-index.json +++ b/.github/guid-index.json @@ -1171,7 +1171,7 @@ "path": ".github/workflows/scripts/release_workflow.py", "guid": "dc14e86d-ff9a-4317-88de-23e0e48651d2", "title": "release workflow", - "version": "2.2.0", + "version": "2.3.0", "header_path": ".github/workflows/scripts/release_workflow.py", "path_mismatch": false }, @@ -3019,7 +3019,7 @@ "path": "ruff.toml", "guid": "f1a2b3c4-d5e6-789f-a012-456789abcdef", "title": "ruff", - "version": "1.3.0", + "version": "1.4.0", "header_path": "ruff.toml", "path_mismatch": false }, diff --git a/.github/workflows/scripts/release_workflow.py b/.github/workflows/scripts/release_workflow.py index 8dc55408..c6263aa5 100755 --- a/.github/workflows/scripts/release_workflow.py +++ b/.github/workflows/scripts/release_workflow.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # file: .github/workflows/scripts/release_workflow.py -# version: 2.2.0 +# version: 2.3.0 # guid: dc14e86d-ff9a-4317-88de-23e0e48651d2 # last-edited: 2026-10-16 """Helper utilities for reusable release workflows.""" @@ -81,6 +81,7 @@ def _docker_matrix_json(platforms: Iterable[str]) -> str: def detect_languages(_: argparse.Namespace) -> None: + """Detect project languages and build targets; write them as step outputs.""" skip_detection = ( os.environ.get("SKIP_LANGUAGE_DETECTION", "false").lower() == "true" ) @@ -219,6 +220,7 @@ def detect_languages(_: argparse.Namespace) -> None: def release_strategy(_: argparse.Namespace) -> None: + """Decide the release strategy and prerelease/draft flags from the branch.""" branch = os.environ.get("BRANCH_NAME", "") input_prerelease = ( os.environ.get("INPUT_PRERELEASE", "false").lower() == "true" @@ -252,7 +254,7 @@ def _run_git( args: list[str], check: bool = False ) -> subprocess.CompletedProcess[str]: return subprocess.run( - ["git"] + args, check=check, capture_output=True, text=True + ["git", *args], check=check, capture_output=True, text=True ) @@ -301,6 +303,7 @@ def _latest_tag_from_git() -> str: def generate_version(_: argparse.Namespace) -> None: + """Compute the next release tag and write it as the ``tag`` output.""" release_type = os.environ.get("RELEASE_TYPE", "auto").lower() branch_name = os.environ.get("BRANCH_NAME", "") auto_prerelease = ( @@ -430,6 +433,7 @@ def _cap_commit_lines(commits: list[str], budget: int) -> list[str]: def generate_changelog(_: argparse.Namespace) -> None: + """Write the release notes file (``changelog_file``) and inline copy.""" branch = os.environ.get("BRANCH_NAME", "") primary_language = os.environ.get("PRIMARY_LANGUAGE", "unknown") strategy = os.environ.get("RELEASE_STRATEGY", "stable") @@ -483,7 +487,7 @@ def generate_changelog(_: argparse.Namespace) -> None: header = "### πŸ“‹ Initial Release Commits:\n" commits = _run_git( - ["log"] + log_args + ["--pretty=%s (%h)"] + ["log", *log_args, "--pretty=%s (%h)"] ).stdout.splitlines() commits = [entry for entry in commits if entry.strip()] @@ -534,6 +538,7 @@ def generate_changelog(_: argparse.Namespace) -> None: def build_parser() -> argparse.ArgumentParser: + """Build the CLI parser with one subcommand per release step.""" parser = argparse.ArgumentParser( description="Release workflow helper commands." ) @@ -552,6 +557,7 @@ def build_parser() -> argparse.ArgumentParser: def main() -> None: + """Run the subcommand named on the command line.""" parser = build_parser() args = parser.parse_args() handler = getattr(args, "handler", None) diff --git a/ruff.toml b/ruff.toml index 2741c651..b7aff3d3 100644 --- a/ruff.toml +++ b/ruff.toml @@ -1,7 +1,7 @@ # file: ruff.toml -# version: 1.3.0 +# version: 1.4.0 # guid: f1a2b3c4-d5e6-789f-a012-456789abcdef -# last-edited: 2026-10-05 +# last-edited: 2026-10-16 # Ruff configuration following Google Python Style Guide # https://google.github.io/styleguide/pyguide.html @@ -77,6 +77,9 @@ ignore = [ # same status emoji the tests assert on (RUF001), and their only "magic" # numbers are argv-length and semver-part counts (PLR2004). ".github/scripts/**/*.py" = ["T20", "S602", "S603", "S607", "RUF001", "PLR2004"] +# Same CLIs, in the directory the reusable workflows check out as +# GHCOMMON_SCRIPTS_DIR. Keep this list identical to the one above. +".github/workflows/scripts/**/*.py" = ["T20", "S602", "S603", "S607", "RUF001", "PLR2004"] "**/migrations/**/*.py" = ["D100", "D103"] [lint.isort]