Skip to content

Commit 7b2cc9c

Browse files
committed
Add API Discovery diff mechanism for detecting GA4 API changes
Weekly CI workflow fetches live Discovery documents for all 4 GA4 APIs (admin v1beta/v1alpha, data v1beta/v1alpha), diffs them against stored snapshots, and opens a GitHub Issue when changes are detected. - Custom semantic diff script (pure stdlib, zero new deps) that detects new/removed methods, parameter changes, schema changes, enum updates, and deprecation flags - Baseline snapshots seeded from current API revisions - GitHub Actions workflow on weekly schedule + manual trigger - 35 unit tests covering all diff logic
1 parent 19795ed commit 7b2cc9c

7 files changed

Lines changed: 18658 additions & 0 deletions

File tree

.api-snapshots/analyticsadmin_v1alpha.json

Lines changed: 9388 additions & 0 deletions
Large diffs are not rendered by default.

.api-snapshots/analyticsadmin_v1beta.json

Lines changed: 3568 additions & 0 deletions
Large diffs are not rendered by default.

.api-snapshots/analyticsdata_v1alpha.json

Lines changed: 2688 additions & 0 deletions
Large diffs are not rendered by default.

.api-snapshots/analyticsdata_v1beta.json

Lines changed: 2306 additions & 0 deletions
Large diffs are not rendered by default.

.github/workflows/api-watch.yml

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
name: API Watch
2+
3+
on:
4+
schedule:
5+
- cron: "0 8 * * 1" # Monday 08:00 UTC
6+
workflow_dispatch:
7+
8+
permissions:
9+
issues: write
10+
contents: read
11+
12+
jobs:
13+
check-api-changes:
14+
runs-on: ubuntu-latest
15+
steps:
16+
- uses: actions/checkout@v4
17+
18+
- uses: actions/setup-python@v5
19+
with:
20+
python-version: "3.12"
21+
22+
- name: Check for API changes
23+
id: diff
24+
run: python scripts/check_api_changes.py
25+
26+
- name: Create GitHub Issue
27+
if: steps.diff.outputs.has_changes == 'true'
28+
uses: actions/github-script@v7
29+
with:
30+
script: |
31+
await github.rest.issues.create({
32+
owner: context.repo.owner,
33+
repo: context.repo.repo,
34+
title: 'GA4 API changes detected',
35+
body: `${{ steps.diff.outputs.issue_body }}`,
36+
labels: ['api-change']
37+
});

