Home / Blog

Never miss a Google Tag Manager update: release notes to Slack with Apps Script

7 min read

Google Tag Manager changes more often than most people notice, and some of those changes touch live tracking. Here is the small, free setup that posts every new GTM release note to a Slack channel, in full and formatted, a few hours after Google publishes it.

A Slack message from a GTM Release Notes app showing the October 12, 2026 entry 'Optimizing Google Tag Manager containers' with its full text, links and an 'All release notes' link
What lands in Slack: the full release note, with links, formatted for Slack.

Why GTM release notes deserve attention

For a long time, I heard about GTM updates the same way most analysts do: a LinkedIn post a few days later, if at all. That works for UI tweaks. It does not work for changes that alter how a container behaves in production.

Two recent examples from the official release notes show why:

Neither is dramatic on its own. But in a setup with many containers, “someone will notice eventually” is not a process. I wanted every new entry in front of the team, with a link, on the day it appears.

The approach: a scheduled Apps Script and a Slack webhook

The whole thing is a single Google Apps Script file of about 150 lines. No server, no hosting, no cost. It works in four steps:

  1. A time-driven trigger runs the script every six hours.
  2. The script fetches the official release notes page and splits it into entries. Every date heading starts a new release, and its content runs until the next date heading.
  3. It compares the newest date with the last date it has seen, which it keeps in Script Properties.
  4. Every newer entry is converted from HTML to Slack formatting and posted through an Incoming Webhook, oldest first.

Apps Script is a good fit because it already has everything this job needs: an HTTP client (UrlFetchApp), a small key-value store (PropertiesService) and a scheduler (time-driven triggers). All of it runs on Google’s infrastructure with your Google account.

The Slack side

On the Slack side you need an app with the Incoming Webhooks feature turned on, connected to the channel you want to post to. Slack then gives you a webhook URL. Anything that sends JSON to that URL appears as a message in the channel.

Plan for approval in company workspaces. Many organizations require an admin to approve new Slack apps, even simple webhook apps. Request it early and explain what the app does and that it only posts, it never reads messages.

Treat the webhook URL like a password. Anyone who has it can post to your channel. Keep it out of the code and store it in Script Properties instead.

The script

This is the script I run. Paste it into a new Apps Script project and follow the setup steps below.

/**
 * GTM Release Notes -> Slack
 * Google Apps Script (script.google.com)
 *
 * Setup:
 * 1) Project Settings > Script Properties > add SLACK_WEBHOOK_URL
 * 2) Run testLatest() -> posts the latest note to Slack (format check)
 * 3) Run setupTrigger() once -> checks every 6 hours
 */

const CONFIG = {
  URL: 'https://support.google.com/tagmanager/answer/4620708?hl=en',
  BASE: 'https://support.google.com',
  CHECK_EVERY_HOURS: 6,
};

/** Main function called by the trigger */
function checkReleaseNotes() {
  const props = PropertiesService.getScriptProperties();
  const webhook = getWebhook_();
  const entries = fetchEntries_();

  const lastSeen = props.getProperty('LAST_SEEN_DATE');
  if (!lastSeen) {
    // First run: don't spam past notes, just store the latest date
    props.setProperty('LAST_SEEN_DATE', entries[0].date);
    return;
  }

  const idx = entries.findIndex(function (e) { return e.date === lastSeen; });
  const fresh = idx === -1 ? [entries[0]] : entries.slice(0, idx);

  // Post from oldest to newest
  fresh.reverse().forEach(function (e) { postToSlack_(webhook, e); });
  props.setProperty('LAST_SEEN_DATE', entries[0].date);
}

/** Manual test: posts the latest release note immediately */
function testLatest() {
  postToSlack_(getWebhook_(), fetchEntries_()[0]);
}

/** Trigger setup (run once) */
function setupTrigger() {
  // Seed the baseline now so nothing published before the first run is skipped
  PropertiesService.getScriptProperties()
    .setProperty('LAST_SEEN_DATE', fetchEntries_()[0].date);

  ScriptApp.getProjectTriggers().forEach(function (t) {
    if (t.getHandlerFunction() === 'checkReleaseNotes') ScriptApp.deleteTrigger(t);
  });
  ScriptApp.newTrigger('checkReleaseNotes')
    .timeBased()
    .everyHours(CONFIG.CHECK_EVERY_HOURS)
    .create();
}

/* ---------------- helpers ---------------- */

function getWebhook_() {
  const w = PropertiesService.getScriptProperties().getProperty('SLACK_WEBHOOK_URL');
  if (!w) throw new Error('Missing script property: SLACK_WEBHOOK_URL');
  return w;
}

function fetchEntries_() {
  const res = UrlFetchApp.fetch(CONFIG.URL, {
    muteHttpExceptions: true,
    headers: { 'Accept-Language': 'en-US,en' },
  });
  if (res.getResponseCode() !== 200) throw new Error('HTTP ' + res.getResponseCode());
  const entries = parseEntries_(res.getContentText());
  if (!entries.length) throw new Error('Could not parse release notes, page structure may have changed');
  return entries;
}

/** Each <h3>Date</h3> is one release; its content runs until the next h3 */
function parseEntries_(html) {
  const h3Re = /<h3[^>]*>([\s\S]*?)<\/h3>/gi;
  const dateRe = /^[A-Z][a-z]+ \d{1,2}, \d{4}$/;
  const heads = [];
  let m;
  while ((m = h3Re.exec(html)) !== null) {
    const text = cleanText_(m[1]);
    if (dateRe.test(text)) heads.push({ date: text, start: m.index, bodyStart: h3Re.lastIndex });
  }
  return heads.map(function (h, i) {
    const end = i + 1 < heads.length ? heads[i + 1].start : h.bodyStart + 20000;
    return { date: h.date, html: html.substring(h.bodyStart, end) };
  });
}

