From e894a3a43afd4327620a53775bf391818a6f96c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 12:13:25 +0000 Subject: [PATCH] Add the release pipeline, matching the other PolyKybd repos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fork had no way to publish a release: build.yml is compile-only (its own comment says packaging is out of scope) and uploads the portable zip as a workflow artifact, so nothing ever produced WinCompose-Setup-.exe. That is the asset PolyKybdHost's new "Install WinCompose…" tray entry looks for, so the entry can only fall back to the releases page today. This mirrors the firmware/host mechanics exactly: a release is created by PUBLISHING it, which fires `release: published`; CI then applies the crafted notes from the `release-notes` branch and attaches the built assets. - .github/workflows/release.yml — builds on windows-latest: InsertIcons, the Release build, the (dormant) signing step, then the installer via installer.csproj, which shells out to iscc — hence `choco install innosetup`, since that target hardcodes "%ProgramFiles(x86)%\Inno Setup 6\iscc". Packages the portable layout the same way build.yml does, writes a SHA256SUMS.txt covering BOTH assets (the docs tell users to verify against it), and uploads everything as artifacts as well, so workflow_dispatch is a safe smoke test that touches no release. - scripts/publish_release.py — the shared script with a wincompose branch added to detect()/parse_version(): version from the csproj AssemblyVersion, tag prefix PK- (GitVersion.yml), target branch main. - src/Makefile — its `all:` target printed download URLs pointing at ell1010/wincompose (inherited from the fork it was based on) and used a `v` tag prefix; both now match this repo. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011PNCWo57UmMMfaNWyfJMBG --- .github/workflows/release.yml | 212 ++++++++++++++++++++++++++++ scripts/publish_release.py | 259 ++++++++++++++++++++++++++++++++++ src/Makefile | 4 +- 3 files changed, 473 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 scripts/publish_release.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..626c942d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,212 @@ +name: Release + +# Same shape as the other PolyKybd repos: a release is created by PUBLISHING it +# (scripts/publish_release.py, or the GitHub UI), which fires `release: +# published`; this workflow then applies the crafted notes and attaches the +# built assets. The tag-push trigger is kept for a tag pushed by hand. +on: + push: + tags: + - 'PK-*' + release: + types: [published] + # Build the assets without releasing, to check this workflow end to end. + workflow_dispatch: {} + +concurrency: + group: release-${{ github.event.release.tag_name || github.ref_name }} + cancel-in-progress: false + +permissions: + contents: write + +jobs: + release: + name: Build and release WinCompose + runs-on: windows-latest + + # Mapped to env because a step's `if` cannot read the secrets context. While + # these are unset the signing step is skipped and builds stay green unsigned + # (same dormant wiring as build.yml). + env: + SIGNING_CLIENT_ID: ${{ secrets.AZURE_SIGNING_CLIENT_ID }} + SIGNING_TENANT_ID: ${{ secrets.AZURE_SIGNING_TENANT_ID }} + SIGNING_CLIENT_SECRET: ${{ secrets.AZURE_SIGNING_CLIENT_SECRET }} + SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }} + SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }} + SIGNING_PROFILE: ${{ secrets.AZURE_SIGNING_PROFILE }} + + steps: + - uses: actions/checkout@v4 + with: + # installer.iss pulls its wizard resources from the issrc submodule + # (INNODIR ../3rdparty/innosetup/Files), and the build embeds data + # files from the other 3rdparty submodules — so recursive, and full + # history for GitVersion (see build.yml for the LibGit2Sharp crash). + submodules: recursive + fetch-depth: 0 + + - name: Add MSBuild to PATH + uses: microsoft/setup-msbuild@v2 + + # src/installer/installer.csproj's Build target shells out to + # "%ProgramFiles(x86)%\Inno Setup 6\iscc", so Inno Setup has to exist at + # exactly that path — which is where choco puts it. + - name: Install Inno Setup 6 + run: choco install innosetup --version=6.2.2 -y --no-progress + + # wincompose.csproj has an InsertIcons target that runs this tool after the + # build; building the project on its own does not build it. Same pinning as + # build.yml (old-style project defines no OutputPath for x86). + - name: Build InsertIcons + run: msbuild src\3rdparty\inserticons\inserticons.csproj -restore -p:Configuration=Release -p:Platform=AnyCPU -p:OutputPath=bin/Release/ -v:minimal + + - name: Build (Release) + run: msbuild src\wincompose\wincompose.csproj -restore -p:Configuration=Release -v:minimal + + # Before packaging, so both the installer payload and the portable zip + # carry signed binaries and the published checksums cover the signed bytes. + - name: Sign binaries + if: env.SIGNING_CLIENT_ID != '' + uses: azure/trusted-signing-action@v0 + with: + azure-tenant-id: ${{ env.SIGNING_TENANT_ID }} + azure-client-id: ${{ env.SIGNING_CLIENT_ID }} + azure-client-secret: ${{ env.SIGNING_CLIENT_SECRET }} + endpoint: ${{ env.SIGNING_ENDPOINT }} + trusted-signing-account-name: ${{ env.SIGNING_ACCOUNT }} + certificate-profile-name: ${{ env.SIGNING_PROFILE }} + files-folder: src/wincompose/bin/Release + files-folder-filter: exe,dll + file-digest: SHA256 + timestamp-rfc3161: http://timestamp.acs.microsoft.com + timestamp-digest: SHA256 + + # iscc derives the version from the built wincompose.exe's version + # resource (GetVersionComponents) and writes WinCompose-Setup-.exe to + # OutputDir `..\` — i.e. src\. + - name: Build installer + run: msbuild src\installer\installer.csproj -p:Configuration=Release -v:minimal + + # The raw bin directory is NOT runnable as a portable install: Utils + # decides it is "debugging" when a .pdb sits next to the exe, which sends + # the rule lookup to ../../../rules and loses Emoji.txt/WinCompose.txt. + # Mirror what src/Makefile assembles for the portable zip instead. + # (Kept in step with build.yml — change both together.) + - name: Package portable + shell: pwsh + run: | + $tfm = @(Get-ChildItem 'src/wincompose/bin/Release' -Directory) + if ($tfm.Count -ne 1) { throw "expected one TFM directory under bin/Release, found $($tfm.Count)" } + $bin = $tfm[0].FullName + $out = 'portable' + New-Item -ItemType Directory -Path $out | Out-Null + Copy-Item "$bin/*" $out -Recurse + Get-ChildItem $out -Recurse -Include *.pdb | Remove-Item -Force + New-Item -ItemType Directory -Path "$out/rules" | Out-Null + Copy-Item 'src/wincompose/rules/DefaultUserSequences.txt', ` + 'src/wincompose/rules/Emoji.txt', ` + 'src/wincompose/rules/WinCompose.txt' "$out/rules/" + # These are the only way back into the UI once the tray icon is + # hidden, so the portable layout ships them like the Makefile does. + 'start wincompose.exe -settings' | Set-Content "$out/wincompose-settings.bat" + 'start wincompose.exe -sequences' | Set-Content "$out/wincompose-sequences.bat" + if (Test-Path "$out/wincompose.pdb") { throw 'pdb survived' } + if (-not (Test-Path "$out/rules/Emoji.txt")) { throw 'rules missing' } + + # Until the builds are signed, a published checksum is the only way for + # someone to confirm a download matches what CI produced — the docs tell + # users to check against SHA256SUMS.txt, so it covers BOTH assets. + - name: Assemble release assets + id: assets + shell: pwsh + run: | + $ver = (Get-Item 'portable/wincompose.exe').VersionInfo.FileVersion + # FileVersion is 4-part (0.9.16.0); the asset names use MAJOR.MINOR.REV, + # matching iscc's VERSION and src/Makefile. + $ver = ($ver -split '\.')[0..2] -join '.' + $setup = "src/WinCompose-Setup-$ver.exe" + if (-not (Test-Path $setup)) { throw "installer not found at $setup" } + New-Item -ItemType Directory -Path 'release' | Out-Null + $zip = "WinCompose-NoInstall-$ver.zip" + Compress-Archive -Path 'portable/*' -DestinationPath "release/$zip" + Copy-Item $setup 'release/' + # Hash our own files, not the artifact zip whose bytes GitHub controls. + Get-ChildItem 'release' -File | Sort-Object Name | ForEach-Object { + "$((Get-FileHash $_.FullName -Algorithm SHA256).Hash.ToLower()) $($_.Name)" + } | Set-Content 'release/SHA256SUMS.txt' -Encoding ascii + Get-Content 'release/SHA256SUMS.txt' + "version=$ver" >> $env:GITHUB_OUTPUT + + # Always upload, so a workflow_dispatch smoke test yields inspectable + # assets without touching any release. + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: wincompose-release-assets + path: release/ + if-no-files-found: error + + # Crafted release notes (optional): one file per tag on the unprotected + # `release-notes` branch — .md, first line "# ", the rest the + # body. Files are keyed by tag, so they are never overwritten or deleted — + # the branch is an accumulating changelog archive. A missing file just + # falls back to --generate-notes, so this step is safe if unused. + - name: Fetch crafted release notes (if any) + id: notes + if: github.event_name == 'release' || github.ref_type == 'tag' + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ github.event.release.tag_name || github.ref_name }} + run: | + if gh api "repos/${{ github.repository }}/contents/${TAG}.md?ref=release-notes" \ + --jq '.content' 2>/dev/null | base64 -d > _release_notes.md && [ -s _release_notes.md ]; then + TITLE=$(head -n1 _release_notes.md | sed 's/^#\s*//') + tail -n +2 _release_notes.md | sed '/./,$!d' > _release_body.md + { + echo "have_notes=1" + echo "title=$TITLE" + } >> "$GITHUB_OUTPUT" + echo "Using crafted notes for $TAG (title: $TITLE)." + else + echo "have_notes=0" >> "$GITHUB_OUTPUT" + echo "No crafted notes on the release-notes branch for $TAG — auto-generating." + fi + + # Idempotent: creates the release if missing, otherwise (re-)uploads the + # assets with --clobber; a create that loses a race falls back to upload. + # Guarded so a workflow_dispatch from a branch builds as a smoke test only. + - name: Create or update GitHub Release + if: github.event_name == 'release' || github.ref_type == 'tag' + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ github.event.release.tag_name || github.ref_name }} + HAVE_NOTES: ${{ steps.notes.outputs.have_notes }} + TITLE: ${{ steps.notes.outputs.title }} + VERSION: ${{ steps.assets.outputs.version }} + run: | + ASSETS=( release/WinCompose-Setup-${VERSION}.exe \ + release/WinCompose-NoInstall-${VERSION}.zip \ + release/SHA256SUMS.txt ) + if [ "$HAVE_NOTES" = "1" ]; then + NOTES_ARGS=( --title "$TITLE" --notes-file _release_body.md ) + else + NOTES_ARGS=( --title "WinCompose $TAG" --generate-notes ) + fi + if gh release view "$TAG" >/dev/null 2>&1; then + # The normal path: the release was published (UI or + # scripts/publish_release.py) and this run attaches the assets. + if [ "$HAVE_NOTES" = "1" ]; then + echo "Release $TAG exists — applying crafted notes + uploading assets." + gh release edit "$TAG" --title "$TITLE" --notes-file _release_body.md + else + echo "Release $TAG exists — uploading assets (keeping existing notes)." + fi + gh release upload "$TAG" "${ASSETS[@]}" --clobber + else + echo "Creating release $TAG (falls back to upload if a concurrent run won the create)." + gh release create "$TAG" "${NOTES_ARGS[@]}" "${ASSETS[@]}" \ + || gh release upload "$TAG" "${ASSETS[@]}" --clobber + fi diff --git a/scripts/publish_release.py b/scripts/publish_release.py new file mode 100644 index 00000000..78234014 --- /dev/null +++ b/scripts/publish_release.py @@ -0,0 +1,259 @@ +#!/usr/bin/env python3 +"""Publish the prepared PolyKybd release with one OS-independent command. + +Run from anywhere inside either repo checkout: + + python scripts/publish_release.py # publish + python scripts/publish_release.py --dry-run # show what it would do + +What it does (no bash-isms, no extra pip installs — Python 3.7+ stdlib only): + 1. Auto-detects the repo (firmware qmk_firmware / host PolyKybdHost / + wincompose) and the current version from the DEFAULT branch (config.h / + polyhost/_version.py / wincompose.csproj), so it is independent of + whatever branch you have checked out. + 2. Reads the prepared release notes for that tag from the unprotected + `release-notes` branch (`<TAG>.md`, first line `# <title>`, rest = body). + 3. Creates + publishes the GitHub Release (or updates it if it already exists). + Firmware and wincompose: publishing fires the `release: published` + workflow, which builds and attaches the assets (.bin/.uf2 / the installer + + portable zip + SHA256SUMS) — you do NOT attach anything by hand. + +Auth: uses `GH_TOKEN` / `GITHUB_TOKEN` if set, else `gh auth token`. No token and +no `gh` -> it tells you how to fix it. `gh` is optional; a token alone is enough. + +Tags: + firmware PolyKybd-fw-v<version> (target branch: PolyKybd) + host v<version> (target branch: main) + wincompose PK-<version> (target branch: main) +""" +import argparse +import json +import os +import re +import subprocess +import sys +import urllib.error +import urllib.request + + +def run(cmd): + # Force UTF-8: git output (release notes) is UTF-8, but on Windows the + # default is the locale codec (cp1252), which raises UnicodeDecodeError on + # emoji/em-dashes and silently drops the output. + return subprocess.run(cmd, capture_output=True, text=True, + encoding="utf-8", errors="replace") + + +def die(msg): + sys.exit("publish_release: " + msg) + + +def repo_root(): + r = run(["git", "rev-parse", "--show-toplevel"]) + if r.returncode: + die("not inside a git repository.") + return r.stdout.strip() + + +def detect(root): + """Return (kind, version_path, default_branch, tag_prefix).""" + if os.path.exists(os.path.join(root, "keyboards", "polykybd", "config.h")): + return ("firmware", "keyboards/polykybd/config.h", "PolyKybd", "PolyKybd-fw-v") + if os.path.exists(os.path.join(root, "polyhost", "_version.py")): + return ("host", "polyhost/_version.py", "main", "v") + # The fork tags PK-<version> (GitVersion.yml tag-prefix), and the shipped + # version is the csproj AssemblyVersion that iscc reads off the built exe. + if os.path.exists(os.path.join(root, "src", "wincompose", "wincompose.csproj")): + return ("wincompose", "src/wincompose/wincompose.csproj", "main", "PK-") + die("can't tell which repo this is (no keyboards/polykybd/config.h, " + "polyhost/_version.py or src/wincompose/wincompose.csproj).") + + +def show(ref_path): + """`git show <ref>:<path>` -> text, or None if absent.""" + r = run(["git", "show", ref_path]) + return r.stdout if r.returncode == 0 else None + + +def parse_version(kind, text): + if kind == "firmware": + m = re.search(r'#define\s+FW_VERSION\s+"(\d+\.\d+\.\d+)"', text) + if not m: + die("couldn't find FW_VERSION in config.h.") + return m.group(1) + if kind == "wincompose": + m = re.search(r'<AssemblyVersion>(\d+)\.(\d+)\.(\d+)', text) + if not m: + die("couldn't find <AssemblyVersion> in wincompose.csproj.") + return f"{m.group(1)}.{m.group(2)}.{m.group(3)}" + maj = re.search(r'__major__\s*=\s*(\d+)', text) + mnr = re.search(r'__minor__\s*=\s*(\d+)', text) + pat = re.search(r'__patch__\s*=\s*(\d+)', text) + if not (maj and mnr and pat): + die("couldn't parse __major__/__minor__/__patch__ from _version.py.") + return f"{maj.group(1)}.{mnr.group(1)}.{pat.group(1)}" + + +def owner_repo(root): + r = run(["git", "remote", "get-url", "origin"]) + m = re.search(r"[:/]([^/]+)/([^/]+?)(?:\.git)?/?$", r.stdout.strip()) + if not m: + die(f"couldn't parse owner/repo from origin remote: {r.stdout.strip()!r}") + return m.group(1), m.group(2) + + +def get_token(): + for var in ("GH_TOKEN", "GITHUB_TOKEN"): + if os.environ.get(var): + return os.environ[var] + r = run(["gh", "auth", "token"]) + if r.returncode == 0 and r.stdout.strip(): + return r.stdout.strip() + return None + + +def api(token, method, path, payload=None): + url = "https://api.github.com" + path + data = json.dumps(payload).encode() if payload is not None else None + req = urllib.request.Request(url, data=data, method=method, headers={ + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "Content-Type": "application/json", + "User-Agent": "polykybd-publish-release", + }) + try: + with urllib.request.urlopen(req) as resp: + return resp.status, json.load(resp) + except urllib.error.HTTPError as e: + try: + return e.code, json.load(e) + except Exception: + return e.code, {"message": e.read().decode(errors="replace")} + + +def prepared_tags(prefix): + """All prepared <prefix><X.Y.Z>.md files on the release-notes branch, + as (version_tuple, tag) sorted ascending.""" + r = run(["git", "ls-tree", "--name-only", "origin/release-notes"]) + out = [] + for name in r.stdout.splitlines(): + if not (name.startswith(prefix) and name.endswith(".md")): + continue + m = re.fullmatch(r"(\d+)\.(\d+)\.(\d+)", name[len(prefix):-3]) + if m: + out.append(((int(m[1]), int(m[2]), int(m[3])), name[:-3])) + out.sort() + return out + + +def main(): + ap = argparse.ArgumentParser(description="Publish the prepared PolyKybd release.") + ap.add_argument("--dry-run", action="store_true", help="show what would happen, change nothing") + ap.add_argument("--tag", help="publish a specific prepared tag instead of the newest one") + args = ap.parse_args() + + # Print UTF-8 (emoji in the notes) even on a cp1252 Windows console. + for stream in (sys.stdout, sys.stderr): + try: + stream.reconfigure(encoding="utf-8", errors="replace") + except Exception: + pass + + root = repo_root() + kind, vpath, default_branch, tag_prefix = detect(root) + + # Fetch the refs we read from (best-effort; fall back to whatever is local). + run(["git", "fetch", "origin", default_branch, "release-notes"]) + + # The release-notes branch is the source of truth for *what is ready to + # publish* — NOT the tree version, which drifts forward on every PR merge + # (each merge auto-bumps the version, so the tree is usually ahead of the + # prepared release by the time you publish). Pick the newest prepared tag. + if args.tag: + tag = args.tag + notes = show(f"origin/release-notes:{tag}.md") + if not notes or not notes.strip(): + die(f"no prepared notes for {tag} on the release-notes branch " + f"(expected release-notes:{tag}.md).") + else: + prepared = prepared_tags(tag_prefix) + if not prepared: + die("no prepared release notes on the release-notes branch " + f"(no {tag_prefix}<X.Y.Z>.md files).\n" + " Draft + stage them first with the polykybd-github-release skill.") + tag = prepared[-1][1] + notes = show(f"origin/release-notes:{tag}.md") + if len(prepared) > 1: + others = ", ".join(t for _, t in prepared[:-1]) + print(f"note: newest prepared tag is {tag}. Others on the branch: {others}") + print(f" (use --tag to publish a specific one.)") + + # Informational: warn if the tree has already bumped past this tag. + vtext = show(f"origin/{default_branch}:{vpath}") + if vtext: + try: + tree_tag = tag_prefix + parse_version(kind, vtext) + if tree_tag != tag: + print(f"note: default branch is at {tree_tag}; publishing prepared {tag} " + f"(the difference is post-prep merges, typically release tooling).") + except SystemExit: + pass + lines = notes.splitlines() + title = re.sub(r"^#\s*", "", lines[0]).strip() + body = "\n".join(lines[1:]).strip("\n") + if not title: + die(f"{tag}.md has an empty title line (first line must be '# <title>').") + + owner, repo = owner_repo(root) + + print(f"repo : {owner}/{repo} ({kind})") + print(f"tag : {tag} target: {default_branch}") + print(f"title : {title}") + print(f"body : {len(body)} chars, {body.count(chr(10)) + 1} lines") + print("-" * 60) + print(body) + print("-" * 60) + + if args.dry_run: + print("dry-run: nothing published.") + return + + token = get_token() + if not token: + die("no GitHub token. Set GH_TOKEN / GITHUB_TOKEN, or install gh and run `gh auth login`.") + + status, rel = api(token, "GET", f"/repos/{owner}/{repo}/releases/tags/{tag}") + if status == 200: + st, res = api(token, "PATCH", f"/repos/{owner}/{repo}/releases/{rel['id']}", + {"name": title, "body": body, "make_latest": "true", "draft": False}) + if st >= 300: + die(f"updating existing release failed ({st}): {res.get('message')}") + print(f"updated existing release {tag}") + print(res.get("html_url")) + print("note: an already-published release does not re-trigger the build; " + "assets are only (re)built when the release is first published.") + return + + st, res = api(token, "POST", f"/repos/{owner}/{repo}/releases", { + "tag_name": tag, + "target_commitish": default_branch, + "name": title, + "body": body, + "make_latest": "true", + "draft": False, + "prerelease": False, + }) + if st >= 300: + die(f"creating release failed ({st}): {res.get('message')}") + print(f"published release {tag}") + print(res.get("html_url")) + if kind == "firmware": + print("firmware CI (release: published) will now build and attach the .bin/.uf2.") + elif kind == "wincompose": + print("wincompose CI (release: published) will now build and attach the " + "installer .exe, the portable .zip and SHA256SUMS.txt.") + + +if __name__ == "__main__": + main() diff --git a/src/Makefile b/src/Makefile index 1b22e5c8..9e88d50d 100644 --- a/src/Makefile +++ b/src/Makefile @@ -42,10 +42,10 @@ MSGMERGE = msgmerge all: check installer portable @echo @echo Latest: $(VERSION) - @echo Installer: https://github.com/ell1010/wincompose/releases/download/v$(VERSION)/WinCompose-Setup-$(VERSION).exe + @echo Installer: https://github.com/thpoll83/wincompose/releases/download/PK-$(VERSION)/WinCompose-Setup-$(VERSION).exe @echo InstallerMD5: $(shell certutil -hashfile WinCompose-Setup-$(VERSION).exe MD5) @echo InstallerSHA256: $(shell certutil -hashfile WinCompose-Setup-$(VERSION).exe sha256) - @echo Portable: https://github.com/ell1010/wincompose/releases/download/v$(VERSION)/WinCompose-NoInstall-$(VERSION).zip + @echo Portable: https://github.com/thpoll83/wincompose/releases/download/PK-$(VERSION)/WinCompose-NoInstall-$(VERSION).zip @echo PortableSHA256: $(shell certutil -hashfile $(PORTABLE) MD5) @echo PortableMD5: $(shell certutil -hashfile $(PORTABLE) sha256)