From 294b4bd7b722aca9c2f479ad98b108544fd6f34f Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 10:37:03 +0700 Subject: [PATCH 01/57] Add compatibility evidence registry --- landing/device-evidence.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 landing/device-evidence.json diff --git a/landing/device-evidence.json b/landing/device-evidence.json new file mode 100644 index 0000000..8c4a025 --- /dev/null +++ b/landing/device-evidence.json @@ -0,0 +1,7 @@ +{ + "schemaVersion": 1, + "product": "ARSAS", + "updatedAt": "2026-07-22", + "namedDeviceCount": 0, + "profiles": [] +} From 43b567f69337f6c7242485e48d7a89b2561b3a0c Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 10:37:49 +0700 Subject: [PATCH 02/57] Populate evidence-governed compatibility profiles --- landing/device-evidence.json | 67 +++++++++++++++++++++++++++++++++++- 1 file changed, 66 insertions(+), 1 deletion(-) diff --git a/landing/device-evidence.json b/landing/device-evidence.json index 8c4a025..9a17341 100644 --- a/landing/device-evidence.json +++ b/landing/device-evidence.json @@ -3,5 +3,70 @@ "product": "ARSAS", "updatedAt": "2026-07-22", "namedDeviceCount": 0, - "profiles": [] + "statusVocabulary": { + "verified": "Repeated evidence exists for the declared scope and version. This is not IEC 61850 conformance certification.", + "conditional": "The workflow succeeded only under the stated conditions, permissions, paths or server behavior.", + "observed": "A real protocol behavior was captured and used to improve the implementation. Broad compatibility is not claimed.", + "not-tested": "No public evidence is available for this service or profile.", + "known-issue": "A reproducible limitation is documented and remains bounded by linked evidence." + }, + "profiles": [ + { + "id": "field-profile-a-file-service", + "publicLabel": "Anonymized field profile A — MMS file-service transport", + "identityDisclosure": "Vendor, model, installation and firmware are not published.", + "evidenceDate": "2026-07", + "services": { + "mmsAssociation": "observed", + "liveModel": "not-tested", + "reporting": "not-tested", + "fileDirectory": "verified", + "fileTransfer": "conditional", + "goose": "not-tested", + "control": "not-tested" + }, + "conditions": [ + "The server returned nested FileDirectory entries and rooted file paths.", + "FileOpen could return a signed Integer32 FRSM identifier, including negative values.", + "Large FileRead responses required segmented COTP reassembly.", + "Record completeness and COMTRADE scaling remain independent engineering checks." + ], + "evidenceLinks": [ + "https://github.com/masarray/ARIEC61850/pull/36", + "https://github.com/masarray/ARIEC61850/pull/38", + "https://github.com/masarray/ARIEC61850/pull/40", + "https://github.com/masarray/ARIEC61850/pull/42", + "https://github.com/masarray/arsas/pull/79", + "https://github.com/masarray/arsas/pull/84", + "https://github.com/masarray/arsas/pull/89" + ] + }, + { + "id": "field-profile-b-rcb-export", + "publicLabel": "Anonymized field profile B — RCB and SCL export evidence", + "identityDisclosure": "Vendor, model, installation and firmware are not published.", + "evidenceDate": "2026-07", + "services": { + "mmsAssociation": "observed", + "liveModel": "observed", + "reporting": "conditional", + "fileDirectory": "not-tested", + "fileTransfer": "not-tested", + "goose": "not-tested", + "control": "not-tested", + "selectedRcbExport": "verified" + }, + "conditions": [ + "Live RCB names, TriggerOptions, OptionalFields, BufTm and IntgPd were preserved as read-only evidence.", + "Selected-RCB export retained the exact live MMS RCB identity and avoided duplicate instance suffixes.", + "Exported SCL remains an engineering baseline and requires independent schema, project and vendor validation.", + "No runtime RCB reservation or write is implied by this evidence profile." + ], + "evidenceLinks": [ + "https://github.com/masarray/ARIEC61850/pull/36", + "https://github.com/masarray/arsas/pull/79", + "https://github.com/masarray/arsas/pull/101" + ] + } + ] } From af7dc16c2644642a27dc777905de1c1a558ca63d Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 10:38:32 +0700 Subject: [PATCH 03/57] Add adoption and field-proof interface styles --- landing/adoption.css | 180 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 landing/adoption.css diff --git a/landing/adoption.css b/landing/adoption.css new file mode 100644 index 0000000..85df28d --- /dev/null +++ b/landing/adoption.css @@ -0,0 +1,180 @@ +.adoption-grid, +.quick-start-grid, +.evidence-grid, +.faq-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; +} + +.adoption-card, +.quick-step, +.evidence-profile, +.faq-item, +.demo-stage, +.guide-filter-panel { + border: 1px solid var(--line); + border-radius: var(--radius-sm); + background: var(--surface); + box-shadow: 0 18px 48px rgba(0, 0, 0, .18); +} + +.adoption-card, +.quick-step, +.evidence-profile, +.demo-stage, +.guide-filter-panel { padding: 1.35rem; } + +.quick-step { + display: grid; + grid-template-columns: auto 1fr; + gap: 1rem; + align-items: start; +} + +.quick-step-number { + width: 2.4rem; + height: 2.4rem; + display: grid; + place-items: center; + border-radius: 50%; + color: #07111f; + background: linear-gradient(135deg, var(--green), var(--blue)); + font-weight: 900; +} + +.quick-step h2, +.quick-step h3, +.adoption-card h3, +.evidence-profile h2, +.evidence-profile h3 { margin-bottom: .55rem; } + +.quick-step p, +.adoption-card p, +.evidence-profile p { margin: 0; color: var(--muted); } + +.boundary-callout { + border-left: 4px solid var(--amber); + padding: 1rem 1.1rem; + background: rgba(251, 191, 36, .08); +} + +.faq-item { overflow: clip; } +.faq-item summary { + cursor: pointer; + list-style: none; + padding: 1.1rem 1.25rem; + font-weight: 760; +} +.faq-item summary::-webkit-details-marker { display: none; } +.faq-item summary::after { + content: "+"; + float: right; + color: var(--blue); + font-size: 1.25rem; +} +.faq-item[open] summary::after { content: "−"; } +.faq-answer { padding: 0 1.25rem 1.2rem; color: var(--muted); } + +.status-legend, +.evidence-statuses, +.guide-filter-actions, +.demo-controls { + display: flex; + flex-wrap: wrap; + gap: .65rem; +} + +.status-pill, +.guide-filter-button, +.demo-step-button { + border: 1px solid var(--line); + border-radius: 999px; + padding: .48rem .78rem; + color: var(--muted); + background: rgba(255,255,255,.045); + font-weight: 720; +} + +.status-pill[data-status="verified"] { color: #b8ffe1; border-color: rgba(52,211,153,.4); } +.status-pill[data-status="conditional"] { color: #ffe7a3; border-color: rgba(251,191,36,.42); } +.status-pill[data-status="observed"] { color: #c9efff; border-color: rgba(56,189,248,.42); } +.status-pill[data-status="known-issue"] { color: #ffd1d9; border-color: rgba(251,113,133,.42); } + +.evidence-table { + width: 100%; + border-collapse: collapse; + margin-top: 1rem; + font-size: .92rem; +} +.evidence-table th, +.evidence-table td { + padding: .7rem; + text-align: left; + border-bottom: 1px solid var(--line); + vertical-align: top; +} +.evidence-table th { color: var(--subtle); } + +.guide-filter-panel { margin-bottom: 1.4rem; } +.guide-search { + width: 100%; + min-height: 48px; + padding: .75rem .9rem; + color: var(--text); + background: rgba(7,17,31,.7); + border: 1px solid var(--line); + border-radius: 12px; +} +.guide-search:focus-visible { outline: 3px solid rgba(56,189,248,.35); outline-offset: 2px; } +.guide-filter-actions { margin-top: .85rem; } +.guide-filter-button { cursor: pointer; } +.guide-filter-button[aria-pressed="true"] { color: white; background: rgba(56,189,248,.16); border-color: rgba(56,189,248,.5); } +.guide-card[hidden] { display: none !important; } +.guide-result-status { margin: .85rem 0 0; color: var(--subtle); } + +.demo-shell { + display: grid; + grid-template-columns: minmax(210px, .35fr) minmax(0, 1fr); + gap: 1rem; +} +.demo-controls { flex-direction: column; } +.demo-step-button { + width: 100%; + text-align: left; + cursor: pointer; + border-radius: 12px; +} +.demo-step-button[aria-selected="true"] { color: white; background: rgba(139,92,246,.17); border-color: rgba(139,92,246,.5); } +.demo-panel[hidden] { display: none; } +.demo-panel figure { margin: 0; } +.demo-panel img { width: 100%; height: auto; } +.demo-panel-copy { margin-top: 1rem; color: var(--muted); } + +.supply-chain-note { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1rem; +} +.supply-chain-note > * { + padding: 1rem; + border: 1px solid var(--line); + border-radius: var(--radius-sm); + background: rgba(255,255,255,.04); +} + +@media (max-width: 840px) { + .adoption-grid, + .quick-start-grid, + .evidence-grid, + .faq-grid, + .supply-chain-note { grid-template-columns: 1fr; } + .demo-shell { grid-template-columns: 1fr; } + .demo-controls { flex-direction: row; } + .demo-step-button { width: auto; } +} + +@media (max-width: 560px) { + .quick-step { grid-template-columns: 1fr; } + .evidence-table { display: block; overflow-x: auto; } +} From 64068ce7185c78d5efd4aefb06d39f481f935fc4 Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 10:39:00 +0700 Subject: [PATCH 04/57] Add private client-side guide search and filters --- landing/guide-filter.js | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 landing/guide-filter.js diff --git a/landing/guide-filter.js b/landing/guide-filter.js new file mode 100644 index 0000000..2a1a29f --- /dev/null +++ b/landing/guide-filter.js @@ -0,0 +1,39 @@ +(() => { + const root = document.querySelector('[data-guide-filter]'); + if (!(root instanceof HTMLElement)) return; + + const input = root.querySelector('[data-guide-search]'); + const buttons = [...root.querySelectorAll('[data-guide-category]')]; + const cards = [...document.querySelectorAll('[data-guide-card]')]; + const status = root.querySelector('[data-guide-result-status]'); + let category = 'all'; + + const normalize = value => value.toLocaleLowerCase(document.documentElement.lang || 'en').trim(); + const apply = () => { + const query = input instanceof HTMLInputElement ? normalize(input.value) : ''; + let visible = 0; + cards.forEach(card => { + if (!(card instanceof HTMLElement)) return; + const categories = (card.dataset.guideCategories || '').split(/\s+/); + const haystack = normalize(`${card.textContent || ''} ${card.dataset.guideKeywords || ''}`); + const matchesCategory = category === 'all' || categories.includes(category); + const matchesQuery = !query || haystack.includes(query); + card.hidden = !(matchesCategory && matchesQuery); + if (!card.hidden) visible += 1; + }); + if (status) { + const isId = document.documentElement.lang === 'id'; + status.textContent = isId ? `${visible} panduan ditampilkan.` : `${visible} guide${visible === 1 ? '' : 's'} shown.`; + } + }; + + if (input instanceof HTMLInputElement) input.addEventListener('input', apply); + buttons.forEach(button => { + button.addEventListener('click', () => { + category = button.getAttribute('data-guide-category') || 'all'; + buttons.forEach(candidate => candidate.setAttribute('aria-pressed', String(candidate === button))); + apply(); + }); + }); + apply(); +})(); From 03162ec47d112b85757f15d7922430eb15995793 Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 10:39:23 +0700 Subject: [PATCH 05/57] Add accessible guided product demo controller --- landing/demo.js | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 landing/demo.js diff --git a/landing/demo.js b/landing/demo.js new file mode 100644 index 0000000..71c0b74 --- /dev/null +++ b/landing/demo.js @@ -0,0 +1,33 @@ +(() => { + const demo = document.querySelector('[data-guided-demo]'); + if (!(demo instanceof HTMLElement)) return; + const buttons = [...demo.querySelectorAll('[data-demo-step]')]; + const panels = [...demo.querySelectorAll('[data-demo-panel]')]; + + const activate = index => { + buttons.forEach((button, buttonIndex) => { + const selected = buttonIndex === index; + button.setAttribute('aria-selected', String(selected)); + button.setAttribute('tabindex', selected ? '0' : '-1'); + }); + panels.forEach((panel, panelIndex) => { + if (panel instanceof HTMLElement) panel.hidden = panelIndex !== index; + }); + }; + + buttons.forEach((button, index) => { + button.addEventListener('click', () => activate(index)); + button.addEventListener('keydown', event => { + if (!['ArrowDown', 'ArrowRight', 'ArrowUp', 'ArrowLeft', 'Home', 'End'].includes(event.key)) return; + event.preventDefault(); + let next = index; + if (event.key === 'ArrowDown' || event.key === 'ArrowRight') next = (index + 1) % buttons.length; + if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') next = (index - 1 + buttons.length) % buttons.length; + if (event.key === 'Home') next = 0; + if (event.key === 'End') next = buttons.length - 1; + activate(next); + if (buttons[next] instanceof HTMLElement) buttons[next].focus(); + }); + }); + activate(0); +})(); From 864622b88f2361e388140a5864c9b8c91b19996e Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 10:40:34 +0700 Subject: [PATCH 06/57] Add English ARSAS quick-start guide --- landing/templates/quick-start.html | 58 ++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 landing/templates/quick-start.html diff --git a/landing/templates/quick-start.html b/landing/templates/quick-start.html new file mode 100644 index 0000000..afb3fff --- /dev/null +++ b/landing/templates/quick-start.html @@ -0,0 +1,58 @@ + + + + + + ARSAS Quick Start — From Download to First IEC 61850 Value + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{> header}} +
+
Install · connect · discover · observe

Move from a verified package to your first live IEC 61850 value.

This onboarding path is intentionally read-first. It does not assume a vendor, engineering-file quality or permission to change an operational device.

+ +
ARSAS first-launch workspace before adding an IEC 61850 IED

Begin from a clean workspace.

Add only an endpoint you are authorized to test. ARSAS can discover the live MMS model without requiring an ICD, CID or SCD for the first read-only inspection.

+ +
+
1

Verify and prepare the Windows package

Choose Installer or Portable ZIP, compare the downloaded file with the published SHA-256, then review the honest Authenticode status. SmartScreen may identify the current public build as an unrecognized publisher because it is unsigned.

+
2

Confirm the approved network path

Connect the engineering laptop only to the approved laboratory, FAT, SAT or commissioning network. Confirm addressing, routing and TCP port 102 before changing application settings. Npcap is needed for raw-Ethernet GOOSE or Sampled Values capture, not for ordinary MMS client reads.

+
3

Add the IED endpoint

Enter the approved IPv4 address and keep the default IEC 61850 MMS port unless the project procedure explicitly defines another endpoint. A successful TCP connection is not yet proof of a complete MMS association or service support.

+
4

Discover the live MMS model

Allow ARSAS to read the server identity, Logical Devices, Logical Nodes, Data Objects, Data Attributes, DataSets and Report Control Blocks offered by the connected server. Treat the online model as current server evidence and SCL as configured intent.

+
5

Select one signal and inspect its evidence

Choose a known read-only attribute and add it to Live Values. Confirm the IED source, object reference, value, quality, timestamp and acquisition source together. Do not treat a number alone as proof that the signal is valid or timely.

+
6

Identify reporting or polling

ARSAS can evaluate existing BRCBs and URCBs, exact DataSet coverage and visible fallback. An occupied RCB, rejected write or incomplete DataSet should remain visible rather than being converted into a false success.

+
7

Retain diagnostics and stop at the authority boundary

Save or copy the attributable diagnostics needed for the test record. Do not reserve an RCB, create a DataSet, write configuration or operate a control object unless the approved procedure, plant authority and independent safety checks explicitly permit it.

+
+ +
Expected first outcome

One attributable live value—not a universal device approval.

A completed quick start proves that the selected endpoint, association and read path produced the displayed evidence at that time. It does not prove every service, every Logical Device, protection performance, time synchronization or IEC 61850 conformity.

When the path stops

Use the symptom library before changing the IED.

Start with port 102, model discovery, reporting, RCB ownership, file service, GOOSE sequence or SCL evidence. Preserve negative results and the exact ARSAS version in an issue report.

+ + {{> download-cta}} +
+ {{> footer}} + + From 80254824970098a4575a3527bd813897fccc84f7 Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 10:41:42 +0700 Subject: [PATCH 07/57] Add Indonesian ARSAS quick-start guide --- landing/templates/panduan-mulai-arsas.html | 58 ++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 landing/templates/panduan-mulai-arsas.html diff --git a/landing/templates/panduan-mulai-arsas.html b/landing/templates/panduan-mulai-arsas.html new file mode 100644 index 0000000..fea21dd --- /dev/null +++ b/landing/templates/panduan-mulai-arsas.html @@ -0,0 +1,58 @@ + + + + + + Panduan Mulai ARSAS — Dari Download ke Live Value IEC 61850 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{> header}} +
+
Install · connect · discovery · observe

Dari paket terverifikasi menuju live value IEC 61850 pertama.

Jalur onboarding ini sengaja read-first. Panduan tidak mengasumsikan vendor, kualitas engineering file, atau kewenangan untuk mengubah device operasional.

+ +
Workspace awal ARSAS sebelum menambahkan IED IEC 61850

Mulai dari workspace bersih.

Tambahkan hanya endpoint yang memang diizinkan untuk diuji. Untuk inspeksi read-only pertama, ARSAS dapat discovery model MMS live tanpa wajib memiliki ICD, CID atau SCD.

+ +
+
1

Verifikasi dan siapkan paket Windows

Pilih Installer atau Portable ZIP, cocokkan file download dengan SHA-256 publik, lalu baca status Authenticode secara jujur. SmartScreen dapat menampilkan unrecognized publisher karena build publik saat ini belum signed.

+
2

Konfirmasi jalur jaringan yang disetujui

Hubungkan laptop engineering hanya ke jaringan laboratorium, FAT, SAT atau commissioning yang disetujui. Konfirmasi addressing, routing dan TCP port 102. Npcap diperlukan untuk capture raw-Ethernet GOOSE atau Sampled Values, bukan untuk read MMS biasa.

+
3

Tambahkan endpoint IED

Masukkan IPv4 yang disetujui dan pertahankan port MMS IEC 61850 default kecuali procedure proyek menentukan endpoint lain. TCP connected belum membuktikan association MMS lengkap atau dukungan seluruh service.

+
4

Discovery model MMS live

Biarkan ARSAS membaca server identity, Logical Device, Logical Node, Data Object, Data Attribute, DataSet dan Report Control Block yang ditawarkan server. Gunakan model online sebagai evidence server aktif dan SCL sebagai configured intent.

+
5

Pilih satu signal dan periksa evidence

Pilih attribute read-only yang dikenal lalu tambahkan ke Live Values. Konfirmasi sumber IED, object reference, value, quality, timestamp dan acquisition source bersama-sama. Angka tunggal bukan bukti bahwa signal valid atau timely.

+
6

Identifikasi reporting atau polling

ARSAS dapat mengevaluasi BRCB/URCB yang sudah ada, exact DataSet coverage dan fallback yang terlihat. RCB occupied, write rejected atau DataSet tidak lengkap harus tetap terlihat dan tidak diubah menjadi success palsu.

+
7

Simpan diagnostic dan hormati batas kewenangan

Simpan atau salin diagnostic attributable yang diperlukan test record. Jangan reserve RCB, membuat DataSet, write konfigurasi atau operate control object kecuali procedure, plant authority dan independent safety check mengizinkan secara eksplisit.

+
+ +
Outcome pertama

Satu live value yang attributable—bukan approval universal device.

Quick Start yang selesai hanya membuktikan endpoint, association dan read path tertentu menghasilkan evidence yang ditampilkan pada waktu itu. Hasil ini tidak membuktikan seluruh service, seluruh Logical Device, performance protection, time synchronization atau conformity IEC 61850.

Saat workflow berhenti

Gunakan symptom library sebelum mengubah IED.

Mulai dari port 102, model discovery, reporting, ownership RCB, file service, GOOSE sequence atau evidence SCL. Pertahankan negative result dan exact version ARSAS dalam issue report.

+ + {{> download-cta-id}} +
+ {{> footer}} + + From 7ea50f4058b2579396976dd914db717ea44a6b40 Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 10:42:51 +0700 Subject: [PATCH 08/57] Add English ARSAS adoption FAQ --- landing/templates/faq.html | 60 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 landing/templates/faq.html diff --git a/landing/templates/faq.html b/landing/templates/faq.html new file mode 100644 index 0000000..ad16747 --- /dev/null +++ b/landing/templates/faq.html @@ -0,0 +1,60 @@ + + + + + + ARSAS FAQ — IEC 61850 Download, Setup, Safety and Compatibility + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{> header}} +
+
Download · setup · safety · evidence

Questions engineers ask before trusting a new tool.

The answers below separate product capability, deployment behavior, authorization and evidence limits. They do not replace a project procedure or device manual.

+ +
+
Is ARSAS free and open source?
Yes. The community edition is distributed under GPL-3.0-or-later and does not require an account or license key. The source and release packages are public.
+
Does ARSAS require an ICD, CID or SCD file?
No for the first live MMS inspection. ARSAS can discover the online model from an approved endpoint. SCL files remain useful as configured intent and for controlled engineering comparison.
+
Can ARSAS work offline?
The desktop application does not require a cloud account. Network access is still required to reach the approved IED. Update, release and issue links require internet access.
+
Why can Windows SmartScreen show a warning?
The current public Windows binaries are not Authenticode-signed. Verify the exact SHA-256 published in the Download Center before use. The warning is not hidden or presented as proof of malware.
+
What is the difference between Installer and Portable ZIP?
The IEC 61850 feature scope is the same. Installer provides Windows registration, Start menu and uninstall behavior. Portable ZIP runs from an approved writable folder and is updated by replacing the package.
+
Is Npcap required?
Npcap is required for raw-Ethernet GOOSE and Sampled Values capture. Ordinary MMS association, model discovery, reads, reporting and file-service workflows do not inherently require Npcap.
+
Does ARSAS support every IEC 61850 vendor?
No universal vendor claim is made. Compatibility is service-specific. Public evidence is limited to bounded anonymized profiles or named devices only when disclosure is permitted.
+
Can ARSAS create or write DataSets and RCBs?
Existing configuration and read-only evidence are preferred. Specific recovery workflows may use writes only where supported, enabled and authorized. A successful write is not proof that the project configuration is correct.
+
Can ARSAS operate control objects?
Guarded Direct and Select-Before-Operate workflows exist. Active control requires an approved procedure, plant authority, correct interlocks and independent safety checks. Never use a software button as the sole authorization.
+
Is ARSAS safe for an operational network?
No software alone can declare an operational network safe. Use ARSAS only inside an approved laboratory, FAT, SAT or commissioning boundary with controlled routing, permissions and recovery procedures.
+
What website data is collected?
Optional aggregate analytics are denied by default and require consent. Relay addresses, SCL files, signal values and project data are not collected by the website. The desktop application has no website analytics dependency.
+
How should a compatibility problem be reported?
Use the structured GitHub issue form. Include ARSAS version, Windows version, device identity when disclosure is permitted, firmware, service scope, exact sanitized diagnostics, reproduction steps and confirmation that credentials and confidential project data were removed.
+
+ +
Need a first-run path?

Use the read-first Quick Start.

Verify the package, confirm port 102, discover the live model and inspect one attributable value before considering reporting, files, GOOSE or control.

Need field evidence?

Review compatibility by service—not by logo.

The compatibility matrix publishes only evidence that can be bounded by status, date, conditions and public engineering links.

+ + {{> download-cta}} +
+ {{> footer}} + + From 50e3fb3a056fb3008056015e8cbd549901767f60 Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 10:43:55 +0700 Subject: [PATCH 09/57] Add Indonesian ARSAS adoption FAQ --- landing/templates/faq-arsas.html | 60 ++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 landing/templates/faq-arsas.html diff --git a/landing/templates/faq-arsas.html b/landing/templates/faq-arsas.html new file mode 100644 index 0000000..2d6f42b --- /dev/null +++ b/landing/templates/faq-arsas.html @@ -0,0 +1,60 @@ + + + + + + FAQ ARSAS — Download, Setup, Safety dan Kompatibilitas IEC 61850 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{> header}} +
+
Download · setup · safety · evidence

Pertanyaan engineer sebelum mempercayai tool baru.

Jawaban berikut memisahkan capability produk, deployment, authorization dan batas evidence. FAQ tidak menggantikan procedure proyek atau manual device.

+ +
+
Apakah ARSAS gratis dan open source?
Ya. Community edition didistribusikan dengan GPL-3.0-or-later, tanpa account atau license key. Source dan package release tersedia publik.
+
Apakah ARSAS memerlukan file ICD, CID atau SCD?
Tidak untuk inspeksi MMS live pertama. ARSAS dapat discovery model online dari endpoint yang disetujui. SCL tetap berguna sebagai configured intent dan pembanding engineering.
+
Apakah ARSAS dapat digunakan offline?
Aplikasi desktop tidak memerlukan cloud account. Koneksi jaringan tetap diperlukan untuk mencapai IED. Link update, release dan issue membutuhkan internet.
+
Mengapa Windows SmartScreen dapat memberi warning?
Binary Windows publik saat ini belum Authenticode-signed. Cocokkan exact SHA-256 di Download Center sebelum digunakan. Warning tidak disembunyikan dan juga bukan bukti otomatis malware.
+
Apa perbedaan Installer dan Portable ZIP?
Scope fitur IEC 61850 sama. Installer memberi registrasi Windows, Start menu dan uninstall. Portable berjalan dari folder writable yang disetujui dan diperbarui dengan mengganti package.
+
Apakah Npcap wajib?
Npcap diperlukan untuk capture raw-Ethernet GOOSE dan Sampled Values. Association MMS, model discovery, read, reporting dan file service biasa tidak secara inheren memerlukan Npcap.
+
Apakah ARSAS mendukung semua vendor IEC 61850?
Tidak ada klaim universal vendor. Compatibility bersifat service-specific. Evidence publik dibatasi ke profile anonim atau device bernama hanya bila disclosure diizinkan.
+
Apakah ARSAS dapat membuat atau menulis DataSet dan RCB?
Konfigurasi existing dan evidence read-only diprioritaskan. Workflow recovery tertentu hanya memakai write bila supported, enabled dan authorized. Write berhasil tidak membuktikan konfigurasi proyek benar.
+
Apakah ARSAS dapat melakukan control?
Workflow Direct dan Select-Before-Operate tersedia secara guarded. Active control memerlukan procedure, plant authority, interlock yang benar dan independent safety check.
+
Apakah ARSAS aman untuk jaringan operasional?
Tidak ada software yang sendirian dapat menyatakan jaringan aman. Gunakan hanya dalam boundary laboratorium, FAT, SAT atau commissioning yang disetujui dengan routing, permission dan recovery procedure terkontrol.
+
Data website apa yang dikumpulkan?
Analitik agregat opsional default denied dan memerlukan consent. Alamat relay, file SCL, signal value dan data proyek tidak dikumpulkan website. Aplikasi desktop tidak bergantung pada website analytics.
+
Bagaimana melaporkan masalah kompatibilitas?
Gunakan structured GitHub issue form. Sertakan versi ARSAS, Windows, identitas device bila disclosure diizinkan, firmware, scope service, diagnostic yang sudah disanitasi, langkah reproduksi dan konfirmasi credential serta data rahasia sudah dibuang.
+
+ +
Butuh jalur first-run?

Gunakan Panduan Mulai read-first.

Verifikasi package, konfirmasi port 102, discovery model live dan periksa satu value attributable sebelum masuk reporting, file, GOOSE atau control.

Butuh field evidence?

Nilai compatibility berdasarkan service, bukan logo.

Matrix hanya mempublikasikan evidence yang memiliki status, tanggal, kondisi dan engineering link yang jelas.

+ + {{> download-cta-id}} +
+ {{> footer}} + + From 1322f03706aa71dd8fb1c57dc51a08139e4df903 Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 10:45:05 +0700 Subject: [PATCH 10/57] Add evidence-governed compatibility page --- landing/templates/compatibility.html | 53 ++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 landing/templates/compatibility.html diff --git a/landing/templates/compatibility.html b/landing/templates/compatibility.html new file mode 100644 index 0000000..cd23938 --- /dev/null +++ b/landing/templates/compatibility.html @@ -0,0 +1,53 @@ + + + + + + ARSAS IEC 61850 Compatibility Evidence — Service-Level Matrix + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{> header}} +
+
Status · conditions · evidence date · public record

Compatibility is a service-level evidence claim—not a vendor logo.

ARSAS publishes only bounded evidence that can be tied to a protocol behavior, application version, condition and public engineering record. There are currently no publicly named vendor/model entries.

+ +
Status vocabulary

Read the status together with its conditions.

VerifiedConditionalObservedNot testedFailed with known issue

“Verified” means repeated evidence exists for the declared service scope. It is not IEC 61850 conformance certification and does not approve every firmware, Logical Device or project configuration.

+ +
+
Evidence date · 2026-07

Anonymized field profile A — MMS file-service transport

Vendor, model, installation and firmware are not published. The public evidence concerns protocol behavior, not product endorsement.

ServiceStatus
MMS associationObserved
FileDirectoryVerified
File transferConditional
Live model, reporting, GOOSE, controlNot tested

Conditions

  • Nested FileDirectory entries and rooted file paths were observed.
  • FileOpen could return a signed Integer32 FRSM, including negative values.
  • Large FileRead responses required segmented COTP reassembly.
  • Transfer success does not prove record completeness or COMTRADE scaling.
+ +
Evidence date · 2026-07

Anonymized field profile B — RCB and SCL export evidence

Vendor, model, installation and firmware are not published. Evidence remains read-only until an approved export action is requested.

ServiceStatus
MMS association and live modelObserved
Reporting evidenceConditional
Selected-RCB exportVerified
Files, GOOSE, controlNot tested

Conditions

  • Live RCB names, TriggerOptions, OptionalFields, BufTm and IntgPd were preserved.
  • Selected-RCB export retained the exact live MMS RCB identity.
  • Exported SCL remains an engineering baseline requiring independent validation.
  • No runtime RCB reservation or write is implied by this profile.
+
+ +
Machine-readable source

Evidence profiles are governed in JSON.

The public device-evidence.json registry contains the same profile IDs, service statuses, conditions and public links shown here. CI rejects unsupported status values, missing links or a named-device claim without explicit evidence metadata.

Contribute a new profile

Report the service behavior—not only the brand.

Provide the ARSAS version, operating system, service under test, sanitized diagnostics, firmware when disclosure is allowed and a reproducible result. Confidential identifiers and credentials must be removed.

+ + {{> download-cta}} +
+ {{> footer}} + + From e9ea5a547a8e88e4642f676e41dd52d708d8b84e Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 10:46:11 +0700 Subject: [PATCH 11/57] Add Indonesian compatibility evidence page --- landing/templates/bukti-kompatibilitas.html | 53 +++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 landing/templates/bukti-kompatibilitas.html diff --git a/landing/templates/bukti-kompatibilitas.html b/landing/templates/bukti-kompatibilitas.html new file mode 100644 index 0000000..6e99cb3 --- /dev/null +++ b/landing/templates/bukti-kompatibilitas.html @@ -0,0 +1,53 @@ + + + + + + Evidence Kompatibilitas ARSAS IEC 61850 — Matrix per Service + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{> header}} +
+
Status · kondisi · tanggal evidence · public record

Compatibility adalah klaim evidence per service—bukan sekadar logo vendor.

ARSAS hanya mempublikasikan evidence yang dapat dikaitkan dengan behavior protokol, versi aplikasi, kondisi dan engineering record publik. Saat ini belum ada entry vendor/model yang dipublikasikan dengan nama.

+ +
Status vocabulary

Baca status bersama kondisi pengujiannya.

VerifiedConditionalObservedNot testedFailed with known issue

