Skip to content

Repository files navigation

gtm-as-code

Google Tag Manager and GA4 configuration, managed as code. Declare your events, GTM resources, and GA4 resources in a YAML file, review the diff with plan, and apply it, Terraform-style.

  • Version-control your analytics setup and review changes in pull requests.
  • Preview what would change before it happens.
  • Deploy through CI/CD instead of clicking through the GTM UI.

Status

Early (0.1.0). init, validate, plan, and apply work and are verified against a real GTM container and GA4 property. See ROADMAP.md and the Roadmap section below for what isn't built yet. Check there before assuming a feature exists.

Install

npm install -D @stackmade/gtm-as-code

Requires Node.js >= 22.

Quickstart

Scaffold a config in the current project:

npx gtm-code init

You'll be prompted for your GTM account ID, GTM container ID, and GA4 property ID. This creates:

analytics/analytics.yaml
analytics/README.md
analytics/.env.analytics.example

analytics/analytics.yaml starts out empty, ready for you to fill in:

version: 1

project:
  name: change-me

google:
  gtm:
    accountId: "${GTM_ACCOUNT_ID}"
    containerId: "${GTM_CONTAINER_ID}"

  ga4:
    propertyId: "${GA4_PROPERTY_ID}"

events: {}

gtm:
  variables: {}
  triggers: {}
  tags: {}

ga4:
  dimensions: {}
  metrics: {}
  keyEvents: {}

analytics/.env.analytics.example holds the IDs you gave init. Copy it to analytics/.env.analytics (gitignored, see State) with your real values, or set the same variables in your CI environment.

Check the config offline, no Google credentials required:

gtm-code validate
✓ analytics/analytics.yaml parsed
✓ 0 events
✓ 0 custom dimensions
✓ 0 key events
✓ configuration valid

Once you're authenticated (see Prerequisites), preview what would change in your live GTM container and GA4 property:

gtm-code plan
GTM as Code

Google Tag Manager

+ create variable   form
~ update trigger    generate_lead
    triggerCondition: {...} → {...}
- delete tag         GA4 - old_tag

Google Analytics 4

+ dimension          form

Plan:
  2 to create
  1 to update
  1 to delete

And apply it:

gtm-code apply

apply runs the same diff as plan, prints a ⚠ DELETE warning for every resource it's about to remove, then asks for confirmation before doing anything. Pass --auto-approve to skip the prompt in CI.

Prerequisites

You need Node.js 22 or newer, and an existing GTM container and GA4 property. This tool manages resources inside them; it does not create the container or the property itself.

Google credentials are resolved through the standard Application Default Credentials chain. Locally that means gcloud auth application-default login. In CI, either point GOOGLE_APPLICATION_CREDENTIALS at a service-account key file or configure Workload Identity Federation.

You don't need to pick scopes yourself. plan requests read-only scopes (tagmanager.readonly, analytics.readonly) and apply requests edit scopes (tagmanager.edit.containers, analytics.edit).

Configuration reference

The config file is looked up as analytics.yaml or analytics/analytics.yaml in the current directory, or you can point at a specific file with -c, --config <path>.

Environment variable interpolation

Any string value can reference an environment variable:

accountId: "${GTM_ACCOUNT_ID}"
propertyId: "${GA4_PROPERTY_ID:-123456789}"   # with a default

A reference with no default that resolves to an unset variable fails validation. Keep real account, container, and property IDs, and any secrets, out of the YAML. Put them in environment variables instead, as init's scaffold does.

Config composition (extends:)

A config can pull in events:/gtm.{variables,triggers,tags,folders}:/ga4.{dimensions,metrics,keyEvents}: entries from other YAML files, for shared conventions or reusable packs:

extends: ./packs/ecommerce.yaml
# or: extends: [./packs/ecommerce.yaml, ./packs/consent.yaml]

Paths resolve relative to the file that contains the extends: key, and an extends target can itself extend further files. The root config always wins: an entry it defines directly can't be overridden by an extends target, and two extends targets can't define the same entry either, both fail validation rather than picking one silently. An extends target may only contain the sections listed above, nothing that identifies or authenticates a specific GTM/GA4 property (version, project, google.*, gtm.builtInVariables), so a shared pack never carries another repo's account IDs by accident. Validation errors on an entry pulled in this way point at the file and line it actually came from, not the root config.

Event packs

packs/ecommerce.yaml and packs/recommended.yaml, shipped in this repo, are extends: targets for GA4's recommended events. packs/ecommerce.yaml covers view_item, add_to_cart, begin_checkout, purchase, and the rest of the ecommerce set, each with its GA4-specified parameters, including the items array. packs/recommended.yaml covers login, sign_up, search, and share. Both default consent to { status: needed, types: [analytics_storage] }; override an event in your own config to change that, root always wins.

extends: ./node_modules/@stackmade/gtm-as-code/packs/ecommerce.yaml

Schema

version: 1                     # only 1 is accepted

project:
  name: string

google:
  gtm:
    accountId: string
    containerId: string
    workspace: string          # optional

  ga4:
    propertyId: string
    measurementId: string      # optional

events:
  <event_name>:
    description: string        # optional
    keyEvent: boolean          # optional
    parameters:
      <param_name>:
        type: string | number | boolean | items   # items is GA4's ecommerce item array
        dimension: boolean     # optional, also registers this parameter as a GA4 dimension
                                # (not allowed on a type: items parameter, GA4 dimensions are scalar)
        optional: boolean      # optional
    consent:                   # required: every event compiles to a ga4Event tag, which needs one
      status: needed | notNeeded
      types: [string, ...]     # required when status is needed, e.g. [analytics_storage]

gtm:
  builtInVariables: [string, ...]  # optional, GTM display names e.g. "Click Text", "Page Path"
  folders:
    <name>: {}
  variables:
    <name>: { type: string, folder: string, protected: boolean, ... }
  triggers:
    <name>: { type: string, folder: string, protected: boolean, ... }
      # a bare "consentInit" trigger (no extra fields) fires before every other trigger
  tags:
    <name>: { type: string, trigger: [string, ...], exceptTrigger: [string, ...],
               setupTags: [string, ...], teardownTags: [string, ...], folder: string,
               protected: boolean, consent: { status: needed | notNeeded, types: [string, ...] }, ... }
      # trigger and exceptTrigger (optional, blocking triggers) reference trigger names above, or
      #   a reserved built-in trigger name ("All Pages", "Initialization - All Pages")
      # setupTags/teardownTags (optional) reference other tag names, mapping to GTM's
      #   setupTag/teardownTag tag sequencing
      # folder (optional, on variables/triggers/tags too) references a name under gtm.folders
      # protected (optional, on any gtm.* resource) requires --allow-destroy-protected, on top of
      #   --allow-destroy, before apply may delete it
      # consent (required on ga4Event/googleTag tags) maps to GTM's consentSettings; types is only
      #   allowed when status is needed

