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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,13 @@ jobs:
if: matrix.python-version == '3.14'
run: git ls-files -z | xargs -0 uv run --frozen detect-secrets-hook --baseline .secrets.baseline

# Backstop only. detect-secrets knows credentials, not business
# identifiers, and CI runs after a push has already made the repo public.
# The pre-commit hook is the gate that prevents publication.
- name: Scan tracked files for real account identifiers
if: matrix.python-version == '3.14'
run: python3 scripts/check_identifiers.py

- name: Test
run: uv run --frozen pytest --cov=google_ads_cli --cov-report=term-missing --cov-fail-under=55

Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ __pycache__/
.venv/
secrets/
.secrets/
.private-values
google-ads.yaml
google-ads*.yaml
credentials.json
Expand Down
32 changes: 32 additions & 0 deletions .identifier-allowlist.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Synthetic identifiers approved for docs, tests, and examples.
#
# `scripts/check_identifiers.py` fails on any 8+ digit number, grouped ID, or
# email address that is not listed here. Adding a line is deliberately a
# reviewable act: that is the moment to ask "is this value real?".
#
# Rule of thumb: only obviously-fake values belong here. If a number came out of
# a live account, do not allowlist it — replace it.

# --- Placeholder account identifiers used across README, skill docs, tests ---
1234567890
123456789
111222333444
555000111222
555000333444

# --- Emails ---
noreply@anthropic.com

# --- Placeholders inherited from existing docs/tests ---
1111111111
2222222222
3333333333
987654321
000000000
8888888888

# --- Synthetic micros amounts used in tests (value * 1_000_000) ---
1000000000
400000000
150000000
50000000
30 changes: 30 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Local gates. This repository is public and pushes happen from a personal
# machine, so CI runs *after* the data would already be published — git history
# keeps a leaked value even if a later commit removes it. These hooks are
# therefore the gate that actually matters.
#
# Install once: uv run pre-commit install
repos:
- repo: local
hooks:
- id: no-real-identifiers
name: Block real account identifiers
entry: python3 scripts/check_identifiers.py
language: system
pass_filenames: true
require_serial: true

- repo: https://github.com/Yelp/detect-secrets
rev: v1.5.0
hooks:
- id: detect-secrets
name: Scan for credentials
args: ["--baseline", ".secrets.baseline"]
exclude: ^(\.secrets\.baseline|uv\.lock)$

- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.14.5
hooks:
- id: ruff-check
args: ["--fix"]
- id: ruff-format
27 changes: 27 additions & 0 deletions .private-values.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Copy to `.private-values` (gitignored) and list the REAL values from the
# accounts you operate. `scripts/check_identifiers.py` hard-fails if any of them
# appears in a tracked file.
#
# This file is the denylist half of the guard. It only catches values you
# remembered to write down — the allowlist half (.identifier-allowlist.txt)
# is what catches the ones you forgot. Keep both.
#
# One value per line. Lines starting with # are ignored.
# Never commit the filled-in `.private-values` file.

# --- Account identifiers ---
# 1234567890 # customer ID
# 12345678901 # campaign ID
# 123456789012 # ad group ID / ad ID / asset ID

# --- People ---
# someone@example.com

# --- Billing ---
# 1234-5678-9012-3456 # payments account ID
# 2830.19 # a real balance figure

# --- Anything else that identifies the business ---
# Live ad headlines and descriptions currently serving
# Internal creative naming prefixes
# Account or client names
36 changes: 34 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,47 @@ Notable changes to this project are documented here. The format is based on

### Added

- CI secret scanning for all tracked files
- Targeted diagnostics for wrong OAuth users, expired OAuth grants, and transport failures
- `scripts/check_identifiers.py` blocks real account identifiers from entering this public
repository, plus a `pre-commit` config that runs it before every commit. `detect-secrets`
recognizes credentials, not business identifiers — a real customer ID, account balance, or
live ad headline is invisible to it. The check pairs a gitignored `.private-values`
denylist with an `.identifier-allowlist.txt` allowlist, so identifiers nobody thought to
list are caught too.

## [0.2.0] - 2026-08-04

### Added

- `gads ads assets AD_ID` reads an App Ad's real asset list from `app_ad.*`, with slot
fill and per-orientation coverage. `ad_group_ad_asset_view` retains historical
associations and can report more assets than the ad actually carries, so it is not
used as the source of truth.
- `gads ads set-assets AD_ID` edits App Ad assets in place. App Ad asset fields are
whole-field replacements, so the command reads current assets first and applies an
`--add-*`/`--remove-*` delta on top; per-ad-group caps, duplicates, removing an absent
asset, and stripping every visual asset are all rejected before the API is called.
- `gads billing show` reports account funding, remaining balance, and spend runway.
`account_budget` returns the net spendable amount, so `--tax-rate` prints a
gross-equivalent column that reconciles with the web UI's "Available funds".
- `gads changes list` surfaces `change_event` history (who changed what, when), supplying
the bounded date window and `LIMIT` the resource requires.
- Report presets `assets` (per-asset performance labels), `network` (Search / YouTube /
Display / Discover split), and `daily-campaign`.

### Changed

- CI secret scanning for all tracked files
- Targeted diagnostics for wrong OAuth users, expired OAuth grants, and transport failures
- OAuth login always shows Google's account picker to prevent accidental account reuse
- macOS uses the native DNS resolver for more reliable operation through VPN/TUN networks
- Setup documentation now distinguishes MCC, OAuth user, OAuth client, and target customer

### Notes

- Promotional account credits, SKAdNetwork reports, and Google's own per-orientation Ad
Strength breakdown for App ads are not exposed by the Google Ads API and remain
web-UI-only. `asset_group.asset_coverage` is Performance Max only.