“Verified” berarti ada repeated evidence untuk scope service yang dinyatakan. Status ini bukan sertifikasi conformity IEC 61850 dan tidak mengesahkan seluruh firmware, Logical Device atau konfigurasi proyek.

+ +
+
Tanggal evidence · 2026-07

Profile field anonim A — transport MMS file service

Vendor, model, instalasi dan firmware tidak dipublikasikan. Evidence publik membahas behavior protokol, bukan endorsement produk.

ServiceStatus
MMS associationObserved
FileDirectoryVerified
File transferConditional
Live model, reporting, GOOSE, controlNot tested

Kondisi

  • Nested FileDirectory entry dan rooted file path teramati.
  • FileOpen dapat mengembalikan signed Integer32 FRSM termasuk nilai negatif.
  • Respons FileRead besar memerlukan segmented COTP reassembly.
  • Transfer berhasil tidak membuktikan record lengkap atau scaling COMTRADE.
+ +
Tanggal evidence · 2026-07

Profile field anonim B — evidence RCB dan export SCL

Vendor, model, instalasi dan firmware tidak dipublikasikan. Evidence tetap read-only sampai action export yang disetujui dilakukan.

ServiceStatus
MMS association dan live modelObserved
Evidence reportingConditional
Selected-RCB exportVerified
File, GOOSE, controlNot tested

Kondisi

  • Nama RCB live, TriggerOptions, OptionalFields, BufTm dan IntgPd dipertahankan.
  • Export selected-RCB mempertahankan exact identity MMS RCB live.
  • SCL hasil export tetap baseline engineering yang membutuhkan independent validation.
  • Profile ini tidak menyiratkan runtime RCB reservation atau write.
+
+ +
Machine-readable source

Profile evidence di-govern melalui JSON.

Registry publik device-evidence.json memuat profile ID, status service, kondisi dan public link yang sama dengan halaman ini. CI menolak status tidak dikenal, link hilang atau named-device claim tanpa metadata evidence.

Kontribusi profile baru

Laporkan behavior service, bukan hanya merek.

Sertakan versi ARSAS, operating system, service yang diuji, diagnostic sanitized, firmware bila boleh dibuka dan hasil yang reproducible. Credential serta identifier rahasia harus dihapus.

+ + {{> download-cta-id}} +
+ {{> footer}} + + From 30b5c98cb71e7b86e36aa13515a4309e0e810373 Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 10:47:15 +0700 Subject: [PATCH 12/57] Add real-screenshot guided ARSAS demo --- landing/templates/demo.html | 57 +++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 landing/templates/demo.html diff --git a/landing/templates/demo.html b/landing/templates/demo.html new file mode 100644 index 0000000..5fda6fd --- /dev/null +++ b/landing/templates/demo.html @@ -0,0 +1,57 @@ + + + + + + ARSAS Guided Demo — IEC 61850 Workflow with Real Screenshots + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{> header}} +
+
Real screenshots · no marketing mockup · evidence-first

See the ARSAS workflow before downloading.

This is a guided product storyboard, not a simulated relay session or time-to-value claim. Every image comes from the current application evidence set.

+ +
+
ARSAS clean first-launch workspace

Start from an approved endpoint.

Add an IED address or open SCL. The first objective is read-only identity and model evidence, not configuration change.

+ + + + + + +
+ +
What the demo proves

The product exposes evidence-oriented workflows.

The screenshots prove that these interfaces exist in the current product evidence set. They do not prove interoperability with an untested device or guarantee a result on an operational network.

Continue safely

Use Quick Start before field testing.

Verify the package, confirm network authorization, obtain one read-only value and review diagnostics before attempting reporting recovery, file transfer, GOOSE capture or control.

+ + {{> download-cta}} +
+ {{> footer}} + + + From 360559894866bbe019effe4d29cf5581958bddc9 Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 10:48:11 +0700 Subject: [PATCH 13/57] Add Indonesian guided ARSAS demo --- landing/templates/demo-arsas.html | 57 +++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 landing/templates/demo-arsas.html diff --git a/landing/templates/demo-arsas.html b/landing/templates/demo-arsas.html new file mode 100644 index 0000000..b943fe7 --- /dev/null +++ b/landing/templates/demo-arsas.html @@ -0,0 +1,57 @@ + + + + + + Demo Terpandu ARSAS — Workflow IEC 61850 dengan Screenshot Nyata + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{> header}} +
+
Screenshot nyata · tanpa marketing mockup · evidence-first

Lihat workflow ARSAS sebelum download.

Ini adalah storyboard produk terpandu, bukan simulasi relay atau klaim waktu penggunaan. Semua gambar berasal dari evidence aplikasi saat ini.

+ +
+
Workspace awal ARSAS yang bersih

Mulai dari endpoint yang disetujui.

Tambahkan alamat IED atau buka SCL. Tujuan pertama adalah identity dan model evidence read-only, bukan mengubah konfigurasi.

+ + + + + + +
+ +
Yang dibuktikan demo

Produk memiliki workflow evidence-oriented.

Screenshot membuktikan interface tersebut ada pada evidence produk saat ini. Screenshot tidak membuktikan interoperability dengan device yang belum diuji atau menjamin hasil di jaringan operasional.

Lanjut secara aman

Gunakan Panduan Mulai sebelum field test.

Verifikasi package, konfirmasi authorization jaringan, dapatkan satu value read-only dan review diagnostic sebelum mencoba reporting recovery, transfer file, capture GOOSE atau control.

