Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,9 @@ jobs:
run: python scripts/build-product-site.py --output _site --release-evidence /tmp/arsas-published.json

- name: Generate bilingual privacy pages
run: python scripts/generate-privacy-pages.py --output _site --release-evidence /tmp/arsas-published.json
env:
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: Configure optional client measurement
env:
Expand Down
8 changes: 6 additions & 2 deletions .github/workflows/site-measurement.yml
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,9 @@ jobs:
run: python scripts/build-product-site.py --output _site --release-evidence /tmp/arsas-published.json

- name: Generate bilingual privacy pages
run: python scripts/generate-privacy-pages.py --output _site --release-evidence /tmp/arsas-published.json
env:
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: Configure optional client measurement
env:
Expand Down Expand Up @@ -172,7 +174,9 @@ jobs:
run: python scripts/build-product-site.py --output _site --release-evidence /tmp/arsas-published.json

- name: Generate bilingual privacy pages
run: python scripts/generate-privacy-pages.py --output _site --release-evidence /tmp/arsas-published.json
env:
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: Configure current client measurement
env:
Expand Down
9 changes: 5 additions & 4 deletions docs/website-measurement.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,9 @@ The browser client disables Google advertising signals, does not request ad-pers
- Declining analytics does not change downloads, content access or application behavior.
- The local key `arsas_analytics_consent_v1` stores only `granted` or `denied`.
- Do Not Track overrides a stored grant and keeps analytics disabled.
- Revoking consent reloads the current page to stop the already-loaded client before further interaction.
- `privacy.html` and `privasi.html` are bilingual `noindex,follow` policy pages and never load the analytics client.
- Revoking consent on a product page reloads that page to stop the already-loaded client before further interaction.
- `privacy.html` and `privasi.html` are bilingual `noindex,follow` preference surfaces. They carry only an inert availability configuration, can save the user’s choice, and never load `analytics.js` or Google Tag.
- A preference saved on a privacy page takes effect when the next product page is opened.
- Project policy requires a two-month GA4 event-data retention window before analytics is enabled.

## Repository configuration
Expand Down Expand Up @@ -57,9 +58,9 @@ Every Pages artifact is stamped after build with:
- stable release version;
- privacy and measurement activation state.

After `actions/deploy-pages`, `scripts/verify-pages-deployment.py` fetches the public `build-info.json` with cache-busting parameters and retries until the public source commit matches the just-deployed commit. It also verifies both privacy routes, denied-by-default consent metadata, the configured measurement state and the shared consent controls on the homepage.
After `actions/deploy-pages`, `scripts/verify-pages-deployment.py` fetches the public `build-info.json` with cache-busting parameters and retries until the public source commit matches the just-deployed commit. It also verifies both privacy routes, their inert measurement-availability configuration, the absence of any analytics client on those policy pages, denied-by-default consent metadata, the configured measurement state and the shared consent controls on the homepage.

A stale public build, missing privacy route or measurement-state mismatch fails the workflow. IndexNow notification depends on this attestation and is skipped when production is stale.
A stale public build, missing privacy route, unexpected analytics client or measurement-state mismatch fails the workflow. IndexNow notification depends on this attestation and is skipped when production is stale.

The `production-pages-attestation` artifact retains Markdown and JSON evidence for 90 days.

Expand Down
8 changes: 5 additions & 3 deletions landing/consent.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
const STORAGE_KEY = 'arsas_analytics_consent_v1';
const EVENT_NAME = 'arsas:consent';
const dntEnabled = navigator.doNotTrack === '1' || window.doNotTrack === '1';
const privacyPage = document.body.dataset.privacyPage === 'true';
const banner = document.querySelector('[data-consent-banner]');
const status = document.querySelector('[data-consent-status]');
const analyticsConfig = document.getElementById('arsas-analytics');
Expand Down Expand Up @@ -48,12 +49,12 @@
document.documentElement.dataset.analyticsConsent = preference;
setStatus(preference);
window.dispatchEvent(new CustomEvent(EVENT_NAME, {
detail: { analytics: preference, source, doNotTrack: dntEnabled, available: analyticsAvailable }
detail: { analytics: preference, source, doNotTrack: dntEnabled, available: analyticsAvailable, privacyPage }
}));
};

