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
34 changes: 34 additions & 0 deletions .github/workflows/alpha-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: Alpha Release

on:
workflow_dispatch:
inputs:
dry_run:
description: Perform a dry run?
type: boolean
default: false

permissions:
contents: write

jobs:
alpha-release:
name: Alpha release
runs-on: ubuntu-latest
if: github.repository_owner == 'NanoForge-dev' && github.ref_name != 'main' && github.ref_name != 'master'
steps:
- name: Checkout repository
uses: actions/checkout@v6

- name: Prepare
uses: ./.github/actions/prepare

- name: Release alpha
uses: ./actions/release-dev
with:
package: "@nanoforge-dev/actions"
tag: alpha
dry: ${{ inputs.dry_run }}
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
10 changes: 5 additions & 5 deletions .github/workflows/pre-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ on:
workflow_dispatch:
inputs:
version:
description: "New version of the package (leave empty for auto generated version)"
description: "New version for the packages"
type: string
required: false
required: true
dry_run:
description: Perform a dry run?
type: boolean
Expand All @@ -31,10 +31,10 @@ jobs:
- name: Prepare
uses: ./.github/actions/prepare

- name: Release packages
uses: ./dist/create-release-pr
- name: Create release PR
uses: ./actions/create-packages-release-pr
with:
package: "@nanoforge-dev/actions"
packages: "@nanoforge-dev/actions"
version: ${{ inputs.version }}
dry: ${{ inputs.dry_run }}
env:
Expand Down
31 changes: 0 additions & 31 deletions .github/workflows/release-tag.yml

This file was deleted.

42 changes: 31 additions & 11 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,21 +1,20 @@
name: Release

on:
workflow_dispatch:
inputs:
dry_run:
description: Perform a dry run?
type: boolean
default: false
pull_request:
types:
- closed
branches:
- main

permissions:
contents: write

jobs:
npm-publish:
name: npm publish
release:
name: Release
runs-on: ubuntu-latest
if: github.repository_owner == 'NanoForge-dev'
if: github.repository_owner == 'NanoForge-dev' && github.event.pull_request.merged == true && startsWith(github.head_ref, 'releases/')
steps:
- name: Checkout repository
uses: actions/checkout@v6
Expand All @@ -24,10 +23,31 @@ jobs:
uses: ./.github/actions/prepare

- name: Release packages
uses: ./dist/release-packages
uses: ./actions/release-packages
with:
packages: "@nanoforge-dev/actions"
tag-format: "{version}"
latest: true
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

beta-release:
name: Beta release
runs-on: ubuntu-latest
if: github.repository_owner == 'NanoForge-dev' && github.event.pull_request.merged == true && !startsWith(github.head_ref, 'releases/')
steps:
- name: Checkout repository
uses: actions/checkout@v6

- name: Prepare
uses: ./.github/actions/prepare

- name: Release beta
uses: ./actions/release-dev
with:
package: "@nanoforge-dev/actions"
dry: ${{ inputs.dry_run }}
tag: beta
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
2 changes: 1 addition & 1 deletion .husky/commit-msg
Original file line number Diff line number Diff line change
@@ -1 +1 @@
pnpm --no-install commitlint --edit "$1"
pnpm commitlint --edit "$1"
2 changes: 1 addition & 1 deletion .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -1 +1 @@
pnpm --no-install lint-staged
pnpm lint-staged
30 changes: 30 additions & 0 deletions actions/create-packages-release-pr/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
name: "Create Packages Release PR"
description: "Create a PR releasing multiple packages at a shared version"
inputs:
packages:
description: "Comma-separated list of package names to release"
required: true
version:
description: "Version to release all packages at"
required: true
branch-format:
description: "Branch name format. Tokens: {org}, {package} (single package only), {version}"
default: "releases/{package}@{version}"
commit-format:
description: "Commit message format. Tokens: {org}, {package} (single package only), {version}"
default: "chore({package}): release {org}/{package}@{version}"
dry:
description: "Perform a dry run that skips PR creation and outputs logs indicating what would have happened"
default: "false"
runs:
using: composite
steps:
- uses: oven-sh/setup-bun@v2
- run: bun $GITHUB_ACTION_PATH/index.ts
shell: bash
env:
INPUT_DRY: ${{ inputs.dry }}
INPUT_PACKAGES: ${{ inputs.packages }}
INPUT_VERSION: ${{ inputs.version }}
INPUT_BRANCH_FORMAT: ${{ inputs.branch-format }}
INPUT_COMMIT_FORMAT: ${{ inputs.commit-format }}
115 changes: 115 additions & 0 deletions actions/create-packages-release-pr/changelog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { $, file, write } from "bun";
import { type IPkg, readPackageChangelog } from "lib";
import { join } from "path";