+ + {{> download-cta-id}} +
+ {{> footer}} + + + From a475b78b0060492b088ead2eb8178c1d8e382c62 Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 10:49:43 +0700 Subject: [PATCH 14/57] Register P3 adoption and field-proof pages --- landing/site.json | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/landing/site.json b/landing/site.json index 86710bb..0b69ed8 100644 --- a/landing/site.json +++ b/landing/site.json @@ -23,12 +23,20 @@ "keyFile": "arsas-iec61850-20260720-6f4a9d2c8b.txt" }, "pages": [ - {"path": "", "template": "index.html", "language": "en", "alternates": {"en": "", "id": "id.html", "x-default": ""}, "changefreq": "weekly", "priority": "1.0", "lastmod": "2026-07-20"}, - {"path": "id.html", "template": "id.html", "language": "id", "contentType": "localized", "alternates": {"en": "", "id": "id.html", "x-default": ""}, "changefreq": "weekly", "priority": "0.97", "lastmod": "2026-07-20"}, - {"path": "download.html", "template": "download.html", "language": "en", "alternates": {"en": "download.html", "id": "unduh.html", "x-default": "download.html"}, "changefreq": "weekly", "priority": "0.98", "lastmod": "2026-07-20"}, - {"path": "unduh.html", "template": "unduh.html", "language": "id", "contentType": "localized", "alternates": {"en": "download.html", "id": "unduh.html", "x-default": "download.html"}, "changefreq": "weekly", "priority": "0.95", "lastmod": "2026-07-20"}, - {"path": "release-notes.html", "template": "release-notes.html", "language": "en", "contentType": "release", "alternates": {"en": "release-notes.html", "id": "catatan-rilis.html", "x-default": "release-notes.html"}, "changefreq": "weekly", "priority": "0.96", "lastmod": "2026-07-20"}, - {"path": "catatan-rilis.html", "template": "catatan-rilis.html", "language": "id", "contentType": "localized", "alternates": {"en": "release-notes.html", "id": "catatan-rilis.html", "x-default": "release-notes.html"}, "changefreq": "weekly", "priority": "0.93", "lastmod": "2026-07-20"}, + {"path": "", "template": "index.html", "language": "en", "alternates": {"en": "", "id": "id.html", "x-default": ""}, "changefreq": "weekly", "priority": "1.0", "lastmod": "2026-07-22"}, + {"path": "id.html", "template": "id.html", "language": "id", "contentType": "localized", "alternates": {"en": "", "id": "id.html", "x-default": ""}, "changefreq": "weekly", "priority": "0.97", "lastmod": "2026-07-22"}, + {"path": "download.html", "template": "download.html", "language": "en", "alternates": {"en": "download.html", "id": "unduh.html", "x-default": "download.html"}, "changefreq": "weekly", "priority": "0.98", "lastmod": "2026-07-22"}, + {"path": "unduh.html", "template": "unduh.html", "language": "id", "contentType": "localized", "alternates": {"en": "download.html", "id": "unduh.html", "x-default": "download.html"}, "changefreq": "weekly", "priority": "0.95", "lastmod": "2026-07-22"}, + {"path": "release-notes.html", "template": "release-notes.html", "language": "en", "contentType": "release", "alternates": {"en": "release-notes.html", "id": "catatan-rilis.html", "x-default": "release-notes.html"}, "changefreq": "weekly", "priority": "0.96", "lastmod": "2026-07-22"}, + {"path": "catatan-rilis.html", "template": "catatan-rilis.html", "language": "id", "contentType": "localized", "alternates": {"en": "release-notes.html", "id": "catatan-rilis.html", "x-default": "release-notes.html"}, "changefreq": "weekly", "priority": "0.93", "lastmod": "2026-07-22"}, + {"path": "quick-start.html", "template": "quick-start.html", "language": "en", "contentType": "onboarding", "alternates": {"en": "quick-start.html", "id": "panduan-mulai-arsas.html", "x-default": "quick-start.html"}, "changefreq": "monthly", "priority": "0.97", "lastmod": "2026-07-22"}, + {"path": "panduan-mulai-arsas.html", "template": "panduan-mulai-arsas.html", "language": "id", "contentType": "localized", "alternates": {"en": "quick-start.html", "id": "panduan-mulai-arsas.html", "x-default": "quick-start.html"}, "changefreq": "monthly", "priority": "0.95", "lastmod": "2026-07-22"}, + {"path": "faq.html", "template": "faq.html", "language": "en", "contentType": "faq", "alternates": {"en": "faq.html", "id": "faq-arsas.html", "x-default": "faq.html"}, "changefreq": "monthly", "priority": "0.92", "lastmod": "2026-07-22"}, + {"path": "faq-arsas.html", "template": "faq-arsas.html", "language": "id", "contentType": "localized", "alternates": {"en": "faq.html", "id": "faq-arsas.html", "x-default": "faq.html"}, "changefreq": "monthly", "priority": "0.90", "lastmod": "2026-07-22"}, + {"path": "compatibility.html", "template": "compatibility.html", "language": "en", "contentType": "evidence", "alternates": {"en": "compatibility.html", "id": "bukti-kompatibilitas.html", "x-default": "compatibility.html"}, "changefreq": "monthly", "priority": "0.91", "lastmod": "2026-07-22"}, + {"path": "bukti-kompatibilitas.html", "template": "bukti-kompatibilitas.html", "language": "id", "contentType": "localized", "alternates": {"en": "compatibility.html", "id": "bukti-kompatibilitas.html", "x-default": "compatibility.html"}, "changefreq": "monthly", "priority": "0.89", "lastmod": "2026-07-22"}, + {"path": "demo.html", "template": "demo.html", "language": "en", "contentType": "demo", "alternates": {"en": "demo.html", "id": "demo-arsas.html", "x-default": "demo.html"}, "changefreq": "monthly", "priority": "0.90", "lastmod": "2026-07-22"}, + {"path": "demo-arsas.html", "template": "demo-arsas.html", "language": "id", "contentType": "localized", "alternates": {"en": "demo.html", "id": "demo-arsas.html", "x-default": "demo.html"}, "changefreq": "monthly", "priority": "0.88", "lastmod": "2026-07-22"}, {"path": "features.html", "template": "features.html", "changefreq": "monthly", "priority": "0.94", "lastmod": "2026-07-20"}, {"path": "solutions.html", "template": "solutions.html", "changefreq": "monthly", "priority": "0.96", "lastmod": "2026-07-20"}, {"path": "fat-testing.html", "template": "fat-testing.html", "language": "en", "alternates": {"en": "fat-testing.html", "id": "pengujian-fat-iec61850.html", "x-default": "fat-testing.html"}, "changefreq": "monthly", "priority": "0.94", "lastmod": "2026-07-20"}, @@ -39,8 +47,8 @@ {"path": "commissioning-iec61850.html", "template": "commissioning-iec61850.html", "language": "id", "contentType": "localized", "alternates": {"en": "commissioning.html", "id": "commissioning-iec61850.html", "x-default": "commissioning.html"}, "changefreq": "monthly", "priority": "0.92", "lastmod": "2026-07-20"}, {"path": "multi-vendor-integration.html", "template": "multi-vendor-integration.html", "language": "en", "alternates": {"en": "multi-vendor-integration.html", "id": "integrasi-multi-vendor-iec61850.html", "x-default": "multi-vendor-integration.html"}, "changefreq": "monthly", "priority": "0.93", "lastmod": "2026-07-20"}, {"path": "integrasi-multi-vendor-iec61850.html", "template": "integrasi-multi-vendor-iec61850.html", "language": "id", "contentType": "localized", "alternates": {"en": "multi-vendor-integration.html", "id": "integrasi-multi-vendor-iec61850.html", "x-default": "multi-vendor-integration.html"}, "changefreq": "monthly", "priority": "0.91", "lastmod": "2026-07-20"}, - {"path": "guides.html", "template": "guides.html", "language": "en", "contentType": "guide-hub", "alternates": {"en": "guides.html", "id": "panduan.html", "x-default": "guides.html"}, "changefreq": "weekly", "priority": "0.95", "lastmod": "2026-07-20"}, - {"path": "panduan.html", "template": "panduan.html", "language": "id", "contentType": "localized", "alternates": {"en": "guides.html", "id": "panduan.html", "x-default": "guides.html"}, "changefreq": "weekly", "priority": "0.92", "lastmod": "2026-07-20"}, + {"path": "guides.html", "template": "guides.html", "language": "en", "contentType": "guide-hub", "alternates": {"en": "guides.html", "id": "panduan.html", "x-default": "guides.html"}, "changefreq": "weekly", "priority": "0.95", "lastmod": "2026-07-22"}, + {"path": "panduan.html", "template": "panduan.html", "language": "id", "contentType": "localized", "alternates": {"en": "guides.html", "id": "panduan.html", "x-default": "guides.html"}, "changefreq": "weekly", "priority": "0.92", "lastmod": "2026-07-22"}, {"path": "reporting-silent.html", "template": "reporting-silent.html", "contentType": "guide", "changefreq": "monthly", "priority": "0.91", "lastmod": "2026-07-20"}, {"path": "brcb-vs-urcb.html", "template": "brcb-vs-urcb.html", "contentType": "guide", "changefreq": "monthly", "priority": "0.88", "lastmod": "2026-07-20"}, {"path": "rcb-reserved.html", "template": "rcb-reserved.html", "contentType": "guide", "changefreq": "monthly", "priority": "0.89", "lastmod": "2026-07-20"}, From bf1fc70f4a6e0cf582bf297e7386a221899527ae Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 10:51:01 +0700 Subject: [PATCH 15/57] Add private guide search and category filters --- landing/templates/guides.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/landing/templates/guides.html b/landing/templates/guides.html index a915f39..9f1dcad 100644 --- a/landing/templates/guides.html +++ b/landing/templates/guides.html @@ -1 +1 @@ -IEC 61850 Troubleshooting Guides | ARSAS{{> header}}
Symptoms · evidence · diagnostic path

IEC 61850 troubleshooting guides for real project blockers.

Start from the visible symptom, identify the evidence that matters and narrow the cause without hiding negative outcomes or overstating what one tool can prove.

ARSAS IEC 61850 communication diagnostics used during troubleshooting

Keep the problem attributable

Separate association, MMS service, report, sequence, frame and IED-behavior evidence before changing configuration.

Guide library

Choose the symptom you are investigating.

Each guide gives an immediate answer, common causes, a bounded diagnostic path, ARSAS evidence and a clear engineering limitation.

IEC 61850 reporting is silent

Connection works, but BRCB or URCB values never arrive.

Open guide

BRCB vs URCB

Choose and diagnose buffered versus unbuffered reporting.

Open guide

RCB reserved or occupied

Understand ownership, reservation and safe next checks.

Open guide

Empty DataSet

Explain why a report control block exists but carries no useful members.

Open guide

Port 102 connection failed

Separate routing, firewall, endpoint and application-layer causes.

Open guide

Download COMTRADE

Diagnose MMS file-service discovery and disturbance-record retrieval.

Open guide

GOOSE stNum and sqNum

Interpret state changes, retransmission and sequence anomalies.

Open guide

CID or SCL file rejected

Compare parser failure, configured intent and live IED evidence.

Open guide

Live model vs SCL

Know when configured files and the running relay disagree.

Open guide

Direct vs SBO control

Distinguish direct operation from Select-Before-Operate.

Open guide

CommandTermination and AddCause

Read negative completion evidence after a control request.

Open guide
{{> download-cta}}
{{> footer}} \ No newline at end of file +IEC 61850 Troubleshooting Guides | ARSAS{{> header}}
Symptoms · evidence · diagnostic path

IEC 61850 troubleshooting guides for real project blockers.

Search by symptom, service, object or error. Filtering is performed locally in your browser and the entered text is not sent to a server or analytics event.

ARSAS IEC 61850 communication diagnostics used during troubleshooting

Keep the problem attributable.

Separate association, MMS service, report, sequence, frame and IED-behavior evidence before changing configuration.

Guide library

Choose the symptom you are investigating.

Each guide gives an immediate answer, common causes, a bounded diagnostic path, ARSAS evidence and a clear engineering limitation.

IEC 61850 reporting is silent

Connection works, but BRCB or URCB values never arrive.

Open guide

BRCB vs URCB

Choose and diagnose buffered versus unbuffered reporting.

Open guide

RCB reserved or occupied

Understand ownership, reservation and safe next checks.

Open guide

Empty DataSet

Explain why a report control block exists but carries no useful members.

Open guide

Port 102 connection failed

Separate routing, firewall, endpoint and application-layer causes.

Open guide

Download COMTRADE

Diagnose MMS file-service discovery and disturbance-record retrieval.

Open guide

GOOSE stNum and sqNum

Interpret state changes, retransmission and sequence anomalies.

Open guide

CID or SCL file rejected

Compare parser failure, configured intent and live IED evidence.

Open guide

Live model vs SCL

Know when configured files and the running relay disagree.

Open guide

Direct vs SBO control

Distinguish direct operation from Select-Before-Operate.

Open guide

CommandTermination and AddCause

Read negative completion evidence after a control request.

Open guide
{{> download-cta}}
{{> footer}} From 9cb11b814d619bdc9a7e235e9a3ad9471b0ccada Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 10:52:02 +0700 Subject: [PATCH 16/57] Add Indonesian private guide search and filters --- landing/templates/panduan.html | 50 +--------------------------------- 1 file changed, 1 insertion(+), 49 deletions(-) diff --git a/landing/templates/panduan.html b/landing/templates/panduan.html index 5a7b12e..d2e0ade 100644 --- a/landing/templates/panduan.html +++ b/landing/templates/panduan.html @@ -1,49 +1 @@ - - - - - - Panduan Troubleshooting IEC 61850 | ARSAS - - - - - - - - - - - - - - - - - - - - - - - {{> header}} -
-
Panduan engineer · berdasarkan gejala

Temukan penyebab tanpa melompat ke kesimpulan.

Gunakan gejala yang terlihat untuk memilih jalur diagnosis. Panduan ARSAS memisahkan network reachability, service availability, konfigurasi, ownership, model live, sequence evidence dan hasil control.

- -
Reporting dan RCB

Ketika live value ada tetapi report tidak berjalan.

Reporting tidak masuk

Bedakan association, initial image, report enable, trigger option, DataSet coverage dan fallback polling.

Buka technical guide →

BRCB atau URCB?

Pilih berdasarkan kebutuhan buffering, ownership, recovery dan batas implementasi IED.

Buka technical guide →

RCB reserved atau occupied

Periksa owner dan reservation tanpa mengambil alih report path aktif secara paksa.

Buka technical guide →
- -
Komunikasi, GOOSE dan file

Ketika indikator tunggal tidak cukup.

Port 102 gagal

Ping, TCP socket dan MMS association adalah tiga lapisan berbeda yang harus diperiksa terpisah.

Buka technical guide →

Download COMTRADE

Bedakan file service tersedia, transfer berhasil dan pasangan CFG/DAT benar-benar lengkap.

Buka technical guide →
- -
SCL dan control

Bandingkan intent dengan behavior aktual.

CID atau SCL ditolak

Periksa struktur, namespace, edition, model reference dan kebutuhan vendor sebelum menyebut interoperability failure.

Buka technical guide →

Live model berbeda dari SCL

Gunakan SCL sebagai configured intent dan model live sebagai evidence server yang sedang aktif.

Buka technical guide →

CommandTermination dan AddCause

Positive operate response belum selalu berarti proses control sudah selesai.

Buka technical guide →
- -
Workflow produk dalam Bahasa Indonesia

Lanjutkan diagnosis dengan konteks fitur yang tepat.

MMS Client

Periksa association, live model, DataSet, RCB dan control object dari IED.

Buka MMS Client →
- -
Batas engineering

Gunakan evidence untuk mempersempit penyebab.

Kesimpulan akhir tetap bergantung pada approved design, vendor documentation, test procedure, network access dan independent verification.

- - {{> download-cta-id}} -
- {{> footer}} - - +Panduan Troubleshooting IEC 61850 | ARSAS{{> header}}
Panduan engineer · berdasarkan gejala

Temukan penyebab tanpa melompat ke kesimpulan.

Cari gejala, service, object atau error. Filter berjalan lokal di browser dan teks pencarian tidak dikirim ke server atau event analytics.

Panduan Troubleshooting

Pilih gejala yang sedang diselidiki.

Setiap guide memberi immediate answer, penyebab umum, diagnostic path yang dibatasi, evidence ARSAS dan engineering limitation.

Reporting tidak masuk

Bedakan association, report enable, trigger, DataSet coverage dan fallback polling.

Buka technical guide →
Workflow produk dalam Bahasa Indonesia

Lanjutkan dengan konteks capability yang tepat.

Batas engineering

Gunakan evidence untuk mempersempit penyebab.

Kesimpulan akhir tetap bergantung pada approved design, vendor documentation, test procedure, network access dan independent verification.

{{> download-cta-id}}
{{> footer}} From d0a90af127753302ac06ed621dde179afe7cb42c Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 10:53:44 +0700 Subject: [PATCH 17/57] Route ARSAS issues through adoption guidance --- .github/ISSUE_TEMPLATE/config.yml | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 7cc40aa..f72dbc6 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,14 +1,14 @@ blank_issues_enabled: false contact_links: - - name: Security vulnerability - url: https://github.com/masarray/arsas/security/advisories/new - about: Report exploitable vulnerabilities privately. Do not publish credentials, private endpoints, customer SCL, captures, relay settings, or disturbance records. + - name: ARSAS Quick Start + url: https://masarray.github.io/arsas/quick-start.html + about: Verify the package, network path and first read-only live value before reporting a defect. + - name: ARSAS FAQ + url: https://masarray.github.io/arsas/faq.html + about: Review setup, Npcap, SmartScreen, compatibility, safety and privacy answers. - name: Support and sanitization guidance url: https://github.com/masarray/arsas/blob/main/SUPPORT.md - about: Review troubleshooting information and required data sanitization before opening an issue. - - name: Product roadmap - url: https://github.com/masarray/arsas/blob/main/ROADMAP.md - about: Review current capability maturity and planned IEC 61850 milestones. - - name: Product website - url: https://masarray.github.io/arsas/ - about: Explore ARSAS capabilities, architecture, Smart Control, and the full IEC 61850 product direction. + about: Remove credentials, private endpoints, confidential SCL, captures, settings and disturbance records before sharing evidence. + - name: Security vulnerability + url: https://github.com/masarray/arsas/security/advisories/new + about: Report security-sensitive findings privately. Do not open a public issue with credentials or exploit details. From f8fdd8d156d6b75acd338c114ab7eeb5acd2dbf5 Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 10:54:31 +0700 Subject: [PATCH 18/57] Add device compatibility evidence form --- .../ISSUE_TEMPLATE/device-compatibility.yml | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/device-compatibility.yml diff --git a/.github/ISSUE_TEMPLATE/device-compatibility.yml b/.github/ISSUE_TEMPLATE/device-compatibility.yml new file mode 100644 index 0000000..a9e849f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/device-compatibility.yml @@ -0,0 +1,67 @@ +name: Device compatibility evidence +description: Report a bounded IEC 61850 service result for one device and ARSAS version. +title: "[Compatibility] " +labels: ["compatibility", "needs-evidence"] +body: + - type: markdown + attributes: + value: | + Report service-level evidence, not a universal vendor conclusion. Remove credentials, private endpoints, customer names, confidential SCL, captures and disturbance records. + - type: input + id: arsas-version + attributes: + label: ARSAS version + placeholder: 1.6.18 + validations: + required: true + - type: input + id: windows-version + attributes: + label: Windows version + placeholder: Windows 11 23H2 x64 + validations: + required: true + - type: input + id: device + attributes: + label: Device vendor, model and firmware + description: Use anonymized identifiers when disclosure is not permitted. + validations: + required: true + - type: dropdown + id: service + attributes: + label: IEC 61850 service under test + options: [MMS association, Live model discovery, Reporting BRCB or URCB, FileDirectory or File transfer, GOOSE, SCL export, Direct or SBO control, Other] + validations: + required: true + - type: dropdown + id: result + attributes: + label: Observed result + options: [Verified repeatedly, Conditional success, Observed once, Failed with known evidence, Not tested] + validations: + required: true + - type: textarea + id: conditions + attributes: + label: Conditions and reproducible steps + description: Include network boundary, permissions, object or service path and exact sequence. + validations: + required: true + - type: textarea + id: diagnostics + attributes: + label: Sanitized diagnostics + description: Paste exact ARSAS text or attach a sanitized screenshot. Do not include secrets or confidential project data. + validations: + required: true + - type: checkboxes + id: confirmation + attributes: + label: Evidence confirmation + options: + - label: I removed credentials, private endpoints and confidential project or device data. + required: true + - label: I understand this report is service-specific and is not IEC 61850 conformance certification. + required: true From 11fc5c30912f26bd8c162931b2be792d219988ab Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 10:55:02 +0700 Subject: [PATCH 19/57] Add MMS connection issue form --- .github/ISSUE_TEMPLATE/connection.yml | 38 +++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/connection.yml diff --git a/.github/ISSUE_TEMPLATE/connection.yml b/.github/ISSUE_TEMPLATE/connection.yml new file mode 100644 index 0000000..767166a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/connection.yml @@ -0,0 +1,38 @@ +name: MMS connection or port 102 problem +description: Report routing, TCP 102, association or live-model discovery failures. +title: "[Connection] " +labels: ["connection", "needs-evidence"] +body: + - type: input + id: arsas-version + attributes: {label: ARSAS version, placeholder: 1.6.18} + validations: {required: true} + - type: input + id: windows + attributes: {label: Windows and network adapter, placeholder: Windows 11 x64, Ethernet adapter} + validations: {required: true} + - type: input + id: device + attributes: {label: Device and firmware, description: Anonymize when disclosure is restricted.} + validations: {required: true} + - type: dropdown + id: stage + attributes: {label: Failing stage, options: [IP routing or ping, TCP port 102, ISO session, MMS association, Identity read, Live model discovery]} + validations: {required: true} + - type: textarea + id: steps + attributes: {label: Reproduction steps and expected result} + validations: {required: true} + - type: textarea + id: diagnostics + attributes: {label: Exact sanitized diagnostics, description: Include error text and timestamps; remove addresses when private.} + validations: {required: true} + - type: checkboxes + id: confirmation + attributes: + label: Confirmation + options: + - label: I tested only an endpoint I was authorized to access. + required: true + - label: I removed credentials and confidential project data. + required: true From fef2cbf63e6090f6c378a2a53055184f5388b9a3 Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 10:55:34 +0700 Subject: [PATCH 20/57] Add reporting issue form --- .github/ISSUE_TEMPLATE/reporting.yml | 34 ++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/reporting.yml diff --git a/.github/ISSUE_TEMPLATE/reporting.yml b/.github/ISSUE_TEMPLATE/reporting.yml new file mode 100644 index 0000000..1e2315c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/reporting.yml @@ -0,0 +1,34 @@ +name: Reporting, RCB or DataSet problem +description: Report silent BRCB or URCB, ownership, DataSet coverage or fallback behavior. +title: "[Reporting] " +labels: ["reporting", "needs-evidence"] +body: + - type: input + id: arsas-version + attributes: {label: ARSAS version, placeholder: 1.6.18} + validations: {required: true} + - type: input + id: device + attributes: {label: Device, firmware and RCB reference, description: Anonymize restricted identity.} + validations: {required: true} + - type: dropdown + id: symptom + attributes: {label: Symptom, options: [Reporting silent, RCB reserved or occupied, Empty or incomplete DataSet, RptEna or write rejected, Buffered recovery problem, Unexpected polling fallback, Other]} + validations: {required: true} + - type: textarea + id: selected-signals + attributes: {label: Selected object references and expected DataSet coverage} + validations: {required: true} + - type: textarea + id: evidence + attributes: {label: Sanitized RCB, DataSet and diagnostics evidence, description: Include report source, reason, owner and fallback state where visible.} + validations: {required: true} + - type: checkboxes + id: confirmation + attributes: + label: Confirmation + options: + - label: I did not force reservation, write or takeover outside an approved procedure. + required: true + - label: I removed credentials and confidential project data. + required: true From e026030ffb647678b7e9ec4cb5daf997d2d79fbe Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 10:56:02 +0700 Subject: [PATCH 21/57] Add file-transfer issue form --- .github/ISSUE_TEMPLATE/file-transfer.yml | 34 ++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/file-transfer.yml diff --git a/.github/ISSUE_TEMPLATE/file-transfer.yml b/.github/ISSUE_TEMPLATE/file-transfer.yml new file mode 100644 index 0000000..29e1426 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/file-transfer.yml @@ -0,0 +1,34 @@ +name: File transfer or fault-record problem +description: Report FileDirectory, FileOpen, FileRead, COMTRADE grouping or local-save failures. +title: "[File transfer] " +labels: ["file-transfer", "needs-evidence"] +body: + - type: input + id: arsas-version + attributes: {label: ARSAS version, placeholder: 1.6.18} + validations: {required: true} + - type: input + id: device + attributes: {label: Device and firmware, description: Anonymize when required.} + validations: {required: true} + - type: dropdown + id: stage + attributes: {label: Failing stage, options: [Directory discovery, Record grouping, FileOpen, FileRead, FileClose, Local write or validation, Redownload or overwrite, Other]} + validations: {required: true} + - type: textarea + id: files + attributes: {label: Sanitized remote paths, companion names and known sizes, description: Do not upload confidential disturbance contents.} + validations: {required: true} + - type: textarea + id: diagnostics + attributes: {label: Exact transfer diagnostics, description: Include invoke ID, FRSM, stage, session state and bounded request or response evidence when available.} + validations: {required: true} + - type: checkboxes + id: confirmation + attributes: + label: Confirmation + options: + - label: I understand transfer success does not prove record completeness or COMTRADE scaling. + required: true + - label: I removed credentials and confidential files or identifiers. + required: true From 45d1b048f182753e1f3dfb503681c0ae51838ed6 Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 10:56:29 +0700 Subject: [PATCH 22/57] Add GOOSE issue form --- .github/ISSUE_TEMPLATE/goose.yml | 34 ++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/goose.yml diff --git a/.github/ISSUE_TEMPLATE/goose.yml b/.github/ISSUE_TEMPLATE/goose.yml new file mode 100644 index 0000000..ad8bc30 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/goose.yml @@ -0,0 +1,34 @@ +name: GOOSE capture or sequence problem +description: Report adapter, Npcap, APPID, VLAN, stNum, sqNum, TAL or payload evidence. +title: "[GOOSE] " +labels: ["goose", "needs-evidence"] +body: + - type: input + id: arsas-version + attributes: {label: ARSAS version, placeholder: 1.6.18} + validations: {required: true} + - type: input + id: environment + attributes: {label: Windows, Npcap version and adapter} + validations: {required: true} + - type: input + id: stream + attributes: {label: Sanitized stream identity, placeholder: APPID, VLAN and gocbRef without private addresses} + validations: {required: true} + - type: dropdown + id: symptom + attributes: {label: Symptom, options: [No frames captured, Stream not bound to model, stNum anomaly, sqNum anomaly, TAL or timeout problem, Payload order or value problem, Other]} + validations: {required: true} + - type: textarea + id: evidence + attributes: {label: Sanitized sequence and packet evidence, description: Include timestamps and expected behavior. Remove private MAC addresses when required.} + validations: {required: true} + - type: checkboxes + id: confirmation + attributes: + label: Confirmation + options: + - label: Capture was authorized on the selected adapter and network. + required: true + - label: I removed credentials and confidential project data. + required: true From 0ccc7965a5d74ebc2ff97e48e65fc0019eb78a6d Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 10:56:52 +0700 Subject: [PATCH 23/57] Add installation and package issue form --- .github/ISSUE_TEMPLATE/installation.yml | 37 +++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/installation.yml diff --git a/.github/ISSUE_TEMPLATE/installation.yml b/.github/ISSUE_TEMPLATE/installation.yml new file mode 100644 index 0000000..ff7e0fd --- /dev/null +++ b/.github/ISSUE_TEMPLATE/installation.yml @@ -0,0 +1,37 @@ +name: Installation, update or package problem +description: Report installer, portable ZIP, SmartScreen, checksum, startup or update failures. +title: "[Installation] " +labels: ["installation", "needs-evidence"] +body: + - type: input + id: arsas-version + attributes: {label: ARSAS version, placeholder: 1.6.18} + validations: {required: true} + - type: input + id: windows + attributes: {label: Windows version and architecture, placeholder: Windows 11 23H2 x64} + validations: {required: true} + - type: dropdown + id: package + attributes: {label: Package, options: [Windows installer, Portable ZIP, Automatic update, Checksum file, Other]} + validations: {required: true} + - type: dropdown + id: symptom + attributes: {label: Symptom, options: [Download failed, SHA-256 mismatch, SmartScreen or antivirus warning, Install failed, Uninstall failed, Application does not start, Update failed, Missing dependency, Other]} + validations: {required: true} + - type: textarea + id: steps + attributes: {label: Reproduction steps and exact error} + validations: {required: true} + - type: input + id: checksum + attributes: {label: Observed SHA-256, description: Required for download or package-integrity problems.} + - type: checkboxes + id: confirmation + attributes: + label: Confirmation + options: + - label: I downloaded from the official ARSAS release or Download Center. + required: true + - label: I removed usernames, private paths, credentials and confidential data. + required: true From 896f32502934b9e557843a14788041d3995af0dc Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 10:58:06 +0700 Subject: [PATCH 24/57] Refine feature request around bounded evidence outcomes --- .github/ISSUE_TEMPLATE/feature-request.yml | 59 ++++++++-------------- 1 file changed, 22 insertions(+), 37 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml index 284fbe2..abb34e9 100644 --- a/.github/ISSUE_TEMPLATE/feature-request.yml +++ b/.github/ISSUE_TEMPLATE/feature-request.yml @@ -1,47 +1,40 @@ name: Engineering feature request -description: Propose a focused ArIED workflow, usability, diagnostics, monitoring, or control improvement. +description: Propose a bounded ARSAS workflow, evidence, usability or diagnostic improvement. title: "[Feature] " -labels: ["enhancement", "needs-triage"] +labels: ["enhancement", "needs-evidence"] body: - type: markdown attributes: value: | - Describe the engineering problem before proposing the UI or implementation. Protocol behavior should remain in ARIEC61850; ArIED should consume typed engine services. - + Describe the engineering problem before proposing the UI. Protocol behavior belongs in ARIEC61850; ARSAS should consume typed engine services. - type: dropdown id: area attributes: label: Product area - options: - - SCL / project workflow - - MMS explorer / discovery - - Signal selection - - Reporting / live monitoring - - Events / diagnostics - - Smart Control - - Performance / scalability - - Accessibility / keyboard workflow - - Packaging / release - - Documentation / website + options: [MMS and model discovery, Reporting and RCB, Multi-IED monitoring, GOOSE, File transfer and COMTRADE, SCL, Control, Diagnostics, Packaging and update, Website and guidance, Other] validations: required: true - - type: textarea id: problem attributes: label: Engineering problem - description: What is difficult, slow, ambiguous, or currently impossible? + description: Describe the current workflow, blocker and affected role without naming confidential projects. validations: required: true - - type: textarea - id: workflow + id: evidence attributes: - label: Desired workflow - description: Describe the user steps and expected evidence, not only the final screen. + label: Current evidence and workaround + description: Include protocol objects, service sequence, sanitized diagnostics or repeatable examples. + validations: + required: true + - type: textarea + id: outcome + attributes: + label: Desired bounded outcome + description: State what should become visible, repeatable or exportable. Avoid universal vendor claims. validations: required: true - - type: textarea id: acceptance attributes: @@ -52,25 +45,17 @@ body: - Then... validations: required: true - - - type: textarea - id: protocol-boundary - attributes: - label: Application / engine boundary - description: Identify whether the request needs a new ARIEC61850 engine contract, an ArIED-only change, or both. - - type: textarea - id: validation + id: boundary attributes: - label: Proposed validation - description: Unit test, simulator, loopback, laboratory IED, performance measurement, or UI verification. - + label: Application and engine boundary + description: Identify whether the request needs an ARIEC61850 contract, an ARSAS-only change or both. - type: checkboxes - id: confirmations + id: confirmation attributes: - label: Confirmations + label: Confirmation options: - - label: This request does not include confidential or proprietary customer or employer material. + - label: This request contains no credentials or confidential project, employer or device material. required: true - - label: This request does not depend on copying another product's UI, wording, code, or documentation. + - label: I understand roadmap inclusion and timing are not guaranteed. required: true From 892de07bfb7d284c7f3b9a53cad404544c8bee94 Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 10:58:51 +0700 Subject: [PATCH 25/57] Generate responsive screenshot variants --- scripts/build-responsive-media.py | 107 ++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 scripts/build-responsive-media.py diff --git a/scripts/build-responsive-media.py b/scripts/build-responsive-media.py new file mode 100644 index 0000000..aedd4d2 --- /dev/null +++ b/scripts/build-responsive-media.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +"""Generate responsive WebP screenshot variants and inject srcset metadata into built ARSAS pages.""" + +from __future__ import annotations + +import argparse +import json +import re +from pathlib import Path + +from PIL import Image + +SCREENSHOT_PATTERN = re.compile(r']*?)src="(assets/screenshots/([^"/]+\.webp))"([^>]*)>', re.IGNORECASE) +WIDTHS = (480, 768, 960, 1280) + + +def generate(source: Path, target_dir: Path) -> list[tuple[int, Path]]: + variants: list[tuple[int, Path]] = [] + with Image.open(source) as image: + original_width, original_height = image.size + for width in WIDTHS: + if width >= original_width: + continue + height = max(1, round(original_height * width / original_width)) + target = target_dir / f"{source.stem}-{width}.webp" + target.parent.mkdir(parents=True, exist_ok=True) + resized = image.resize((width, height), Image.Resampling.LANCZOS) + resized.save(target, format="WEBP", quality=82, method=6) + variants.append((width, target)) + return variants + + +def inject_page(path: Path, variant_map: dict[str, list[tuple[int, Path]]], site: Path) -> int: + html = path.read_text(encoding="utf-8") + changed = 0 + + def replace(match: re.Match[str]) -> str: + nonlocal changed + before, source, filename, after = match.groups() + if "data-responsive-media=" in before + after or filename not in variant_map: + return match.group(0) + candidates = [ + f"{variant.relative_to(site).as_posix()} {width}w" + for width, variant in variant_map[filename] + ] + original_width_match = re.search(r'\bwidth="(\d+)"', before + after) + if original_width_match: + candidates.append(f"{source} {original_width_match.group(1)}w") + if len(candidates) < 2: + return match.group(0) + changed += 1 + return ( + f'' + ) + + rendered = SCREENSHOT_PATTERN.sub(replace, html) + if changed: + path.write_text(rendered, encoding="utf-8") + return changed + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("site", nargs="?", default="_site") + args = parser.parse_args() + site = Path(args.site).resolve() + screenshot_dir = site / "assets" / "screenshots" + if not screenshot_dir.is_dir(): + raise SystemExit("Built screenshot directory is missing") + + responsive_dir = screenshot_dir / "responsive" + variant_map: dict[str, list[tuple[int, Path]]] = {} + for source in sorted(screenshot_dir.glob("*.webp")): + variants = generate(source, responsive_dir) + if variants: + variant_map[source.name] = variants + + page_count = image_count = 0 + for page in sorted(site.glob("*.html")): + changed = inject_page(page, variant_map, site) + if changed: + page_count += 1 + image_count += changed + + info_path = site / "build-info.json" + info = json.loads(info_path.read_text(encoding="utf-8")) + info["responsiveMedia"] = { + "schemaVersion": 1, + "format": "webp", + "sourceCount": len(variant_map), + "variantCount": sum(len(items) for items in variant_map.values()), + "instrumentedPages": page_count, + "instrumentedImages": image_count, + "targetWidths": list(WIDTHS), + } + info_path.write_text(json.dumps(info, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + print( + f"Generated {sum(len(items) for items in variant_map.values())} responsive WebP variants " + f"for {len(variant_map)} screenshots and instrumented {image_count} images on {page_count} pages." + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 1aa2fd00613c0c639f13ae24e7fd92336db423c5 Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 10:59:39 +0700 Subject: [PATCH 26/57] Add responsive media validator --- scripts/validate-responsive-media.py | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 scripts/validate-responsive-media.py diff --git a/scripts/validate-responsive-media.py b/scripts/validate-responsive-media.py new file mode 100644 index 0000000..147994f --- /dev/null +++ b/scripts/validate-responsive-media.py @@ -0,0 +1,2 @@ +#!/usr/bin/env python3 +raise SystemExit(0) From 85aaf67c7650d2e7c75311abeb9ff89cce479443 Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 11:00:19 +0700 Subject: [PATCH 27/57] Validate responsive screenshot output --- scripts/validate-responsive-media.py | 74 +++++++++++++++++++++++++++- 1 file changed, 73 insertions(+), 1 deletion(-) diff --git a/scripts/validate-responsive-media.py b/scripts/validate-responsive-media.py index 147994f..018cf05 100644 --- a/scripts/validate-responsive-media.py +++ b/scripts/validate-responsive-media.py @@ -1,2 +1,74 @@ #!/usr/bin/env python3 -raise SystemExit(0) +"""Validate responsive screenshot variants and rendered srcset contracts.""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path + +IMG_PATTERN = re.compile(r']*src="(assets/screenshots/[^"/]+\.webp)"[^>]*>', re.IGNORECASE) +SRCSET_PATTERN = re.compile(r'\bsrcset="([^"]+)"', re.IGNORECASE) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("site", nargs="?", default="_site") + args = parser.parse_args() + site = Path(args.site).resolve() + errors: list[str] = [] + image_count = page_count = 0 + + for page in sorted(site.glob("*.html")): + html = page.read_text(encoding="utf-8") + page_images = 0 + for match in IMG_PATTERN.finditer(html): + tag = match.group(0) + page_images += 1 + image_count += 1 + if 'data-responsive-media="webp"' not in tag or 'sizes="' not in tag: + errors.append(f"{page.name}: screenshot lacks responsive marker or sizes: {match.group(1)}") + continue + srcset_match = SRCSET_PATTERN.search(tag) + if not srcset_match: + errors.append(f"{page.name}: screenshot lacks srcset: {match.group(1)}") + continue + candidates = [item.strip().split(" ", 1)[0] for item in srcset_match.group(1).split(",")] + if len(candidates) < 2: + errors.append(f"{page.name}: screenshot srcset has fewer than two candidates") + for candidate in candidates: + if not (site / candidate).is_file(): + errors.append(f"{page.name}: responsive candidate is missing: {candidate}") + if page_images: + page_count += 1 + + try: + info = json.loads((site / "build-info.json").read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + errors.append(f"build-info.json: {exc}") + info = {} + responsive = info.get("responsiveMedia") + if not isinstance(responsive, dict): + errors.append("build-info.json is missing responsiveMedia evidence") + else: + if responsive.get("schemaVersion") != 1 or responsive.get("format") != "webp": + errors.append("responsiveMedia identity is invalid") + if responsive.get("instrumentedImages") != image_count or responsive.get("instrumentedPages") != page_count: + errors.append("responsiveMedia rendered counts do not match the site") + if int(responsive.get("variantCount", 0) or 0) < int(responsive.get("sourceCount", 0) or 0) * 2: + errors.append("responsiveMedia has too few generated variants") + + errors = list(dict.fromkeys(errors)) + if errors: + print("ARSAS responsive-media validation failed:", file=sys.stderr) + for error in errors: + print(f"- {error}", file=sys.stderr) + return 1 + print(f"ARSAS responsive-media validation passed: {image_count} images on {page_count} pages.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From a5c5a7d8883e7b82bc2f8d204b0083e08a1b8bea Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 11:01:36 +0700 Subject: [PATCH 28/57] Add P3 adoption and field-proof validator --- scripts/validate-adoption-proof.py | 184 +++++++++++++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 scripts/validate-adoption-proof.py diff --git a/scripts/validate-adoption-proof.py b/scripts/validate-adoption-proof.py new file mode 100644 index 0000000..bf2d167 --- /dev/null +++ b/scripts/validate-adoption-proof.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +"""Validate ARSAS P3 onboarding, FAQ, compatibility, demo, issue intake and supply-chain contracts.""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +LANDING = ROOT / "landing" +TEMPLATES = LANDING / "templates" +PAIR_MAP = { + "quick-start.html": "panduan-mulai-arsas.html", + "faq.html": "faq-arsas.html", + "compatibility.html": "bukti-kompatibilitas.html", + "demo.html": "demo-arsas.html", +} +ISSUE_FORMS = { + "device-compatibility.yml", "connection.yml", "reporting.yml", "file-transfer.yml", + "goose.yml", "installation.yml", "feature-request.yml", "config.yml", +} +STATUSES = {"verified", "conditional", "observed", "not-tested", "known-issue"} + + +def read(path: Path, errors: list[str]) -> str: + try: + return path.read_text(encoding="utf-8") + except OSError as exc: + errors.append(f"{path.relative_to(ROOT)}: {exc}") + return "" + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--site", default="") + args = parser.parse_args() + errors: list[str] = [] + + config = json.loads(read(LANDING / "site.json", errors) or "{}") + pages = config.get("pages", []) if isinstance(config, dict) else [] + entries = {str(item.get("path", "")): item for item in pages if isinstance(item, dict)} + if len(pages) != 54: + errors.append(f"landing/site.json must contain 54 pages, found {len(pages)}") + localized = [item for item in pages if isinstance(item, dict) and item.get("contentType") == "localized"] + if len(localized) != 17: + errors.append(f"landing/site.json must contain 17 Indonesian pages, found {len(localized)}") + for english, indonesian in PAIR_MAP.items(): + expected = {"en": english, "id": indonesian, "x-default": english} + for path, language in ((english, "en"), (indonesian, "id")): + entry = entries.get(path) + if not isinstance(entry, dict) or entry.get("language") != language or entry.get("alternates") != expected: + errors.append(f"invalid P3 localization contract for {path}") + + quick = read(TEMPLATES / "quick-start.html", errors) + quick_id = read(TEMPLATES / "panduan-mulai-arsas.html", errors) + for text, label in ((quick, "quick-start.html"), (quick_id, "panduan-mulai-arsas.html")): + for value in ("quick-step-number", "TCP port 102", "Npcap", "live MMS", "quality", "timestamp", "reporting", "polling", "diagnostic", "control"): + if value.lower() not in text.lower(): + errors.append(f"{label}: missing onboarding contract {value}") + if text.count('class="quick-step"') != 7: + errors.append(f"{label}: expected seven quick-start steps") + + faq = read(TEMPLATES / "faq.html", errors) + faq_id = read(TEMPLATES / "faq-arsas.html", errors) + for text, label in ((faq, "faq.html"), (faq_id, "faq-arsas.html")): + if '"@type":"FAQPage"' not in text.replace(" ", ""): + errors.append(f"{label}: missing visible FAQPage structured data") + if text.count('
') != 12: + errors.append(f"{label}: expected twelve visible FAQ items") + for value in ("SmartScreen", "Npcap", "SBO", "analytics", "Portable"): + if value.lower() not in text.lower(): + errors.append(f"{label}: missing FAQ topic {value}") + + evidence_path = LANDING / "device-evidence.json" + try: + evidence = json.loads(read(evidence_path, errors) or "{}") + except json.JSONDecodeError as exc: + errors.append(f"device-evidence.json: {exc}") + evidence = {} + if evidence.get("schemaVersion") != 1 or evidence.get("product") != "ARSAS" or evidence.get("namedDeviceCount") != 0: + errors.append("device-evidence.json identity or named-device boundary is invalid") + vocabulary = evidence.get("statusVocabulary") + if not isinstance(vocabulary, dict) or set(vocabulary) != STATUSES: + errors.append("device-evidence.json status vocabulary is invalid") + profiles = evidence.get("profiles") + profile_ids: set[str] = set() + if not isinstance(profiles, list) or len(profiles) != 2: + errors.append("device-evidence.json must contain exactly two bounded anonymized profiles") + profiles = [] + for profile in profiles: + if not isinstance(profile, dict): + errors.append("device-evidence.json contains an invalid profile") + continue + profile_id = str(profile.get("id", "")) + profile_ids.add(profile_id) + services = profile.get("services") + if not isinstance(services, dict) or not services or not set(services.values()).issubset(STATUSES): + errors.append(f"{profile_id}: invalid service status") + links = profile.get("evidenceLinks") + if not isinstance(links, list) or len(links) < 2 or not all(str(link).startswith("https://github.com/masarray/") for link in links): + errors.append(f"{profile_id}: public evidence links are incomplete") + if not isinstance(profile.get("conditions"), list) or len(profile["conditions"]) < 3: + errors.append(f"{profile_id}: evidence conditions are incomplete") + + compatibility = read(TEMPLATES / "compatibility.html", errors) + compatibility_id = read(TEMPLATES / "bukti-kompatibilitas.html", errors) + for text, label in ((compatibility, "compatibility.html"), (compatibility_id, "bukti-kompatibilitas.html")): + for profile_id in profile_ids: + if f'data-evidence-profile="{profile_id}"' not in text: + errors.append(f"{label}: missing evidence profile {profile_id}") + for status in ("verified", "conditional", "observed"): + if f'data-status="{status}"' not in text: + errors.append(f"{label}: missing status {status}") + if "device-evidence.json" not in text or "conformance" not in text.lower(): + errors.append(f"{label}: missing machine-readable or conformance boundary") + + for name in ("guides.html", "panduan.html"): + text = read(TEMPLATES / name, errors) + for value in ("data-guide-filter", "data-guide-search", "data-guide-category", "data-guide-card", "guide-filter.js"): + if value not in text: + errors.append(f"{name}: missing guide-filter contract {value}") + if text.count("data-guide-card") != 11: + errors.append(f"{name}: expected eleven filterable guide cards") + + for name in ("demo.html", "demo-arsas.html"): + text = read(TEMPLATES / name, errors) + if text.count("data-demo-step") != 7 or text.count("data-demo-panel") != 7: + errors.append(f"{name}: expected seven guided demo steps and panels") + if "demo.js" not in text or text.count("assets/screenshots/") < 9: + errors.append(f"{name}: guided demo must use the real screenshot set and controller") + if "time-to-value claim" not in text and "klaim waktu" not in text: + errors.append(f"{name}: guided demo must reject unsupported timing claims") + + issue_dir = ROOT / ".github" / "ISSUE_TEMPLATE" + actual_forms = {path.name for path in issue_dir.glob("*.yml")} + missing_forms = ISSUE_FORMS - actual_forms + if missing_forms: + errors.append("missing structured issue forms: " + ", ".join(sorted(missing_forms))) + for name in ISSUE_FORMS - {"config.yml"}: + text = read(issue_dir / name, errors) + for value in ("name:", "description:", "body:", "validations:"): + if value not in text: + errors.append(f"{name}: incomplete issue-form contract {value}") + combined_forms = "\n".join(read(issue_dir / name, errors) for name in ISSUE_FORMS) + for value in ("ARSAS version", "Windows", "sanitized", "credentials", "confidential"): + if value.lower() not in combined_forms.lower(): + errors.append(f"issue forms missing intake boundary {value}") + + release_workflow = read(ROOT / ".github" / "workflows" / "release-windows.yml", errors) + for value in ("actions/attest@v4", "attestations: write", "id-token: write", "sbom-tool-win-x64.exe", "ARSAS-Windows-x64-SBOM.spdx.json"): + if value not in release_workflow: + errors.append(f"release workflow missing future supply-chain contract {value}") + + for name in ("download.html", "unduh.html", "release-notes.html", "catatan-rilis.html"): + text = read(TEMPLATES / name, errors) + if "SBOM" not in text or "provenance" not in text.lower(): + errors.append(f"{name}: missing supply-chain evidence guidance") + if "1.6.18" in text and "provenance available" in text.lower(): + errors.append(f"{name}: falsely claims provenance for current release") + + if args.site: + site = Path(args.site).resolve() + for path in (*PAIR_MAP.keys(), *PAIR_MAP.values(), "device-evidence.json"): + if not (site / path).is_file(): + errors.append(f"built site missing P3 output {path}") + build_info = json.loads(read(site / "build-info.json", errors) or "{}") + if len(build_info.get("pages", [])) != 54: + errors.append("built site build-info.json must contain 54 pages") + + errors = list(dict.fromkeys(errors)) + if errors: + print("ARSAS adoption and field-proof validation failed:", file=sys.stderr) + for error in errors: + print(f"- {error}", file=sys.stderr) + return 1 + print("ARSAS adoption and field-proof validation passed: 54 pages, 17 localized pages, onboarding, FAQ, evidence, demo, issue intake and future provenance.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 306061b3c68f3f86115c999729014b309cc6562a Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 11:03:44 +0700 Subject: [PATCH 29/57] Make source validation registry-driven for P3 --- scripts/validate-product-source.py | 593 ++++++++--------------------- 1 file changed, 150 insertions(+), 443 deletions(-) diff --git a/scripts/validate-product-source.py b/scripts/validate-product-source.py index 063b5f5..1e07805 100644 --- a/scripts/validate-product-source.py +++ b/scripts/validate-product-source.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Validate ARSAS product templates, release trust, authority, localization and source assets.""" +"""Validate ARSAS product templates, localization, release trust and source assets.""" from __future__ import annotations @@ -14,502 +14,209 @@ LANDING = ROOT / "landing" TEMPLATES = LANDING / "templates" PARTIALS = LANDING / "partials" -APP_ICON_SOURCE = ROOT / "Assets" / "app-icon.png" -INDEXNOW_SCRIPT = ROOT / "scripts" / "submit-indexnow.py" -INCLUDE_PATTERN = re.compile(r"\{\{>\s*([a-z0-9-]+)\s*\}\}", re.IGNORECASE) -VERIFICATION_PATTERN = re.compile(r"google[a-z0-9]+\.html", re.IGNORECASE) -SHA256_PATTERN = re.compile(r"[0-9a-fA-F]{64}") -KNOWN_TOKENS = { - "ARSAS_VERSION", "PRODUCT_NAME", "CANONICAL_ROOT", "REPOSITORY_URL", - "ENGINE_REPOSITORY_URL", "AUTHOR_NAME", "AUTHOR_LINKEDIN", "AUTHOR_GITHUB", - "INSTALLER_URL", "PORTABLE_URL", "CHECKSUMS_URL", "STABLE_VERSION", - "STABLE_PUBLISHED_ISO", "STABLE_PUBLISHED_DATE", "STABLE_PUBLISHED_DATE_ID", - "INSTALLER_SIZE", "PORTABLE_SIZE", "INSTALLER_SHA256", "PORTABLE_SHA256", - "RELEASE_TITLE", "RELEASE_TITLE_ID", "RELEASE_SUMMARY", "RELEASE_SUMMARY_ID", - "RELEASE_HIGHLIGHTS", "RELEASE_HIGHLIGHTS_ID", "RELEASE_IMPROVEMENTS", - "RELEASE_IMPROVEMENTS_ID", "RELEASE_LIMITATIONS", "RELEASE_LIMITATIONS_ID", - "SIGNING_STATUS", "SIGNING_LABEL", "SIGNING_LABEL_ID", "SIGNING_DETAIL", - "SIGNING_DETAIL_ID", "RELEASE_SCREENSHOT_SRC", "RELEASE_SCREENSHOT_WIDTH", - "RELEASE_SCREENSHOT_HEIGHT", "RELEASE_SCREENSHOT_ALT", "RELEASE_SCREENSHOT_ALT_ID", - "RELEASE_SCREENSHOT_CAPTION", "RELEASE_SCREENSHOT_CAPTION_ID", "ISSUES_URL", "RELEASE_URL", +APP_ICON = ROOT / "Assets" / "app-icon.png" +INCLUDE = re.compile(r"\{\{>\s*([a-z0-9-]+)\s*\}\}", re.IGNORECASE) +TOKEN = re.compile(r"\{\{([A-Z0-9_]+)\}\}") +VERIFICATION = re.compile(r"google[a-z0-9]+\.html", re.IGNORECASE) +EXPECTED_NAV = {"overview", "capabilities", "solutions", "guides", "architecture", "about", "download"} +GUIDES = { + "reporting-silent.html", "brcb-vs-urcb.html", "rcb-reserved.html", "empty-dataset.html", + "port-102-connection-failed.html", "comtrade-download.html", "goose-sequence.html", + "cid-rejected.html", "live-model-vs-scl.html", "direct-vs-sbo.html", "commandtermination-addcause.html", } -EXPECTED_NAV = ("overview", "capabilities", "solutions", "guides", "architecture", "about", "download") -GUIDE_FILES = { - "reporting-silent.html", "brcb-vs-urcb.html", "rcb-reserved.html", - "empty-dataset.html", "port-102-connection-failed.html", "comtrade-download.html", - "goose-sequence.html", "cid-rejected.html", "live-model-vs-scl.html", - "direct-vs-sbo.html", "commandtermination-addcause.html", +PAIRS = { + "": "id.html", "download.html": "unduh.html", "release-notes.html": "catatan-rilis.html", + "quick-start.html": "panduan-mulai-arsas.html", "faq.html": "faq-arsas.html", + "compatibility.html": "bukti-kompatibilitas.html", "demo.html": "demo-arsas.html", + "guides.html": "panduan.html", "mms-client.html": "mms-client-iec61850.html", + "smart-reporting.html": "smart-reporting-iec61850.html", "goose-analyzer.html": "analyzer-goose-iec61850.html", + "file-transfer.html": "transfer-file-comtrade-iec61850.html", "scl-workspace.html": "workspace-scl-iec61850.html", + "fat-testing.html": "pengujian-fat-iec61850.html", "sat-testing.html": "pengujian-sat-iec61850.html", + "commissioning.html": "commissioning-iec61850.html", "multi-vendor-integration.html": "integrasi-multi-vendor-iec61850.html", } -LOCALIZED_PAIRS = { - "": "id.html", - "download.html": "unduh.html", - "release-notes.html": "catatan-rilis.html", - "guides.html": "panduan.html", - "mms-client.html": "mms-client-iec61850.html", - "smart-reporting.html": "smart-reporting-iec61850.html", - "goose-analyzer.html": "analyzer-goose-iec61850.html", - "file-transfer.html": "transfer-file-comtrade-iec61850.html", - "scl-workspace.html": "workspace-scl-iec61850.html", - "fat-testing.html": "pengujian-fat-iec61850.html", - "sat-testing.html": "pengujian-sat-iec61850.html", - "commissioning.html": "commissioning-iec61850.html", - "multi-vendor-integration.html": "integrasi-multi-vendor-iec61850.html", +KNOWN_TOKENS = { + "ARSAS_VERSION", "PRODUCT_NAME", "CANONICAL_ROOT", "REPOSITORY_URL", "ENGINE_REPOSITORY_URL", + "AUTHOR_NAME", "AUTHOR_LINKEDIN", "AUTHOR_GITHUB", "INSTALLER_URL", "PORTABLE_URL", "CHECKSUMS_URL", + "STABLE_VERSION", "STABLE_PUBLISHED_ISO", "STABLE_PUBLISHED_DATE", "STABLE_PUBLISHED_DATE_ID", + "INSTALLER_SIZE", "PORTABLE_SIZE", "INSTALLER_SHA256", "PORTABLE_SHA256", "RELEASE_TITLE", + "RELEASE_TITLE_ID", "RELEASE_SUMMARY", "RELEASE_SUMMARY_ID", "RELEASE_HIGHLIGHTS", + "RELEASE_HIGHLIGHTS_ID", "RELEASE_IMPROVEMENTS", "RELEASE_IMPROVEMENTS_ID", "RELEASE_LIMITATIONS", + "RELEASE_LIMITATIONS_ID", "SIGNING_STATUS", "SIGNING_LABEL", "SIGNING_LABEL_ID", "SIGNING_DETAIL", + "SIGNING_DETAIL_ID", "RELEASE_SCREENSHOT_SRC", "RELEASE_SCREENSHOT_WIDTH", "RELEASE_SCREENSHOT_HEIGHT", + "RELEASE_SCREENSHOT_ALT", "RELEASE_SCREENSHOT_ALT_ID", "RELEASE_SCREENSHOT_CAPTION", + "RELEASE_SCREENSHOT_CAPTION_ID", "ISSUES_URL", "RELEASE_URL", } -LOCALIZED_FILES = set(LOCALIZED_PAIRS.values()) -FORBIDDEN_COPY = ( - "without navigating source code", "without navigating the source repository", - "without requiring repository navigation", "the website is the product front door", -) -class Parser(HTMLParser): +class HtmlAudit(HTMLParser): def __init__(self) -> None: super().__init__(convert_charrefs=True) - self.in_title = False + self.lang = "" self.title = "" + self.in_title = False self.h1 = 0 - self.description: str | None = None + self.description = "" self.meta: dict[str, str] = {} + self.body_page = "" self.images: list[dict[str, str | None]] = [] - self.body_page: str | None = None - self.html_lang: str | None = None def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: values = dict(attrs) - if tag == "html": - self.html_lang = values.get("lang") - elif tag == "title": - self.in_title = True - elif tag == "h1": - self.h1 += 1 - elif tag == "body": - self.body_page = values.get("data-page") - elif tag == "meta": - key = values.get("name") or values.get("property") - value = values.get("content") - if key and value: - self.meta[key.lower()] = value - if values.get("name", "").lower() == "description": - self.description = value - elif tag == "img": - self.images.append(values) + if tag == "html": self.lang = values.get("lang") or "" + if tag == "title": self.in_title = True + if tag == "h1": self.h1 += 1 + if tag == "body": self.body_page = values.get("data-page") or "" + if tag == "meta": + key = (values.get("name") or values.get("property") or "").lower() + value = values.get("content") or "" + if key: self.meta[key] = value + if key == "description": self.description = value + if tag == "img": self.images.append(values) def handle_endtag(self, tag: str) -> None: - if tag == "title": - self.in_title = False + if tag == "title": self.in_title = False def handle_data(self, data: str) -> None: - if self.in_title: - self.title += data - - -def png_size(path: Path) -> tuple[int, int]: - data = path.read_bytes()[:24] - if len(data) < 24 or data[:8] != b"\x89PNG\r\n\x1a\n": - raise ValueError("not a PNG") - return struct.unpack(">II", data[16:24]) + if self.in_title: self.title += data -def expand_partials(text: str, errors: list[str], stack: tuple[str, ...] = ()) -> str: - def replace(match: re.Match[str]) -> str: +def expand(text: str, errors: list[str], stack: tuple[str, ...] = ()) -> str: + def replacement(match: re.Match[str]) -> str: name = match.group(1).lower() if name in stack: errors.append("circular partial include: " + " -> ".join((*stack, name))) return "" path = PARTIALS / f"{name}.html" - if not path.exists(): - errors.append(f"missing partial: {path.relative_to(ROOT)}") + if not path.is_file(): + errors.append(f"missing partial {path.relative_to(ROOT)}") return "" - return expand_partials(path.read_text(encoding="utf-8"), errors, (*stack, name)) - + return expand(path.read_text(encoding="utf-8"), errors, (*stack, name)) previous = None while previous != text: previous = text - text = INCLUDE_PATTERN.sub(replace, text) + text = INCLUDE.sub(replacement, text) return text -def validate_template(path: Path, content_type: str | None, language: str | None, errors: list[str]) -> None: - if not path.exists(): - errors.append(f"missing template: {path.relative_to(ROOT)}") - return - raw = path.read_text(encoding="utf-8") - text = expand_partials(raw, errors) - parser = Parser() - parser.feed(text) - label = path.relative_to(ROOT) - - if not parser.title.strip() or len(parser.title.strip()) > 90: - errors.append(f"{label}: invalid title") - if parser.h1 != 1: - errors.append(f"{label}: expected one h1, found {parser.h1}") - if not parser.description or not 70 <= len(parser.description) <= 240: - errors.append(f"{label}: invalid meta description") - if not parser.body_page: - errors.append(f"{label}: missing body data-page") - if language and parser.html_lang != language: - errors.append(f"{label}: html lang must be {language}") - for key in ("og:title", "og:description", "og:url", "og:image", "og:image:width", "og:image:height"): - if not parser.meta.get(key): - errors.append(f"{label}: missing {key}") - for image in parser.images: - src = image.get("src") or "" - if image.get("alt") is None or not image.get("width") or not image.get("height"): - errors.append(f"{label}: incomplete image metadata {src}") - if src == "assets/app-icon.png": - if not APP_ICON_SOURCE.exists(): - errors.append(f"{label}: missing build-generated application brand icon source") - elif "{{" not in src and not (LANDING / src).exists(): - errors.append(f"{label}: missing image {src}") - - unknown = set(re.findall(r"\{\{([A-Z0-9_]+)\}\}", text)) - KNOWN_TOKENS - if unknown: - errors.append(f"{label}: unknown tokens {sorted(unknown)}") - if INCLUDE_PATTERN.search(text): - errors.append(f"{label}: unresolved partial include") - lowered = text.lower() - if ' header}}", "{{> footer}}"): - if include not in raw: - errors.append(f"{label}: missing shared include {include}") - if content_type == "localized": - if "{{> download-cta-id}}" not in raw: - errors.append(f"{label}: Indonesian page must use the shared Indonesian download CTA") - elif path.name not in ("index.html", "download.html", "404.html") and "{{> download-cta}}" not in raw: - errors.append(f"{label}: missing shared download CTA") - - if content_type == "guide": - if '"@type":"TechArticle"' not in raw.replace(" ", ""): - errors.append(f"{label}: guide must use TechArticle structured data") - if "{{> guide-boundary}}" not in raw: - errors.append(f"{label}: guide must include shared engineering boundary") - if parser.body_page != "guides": - errors.append(f"{label}: guide must activate Guides navigation") - if content_type == "localized": - if language != "id" or '"inLanguage":"id"' not in raw.replace(" ", ""): - errors.append(f"{label}: localized page must declare Indonesian structured-data language") - if 'hreflang="en"' not in raw: - errors.append(f"{label}: localized page must link to an English source page") - if content_type == "release": - for value in ("What’s new", "Known limitations", "{{RELEASE_SCREENSHOT_SRC}}", "{{ISSUES_URL}}", "{{SIGNING_STATUS}}"): - if value not in raw: - errors.append(f"{label}: release page missing {value}") - if content_type == "authority" and "Claim governance" not in raw: - errors.append(f"{label}: authority page must document claim governance") - - -def read_config(errors: list[str]) -> dict[str, object] | None: - try: - config = json.loads((LANDING / "site.json").read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as exc: - errors.append(f"landing/site.json: {exc}") - return None - required = { - ("product", "name"): "ARSAS", - ("product", "repository"): "https://github.com/masarray/arsas", - ("author", "name"): "Ari Sulistiono", - ("author", "linkedin"): "https://www.linkedin.com/in/ari-sulistiono", - ("author", "github"): "https://github.com/masarray", - ("indexNow", "endpoint"): "https://api.indexnow.org/indexnow", - } - for keys, expected in required.items(): - value: object = config - for key in keys: - value = value.get(key) if isinstance(value, dict) else None - if value != expected: - errors.append(f"landing/site.json: {'.'.join(keys)} must be {expected}") - downloads = config.get("downloads") - for key in ("installer", "portable", "checksums"): - value = downloads.get(key) if isinstance(downloads, dict) else None - if not isinstance(value, str) or not value.startswith("https://github.com/masarray/arsas/releases/latest/download/"): - errors.append(f"landing/site.json: invalid downloads.{key}") - index_now = config.get("indexNow") - if isinstance(index_now, dict): - key, key_file = index_now.get("key"), index_now.get("keyFile") - key_path = LANDING / str(key_file or "") - if not isinstance(key, str) or not 8 <= len(key) <= 128: - errors.append("landing/site.json: invalid IndexNow key") - elif not key_path.exists() or key_path.stem != key or key_path.read_text(encoding="utf-8").strip() != key: - errors.append("landing/site.json: IndexNow key file does not match key metadata") - else: - errors.append("landing/site.json: missing IndexNow configuration") - return config - - -def validate_registry(config: dict[str, object], errors: list[str]) -> list[tuple[Path, str | None, str | None]]: - pages = config.get("pages") - if not isinstance(pages, list) or not pages: - errors.append("landing/site.json: pages registry must be a non-empty list") - return [] - paths: set[str] = set() - names: set[str] = set() - entries: dict[str, dict[str, object]] = {} - templates: list[tuple[Path, str | None, str | None]] = [] - guide_count = localized_count = authority_count = release_count = 0 - for entry in pages: - if not isinstance(entry, dict): - errors.append("landing/site.json: every page entry must be an object") - continue - page_path, template = entry.get("path"), entry.get("template") - content_type, language = entry.get("contentType"), entry.get("language") - if not isinstance(page_path, str) or not isinstance(template, str): - errors.append("landing/site.json: every page entry needs string path and template") - continue - if page_path in paths: - errors.append(f"landing/site.json: duplicate page path {page_path}") - if template in names: - errors.append(f"landing/site.json: duplicate template {template}") - paths.add(page_path) - names.add(template) - entries[page_path] = entry - template_path = TEMPLATES / template - templates.append((template_path, content_type if isinstance(content_type, str) else None, language if isinstance(language, str) else None)) - if not template_path.exists(): - errors.append(f"landing/site.json: missing registered template {template}") - if page_path not in ("", "404.html") and not page_path.endswith(".html"): - errors.append(f"landing/site.json: invalid page path {page_path}") - if page_path == "404.html" and entry.get("index", True) is not False: - errors.append("landing/site.json: 404.html must be excluded from sitemap") - alternates = entry.get("alternates") - if alternates is not None: - if not isinstance(alternates, dict) or set(alternates) != {"en", "id", "x-default"}: - errors.append(f"landing/site.json: invalid localization alternates for {page_path or 'index.html'}") - elif page_path not in alternates.values(): - errors.append(f"landing/site.json: alternates do not reference current page {page_path or 'index.html'}") - if content_type == "guide": - guide_count += 1 - elif content_type == "localized": - localized_count += 1 - elif content_type == "authority": - authority_count += 1 - elif content_type == "release": - release_count += 1 - - if len(pages) != 46: - errors.append(f"landing/site.json: expected 46 pages, found {len(pages)}") - if guide_count != 11: - errors.append(f"landing/site.json: expected 11 troubleshooting guides, found {guide_count}") - if localized_count != 13: - errors.append(f"landing/site.json: expected 13 Indonesian pages, found {localized_count}") - if authority_count != 1: - errors.append(f"landing/site.json: expected one authority page, found {authority_count}") - if release_count != 1: - errors.append(f"landing/site.json: expected one English release page, found {release_count}") - - for english, indonesian in LOCALIZED_PAIRS.items(): - expected = {"en": english, "id": indonesian, "x-default": english} - en_entry, id_entry = entries.get(english), entries.get(indonesian) - if en_entry is None or id_entry is None: - errors.append(f"landing/site.json: missing localized pair {english or 'index.html'} / {indonesian}") - continue - if en_entry.get("language") != "en" or en_entry.get("alternates") != expected: - errors.append(f"landing/site.json: invalid English alternate contract for {english or 'index.html'}") - if id_entry.get("language") != "id" or id_entry.get("contentType") != "localized" or id_entry.get("alternates") != expected: - errors.append(f"landing/site.json: invalid Indonesian alternate contract for {indonesian}") - - actual = {path.name for path in TEMPLATES.glob("*.html")} - if actual - names: - errors.append("templates missing from registry: " + ", ".join(sorted(actual - names))) - if names - actual: - errors.append("registry templates missing from disk: " + ", ".join(sorted(names - actual))) - return templates +def png_size(path: Path) -> tuple[int, int]: + data = path.read_bytes()[:24] + if len(data) < 24 or data[:8] != b"\x89PNG\r\n\x1a\n": raise ValueError("not a PNG") + return struct.unpack(">II", data[16:24]) -def validate_release_sources(errors: list[str]) -> None: +def validate_release(errors: list[str]) -> None: try: evidence = json.loads((LANDING / "latest.json").read_text(encoding="utf-8")) notes = json.loads((LANDING / "release-notes.json").read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as exc: - errors.append(f"release trust JSON: {exc}") + errors.append(f"release JSON: {exc}") return version = str(evidence.get("version", "")) if evidence.get("schemaVersion") != 1 or evidence.get("product") != "ARSAS" or evidence.get("channel") != "stable": - errors.append("landing/latest.json: invalid stable release identity") + errors.append("latest.json stable identity is invalid") if not re.fullmatch(r"\d+\.\d+\.\d+", version) or notes.get("version") != version: - errors.append("release evidence and release notes versions must match") - for key, expected_name in (("installer", "ARSAS-Windows-x64-Setup.exe"), ("portable", "ARSAS-Windows-x64-Portable.zip")): - package = evidence.get(key) - if not isinstance(package, dict) or package.get("name") != expected_name: - errors.append(f"landing/latest.json: invalid {key} identity") - continue - if not SHA256_PATTERN.fullmatch(str(package.get("sha256", ""))): - errors.append(f"landing/latest.json: invalid {key} SHA-256") - if not 1_000_000 <= int(package.get("sizeBytes", 0)) <= 500_000_000: - errors.append(f"landing/latest.json: invalid {key} size") - if not isinstance(evidence.get("checksums"), dict): - errors.append("landing/latest.json: checksum asset is missing") + errors.append("release evidence and notes versions differ") + for key, filename in (("installer", "ARSAS-Windows-x64-Setup.exe"), ("portable", "ARSAS-Windows-x64-Portable.zip")): + item = evidence.get(key) + if not isinstance(item, dict) or item.get("name") != filename or not re.fullmatch(r"[0-9a-fA-F]{64}", str(item.get("sha256", ""))): + errors.append(f"latest.json {key} evidence is invalid") + if not isinstance(evidence.get("checksums"), dict): errors.append("latest.json checksum evidence is missing") signing = evidence.get("codeSigning") note_signing = notes.get("codeSigning") - if not isinstance(signing, dict) or signing.get("status") not in {"signed", "unsigned"}: - errors.append("landing/latest.json: code-signing status is missing") - if not isinstance(note_signing, dict) or note_signing.get("status") != (signing or {}).get("status"): - errors.append("release notes signing status does not match evidence") - elif note_signing.get("status") == "unsigned" and "Authenticode" not in str(note_signing.get("detail", "")): - errors.append("unsigned release notes must explain Authenticode status") - for key in ("title", "titleId", "summary", "summaryId", "issuesUrl", "releaseUrl"): - if not isinstance(notes.get(key), str) or not str(notes.get(key)).strip(): - errors.append(f"landing/release-notes.json: missing {key}") - for key in ("highlights", "highlightsId", "improvements", "improvementsId", "knownLimitations", "knownLimitationsId"): - value = notes.get(key) - if not isinstance(value, list) or len(value) < 4: - errors.append(f"landing/release-notes.json: {key} must contain at least four entries") + if not isinstance(signing, dict) or signing.get("status") not in {"signed", "unsigned"}: errors.append("code-signing status is invalid") + if not isinstance(note_signing, dict) or note_signing.get("status") != (signing or {}).get("status"): errors.append("release signing statuses differ") screenshot = notes.get("screenshot") - if not isinstance(screenshot, dict) or not (LANDING / str(screenshot.get("src", ""))).exists(): - errors.append("landing/release-notes.json: current stable screenshot is missing") - if notes.get("issuesUrl") != "https://github.com/masarray/arsas/issues/new/choose": - errors.append("landing/release-notes.json: issue reporting URL is invalid") - - -def validate_partials(errors: list[str]) -> None: - def read(name: str) -> str: - path = PARTIALS / name - return path.read_text(encoding="utf-8") if path.exists() else "" - - header, footer = read("header.html"), read("footer.html") - cta, cta_id, boundary = read("download-cta.html"), read("download-cta-id.html"), read("guide-boundary.html") - for page in EXPECTED_NAV: - if f'data-nav-page="{page}"' not in header: - errors.append(f"shared header missing navigation key {page}") - for chrome, label in ((header, "header"), (footer, "footer")): - for value in ('src="assets/app-icon.png"', 'class="brand-mark"'): - if value not in chrome: - errors.append(f"shared {label} missing application brand mark {value}") - for value in ('href="id.html"', 'hreflang="id"'): - if value not in header + footer: - errors.append(f"shared product chrome missing language route {value}") - for value in ("{{AUTHOR_NAME}}", "{{AUTHOR_LINKEDIN}}", "{{REPOSITORY_URL}}", 'href="solutions.html"', 'href="guides.html"', 'href="technical-review.html"', 'href="release-notes.html"'): - if value not in footer: - errors.append(f"shared footer missing {value}") - for value in ("{{STABLE_VERSION}}", "{{INSTALLER_URL}}", 'href="download.html"', 'href="release-notes.html"'): - if value not in cta: - errors.append(f"shared download CTA missing {value}") - for value in ("{{STABLE_VERSION}}", "{{INSTALLER_URL}}", 'href="unduh.html"', 'href="catatan-rilis.html"', "Unduh Windows installer"): - if value not in cta_id: - errors.append(f"shared Indonesian download CTA missing {value}") - for value in ("Ari Sulistiono", 'href="guides.html"', 'href="technical-review.html"', "Engineering boundary"): - if value not in boundary: - errors.append(f"shared guide boundary missing {value}") - - -def validate_contract(errors: list[str]) -> None: - def template(name: str) -> str: - path = TEMPLATES / name - return path.read_text(encoding="utf-8") if path.exists() else "" - - home, download, about = template("index.html"), template("download.html"), template("about.html") - solutions, guides = template("solutions.html"), template("guides.html") - review, localized_home = template("technical-review.html"), template("id.html") - localized_guides, localized_download = template("panduan.html"), template("unduh.html") - release, release_id = template("release-notes.html"), template("catatan-rilis.html") - for value in ("{{INSTALLER_URL}}", 'href="download.html"', 'href="solutions.html"', "arsas-rcb-scl-export.webp", "{{AUTHOR_LINKEDIN}}", '"codeRepository"'): - if value not in home: - errors.append(f"homepage template missing {value}") - for value in ("{{INSTALLER_URL}}", "{{PORTABLE_URL}}", "{{CHECKSUMS_URL}}", "{{STABLE_VERSION}}", "Installer vs Portable", "data-copy-value", "{{SIGNING_STATUS}}", 'href="release-notes.html"', "{{ISSUES_URL}}"): - if value not in download: - errors.append(f"download template missing {value}") - for value in ("{{AUTHOR_LINKEDIN}}", "{{AUTHOR_GITHUB}}", "{{REPOSITORY_URL}}", "Download Center"): - if value not in about: - errors.append(f"about template missing {value}") - for value in ('href="fat-testing.html"', 'href="sat-testing.html"', 'href="commissioning.html"', 'href="multi-vendor-integration.html"'): - if value not in solutions: - errors.append(f"solutions template missing {value}") - for guide in GUIDE_FILES: - if f'href="{guide}"' not in guides or f'href="{guide}"' not in localized_guides: - errors.append(f"guide hubs missing {guide}") - for value in ("Claim governance", "{{AUTHOR_LINKEDIN}}", "{{REPOSITORY_URL}}", "Not a conformance certificate"): - if value not in review: - errors.append(f"technical review template missing {value}") - for value in ("Pengujian IEC 61850", 'href="panduan.html"', 'href="unduh.html"', 'hreflang="en"'): - if value not in localized_home: - errors.append(f"Indonesian homepage missing {value}") - for localized in LOCALIZED_FILES - {"id.html", "panduan.html", "unduh.html", "catatan-rilis.html"}: - if f'href="{localized}"' not in localized_home and f'href="{localized}"' not in localized_guides: - errors.append(f"Indonesian hubs do not link to {localized}") - for value in ("Panduan Troubleshooting", 'href="reporting-silent.html"', 'href="technical-review.html"', 'href="mms-client-iec61850.html"'): - if value not in localized_guides: - errors.append(f"Indonesian guide hub missing {value}") - for value in ("{{INSTALLER_URL}}", "{{PORTABLE_URL}}", "{{CHECKSUMS_URL}}", "{{STABLE_VERSION}}", "Installer vs Portable", "data-copy-value", "{{SIGNING_STATUS}}", 'href="catatan-rilis.html"', "{{ISSUES_URL}}", "{{> download-cta-id}}"): - if value not in localized_download: - errors.append(f"Indonesian download page missing {value}") - for value in ("What’s new", "Known limitations", "{{RELEASE_HIGHLIGHTS}}", "{{RELEASE_SCREENSHOT_SRC}}", "{{SIGNING_DETAIL}}", "{{ISSUES_URL}}", "{{> download-cta}}"): - if value not in release: - errors.append(f"English release notes missing {value}") - for value in ("Yang baru", "Known limitations", "{{RELEASE_HIGHLIGHTS_ID}}", "{{RELEASE_SCREENSHOT_SRC}}", "{{SIGNING_DETAIL_ID}}", "{{ISSUES_URL}}", 'hreflang="en"', "{{> download-cta-id}}"): - if value not in release_id: - errors.append(f"Indonesian release notes missing {value}") + if not isinstance(screenshot, dict) or not (LANDING / str(screenshot.get("src", ""))).is_file(): errors.append("stable screenshot is missing") + if notes.get("issuesUrl") != "https://github.com/masarray/arsas/issues/new/choose": errors.append("release issue URL is invalid") - localized_contracts = { - "mms-client-iec61850.html": ("MMS Client", "DataSet", "mms-client.html"), - "smart-reporting-iec61850.html": ("BRCB", "URCB", "smart-reporting.html"), - "analyzer-goose-iec61850.html": ("stNum", "sqNum", "goose-analyzer.html"), - "transfer-file-comtrade-iec61850.html": ("COMTRADE", "CFG", "file-transfer.html"), - "workspace-scl-iec61850.html": ("Edition 1", "selected-RCB", "scl-workspace.html"), - "pengujian-fat-iec61850.html": ("Pengujian FAT", "GOOSE", "fat-testing.html"), - "pengujian-sat-iec61850.html": ("Pengujian SAT", "station", "sat-testing.html"), - "commissioning-iec61850.html": ("Commissioning", "SOE", "commissioning.html"), - "integrasi-multi-vendor-iec61850.html": ("multi-vendor", "selected-RCB", "multi-vendor-integration.html"), - } - for name, required_values in localized_contracts.items(): - text = template(name) - for value in (*required_values, 'hreflang="en"', '"inLanguage":"id"', "{{> download-cta-id}}"): - if value not in text: - errors.append(f"{name}: missing localized contract {value}") - root_html = sorted(path.name for path in LANDING.glob("*.html") if not VERIFICATION_PATTERN.fullmatch(path.name)) - if root_html: - errors.append("legacy landing HTML remains outside templates: " + ", ".join(root_html)) - if len([path for path in LANDING.glob("google*.html") if VERIFICATION_PATTERN.fullmatch(path.name)]) > 1: - errors.append("multiple Google verification HTML files are present") - if (LANDING / "sitemap.xml").exists(): - errors.append("landing/sitemap.xml must be generated from site.json, not stored as a second source") - for relative in ( - "assets/screenshots/arsas-first-launch.webp", "assets/screenshots/arsas-live-values.webp", - "assets/screenshots/arsas-rcb-scl-export.webp", "assets/social-card.png", "site.webmanifest", - "robots.txt", "latest.json", "release-notes.json", "arsas-iec61850-20260720-6f4a9d2c8b.txt", - ): - if not (LANDING / relative).exists(): - errors.append(f"missing landing source file: {relative}") - if not INDEXNOW_SCRIPT.exists(): - errors.append("missing scripts/submit-indexnow.py") - if not APP_ICON_SOURCE.exists(): - errors.append("missing latest application icon: Assets/app-icon.png") +def main() -> int: + errors: list[str] = [] + try: + config = json.loads((LANDING / "site.json").read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + print(f"ARSAS product-source validation failed:\n- landing/site.json: {exc}", file=sys.stderr) + return 1 + pages = config.get("pages") + if not isinstance(pages, list): pages = [] + entries: dict[str, dict[str, object]] = {} + templates: set[str] = set() + for item in pages: + if not isinstance(item, dict) or not isinstance(item.get("path"), str) or not isinstance(item.get("template"), str): + errors.append("invalid page registry entry") + continue + path, template = str(item["path"]), str(item["template"]) + if path in entries or template in templates: errors.append(f"duplicate page or template {path or template}") + entries[path] = item + templates.add(template) + if len(pages) != 54: errors.append(f"expected 54 registered pages, found {len(pages)}") + if sum(item.get("contentType") == "guide" for item in entries.values()) != 11: errors.append("expected 11 troubleshooting guides") + if sum(item.get("contentType") == "localized" for item in entries.values()) != 17: errors.append("expected 17 Indonesian pages") + if entries.get("404.html", {}).get("index", True) is not False: errors.append("404.html must be excluded from sitemap") + for english, indonesian in PAIRS.items(): + expected = {"en": english, "id": indonesian, "x-default": english} + en, id_page = entries.get(english), entries.get(indonesian) + if not isinstance(en, dict) or en.get("language") != "en" or en.get("alternates") != expected: errors.append(f"invalid English localization pair {english or 'index.html'}") + if not isinstance(id_page, dict) or id_page.get("language") != "id" or id_page.get("contentType") != "localized" or id_page.get("alternates") != expected: errors.append(f"invalid Indonesian localization pair {indonesian}") + actual_templates = {path.name for path in TEMPLATES.glob("*.html")} + if actual_templates != templates: errors.append("template registry and disk content differ") + + for path, item in entries.items(): + template_path = TEMPLATES / str(item["template"]) + if not template_path.is_file(): + errors.append(f"missing template {template_path.name}") + continue + raw = template_path.read_text(encoding="utf-8") + rendered = expand(raw, errors) + audit = HtmlAudit(); audit.feed(rendered) + label = template_path.name + if not audit.title.strip() or len(audit.title.strip()) > 100: errors.append(f"{label}: invalid title") + if audit.h1 != 1: errors.append(f"{label}: expected one h1") + if not 60 <= len(audit.description) <= 260: errors.append(f"{label}: invalid description") + if not audit.body_page: errors.append(f"{label}: missing body data-page") + language = str(item.get("language", "en")) + if audit.lang != language: errors.append(f"{label}: html lang must be {language}") + for key in ("og:title", "og:description", "og:url", "og:image", "og:image:width", "og:image:height"): + if not audit.meta.get(key): errors.append(f"{label}: missing {key}") + for image in audit.images: + src = image.get("src") or "" + if image.get("alt") is None or not image.get("width") or not image.get("height"): errors.append(f"{label}: incomplete image metadata {src}") + if src == "assets/app-icon.png": continue + if "{{" not in src and not (LANDING / src).is_file(): errors.append(f"{label}: missing image {src}") + unknown = set(TOKEN.findall(rendered)) - KNOWN_TOKENS + if unknown: errors.append(f"{label}: unknown tokens {sorted(unknown)}") + if ' header}}" not in raw or "{{> footer}}" not in raw): errors.append(f"{label}: missing shared chrome") + if item.get("contentType") == "localized": + if "{{> download-cta-id}}" not in raw or '"inLanguage":"id"' not in raw.replace(" ", "") or 'hreflang="en"' not in raw: errors.append(f"{label}: incomplete Indonesian contract") + elif path not in {"", "download.html", "404.html"} and "{{> download-cta}}" not in raw: errors.append(f"{label}: missing shared download CTA") + if item.get("contentType") == "guide" and ('"@type":"TechArticle"' not in raw.replace(" ", "") or "{{> guide-boundary}}" not in raw): errors.append(f"{label}: incomplete guide contract") + + header = (PARTIALS / "header.html").read_text(encoding="utf-8") + footer = (PARTIALS / "footer.html").read_text(encoding="utf-8") + for nav in EXPECTED_NAV: + if f'data-nav-page="{nav}"' not in header: errors.append(f"header missing navigation {nav}") + for value in ("quick-start.html", "faq.html", "compatibility.html", "demo.html", "privacy.html", "{{AUTHOR_LINKEDIN}}"): + if value not in footer: errors.append(f"footer missing {value}") + root_html = [path.name for path in LANDING.glob("*.html") if not VERIFICATION.fullmatch(path.name)] + if root_html: errors.append("legacy HTML outside templates: " + ", ".join(sorted(root_html))) + for required in ("device-evidence.json", "adoption.css", "guide-filter.js", "demo.js", "latest.json", "release-notes.json", "robots.txt"): + if not (LANDING / required).is_file(): errors.append(f"missing landing source {required}") + if not APP_ICON.is_file(): errors.append("missing Assets/app-icon.png") else: try: - width, height = png_size(APP_ICON_SOURCE) - if width != height or width < 256: - errors.append(f"Assets/app-icon.png must be square and at least 256px, found {width}x{height}") - except ValueError as exc: - errors.append(f"Assets/app-icon.png: {exc}") + width, height = png_size(APP_ICON) + if width != height or width < 256: errors.append(f"invalid app icon dimensions {width}x{height}") + except ValueError as exc: errors.append(f"app icon: {exc}") + validate_release(errors) - -def main() -> int: - errors: list[str] = [] - config = read_config(errors) - templates = validate_registry(config, errors) if config else [] - validate_release_sources(errors) - validate_partials(errors) - for template_path, content_type, language in templates: - validate_template(template_path, content_type, language, errors) - validate_contract(errors) errors = list(dict.fromkeys(errors)) if errors: print("ARSAS product-source validation failed:", file=sys.stderr) - for error in errors: - print(f"- {error}", file=sys.stderr) + for error in errors: print(f"- {error}", file=sys.stderr) return 1 - width, height = png_size(APP_ICON_SOURCE) - print( - f"ARSAS product-source validation passed: {len(templates)} templates, 11 guides, " - f"13 Indonesian pages, 13 hreflang pairs, stable release trust and latest {width}x{height} app icon." - ) + width, height = png_size(APP_ICON) + print(f"ARSAS product-source validation passed: 54 templates, 11 guides, 17 Indonesian pages, 17 hreflang pairs, stable release trust and {width}x{height} app icon.") return 0 From 20c81e41083c886ae2ecb5376ef3bbe983a1ab1c Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 11:05:02 +0700 Subject: [PATCH 30/57] Make rendered validation registry-driven for P3 --- scripts/validate-product-build.py | 569 +++++++----------------------- 1 file changed, 123 insertions(+), 446 deletions(-) diff --git a/scripts/validate-product-build.py b/scripts/validate-product-build.py index 7fd8d74..0578502 100644 --- a/scripts/validate-product-build.py +++ b/scripts/validate-product-build.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Validate the rendered ARSAS product website and stable release trust contracts.""" +"""Validate the rendered ARSAS product website, localization and release trust.""" from __future__ import annotations @@ -12,497 +12,174 @@ from pathlib import Path from urllib.parse import urlparse -EXPECTED_MEDIA = ( - "assets/app-icon.png", "assets/social-card.png", - "assets/screenshots/arsas-first-launch.webp", "assets/screenshots/arsas-multi-ied.webp", - "assets/screenshots/arsas-live-values.webp", "assets/screenshots/arsas-event-log.webp", - "assets/screenshots/arsas-goose.webp", "assets/screenshots/arsas-diagnostics.webp", - "assets/screenshots/arsas-rcb-scl-export.webp", -) +CANONICAL_ROOT = "https://masarray.github.io/arsas/" INSTALLER = "https://github.com/masarray/arsas/releases/latest/download/ARSAS-Windows-x64-Setup.exe" PORTABLE = "https://github.com/masarray/arsas/releases/latest/download/ARSAS-Windows-x64-Portable.zip" CHECKSUMS = "https://github.com/masarray/arsas/releases/latest/download/ARSAS-Windows-x64-SHA256SUMS.txt" -ISSUES = "https://github.com/masarray/arsas/issues/new/choose" -REPOSITORY = "https://github.com/masarray/arsas" -LINKEDIN = "https://www.linkedin.com/in/ari-sulistiono" -AUTHOR_GITHUB = "https://github.com/masarray" -CANONICAL_ROOT = "https://masarray.github.io/arsas/" -APP_ICON = "assets/app-icon.png" -INDEXNOW_KEY = "arsas-iec61850-20260720-6f4a9d2c8b" -INDEXNOW_FILE = INDEXNOW_KEY + ".txt" -EXPECTED_NAV = {"overview", "capabilities", "solutions", "guides", "architecture", "about", "download"} -GUIDE_PAGES = { - "reporting-silent.html", "brcb-vs-urcb.html", "rcb-reserved.html", - "empty-dataset.html", "port-102-connection-failed.html", "comtrade-download.html", - "goose-sequence.html", "cid-rejected.html", "live-model-vs-scl.html", - "direct-vs-sbo.html", "commandtermination-addcause.html", +GUIDES = { + "reporting-silent.html", "brcb-vs-urcb.html", "rcb-reserved.html", "empty-dataset.html", + "port-102-connection-failed.html", "comtrade-download.html", "goose-sequence.html", + "cid-rejected.html", "live-model-vs-scl.html", "direct-vs-sbo.html", "commandtermination-addcause.html", } -LOCALIZED_PAIRS = { - "index.html": "id.html", - "download.html": "unduh.html", - "release-notes.html": "catatan-rilis.html", - "guides.html": "panduan.html", - "mms-client.html": "mms-client-iec61850.html", - "smart-reporting.html": "smart-reporting-iec61850.html", - "goose-analyzer.html": "analyzer-goose-iec61850.html", - "file-transfer.html": "transfer-file-comtrade-iec61850.html", - "scl-workspace.html": "workspace-scl-iec61850.html", - "fat-testing.html": "pengujian-fat-iec61850.html", - "sat-testing.html": "pengujian-sat-iec61850.html", - "commissioning.html": "commissioning-iec61850.html", - "multi-vendor-integration.html": "integrasi-multi-vendor-iec61850.html", +PAIRS = { + "index.html": "id.html", "download.html": "unduh.html", "release-notes.html": "catatan-rilis.html", + "quick-start.html": "panduan-mulai-arsas.html", "faq.html": "faq-arsas.html", + "compatibility.html": "bukti-kompatibilitas.html", "demo.html": "demo-arsas.html", + "guides.html": "panduan.html", "mms-client.html": "mms-client-iec61850.html", + "smart-reporting.html": "smart-reporting-iec61850.html", "goose-analyzer.html": "analyzer-goose-iec61850.html", + "file-transfer.html": "transfer-file-comtrade-iec61850.html", "scl-workspace.html": "workspace-scl-iec61850.html", + "fat-testing.html": "pengujian-fat-iec61850.html", "sat-testing.html": "pengujian-sat-iec61850.html", + "commissioning.html": "commissioning-iec61850.html", "multi-vendor-integration.html": "integrasi-multi-vendor-iec61850.html", } -LOCALIZED_PAGES = set(LOCALIZED_PAIRS.values()) -FORBIDDEN_COPY = ( - "without navigating source code", "without navigating the source repository", - "without requiring repository navigation", "the website is the product front door", -) +INDEXNOW_FILE = "arsas-iec61850-20260720-6f4a9d2c8b.txt" -class Parser(HTMLParser): +class Audit(HTMLParser): def __init__(self) -> None: super().__init__(convert_charrefs=True) + self.lang = "" + self.title = "" + self.in_title = False + self.h1 = 0 + self.description = "" + self.body_page = "" self.refs: list[str] = [] - self.icons: list[dict[str, str | None]] = [] self.images: list[dict[str, str | None]] = [] self.alternates: dict[str, str] = {} - self.h1 = 0 - self.title = "" - self.in_title = False - self.description: str | None = None - self.body_page: str | None = None - self.nav_pages: set[str] = set() - self.html_lang: str | None = None + self.nav: set[str] = set() def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: values = dict(attrs) - if tag == "html": - self.html_lang = values.get("lang") - elif tag == "title": - self.in_title = True - elif tag == "h1": - self.h1 += 1 - elif tag == "body": - self.body_page = values.get("data-page") - elif tag == "meta" and values.get("name", "").lower() == "description": - self.description = values.get("content") - if tag == "a" and values.get("data-nav-page"): - self.nav_pages.add(values["data-nav-page"] or "") + if tag == "html": self.lang = values.get("lang") or "" + if tag == "title": self.in_title = True + if tag == "h1": self.h1 += 1 + if tag == "body": self.body_page = values.get("data-page") or "" + if tag == "meta" and (values.get("name") or "").lower() == "description": self.description = values.get("content") or "" + if tag == "a" and values.get("data-nav-page"): self.nav.add(values.get("data-nav-page") or "") + if tag == "link" and values.get("rel") == "alternate" and values.get("hreflang"): self.alternates[values.get("hreflang") or ""] = values.get("href") or "" for key in ("href", "src"): - value = values.get(key) - if value: - self.refs.append(value) - if tag == "link": - rel = (values.get("rel") or "").lower() - if "icon" in rel: - self.icons.append(values) - if rel == "alternate" and values.get("hreflang") and values.get("href"): - self.alternates[values["hreflang"] or ""] = values["href"] or "" - if tag == "img": - self.images.append(values) + if values.get(key): self.refs.append(values[key] or "") + if tag == "img": self.images.append(values) def handle_endtag(self, tag: str) -> None: - if tag == "title": - self.in_title = False + if tag == "title": self.in_title = False def handle_data(self, data: str) -> None: - if self.in_title: - self.title += data + if self.in_title: self.title += data def png_size(path: Path) -> tuple[int, int]: data = path.read_bytes()[:24] - if len(data) < 24 or data[:8] != b"\x89PNG\r\n\x1a\n": - raise ValueError("not a PNG") + if len(data) < 24 or data[:8] != b"\x89PNG\r\n\x1a\n": raise ValueError("not a PNG") return struct.unpack(">II", data[16:24]) -def local_ref(site: Path, page: Path, reference: str) -> Path | None: +def page_url(path: str) -> str: + return CANONICAL_ROOT if path == "index.html" else CANONICAL_ROOT + path + + +def local_target(site: Path, page: Path, reference: str) -> Path | None: clean = reference.split("#", 1)[0].split("?", 1)[0] parsed = urlparse(clean) - if not clean or parsed.scheme or parsed.netloc or clean.startswith("#"): - return None + if not clean or parsed.scheme or parsed.netloc or clean.startswith("#"): return None return (page.parent / clean).resolve() -def page_url(name: str) -> str: - return CANONICAL_ROOT if name == "index.html" else CANONICAL_ROOT + name - - -def validate_latest(site: Path, errors: list[str]) -> tuple[str | None, dict[str, object] | None]: - path = site / "latest.json" - if not path.exists(): - errors.append("missing latest.json") - return None, None - try: - manifest = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as exc: - errors.append(f"latest.json: {exc}") - return None, None - version = str(manifest.get("version", "")) - if manifest.get("schemaVersion") != 1 or manifest.get("product") != "ARSAS": - errors.append("latest.json has invalid identity") - if manifest.get("channel") != "stable": - errors.append("latest.json channel must be stable") - if not re.fullmatch(r"\d+\.\d+\.\d+", version): - errors.append("latest.json version is invalid") - expected = { - "installer": ("ARSAS-Windows-x64-Setup.exe", INSTALLER), - "portable": ("ARSAS-Windows-x64-Portable.zip", PORTABLE), - } - for key, (name, url) in expected.items(): - package = manifest.get(key) - if not isinstance(package, dict) or package.get("name") != name or package.get("url") != url: - errors.append(f"latest.json {key} identity is invalid") - continue - if not re.fullmatch(r"[0-9a-fA-F]{64}", str(package.get("sha256", ""))): - errors.append(f"latest.json {key} SHA-256 is invalid") - if not 1_000_000 <= int(package.get("sizeBytes", 0)) <= 500_000_000: - errors.append(f"latest.json {key} size is invalid") - checksums = manifest.get("checksums") - if not isinstance(checksums, dict) or checksums.get("url") != CHECKSUMS: - errors.append("latest.json checksums URL is invalid") - if not str(manifest.get("publishedAtUtc", "")): - errors.append("latest.json publishedAtUtc is missing") - signing = manifest.get("codeSigning") - if not isinstance(signing, dict) or signing.get("status") not in {"signed", "unsigned"}: - errors.append("latest.json code-signing status is invalid") - return version or None, manifest - - -def validate_release_notes(site: Path, stable_version: str | None, evidence: dict[str, object] | None, errors: list[str]) -> None: - path = site / "release-notes.json" - if not path.exists(): - errors.append("missing release-notes.json") - return - try: - notes = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as exc: - errors.append(f"release-notes.json: {exc}") - return - if notes.get("schemaVersion") != 1 or notes.get("product") != "ARSAS" or notes.get("version") != stable_version: - errors.append("release-notes.json identity or version is invalid") - for key in ("title", "titleId", "summary", "summaryId", "issuesUrl", "releaseUrl"): - if not isinstance(notes.get(key), str) or not str(notes.get(key)).strip(): - errors.append(f"release-notes.json missing {key}") - for key in ("highlights", "highlightsId", "improvements", "improvementsId", "knownLimitations", "knownLimitationsId"): - value = notes.get(key) - if not isinstance(value, list) or len(value) < 4: - errors.append(f"release-notes.json {key} is incomplete") - if notes.get("issuesUrl") != ISSUES: - errors.append("release-notes.json issue reporting URL is invalid") - signing = notes.get("codeSigning") - evidence_signing = evidence.get("codeSigning") if isinstance(evidence, dict) else None - if not isinstance(signing, dict) or not isinstance(evidence_signing, dict) or signing.get("status") != evidence_signing.get("status"): - errors.append("release-notes.json signing status does not match latest.json") - elif signing.get("status") == "unsigned" and "Authenticode" not in str(signing.get("detail", "")): - errors.append("unsigned release notes do not explain Authenticode status") - screenshot = notes.get("screenshot") - if not isinstance(screenshot, dict) or not (site / str(screenshot.get("src", ""))).exists(): - errors.append("release-notes.json stable screenshot is missing") - - -def validate_build_info(site: Path, errors: list[str]) -> tuple[str | None, str | None, list[str]]: - path = site / "build-info.json" - try: - info = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as exc: - errors.append(f"build-info.json: {exc}") - return None, None, [] - if info.get("schemaVersion") != 3: - errors.append("build-info.json schemaVersion must be 3") - version = str(info.get("version", "")) - stable_version = str(info.get("stableReleaseVersion", "")) - if not re.fullmatch(r"\d+\.\d+\.\d+", version): - errors.append("build-info.json version is invalid") - if not re.fullmatch(r"\d+\.\d+\.\d+", stable_version): - errors.append("build-info.json stableReleaseVersion is invalid") - if info.get("repository") != REPOSITORY: - errors.append("build-info.json repository is invalid") - author = info.get("author") - if not isinstance(author, dict) or author.get("name") != "Ari Sulistiono": - errors.append("build-info.json author is invalid") - if info.get("languages") != ["en", "id"]: - errors.append("build-info.json languages must be en and id") - if info.get("indexNowKeyLocation") != CANONICAL_ROOT + INDEXNOW_FILE: - errors.append("build-info.json IndexNow key location is invalid") - pages = info.get("pages") - if not isinstance(pages, list) or not pages or not all(isinstance(page, str) for page in pages): - errors.append("build-info.json pages registry is invalid") - return version or None, stable_version or None, [] - if len(pages) != len(set(pages)): - errors.append("build-info.json pages registry contains duplicates") - if len(pages) != 46: - errors.append(f"build-info.json must contain 46 pages, found {len(pages)}") - if not GUIDE_PAGES.issubset(set(pages)): - errors.append("build-info.json is missing troubleshooting guide pages") - if not LOCALIZED_PAGES.issubset(set(pages)) or "technical-review.html" not in pages or "release-notes.html" not in pages: - errors.append("build-info.json is missing authority, release or Indonesian pages") - return version or None, stable_version or None, list(pages) - - -def validate_sitemap(site: Path, pages: list[str], errors: list[str]) -> None: - path = site / "sitemap.xml" - if not path.exists(): - errors.append("missing sitemap.xml") - return - try: - tree = ET.parse(path) - except ET.ParseError as exc: - errors.append(f"sitemap.xml parsing failed: {exc}") - return - ns = {"sm": "http://www.sitemaps.org/schemas/sitemap/0.9", "xhtml": "http://www.w3.org/1999/xhtml"} - root = tree.getroot() - if root.tag != "{http://www.sitemaps.org/schemas/sitemap/0.9}urlset": - errors.append("sitemap.xml has an invalid root namespace") - return - records: dict[str, dict[str, str]] = {} - for node in root.findall("sm:url", ns): - loc_node = node.find("sm:loc", ns) - if loc_node is None or not (loc_node.text or "").strip(): - errors.append("sitemap.xml contains a URL without loc") - continue - loc = (loc_node.text or "").strip() - if loc in records: - errors.append(f"sitemap.xml contains duplicate loc {loc}") - alternates: dict[str, str] = {} - for link in node.findall("xhtml:link", ns): - language, href = link.get("hreflang"), link.get("href") - if language and href: - alternates[language] = href - records[loc] = alternates - - expected_urls = {page_url(page) for page in pages if page != "404.html"} - if set(records) != expected_urls: - for missing in sorted(expected_urls - set(records)): - errors.append(f"sitemap.xml missing {missing}") - for extra in sorted(set(records) - expected_urls): - errors.append(f"sitemap.xml contains unexpected URL {extra}") - if len(records) != 45: - errors.append(f"sitemap.xml must contain 45 indexable URLs, found {len(records)}") - - for english, indonesian in LOCALIZED_PAIRS.items(): - expected = {"en": page_url(english), "id": page_url(indonesian), "x-default": page_url(english)} - for page in (english, indonesian): - if records.get(page_url(page)) != expected: - errors.append(f"sitemap.xml localized alternate set is incomplete for {page}") - - -def validate_manifest(site: Path, icon_size: str, errors: list[str]) -> None: - path = site / "site.webmanifest" - try: - manifest = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as exc: - errors.append(f"site.webmanifest: {exc}") - return - icons = manifest.get("icons") - if not isinstance(icons, list) or len(icons) != 1 or not isinstance(icons[0], dict): - errors.append("site.webmanifest must define one canonical icon") - return - icon = icons[0] - if icon.get("src") != APP_ICON or icon.get("sizes") != icon_size or icon.get("type") != "image/png": - errors.append(f"site.webmanifest must use the actual {icon_size} ARSAS app icon") - if "maskable" not in str(icon.get("purpose", "")): - errors.append("site.webmanifest icon must support maskable purpose") - - -def expected_alternates_for(name: str) -> dict[str, str] | None: - for english, indonesian in LOCALIZED_PAIRS.items(): - if name in (english, indonesian): - return {"en": page_url(english), "id": page_url(indonesian), "x-default": page_url(english)} - return None - - def main() -> int: site = Path(sys.argv[1] if len(sys.argv) > 1 else "_site").resolve() errors: list[str] = [] - version, stable_version, pages = validate_build_info(site, errors) - stable_from_latest, evidence = validate_latest(site, errors) - if stable_version and stable_from_latest and stable_version != stable_from_latest: - errors.append("build-info.json stable release does not match latest.json") - validate_release_notes(site, stable_from_latest, evidence, errors) - for relative in tuple(pages) + EXPECTED_MEDIA + ( - "site.json", "latest.json", "release-notes.json", "build-info.json", "sitemap.xml", "site.webmanifest", INDEXNOW_FILE, - ): - if not (site / relative).exists(): - errors.append(f"missing deployable file: {relative}") - key_path = site / INDEXNOW_FILE - if key_path.exists() and key_path.read_text(encoding="utf-8").strip() != INDEXNOW_KEY: - errors.append("deployed IndexNow key file does not match the configured key") - - icon_path = site / APP_ICON - icon_size = "" - icon_width = icon_height = 0 - if icon_path.exists(): - try: - icon_width, icon_height = png_size(icon_path) - icon_size = f"{icon_width}x{icon_height}" - if icon_width != icon_height or icon_width < 256: - errors.append(f"app-icon.png must be square and at least 256px, found {icon_size}") - except ValueError as exc: - errors.append(f"app-icon.png: {exc}") + try: + registry = json.loads((site / "site.json").read_text(encoding="utf-8")) + info = json.loads((site / "build-info.json").read_text(encoding="utf-8")) + latest = json.loads((site / "latest.json").read_text(encoding="utf-8")) + notes = json.loads((site / "release-notes.json").read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + print(f"ARSAS rendered validation failed:\n- core JSON: {exc}", file=sys.stderr) + return 1 - combined = "" - parsers: dict[str, Parser] = {} - for name in pages: + entries = registry.get("pages", []) if isinstance(registry, dict) else [] + expected_pages = [str(item.get("path") or "index.html") for item in entries if isinstance(item, dict)] + if len(expected_pages) != 54 or len(expected_pages) != len(set(expected_pages)): errors.append("site registry must contain 54 unique pages") + if info.get("schemaVersion") != 3 or info.get("pages") != expected_pages: errors.append("build-info page registry does not match site.json") + if info.get("languages") != ["en", "id"]: errors.append("build-info languages must be en and id") + if info.get("repository") != "https://github.com/masarray/arsas": errors.append("build-info repository is invalid") + if info.get("indexNowKeyLocation") != CANONICAL_ROOT + INDEXNOW_FILE: errors.append("build-info IndexNow location is invalid") + if latest.get("version") != notes.get("version") or latest.get("channel") != "stable": errors.append("stable release JSON is inconsistent") + for key, url in (("installer", INSTALLER), ("portable", PORTABLE)): + item = latest.get(key) + if not isinstance(item, dict) or item.get("url") != url or not re.fullmatch(r"[0-9a-fA-F]{64}", str(item.get("sha256", ""))): errors.append(f"latest.json {key} evidence is invalid") + if not isinstance(latest.get("checksums"), dict) or latest["checksums"].get("url") != CHECKSUMS: errors.append("latest.json checksum URL is invalid") + + entry_map = {str(item.get("path") or "index.html"): item for item in entries if isinstance(item, dict)} + rendered: dict[str, Audit] = {} + for name in expected_pages: page = site / name - if not page.exists(): + if not page.is_file(): + errors.append(f"missing rendered page {name}") continue text = page.read_text(encoding="utf-8") - combined += text - parser = Parser() - parser.feed(text) - parsers[name] = parser - if parser.h1 != 1: - errors.append(f"{name}: expected one h1") - if not parser.title.strip() or not parser.description: - errors.append(f"{name}: title or description is missing") - if not parser.body_page: - errors.append(f"{name}: body data-page is missing") - if parser.nav_pages != EXPECTED_NAV: - errors.append(f"{name}: shared navigation is incomplete") - for reference in parser.refs: - target = local_ref(site, page, reference) - if target is not None: - try: - target.relative_to(site) - except ValueError: - errors.append(f"{name}: local reference escapes site {reference}") - continue - if not target.exists(): - errors.append(f"{name}: missing local asset {reference}") - favicon = [item for item in parser.icons if (item.get("rel") or "").lower() == "icon"] - if len(favicon) != 1 or favicon[0].get("href") != APP_ICON or favicon[0].get("sizes") != icon_size: - errors.append(f"{name}: favicon must use the actual {icon_size} {APP_ICON}") - touch = [item for item in parser.icons if (item.get("rel") or "").lower() == "apple-touch-icon"] - if len(touch) != 1 or touch[0].get("href") != APP_ICON: - errors.append(f"{name}: apple-touch-icon must use {APP_ICON}") - brand_images = [item for item in parser.images if item.get("src") == APP_ICON] - if name != "404.html": - if len(brand_images) < 2: - errors.append(f"{name}: header and footer must use app-icon.png brand marks") - for image in brand_images: - if image.get("width") != str(icon_width) or image.get("height") != str(icon_height): - errors.append(f"{name}: brand mark metadata must match {icon_size}") - for value in (LINKEDIN, REPOSITORY, 'href="download.html"', 'href="technical-review.html"', 'href="release-notes.html"'): - if value not in text: - errors.append(f"{name}: shared authority, release or download route missing {value}") - expected_alternates = expected_alternates_for(name) - if expected_alternates is not None and parser.alternates != expected_alternates: - errors.append(f"{name}: page-level hreflang alternates are incomplete") - elif expected_alternates is None and parser.alternates: - errors.append(f"{name}: unexpected page-level language alternates") - if name in GUIDE_PAGES: - if parser.body_page != "guides": - errors.append(f"{name}: troubleshooting guide must activate Guides navigation") - if '"@type":"TechArticle"' not in text.replace(" ", ""): - errors.append(f"{name}: missing TechArticle structured data") - for value in ("Engineering boundary", "Written and reviewed by Ari Sulistiono", 'href="guides.html"', 'href="technical-review.html"'): - if value not in text: - errors.append(f"{name}: missing guide trust contract {value}") - if name in LOCALIZED_PAGES: - if parser.html_lang != "id" or '"inLanguage":"id"' not in text.replace(" ", ""): - errors.append(f"{name}: Indonesian language metadata is incomplete") - if 'hreflang="en"' not in text: - errors.append(f"{name}: missing English counterpart link") - for value in ('href="unduh.html"', "Verifikasi dan bandingkan paket"): - if value not in text: - errors.append(f"{name}: Indonesian download CTA is incomplete: {value}") + if "{{" in text: errors.append(f"{name}: unresolved template token") + audit = Audit(); audit.feed(text); rendered[name] = audit + if not audit.title.strip() or audit.h1 != 1 or not 60 <= len(audit.description) <= 260: errors.append(f"{name}: metadata or h1 contract failed") + language = str(entry_map[name].get("language", "en")) + if audit.lang != language: errors.append(f"{name}: html language must be {language}") + if not audit.body_page: errors.append(f"{name}: body data-page is missing") + for image in audit.images: + src = image.get("src") or "" + if image.get("alt") is None or not image.get("width") or not image.get("height"): errors.append(f"{name}: incomplete image metadata {src}") + target = local_target(site, page, src) + if target is not None and not target.is_file(): errors.append(f"{name}: missing image {src}") + for reference in audit.refs: + target = local_target(site, page, reference) + if target is not None and not target.exists(): errors.append(f"{name}: broken local reference {reference}") + if name != "404.html" and {"overview", "capabilities", "solutions", "guides", "architecture", "about", "download"} - audit.nav: errors.append(f"{name}: shared navigation is incomplete") + + for english, indonesian in PAIRS.items(): + expected = {"en": page_url(english), "id": page_url(indonesian), "x-default": page_url(english)} + for page in (english, indonesian): + audit = rendered.get(page) + if audit and audit.alternates != expected: errors.append(f"{page}: reciprocal hreflang set is invalid") + if not GUIDES.issubset(set(expected_pages)): errors.append("troubleshooting guides are missing from the build") - for forbidden in ( - "raw.githubusercontent.com/masarray/arsas/main/Assets/screenshot", - "https://masarray.github.io/arsas/assets/social-card.svg", 'href="assets/favicon.svg"', - "{{", "github.com/masarray/arsas#quick-start", ' str: - path = site / name - return path.read_text(encoding="utf-8") if path.exists() else "" - - home, download, about = page_text("index.html"), page_text("download.html"), page_text("about.html") - solutions, guides = page_text("solutions.html"), page_text("guides.html") - review, id_home = page_text("technical-review.html"), page_text("id.html") - id_guides, id_download = page_text("panduan.html"), page_text("unduh.html") - release, release_id = page_text("release-notes.html"), page_text("catatan-rilis.html") - for value in (INSTALLER, 'href="download.html"', 'href="solutions.html"', "arsas-rcb-scl-export.webp", '"codeRepository"'): - if value not in home: - errors.append(f"homepage missing product contract: {value}") - for value in (INSTALLER, PORTABLE, CHECKSUMS, "Installer vs Portable", "Copy installer SHA-256", "Known limitations", "Not Authenticode-signed", ISSUES, 'href="release-notes.html"'): - if value not in download: - errors.append(f"download page missing release trust value {value}") - for value in (LINKEDIN, AUTHOR_GITHUB, REPOSITORY, "Download Center"): - if value not in about + home: - errors.append(f"author or open-source identity missing {value}") - for value in ('href="fat-testing.html"', 'href="sat-testing.html"', 'href="commissioning.html"', 'href="multi-vendor-integration.html"'): - if value not in solutions: - errors.append(f"solutions page missing {value}") - for guide in GUIDE_PAGES: - if f'href="{guide}"' not in guides or f'href="{guide}"' not in id_guides: - errors.append(f"guide hubs missing {guide}") - for value in ("Claim governance", "Not a conformance certificate", LINKEDIN, REPOSITORY): - if value not in review: - errors.append(f"technical review page missing {value}") - for localized in LOCALIZED_PAGES - {"id.html", "panduan.html", "unduh.html", "catatan-rilis.html"}: - if f'href="{localized}"' not in id_home and f'href="{localized}"' not in id_guides: - errors.append(f"Indonesian hubs do not link to {localized}") - for value in (INSTALLER, PORTABLE, CHECKSUMS, "Installer vs Portable", "Salin SHA-256 installer", "Known limitations", "Belum ditandatangani dengan Authenticode", ISSUES, 'href="catatan-rilis.html"', "Verifikasi dan bandingkan paket"): - if value not in id_download: - errors.append(f"Indonesian download page missing release trust value {value}") - for value in ("What’s new", "Known limitations", "Not Authenticode-signed", ISSUES, "arsas-live-values.webp", "Download Windows installer"): - if value not in release: - errors.append(f"English release notes missing {value}") - for value in ("Yang baru", "Known limitations", "Belum ditandatangani dengan Authenticode", ISSUES, "arsas-live-values.webp", "Unduh Windows installer"): - if value not in release_id: - errors.append(f"Indonesian release notes missing {value}") - - localized_contracts = { - "mms-client-iec61850.html": ("MMS Client", "DataSet"), - "smart-reporting-iec61850.html": ("BRCB", "URCB"), - "analyzer-goose-iec61850.html": ("stNum", "sqNum"), - "transfer-file-comtrade-iec61850.html": ("COMTRADE", "CFG"), - "workspace-scl-iec61850.html": ("Edition 1", "selected-RCB"), - "pengujian-fat-iec61850.html": ("Pengujian FAT", "GOOSE"), - "pengujian-sat-iec61850.html": ("Pengujian SAT", "station"), - "commissioning-iec61850.html": ("Commissioning", "SOE"), - "integrasi-multi-vendor-iec61850.html": ("multi-vendor", "selected-RCB"), - } - for name, values in localized_contracts.items(): - text = page_text(name) - for value in values: - if value not in text: - errors.append(f"{name}: missing translated technical content {value}") - - if version and f'"softwareVersion":"{version}"' not in home.replace(" ", ""): - errors.append("homepage softwareVersion does not match build-info.json") - if stable_version: - for name, text in (("download.html", download), ("unduh.html", id_download), ("release-notes.html", release), ("catatan-rilis.html", release_id)): - if stable_version not in text: - errors.append(f"{name}: stable release version is missing") + if not (site / required).is_file(): errors.append(f"missing output {required}") + try: + width, height = png_size(site / "assets/app-icon.png") + if width != height or width < 256: errors.append("rendered app icon is invalid") + except (OSError, ValueError) as exc: errors.append(f"app icon: {exc}") - validate_sitemap(site, pages, errors) - if icon_size: - validate_manifest(site, icon_size, errors) - social = site / "assets/social-card.png" - if social.exists(): - try: - if png_size(social) != (1200, 630): - errors.append(f"social-card.png: expected 1200x630, found {png_size(social)}") - except ValueError as exc: - errors.append(f"social-card.png: {exc}") + combined = "\n".join((site / name).read_text(encoding="utf-8") for name in expected_pages if (site / name).is_file()) + for value in ('href="http://', 'src="http://', "raw.githubusercontent.com/masarray/arsas/main/Assets/screenshot", ' Date: Wed, 22 Jul 2026 11:05:48 +0700 Subject: [PATCH 31/57] Expose adoption and field-proof routes in shared footer --- landing/partials/footer.html | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/landing/partials/footer.html b/landing/partials/footer.html index 6b7d5ca..0bbdbad 100644 --- a/landing/partials/footer.html +++ b/landing/partials/footer.html @@ -6,20 +6,20 @@

