From 767683f4602862fab2e601161ed96ce0fecfa0b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=8E=E4=BC=9F=E6=9D=B0?= <674416404@qq.com> Date: Thu, 23 Jul 2026 11:58:09 +0800 Subject: [PATCH] =?UTF-8?q?feat(publish):=20=E6=8C=89=E4=BE=9D=E8=B5=96?= =?UTF-8?q?=E9=A1=BA=E5=BA=8F=E5=8F=91=E5=B8=83=E5=A4=9A=E5=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dist/index.mjs | 48 +++++++++++++++++++++++- src/github-event/pull-request.ts | 5 ++- src/utils/get-packages.ts | 11 ++++++ src/utils/publish.ts | 51 +++++++++++++++++++++++++ test/get-packages.test.ts | 12 ++++++ test/publish.test.ts | 64 +++++++++++++++++++++++++++++++- 6 files changed, 187 insertions(+), 4 deletions(-) diff --git a/dist/index.mjs b/dist/index.mjs index b0390cc..540deea 100644 --- a/dist/index.mjs +++ b/dist/index.mjs @@ -30738,6 +30738,12 @@ function parseManifest(manifestPath) { throw new Error(`Failed to parse package manifest "${manifestPath}": ${message}`); } } +function getNodeDependencies(manifest) { + return [...new Set(["dependencies", "devDependencies"].flatMap((field) => { + const dependencies = manifest[field]; + return dependencies && typeof dependencies === "object" && !Array.isArray(dependencies) ? Object.keys(dependencies) : []; + }))]; +} function getPackages(path) { const rootDir = resolve$1(path); const manifestPaths = globSync(MANIFEST_PATTERN, { @@ -30765,6 +30771,7 @@ function getPackages(path) { version: typeof manifest.version === "string" ? manifest.version : void 0, type, private: type === "node" ? manifest.private === true : manifest.publish_to === "none", + dependencies: type === "node" ? getNodeDependencies(manifest) : [], dir, relativeDir }; @@ -31441,6 +31448,44 @@ async function confirmReleaseLog(prNumber, log, token) { } //#endregion //#region src/utils/publish.ts +function sortReleasePackages(releases, packages) { + const releaseIndexes = /* @__PURE__ */ new Map(); + releases.forEach((release, index) => { + if (releaseIndexes.has(release.name)) throw new Error(`Duplicate release package name: ${release.name}`); + releaseIndexes.set(release.name, index); + }); + const packageDependencies = new Map(packages.map((pkg) => [pkg.name, pkg.dependencies])); + const indegrees = new Map(releases.map((release) => [release.name, 0])); + const dependents = /* @__PURE__ */ new Map(); + releases.forEach((release) => { + packageDependencies.get(release.name)?.forEach((dependency) => { + if (!releaseIndexes.has(dependency)) return; + indegrees.set(release.name, (indegrees.get(release.name) || 0) + 1); + dependents.set(dependency, [...dependents.get(dependency) || [], release]); + }); + }); + const ready = releases.filter((release) => indegrees.get(release.name) === 0); + const sorted = []; + const enqueue = (release) => { + const index = ready.findIndex((item) => releaseIndexes.get(item.name) > releaseIndexes.get(release.name)); + if (index === -1) ready.push(release); + else ready.splice(index, 0, release); + }; + while (ready.length) { + const release = ready.shift(); + sorted.push(release); + dependents.get(release.name)?.forEach((dependent) => { + const indegree = (indegrees.get(dependent.name) || 0) - 1; + indegrees.set(dependent.name, indegree); + if (indegree === 0) enqueue(dependent); + }); + } + if (sorted.length !== releases.length) { + const circularPackages = releases.filter((release) => indegrees.get(release.name) > 0).map((release) => release.name); + throw new Error(`Circular package dependencies detected: ${circularPackages.join(", ")}`); + } + return sorted; +} function publishRelease(release) { if (release.type === "flutter") return Promise.resolve(0); return exec("pnpm", [ @@ -82732,7 +82777,8 @@ async function pull_request(token) { await useGit(token).checkoutCommit(pullRequestData.merge_commit_sha); const changeFiles = await getPullRequestFiles(prNumber); info(`changeFiles: ${JSON.stringify(changeFiles, null, 2)}`); - const releaseDirs = await getPullRequestReleaseDirs(changeFiles, getConfiguredPackages(cwd())); + const packages = getConfiguredPackages(cwd()); + const releaseDirs = sortReleasePackages(getPullRequestReleaseDirs(changeFiles, packages), packages); info(`releaseDirs: ${JSON.stringify(releaseDirs, null, 2)}`); if (!releaseDirs.length) { info("没有更新发布版本"); diff --git a/src/github-event/pull-request.ts b/src/github-event/pull-request.ts index 85cf4b4..5219fcb 100644 --- a/src/github-event/pull-request.ts +++ b/src/github-event/pull-request.ts @@ -2,7 +2,7 @@ import type { PullRequestData } from '../types' import { cwd } from 'node:process' import { getInput, info, setOutput } from '@actions/core' import * as github from '@actions/github' -import { extractChangelog, getConfiguredPackages, getInputPkgs, getPullRequestNumber, getPullRequestReleaseDirs, getStashChangelog, publishRelease, renderChangelogMarkdown } from '../utils' +import { extractChangelog, getConfiguredPackages, getInputPkgs, getPullRequestNumber, getPullRequestReleaseDirs, getStashChangelog, publishRelease, renderChangelogMarkdown, sortReleasePackages } from '../utils' import useGit from '../utils/git' import useGithub from '../utils/github' import { translateText } from '../utils/translate' @@ -97,7 +97,8 @@ export async function pull_request(token: string) { await useGit(token).checkoutCommit(pullRequestData.merge_commit_sha) const changeFiles = await getPullRequestFiles(prNumber) info(`changeFiles: ${JSON.stringify(changeFiles, null, 2)}`) - const releaseDirs = await getPullRequestReleaseDirs(changeFiles, getConfiguredPackages(cwd())) + const packages = getConfiguredPackages(cwd()) + const releaseDirs = sortReleasePackages(getPullRequestReleaseDirs(changeFiles, packages), packages) info(`releaseDirs: ${JSON.stringify(releaseDirs, null, 2)}`) if (!releaseDirs.length) { info('没有更新发布版本') diff --git a/src/utils/get-packages.ts b/src/utils/get-packages.ts index 8615318..7918132 100644 --- a/src/utils/get-packages.ts +++ b/src/utils/get-packages.ts @@ -9,6 +9,7 @@ export interface Package { version?: string type: PackageType private: boolean + dependencies: string[] dir: string relativeDir: string } @@ -38,6 +39,15 @@ function parseManifest(manifestPath: string) { } } +function getNodeDependencies(manifest: Record) { + return [...new Set(['dependencies', 'devDependencies'].flatMap((field) => { + const dependencies = manifest[field] + return dependencies && typeof dependencies === 'object' && !Array.isArray(dependencies) + ? Object.keys(dependencies) + : [] + }))] +} + export function getPackages(path: string): Package[] { const rootDir = resolve(path) const manifestPaths = globSync(MANIFEST_PATTERN, { @@ -79,6 +89,7 @@ export function getPackages(path: string): Package[] { version: typeof manifest.version === 'string' ? manifest.version : undefined, type, private: type === 'node' ? manifest.private === true : manifest.publish_to === 'none', + dependencies: type === 'node' ? getNodeDependencies(manifest) : [], dir, relativeDir, } diff --git a/src/utils/publish.ts b/src/utils/publish.ts index 95864ee..ec5b231 100644 --- a/src/utils/publish.ts +++ b/src/utils/publish.ts @@ -1,6 +1,57 @@ import type { ReleasePackage } from '../types' +import type { Package } from './get-packages' import { exec } from '@actions/exec' +export function sortReleasePackages(releases: ReleasePackage[], packages: Package[]) { + const releaseIndexes = new Map() + releases.forEach((release, index) => { + if (releaseIndexes.has(release.name)) + throw new Error(`Duplicate release package name: ${release.name}`) + releaseIndexes.set(release.name, index) + }) + + const packageDependencies = new Map(packages.map(pkg => [pkg.name, pkg.dependencies])) + const indegrees = new Map(releases.map(release => [release.name, 0])) + const dependents = new Map() + + releases.forEach((release) => { + packageDependencies.get(release.name)?.forEach((dependency) => { + if (!releaseIndexes.has(dependency)) + return + indegrees.set(release.name, (indegrees.get(release.name) || 0) + 1) + dependents.set(dependency, [...(dependents.get(dependency) || []), release]) + }) + }) + + const ready = releases.filter(release => indegrees.get(release.name) === 0) + const sorted: ReleasePackage[] = [] + const enqueue = (release: ReleasePackage) => { + const index = ready.findIndex(item => releaseIndexes.get(item.name)! > releaseIndexes.get(release.name)!) + if (index === -1) + ready.push(release) + else + ready.splice(index, 0, release) + } + + while (ready.length) { + const release = ready.shift()! + sorted.push(release) + dependents.get(release.name)?.forEach((dependent) => { + const indegree = (indegrees.get(dependent.name) || 0) - 1 + indegrees.set(dependent.name, indegree) + if (indegree === 0) + enqueue(dependent) + }) + } + + if (sorted.length !== releases.length) { + const circularPackages = releases.filter(release => indegrees.get(release.name)! > 0).map(release => release.name) + throw new Error(`Circular package dependencies detected: ${circularPackages.join(', ')}`) + } + + return sorted +} + export function publishRelease(release: ReleasePackage) { if (release.type === 'flutter') return Promise.resolve(0) diff --git a/test/get-packages.test.ts b/test/get-packages.test.ts index ed80cde..8578f5b 100644 --- a/test/get-packages.test.ts +++ b/test/get-packages.test.ts @@ -33,6 +33,18 @@ describe('getPackages', () => { expect(packages[0].dir).toBe(resolve('fixtures/repo3/packages/flutter-a')) }) + it('reads Node runtime and development dependencies', () => { + const dir = createTempDir() + writeFileSync(join(dir, 'package.json'), JSON.stringify({ + name: 'example', + dependencies: { runtime: 'workspace:^', shared: '^1.0.0' }, + devDependencies: { development: '^1.0.0', shared: '^1.0.0' }, + peerDependencies: { peer: '^1.0.0' }, + })) + + expect(getPackages(dir)[0].dependencies).toEqual(['runtime', 'shared', 'development']) + }) + it('keeps a Flutter root package when it contains an example app', () => { const dir = createTempDir() mkdirSync(join(dir, 'example'), { recursive: true }) diff --git a/test/publish.test.ts b/test/publish.test.ts index 07a2316..480aa39 100644 --- a/test/publish.test.ts +++ b/test/publish.test.ts @@ -1,7 +1,8 @@ import type { ReleasePackage } from '../src/types' +import type { Package } from '../src/utils/get-packages' import { exec } from '@actions/exec' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { publishRelease } from '../src/utils/publish' +import { publishRelease, sortReleasePackages } from '../src/utils/publish' vi.mock('@actions/exec', () => ({ exec: vi.fn() })) @@ -15,6 +16,67 @@ const release: ReleasePackage = { changelog: '', } +function createRelease(name: string): ReleasePackage { + return { ...release, dir: `packages/${name}`, name } +} + +function createPackage(name: string, dependencies: string[] = []): Package { + return { + name, + version: '1.0.0', + type: 'node', + private: false, + dependencies, + dir: `packages/${name}`, + relativeDir: `packages/${name}`, + } +} + +describe('sortReleasePackages', () => { + it('publishes workspace dependencies before their dependents', () => { + const releases = [createRelease('tdesign-icons-view'), createRelease('tdesign-icons-vue-next')] + const packages = [ + createPackage('tdesign-icons-view', ['tdesign-icons-vue-next']), + createPackage('tdesign-icons-vue-next'), + ] + + expect(sortReleasePackages(releases, packages).map(item => item.name)).toEqual([ + 'tdesign-icons-vue-next', + 'tdesign-icons-view', + ]) + }) + + it('sorts transitive dependencies', () => { + const releases = [createRelease('a'), createRelease('b'), createRelease('c')] + const packages = [createPackage('a', ['b']), createPackage('b', ['c']), createPackage('c')] + + expect(sortReleasePackages(releases, packages).map(item => item.name)).toEqual(['c', 'b', 'a']) + }) + + it('keeps unrelated packages stable and ignores dependencies outside the release', () => { + const releases = [createRelease('consumer'), createRelease('unrelated'), createRelease('dependency')] + const packages = [ + createPackage('consumer', ['dependency', 'external']), + createPackage('unrelated'), + createPackage('dependency'), + ] + + expect(sortReleasePackages(releases, packages).map(item => item.name)).toEqual([ + 'unrelated', + 'dependency', + 'consumer', + ]) + }) + + it('rejects circular dependencies before publishing', () => { + const releases = [createRelease('a'), createRelease('b')] + const packages = [createPackage('a', ['b']), createPackage('b', ['a'])] + + expect(() => sortReleasePackages(releases, packages)).toThrow('Circular package dependencies detected: a, b') + expect(exec).not.toHaveBeenCalled() + }) +}) + describe('publishRelease', () => { beforeEach(() => { vi.mocked(exec).mockReset()