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
57 changes: 43 additions & 14 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -256,28 +256,57 @@ jobs:
}
NODE

- name: Prepare GitHub family release
id: github-release
run: |
node scripts/github-release-metadata.mjs \
.changeset/release-plan.json \
"$(git rev-parse HEAD)" \
"$GITHUB_REPOSITORY" \
"$RUNNER_TEMP/package-release-notes.md" \
"$GITHUB_OUTPUT"

- name: Reconcile family release tag
if: steps.github-release.outputs.has-release == 'true'
env:
FAMILY_RELEASE_TAG: ${{ steps.github-release.outputs.tag }}
run: |
if git rev-parse --verify "refs/tags/$FAMILY_RELEASE_TAG" \
>/dev/null 2>&1; then
existing_commit="$(git rev-list -n 1 "$FAMILY_RELEASE_TAG")"
release_commit="$(git rev-parse HEAD)"

if [ "$existing_commit" != "$release_commit" ]; then
echo "::error::Family tag $FAMILY_RELEASE_TAG points at $existing_commit, not $release_commit."
exit 1
fi

echo "Existing family tag: $FAMILY_RELEASE_TAG"
else
git tag "$FAMILY_RELEASE_TAG"
echo "New family tag: $FAMILY_RELEASE_TAG"
fi

- name: Push release tags
run: git push origin --tags

- name: Create GitHub releases
- name: Create GitHub family release
if: steps.github-release.outputs.has-release == 'true'
env:
FAMILY_RELEASE_NOTES: ${{ steps.github-release.outputs.notes }}
FAMILY_RELEASE_TAG: ${{ steps.github-release.outputs.tag }}
FAMILY_RELEASE_TITLE: ${{ steps.github-release.outputs.title }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
mapfile -t release_tags < <(git tag --points-at HEAD | grep "^@agentic-react/" || true)

if [ "${#release_tags[@]}" -eq 0 ]; then
echo "No package release tags point at HEAD."
exit 0
if gh release view "$FAMILY_RELEASE_TAG" >/dev/null 2>&1; then
echo "GitHub family release already exists for $FAMILY_RELEASE_TAG."
else
gh release create "$FAMILY_RELEASE_TAG" \
--title "$FAMILY_RELEASE_TITLE" \
--notes-file "$FAMILY_RELEASE_NOTES" \
--latest
fi

for tag in "${release_tags[@]}"; do
if gh release view "$tag" >/dev/null 2>&1; then
echo "GitHub release already exists for $tag."
else
gh release create "$tag" --title "$tag" --generate-notes --latest
fi
done

- name: Clear recovery state
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Expand Down
163 changes: 163 additions & 0 deletions scripts/github-release-metadata.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const publishableReleases = (plan) =>
plan.releases
.filter((release) => release.type !== 'none' && release.newVersion)
.toSorted((left, right) => left.name.localeCompare(right.name));

const packageDirectory = (packageName) => packageName.split('/').at(-1);

const changelogSection = (rootDir, release) => {
const changelogPath = path.join(
rootDir,
'packages',
packageDirectory(release.name),
'CHANGELOG.md',
);

if (!fs.existsSync(changelogPath)) {
return undefined;
}

const changelog = fs.readFileSync(changelogPath, 'utf8');
const heading = `## ${release.newVersion}`;
const headingIndex = changelog.indexOf(heading);

if (headingIndex === -1) {
return undefined;
}

const sectionStart = headingIndex + heading.length;
const remaining = changelog.slice(sectionStart);
const nextHeading = remaining.search(/\n## \S/);
const section =
nextHeading === -1 ? remaining : remaining.slice(0, nextHeading);

return section.trim() || undefined;
};

const releaseTitle = (releases) => {
if (releases.length === 1) {
return `${releases[0].name} v${releases[0].newVersion}`;
}

const versions = [...new Set(releases.map((release) => release.newVersion))];

if (versions.length === 1) {
return `Agentic React packages v${versions[0]}`;
}

return `Agentic React packages · ${versions.map((version) => `v${version}`).join(' / ')}`;
};

export const createGitHubReleaseMetadata = ({
plan,
repository,
rootDir,
sha,
}) => {
if (!/^[\w.-]+\/[\w.-]+$/.test(repository)) {
throw new Error(`Invalid GitHub repository: ${repository}`);
}

if (!/^[0-9a-f]{12,40}$/.test(sha)) {
throw new Error(`Invalid release commit SHA: ${sha}`);
}

const releases = publishableReleases(plan);

if (releases.length === 0) {
return undefined;
}

const shortSha = sha.slice(0, 12);
const tag = `packages@${shortSha}`;
const encodedTag = encodeURIComponent(tag);
const lines = [
'## Published packages',
'',
'These packages were built, tested, and published together from ' +
`release commit [\`${shortSha}\`](https://github.com/${repository}/commit/${sha}).`,
'',
'| Package | Version | npm | Changelog |',
'| --- | --- | --- | --- |',
];

for (const release of releases) {
const directory = packageDirectory(release.name);
const npmUrl =
`https://www.npmjs.com/package/${release.name}/v/` +
release.newVersion;
const changelogUrl =
`https://github.com/${repository}/blob/${encodedTag}/packages/` +
`${directory}/CHANGELOG.md`;

lines.push(
`| \`${release.name}\` | \`${release.newVersion}\` | ` +
`[npm](${npmUrl}) | [changelog](${changelogUrl}) |`,
);
}

const sections = releases
.map((release) => ({
name: release.name,
section: changelogSection(rootDir, release),
}))
.filter(({ section }) => section);

if (sections.length > 0) {
lines.push('', '## Package changes');

for (const { name, section } of sections) {
lines.push('', `### \`${name}\``, '', section);
}
}

return {
notes: `${lines.join('\n')}\n`,
tag,
title: releaseTitle(releases),
};
};

const isMain =
process.argv[1] &&
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);

if (isMain) {
const [planPath, sha, repository, notesPath, outputPath] =
process.argv.slice(2);

if (!planPath || !sha || !repository || !notesPath || !outputPath) {
throw new Error(
'Usage: github-release-metadata.mjs ' +
'<plan> <sha> <repository> <notes> <output>',
);
}

const plan = JSON.parse(fs.readFileSync(planPath, 'utf8'));
const metadata = createGitHubReleaseMetadata({
plan,
repository,
rootDir: process.cwd(),
sha,
});

if (!metadata) {
fs.appendFileSync(outputPath, 'has-release=false\n');
} else {
fs.writeFileSync(notesPath, metadata.notes);
fs.appendFileSync(
outputPath,
[
'has-release=true',
`tag=${metadata.tag}`,
`title=${metadata.title}`,
`notes=${notesPath}`,
'',
].join('\n'),
);
}
}
Loading
Loading