ga4:
  dimensions:
    <name>: { scope: event | user, parameter: string, protected: boolean }
  metrics:
    <name>: { scope: event | user, parameter: string, measurementUnit: string, protected: boolean }
      # measurementUnit optional
  keyEvents:
    <name>: { protected: boolean }
  streamWebsiteUrl: string
    # the web data stream these stream-scoped settings apply to, looked up by URL, never created
  dataRetention: TWO_MONTHS | FOURTEEN_MONTHS | TWENTY_SIX_MONTHS | THIRTY_EIGHT_MONTHS | FIFTY_MONTHS
    # manages event data retention only, userDataRetention is untouched
    # THIRTY_EIGHT_MONTHS and FIFTY_MONTHS are only valid on GA4 360 properties
  googleSignals: GOOGLE_SIGNALS_ENABLED | GOOGLE_SIGNALS_DISABLED
    # Google Signals must already be activated on the property through the GA4 UI once;
    # this tool can only change its state after that, not activate it
  enhancedMeasurement:
    scrollsEnabled: boolean
    outboundClicksEnabled: boolean
    siteSearchEnabled: boolean
    videoEngagementEnabled: boolean
    fileDownloadsEnabled: boolean
    formInteractionsEnabled: boolean
    # requires streamWebsiteUrl to be set
  audiences:
    <name>:
      description: string
      membershipDurationDays: number
      exclusionDurationMode: EXCLUDE_TEMPORARILY | EXCLUDE_PERMANENTLY  # optional
      eventTrigger: { eventName: string, logCondition: AUDIENCE_JOINED | AUDIENCE_MEMBERSHIP_RENEWED }  # optional
      protected: boolean  # optional
      filterClauses:
        - clauseType: INCLUDE | EXCLUDE
          scope: AUDIENCE_FILTER_SCOPE_WITHIN_SAME_SESSION | AUDIENCE_FILTER_SCOPE_WITHIN_SAME_EVENT
            | AUDIENCE_FILTER_SCOPE_ACROSS_ALL_SESSIONS
          filter:
            # exactly one of the following, and/or/not may nest recursively:
            and: [<filter>, ...]
            or: [<filter>, ...]
            not: <filter>
            dimensionOrMetric: { fieldName: string, atAnyPointInTime: boolean,
              string: { matchType: string, value: string, caseSensitive: boolean } |
              inList: { values: [string], caseSensitive: boolean } |
              numeric: { operation: EQUAL | LESS_THAN | GREATER_THAN, value: number } |
              between: { from: number, to: number } }
            event: { eventName: string, eventParameterFilterExpression: <filter> }  # eventParameterFilterExpression optional
  eventCreateRules:
    <name>:  # <name> is the rule's destinationEvent
      eventConditions:
        - { field: string, comparisonType: string, value: string, negated: boolean }  # 1-10, negated optional
      sourceCopyParameters: boolean  # optional
      parameterMutations:  # optional, up to 20
        - { parameter: string, parameterValue: string }
    # requires streamWebsiteUrl to be set
  eventEditRules:
    <name>:  # <name> is the rule's displayName
      eventConditions:
        - { field: string, comparisonType: string, value: string, negated: boolean }  # 1-10, negated optional
      parameterMutations:  # required, 1-20
        - { parameter: string, parameterValue: string }
    # requires streamWebsiteUrl to be set
  calculatedMetrics:
    <name>:  # <name> is the metric's calculatedMetricId (immutable once created)
      displayName: string
      metricUnit: STANDARD | CURRENCY | FEET | MILES | METERS | KILOMETERS | MILLISECONDS | SECONDS | MINUTES | HOURS
      formula: string  # e.g. "(eventCount / 2.0)"
      description: string  # optional
  channelGroups:
    <name>:  # <name> is the group's displayName
      description: string  # optional
      primary: boolean  # optional, only one channel group can be primary
      groupingRule:  # 1-50
        - displayName: string
          expression:
            # exactly one of the following, and/or/not may nest recursively:
            and: [<filter>, ...]
            or: [<filter>, ...]
            not: <filter>
            filter: { fieldName: string, string: { matchType: string, value: string } |
              inList: { values: [string] } }

Only type (and, for tags, trigger/exceptTrigger/setupTags/teardownTags) is schema-validated on gtm.variables, gtm.triggers and gtm.tags entries today. Per-resource-type property shapes aren't validated yet.

gtm.builtInVariables is enable-only: plan/drift report a name listed but not yet enabled remotely, apply enables it. Names not in the config are left alone — enabling one by hand in the GTM UI is never reported as drift, and apply never disables one. Only the click/page/form/error/ history/debug set is supported today (see src/providers/google/gtm/builtin-variables.ts for the exact list); scroll depth, element visibility, video, and AMP variables aren't yet.

A tag's trigger/exceptTrigger can reference GTM's built-in triggers by name ("All Pages", "Initialization - All Pages") without declaring them under gtm.triggers. These trigger objects always exist in every container but are never returned by GTM's own triggers.list/triggers.get, so a lookup against gtm.triggers can't resolve them, this tool maps the reserved name straight to GTM's fixed numeric trigger id instead (see src/providers/google/gtm/builtin-triggers.ts). Only the two names above are supported; "Consent Initialization - All Pages" is a known GTM UI trigger whose numeric id couldn't be confirmed through the API and isn't guessed at.