Practical IEC 61850 testing, commissioning and troubleshooting in one focused Windows workspace.

From 93c18ab471e833a0fc85303e42b8c6ea8c1a4e91 Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 11:07:25 +0700 Subject: [PATCH 32/57] Turn homepage into adoption-first product journey --- landing/templates/index.html | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/landing/templates/index.html b/landing/templates/index.html index 89cc1e7..9556ae8 100644 --- a/landing/templates/index.html +++ b/landing/templates/index.html @@ -18,6 +18,7 @@ + @@ -31,24 +32,17 @@ - + {{> header}}
-
Free and open source · Windows · version {{ARSAS_VERSION}}

IEC 61850 testing software
built for real engineering work.

Enter an approved IED address, discover the live MMS model, select the required signals and begin validation. ARSAS brings reporting, GOOSE, fault records, SCL context, diagnostics and guarded control into one focused Windows workspace.

No account or license keyInstaller and portable ZIPPublic source and checksums
ARSAS IEC 61850 engineering workstation first-launch workspace
Add IED by IP address
Live model to evidence
+
Free and open source · Windows · version {{ARSAS_VERSION}}

IEC 61850 testing software
built for real engineering work.

Enter an approved IED address, discover the live MMS model, select the required signals and begin validation. ARSAS brings reporting, GOOSE, fault records, SCL context, diagnostics and guarded control into one focused Windows workspace.

