diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 5002eb2e5..780f62ac3 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -12,6 +12,9 @@ on: - "scripts/inject-site-measurement.py" - "scripts/validate-site-measurement.py" - "scripts/check-site-health.py" + - "scripts/generate-privacy-pages.py" + - "scripts/stamp-site-build.py" + - "scripts/verify-pages-deployment.py" - ".github/workflows/pages.yml" pull_request: branches: [ main ] @@ -24,6 +27,9 @@ on: - "scripts/inject-site-measurement.py" - "scripts/validate-site-measurement.py" - "scripts/check-site-health.py" + - "scripts/generate-privacy-pages.py" + - "scripts/stamp-site-build.py" + - "scripts/verify-pages-deployment.py" - ".github/workflows/pages.yml" workflow_dispatch: @@ -34,6 +40,9 @@ concurrency: group: pages cancel-in-progress: true +env: + CANONICAL_ROOT: https://masarray.github.io/arsas/ + jobs: validate: name: Validate ARSAS product website @@ -109,12 +118,27 @@ jobs: - name: Build deterministic product website 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 + - name: Configure optional client measurement env: GA4_MEASUREMENT_ID: ${{ vars.GA4_MEASUREMENT_ID }} run: python scripts/inject-site-measurement.py _site --measurement-id "$GA4_MEASUREMENT_ID" - - name: Validate measurement contract + - name: Stamp source and workflow attestation + shell: bash + run: | + set -euo pipefail + commit_timestamp="$(git show -s --format=%cI "$GITHUB_SHA")" + python scripts/stamp-site-build.py _site \ + --source-commit "$GITHUB_SHA" \ + --source-ref "$GITHUB_REF" \ + --commit-timestamp "$commit_timestamp" \ + --workflow-run-id "$GITHUB_RUN_ID" \ + --workflow-run-attempt "$GITHUB_RUN_ATTEMPT" + + - name: Validate consent, privacy and measurement contract env: GA4_MEASUREMENT_ID: ${{ vars.GA4_MEASUREMENT_ID }} run: python scripts/validate-site-measurement.py _site --measurement-id "$GA4_MEASUREMENT_ID" @@ -180,10 +204,50 @@ jobs: id: deployment uses: actions/deploy-pages@v4 + verify-production: + name: Verify public Pages attestation + if: github.event_name != 'pull_request' + needs: deploy + 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: Verify deployed commit, privacy and measurement state + env: + GA4_MEASUREMENT_ID: ${{ vars.GA4_MEASUREMENT_ID }} + shell: bash + run: | + set -euo pipefail + stable_version="$(python -c 'import json; print(json.load(open("landing/latest.json"))["version"])')" + if [ -n "${GA4_MEASUREMENT_ID:-}" ]; then measurement_enabled=true; else measurement_enabled=false; fi + python scripts/verify-pages-deployment.py \ + --base-url "$CANONICAL_ROOT" \ + --source-commit "$GITHUB_SHA" \ + --stable-version "$stable_version" \ + --measurement-enabled "$measurement_enabled" \ + --output _validation/production-attestation.md + + - name: Upload production attestation + if: always() + uses: actions/upload-artifact@v4 + with: + name: production-pages-attestation + path: _validation/production-attestation.* + if-no-files-found: error + retention-days: 90 + notify-indexing: name: Notify search engines if: github.event_name != 'pull_request' - needs: deploy + needs: verify-production runs-on: ubuntu-latest steps: - name: Checkout diff --git a/.github/workflows/site-measurement.yml b/.github/workflows/site-measurement.yml index b669b5ff5..9bbc47748 100644 --- a/.github/workflows/site-measurement.yml +++ b/.github/workflows/site-measurement.yml @@ -16,6 +16,9 @@ on: paths: - "landing/**" - "scripts/build-product-site.py" + - "scripts/generate-privacy-pages.py" + - "scripts/stamp-site-build.py" + - "scripts/verify-pages-deployment.py" - "scripts/inject-site-measurement.py" - "scripts/validate-site-measurement.py" - "scripts/check-site-health.py" @@ -27,6 +30,9 @@ on: paths: - "landing/**" - "scripts/build-product-site.py" + - "scripts/generate-privacy-pages.py" + - "scripts/stamp-site-build.py" + - "scripts/verify-pages-deployment.py" - "scripts/inject-site-measurement.py" - "scripts/validate-site-measurement.py" - "scripts/check-site-health.py" @@ -46,7 +52,7 @@ env: jobs: quality: - name: Validate measurement and internal links + name: Validate measurement, privacy and internal links runs-on: ubuntu-latest steps: - name: Checkout @@ -77,12 +83,27 @@ jobs: - name: Build deterministic website 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 + - name: Configure optional client measurement env: GA4_MEASUREMENT_ID: ${{ vars.GA4_MEASUREMENT_ID }} run: python scripts/inject-site-measurement.py _site --measurement-id "$GA4_MEASUREMENT_ID" - - name: Validate page, download, language, 404 and Web Vitals measurement + - name: Stamp source and workflow attestation + shell: bash + run: | + set -euo pipefail + commit_timestamp="$(git show -s --format=%cI "$GITHUB_SHA")" + python scripts/stamp-site-build.py _site \ + --source-commit "$GITHUB_SHA" \ + --source-ref "$GITHUB_REF" \ + --commit-timestamp "$commit_timestamp" \ + --workflow-run-id "$GITHUB_RUN_ID" \ + --workflow-run-attempt "$GITHUB_RUN_ATTEMPT" + + - name: Validate page, consent, privacy, download, language, 404 and Web Vitals measurement env: GA4_MEASUREMENT_ID: ${{ vars.GA4_MEASUREMENT_ID }} run: python scripts/validate-site-measurement.py _site --measurement-id "$GA4_MEASUREMENT_ID" @@ -150,12 +171,27 @@ jobs: - name: Build current website 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 + - name: Configure current client measurement env: GA4_MEASUREMENT_ID: ${{ vars.GA4_MEASUREMENT_ID }} run: python scripts/inject-site-measurement.py _site --measurement-id "$GA4_MEASUREMENT_ID" - - name: Check deployed pages, release assets and 404 response + - name: Stamp reporting build attestation + shell: bash + run: | + set -euo pipefail + commit_timestamp="$(git show -s --format=%cI "$GITHUB_SHA")" + python scripts/stamp-site-build.py _site \ + --source-commit "$GITHUB_SHA" \ + --source-ref "$GITHUB_REF" \ + --commit-timestamp "$commit_timestamp" \ + --workflow-run-id "$GITHUB_RUN_ID" \ + --workflow-run-attempt "$GITHUB_RUN_ATTEMPT" + + - name: Check deployed pages, privacy routes, release assets and 404 response id: deployed_health continue-on-error: true run: python scripts/check-site-health.py --site _site --output _measurement --remote --base-url "$CANONICAL_ROOT" diff --git a/docs/website-measurement.md b/docs/website-measurement.md index 66200f92e..4f63dd93a 100644 --- a/docs/website-measurement.md +++ b/docs/website-measurement.md @@ -1,6 +1,6 @@ # ARSAS website measurement -ARSAS uses a lightweight, evidence-oriented measurement pipeline. Runtime analytics are optional and remain disabled when no valid measurement ID is configured. Search and performance reports are private GitHub Actions artifacts; they are not deployed to the public website. +ARSAS uses a lightweight, evidence-oriented measurement pipeline. Runtime analytics are optional and remain disabled when no valid measurement ID is configured. Even when configured, Google Analytics is denied by default and its client is not loaded until the visitor explicitly allows optional analytics. Search and performance reports are private GitHub Actions artifacts; they are not deployed to the public website. ## What is measured @@ -14,13 +14,25 @@ ARSAS uses a lightweight, evidence-oriented measurement pipeline. Runtime analyt | Are links broken or 404s occurring? | Build-time crawler + deployed probe + GA4 | Missing files/fragments, HTTP failures, 404 paths and referrers | | Are Core Web Vitals healthy? | Browser PerformanceObserver + PageSpeed/CrUX | LCP, CLS and INP field/lab evidence | -The browser client disables Google advertising signals, does not request ad-personalization signals, respects `Do Not Track`, loads asynchronously and performs no network request when the measurement ID is absent. +The browser client disables Google advertising signals, does not request ad-personalization signals, respects `Do Not Track`, loads asynchronously and performs no network request when the measurement ID is absent or consent has not been granted. + +## Consent and privacy contract + +- Default Google consent state: `analytics_storage=denied`. +- Advertising storage, ad-user-data and ad-personalization remain denied. +- The public GA4 client is dynamically loaded only after **Allow analytics**. +- 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. +- Project policy requires a two-month GA4 event-data retention window before analytics is enabled. ## Repository configuration Configure these **Actions variables**: -- `GA4_MEASUREMENT_ID`: public web stream ID such as `G-XXXXXXXXXX`. Leaving it empty keeps client measurement disabled. +- `GA4_MEASUREMENT_ID`: public web stream ID such as `G-XXXXXXXXXX`. Leaving it empty keeps client measurement disabled and suppresses the first-visit consent prompt. - `GA4_PROPERTY_ID`: numeric GA4 property ID used by the private reporting workflow. - `GSC_SITE_URL`: the exact verified Search Console property, normally `https://masarray.github.io/arsas/` for a URL-prefix property. - `PAGESPEED_URLS`: optional comma-separated URLs. When omitted, the workflow checks the English and Indonesian home/download pages plus Smart Reporting and Guides. @@ -32,17 +44,37 @@ Configure these **Actions secrets**: Grant the service account Viewer/read access only. It does not need permission to modify analytics, Search Console, releases or the website. +Before setting `GA4_MEASUREMENT_ID`, verify in GA4 that event-level retention is two months, Google Signals is disabled, Google Ads is not linked for this project, and no User-ID collection is configured. + +## Production deployment attestation + +Every Pages artifact is stamped after build with: + +- full source commit SHA; +- source ref; +- source commit timestamp; +- GitHub Actions workflow run ID and attempt; +- 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. + +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. + +The `production-pages-attestation` artifact retains Markdown and JSON evidence for 90 days. + ## Workflow behavior `.github/workflows/site-measurement.yml` runs: -- on relevant pull requests and pushes: deterministic build, measurement contract validation, internal links and fragment validation; +- on relevant pull requests and pushes: deterministic build, privacy generation, deployment stamping, consent/measurement validation, internal links and fragment validation; - every Monday at 03:17 UTC, or manually: deployed page checks, official release-asset checks, an intentional 404 probe, GA4 aggregate reports, Search Console reports and PageSpeed/CrUX collection. Artifacts: -- `site-measurement-quality`: local link and instrumentation evidence, retained for 30 days; -- `site-measurement-`: private Markdown/JSON traffic, search, 404 and Core Web Vitals evidence, retained for 90 days. +- `site-measurement-quality`: local link, privacy and instrumentation evidence, retained for 30 days; +- `site-measurement-`: private Markdown/JSON traffic, search, 404 and Core Web Vitals evidence, retained for 90 days; +- `production-pages-attestation`: deployed-commit and privacy evidence, retained for 90 days. The same Markdown report is written to the GitHub Actions job summary. @@ -57,7 +89,7 @@ These thresholds are implemented in `scripts/build-site-measurement-report.py` a ## Event contract -The local `landing/analytics.js` client emits: +After consent, the local `landing/analytics.js` client emits: - `page_view`; - `page_not_found`; @@ -74,14 +106,15 @@ Every event carries page path, page title, site language, content group and stab ## Interpreting Core Web Vitals -Browser RUM events provide continuous observations from measured visits. The scheduled PageSpeed report remains the decision source for field CWV because it uses CrUX data when enough real-user samples exist. When CrUX has insufficient traffic, the report retains Lighthouse lab values and marks field data unavailable instead of inventing a pass/fail result. +Browser RUM events provide continuous observations from consented visits. The scheduled PageSpeed report remains the decision source for field CWV because it uses CrUX data when enough real-user samples exist. When CrUX has insufficient traffic, the report retains Lighthouse lab values and marks field data unavailable instead of inventing a pass/fail result. ## Continuous-improvement loop -1. Review the weekly job summary. -2. Repair any broken internal link or failed 404 behavior immediately. -3. Prioritize high-impression pages with low CTR for title, description and intent alignment. -4. Compare English and Indonesian traffic before deciding which translations to expand. -5. Trace download clicks back to the page that generated them. -6. Investigate repeated 404 paths and add a valid route or redirect where appropriate. -7. Treat poor LCP, CLS or INP as a release-quality issue, then confirm the improvement in the next field-data cycle. +1. Confirm the latest `production-pages-attestation` matches `main` before interpreting any website metric. +2. Review the weekly job summary. +3. Repair any broken internal link or failed 404 behavior immediately. +4. Prioritize high-impression pages with low CTR for title, description and intent alignment. +5. Compare English and Indonesian traffic before deciding which translations to expand. +6. Trace download clicks back to the page that generated them. +7. Investigate repeated 404 paths and add a valid route or redirect where appropriate. +8. Treat poor LCP, CLS or INP as a release-quality issue, then confirm the improvement in the next field-data cycle. diff --git a/landing/audit.css b/landing/audit.css index 483cf249a..38121e819 100644 --- a/landing/audit.css +++ b/landing/audit.css @@ -1,2 +1,2 @@ /* Landing hardening: static branding, compact hierarchy, accessibility and performance. */ -:root{--radius:19px;--radius-sm:13px;--max:1160px}.brand{font-weight:700}.brand-mark{overflow:hidden;background:transparent;border-radius:9px;box-shadow:none}.brand-mark img{display:block;width:100%;height:100%;object-fit:contain}.brand-mark svg{display:none}.language-link{min-width:2.2rem;text-align:center;font-size:.78rem!important;font-weight:760!important;letter-spacing:.04em}h1{font-size:clamp(2.75rem,6vw,4.85rem);font-weight:720;letter-spacing:-.052em}h2{font-size:clamp(2rem,4.2vw,3.25rem);font-weight:710;letter-spacing:-.043em}h3{font-weight:700}.eyebrow,.kicker{font-weight:720}.hero{padding:5.8rem 0 3.6rem}.hero-grid{gap:3rem}.hero-copy{font-size:clamp(1rem,1.55vw,1.16rem);line-height:1.7}.hero-window,.screenshot-link,.reporting-visual .screenshot-link{aspect-ratio:1507/893}.hero-window img,.screenshot-link img{height:100%;object-fit:cover}.section{padding:4.8rem 0}.section-tight{padding:3rem 0}.card{padding:1.2rem}.callout{padding:1.75rem;border-radius:24px}.reveal{opacity:1;transform:none;transition:none}:focus-visible{outline:3px solid rgba(125,211,252,.92);outline-offset:3px}code{padding:.12rem .32rem;border-radius:6px;color:#dbeafe;background:rgba(255,255,255,.075);font-family:"Cascadia Code",Consolas,monospace;font-size:.9em}pre code{padding:0;background:transparent}.nav-links a{font-weight:610}.nav-cta{font-weight:700!important}.comparison-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:1rem}.comparison-panel{padding:1.35rem;border:1px solid var(--line);border-radius:var(--radius);background:rgba(255,255,255,.035)}.comparison-panel.highlight{border-color:rgba(56,189,248,.26);background:linear-gradient(145deg,rgba(56,189,248,.09),rgba(139,92,246,.055))}.comparison-panel h3{margin-top:.65rem;font-size:1.18rem}.comparison-panel p{color:var(--muted)}.comparison-panel ul{margin:.9rem 0 0;padding-left:1.15rem;color:var(--muted)}.comparison-panel li+li{margin-top:.42rem}.faq-grid{display:grid;gap:.75rem}.faq-item{padding:1.15rem 1.2rem;border:1px solid var(--line);border-radius:17px;background:rgba(255,255,255,.035)}.faq-item h3{font-size:1.02rem}.faq-item p{margin:.45rem 0 0;color:var(--muted);font-size:.92rem}.proof-strip{display:flex;flex-wrap:wrap;gap:.55rem 1rem;margin-top:1.25rem;color:var(--subtle);font-size:.86rem}.proof-strip span{display:inline-flex;align-items:center;gap:.4rem}.proof-strip span:before{content:"✓";color:var(--green);font-weight:800}.footer-grid{grid-template-columns:1.2fr repeat(4,.75fr);gap:1.5rem}@media(max-width:1080px){.menu-toggle{display:grid;place-items:center}.nav-links{position:absolute;top:72px;left:1rem;right:1rem;display:none;padding:.7rem;border:1px solid var(--line);border-radius:16px;background:rgba(7,17,31,.98);box-shadow:var(--shadow)}.nav-links.open{display:grid}.nav-links a{width:100%}}@media(max-width:980px){.footer-grid{grid-template-columns:1fr 1fr}.footer-brand{grid-column:1/-1}}@media(max-width:760px){.comparison-grid{grid-template-columns:1fr}.hero{padding-top:4.4rem}.footer-grid{grid-template-columns:1fr}.footer-brand{grid-column:auto}}@media(max-width:520px){h1{font-size:clamp(2.45rem,12vw,3.7rem)}.section{padding:3.8rem 0}.section-tight{padding:2.6rem 0}} +:root{--radius:19px;--radius-sm:13px;--max:1160px}.brand{font-weight:700}.brand-mark{overflow:hidden;background:transparent;border-radius:9px;box-shadow:none}.brand-mark img{display:block;width:100%;height:100%;object-fit:contain}.brand-mark svg{display:none}.language-link{min-width:2.2rem;text-align:center;font-size:.78rem!important;font-weight:760!important;letter-spacing:.04em}h1{font-size:clamp(2.75rem,6vw,4.85rem);font-weight:720;letter-spacing:-.052em}h2{font-size:clamp(2rem,4.2vw,3.25rem);font-weight:710;letter-spacing:-.043em}h3{font-weight:700}.eyebrow,.kicker{font-weight:720}.hero{padding:5.8rem 0 3.6rem}.hero-grid{gap:3rem}.hero-copy{font-size:clamp(1rem,1.55vw,1.16rem);line-height:1.7}.hero-window,.screenshot-link,.reporting-visual .screenshot-link{aspect-ratio:1507/893}.hero-window img,.screenshot-link img{height:100%;object-fit:cover}.section{padding:4.8rem 0}.section-tight{padding:3rem 0}.card{padding:1.2rem}.callout{padding:1.75rem;border-radius:24px}.reveal{opacity:1;transform:none;transition:none}:focus-visible{outline:3px solid rgba(125,211,252,.92);outline-offset:3px}code{padding:.12rem .32rem;border-radius:6px;color:#dbeafe;background:rgba(255,255,255,.075);font-family:"Cascadia Code",Consolas,monospace;font-size:.9em}pre code{padding:0;background:transparent}.nav-links a{font-weight:610}.nav-cta{font-weight:700!important}.comparison-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:1rem}.comparison-panel{padding:1.35rem;border:1px solid var(--line);border-radius:var(--radius);background:rgba(255,255,255,.035)}.comparison-panel.highlight{border-color:rgba(56,189,248,.26);background:linear-gradient(145deg,rgba(56,189,248,.09),rgba(139,92,246,.055))}.comparison-panel h3{margin-top:.65rem;font-size:1.18rem}.comparison-panel p{color:var(--muted)}.comparison-panel ul{margin:.9rem 0 0;padding-left:1.15rem;color:var(--muted)}.comparison-panel li+li{margin-top:.42rem}.faq-grid{display:grid;gap:.75rem}.faq-item{padding:1.15rem 1.2rem;border:1px solid var(--line);border-radius:17px;background:rgba(255,255,255,.035)}.faq-item h3{font-size:1.02rem}.faq-item p{margin:.45rem 0 0;color:var(--muted);font-size:.92rem}.proof-strip{display:flex;flex-wrap:wrap;gap:.55rem 1rem;margin-top:1.25rem;color:var(--subtle);font-size:.86rem}.proof-strip span{display:inline-flex;align-items:center;gap:.4rem}.proof-strip span:before{content:"✓";color:var(--green);font-weight:800}.footer-grid{grid-template-columns:1.2fr repeat(4,.75fr);gap:1.5rem}.footer-legal{display:flex;flex-wrap:wrap;align-items:center;justify-content:flex-end;gap:.65rem 1rem}.footer-preference{padding:0;border:0;background:transparent;color:var(--subtle);font:inherit;text-decoration:underline;text-underline-offset:3px;cursor:pointer}.footer-preference:hover{color:var(--text)}[data-lang="id"]{display:none}html[lang="id"] [data-lang="en"]{display:none}html[lang="id"] [data-lang="id"]{display:inline}.consent-banner[hidden]{display:none}.consent-banner{position:fixed;z-index:1200;left:1rem;right:1rem;bottom:1rem;max-width:1080px;margin:auto;padding:1px;border-radius:18px;background:linear-gradient(135deg,rgba(56,189,248,.72),rgba(139,92,246,.62));box-shadow:0 24px 70px rgba(0,0,0,.48)}.consent-banner-inner{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:1.25rem;padding:1.15rem 1.2rem;border-radius:17px;background:rgba(7,17,31,.985);backdrop-filter:blur(18px)}.consent-copy h2{margin:.3rem 0 .45rem;font-size:1.18rem;letter-spacing:-.02em}.consent-copy p{margin:0;max-width:760px;color:var(--muted);font-size:.9rem;line-height:1.55}.consent-status,.consent-policy-status{margin-top:.45rem!important;color:var(--subtle)!important;font-size:.8rem!important}.consent-actions{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:.55rem}.consent-actions .btn{white-space:nowrap}.consent-open{padding-bottom:10rem}.consent-policy-status{padding:.75rem 1rem;border:1px solid var(--line);border-radius:12px;background:rgba(255,255,255,.03)}@media(max-width:1080px){.menu-toggle{display:grid;place-items:center}.nav-links{position:absolute;top:72px;left:1rem;right:1rem;display:none;padding:.7rem;border:1px solid var(--line);border-radius:16px;background:rgba(7,17,31,.98);box-shadow:var(--shadow)}.nav-links.open{display:grid}.nav-links a{width:100%}}@media(max-width:980px){.footer-grid{grid-template-columns:1fr 1fr}.footer-brand{grid-column:1/-1}.consent-banner-inner{grid-template-columns:1fr}.consent-actions{justify-content:flex-start}}@media(max-width:760px){.comparison-grid{grid-template-columns:1fr}.hero{padding-top:4.4rem}.footer-grid{grid-template-columns:1fr}.footer-brand{grid-column:auto}.footer-legal{justify-content:flex-start}.consent-banner{left:.55rem;right:.55rem;bottom:.55rem}.consent-actions{display:grid;grid-template-columns:1fr 1fr}.consent-actions .btn{width:100%}.consent-actions .btn-quiet{grid-column:1/-1}.consent-open{padding-bottom:18rem}}@media(max-width:520px){h1{font-size:clamp(2.45rem,12vw,3.7rem)}.section{padding:3.8rem 0}.section-tight{padding:2.6rem 0}.consent-banner-inner{padding:1rem}.consent-actions{grid-template-columns:1fr}.consent-actions .btn-quiet{grid-column:auto}} diff --git a/landing/consent.js b/landing/consent.js new file mode 100644 index 000000000..1f9035345 --- /dev/null +++ b/landing/consent.js @@ -0,0 +1,129 @@ +(() => { + const STORAGE_KEY = 'arsas_analytics_consent_v1'; + const EVENT_NAME = 'arsas:consent'; + const dntEnabled = navigator.doNotTrack === '1' || window.doNotTrack === '1'; + const banner = document.querySelector('[data-consent-banner]'); + const status = document.querySelector('[data-consent-status]'); + const analyticsConfig = document.getElementById('arsas-analytics'); + const measurementId = analyticsConfig instanceof HTMLScriptElement ? analyticsConfig.dataset.measurementId || '' : ''; + const analyticsAvailable = /^G-[A-Z0-9]+$/.test(measurementId); + const acceptButtons = document.querySelectorAll('[data-consent-accept]'); + const rejectButtons = document.querySelectorAll('[data-consent-reject]'); + const manageButtons = document.querySelectorAll('[data-consent-manage]'); + let analyticsLoaded = false; + + window.dataLayer = window.dataLayer || []; + window.gtag = window.gtag || function gtag() { + window.dataLayer.push(arguments); + }; + window.gtag('consent', 'default', { + analytics_storage: 'denied', + ad_storage: 'denied', + ad_user_data: 'denied', + ad_personalization: 'denied', + wait_for_update: 500 + }); + + const readPreference = () => { + if (dntEnabled || !analyticsAvailable) return 'denied'; + try { + const value = window.localStorage.getItem(STORAGE_KEY); + return value === 'granted' || value === 'denied' ? value : 'unset'; + } catch { + return 'unset'; + } + }; + + const setStatus = preference => { + if (!status) return; + const isId = document.documentElement.lang === 'id'; + if (!analyticsAvailable) status.textContent = isId ? 'Analitik belum diaktifkan pada deployment ini.' : 'Analytics is not enabled on this deployment.'; + else if (dntEnabled) status.textContent = isId ? 'Do Not Track aktif; analitik tetap nonaktif.' : 'Do Not Track is active; analytics stays disabled.'; + else if (preference === 'granted') status.textContent = isId ? 'Analitik opsional diizinkan.' : 'Optional analytics is allowed.'; + else if (preference === 'denied') status.textContent = isId ? 'Analitik opsional ditolak.' : 'Optional analytics is declined.'; + else status.textContent = isId ? 'Belum ada pilihan analitik.' : 'No analytics preference has been saved.'; + }; + + const dispatch = (preference, source) => { + document.documentElement.dataset.analyticsConsent = preference; + setStatus(preference); + window.dispatchEvent(new CustomEvent(EVENT_NAME, { + detail: { analytics: preference, source, doNotTrack: dntEnabled, available: analyticsAvailable } + })); + }; + + const loadAnalytics = () => { + if (analyticsLoaded || dntEnabled || !analyticsAvailable) return; + analyticsLoaded = true; + window.gtag('consent', 'update', { + analytics_storage: 'granted', + ad_storage: 'denied', + ad_user_data: 'denied', + ad_personalization: 'denied' + }); + const client = document.createElement('script'); + client.src = 'analytics.js'; + client.async = true; + client.dataset.consentLoaded = 'true'; + document.head.appendChild(client); + }; + + const showBanner = focus => { + if (!(banner instanceof HTMLElement)) return; + banner.hidden = false; + document.body.classList.add('consent-open'); + acceptButtons.forEach(button => { + if (button instanceof HTMLButtonElement) button.disabled = !analyticsAvailable || dntEnabled; + }); + if (focus) { + const first = banner.querySelector('button:not([disabled])'); + if (first instanceof HTMLButtonElement) first.focus(); + } + }; + + const hideBanner = () => { + if (!(banner instanceof HTMLElement)) return; + banner.hidden = true; + document.body.classList.remove('consent-open'); + }; + + const save = preference => { + const previous = readPreference(); + const effective = dntEnabled || !analyticsAvailable ? 'denied' : preference; + try { + window.localStorage.setItem(STORAGE_KEY, effective); + } catch { + // The effective preference still applies for this page when storage is unavailable. + } + hideBanner(); + dispatch(effective, 'user-choice'); + if (effective === 'granted') loadAnalytics(); + else if (analyticsLoaded || previous === 'granted') window.location.reload(); + }; + + acceptButtons.forEach(button => button.addEventListener('click', () => save('granted'))); + rejectButtons.forEach(button => button.addEventListener('click', () => save('denied'))); + manageButtons.forEach(button => button.addEventListener('click', () => showBanner(true))); + + if (banner instanceof HTMLElement) { + banner.addEventListener('keydown', event => { + if (event.key === 'Escape' && readPreference() !== 'unset') hideBanner(); + }); + } + + const initial = readPreference(); + setStatus(initial); + if (analyticsAvailable && initial === 'unset' && !dntEnabled) showBanner(false); + dispatch(initial === 'granted' ? 'granted' : 'denied', 'initial'); + if (initial === 'granted') loadAnalytics(); + + window.ARSASConsent = Object.freeze({ + storageKey: STORAGE_KEY, + available: analyticsAvailable, + doNotTrack: dntEnabled, + get: readPreference, + manage: () => showBanner(true), + grant: () => save('granted'), + deny: () => save('denied') + }); +})(); diff --git a/landing/partials/footer.html b/landing/partials/footer.html index 5204177be..6b7d5cade 100644 --- a/landing/partials/footer.html +++ b/landing/partials/footer.html @@ -19,11 +19,27 @@
- +
- + - + + + diff --git a/landing/privacy-source/privacy.en.html.tmpl b/landing/privacy-source/privacy.en.html.tmpl new file mode 100644 index 000000000..1c0bc544a --- /dev/null +++ b/landing/privacy-source/privacy.en.html.tmpl @@ -0,0 +1,47 @@ + + + + + + Privacy and Optional Analytics | ARSAS + + + + + + + + + + + + + + + + + + + + + + + + + + + {{> header}} +
+
Privacy · optional analytics · user choice

