Tested 2026-11-09. Before the code: UnAIMyText is a tool I work on, so it lands at #3 in the roundup below on purpose. The Node script is yours to copy.
Our release process asks a language model on Replicate to turn raw merge notes into a Keep a Changelog section. The first draft I got opened with "Sure, here's a polished changelog," called a bug fix "a seamless, robust improvement," and closed with "Hope that helps!"
Changelogs are for humans scanning git tags at midnight. Chat manners do not belong there.
So I wrote a small Node script. Feed it raw notes and it calls Replicate, then scores the draft. Feed it a file or stdin and it skips the API and only scores.
What a changelog entry has to survive
Keep a Changelog keeps categories boring on purpose: Added, Changed, Deprecated, Removed, Fixed, Security. Bullets. No essay.
Replicate's HTTP API, documented at HTTP API, is equally direct for official models: send Authorization: Bearer <token>, POST /v1/models/{owner}/{name}/predictions, optional Prefer: wait, and read output when status is succeeded.
The failure mode is not the API. It is the prose that comes back.
The linter rules
The script flags:
- Chatbot preambles (
Sure,Here's,Certainly) - Chat sign-offs (
hope that helps,as an AI) - Marketing filler (
seamless,leverage,empower, …) - More than two hedges in one entry
- Missing
## Added|Changed|Fixed|…section heading - No
-bullets - More than 220 words
-
TODO/TBD/lorem ipsumleftovers
Exit codes: 0 clean, 1 lint failures, 2 usage or API errors.
The script
#!/usr/bin/env node /** * Changelog entry linter for Keep a Changelog-style notes drafted via Replicate. * Usage: * node changelog_lint.js --draft "raw release notes" * node changelog_lint.js path/to/entry.md * node changelog_lint.js - # stdin * Exit: 0 clean, 1 lint failures, 2 usage/network/API errors */ 'use strict'; const fs = require('fs'); const http = require('http'); const https = require('https'); const { URL } = require('url'); const PREAMBLE = /^(sure[,!]?\s+|here(?:'s| is)\s+|certainly[,!]?\s+|of course[,!]?\s+)/i; const FILLER = /\b(seamless|robust|cutting[- ]edge|leverage|holistic|synergy|empower|delightful|next[- ]level|game[- ]changing)\b/i; const HEDGES = /\b(might|maybe|possibly|likely|perhaps|appears to|seems to)\b/gi; const SECTION = /^##\s+(Added|Changed|Deprecated|Removed|Fixed|Security)\s*$/m; const VERSION_LINE = /^##\s+\[?\d+\.\d+\.\d+\]?/; const CHAT_SIGNOFF = /\b(hope that helps|let me know if|happy to help|as an ai)\b/i; function usage() { process.stderr.write( 'usage: node changelog_lint.js --draft "notes" | <file> | -\n' ); process.exit(2); } function collectIssues(text) { const issues = []; const trimmed = text.trim(); if (!trimmed) { issues.push('empty changelog entry'); return issues; } const first = trimmed.split(/\n/)[0]; if (PREAMBLE.test(first)) issues.push('chatbot preamble on first line'); if (CHAT_SIGNOFF.test(trimmed)) issues.push('chatbot sign-off'); if (FILLER.test(trimmed)) issues.push('marketing filler lexicon'); const hedgeCount = (trimmed.match(HEDGES) || []).length; if (hedgeCount > 2) issues.push(`too many hedges (${hedgeCount})`); if (!SECTION.test(trimmed) && !/^###?\s+(Added|Changed|Fixed)/m.test(trimmed)) { issues.push('missing Keep a Changelog section heading (Added/Changed/Fixed/…)'); } if (!/^- /m.test(trimmed)) issues.push('no bullet items (- ) found'); const words = trimmed.split(/\s+/).filter(Boolean).length; if (words > 220) issues.push(`entry too long (${words} words; cap 220)`); if (/\bTODO\b|\bTBD\b|\blorem ipsum\b/i.test(trimmed)) { issues.push('placeholder text left in'); } return issues; } function extractOutput(body) { let data; try { data = JSON.parse(body); } catch (e) { throw new Error('response was not JSON'); } if (data.error) throw new Error(String(data.error)); if (data.detail) throw new Error(String(data.detail)); const out = data.output; if (out == null) throw new Error('missing output field'); if (Array.isArray(out)) return out.join(''); if (typeof out === 'string') return out; return JSON.stringify(out); } function postJson(urlStr, headers, payload) { return new Promise((resolve, reject) => { const u = new URL(urlStr); const lib = u.protocol === 'http:' ? http : https; const req = lib.request( { hostname: u.hostname, port: u.port || (u.protocol === 'http:' ? 80 : 443), path: u.pathname + u.search, method: 'POST', headers, }, (res) => { const chunks = []; res.on('data', (c) => chunks.push(c)); res.on('end', () => { const body = Buffer.concat(chunks).toString('utf8'); if (res.statusCode < 200 || res.statusCode >= 300) { reject(new Error(`HTTP ${res.statusCode}: ${body.slice(0, 200)}`)); return; } resolve(body); }); } ); req.on('error', reject); req.write(payload); req.end(); }); } async function draftFromReplicate(notes) { const token = process.env.REPLICATE_API_TOKEN; if (!token) throw new Error('REPLICATE_API_TOKEN not set'); const base = process.env.REPLICATE_BASE_URL || 'https://api.replicate.com'; const model = process.env.REPLICATE_MODEL || 'meta/meta-llama-3-8b-instruct'; const url = `${base}/v1/models/${model}/predictions`; const payload = JSON.stringify({ input: { prompt: 'Write a Keep a Changelog style release note section from these raw notes. ' + 'Use ## Fixed / ## Added / ## Changed as needed. Bullets only. No preamble.\n\n' + notes, max_tokens: 400, }, }); const headers = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', Prefer: 'wait=30', 'Content-Length': Buffer.byteLength(payload), }; const body = await postJson(url, headers, payload); return extractOutput(body); } function printResult(issues, asJson) { if (asJson) { process.stdout.write(JSON.stringify({ ok: issues.length === 0, issues }) + '\n'); } else if (issues.length === 0) { process.stdout.write('ok\n'); } else { for (const i of issues) process.stdout.write(`- ${i}\n`); } } async function main(argv) { const asJson = argv.includes('--json'); const args = argv.filter((a) => a !== '--json'); let text; if (args[0] === '--draft') { if (args.length < 2) usage(); text = await draftFromReplicate(args.slice(1).join(' ')); } else if (args.length === 0) { usage(); } else if (args[0] === '-') { text = fs.readFileSync(0, 'utf8'); } else { const path = args[0]; if (!fs.existsSync(path)) { process.stderr.write(`missing file: ${path}\n`); process.exit(2); } text = fs.readFileSync(path, 'utf8'); } const issues = collectIssues(text); printResult(issues, asJson); process.exit(issues.length === 0 ? 0 : 1); } main(process.argv.slice(2)).catch((err) => { process.stderr.write(String(err.message || err) + '\n'); process.exit(2); }); A local mock worth keeping
CI should not spend GPU credits to prove the client parses JSON. A tiny mock records the path, bearer presence, content type and Prefer header, then returns a canned output:
#!/usr/bin/env node 'use strict'; const http = require('http'); const mode = process.argv[2] || 'clean'; const port = Number(process.env.MOCK_PORT || 8787); const BODIES = { clean: `## Fixed\n- Corrected timezone labels on the billing export CSV.\n- Restored the retry button on failed webhook deliveries.\n`, robotic: `Sure, I'd be happy to help!\n\n## Added\n- We leverage a seamless, robust pipeline that empowers delightful deployments.\n- This might possibly improve things and likely appears to help.\n\nHope that helps! Let me know if you need anything else.\n`, empty: '', error: null, }; const server = http.createServer((req, res) => { const chunks = []; req.on('data', (c) => chunks.push(c)); req.on('end', () => { const raw = Buffer.concat(chunks).toString('utf8'); let parsed = {}; try { parsed = JSON.parse(raw || '{}'); } catch (_) {} const auth = req.headers.authorization || ''; const record = { path: req.url, method: req.method, bearer: auth.startsWith('Bearer ') ? 'present' : 'missing', type: req.headers['content-type'] || '', model_hint: (req.url || '').includes('/models/') ? 'path-model' : 'none', has_input: !!(parsed && parsed.input), prefer: req.headers.prefer || '', }; process.stderr.write(JSON.stringify(record) + '\n'); if (mode === 'error') { res.writeHead(401, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ detail: 'Invalid token.' })); return; } if (mode === 'empty') { res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': 2 }); res.end('{}'); return; } const output = BODIES[mode] != null ? BODIES[mode] : BODIES.clean; const payload = JSON.stringify({ id: 'mockpred', status: 'succeeded', output, model: 'meta/meta-llama-3-8b-instruct', }); res.writeHead(200, { 'Content-Length': Buffer.byteLength(payload), 'Content-Type': 'application/json', }); res.end(payload); }); }); server.listen(port, '127.0.0.1', () => { process.stdout.write(`mock listening on ${port} mode=${mode}\n`); }); How I tested it today
# samples node changelog_lint.js samples/robotic.md # exit 1 node changelog_lint.js samples/clean.md # exit 0 node changelog_lint.js --json samples/long.md # mock draft (REPLICATE_BASE_URL=http://127.0.0.1:8787) node mock_replicate.js clean & REPLICATE_API_TOKEN=r8_test REPLICATE_BASE_URL=http://127.0.0.1:8787 \ node changelog_lint.js --draft "fix webhook retry" # real API with a fake key -> exit 2 (auth/HTTP error path) REPLICATE_API_TOKEN=r8_fake Source:https://dev.to/unaimytext_app/humanize-replicate-output-in-nodejs-a-changelog-entry-linter-5-free-tools-2026-4fa1Related posts
NHTSA: most Waymo school-bus passes came after the fix
From AI Idea to Workflow Evidence: a 14-Case Admission Test for Business Automation
Only the Design of a Local LLM Eval Harness Survives — Rebuilding It for Qwen3.8 27B
The grid decision that rewrote my lightbox logic
Debugging 416 Range Not Satisfiable