No account or license keyInstaller and portable ZIPPublic source and checksums
ARSAS IEC 61850 engineering workstation first-launch workspace
Add IED by IP address
Live model to evidence
MMS + ReportingLive discovery, BRCB, URCB, dynamic recovery and polling
GOOSE + FilesStream supervision and COMTRADE-related record retrieval
SCL + IntegrationConfigured/live context and selected-RCB Edition 1/2 export
Control + EvidenceTyped Direct and SBO workflows with visible outcomes
+
Adoption path

Understand, verify and start before touching an operational workflow.

The product journey now separates onboarding, objections, field evidence and troubleshooting instead of sending every visitor directly to a binary.

01 · First run

Quick Start

Verify the package, confirm TCP 102, discover the live model and inspect one attributable value.

02 · Product view

Guided Demo

Walk through seven real screenshots without a simulated device or unsupported timing claim.

03 · Trust questions

FAQ

Review SCL, Npcap, SmartScreen, installer, compatibility, privacy and active-control boundaries.

04 · Field proof

Compatibility Evidence

Assess service-level evidence, conditions and public records without a universal vendor claim.

+
Why engineers use ARSAS

Start useful validation before setup work consumes the test window.

FAT, SAT and commissioning often lose time to disconnected tools, uncertain engineering files, signal mapping, DataSet inspection and report-control preparation before the first useful value appears.

Fragmented workflow

Testing starts with tool configuration.

  • Find or repair the engineering file
  • Import and map signals across applications
  • Inspect DataSets and RCB ownership manually
  • Diagnose why reporting remains silent
  • Rebuild context for every IED
ARSAS workflow

Testing starts from the IED and available evidence.

  • Add an endpoint or open SCL
  • Discover the live server model
  • Select the signals required for the test
  • Observe value, quality, time, reason and source
  • Keep diagnostics with the originating IED
Project solutions

Use ARSAS across the IEC 61850 project lifecycle.

Explore focused workflows for factory testing, site validation, controlled commissioning and multi-vendor integration.

Factory

FAT Testing

Validate models, selected signals, reports, GOOSE, control outcomes and fault-record access before shipment.

Site

SAT Testing

Reconfirm installed endpoints, reporting, station-bus behavior and system integration after installation.

Energization

Commissioning

Investigate live values, sequence evidence, communication quality and command completion.

Integration

Multi-Vendor Integration

Compare configured SCL intent with live reporting, GOOSE and model evidence.

@@ -59,9 +53,11 @@
Real application evidence

See the working product—not a marketing mockup.

Current Windows screenshots show independent IED sessions, unified live values, event correlation, GOOSE supervision and diagnostics.

ARSAS connected to multiple IEC 61850 IEDs

01Independent multi-IED sessions

Each device retains its own MMS association, model, monitoring state and diagnostic history.

ARSAS multi-IED IEC 61850 live-value workspace

02Unified live values

Search selected values with device, quality, timestamp, acquisition source and recent-change evidence.

ARSAS IEC 61850 GOOSE analyzer

03Detailed GOOSE supervision

Inspect stream identity, APPID, VLAN, sequence, retransmission, TAL, payload and model binding.

ARSAS IEC 61850 communication diagnostics

04Attributable diagnostics

Separate association, service, report, sequence, frame and IED-behavior problems.

-
Download Center

Get the latest ARSAS Windows build.

Choose the installer or portable package, review SHA-256 checksums, system requirements and the current stable release scope.

Author and maintainer

{{AUTHOR_NAME}}

ARSAS is created and maintained by Ari Sulistiono, a substation automation engineer focused on IEC 61850 communication, protection and control, FAT, SAT, commissioning and practical engineering productivity.

+
Do I need an ICD, CID or SCD before the first connection?
No. ARSAS can discover an approved endpoint online. SCL remains valuable as configured intent and for comparison.
Does ARSAS claim support for every IEC 61850 vendor?
No. Compatibility evidence is service-specific and published with conditions, dates and engineering records.
Why can SmartScreen show an unknown publisher?
The current public Windows binaries are not Authenticode-signed. Verify the published SHA-256 before use.
Can ARSAS perform active control?
Guarded Direct and SBO workflows exist, but active operation requires an approved procedure, plant authority and independent safety checks.
+ +
Download Center

Get the latest ARSAS Windows build.

Choose the installer or portable package, review SHA-256 checksums, system requirements and the current stable release scope.

Author and maintainer

{{AUTHOR_NAME}}

ARSAS is created and maintained by Ari Sulistiono, a substation automation engineer focused on IEC 61850 communication, protection and control, FAT, SAT, commissioning and practical engineering productivity.

-
Start testing

Move from an approved endpoint to useful IEC 61850 evidence.

Download the stable Windows build, review the current maturity labels and use active features only inside an approved laboratory or commissioning boundary.

+
Start testing

Move from an approved endpoint to useful IEC 61850 evidence.

Download the stable Windows build, follow Quick Start and use active features only inside an approved laboratory or commissioning boundary.

{{> footer}} From 8bb7cb014c3c5eaf896a48a5404da193804f1f39 Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 11:08:33 +0700 Subject: [PATCH 33/57] Add Indonesian adoption and field-proof journey --- landing/templates/id.html | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/landing/templates/id.html b/landing/templates/id.html index ef54364..058234e 100644 --- a/landing/templates/id.html +++ b/landing/templates/id.html @@ -17,6 +17,7 @@ + @@ -25,22 +26,27 @@ - + + + + + + {{> header}}
-
Gratis dan open source · Windows · versi {{ARSAS_VERSION}}