Measurement is optional and disabled until you allow it.

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.

Baca dalam Bahasa Indonesia
+ +
What may be measured

Aggregate product-site signals

  • Page views and page groups.
  • Installer, portable ZIP and checksum link clicks.
  • English or Indonesian page usage and language switches.
  • 404 paths and referring pages.
  • LCP, CLS, INP and diagnostic TTFB values.
What ARSAS does not add

No engineering or device data

  • No relay IP address, SCL content, signal value or project file.
  • No form content, account identifier or ARSAS application telemetry.
  • No Google Signals, advertising storage or ad-personalization signals.
  • No custom User-ID and no attempt to identify an individual engineer.
+ +
Consent behavior

Google Analytics loads only after approval.

The default consent state is denied. A local preference named arsas_analytics_consent_v1 stores only granted or denied. 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.

Processors and hosting

GitHub hosts the site; Google processes optional analytics.

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.

+ +
Retention policy

Short operational retention

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.

Your control

Change the preference at any time.

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.

+ +
Questions or concerns

Report a privacy or measurement issue with evidence.

Describe the page, browser, observed request or consent behavior. Do not attach confidential project, IED or customer information.

+
+ {{> footer}} + + diff --git a/landing/privacy-source/privacy.id.html.tmpl b/landing/privacy-source/privacy.id.html.tmpl new file mode 100644 index 000000000..167f9bb73 --- /dev/null +++ b/landing/privacy-source/privacy.id.html.tmpl @@ -0,0 +1,47 @@ + + + + + + Privasi dan Analitik Opsional | ARSAS + + + + + + + + + + + + + + + + + + + + + + + + + + + {{> header}} +
+
Privasi · analitik opsional · pilihan pengguna

