How to Automate Release Notes with n8n

By The LightCube Labs team

The Problem

Release notes are written at the worst possible moment: right after a deploy, when the engineer who shipped it wants to move on. Someone scrolls through a list of merged pull requests, copies commit titles into a document, guesses which ones matter to customers, and pastes the result into Slack and the wiki. Breaking changes get buried, fixes are mixed with refactors, and the Confluence page is often a day late or missing. This n8n workflow reads the commits between two tags, sorts them by conventional commit type, and publishes the same notes everywhere in one run.

What You Will Need

Step-by-Step Workflow Setup

1 Trigger on a published GitHub release

In n8n, this trigger is the entry point of the whole release flow, so every later n8n node can read the release data with a single expression.

The GitHub Trigger node registers a webhook on your repository and starts the n8n workflow whenever a release is published. Draft releases do not fire this event, which is exactly what you want.

Add a GitHub Trigger node and configure:

Node: GitHub Trigger
Authentication: Access Token
Owner: acme-corp
Repository: billing-api
Events: release
Resource: Release (webhook event)

The webhook delivers an action field. Add an IF node right after the trigger or filter inside the Code node so that only the published action continues. Use this expression for the condition:

Expression: {{ $json.body.action === 'published' }}
Sample output:
{
  "action": "published",
  "release": {
    "tag_name": "v2.4.0",
    "name": "Spring release",
    "prerelease": false,
    "html_url": "https://github.com/acme-corp/billing-api/releases/tag/v2.4.0"
  },
  "repository": { "full_name": "acme-corp/billing-api" }
}

Pre-releases publish the same event. If you only want stable versions, add a second condition on release.prerelease being false.

2 Find the previous tag

You can build and test this lookup in the n8n editor by pinning the trigger output, which saves repeated real releases while you tune the n8n expression.

The compare API needs a base and a head. The head is the new tag from the trigger. For the base, add an HTTP Request node that lists the two most recent releases and pick the second one.

Node: HTTP Request
Method: GET
URL: https://api.github.com/repos/{{ $json.body.repository.full_name }}/releases?per_page=5
Authentication: Predefined Credential Type
Credential Type: GitHub API
Send Headers: on
  Accept: application/vnd.github+json

The response is an array. The next node can pick the right tag with an expression, so no extra Code node is needed. Use this expression to take the newest release that is not the current one:

Expression: {{ $json.filter(r => r.tag_name === $('GitHub Trigger').first().json.body.release.tag_name === false && r.draft === false)[0].tag_name }}
Sample output:
[
  { "tag_name": "v2.4.0", "draft": false },
  { "tag_name": "v2.3.2", "draft": false },
  { "tag_name": "v2.3.1", "draft": false }
]

For the very first release of a repository there is no previous tag. In that case fall back to the first commit by using the default branch history instead of a compare.

3 Compare commits between the two tags

The n8n HTTP Request node handles authentication for you once the GitHub credential is saved, so no token ever appears in the n8n workflow JSON.

The GitHub compare endpoint returns every commit reachable from the new tag but not from the old one. Add a second HTTP Request node.

Node: HTTP Request
Method: GET
URL: https://api.github.com/repos/acme-corp/billing-api/compare/v2.3.2...v2.4.0
Authentication: GitHub API credential
Options: Response Format: JSON
Pagination: off (compare returns up to 250 commits)

Build the URL from your earlier data with an expression so the node works for any repository:

Expression: https://api.github.com/repos/{{ $('GitHub Trigger').first().json.body.repository.full_name }}/compare/{{ $json.previousTag }}...{{ $('GitHub Trigger').first().json.body.release.tag_name }}
Sample output (trimmed):
{
  "status": "ahead",
  "ahead_by": 3,
  "commits": [
    { "sha": "a1b2c3d4e5f6", "html_url": "https://github.com/acme-corp/billing-api/commit/a1b2c3d4e5f6",
      "commit": { "message": "feat(invoices): add PDF export" } },
    { "sha": "b2c3d4e5f6a7", "html_url": "https://github.com/acme-corp/billing-api/commit/b2c3d4e5f6a7",
      "commit": { "message": "fix(tax): round VAT per line" } },
    { "sha": "c3d4e5f6a7b8", "html_url": "https://github.com/acme-corp/billing-api/commit/c3d4e5f6a7b8",
      "commit": { "message": "feat(api): drop v1 endpoints\n\nBREAKING CHANGE: v1 removed" } }
  ]
}

Compare is limited to 250 commits per response. For larger releases, page through with the page query parameter.

4 Parse conventional commits

Because n8n Code nodes run plain JavaScript, the parsing logic stays easy to unit test outside n8n before you paste it in.

This is the core of the workflow. Add a Code node set to Run Once for All Items. It matches each message against the conventional commit pattern, detects breaking changes from either an exclamation marker after the type (matched as \x21 in the regex) or a BREAKING CHANGE footer, and groups everything by category.

Node: Code
Mode: Run Once for All Items
Language: JavaScript

const commits = $input.first().json.commits;
const re = /^(feat|fix|chore|docs|refactor|perf|test|build|ci)(\(([^)]+)\))?(\x21)?:\s*(.+)/;
const groups = { breaking: [], feat: [], fix: [], chore: [] };
for (const c of commits) {
  const msg = c.commit.message;
  const first = msg.split('\n')[0];
  const m = first.match(re);
  if (m === null) { groups.chore.push({ text: first, sha: c.sha, url: c.html_url }); continue; }
  const item = { text: m[5], scope: m[3] || '', sha: c.sha, url: c.html_url };
  const isBreaking = m[4] === '\x21' || msg.includes('BREAKING CHANGE');
  if (isBreaking) groups.breaking.push(item);
  else if (m[1] === 'feat') groups.feat.push(item);
  else if (m[1] === 'fix') groups.fix.push(item);
  else groups.chore.push(item);
}
return [{ json: groups }];
Sample output:
{
  "breaking": [{ "text": "drop v1 endpoints", "scope": "api", "sha": "c3d4e5f6a7b8" }],
  "feat": [{ "text": "add PDF export", "scope": "invoices", "sha": "a1b2c3d4e5f6" }],
  "fix": [{ "text": "round VAT per line", "scope": "tax", "sha": "b2c3d4e5f6a7" }],
  "chore": []
}

Messages that do not match the pattern fall into chore so nothing is silently dropped. Merge commits are a common source of these.

5 Generate the markdown

Keeping the markdown builder in its own n8n node means you can change the layout later without touching the n8n parsing step.

A second Code node turns the groups into a markdown document. Each line carries a short hash that links to the commit, so readers can click through to the diff.

Node: Code
Mode: Run Once for All Items

const g = $input.first().json;
const tag = $('GitHub Trigger').first().json.body.release.tag_name;
const line = (i) => `- ${i.scope ? '**' + i.scope + ':** ' : ''}${i.text} ([${i.sha.slice(0,7)}](${i.url}))`;
let md = `# ${tag}\n\n`;
if (g.breaking.length) md += `## Breaking changes\n${g.breaking.map(line).join('\n')}\n\n`;
if (g.feat.length) md += `## Features\n${g.feat.map(line).join('\n')}\n\n`;
if (g.fix.length) md += `## Fixes\n${g.fix.map(line).join('\n')}\n\n`;
return [{ json: { tag, markdown: md.trim() } }];

Chores are intentionally left out of customer-facing notes. Keep them in the data if you want an internal changelog later.

Sample output:
{
  "tag": "v2.4.0",
  "markdown": "# v2.4.0\n\n## Breaking changes\n- **api:** drop v1 endpoints ([c3d4e5f](https://github.com/...))\n\n## Features\n- **invoices:** add PDF export ([a1b2c3d](https://github.com/...))"
}

6 Post to Slack

The n8n Slack node reuses one saved credential, so adding a second channel for another team is a copy of this single n8n node.