scripts/check_api_changes.py

Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
#!/usr/bin/env python3
2+
"""Check GA4 Discovery documents for API changes.
3+
4+
Fetches live Discovery JSON documents for the 4 GA4 APIs, compares them
5+
against stored snapshots, and outputs a structured semantic diff. Designed
6+
to run in GitHub Actions (writes to $GITHUB_OUTPUT) but works locally too.
7+
8+
Usage:
9+
python scripts/check_api_changes.py # Check for changes
10+
python scripts/check_api_changes.py --update # Update snapshots
11+
"""
12+
13+
from __future__ import annotations
14+
15+
import argparse
16+
import json
17+
import os
18+
import sys
19+
import uuid
20+
from datetime import datetime, timezone
21+
from pathlib import Path
22+
from urllib.error import URLError
23+
from urllib.request import urlopen
24+
25+
DISCOVERY_URLS = {
26+
"analyticsadmin_v1beta": "https://analyticsadmin.googleapis.com/$discovery/rest?version=v1beta",
27+
"analyticsadmin_v1alpha": "https://analyticsadmin.googleapis.com/$discovery/rest?version=v1alpha",
28+
"analyticsdata_v1beta": "https://analyticsdata.googleapis.com/$discovery/rest?version=v1beta",
29+
"analyticsdata_v1alpha": "https://analyticsdata.googleapis.com/$discovery/rest?version=v1alpha",
30+
}
31+
32+
DEFAULT_SNAPSHOT_DIR = Path(__file__).resolve().parent.parent / ".api-snapshots"
33+
34+
35+
def fetch_discovery(url: str) -> dict:
36+
"""Fetch a Discovery document from the given URL."""
37+
try:
38+
with urlopen(url, timeout=30) as resp:
39+
return json.loads(resp.read())
40+
except (URLError, OSError) as e:
41+
raise RuntimeError(f"Failed to fetch {url}: {e}") from e
42+
43+
44+
def load_snapshot(path: Path) -> dict:
45+
"""Load a snapshot JSON file. Returns empty dict if missing."""
46+
if not path.exists():
47+
return {}
48+
return json.loads(path.read_text())
49+
50+
51+
def save_snapshot(path: Path, doc: dict) -> None:
52+
"""Write a Discovery document as a formatted JSON snapshot."""
53+
path.write_text(json.dumps(doc, indent=2) + "\n")
54+
55+
56+
def flatten_methods(
57+
resources: dict, prefix: str = ""
58+
) -> dict[str, dict]:
59+
"""Recursively flatten the resources tree into a flat method map.
60+
61+
Returns e.g. {"properties.dataStreams.list": {method_def}, ...}
62+
"""
63+
methods = {}
64+
for resource_name, resource in resources.items():
65+
resource_path = f"{prefix}{resource_name}" if not prefix else f"{prefix}.{resource_name}"
66+
for method_name, method_def in resource.get("methods", {}).items():
67+
methods[f"{resource_path}.{method_name}"] = method_def
68+
if "resources" in resource:
69+
methods.update(flatten_methods(resource["resources"], resource_path))
70+
return methods
71+
72+
73+
def diff_methods(old_methods: dict, new_methods: dict) -> list[str]:
74+
"""Diff two flat method maps. Returns list of change descriptions."""
75+
changes = []
76+
old_keys = set(old_methods)
77+
new_keys = set(new_methods)
78+
79+
for key in sorted(new_keys - old_keys):
80+
http = new_methods[key].get("httpMethod", "?")
81+
changes.append(f"Added method `{key}` ({http})")
82+
83+
for key in sorted(old_keys - new_keys):
84+
changes.append(f"Removed method `{key}`")
85+
86+
for key in sorted(old_keys & new_keys):
87+
old_m = old_methods[key]
88+
new_m = new_methods[key]
89+
90+
if old_m.get("httpMethod") != new_m.get("httpMethod"):
91+
changes.append(
92+
f"Method `{key}`: httpMethod changed "
93+
f"`{old_m.get('httpMethod')}` -> `{new_m.get('httpMethod')}`"
94+
)
95+
96+
old_params = old_m.get("parameters", {})
97+
new_params = new_m.get("parameters", {})
98+
for p in sorted(set(new_params) - set(old_params)):
99+
req = " (required)" if new_params[p].get("required") else ""
100+
changes.append(f"Method `{key}`: new parameter `{p}`{req}")
101+
for p in sorted(set(old_params) - set(new_params)):
102+
changes.append(f"Method `{key}`: removed parameter `{p}`")
103+
104+
if not old_m.get("deprecated") and new_m.get("deprecated"):
105+
changes.append(f"Method `{key}`: now deprecated")
106+
107+
return changes
108+
109+
110+
def diff_schemas(old_schemas: dict, new_schemas: dict) -> list[str]:
111+
"""Diff two schema maps. Returns list of change descriptions."""
112+
changes = []
113+
old_keys = set(old_schemas)
114+
new_keys = set(new_schemas)
115+
116+
for key in sorted(new_keys - old_keys):
117+
changes.append(f"Added schema `{key}`")
118+
119+
for key in sorted(old_keys - new_keys):
120+
changes.append(f"Removed schema `{key}`")
121+
122+
for key in sorted(old_keys & new_keys):
123+
old_s = old_schemas[key]
124+
new_s = new_schemas[key]
125+
126+
old_props = old_s.get("properties", {})
127+
new_props = new_s.get("properties", {})
128+
129+
for p in sorted(set(new_props) - set(old_props)):
130+
ptype = new_props[p].get("type", new_props[p].get("$ref", "unknown"))
131+
changes.append(f"Schema `{key}`: new property `{p}` ({ptype})")
132+
for p in sorted(set(old_props) - set(new_props)):
133+
changes.append(f"Schema `{key}`: removed property `{p}`")
134+
135+
for p in sorted(set(old_props) & set(new_props)):
136+
old_p = old_props[p]
137+
new_p = new_props[p]
138+
139+
old_type = old_p.get("type")
140+
new_type = new_p.get("type")
141+
if old_type != new_type:
142+
changes.append(
143+
f"Schema `{key}`: property `{p}` type changed "
144+
f"`{old_type}` -> `{new_type}`"
145+
)
146+
147+
old_enum = set(old_p.get("enum", []))
148+
new_enum = set(new_p.get("enum", []))
149+
for v in sorted(new_enum - old_enum):
150+
changes.append(
151+
f"Schema `{key}`: new enum value `{v}` on `{p}`"
152+
)
153+
for v in sorted(old_enum - new_enum):
154+
changes.append(
155+
f"Schema `{key}`: removed enum value `{v}` from `{p}`"
156+
)
157+
158+
if not old_p.get("deprecated") and new_p.get("deprecated"):
159+
changes.append(f"Schema `{key}`: property `{p}` now deprecated")
160+
161+
return changes
162+
163+
164+
def diff_document(old_doc: dict, new_doc: dict) -> list[str]:
165+
"""Produce a semantic diff between two Discovery documents."""
166+
changes = []
167+
168+
old_rev = old_doc.get("revision", "unknown")
169+
new_rev = new_doc.get("revision", "unknown")
170+
if old_rev != new_rev:
171+
changes.append(f"Revision changed: `{old_rev}` -> `{new_rev}`")
172+
173+
old_methods = flatten_methods(old_doc.get("resources", {}))
174+
new_methods = flatten_methods(new_doc.get("resources", {}))
175+
changes.extend(diff_methods(old_methods, new_methods))
176+
177+
changes.extend(diff_schemas(
178+
old_doc.get("schemas", {}),
179+
new_doc.get("schemas", {}),
180+
))
181+
182+
return changes
183+
184+
185+
def format_markdown(all_changes: dict[str, list[str]]) -> str:
186+
"""Format all API changes as a markdown document."""
187+
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
188+
lines = [
189+
"## GA4 API Changes Detected",
190+
"",
191+
f"**Date:** {today}",
192+
"",
193+
]
194+
195+
for api_name, changes in all_changes.items():
196+
display_name = api_name.replace("_", " ")
197+
if changes:
198+
lines.append(f"### {display_name}")
199+
for change in changes:
200+
lines.append(f"- {change}")
201+
lines.append("")
202+
else:
203+
lines.append(f"### {display_name}")
204+
lines.append("No changes detected.")
205+
lines.append("")
206+
207+
lines.append("---")
208+
lines.append("> To update snapshots: `python scripts/check_api_changes.py --update`")
209+
lines.append("")
210+
return "\n".join(lines)
211+
212+
213+
def set_github_output(key: str, value: str) -> None:
214+
"""Write a key-value pair to $GITHUB_OUTPUT (no-op outside CI)."""
215+
output_file = os.environ.get("GITHUB_OUTPUT")
216+
if not output_file:
217+
return
218+
with open(output_file, "a") as f:
219+
if "\n" in value:
220+
delimiter = f"ghadelimiter_{uuid.uuid4()}"
221+
f.write(f"{key}<<{delimiter}\n{value}\n{delimiter}\n")
222+
else:
223+
f.write(f"{key}={value}\n")
224+
225+
226+
def main(argv: list[str] | None = None) -> None:
227+
parser = argparse.ArgumentParser(description=__doc__)
228+
parser.add_argument(
229+
"--update",
230+
action="store_true",
231+
help="Fetch and save new snapshots instead of diffing",
232+
)
233+
parser.add_argument(
234+
"--snapshot-dir",
235+
type=Path,
236+
default=DEFAULT_SNAPSHOT_DIR,
237+
help="Directory containing snapshot files",
238+
)
239+
args = parser.parse_args(argv)
240+
241+
snapshot_dir: Path = args.snapshot_dir
242+
snapshot_dir.mkdir(parents=True, exist_ok=True)
243+
244+
if args.update:
245+
for api_name, url in DISCOVERY_URLS.items():
246+
print(f"Fetching {api_name}...")
247+
doc = fetch_discovery(url)
248+
save_snapshot(snapshot_dir / f"{api_name}.json", doc)
249+
print(f" Saved {api_name}.json (revision: {doc.get('revision', '?')})")
250+
print(f"\nUpdated {len(DISCOVERY_URLS)} snapshots in {snapshot_dir}")
251+
return
252+
253+
# Check mode (default)
254+
all_changes: dict[str, list[str]] = {}
255+
has_any_changes = False
256+
257+
for api_name, url in DISCOVERY_URLS.items():
258+
snapshot_path = snapshot_dir / f"{api_name}.json"
259+
old_doc = load_snapshot(snapshot_path)
260+
if not old_doc:
261+
print(f"Warning: No snapshot found for {api_name}, skipping diff", file=sys.stderr)
262+
all_changes[api_name] = [
263+
f"No snapshot found at `{snapshot_path.name}` — run with `--update` first"
264+
]
265+
has_any_changes = True
266+
continue
267+
268+
print(f"Fetching {api_name}...", file=sys.stderr)
269+
new_doc = fetch_discovery(url)
270+
changes = diff_document(old_doc, new_doc)
271+
all_changes[api_name] = changes
272+
if changes:
273+
has_any_changes = True
274+
275+
markdown = format_markdown(all_changes)
276+
print(markdown)
277+
278+
set_github_output("has_changes", str(has_any_changes).lower())
279+
if has_any_changes:
280+
set_github_output("issue_body", markdown)
281+
282+
283+
if __name__ == "__main__":
284+
main()

0 commit comments

Comments
 (0)