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
10 changes: 5 additions & 5 deletions .github/workflows/sync.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ permissions:

jobs:
sync:
name: Version tags
name: Versions
runs-on: ubuntu-latest
permissions:
actions: read
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
20 changes: 20 additions & 0 deletions site/scripts/rules.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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}`]
Expand Down
25 changes: 25 additions & 0 deletions site/scripts/rules.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
compareVersions,
concernsSibling,
isVersion,
nextVersion,
parseTag,
tagsFor,
} from './rules.mjs';
Expand Down Expand Up @@ -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'), [
Expand Down
124 changes: 95 additions & 29 deletions site/scripts/sync.mjs
Original file line number Diff line number Diff line change
@@ -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}`,
Expand Down Expand Up @@ -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]) => [
Expand All @@ -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
? [
{
Expand All @@ -124,27 +157,57 @@ 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);
if (await exists(file)) {
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}`);
Expand All @@ -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(', ')}.`
Expand Down
2 changes: 2 additions & 0 deletions site/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading