Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 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
26 changes: 26 additions & 0 deletions .github/actions/clone/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
name: 'Clone Repositories'
description: 'Clone self and upstream repositories'

inputs:
ecosystem-ci-project:
description: 'The ecosystem ci project to clone'
required: false
default: ''

runs:
using: 'composite'
steps:
- name: Output ecosystem ci project hash
shell: bash
id: ecosystem-ci-project-hash
if: ${{ inputs.ecosystem-ci-project != '' }}
run: |
node -e "const fs = require('fs'); const data = JSON.parse(fs.readFileSync('./ecosystem-ci/repo.json', 'utf8')); const project = data['${{ inputs.ecosystem-ci-project }}']; console.log('ECOSYSTEM_CI_PROJECT_HASH=' + project.hash);" >> $GITHUB_OUTPUT
node -e "const fs = require('fs'); const data = JSON.parse(fs.readFileSync('./ecosystem-ci/repo.json', 'utf8')); const project = data['${{ inputs.ecosystem-ci-project }}']; console.log('ECOSYSTEM_CI_PROJECT_REPOSITORY=' + project.repository.replace('https://github.com/', '').replace('.git', ''));" >> $GITHUB_OUTPUT

- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
if: ${{ inputs.ecosystem-ci-project != '' }}
with:
repository: ${{ steps.ecosystem-ci-project-hash.outputs.ECOSYSTEM_CI_PROJECT_REPOSITORY }}
path: ecosystem-ci/${{ inputs.ecosystem-ci-project }}
ref: ${{ steps.ecosystem-ci-project-hash.outputs.ECOSYSTEM_CI_PROJECT_HASH }}
91 changes: 91 additions & 0 deletions .github/workflows/e2e-test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
name: E2E Test

on:
push:
branches:
- next
paths-ignore:
- '**/*.md'
pull_request:
branches:
- next
paths-ignore:
- '**/*.md'

concurrency:
group: ${{ github.workflow }}-#${{ github.event.pull_request.number || github.head_ref || github.ref }}
cancel-in-progress: true

defaults:
run:
shell: bash

jobs:
e2e-test:
name: ${{ matrix.project.name }} E2E test
permissions:
contents: read
packages: read
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
project:
- name: cnpmcore
node-version: 24
command: |
npm install
npm run lint -- --quiet
npm run typecheck
npm run build
npm run prepublishOnly
- name: examples
node-version: 24
command: |
# examples/helloworld https://github.com/eggjs/examples/blob/master/helloworld/package.json
cd helloworld
npm install
npm run lint
npm run test
npm run prepublishOnly
cd ..

# examples/hello-tegg https://github.com/eggjs/examples/blob/master/hello-tegg/package.json
cd hello-tegg
npm install
npm run lint
npm run test
npm run prepublishOnly
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- uses: ./.github/actions/clone
with:
ecosystem-ci-project: ${{ matrix.project.name }}

- name: Install pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4

- name: Set up Node.js
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
with:
node-version: ${{ matrix.project.node-version }}
cache: 'pnpm'

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Build all packages
run: pnpm build

- name: Pack packages into tgz
run: |
pnpm -r pack

- name: Override dependencies from tgz in ${{ matrix.project.name }}
working-directory: ecosystem-ci/${{ matrix.project.name }}
run: |
node ../patch-project.ts ${{ matrix.project.name }}

- name: Run e2e test commands in ${{ matrix.project.name }}
working-directory: ecosystem-ci/${{ matrix.project.name }}
run: ${{ matrix.project.command }}
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -102,4 +102,5 @@ tegg/plugin/tegg/test/fixtures/apps/**/*.js
!tegg/plugin/tegg/test/fixtures/**/node_modules
!tegg/plugin/config/test/fixtures/**/node_modules

