A free, local, open-source macOS app for GAM7, the Google Workspace admin command line. The recurring admin chores get a screen, the big changes show a preview and wait for your confirm, and every change lands in an audit log — with GAM's full reach still underneath and your credentials in the macOS Keychain. Nothing runs in the cloud.
Who it's for: the Google Workspace admin on a Mac — often the one person who runs Workspace for the company — who knows GAM can do it but would rather not type it, or rebuild it from memory, every time.
What it does — the everyday tasks, one screen each:
- Onboard a hire: the account, OU, signature, groups, shared calendars, a setup checklist and a welcome email — one person, or a CSV of them.
- Offboard a leaver: reset the password, revoke access and sign out, turn off forwarding, hand the mailbox to a delegate, set an auto-reply, transfer Drive and calendars, remind the manager.
- Roll out a signature to one person, a group, an OU, a department or everyone, with a live preview.
- Find anyone in the directory; edit title and department (one person or in bulk), delegates, auto-replies and groups; sign out or suspend.
- Share a calendar so it actually appears, and see who has access to any shared calendar.
- Audit: 2SV gaps, inactive accounts, admins and storage in Reports; in the Builder, any of GAM's read commands, filtered down to the rows that reach outside your domains.
How to get it: there is no notarized download yet — you build it from source on your own Mac.
It takes a few minutes and Python 3.10+ (macOS ships 3.9: brew install python@3.13 first); no
Google credentials are needed to build:
git clone https://github.com/goetchstone/gamgui.git && cd gamgui
make setup && make gam && make run # `make app` builds a standalone GamGUI.appDetails in Build from source.
Your first five minutes:
- Setup imports an existing GAM install into the Keychain (or walks you through a new one), then shows the one step Google makes you do by hand — domain-wide delegation, with a pre-filled Admin-console link — and a Verify.
- Look around Home, Users and Reports. Reading never changes anything, and nothing in your domain changes until you start a change yourself; the big ones — onboarding, offboarding, bulk runs, deletes — show a preview first and wait for your confirm.
- Before you rely on a write, check Live verification status — some writes have run against a real domain, others only offline.
- Read the user guide: setup and each everyday task, with what to check before and after.
Actively developed and used against live Google Workspace tenants. Working today:
- Setup wizard — either import an existing GAM install (it auto-detects
$GAMCFGDIR,~/.gam, and its own setup dir, shows which credential files each one holds, and moves them into the Keychain) or follow the guided fresh GAM project / OAuth flow; then the manual domain-wide-delegation step — the scopes GamGUI uses and an Admin-console link with the client ID and scopes filled in, shown up front — and a verify. The domain is filled in from the admin email. - Users — a fast, cached list (search name, email, title, department or org unit; sort any column; 15–50 a page; opening a person and coming back keeps your place) and detail, profile editing (title/department — location is shown but not editable) with a bulk "set department" tool, mailbox delegates, vacation responders, group membership, per-user calendar sharing (grant/revoke access to that person's own calendar), sign out everywhere, and a guarded suspend.
- Gmail signatures — a scoped designer with variables, saved templates, a live preview, and bulk apply with a live per-user ✓/✗ feed as each signature gets set.
- Groups — find a group, see its members and their roles, add someone with a role, remove a member after a confirm step.
- Calendars — find any shared calendar by name. The first time, click Build index on the Calendars screen: a one-time background scan of every user's calendars (minutes on a large tenant, with live progress). After that, name search is instant and served entirely from the local index — no live domain scan — and a Rebuild button refreshes it (deleting a calendar in the app drops it from the index immediately). From there: list every room/resource in the domain, list one person's calendars with their access role on each, see who has access and grant or revoke it at a chosen role. Sharing also makes the calendar actually appear, which an ACL alone does not do: a person is auto-subscribed, and a group is expanded and subscribed member by member as a background job with live per-member progress (a group of ten or more asks first, naming the count) — so nobody is left saying "you shared it but I can't see it", and you get the list of anyone it couldn't be added for. Also: search a calendar's events, and remove a stray event or an entire orphaned secondary calendar.
- Lifecycle — a guided offboarding routine (reset password → revoke access & sign out →
turn off forwarding → delegate → auto-responder → transfer Drive & calendars → remove from
everyone's calendars → reminder on the manager), with a live preview of the generated auto-reply
and the exact
gamcommand each step will run. Both addresses are checked against the directory, Run executes exactly what was previewed, a failed step stops the steps that rely on it, and a re-run can skip the steps that already succeeded. - Onboarding — editable role templates that set up a new hire: create the account (its one-time
password goes on a copyable, printable sheet, or Google emails it via GAM's
notify), the role's OU and signature, its groups and shared calendars, a Google Tasks checklist for whoever does the setup, and a templated welcome email — for one person, or a CSV of hires as a background job. - Command Builder — browse/search the full categorized GAM catalog (1,075 commands, 538 of them runnable: 26 hand-curated, the only ones that can change anything, plus 512 read-only commands derived from GAM's grammar); curated commands get typed slots, drag-a-user targeting, a guarded preview → run, linear sequencing, and results export (CSV download or straight to a Google Sheet). Result tables slice like grep — a live row filter plus an External only toggle that keeps just the rows referencing addresses outside your domains (primary, secondaries, aliases), both over every row of the result and downloadable as CSV — and clicking any address in a result offers pre-filled follow-ups (delegates, forwarding, vacation, signature, title/dept, reset password, suspend, delete, group add/remove).
- Reports — 2SV gaps, inactive accounts, admins, missing recovery, suspended accounts, and directory completeness (missing title/department/phone/location), plus a storage & mail usage panel: top users by Drive/Gmail storage with that day's sent/received counts, from the Reports API (which lags a few days).
- Audit viewer — every guarded write, searchable with a failures-only filter and CSV export.
- Jobs tray — every background run (signatures, bulk department, onboarding CSV, offboarding, calendar fan-out, Builder sequence, calendar index) is listed in the header from any page, with its progress, its result and a link back to its panel. A running loop has a Stop: it finishes the one in hand, then ends, saying how many it didn't attempt (an offboarding asks first, and marks the steps after it "not run: stopped"). A signature apply or bulk department run that failed for some people offers Retry the N that failed: the same preview and confirm step, for just them (not past the 200 failures a job keeps). Jobs live in memory: quitting the app ends them.
Destructive actions are guarded — but check what has actually been proven live. Suspend, account delete, calendar/event delete, a Builder data transfer, the offboarding routine, and the bulk jobs (department, signatures, onboarding — one hire or a CSV) all run behind a preview → confirmation → audit-logged path (typed for account and calendar delete, a destructive bulk run, and a signature apply to more than 25 people). The server refuses a request that skipped the confirmation — the page asking is not enough — and runs what the preview showed: a form edited after its preview has to be previewed again. Every Builder write runs only from its preview, too. Single-target changes on the Users, Groups and Calendars screens (a delegate, a group member, a calendar share) need no server-checked confirmation — a few, like removing a delegate, ask in the page; a calendar share to a group of ten or more, which subscribes each member, shows the member count and asks first. That guard is well covered by tests; what tests cannot prove is that a given GAM command behaves as expected against a real tenant. See Live verification status for which writes have been confirmed against a production domain and which have not — and run anything marked not yet once on a throwaway user/event/calendar before you rely on it. Account deletion is reversible only within Google's ~20-day window. GamGUI is provided as-is under the MIT License, with no warranty — use at your own risk; you are responsible for what you run against your own tenant.
Every write is audited, so this table comes from real audit logs rather than memory. Confirmed
means the operation has succeeded at least once against a production Google Workspace domain. Not
yet means unproven: the offline suite shows the command matches GAM's grammar and the mock accepts
it, nothing more — run it once on a throwaway user, group or calendar before you rely on it. All
reads are confirmed (a read-only pass over the parsers ships as scripts/acceptance.py; last run
2026-09-25 on GAM 7.48.11, together with setup's scoped check serviceaccount scopes …, print domains
for the Builder's External-only filter, and reading the connected admin and its granted scopes from
oauth2.txt).
| Write | Where | Live |
|---|---|---|
| Create an account | Onboard (one hire or a CSV) | confirmed |
| Tasks checklist (a task list, one task per step) | Onboard | confirmed |
| Welcome email | Onboard | confirmed |
| Set signature | Signatures (bulk), Users, Onboard, Builder | confirmed |
| Set title / department | Users (one person, or bulk set department), Builder | confirmed |
| Add group member | Groups, Users, Onboard, Builder | confirmed (as a member) |
| Remove group member | Groups, Users, Builder | confirmed |
| Add delegate | Users, Offboarding, Builder | confirmed |
| Remove delegate | Users, Builder | confirmed |
| Set vacation (auto-reply) | Users, Offboarding, Builder | confirmed, including the explicit contactsonly false domainonly false and dated start/end the user page sends; offboarding's "starts now, no end" start Started end NotSpecified, not yet |
| Clear vacation | Users, Builder | confirmed |
| Reset password (it runs no sign-out: offboarding's next step does, or Sign out everywhere) | Offboarding, Builder | confirmed |
| Sign out everywhere | Users, Builder | not yet |
Revoke access: app passwords, backup codes, OAuth tokens, and sign out (deprovision signout) |
Offboarding | not yet |
| Suspend / unsuspend | Users (both), Builder (suspend) | not yet |
| Delete an account | Users, Builder | not yet |
| Undelete an account | Builder | not yet |
| Transfer Drive + calendar data | Offboarding, Builder | confirmed as two calls; the single call offboarding now makes (with all: private and shared Drive files) is not yet |
| Remove the leaver from everyone's calendars | Offboarding | not yet |
| Add a calendar event (the manager's reminder) | Offboarding | confirmed; with an invitee it now adds sendupdates all (so they get the invitation email), not yet run live |
| Share a calendar (add an ACL) | Calendars, Users (their own calendar) | confirmed |
| Subscribe someone, so a shared calendar appears | Calendars, Onboard | confirmed |
| Share with a group, subscribing each member | Calendars | not yet — each call it makes is confirmed, the expansion is not |
| Unshare a calendar (remove an ACL) | Calendars, Users | not yet |
| Delete an event | Calendars | not yet |
| Delete a secondary calendar | Calendars | confirmed |
| Forwarding: add an address, forward on / off | Builder (forward off also Offboarding) | not yet |
| Create / delete an alias | Builder | not yet |
| Create a group | Builder | not yet |
| Export a result to a Google Sheet | Builder | not yet |
A new account isn't ready at once. On the first live onboarding (2026-09-30) the account, groups, checklist and welcome email went through, but the signature (1 s after the create) and a shared calendar (7 s after) were refused because Google hadn't finished setting the account up. Onboarding now keeps those steps and retries them for about seven minutes; that retry has not yet run live. A create refused for want of a license now says so.
Offboarding repairs awaiting a live run. Two offboarding bugs were found in real audit logs and
fixed, but the fixes have not themselves run live yet: Drive and calendar are now transferred in a
single data-transfer call (two separate calls collided with a 409 conflict), and "remove from
everyone's calendars" now tolerates the cannotChangeOwnAcl error that used to abort the sweep —
along with a user who has no ACL for the leaver or no Calendar service, and nothing else. That
sweep is one domain-wide call, so it runs under a 1-hour timeout instead of the 2-minute
per-call default; how long it really takes on a large tenant is unmeasured. Revoking the leaver's
access (app passwords, backup codes, connected apps' tokens, sessions) and turning off mail
forwarding are steps of their own that have never run live. How the routine runs —
Run executes exactly the previewed steps, a failed step stops the ones that rely on it, a re-run
skips the steps ticked as done — is proven offline only. The
first live run checklist says
what to check in the preview, what to verify in Google afterwards, and how to recover from a failed
step.
- Local & native — a single bundled
.app; no cloud service, nothing leaves your machine except GAM's own calls to Google (the fonts and scripts are bundled too). The UI is served by a loopback-only local server on a random port, gated by a per-launch token (see Security model). - Secure — secrets live in the macOS Keychain; GAM's plaintext credential files are
materialized into a locked-down temporary directory only for the duration of each
gaminvocation, then wiped. (details) - Easy but powerful — form/table UI for the common painful tasks, full GAM power underneath.
- Connector-ready — built around a connector protocol, so the Google Workspace connector is cleanly isolated and other systems could be added later without touching the UI.
HTMX views → FastAPI routes → Services → Connector protocol → GAMConnector
→ GAMRunner (subprocess)
→ SecretsVault (Keychain) + EphemeralConfig (temp GAMCFGDIR)
Wrapped in a pywebview native window (WKWebView). See CONTRIBUTING.md for the
layout and conventions, and docs/builder-commands.md for the Builder
catalog.
GAM stores credentials as plaintext files (client_secrets.json, oauth2.txt,
oauth2service.json) in its config dir. oauth2service.json can impersonate any user in the
domain and oauth2.txt is effectively an admin password, so GamGUI:
- keeps the canonical copies in the Keychain (
keyring, device-bound, not synced); - materializes them into a
chmod 700temp dir (fileschmod 600) set asGAMCFGDIRonly for eachgamcall; - wipes that dir on completion (success or failure) — and, because "the app quit mid-call" is the
case that actually strands plaintext credentials, also via an
atexithook, a graceful-shutdown timeout that lets in-flight calls unwind (stopping theirgamprocess, and auditing an interrupted write as such), and an owner-PID marker so a later run can collect a directory whose owning process is gone; - writes refreshed OAuth tokens back to the Keychain.
Beyond the credentials themselves:
- The local server is not open to other local processes. It binds loopback on a random port and
requires a per-launch token — and because cookies are not port-scoped (so
SameSitealone would treat every port on127.0.0.1as the same site), it also rejects cross-origin callers outright, and any request whoseHostis not its own loopback port (DNS rebinding). The browser fallback and the length of agamcall have limits — see SECURITY.md → Known limitations. - GAM is never invoked through a shell. Every command is an explicit argv list built by
GAMCommands; user input is always a single list element, never string-interpolated. - Every mutation is guarded and audited —
guard.evaluate()classifies risk and resolves the concrete affected set for a preview,guard.enforce()re-checks the confirmation server-side before the write, and the write is appended to a local audit log. - The vendored
gambinary is checksum-pinned and verified fail-closed. A release asset with no committed pin is refused rather than installed, since a swapped binary would inherit domain-wide impersonation.
Requirements: Python 3.10+ and macOS (to run the native window; the test suite itself runs on Linux too). No Google credentials are needed to build or test.
git clone <repo-url> && cd gamgui
make setup # create .venv, install the hash-locked dev + native-window deps
make gam # vendor the pinned GAM7 binary into gamgui/resources/gam7 (needs network)
make test # offline test suite — uses a mock gam, no binary/credentials required
make lint # ruff (the bug-catching rule set), mypy (core + web) and app.css freshness, as CI enforces
make css # rebuild the UI's Tailwind CSS after a template adds a class (pinned CLI, needs network once)
make run # launch the app (native window; without pywebview, a browser URL — dev only)make setup builds the venv with the newest Python 3.10+ on your PATH (python3.14 down to
python3.10, then python3). macOS's bundled python3 is 3.9, which cannot install GamGUI; with
nothing newer, make setup stops and says how to get one (brew install python@3.13 or the
python.org installer). Point it at a specific interpreter with make setup PYTHON=python3.13.
The venv make setup builds holds exactly what CI tests: it installs requirements/dev.txt and
requirements/app.txt with pip install --require-hashes (first a locked pip, since Python
3.10–3.12 bundle one too old for the install's --build-constraint), then the project itself with
--no-deps. That is the venv make run starts the app from, credentials and all, so a package
swapped on the index fails the install instead. make setup-latest builds the same venv from
pyproject.toml's flexible ranges at their newest, with no hash checks — for trying a dependency
ahead of make lock, not for running against a real domain. To start from nothing, make clean
first: pip leaves a package that is already installed at the pinned version alone.
make help lists all targets. Prefer raw commands? See the setup target in the Makefile for the
locked install, then scripts/fetch_gam.sh, pytest, python -m gamgui.app.
The GAM7 binary is not committed (platform-specific, large) — make gam / scripts/fetch_gam.sh
fetches the pinned, tested version from the official releases and verifies it against the committed
checksum (the pin's source of truth is EXPECTED_GAM_VERSION — see "Staying current with GAM").
make app # PyInstaller -> dist/GamGUI.app (bundles Python + the GAM7 binary; needs network)
build/venv/bin/python scripts/check_app.py dist/GamGUI.app # optional: CI's smoke check of the bundleFor distribution to other Macs you must codesign + notarize the bundle (including the embedded gam binary); running it yourself needs no signing.
Every build is signed with the hardened runtime (scripts/sign_app.sh), so launch-time DYLD_*
variables can't inject code into the app or its bundled gam. Without a stable identity, though, the
signature is ad-hoc, so macOS treats each rebuild as a new identity and
re-prompts for the Keychain on every launch — and "Always Allow" never sticks. The fix (no Apple
Developer account needed — that's only for shipping the app to other people's Macs) is a stable
self-signed code-signing cert named GamGUI Local. Once it exists in your login keychain,
scripts/build_app.sh signs with it automatically, so your one-time Always Allow persists
across launches and rebuilds.
Create it once, either way:
- GUI: Keychain Access → Certificate Assistant → Create a Certificate… → name it
GamGUI Local, Identity Type Self-Signed Root, Certificate Type Code Signing. - CLI:
D=$(mktemp -d) printf '[req]\ndistinguished_name=dn\nx509_extensions=v3\nprompt=no\n[dn]\nCN=GamGUI Local\n[v3]\nbasicConstraints=critical,CA:false\nkeyUsage=critical,digitalSignature\nextendedKeyUsage=critical,codeSigning\n' > "$D/c.cnf" openssl req -x509 -newkey rsa:2048 -keyout "$D/k.pem" -out "$D/c.pem" -days 3650 -nodes -config "$D/c.cnf" openssl pkcs12 -export -inkey "$D/k.pem" -in "$D/c.pem" -out "$D/id.p12" -passout pass:gamgui-local -name "GamGUI Local" security import "$D/id.p12" -P gamgui-local -T /usr/bin/codesign && rm -rf "$D"
Then rebuild (make app). The first launch still asks once per credential — click Always
Allow on each — and you won't be prompted again, even after future rebuilds. (Override the cert
name with CODESIGN_IDENTITY=…. The cert is local and not trusted for distribution by design — it
only quiets your own Keychain.)
The app also caches the three secrets in-process for a sliding window (default 5 min) so a burst of
actions doesn't re-prompt; tune with GAMGUI_SECRET_CACHE_TTL (seconds; 0 disables).
GamGUI pins a tested GAM7 version — EXPECTED_GAM_VERSION in gamgui/core/gam/commands.py, matched by
scripts/fetch_gam.sh. It never auto-upgrades; you bump deliberately, and three guards keep that safe:
- Release-watch (
.github/workflows/gam-watch.yml, weekly) does the mechanical bump when GAM ships a newer version and opens a PR — the asset's GitHub build attestation verified (signed by GAM-team's release workflow onmain, nothing else in their repo), pinned, vendored, catalog regenerated, suite run — in a job with a read-only token; a second job only pushes the branch and opens the PR. It never merges: you approve its CI runs (a PR opened by the workflow's own token doesn't start them), review the changelog, run the live acceptance pass, and merge once branch protection's required checks are green. Automatic pinning is safe because the attestation check gates it — no verified provenance, no PR — so it isn't trust-on-first-use. - Compat check (
gam-compatCI job +tests/test_command_contract.py) asserts every GAM sub-command our builders use still exists in the vendored command reference, so a renamed/removed command fails CI rather than your tenant; the same job runs the whole suite, which ends by checking every argv it sent the mockgamagainst that reference. A non-blockinggam-latest-previewjob runs the sub-command check against the newest GAM as an early warning. - Runtime self-check — if the running
gamdiffers from the tested version (e.g. aGAMGUI_GAM_BINARYoverride in a source checkout; the packaged.appignores it), the setup screen shows a soft warning. It never blocks.
To bump GAM: the weekly workflow usually opens the PR for you. To do it by hand — or to understand
what that PR contains — python scripts/bump_gam.py vX.Y.Z runs steps 1–4 below as one command
(attestation-verify → pin → vendor → bump → regenerate). The manual steps, for when you want them:
make gam TAG=vX.Y.Z(or./scripts/fetch_gam.sh --tag vX.Y.Z) — this first run fails by design. The new release asset has no committed pin yet, so the script downloads it, prints its SHA-256, and exits without extracting or installing anything.- Add that
<sha> <asset-name>line toscripts/gam_checksums.txt— exactly as the script prints it. Verify the download yourself first; this line is the pin every future fetch is checked against, and a swappedgaminherits domain-wide impersonation. make gam TAG=vX.Y.Zagain — now the checksum matches the committed pin, and the binary + command reference are vendored intogamgui/resources/gam7.- Update
EXPECTED_GAM_VERSION(gamgui/core/gam/commands.py) andTAG(scripts/fetch_gam.sh);.venv/bin/python scripts/build_command_catalog.pyto regenerate the browse catalog. make test— the command-contract, catalog-matches-grammar, and pinned-version-consistent tests flag any sub-command that changed or any version string left behind.- Skim
gamgui/resources/gam7/GamUpdate.txtfor breaking changes. .venv/bin/python scripts/acceptance.pyagainst a tenant — read-only; the true output-shape check.- Commit.
Steps 1–3 are not busywork: the fetch is fail-closed precisely so "no pin for this asset name" can
never be silently upgraded to trust-on-first-use. There is an escape hatch —
./scripts/fetch_gam.sh --tag vX.Y.Z --allow-unpinned installs without verifying (it isn't reachable
through make gam) — and it exists for CI's throwaway gam-latest-preview job. Never use it for a
build you intend to run against a real domain.
pytest is fully offline (mock gam + in-memory Keychain). CI runs it on Ubuntu and macOS across
Python 3.10, 3.12, and 3.14 — the macOS 3.14 run under a line-coverage floor (90%, make cov) — and
runs ruff check and mypy once. It also builds the .app from the pinned GAM (ad-hoc signed) and
smoke-checks the bundle without launching it; see .github/workflows/ci.yml.
Static analysis. CodeQL runs on every push and PR to main, plus weekly, over both the Python
code and the workflows themselves — configured in-tree so it is reviewable rather than hidden in
repository settings: .github/workflows/codeql.yml with
.github/codeql/codeql-config.yml. It uses the broader
security-extended suite, and skips tests/, the vendored GAM release, and vendored browser
libraries — the config explains why for each.
Dependencies. CI, make setup and the .app build install Python packages only from
hash-locked files (requirements/dev.txt, requirements/app.txt and the pip that installs them,
requirements/pip.txt, with pip install --require-hashes), so a package
swapped on the index fails the install instead of shipping.
.github/dependabot.yml watches those locks and the GitHub Actions weekly
and — with the dependency graph enabled — opens PRs for known CVEs. The actions are pinned by commit
SHA, not by tag, so nobody can change what CI runs by moving a tag. The vendored GAM binary is
intentionally excluded: it is pinned by SHA-256 and bumped through its own fail-closed runbook, not by
a bot. So is the Tailwind CLI that builds the UI's CSS (scripts/tailwind_checksums.txt).
The Signatures screen designs one HTML signature with variables, previews it rendered for a real person, and applies it in bulk — scoped to a single user (for testing), a group, an org unit, a department, a location, or the whole company. It opens on a single user — the admin account GamGUI is connected as when that is an active user, otherwise nobody chosen — so the first apply is a test on your own signature; applying to more than 25 people asks you to type how many, and the server checks that count too. Apply writes exactly the people and template the preview showed. Each user's current signature is also shown rendered on their detail page.
Template variables (filled per user from the directory):
{name} {first} {last} {email} {title} ({role} is an alias) {phone} {department}
{location} {ou}. Wrap a fragment in [[ … ]] to drop it when a variable inside is empty — e.g.
[[{title} · ]] vanishes for people with no title, so one template can roll out before every profile
is filled in.
Gmail does not allow inline/base64 images or Google Drive links in signatures — every image must
be a file at a public HTTPS URL. GamGUI is a local app and doesn't host images itself; you point
the template's <img src="…"> at wherever you host them. Whatever host you choose, the URL must be:
- HTTPS and anonymously reachable — Gmail fetches images through its own proxy (no cookies/referer) and caches them. Test a URL in a private/incognito window; if it loads there, Gmail can fetch it.
- served with the correct
Content-Type(image/png, …) and no hotlink/referer protection — referer-based protection is the usual cause of "the logo shows for me but not for recipients." - versioned by filename when an image changes (
logo-2026.png) — Gmail caches by URL, so overwriting the same name can keep serving the old one.
Size icons ~2× their display size and set explicit width/height on each <img>.
Where to host — pick one:
- A web host you already have (simplest). Drop the files in a public folder, e.g.
https://yourdomain.com/email/logo.png. Done. - Google Cloud Storage (Google-native; reuse the GCP project GAM created). Requires a billing
account linked to the project — but small signature assets fall under the Always-Free tier, so
the bill rounds to $0:
- Cloud Console → Billing → link a billing account to the project (if not already).
- Cloud Storage → Create bucket — globally-unique name, a US region, Standard class, Uniform bucket-level access.
- Make objects public: bucket Permissions → Grant access → principal
allUsers→ roleStorage Object Viewer. (If your org enforces Public access prevention, allow it on this bucket.) - Upload the images.
- Reference them at
https://storage.googleapis.com/<bucket>/<path>/logo.png. (Pricing changes — confirm the current free-tier limits, but for a handful of small PNGs it is effectively free.)
- GitHub + jsDelivr (free, no billing). Commit the images to a public repo and serve them via the
jsDelivr CDN:
https://cdn.jsdelivr.net/gh/<user>/<repo>@<branch>/path/logo.png. CDN-fast, no card. - Cloudflare R2 / Amazon S3 — or any public-object store — also work.
MIT — see LICENSE.