ga4.dataRetention, ga4.googleSignals and ga4.enhancedMeasurement have no create/delete lifecycle, unlike dimensions/metrics/keyEvents. plan/drift compare the declared value against GA4's live property/stream state and report a difference as an update; apply PATCHes it. A setting left out of config is never touched, so enabling one by hand in the GA4 UI is never reported as drift. dataRetention and googleSignals are property-level and apply regardless of streamWebsiteUrl; enhancedMeasurement is scoped to the stream streamWebsiteUrl resolves to, so it requires that field. googleSignals and enhancedMeasurement live under the GA4 Admin API's v1alpha, not v1beta like everything else this tool touches, since GA4 hasn't promoted them to v1beta yet; this tool routes each request to the right version internally.

ga4.audiences are create/update/archive Resources like dimensions and key events, not settings-diffed like the property/stream fields above, since GA4's Admin API has no audience delete, only archive, and an archived audience drops out of list(). membershipDurationDays, exclusionDurationMode, and filterClauses cannot be changed on an existing audience through the API; plan fails with an error naming the field rather than attempting a PATCH GA4 would reject. Only description (mapped from the config key's <name>, i.e. displayName) can be updated in place. A filter's and/or/not/dimensionOrMetric/event nesting is validated to have exactly one of those keys set at each level; GA4 also requires every clause's top-level filter to be an and of ors, which validation adds automatically, so a single bare condition (filter: { event: { eventName: ... } } }) is enough and never needs writing out by hand. sequenceFilter isn't implemented.

ga4.eventCreateRules and ga4.eventEditRules are also create/update/delete Resources, but nested under a data stream rather than the property (confirmed live 2026-08-29: both 404 on v1beta), so both require streamWebsiteUrl. Unlike audiences, GA4 supports a real DELETE for both, no archive step. A create rule's config key is its destinationEvent (it has no separate label field, the same non-uniqueness caveat ga4.keyEvents' eventName already carries); an edit rule's config key is its displayName. comparisonType accepts GA4's standard set (EQUALS/CONTAINS/STARTS_WITH/ENDS_WITH/GREATER_THAN/LESS_THAN, their _CASE_INSENSITIVE/_OR_EQUAL variants, and REGULAR_EXPRESSION*, web-stream only, not enforced here). Edit rules also carry a GA4-assigned processingOrder (their evaluation order relative to other edit rules on the same stream) that's read-only through this API (confirmed live that GA4 rejects it in any updateMask), so it's never part of config and never round-tripped back from a pull; reordering rules isn't supported. pull only fetches these two kinds when the property has exactly one web data stream to resolve to; with more than one, it skips them and prints why.

ga4.calculatedMetrics and ga4.channelGroups are property-scoped create/update/delete Resources (v1alpha, confirmed live 2026-08-29: both 404 on v1beta), unaffected by streamWebsiteUrl. A calculated metric's config key is its calculatedMetricId, GA4's immutable identifier (externally referenced as calcMetric:{id}); unlike every other kind's identity field, GA4 requires it as a create-time query parameter rather than a body field, since the body field itself is output-only. metricUnit is mutable after creation (confirmed live, despite there being no field-level note in GA4's own docs saying so); formula, description, and displayName (a separate, mutable label, distinct from the config key) are too. A channel group's config key is its displayName, like ga4.audiences. groupingRule[].expression follows the same and-of-ors top-level nesting GA4 requires of an audience filter, so a single bare filter condition is enough and gets normalized automatically. Its leaf filter has no dimensionOrMetric/ event wrapper, just fieldName plus a string or inList match, and neither supports caseSensitive (confirmed live GA4 rejects the field outright, unlike an audience's dimensionOrMetric filter). Valid fieldName values use an undocumented eachScope-prefixed convention (e.g. eachScopeSource, eachScopeMedium, eachScopeCampaignId) discovered by reading GA4's own pre-existing "Default channel group"; this tool doesn't validate fieldName against that set; a wrong value surfaces as GA4's own error on apply. Both kinds support a real DELETE, no archive step; deleting the property's system-defined "Default channel group" isn't possible through the API regardless.