*.tsbuildinfo
*.tsbuildinfo
*.tgz
2 changes: 2 additions & 0 deletions ecosystem-ci/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
cnpmcore
examples
87 changes: 87 additions & 0 deletions ecosystem-ci/clone.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { execSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import { join } from 'node:path';

import repos from './repo.json' with { type: 'json' };

const cwd = import.meta.dirname;

function exec(cmd: string, execCwd: string = cwd): string {
return execSync(cmd, { cwd: execCwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
}

function getRemoteUrl(dir: string): string | null {
try {
return exec('git remote get-url origin', dir);
} catch {
return null;
}
}

function normalizeGitUrl(url: string): string {
// Convert git@github.com:owner/repo.git to github.com/owner/repo
// Convert https://github.com/owner/repo.git to github.com/owner/repo
return url
.replace(/^git@([^:]+):/, '$1/')
.replace(/^https?:\/\//, '')
.replace(/\.git$/, '');
}

function isSameRepo(url1: string, url2: string): boolean {
return normalizeGitUrl(url1) === normalizeGitUrl(url2);
}

function getCurrentHash(dir: string): string | null {
try {
return exec('git rev-parse HEAD', dir);
} catch {
return null;
}
}

function cloneRepo(repoUrl: string, branch: string, targetDir: string): void {
console.info(`Cloning ${repoUrl} (branch: ${branch})...`);
exec(`git clone --branch ${branch} ${repoUrl} ${targetDir}`);
Comment on lines +42 to +44
Copy link

Copilot AI Dec 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The git command construction in cloneRepo is vulnerable to command injection. The repoUrl, branch, and targetDir parameters are directly interpolated into the shell command without sanitization. While currently these values come from a static JSON file, if the source of these values changes in the future, malicious input could execute arbitrary commands. Use array syntax for execSync or properly escape/validate these parameters to prevent potential command injection.

Copilot uses AI. Check for mistakes.
}

function checkoutHash(dir: string, hash: string): void {
console.info(`Checking out ${hash}...`);
exec(`git fetch origin`, dir);
exec(`git checkout ${hash}`, dir);
Comment on lines +42 to +50
Copy link

Copilot AI Dec 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential command injection vulnerability. The git commands use unsanitized input from repo.json (branch, repository URL, and hash). While these are configuration values, if repo.json is ever modified maliciously, this could lead to command injection. Consider validating or sanitizing these inputs before using them in shell commands.

Suggested change
function cloneRepo(repoUrl: string, branch: string, targetDir: string): void {
console.info(`Cloning ${repoUrl} (branch: ${branch})...`);
exec(`git clone --branch ${branch} ${repoUrl} ${targetDir}`);
}
function checkoutHash(dir: string, hash: string): void {
console.info(`Checking out ${hash}...`);
exec(`git fetch origin`, dir);
exec(`git checkout ${hash}`, dir);
function validateGitUrl(name: string, url: string): string {
// Allow typical Git URL characters, disallow whitespace and shell metacharacters.
const gitUrlPattern = /^[A-Za-z0-9@._:/%+-]+$/;
if (!gitUrlPattern.test(url)) {
throw new Error(`Invalid ${name}: contains unsafe characters`);
}
return url;
}
function validateGitRef(name: string, ref: string): string {
// Allow typical Git ref characters (no spaces or shell metacharacters).
const gitRefPattern = /^[A-Za-z0-9._\/\-]+$/;
if (!gitRefPattern.test(ref)) {
throw new Error(`Invalid ${name}: contains unsafe characters`);
}
return ref;
}
function validateGitHash(name: string, hash: string): string {
// Git hashes are hex strings, usually 40 chars, but allow shorter prefixes.
const gitHashPattern = /^[0-9a-fA-F]{7,40}$/;
if (!gitHashPattern.test(hash)) {
throw new Error(`Invalid ${name}: must be a hex commit hash`);
}
return hash;
}
function cloneRepo(repoUrl: string, branch: string, targetDir: string): void {
const safeRepoUrl = validateGitUrl('repository URL', repoUrl);
const safeBranch = validateGitRef('branch', branch);
console.info(`Cloning ${safeRepoUrl} (branch: ${safeBranch})...`);
exec(`git clone --branch ${safeBranch} ${safeRepoUrl} ${targetDir}`);
}
function checkoutHash(dir: string, hash: string): void {
const safeHash = validateGitHash('hash', hash);
console.info(`Checking out ${safeHash}...`);
exec(`git fetch origin`, dir);
exec(`git checkout ${safeHash}`, dir);

Copilot uses AI. Check for mistakes.
}

for (const [repoName, repo] of Object.entries(repos)) {
const targetDir = join(cwd, repoName);

if (existsSync(targetDir)) {
console.info(`\nDirectory ${repoName} exists, validating...`);

const remoteUrl = getRemoteUrl(targetDir);
if (!remoteUrl) {
console.error(` ✗ ${repoName} is not a git repository`);
continue;
}

if (!isSameRepo(remoteUrl, repo.repository)) {
console.error(` ✗ Remote mismatch: expected ${repo.repository}, got ${remoteUrl}`);
continue;
}

console.info(` ✓ Remote matches`);

const currentHash = getCurrentHash(targetDir);
if (currentHash === repo.hash) {
console.info(` ✓ Already at correct commit ${repo.hash.slice(0, 7)}`);
} else {
console.info(` → Current: ${currentHash?.slice(0, 7)}, expected: ${repo.hash.slice(0, 7)}`);
checkoutHash(targetDir, repo.hash);
console.info(` ✓ Checked out ${repo.hash.slice(0, 7)}`);
}
} else {
cloneRepo(repo.repository, repo.branch, targetDir);
checkoutHash(targetDir, repo.hash);
console.info(`✓ Cloned and checked out ${repo.hash.slice(0, 7)}`);
}
}

console.info('\nDone!');
Comment on lines +53 to +87
Copy link

Copilot AI Dec 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing error handling for git command failures. When cloneRepo or checkoutHash fail (lines 81-82), the script continues execution and prints "Done!" even though the operation may have failed. This could lead to false positives in CI/CD. Consider wrapping the main loop in a try-catch block or tracking failures to exit with an appropriate error code.

Suggested change
for (const [repoName, repo] of Object.entries(repos)) {
const targetDir = join(cwd, repoName);
if (existsSync(targetDir)) {
console.info(`\nDirectory ${repoName} exists, validating...`);
const remoteUrl = getRemoteUrl(targetDir);
if (!remoteUrl) {
console.error(` ✗ ${repoName} is not a git repository`);
continue;
}
if (!isSameRepo(remoteUrl, repo.repository)) {
console.error(` ✗ Remote mismatch: expected ${repo.repository}, got ${remoteUrl}`);
continue;
}
console.info(` ✓ Remote matches`);
const currentHash = getCurrentHash(targetDir);
if (currentHash === repo.hash) {
console.info(` ✓ Already at correct commit ${repo.hash.slice(0, 7)}`);
} else {
console.info(` → Current: ${currentHash?.slice(0, 7)}, expected: ${repo.hash.slice(0, 7)}`);
checkoutHash(targetDir, repo.hash);
console.info(` ✓ Checked out ${repo.hash.slice(0, 7)}`);
}
} else {
cloneRepo(repo.repository, repo.branch, targetDir);
checkoutHash(targetDir, repo.hash);
console.info(`✓ Cloned and checked out ${repo.hash.slice(0, 7)}`);
}
}
console.info('\nDone!');
let hasError = false;
for (const [repoName, repo] of Object.entries(repos)) {
const targetDir = join(cwd, repoName);
try {
if (existsSync(targetDir)) {
console.info(`\nDirectory ${repoName} exists, validating...`);
const remoteUrl = getRemoteUrl(targetDir);
if (!remoteUrl) {
console.error(` ✗ ${repoName} is not a git repository`);
continue;
}
if (!isSameRepo(remoteUrl, repo.repository)) {
console.error(` ✗ Remote mismatch: expected ${repo.repository}, got ${remoteUrl}`);
continue;
}
console.info(` ✓ Remote matches`);
const currentHash = getCurrentHash(targetDir);
if (currentHash === repo.hash) {
console.info(` ✓ Already at correct commit ${repo.hash.slice(0, 7)}`);
} else {
console.info(` → Current: ${currentHash?.slice(0, 7)}, expected: ${repo.hash.slice(0, 7)}`);
checkoutHash(targetDir, repo.hash);
console.info(` ✓ Checked out ${repo.hash.slice(0, 7)}`);
}
} else {
cloneRepo(repo.repository, repo.branch, targetDir);
checkoutHash(targetDir, repo.hash);
console.info(`✓ Cloned and checked out ${repo.hash.slice(0, 7)}`);
}
} catch (error) {
hasError = true;
console.error(`\n✗ Failed to process ${repoName}:`, error);
}
}
if (hasError) {
console.error('\nDone with errors.');
process.exitCode = 1;
} else {
console.info('\nDone!');
}

Copilot uses AI. Check for mistakes.
125 changes: 125 additions & 0 deletions ecosystem-ci/patch-project.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import fs from 'node:fs';
import { glob } from 'node:fs/promises';
import { dirname, join, relative } from 'node:path';

import yaml from 'js-yaml';

import repos from './repo.json' with { type: 'json' };

const projectDir = import.meta.dirname;
const rootDir = join(projectDir, '..');
const tgzPath = rootDir;

const projects = Object.keys(repos);

const project = process.argv[2];

if (!projects.includes(project)) {
console.error(`Project ${project} is not defined in repo.json`);
process.exit(1);
}

// Read pnpm-workspace.yaml to get workspace patterns
const workspaceConfig = yaml.load(fs.readFileSync(join(rootDir, 'pnpm-workspace.yaml'), 'utf8')) as {
packages: string[];
};

// Use glob to find all package directories dynamically
async function discoverPackages(): Promise<[string, string][]> {
const packages: [string, string][] = [];

for (const pattern of workspaceConfig.packages) {
// Convert pnpm patterns (e.g., 'packages/*') to glob patterns for package.json
const globPattern = `${pattern}/package.json`;

for await (const entry of glob(globPattern, { cwd: rootDir })) {
const pkgJsonPath = join(rootDir, entry);
try {
const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'));

// Skip private packages and packages without names
if (pkgJson.private || !pkgJson.name) continue;

const relativePath = dirname(relative(rootDir, pkgJsonPath));
packages.push([pkgJson.name, relativePath]);
} catch {
// Skip if package.json is invalid or cannot be read
console.warn(`Warning: Could not read ${pkgJsonPath}`);
}
Comment on lines +45 to +48
Copy link

Copilot AI Dec 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error handling in this catch block silently ignores all errors by logging a warning and continuing. If package.json parsing fails for important packages, the script will silently skip them without clearly indicating the impact. Consider either failing fast when critical packages cannot be read, or collecting and reporting all warnings at the end so users are aware of skipped packages.

Copilot uses AI. Check for mistakes.
}
}

return packages;
}

async function buildOverrides(): Promise<Record<string, string>> {
const packages = await discoverPackages();
const overrides: Record<string, string> = {};

for (const [name, path] of packages) {
const version = JSON.parse(fs.readFileSync(join(tgzPath, path, 'package.json'), 'utf8')).version;
const filename = `${name.replace('@', '').replace('/', '-')}-${version}.tgz`;
overrides[name] = `file:${tgzPath}/${filename}`;
}

return overrides;
}

async function patchPackageJSON(filePath: string, overrides: Record<string, string>): Promise<void> {
const packageJson = JSON.parse(fs.readFileSync(filePath, 'utf8'));
// Add overrides with tgz files
packageJson.overrides = {
...packageJson.overrides,
...overrides,
};

for (const name in packageJson.dependencies) {
const override = overrides[name];
if (override) {
packageJson.dependencies[name] = override;
}
}
for (const name in packageJson.devDependencies) {
const override = overrides[name];
if (override) {
packageJson.devDependencies[name] = override;
}
}

const packageJsonString = JSON.stringify(packageJson, null, 2) + '\n';
console.log(packageJsonString);
Copy link

Copilot AI Dec 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The console.log output on line 90 will be mixed with the actual execution output, making it difficult to distinguish between debug information and actual results. Consider using console.error for debug output or adding a flag to control verbosity.

Suggested change
console.log(packageJsonString);
console.error(packageJsonString);

Copilot uses AI. Check for mistakes.
fs.writeFileSync(filePath, packageJsonString);
}

async function patchCnpmcore(overrides: Record<string, string>): Promise<void> {
const packageJsonPath = join(projectDir, 'cnpmcore', 'package.json');
await patchPackageJSON(packageJsonPath, overrides);
}

async function patchExamples(overrides: Record<string, string>): Promise<void> {
// https://github.com/eggjs/examples/tree/master/hello-tegg
let packageJsonPath = join(projectDir, 'examples', 'hello-tegg', 'package.json');
await patchPackageJSON(packageJsonPath, overrides);

// https://github.com/eggjs/examples/blob/master/helloworld/package.json
packageJsonPath = join(projectDir, 'examples', 'helloworld', 'package.json');
await patchPackageJSON(packageJsonPath, overrides);
}

async function main(): Promise<void> {
const overrides = await buildOverrides();

switch (project) {
case 'cnpmcore':
await patchCnpmcore(overrides);
break;
case 'examples':
await patchExamples(overrides);
break;
default:
console.error(`Project ${project} is not supported`);
Copy link

Copilot AI Dec 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error message when an unsupported project is encountered in the switch statement is inconsistent with the earlier validation. At this point, the project has already been validated to exist in repo.json (line 17-20), so this error message is misleading. Consider changing to something like console.error(\Project ${project} exists in repo.json but has no patch handler defined`)` to accurately reflect the issue.

Suggested change
console.error(`Project ${project} is not supported`);
console.error(`Project ${project} exists in repo.json but has no patch handler defined`);

Copilot uses AI. Check for mistakes.
process.exit(1);
}
}

main();
Copy link

Copilot AI Dec 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing error handling for the main function. If any of the async operations fail, the process will exit with a success code (0) by default. Add a catch block to ensure the process exits with a non-zero code on failure.

Suggested change
main();
main().catch((err) => {
console.error(err);
process.exitCode = 1;
});

Copilot uses AI. Check for mistakes.
12 changes: 12 additions & 0 deletions ecosystem-ci/repo.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"cnpmcore": {
"repository": "https://github.com/cnpm/cnpmcore.git",
"branch": "master",
"hash": "8a1b79f3e0ec1aa1a9dcd37d059846bc4f4219f8"
},
"examples": {
"repository": "https://github.com/eggjs/examples.git",
"branch": "master",
"hash": "9191a92e5ec0712a2e4864e999b7db1874fe8ca3"
}
}
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
"type": "git",
"url": "git+https://github.com/eggjs/egg.git"
},
"files": [
"README.md"
],
Comment on lines +11 to +13
Copy link

Copilot AI Dec 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding a "files" field with only "README.md" to a private monorepo package is unnecessary since private packages are never published to npm. This field has no effect and should be removed to avoid confusion.

Suggested change
"files": [
"README.md"
],

Copilot uses AI. Check for mistakes.
"type": "module",
"scripts": {
"clean-dist": "pnpm -r --parallel exec rimraf dist",
Expand Down Expand Up @@ -45,6 +48,7 @@
"devDependencies": {
"@eggjs/bin": "workspace:*",
"@eggjs/tsconfig": "workspace:*",
"@types/js-yaml": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"@vitest/coverage-v8": "catalog:",
Expand Down
2 changes: 1 addition & 1 deletion plugins/schedule/test/immediate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ describe.skipIf(process.platform === 'win32')('cluster - immediate', () => {
});
});

describe('cluster - immediate-onlyonce', () => {
describe.skipIf(process.platform === 'win32')('cluster - immediate-onlyonce', () => {
let app: MockApplication;
beforeAll(async () => {
app = mm.cluster({
Expand Down
Loading
Loading