Measurement bersifat opsional dan nonaktif sampai Anda mengizinkannya.

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.

Read in English
+ +
Yang dapat diukur

Sinyal agregat website produk

  • Page view dan kelompok halaman.
  • Klik link Installer, Portable ZIP dan checksum.
  • Pemakaian halaman English atau Indonesia serta perpindahan bahasa.
  • Path 404 dan halaman perujuk.
  • Nilai LCP, CLS, INP dan diagnostic TTFB.
Yang tidak ditambahkan ARSAS

Tidak ada data engineering atau device

  • Tidak ada IP relay, isi SCL, nilai sinyal atau file proyek.
  • Tidak ada isi form, account identifier atau telemetry aplikasi ARSAS.
  • Tidak ada Google Signals, advertising storage atau ad-personalization signals.
  • Tidak ada custom User-ID dan tidak ada upaya mengidentifikasi engineer tertentu.
+ +
Perilaku persetujuan

Google Analytics hanya dimuat setelah disetujui.

Status awal consent adalah denied. Preferensi lokal bernama arsas_analytics_consent_v1 hanya menyimpan nilai granted atau denied. Menolak analitik tidak memblokir download, dokumentasi atau halaman produk. Browser Do Not Track mempertahankan analitik tetap nonaktif walaupun preferensi sebelumnya pernah diberikan.

