Merge pull request #125 from jgoguen/gha-release-fix

feat(gha): Auto-publish releases
This commit is contained in:
jpirnay
2026-04-25 17:46:57 +02:00
committed by GitHub
7 changed files with 423 additions and 47 deletions
+6
View File
@@ -0,0 +1,6 @@
{
"ref": "refs/heads/release/1.2",
"repository": {
"full_name": "jgoguen/crosspoint-reader"
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"action": "published",
"release": {
"tag_name": "1.38"
},
"repository": {
"full_name": "jgoguen/crosspoint-reader"
}
}
+15 -39
View File
@@ -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
+32 -8
View File
@@ -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
@@ -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
+93
View File
@@ -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
+267
View File
@@ -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<type>[A-Za-z]+)(?:\([^)]*\))?(?P<breaking>!)?:\s+(?P<description>.+)$"
)
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())