export const runPackageChangelog = async (name: string): Promise<void> => {
await $`pnpm --filter=${name} run release --skip-automatic-bump --skip-tag`;
};

const parseIntoSections = (body: string): Map<string, string[]> => {
const sections = new Map<string, string[]>();
let currentSection: string | null = null;

for (const line of body.split("\n")) {
if (line.startsWith("## ")) {
currentSection = line.slice(3).trim();
if (!sections.has(currentSection)) sections.set(currentSection, []);
} else if (currentSection && line.trim()) {
sections.get(currentSection)?.push(line);
}
}
return sections;
};

const extractCommitHash = (line: string): string | null => {
const match = line.match(/\(([a-f0-9]{7,40})]\(https?:\/\//);
return match?.[1] ?? null;
};

const mergePackageBodies = (bodies: string[]): string => {
const merged = new Map<string, Map<string, string>>();

for (const body of bodies) {
for (const [section, lines] of parseIntoSections(body)) {
if (!merged.has(section)) merged.set(section, new Map());
const entries = merged.get(section);
for (const line of lines) {
const key = extractCommitHash(line) ?? line;
if (entries && !entries.has(key)) entries.set(key, line);
}
}
}

return [...merged.entries()]
.map(([section, entries]) => `## ${section}\n\n${[...entries.values()].join("\n")}`)
.join("\n\n");
};

const buildVersionHeader = async (
rootPath: string,
version: string,
today: string,
existing: string | null,
): Promise<string> => {
try {
const pkgJson = await file(join(rootPath, "package.json")).json();
const repoUrl = ((pkgJson.repository?.url as string) ?? "")
.replace(/^git\+/, "")
.replace(/\.git$/, "");
if (!repoUrl) throw new Error("No repository URL found");
const name = pkgJson.name as string;
const prevVersion = existing?.match(/^# \[(\d+\.\d+\.\d+)/m)?.[1];

if (prevVersion) {
return `# [${version}](${repoUrl}/compare/${name}@${prevVersion}...${name}@${version}) - (${today})`;
}
return `# [${version}](${repoUrl}/tree/${name}@${version}) - (${today})`;
} catch {
return `# ${version} - (${today})`;
}
};

export const updateRootChangelog = async (
rootPath: string,
pkgs: IPkg[],
version: string,
): Promise<void> => {
const changelogPath = join(rootPath, "CHANGELOG.md");
const today = new Date().toISOString().slice(0, 10);

const bodies: string[] = [];
for (const pkg of pkgs) {
const body = await readPackageChangelog(pkg.path, pkg.name, version);
if (body) bodies.push(body);
}

if (!bodies.length) return;

const mergedBody = mergePackageBodies(bodies);

let existing: string | null = null;
try {
existing = await file(changelogPath).text();
} catch {
// file doesn't exist yet
}

const versionHeader = await buildVersionHeader(rootPath, version, today, existing);
const newEntry = `${versionHeader}\n\n${mergedBody}\n\n`;

if (!existing) {
await write(
changelogPath,
`# Changelog\n\nAll notable changes to this project will be documented in this file.\n\n${newEntry}`,
);
return;
}

const insertAt = existing.search(/\n# \[/);
const updated =
insertAt !== -1
? `${existing.slice(0, insertAt + 1)}${newEntry}${existing.slice(insertAt + 1)}`
: `${existing}\n${newEntry}`;

await write(changelogPath, updated);
};
11 changes: 11 additions & 0 deletions actions/create-packages-release-pr/git.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { $ } from "bun";

export const checkoutToReleaseBranch = async (branchName: string): Promise<void> => {
await $`git checkout -B ${branchName}`;
};

export const commitAndPush = async (branchName: string, message: string): Promise<void> => {
await $`git add --all`;
await $`git -c user.name='github-actions[bot]' -c user.email='username@users.noreply.github.com' commit -m ${message}`;
await $`git push origin refs/heads/${branchName}:${branchName}`;
};
14 changes: 14 additions & 0 deletions actions/create-packages-release-pr/github.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { context } from "@actions/github";
import { createOctokit } from "lib";

const octokit = createOctokit();

export const createPR = async (branchName: string, title: string): Promise<void> => {
const base = context.payload.repository?.default_branch ?? "main";
await octokit?.rest.pulls.create({
...context.repo,
base,
head: branchName,
title,
});
};
Loading
Loading