/** HTML -> Slack mrkdwn */
function toSlackMrkdwn_(html) {
  const L = '\u0001', R = '\u0002'; // link placeholders (protect links from tag stripping)
  let s = html;

  s = s.replace(/<a[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/gi, function (_, href, text) {
    href = decode_(href);
    if (href.charAt(0) === '/') href = CONFIG.BASE + href;
    return L + href + '|' + cleanText_(text) + R;
  });
  s = s.replace(/<h4[^>]*>([\s\S]*?)<\/h4>/gi, function (_, t) { return '\n*' + cleanText_(t) + '*\n'; });
  s = s.replace(/<(strong|b)[^>]*>([\s\S]*?)<\/\1>/gi, '*$2*');
  s = s.replace(/<code[^>]*>([\s\S]*?)<\/code>/gi, '`$1`');
  s = s.replace(/<li[^>]*>/gi, '\n• ');
  s = s.replace(/<br\s*\/?>/gi, '\n');
  s = s.replace(/<\/(p|ul|ol|div)>/gi, '\n\n');
  s = s.replace(/<[^>]+>/g, '');
  s = decode_(s);

  // Escape for Slack, then restore link placeholders
  s = s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
  s = s.split(L).join('<').split(R).join('>');

  return s.replace(/[ \t]+/g, ' ').replace(/ *\n */g, '\n').replace(/\n{3,}/g, '\n\n').trim();
}

function postToSlack_(webhook, entry) {
  const title = 'GTM Release Notes ' + entry.date;
  const body = toSlackMrkdwn_(entry.html);
  const chunks = body.match(/[\s\S]{1,2900}/g) || ['(no content)']; // section text limit is 3000

  const blocks = [{ type: 'header', text: { type: 'plain_text', text: title } }]
    .concat(chunks.map(function (c) { return { type: 'section', text: { type: 'mrkdwn', text: c } }; }))
    .concat([{ type: 'context', elements: [{ type: 'mrkdwn', text: '<' + CONFIG.URL + '|All release notes>' }] }]);

  const res = UrlFetchApp.fetch(webhook, {
    method: 'post',
    contentType: 'application/json',
    payload: JSON.stringify({ text: title, blocks: blocks }),
    muteHttpExceptions: true,
  });
  if (res.getResponseCode() !== 200) throw new Error('Slack error: ' + res.getContentText());
}

function cleanText_(s) {
  return decode_(s.replace(/<[^>]+>/g, '')).replace(/\s+/g, ' ').trim();
}

function decode_(s) {
  return s
    .replace(/&nbsp;/g, ' ')
    .replace(/&quot;/g, '"')
    .replace(/&#39;|&apos;/g, "'")
    .replace(/&lt;/g, '<')
    .replace(/&gt;/g, '>')
    .replace(/&#(\d+);/g, function (_, n) { return String.fromCharCode(+n); })
    .replace(/&#x([0-9a-f]+);/gi, function (_, n) { return String.fromCharCode(parseInt(n, 16)); })
    .replace(/&amp;/g, '&');
}

Setting it up step by step

  1. Go to script.google.com and create a new project.
  2. Paste the script into Code.gs and save.
  3. Open Project Settings → Script Properties and add SLACK_WEBHOOK_URL with your webhook URL.
  4. Run testLatest(). It posts the most recent release note straight to Slack, so you can check the formatting. Apps Script will ask for permission to make external requests the first time.
  5. Run setupTrigger() once. It remembers the latest release date as a starting point, removes any older copies of the trigger and schedules checkReleaseNotes() every six hours.
The Apps Script Triggers page showing one time-based trigger for the checkReleaseNotes function with a 0% error rate
One time-based trigger for checkReleaseNotes, and nothing else to maintain.

Things I learned along the way

Parsing a help page is fragile, so fail loudly

There is no official feed for these release notes, so the script reads the HTML of the help page. If Google redesigns that page, the parser may find nothing. The worst outcome would be a script that silently stops working. That is why fetchEntries_() throws an error when the request fails or no entries can be parsed. A failing trigger shows up in the Executions log, and Apps Script can email the owner about failed runs, so a broken parser does not go unnoticed.

Release notes are not Slack-ready

The help page is HTML, and Slack uses its own markup. The script converts headings, bold text, code, lists and links. Links need special care: they are swapped for placeholders before the remaining tags are stripped, then restored in Slack’s <url|text> format. Relative links on the help page are turned into absolute ones, so they still work in Slack.

Respect Slack’s limits

A single text section in a Slack Block Kit message can hold up to 3,000 characters, and some release notes are longer than that. The script splits long notes into several sections, so nothing gets cut off or rejected.

Seed the state before the first run

Without a starting point, the first run would have nothing to compare against. setupTrigger() stores the current latest date right away, so only notes published after setup are posted, and the channel is not flooded with years of history.

Know the trade-off of tracking dates

The script remembers the date of the newest release, not every individual note. That keeps it simple and covers how Google publishes almost every time. The one case it would miss is Google adding a new note under a date that already exists on the page. For release notes that appear a few times a month, I am happy with that trade-off.

What changed for us

The biggest effect is not speed, it is the conversation. A new entry now arrives as a Slack message with its own thread, and the question “does this affect our setup?” gets answered there, on the same day, by the people who know the containers. For changes like the gtag.config event, that is the difference between a quick check of wildcard triggers and a surprise in next month’s report.