Processor dan hosting

GitHub meng-host website; Google memproses analitik opsional.

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.

+ +
Kebijakan retensi

Retensi operasional singkat

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.

Kontrol Anda

Preferensi dapat diubah kapan saja.

Gunakan kontrol preferensi analitik di footer atau tombol berikut. Mencabut consent mencegah event analitik baru dikirim dari interaksi dan page load berikutnya.

+ +
Pertanyaan atau keberatan

Laporkan masalah privasi atau measurement dengan evidence.

Jelaskan halaman, browser, request atau perilaku consent yang terlihat. Jangan melampirkan informasi rahasia proyek, IED atau customer.

+
+ {{> footer}} + + diff --git a/scripts/generate-privacy-pages.py b/scripts/generate-privacy-pages.py new file mode 100644 index 000000000..437e35f0f --- /dev/null +++ b/scripts/generate-privacy-pages.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""Generate bilingual noindex privacy pages with shared ARSAS chrome.""" + +from __future__ import annotations + +import argparse +import importlib.util +import json +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*]*>\s*', + re.IGNORECASE, +) + + +def load_builder(): + spec = importlib.util.spec_from_file_location("arsas_product_builder", BUILDER_PATH) + if spec is None or spec.loader is None: + raise SystemExit("Cannot load product website builder") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +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")) + args = parser.parse_args() + + 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(): + raise SystemExit("Build the product website before generating privacy pages") + + builder = load_builder() + config = builder.read_config() + version = builder.read_version() + evidence, notes = builder.read_release_data(evidence_path) + values = builder.token_values(config, version, evidence, notes) + width, height = builder.icon_dimensions() + icon_size = f"{width}x{height}" + root = str(config["product"]["canonicalRoot"]) + + pages = ( + ( + SOURCE / "privacy.en.html.tmpl", + output / "privacy.html", + {"en": "privacy.html", "id": "privasi.html", "x-default": "privacy.html"}, + ), + ( + SOURCE / "privacy.id.html.tmpl", + output / "privasi.html", + {"en": "privacy.html", "id": "privasi.html", "x-default": "privacy.html"}, + ), + ) + + for source, target, alternates in pages: + if not source.is_file(): + 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}") + target.write_text(rendered, encoding="utf-8") + + build_info_path = output / "build-info.json" + build_info = json.loads(build_info_path.read_text(encoding="utf-8")) + build_info["privacyPages"] = ["privacy.html", "privasi.html"] + build_info["privacy"] = { + "indexing": "noindex,follow", + "consentRequired": True, + "defaultAnalyticsConsent": "denied", + "preferenceStorage": "localStorage", + "preferenceKey": "arsas_analytics_consent_v1", + } + 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.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/inject-site-measurement.py b/scripts/inject-site-measurement.py index d88a37476..896692b1a 100644 --- a/scripts/inject-site-measurement.py +++ b/scripts/inject-site-measurement.py @@ -11,6 +11,7 @@ PLACEHOLDER = "__ARSAS_GA4_MEASUREMENT_ID__" MEASUREMENT_PATTERN = re.compile(r"G-[A-Z0-9]+") +CONSENT_STORAGE_KEY = "arsas_analytics_consent_v1" def configure(site: Path, measurement_id: str) -> None: @@ -19,8 +20,9 @@ def configure(site: Path, measurement_id: str) -> None: raise SystemExit("Measurement ID must use the G-XXXXXXXX format") if not site.is_dir(): raise SystemExit(f"Site directory does not exist: {site}") - if not (site / "analytics.js").is_file(): - raise SystemExit("Built site is missing analytics.js") + for required in ("analytics.js", "consent.js"): + if not (site / required).is_file(): + raise SystemExit(f"Built site is missing {required}") build_info_path = site / "build-info.json" if not build_info_path.is_file(): @@ -49,16 +51,23 @@ def configure(site: Path, measurement_id: str) -> None: build_info["measurement"] = { "provider": "google-analytics-4", "enabled": bool(measurement_id), + "configElement": "arsas-analytics", "client": "analytics.js", + "consentController": "consent.js", + "consentRequired": True, + "defaultConsent": "denied", + "preferenceStorage": "localStorage", + "preferenceKey": CONSENT_STORAGE_KEY, "doNotTrackRespected": True, "advertisingSignals": False, + "adPersonalizationSignals": False, } build_info_path.write_text( json.dumps(build_info, indent=2, ensure_ascii=False) + "\n", encoding="utf-8", ) - state = "enabled" if measurement_id else "disabled" + state = "configured behind consent" if measurement_id else "disabled" print(f"ARSAS site measurement {state}: {replacements} registered pages configured.") diff --git a/scripts/stamp-site-build.py b/scripts/stamp-site-build.py new file mode 100644 index 000000000..f6e2dd2fa --- /dev/null +++ b/scripts/stamp-site-build.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""Stamp build-info.json with immutable deployment provenance.""" + +from __future__ import annotations + +import argparse +import json +import re +from datetime import datetime +from pathlib import Path + +SHA_PATTERN = re.compile(r"[0-9a-f]{40}", re.IGNORECASE) + + +def iso_timestamp(value: str) -> str: + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError as exc: + raise SystemExit(f"Invalid commit timestamp: {value}") from exc + return parsed.isoformat() + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("site", nargs="?", default="_site") + parser.add_argument("--source-commit", required=True) + parser.add_argument("--source-ref", required=True) + parser.add_argument("--commit-timestamp", required=True) + parser.add_argument("--workflow-run-id", required=True) + parser.add_argument("--workflow-run-attempt", default="1") + args = parser.parse_args() + + site = Path(args.site).resolve() + path = site / "build-info.json" + if not path.is_file(): + raise SystemExit(f"Missing build-info.json: {path}") + source_commit = args.source_commit.strip().lower() + if not SHA_PATTERN.fullmatch(source_commit): + raise SystemExit("source commit must be a full 40-character Git SHA") + if not args.source_ref.strip(): + raise SystemExit("source ref is required") + commit_timestamp = iso_timestamp(args.commit_timestamp.strip()) + if not str(args.workflow_run_id).strip(): + raise SystemExit("workflow run ID is required") + + payload = json.loads(path.read_text(encoding="utf-8")) + payload["sourceCommit"] = source_commit + payload["sourceRef"] = args.source_ref.strip() + payload["buildTimestampUtc"] = commit_timestamp + payload["workflowRunId"] = str(args.workflow_run_id).strip() + payload["workflowRunAttempt"] = str(args.workflow_run_attempt).strip() or "1" + payload["deploymentAttestation"] = { + "provider": "github-pages", + "sourceCommit": source_commit, + "sourceRef": args.source_ref.strip(), + "commitTimestampUtc": commit_timestamp, + "workflowRunId": str(args.workflow_run_id).strip(), + "workflowRunAttempt": str(args.workflow_run_attempt).strip() or "1", + } + path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + print(f"Stamped ARSAS website build with source commit {source_commit}.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/validate-site-measurement.py b/scripts/validate-site-measurement.py index f63609a25..40a60ad9b 100644 --- a/scripts/validate-site-measurement.py +++ b/scripts/validate-site-measurement.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Validate ARSAS client measurement instrumentation in a rendered site.""" +"""Validate ARSAS consent-gated measurement and privacy instrumentation.""" from __future__ import annotations @@ -12,14 +12,20 @@ MEASUREMENT_PATTERN = re.compile(r"G-[A-Z0-9]+") PLACEHOLDER = "__ARSAS_GA4_MEASUREMENT_ID__" +CONSENT_KEY = "arsas_analytics_consent_v1" class Parser(HTMLParser): def __init__(self) -> None: super().__init__(convert_charrefs=True) - self.analytics: list[dict[str, str | None]] = [] + self.scripts: list[dict[str, str | None]] = [] self.body_page: str | None = None self.language: str | None = None + self.robots: str | None = None + self.consent_banners = 0 + self.consent_manage = 0 + self.consent_status = 0 + self.alternates: dict[str, str] = {} def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: values = dict(attrs) @@ -27,8 +33,25 @@ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None self.language = values.get("lang") elif tag == "body": self.body_page = values.get("data-page") - elif tag == "script" and values.get("id") == "arsas-analytics": - self.analytics.append(values) + elif tag == "script": + self.scripts.append(values) + elif tag == "meta" and values.get("name", "").lower() == "robots": + self.robots = values.get("content") + elif tag == "link" and values.get("rel") == "alternate" and values.get("hreflang"): + self.alternates[values.get("hreflang") or ""] = values.get("href") or "" + if "data-consent-banner" in values: + self.consent_banners += 1 + if "data-consent-manage" in values: + self.consent_manage += 1 + if "data-consent-status" in values: + self.consent_status += 1 + + +def parse_page(path: Path) -> tuple[str, Parser]: + text = path.read_text(encoding="utf-8") + parsed = Parser() + parsed.feed(text) + return text, parsed def main() -> int: @@ -43,24 +66,36 @@ def main() -> int: if expected_id and not MEASUREMENT_PATTERN.fullmatch(expected_id): errors.append("expected measurement ID is invalid") - client = site / "analytics.js" - if not client.exists(): + analytics_path = site / "analytics.js" + consent_path = site / "consent.js" + analytics_text = analytics_path.read_text(encoding="utf-8") if analytics_path.exists() else "" + consent_text = consent_path.read_text(encoding="utf-8") if consent_path.exists() else "" + if not analytics_text: errors.append("analytics.js is missing") - client_text = "" - else: - client_text = client.read_text(encoding="utf-8") + if not consent_text: + errors.append("consent.js is missing") + for required in ( "download_installer", "download_portable", "download_checksums", "page_not_found", "language_switch", "reportVital('LCP'", "reportVital('CLS'", - "reportVital('INP'", "navigator.doNotTrack", "allow_google_signals: false", - "allow_ad_personalization_signals: false", + "reportVital('INP'", "allow_google_signals: false", "allow_ad_personalization_signals: false", ): - if required not in client_text: + if required not in analytics_text: errors.append(f"analytics.js missing measurement contract: {required}") + for required in ( + CONSENT_KEY, "analytics_storage: 'denied'", "ad_storage: 'denied'", + "ad_user_data: 'denied'", "ad_personalization: 'denied'", "navigator.doNotTrack", + "const loadAnalytics", "client.src = 'analytics.js'", "window.location.reload()", + ): + if required not in consent_text: + errors.append(f"consent.js missing privacy contract: {required}") + if "googletagmanager.com" in consent_text: + errors.append("consent.js must load only the local analytics client") build_info_path = site / "build-info.json" build_info: dict[str, object] = {} registered: list[str] = [] + privacy_pages: list[str] = [] if not build_info_path.exists(): errors.append("build-info.json is missing") else: @@ -70,56 +105,101 @@ def main() -> int: errors.append("build-info.json has an invalid page registry") else: registered = list(raw_pages) + raw_privacy = build_info.get("privacyPages") + if raw_privacy != ["privacy.html", "privasi.html"]: + errors.append("build-info.json must declare privacy.html and privasi.html") + else: + privacy_pages = list(raw_privacy) - pages = [site / item for item in registered] - for page in pages: + for relative in registered: + page = site / relative if not page.exists(): - errors.append(f"registered page is missing: {page.relative_to(site)}") + errors.append(f"registered page is missing: {relative}") continue - text = page.read_text(encoding="utf-8") - label = page.relative_to(site) + text, parsed = parse_page(page) if PLACEHOLDER in text: - errors.append(f"{label}: unresolved measurement placeholder") - parsed = Parser() - parsed.feed(text) - if len(parsed.analytics) != 1: - errors.append(f"{label}: expected one shared analytics client") - continue - script = parsed.analytics[0] - if script.get("src") != "analytics.js" or "defer" not in script: - errors.append(f"{label}: analytics client must be local and deferred") - actual_id = script.get("data-measurement-id") or "" - if actual_id != expected_id: - errors.append(f"{label}: measurement ID does not match configured deployment value") - stable_version = script.get("data-stable-version") or "" - if not re.fullmatch(r"\d+\.\d+\.\d+", stable_version): - errors.append(f"{label}: stable release version is missing from measurement context") + errors.append(f"{relative}: unresolved measurement placeholder") + configs = [item for item in parsed.scripts if item.get("id") == "arsas-analytics"] + if len(configs) != 1: + errors.append(f"{relative}: expected one inert analytics configuration") + else: + config = configs[0] + if config.get("type") != "application/json" or config.get("src") is not None: + errors.append(f"{relative}: analytics configuration must be inert and local") + if (config.get("data-measurement-id") or "") != expected_id: + errors.append(f"{relative}: measurement ID does not match deployment configuration") + if not re.fullmatch(r"\d+\.\d+\.\d+", config.get("data-stable-version") or ""): + errors.append(f"{relative}: stable release context is missing") + consent_scripts = [item for item in parsed.scripts if item.get("src") == "consent.js"] + if len(consent_scripts) != 1 or "defer" not in consent_scripts[0]: + errors.append(f"{relative}: expected one deferred consent controller") + if any(item.get("src") == "analytics.js" for item in parsed.scripts): + errors.append(f"{relative}: analytics.js must not load before consent") + if parsed.consent_banners != 1 or parsed.consent_manage < 1 or parsed.consent_status < 1: + errors.append(f"{relative}: consent banner or preference controls are incomplete") if parsed.language not in {"en", "id"}: - errors.append(f"{label}: language is unavailable for traffic segmentation") - if page.name == "404.html" and parsed.body_page != "none": + errors.append(f"{relative}: language is unavailable for consent copy") + if relative == "404.html" and parsed.body_page != "none": errors.append("404.html must use data-page=none for page_not_found measurement") + expected_privacy_alternates = { + "en": "https://masarray.github.io/arsas/privacy.html", + "id": "https://masarray.github.io/arsas/privasi.html", + "x-default": "https://masarray.github.io/arsas/privacy.html", + } + for relative in privacy_pages: + page = site / relative + if not page.exists(): + errors.append(f"privacy page is missing: {relative}") + continue + text, parsed = parse_page(page) + if parsed.robots != "noindex,follow": + errors.append(f"{relative}: privacy page must use noindex,follow") + if parsed.alternates != expected_privacy_alternates: + errors.append(f"{relative}: bilingual privacy alternates are incomplete") + if any(item.get("id") == "arsas-analytics" or item.get("src") == "analytics.js" for item in parsed.scripts): + errors.append(f"{relative}: privacy policy must not load analytics") + consent_scripts = [item for item in parsed.scripts if item.get("src") == "consent.js"] + if len(consent_scripts) != 1 or "defer" not in consent_scripts[0]: + errors.append(f"{relative}: privacy page must load the consent controller") + if parsed.consent_banners != 1 or parsed.consent_manage < 2 or parsed.consent_status < 1: + errors.append(f"{relative}: privacy preference controls are incomplete") + if CONSENT_KEY not in text or "two-month" not in text and "dua bulan" not in text: + errors.append(f"{relative}: retention or preference-key disclosure is incomplete") + measurement = build_info.get("measurement") if not isinstance(measurement, dict): errors.append("build-info.json is missing measurement status") else: - if measurement.get("provider") != "google-analytics-4": - errors.append("build-info.json has invalid measurement provider") - if measurement.get("enabled") is not bool(expected_id): - errors.append("build-info.json measurement enabled state is incorrect") - if measurement.get("doNotTrackRespected") is not True: - errors.append("build-info.json must declare Do Not Track handling") - if measurement.get("advertisingSignals") is not False: - errors.append("build-info.json must declare advertising signals disabled") + expected = { + "provider": "google-analytics-4", + "enabled": bool(expected_id), + "consentRequired": True, + "defaultConsent": "denied", + "preferenceStorage": "localStorage", + "preferenceKey": CONSENT_KEY, + "doNotTrackRespected": True, + "advertisingSignals": False, + "adPersonalizationSignals": False, + } + for key, value in expected.items(): + if measurement.get(key) != value: + errors.append(f"build-info.json measurement.{key} must be {value!r}") + privacy = build_info.get("privacy") + if not isinstance(privacy, dict) or privacy.get("consentRequired") is not True or privacy.get("defaultAnalyticsConsent") != "denied": + errors.append("build-info.json privacy contract is incomplete") errors = list(dict.fromkeys(errors)) if errors: - print("ARSAS site-measurement validation failed:", file=sys.stderr) + print("ARSAS consent and measurement validation failed:", file=sys.stderr) for error in errors: print(f"- {error}", file=sys.stderr) return 1 - state = "enabled" if expected_id else "disabled/no-op" - print(f"ARSAS site-measurement validation passed: {len(pages)} registered pages, client {state}, downloads, language, 404 and Core Web Vitals contracts present.") + state = "configured behind consent" if expected_id else "disabled/no-op" + print( + f"ARSAS consent and measurement validation passed: {len(registered)} product pages, " + f"{len(privacy_pages)} privacy pages, client {state}, denied by default." + ) return 0 diff --git a/scripts/verify-pages-deployment.py b/scripts/verify-pages-deployment.py new file mode 100644 index 000000000..2a6c4f9ae --- /dev/null +++ b/scripts/verify-pages-deployment.py @@ -0,0 +1,174 @@ +#!/usr/bin/env python3 +"""Verify that public GitHub Pages serves the expected ARSAS build.""" + +from __future__ import annotations + +import argparse +import json +import time +from datetime import datetime, timezone +from pathlib import Path +from urllib.error import HTTPError, URLError +from urllib.parse import urlencode, urljoin +from urllib.request import Request, urlopen + + +def parse_bool(value: str) -> bool: + normalized = value.strip().lower() + if normalized in {"1", "true", "yes", "enabled"}: + return True + if normalized in {"0", "false", "no", "disabled"}: + return False + raise argparse.ArgumentTypeError("expected true or false") + + +def fetch(url: str, timeout: int = 20) -> tuple[int, str, dict[str, str]]: + request = Request( + url, + headers={ + "User-Agent": "ARSAS-Pages-Attestation/1.0", + "Accept": "application/json,text/html;q=0.9,*/*;q=0.8", + "Cache-Control": "no-cache", + "Pragma": "no-cache", + }, + ) + try: + with urlopen(request, timeout=timeout) as response: + return response.status, response.read().decode("utf-8", errors="replace"), dict(response.headers.items()) + except HTTPError as exc: + return exc.code, exc.read().decode("utf-8", errors="replace"), dict(exc.headers.items()) + except URLError as exc: + raise RuntimeError(str(exc.reason)) from exc + + +def check_once(base_url: str, source_commit: str, stable_version: str, measurement_enabled: bool, nonce: str) -> tuple[list[str], dict[str, object]]: + errors: list[str] = [] + evidence: dict[str, object] = {} + info_url = urljoin(base_url, "build-info.json") + "?" + urlencode({"attest": source_commit, "n": nonce}) + status, body, headers = fetch(info_url) + evidence["buildInfoUrl"] = info_url + evidence["buildInfoStatus"] = status + evidence["buildInfoHeaders"] = {key: headers[key] for key in headers if key.lower() in {"etag", "last-modified", "cache-control", "content-type"}} + if status != 200: + return [f"build-info.json returned HTTP {status}"], evidence + try: + info = json.loads(body) + except json.JSONDecodeError as exc: + return [f"build-info.json is not valid JSON: {exc}"], evidence + evidence["publicBuildInfo"] = info + + if info.get("sourceCommit") != source_commit: + errors.append(f"public sourceCommit is {info.get('sourceCommit')!r}, expected {source_commit}") + attestation = info.get("deploymentAttestation") + if not isinstance(attestation, dict) or attestation.get("sourceCommit") != source_commit: + errors.append("deploymentAttestation does not match the expected source commit") + if info.get("stableReleaseVersion") != stable_version: + errors.append(f"public stable release is {info.get('stableReleaseVersion')!r}, expected {stable_version}") + if not info.get("workflowRunId") or not info.get("buildTimestampUtc"): + errors.append("public build metadata is missing workflowRunId or buildTimestampUtc") + + measurement = info.get("measurement") + if not isinstance(measurement, dict): + errors.append("public build is missing measurement metadata") + else: + if measurement.get("enabled") is not measurement_enabled: + errors.append(f"public measurement enabled={measurement.get('enabled')!r}, expected {measurement_enabled}") + if measurement.get("consentRequired") is not True or measurement.get("defaultConsent") != "denied": + errors.append("public measurement metadata does not require denied-by-default consent") + if measurement.get("advertisingSignals") is not False: + errors.append("public measurement metadata must keep advertising signals disabled") + + privacy_pages = info.get("privacyPages") + if privacy_pages != ["privacy.html", "privasi.html"]: + errors.append("public build does not declare both bilingual privacy pages") + privacy = info.get("privacy") + if not isinstance(privacy, dict) or privacy.get("consentRequired") is not True or privacy.get("defaultAnalyticsConsent") != "denied": + errors.append("public privacy metadata is incomplete") + + for relative in ("privacy.html", "privasi.html"): + url = urljoin(base_url, relative) + "?" + urlencode({"attest": source_commit}) + page_status, page, _ = fetch(url) + evidence[f"{relative}Status"] = page_status + if page_status != 200: + errors.append(f"{relative} returned HTTP {page_status}") + continue + for required in ('name="robots" content="noindex,follow"', 'src="consent.js"', 'data-consent-manage', 'data-consent-status'): + if required not in page: + errors.append(f"{relative} is missing {required}") + if 'id="arsas-analytics"' in page: + errors.append(f"{relative} must not load the analytics client") + + home_url = base_url + "?" + urlencode({"attest": source_commit}) + home_status, home, _ = fetch(home_url) + evidence["homeStatus"] = home_status + if home_status != 200: + errors.append(f"homepage returned HTTP {home_status}") + else: + for required in ('src="consent.js"', 'id="arsas-analytics"', 'data-consent-banner', 'assets/app-icon.png'): + if required not in home: + errors.append(f"homepage is missing {required}") + + return errors, evidence + + +def write_report(path: Path, success: bool, attempts: int, evidence: dict[str, object], errors: list[str]) -> None: + payload = { + "schemaVersion": 1, + "verifiedAtUtc": datetime.now(timezone.utc).isoformat(), + "success": success, + "attempts": attempts, + "errors": errors, + "evidence": evidence, + } + path.parent.mkdir(parents=True, exist_ok=True) + path.with_suffix(".json").write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + lines = ["# ARSAS production deployment attestation", "", f"- Status: **{'PASS' if success else 'FAIL'}**", f"- Attempts: {attempts}"] + if errors: + lines.extend(["", "## Last observed errors", "", *[f"- {error}" for error in errors]]) + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--base-url", required=True) + parser.add_argument("--source-commit", required=True) + parser.add_argument("--stable-version", required=True) + parser.add_argument("--measurement-enabled", type=parse_bool, required=True) + parser.add_argument("--attempts", type=int, default=24) + parser.add_argument("--delay", type=float, default=10.0) + parser.add_argument("--output", default="_validation/production-attestation.md") + args = parser.parse_args() + + base_url = args.base_url.rstrip("/") + "/" + source_commit = args.source_commit.strip().lower() + last_errors: list[str] = [] + last_evidence: dict[str, object] = {} + used_attempts = 0 + for attempt in range(1, max(1, args.attempts) + 1): + used_attempts = attempt + try: + last_errors, last_evidence = check_once( + base_url, + source_commit, + args.stable_version.strip(), + args.measurement_enabled, + str(attempt), + ) + except Exception as exc: + last_errors = [f"network verification failed: {exc}"] + last_evidence = {} + if not last_errors: + write_report(Path(args.output), True, used_attempts, last_evidence, []) + print(f"Public ARSAS Pages attestation passed for {source_commit} after {used_attempts} attempt(s).") + return 0 + print(f"Attempt {attempt}/{args.attempts}: " + "; ".join(last_errors)) + if attempt < args.attempts: + time.sleep(max(0.0, args.delay)) + + write_report(Path(args.output), False, used_attempts, last_evidence, last_errors) + print("Public ARSAS Pages attestation failed: " + "; ".join(last_errors)) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main())