diff --git a/.github/workflows/m3h-release-notes-sync.yml b/.github/workflows/m3h-release-notes-sync.yml new file mode 100644 index 000000000..af893b300 --- /dev/null +++ b/.github/workflows/m3h-release-notes-sync.yml @@ -0,0 +1,107 @@ +name: Synchronize M3.H Public Release Notes + +on: + pull_request: + paths: + - 'docs/releases/PROCESS_BUS_SIMULATOR_M3H_PUBLIC_20261024.md' + - 'scripts/render_m3h_release_notes.py' + - '.github/workflows/m3h-release-notes-sync.yml' + push: + branches: [main] + paths: + - 'docs/releases/PROCESS_BUS_SIMULATOR_M3H_PUBLIC_20261024.md' + - 'scripts/render_m3h_release_notes.py' + - '.github/workflows/m3h-release-notes-sync.yml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: m3h-public-release-notes-sync + cancel-in-progress: false + +jobs: + validate-notes: + name: Validate product release copy and links + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Render release notes from canonical public documentation + run: | + python3 scripts/render_m3h_release_notes.py /tmp/m3h-release-notes.md + grep -Fq 'ARStack Process Bus Simulator M3.H' /tmp/m3h-release-notes.md + grep -Fq 'raw.githubusercontent.com/masarray/arstack61850/main/assets/screenshots/arstack-studio-windows.png' /tmp/m3h-release-notes.md + + update-existing-release-description: + name: Refresh M3.H release description only + needs: validate-notes + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + permissions: + contents: write + env: + GH_TOKEN: ${{ github.token }} + TAG: processbus-sim-m3h-20261024 + steps: + - uses: actions/checkout@v4 + - name: Render existing stable release description + run: python3 scripts/render_m3h_release_notes.py /tmp/m3h-release-notes.md + + - name: Verify release and snapshot immutable assets + run: | + set -euo pipefail + gh api "repos/$GITHUB_REPOSITORY/releases/tags/$TAG" > /tmp/m3h-before.json + python3 - <<'PY' + import json + r=json.load(open("/tmp/m3h-before.json")) + assert r["tag_name"]=="processbus-sim-m3h-20261024" and not r["draft"] and not r["prerelease"], "Expected existing public stable M3.H release" + expected={ + "ARStack-ESP32P4-ProcessBus-M3H-firmware.bin", + "ARStack-ProcessBus-M3H-win-x64-portable.zip", + "ARStack-ProcessBus-M3H-win-x64-setup.exe", + "firmware-manifest.json", + "SHA256SUMS.txt", + } + actual={a["name"] for a in r["assets"]} + assert actual==expected, f"Release asset inventory changed: {actual ^ expected}" + print("PASS: verified existing stable tag and exact five assets") + PY + + - name: Update descriptive release notes if changed + id: check + run: | + set -euo pipefail + python3 - <<'PY' + import json, os + previous=json.load(open("/tmp/m3h-before.json")) + desired=open("/tmp/m3h-release-notes.md", encoding="utf-8").read() + changed=previous.get("body","").strip()!=desired.strip() + with open(os.environ["GITHUB_OUTPUT"],"a") as out: + out.write(f"changed={'true' if changed else 'false'}\n") + print("Release description update required:", changed) + PY + + - name: Synchronize only existing release description + if: steps.check.outputs.changed == 'true' + run: | + gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" \ + --notes-file /tmp/m3h-release-notes.md + + - name: Verify description and asset identities unchanged + run: | + set -euo pipefail + gh api "repos/$GITHUB_REPOSITORY/releases/tags/$TAG" > /tmp/m3h-after.json + python3 - <<'PY' + import json + before=json.load(open("/tmp/m3h-before.json")) + after=json.load(open("/tmp/m3h-after.json")) + desired=open("/tmp/m3h-release-notes.md", encoding="utf-8").read() + assert after.get("body","").strip()==desired.strip(), "Release notes did not match source" + for key in ("id", "tag_name", "target_commitish", "draft", "prerelease", "published_at"): + assert before[key]==after[key], f"Unexpected release field change: {key}" + def assets(r): + return sorted((a["id"], a["name"], a["size"], a.get("digest")) for a in r["assets"]) + assert assets(before)==assets(after), "Release assets changed unexpectedly" + print("PASS: release description synchronized, tag and assets preserved") + PY diff --git a/docs/releases/PROCESS_BUS_SIMULATOR_M3H_PUBLIC_20261024.md b/docs/releases/PROCESS_BUS_SIMULATOR_M3H_PUBLIC_20261024.md index b2acbecae..acbc88bde 100644 --- a/docs/releases/PROCESS_BUS_SIMULATOR_M3H_PUBLIC_20261024.md +++ b/docs/releases/PROCESS_BUS_SIMULATOR_M3H_PUBLIC_20261024.md @@ -4,6 +4,8 @@ Generate IEC 61850 Sampled Values and emulate PTP synchronization using **ARStack Studio and ESP32-P4 Ethernet hardware**. This edition combines a 4-current + 4-voltage SV publisher, internal laboratory PTP grandmaster simulation, and hardware-assisted phase synchronization. +![ARStack Studio 4I+4V workspace and waveforms](../../assets/screenshots/arstack-studio-windows.png) + ## What's new - **SV + PTP in one lab setup** — real Ethernet SV at 4,000 frames/s alongside Layer-2 PTP Announce, Sync and Follow_Up. diff --git a/scripts/render_m3h_release_notes.py b/scripts/render_m3h_release_notes.py new file mode 100644 index 000000000..dd38e6694 --- /dev/null +++ b/scripts/render_m3h_release_notes.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +"""Render the M3.H public release document into portable GitHub Release notes.""" +from pathlib import Path +import posixpath +import re +import sys + +ROOT = Path(__file__).resolve().parents[1] +SOURCE = ROOT / "docs/releases/PROCESS_BUS_SIMULATOR_M3H_PUBLIC_20261024.md" +REPO = "masarray/arstack61850" +BASE = "docs/releases" + +def normalize(match): + prefix, relative, suffix = match.groups() + target = relative.split("#", 1) + path = posixpath.normpath(posixpath.join(BASE, target[0])) + if path.startswith("../") or path == "..": + raise ValueError(f"Relative path escapes repository: {relative}") + anchor = "#" + target[1] if len(target) > 1 else "" + if not (ROOT / path).is_file(): + raise FileNotFoundError(f"Broken release-notes link: {relative} → {path}") + if prefix.startswith("!["): + url = f"https://raw.githubusercontent.com/{REPO}/main/{path}{anchor}" + else: + url = f"https://github.com/{REPO}/blob/main/{path}{anchor}" + return prefix + url + suffix + +def main(): + if len(sys.argv) != 2: + raise SystemExit("Usage: render_m3h_release_notes.py output.md") + source = SOURCE.read_text(encoding="utf-8") + for required in ( + "163,048", "3,999.884", "smpSynch=2", + "a922e9bdba4e57a4e88406eb83344455dd8ff142", + "4902e8bf44e78fc55a139ce84a1273f0768ef6fe10000f5d0f85d75d2f6c736e", + "ca01424e4102aa65e353737024ebf3258b4d2f614fa60d932c95c353ee228d8f", + "61c508e9bf1357eb964d2b817b260b6a0e985446ddd68de1d45d9467e25c4607", + ): + if required not in source: + raise ValueError(f"Missing immutable release evidence: {required}") + rendered = re.sub(r"(!?\[[^\]]+\]\()(\.\.?/[^)\s]+)(\))", normalize, source) + if re.search(r"!?\[[^\]]+\]\(\.\.?/", rendered): + raise ValueError("Unresolved relative release link") + dest = Path(sys.argv[1]) + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_text(rendered, encoding="utf-8") + print(f"PASS: verified and rendered {dest}, {len(rendered)} characters") + +if __name__ == "__main__": + main()