diff --git a/.github/workflows/sync-release-documentation.yml b/.github/workflows/sync-release-documentation.yml index 33404d6be..98401b9d9 100644 --- a/.github/workflows/sync-release-documentation.yml +++ b/.github/workflows/sync-release-documentation.yml @@ -10,6 +10,7 @@ on: permissions: contents: write + actions: write concurrency: group: sync-release-documentation @@ -179,18 +180,27 @@ jobs: if not re.fullmatch(r"[0-9a-f]{64}", item.get("sha256", "")) or int(item.get("sizeBytes", 0)) < 1_000_000: raise SystemExit(f"Invalid {key} evidence") - - name: Update public landing release record + - name: Synchronize landing release notes + shell: bash + run: | + set -euo pipefail + python scripts/sync-landing-release.py \ + --evidence _release-sync/published.json \ + --release _release-sync/release.json \ + --notes landing/release-notes.json + + - name: Update public landing release records shell: bash run: | set -euo pipefail cp _release-sync/published.json landing/latest.json - if git diff --quiet -- landing/latest.json; then - echo "Public landing release record is already synchronized." + if git diff --quiet -- landing/latest.json landing/release-notes.json; then + echo "Public landing release records are already synchronized." else git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add landing/latest.json - git commit -m "docs: synchronize ARSAS ${{ steps.version.outputs.version }} release evidence" + git add landing/latest.json landing/release-notes.json + git commit -m "docs: synchronize ARSAS ${{ steps.version.outputs.version }} release landing" git fetch origin main git rebase origin/main git push origin HEAD:main @@ -217,6 +227,15 @@ jobs: )" printf '%s' "$body" | gh api --method PUT "$api_path" --input - >/dev/null + - name: Dispatch synchronized product website + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + gh workflow run pages.yml --repo "$GITHUB_REPOSITORY" --ref main + echo "Dispatched Pages deployment for synchronized stable release ${{ steps.version.outputs.version }}." + - name: Summarize synchronized release shell: python run: | diff --git a/landing/app.js b/landing/app.js index 96c902975..9a8130596 100644 --- a/landing/app.js +++ b/landing/app.js @@ -1,4 +1,15 @@ (() => { + const latestInstallerUrl = 'https://github.com/masarray/arsas/releases/latest/download/ARSAS-Windows-x64-Setup.exe'; + + // Keep the Download Center as the no-JavaScript fallback, but make the prominent + // download CTAs resolve the immutable public asset name on GitHub's latest stable + // release. The release workflow always republishes this exact asset name. + document.querySelectorAll('a.nav-cta, .hero-actions a.btn-primary[href="download.html"], .hero-actions a.btn-primary[href="unduh.html"]').forEach(link => { + if (!(link instanceof HTMLAnchorElement)) return; + link.href = latestInstallerUrl; + link.setAttribute('download', ''); + }); + const toggle = document.querySelector('[data-menu-toggle]'); const links = document.querySelector('[data-nav-links]'); diff --git a/landing/release-notes.json b/landing/release-notes.json index c4447bef5..308e41ed3 100644 --- a/landing/release-notes.json +++ b/landing/release-notes.json @@ -1,38 +1,34 @@ { "schemaVersion": 1, "product": "ARSAS", - "version": "1.6.33", - "title": "Fast, unified and responsive IEC 61850 FAT workspace", - "titleId": "Workspace IEC 61850 FAT yang cepat, terpadu, dan responsif", - "summary": "ARSAS 1.6.33 unifies Engineering and FAT around the imported SCL model, shortens direct-SCL connection readiness, projects structured THD and demand-energy values correctly, and keeps operator input responsive during continuous live acquisition.", - "summaryId": "ARSAS 1.6.33 menyatukan Engineering dan FAT pada model SCL yang diimpor, mempercepat kesiapan koneksi direct-SCL, memproyeksikan THD terstruktur dan demand-energy dengan benar, serta menjaga input operator tetap responsif saat akuisisi live terus berjalan.", + "version": "1.6.35", + "title": "Field-stable FAT continuity, semantic signals and relay fascia", + "titleId": "Kontinuitas FAT, semantic signal, dan relay fascia yang lebih stabil", + "summary": "ARSAS 1.6.35 carries the field-accepted relay fascia and monitoring-state LEDs, context-aware IEC 61850 signal presentation, Engineering-to-FAT progress continuation for the same SCL content, and persistent multi-RCB selection across virtualized scrolling.", + "summaryId": "ARSAS 1.6.35 membawa relay fascia dan LED status monitoring yang sudah diterima di field, presentasi signal IEC 61850 context-aware, kelanjutan progress Engineering-ke-FAT untuk konten SCL yang sama, serta pilihan multi-RCB yang tetap tersimpan saat grid virtualized di-scroll.", "highlights": [ - "Direct-SCL FAT connection reuses the imported model and reaches monitoring after the MMS association and bounded point setup without a discovery-from-zero or legacy settle delay.", - "Engineering and FAT share SCL signal identity and operator selection; switching workspace no longer creates an isolated signal universe.", - "Additional CID, SCD, ICD or IID sources can be added to an existing FAT project, including while another IED evidence session remains active.", - "Structured ThdA and ThdPPV parent values preserve their phase A/B/C or AB/BC/CA members, while DmdWhMV uses the exact MMS value authority exposed by the relay.", - "Windows taskbar grouping uses the embedded ARSAS icon and a stable explicit application identity." + "The IED card uses the black relay fascia with monitoring-state LEDs: LIVE is green and the first Stop state is red.", + "Engineering, FAT and report preview share context-aware signal naming, including phase-aware analog names and logical-node-qualified switching positions.", + "Opening FAT again from Engineering can restore local progress for the same SCL content while incompatible point evidence remains fail-closed.", + "Legacy SAS multi-RCB selections remain durable while the WPF DataGrid virtualizes and recycles rows during scrolling." ], "highlightsId": [ - "Koneksi FAT direct-SCL menggunakan kembali model hasil import dan masuk ke monitoring setelah association MMS serta bounded point setup tanpa discovery dari nol atau legacy settle delay.", - "Engineering dan FAT berbagi identitas serta pilihan signal SCL; perpindahan workspace tidak lagi membentuk signal universe yang terpisah.", - "Source CID, SCD, ICD, atau IID tambahan dapat dimasukkan ke project FAT yang sedang digunakan, termasuk ketika evidence session IED lain masih aktif.", - "Parent value ThdA dan ThdPPV terstruktur mempertahankan member phase A/B/C atau AB/BC/CA, sedangkan DmdWhMV menggunakan exact MMS value authority yang diberikan relay.", - "Grouping taskbar Windows menggunakan icon ARSAS yang tertanam dan explicit application identity yang stabil." + "IED card menggunakan black relay fascia dengan LED mengikuti status monitoring: LIVE berwarna hijau dan kondisi Stop pertama berwarna merah.", + "Engineering, FAT, dan report preview memakai context-aware signal naming yang sama, termasuk nama analog berbasis phase dan switching position yang dibedakan berdasarkan logical node.", + "Saat FAT dibuka kembali dari Engineering, progress lokal untuk konten SCL yang sama dapat direstore sementara evidence point yang tidak kompatibel tetap fail-closed.", + "Pilihan multi-RCB Legacy SAS tetap tersimpan saat WPF DataGrid melakukan virtualisasi dan recycle row ketika di-scroll." ], "improvements": [ - "Live FAT observations are coalesced in bounded dispatcher batches below mouse and keyboard input priority, while the dedicated digital-edge queue remains lossless.", - "Steady telemetry updates only visible value cells; it no longer re-filters the entire DataGrid or resets autosave for every poll.", - "Capture progress and workspace summaries update only when evidence or test state actually changes.", - "Adding an IED synchronizes only the new endpoints, leaving the active IED's capture map and evidence scope immutable.", - "The Windows release remains gated by the full ARSAS regression suite, portable smoke validation, checksums, SPDX SBOM, provenance and GitHub attestations." + "Canonical IEC references remain unchanged for binding, report identity, evidence and persistence while operator-facing labels gain useful phase and logical-node context.", + "FAT continuation can recognize the same SCL content across compatible source presentation changes without weakening per-point configuration matching.", + "RCB selection state is owned by the row model rather than transient visual containers, preventing scroll-induced selection loss.", + "The stable Windows release is gated by full regression tests, portable and installer smoke tests, SHA-256 checksums, SPDX SBOM, provenance and GitHub attestations." ], "improvementsId": [ - "Observasi live FAT di-coalesce dalam bounded dispatcher batch di bawah prioritas input mouse dan keyboard, sementara queue digital-edge khusus tetap lossless.", - "Telemetry steady hanya mengubah cell value yang terlihat; seluruh DataGrid tidak lagi di-filter ulang dan timer autosave tidak lagi di-reset pada setiap poll.", - "Progress capture dan ringkasan workspace hanya diperbarui ketika evidence atau test state benar-benar berubah.", - "Penambahan IED hanya menyinkronkan endpoint baru sehingga capture map dan evidence scope IED aktif tetap immutable.", - "Release Windows tetap digate oleh regression suite ARSAS lengkap, portable smoke validation, checksum, SPDX SBOM, provenance, dan GitHub attestation." + "Canonical IEC reference tetap tidak berubah untuk binding, report identity, evidence, dan persistence sementara label operator mendapat konteks phase dan logical node yang lebih jelas.", + "FAT continuation dapat mengenali konten SCL yang sama pada perubahan presentasi source yang kompatibel tanpa melemahkan per-point configuration matching.", + "State pilihan RCB dimiliki oleh row model, bukan visual container sementara, sehingga scrolling tidak lagi menghilangkan selection.", + "Stable release Windows digate oleh regression test lengkap, portable dan installer smoke test, checksum SHA-256, SPDX SBOM, provenance, serta GitHub attestation." ], "knownLimitations": [ "Physical relay validation remains necessary for vendor-specific values, report behavior and field network conditions that cannot be reproduced by CI.", @@ -59,11 +55,11 @@ "src": "assets/screenshots/arsas-live-values.webp", "width": 1507, "height": 893, - "alt": "ARSAS 1.6.33 Engineering and FAT live IEC 61850 workspace", - "altId": "Workspace live IEC 61850 Engineering dan FAT ARSAS 1.6.33", - "caption": "ARSAS 1.6.33 shares imported SCL signal identity between Engineering and FAT while bounded background refresh keeps operator interaction responsive.", - "captionId": "ARSAS 1.6.33 berbagi identitas signal SCL antara Engineering dan FAT sementara bounded background refresh menjaga interaksi operator tetap responsif." + "alt": "ARSAS 1.6.35 Engineering and FAT live IEC 61850 workspace", + "altId": "Workspace live IEC 61850 Engineering dan FAT ARSAS 1.6.35", + "caption": "ARSAS 1.6.35 keeps IEC 61850 engineering identity consistent across Engineering, FAT and report evidence while preserving field-tested operator continuity.", + "captionId": "ARSAS 1.6.35 menjaga identitas engineering IEC 61850 tetap konsisten di Engineering, FAT, dan report evidence sekaligus mempertahankan kontinuitas operator yang sudah diuji di field." }, "issuesUrl": "https://github.com/masarray/arsas/issues/new/choose", - "releaseUrl": "https://github.com/masarray/arsas/releases/tag/v1.6.33" + "releaseUrl": "https://github.com/masarray/arsas/releases/tag/v1.6.35" } diff --git a/scripts/sync-landing-release.py b/scripts/sync-landing-release.py new file mode 100644 index 000000000..f63f42d9c --- /dev/null +++ b/scripts/sync-landing-release.py @@ -0,0 +1,256 @@ +#!/usr/bin/env python3 +"""Synchronize public landing release notes from verified stable release evidence. + +The stable package identity (version, release URL, signing state and published assets) +is authoritative. Manually curated release notes are preserved when they already match +the published version; otherwise a conservative bilingual release summary is generated +from the GitHub Release body plus verified package evidence. +""" + +from __future__ import annotations + +import argparse +import json +import re +from pathlib import Path + +SEMVER = re.compile(r"\d+\.\d+\.\d+") +SHA256 = re.compile(r"[0-9a-fA-F]{64}") +DEFAULT_ISSUES_URL = "https://github.com/masarray/arsas/issues/new/choose" +DEFAULT_SCREENSHOT = { + "src": "assets/screenshots/arsas-live-values.webp", + "width": 1507, + "height": 893, +} + + +def read_object(path: Path, label: str) -> dict[str, object]: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise SystemExit(f"Cannot read {label}: {exc}") from exc + if not isinstance(value, dict): + raise SystemExit(f"{label} must contain a JSON object") + return value + + +def valid_text(value: object) -> bool: + return isinstance(value, str) and bool(value.strip()) + + +def valid_list(value: object, minimum: int = 4) -> bool: + return isinstance(value, list) and len(value) >= minimum and all(valid_text(item) for item in value) + + +def release_changes(release: dict[str, object]) -> list[str]: + body = release.get("body") + if not isinstance(body, str): + return [] + changes: list[str] = [] + for raw in body.splitlines(): + line = raw.strip() + match = re.match(r"^[*-]\s+(.+?)(?:\s+by\s+@[^\s]+)?(?:\s+in\s+https?://\S+)?$", line) + if not match: + continue + title = match.group(1).strip() + title = re.sub(r"\s+by\s+@[^\s]+.*$", "", title).strip() + if not title or title.lower().startswith("release:") or title in changes: + continue + changes.append(title) + return changes[:5] + + +def id_change_title(title: str) -> str: + replacements = ( + ("feat:", "Fitur:"), + ("fix:", "Perbaikan:"), + ("docs:", "Dokumentasi:"), + ("chore:", "Pemeliharaan:"), + ("perf:", "Performa:"), + ("refactor:", "Refactor:"), + ) + lowered = title.lower() + for prefix, replacement in replacements: + if lowered.startswith(prefix): + return replacement + title[len(prefix):] + return "Perubahan rilis: " + title + + +def signing_notes(status: str, evidence_detail: str) -> dict[str, str]: + if status == "signed": + return { + "status": "signed", + "label": "Authenticode-signed", + "labelId": "Ditandatangani dengan Authenticode", + "detail": evidence_detail, + "detailId": "Binary Windows publik membawa signature Authenticode. Tetap verifikasi SHA-256 yang dipublikasikan sebelum digunakan.", + } + return { + "status": "unsigned", + "label": "Not Authenticode-signed", + "labelId": "Belum ditandatangani dengan Authenticode", + "detail": evidence_detail, + "detailId": "Installer Windows dan portable EXE publik saat ini belum memiliki commercial Authenticode publisher signature. Verifikasi nilai SHA-256 yang dipublikasikan sebelum digunakan. Karena itu peringatan SmartScreen masih mungkin muncul dan status ini tidak disembunyikan dari user.", + } + + +def generated_notes( + evidence: dict[str, object], + release: dict[str, object], + previous: dict[str, object], +) -> dict[str, object]: + version = str(evidence["version"]) + changes = release_changes(release) + + highlights = list(changes) + fallback_highlights = [ + f"The verified Windows installer is published as the stable ARSAS {version} package.", + "The portable single EXE is published from the same verified stable release.", + "SHA-256 checksums, SPDX SBOM and release provenance are published alongside the Windows binaries.", + "The public Download Center resolves package links through the latest stable GitHub Release authority.", + ] + for item in fallback_highlights: + if len(highlights) >= 4: + break + highlights.append(item) + + highlights_id = [id_change_title(item) for item in changes] + fallback_highlights_id = [ + f"Installer Windows terverifikasi dipublikasikan sebagai paket stabil ARSAS {version}.", + "Portable single EXE dipublikasikan dari stable release terverifikasi yang sama.", + "Checksum SHA-256, SPDX SBOM, dan provenance release dipublikasikan bersama binary Windows.", + "Download Center publik menggunakan authority GitHub Release stabil terbaru untuk link paket.", + ] + for item in fallback_highlights_id: + if len(highlights_id) >= 4: + break + highlights_id.append(item) + + improvements = [ + "Stable release identity, publish date, package size and SHA-256 are sourced from verified release evidence rather than hand-maintained version text.", + "The Windows installer and portable download URLs use GitHub's releases/latest/download endpoints so the public buttons always resolve to the newest stable assets.", + "Release notes are synchronized before website deployment, preventing a newer binary release from being blocked by stale landing-page metadata.", + "The website deployment is explicitly dispatched after release synchronization so GitHub Actions token commits cannot leave Pages behind the published release.", + ] + improvements_id = [ + "Identitas stable release, tanggal publikasi, ukuran paket, dan SHA-256 diambil dari evidence release terverifikasi, bukan teks versi yang dipelihara manual.", + "URL installer Windows dan portable memakai endpoint releases/latest/download GitHub sehingga tombol publik selalu menuju asset stabil terbaru.", + "Catatan rilis disinkronkan sebelum deployment website agar binary release baru tidak terblokir metadata landing page yang tertinggal.", + "Deployment website dipicu eksplisit setelah sinkronisasi release sehingga commit dari GitHub Actions token tidak membuat Pages tertinggal dari release publik.", + ] + + old_limits = previous.get("knownLimitations") + old_limits_id = previous.get("knownLimitationsId") + if not valid_list(old_limits): + old_limits = [ + "Physical relay validation remains necessary for vendor-specific values, report behavior and field network conditions that cannot be reproduced by CI.", + "Windows x64 is the only packaged desktop platform in this stable release.", + "The public binaries may trigger Windows SmartScreen when they are not Authenticode-signed.", + "Raw-Ethernet GOOSE and Sampled Values workflows require an approved Npcap installation and suitable capture permissions.", + ] + if not valid_list(old_limits_id): + old_limits_id = [ + "Validasi relay fisik tetap diperlukan untuk value vendor-specific, perilaku report, dan kondisi network lapangan yang tidak dapat direproduksi oleh CI.", + "Windows x64 adalah satu-satunya platform desktop yang dipaketkan pada stable release ini.", + "Binary publik dapat memicu Windows SmartScreen ketika belum ditandatangani dengan Authenticode.", + "Workflow raw-Ethernet GOOSE dan Sampled Values memerlukan instalasi Npcap yang disetujui dan capture permission yang sesuai.", + ] + + evidence_signing = evidence["codeSigning"] + assert isinstance(evidence_signing, dict) + status = str(evidence_signing["status"]) + signing = signing_notes(status, str(evidence_signing.get("detail", ""))) + + previous_screenshot = previous.get("screenshot") + screenshot = dict(DEFAULT_SCREENSHOT) + if isinstance(previous_screenshot, dict): + for key in ("src", "width", "height"): + if key in previous_screenshot: + screenshot[key] = previous_screenshot[key] + screenshot.update( + { + "alt": f"ARSAS {version} Engineering and FAT live IEC 61850 workspace", + "altId": f"Workspace live IEC 61850 Engineering dan FAT ARSAS {version}", + "caption": f"ARSAS {version} stable Windows release with verified installer, portable package and release evidence.", + "captionId": f"Stable release Windows ARSAS {version} dengan installer, portable package, dan release evidence yang terverifikasi.", + } + ) + + issues_url = previous.get("issuesUrl") if valid_text(previous.get("issuesUrl")) else DEFAULT_ISSUES_URL + return { + "schemaVersion": 1, + "product": "ARSAS", + "version": version, + "title": f"ARSAS {version} stable release", + "titleId": f"Stable release ARSAS {version}", + "summary": f"ARSAS {version} is the latest verified stable Windows release. Package identity, download links, checksums and publication evidence are synchronized automatically from the tagged GitHub Release.", + "summaryId": f"ARSAS {version} adalah stable release Windows terverifikasi terbaru. Identitas paket, link download, checksum, dan publication evidence disinkronkan otomatis dari GitHub Release bertag.", + "highlights": highlights, + "highlightsId": highlights_id, + "improvements": improvements, + "improvementsId": improvements_id, + "knownLimitations": old_limits, + "knownLimitationsId": old_limits_id, + "codeSigning": signing, + "screenshot": screenshot, + "issuesUrl": issues_url, + "releaseUrl": evidence["releaseUrl"], + } + + +def synchronize(evidence: dict[str, object], release: dict[str, object], previous: dict[str, object]) -> dict[str, object]: + if evidence.get("schemaVersion") != 1 or evidence.get("product") != "ARSAS" or evidence.get("channel") != "stable": + raise SystemExit("Stable release evidence has invalid identity") + version = str(evidence.get("version", "")) + if not SEMVER.fullmatch(version): + raise SystemExit("Stable release version must use major.minor.patch") + if release.get("draft") or release.get("prerelease"): + raise SystemExit("Landing pages may only synchronize a published stable release") + if release.get("tag_name") != f"v{version}": + raise SystemExit("GitHub Release tag does not match stable evidence version") + if release.get("html_url") != evidence.get("releaseUrl"): + raise SystemExit("GitHub Release URL does not match stable evidence") + + for name in ("installer", "portable"): + package = evidence.get(name) + if not isinstance(package, dict) or not SHA256.fullmatch(str(package.get("sha256", ""))): + raise SystemExit(f"Stable release {name} evidence is invalid") + url = str(package.get("url", "")) + if "/releases/latest/download/" not in url: + raise SystemExit(f"Stable release {name} URL must use releases/latest/download") + + signing = evidence.get("codeSigning") + if not isinstance(signing, dict) or signing.get("status") not in {"signed", "unsigned"}: + raise SystemExit("Stable release signing evidence is invalid") + + # Keep curated copy when a human already prepared notes for this exact version. + if previous.get("version") == version: + result = dict(previous) + result["schemaVersion"] = 1 + result["product"] = "ARSAS" + result["version"] = version + result["releaseUrl"] = evidence["releaseUrl"] + result["codeSigning"] = signing_notes(str(signing["status"]), str(signing.get("detail", ""))) + return result + + return generated_notes(evidence, release, previous) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--evidence", type=Path, required=True) + parser.add_argument("--release", type=Path, required=True) + parser.add_argument("--notes", type=Path, required=True) + args = parser.parse_args() + + evidence = read_object(args.evidence, "stable release evidence") + release = read_object(args.release, "GitHub Release metadata") + previous = read_object(args.notes, "landing release notes") if args.notes.exists() else {} + synchronized = synchronize(evidence, release, previous) + args.notes.write_text(json.dumps(synchronized, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + print(f"Landing release notes synchronized to ARSAS {synchronized['version']}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())