Validation also catches:

  • unknown top-level or nested keys (with a "did you mean" suggestion),
  • the same resource id defined twice across gtm.* and ga4.*,
  • a tag's trigger or exceptTrigger referencing a trigger name that doesn't exist,
  • a tag's setupTags or teardownTags referencing a tag name that doesn't exist, or itself,
  • a folder referencing a name not defined under gtm.folders,
  • a ga4Event or googleTag tag (hand-authored, or compiled from events.*) with no consent block, since it would otherwise load a Google measurement script with no consent check,
  • an event parameter whose name suggests personal data (email, phone, address, and similar). GA4 responds to PII in event parameters by deleting the data, not just rejecting the request.
  • GA4 naming and limit violations: an event or parameter name that doesn't start with a letter or contains characters other than letters/digits/underscores, a name over 40 characters, a reserved event name (page_view, click, first_visit, and the rest of GA4's automatically-collected set), a reserved parameter name or prefix (user_id, ga_*, firebase_*, google_*, gtag.*), an event with more than 25 parameters, or (checked after events.* compiles, since hand-written and event-derived dimensions share one cap) more than 50 event-scoped custom dimensions. currency is only reserved when marked dimension: true, GA4's own recommended ecommerce events send it as a plain parameter.

CLI reference

Global flags, available on every command:

Flag Description
-c, --config <path> Path to the config file (default: auto-discovered)
-v, --verbose Verbose output
-q, --quiet Suppress non-error output
-f, --format <type> text, json, or markdown. plan supports all three; other commands ignore it and print text

gtm-code init

Interactively scaffolds analytics/analytics.yaml, analytics/README.md, and analytics/.env.analytics.example. If analytics/analytics.yaml already exists, asks before overwriting.

gtm-code validate

Parses and schema-checks the config offline. No Google API calls, no credentials needed.

gtm-code plan [--format text|json|markdown]

Authorizes with read-only scopes, diffs your config against the live GTM container and GA4 property, and prints the result. Never creates, updates, or deletes anything.

See Output contract below for --format json, --format markdown, and exit codes.

gtm-code diff <fileA> <fileB> [--format text|json|markdown]

Compares two config files against each other (fileA as the baseline, fileB as desired), the same way plan compares a config against live state. No network calls, no credentials needed. Exit code 2 if the two files differ, 1 on a load/validation error, 0 if they resolve to the same resources.

gtm-code drift [--format json|markdown]

The read-only, CI-friendly sibling of plan. Authorizes with the same read-only scopes, compares live state against config, and reports whether they've diverged, without printing the full plan detail plan does. Exit code 1 if drift is found (or on error), 0 if clean.

gtm-code apply [--auto-approve] [--allow-destroy] [--allow-destroy-protected]

Runs the same diff as plan, then executes it. Deletes go first (tags, then triggers, then variables, the reverse of creation order), then creates in dependency order so a tag can reference a trigger created in the same run, then updates. Prompts Continue? [y/N] unless --auto-approve is passed.

apply only writes to the GTM workspace. Nothing reaches the live container until you run gtm-code publish. Before writing anything, apply also checks whether the workspace has changes it can't safely merge, most likely from someone editing the same workspace in the GTM UI, and refuses to proceed if it finds any rather than overwriting them.

If the plan includes any deletes, apply refuses to run unless --allow-destroy is also passed, even with --auto-approve. The two flags are independent on purpose: --auto-approve is the flag your routine CI apply already uses, and a destructive change deserves its own opt-in rather than riding along with it. This applies to GA4 deletes too, even though a GA4 custom dimension or metric is archived rather than hard-deleted (key events are hard-deleted); plan prints all three as - delete, so apply treats them the same way for this gate.

A resource marked protected: true in config needs --allow-destroy-protected on top of --allow-destroy before apply deletes it, even if --allow-destroy alone would otherwise cover the rest of the plan. GTM resources carry the flag in their own notes field, alongside the existing ownership metadata; GA4 has no such field, so its flag lives in .analytics/state.json instead (see State).

gtm-code publish

