feat(analytics): GA4 with secret-managed gtag + fork-safety (#198)
Closes #182. Closes #191. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Juan Manuel Servera committed
May 25, 2026 at 22:33 UTC
3ba06d98d8056e19632ba188274966a5d50f5e0d
7 files changed
+69
.github/workflows/deploy-site.yml
+1
@@ -30,6 +30,7 @@ jobs:
30
runs-on: ubuntu-latest
31
env:
32
HUGO_VERSION: 0.161.1
33
+ HUGO_PARAMS_GA_MEASUREMENT_ID: ${{ secrets.GA_MEASUREMENT_ID }}
34
steps:
35
- name: Check out repository
36
uses: actions/checkout@v4
.squad/agents/bender/history.md
+1
@@ -14,3 +14,4 @@
14
- Hugo's `ignoreFiles` config is overridden by explicit `[module.mounts]` declarations; use `excludeFiles` on the mount definition to exclude files from mounted directories (W21 rescue, PR #167).
15
- GitHub Actions schedule events do not have an `inputs` object; use `!inputs.X` instead of `github.event.inputs.X == ''` to safely check optional manual inputs without breaking cron triggers (critical fix, PR #164).
16
- Deploy pipeline should hydrate previous-week content/data from publish branch before Hugo build to prevent main/publish divergence and preserve existing data integrity (PR #164, W21 rescue architectural fix).
17
+- Fork-safe deploy secrets should default to empty in Hugo config and be injected via `HUGO_PARAMS_*` environment overrides so forks render safe defaults without inherited maintainer secrets (GA4 PR #182/#191).
.squad/decisions/inbox/bender-ga4-fork-safety.md
new
+24
@@ -0,0 +1,24 @@
1
+# Decision: GA4 fork-safe secret injection
2
+
3
+**Date:** 2026-05-25T22:30:00+02:00
4
+**Author:** Bender (Crawler/CI)
5
+**Status:** Proposed
6
+
7
+## Context
8
+
9
+SquadScope needs GA4 analytics for the upstream site, but forks must not silently report traffic to the maintainer's GA property. Repository secrets are not inherited by forks, so analytics must depend on an explicitly provided secret and render nothing when absent.
10
+
11
+## Decision
12
+
13
+Use a secret-default-empty pattern: Hugo config defines `params.ga_measurement_id = ""`, while the Pages deploy workflow injects `${{ secrets.GA_MEASUREMENT_ID }}` through `HUGO_PARAMS_GA_MEASUREMENT_ID`. Hugo maps that environment key to `params.ga.measurement.id`, and the analytics partial renders GA4 only when either config path is non-empty. The rendered scripts are marked with `data-cc-category="analytics"` so Cookie Consent v3 can load them only after analytics consent.
14
+
15
+## Rationale
16
+
17
+The empty config default is safe for forks and local builds. The environment override keeps the maintainer measurement ID out of source control while still enabling analytics in the upstream deployment. Consent-category script tagging keeps analytics dormant until the consent integration activates the analytics category.
18
+
19
+## Impact
20
+
21
+- Upstream deploys can enable GA4 by setting `GA_MEASUREMENT_ID`.
22
+- Forks build without analytics by default.
23
+- Maintainers can opt out by deleting the secret.
24
+- Cookie consent integration can activate the tagged scripts without changing the GA4 partial.
docs/setup-secrets.md
new
+27
@@ -0,0 +1,27 @@
1
+# Setup secrets
2
+
3
+## Required secrets
4
+
5
+### `GA_MEASUREMENT_ID`
6
+
7
+SquadScope uses `GA_MEASUREMENT_ID` to enable Google Analytics 4 on the deployed Hugo site.
8
+
9
+Set it on the upstream repository with:
10
+
11
+```bash
12
+gh secret set GA_MEASUREMENT_ID --body "G-XXXXXXXX"
13
+```
14
+
15
+## Fork-safety behavior
16
+
17
+`hugo.toml` defaults `params.ga_measurement_id` to an empty string. The deploy workflow maps the repository secret to `HUGO_PARAMS_GA_MEASUREMENT_ID`; Hugo exposes that environment override as `params.ga.measurement.id`, and the analytics partial uses it only when the secret exists.
18
+
19
+Forks do not inherit repository secrets, so fork builds render with no analytics by default. This is intentional: forks must not silently send traffic to the maintainer's GA property.
20
+
21
+## Opting out
22
+
23
+Maintainers can disable analytics entirely by unsetting the repository secret:
24
+
25
+```bash
26
+gh secret delete GA_MEASUREMENT_ID
27
+```
hugo.toml
+1
@@ -20,6 +20,7 @@ rssLimit = 20
20
term = ['HTML', 'RSS']
21
22
[params]
23
+ ga_measurement_id = ""
24
defaultTheme = 'auto'
25
disableSpecial1stPost = true
26
ShowReadingTime = true
layouts/partials/analytics.html
new
+14
@@ -0,0 +1,14 @@
1
+{{- $ga := index .Site.Params "ga" | default dict }}
2
+{{- $measurement := index $ga "measurement" | default dict }}
3
+{{- $envGAID := index $measurement "id" | default "" }}
4
+{{- $configGAID := index .Site.Params "ga_measurement_id" | default "" }}
5
+{{- $gaID := or $configGAID $envGAID }}
6
+{{- with $gaID }}
7
+<script type="text/plain" data-cc-category="analytics" async data-src="https://www.googletagmanager.com/gtag/js?id={{ . | urlquery }}"></script>
8
+<script type="text/plain" data-cc-category="analytics">
9
+ window.dataLayer = window.dataLayer || [];
10
+ function gtag(){dataLayer.push(arguments);}
11
+ gtag('js', new Date());
12
+ gtag('config', '{{ . }}');
13
+</script>
14
+{{- end }}
layouts/partials/head.html
+1
@@ -190,6 +190,7 @@
190
{{- end }}
191
192
{{- partial "extend_head.html" . -}}
193
+{{- partial "analytics.html" . -}}
194
195
{{- /* Misc */}}
196
{{- if hugo.IsProduction | or (eq site.Params.env "production") }}