From 5ace78dcdeed4abf5f72b85b1ac928d39209dc4a Mon Sep 17 00:00:00 2001 From: Anthony Fu Date: Fri, 22 May 2026 11:34:25 +0900 Subject: [PATCH 1/2] ci: add ecosystem test against vitejs/devtools Pack the local devframe build, clone vitejs/devtools at its latest released tag, override its devframe dependency via pnpm.overrides, and run the downstream install/build/test to catch regressions before release. Triggered manually (with an optional ref input) or on a weekly schedule. --- .github/workflows/ecosystem-ci.yml | 39 +++++++++ .gitignore | 1 + package.json | 1 + scripts/ecosystem-ci.ts | 123 +++++++++++++++++++++++++++++ 4 files changed, 164 insertions(+) create mode 100644 .github/workflows/ecosystem-ci.yml create mode 100644 scripts/ecosystem-ci.ts diff --git a/.github/workflows/ecosystem-ci.yml b/.github/workflows/ecosystem-ci.yml new file mode 100644 index 0000000..5a6dd64 --- /dev/null +++ b/.github/workflows/ecosystem-ci.yml @@ -0,0 +1,39 @@ +name: Ecosystem CI + +on: + workflow_dispatch: + inputs: + ref: + description: 'vitejs/devtools ref to test against (tag, branch, or commit). Defaults to latest released tag.' + required: false + type: string + schedule: + - cron: '0 4 * * 1' + +permissions: + contents: read + +jobs: + vitejs-devtools: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm test:ecosystem + env: + ECOSYSTEM_DEVTOOLS_REF: ${{ inputs.ref }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - if: failure() + uses: actions/upload-artifact@v4 + with: + name: ecosystem-devtools-logs + path: | + .ecosystem/devtools/**/*.log + .ecosystem/devtools/packages/**/test-results/** + retention-days: 7 diff --git a/.gitignore b/.gitignore index 0f7cea9..584bb75 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ test-results playwright-report playwright/.cache blob-report +.ecosystem diff --git a/package.json b/package.json index 9f1f51f..b28803d 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "test": "turbo run build && vitest", "test:e2e": "turbo run build && playwright test", "test:e2e:ui": "turbo run build && playwright test --ui", + "test:ecosystem": "tsx scripts/ecosystem-ci.ts", "release": "bumpp -r", "typecheck": "tsc -b", "postinstall": "npx simple-git-hooks && skills-npm" diff --git a/scripts/ecosystem-ci.ts b/scripts/ecosystem-ci.ts new file mode 100644 index 0000000..1f39e6a --- /dev/null +++ b/scripts/ecosystem-ci.ts @@ -0,0 +1,123 @@ +import { spawnSync } from 'node:child_process' +import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import process from 'node:process' +import { fileURLToPath } from 'node:url' + +const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const ecosystemDir = resolve(rootDir, '.ecosystem') +const devtoolsDir = resolve(ecosystemDir, 'devtools') + +const REPO_URL = 'https://github.com/vitejs/devtools.git' +const RELEASES_API = 'https://api.github.com/repos/vitejs/devtools/releases/latest' +const KEEP = process.env.ECOSYSTEM_KEEP === '1' + +interface PackageManifest { + name: string + version: string + pnpm?: { + overrides?: Record + [key: string]: unknown + } + [key: string]: unknown +} + +async function main(): Promise { + const ref = await resolveRef() + log(`Target: vitejs/devtools @ ${ref}`) + + const tarball = packDevframe() + log(`Packed devframe: ${tarball}`) + + prepareClone(ref) + patchPackageJson(devtoolsDir, tarball) + + run('pnpm', ['install', '--no-frozen-lockfile'], devtoolsDir) + run('pnpm', ['build'], devtoolsDir) + run('pnpm', ['test'], devtoolsDir) + + log('All downstream checks passed') +} + +async function resolveRef(): Promise { + const override = process.env.ECOSYSTEM_DEVTOOLS_REF + if (override) + return override + + const headers: Record = { 'user-agent': 'devframe-ecosystem-ci' } + if (process.env.GITHUB_TOKEN) + headers.authorization = `Bearer ${process.env.GITHUB_TOKEN}` + + const res = await fetch(RELEASES_API, { headers }) + if (!res.ok) + throw new Error(`Failed to query latest release: ${res.status} ${res.statusText}`) + const data = await res.json() as { tag_name?: string } + if (!data.tag_name) + throw new Error('GitHub release payload missing tag_name') + return data.tag_name +} + +function packDevframe(): string { + const devframePkg = resolve(rootDir, 'packages', 'devframe') + const pkg = readManifest(resolve(devframePkg, 'package.json')) + const expected = `${pkg.name}-${pkg.version}.tgz` + + mkdirSync(ecosystemDir, { recursive: true }) + for (const f of readdirSync(ecosystemDir)) { + if (/^devframe-\d.*\.tgz$/.test(f)) + rmSync(resolve(ecosystemDir, f)) + } + + run('pnpm', ['pack', '--pack-destination', ecosystemDir], devframePkg) + + const tarball = resolve(ecosystemDir, expected) + if (!existsSync(tarball)) + throw new Error(`Expected packed tarball not found: ${tarball}`) + return tarball +} + +function prepareClone(ref: string): void { + mkdirSync(ecosystemDir, { recursive: true }) + + if (KEEP && existsSync(devtoolsDir)) { + log(`Reusing existing clone at ${devtoolsDir} (ECOSYSTEM_KEEP=1)`) + return + } + + if (existsSync(devtoolsDir)) + rmSync(devtoolsDir, { recursive: true, force: true }) + + run('git', ['clone', '--depth', '1', '--branch', ref, REPO_URL, devtoolsDir]) +} + +function patchPackageJson(repoDir: string, tarball: string): void { + const pkgPath = resolve(repoDir, 'package.json') + const pkg = readManifest(pkgPath) + pkg.pnpm ??= {} + pkg.pnpm.overrides = { ...(pkg.pnpm.overrides ?? {}), devframe: `file:${tarball}` } + writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`) + log(`Patched ${pkgPath}: devframe -> file:${tarball}`) +} + +function readManifest(file: string): PackageManifest { + return JSON.parse(readFileSync(file, 'utf8')) as PackageManifest +} + +function run(cmd: string, args: string[], cwd: string = rootDir): void { + log(`$ ${cmd} ${args.join(' ')} (in ${cwd})`) + const result = spawnSync(cmd, args, { cwd, stdio: 'inherit', shell: false }) + if (result.status !== 0) { + throw new Error( + `Command failed (exit ${result.status ?? 'unknown'}): ${cmd} ${args.join(' ')}`, + ) + } +} + +function log(msg: string): void { + console.log(`[ecosystem-ci] ${msg}`) +} + +main().catch((err) => { + console.error(err instanceof Error ? err.message : err) + process.exit(1) +}) From cebacc3ed2626087aa2eb401414e0f696fc307cf Mon Sep 17 00:00:00 2001 From: Anthony Fu Date: Fri, 22 May 2026 12:05:23 +0900 Subject: [PATCH 2/2] ci: support commit SHAs and surface spawn errors in ecosystem-ci Switch from `git clone --branch` to `git init` + `git fetch --depth 1` so the workflow's `ref` input accepts commit SHAs (as the description already advertises), and include `result.error` / `result.signal` in the thrown message so spawn failures are diagnosable. Addresses Copilot review on #23. --- scripts/ecosystem-ci.ts | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/scripts/ecosystem-ci.ts b/scripts/ecosystem-ci.ts index 1f39e6a..6858650 100644 --- a/scripts/ecosystem-ci.ts +++ b/scripts/ecosystem-ci.ts @@ -87,7 +87,13 @@ function prepareClone(ref: string): void { if (existsSync(devtoolsDir)) rmSync(devtoolsDir, { recursive: true, force: true }) - run('git', ['clone', '--depth', '1', '--branch', ref, REPO_URL, devtoolsDir]) + // Use init + fetch instead of `clone --branch` so any ref works — tag, + // branch, or commit SHA. GitHub allows fetching reachable SHAs by default. + mkdirSync(devtoolsDir, { recursive: true }) + run('git', ['init', '--quiet'], devtoolsDir) + run('git', ['remote', 'add', 'origin', REPO_URL], devtoolsDir) + run('git', ['fetch', '--depth', '1', 'origin', ref], devtoolsDir) + run('git', ['checkout', '--quiet', 'FETCH_HEAD'], devtoolsDir) } function patchPackageJson(repoDir: string, tarball: string): void { @@ -106,11 +112,12 @@ function readManifest(file: string): PackageManifest { function run(cmd: string, args: string[], cwd: string = rootDir): void { log(`$ ${cmd} ${args.join(' ')} (in ${cwd})`) const result = spawnSync(cmd, args, { cwd, stdio: 'inherit', shell: false }) - if (result.status !== 0) { - throw new Error( - `Command failed (exit ${result.status ?? 'unknown'}): ${cmd} ${args.join(' ')}`, - ) - } + if (result.error) + throw new Error(`Command failed to spawn: ${cmd} ${args.join(' ')}: ${result.error.message}`) + if (result.signal) + throw new Error(`Command terminated by signal ${result.signal}: ${cmd} ${args.join(' ')}`) + if (result.status !== 0) + throw new Error(`Command failed (exit ${result.status ?? 'unknown'}): ${cmd} ${args.join(' ')}`) } function log(msg: string): void {