appscript.dev
Automation Beginner Docs Sheets

Generate release notes from a changelog sheet

Format Northwind's changelog rows into a published-ready Doc per version.

Published Nov 16, 2025

Northwind’s engineers log every shipped change in a Changelog sheet — one row per item, tagged as a feature, a fix, or a breaking change. The data is tidy, but a spreadsheet is not what you hand to customers when a version ships. Someone still has to copy the rows out, group them, and format a release notes document.

This script does that grouping for you. Give it a version number and it pulls every changelog row for that version, sorts the items into the three sections customers expect — breaking changes first, then features, then fixes — and writes a clean Google Doc ready to publish. It is a small job, but it is the kind of small job that gets skipped under deadline pressure.

What you’ll need

  • A Changelog Google Sheet with a header row and three columns: version, type (one of feat, fix, or breaking), and description.
  • Nothing else — the script creates the release notes Doc itself.

The script

// The sheet that holds your changelog rows.
const CHANGELOG_SHEET_ID = '1abcChangelogId';

// The order sections appear in the Doc, and the heading shown for each.
// Breaking changes come first because that is what customers must read.
const SECTIONS = [
  { type: 'breaking', heading: 'Breaking changes' },
  { type: 'feat', heading: 'Features' },
  { type: 'fix', heading: 'Fixes' },
];

/**
 * Reads every changelog row for one version and writes a grouped
 * release notes Doc. Returns the URL of the new document.
 */
function buildReleaseNotes(version) {
  // 1. Read the changelog and keep only rows for the requested version.
  const items = readSheet(CHANGELOG_SHEET_ID)
    .filter((r) => r.version === version);

  // 2. Bail out early if nothing matches — no point creating an empty Doc.
  if (items.length === 0) {
    throw new Error(`No changelog entries found for version ${version}.`);
  }

  // 3. Create the Doc and give it a title heading.
  const doc = DocumentApp.create(`Release notes — ${version}`);
  const body = doc.getBody();
  body.appendParagraph(`Release ${version}`)
    .setHeading(DocumentApp.ParagraphHeading.TITLE);

  // 4. Walk the sections in order, appending a heading and a bulleted
  //    list for each type that actually has entries.
  for (const section of SECTIONS) {
    const filtered = items.filter((i) => i.type === section.type);
    if (filtered.length === 0) continue;

    body.appendParagraph(section.heading)
      .setHeading(DocumentApp.ParagraphHeading.HEADING2);
    for (const f of filtered) {
      body.appendListItem(f.description);
    }
  }

  // 5. Save the Doc and hand back its URL.
  doc.saveAndClose();
  Logger.log(`Wrote release notes for ${version}: ${doc.getUrl()}`);
  return doc.getUrl();
}

/**
 * Reads the first sheet of a spreadsheet into an array of objects,
 * keyed by the header row. Keeps the main function readable.
 */
function readSheet(id) {
  const [h, ...rows] = SpreadsheetApp.openById(id)
    .getSheets()[0]
    .getDataRange()
    .getValues();
  return rows.map((r) => Object.fromEntries(h.map((k, i) => [k, r[i]])));
}

How it works

  1. buildReleaseNotes takes a version string and calls readSheet to load the whole changelog as an array of objects, then filters it down to rows whose version matches.
  2. If no rows match, it throws straight away — an empty release notes document is worse than a clear error.
  3. It creates a new Doc named after the version and adds a title paragraph.
  4. It walks the SECTIONS list in a fixed order. For each type, it filters the matching items, and — only if there are any — appends a HEADING2 and one bulleted list item per change. Empty sections are skipped, so a fix-only release never shows an empty “Features” heading.
  5. It saves the Doc and returns the URL so you can open or share it.

Example run

Say the Changelog sheet holds these rows:

versiontypedescription
2.4.0featBulk export now supports CSV and XLSX
2.4.0fixDate picker no longer offsets by a day in some timezones
2.4.0breakingThe legacy /v1/reports endpoint has been removed
2.3.0fixLogin redirect loop on Safari

Calling buildReleaseNotes('2.4.0') produces a Doc titled Release notes — 2.4.0 with three sections:

  • Breaking changes — The legacy /v1/reports endpoint has been removed
  • Features — Bulk export now supports CSV and XLSX
  • Fixes — Date picker no longer offsets by a day in some timezones

The 2.3.0 row is ignored, and because there is only one of each type, you get exactly three headings.

Run it

This runs once per release, so trigger it by hand:

  1. In the Apps Script editor, open a function that calls buildReleaseNotes('2.4.0') with your version, or run it from the editor after setting the argument.
  2. Approve the authorisation prompt the first time.
  3. Open the URL it logs to read the finished Doc.

To make it self-serve, add a custom menu that prompts for the version:

function onOpen() {
  SpreadsheetApp.getUi()
    .createMenu('Release tools')
    .addItem('Build release notes', 'promptForVersion')
    .addToUi();
}

function promptForVersion() {
  const ui = SpreadsheetApp.getUi();
  const res = ui.prompt('Which version?', ui.ButtonSet.OK_CANCEL);
  if (res.getSelectedButton() === ui.Button.OK) {
    buildReleaseNotes(res.getResponseText().trim());
  }
}

Watch out for

  • The type values must match exactly. A row tagged feature instead of feat lands in no section and is silently dropped. Use data validation on the column to keep the three values clean.
  • Items appear in sheet order within each section. If you want the most important change at the top of “Features”, sort the changelog rows before running, or add a priority column and sort on it.
  • DocumentApp.create always makes a fresh Doc in your My Drive root. If you want release notes filed in a specific folder, move the file with DriveApp after creating it, or copy a template instead.
  • The Doc is plain — headings and bullets, no styling. If you publish to a branded template, replace DocumentApp.create with a makeCopy of a styled template and append into that instead.

Related