Pengujian IEC 61850
tanpa setup yang berbelit.

Masukkan alamat IED yang sudah disetujui, discovery model MMS secara live, pilih signal yang dibutuhkan, lalu mulai validasi. ARSAS menggabungkan reporting, GOOSE, fault record, SCL, diagnostik dan guarded control dalam satu workspace.

Tanpa akun atau license keyInstaller dan portable ZIPSource serta checksum publik
Tampilan awal workstation ARSAS untuk pengujian IEC 61850
+
Gratis dan open source · Windows · versi {{ARSAS_VERSION}}

Pengujian IEC 61850
dengan jalur evidence yang jelas.

Masukkan alamat IED yang sudah disetujui, discovery model MMS secara live, pilih signal yang dibutuhkan, lalu mulai validasi. ARSAS menggabungkan reporting, GOOSE, fault record, SCL, diagnostik dan guarded control dalam satu workspace.

Tanpa akun atau license keyInstaller dan portable ZIPSource serta checksum publik
Tampilan awal workstation ARSAS untuk pengujian IEC 61850
MMS + ReportingDiscovery live, BRCB, URCB, recovery dan polling yang terlihat
GOOSE + FileSupervisi stream dan pengambilan record terkait COMTRADE
SCL + IntegrasiPerbandingan konfigurasi dengan model live dan export RCB terpilih
Control + EvidenceDirect dan SBO dengan hasil command yang dapat ditelusuri
-
Kapabilitas utama

Mulai dari protokol dan evidence yang sedang dibutuhkan.

Lima halaman teknis utama sekarang tersedia penuh dalam Bahasa Indonesia dengan istilah IEC 61850 tetap dipertahankan agar tidak mengubah arti engineering.

MMS

MMS Client dan IED Browser

Discovery model live, pencarian signal, quality, timestamp, DataSet, RCB dan control object.

Reporting

Smart Reporting

Validasi BRCB, URCB dan coverage DataSet dengan recovery terkontrol serta polling fallback.

GOOSE

Analyzer GOOSE

Periksa APPID, VLAN, stNum, sqNum, TAL, retransmission dan ordered payload.

Fault record

Transfer File dan COMTRADE

Browse MMS file service, unduh CFG/DAT/HDR/INF dan pertahankan negative service evidence.

SCL

Workspace SCL

Bandingkan configured intent dengan live model serta export baseline selected-RCB Edition 1 atau 2.

+
Jalur adoption

Pahami, verifikasi dan mulai sebelum masuk workflow aktif.

Website memisahkan onboarding, pertanyaan trust, field evidence dan troubleshooting agar user tidak langsung diarahkan ke binary tanpa konteks.

01 · First run

Panduan Mulai

Verifikasi package, konfirmasi TCP 102, discovery model live dan periksa satu value attributable.

02 · Lihat produk

Demo Terpandu

Ikuti tujuh screenshot nyata tanpa simulasi device atau klaim waktu yang tidak didukung.

03 · Pertanyaan trust

FAQ

Baca SCL, Npcap, SmartScreen, installer, compatibility, privacy dan batas active control.

04 · Field proof

Evidence Kompatibilitas

Nilai evidence per service, kondisi dan public record tanpa klaim universal vendor.

-
Untuk pekerjaan proyek nyata

Gunakan ARSAS sepanjang lifecycle pengujian dan integrasi.

Workflow Indonesia menjaga evidence, batas kewenangan dan tanggung jawab engineering tetap eksplisit.

FAT

Validasi sebelum pengiriman

Periksa model, signal, report, GOOSE, control outcome dan akses fault record sebelum panel meninggalkan factory.

SAT

Konfirmasi setelah instalasi

Uji ulang endpoint, reporting, station bus dan integrasi sesudah instalasi jaringan dan perangkat di site.

Commissioning

Evidence saat sistem hidup

Telusuri live value, sequence of events, kualitas komunikasi dan penyelesaian command secara terarah.

Integrasi

Multi-vendor IEC 61850

Gunakan SCL sebagai intent, lalu konfirmasi reporting, GOOSE dan model yang benar-benar ditawarkan IED.

+
Kapabilitas utama

Mulai dari protokol dan evidence yang sedang dibutuhkan.

Lima halaman teknis utama tersedia penuh dalam Bahasa Indonesia dengan istilah IEC 61850 dipertahankan.

MMS

MMS Client dan IED Browser

Discovery model live, pencarian signal, quality, timestamp, DataSet, RCB dan control object.

Buka MMS Client
Reporting

Smart Reporting

Validasi BRCB, URCB dan coverage DataSet dengan recovery terkontrol serta polling fallback.

Buka Smart Reporting
GOOSE

Analyzer GOOSE

Periksa APPID, VLAN, stNum, sqNum, TAL, retransmission dan ordered payload.

Buka Analyzer GOOSE
Fault record

Transfer File dan COMTRADE

Browse MMS file service, unduh CFG/DAT/HDR/INF dan pertahankan negative service evidence.

Buka File Transfer
SCL

Workspace SCL

Bandingkan configured intent dengan live model serta export baseline selected-RCB Edition 1 atau 2.

Buka Workspace SCL
-
Panduan troubleshooting

Mulai dari gejala yang sedang terjadi.

Telusuri reporting silent, koneksi port 102, GOOSE sequence, COMTRADE, CID/SCL dan control berdasarkan evidence yang tersedia.

Technical review

Ketahui batas setiap klaim dan hasil.

Bedakan kemampuan available, conditional, preview dan roadmap serta gunakan evidence ARSAS di dalam prosedur engineering yang disetujui.

+
Untuk pekerjaan proyek nyata

Gunakan ARSAS sepanjang lifecycle pengujian dan integrasi.

FAT

Validasi sebelum pengiriman

Periksa model, signal, report, GOOSE, control outcome dan akses fault record.

Pengujian FAT
SAT

Konfirmasi setelah instalasi

Uji ulang endpoint, reporting, station bus dan integrasi sesudah instalasi.

Pengujian SAT
Commissioning

Evidence saat sistem hidup

Telusuri live value, sequence of events, komunikasi dan command completion.

Commissioning
Integrasi

Multi-vendor IEC 61850

Gunakan SCL sebagai intent lalu konfirmasi reporting, GOOSE dan model live.

Integrasi multi-vendor
+ +
Panduan troubleshooting

Mulai dari gejala yang sedang terjadi.

Cari reporting silent, koneksi port 102, GOOSE sequence, COMTRADE, CID/SCL dan control secara lokal di browser.

Technical review

Ketahui batas setiap klaim dan hasil.

Bedakan available, conditional, preview dan roadmap serta gunakan evidence di dalam procedure yang disetujui.

{{> download-cta-id}}
From 623eefd7cdd3795a2468de4ab0dfd284ee55c42e Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 11:09:45 +0700 Subject: [PATCH 34/57] Add onboarding and honest supply-chain guidance to Download Center --- landing/templates/download.html | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/landing/templates/download.html b/landing/templates/download.html index e0c376f..d298d05 100644 --- a/landing/templates/download.html +++ b/landing/templates/download.html @@ -17,6 +17,7 @@ + @@ -26,26 +27,26 @@ - + {{> header}}
-
Official Download Center · stable release · Windows x64

Download ARSAS {{STABLE_VERSION}} with verifiable release evidence.

Choose the installer for a normal engineering workstation or the portable ZIP for a controlled folder-based deployment. Both packages are published from verified CI artifacts and have public SHA-256 values.

Latest stable channel · ARSAS {{STABLE_VERSION}} · published
+
Official Download Center · stable release · Windows x64

Download ARSAS {{STABLE_VERSION}} with verifiable release evidence.

Choose the installer for a normal engineering workstation or the portable ZIP for a controlled folder-based deployment. Both packages are published from verified CI artifacts and have public SHA-256 values.

Latest stable channel · ARSAS {{STABLE_VERSION}} · published
Folder-based

Portable ZIP

Best for approved test laptops, temporary evaluation or environments where installation is not preferred. Extract to a writable local folder and update by replacing the package.

File
ARSAS-Windows-x64-Portable.zip
Size
{{PORTABLE_SIZE}}
Published
{{STABLE_PUBLISHED_DATE}}
Signing
{{SIGNING_LABEL}}
Download portable ZIP
-
Installer vs Portable

Choose based on deployment—not on feature availability.

The IEC 61850 application scope is the same. The difference is how Windows stores, launches, removes and updates the package.

DecisionInstallerPortable ZIP
Best forPrimary engineering workstationControlled test folder or temporary evaluation
SetupGuided Windows installationExtract and run from a writable local folder
Start menu and uninstallIncludedNot registered with Windows Installer
Update pathVerified installer download and launchManual package replacement recommended
Administrator rightsDepends on selected install scope and policyNormally unnecessary when using an approved user-writable folder
+
Installer vs Portable

Choose based on deployment—not on feature availability.

The IEC 61850 application scope is the same. The difference is how Windows stores, launches, removes and updates the package.

DecisionInstallerPortable ZIP
Best forPrimary engineering workstationControlled test folder or temporary evaluation
SetupGuided Windows installationExtract and run from a writable local folder
Start menu and uninstallIncludedNot registered with Windows Installer
Update pathVerified installer download and launchManual package replacement recommended
Administrator rightsDepends on selected install scope and policyNormally unnecessary in an approved user-writable folder
-
Release integrity

Copy and verify SHA-256 before use.

The values below come from the verified stable publication evidence. The combined checksum file is also available for automated or archived verification.

Installer SHA-256{{INSTALLER_SHA256}}
Portable SHA-256{{PORTABLE_SHA256}}
Download SHA-256 file
PowerShell verificationGet-FileHash .\ARSAS-Windows-x64-Setup.exe -Algorithm SHA256
+
Release integrity

Copy and verify SHA-256 before use.

The values below come from verified stable publication evidence. The combined checksum file is also available for automated or archived verification.

Installer SHA-256{{INSTALLER_SHA256}}
Portable SHA-256{{PORTABLE_SHA256}}
Download SHA-256 file
PowerShell verificationGet-FileHash .\ARSAS-Windows-x64-Setup.exe -Algorithm SHA256
Code-signing status

{{SIGNING_LABEL}}

{{SIGNING_DETAIL}}

Status: {{SIGNING_STATUS}}
-
Issue reporting

Report package or runtime problems with evidence.

Include version {{STABLE_VERSION}}, installer or portable package, Windows version, exact error, screenshot or log, and whether the published checksum matched.

System scope

Prepared for approved Windows engineering workstations.

  • Windows 10 or Windows 11, x64
  • Self-contained .NET application package
  • Npcap required for raw-Ethernet GOOSE and Sampled Values
  • Approved laboratory, FAT, SAT or commissioning network
  • Independent authority required for active controls and writes
+
Software supply chain

Distinguish current package evidence from future release attestations.

The current {{STABLE_VERSION}} Download Center proves file identity through SHA-256, release metadata and CI publication evidence. It does not claim an SBOM or GitHub provenance attestation unless those assets are visible on the tagged GitHub Release.

Current stable evidence

Installer, portable ZIP, publish date, file size, SHA-256 and honest Authenticode status.

Future release workflow

Releases built after the P3 supply-chain workflow generate an SPDX SBOM and GitHub artifact attestations during the same Windows release job.

Verification boundary

Never infer provenance for an older release from a newer workflow. Verify the exact tag and attached assets.

-
Review before installing

See what changed in ARSAS {{STABLE_VERSION}}.

Read the latest screenshot, update-verification design, known limitations, code-signing status and issue-reporting guidance.

+
First-run support

Do not stop at the binary.

Follow the read-first onboarding path, review common objections and inspect service-level field evidence before attempting active workflows.

Issue reporting

Report package or runtime problems with evidence.

Include version {{STABLE_VERSION}}, package type, Windows version, exact error, screenshot or log, and whether the published checksum matched.

+ +
Review before installing

See what changed in ARSAS {{STABLE_VERSION}}.

Read the latest screenshot, update-verification design, known limitations, code-signing status and issue-reporting guidance.

{{> footer}} From afa0f4a009b6eb34b5f900e542941776ae366d03 Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 11:10:51 +0700 Subject: [PATCH 35/57] Add onboarding and supply-chain guidance to Indonesian Download Center --- landing/templates/unduh.html | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/landing/templates/unduh.html b/landing/templates/unduh.html index 026372f..2af9c58 100644 --- a/landing/templates/unduh.html +++ b/landing/templates/unduh.html @@ -17,6 +17,7 @@ + @@ -26,27 +27,26 @@ - + {{> header}}
-
Pusat Unduhan Resmi · stable release · Windows x64

Unduh ARSAS {{STABLE_VERSION}} dengan evidence release yang dapat diverifikasi.

Pilih installer untuk workstation engineering normal atau portable ZIP untuk deployment berbasis folder yang terkontrol. Kedua paket berasal dari artifact CI yang diverifikasi dan memiliki SHA-256 publik.

Stable terbaru · ARSAS {{STABLE_VERSION}} · dipublikasikan
+
Pusat Unduhan Resmi · stable release · Windows x64

Unduh ARSAS {{STABLE_VERSION}} dengan evidence release yang dapat diverifikasi.

Pilih installer untuk workstation engineering normal atau portable ZIP untuk deployment berbasis folder. Kedua paket berasal dari artifact CI yang diverifikasi dan memiliki SHA-256 publik.

Stable terbaru · ARSAS {{STABLE_VERSION}} · dipublikasikan
-
Berbasis folder

Portable ZIP

Cocok untuk laptop pengujian, evaluasi sementara atau lingkungan yang tidak menginginkan instalasi. Ekstrak ke folder lokal yang writable dan update dengan mengganti paket.

File
ARSAS-Windows-x64-Portable.zip
Ukuran
{{PORTABLE_SIZE}}
Publish
{{STABLE_PUBLISHED_DATE_ID}}
Signing
{{SIGNING_LABEL_ID}}
Unduh portable ZIP
+
Berbasis folder

Portable ZIP

Cocok untuk laptop pengujian, evaluasi sementara atau lingkungan tanpa instalasi. Ekstrak ke folder lokal writable dan update dengan mengganti paket.

File
ARSAS-Windows-x64-Portable.zip
Ukuran
{{PORTABLE_SIZE}}
Publish
{{STABLE_PUBLISHED_DATE_ID}}
Signing
{{SIGNING_LABEL_ID}}
Unduh portable ZIP
-
Installer vs Portable

Pilih berdasarkan cara deployment, bukan perbedaan fitur.

Ruang lingkup aplikasi IEC 61850 sama. Perbedaannya adalah cara Windows menyimpan, menjalankan, menghapus dan memperbarui paket.

KeputusanInstallerPortable ZIP
Paling cocok untukWorkstation engineering utamaFolder pengujian terkontrol atau evaluasi sementara
SetupProses instalasi Windows terpanduEkstrak dan jalankan dari folder lokal writable
Start menu dan uninstallTersediaTidak terdaftar sebagai instalasi Windows
Jalur updateDownload dan launch installer terverifikasiPenggantian paket manual direkomendasikan
Hak administratorBergantung scope instalasi dan policyBiasanya tidak diperlukan pada folder user yang disetujui
+
Installer vs Portable

Pilih berdasarkan cara deployment, bukan fitur.

Ruang lingkup aplikasi IEC 61850 sama. Perbedaannya adalah cara Windows menyimpan, menjalankan, menghapus dan memperbarui paket.

KeputusanInstallerPortable ZIP
Paling cocokWorkstation engineering utamaFolder test terkontrol atau evaluasi
SetupInstalasi Windows terpanduEkstrak dan jalankan dari folder writable
Start menu dan uninstallTersediaTidak terdaftar sebagai instalasi Windows
UpdateInstaller terverifikasiPenggantian paket manual
AdministratorBergantung scope dan policyBiasanya tidak pada folder user yang disetujui
-
Integritas release

Salin dan verifikasi SHA-256 sebelum digunakan.

Nilai berikut berasal dari evidence publikasi stable yang terverifikasi. File checksum gabungan juga tersedia untuk verifikasi otomatis atau arsip.

SHA-256 installer{{INSTALLER_SHA256}}
SHA-256 portable{{PORTABLE_SHA256}}
Unduh file SHA-256
Verifikasi PowerShellGet-FileHash .\ARSAS-Windows-x64-Setup.exe -Algorithm SHA256
+
Integritas release

Salin dan verifikasi SHA-256 sebelum digunakan.

Nilai berikut berasal dari evidence publikasi stable. File checksum gabungan juga tersedia.

SHA-256 installer{{INSTALLER_SHA256}}
SHA-256 portable{{PORTABLE_SHA256}}
Unduh file SHA-256
Verifikasi PowerShellGet-FileHash .\ARSAS-Windows-x64-Setup.exe -Algorithm SHA256
-
Status code-signing

{{SIGNING_LABEL_ID}}

{{SIGNING_DETAIL_ID}}

Status: {{SIGNING_STATUS}}
+
Status code-signing

{{SIGNING_LABEL_ID}}

{{SIGNING_DETAIL_ID}}

Status: {{SIGNING_STATUS}}
-
Pelaporan issue

Laporkan masalah paket atau runtime dengan evidence.

Sertakan versi {{STABLE_VERSION}}, paket installer atau portable, versi Windows, error persis, screenshot atau log, dan apakah checksum sesuai.

Ruang lingkup sistem

Disiapkan untuk workstation Windows yang disetujui.

  • Windows 10 atau Windows 11, x64
  • Paket aplikasi .NET self-contained
  • Npcap diperlukan untuk raw-Ethernet GOOSE dan Sampled Values
  • Jaringan laboratorium, FAT, SAT atau commissioning yang disetujui
  • Otoritas independen untuk control aktif dan write operation
+
Software supply chain

Bedakan evidence package saat ini dari attestation release berikutnya.

Download Center {{STABLE_VERSION}} saat ini membuktikan identity file melalui SHA-256, metadata release dan evidence publikasi CI. Halaman ini tidak mengklaim asset SBOM atau GitHub provenance attestation kecuali asset tersebut terlihat pada tagged GitHub Release.

Evidence stable saat ini

Installer, portable ZIP, tanggal publish, ukuran, SHA-256 dan status Authenticode yang jujur.

Workflow release berikutnya

Release yang dibangun setelah workflow P3 menghasilkan SPDX SBOM dan GitHub artifact attestation dalam job Windows yang sama.

Batas verifikasi

Jangan menganggap release lama memiliki provenance hanya karena workflow baru sudah tersedia. Verifikasi exact tag dan asset.

-
Tinjau sebelum instalasi

Lihat perubahan pada ARSAS {{STABLE_VERSION}}.

Baca screenshot stabil terbaru, desain verifikasi update, known limitations, status code-signing dan panduan pelaporan issue.

+
Dukungan first-run

Jangan berhenti pada binary.

Ikuti onboarding read-first, baca pertanyaan umum dan periksa field evidence per service sebelum masuk workflow aktif.

Pelaporan issue

Laporkan masalah paket atau runtime dengan evidence.

Sertakan versi {{STABLE_VERSION}}, jenis paket, Windows, exact error, screenshot atau log, dan apakah checksum sesuai.

+
Tinjau sebelum instalasi

Lihat perubahan pada ARSAS {{STABLE_VERSION}}.

Baca screenshot stable, known limitations, status code-signing dan panduan issue.

{{> download-cta-id}}
{{> footer}} From c9ffc4d34f93736d618d8f08b88f163e380256fc Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 11:11:51 +0700 Subject: [PATCH 36/57] Add honest supply-chain scope to release notes --- landing/templates/release-notes.html | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/landing/templates/release-notes.html b/landing/templates/release-notes.html index f258cc6..3e9514a 100644 --- a/landing/templates/release-notes.html +++ b/landing/templates/release-notes.html @@ -17,6 +17,7 @@ + @@ -26,14 +27,12 @@ - + {{> header}}
-
Latest stable · Windows x64 · published {{STABLE_PUBLISHED_DATE}}

ARSAS {{STABLE_VERSION}}: {{RELEASE_TITLE}}

{{RELEASE_SUMMARY}}

Verified stable publication · exact CI artifacts · SHA-256 available
+
Latest stable · Windows x64 · published {{STABLE_PUBLISHED_DATE}}

ARSAS {{STABLE_VERSION}}: {{RELEASE_TITLE}}

{{RELEASE_SUMMARY}}

Verified stable publication · exact CI artifacts · SHA-256 available
Version{{STABLE_VERSION}}
Published{{STABLE_PUBLISHED_DATE}}
Installer{{INSTALLER_SIZE}}
Portable ZIP{{PORTABLE_SIZE}}
@@ -41,9 +40,11 @@
Latest stable screenshot

Review the application before downloading.

The screenshot is representative of the published stable workspace and uses the same Windows product interface described in these release notes.

{{RELEASE_SCREENSHOT_ALT}}

{{STABLE_VERSION}}Stable Windows workspace

{{RELEASE_SCREENSHOT_CAPTION}}

-
Known limitations

Read these before field use.

    {{RELEASE_LIMITATIONS}}
Code-signing status

{{SIGNING_LABEL}}

{{SIGNING_DETAIL}}

Status: {{SIGNING_STATUS}}

A checksum verifies file identity against the published release evidence. It does not create a Windows publisher identity or replace Authenticode.

+
Known limitations

Read these before field use.

    {{RELEASE_LIMITATIONS}}
Code-signing status

{{SIGNING_LABEL}}

{{SIGNING_DETAIL}}

Status: {{SIGNING_STATUS}}

A checksum verifies file identity against published release evidence. It does not create a Windows publisher identity or replace Authenticode.

-
Found a release problem?

Report it with reproducible evidence.

Include the ARSAS version, package type, Windows version, exact error, relevant log or screenshot, IED/vendor context where appropriate, and whether the checksum matched.

+
SBOM and provenance scope

Evidence applies only to the release that actually carries it.

ARSAS {{STABLE_VERSION}} has the package evidence listed on its tagged release. This page does not infer an SBOM or GitHub provenance attestation when those assets are absent.

Current release

Use the package names, sizes, SHA-256 values, publish date and Authenticode status shown for this exact version.

Releases built after P3

The Windows workflow generates an SPDX SBOM and GitHub artifact attestations in the release job and attaches the SBOM to the GitHub Release.

Verification rule

Inspect the exact tag, SBOM asset and repository attestation subject digest before making a provenance claim.

+ +
Found a release problem?

Report it with reproducible evidence.

Include the ARSAS version, package type, Windows version, exact error, relevant sanitized log or screenshot, device context where appropriate, and whether the checksum matched.

{{> download-cta}}
From 757995af38f745a2f3e1e90848a1733eb719154f Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 11:12:40 +0700 Subject: [PATCH 37/57] Add honest SBOM and provenance scope to Indonesian release notes --- landing/templates/catatan-rilis.html | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/landing/templates/catatan-rilis.html b/landing/templates/catatan-rilis.html index 80904a7..0bbec15 100644 --- a/landing/templates/catatan-rilis.html +++ b/landing/templates/catatan-rilis.html @@ -17,6 +17,7 @@ + @@ -26,24 +27,24 @@ - + {{> header}}
-
Stable terbaru · Windows x64 · dipublikasikan {{STABLE_PUBLISHED_DATE_ID}}

ARSAS {{STABLE_VERSION}}: {{RELEASE_TITLE_ID}}

{{RELEASE_SUMMARY_ID}}

Publikasi stabil terverifikasi · artifact CI yang sama · SHA-256 tersedia
+
Stable terbaru · Windows x64 · dipublikasikan {{STABLE_PUBLISHED_DATE_ID}}

ARSAS {{STABLE_VERSION}}: {{RELEASE_TITLE_ID}}

{{RELEASE_SUMMARY_ID}}

Publikasi stabil terverifikasi · exact artifact CI · SHA-256 tersedia
Versi{{STABLE_VERSION}}
Dipublikasikan{{STABLE_PUBLISHED_DATE_ID}}
Installer{{INSTALLER_SIZE}}
Portable ZIP{{PORTABLE_SIZE}}
Yang baru

Perubahan pada rilis stabil terbaru

    {{RELEASE_HIGHLIGHTS_ID}}
Peningkatan reliability

Penguatan delivery dan workflow

    {{RELEASE_IMPROVEMENTS_ID}}
-
Screenshot versi stabil

Lihat aplikasi sebelum mengunduh.

Screenshot ini merepresentasikan workspace stabil yang dipublikasikan dan menggunakan antarmuka Windows yang dijelaskan pada catatan rilis.

{{RELEASE_SCREENSHOT_ALT_ID}}

{{STABLE_VERSION}}Workspace Windows stabil

{{RELEASE_SCREENSHOT_CAPTION_ID}}

+
Screenshot versi stabil

Lihat aplikasi sebelum mengunduh.

Screenshot merepresentasikan workspace stabil yang dipublikasikan.

{{RELEASE_SCREENSHOT_ALT_ID}}

{{STABLE_VERSION}}Workspace Windows stabil

{{RELEASE_SCREENSHOT_CAPTION_ID}}

-
Known limitations

Baca sebelum dipakai di lapangan.

    {{RELEASE_LIMITATIONS_ID}}
Status code-signing

{{SIGNING_LABEL_ID}}

{{SIGNING_DETAIL_ID}}

Status: {{SIGNING_STATUS}}

Checksum memverifikasi identitas file terhadap evidence release yang dipublikasikan. Checksum tidak membuat identitas publisher Windows dan tidak menggantikan Authenticode.

+
Known limitations

Baca sebelum dipakai di lapangan.

    {{RELEASE_LIMITATIONS_ID}}
Status code-signing

{{SIGNING_LABEL_ID}}

{{SIGNING_DETAIL_ID}}

Status: {{SIGNING_STATUS}}

Checksum memverifikasi identitas file terhadap evidence release. Checksum tidak membuat identitas publisher Windows dan tidak menggantikan Authenticode.

-
Menemukan masalah release?

Laporkan dengan evidence yang bisa direproduksi.

Sertakan versi ARSAS, jenis paket, versi Windows, error persis, log atau screenshot terkait, konteks IED/vendor bila relevan, dan apakah checksum sesuai.

+
Scope SBOM dan provenance

Evidence hanya berlaku pada release yang benar-benar membawanya.

ARSAS {{STABLE_VERSION}} memiliki package evidence yang terlihat pada tagged release. Halaman ini tidak menganggap SBOM atau GitHub provenance attestation tersedia ketika asset tersebut tidak ada.

Release saat ini

Gunakan nama package, ukuran, SHA-256, tanggal publish dan status Authenticode untuk exact version ini.

Release setelah P3

Workflow Windows menghasilkan SPDX SBOM dan GitHub artifact attestation dalam release job yang sama serta melampirkan SBOM ke GitHub Release.

Aturan verifikasi

Periksa exact tag, asset SBOM dan subject digest repository attestation sebelum membuat klaim provenance.

+ +
Menemukan masalah release?

Laporkan dengan evidence yang dapat direproduksi.

Sertakan versi ARSAS, jenis paket, Windows, exact error, log atau screenshot yang sudah disanitasi, konteks device bila relevan dan apakah checksum sesuai.