Creates a GTM container version from the current workspace and publishes it. The version name and notes are taken from the current commit (short SHA and subject line) when run inside a git checkout, so the GTM version history stays readable against your commit history. Requires the tagmanager.publish scope in addition to the ones apply already needs.

gtm-code rollback [--auto-approve]

Republishes the container version that was live immediately before the current one. Prints both versions and prompts Continue? [y/N] unless --auto-approve is passed. Does nothing to the workspace or to .analytics/state.json; it only changes which already-existing version is live, so it's close to free to run once publish has created a version history to roll back through.

gtm-code pull [--resource <kindAndId>] [--out <path>] [--from-export <path>]

Reverse-generates YAML from a live GTM container and GA4 property. It's the starting point for putting an existing setup under this tool's management. Three modes, mutually exclusive: no flags pulls everything into the resolved config; --resource tag:generate_lead_tag pulls one resource into the existing config; --from-export path.json ingests a GTM UI container export (Admin > Export Container) instead of calling the API, so it needs no GTM permissions at all. --out overrides where the config is written (default: the resolved config path). Logical ids are derived by slugifying each object's GTM name, with _2/_3 suffixes on collisions. Asks before overwriting an existing output file, except when pulling a single --resource, which merges in place. Requires the same read-only scopes as plan, unless you're using --from-export.

gtm-code docs [--out <path>]

Generates a data dictionary (Markdown) from events:: one section per event with its description, key-event and consent flags, and a parameter table (type, required, custom dimension). Prints to stdout by default; --out writes it to a file instead. Runs the same validate/compile pipeline as validate, so a config that fails to validate fails docs too.

gtm-code generate [--out <path>]

