From bb0ec646e8a54c33ee6dba68e99066612697c1c0 Mon Sep 17 00:00:00 2001 From: Lars Weiser Date: Tue, 12 May 2026 12:21:42 +0200 Subject: [PATCH] =?UTF-8?q?feat(verifier):=20G=20D.12=20=E2=80=94=20per-fa?= =?UTF-8?q?ct=20verified=5Fat=20via=20repo=20variables?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the env-var → repo-variable display refresh pattern from G D.11 to the per-fact `bbl-verified-at-dpf` and `bbl-verified-at-cwa` hidden meta markers on the privacy pages. Previously those markers read JSON's per-fact `verified_at` fields directly; on the silent-when-healthy weekly cron path JSON stays untouched, so the per-fact markers were stuck at the last value-change date (the 2026-04-29 seed) even when both checks DID re-verify clean today. The field name "verified_at" strongly implies "when we last verified" — JSON-direct sourcing didn't match. Two new repo variables (created manually by Lars, seed 2026-04-29): - VERIFIER_DPF_VERIFIED_AT — advances when the DPF check returns ok - VERIFIER_CWA_VERIFIED_AT — advances when the CWA check returns ok These advance independently of the top-level VERIFIER_LAST_OK_AT, so on mixed-status runs the per-fact marker for the clean-on-this-run fact still refreshes: - all-ok run: all three variables advance - CWA changed (PR path): DPF variable still advances; CWA stays - DPF absent (PR path): CWA variable still advances; DPF stays - CWA parser-broken: DPF variable advances; Issue opens for CWA - DPF unreachable: CWA variable advances; Issue opens for DPF Implementation: - scripts/run-verifier.mjs: emit dpf_ok + cwa_retention_ok GH outputs alongside the existing three flags; expand the dry-run preview log to include the per-fact variable-update paths. - .github/workflows/verify-cloudflare-facts.yml: pin the two new flags to false in the mock-dispatch dry-run branch; add two new variable- update steps gated independently on dpf_ok / cwa_retention_ok (using the same VERIFIER_VARIABLE_TOKEN fine-grained PAT). - All four build workflows (deploy-staging, deploy-production, rebuild-nightly, rebuild-nightly-staging): pass VERIFIED_AT_DPF and VERIFIED_AT_CWA env vars to `npm run build` from the new repo vars. - src/lib/cloudflare-facts.ts: generalize getEffectiveVerifiedDate with a `source: 'top' | 'dpf' | 'cwa'` parameter. JSON fallback per source; Vite-friendly literal env-property access. File-level JSDoc rewritten: the G D.11 note that said "per-fact markers deliberately read JSON, NOT this helper" is reversed under G D.12. - src/pages/datenschutz.astro + src/pages/en/privacy.astro: route the per-fact `bbl-verified-at-{dpf,cwa}` markers through the generalized helper. The frontmatter comment about the verifier's post-deploy smoke step is dropped (that step was removed by G D.11.1). Local smoke: - npm run build with all three env vars set → all three markers show the env-supplied date on both DE and EN pages. Unset env → all three fall back to JSON. Malformed env → fallback (Date.parse guard). - astro check: 0 errors, 0 warnings (2 pre-existing handoff-bundle CJS hints). - Verifier dry-run matrix (per-fact independence): cwa-active → dpf_ok=true, cwa_ok=true → all 3 vars cwa-changed-figure→ dpf_ok=true, cwa_ok=false → DPF var + PR dpf-absent → dpf_ok=false, cwa_ok=true → CWA var + PR cwa-parser-broken → dpf_ok=true, cwa_ok=false → DPF var + Issue Surfaced post-G-D.11 ship when the per-fact markers stayed at 2026-04-29 even after the top-level marker advanced to today, prompting the design discussion that produced this gate. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/deploy-production.yml | 2 + .github/workflows/deploy-staging.yml | 2 + .github/workflows/rebuild-nightly-staging.yml | 2 + .github/workflows/rebuild-nightly.yml | 2 + .github/workflows/verify-cloudflare-facts.yml | 30 ++++++- scripts/run-verifier.mjs | 16 +++- src/lib/cloudflare-facts.ts | 90 +++++++++++++------ src/pages/datenschutz.astro | 13 +-- src/pages/en/privacy.astro | 13 +-- 9 files changed, 125 insertions(+), 45 deletions(-) diff --git a/.github/workflows/deploy-production.yml b/.github/workflows/deploy-production.yml index 80b7da1..6810697 100644 --- a/.github/workflows/deploy-production.yml +++ b/.github/workflows/deploy-production.yml @@ -47,6 +47,8 @@ jobs: PUBLIC_CWA_TOKEN: ${{ secrets.PUBLIC_CWA_TOKEN }} PRODUCT_REPOS_PAT: ${{ secrets.PRODUCT_REPOS_PAT }} VERIFIED_AT: ${{ vars.VERIFIER_LAST_OK_AT }} + VERIFIED_AT_DPF: ${{ vars.VERIFIER_DPF_VERIFIED_AT }} + VERIFIED_AT_CWA: ${{ vars.VERIFIER_CWA_VERIFIED_AT }} run: npm run build - name: Deploy to Cloudflare Workers (production) diff --git a/.github/workflows/deploy-staging.yml b/.github/workflows/deploy-staging.yml index e2f6b90..88a8b04 100644 --- a/.github/workflows/deploy-staging.yml +++ b/.github/workflows/deploy-staging.yml @@ -47,6 +47,8 @@ jobs: env: PRODUCT_REPOS_PAT: ${{ secrets.PRODUCT_REPOS_PAT }} VERIFIED_AT: ${{ vars.VERIFIER_LAST_OK_AT }} + VERIFIED_AT_DPF: ${{ vars.VERIFIER_DPF_VERIFIED_AT }} + VERIFIED_AT_CWA: ${{ vars.VERIFIER_CWA_VERIFIED_AT }} run: npm run build - name: Deploy to Cloudflare Workers (staging) diff --git a/.github/workflows/rebuild-nightly-staging.yml b/.github/workflows/rebuild-nightly-staging.yml index c1b3b7a..bfcc871 100644 --- a/.github/workflows/rebuild-nightly-staging.yml +++ b/.github/workflows/rebuild-nightly-staging.yml @@ -58,6 +58,8 @@ jobs: PRODUCT_REPOS_PAT: ${{ secrets.PRODUCT_REPOS_PAT }} PUBLIC_CWA_TOKEN: ${{ secrets.PUBLIC_CWA_TOKEN }} VERIFIED_AT: ${{ vars.VERIFIER_LAST_OK_AT }} + VERIFIED_AT_DPF: ${{ vars.VERIFIER_DPF_VERIFIED_AT }} + VERIFIED_AT_CWA: ${{ vars.VERIFIER_CWA_VERIFIED_AT }} run: npm run build - name: Deploy to Cloudflare diff --git a/.github/workflows/rebuild-nightly.yml b/.github/workflows/rebuild-nightly.yml index 7caabd6..555e92a 100644 --- a/.github/workflows/rebuild-nightly.yml +++ b/.github/workflows/rebuild-nightly.yml @@ -62,6 +62,8 @@ jobs: PRODUCT_REPOS_PAT: ${{ secrets.PRODUCT_REPOS_PAT }} PUBLIC_CWA_TOKEN: ${{ secrets.PUBLIC_CWA_TOKEN }} VERIFIED_AT: ${{ vars.VERIFIER_LAST_OK_AT }} + VERIFIED_AT_DPF: ${{ vars.VERIFIER_DPF_VERIFIED_AT }} + VERIFIED_AT_CWA: ${{ vars.VERIFIER_CWA_VERIFIED_AT }} run: npm run build - name: Deploy to Cloudflare diff --git a/.github/workflows/verify-cloudflare-facts.yml b/.github/workflows/verify-cloudflare-facts.yml index e979f83..4ef3eed 100644 --- a/.github/workflows/verify-cloudflare-facts.yml +++ b/.github/workflows/verify-cloudflare-facts.yml @@ -71,11 +71,13 @@ jobs: node scripts/run-verifier.mjs --dry-run echo "dry_run=true" >> "$GITHUB_OUTPUT" # In dry-run the orchestrator does not advance GH outputs that - # gate live channel steps; explicitly pin all three flags to - # false so the variable-update, PR, and Issue gates are skipped. + # gate live channel steps; explicitly pin all flags to false + # so the variable-update, PR, and Issue gates all skip. echo "all_ok=false" >> "$GITHUB_OUTPUT" echo "has_value_diff=false" >> "$GITHUB_OUTPUT" echo "has_failure=false" >> "$GITHUB_OUTPUT" + echo "dpf_ok=false" >> "$GITHUB_OUTPUT" + echo "cwa_retention_ok=false" >> "$GITHUB_OUTPUT" exit 0 fi node scripts/run-verifier.mjs @@ -97,6 +99,30 @@ jobs: "/repos/${{ github.repository }}/actions/variables/VERIFIER_LAST_OK_AT" \ -f name=VERIFIER_LAST_OK_AT -f value="$NOW" + - name: Update VERIFIER_DPF_VERIFIED_AT (when DPF check returned ok) + if: ${{ steps.run.outputs.dpf_ok == 'true' && steps.run.outputs.dry_run == 'false' }} + env: + GH_TOKEN: ${{ secrets.VERIFIER_VARIABLE_TOKEN }} + run: | + set -euo pipefail + NOW=$(date -u +%Y-%m-%dT%H:%M:%SZ) + echo "[workflow] updating VERIFIER_DPF_VERIFIED_AT to $NOW" + gh api -X PATCH \ + "/repos/${{ github.repository }}/actions/variables/VERIFIER_DPF_VERIFIED_AT" \ + -f name=VERIFIER_DPF_VERIFIED_AT -f value="$NOW" + + - name: Update VERIFIER_CWA_VERIFIED_AT (when CWA check returned ok) + if: ${{ steps.run.outputs.cwa_retention_ok == 'true' && steps.run.outputs.dry_run == 'false' }} + env: + GH_TOKEN: ${{ secrets.VERIFIER_VARIABLE_TOKEN }} + run: | + set -euo pipefail + NOW=$(date -u +%Y-%m-%dT%H:%M:%SZ) + echo "[workflow] updating VERIFIER_CWA_VERIFIED_AT to $NOW" + gh api -X PATCH \ + "/repos/${{ github.repository }}/actions/variables/VERIFIER_CWA_VERIFIED_AT" \ + -f name=VERIFIER_CWA_VERIFIED_AT -f value="$NOW" + - name: Open auto-PR for value change if: ${{ steps.run.outputs.has_value_diff == 'true' && steps.run.outputs.dry_run == 'false' }} env: diff --git a/scripts/run-verifier.mjs b/scripts/run-verifier.mjs index ba8c074..14d6384 100644 --- a/scripts/run-verifier.mjs +++ b/scripts/run-verifier.mjs @@ -212,17 +212,27 @@ async function main() { const previousJson = await readFile(DATA_PATH, 'utf8'); const nextJson = JSON.stringify(data, null, 2) + '\n'; + // Per-fact ok flags for independent per-fact variable updates + // (G D.12). Allows VERIFIER_DPF_VERIFIED_AT / VERIFIER_CWA_VERIFIED_AT + // to advance whenever the corresponding fact returned 'ok', even on + // mixed-status runs where one fact is changed/failing and the other + // is fine. Independent of the top-level `all_ok` gate. + const dpfOk = results.dpf.status === 'ok'; + const cwaRetentionOk = results.cwa_retention.status === 'ok'; + // Emit GH outputs only on real (non-dry-run) execution. Dry-run is the // synthetic / dispatch path; the workflow explicitly pins flags to - // false in that branch so live PR / Issue steps stay quiet. + // false in that branch so live PR / Issue / variable steps stay quiet. if (!DRY_RUN) { await emitGithubOutput('all_ok', allOk); await emitGithubOutput('has_value_diff', hasValueDiff); await emitGithubOutput('has_failure', hasFailure); + await emitGithubOutput('dpf_ok', dpfOk); + await emitGithubOutput('cwa_retention_ok', cwaRetentionOk); } console.log( - `[verifier:orchestrator] summary: all_ok=${allOk} has_value_diff=${hasValueDiff} has_failure=${hasFailure}`, + `[verifier:orchestrator] summary: all_ok=${allOk} has_value_diff=${hasValueDiff} has_failure=${hasFailure} dpf_ok=${dpfOk} cwa_retention_ok=${cwaRetentionOk}`, ); if (DRY_RUN) { @@ -230,6 +240,8 @@ async function main() { console.log(nextJson); const wouldDo = []; if (allOk) wouldDo.push('update VERIFIER_LAST_OK_AT variable'); + if (dpfOk) wouldDo.push('update VERIFIER_DPF_VERIFIED_AT variable'); + if (cwaRetentionOk) wouldDo.push('update VERIFIER_CWA_VERIFIED_AT variable'); if (hasValueDiff) wouldDo.push('open auto-PR'); if (hasFailure) wouldDo.push('open verifier-alert Issue'); console.log( diff --git a/src/lib/cloudflare-facts.ts b/src/lib/cloudflare-facts.ts index 944e55f..c1ee9d4 100644 --- a/src/lib/cloudflare-facts.ts +++ b/src/lib/cloudflare-facts.ts @@ -8,20 +8,29 @@ * Schema (verifier-era, schema_version=1): * - `_meta.last_check_attempt` is updated every run (success or failure) * and drives the build-time freshness gate. - * - Per-fact `verified_at` is updated only on successful checks. This is - * the date users see on the privacy pages. + * - Per-fact `verified_at` is updated only when a value change forces a + * JSON commit (the verifier-bot PR). For the silent-when-healthy + * weekly cron path, JSON stays untouched and the displayed dates flow + * from GitHub Actions repo variables instead — see below. * - Per-fact `last_known_good_at` mirrors `verified_at` on success and is * left untouched on failure — useful for "stale" detection. * - * G D.11 display-date sourcing note: the top-level `bbl-verified-at` - * meta marker + visible "zuletzt geprüft am ..." prose on the privacy - * pages flows through `getEffectiveVerifiedDate()` (env-preferred, JSON - * fallback). The per-fact `bbl-verified-at-dpf` / `bbl-verified-at-cwa` - * markers in `` deliberately read JSON's per-fact `verified_at` - * fields directly, NOT this helper — they're diagnostic markers - * tracking the JSON-side ground truth per fact, separate from the - * unified display date. Don't "fix" the asymmetry without revisiting - * the verifier post-deploy smoke design. + * G D.11 + G D.12 display-date sourcing — all three rendered "verified + * at" surfaces on the privacy pages flow through + * `getEffectiveVerifiedDate()` with a `source` argument, preferring + * GitHub Actions repo variables (set by the verifier's clean-run path) + * over JSON: + * - top-level `` + visible "zuletzt geprüft + * am ..." prose → source `'top'` → env `VERIFIED_AT` ← variable + * `VERIFIER_LAST_OK_AT` (advances on all-ok runs). + * - `` → source `'dpf'` → env + * `VERIFIED_AT_DPF` ← variable `VERIFIER_DPF_VERIFIED_AT` (advances + * whenever the DPF check returns ok, independent of CWA). + * - `` → source `'cwa'` → env + * `VERIFIED_AT_CWA` ← variable `VERIFIER_CWA_VERIFIED_AT` (advances + * whenever the CWA check returns ok, independent of DPF). + * JSON's per-fact `verified_at` is the fallback if any env var is + * unset or unparseable (local dev, first deploy, reset scenarios). */ import facts from '../data/cloudflare-facts.json'; @@ -59,30 +68,53 @@ export interface CloudflareFacts { export const cloudflareFacts: CloudflareFacts = facts as CloudflareFacts; /** - * Effective verified-date for the privacy pages' single-date contract. + * Source selector for `getEffectiveVerifiedDate`. + * - `'top'` (default): unified display date for the headline "zuletzt + * geprüft am ..." prose + ``. Sourced + * from `VERIFIED_AT` env (← repo var `VERIFIER_LAST_OK_AT`); JSON + * fallback is the older of the two per-fact dates. + * - `'dpf'`: per-fact diagnostic marker for the DPF claim. Sourced from + * `VERIFIED_AT_DPF` env (← repo var `VERIFIER_DPF_VERIFIED_AT`); JSON + * fallback is `facts.dpf.verified_at`. + * - `'cwa'`: per-fact diagnostic marker for the CWA retention claim. + * Sourced from `VERIFIED_AT_CWA` env (← repo var + * `VERIFIER_CWA_VERIFIED_AT`); JSON fallback is + * `facts.cwa_retention.verified_at`. + */ +export type VerifiedAtSource = 'top' | 'dpf' | 'cwa'; + +/** + * Effective verified-date for the privacy pages. * - * Resolution order (G D.11): - * 1. `import.meta.env.VERIFIED_AT` — set from the `VERIFIER_LAST_OK_AT` - * GitHub Actions repo variable by every build workflow. Wins when - * present and parseable so prod/staging refresh the displayed date - * from the verifier's last clean run without any git operations. - * (Astro/Vite exposes non-public env vars at build time through - * `import.meta.env`; this is the project-wide pattern — see - * `src/lib/github-api.ts`, `src/lib/env.ts`.) - * 2. Otherwise, the older of the two per-fact `verified_at` values in - * `src/data/cloudflare-facts.json` (worst-case freshness signal: - * "the data is at most this stale"). This is the local-dev path and - * the safe fallback for any first-deploy / reset scenario before - * the verifier has run. + * Resolution order (G D.11 + G D.12): env var matching `source` wins + * when present and parseable so prod/staging refresh the displayed date + * from the verifier's last clean run without any git operations. Otherwise + * fall back to JSON's per-fact `verified_at` (per-fact sources) or the + * older of the two (top-level — worst-case freshness signal). * - * The `Date.parse()` guard ensures malformed env input falls through to - * JSON rather than rendering a literal "Invalid Date" string. + * Vite replaces `import.meta.env.X` at build time only when `X` is a + * literal property access — computed property access wouldn't be + * replaced — so the three sources branch explicitly. The `Date.parse()` + * guard ensures malformed env input falls through to JSON rather than + * rendering a literal "Invalid Date" string. */ -export function getEffectiveVerifiedDate(facts: CloudflareFacts): string { - const envValue = import.meta.env.VERIFIED_AT as string | undefined; +export function getEffectiveVerifiedDate( + facts: CloudflareFacts, + source: VerifiedAtSource = 'top', +): string { + let envValue: string | undefined; + if (source === 'dpf') { + envValue = import.meta.env.VERIFIED_AT_DPF as string | undefined; + } else if (source === 'cwa') { + envValue = import.meta.env.VERIFIED_AT_CWA as string | undefined; + } else { + envValue = import.meta.env.VERIFIED_AT as string | undefined; + } if (envValue && !Number.isNaN(Date.parse(envValue))) { return envValue; } + if (source === 'dpf') return facts.dpf.verified_at; + if (source === 'cwa') return facts.cwa_retention.verified_at; const a = facts.dpf.verified_at; const b = facts.cwa_retention.verified_at; return a < b ? a : b; diff --git a/src/pages/datenschutz.astro b/src/pages/datenschutz.astro index 8147f48..dd321f3 100644 --- a/src/pages/datenschutz.astro +++ b/src/pages/datenschutz.astro @@ -22,13 +22,14 @@ const description = const standDate = 'April 2026'; const cwaMonths = cloudflareFacts.cwa_retention.aggregated_retention_months; const verifiedHuman = formatLocaleDate(getEffectiveVerifiedDate(cloudflareFacts), 'de'); -// ISO-date markers for the verifier workflow's post-deploy smoke step -// (mode #8 v1). Locale-independent; invisible to users; greppable from -// the deployed HTML. Format: YYYY-MM-DD. See -// .github/workflows/verify-cloudflare-facts.yml ("Post-deploy smoke"). +// Hidden ISO-date markers for diagnostics / external audit tooling. +// Locale-independent (YYYY-MM-DD); invisible to users; greppable from +// the deployed HTML. All three flow through getEffectiveVerifiedDate +// with a source argument; see src/lib/cloudflare-facts.ts for the +// env-var → repo-variable → JSON fallback chain (G D.12). const verifiedAtEffective = getEffectiveVerifiedDate(cloudflareFacts).slice(0, 10); -const verifiedAtDpf = cloudflareFacts.dpf.verified_at.slice(0, 10); -const verifiedAtCwa = cloudflareFacts.cwa_retention.verified_at.slice(0, 10); +const verifiedAtDpf = getEffectiveVerifiedDate(cloudflareFacts, 'dpf').slice(0, 10); +const verifiedAtCwa = getEffectiveVerifiedDate(cloudflareFacts, 'cwa').slice(0, 10); const tocItems = [ { num: '1', label: 'Verantwortlicher' }, diff --git a/src/pages/en/privacy.astro b/src/pages/en/privacy.astro index 9fa6c65..3d7f9aa 100644 --- a/src/pages/en/privacy.astro +++ b/src/pages/en/privacy.astro @@ -25,13 +25,14 @@ const description = const lastUpdated = 'April 2026'; const cwaMonths = cloudflareFacts.cwa_retention.aggregated_retention_months; const verifiedHuman = formatLocaleDate(getEffectiveVerifiedDate(cloudflareFacts), 'en'); -// ISO-date markers for the verifier workflow's post-deploy smoke step -// (mode #8 v1). Locale-independent; invisible to users; greppable from -// the deployed HTML. Format: YYYY-MM-DD. See -// .github/workflows/verify-cloudflare-facts.yml ("Post-deploy smoke"). +// Hidden ISO-date markers for diagnostics / external audit tooling. +// Locale-independent (YYYY-MM-DD); invisible to users; greppable from +// the deployed HTML. All three flow through getEffectiveVerifiedDate +// with a source argument; see src/lib/cloudflare-facts.ts for the +// env-var → repo-variable → JSON fallback chain (G D.12). const verifiedAtEffective = getEffectiveVerifiedDate(cloudflareFacts).slice(0, 10); -const verifiedAtDpf = cloudflareFacts.dpf.verified_at.slice(0, 10); -const verifiedAtCwa = cloudflareFacts.cwa_retention.verified_at.slice(0, 10); +const verifiedAtDpf = getEffectiveVerifiedDate(cloudflareFacts, 'dpf').slice(0, 10); +const verifiedAtCwa = getEffectiveVerifiedDate(cloudflareFacts, 'cwa').slice(0, 10); const tocItems = [ { num: '1', label: 'Controller' },