From 15503b009fd767dcd99f08abb847bc66e611a9fc Mon Sep 17 00:00:00 2001 From: Amr Mohammed El-Sheraey <141947355+AmrMsCLL@users.noreply.github.com> Date: Sun, 4 Oct 2026 15:39:20 +0300 Subject: [PATCH] feat: cut versions automatically in the release sync For every project with releases: auto, the daily sync now tags the next version at its newest commit when user-facing commits have landed since the last tag: minor for features or removals, patch for fixes and other changes, major for breaking changes from 1.0.0. Commits are attributed by the project's paths, so projects inside an epic version on their own. Without --cut the script only reports what it would tag. --- .github/workflows/sync.yml | 10 +-- site/scripts/rules.mjs | 20 ++++++ site/scripts/rules.test.mjs | 25 ++++++++ site/scripts/sync.mjs | 124 +++++++++++++++++++++++++++--------- site/src/types.ts | 2 + 5 files changed, 147 insertions(+), 34 deletions(-) diff --git a/.github/workflows/sync.yml b/.github/workflows/sync.yml index 0f31053..e10be75 100644 --- a/.github/workflows/sync.yml +++ b/.github/workflows/sync.yml @@ -16,7 +16,7 @@ permissions: jobs: sync: - name: Version tags + name: Versions runs-on: ubuntu-latest permissions: actions: read @@ -44,12 +44,12 @@ jobs: private-key: ${{ secrets.PLATFORM_APP_PRIVATE_KEY }} owner: ${{ github.repository_owner }} - - name: Draft releases from version tags + - name: Tag new versions and draft their entries id: sync continue-on-error: true env: GH_TOKEN: ${{ steps.app-token.outputs.token }} - run: npm run sync:releases + run: npm run sync:releases -- --cut - run: node scripts/compile.mjs @@ -82,7 +82,7 @@ jobs: --base main \ --head "$branch" \ --title "chore: record releases" \ - --body "Drafts changelog entries for version tags that do not have one yet. Titles and summaries can be refined in a follow-up pull request." + --body "Tags a new version for each project with releases: auto that has user-facing commits since its last version, and drafts changelog entries for every version tag without one. Titles and summaries can be refined in a follow-up pull request." pr="$(gh pr list --head "$branch" --state open --json number --jq '.[0].number')" fi @@ -132,5 +132,5 @@ jobs: - name: Report unread repositories if: steps.sync.outcome == 'failure' run: | - echo "::error::The sync could not read every repository; see the Draft releases from version tags step." + echo "::error::The sync could not read every repository; see the Tag new versions and draft their entries step." exit 1 diff --git a/site/scripts/rules.mjs b/site/scripts/rules.mjs index 875f2be..1580c51 100644 --- a/site/scripts/rules.mjs +++ b/site/scripts/rules.mjs @@ -117,6 +117,26 @@ export function compareVersions(a, b) { return 0; } +export function nextVersion(current, types) { + if (!current) { + return '0.1.0'; + } + + const [core, prerelease] = current.split(/-(.*)/s); + if (prerelease) { + return core; + } + + const [major, minor, patch] = core.split('.').map(Number); + if (types.includes('breaking')) { + return major === 0 ? `0.${minor + 1}.0` : `${major + 1}.0.0`; + } + if (types.includes('added') || types.includes('removed')) { + return `${major}.${minor + 1}.0`; + } + return `${major}.${minor}.${patch + 1}`; +} + export function tagsFor(project, version) { return project.parent ? [`${project.slug}/v${version}`] diff --git a/site/scripts/rules.test.mjs b/site/scripts/rules.test.mjs index 199739d..dcb9b1a 100644 --- a/site/scripts/rules.test.mjs +++ b/site/scripts/rules.test.mjs @@ -5,6 +5,7 @@ import { compareVersions, concernsSibling, isVersion, + nextVersion, parseTag, tagsFor, } from './rules.mjs'; @@ -133,6 +134,30 @@ describe('compareVersions', () => { }); }); +describe('nextVersion', () => { + it('starts a project with no versions at 0.1.0', () => { + assert.equal(nextVersion(null, ['fixed']), '0.1.0'); + }); + + it('bumps the minor version for added or removed features', () => { + assert.equal(nextVersion('1.4.0', ['fixed', 'added']), '1.5.0'); + assert.equal(nextVersion('1.4.2', ['removed']), '1.5.0'); + }); + + it('bumps the patch version for fixes and other changes', () => { + assert.equal(nextVersion('1.4.0', ['fixed', 'changed', 'security']), '1.4.1'); + }); + + it('bumps the major version for breaking changes from 1.0.0 onwards', () => { + assert.equal(nextVersion('2.3.1', ['breaking', 'added']), '3.0.0'); + assert.equal(nextVersion('0.3.0', ['breaking']), '0.4.0'); + }); + + it('releases the pending version after a prerelease', () => { + assert.equal(nextVersion('1.0.0-rc.2', ['fixed']), '1.0.0'); + }); +}); + describe('tagsFor', () => { it('accepts a bare tag only for a repository top-level entry', () => { assert.deepEqual(tagsFor({ slug: 'graphite-tracker', parent: null }, '1.0.0'), [ diff --git a/site/scripts/sync.mjs b/site/scripts/sync.mjs index c89df2c..6e3a834 100644 --- a/site/scripts/sync.mjs +++ b/site/scripts/sync.mjs @@ -1,15 +1,25 @@ import { access, mkdir, writeFile } from 'node:fs/promises'; import { stringify } from 'yaml'; import { loadRegistry, RELEASES } from './releases.mjs'; -import { classify, compareVersions, concernsSibling, parseTag } from './rules.mjs'; +import { + classify, + compareVersions, + concernsSibling, + nextVersion, + parseTag, + tagsFor, +} from './rules.mjs'; const token = process.env.GH_TOKEN; if (!token) { throw new Error('GH_TOKEN is required'); } -async function github(path) { +const cut = process.argv.includes('--cut'); + +async function github(path, init = {}) { const response = await fetch(`https://api.github.com${path}`, { + ...init, headers: { Accept: 'application/vnd.github+json', Authorization: `Bearer ${token}`, @@ -48,25 +58,18 @@ async function exists(url) { } } -function releasesByProject(repo, projects, tags) { - const inRepo = projects.filter((project) => project.repo.toLowerCase() === repo.toLowerCase()); +function releasesByProject(inRepo, tags) { const topLevel = inRepo.find((project) => !project.parent); - const found = new Map(); + const found = new Map(inRepo.map((project) => [project, new Map()])); for (const tag of tags) { const parsed = parseTag(tag.name); const project = parsed?.slug ? inRepo.find((candidate) => candidate.slug === parsed.slug) : topLevel; - if (!parsed || !project) { - continue; - } - - const versions = found.get(project) ?? new Map(); - if (!versions.has(parsed.version)) { - versions.set(parsed.version, { tag: tag.name, sha: tag.commit.sha }); + if (parsed && project && !found.get(project).has(parsed.version)) { + found.get(project).set(parsed.version, { tag: tag.name, sha: tag.commit.sha }); } - found.set(project, versions); } return [...found].map(([project, versions]) => [ @@ -77,28 +80,58 @@ function releasesByProject(repo, projects, tags) { ]); } -async function commitsFor(repo, previous, tag) { +async function commitsBetween(repo, previous, head) { if (!previous) { - return paginate(`/repos/${repo}/commits?sha=${encodeURIComponent(tag)}`); + return paginate(`/repos/${repo}/commits?sha=${head}`); } const commits = await paginate( - `/repos/${repo}/compare/${previous}...${tag}`, + `/repos/${repo}/compare/${previous}...${head}`, (page) => page.commits, ); return commits.reverse(); } -async function draft(project, release, previous, siblings) { +async function touching(repo, paths) { + const shas = new Set(); + for (const path of paths) { + for (const commit of await paginate(`/repos/${repo}/commits?path=${path}`)) { + shas.add(commit.sha); + } + } + return shas; +} + +async function attribute(project, inRepo, commits) { + if (project.paths.length > 0) { + const own = await touching(project.repo, project.paths); + return commits.filter((commit) => own.has(commit.sha)); + } + + const childPaths = inRepo + .filter((other) => other.parent === project.slug) + .flatMap((child) => child.paths); + if (childPaths.length > 0) { + const theirs = await touching(project.repo, childPaths); + return commits.filter((commit) => !theirs.has(commit.sha)); + } + + const siblings = inRepo + .filter((other) => project.parent && other.parent === project.parent && other !== project) + .map((other) => other.slug); + return commits.filter( + (commit) => !concernsSibling(commit.commit.message, project.slug, siblings), + ); +} + +async function draft(project, inRepo, release, previous) { const [head, commits] = await Promise.all([ github(`/repos/${project.repo}/commits/${release.sha}`), - commitsFor(project.repo, previous, release.tag), + commitsBetween(project.repo, previous, release.sha), ]); - const changes = commits.flatMap((commit) => { - const change = concernsSibling(commit.commit.message, project.slug, siblings) - ? null - : classify(commit.commit.message); + const changes = (await attribute(project, inRepo, commits)).flatMap((commit) => { + const change = classify(commit.commit.message); return change ? [ { @@ -124,19 +157,49 @@ async function draft(project, release, previous, siblings) { }; } +async function nextRelease(project, inRepo, latest, branch) { + const commits = await attribute( + project, + inRepo, + await commitsBetween(project.repo, latest?.tag ?? null, branch), + ); + const types = commits.flatMap((commit) => classify(commit.commit.message)?.type ?? []); + if (types.length === 0) { + return null; + } + + const version = nextVersion(latest?.version ?? null, types); + return { version, tag: tagsFor(project, version)[0], sha: commits[0].sha }; +} + const { projects } = await loadRegistry(); const repos = [...new Set(projects.map((project) => project.repo.toLowerCase()))].sort(); const drafted = []; +const tagged = []; const failed = []; for (const repo of repos) { try { - const tags = await paginate(`/repos/${repo}/tags`); - - for (const [project, releases] of releasesByProject(repo, projects, tags)) { - const siblings = projects - .filter((other) => project.parent && other.parent === project.parent && other !== project) - .map((other) => other.slug); + const inRepo = projects.filter((project) => project.repo.toLowerCase() === repo); + const [tags, { default_branch: branch }] = await Promise.all([ + paginate(`/repos/${repo}/tags`), + github(`/repos/${repo}`), + ]); + + for (const [project, releases] of releasesByProject(inRepo, tags)) { + if (project.releases === 'auto' && !(project.parent && project.paths.length === 0)) { + const release = await nextRelease(project, inRepo, releases.at(-1), branch); + if (release && cut) { + await github(`/repos/${repo}/git/refs`, { + method: 'POST', + body: JSON.stringify({ ref: `refs/tags/${release.tag}`, sha: release.sha }), + }); + releases.push(release); + tagged.push(`${repo}@${release.tag}`); + } else if (release) { + tagged.push(`${repo}@${release.tag} (not created without --cut)`); + } + } for (const [index, release] of releases.entries()) { const file = new URL(`${project.slug}/${release.version}.yml`, RELEASES); @@ -144,7 +207,7 @@ for (const repo of repos) { continue; } - const entry = await draft(project, release, releases[index - 1]?.tag ?? null, siblings); + const entry = await draft(project, inRepo, release, releases[index - 1]?.tag ?? null); await mkdir(new URL(`${project.slug}/`, RELEASES), { recursive: true }); await writeFile(file, stringify(entry, { lineWidth: 100 }), 'utf8'); drafted.push(`${project.slug}@${release.version}`); @@ -156,6 +219,9 @@ for (const repo of repos) { } } +console.log( + tagged.length > 0 ? `Tagged ${tagged.join(', ')}.` : 'No project has untagged changes.', +); console.log( drafted.length > 0 ? `Drafted ${drafted.length} releases: ${drafted.join(', ')}.` diff --git a/site/src/types.ts b/site/src/types.ts index f0cda9c..6bf6a35 100644 --- a/site/src/types.ts +++ b/site/src/types.ts @@ -10,6 +10,8 @@ export interface Project { owner: string; authors: string[]; status: 'live' | 'wip' | 'archived' | 'source-only'; + paths: string[]; + releases: 'auto' | 'manual'; showcase: ProjectShowcase | null; url: string | null; path: string;