diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
index 7cc40aa1..f72dbc65 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.
diff --git a/.github/ISSUE_TEMPLATE/connection.yml b/.github/ISSUE_TEMPLATE/connection.yml
new file mode 100644
index 00000000..767166aa
--- /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
diff --git a/.github/ISSUE_TEMPLATE/device-compatibility.yml b/.github/ISSUE_TEMPLATE/device-compatibility.yml
new file mode 100644
index 00000000..a9e849fd
--- /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
diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml
index 284fbe29..abb34e94 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
diff --git a/.github/ISSUE_TEMPLATE/file-transfer.yml b/.github/ISSUE_TEMPLATE/file-transfer.yml
new file mode 100644
index 00000000..29e14263
--- /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
diff --git a/.github/ISSUE_TEMPLATE/goose.yml b/.github/ISSUE_TEMPLATE/goose.yml
new file mode 100644
index 00000000..ad8bc308
--- /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
diff --git a/.github/ISSUE_TEMPLATE/installation.yml b/.github/ISSUE_TEMPLATE/installation.yml
new file mode 100644
index 00000000..ff7e0fd6
--- /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
diff --git a/.github/ISSUE_TEMPLATE/reporting.yml b/.github/ISSUE_TEMPLATE/reporting.yml
new file mode 100644
index 00000000..1e2315ce
--- /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
diff --git a/.github/workflows/adoption-proof.yml b/.github/workflows/adoption-proof.yml
new file mode 100644
index 00000000..8cb33cec
--- /dev/null
+++ b/.github/workflows/adoption-proof.yml
@@ -0,0 +1,126 @@
+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/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]
+ 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/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:
+
+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 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
+
+ - 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
diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
index 5492f5ad..3d17d58a 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
diff --git a/.github/workflows/release-supply-chain.yml b/.github/workflows/release-supply-chain.yml
new file mode 100644
index 00000000..c3a02257
--- /dev/null
+++ b/.github/workflows/release-supply-chain.yml
@@ -0,0 +1,164 @@
+name: Backfill ARSAS release supply chain
+
+on:
+ 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-${{ 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:
+ INPUT_TAG: ${{ inputs.tag }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ 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: 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 }}
+ 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 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 '${{ steps.source.outputs.source_commit }}' \
+ --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
+ 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)"
+ printf '%s\n' \
+ '# ARSAS release supply-chain evidence' \
+ '' \
+ "- Tag: $RELEASE_TAG" \
+ "- Tagged source commit: $SOURCE_COMMIT" \
+ "- Installer SHA-256: $installer_sha" \
+ "- Portable SHA-256: $portable_sha" \
+ '- SBOM: ARSAS-Windows-x64-SBOM.spdx.json' \
+ '- Attestation type: GitHub artifact digest and SBOM attestation after checksum-verified publication.' \
+ '' \
+ 'This post-publication evidence does not retroactively describe another tag, does not claim a reproducible build, 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
diff --git a/.github/workflows/release-windows.yml b/.github/workflows/release-windows.yml
index 450754ab..92672a0a 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
diff --git a/docs/adoption-field-proof.md b/docs/adoption-field-proof.md
new file mode 100644
index 00000000..2740e409
--- /dev/null
+++ b/docs/adoption-field-proof.md
@@ -0,0 +1,59 @@
+# 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 releases built after P3, `.github/workflows/release-windows.yml`:
+
+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.
+
+`.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.
diff --git a/landing/adoption.css b/landing/adoption.css
new file mode 100644
index 00000000..85df28d0
--- /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; }
+}
diff --git a/landing/demo.js b/landing/demo.js
new file mode 100644
index 00000000..71c0b74d
--- /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);
+})();
diff --git a/landing/device-evidence.json b/landing/device-evidence.json
new file mode 100644
index 00000000..9a17341e
--- /dev/null
+++ b/landing/device-evidence.json
@@ -0,0 +1,72 @@
+{
+ "schemaVersion": 1,
+ "product": "ARSAS",
+ "updatedAt": "2026-07-22",
+ "namedDeviceCount": 0,
+ "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"
+ ]
+ }
+ ]
+}
diff --git a/landing/guide-filter.js b/landing/guide-filter.js
new file mode 100644
index 00000000..2a1a29f6
--- /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();
+})();
diff --git a/landing/partials/footer.html b/landing/partials/footer.html
index 6b7d5cad..0bbdbad6 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.
-
-
+
+
-
-
+
+
-
-
+
+
-
+
diff --git a/landing/site.json b/landing/site.json
index 86710bb7..0b69ed8c 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"},
diff --git a/landing/templates/bukti-kompatibilitas.html b/landing/templates/bukti-kompatibilitas.html
new file mode 100644
index 00000000..6e99cb3f
--- /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. Verified Conditional Observed Not tested Failed 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.
Service Status MMS association Observed FileDirectory Verified File transfer Conditional Live model, reporting, GOOSE, control Not 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.
Service Status MMS association dan live model Observed Evidence reporting Conditional Selected-RCB export Verified File, GOOSE, control Not 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}}
+
+
diff --git a/landing/templates/catatan-rilis.html b/landing/templates/catatan-rilis.html
index 80904a78..0bbec150 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.
{{STABLE_VERSION}} Workspace Windows stabil{{RELEASE_SCREENSHOT_CAPTION_ID}}
+ Screenshot versi stabil Lihat aplikasi sebelum mengunduh. Screenshot merepresentasikan workspace stabil yang dipublikasikan.
{{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}}
diff --git a/landing/templates/compatibility.html b/landing/templates/compatibility.html
new file mode 100644
index 00000000..cd23938f
--- /dev/null
+++ b/landing/templates/compatibility.html
@@ -0,0 +1,53 @@
+
+
+
+
+
+ ARSAS IEC 61850 Compatibility Evidence — Service-Level Matrix
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{> header}}
+
+ Home / Compatibility evidence
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. Verified Conditional Observed Not tested Failed 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.
Service Status MMS association Observed FileDirectory Verified File transfer Conditional Live model, reporting, GOOSE, control Not 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.
Service Status MMS association and live model Observed Reporting evidence Conditional Selected-RCB export Verified Files, GOOSE, control Not 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}}
+
+
diff --git a/landing/templates/demo-arsas.html b/landing/templates/demo-arsas.html
new file mode 100644
index 00000000..b943fe7f
--- /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.
+
+ 1. Workspace awal 2. Session IED independen 3. Evidence live value 4. Diagnostic attributable 5. Supervisi GOOSE 6. Konteks event dan record 7. Export SCL selected-RCB
+
Mulai dari endpoint yang disetujui. Tambahkan alamat IED atau buka SCL. Tujuan pertama adalah identity dan model evidence read-only, bukan mengubah konfigurasi.
+
Pertahankan attribution setiap IED. Association, live model, monitoring state dan diagnostic dipisahkan per device agar tidak bercampur dalam session ambigu.
+
Periksa value, quality, timestamp dan source bersama-sama. Selected value menjadi evidence berguna saat object identity dan acquisition context tetap terlihat.
+
Pertahankan negative dan fallback evidence. Diagnostic association, service, reporting dan behavior device tetap terlihat saat RCB occupied, write rejected atau polling digunakan.
+
Supervisi GOOSE sebagai stream. APPID, VLAN, stNum, sqNum, TAL, retransmission dan ordered payload harus diinterpretasikan bersama.
+
Simpan konteks event dan transfer. Sequence evidence dan diagnostic fault-record dapat masuk ke test record tanpa mengklaim record hasil download pasti lengkap atau scaling sudah benar.
+
Export baseline SCL yang dibatasi. Pilih satu jalur evidence RCB live dan pertahankan exact identity. Output tetap baseline engineering yang membutuhkan review schema, proyek dan vendor.
+
+
+ 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}}
+
+
+
diff --git a/landing/templates/demo.html b/landing/templates/demo.html
new file mode 100644
index 00000000..5fda6fd3
--- /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.
+
+ 1. Start workspace 2. Independent IED sessions 3. Live-value evidence 4. Attributable diagnostics 5. GOOSE supervision 6. Event and record context 7. Selected-RCB SCL export
+
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.
+
Keep each IED attributable. Associations, live models, monitoring state and diagnostics remain separated by device instead of being merged into an ambiguous session.
+
Inspect value, quality, timestamp and source together. A selected value becomes useful evidence only when its object identity and acquisition context remain visible.
+
Preserve negative and fallback evidence. Association, service, reporting and device-behavior diagnostics remain visible when an RCB is occupied, a write is rejected or polling is used.
+
Supervise GOOSE as a stream. APPID, VLAN, stNum, sqNum, TAL, retransmission and ordered payload need to be interpreted together.
+
Retain event and transfer context. Sequence evidence and fault-record workflow diagnostics can be carried into a test record without claiming that the retrieved record is complete or correctly scaled.
+
Export a bounded SCL baseline. Choose one live RCB evidence path and preserve its exact identity. The output remains an engineering baseline requiring schema, project and vendor review.
+
+
+ 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}}
+
+
+
diff --git a/landing/templates/download.html b/landing/templates/download.html
index e0c376f1..d298d05d 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 {{STABLE_PUBLISHED_DATE}}
+ 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 {{STABLE_PUBLISHED_DATE}}
Recommended Windows installer Best for a primary workstation. Installs to a predictable location and provides Start menu, uninstall and the verified installer-based update path.
File ARSAS-Windows-x64-Setup.exe
Size {{INSTALLER_SIZE}}
Published {{STABLE_PUBLISHED_DATE}}
Signing {{SIGNING_LABEL}} Download Windows installer 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.
Decision Installer Portable ZIP
Best for Primary engineering workstation Controlled test folder or temporary evaluation
Setup Guided Windows installation Extract and run from a writable local folder
Start menu and uninstall Included Not registered with Windows Installer
Update path Verified installer download and launch Manual package replacement recommended
Administrator rights Depends on selected install scope and policy Normally 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.
Decision Installer Portable ZIP
Best for Primary engineering workstation Controlled test folder or temporary evaluation
Setup Guided Windows installation Extract and run from a writable local folder
Start menu and uninstall Included Not registered with Windows Installer
Update path Verified installer download and launch Manual package replacement recommended
Administrator rights Depends on selected install scope and policy Normally 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}}Copy installer SHA-256
Portable SHA-256 {{PORTABLE_SHA256}}Copy portable SHA-256
PowerShell verification Get-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}}Copy installer SHA-256
Portable SHA-256 {{PORTABLE_SHA256}}Copy portable SHA-256
PowerShell verification Get-FileHash .\ARSAS-Windows-x64-Setup.exe -Algorithm SHA256
Code-signing status {{SIGNING_LABEL}} {{SIGNING_DETAIL}}
Status: {{SIGNING_STATUS}}
Known limitations Review the current boundaries.
- 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}}
diff --git a/landing/templates/faq-arsas.html b/landing/templates/faq-arsas.html
new file mode 100644
index 00000000..2d6f42bb
--- /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}}
+
+
diff --git a/landing/templates/faq.html b/landing/templates/faq.html
new file mode 100644
index 00000000..ad16747a
--- /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}}
+
+
diff --git a/landing/templates/guides.html b/landing/templates/guides.html
index a915f390..9f1dcad0 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.
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.
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}}
diff --git a/landing/templates/id.html b/landing/templates/id.html
index ef54364d..058234e6 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 key Installer dan portable ZIP Source serta checksum publik
+ 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 key Installer dan portable ZIP Source serta checksum publik
MMS + Reporting Discovery live, BRCB, URCB, recovery dan polling yang terlihat
GOOSE + File Supervisi stream dan pengambilan record terkait COMTRADE
SCL + Integrasi Perbandingan konfigurasi dengan model live dan export RCB terpilih
Control + Evidence Direct 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}}
diff --git a/landing/templates/index.html b/landing/templates/index.html
index 89cc1e70..9556ae8e 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 key Installer and portable ZIP Public source and checksums
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 key Installer and portable ZIP Public source and checksums
Add IED by IP address
Live model to evidence
MMS + Reporting Live discovery, BRCB, URCB, dynamic recovery and polling
GOOSE + Files Stream supervision and COMTRADE-related record retrieval
SCL + Integration Configured/live context and selected-RCB Edition 1/2 export
Control + Evidence Typed 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.
01 Independent multi-IED sessionsEach device retains its own MMS association, model, monitoring state and diagnostic history.
02 Unified live valuesSearch selected values with device, quality, timestamp, acquisition source and recent-change evidence.
03 Detailed GOOSE supervisionInspect stream identity, APPID, VLAN, sequence, retransmission, TAL, payload and model binding.
04 Attributable diagnosticsSeparate 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}}
diff --git a/landing/templates/panduan-mulai-arsas.html b/landing/templates/panduan-mulai-arsas.html
new file mode 100644
index 00000000..fea21dde
--- /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.
+
+ 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}}
+
+
diff --git a/landing/templates/panduan.html b/landing/templates/panduan.html
index 5a7b12e3..d2e0ade5 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 → DataSet kosong Konfirmasi referensi, member list, dynamic write permission dan source live/SCL.
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 → GOOSE stNum dan sqNum Gunakan state change, retransmission, TAL dan continuity bersama-sama.
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 → Direct atau SBO Konfirmasi control model dan urutan service sebelum mengirim command.
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 → Smart Reporting Pelajari BRCB, URCB, exact coverage, recovery dan polling fallback.
Buka Smart Reporting → Analyzer GOOSE Periksa APPID, VLAN, stNum, sqNum, TAL dan payload.
Buka Analyzer GOOSE → Transfer File Telusuri MMS file service dan record set COMTRADE.
Buka File Transfer → Workspace SCL Bandingkan configured intent dengan live evidence dan export RCB terpilih.
Buka Workspace SCL →
-
- 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 → BRCB atau URCB? Pilih berdasarkan buffering, ownership dan recovery.
Buka technical guide → RCB reserved atau occupied Periksa owner tanpa forced takeover.
Buka technical guide → DataSet kosong Konfirmasi reference, member list dan source live/SCL.
Buka technical guide → Port 102 gagal Pisahkan ping, TCP socket dan MMS association.
Buka technical guide → Download COMTRADE Bedakan file service, transfer dan record set lengkap.
Buka technical guide → GOOSE stNum dan sqNum Interpretasikan state change, retransmission dan continuity.
Buka technical guide → CID atau SCL ditolak Periksa struktur, namespace, edition dan reference.
Buka technical guide → Live model berbeda dari SCL Bandingkan configured intent dengan server evidence.
Buka technical guide → Direct atau SBO Konfirmasi control model dan urutan service.
Buka technical guide → CommandTermination dan AddCause Positive response belum selalu berarti proses selesai.
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}}
diff --git a/landing/templates/quick-start.html b/landing/templates/quick-start.html
new file mode 100644
index 00000000..afb3fff8
--- /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.
+
+ 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}}
+
+
diff --git a/landing/templates/release-notes.html b/landing/templates/release-notes.html
index f258cc6e..3e9514a1 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.
{{STABLE_VERSION}} Stable Windows workspace{{RELEASE_SCREENSHOT_CAPTION}}
- Known limitations Read these before field use. 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. 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}}
diff --git a/landing/templates/unduh.html b/landing/templates/unduh.html
index 026372f2..2af9c584 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 {{STABLE_PUBLISHED_DATE_ID}}
+ 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 {{STABLE_PUBLISHED_DATE_ID}}
- Direkomendasikan Windows installer Pilihan terbaik untuk workstation utama. Lokasi instalasi lebih teratur serta menyediakan Start menu, uninstall dan jalur update berbasis installer terverifikasi.
File ARSAS-Windows-x64-Setup.exe
Ukuran {{INSTALLER_SIZE}}
Publish {{STABLE_PUBLISHED_DATE_ID}}
Signing {{SIGNING_LABEL_ID}} Unduh Windows installer 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
+ Direkomendasikan Windows installer Pilihan terbaik untuk workstation utama. Lokasi instalasi teratur serta menyediakan Start menu, uninstall dan jalur update berbasis installer.
File ARSAS-Windows-x64-Setup.exe
Ukuran {{INSTALLER_SIZE}}
Publish {{STABLE_PUBLISHED_DATE_ID}}
Signing {{SIGNING_LABEL_ID}} Unduh Windows installer 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.
Keputusan Installer Portable ZIP
Paling cocok untuk Workstation engineering utama Folder pengujian terkontrol atau evaluasi sementara
Setup Proses instalasi Windows terpandu Ekstrak dan jalankan dari folder lokal writable
Start menu dan uninstall Tersedia Tidak terdaftar sebagai instalasi Windows
Jalur update Download dan launch installer terverifikasi Penggantian paket manual direkomendasikan
Hak administrator Bergantung scope instalasi dan policy Biasanya 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.
Keputusan Installer Portable ZIP
Paling cocok Workstation engineering utama Folder test terkontrol atau evaluasi
Setup Instalasi Windows terpandu Ekstrak dan jalankan dari folder writable
Start menu dan uninstall Tersedia Tidak terdaftar sebagai instalasi Windows
Update Installer terverifikasi Penggantian paket manual
Administrator Bergantung scope dan policy Biasanya 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}}Salin SHA-256 installer
SHA-256 portable {{PORTABLE_SHA256}}Salin SHA-256 portable
Verifikasi PowerShell Get-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}}Salin SHA-256 installer
SHA-256 portable {{PORTABLE_SHA256}}Salin SHA-256 portable
Verifikasi PowerShell Get-FileHash .\ARSAS-Windows-x64-Setup.exe -Algorithm SHA256
- Status code-signing {{SIGNING_LABEL_ID}} {{SIGNING_DETAIL_ID}}
Status: {{SIGNING_STATUS}}
Known limitations Tinjau batas versi saat ini. {{RELEASE_LIMITATIONS_ID}}
+ Status code-signing {{SIGNING_LABEL_ID}} {{SIGNING_DETAIL_ID}}
Status: {{SIGNING_STATUS}}
Known limitations Tinjau batas versi saat ini. {{RELEASE_LIMITATIONS_ID}} Catatan rilis lengkap
- 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}}
diff --git a/scripts/build-responsive-media.py b/scripts/build-responsive-media.py
new file mode 100644
index 00000000..977a1fa5
--- /dev/null
+++ b/scripts/build-responsive-media.py
@@ -0,0 +1,132 @@
+#!/usr/bin/env python3
+"""Generate responsive WebP variants and inject image plus preload metadata into built ARSAS pages."""
+
+from __future__ import annotations
+
+import argparse
+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) -> dict[str, Any]:
+ 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 {"originalWidth": original_width, "variants": variants}
+
+
+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")
+ image_changes = preload_changes = 0
+
+ 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 media_map:
+ return match.group(0)
+ srcset = candidates(source, media_map[filename], site)
+ if srcset.count(",") < 1:
+ return match.group(0)
+ image_changes += 1
+ return (
+ f' '
+ )
+
+ 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 image_changes, preload_changes
+
+
+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"
+ media_map: dict[str, dict[str, Any]] = {}
+ for source in sorted(screenshot_dir.glob("*.webp")):
+ media = generate(source, responsive_dir)
+ if media["variants"]:
+ media_map[source.name] = media
+
+ page_count = image_count = preload_count = 0
+ for page in sorted(site.glob("*.html")):
+ images, preloads = inject_page(page, media_map, site)
+ if images or preloads:
+ page_count += 1
+ 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": 2,
+ "format": "webp",
+ "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(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
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/generate-release-sbom.py b/scripts/generate-release-sbom.py
new file mode 100644
index 00000000..084e6c1f
--- /dev/null
+++ b/scripts/generate-release-sbom.py
@@ -0,0 +1,141 @@
+#!/usr/bin/env python3
+"""Generate an 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 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""):
+ 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:
+ 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("--created", default="", help="ISO-8601 source timestamp used for reproducible SBOM metadata")
+ 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")
+ 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 = []
+ 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()
+ 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": "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",
+ "SPDXID": "SPDXRef-DOCUMENT",
+ "name": f"ARSAS-{args.version}-windows-x64",
+ "documentNamespace": f"https://github.com/masarray/arsas/sbom/{args.version}/{namespace_seed}",
+ "creationInfo": {
+ "created": created,
+ "creators": ["Tool: ARSAS generate-release-sbom.py", "Person: Ari Sulistiono"],
+ },
+ "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}",
+ "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,
+ }
+ 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())
diff --git a/scripts/resolve-site-source-commit.py b/scripts/resolve-site-source-commit.py
index 62107792..c1b07d1c 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
diff --git a/scripts/validate-adoption-proof.py b/scripts/validate-adoption-proof.py
new file mode 100644
index 00000000..ceaa814f
--- /dev/null
+++ b/scripts/validate-adoption-proof.py
@@ -0,0 +1,189 @@
+#!/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 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", "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:
+ 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}")
+ 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"):
+ 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}")
+
+ 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)
+ lower = text.lower()
+ if "SBOM" not in text or "provenance" not in lower:
+ errors.append(f"{name}: missing supply-chain evidence guidance")
+ 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:
+ 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, responsive media and future supply-chain attestations.")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/validate-product-build.py b/scripts/validate-product-build.py
index 7fd8d744..05785025 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", ' \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
diff --git a/scripts/validate-responsive-media.py b/scripts/validate-responsive-media.py
new file mode 100644
index 00000000..02f49a0b
--- /dev/null
+++ b/scripts/validate-responsive-media.py
@@ -0,0 +1,106 @@
+#!/usr/bin/env python3
+"""Validate responsive screenshot variants, rendered srcsets and hero preload candidates."""
+
+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)
+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:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("site", nargs="?", default="_site")
+ args = parser.parse_args()
+ site = Path(args.site).resolve()
+ errors: list[str] = []
+ image_count = preload_count = page_count = 0
+
+ for page in sorted(site.glob("*.html")):
+ html = page.read_text(encoding="utf-8")
+ page_instrumented = False
+ for match in IMG_PATTERN.finditer(html):
+ tag = match.group(0)
+ 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)}")
+ continue
+ srcset_match = SRCSET_PATTERN.search(tag)
+ if not srcset_match:
+ errors.append(f"{page.name}: screenshot lacks srcset: {match.group(1)}")
+ continue
+ 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:
+ 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") != 2 or responsive.get("format") != "webp":
+ errors.append("responsiveMedia identity is invalid")
+ 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")
+
+ 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, {preload_count} responsive preloads "
+ f"on {page_count} pages."
+ )
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/validate-supply-chain-workflows.py b/scripts/validate-supply-chain-workflows.py
new file mode 100644
index 00000000..ae63b1f7
--- /dev/null
+++ b/scripts/validate-supply-chain-workflows.py
@@ -0,0 +1,109 @@
+#!/usr/bin/env python3
+"""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")
+ 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")
+
+ 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 validation passed: primary build attestations, explicit backfill and SPDX verification code are correct.")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/verify-adoption-deployment.py b/scripts/verify-adoption-deployment.py
new file mode 100644
index 00000000..9bdf0e8b
--- /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())