Generates typed event helpers from events:: a TypeScript EventName union, one params interface per event, an EventParams map, and a track<E extends EventName>(event: E, params: EventParams[E]) function that pushes { event, ...params } onto window.dataLayer. A typo in an event name or a missing required parameter is a compile error in the consuming app rather than missing data in GA4. A type: items parameter emits as Item[], backed by a shared Item interface (GA4's ecommerce item fields) that's only emitted when at least one event uses it. Prints to stdout by default; --out writes it to a file. Runs the same validate/compile pipeline as validate.

gtm-code adopt <kindAndId>

Stamps ownership on a resource pull already brought into the config, so plan/apply start treating it as managed. It's the one write in an otherwise read-only pull/adopt flow. Takes the same <kind>:<id> argument as pull --resource (e.g. tag:generate_lead_tag). For GTM kinds (folder/variable/trigger/tag) it re-submits the matching live object with an ownership marker in its notes field, with no functional change to the object itself. For GA4 kinds (dimension/metric/keyEvent) it records the resource as managed in .analytics/state.json instead, with no live write. Prompts Continue? [y/N] before writing either way.

State

.analytics/state.json is created and updated automatically by plan and apply. It tracks which GA4 resources this tool owns, plus which of them are protected: true (its protectedResources field). GTM ownership, and its own protected flag, are tracked differently, via a notes field this tool writes onto each GTM object it manages. Resources not tracked as managed are never touched or deleted.

apply holds an exclusive lock (.analytics/state.json.lock) for the duration of the run. Two concurrent applys against the same state file can't interleave writes: the second one fails fast with a clear error instead of corrupting the file. If a run crashes without cleaning up, delete the leftover .lock file before retrying. The state file also carries a version field. A version this CLI doesn't understand fails plan/apply loudly instead of silently treating the state as empty.

Don't hand-edit .analytics/state.json. Commit it. It holds no secrets, only resource ids, and it's the only record of which GA4 resources this tool owns. A CI runner that checks out the repo needs this file to know what already exists, or every run tries to re-create every GA4 resource. Keep your real analytics/.env.analytics file gitignored instead, and commit only .env.analytics.example.

Output contract

gtm-code plan is the command scripts and the companion GitHub Action are meant to parse. Its --format json and exit codes are treated as stable; a breaking change to either is a major-version change for this package.

--format json prints:

{
  "hasChanges": true,
  "counts": { "create": 1, "update": 0, "delete": 0 },
  "changes": [{ "operation": "create", "type": "gtm.trigger", "id": "generate_lead" }]
}

operation is "create", "update", or "delete". type is the resource type as it appears in plan's text output (gtm.variable, gtm.trigger, gtm.tag, ga4.dimension, ga4.metric, ga4.keyEvent). id is the logical id from your config, the same one that appears as a YAML key under events:, gtm:, or ga4:.

--format markdown prints a one-line counts summary and a table with the same three columns, formatted so a caller can post it as a PR comment as-is:

**GTM as Code plan**

1 to create, 0 to update, 0 to delete

| Action | Kind | Id |
| --- | --- | --- |
| + create | trigger | `generate_lead` |

Exit codes: 0 when the plan has no changes, 2 when it does, 1 on error, the same three-way convention as terraform plan -detailed-exitcode. apply and rollback use the plain 0/1 convention instead: once they've run, there's no "pending" state left to signal.

CI/CD

The quickest way is the companion action:

- uses: StackMade/gtm-as-code-action@v0
  with:
    command: plan

See gtm-as-code-action for its inputs. It installs this CLI from npm at a pinned version, so its own tag tells you which CLI you get.

To call the CLI directly instead:

name: gtm-as-code

on:
  pull_request:
  push:
    branches: [main]

jobs:
  plan:
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: 22
      - run: npm ci
      - run: npx gtm-code validate
      - run: npx gtm-code plan
        env:
          GOOGLE_APPLICATION_CREDENTIALS: ${{ secrets.GOOGLE_APPLICATION_CREDENTIALS_PATH }}
          GTM_ACCOUNT_ID: ${{ secrets.GTM_ACCOUNT_ID }}
          GTM_CONTAINER_ID: ${{ secrets.GTM_CONTAINER_ID }}
          GA4_PROPERTY_ID: ${{ secrets.GA4_PROPERTY_ID }}

  apply:
    if: github.event_name == 'push'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: 22
      - run: npm ci
      - run: npx gtm-code apply --auto-approve
        env:
          GOOGLE_APPLICATION_CREDENTIALS: ${{ secrets.GOOGLE_APPLICATION_CREDENTIALS_PATH }}
          GTM_ACCOUNT_ID: ${{ secrets.GTM_ACCOUNT_ID }}
          GTM_CONTAINER_ID: ${{ secrets.GTM_CONTAINER_ID }}
          GA4_PROPERTY_ID: ${{ secrets.GA4_PROPERTY_ID }}

Use a real credential-loading action, or Workload Identity Federation, to populate GOOGLE_APPLICATION_CREDENTIALS, and adapt this to whatever your organization already uses. apply only writes to the GTM workspace; add a run: npx gtm-code publish step after it if you want every push to main to also go live.

GA4 ownership tracking works in CI as long as .analytics/state.json is committed (see State above), so a fresh checkout already knows which GA4 resources this tool manages.

Roadmap / not yet implemented

Not available yet. These are known gaps, so please don't file a bug for them:

  • gtm-code verify, gtm-code docs, gtm-code doctor
  • gtm-code generate (typed event helpers)
  • gtm-code migrate
  • GTM custom templates; conversion linker and community-gallery template tags (their payloads need fields, like Floodlight ids or a gallery template's own parameter schema, this tool can't live-verify against a sandbox container). See Schema for what is covered
  • GA4 data streams, enhanced measurement, audiences, and property settings

There is deliberately no action.yml in this repository. The GitHub Action ships from StackMade/gtm-as-code-action so that its version tags don't share a namespace with this package's npm releases.

See ROADMAP.md for what's planned and in what order.

Releasing

Versions come from Changesets, not from editing package.json by hand. If a pull request changes something a user of the package would notice, run npx changeset, pick the bump level, write a line of summary, and commit the generated file in .changeset/ alongside the code. Docs, CI and internal refactors don't need one.

Merging to main opens a pull request titled "Release" that carries the version bump and the CHANGELOG.md entries. Merging that pull request publishes to npm with provenance, pushes the v* tag and creates the GitHub release.

License

MIT, see LICENSE.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages