Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
212 changes: 127 additions & 85 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ permissions:
env:
NPM_CONFIG_FUND: false
AGENT_RELAY_TELEMETRY_DISABLED: 1
WITHDRAWN_STABLE_TAG_PATTERN: '^(v6\.3\.6)$'

jobs:
# Build Rust broker binary for all platforms (needed by SDK's AgentRelayClient)
Expand Down Expand Up @@ -1915,8 +1916,14 @@ jobs:
exit 0
fi

# Get last stable tag (semver only, excluding prerelease tags like v2.1.0-beta.1)
LAST_TAG=$(git tag -l --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -n1)
# Get last published stable tag (semver only, excluding prerelease
# tags like v2.1.0-beta.1 and withdrawn stable tags).
LAST_TAG=$(
git tag -l --sort=-v:refname |
grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' |
grep -Ev "$WITHDRAWN_STABLE_TAG_PATTERN" |
head -n1
)
if [ -z "$LAST_TAG" ]; then
echo "No previous tag found, skipping changelog generation"
exit 0
Expand All @@ -1941,110 +1948,140 @@ jobs:
}

const commits = log.split('\0').filter(Boolean).map(record => {
const idx = record.indexOf('|');
const idx2 = record.indexOf('|', idx + 1);
const normalized = record.trimStart();
const idx = normalized.indexOf('|');
const idx2 = normalized.indexOf('|', idx + 1);
const hash = normalized.slice(0, 8);
const files = execSync(`git show --pretty=format: --name-only ${hash}`, {
encoding: 'utf-8',
Comment on lines +1947 to +1949

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use full commit SHA for git show lookup.

git show is currently called with an 8-char hash prefix. In large repos this can become ambiguous and return the wrong commit (or fail), which can misclassify changelog entries.

Suggested fix
-            const hash = normalized.slice(0, 8);
-            const files = execSync(`git show --pretty=format: --name-only ${hash}`, {
+            const hash = normalized.slice(0, idx);
+            const files = execSync(`git show --pretty=format: --name-only ${hash}`, {
               encoding: 'utf-8',
             })
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/publish.yml around lines 1947 - 1949, The code computes a
short 8-char commit prefix in variable "hash" from "normalized" and then uses
execSync to run `git show --name-only ${hash}`, which can be ambiguous; change
it to use the full commit SHA instead of the 8-char prefix by passing the full
"normalized" value (or another full-sha variable) into the execSync call so that
the `git show` lookup is unambiguous (update the variable used in the `git show`
command that assigns "files" accordingly).

})
.split('\n')
.map(file => file.trim())
.filter(Boolean);
return {
hash: record.slice(0, 8),
subject: record.slice(idx + 1, idx2).trim(),
body: record.slice(idx2 + 1).trim(),
hash,
subject: normalized.slice(idx + 1, idx2).trim(),
body: normalized.slice(idx2 + 1).trim(),
files,
};
});

function extractPR(subject, body) {
const m = (subject + ' ' + body).match(/#(\d+)/);
return m ? `(#${m[1]})` : '';
function parseSubject(subject) {
const conventional = subject.match(
/^(feat|fix|refactor|perf|chore|test|ci|docs|build|style|security|deprecate|deprecated|remove|removed)(\(([^)]+)\))?(!)?:\s*(.*)$/i
);

if (!conventional) {
return {
type: 'changed',
scope: '',
title: cleanTitle(subject),
breaking: false,
};
}

const [, typeRaw, , scopeRaw = '', bang = '', titleRaw] = conventional;
const type = typeRaw.toLowerCase();
return {
type,
scope: scopeRaw.toLowerCase(),
title: cleanTitle(titleRaw),
breaking: bang === '!',
};
Comment on lines +1969 to +1990

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Detect BREAKING CHANGE: footers, not just ! in subjects.

On Line 1953-Line 1974, breaking detection only checks ! in the subject. Commits that declare breaking changes in body/footer (BREAKING CHANGE:) will be miscategorized (typically into Added/Changed) when processed on Line 2027-Line 2031.

💡 Proposed fix
-          function parseSubject(subject) {
+          function parseSubject(subject, body = '') {
             const conventional = subject.match(
               /^(feat|fix|refactor|perf|chore|test|ci|docs|build|style|security|deprecate|deprecated|remove|removed)(\(([^)]+)\))?(!)?:\s*(.*)$/i
             );

             if (!conventional) {
               return {
                 type: 'changed',
                 scope: '',
                 title: cleanTitle(subject),
-                breaking: false,
+                breaking: /(^|\n)\s*BREAKING[\s-]CHANGE\s*:/i.test(body),
               };
             }

             const [, typeRaw, , scopeRaw = '', bang = '', titleRaw] = conventional;
             const type = typeRaw.toLowerCase();
             return {
               type,
               scope: scopeRaw.toLowerCase(),
               title: cleanTitle(titleRaw),
-              breaking: bang === '!',
+              breaking: bang === '!' || /(^|\n)\s*BREAKING[\s-]CHANGE\s*:/i.test(body),
             };
           }
@@
-            const parsed = parseSubject(c.subject);
+            const parsed = parseSubject(c.subject, c.body);

Also applies to: 2026-2031

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/publish.yml around lines 1953 - 1974, The parseSubject
function currently only treats a trailing "!" in the subject as breaking; update
parseSubject to also inspect the commit body/footer for "BREAKING CHANGE:"
(case-insensitive and multi-line) and set breaking: true if that footer exists.
Locate parseSubject and the destructuring that produces breaking from bang and
instead compute breaking = (bang === '!') || /(^|\\n)BREAKING[
-]?CHANGE:/i.test(fullMessageOrBody) so the returned object (type, scope, title,
breaking) reflects either a "!" in the subject or a BREAKING CHANGE footer;
ensure the code that calls parseSubject supplies the commit body/footer text or
adjust its caller to provide it.

}

function formatTitle(subject) {
const cleaned = subject.replace(
/^(feat|fix|refactor|perf|chore|test|ci|docs|build|style)(\([^)]+\))?!?:\s*/i,
''
);
function cleanTitle(title) {
const cleaned = title
.replace(/\s*\(#[0-9]+(?:[^)]*)?\)/g, '')
.replace(/\s+#\d+\b/g, '')
.replace(/\s+/g, ' ')
.trim();
return cleaned.charAt(0).toUpperCase() + cleaned.slice(1);
}

function getType(subject) {
const m = subject.match(/^(feat|fix|refactor|perf|chore|test|ci|docs|build|style)(\([^)]+\))?(!)?:/i);
if (!m) return 'other';
const type = m[1].toLowerCase();
const scope = (m[2] || '').replace(/[()]/g, '');
const breaking = m[3] === '!';
if (breaking) return 'breaking';
if (type === 'feat') return 'feat';
if (type === 'fix') return 'fix';
if (type === 'refactor' || type === 'perf' || type === 'build') return 'arch';
if (type === 'test' || type === 'ci') return 'reliability';
if (type === 'chore' && scope === 'deps') return 'deps';
if (type === 'chore' && scope === 'release') return 'release';
if (type === 'chore') return 'deps';
return 'other';
function isWebOnlyCommit(files) {
const webOrLockfile = file =>
file === 'web' || file.startsWith('web/') || file === 'package-lock.json';
return files.length > 0 && files.some(file => file === 'web' || file.startsWith('web/')) && files.every(webOrLockfile);
}

const cats = { breaking: [], feat: [], fix: [], arch: [], reliability: [], deps: [], release: [] };
function isWebRelatedTitle(text) {
return (
text.includes('@posthog/next') ||
text.includes('opennext') ||
text.includes('sst') ||
/\bnext\b/.test(text)
);
}

for (const c of commits) {
const type = getType(c.subject);
const title = formatTitle(c.subject);
const pr = extractPR(c.subject, c.body);
if (cats[type]) cats[type].push({ title, pr });
function shouldSkip({ type, scope, title }, files) {
const text = title.toLowerCase();
if (type === 'chore' && (scope === 'release' || scope === 'prerelease')) return true;
if (scope === 'web' || isWebOnlyCommit(files)) return true;
if (isWebRelatedTitle(text)) return true;
if (scope === 'trajectories' || scope === 'comments') return true;
if (text.includes('compact trajectories')) return true;
if (text.includes('record ') && text.includes('trajectory')) return true;
if (text.includes('address pr review')) return true;
if (text.includes('review feedback')) return true;
if (text.includes('retrigger flaky')) return true;
if (text === 'clean up skills' || text === 'bump skills') return true;
if (
(type === 'chore' || type === 'style') &&
(text.startsWith('auto-format ') || text.startsWith('format '))
) {
return true;
}
if (text.startsWith('revert version bump')) return true;
return title.length === 0;
}

const lines = [];
lines.push(`## [${newVersion}] - ${today}`);
lines.push('');
function sectionFor(commit) {
if (commit.breaking) return 'Breaking Changes';
if (commit.type === 'feat') return 'Added';
if (commit.type === 'fix') return 'Fixed';
if (commit.type === 'security') return 'Security';
if (commit.type === 'deprecate' || commit.type === 'deprecated') return 'Deprecated';
if (commit.type === 'remove' || commit.type === 'removed') return 'Removed';
return 'Changed';
}

const hasProd = cats.breaking.length + cats.feat.length + cats.fix.length > 0;
const hasTech = cats.arch.length + cats.reliability.length + cats.deps.length > 0;
const sections = new Map([
['Breaking Changes', []],
['Added', []],
['Changed', []],
['Deprecated', []],
['Removed', []],
['Fixed', []],
['Security', []],
]);

if (hasProd) {
lines.push('### Product Perspective');
if (cats.breaking.length > 0) {
lines.push('#### Breaking Changes');
for (const c of cats.breaking) lines.push(`- **${c.title}** ${c.pr}`.trimEnd());
lines.push('');
}
if (cats.feat.length > 0) {
lines.push('#### User-Facing Features & Improvements');
for (const c of cats.feat) lines.push(`- **${c.title}** ${c.pr}`.trimEnd());
lines.push('');
}
if (cats.fix.length > 0) {
lines.push('#### User-Impacting Fixes');
for (const c of cats.fix) lines.push(`- ${c.title} ${c.pr}`.trimEnd());
lines.push('');
}
for (const c of commits) {
const parsed = parseSubject(c.subject);
if (shouldSkip(parsed, c.files)) continue;
const section = sectionFor(parsed);
const entries = sections.get(section);
if (!entries.includes(parsed.title)) entries.push(parsed.title);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Deduping by title can hide distinct changes that happen to share the same summary.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At .github/workflows/publish.yml, line 2026:

<comment>Deduping by title can hide distinct changes that happen to share the same summary.</comment>

<file context>
@@ -1950,101 +1950,100 @@ jobs:
+            if (shouldSkip(parsed)) continue;
+            const section = sectionFor(parsed);
+            const entries = sections.get(section);
+            if (!entries.includes(parsed.title)) entries.push(parsed.title);
+          }
+
</file context>
Suggested change
if (!entries.includes(parsed.title)) entries.push(parsed.title);
entries.push(parsed.title);

}

if (hasTech) {
lines.push('### Technical Perspective');
if (cats.arch.length > 0) {
lines.push('#### Architecture & API Changes');
for (const c of cats.arch) lines.push(`- ${c.title} ${c.pr}`.trimEnd());
lines.push('');
}
if (cats.reliability.length > 0) {
lines.push('#### Performance & Reliability');
for (const c of cats.reliability) lines.push(`- ${c.title} ${c.pr}`.trimEnd());
lines.push('');
}
if (cats.deps.length > 0) {
lines.push('#### Dependencies & Tooling');
for (const c of cats.deps) lines.push(`- ${c.title} ${c.pr}`.trimEnd());
lines.push('');
}
if ([...sections.values()].every(entries => entries.length === 0)) {
console.log('No changelog-worthy commits since last tag, skipping changelog');
process.exit(0);
}

if (!hasTech) {
lines.push('### Technical Perspective');
}
lines.push('#### Releases');
lines.push(`- v${newVersion}`);
lines.push('');
lines.push('---');
lines.push('');
const lines = [];
lines.push(`## [${newVersion}] - ${today}`);
lines.push('');

const newEntry = lines.join('\n');
for (const [section, entries] of sections) {
if (entries.length === 0) continue;
lines.push(`### ${section}`);
lines.push('');
for (const entry of entries) lines.push(`- ${entry}`);
lines.push('');
}

const newEntry = lines.join('\n').trimEnd() + '\n\n';
const changelog = readFileSync('CHANGELOG.md', 'utf-8');

// Insert after the [Unreleased] block, before the first versioned entry
Expand All @@ -2065,7 +2102,12 @@ jobs:
- name: Compact trajectories
run: |
if command -v npx &>/dev/null && [ -d ".trajectories" ]; then
LAST_TAG=$(git tag -l --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -n1)
LAST_TAG=$(
git tag -l --sort=-v:refname |
grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' |
grep -Ev "$WITHDRAWN_STABLE_TAG_PATTERN" |
head -n1
)
if [ -n "$LAST_TAG" ]; then
RELEASE_COMMITS=$(git log "${LAST_TAG}..HEAD" --format=%H | paste -sd, -)
if [ -n "$RELEASE_COMMITS" ]; then
Expand Down
23 changes: 18 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,26 @@ git push origin main # NO!

This ensures the user maintains control over what goes into the main branch.

## Changelog Style
## Changelog

Curate `[Unreleased]` in `CHANGELOG.md` as you land PRs. The root changelog is
the cross-package, user-facing release narrative for Relay. It follows
[Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and Semantic
Versioning.

Changelog entries should be concise and impact-first. Prefer one short bullet
per user-visible change: name the command, API, or schema touched and the
practical effect. Drop issue/PR links, internal review notes, implementation
backstory, and "foundation for..." phrasing unless that text clearly explains
the shipped impact.
per user-visible change: name the command, API, schema, or package touched and
the practical effect. Drop issue/PR links, internal review notes,
implementation backstory, release-only entries, and "foundation for..." phrasing
unless that text clearly explains the shipped impact.

Use Keep a Changelog sections (`Added`, `Changed`, `Deprecated`, `Removed`,
`Fixed`, `Security`), plus `Breaking Changes` and `Migration Guidance` when a
SemVer-major change needs explicit callouts. Do not use generated perspective
sections such as "Product Perspective", "Technical Perspective", or "Releases".
Do not add web-only changes to the changelog. Omit unpublished or withdrawn
versions as release headings; move their shipped user-visible changes into the
corrected published release.

## .trajectories Must Be Tracked

Expand Down
Loading
Loading