diff --git a/.github/act/release-candidate-push.json b/.github/act/release-candidate-push.json new file mode 100644 index 00000000..f15a9b43 --- /dev/null +++ b/.github/act/release-candidate-push.json @@ -0,0 +1,6 @@ +{ + "ref": "refs/heads/release/1.2", + "repository": { + "full_name": "jgoguen/crosspoint-reader" + } +} diff --git a/.github/act/release-published.json b/.github/act/release-published.json new file mode 100644 index 00000000..d709b2d0 --- /dev/null +++ b/.github/act/release-published.json @@ -0,0 +1,9 @@ +{ + "action": "published", + "release": { + "tag_name": "1.38" + }, + "repository": { + "full_name": "jgoguen/crosspoint-reader" + } +} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5dc3ab5f..26d416ce 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,6 +3,9 @@ on: release: types: [published] +permissions: + contents: write + jobs: build-release: runs-on: ubuntu-latest @@ -14,7 +17,7 @@ jobs: - uses: actions/setup-python@v6 with: - python-version: '3.14' + python-version: "3.14" - name: Install uv uses: astral-sh/setup-uv@v7 @@ -31,42 +34,15 @@ jobs: - name: Patch min_chip_rev_full to 0 run: python3 scripts/patch_min_chip_rev.py .pio/build/gh_release/firmware.bin - - name: Upload bootloader.bin to Release - uses: actions/upload-release-asset@v1 + - name: Upload release assets + if: ${{ env.ACT != 'true' }} + uses: softprops/action-gh-release@v2 with: - upload_url: ${{ github.event.release.upload_url }} - asset_path: .pio/build/gh_release/bootloader.bin - asset_name: bootloader.bin - asset_content_type: application/octet-stream - - - name: Upload firmware.bin to Release - uses: actions/upload-release-asset@v1 - with: - upload_url: ${{ github.event.release.upload_url }} - asset_path: .pio/build/gh_release/firmware.bin - asset_name: firmware.bin - asset_content_type: application/octet-stream - - - name: Upload firmware.elf to Release - uses: actions/upload-release-asset@v1 - with: - upload_url: ${{ github.event.release.upload_url }} - asset_path: .pio/build/gh_release/firmware.elf - asset_name: firmware.elf - asset_content_type: application/octet-stream - - - name: Upload firmware.map to Release - uses: actions/upload-release-asset@v1 - with: - upload_url: ${{ github.event.release.upload_url }} - asset_path: .pio/build/gh_release/firmware.map - asset_name: firmware.map - asset_content_type: application/octet-stream - - - name: Upload partitions.bin to Release - uses: actions/upload-release-asset@v1 - with: - upload_url: ${{ github.event.release.upload_url }} - asset_path: .pio/build/gh_release/partitions.bin - asset_name: partitions.bin - asset_content_type: application/octet-stream + tag_name: ${{ github.event.release.tag_name }} + files: | + .pio/build/gh_release/bootloader.bin + .pio/build/gh_release/firmware.bin + .pio/build/gh_release/firmware.elf + .pio/build/gh_release/firmware.map + .pio/build/gh_release/partitions.bin + fail_on_unmatched_files: true diff --git a/.github/workflows/release_candidate.yml b/.github/workflows/release_candidate.yml index 2b333524..eb663608 100644 --- a/.github/workflows/release_candidate.yml +++ b/.github/workflows/release_candidate.yml @@ -1,20 +1,25 @@ -name: Compile Release Candidate +name: Publish Release Candidate on: - workflow_dispatch: + push: + branches: + - "release/**" + +permissions: + contents: write jobs: build-release-candidate: - if: startsWith(github.ref_name, 'release/') runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 with: submodules: recursive + fetch-depth: 0 - uses: actions/setup-python@v6 with: - python-version: '3.14' + python-version: "3.14" - name: Install uv uses: astral-sh/setup-uv@v7 @@ -29,6 +34,8 @@ jobs: run: | echo "SHORT_SHA=${GITHUB_SHA::7}" >> $GITHUB_ENV echo "BRANCH_SUFFIX=${GITHUB_REF_NAME#release/}" >> $GITHUB_ENV + echo "RC_TAG=${GITHUB_REF_NAME#release/}-rc.${GITHUB_RUN_NUMBER}.${GITHUB_RUN_ATTEMPT}" >> $GITHUB_ENV + echo "RELEASE_NOTES_PATH=${RUNNER_TEMP}/release-notes.md" >> $GITHUB_ENV - name: Build CrossPoint Release Candidate env: @@ -38,13 +45,30 @@ jobs: - name: Patch min_chip_rev_full to 0 run: python3 scripts/patch_min_chip_rev.py .pio/build/gh_release_rc/firmware.bin - - name: Upload Artifacts - uses: actions/upload-artifact@v4 + - name: Generate release notes + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + python3 scripts/generate_release_notes.py \ + --output "${RELEASE_NOTES_PATH}" \ + --target-sha "${GITHUB_SHA}" \ + --current-label "${RC_TAG}" \ + --release-scope any \ + --exclude-tag "${RC_TAG}" + + - name: Publish prerelease assets + if: ${{ env.ACT != 'true' }} + uses: softprops/action-gh-release@v2 with: - name: CrossPoint-RC-${{ env.BRANCH_SUFFIX }} - path: | + tag_name: ${{ env.RC_TAG }} + target_commitish: ${{ github.sha }} + name: CrossPoint ${{ env.RC_TAG }} + body_path: ${{ env.RELEASE_NOTES_PATH }} + prerelease: true + files: | .pio/build/gh_release_rc/bootloader.bin .pio/build/gh_release_rc/firmware.bin .pio/build/gh_release_rc/firmware.elf .pio/build/gh_release_rc/firmware.map .pio/build/gh_release_rc/partitions.bin + fail_on_unmatched_files: true diff --git a/docs/contributing/development-workflow.md b/docs/contributing/development-workflow.md index ab7d64f8..0423c09d 100644 --- a/docs/contributing/development-workflow.md +++ b/docs/contributing/development-workflow.md @@ -27,6 +27,7 @@ pio run CI enforces formatting, static analysis, and build checks. Use clang-format 21+ locally to match CI. If `clang-format` is missing or too old locally, see [Getting Started](./getting-started.md). +If your change affects release automation, also dry-run the release workflows locally with `act` before opening the PR. See [Testing and Debugging](./testing-debugging.md#test-release-workflows-locally-with-act). ## 4) Open the PR diff --git a/docs/contributing/testing-debugging.md b/docs/contributing/testing-debugging.md index 180e939f..d4ebf282 100644 --- a/docs/contributing/testing-debugging.md +++ b/docs/contributing/testing-debugging.md @@ -34,6 +34,99 @@ python3 -m pip install pyserial colorama matplotlib python3 scripts/debugging_monitor.py ``` +## Test release workflows locally with `act` + +Use [`act`](https://github.com/nektos/act) to dry-run the release workflows +after modifying them. Running locally with `act` is faster and more iterative +than pushing commits to GitHub and waiting for Actions to run. It will test the +entire workflow, including job conditions and release-notes generation, without +actually publishing releases or uploading assets. + +For this repository, local `act` runs simulate different GitHub event types for +the two release workflows: + +- `release.yml` is exercised with a simulated `release` event whose action is + `published` +- `release_candidate.yml` is exercised with a simulated `push` event on + a `release/**` branch + +### What local `act` runs validate + +Local runs are useful for validating: + +- workflow wiring and job conditions +- PlatformIO release builds +- release-notes generation via `scripts/generate_release_notes.py` + +Local `act` runs do **not** publish GitHub releases or upload release assets. +Those steps are skipped when `ACT=true`, so final release publication still +requires a real GitHub Actions run. + +### Prerequisites + +- `act` installed locally +- Docker available and running + - Podman will most likely also work. Ensure the rootless user socket is + configured and set `DOCKER_HOST` to its path (e.g. + `export DOCKER_HOST=unix:///run/user/1000/podman/podman.sock`). +- `gh` CLI authenticated (`gh auth status`) +- event payload files in `.github/act/` + +Included payload files: + +- `.github/act/release-published.json` to simulate the `release.published` event + used by `release.yml` +- `.github/act/release-candidate-push.json` to simulate the branch-push event + used by `release_candidate.yml` + +### Provide `GITHUB_TOKEN` safely + +Release-notes generation expects `GITHUB_TOKEN`. Prefer exporting it from `gh` +instead of pasting a token directly into shell history: + +```sh +export GITHUB_TOKEN="$(gh auth token)" +``` + +Then pass it to `act` by name: + +```sh +act ... -s GITHUB_TOKEN +``` + +Unset it when finished: + +```sh +unset GITHUB_TOKEN +``` + +### Run the stable release workflow locally + +```sh +act release \ + -W .github/workflows/release.yml \ + -e .github/act/release-published.json \ + -s GITHUB_TOKEN +``` + +### Run the release-candidate workflow locally + +```sh +act push \ + -W .github/workflows/release_candidate.yml \ + -e .github/act/release-candidate-push.json \ + -s GITHUB_TOKEN +``` + +### What still needs a real GitHub run + +After a local `act` pass, a real GitHub Actions run is still required to verify: + +- GitHub release creation +- asset upload +- workflow permissions and repository-token behavior +- the exact GitHub-hosted runner environment + ## Useful bug report contents - Firmware version and build environment diff --git a/scripts/generate_release_notes.py b/scripts/generate_release_notes.py new file mode 100644 index 00000000..72fc11a3 --- /dev/null +++ b/scripts/generate_release_notes.py @@ -0,0 +1,267 @@ +#!/usr/bin/env python3 +"""Generate release notes from semantic commit messages. + +This script finds the most recent qualifying GitHub release, resolves its tag to +the underlying commit SHA, and builds release notes from semantic commits since +that commit. If no qualifying release is found, it falls back to the most recent +100 non-merge commits reachable from the target SHA. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +import sys +import urllib.error +import urllib.parse +import urllib.request + +REQUEST_TIMEOUT = 15 +USER_FACING_TYPES = {"docs", "feat", "fix", "perf"} +BACKEND_TYPES = {"build", "chore", "ci", "refactor", "test"} +DEFAULT_FALLBACK_LIMIT = 100 +CONVENTIONAL_RE = re.compile( + r"^(?P[A-Za-z]+)(?:\([^)]*\))?(?P!)?:\s+(?P.+)$" +) + + +def run_git(args: list[str], check: bool = True) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["git", *args], + capture_output=True, + text=True, + check=check, + ) + + +def resolve_tag_to_sha(tag_name: str) -> str | None: + result = run_git(["rev-list", "-n", "1", tag_name], check=False) + if result.returncode != 0: + return None + sha = result.stdout.strip() + return sha or None + + +def git_commits_since( + base_sha: str | None, target_sha: str, limit: int +) -> list[tuple[str, str]]: + format_arg = "%H%x1f%s" + cmd = ["log", "--reverse", "--no-merges", f"--format={format_arg}"] + if base_sha: + cmd.append(f"{base_sha}..{target_sha}") + else: + cmd.extend([f"-{limit}", target_sha]) + + result = run_git(cmd) + commits: list[tuple[str, str]] = [] + for line in result.stdout.splitlines(): + if not line.strip(): + continue + sha, subject = line.split("\x1f", 1) + commits.append((sha.strip(), subject.strip())) + return commits + + +def github_request(url: str, token: str) -> tuple[list[dict], str | None]: + request = urllib.request.Request( + url, + headers={ + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {token}", + "User-Agent": "crosspoint-release-notes-generator", + }, + ) + with urllib.request.urlopen(request, timeout=REQUEST_TIMEOUT) as response: + payload = json.loads(response.read().decode("utf-8")) + return payload, response.headers.get("Link") + + +def parse_next_link(link_header: str | None) -> str | None: + if not link_header: + return None + for part in link_header.split(","): + section = part.strip() + if 'rel="next"' not in section: + continue + start = section.find("<") + end = section.find(">") + if start == -1 or end == -1 or end <= start + 1: + continue + return section[start + 1 : end] + return None + + +def iter_releases(repo: str, token: str): + url = f"https://api.github.com/repos/{repo}/releases?per_page=100" + while url: + payload, link_header = github_request(url, token) + for release in payload: + yield release + url = parse_next_link(link_header) + + +def select_previous_release( + repo: str, + token: str, + include_prereleases: bool, + exclude_tag: str | None, +) -> dict | None: + for release in iter_releases(repo, token): + if release.get("draft"): + continue + if not include_prereleases and release.get("prerelease"): + continue + tag_name = release.get("tag_name") + if not tag_name: + continue + if exclude_tag and tag_name == exclude_tag: + continue + return release + return None + + +def categorize_commit(subject: str) -> tuple[str, str]: + match = CONVENTIONAL_RE.match(subject) + if not match: + return "other", subject + + commit_type = match.group("type").lower() + description = match.group("description") + if match.group("breaking"): + description = f"BREAKING: {description}" + + if commit_type in USER_FACING_TYPES: + return "user", description + if commit_type in BACKEND_TYPES: + return "backend", description + return "other", subject + + +def render_section(title: str, items: list[str]) -> list[str]: + lines = [f"## {title}", ""] + if items: + lines.extend(items) + else: + lines.append("- None.") + lines.append("") + return lines + + +def build_release_notes( + target_sha: str, + current_label: str, + previous_release: dict | None, + commits: list[tuple[str, str]], + fallback_limit: int, +) -> str: + lines = [f"# CrossPoint {current_label}", ""] + + if previous_release: + prev_tag = previous_release["tag_name"] + prev_sha = previous_release.get("resolved_sha") + lines.append( + f"Changes since `{prev_tag}` (`{prev_sha[:7]}`) through `{target_sha[:7]}`." + ) + else: + lines.append( + f"No qualifying previous release was found; showing up to the last {fallback_limit} commits through `{target_sha[:7]}`." + ) + lines.append("") + + user_items: list[str] = [] + backend_items: list[str] = [] + other_items: list[str] = [] + + for sha, subject in commits: + category, description = categorize_commit(subject) + item = f"- {description} (`{sha[:7]}`)" + if category == "user": + user_items.append(item) + elif category == "backend": + backend_items.append(item) + else: + other_items.append(item) + + lines.extend(render_section("User-Facing Changes", user_items)) + lines.extend(render_section("Backend Changes", backend_items)) + lines.extend(render_section("Other Changes", other_items)) + return "\n".join(lines).rstrip() + "\n" + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--output", required=True) + parser.add_argument("--target-sha", required=True) + parser.add_argument("--current-label", required=True) + parser.add_argument( + "--release-scope", + choices=("full", "any"), + required=True, + help="Use 'full' for stable releases only or 'any' for any published release type.", + ) + parser.add_argument("--exclude-tag") + parser.add_argument("--fallback-limit", type=int, default=DEFAULT_FALLBACK_LIMIT) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + repo = os.environ.get("GITHUB_REPOSITORY") + token = os.environ.get("GITHUB_TOKEN") + + if not repo: + print("[error] GITHUB_REPOSITORY is required", file=sys.stderr) + return 1 + if not token: + print("[error] GITHUB_TOKEN is required", file=sys.stderr) + return 1 + + include_prereleases = args.release_scope == "any" + previous_release = None + base_sha = None + + try: + previous_release = select_previous_release( + repo=repo, + token=token, + include_prereleases=include_prereleases, + exclude_tag=args.exclude_tag, + ) + except urllib.error.HTTPError as err: + print( + f"[warn] Failed to query GitHub releases: HTTP {err.code}", file=sys.stderr + ) + except urllib.error.URLError as err: + print(f"[warn] Failed to query GitHub releases: {err.reason}", file=sys.stderr) + + if previous_release: + previous_tag = previous_release["tag_name"] + base_sha = resolve_tag_to_sha(previous_tag) + if base_sha: + previous_release["resolved_sha"] = base_sha + else: + print( + f"[warn] Could not resolve previous release tag '{previous_tag}' locally; falling back to recent commits.", + file=sys.stderr, + ) + previous_release = None + + commits = git_commits_since(base_sha, args.target_sha, args.fallback_limit) + notes = build_release_notes( + target_sha=args.target_sha, + current_label=args.current_label, + previous_release=previous_release, + commits=commits, + fallback_limit=args.fallback_limit, + ) + + with open(args.output, "w", encoding="utf-8") as handle: + handle.write(notes) + return 0 + + +if __name__ == "__main__": + sys.exit(main())