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;