## [0.1.0] - 2026-07-28

### Added
Expand Down
32 changes: 32 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,37 @@ uv run gads --version

Create a branch from `main` and keep each pull request focused on one change.

## Install the local gates first

This repository is public. A value committed here stays in git history even after a later
commit removes it, so the gate that matters runs **before** the commit, not in CI:

```bash
cp .private-values.example .private-values # then fill in YOUR real account values
uv run pre-commit install
```

`.private-values` is gitignored and must never be committed.

## Never put real account data in the repository

Examples, tests, and documentation must use synthetic identifiers only. This includes
customer/campaign/ad group/ad/asset IDs, account budget and billing IDs, payments account
numbers, balances, emails, live ad copy, and internal creative naming.

`detect-secrets` does not help here — it recognizes credentials, not business identifiers.
A real customer ID is just a ten-digit number to it. `scripts/check_identifiers.py` covers
that gap with two rules:

- **Denylist** — anything in `.private-values` fails. Precise, but only catches what someone
remembered to list.
- **Allowlist** — every 8+ digit number, grouped ID, and email must appear in
`.identifier-allowlist.txt`. This is the rule that catches values nobody knew to list yet.

Adding a line to `.identifier-allowlist.txt` is intentionally a reviewable act: that line is
where a reviewer asks "is this value real?". Only allowlist obviously synthetic values. If a
number came out of a live account, replace it instead.

## Checks

Run all checks before opening a pull request:
Expand All @@ -35,6 +66,7 @@ uv run ruff format .
uv run ruff check .
uv run ruff format --check .
uv run pytest --cov=google_ads_cli --cov-fail-under=55
python3 scripts/check_identifiers.py
uv build
```

Expand Down
64 changes: 63 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,14 @@ reviewable action.
- Discover directly accessible accounts and recursive manager hierarchies
- Run and validate arbitrary GAQL; discover valid Google Ads fields
- Output tables, JSON, JSONL, or CSV
- Run curated account, campaign, ad group, ad, daily, and conversion reports
- Run curated account, campaign, ad group, ad, daily, conversion, per-asset, and ad-network reports
- Inspect campaigns, budgets, ad groups, ads, assets, and conversion actions
- Pause, enable, or remove campaigns and update daily budgets
- Create an atomic App Campaign with budget, criteria, ad group, app ad, and assets
- Upload image assets and create YouTube assets
- Inspect an App Ad's real assets and edit them in place without rebuilding the ad group
- Report account funding, tax-adjusted balance, and spend runway
- Review account change history (who changed what, when)
- Resolve geographic and language constants
- Apply versioned `GoogleAdsService.Mutate` YAML manifests
- Keep a non-secret JSONL audit trail with deterministic plan hashes
Expand Down Expand Up @@ -329,6 +332,63 @@ gads assets create-youtube VIDEO_ID --name "US Demo 15s"
Google Ads assets are generally immutable. Stop one from serving by changing the ad or
association that uses it.

## Edit App Ad assets in place

An App Ad cannot be duplicated within its ad group or removed, but its **asset fields can
be updated**. Rebuilding the ad group for every creative change is unnecessary — and it
leaves undeletable ads behind.

Read what the ad actually carries, with slot fill and orientation coverage:

```bash
gads ads assets 111222333444
```

`ad_group_ad_asset_view` is *not* the source of truth: it retains historical associations
and can report more assets than the ad has. These commands read `app_ad.*` instead.

Asset fields are whole-field replacements, so an `update_mask` on `app_ad.images` drops
anything left out of the payload. `set-assets` reads the current assets first and applies
your delta on top:

```bash
gads ads set-assets 111222333444 --add-video 555000111222 --remove-video 555000333444
gads ads set-assets 111222333444 --add-video 555000111222 --validate-only
gads ads set-assets 111222333444 --add-video 555000111222 --execute
```

Use `--set-image`, `--set-video`, `--set-headline`, or `--set-description` to replace a
whole list. Per-ad-group caps, duplicate assets, removing an asset the ad does not have,
and stripping every visual asset are all rejected before anything reaches the API.

Each change triggers an ad review, so batch creative edits into one call.

## Check funding and runway

```bash
gads billing show
gads billing show --tax-rate 0.06
```

`account_budget` reports the **net** spendable amount. A prepay top-up shown as a gross
figure in the web UI arrives here already divided by the local tax rate, so the real
runway is shorter than the UI number suggests — `--tax-rate` prints a gross-equivalent
column to reconcile the two. Runway defaults to the summed daily budgets of enabled
campaigns; override it with `--daily-budget`.

Promotional credits ("spend X, get X") are not exposed by the API at all.

## Review change history

```bash
gads changes list --days 14
gads changes list --days 7 --resource-type CAMPAIGN_BUDGET
gads changes list --campaign-id 123456789 --limit 500
```

`change_event` retains 30 days, requires a bounded date window, and requires a `LIMIT`;
this command supplies all three.

## Use generic mutation manifests

Dedicated commands cover common operations. The versioned manifest escape hatch covers
Expand Down Expand Up @@ -444,6 +504,8 @@ Do not paste credentials into an issue. See [SECURITY.md](SECURITY.md) for priva
- Mutations do not add blanket retries because Google Ads mutates do not provide a universal
idempotency key.
- Unit tests require no Google credentials and never contact or mutate a Google Ads account.
- A pre-commit hook blocks real account identifiers — IDs, balances, emails, live ad copy —
from entering this public repository. See [CONTRIBUTING.md](CONTRIBUTING.md).

Review [SECURITY.md](SECURITY.md) before using the CLI in production. You remain responsible
for account permissions, policy compliance, spend, and every command executed with
Expand Down
Loading