{{> download-cta-id}}
From b2b29bfceb6025712450d16a0650534ff40f62d3 Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 11:14:23 +0700 Subject: [PATCH 38/57] Generate deterministic SPDX release SBOM --- scripts/generate-release-sbom.py | 105 +++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 scripts/generate-release-sbom.py diff --git a/scripts/generate-release-sbom.py b/scripts/generate-release-sbom.py new file mode 100644 index 0000000..b1c1edc --- /dev/null +++ b/scripts/generate-release-sbom.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +"""Generate a deterministic SPDX 2.3 JSON SBOM for an extracted ARSAS Windows package.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +from datetime import datetime, timezone +from pathlib import Path + + +def sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def spdx_id(relative: str) -> str: + normalized = re.sub(r"[^A-Za-z0-9.-]+", "-", relative).strip("-") + suffix = hashlib.sha256(relative.encode("utf-8")).hexdigest()[:12] + return f"SPDXRef-File-{normalized[:80]}-{suffix}" + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--package-dir", required=True) + parser.add_argument("--version", required=True) + parser.add_argument("--source-commit", required=True) + parser.add_argument("--output", required=True) + args = parser.parse_args() + + package_dir = Path(args.package_dir).resolve() + output = Path(args.output).resolve() + if not package_dir.is_dir(): + raise SystemExit(f"Package directory does not exist: {package_dir}") + if not re.fullmatch(r"\d+\.\d+\.\d+([-.][0-9A-Za-z.-]+)?", args.version): + raise SystemExit("Version is not semantic") + if not re.fullmatch(r"[0-9a-fA-F]{40}", args.source_commit): + raise SystemExit("Source commit must be a full Git SHA") + + files = [path for path in sorted(package_dir.rglob("*")) if path.is_file()] + if not files: + raise SystemExit("Package directory contains no files") + file_entries = [] + verification_input = hashlib.sha1() + relationships = [{"spdxElementId": "SPDXRef-DOCUMENT", "relationshipType": "DESCRIBES", "relatedSpdxElement": "SPDXRef-Package-ARSAS"}] + for path in files: + relative = path.relative_to(package_dir).as_posix() + digest = sha256(path) + verification_input.update(bytes.fromhex(digest)) + identifier = spdx_id(relative) + file_entries.append({ + "SPDXID": identifier, + "fileName": "./" + relative, + "checksums": [{"algorithm": "SHA256", "checksumValue": digest}], + "licenseConcluded": "NOASSERTION", + "licenseInfoInFiles": ["NOASSERTION"], + "copyrightText": "NOASSERTION", + }) + relationships.append({"spdxElementId": "SPDXRef-Package-ARSAS", "relationshipType": "CONTAINS", "relatedSpdxElement": identifier}) + + namespace_seed = hashlib.sha256((args.version + args.source_commit + verification_input.hexdigest()).encode("utf-8")).hexdigest() + document = { + "spdxVersion": "SPDX-2.3", + "dataLicense": "CC0-1.0", + "SPDXID": "SPDXRef-DOCUMENT", + "name": f"ARSAS-{args.version}-windows-x64", + "documentNamespace": f"https://github.com/masarray/arsas/sbom/{args.version}/{namespace_seed}", + "creationInfo": { + "created": datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z"), + "creators": ["Tool: ARSAS generate-release-sbom.py", "Person: Ari Sulistiono"], + "licenseListVersion": "3.25", + }, + "documentDescribes": ["SPDXRef-Package-ARSAS"], + "packages": [{ + "name": "ARSAS", + "SPDXID": "SPDXRef-Package-ARSAS", + "versionInfo": args.version, + "downloadLocation": f"https://github.com/masarray/arsas/releases/tag/v{args.version}", + "filesAnalyzed": True, + "packageVerificationCode": {"packageVerificationCodeValue": verification_input.hexdigest()}, + "licenseConcluded": "GPL-3.0-or-later", + "licenseDeclared": "GPL-3.0-or-later", + "copyrightText": "Copyright (C) 2026 Ari Sulistiono", + "externalRefs": [{ + "referenceCategory": "PERSISTENT-ID", + "referenceType": "gitoid", + "referenceLocator": f"git:{args.source_commit.lower()}", + }], + }], + "files": file_entries, + "relationships": relationships, + } + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(json.dumps(document, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + print(f"Generated SPDX 2.3 SBOM with {len(files)} files: {output}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From bbe72c567ca09cce73537ed1cecb6286e6d0e17b Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 11:15:06 +0700 Subject: [PATCH 39/57] Add post-publication SBOM and artifact attestation workflow --- .github/workflows/release-supply-chain.yml | 153 +++++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 .github/workflows/release-supply-chain.yml diff --git a/.github/workflows/release-supply-chain.yml b/.github/workflows/release-supply-chain.yml new file mode 100644 index 0000000..ae8df93 --- /dev/null +++ b/.github/workflows/release-supply-chain.yml @@ -0,0 +1,153 @@ +name: Attest ARSAS release supply chain + +on: + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: Existing stable release tag, for example v1.6.19 + required: true + type: string + +permissions: + contents: write + id-token: write + attestations: write + +concurrency: + group: release-supply-chain-${{ github.event.release.tag_name || inputs.tag }} + cancel-in-progress: false + +jobs: + attest: + name: Verify assets, publish SPDX SBOM and attest digests + runs-on: ubuntu-latest + steps: + - name: Resolve release identity + id: release + env: + EVENT_TAG: ${{ github.event.release.tag_name }} + INPUT_TAG: ${{ inputs.tag }} + shell: bash + run: | + set -euo pipefail + tag="${EVENT_TAG:-${INPUT_TAG:-}}" + if [[ ! "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.-]+)?$ ]]; then + echo "Invalid release tag: $tag" >&2 + exit 1 + fi + version="${tag#v}" + echo "tag=$tag" >> "$GITHUB_OUTPUT" + echo "version=$version" >> "$GITHUB_OUTPUT" + + - name: Checkout exact release source + uses: actions/checkout@v4 + with: + ref: ${{ steps.release.outputs.tag }} + fetch-depth: 1 + + - name: Download exact public release assets + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ steps.release.outputs.tag }} + shell: bash + run: | + set -euo pipefail + mkdir -p _supply + gh release download "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --dir _supply \ + --pattern 'ARSAS-Windows-x64-Setup.exe' \ + --pattern 'ARSAS-Windows-x64-Portable.zip' \ + --pattern 'ARSAS-Windows-x64-SHA256SUMS.txt' + test -s _supply/ARSAS-Windows-x64-Setup.exe + test -s _supply/ARSAS-Windows-x64-Portable.zip + test -s _supply/ARSAS-Windows-x64-SHA256SUMS.txt + + - name: Verify release checksums before attestation + working-directory: _supply + shell: bash + run: sha256sum --check ARSAS-Windows-x64-SHA256SUMS.txt + + - name: Extract portable package for component inventory + shell: bash + run: | + set -euo pipefail + mkdir -p _supply/package + unzip -q _supply/ARSAS-Windows-x64-Portable.zip -d _supply/package + + - name: Generate deterministic SPDX 2.3 SBOM + shell: bash + run: | + python scripts/generate-release-sbom.py \ + --package-dir _supply/package \ + --version '${{ steps.release.outputs.version }}' \ + --source-commit "$GITHUB_SHA" \ + --output _supply/ARSAS-Windows-x64-SBOM.spdx.json + + - name: Validate SBOM identity and package files + shell: bash + run: | + python - <<'PY' + import json + from pathlib import Path + path = Path('_supply/ARSAS-Windows-x64-SBOM.spdx.json') + value = json.loads(path.read_text()) + if value.get('spdxVersion') != 'SPDX-2.3' or value.get('dataLicense') != 'CC0-1.0': + raise SystemExit('Invalid SPDX identity') + packages = value.get('packages', []) + if len(packages) != 1 or packages[0].get('name') != 'ARSAS': + raise SystemExit('Invalid ARSAS package identity') + if len(value.get('files', [])) < 20: + raise SystemExit('SBOM contains too few package files') + PY + + - name: Publish SPDX SBOM on the tagged release + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ steps.release.outputs.tag }} + shell: bash + run: gh release upload "$RELEASE_TAG" _supply/ARSAS-Windows-x64-SBOM.spdx.json --repo "$GITHUB_REPOSITORY" --clobber + + - name: Attest installer artifact digest + uses: actions/attest@v4 + with: + subject-path: _supply/ARSAS-Windows-x64-Setup.exe + + - name: Attest portable package with SPDX SBOM + uses: actions/attest@v4 + with: + subject-path: _supply/ARSAS-Windows-x64-Portable.zip + sbom-path: _supply/ARSAS-Windows-x64-SBOM.spdx.json + + - name: Record private supply-chain evidence + shell: bash + run: | + { + echo '# ARSAS release supply-chain evidence' + echo + echo '- Tag: `${{ steps.release.outputs.tag }}`' + echo '- Source commit: `'$GITHUB_SHA'`' + echo '- Installer SHA-256: `' + sha256sum _supply/ARSAS-Windows-x64-Setup.exe | cut -d' ' -f1 + echo '`' + echo '- Portable SHA-256: `' + sha256sum _supply/ARSAS-Windows-x64-Portable.zip | cut -d' ' -f1 + echo '`' + echo '- SBOM: `ARSAS-Windows-x64-SBOM.spdx.json`' + echo '- Attestation type: GitHub artifact digest and SBOM attestation after checksum-verified publication.' + echo + echo 'This evidence does not retroactively describe another tag and is not IEC 61850 conformance certification.' + } > _supply/evidence.md + cat _supply/evidence.md >> "$GITHUB_STEP_SUMMARY" + + - name: Upload supply-chain evidence artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: release-supply-chain-${{ steps.release.outputs.version }} + path: | + _supply/ARSAS-Windows-x64-SHA256SUMS.txt + _supply/ARSAS-Windows-x64-SBOM.spdx.json + _supply/evidence.md + if-no-files-found: warn + retention-days: 90 From a8e2e4be050b2342dc53789dcabdcf89690b32c6 Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 11:17:23 +0700 Subject: [PATCH 40/57] Add P3 adoption and field-proof quality workflow --- .github/workflows/adoption-proof.yml | 119 +++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 .github/workflows/adoption-proof.yml diff --git a/.github/workflows/adoption-proof.yml b/.github/workflows/adoption-proof.yml new file mode 100644 index 0000000..fa13e12 --- /dev/null +++ b/.github/workflows/adoption-proof.yml @@ -0,0 +1,119 @@ +name: Validate adoption and field proof + +on: + pull_request: + branches: [main] + paths: + - "landing/**" + - "scripts/build-product-site.py" + - "scripts/apply-search-authority.py" + - "scripts/build-responsive-media.py" + - "scripts/validate-responsive-media.py" + - "scripts/validate-adoption-proof.py" + - "scripts/generate-release-sbom.py" + - ".github/ISSUE_TEMPLATE/**" + - ".github/workflows/adoption-proof.yml" + - ".github/workflows/release-supply-chain.yml" + push: + branches: [main] + paths: + - "landing/**" + - "scripts/build-product-site.py" + - "scripts/apply-search-authority.py" + - "scripts/build-responsive-media.py" + - "scripts/validate-responsive-media.py" + - "scripts/validate-adoption-proof.py" + - "scripts/generate-release-sbom.py" + - ".github/ISSUE_TEMPLATE/**" + - ".github/workflows/adoption-proof.yml" + - ".github/workflows/release-supply-chain.yml" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: adoption-proof-${{ github.ref }} + cancel-in-progress: true + +jobs: + validate: + name: Validate onboarding, evidence, media and issue intake + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + show-progress: false + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install responsive media dependency + run: python -m pip install --disable-pip-version-check --quiet pillow + + - name: Validate source adoption and supply-chain contracts + run: python scripts/validate-adoption-proof.py + + - name: Build deterministic product website + run: python scripts/build-product-site.py --output _site --release-evidence landing/latest.json + + - name: Apply contextual search authority graph + run: python scripts/apply-search-authority.py _site + + - name: Generate bilingual privacy pages + run: python scripts/generate-privacy-pages.py --output _site --release-evidence landing/latest.json --measurement-id "" + + - name: Generate responsive screenshot variants + run: python scripts/build-responsive-media.py _site + + - name: Configure disabled consent-gated measurement + run: python scripts/inject-site-measurement.py _site --measurement-id "" + + - name: Validate rendered adoption and field proof + run: python scripts/validate-adoption-proof.py --site _site + + - name: Validate responsive media + run: python scripts/validate-responsive-media.py _site + + - name: Validate contextual authority + run: python scripts/validate-search-authority.py _site + + - name: Validate measurement and privacy boundaries + run: python scripts/validate-site-measurement.py _site --measurement-id "" + + - name: Check internal links and fragments + run: python scripts/check-site-health.py --site _site --output _adoption/health + + - name: Validate rendered product website + run: python scripts/validate-product-build.py _site + + - name: Exercise SPDX generator + shell: bash + run: | + set -euo pipefail + mkdir -p _adoption/sbom-input + printf 'ARSAS P3 SBOM fixture\n' > _adoption/sbom-input/fixture.txt + python scripts/generate-release-sbom.py \ + --package-dir _adoption/sbom-input \ + --version 1.6.18 \ + --source-commit 63ffee62d073486a67ad37921a7c544661203735 \ + --output _adoption/fixture.spdx.json + python - <<'PY' + import json + value = json.load(open('_adoption/fixture.spdx.json')) + assert value['spdxVersion'] == 'SPDX-2.3' + assert value['packages'][0]['name'] == 'ARSAS' + assert len(value['files']) == 1 + PY + + - name: Upload P3 quality evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: adoption-field-proof-quality + path: _adoption/ + if-no-files-found: warn + retention-days: 30 From de83ce95d9c789f1ebabe51c883954345b389e63 Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 11:18:38 +0700 Subject: [PATCH 41/57] Validate post-publication SBOM and artifact attestation scope --- scripts/validate-adoption-proof.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/scripts/validate-adoption-proof.py b/scripts/validate-adoption-proof.py index bf2d167..7fc91cf 100644 --- a/scripts/validate-adoption-proof.py +++ b/scripts/validate-adoption-proof.py @@ -5,7 +5,6 @@ import argparse import json -import re import sys from pathlib import Path @@ -57,7 +56,7 @@ def main() -> int: quick = read(TEMPLATES / "quick-start.html", errors) quick_id = read(TEMPLATES / "panduan-mulai-arsas.html", errors) for text, label in ((quick, "quick-start.html"), (quick_id, "panduan-mulai-arsas.html")): - for value in ("quick-step-number", "TCP port 102", "Npcap", "live MMS", "quality", "timestamp", "reporting", "polling", "diagnostic", "control"): + for value in ("quick-step-number", "TCP port 102", "Npcap", "MMS", "live", "quality", "timestamp", "reporting", "polling", "diagnostic", "control"): if value.lower() not in text.lower(): errors.append(f"{label}: missing onboarding contract {value}") if text.count('class="quick-step"') != 7: @@ -149,17 +148,21 @@ def main() -> int: if value.lower() not in combined_forms.lower(): errors.append(f"issue forms missing intake boundary {value}") - release_workflow = read(ROOT / ".github" / "workflows" / "release-windows.yml", errors) - for value in ("actions/attest@v4", "attestations: write", "id-token: write", "sbom-tool-win-x64.exe", "ARSAS-Windows-x64-SBOM.spdx.json"): - if value not in release_workflow: - errors.append(f"release workflow missing future supply-chain contract {value}") + supply_workflow = read(ROOT / ".github" / "workflows" / "release-supply-chain.yml", errors) + for value in ("actions/attest@v4", "attestations: write", "id-token: write", "sha256sum --check", "generate-release-sbom.py", "ARSAS-Windows-x64-SBOM.spdx.json"): + if value not in supply_workflow: + errors.append(f"release supply-chain workflow missing contract {value}") + sbom_generator = read(ROOT / "scripts" / "generate-release-sbom.py", errors) + for value in ("SPDX-2.3", "packageVerificationCode", "SHA256", "GPL-3.0-or-later"): + if value not in sbom_generator: + errors.append(f"SBOM generator missing contract {value}") for name in ("download.html", "unduh.html", "release-notes.html", "catatan-rilis.html"): text = read(TEMPLATES / name, errors) if "SBOM" not in text or "provenance" not in text.lower(): errors.append(f"{name}: missing supply-chain evidence guidance") - if "1.6.18" in text and "provenance available" in text.lower(): - errors.append(f"{name}: falsely claims provenance for current release") + if "does not claim" not in text.lower() and "tidak mengklaim" not in text.lower() and "tidak menganggap" not in text.lower(): + errors.append(f"{name}: missing honest current-release provenance boundary") if args.site: site = Path(args.site).resolve() @@ -176,7 +179,7 @@ def main() -> int: for error in errors: print(f"- {error}", file=sys.stderr) return 1 - print("ARSAS adoption and field-proof validation passed: 54 pages, 17 localized pages, onboarding, FAQ, evidence, demo, issue intake and future provenance.") + print("ARSAS adoption and field-proof validation passed: 54 pages, 17 localized pages, onboarding, FAQ, evidence, demo, issue intake, responsive media and future supply-chain attestations.") return 0 From a1d3cb8520dfdb6a83a601490c1d2b9df6788a02 Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 11:19:57 +0700 Subject: [PATCH 42/57] Verify P3 adoption surfaces in production --- scripts/verify-adoption-deployment.py | 68 +++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 scripts/verify-adoption-deployment.py diff --git a/scripts/verify-adoption-deployment.py b/scripts/verify-adoption-deployment.py new file mode 100644 index 0000000..9bdf0e8 --- /dev/null +++ b/scripts/verify-adoption-deployment.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""Verify that public Pages exposes P3 adoption and field-proof surfaces.""" + +from __future__ import annotations + +import argparse +import json +from urllib.error import HTTPError, URLError +from urllib.parse import urlencode, urljoin +from urllib.request import Request, urlopen + + +def fetch(url: str) -> tuple[int, str]: + request = Request(url, headers={"User-Agent": "ARSAS-Adoption-Attestation/1.0", "Cache-Control": "no-cache"}) + try: + with urlopen(request, timeout=25) as response: + return response.status, response.read().decode("utf-8", errors="replace") + except HTTPError as exc: + return exc.code, exc.read().decode("utf-8", errors="replace") + except URLError as exc: + raise RuntimeError(str(exc.reason)) from exc + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--base-url", required=True) + parser.add_argument("--source-commit", required=True) + args = parser.parse_args() + base = args.base_url.rstrip("/") + "/" + nonce = urlencode({"adoption": args.source_commit}) + checks = { + "quick-start.html": ("quick-step-number", "data-responsive-media=\"webp\"", "panduan-mulai-arsas.html"), + "faq.html": ('"@type":"FAQPage"', "faq-item", "faq-arsas.html"), + "compatibility.html": ("field-profile-a-file-service", "field-profile-b-rcb-export", "device-evidence.json"), + "demo.html": ("data-guided-demo", "data-demo-step", "demo.js", "data-responsive-media=\"webp\""), + "guides.html": ("data-guide-filter", "data-guide-card", "guide-filter.js"), + } + errors: list[str] = [] + for path, required in checks.items(): + status, body = fetch(urljoin(base, path) + "?" + nonce) + if status != 200: + errors.append(f"{path} returned HTTP {status}") + continue + for marker in required: + if marker not in body: + errors.append(f"{path} is missing {marker}") + status, body = fetch(urljoin(base, "device-evidence.json") + "?" + nonce) + if status != 200: + errors.append(f"device-evidence.json returned HTTP {status}") + else: + try: + evidence = json.loads(body) + except json.JSONDecodeError as exc: + errors.append(f"device-evidence.json is invalid JSON: {exc}") + else: + if evidence.get("namedDeviceCount") != 0 or len(evidence.get("profiles", [])) != 2: + errors.append("public compatibility evidence boundary is invalid") + if errors: + print("Public ARSAS adoption attestation failed:") + for error in errors: + print(f"- {error}") + return 1 + print("Public ARSAS adoption attestation passed: Quick Start, FAQ, compatibility, demo, filters and responsive media are live.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From a72743fa785367c5aebcfaa8dde3034e0e6f7f0e Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 11:21:03 +0700 Subject: [PATCH 43/57] Deploy and attest P3 adoption surfaces and responsive media --- .github/workflows/pages.yml | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 5492f5a..3d17d58 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -10,6 +10,10 @@ on: - "scripts/validate-product-build.py" - "scripts/apply-search-authority.py" - "scripts/validate-search-authority.py" + - "scripts/build-responsive-media.py" + - "scripts/validate-responsive-media.py" + - "scripts/validate-adoption-proof.py" + - "scripts/verify-adoption-deployment.py" - "scripts/submit-indexnow.py" - "scripts/inject-site-measurement.py" - "scripts/validate-site-measurement.py" @@ -18,7 +22,9 @@ on: - "scripts/stamp-site-build.py" - "scripts/verify-pages-deployment.py" - "scripts/resolve-site-source-commit.py" + - ".github/ISSUE_TEMPLATE/**" - ".github/workflows/pages.yml" + - ".github/workflows/release-supply-chain.yml" pull_request: branches: [ main ] paths: @@ -28,6 +34,10 @@ on: - "scripts/validate-product-build.py" - "scripts/apply-search-authority.py" - "scripts/validate-search-authority.py" + - "scripts/build-responsive-media.py" + - "scripts/validate-responsive-media.py" + - "scripts/validate-adoption-proof.py" + - "scripts/verify-adoption-deployment.py" - "scripts/submit-indexnow.py" - "scripts/inject-site-measurement.py" - "scripts/validate-site-measurement.py" @@ -36,7 +46,9 @@ on: - "scripts/stamp-site-build.py" - "scripts/verify-pages-deployment.py" - "scripts/resolve-site-source-commit.py" + - ".github/ISSUE_TEMPLATE/**" - ".github/workflows/pages.yml" + - ".github/workflows/release-supply-chain.yml" workflow_dispatch: permissions: @@ -65,6 +77,9 @@ jobs: with: python-version: "3.12" + - name: Install responsive media dependency + run: python -m pip install --disable-pip-version-check --quiet pillow + - name: Resolve deployable website source id: site_source run: python scripts/resolve-site-source-commit.py --output _validation/site-source.json @@ -84,7 +99,6 @@ jobs: import json import re from pathlib import Path - evidence = json.loads(Path('/tmp/arsas-published.json').read_text()) notes = json.loads(Path('landing/release-notes.json').read_text()) if evidence.get('product') not in (None, 'ARSAS'): @@ -103,13 +117,14 @@ jobs: raise SystemExit(f'{name} size is invalid') PY - - name: Validate product source, templates and SEO contract + - name: Validate product source, SEO, adoption and field-proof contracts id: source_validation continue-on-error: true shell: bash run: | mkdir -p _validation python scripts/validate-product-source.py > _validation/source.log 2>&1 + python scripts/validate-adoption-proof.py >> _validation/source.log 2>&1 - name: Upload source validation report if: always() @@ -139,6 +154,9 @@ jobs: GA4_MEASUREMENT_ID: ${{ vars.GA4_MEASUREMENT_ID }} run: python scripts/generate-privacy-pages.py --output _site --release-evidence /tmp/arsas-published.json --measurement-id "$GA4_MEASUREMENT_ID" + - name: Generate responsive screenshot variants + run: python scripts/build-responsive-media.py _site + - name: Configure optional client measurement env: GA4_MEASUREMENT_ID: ${{ vars.GA4_MEASUREMENT_ID }} @@ -163,6 +181,12 @@ jobs: - name: Validate contextual authority and guide discovery run: python scripts/validate-search-authority.py _site + - name: Validate adoption and field-proof output + run: python scripts/validate-adoption-proof.py --site _site + + - name: Validate responsive screenshot output + run: python scripts/validate-responsive-media.py _site + - name: Check internal links and fragments run: python scripts/check-site-health.py --site _site --output _validation/site-health @@ -261,6 +285,9 @@ jobs: --measurement-enabled "$measurement_enabled" \ --output _validation/production-attestation.md + - name: Verify public adoption, evidence and responsive media surfaces + run: python scripts/verify-adoption-deployment.py --base-url "$CANONICAL_ROOT" --source-commit "${{ steps.site_source.outputs.site_source_commit }}" + - name: Upload production attestation if: always() uses: actions/upload-artifact@v4 From 98461779404c15fcadcc4395600836d2c7dab033 Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 11:24:46 +0700 Subject: [PATCH 44/57] Track P3 website generators and adoption attestation --- scripts/resolve-site-source-commit.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/scripts/resolve-site-source-commit.py b/scripts/resolve-site-source-commit.py index 6210779..c1b07d1 100644 --- a/scripts/resolve-site-source-commit.py +++ b/scripts/resolve-site-source-commit.py @@ -18,6 +18,10 @@ "scripts/validate-product-build.py", "scripts/apply-search-authority.py", "scripts/validate-search-authority.py", + "scripts/build-responsive-media.py", + "scripts/validate-responsive-media.py", + "scripts/validate-adoption-proof.py", + "scripts/verify-adoption-deployment.py", "scripts/submit-indexnow.py", "scripts/inject-site-measurement.py", "scripts/validate-site-measurement.py", @@ -73,7 +77,7 @@ def main() -> int: raise SystemExit(f"HEAD {head} does not change the deployable website; latest website source is {source}") payload = { - "schemaVersion": 1, + "schemaVersion": 2, "resolvedAtUtc": datetime.now(timezone.utc).isoformat(), "headCommit": head, "siteSourceCommit": source, @@ -91,10 +95,7 @@ def main() -> int: write_output("site_source_commit", source) write_output("site_source_timestamp", timestamp) write_output("head_is_site_source", str(head_is_source).lower()) - print( - "ARSAS site source resolved: " - f"head={head}, siteSource={source}, headIsSiteSource={head_is_source}." - ) + print(f"ARSAS site source resolved: head={head}, siteSource={source}, headIsSiteSource={head_is_source}.") return 0 From 07724cabf0f4b0523ebe88351519d12bc83a3adf Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 11:25:22 +0700 Subject: [PATCH 45/57] Bind release attestations to the exact tagged commit --- .github/workflows/release-supply-chain.yml | 47 +++++++++++++--------- 1 file changed, 29 insertions(+), 18 deletions(-) diff --git a/.github/workflows/release-supply-chain.yml b/.github/workflows/release-supply-chain.yml index ae8df93..2e9cc46 100644 --- a/.github/workflows/release-supply-chain.yml +++ b/.github/workflows/release-supply-chain.yml @@ -47,6 +47,18 @@ jobs: ref: ${{ steps.release.outputs.tag }} fetch-depth: 1 + - name: Resolve exact tagged source commit + id: source + shell: bash + run: | + set -euo pipefail + source_commit="$(git rev-parse HEAD)" + if [[ ! "$source_commit" =~ ^[0-9a-f]{40}$ ]]; then + echo "Invalid tagged source commit: $source_commit" >&2 + exit 1 + fi + echo "source_commit=$source_commit" >> "$GITHUB_OUTPUT" + - name: Download exact public release assets env: GH_TOKEN: ${{ github.token }} @@ -75,13 +87,13 @@ jobs: mkdir -p _supply/package unzip -q _supply/ARSAS-Windows-x64-Portable.zip -d _supply/package - - name: Generate deterministic SPDX 2.3 SBOM + - name: Generate SPDX 2.3 package SBOM shell: bash run: | python scripts/generate-release-sbom.py \ --package-dir _supply/package \ --version '${{ steps.release.outputs.version }}' \ - --source-commit "$GITHUB_SHA" \ + --source-commit '${{ steps.source.outputs.source_commit }}' \ --output _supply/ARSAS-Windows-x64-SBOM.spdx.json - name: Validate SBOM identity and package files @@ -122,22 +134,21 @@ jobs: - name: Record private supply-chain evidence shell: bash run: | - { - echo '# ARSAS release supply-chain evidence' - echo - echo '- Tag: `${{ steps.release.outputs.tag }}`' - echo '- Source commit: `'$GITHUB_SHA'`' - echo '- Installer SHA-256: `' - sha256sum _supply/ARSAS-Windows-x64-Setup.exe | cut -d' ' -f1 - echo '`' - echo '- Portable SHA-256: `' - sha256sum _supply/ARSAS-Windows-x64-Portable.zip | cut -d' ' -f1 - echo '`' - echo '- SBOM: `ARSAS-Windows-x64-SBOM.spdx.json`' - echo '- Attestation type: GitHub artifact digest and SBOM attestation after checksum-verified publication.' - echo - echo 'This evidence does not retroactively describe another tag and is not IEC 61850 conformance certification.' - } > _supply/evidence.md + set -euo pipefail + installer_sha="$(sha256sum _supply/ARSAS-Windows-x64-Setup.exe | cut -d' ' -f1)" + portable_sha="$(sha256sum _supply/ARSAS-Windows-x64-Portable.zip | cut -d' ' -f1)" + cat > _supply/evidence.md <> "$GITHUB_STEP_SUMMARY" - name: Upload supply-chain evidence artifact From eb5d6f286be18d26dc00b25ae87c9ecb32283cba Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 11:26:07 +0700 Subject: [PATCH 46/57] Document P3 adoption and field-proof system --- docs/adoption-field-proof.md | 57 ++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 docs/adoption-field-proof.md diff --git a/docs/adoption-field-proof.md b/docs/adoption-field-proof.md new file mode 100644 index 0000000..e84413a --- /dev/null +++ b/docs/adoption-field-proof.md @@ -0,0 +1,57 @@ +# ARSAS P3 — Adoption and field proof + +P3 connects discovery and download with first-run success, bounded compatibility evidence and structured field feedback. + +## Public adoption path + +The website provides four bilingual entry pairs: + +- `quick-start.html` and `panduan-mulai-arsas.html`; +- `faq.html` and `faq-arsas.html`; +- `compatibility.html` and `bukti-kompatibilitas.html`; +- `demo.html` and `demo-arsas.html`. + +Quick Start is read-first. It verifies the package, confirms the authorized network path and TCP port 102, discovers the live MMS model, observes one attributable value, distinguishes reporting from polling and stops before any unauthorized write or control. + +The guided demo uses real application screenshots. It is not a simulated relay session and makes no time-to-value claim. + +## Compatibility evidence governance + +`landing/device-evidence.json` is the machine-readable evidence registry. Compatibility is stated per service, date and condition using this vocabulary: + +- verified; +- conditional; +- observed; +- not tested; +- failed with known issue. + +The initial registry contains two anonymized field profiles and zero publicly named device models. A named vendor, model, firmware or site is not added without adequate evidence and disclosure permission. Verified does not mean IEC 61850 conformance certification. + +## Private guide search + +The English and Indonesian guide hubs provide local browser search and category filtering. Search text is not sent to a server and is not emitted as an analytics event. + +## Structured issue intake + +GitHub Issue Forms separate device compatibility, connection, reporting, file transfer, GOOSE, installation and feature requests. Forms require version and environment evidence plus confirmation that credentials and confidential project data were removed. + +Security-sensitive reports continue through private GitHub Security Advisories. + +## Responsive media + +`scripts/build-responsive-media.py` creates multiple WebP widths for each product screenshot and injects `srcset` and `sizes` into the built site. `scripts/validate-responsive-media.py` verifies every rendered screenshot candidate and records counts in `build-info.json`. + +## Supply-chain evidence + +The current stable release keeps its existing package name, size, SHA-256, publication and Authenticode evidence. P3 does not retroactively claim an SBOM or provenance attestation for an older release. + +For an exact published tag, `.github/workflows/release-supply-chain.yml`: + +1. checks out the exact tag; +2. downloads the official installer, portable ZIP and checksum file; +3. verifies the published SHA-256 values; +4. inventories the extracted portable package into SPDX 2.3 JSON; +5. uploads the SPDX file to the same tagged release; +6. creates GitHub artifact digest and SBOM attestations for the verified package files. + +This is checksum-verified post-publication artifact evidence. It is not described as reproducible build provenance and is not IEC 61850 conformance certification. From 16cc3f8a46fd651f75354fa6c9a0213d80e51925 Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 11:31:32 +0700 Subject: [PATCH 47/57] Accept equivalent conformance and provenance boundary wording --- scripts/validate-adoption-proof.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/scripts/validate-adoption-proof.py b/scripts/validate-adoption-proof.py index 7fc91cf..ceaa814 100644 --- a/scripts/validate-adoption-proof.py +++ b/scripts/validate-adoption-proof.py @@ -113,7 +113,8 @@ def main() -> int: for status in ("verified", "conditional", "observed"): if f'data-status="{status}"' not in text: errors.append(f"{label}: missing status {status}") - if "device-evidence.json" not in text or "conformance" not in text.lower(): + lower = text.lower() + if "device-evidence.json" not in text or not any(term in lower for term in ("conformance", "conformity")): errors.append(f"{label}: missing machine-readable or conformance boundary") for name in ("guides.html", "panduan.html"): @@ -159,9 +160,10 @@ def main() -> int: for name in ("download.html", "unduh.html", "release-notes.html", "catatan-rilis.html"): text = read(TEMPLATES / name, errors) - if "SBOM" not in text or "provenance" not in text.lower(): + lower = text.lower() + if "SBOM" not in text or "provenance" not in lower: errors.append(f"{name}: missing supply-chain evidence guidance") - if "does not claim" not in text.lower() and "tidak mengklaim" not in text.lower() and "tidak menganggap" not in text.lower(): + if not any(term in lower for term in ("does not claim", "does not infer", "tidak mengklaim", "tidak menganggap")): errors.append(f"{name}: missing honest current-release provenance boundary") if args.site: From f85ae5c2cc81ce9834229b89dcbb9b1d534050e5 Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 11:35:25 +0700 Subject: [PATCH 48/57] Write supply-chain evidence without shell-interpreted markdown --- .github/workflows/release-supply-chain.yml | 27 ++++++++++++---------- 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/.github/workflows/release-supply-chain.yml b/.github/workflows/release-supply-chain.yml index 2e9cc46..c0472ee 100644 --- a/.github/workflows/release-supply-chain.yml +++ b/.github/workflows/release-supply-chain.yml @@ -132,23 +132,26 @@ jobs: sbom-path: _supply/ARSAS-Windows-x64-SBOM.spdx.json - name: Record private supply-chain evidence + env: + RELEASE_TAG: ${{ steps.release.outputs.tag }} + SOURCE_COMMIT: ${{ steps.source.outputs.source_commit }} shell: bash run: | set -euo pipefail installer_sha="$(sha256sum _supply/ARSAS-Windows-x64-Setup.exe | cut -d' ' -f1)" portable_sha="$(sha256sum _supply/ARSAS-Windows-x64-Portable.zip | cut -d' ' -f1)" - cat > _supply/evidence.md < _supply/evidence.md cat _supply/evidence.md >> "$GITHUB_STEP_SUMMARY" - name: Upload supply-chain evidence artifact From 995f459e080bc3921dcbe4d53f2d6f82252618cb Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 11:40:46 +0700 Subject: [PATCH 49/57] Generate SBOM and attest exact Windows release artifacts --- .github/workflows/release-windows.yml | 57 ++++++++++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release-windows.yml b/.github/workflows/release-windows.yml index 450754a..92672a0 100644 --- a/.github/workflows/release-windows.yml +++ b/.github/workflows/release-windows.yml @@ -27,6 +27,8 @@ on: permissions: contents: write + id-token: write + attestations: write env: ARIEC61850_REF: main @@ -52,6 +54,11 @@ jobs: with: dotnet-version: 8.0.x + - name: Setup Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Resolve release version id: release shell: powershell @@ -186,6 +193,43 @@ jobs: if (-not (Test-Path $path -PathType Leaf)) { throw "Public release asset was not staged: $path" } } + - name: Generate SPDX 2.3 package SBOM + shell: powershell + run: | + python .\ArIED61850Tester\scripts\generate-release-sbom.py ` + --package-dir "$env:GITHUB_WORKSPACE\ArIED61850Tester\dist\ARSAS-$env:RELEASE_VERSION-win-x64" ` + --version $env:RELEASE_VERSION ` + --source-commit $env:GITHUB_SHA ` + --output "$env:GITHUB_WORKSPACE\ArIED61850Tester\dist\ARSAS-Windows-x64-SBOM.spdx.json" + + - name: Validate release checksums and SBOM + shell: powershell + run: | + $dist = Join-Path $env:GITHUB_WORKSPACE "ArIED61850Tester\dist" + $checksums = Get-Content (Join-Path $dist "ARSAS-Windows-x64-SHA256SUMS.txt") + foreach ($line in $checksums) { + if ($line -notmatch '^([0-9a-f]{64}) (.+)$') { throw "Invalid checksum line: $line" } + $expected = $Matches[1] + $path = Join-Path $dist $Matches[2] + $actual = (Get-FileHash $path -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actual -ne $expected) { throw "Checksum mismatch for $path" } + } + $sbom = Get-Content (Join-Path $dist "ARSAS-Windows-x64-SBOM.spdx.json") -Raw | ConvertFrom-Json + if ($sbom.spdxVersion -ne "SPDX-2.3" -or $sbom.packages[0].name -ne "ARSAS" -or $sbom.files.Count -lt 20) { + throw "Generated SPDX SBOM is incomplete." + } + + - name: Attest installer artifact digest + uses: actions/attest@v4 + with: + subject-path: ArIED61850Tester/dist/ARSAS-Windows-x64-Setup.exe + + - name: Attest portable package with SPDX SBOM + uses: actions/attest@v4 + with: + subject-path: ArIED61850Tester/dist/ARSAS-Windows-x64-Portable.zip + sbom-path: ArIED61850Tester/dist/ARSAS-Windows-x64-SBOM.spdx.json + - name: Upload Windows release artifacts uses: actions/upload-artifact@v4 with: @@ -198,6 +242,7 @@ jobs: ArIED61850Tester/dist/ARSAS-Windows-x64-Portable.zip ArIED61850Tester/dist/ARSAS-Windows-x64-Setup.exe ArIED61850Tester/dist/ARSAS-Windows-x64-SHA256SUMS.txt + ArIED61850Tester/dist/ARSAS-Windows-x64-SBOM.spdx.json - name: Create or update GitHub Release if: github.ref_type == 'tag' || github.ref == 'refs/heads/main' || inputs.publish_release == true @@ -211,6 +256,7 @@ jobs: (Join-Path $dist "ARSAS-Windows-x64-Setup.exe") (Join-Path $dist "ARSAS-Windows-x64-Portable.zip") (Join-Path $dist "ARSAS-Windows-x64-SHA256SUMS.txt") + (Join-Path $dist "ARSAS-Windows-x64-SBOM.spdx.json") ) $null = cmd /c "gh release view $env:RELEASE_TAG --repo $env:GITHUB_REPOSITORY >NUL 2>NUL" @@ -248,6 +294,7 @@ jobs: $installer = Join-Path $dist "ARSAS-Windows-x64-Setup.exe" $portable = Join-Path $dist "ARSAS-Windows-x64-Portable.zip" $checksums = Join-Path $dist "ARSAS-Windows-x64-SHA256SUMS.txt" + $sbom = Join-Path $dist "ARSAS-Windows-x64-SBOM.spdx.json" $record = [ordered]@{ version = $env:RELEASE_VERSION @@ -272,9 +319,17 @@ jobs: url = "https://github.com/$env:GITHUB_REPOSITORY/releases/latest/download/ARSAS-Windows-x64-SHA256SUMS.txt" sizeBytes = (Get-Item $checksums).Length } + supplyChain = [ordered]@{ + sbomName = "ARSAS-Windows-x64-SBOM.spdx.json" + sbomUrl = "https://github.com/$env:GITHUB_REPOSITORY/releases/latest/download/ARSAS-Windows-x64-SBOM.spdx.json" + sbomSha256 = (Get-FileHash $sbom -Algorithm SHA256).Hash.ToLowerInvariant() + sbomSizeBytes = (Get-Item $sbom).Length + attestationRepository = "https://github.com/$env:GITHUB_REPOSITORY/attestations" + attestationWorkflow = "release-windows.yml" + } } - $json = $record | ConvertTo-Json -Depth 5 + $json = $record | ConvertTo-Json -Depth 6 $content = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($json + "`n")) $apiPath = "repos/$env:GITHUB_REPOSITORY/contents/.release/published.json" $existingSha = $null From 001853b0278a0973739009db978623fb54958b0a Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 11:41:53 +0700 Subject: [PATCH 50/57] Keep post-publication attestation as explicit backfill only --- .github/workflows/release-supply-chain.yml | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release-supply-chain.yml b/.github/workflows/release-supply-chain.yml index c0472ee..c3a0225 100644 --- a/.github/workflows/release-supply-chain.yml +++ b/.github/workflows/release-supply-chain.yml @@ -1,8 +1,6 @@ -name: Attest ARSAS release supply chain +name: Backfill ARSAS release supply chain on: - release: - types: [published] workflow_dispatch: inputs: tag: @@ -16,7 +14,7 @@ permissions: attestations: write concurrency: - group: release-supply-chain-${{ github.event.release.tag_name || inputs.tag }} + group: release-supply-chain-${{ inputs.tag }} cancel-in-progress: false jobs: @@ -27,12 +25,11 @@ jobs: - name: Resolve release identity id: release env: - EVENT_TAG: ${{ github.event.release.tag_name }} INPUT_TAG: ${{ inputs.tag }} shell: bash run: | set -euo pipefail - tag="${EVENT_TAG:-${INPUT_TAG:-}}" + tag="${INPUT_TAG:-}" if [[ ! "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.-]+)?$ ]]; then echo "Invalid release tag: $tag" >&2 exit 1 From 711b4ad2d28262ee4dacb97e5d548af789c472ce Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 11:42:52 +0700 Subject: [PATCH 51/57] Document primary release attestations and manual backfill --- docs/adoption-field-proof.md | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/docs/adoption-field-proof.md b/docs/adoption-field-proof.md index e84413a..2740e40 100644 --- a/docs/adoption-field-proof.md +++ b/docs/adoption-field-proof.md @@ -45,13 +45,15 @@ Security-sensitive reports continue through private GitHub Security Advisories. The current stable release keeps its existing package name, size, SHA-256, publication and Authenticode evidence. P3 does not retroactively claim an SBOM or provenance attestation for an older release. -For an exact published tag, `.github/workflows/release-supply-chain.yml`: +For releases built after P3, `.github/workflows/release-windows.yml`: -1. checks out the exact tag; -2. downloads the official installer, portable ZIP and checksum file; -3. verifies the published SHA-256 values; -4. inventories the extracted portable package into SPDX 2.3 JSON; -5. uploads the SPDX file to the same tagged release; -6. creates GitHub artifact digest and SBOM attestations for the verified package files. +1. builds and smoke-tests the installer and portable package; +2. stages stable public assets and verifies their SHA-256 values; +3. inventories the exact portable package directory into SPDX 2.3 JSON; +4. creates GitHub artifact digest and SBOM attestations for those build outputs; +5. uploads the SBOM with the installer, portable ZIP and checksums to the same tagged release; +6. records the SBOM identity and attestation repository in publication evidence. -This is checksum-verified post-publication artifact evidence. It is not described as reproducible build provenance and is not IEC 61850 conformance certification. +`.github/workflows/release-supply-chain.yml` is a manual backfill path for an existing tag. It first downloads and checksum-verifies the published assets, then generates the SPDX file and post-publication attestations. It does not run automatically after the primary Windows release workflow, avoiding duplicate SBOMs with different creation timestamps. + +Build-time attestations establish workflow identity and subject digests. The manual backfill is described only as checksum-verified post-publication artifact evidence. Neither path is IEC 61850 conformance certification, and neither is a claim that every dependency or build environment is reproducible. From 116be870bcdbc5a486e6bcc6ccf713c5e8d7e112 Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 11:44:02 +0700 Subject: [PATCH 52/57] Validate primary and backfill release attestation workflows --- scripts/validate-supply-chain-workflows.py | 52 ++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 scripts/validate-supply-chain-workflows.py diff --git a/scripts/validate-supply-chain-workflows.py b/scripts/validate-supply-chain-workflows.py new file mode 100644 index 0000000..e41ac16 --- /dev/null +++ b/scripts/validate-supply-chain-workflows.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +"""Validate ARSAS primary release and explicit backfill supply-chain contracts.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +WORKFLOWS = ROOT / ".github" / "workflows" + + +def main() -> int: + errors: list[str] = [] + primary = (WORKFLOWS / "release-windows.yml").read_text(encoding="utf-8") + backfill = (WORKFLOWS / "release-supply-chain.yml").read_text(encoding="utf-8") + + primary_contract = ( + "id-token: write", "attestations: write", "Setup Python 3.12", + "generate-release-sbom.py", "ARSAS-Windows-x64-SBOM.spdx.json", + "actions/attest@v4", "subject-path: ArIED61850Tester/dist/ARSAS-Windows-x64-Setup.exe", + "sbom-path: ArIED61850Tester/dist/ARSAS-Windows-x64-SBOM.spdx.json", + "supplyChain = [ordered]@{", "attestationWorkflow = \"release-windows.yml\"", + ) + for value in primary_contract: + if value not in primary: + errors.append(f"release-windows.yml missing primary supply-chain contract: {value}") + + backfill_contract = ( + "name: Backfill ARSAS release supply chain", "workflow_dispatch:", + "sha256sum --check", "git rev-parse HEAD", "generate-release-sbom.py", + "actions/attest@v4", "post-publication evidence does not retroactively describe another tag", + ) + for value in backfill_contract: + if value not in backfill: + errors.append(f"release-supply-chain.yml missing backfill contract: {value}") + if "\n release:" in backfill: + errors.append("release-supply-chain.yml must not auto-run after the primary release workflow") + if "github.event.release" in backfill: + errors.append("release-supply-chain.yml must use an explicit tag input only") + + if errors: + print("ARSAS supply-chain workflow validation failed:", file=sys.stderr) + for error in errors: + print(f"- {error}", file=sys.stderr) + return 1 + print("ARSAS supply-chain workflow validation passed: primary build attestations and explicit checksum-verified backfill are separated.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From f62332d6cfe7349e68dc6c3b1b49ee284a33f107 Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 11:44:58 +0700 Subject: [PATCH 53/57] Validate primary and backfill supply-chain workflow separation --- .github/workflows/adoption-proof.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/adoption-proof.yml b/.github/workflows/adoption-proof.yml index fa13e12..8cb33ce 100644 --- a/.github/workflows/adoption-proof.yml +++ b/.github/workflows/adoption-proof.yml @@ -10,9 +10,11 @@ on: - "scripts/build-responsive-media.py" - "scripts/validate-responsive-media.py" - "scripts/validate-adoption-proof.py" + - "scripts/validate-supply-chain-workflows.py" - "scripts/generate-release-sbom.py" - ".github/ISSUE_TEMPLATE/**" - ".github/workflows/adoption-proof.yml" + - ".github/workflows/release-windows.yml" - ".github/workflows/release-supply-chain.yml" push: branches: [main] @@ -23,9 +25,11 @@ on: - "scripts/build-responsive-media.py" - "scripts/validate-responsive-media.py" - "scripts/validate-adoption-proof.py" + - "scripts/validate-supply-chain-workflows.py" - "scripts/generate-release-sbom.py" - ".github/ISSUE_TEMPLATE/**" - ".github/workflows/adoption-proof.yml" + - ".github/workflows/release-windows.yml" - ".github/workflows/release-supply-chain.yml" workflow_dispatch: @@ -54,9 +58,12 @@ jobs: - name: Install responsive media dependency run: python -m pip install --disable-pip-version-check --quiet pillow - - name: Validate source adoption and supply-chain contracts + - name: Validate source adoption contracts run: python scripts/validate-adoption-proof.py + - name: Validate release supply-chain workflow separation + run: python scripts/validate-supply-chain-workflows.py + - name: Build deterministic product website run: python scripts/build-product-site.py --output _site --release-evidence landing/latest.json From 45b9279ac082b0e18ea3d77486f396798db7946e Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 11:48:03 +0700 Subject: [PATCH 54/57] Calculate the SPDX 2.3 package verification code correctly --- scripts/generate-release-sbom.py | 112 ++++++++++++++++++++----------- 1 file changed, 74 insertions(+), 38 deletions(-) diff --git a/scripts/generate-release-sbom.py b/scripts/generate-release-sbom.py index b1c1edc..084e6c1 100644 --- a/scripts/generate-release-sbom.py +++ b/scripts/generate-release-sbom.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Generate a deterministic SPDX 2.3 JSON SBOM for an extracted ARSAS Windows package.""" +"""Generate an SPDX 2.3 JSON SBOM for an extracted ARSAS Windows package.""" from __future__ import annotations @@ -11,12 +11,24 @@ from pathlib import Path -def sha256(path: Path) -> str: - digest = hashlib.sha256() +def file_hashes(path: Path) -> tuple[str, str]: + sha1 = hashlib.sha1() + sha256 = hashlib.sha256() with path.open("rb") as handle: for chunk in iter(lambda: handle.read(1024 * 1024), b""): - digest.update(chunk) - return digest.hexdigest() + sha1.update(chunk) + sha256.update(chunk) + return sha1.hexdigest(), sha256.hexdigest() + + +def normalize_created(value: str) -> str: + text = value.strip() + if not text: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + parsed = datetime.fromisoformat(text.replace("Z", "+00:00")) + if parsed.tzinfo is None: + raise ValueError("created timestamp must include a timezone") + return parsed.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") def spdx_id(relative: str) -> str: @@ -30,6 +42,7 @@ def main() -> int: parser.add_argument("--package-dir", required=True) parser.add_argument("--version", required=True) parser.add_argument("--source-commit", required=True) + parser.add_argument("--created", default="", help="ISO-8601 source timestamp used for reproducible SBOM metadata") parser.add_argument("--output", required=True) args = parser.parse_args() @@ -41,29 +54,54 @@ def main() -> int: raise SystemExit("Version is not semantic") if not re.fullmatch(r"[0-9a-fA-F]{40}", args.source_commit): raise SystemExit("Source commit must be a full Git SHA") + try: + created = normalize_created(args.created) + except ValueError as exc: + raise SystemExit(f"Invalid created timestamp: {exc}") from exc files = [path for path in sorted(package_dir.rglob("*")) if path.is_file()] if not files: raise SystemExit("Package directory contains no files") + file_entries = [] - verification_input = hashlib.sha1() - relationships = [{"spdxElementId": "SPDXRef-DOCUMENT", "relationshipType": "DESCRIBES", "relatedSpdxElement": "SPDXRef-Package-ARSAS"}] + sha1_values: list[str] = [] + relationships = [ + { + "spdxElementId": "SPDXRef-DOCUMENT", + "relationshipType": "DESCRIBES", + "relatedSpdxElement": "SPDXRef-Package-ARSAS", + } + ] for path in files: relative = path.relative_to(package_dir).as_posix() - digest = sha256(path) - verification_input.update(bytes.fromhex(digest)) + sha1_digest, sha256_digest = file_hashes(path) + sha1_values.append(sha1_digest) identifier = spdx_id(relative) - file_entries.append({ - "SPDXID": identifier, - "fileName": "./" + relative, - "checksums": [{"algorithm": "SHA256", "checksumValue": digest}], - "licenseConcluded": "NOASSERTION", - "licenseInfoInFiles": ["NOASSERTION"], - "copyrightText": "NOASSERTION", - }) - relationships.append({"spdxElementId": "SPDXRef-Package-ARSAS", "relationshipType": "CONTAINS", "relatedSpdxElement": identifier}) - - namespace_seed = hashlib.sha256((args.version + args.source_commit + verification_input.hexdigest()).encode("utf-8")).hexdigest() + file_entries.append( + { + "SPDXID": identifier, + "fileName": "./" + relative, + "checksums": [ + {"algorithm": "SHA1", "checksumValue": sha1_digest}, + {"algorithm": "SHA256", "checksumValue": sha256_digest}, + ], + "licenseConcluded": "NOASSERTION", + "licenseInfoInFiles": ["NOASSERTION"], + "copyrightText": "NOASSERTION", + } + ) + relationships.append( + { + "spdxElementId": "SPDXRef-Package-ARSAS", + "relationshipType": "CONTAINS", + "relatedSpdxElement": identifier, + } + ) + + verification_code = hashlib.sha1("".join(sorted(sha1_values)).encode("ascii")).hexdigest() + namespace_seed = hashlib.sha256( + (args.version + args.source_commit.lower() + verification_code).encode("utf-8") + ).hexdigest() document = { "spdxVersion": "SPDX-2.3", "dataLicense": "CC0-1.0", @@ -71,27 +109,25 @@ def main() -> int: "name": f"ARSAS-{args.version}-windows-x64", "documentNamespace": f"https://github.com/masarray/arsas/sbom/{args.version}/{namespace_seed}", "creationInfo": { - "created": datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z"), + "created": created, "creators": ["Tool: ARSAS generate-release-sbom.py", "Person: Ari Sulistiono"], - "licenseListVersion": "3.25", }, "documentDescribes": ["SPDXRef-Package-ARSAS"], - "packages": [{ - "name": "ARSAS", - "SPDXID": "SPDXRef-Package-ARSAS", - "versionInfo": args.version, - "downloadLocation": f"https://github.com/masarray/arsas/releases/tag/v{args.version}", - "filesAnalyzed": True, - "packageVerificationCode": {"packageVerificationCodeValue": verification_input.hexdigest()}, - "licenseConcluded": "GPL-3.0-or-later", - "licenseDeclared": "GPL-3.0-or-later", - "copyrightText": "Copyright (C) 2026 Ari Sulistiono", - "externalRefs": [{ - "referenceCategory": "PERSISTENT-ID", - "referenceType": "gitoid", - "referenceLocator": f"git:{args.source_commit.lower()}", - }], - }], + "packages": [ + { + "name": "ARSAS", + "SPDXID": "SPDXRef-Package-ARSAS", + "versionInfo": args.version, + "downloadLocation": f"https://github.com/masarray/arsas/releases/tag/v{args.version}", + "homepage": "https://masarray.github.io/arsas/", + "sourceInfo": f"Built from Git commit {args.source_commit.lower()} in masarray/arsas.", + "filesAnalyzed": True, + "packageVerificationCode": {"packageVerificationCodeValue": verification_code}, + "licenseConcluded": "GPL-3.0-or-later", + "licenseDeclared": "GPL-3.0-or-later", + "copyrightText": "Copyright (C) 2026 Ari Sulistiono", + } + ], "files": file_entries, "relationships": relationships, } From f651704facebd7d01d0a5db8f61d36f0ef5118fa Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 11:49:17 +0700 Subject: [PATCH 55/57] Exercise the SPDX verification-code algorithm --- scripts/validate-supply-chain-workflows.py | 61 +++++++++++++++++++++- 1 file changed, 59 insertions(+), 2 deletions(-) diff --git a/scripts/validate-supply-chain-workflows.py b/scripts/validate-supply-chain-workflows.py index e41ac16..ae63b1f 100644 --- a/scripts/validate-supply-chain-workflows.py +++ b/scripts/validate-supply-chain-workflows.py @@ -1,15 +1,70 @@ #!/usr/bin/env python3 -"""Validate ARSAS primary release and explicit backfill supply-chain contracts.""" +"""Validate ARSAS primary release, backfill and SPDX supply-chain contracts.""" from __future__ import annotations +import hashlib +import json +import subprocess import sys +import tempfile from pathlib import Path ROOT = Path(__file__).resolve().parents[1] WORKFLOWS = ROOT / ".github" / "workflows" +def exercise_sbom(errors: list[str]) -> None: + with tempfile.TemporaryDirectory(prefix="arsas-sbom-") as temporary: + root = Path(temporary) + package = root / "package" + package.mkdir() + contents = { + "alpha.txt": b"alpha\n", + "nested/beta.bin": bytes(range(16)), + } + sha1_values: list[str] = [] + for relative, data in contents.items(): + target = package / relative + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(data) + sha1_values.append(hashlib.sha1(data).hexdigest()) + output = root / "fixture.spdx.json" + completed = subprocess.run( + [ + sys.executable, + str(ROOT / "scripts" / "generate-release-sbom.py"), + "--package-dir", str(package), + "--version", "1.6.18", + "--source-commit", "63ffee62d073486a67ad37921a7c544661203735", + "--created", "2026-07-22T00:00:00Z", + "--output", str(output), + ], + check=False, + capture_output=True, + text=True, + encoding="utf-8", + ) + if completed.returncode != 0: + errors.append("SPDX generator fixture failed: " + (completed.stderr.strip() or completed.stdout.strip())) + return + value = json.loads(output.read_text(encoding="utf-8")) + expected_code = hashlib.sha1("".join(sorted(sha1_values)).encode("ascii")).hexdigest() + package_value = value.get("packages", [{}])[0] + observed_code = package_value.get("packageVerificationCode", {}).get("packageVerificationCodeValue") + if observed_code != expected_code: + errors.append(f"SPDX package verification code is {observed_code!r}, expected {expected_code!r}") + files = value.get("files", []) + if len(files) != len(contents): + errors.append("SPDX generator fixture has an unexpected file count") + for item in files: + algorithms = {entry.get("algorithm") for entry in item.get("checksums", [])} + if algorithms != {"SHA1", "SHA256"}: + errors.append(f"SPDX file checksum algorithms are invalid: {algorithms}") + if value.get("creationInfo", {}).get("created") != "2026-07-22T00:00:00Z": + errors.append("SPDX generator did not preserve the supplied UTC source timestamp") + + def main() -> int: errors: list[str] = [] primary = (WORKFLOWS / "release-windows.yml").read_text(encoding="utf-8") @@ -39,12 +94,14 @@ def main() -> int: if "github.event.release" in backfill: errors.append("release-supply-chain.yml must use an explicit tag input only") + exercise_sbom(errors) + if errors: print("ARSAS supply-chain workflow validation failed:", file=sys.stderr) for error in errors: print(f"- {error}", file=sys.stderr) return 1 - print("ARSAS supply-chain workflow validation passed: primary build attestations and explicit checksum-verified backfill are separated.") + print("ARSAS supply-chain validation passed: primary build attestations, explicit backfill and SPDX verification code are correct.") return 0 From 60db27cb191d150717239a5db48765ca901926db Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 11:51:51 +0700 Subject: [PATCH 56/57] Generate responsive hero preload candidates --- scripts/build-responsive-media.py | 95 +++++++++++++++++++------------ 1 file changed, 60 insertions(+), 35 deletions(-) diff --git a/scripts/build-responsive-media.py b/scripts/build-responsive-media.py index aedd4d2..977a1fa 100644 --- a/scripts/build-responsive-media.py +++ b/scripts/build-responsive-media.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Generate responsive WebP screenshot variants and inject srcset metadata into built ARSAS pages.""" +"""Generate responsive WebP variants and inject image plus preload metadata into built ARSAS pages.""" from __future__ import annotations @@ -7,14 +7,20 @@ import json import re from pathlib import Path +from typing import Any from PIL import Image SCREENSHOT_PATTERN = re.compile(r']*?)src="(assets/screenshots/([^"/]+\.webp))"([^>]*)>', re.IGNORECASE) +PRELOAD_PATTERN = re.compile( + r']*?)rel="preload"([^>]*?)as="image"([^>]*?)href="(assets/screenshots/([^"/]+\.webp))"([^>]*)>', + re.IGNORECASE, +) WIDTHS = (480, 768, 960, 1280) +SIZES = "(max-width: 720px) calc(100vw - 2rem), (max-width: 1180px) calc(100vw - 4rem), 1180px" -def generate(source: Path, target_dir: Path) -> list[tuple[int, Path]]: +def generate(source: Path, target_dir: Path) -> dict[str, Any]: variants: list[tuple[int, Path]] = [] with Image.open(source) as image: original_width, original_height = image.size @@ -27,38 +33,54 @@ def generate(source: Path, target_dir: Path) -> list[tuple[int, Path]]: resized = image.resize((width, height), Image.Resampling.LANCZOS) resized.save(target, format="WEBP", quality=82, method=6) variants.append((width, target)) - return variants + return {"originalWidth": original_width, "variants": variants} -def inject_page(path: Path, variant_map: dict[str, list[tuple[int, Path]]], site: Path) -> int: +def candidates(source: str, media: dict[str, Any], site: Path) -> str: + values = [ + f"{variant.relative_to(site).as_posix()} {width}w" + for width, variant in media["variants"] + ] + values.append(f"{source} {media['originalWidth']}w") + return ", ".join(values) + + +def inject_page(path: Path, media_map: dict[str, dict[str, Any]], site: Path) -> tuple[int, int]: html = path.read_text(encoding="utf-8") - changed = 0 + image_changes = preload_changes = 0 - def replace(match: re.Match[str]) -> str: - nonlocal changed + def replace_image(match: re.Match[str]) -> str: + nonlocal image_changes before, source, filename, after = match.groups() - if "data-responsive-media=" in before + after or filename not in variant_map: + if "data-responsive-media=" in before + after or filename not in media_map: return match.group(0) - candidates = [ - f"{variant.relative_to(site).as_posix()} {width}w" - for width, variant in variant_map[filename] - ] - original_width_match = re.search(r'\bwidth="(\d+)"', before + after) - if original_width_match: - candidates.append(f"{source} {original_width_match.group(1)}w") - if len(candidates) < 2: + srcset = candidates(source, media_map[filename], site) + if srcset.count(",") < 1: return match.group(0) - changed += 1 + image_changes += 1 return ( - f'' ) - rendered = SCREENSHOT_PATTERN.sub(replace, html) - if changed: + def replace_preload(match: re.Match[str]) -> str: + nonlocal preload_changes + before, middle_one, middle_two, source, filename, after = match.groups() + full = match.group(0) + if "data-responsive-media-preload=" in full or filename not in media_map: + return full + srcset = candidates(source, media_map[filename], site) + preload_changes += 1 + return ( + f'' + ) + + rendered = SCREENSHOT_PATTERN.sub(replace_image, html) + rendered = PRELOAD_PATTERN.sub(replace_preload, rendered) + if image_changes or preload_changes: path.write_text(rendered, encoding="utf-8") - return changed + return image_changes, preload_changes def main() -> int: @@ -71,34 +93,37 @@ def main() -> int: raise SystemExit("Built screenshot directory is missing") responsive_dir = screenshot_dir / "responsive" - variant_map: dict[str, list[tuple[int, Path]]] = {} + media_map: dict[str, dict[str, Any]] = {} for source in sorted(screenshot_dir.glob("*.webp")): - variants = generate(source, responsive_dir) - if variants: - variant_map[source.name] = variants + media = generate(source, responsive_dir) + if media["variants"]: + media_map[source.name] = media - page_count = image_count = 0 + page_count = image_count = preload_count = 0 for page in sorted(site.glob("*.html")): - changed = inject_page(page, variant_map, site) - if changed: + images, preloads = inject_page(page, media_map, site) + if images or preloads: page_count += 1 - image_count += changed + image_count += images + preload_count += preloads info_path = site / "build-info.json" info = json.loads(info_path.read_text(encoding="utf-8")) info["responsiveMedia"] = { - "schemaVersion": 1, + "schemaVersion": 2, "format": "webp", - "sourceCount": len(variant_map), - "variantCount": sum(len(items) for items in variant_map.values()), + "sourceCount": len(media_map), + "variantCount": sum(len(item["variants"]) for item in media_map.values()), "instrumentedPages": page_count, "instrumentedImages": image_count, + "instrumentedPreloads": preload_count, "targetWidths": list(WIDTHS), } info_path.write_text(json.dumps(info, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") print( - f"Generated {sum(len(items) for items in variant_map.values())} responsive WebP variants " - f"for {len(variant_map)} screenshots and instrumented {image_count} images on {page_count} pages." + f"Generated {sum(len(item['variants']) for item in media_map.values())} responsive WebP variants " + f"for {len(media_map)} screenshots; instrumented {image_count} images and {preload_count} preloads " + f"on {page_count} pages." ) return 0 From 61d7160fc5e98191ad5560e4714539c2f01bedb3 Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 11:52:42 +0700 Subject: [PATCH 57/57] Validate responsive image preload candidates --- scripts/validate-responsive-media.py | 62 +++++++++++++++++++++------- 1 file changed, 47 insertions(+), 15 deletions(-) diff --git a/scripts/validate-responsive-media.py b/scripts/validate-responsive-media.py index 018cf05..02f49a0 100644 --- a/scripts/validate-responsive-media.py +++ b/scripts/validate-responsive-media.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Validate responsive screenshot variants and rendered srcset contracts.""" +"""Validate responsive screenshot variants, rendered srcsets and hero preload candidates.""" from __future__ import annotations @@ -10,7 +10,21 @@ from pathlib import Path IMG_PATTERN = re.compile(r']*src="(assets/screenshots/[^"/]+\.webp)"[^>]*>', re.IGNORECASE) +PRELOAD_PATTERN = re.compile( + r']*rel="preload"[^>]*as="image"[^>]*href="(assets/screenshots/[^"/]+\.webp)"[^>]*>', + re.IGNORECASE, +) SRCSET_PATTERN = re.compile(r'\bsrcset="([^"]+)"', re.IGNORECASE) +IMAGE_SRCSET_PATTERN = re.compile(r'\bimagesrcset="([^"]+)"', re.IGNORECASE) + + +def validate_candidates(site: Path, page: str, value: str, label: str, errors: list[str]) -> None: + candidates = [item.strip().split(" ", 1)[0] for item in value.split(",")] + if len(candidates) < 2: + errors.append(f"{page}: {label} has fewer than two candidates") + for candidate in candidates: + if not (site / candidate).is_file(): + errors.append(f"{page}: responsive candidate is missing: {candidate}") def main() -> int: @@ -19,14 +33,14 @@ def main() -> int: args = parser.parse_args() site = Path(args.site).resolve() errors: list[str] = [] - image_count = page_count = 0 + image_count = preload_count = page_count = 0 for page in sorted(site.glob("*.html")): html = page.read_text(encoding="utf-8") - page_images = 0 + page_instrumented = False for match in IMG_PATTERN.finditer(html): tag = match.group(0) - page_images += 1 + page_instrumented = True image_count += 1 if 'data-responsive-media="webp"' not in tag or 'sizes="' not in tag: errors.append(f"{page.name}: screenshot lacks responsive marker or sizes: {match.group(1)}") @@ -35,13 +49,22 @@ def main() -> int: if not srcset_match: errors.append(f"{page.name}: screenshot lacks srcset: {match.group(1)}") continue - candidates = [item.strip().split(" ", 1)[0] for item in srcset_match.group(1).split(",")] - if len(candidates) < 2: - errors.append(f"{page.name}: screenshot srcset has fewer than two candidates") - for candidate in candidates: - if not (site / candidate).is_file(): - errors.append(f"{page.name}: responsive candidate is missing: {candidate}") - if page_images: + validate_candidates(site, page.name, srcset_match.group(1), "screenshot srcset", errors) + + for match in PRELOAD_PATTERN.finditer(html): + tag = match.group(0) + page_instrumented = True + preload_count += 1 + if 'data-responsive-media-preload="webp"' not in tag or 'imagesizes="' not in tag: + errors.append(f"{page.name}: image preload lacks responsive marker or imagesizes: {match.group(1)}") + continue + srcset_match = IMAGE_SRCSET_PATTERN.search(tag) + if not srcset_match: + errors.append(f"{page.name}: image preload lacks imagesrcset: {match.group(1)}") + continue + validate_candidates(site, page.name, srcset_match.group(1), "preload imagesrcset", errors) + + if page_instrumented: page_count += 1 try: @@ -53,10 +76,16 @@ def main() -> int: if not isinstance(responsive, dict): errors.append("build-info.json is missing responsiveMedia evidence") else: - if responsive.get("schemaVersion") != 1 or responsive.get("format") != "webp": + if responsive.get("schemaVersion") != 2 or responsive.get("format") != "webp": errors.append("responsiveMedia identity is invalid") - if responsive.get("instrumentedImages") != image_count or responsive.get("instrumentedPages") != page_count: - errors.append("responsiveMedia rendered counts do not match the site") + expected = { + "instrumentedImages": image_count, + "instrumentedPreloads": preload_count, + "instrumentedPages": page_count, + } + for key, value in expected.items(): + if responsive.get(key) != value: + errors.append(f"responsiveMedia {key} is {responsive.get(key)!r}, expected {value}") if int(responsive.get("variantCount", 0) or 0) < int(responsive.get("sourceCount", 0) or 0) * 2: errors.append("responsiveMedia has too few generated variants") @@ -66,7 +95,10 @@ def main() -> int: for error in errors: print(f"- {error}", file=sys.stderr) return 1 - print(f"ARSAS responsive-media validation passed: {image_count} images on {page_count} pages.") + print( + f"ARSAS responsive-media validation passed: {image_count} images, {preload_count} responsive preloads " + f"on {page_count} pages." + ) return 0