const loadAnalytics = () => {
if (analyticsLoaded || dntEnabled || !analyticsAvailable) return;
if (privacyPage || analyticsLoaded || dntEnabled || !analyticsAvailable) return;
analyticsLoaded = true;
window.gtag('consent', 'update', {
analytics_storage: 'granted',
Expand Down Expand Up @@ -98,7 +99,7 @@
hideBanner();
dispatch(effective, 'user-choice');
if (effective === 'granted') loadAnalytics();
else if (analyticsLoaded || previous === 'granted') window.location.reload();
else if (!privacyPage && (analyticsLoaded || previous === 'granted')) window.location.reload();
};

acceptButtons.forEach(button => button.addEventListener('click', () => save('granted')));
Expand All @@ -120,6 +121,7 @@
window.ARSASConsent = Object.freeze({
storageKey: STORAGE_KEY,
available: analyticsAvailable,
privacyPage,
doNotTrack: dntEnabled,
get: readPreference,
manage: () => showBanner(true),
Expand Down
4 changes: 2 additions & 2 deletions landing/privacy-source/privacy.en.html.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
<meta name="twitter:description" content="How ARSAS handles optional website measurement and privacy choices." />
<meta name="twitter:image" content="{{CANONICAL_ROOT}}assets/social-card.png" />
</head>
<body data-page="none">
<body data-page="none" data-privacy-page="true">
{{> header}}
<main id="main">
<section class="page-hero"><div class="container prose"><div class="breadcrumbs"><a href="./">Home</a> / Privacy</div><span class="eyebrow">Privacy · optional analytics · user choice</span><h1>Measurement is optional and <span class="gradient-text">disabled until you allow it.</span></h1><p>ARSAS uses a small, consent-controlled measurement layer to understand product-page usage, download intent, language demand, broken routes and Core Web Vitals. The website remains fully usable when analytics is declined.</p><div class="hero-actions"><button class="btn btn-primary" type="button" data-consent-manage>Manage analytics preference</button><a class="btn" href="privasi.html" lang="id" hreflang="id">Baca dalam Bahasa Indonesia</a></div><p class="consent-policy-status" data-consent-status aria-live="polite">Loading analytics preference…</p></div></section>
Expand All @@ -38,7 +38,7 @@

<section class="section"><div class="container split-panel"><article class="panel"><span class="kicker">Consent behavior</span><h2>Google Analytics loads only after approval.</h2><p>The default consent state is denied. A local preference named <code>arsas_analytics_consent_v1</code> stores only <code>granted</code> or <code>denied</code>. Declining analytics does not block downloads, documentation or any product page. Browser Do Not Track keeps analytics disabled even if a previous preference was granted.</p></article><article class="panel"><span class="kicker">Processors and hosting</span><h2>GitHub hosts the site; Google processes optional analytics.</h2><p>GitHub Pages serves the public files and may process normal web-request metadata under GitHub’s own terms. When consent is granted, Google Analytics receives the aggregate event fields described above under Google’s terms. Private weekly reports are stored as GitHub Actions artifacts rather than being published on this website.</p></article></div></section>

<section class="section section-tight"><div class="container comparison-grid"><article class="comparison-panel"><span class="kicker">Retention policy</span><h2>Short operational retention</h2><p>The ARSAS project policy is a two-month event-data retention window for GA4 and a 90-day maximum for private aggregate workflow artifacts. Analytics must remain disabled until the GA4 property is configured consistently with this policy.</p></article><article class="comparison-panel"><span class="kicker">Your control</span><h2>Change the preference at any time.</h2><p>Use the analytics-preferences control in the footer or the button below. Revoking consent prevents new analytics events from being sent from subsequent interactions and page loads.</p><div class="section-actions"><button class="btn" type="button" data-consent-manage>Open analytics preferences</button></div></article></div></section>
<section class="section section-tight"><div class="container comparison-grid"><article class="comparison-panel"><span class="kicker">Retention policy</span><h2>Short operational retention</h2><p>The ARSAS project policy is a two-month event-data retention window for GA4 and a 90-day maximum for private aggregate workflow artifacts. Analytics must remain disabled until the GA4 property is configured consistently with this policy.</p></article><article class="comparison-panel"><span class="kicker">Your control</span><h2>Change the preference at any time.</h2><p>Use the analytics-preferences control in the footer or the button below. This policy page saves your choice without loading the analytics client; the choice takes effect on the next product page. Revoking consent prevents new analytics events from being sent from subsequent interactions and page loads.</p><div class="section-actions"><button class="btn" type="button" data-consent-manage>Open analytics preferences</button></div></article></div></section>

<section class="section"><div class="container callout"><div class="callout-grid"><div><span class="kicker">Questions or concerns</span><h2>Report a privacy or measurement issue with evidence.</h2><p>Describe the page, browser, observed request or consent behavior. Do not attach confidential project, IED or customer information.</p></div><div class="section-actions"><a class="btn btn-primary" href="{{ISSUES_URL}}" target="_blank" rel="noopener">Report an issue</a><a class="btn" href="technical-review.html">Technical review policy</a></div></div></div></section>
</main>
Expand Down
4 changes: 2 additions & 2 deletions landing/privacy-source/privacy.id.html.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
<meta name="twitter:description" content="Cara ARSAS menangani measurement website opsional dan pilihan privasi." />
<meta name="twitter:image" content="{{CANONICAL_ROOT}}assets/social-card.png" />
</head>
<body data-page="none">
<body data-page="none" data-privacy-page="true">
{{> header}}
<main id="main">
<section class="page-hero"><div class="container prose"><div class="breadcrumbs"><a href="id.html">Beranda</a> / Privasi</div><span class="eyebrow">Privasi · analitik opsional · pilihan pengguna</span><h1>Measurement bersifat opsional dan <span class="gradient-text">nonaktif sampai Anda mengizinkannya.</span></h1><p>ARSAS memakai measurement kecil berbasis persetujuan untuk memahami penggunaan halaman produk, niat download, kebutuhan bahasa, broken route dan Core Web Vitals. Website tetap berfungsi penuh saat analitik ditolak.</p><div class="hero-actions"><button class="btn btn-primary" type="button" data-consent-manage>Atur preferensi analitik</button><a class="btn" href="privacy.html" lang="en" hreflang="en">Read in English</a></div><p class="consent-policy-status" data-consent-status aria-live="polite">Memuat preferensi analitik…</p></div></section>
Expand All @@ -38,7 +38,7 @@

<section class="section"><div class="container split-panel"><article class="panel"><span class="kicker">Perilaku persetujuan</span><h2>Google Analytics hanya dimuat setelah disetujui.</h2><p>Status awal consent adalah denied. Preferensi lokal bernama <code>arsas_analytics_consent_v1</code> hanya menyimpan nilai <code>granted</code> atau <code>denied</code>. Menolak analitik tidak memblokir download, dokumentasi atau halaman produk. Browser Do Not Track mempertahankan analitik tetap nonaktif walaupun preferensi sebelumnya pernah diberikan.</p></article><article class="panel"><span class="kicker">Processor dan hosting</span><h2>GitHub meng-host website; Google memproses analitik opsional.</h2><p>GitHub Pages melayani file publik dan dapat memproses metadata request web normal sesuai ketentuan GitHub. Setelah consent diberikan, Google Analytics menerima field event agregat yang dijelaskan di atas sesuai ketentuan Google. Laporan mingguan privat disimpan sebagai artifact GitHub Actions dan tidak dipublikasikan di website.</p></article></div></section>

<section class="section section-tight"><div class="container comparison-grid"><article class="comparison-panel"><span class="kicker">Kebijakan retensi</span><h2>Retensi operasional singkat</h2><p>Kebijakan proyek ARSAS adalah retensi event-data GA4 selama dua bulan dan maksimal 90 hari untuk artifact workflow agregat privat. Analitik harus tetap nonaktif sampai property GA4 dikonfigurasi konsisten dengan kebijakan ini.</p></article><article class="comparison-panel"><span class="kicker">Kontrol Anda</span><h2>Preferensi dapat diubah kapan saja.</h2><p>Gunakan kontrol preferensi analitik di footer atau tombol berikut. Mencabut consent mencegah event analitik baru dikirim dari interaksi dan page load berikutnya.</p><div class="section-actions"><button class="btn" type="button" data-consent-manage>Buka preferensi analitik</button></div></article></div></section>
<section class="section section-tight"><div class="container comparison-grid"><article class="comparison-panel"><span class="kicker">Kebijakan retensi</span><h2>Retensi operasional singkat</h2><p>Kebijakan proyek ARSAS adalah retensi event-data GA4 selama dua bulan dan maksimal 90 hari untuk artifact workflow agregat privat. Analitik harus tetap nonaktif sampai property GA4 dikonfigurasi konsisten dengan kebijakan ini.</p></article><article class="comparison-panel"><span class="kicker">Kontrol Anda</span><h2>Preferensi dapat diubah kapan saja.</h2><p>Gunakan kontrol preferensi analitik di footer atau tombol berikut. Halaman kebijakan ini menyimpan pilihan tanpa memuat analytics client; pilihan berlaku ketika membuka halaman produk berikutnya. Mencabut consent mencegah event analitik baru dikirim dari interaksi dan page load berikutnya.</p><div class="section-actions"><button class="btn" type="button" data-consent-manage>Buka preferensi analitik</button></div></article></div></section>

<section class="section"><div class="container callout"><div class="callout-grid"><div><span class="kicker">Pertanyaan atau keberatan</span><h2>Laporkan masalah privasi atau measurement dengan evidence.</h2><p>Jelaskan halaman, browser, request atau perilaku consent yang terlihat. Jangan melampirkan informasi rahasia proyek, IED atau customer.</p></div><div class="section-actions"><a class="btn btn-primary" href="{{ISSUES_URL}}" target="_blank" rel="noopener">Laporkan issue</a><a class="btn" href="technical-review.html">Kebijakan technical review</a></div></div></div></section>
</main>
Expand Down
30 changes: 22 additions & 8 deletions scripts/generate-privacy-pages.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,16 @@
import argparse
import importlib.util
import json
import os
import re
import shutil
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
BUILDER_PATH = ROOT / "scripts" / "build-product-site.py"
SOURCE = ROOT / "landing" / "privacy-source"
ANALYTICS_SCRIPT = re.compile(
r'\s*<script\s+id="arsas-analytics"[^>]*>\s*</script>',
re.IGNORECASE,
)
MEASUREMENT_PATTERN = re.compile(r"G-[A-Z0-9]+")
PLACEHOLDER = "__ARSAS_GA4_MEASUREMENT_ID__"


def load_builder():
Expand All @@ -32,8 +31,17 @@ def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--output", default=str(ROOT / "_site"))
parser.add_argument("--release-evidence", default=str(ROOT / "landing" / "latest.json"))
parser.add_argument(
"--measurement-id",
default=os.environ.get("GA4_MEASUREMENT_ID", ""),
help="Public GA4 web-stream ID. It remains inert on privacy pages.",
)
args = parser.parse_args()

measurement_id = args.measurement_id.strip().upper()
if measurement_id and not MEASUREMENT_PATTERN.fullmatch(measurement_id):
raise SystemExit("Measurement ID must use the G-XXXXXXXX format")

output = Path(args.output).resolve()
evidence_path = Path(args.release_evidence).resolve()
if not output.is_dir() or not (output / "build-info.json").is_file():
Expand Down Expand Up @@ -66,9 +74,12 @@ def main() -> int:
raise SystemExit(f"Missing privacy source: {source}")
rendered = builder.render(source.read_text(encoding="utf-8"), values, icon_size)
rendered = builder.inject_alternate_links(rendered, {"template": source.name, "alternates": alternates}, root)
rendered = ANALYTICS_SCRIPT.sub("", rendered)
if "__ARSAS_GA4_MEASUREMENT_ID__" in rendered:
raise SystemExit(f"Privacy page still contains a measurement placeholder: {target.name}")
placeholder_count = rendered.count(PLACEHOLDER)
if placeholder_count != 1:
raise SystemExit(f"{target.name} must contain exactly one inert analytics configuration")
rendered = rendered.replace(PLACEHOLDER, measurement_id)
if 'src="analytics.js"' in rendered:
raise SystemExit(f"Privacy page must not load analytics.js: {target.name}")
target.write_text(rendered, encoding="utf-8")

build_info_path = output / "build-info.json"
Expand All @@ -80,11 +91,14 @@ def main() -> int:
"defaultAnalyticsConsent": "denied",
"preferenceStorage": "localStorage",
"preferenceKey": "arsas_analytics_consent_v1",
"measurementAvailable": bool(measurement_id),
"analyticsClientLoadedOnPolicyPages": False,
}
build_info_path.write_text(json.dumps(build_info, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")

shutil.rmtree(output / "privacy-source", ignore_errors=True)
print("Generated privacy.html and privasi.html with noindex, consent controls and shared ARSAS chrome.")
state = "measurement available" if measurement_id else "measurement disabled"
print(f"Generated privacy.html and privasi.html with {state}; policy pages never load analytics.js.")
return 0


Expand Down
Loading
Loading