From b3315da0f9ddaf2e467841db39757fa4b7dee646 Mon Sep 17 00:00:00 2001 From: Exelo Date: Thu, 9 Apr 2026 05:58:25 +0200 Subject: [PATCH] feat: add release nanoforge packages action --- README.md | 4 +- docs/docs/actions.rst | 117 ++++++++++++- docs/docs/api.rst | 66 ++++++++ docs/docs/architecture.rst | 11 +- src/release-nanoforge-packages/action.yml | 24 +++ .../generate-release-tree.ts | 160 ++++++++++++++++++ src/release-nanoforge-packages/index.ts | 102 +++++++++++ .../release-package.ts | 39 +++++ tsconfig.json | 1 - tsup.config.ts | 1 + 10 files changed, 518 insertions(+), 7 deletions(-) create mode 100644 src/release-nanoforge-packages/action.yml create mode 100644 src/release-nanoforge-packages/generate-release-tree.ts create mode 100644 src/release-nanoforge-packages/index.ts create mode 100644 src/release-nanoforge-packages/release-package.ts diff --git a/README.md b/README.md index 35f8c86..937c049 100644 --- a/README.md +++ b/README.md @@ -18,11 +18,11 @@ `@nanoforge-dev/actions` is library that contains all utils for nanoforge actions. -Most of the sources of this package come from [discord.js][discordjs-source]. Spetial thanks to them ! +Most of the sources of this package come from [discord.js][discordjs-source]. Special thanks to them! ## Installation -**Node.js 24.11.0 or newer is required.** +**Node.js 25 or newer is required.** ```sh npm install --save-dev @nanoforge-dev/actions diff --git a/docs/docs/actions.rst b/docs/docs/actions.rst index bd16103..e409d81 100644 --- a/docs/docs/actions.rst +++ b/docs/docs/actions.rst @@ -46,11 +46,21 @@ Inputs - No - ``false`` - Publish development versions with commit hash suffix + * - ``npm`` + - boolean + - No + - ``true`` + - Publish to the npm registry * - ``tag`` - string - No - ``"dev"`` - npm dist-tag for dev releases (only valid with ``dev: true``) + * - ``format`` + - string + - No + - ``{org}/{package}@{version}`` + - Tag format string (placeholders: ``{org}``, ``{package}``, ``{version}``) Environment Variables ^^^^^^^^^^^^^^^^^^^^^ @@ -227,6 +237,11 @@ Inputs - Yes - -- - Head branch of the merged PR (e.g., ``releases/actions@1.1.0``) + * - ``format`` + - string + - No + - ``{org}/{package}@{version}`` + - Tag format string (placeholders: ``{org}``, ``{package}``, ``{version}``) Environment Variables ^^^^^^^^^^^^^^^^^^^^^ @@ -251,9 +266,13 @@ Behavior Tag Format ^^^^^^^^^^ -Tags follow the npm package identifier format:: +Tags are produced by substituting placeholders in the ``format`` input:: + + {org}/{package}@{version} - @nanoforge-dev/@ +For example, with the default format and branch ``releases/actions@1.1.0``:: + + @nanoforge-dev/actions@1.1.0 Example Usage ^^^^^^^^^^^^^ @@ -270,6 +289,100 @@ Example Usage ---- +.. _action-release-nanoforge-packages: + +release-nanoforge-packages +-------------------------- + +Releases NanoForge components and systems to the NanoForge private registry +(``api.nanoforge.eu``). Discovers packages via ``nanoforge.manifest.json`` +files and resolves publish order from their declared dependencies. + +**Entry Point**: ``src/release-nanoforge-packages/index.ts`` + +Inputs +^^^^^^ + +.. list-table:: + :header-rows: 1 + :widths: 18 10 10 15 47 + + * - Input + - Type + - Required + - Default + - Description + * - ``path`` + - string + - No + - ``packages`` + - Path to the packages directory + * - ``package`` + - string + - No + - ``"all"`` + - Specific package to release (with dependencies), or ``"all"`` + * - ``exclude`` + - string + - No + - ``""`` + - Comma-separated list of packages to skip (unless required by another package) + * - ``dry`` + - boolean + - No + - ``false`` + - Perform a dry run without actual publishing + +Environment Variables +^^^^^^^^^^^^^^^^^^^^^ + +.. list-table:: + :header-rows: 1 + :widths: 30 70 + + * - Variable + - Description + * - ``GITHUB_TOKEN`` + - GitHub token (required for job summary writing) + +Behavior +^^^^^^^^ + +1. Recursively scans the ``path`` directory for ``nanoforge.manifest.json`` files +2. Builds a dependency tree from ``dependencies`` fields in each manifest +3. Releases packages in topological order; independent packages are released + in parallel within each level +4. After each publish, polls ``api.nanoforge.eu/registry/`` every 15 s + (up to 5 min) to confirm the package is available before proceeding +5. Generates a job summary listing released and skipped packages + +Manifest Format +^^^^^^^^^^^^^^^ + +Each package must have a ``nanoforge.manifest.json`` at its root: + +.. code-block:: json + + { + "name": "@nanoforge-dev/my-package", + "dependencies": ["@nanoforge-dev/other-package"] + } + +Example Usage +^^^^^^^^^^^^^ + +.. code-block:: yaml + + - name: Release NanoForge packages + uses: ./dist/release-nanoforge-packages + with: + path: packages + dry: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + +---- + Workflow Integration -------------------- diff --git a/docs/docs/api.rst b/docs/docs/api.rst index 94c07c6..1fc3230 100644 --- a/docs/docs/api.rst +++ b/docs/docs/api.rst @@ -94,6 +94,72 @@ Functions :param release: Release entry with changelog :param dry: If true, logs instead of creating release +release-nanoforge-packages: generate-release-tree +------------------------------------------------- + +**Module**: ``src/release-nanoforge-packages/generate-release-tree.ts`` + +Builds a dependency-ordered release tree for NanoForge packages discovered +via ``nanoforge.manifest.json`` manifests. + +Types +^^^^^ + +.. code-block:: typescript + + interface ReleaseEntry { + name: string; // Package name from manifest + path: string; // Absolute path to the package directory + dependsOn?: string[]; // Names of packages that must be released first + } + +Functions +^^^^^^^^^ + +.. function:: generateReleaseTree(path: string, packageName?: string, exclude?: string[]): Promise + + Generates a two-dimensional array of release entries ordered by dependency + level. Each inner array can be released in parallel. + + :param path: Root directory to scan for ``nanoforge.manifest.json`` files + :param packageName: Target a specific package (with its deps) or ``"all"`` + :param exclude: Package names to skip (unless required by another package) + :returns: Promise resolving to ordered release tree + :raises Error: If a named package is not found, or a dependency cycle exists + + **Algorithm**: + + 1. Recursively scans ``path`` for ``nanoforge.manifest.json`` files + 2. Builds dependency graph from manifest ``dependencies`` fields + 3. Topologically sorts into release levels (BFS) + 4. Prunes tree based on ``packageName`` or ``exclude`` options + +release-nanoforge-packages: release-package +-------------------------------------------- + +**Module**: ``src/release-nanoforge-packages/release-package.ts`` + +Handles publishing individual NanoForge packages via the ``nf publish`` CLI. + +Functions +^^^^^^^^^ + +.. function:: releasePackage(release: ReleaseEntry, dry: boolean): Promise + + Publishes a single NanoForge package. + + :param release: Release entry with name and path + :param dry: If true, logs instead of publishing + :returns: Promise resolving to ``true`` when done + + **Behavior**: + + 1. In dry mode: logs the action and returns immediately + 2. Runs ``bun exec "nf publish -d "`` + 3. Polls ``api.nanoforge.eu/registry/`` every 15 s (up to 5 min) to + confirm the package is available on the registry before returning + :raises Error: If the registry does not confirm the package within 5 minutes + create-release-pr Functions --------------------------- diff --git a/docs/docs/architecture.rst b/docs/docs/architecture.rst index 4364a7f..bea807f 100644 --- a/docs/docs/architecture.rst +++ b/docs/docs/architecture.rst @@ -5,7 +5,7 @@ Overview -------- NanoForge Actions is a collection of GitHub Actions designed to automate the -release workflow for NanoForge monorepo packages. It provides three main actions +release workflow for NanoForge monorepo packages. It provides four main actions that handle different stages of the release process: 1. **create-release-pr** -- Creates a release pull request with version bumps @@ -13,6 +13,8 @@ that handle different stages of the release process: 2. **release-packages** -- Publishes packages to npm with proper dependency sequencing 3. **create-release-tag** -- Creates git tags after a release PR is merged +4. **release-nanoforge-packages** -- Publishes NanoForge components and systems + to the private NanoForge registry The package is published to npm as ``@nanoforge-dev/actions`` and is built using tsup with ESM output. @@ -59,12 +61,17 @@ Project Structure | +-- index.ts # Entry point | +-- functions.ts # Core functions | +-- types.ts # Type definitions - +-- release-packages/ # Package publishing action + +-- release-packages/ # npm package publishing action | +-- index.ts # Entry point | +-- generate-release-tree.ts # Dependency tree generation | +-- release-package.ts # Individual package release +-- create-release-tag/ # Tag creation action + | +-- index.ts # Entry point + +-- release-nanoforge-packages/ # NanoForge registry publishing action +-- index.ts # Entry point + +-- generate-release-tree.ts # Manifest-based dependency tree + +-- release-package.ts # Individual NanoForge package release + +-- action.yml # Action definition Action Pattern -------------- diff --git a/src/release-nanoforge-packages/action.yml b/src/release-nanoforge-packages/action.yml new file mode 100644 index 0000000..101910c --- /dev/null +++ b/src/release-nanoforge-packages/action.yml @@ -0,0 +1,24 @@ +name: "Release Nanoforge Packages" +description: "Releases any nanoforge component or system" +inputs: + dry: + description: "Perform a dry run that skips publishing and outputs logs indicating what would have happened" + default: "false" + path: + description: "Path to the packages directory" + default: "packages" + package: + description: "The published name of a single package to release" + exclude: + description: "Comma separated list of packages to exclude from release (if not depended upon)" +runs: + using: composite + steps: + - uses: oven-sh/setup-bun@v2 + - run: bun $GITHUB_ACTION_PATH/index.js + shell: bash + env: + INPUT_DRY: ${{ inputs.dry }} + INPUT_PATH: ${{ inputs.path }} + INPUT_PACKAGE: ${{ inputs.package }} + INPUT_EXCLUDE: ${{ inputs.exclude }} diff --git a/src/release-nanoforge-packages/generate-release-tree.ts b/src/release-nanoforge-packages/generate-release-tree.ts new file mode 100644 index 0000000..c07a67e --- /dev/null +++ b/src/release-nanoforge-packages/generate-release-tree.ts @@ -0,0 +1,160 @@ +import * as fs from "node:fs"; +import { join } from "path"; + +interface nfTree { + name?: string; + path: string; + dependencies?: string[]; +} + +export interface ReleaseEntry { + name: string; + path: string; + dependsOn?: string[]; +} + +const MANIFEST_FILE_NAME = "nanoforge.manifest.json"; + +const handlePackage = (dirent: fs.Dirent): nfTree => { + const manifestPath = join(dirent.parentPath, dirent.name); + const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf-8")) as { + name: string; + dependencies?: string[]; + }; + return { + name: manifest.name, + path: dirent.parentPath, + dependencies: manifest.dependencies, + }; +}; + +const getPackageList = (path: string): nfTree[] => { + const dirs = fs.readdirSync(path, { recursive: true, withFileTypes: true }); + return dirs + .filter((dirent) => dirent.isFile() && dirent.name === MANIFEST_FILE_NAME) + .map((dirent) => handlePackage(dirent)); +}; + +async function getReleaseEntries(path: string) { + const releaseEntries: ReleaseEntry[] = []; + const packageList: nfTree[] = getPackageList(path); + + for (const pkg of packageList) { + // Just in case + if (!pkg.name) continue; + + const release: ReleaseEntry = { + name: pkg.name, + path: pkg.path, + }; + + if (pkg.dependencies) { + release.dependsOn = pkg.dependencies; + } + + releaseEntries.push(release); + } + + return releaseEntries; +} + +export async function generateReleaseTree(path: string, packageName?: string, exclude?: string[]) { + let releaseEntries = await getReleaseEntries(path); + // Try to early return if the package doesn't have deps + if (packageName && packageName !== "all") { + const releaseEntry = releaseEntries.find((entry) => entry.name === packageName); + if (!releaseEntry) { + throw new Error(`Package ${packageName} not releaseable`); + } + + if (!releaseEntry.dependsOn) { + return [[releaseEntry]]; + } + } + + // Generate the whole tree first, then prune if specified + const releaseTree: ReleaseEntry[][] = []; + const didRelease = new Set(); + + while (releaseEntries.length) { + const nextBranch: ReleaseEntry[] = []; + const unreleased: ReleaseEntry[] = []; + for (const entry of releaseEntries) { + if (!entry.dependsOn) { + nextBranch.push(entry); + continue; + } + + const allDepsReleased = entry.dependsOn.every((dep) => didRelease.has(dep)); + if (allDepsReleased) { + nextBranch.push(entry); + } else { + unreleased.push(entry); + } + } + + // Update didRelease in a second loop to avoid loop order issues + for (const release of nextBranch) { + didRelease.add(release.name); + } + + if (releaseEntries.length === unreleased.length) { + throw new Error( + `One or more packages have dependents that can't be released: ${unreleased.map((entry) => entry.name).join(",")}`, + ); + } + + releaseTree.push(nextBranch); + releaseEntries = unreleased; + } + + // Prune exclusions + if ((!packageName || packageName === "all") && Array.isArray(exclude) && exclude.length) { + const neededPackages = new Set(); + const excludedReleaseTree: ReleaseEntry[][] = []; + + for (const releaseBranch of releaseTree.reverse()) { + const newThisBranch: ReleaseEntry[] = []; + + for (const entry of releaseBranch) { + if (exclude.includes(entry.name) && !neededPackages.has(entry.name)) { + continue; + } + + newThisBranch.push(entry); + for (const dep of entry.dependsOn ?? []) { + neededPackages.add(dep); + } + } + + if (newThisBranch.length) excludedReleaseTree.unshift(newThisBranch); + } + + return excludedReleaseTree; + } + + if (!packageName || packageName === "all") { + return releaseTree; + } + + // Prune the tree for the specified package + const neededPackages = new Set([packageName]); + const packageReleaseTree: ReleaseEntry[][] = []; + + for (const releaseBranch of releaseTree.reverse()) { + const newThisBranch: ReleaseEntry[] = []; + + for (const entry of releaseBranch) { + if (neededPackages.has(entry.name)) { + newThisBranch.push(entry); + for (const dep of entry.dependsOn ?? []) { + neededPackages.add(dep); + } + } + } + + if (newThisBranch.length) packageReleaseTree.unshift(newThisBranch); + } + + return packageReleaseTree; +} diff --git a/src/release-nanoforge-packages/index.ts b/src/release-nanoforge-packages/index.ts new file mode 100644 index 0000000..d41cbfb --- /dev/null +++ b/src/release-nanoforge-packages/index.ts @@ -0,0 +1,102 @@ +import { endGroup, getBooleanInput, getInput, startGroup, summary } from "@actions/core"; +import { program } from "commander"; + +import { generateReleaseTree } from "./generate-release-tree"; +import { releasePackage } from "./release-package"; + +function npmPackageLink(packageName: string) { + return `https://api.nanoforge.eu/registry/${packageName}` as const; +} + +const excludeInput = getInput("exclude"); +let dryInput = false; + +try { + dryInput = getBooleanInput("dry"); +} catch { + // We're not running in actions or the input isn't set (cron) +} + +program + .name("release Nanoforge packages") + .description("releases Nanoforge components and systems with proper sequencing") + .argument("[package]", "release a specific package (and it's dependencies)", getInput("package")) + .option( + "-e, --exclude ", + "exclude specific packages from releasing (will still release if necessary for another package)", + excludeInput ? excludeInput.split(",") : [], + ) + .option("--dry", "skips actual publishing and outputs logs instead", dryInput) + .option("--path [path]", "path to the packages directory", getInput("path") ?? "packages") + .parse(); + +const { dry, exclude, path } = program.opts<{ + dry: boolean; + exclude: string[]; + path: string; +}>(); + +const [packageName] = program.processedArgs as [string]; +const tree = await generateReleaseTree(path, packageName, exclude); + +interface ReleaseResult { + identifier: string; + url: string; +} + +const publishedPackages: ReleaseResult[] = []; +const skippedPackages: ReleaseResult[] = []; + +for (const branch of tree) { + startGroup(`Releasing ${branch.map(({ name }) => name).join(", ")}`); + + await Promise.all( + branch.map(async (release) => { + const published = await releasePackage(release, dry); + + if (published) { + publishedPackages.push({ identifier: release.name, url: npmPackageLink(release.name) }); + } else { + skippedPackages.push({ identifier: release.name, url: npmPackageLink(release.name) }); + } + }), + ); + + endGroup(); +} + +const result = summary.addHeading("Release summary"); + +if (dry) { + result.addRaw("\n\n> [!NOTE]\n> This is a dry run.\n\n"); +} + +result.addHeading("Released", 2); + +if (publishedPackages.length === 0) { + result.addRaw("\n_None_\n\n"); +} else { + result.addRaw("\n"); + + for (const { identifier, url } of publishedPackages) { + result.addRaw(`- [${identifier}](${url})\n`); + } + + result.addRaw(`\n`); +} + +result.addHeading("Skipped", 2); + +if (skippedPackages.length === 0) { + result.addRaw("\n_None_\n\n"); +} else { + result.addRaw("\n"); + + for (const { identifier, url } of skippedPackages) { + result.addRaw(`- [${identifier}](${url})\n`); + } + + result.addRaw(`\n`); +} + +await result.write(); diff --git a/src/release-nanoforge-packages/release-package.ts b/src/release-nanoforge-packages/release-package.ts new file mode 100644 index 0000000..3434c50 --- /dev/null +++ b/src/release-nanoforge-packages/release-package.ts @@ -0,0 +1,39 @@ +import { info } from "@actions/core"; +import { $ } from "bun"; + +import type { ReleaseEntry } from "./generate-release-tree"; + +async function checkRegistry(release: ReleaseEntry) { + const res = await fetch(`https://api.nanoforge.eu/registry/${release.name}`); + return res.ok; +} + +export async function releasePackage(release: ReleaseEntry, dry: boolean) { + if (dry) { + info(`[DRY] Releasing ${release.name}`); + } else { + await $`bun exec "nf publish -d ${release.path}"`; + } + + if (dry) return true; + + const before = performance.now(); + + // Poll registry to ensure next publishes won't fail + await new Promise((resolve, reject) => { + const interval = setInterval(async () => { + if (await checkRegistry(release)) { + clearInterval(interval); + resolve(); + return; + } + + if (performance.now() > before + 5 * 60 * 1_000) { + clearInterval(interval); + reject(new Error(`Release for ${release.name} failed.`)); + } + }, 15_000); + }); + + return true; +} diff --git a/tsconfig.json b/tsconfig.json index d9d5a5e..607da74 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,7 +4,6 @@ // Type Checking "allowUnreachableCode": false, "allowUnusedLabels": false, - "exactOptionalPropertyTypes": true, "noFallthroughCasesInSwitch": true, "noImplicitOverride": true, "noImplicitReturns": true, diff --git a/tsup.config.ts b/tsup.config.ts index d7e72e2..844915b 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -71,4 +71,5 @@ export default [ createConfig("create-release-pr", ["index.ts"]), createConfig("create-release-tag", ["index.ts"]), createConfig("release-packages", ["index.ts"]), + createConfig("release-nanoforge-packages", ["index.ts"]), ];