Add a Slack node to announce the release. Slack uses its own mrkdwn dialect, so convert double asterisks to single ones with a replace call in the message expression.

Node: Slack
Resource: Message
Operation: Send
Send Message To: Channel
Channel: #releases
Message Type: Simple Text Message
Text: {{ $json.markdown.replace(/\*\*/g, '*').replace(/^#+ /gm, '') }}
Options: Include Link to Workflow: off

The expression strips markdown heading markers, which Slack does not render, and converts bold syntax.

7 Create the Confluence page

Since n8n has no built in Confluence node, the n8n HTTP Request node is the supported way to reach the REST API.

The last HTTP Request node calls the Confluence Cloud REST API to create a child page. Confluence storage format is XHTML, so convert the markdown headings and list items before sending, or pass the content as a simple paragraph block.

Node: HTTP Request
Method: POST
URL: https://your-site.atlassian.net/wiki/rest/api/content
Authentication: Generic Credential Type, Basic Auth
Send Body: JSON
Body:
{
  "type": "page",
  "title": "{{ $json.tag }} Release Notes",
  "ancestors": [{ "id": "123456789" }],
  "space": { "key": "REL" },
  "body": { "storage": { "value": "{{ $json.html }}", "representation": "storage" } }
}

The ancestors id is the page id of the parent page in the Release Notes space. A tiny conversion step before this node can map lines starting with two hashes to h2 tags and lines starting with a dash to li tags wrapped in ul.

Sample response:
{
  "id": "987654321",
  "type": "page",
  "title": "v2.4.0 Release Notes",
  "_links": { "webui": "/spaces/REL/pages/987654321" }
}

Before you activate the n8n workflow, publish a test release on a scratch repository and watch the n8n execution from start to finish. Each n8n node shows its input and output, so you can confirm the tag pair, the grouped commits, and the final markdown. When it looks right, activate the workflow and let n8n listen for real releases.

Troubleshooting

The compare call returns 404

The tag name in the URL does not exist yet or contains a slash. Tags such as release/2.4 must be URL encoded with encodeURIComponent() in the expression. Also confirm the token can read the repository. Open the failed execution in n8n and inspect the exact URL the n8n node built.

Confluence rejects the page with a 400 error

Storage format must be valid XHTML. Unescaped ampersands and angle brackets in commit messages break it. Escape the message text in the conversion step, and make sure a page with the same title does not already exist in the space. Check the n8n node output tab for the raw body before blaming the API.

Squash merges produce one giant commit

If your team squash merges, the commit title is the pull request title. Enforce the conventional format with a pull request title check, otherwise most entries land in the chore group. Use the n8n Execute Node button on the parsing step with a few sample commits to confirm the grouping.

Free n8n Workflow Resources

Get started with the free workflow pack on the LightCube Labs templates page.

Production-Ready Workflow Packs

Get this workflow and more, with setup guides, editable configurations, and a shared failure-alert system.

View the Starter ($29) and Pro ($99) Packs

Frequently Asked Questions

Does this n8n workflow work if my team does not use conventional commits?

Yes, but the n8n output will be thin. Unmatched messages land in the chore group and are left out of the notes. Adopt the format gradually, or add a keyword fallback so messages starting with Add or Fix are still sorted. Everything described here runs in n8n without extra services.

How does the workflow find the previous version tag?

It lists recent releases through the GitHub API and takes the newest published one that is not the current tag. If you tag without creating releases, call the tags endpoint and sort with a semantic version comparison instead. The n8n execution log shows which tag pair was compared.

Can the release notes go somewhere other than Slack and Confluence?

Yes. Add another n8n node after the markdown step: an Email Send node for customers, a Discord node, or an HTTP Request that updates the GitHub release body so the notes also appear on the release page. Each addition is just another n8n node on the canvas.

Will it run again if I edit a release after publishing?

No. The trigger listens for the published action only. Edits send a different action, which the IF condition filters out, so you will not get duplicate Slack posts or Confluence pages. You can confirm this in the n8n executions list.