├── .editorconfig ├── .github ├── renovate.json5 └── workflows │ ├── ci.yml │ ├── ecosystem-ci-from-pr.yml │ ├── ecosystem-ci-selected.yml │ └── ecosystem-ci.yml ├── .gitignore ├── .npmrc ├── .prettierrc.json ├── LICENSE ├── README.md ├── discord-webhook.ts ├── ecosystem-ci.ts ├── eslint.config.ts ├── package.json ├── pnpm-lock.yaml ├── registry.ts ├── tests ├── _selftest.ts ├── language-tools.ts ├── naive-ui.ts ├── nuxt.ts ├── pinia.ts ├── primevue.ts ├── quasar.ts ├── radix-vue.ts ├── router.ts ├── test-utils.ts ├── vant.ts ├── vite-plugin-vue.ts ├── vitepress.ts ├── vue-i18n.ts ├── vue-macros.ts ├── vue-simple-compiler.ts ├── vuetify.ts └── vueuse.ts ├── tsconfig.json ├── types.d.ts ├── typings ├── package.json └── pnpm-lock.yaml ├── utils.ts └── verdaccio.yaml /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | indent_style = tab 7 | indent_size = 2 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | 11 | [package.json] 12 | indent_style = space 13 | 14 | [*.{yml,yaml}] 15 | indent_style = space 16 | -------------------------------------------------------------------------------- /.github/renovate.json5: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "github>haoqunjiang/renovate-presets:npm.json5", 5 | "schedule:monthly", 6 | ], 7 | } 8 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | ci: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | - uses: actions/setup-node@v4 17 | with: 18 | node-version: 22.13.1 19 | - run: npm install --global corepack 20 | - run: corepack enable 21 | - run: pnpm --version 22 | - uses: actions/setup-node@v4 23 | with: 24 | node-version: 22.13.1 25 | cache: "pnpm" 26 | cache-dependency-path: "**/pnpm-lock.yaml" 27 | - name: install 28 | run: pnpm install --frozen-lockfile --prefer-offline 29 | - name: format 30 | run: pnpm format 31 | - name: lint 32 | run: pnpm run lint 33 | - name: typecheck 34 | run: pnpm run typecheck 35 | - name: audit 36 | if: (${{ success() }} || ${{ failure() }}) 37 | run: pnpm audit 38 | - name: test 39 | if: (${{ success() }} || ${{ failure() }}) 40 | run: pnpm test:self 41 | env: 42 | COREPACK_ENABLE_STRICT: 0 43 | -------------------------------------------------------------------------------- /.github/workflows/ecosystem-ci-from-pr.yml: -------------------------------------------------------------------------------- 1 | # integration tests for vue ecosystem - run from pr comments 2 | name: ecosystem-ci-from-pr 3 | 4 | env: 5 | # 7 GiB by default on GitHub, setting to 6 GiB 6 | # https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners#supported-runners-and-hardware-resources 7 | NODE_OPTIONS: --max-old-space-size=6144 8 | 9 | on: 10 | workflow_dispatch: 11 | inputs: 12 | prNumber: 13 | description: "PR number (e.g. 9887)" 14 | required: true 15 | type: string 16 | branchName: 17 | description: "vue branch to use" 18 | required: true 19 | type: string 20 | default: "main" 21 | repo: 22 | description: "vue repository to use" 23 | required: true 24 | type: string 25 | default: "vuejs/core" 26 | commit: 27 | description: "commit to use" 28 | type: string 29 | suite: 30 | description: "testsuite to run. runs all testsuits when `-`." 31 | required: false 32 | type: choice 33 | options: 34 | - "-" 35 | - language-tools 36 | # - naive-ui 37 | - nuxt 38 | - pinia 39 | - primevue 40 | - quasar 41 | - radix-vue 42 | - router 43 | - test-utils 44 | - vant 45 | - vite-plugin-vue 46 | - vitepress 47 | - vue-i18n 48 | - vue-macros 49 | - vuetify 50 | - vueuse 51 | - vue-simple-compiler 52 | jobs: 53 | init: 54 | name: "Running for PR #${{ github.event.inputs.prNumber }}" 55 | runs-on: ubuntu-latest 56 | outputs: 57 | comment-id: ${{ steps.create-comment.outputs.result }} 58 | steps: 59 | - id: create-comment 60 | uses: actions/github-script@v7 61 | with: 62 | github-token: ${{ secrets.ECOSYSTEM_CI_ACCESS_TOKEN }} 63 | result-encoding: string 64 | script: | 65 | const url = `${context.serverUrl}//${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}` 66 | const urlLink = `[Open](${url})` 67 | 68 | const { data: comment } = await github.rest.issues.createComment({ 69 | issue_number: context.payload.inputs.prNumber, 70 | owner: context.repo.owner, 71 | repo: 'core', 72 | body: `⏳ Triggered ecosystem CI: ${urlLink}` 73 | }) 74 | return comment.id 75 | 76 | execute-selected-suite: 77 | timeout-minutes: 60 78 | runs-on: ubuntu-latest 79 | needs: init 80 | if: "inputs.suite != '-'" 81 | steps: 82 | - uses: actions/checkout@v4 83 | - uses: actions/setup-node@v4 84 | with: 85 | node-version: 22.13.1 86 | - run: npm install --global corepack 87 | - run: corepack enable 88 | - run: pnpm --version 89 | - run: pnpm i --frozen-lockfile 90 | 91 | - if: ${{ !inputs.commit }} 92 | run: >- 93 | pnpm tsx ecosystem-ci.ts 94 | --branch ${{ inputs.branchName }} 95 | --repo ${{ inputs.repo }} 96 | ${{ inputs.suite }} 97 | env: 98 | COREPACK_ENABLE_STRICT: 0 99 | 100 | - if: ${{ inputs.commit }} 101 | run: pnpm tsx ecosystem-ci.ts --commit ${{ inputs.commit }} ${{ inputs.suite }} 102 | env: 103 | COREPACK_ENABLE_STRICT: 0 104 | 105 | execute-all: 106 | timeout-minutes: 60 107 | runs-on: ubuntu-latest 108 | needs: init 109 | if: "inputs.suite == '-'" 110 | strategy: 111 | matrix: 112 | suite: 113 | - language-tools 114 | # - naive-ui 115 | - nuxt 116 | - pinia 117 | - primevue 118 | - quasar 119 | - radix-vue 120 | - router 121 | - test-utils 122 | - vant 123 | - vite-plugin-vue 124 | - vitepress 125 | - vue-i18n 126 | - vue-macros 127 | - vuetify 128 | - vueuse 129 | - vue-simple-compiler 130 | fail-fast: false 131 | steps: 132 | - uses: actions/checkout@v4 133 | - uses: actions/setup-node@v4 134 | with: 135 | node-version: 22.13.1 136 | - run: npm install --global corepack 137 | - run: corepack enable 138 | - run: pnpm --version 139 | - run: pnpm i --frozen-lockfile 140 | 141 | - if: ${{ !inputs.commit }} 142 | run: >- 143 | pnpm tsx ecosystem-ci.ts 144 | --branch ${{ inputs.branchName }} 145 | --repo ${{ inputs.repo }} 146 | ${{ matrix.suite }} 147 | env: 148 | COREPACK_ENABLE_STRICT: 0 149 | 150 | - if: ${{ inputs.commit }} 151 | run: pnpm tsx ecosystem-ci.ts --commit ${{ inputs.commit }} ${{ matrix.suite }} 152 | env: 153 | COREPACK_ENABLE_STRICT: 0 154 | 155 | update-comment: 156 | runs-on: ubuntu-latest 157 | needs: [init, execute-selected-suite, execute-all] 158 | if: always() 159 | steps: 160 | - uses: actions/github-script@v7 161 | with: 162 | github-token: ${{ secrets.ECOSYSTEM_CI_ACCESS_TOKEN }} 163 | script: | 164 | const { data: { jobs } } = await github.rest.actions.listJobsForWorkflowRun({ 165 | owner: context.repo.owner, 166 | repo: context.repo.repo, 167 | run_id: context.runId, 168 | per_page: 100 169 | }); 170 | 171 | const selectedSuite = context.payload.inputs.suite 172 | let results 173 | if (selectedSuite !== "-") { 174 | const { conclusion, html_url } = jobs.find(job => job.name === "execute-selected-suite") 175 | results = [{ suite: selectedSuite, conclusion, link: html_url }] 176 | } else { 177 | results = jobs 178 | .filter(job => job.name.startsWith('execute-all ')) 179 | .map(job => { 180 | const suite = job.name.replace(/^execute-all \(([^)]+)\)$/, "$1") 181 | return { suite, conclusion: job.conclusion, link: job.html_url } 182 | }) 183 | } 184 | 185 | const url = `${context.serverUrl}//${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}` 186 | const urlLink = `[Open](${url})` 187 | 188 | const conclusionEmoji = { 189 | success: ":white_check_mark:", 190 | failure: ":x:", 191 | cancelled: ":stop_button:" 192 | } 193 | 194 | // check for previous ecosystem-ci runs against the main branch 195 | 196 | // first, list workflow runs for ecosystem-ci.yml 197 | const { data: { workflow_runs } } = await github.rest.actions.listWorkflowRuns({ 198 | owner: context.repo.owner, 199 | repo: context.repo.repo, 200 | workflow_id: 'ecosystem-ci.yml' 201 | }); 202 | 203 | // for simplity, we only take the latest completed scheduled run 204 | // otherwise we would have to check the inputs for every maunally triggerred runs, which is an overkill 205 | const latestScheduledRun = workflow_runs.find(run => run.event === "schedule" && run.status === "completed") 206 | 207 | // get the jobs for the latest scheduled run 208 | const { data: { jobs: scheduledJobs } } = await github.rest.actions.listJobsForWorkflowRun({ 209 | owner: context.repo.owner, 210 | repo: context.repo.repo, 211 | run_id: latestScheduledRun.id 212 | }); 213 | const scheduledResults = scheduledJobs 214 | .filter(job => job.name.startsWith('test-ecosystem ')) 215 | .map(job => { 216 | const suite = job.name.replace(/^test-ecosystem \(([^)]+)\)$/, "$1") 217 | return { suite, conclusion: job.conclusion, link: job.html_url } 218 | }) 219 | 220 | const body = ` 221 | 📝 Ran ecosystem CI: ${urlLink} 222 | 223 | | suite | result | [latest scheduled](${latestScheduledRun.html_url}) | 224 | |-------|--------|----------------| 225 | ${results.map(current => { 226 | const latest = scheduledResults.find(s => s.suite === current.suite) || {} // in case a new suite is added after latest scheduled 227 | 228 | const firstColumn = current.suite 229 | const secondColumn = `${conclusionEmoji[current.conclusion]} [${current.conclusion}](${current.link})` 230 | const thirdColumn = `${conclusionEmoji[latest.conclusion]} [${latest.conclusion}](${latest.link})` 231 | 232 | return `| ${firstColumn} | ${secondColumn} | ${thirdColumn} |` 233 | }).join("\n")} 234 | ` 235 | 236 | if (selectedSuite === "-") { 237 | // delete the previous ran results 238 | try { 239 | const { data: comments } = await github.rest.issues.listComments({ 240 | issue_number: context.payload.inputs.prNumber, 241 | owner: context.repo.owner, 242 | repo: 'core' 243 | }) 244 | 245 | const triggerComments = comments.filter(comment => 246 | comment.body.includes('/ecosystem-ci run') 247 | ) 248 | // note: issue comments are ordered by ascending ID. 249 | // delete the previous ecosystem-ci trigger comments 250 | // just keep the latest one 251 | triggerComments.pop() 252 | 253 | const workflowComments = comments.filter(comment => 254 | comment.body.includes('Ran ecosystem CI:') || comment.body.includes('Triggered ecosystem CI:') 255 | ) 256 | for (const comment of [...workflowComments, ...triggerComments]) { 257 | await github.rest.issues.deleteComment({ 258 | owner: context.repo.owner, 259 | repo: 'core', 260 | comment_id: comment.id 261 | }); 262 | console.log(`Deleted previous comment: ${comment.id}`); 263 | } 264 | } catch (error) { 265 | console.log('Error when trying to delete previous comments:', error); 266 | } 267 | } 268 | 269 | try { 270 | await github.rest.issues.deleteComment({ 271 | owner: context.repo.owner, 272 | repo: 'core', 273 | comment_id: ${{ needs.init.outputs.comment-id }} 274 | }) 275 | } catch (error) { 276 | } 277 | 278 | await github.rest.issues.createComment({ 279 | issue_number: context.payload.inputs.prNumber, 280 | owner: context.repo.owner, 281 | repo: 'core', 282 | body 283 | }) 284 | -------------------------------------------------------------------------------- /.github/workflows/ecosystem-ci-selected.yml: -------------------------------------------------------------------------------- 1 | # integration tests for vue ecosystem - single run of selected testsuite 2 | name: ecosystem-ci-selected 3 | 4 | env: 5 | # 7 GiB by default on GitHub, setting to 6 GiB 6 | # https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners#supported-runners-and-hardware-resources 7 | NODE_OPTIONS: --max-old-space-size=6144 8 | 9 | on: 10 | workflow_dispatch: 11 | inputs: 12 | refType: 13 | description: "type of vue ref to use" 14 | required: true 15 | type: choice 16 | options: 17 | - branch 18 | - tag 19 | - commit 20 | - release 21 | default: "branch" 22 | ref: 23 | description: "vue ref to use" 24 | required: true 25 | type: string 26 | default: "main" 27 | repo: 28 | description: "vue repository to use" 29 | required: true 30 | type: string 31 | default: "vuejs/core" 32 | suite: 33 | description: "testsuite to run" 34 | required: true 35 | type: choice 36 | options: 37 | - language-tools 38 | # - naive-ui 39 | - nuxt 40 | - pinia 41 | - primevue 42 | - quasar 43 | - radix-vue 44 | - router 45 | - test-utils 46 | - vant 47 | - vite-plugin-vue 48 | - vitepress 49 | - vue-i18n 50 | - vue-macros 51 | - vuetify 52 | - vueuse 53 | - vue-simple-compiler 54 | jobs: 55 | execute-selected-suite: 56 | timeout-minutes: 60 57 | runs-on: ubuntu-latest 58 | steps: 59 | - uses: actions/checkout@v4 60 | - uses: actions/setup-node@v4 61 | with: 62 | node-version: 22.13.1 63 | id: setup-node 64 | 65 | - run: npm install --global corepack 66 | - run: corepack enable 67 | - run: pnpm --version 68 | - run: pnpm i --frozen-lockfile 69 | - run: >- 70 | pnpm tsx ecosystem-ci.ts 71 | --${{ inputs.refType }} ${{ inputs.ref }} 72 | --repo ${{ inputs.repo }} 73 | ${{ inputs.suite }} 74 | id: ecosystem-ci-run 75 | env: 76 | COREPACK_ENABLE_STRICT: 0 77 | -------------------------------------------------------------------------------- /.github/workflows/ecosystem-ci.yml: -------------------------------------------------------------------------------- 1 | # integration tests for vue ecosystem projects - scheduled or manual run for all suites 2 | name: ecosystem-ci 3 | 4 | env: 5 | # 7 GiB by default on GitHub, setting to 6 GiB 6 | # https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners#supported-runners-and-hardware-resources 7 | NODE_OPTIONS: --max-old-space-size=6144 8 | 9 | on: 10 | schedule: 11 | - cron: "0 5 * * 1,3,5" # monday,wednesday,friday 5AM 12 | workflow_dispatch: 13 | inputs: 14 | refType: 15 | description: "type of ref" 16 | required: true 17 | type: choice 18 | options: 19 | - branch 20 | - tag 21 | - commit 22 | - release 23 | default: "branch" 24 | ref: 25 | description: "vue ref to use" 26 | required: true 27 | type: string 28 | default: "main" 29 | repo: 30 | description: "vue repository to use" 31 | required: true 32 | type: string 33 | default: "vuejs/core" 34 | repository_dispatch: 35 | types: [ecosystem-ci] 36 | jobs: 37 | test-ecosystem: 38 | timeout-minutes: 60 39 | runs-on: ubuntu-latest 40 | strategy: 41 | matrix: 42 | suite: 43 | - language-tools 44 | # - naive-ui 45 | - nuxt 46 | - pinia 47 | - primevue 48 | - quasar 49 | - radix-vue 50 | - router 51 | - test-utils 52 | - vant 53 | - vite-plugin-vue 54 | - vitepress 55 | - vue-i18n 56 | - vue-macros 57 | - vuetify 58 | - vueuse 59 | - vue-simple-compiler 60 | fail-fast: false 61 | steps: 62 | - uses: actions/checkout@v4 63 | - uses: actions/setup-node@v4 64 | with: 65 | node-version: 22.13.1 66 | id: setup-node 67 | 68 | - run: npm install --global corepack 69 | - run: corepack enable 70 | - run: pnpm --version 71 | - run: pnpm i --frozen-lockfile 72 | - run: >- 73 | pnpm tsx ecosystem-ci.ts 74 | --${{ inputs.refType || github.event.client_payload.refType || 'branch' }} ${{ inputs.ref || github.event.client_payload.ref || 'main' }} 75 | --repo ${{ inputs.repo || github.event.client_payload.repo || 'vuejs/core' }} 76 | ${{ matrix.suite }} 77 | id: ecosystem-ci-run 78 | env: 79 | COREPACK_ENABLE_STRICT: 0 80 | - if: always() 81 | run: pnpm tsx discord-webhook.ts 82 | env: 83 | WORKFLOW_NAME: ci 84 | REF_TYPE: ${{ inputs.refType || github.event.client_payload.refType || 'branch' }} 85 | REF: ${{ inputs.ref || github.event.client_payload.ref || 'main' }} 86 | REPO: ${{ inputs.repo || github.event.client_payload.repo || 'vuejs/core' }} 87 | SUITE: ${{ matrix.suite }} 88 | STATUS: ${{ job.status }} 89 | DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} 90 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 91 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .DS_Store? 3 | node_modules 4 | core 5 | vue 6 | workspace 7 | .pnpm-debug.log 8 | .idea 9 | .verdaccio-cache 10 | built-packages 11 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict = false 2 | strict-peer-dependencies = false 3 | package-manager-strict = false 4 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": true, 3 | "semi": false, 4 | "tabWidth": 2, 5 | "singleQuote": true, 6 | "printWidth": 80, 7 | "trailingComma": "all", 8 | "overrides": [ 9 | { 10 | "files": ["*.json5"], 11 | "options": { 12 | "singleQuote": false, 13 | "quoteProps": "preserve" 14 | } 15 | }, 16 | { 17 | "files": ["*.yml"], 18 | "options": { 19 | "singleQuote": false 20 | } 21 | }, 22 | { 23 | "files": "**/pnpm-lock.yaml", 24 | "options": { 25 | "requirePragma": true 26 | } 27 | }, 28 | { 29 | "files": "**/package.json", 30 | "options": { 31 | "useTabs": false, 32 | "tabWidth": 2 33 | } 34 | } 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023-present, Vue contributors 4 | Copyright (c) 2021-present, Vite contributors 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Vue Ecosystem CI 2 | 3 | This repository is used to run integration tests for vue ecosystem projects 4 | 5 | ## How it works 6 | 7 | We now have continuous release like [this](https://github.com/vuejs/core/runs/28854321865) via [pkg.pr.new](https://github.com/stackblitz-labs/pkg.pr.new). By default when running against a branch or a commit, we will use the corresponding release from `pkg.pr.new` so we don't need to build / mock publish the packages again. 8 | 9 | We will use pnpm override to force install the specific version of Vue in the downstream projects and then run their tests. 10 | 11 | In cases where we cannot use pre-built packages, the script will perform a fresh build by pulling the specific Vue branch / commit and publish them to a local verdaccio registry. 12 | 13 | ## via github workflow 14 | 15 | ### scheduled 16 | 17 | Workflows are scheduled to run automatically every Monday, Wednesday and Friday 18 | 19 | ### manually 20 | 21 | - open [workflow](../../actions/workflows/ecosystem-ci-selected.yml) 22 | - click 'Run workflow' button on top right of the list 23 | - select suite to run in dropdown 24 | - start workflow 25 | 26 | ## via shell script 27 | 28 | - clone this repo 29 | - run `pnpm i` 30 | - run `pnpm test` to run all suites 31 | - or `pnpm test ` to select a suite 32 | - or `tsx ecosystem-ci.ts` 33 | 34 | Note if you are not using `pnpm` through `corepack` locally, you need to prepend every command with `COREPACK_ENABLE_STRICT=0 `. 35 | 36 | You can pass `--tag v3.2.0-beta.1`, `--branch somebranch` or `--commit abcd1234` option to select a specific vue version to build. 37 | If you pass `--release 3.2.45`, vue build will be skipped and vue is fetched from the registry instead. 38 | 39 | The repositories are checked out into `workspace` subdirectory as shallow clones. 40 | 41 | If you want to test **the same version (or tag)** of vue multiple times, please **run `pnpm clean` first** to ensure the workspace is clean and the package registry cache is purged. 42 | 43 | ### Running against local build 44 | 45 | To run against the local build, link the `packages` directory of a local `vuejs/core` clone to `built-packages` inside this repo, then run with the `--local` option. 46 | 47 | ### Explicitly running against pkg.pr.new releases 48 | 49 | You can run against a specific continuous release via `--release @`. For example: 50 | 51 | ``` 52 | tsx ecosystem-ci.ts --release @main 53 | tsx ecosystem-ci.ts --release @ca41b9202 54 | ``` 55 | 56 | ## how to add a new integration test 57 | 58 | - check out the existing [tests](./tests) and add one yourself. Thanks to some utilities it is really easy 59 | - once you are confident the suite works, add it to the lists of suites in the [workflows](../../actions/) 60 | 61 | > the current utilities focus on pnpm based projects. Consider switching to pnpm or contribute utilities for other pms 62 | 63 | If your project needs some special setup when running in the Ecosystem CI, you can detect the environment by checking for the `ECOSYSTEM_CI` environment variable. It would be set to `vue` if running in the Vue Ecosystem CI. 64 | 65 | ## reporting results 66 | 67 | ### on your own server 68 | 69 | - Go to `Server settings > Integrations > Webhooks` and click `New Webhook` 70 | - Give it a name, icon and a channel to post to 71 | - copy the webhook url 72 | - get in touch with admins of this repo so they can add the webhook 73 | 74 | ### how to add a discord webhook here 75 | 76 | - Go to `/settings/secrets/actions` and click on `New repository secret` 77 | - set `Name` as `DISCORD_WEBHOOK_URL` 78 | - paste the discord webhook url you copied from above into `Value` 79 | - Click `Add secret` 80 | -------------------------------------------------------------------------------- /discord-webhook.ts: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch' 2 | import { 3 | getPermanentRef, 4 | setupEnvironment, 5 | teardownEnvironment, 6 | } from './utils.ts' 7 | 8 | type RefType = 'branch' | 'tag' | 'commit' | 'release' 9 | type Status = 'success' | 'failure' | 'cancelled' 10 | type Env = { 11 | WORKFLOW_NAME?: string 12 | REF_TYPE?: RefType 13 | REF?: string 14 | REPO?: string 15 | SUITE?: string 16 | STATUS?: Status 17 | DISCORD_WEBHOOK_URL?: string 18 | } 19 | 20 | const statusConfig = { 21 | success: { 22 | color: parseInt('57ab5a', 16), 23 | emoji: ':white_check_mark:', 24 | }, 25 | failure: { 26 | color: parseInt('e5534b', 16), 27 | emoji: ':x:', 28 | }, 29 | cancelled: { 30 | color: parseInt('768390', 16), 31 | emoji: ':stop_button:', 32 | }, 33 | } 34 | 35 | async function run() { 36 | if (!process.env.GITHUB_ACTIONS) { 37 | throw new Error('This script can only run on GitHub Actions.') 38 | } 39 | if (!process.env.DISCORD_WEBHOOK_URL) { 40 | console.warn( 41 | "Skipped beacuse process.env.DISCORD_WEBHOOK_URL was empty or didn't exist", 42 | ) 43 | return 44 | } 45 | if (!process.env.GITHUB_TOKEN) { 46 | console.warn( 47 | "Not using a token because process.env.GITHUB_TOKEN was empty or didn't exist", 48 | ) 49 | } 50 | 51 | const env = process.env as Env 52 | 53 | assertEnv('WORKFLOW_NAME', env.WORKFLOW_NAME) 54 | assertEnv('REF_TYPE', env.REF_TYPE) 55 | assertEnv('REF', env.REF) 56 | assertEnv('REPO', env.REPO) 57 | assertEnv('SUITE', env.SUITE) 58 | assertEnv('STATUS', env.STATUS) 59 | assertEnv('DISCORD_WEBHOOK_URL', env.DISCORD_WEBHOOK_URL) 60 | 61 | await setupEnvironment() 62 | 63 | const refType = env.REF_TYPE 64 | // vue repo is not cloned when release 65 | const permRef = refType === 'release' ? undefined : await getPermanentRef() 66 | 67 | const targetText = createTargetText(refType, env.REF, permRef, env.REPO) 68 | 69 | const webhookContent = { 70 | username: `ecosystem-ci (${env.WORKFLOW_NAME})`, 71 | avatar_url: 'https://github.com/vuejs.png', 72 | embeds: [ 73 | { 74 | title: `${statusConfig[env.STATUS].emoji} ${env.SUITE}`, 75 | description: await createDescription(env.SUITE, targetText), 76 | color: statusConfig[env.STATUS].color, 77 | }, 78 | ], 79 | } 80 | 81 | const res = await fetch(env.DISCORD_WEBHOOK_URL, { 82 | method: 'POST', 83 | headers: { 84 | 'Content-Type': 'application/json', 85 | }, 86 | body: JSON.stringify(webhookContent), 87 | }) 88 | if (res.ok) { 89 | console.log('Sent Webhook') 90 | } else { 91 | console.error(`Webhook failed ${res.status}:`, await res.text()) 92 | } 93 | 94 | await teardownEnvironment() 95 | } 96 | 97 | function assertEnv( 98 | name: string, 99 | value: T, 100 | ): asserts value is Exclude { 101 | if (!value) { 102 | throw new Error(`process.env.${name} is empty or does not exist.`) 103 | } 104 | } 105 | 106 | async function createRunUrl(suite: string) { 107 | const result = await fetchJobs() 108 | if (!result) { 109 | return undefined 110 | } 111 | 112 | if (result.total_count <= 0) { 113 | console.warn('total_count was 0') 114 | return undefined 115 | } 116 | 117 | const job = result.jobs.find((job) => job.name === process.env.GITHUB_JOB) 118 | if (job) { 119 | return job.html_url 120 | } 121 | 122 | // when matrix 123 | const jobM = result.jobs.find( 124 | (job) => job.name === `${process.env.GITHUB_JOB} (${suite})`, 125 | ) 126 | return jobM?.html_url 127 | } 128 | 129 | interface GitHubActionsJob { 130 | name: string 131 | html_url: string 132 | } 133 | 134 | async function fetchJobs() { 135 | const url = `${process.env.GITHUB_API_URL}/repos/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}/jobs` 136 | const res = await fetch(url, { 137 | headers: { 138 | Accept: 'application/vnd.github.v3+json', 139 | ...(process.env.GITHUB_TOKEN 140 | ? { 141 | Authorization: `token ${process.env.GITHUB_TOKEN}`, 142 | } 143 | : undefined), 144 | }, 145 | }) 146 | if (!res.ok) { 147 | console.warn( 148 | `Failed to fetch jobs (${res.status} ${res.statusText}): ${res.text()}`, 149 | ) 150 | return null 151 | } 152 | 153 | const result = await res.json() 154 | return result as { 155 | total_count: number 156 | jobs: GitHubActionsJob[] 157 | } 158 | } 159 | 160 | async function createDescription(suite: string, targetText: string) { 161 | const runUrl = await createRunUrl(suite) 162 | const open = runUrl === undefined ? 'Null' : `[Open](${runUrl})` 163 | 164 | return ` 165 | :scroll:\u00a0\u00a0${open}\u3000\u3000:zap:\u00a0\u00a0${targetText} 166 | `.trim() 167 | } 168 | 169 | function createTargetText( 170 | refType: RefType, 171 | ref: string, 172 | permRef: string | undefined, 173 | repo: string, 174 | ) { 175 | const repoText = repo !== 'vuejs/core' ? `${repo}:` : '' 176 | if (refType === 'branch') { 177 | const link = `https://github.com/${repo}/commits/${permRef || ref}` 178 | return `[${repoText}${ref} (${permRef || 'unknown'})](${link})` 179 | } 180 | 181 | const refTypeText = refType === 'release' ? ' (release)' : '' 182 | const link = `https://github.com/${repo}/commits/${ref}` 183 | return `[${repoText}${ref}${refTypeText}](${link})` 184 | } 185 | 186 | run().catch((e) => { 187 | console.error('Error sending webhook:', e) 188 | }) 189 | -------------------------------------------------------------------------------- /ecosystem-ci.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs' 2 | import path from 'node:path' 3 | import process from 'node:process' 4 | import https from 'node:https' 5 | import { cac } from 'cac' 6 | 7 | import { 8 | setupEnvironment, 9 | teardownEnvironment, 10 | setupVueRepo, 11 | buildVue, 12 | bisectVue, 13 | parseVueVersion, 14 | } from './utils.ts' 15 | import { CommandOptions, RunOptions } from './types.ts' 16 | 17 | const cli = cac() 18 | cli 19 | .command('[...suites]', 'build vue and run selected suites') 20 | .option('--verify', 'verify checkouts by running tests', { default: false }) 21 | .option('--repo ', 'vue repository to use', { default: 'vuejs/core' }) 22 | .option('--branch ', 'vue branch to use', { default: 'main' }) 23 | .option('--tag ', 'vue tag to use') 24 | .option('--commit ', 'vue commit sha to use') 25 | .option( 26 | '--release ', 27 | 'vue release to use from npm registry or pkg.pr.new.', 28 | ) 29 | .option('--local', 'test locally (expects built-packages to be present)') 30 | .action(async (suites, options: CommandOptions) => { 31 | const { root, vuePath, workspace } = await setupEnvironment() 32 | const suitesToRun = getSuitesToRun(suites, root) 33 | let vueVersion 34 | 35 | // Need to setup the Vue repo to get the package names 36 | await setupVueRepo(options) 37 | 38 | const isSelfTest = suites.length === 1 && suites[0] === '_selftest' 39 | 40 | // Normalize branch / commit to pkg.pr.new releases 41 | if (!isSelfTest && !options.release && !options.tag && !options.local) { 42 | if (options.commit) { 43 | const shortSha = options.commit.slice(0, 7) 44 | const hasRelease = await checkRelease(shortSha) 45 | if (hasRelease) { 46 | options.release = `@${shortSha}` 47 | } else { 48 | console.log( 49 | '\nCommit not released on pkg.pr.new yet, building from source.\n', 50 | ) 51 | } 52 | } else if (options.repo === 'vuejs/core' && options.branch) { 53 | options.release = `@${options.branch}` 54 | } 55 | } 56 | 57 | if (options.release) { 58 | vueVersion = options.release 59 | } else { 60 | if (!options.local) { 61 | await buildVue({ verify: options.verify, publish: true }) 62 | } 63 | vueVersion = parseVueVersion(vuePath) 64 | } 65 | 66 | const runOptions: RunOptions = { 67 | root, 68 | vuePath, 69 | vueVersion, 70 | workspace, 71 | release: options.release, 72 | verify: options.verify, 73 | skipGit: false, 74 | } 75 | for (const suite of suitesToRun) { 76 | await run(suite, runOptions) 77 | } 78 | await teardownEnvironment() 79 | }) 80 | 81 | cli 82 | .command('build-vue', 'build vue only') 83 | .option('--verify', 'verify vue checkout by running tests', { 84 | default: false, 85 | }) 86 | .option('--publish', 'publish the built vue packages to the local registry', { 87 | default: false, 88 | }) 89 | .option('--repo ', 'vue repository to use', { default: 'vuejs/core' }) 90 | .option('--branch ', 'vue branch to use', { default: 'main' }) 91 | .option('--tag ', 'vue tag to use') 92 | .option('--commit ', 'vue commit sha to use') 93 | .action(async (options: CommandOptions) => { 94 | await setupEnvironment() 95 | await setupVueRepo(options) 96 | await buildVue({ verify: options.verify, publish: options.publish }) 97 | await teardownEnvironment() 98 | }) 99 | 100 | cli 101 | .command( 102 | 'run-suites [...suites]', 103 | 'run single suite with pre-built and locally-published vue', 104 | ) 105 | .option( 106 | '--verify', 107 | 'verify checkout by running tests before using local vue', 108 | { default: false }, 109 | ) 110 | .option('--repo ', 'vue repository to use', { default: 'vuejs/core' }) 111 | .option('--release ', 'vue release to use from npm registry') 112 | .action(async (suites, options: CommandOptions) => { 113 | const { root, vuePath, workspace } = await setupEnvironment() 114 | const suitesToRun = getSuitesToRun(suites, root) 115 | const runOptions: RunOptions = { 116 | ...options, 117 | root, 118 | vuePath, 119 | vueVersion: parseVueVersion(vuePath), 120 | workspace, 121 | } 122 | for (const suite of suitesToRun) { 123 | await run(suite, runOptions) 124 | } 125 | await teardownEnvironment() 126 | }) 127 | 128 | cli 129 | .command( 130 | 'bisect [...suites]', 131 | 'use git bisect to find a commit in vue that broke suites', 132 | ) 133 | .option('--good ', 'last known good ref, e.g. a previous tag. REQUIRED!') 134 | .option('--verify', 'verify checkouts by running tests', { default: false }) 135 | .option('--repo ', 'vue repository to use', { default: 'vuejs/core' }) 136 | .option('--branch ', 'vue branch to use', { default: 'main' }) 137 | .option('--tag ', 'vue tag to use') 138 | .option('--commit ', 'vue commit sha to use') 139 | .action(async (suites, options: CommandOptions & { good: string }) => { 140 | if (!options.good) { 141 | console.log( 142 | 'you have to specify a known good version with `--good `', 143 | ) 144 | process.exit(1) 145 | } 146 | const { root, vuePath, workspace } = await setupEnvironment() 147 | const suitesToRun = getSuitesToRun(suites, root) 148 | let isFirstRun = true 149 | const { verify } = options 150 | const runSuite = async () => { 151 | try { 152 | await buildVue({ verify: isFirstRun && verify, publish: true }) 153 | for (const suite of suitesToRun) { 154 | await run(suite, { 155 | verify: !!(isFirstRun && verify), 156 | skipGit: !isFirstRun, 157 | root, 158 | vuePath, 159 | vueVersion: parseVueVersion(vuePath), 160 | workspace, 161 | }) 162 | } 163 | isFirstRun = false 164 | return null 165 | } catch (e) { 166 | return e 167 | } 168 | } 169 | await setupVueRepo({ ...options, shallow: false }) 170 | const initialError = await runSuite() 171 | if (initialError) { 172 | await bisectVue(options.good, runSuite) 173 | } else { 174 | console.log(`no errors for starting commit, cannot bisect`) 175 | } 176 | await teardownEnvironment() 177 | }) 178 | cli.help() 179 | cli.parse() 180 | 181 | async function run(suite: string, options: RunOptions) { 182 | const { test } = await import(`./tests/${suite}.ts`) 183 | await test({ 184 | ...options, 185 | workspace: path.resolve(options.workspace, suite), 186 | }) 187 | } 188 | 189 | function getSuitesToRun(suites: string[], root: string) { 190 | let suitesToRun: string[] = suites 191 | const availableSuites: string[] = fs 192 | .readdirSync(path.join(root, 'tests')) 193 | .filter((f: string) => !f.startsWith('_') && f.endsWith('.ts')) 194 | .map((f: string) => f.slice(0, -3)) 195 | availableSuites.sort() 196 | if (suitesToRun.length === 0) { 197 | suitesToRun = availableSuites 198 | } else { 199 | const invalidSuites = suitesToRun.filter( 200 | (x) => !x.startsWith('_') && !availableSuites.includes(x), 201 | ) 202 | if (invalidSuites.length) { 203 | console.log(`invalid suite(s): ${invalidSuites.join(', ')}`) 204 | console.log(`available suites: ${availableSuites.join(', ')}`) 205 | process.exit(1) 206 | } 207 | } 208 | return suitesToRun 209 | } 210 | 211 | async function checkRelease(commit: string) { 212 | return new Promise((resolve, reject) => { 213 | const req = https.request( 214 | { 215 | method: 'GET', 216 | hostname: 'pkg.pr.new', 217 | path: `/vuejs/core/vue@${commit}`, 218 | }, 219 | (res) => { 220 | if (res.statusCode === 200) { 221 | resolve(true) 222 | } else { 223 | resolve(false) 224 | } 225 | req.destroy() 226 | }, 227 | ) 228 | req.on('error', reject) 229 | req.end() 230 | }) 231 | } 232 | -------------------------------------------------------------------------------- /eslint.config.ts: -------------------------------------------------------------------------------- 1 | import eslint from '@eslint/js' 2 | import pluginN from 'eslint-plugin-n' 3 | import tseslint from 'typescript-eslint' 4 | 5 | export default tseslint.config( 6 | { 7 | ignores: ['workspace/**'], 8 | }, 9 | eslint.configs.recommended, 10 | ...tseslint.configs.recommended, 11 | { 12 | name: 'main', 13 | languageOptions: { 14 | parser: tseslint.parser, 15 | parserOptions: { 16 | sourceType: 'module', 17 | ecmaVersion: 2022, 18 | project: true, 19 | }, 20 | }, 21 | plugins: { 22 | n: pluginN, 23 | }, 24 | rules: { 25 | eqeqeq: ['warn', 'always', { null: 'never' }], 26 | 'no-debugger': ['error'], 27 | 'no-empty': ['warn', { allowEmptyCatch: true }], 28 | 'no-process-exit': 'off', 29 | 'no-useless-escape': 'off', 30 | 'prefer-const': [ 31 | 'warn', 32 | { 33 | destructuring: 'all', 34 | }, 35 | ], 36 | 'n/no-missing-import': 'off', // doesn't like ts imports 37 | 'n/no-process-exit': 'off', 38 | '@typescript-eslint/no-explicit-any': 'off', // we use any in some places 39 | }, 40 | }, 41 | ) 42 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@vue/ecosystem-ci", 3 | "private": true, 4 | "version": "0.0.1", 5 | "description": "Vue Ecosystem CI", 6 | "scripts": { 7 | "preinstall": "npx only-allow pnpm", 8 | "postinstall": "pnpm install --dir typings", 9 | "prepare": "pnpm exec simple-git-hooks", 10 | "lint": "eslint", 11 | "lint:fix": "pnpm lint --fix", 12 | "format": "prettier --ignore-path .gitignore --check .", 13 | "format:fix": "pnpm format --write", 14 | "typecheck": "tsc", 15 | "test:self": "tsx ecosystem-ci.ts _selftest", 16 | "test": "tsx ecosystem-ci.ts", 17 | "clean": "rimraf .verdaccio-cache/.local workspace && pnpm store prune", 18 | "bisect": "tsx ecosystem-ci.ts bisect" 19 | }, 20 | "simple-git-hooks": { 21 | "pre-commit": "pnpm exec lint-staged --concurrent false" 22 | }, 23 | "lint-staged": { 24 | "*": [ 25 | "prettier --write --ignore-unknown" 26 | ], 27 | "*.ts": [ 28 | "eslint --fix" 29 | ] 30 | }, 31 | "packageManager": "pnpm@10.12.1", 32 | "type": "module", 33 | "engines": { 34 | "node": ">=18", 35 | "pnpm": "^10.12.1" 36 | }, 37 | "repository": { 38 | "type": "git", 39 | "url": "git+https://github.com/vuejs/ecosystem-ci.git" 40 | }, 41 | "license": "MIT", 42 | "bugs": { 43 | "url": "https://github.com/vuejs/ecosystem-ci/issues" 44 | }, 45 | "homepage": "https://github.com/vuejs/ecosystem-ci#readme", 46 | "dependencies": { 47 | "@actions/core": "^1.11.1", 48 | "cac": "^6.7.14", 49 | "execa": "^9.6.0", 50 | "node-fetch": "^3.3.2", 51 | "verdaccio": "^6.1.2", 52 | "verdaccio-auth-memory": "^10.2.2" 53 | }, 54 | "devDependencies": { 55 | "@antfu/ni": "^25.0.0", 56 | "@eslint/js": "^9.29.0", 57 | "eslint": "^9.28.0", 58 | "eslint-plugin-n": "^17.20.0", 59 | "jiti": "^2.4.2", 60 | "lint-staged": "^16.1.0", 61 | "prettier": "3.5.2", 62 | "rimraf": "^6.0.1", 63 | "simple-git-hooks": "^2.13.0", 64 | "tsx": "^4.20.3", 65 | "typescript": "^5.7.3", 66 | "typescript-eslint": "^8.34.0", 67 | "yaml": "^2.8.0" 68 | }, 69 | "pnpm": { 70 | "overrides": { 71 | "cookie@<0.7.0": ">=0.7.0" 72 | }, 73 | "auditConfig": { 74 | "ignoreGhsas": [ 75 | "GHSA-cxrh-j4jr-qwg3", 76 | "GHSA-v6h2-p8h4-qcjw" 77 | ] 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /registry.ts: -------------------------------------------------------------------------------- 1 | // local-registry related utils 2 | 3 | import { fileURLToPath } from 'node:url' 4 | import { runServer, parseConfigFile } from 'verdaccio' 5 | 6 | const START_VERDACCIO_TIMEOUT_IN_SECONDS = 60 7 | // use an unconventional port to avoid conflicts with other local registries 8 | const LOCAL_REGISTRY_PORT = 6173 9 | 10 | export const REGISTRY_ADDRESS = `http://localhost:${LOCAL_REGISTRY_PORT}/` 11 | 12 | export async function startRegistry() { 13 | // It's not ideal to repeat this config option here, 14 | // luckily, `self_path` would no longer be required in verdaccio 6 15 | const cache = fileURLToPath(new URL('./.verdaccio-cache', import.meta.url)) 16 | const config = { 17 | ...parseConfigFile( 18 | fileURLToPath(new URL('./verdaccio.yaml', import.meta.url)), 19 | ), 20 | self_path: cache, 21 | } 22 | 23 | return new Promise((resolve, reject) => { 24 | // A promise can only be fulfilled/rejected once, so we can use this as a shortcut of `Promise.race` 25 | setTimeout(() => { 26 | reject( 27 | new Error( 28 | `Verdaccio did not start in ${START_VERDACCIO_TIMEOUT_IN_SECONDS} seconds`, 29 | ), 30 | ) 31 | }, START_VERDACCIO_TIMEOUT_IN_SECONDS * 1000) 32 | 33 | runServer(config).then((app) => { 34 | app.listen(LOCAL_REGISTRY_PORT, () => { 35 | console.log(`Verdaccio started on port ${LOCAL_REGISTRY_PORT}`) 36 | resolve(app) 37 | }) 38 | 39 | for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) { 40 | // Use once() so that receiving double signals exit the app. 41 | process.once(signal, () => { 42 | console.log('Received shutdown signal - closing server...') 43 | app.close(() => { 44 | console.log('Server closed') 45 | process.exit(0) 46 | }) 47 | }) 48 | } 49 | }) 50 | }) 51 | } 52 | -------------------------------------------------------------------------------- /tests/_selftest.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path' 2 | import { runInRepo } from '../utils.ts' 3 | import { RunOptions } from '../types.ts' 4 | 5 | export async function test(options: RunOptions) { 6 | await runInRepo({ 7 | ...options, 8 | repo: 'vuejs/ecosystem-ci', 9 | test: 'pnpm run selftestscript', 10 | verify: false, 11 | patchFiles: { 12 | 'package.json': (content) => { 13 | const pkg = JSON.parse(content) 14 | if (pkg.name !== '@vue/ecosystem-ci') { 15 | throw new Error( 16 | `invalid checkout, expected package.json with "name": "@vue/ecosystem-ci" in ${path.resolve( 17 | options.workspace, 18 | 'ecosystem-ci', 19 | )}`, 20 | ) 21 | } 22 | pkg.scripts.selftestscript = 23 | "[ -d ../../core/packages/vue/dist ] || (echo 'vue build failed' && exit 1)" 24 | return JSON.stringify(pkg, null, 2) 25 | }, 26 | }, 27 | }) 28 | } 29 | -------------------------------------------------------------------------------- /tests/language-tools.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path' 2 | import fs from 'node:fs' 3 | import { runInRepo } from '../utils.ts' 4 | import { RunOptions } from '../types.ts' 5 | import { REGISTRY_ADDRESS } from '../registry.ts' 6 | import YAML from 'yaml' 7 | 8 | export async function test(options: RunOptions) { 9 | await runInRepo({ 10 | ...options, 11 | repo: 'vuejs/language-tools', 12 | branch: 'master', 13 | beforeBuild: `pnpm dedupe --registry=${REGISTRY_ADDRESS}`, 14 | build: 'build', 15 | test: 'test', 16 | overrideVueVersion: '@^3.5.2', 17 | patchFiles: { 18 | 'package.json': (content) => { 19 | const pkg = JSON.parse(content) 20 | const versions = resolveTypeScriptVersion(options) 21 | if (versions.typescript) 22 | pkg.devDependencies.typescript = versions.typescript 23 | return JSON.stringify(pkg, null, 2) 24 | }, 25 | 'test-workspace/package.json': (content) => { 26 | const pkg = JSON.parse(content) 27 | const versions = resolveTypeScriptVersion(options, 'test-workspace', [ 28 | 'typescript-stable', 29 | 'typescript-next', 30 | ]) 31 | if (versions['typescript-stable']) 32 | pkg.devDependencies['typescript-stable'] = 33 | versions['typescript-stable'] 34 | if (versions['typescript-next']) 35 | pkg.devDependencies['typescript-next'] = versions['typescript-next'] 36 | return JSON.stringify(pkg, null, 2) 37 | }, 38 | }, 39 | }) 40 | } 41 | 42 | function resolveTypeScriptVersion( 43 | options: RunOptions, 44 | importer = '.', 45 | pkgNames = ['typescript'], 46 | ): Record { 47 | const data = resolveLockFile(options) 48 | if (!data) return {} 49 | 50 | return pkgNames.reduce( 51 | (acc, pkgName) => { 52 | const version = 53 | data.importers[importer]?.devDependencies?.[pkgName]?.version 54 | if (version) { 55 | acc[pkgName] = `npm:typescript@${version.split('@').pop()}` 56 | } 57 | return acc 58 | }, 59 | {} as Record, 60 | ) 61 | } 62 | 63 | let lockFileCache: any 64 | function resolveLockFile(options: RunOptions, dirName = 'language-tools'): any { 65 | if (lockFileCache) return lockFileCache 66 | 67 | const filePath = path.resolve(options.workspace, dirName, 'pnpm-lock.yaml') 68 | try { 69 | const content = fs.readFileSync(filePath, 'utf-8') 70 | return (lockFileCache = YAML.parse(content)) 71 | } catch (error) { 72 | console.error('Error reading lockfile:', error) 73 | return null 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /tests/naive-ui.ts: -------------------------------------------------------------------------------- 1 | import { runInRepo } from '../utils.ts' 2 | import { RunOptions } from '../types.ts' 3 | 4 | export async function test(options: RunOptions) { 5 | await runInRepo({ 6 | ...options, 7 | repo: 'tusen-ai/naive-ui', 8 | branch: 'main', 9 | build: 'build:package', 10 | test: 'test:cov', 11 | }) 12 | } 13 | -------------------------------------------------------------------------------- /tests/nuxt.ts: -------------------------------------------------------------------------------- 1 | import { runInRepo } from '../utils.ts' 2 | import { RunOptions } from '../types.ts' 3 | 4 | export async function test(options: RunOptions) { 5 | await runInRepo({ 6 | ...options, 7 | repo: 'nuxt/nuxt', 8 | branch: 'main', 9 | build: ['dev:prepare', 'typecheck', 'build'], 10 | beforeTest: ['pnpm playwright-core install chromium'], 11 | test: ['test:unit', 'test:types', 'test:fixtures'], 12 | }) 13 | } 14 | -------------------------------------------------------------------------------- /tests/pinia.ts: -------------------------------------------------------------------------------- 1 | import { runInRepo } from '../utils.ts' 2 | import { RunOptions } from '../types.ts' 3 | 4 | export async function test(options: RunOptions) { 5 | await runInRepo({ 6 | ...options, 7 | repo: 'vuejs/pinia', 8 | branch: 'v3', 9 | test: 'test', 10 | }) 11 | } 12 | -------------------------------------------------------------------------------- /tests/primevue.ts: -------------------------------------------------------------------------------- 1 | import { runInRepo } from '../utils.ts' 2 | import { RunOptions } from '../types.ts' 3 | 4 | export async function test(options: RunOptions) { 5 | await runInRepo({ 6 | ...options, 7 | repo: 'primefaces/primevue', 8 | branch: 'master', 9 | // 4.3.1 but not tagged on GitHub. It's the latest commit that with passing tests. 10 | commit: 'e53d48ed62dc845d8f8ca3c7ccd00941229b98f9', 11 | build: ['format', 'build'], 12 | test: 'test:unit', 13 | }) 14 | } 15 | -------------------------------------------------------------------------------- /tests/quasar.ts: -------------------------------------------------------------------------------- 1 | import { runInRepo } from '../utils.ts' 2 | import { RunOptions } from '../types.ts' 3 | 4 | export async function test(options: RunOptions) { 5 | await runInRepo({ 6 | ...options, 7 | repo: 'quasarframework/quasar', 8 | branch: 'dev', 9 | build: 'vue-ecosystem-ci:build', 10 | test: 'vue-ecosystem-ci:test', 11 | }) 12 | } 13 | -------------------------------------------------------------------------------- /tests/radix-vue.ts: -------------------------------------------------------------------------------- 1 | import { runInRepo } from '../utils.ts' 2 | import { RunOptions } from '../types.ts' 3 | 4 | export async function test(options: RunOptions) { 5 | await runInRepo({ 6 | ...options, 7 | repo: 'radix-vue/radix-vue', 8 | branch: 'main', 9 | test: 'test', 10 | }) 11 | } 12 | -------------------------------------------------------------------------------- /tests/router.ts: -------------------------------------------------------------------------------- 1 | import { runInRepo } from '../utils.ts' 2 | import { RunOptions } from '../types.ts' 3 | 4 | export async function test(options: RunOptions) { 5 | await runInRepo({ 6 | ...options, 7 | repo: 'vuejs/router', 8 | branch: 'main', 9 | test: [ 10 | 'pnpm -r build', 11 | 'pnpm -r build:dts', 12 | 'pnpm -r test:types', 13 | 'pnpm -r test:unit', 14 | 'pnpm -r test:e2e:ci', 15 | ], 16 | }) 17 | } 18 | -------------------------------------------------------------------------------- /tests/test-utils.ts: -------------------------------------------------------------------------------- 1 | import { runInRepo } from '../utils.ts' 2 | import { RunOptions } from '../types.ts' 3 | 4 | export async function test(options: RunOptions) { 5 | await runInRepo({ 6 | ...options, 7 | repo: 'vuejs/test-utils', 8 | branch: 'main', 9 | test: ['test:coverage', 'test:build', 'tsd', 'vue-tsc'], 10 | }) 11 | } 12 | -------------------------------------------------------------------------------- /tests/vant.ts: -------------------------------------------------------------------------------- 1 | import { runInRepo } from '../utils.ts' 2 | import { RunOptions } from '../types.ts' 3 | import fs from 'node:fs' 4 | import path from 'node:path' 5 | 6 | export async function test(options: RunOptions) { 7 | await runInRepo({ 8 | ...options, 9 | repo: 'youzan/vant', 10 | branch: 'main', 11 | build: 'build', 12 | test: 'test', 13 | async beforeTest() { 14 | fs.rmSync( 15 | path.join( 16 | options.workspace, 17 | 'vant/packages/vant/src/col/test/demo-ssr.spec.ts', 18 | ), 19 | ) 20 | }, 21 | }) 22 | } 23 | -------------------------------------------------------------------------------- /tests/vite-plugin-vue.ts: -------------------------------------------------------------------------------- 1 | import { runInRepo } from '../utils.ts' 2 | import { RunOptions } from '../types.ts' 3 | 4 | export async function test(options: RunOptions) { 5 | await runInRepo({ 6 | ...options, 7 | repo: 'vitejs/vite-plugin-vue', 8 | build: 'build', 9 | beforeTest: 'pnpm playwright install chromium', 10 | test: 'test', 11 | }) 12 | } 13 | -------------------------------------------------------------------------------- /tests/vitepress.ts: -------------------------------------------------------------------------------- 1 | import { runInRepo } from '../utils.ts' 2 | import { RunOptions } from '../types.ts' 3 | 4 | export async function test(options: RunOptions) { 5 | await runInRepo({ 6 | ...options, 7 | repo: 'vuejs/vitepress', 8 | branch: 'main', 9 | build: 'build', 10 | test: 'test', 11 | }) 12 | } 13 | -------------------------------------------------------------------------------- /tests/vue-i18n.ts: -------------------------------------------------------------------------------- 1 | import { runInRepo } from '../utils.ts' 2 | import { RunOptions } from '../types.ts' 3 | 4 | export async function test(options: RunOptions) { 5 | await runInRepo({ 6 | ...options, 7 | repo: 'intlify/vue-i18n', 8 | branch: 'master', 9 | build: { 10 | script: 'build', 11 | args: ['--all', '-t'], 12 | }, 13 | beforeTest: 'pnpm playwright-core install chromium', 14 | test: [ 15 | 'test:cover', 16 | { 17 | script: 'test:e2e', 18 | args: ['--exclude', 'e2e/bridge/**'], 19 | }, 20 | ], 21 | }) 22 | } 23 | -------------------------------------------------------------------------------- /tests/vue-macros.ts: -------------------------------------------------------------------------------- 1 | import { runInRepo } from '../utils.ts' 2 | import { Overrides, RunOptions } from '../types.ts' 3 | import YAML from 'yaml' 4 | 5 | export async function test(options: RunOptions) { 6 | const overrideVueVersion = '@^3' 7 | await runInRepo({ 8 | ...options, 9 | repo: 'vue-macros/vue-macros', 10 | branch: 'main', 11 | build: 'build', 12 | test: ['test:ecosystem'], 13 | overrideVueVersion, 14 | patchFiles: { 15 | 'pnpm-workspace.yaml': (content: string, overrides: Overrides) => { 16 | const data = YAML.parse(content) 17 | Object.keys(overrides).forEach((key) => { 18 | const pkgName = key.replace(overrideVueVersion, '') 19 | if (data.catalog[pkgName]) { 20 | data.catalog[pkgName] = overrides[key] 21 | } 22 | }) 23 | return YAML.stringify(data) 24 | }, 25 | }, 26 | }) 27 | } 28 | -------------------------------------------------------------------------------- /tests/vue-simple-compiler.ts: -------------------------------------------------------------------------------- 1 | import { runInRepo } from '../utils.ts' 2 | import { RunOptions } from '../types.ts' 3 | 4 | export async function test(options: RunOptions) { 5 | await runInRepo({ 6 | ...options, 7 | repo: 'jinjiang/vue-simple-compiler', 8 | branch: 'main', 9 | test: 'test', 10 | }) 11 | } 12 | -------------------------------------------------------------------------------- /tests/vuetify.ts: -------------------------------------------------------------------------------- 1 | import { runInRepo } from '../utils.ts' 2 | import { RunOptions } from '../types.ts' 3 | 4 | export async function test(options: RunOptions) { 5 | await runInRepo({ 6 | ...options, 7 | repo: 'vuetifyjs/vuetify', 8 | branch: 'master', 9 | build: 'vue-ecosystem-ci:build', 10 | test: 'vue-ecosystem-ci:test', 11 | }) 12 | } 13 | -------------------------------------------------------------------------------- /tests/vueuse.ts: -------------------------------------------------------------------------------- 1 | import { runInRepo } from '../utils.ts' 2 | import { RunOptions } from '../types.ts' 3 | 4 | export async function test(options: RunOptions) { 5 | await runInRepo({ 6 | ...options, 7 | repo: 'vueuse/vueuse', 8 | branch: 'main', 9 | build: 'build', 10 | test: ['typecheck', 'test'], 11 | }) 12 | } 13 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["./**/*.ts"], 3 | "exclude": ["**/node_modules/**/*", "./workspace/**/*"], 4 | "compilerOptions": { 5 | "target": "esnext", 6 | "module": "NodeNext", 7 | "moduleResolution": "NodeNext", 8 | "allowImportingTsExtensions": true, 9 | "strict": true, 10 | "declaration": false, 11 | "noImplicitOverride": true, 12 | "noUnusedLocals": true, 13 | "esModuleInterop": true, 14 | "useUnknownInCatchVariables": false, 15 | "allowSyntheticDefaultImports": true, 16 | "lib": ["esnext"], 17 | "sourceMap": true, 18 | "typeRoots": ["./typings/node_modules/@types"], 19 | "noEmit": true, 20 | "skipLibCheck": true 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /types.d.ts: -------------------------------------------------------------------------------- 1 | import type { AGENTS } from '@antfu/ni' 2 | 3 | export interface EnvironmentData { 4 | root: string 5 | workspace: string 6 | vuePath: string 7 | cwd: string 8 | env: ProcessEnv 9 | } 10 | 11 | export interface RunOptions { 12 | workspace: string 13 | root: string 14 | vuePath: string 15 | vueVersion: string 16 | overrideVueVersion?: string 17 | verify?: boolean 18 | skipGit?: boolean 19 | release?: string 20 | agent?: (typeof AGENTS)[number] 21 | build?: Task | Task[] 22 | test?: Task | Task[] 23 | beforeInstall?: Task | Task[] 24 | beforeBuild?: Task | Task[] 25 | beforeTest?: Task | Task[] 26 | patchFiles?: Record string> 27 | } 28 | 29 | type Task = string | { script: string; args?: string[] } | (() => Promise) 30 | 31 | export interface CommandOptions { 32 | suites?: string[] 33 | repo?: string 34 | branch?: string 35 | tag?: string 36 | commit?: string 37 | release?: string 38 | verify?: boolean 39 | publish?: boolean 40 | skipGit?: boolean 41 | local?: boolean 42 | } 43 | 44 | export interface RepoOptions { 45 | repo: string 46 | dir?: string 47 | branch?: string 48 | tag?: string 49 | commit?: string 50 | shallow?: boolean 51 | overrides?: Overrides 52 | } 53 | 54 | export interface Overrides { 55 | [key: string]: string | boolean 56 | } 57 | 58 | export interface ProcessEnv { 59 | [key: string]: string | undefined 60 | } 61 | -------------------------------------------------------------------------------- /typings/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "typings", 3 | "version": "0.0.0", 4 | "private": true, 5 | "description": "Alternative typing root directory for the project in order not to pollute the root node_modules", 6 | "devDependencies": { 7 | "@types/node": "^22.15.31" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /typings/pnpm-lock.yaml: -------------------------------------------------------------------------------- 1 | lockfileVersion: '9.0' 2 | 3 | settings: 4 | autoInstallPeers: true 5 | excludeLinksFromLockfile: false 6 | 7 | importers: 8 | 9 | .: 10 | devDependencies: 11 | '@types/node': 12 | specifier: ^22.15.31 13 | version: 22.15.31 14 | 15 | packages: 16 | 17 | '@types/node@22.15.31': 18 | resolution: {integrity: sha512-jnVe5ULKl6tijxUhvQeNbQG/84fHfg+yMak02cT8QVhBx/F05rAVxCGBYYTh2EKz22D6JF5ktXuNwdx7b9iEGw==} 19 | 20 | undici-types@6.21.0: 21 | resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} 22 | 23 | snapshots: 24 | 25 | '@types/node@22.15.31': 26 | dependencies: 27 | undici-types: 6.21.0 28 | 29 | undici-types@6.21.0: {} 30 | -------------------------------------------------------------------------------- /utils.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path' 2 | import fs from 'node:fs' 3 | import { fileURLToPath } from 'node:url' 4 | import { execaCommand } from 'execa' 5 | import { 6 | EnvironmentData, 7 | Overrides, 8 | ProcessEnv, 9 | RepoOptions, 10 | RunOptions, 11 | Task, 12 | } from './types.ts' 13 | import { REGISTRY_ADDRESS, startRegistry } from './registry.ts' 14 | import { detect, AGENTS, getCommand, serializeCommand } from '@antfu/ni' 15 | import actionsCore from '@actions/core' 16 | 17 | const isGitHubActions = !!process.env.GITHUB_ACTIONS 18 | 19 | let vuePath: string 20 | let builtPath: string 21 | let cwd: string 22 | let env: ProcessEnv 23 | 24 | function cd(dir: string) { 25 | cwd = path.resolve(cwd, dir) 26 | } 27 | 28 | // Execute command with `stdio: 'inherit'` 29 | export async function $(literals: TemplateStringsArray, ...values: any[]) { 30 | const cmd = literals.reduce( 31 | (result, current, i) => 32 | result + current + (values?.[i] != null ? `${values[i]}` : ''), 33 | '', 34 | ) 35 | 36 | if (isGitHubActions) { 37 | actionsCore.startGroup(`${cwd} $> ${cmd}`) 38 | } else { 39 | console.log(`${cwd} $> ${cmd}`) 40 | } 41 | 42 | await execaCommand(cmd, { 43 | env, 44 | stdio: 'inherit', 45 | cwd, 46 | }) 47 | 48 | if (isGitHubActions) { 49 | actionsCore.endGroup() 50 | } 51 | } 52 | 53 | // Execute command with `stdio: 'pipe'` and returns the stdout 54 | // Use a separate function here because there's a bug in execa that causes EPIPE error 55 | // when the process executes too fast. So we only use `stdio: 'pipe'` when we need to capture the output. 56 | export async function $$(literals: TemplateStringsArray, ...values: any[]) { 57 | const cmd = literals.reduce( 58 | (result, current, i) => 59 | result + current + (values?.[i] != null ? `${values[i]}` : ''), 60 | '', 61 | ) 62 | 63 | if (isGitHubActions) { 64 | actionsCore.startGroup(`${cwd} $> ${cmd}`) 65 | } else { 66 | console.log(`${cwd} $> ${cmd}`) 67 | } 68 | 69 | const proc = execaCommand(cmd, { 70 | env, 71 | stdio: 'pipe', 72 | cwd, 73 | }) 74 | if (proc.stdin) { 75 | process.stdin.pipe(proc.stdin) 76 | } 77 | if (proc.stdout) { 78 | proc.stdout.pipe(process.stdout) 79 | } 80 | if (proc.stderr) { 81 | proc.stderr.pipe(process.stderr) 82 | } 83 | 84 | let result 85 | try { 86 | result = await proc 87 | } catch (error) { 88 | // Since we already piped the io to the parent process, we remove the duplicated 89 | // messages here so it's easier to read the error message. 90 | if (error.stdout) error.stdout = 'value removed by vuejs ecosystem-ci' 91 | if (error.stderr) error.stderr = 'value removed by vuejs ecosystem-ci' 92 | if (error.stdio) error.stdio = ['value removed by vuejs ecosystem-ci'] 93 | throw error 94 | } 95 | 96 | if (isGitHubActions) { 97 | actionsCore.endGroup() 98 | } 99 | 100 | return result.stdout 101 | } 102 | 103 | let app: any 104 | export async function setupEnvironment(): Promise { 105 | app = await startRegistry() 106 | 107 | const root = dirnameFrom(import.meta.url) 108 | const workspace = path.resolve(root, 'workspace') 109 | vuePath = path.resolve(workspace, 'core') 110 | builtPath = path.resolve(root, 'built-packages') 111 | cwd = process.cwd() 112 | env = { 113 | ...process.env, 114 | CI: 'true', 115 | ECOSYSTEM_CI: 'vue', // for downstream packages to detect 116 | TURBO_FORCE: 'true', // disable turbo caching, ecosystem-ci modifies things and we don't want replays 117 | YARN_ENABLE_IMMUTABLE_INSTALLS: 'false', // to avoid errors with mutated lockfile due to overrides 118 | NODE_OPTIONS: '--max-old-space-size=6144', // GITHUB CI has 7GB max, stay below 119 | } 120 | return { root, workspace, vuePath, cwd, env } 121 | } 122 | 123 | export async function teardownEnvironment() { 124 | app.close(() => process.exit(0)) 125 | } 126 | 127 | export async function setupRepo(options: RepoOptions) { 128 | if (options.branch == null) { 129 | options.branch = 'main' 130 | } 131 | if (options.shallow == null) { 132 | options.shallow = true 133 | } 134 | 135 | let { repo, commit, branch, tag, dir, shallow } = options 136 | if (!dir) { 137 | throw new Error('setupRepo must be called with options.dir') 138 | } 139 | if (!repo.includes(':')) { 140 | repo = `https://github.com/${repo}.git` 141 | } 142 | 143 | let needClone = true 144 | if (fs.existsSync(dir)) { 145 | const _cwd = cwd 146 | cd(dir) 147 | let currentClonedRepo: string | undefined 148 | try { 149 | currentClonedRepo = await $$`git ls-remote --get-url` 150 | } catch { 151 | // when not a git repo 152 | } 153 | cd(_cwd) 154 | 155 | if (repo === currentClonedRepo) { 156 | needClone = false 157 | } else { 158 | fs.rmSync(dir, { recursive: true, force: true }) 159 | } 160 | } 161 | 162 | if (needClone) { 163 | await $`git -c advice.detachedHead=false clone ${ 164 | shallow ? '--depth=1 --no-tags' : '' 165 | } --branch ${tag || branch} ${repo} ${dir}` 166 | } 167 | cd(dir) 168 | await $`git clean -fdxq` 169 | await $`git fetch ${shallow ? '--depth=1 --no-tags' : '--tags'} origin ${ 170 | tag ? `tag ${tag}` : `${commit || branch}` 171 | }` 172 | if (shallow) { 173 | await $`git -c advice.detachedHead=false checkout ${ 174 | tag ? `tags/${tag}` : `${commit || branch}` 175 | }` 176 | } else { 177 | await $`git checkout ${branch}` 178 | await $`git merge FETCH_HEAD` 179 | if (tag || commit) { 180 | await $`git reset --hard ${tag || commit}` 181 | } 182 | } 183 | } 184 | 185 | function toCommand( 186 | task: Task | Task[] | void, 187 | agent: (typeof AGENTS)[number], 188 | ): ((scripts: any) => Promise) | void { 189 | return async (scripts: any) => { 190 | const tasks = Array.isArray(task) ? task : [task] 191 | for (const task of tasks) { 192 | if (task == null || task === '') { 193 | continue 194 | } else if (typeof task === 'string') { 195 | if (scripts[task] != null) { 196 | const runTaskWithAgent = getCommand(agent, 'run', [task]) 197 | await $`${serializeCommand(runTaskWithAgent)}` 198 | } else { 199 | await $`${task}` 200 | } 201 | } else if (typeof task === 'function') { 202 | await task() 203 | } else if (task?.script) { 204 | if (scripts[task.script] != null) { 205 | const runTaskWithAgent = getCommand(agent, 'run', [ 206 | task.script, 207 | ...(task.args ?? []), 208 | ]) 209 | await $`${serializeCommand(runTaskWithAgent)}` 210 | } else { 211 | throw new Error( 212 | `invalid task, script "${task.script}" does not exist in package.json`, 213 | ) 214 | } 215 | } else { 216 | throw new Error( 217 | `invalid task, expected string or function but got ${typeof task}: ${task}`, 218 | ) 219 | } 220 | } 221 | } 222 | } 223 | 224 | export async function runInRepo(options: RunOptions & RepoOptions) { 225 | if (options.verify == null) { 226 | options.verify = true 227 | } 228 | if (options.skipGit == null) { 229 | options.skipGit = false 230 | } 231 | if (options.branch == null) { 232 | options.branch = 'main' 233 | } 234 | const { 235 | build, 236 | test, 237 | repo, 238 | branch, 239 | tag, 240 | commit, 241 | skipGit, 242 | verify, 243 | beforeInstall, 244 | beforeBuild, 245 | beforeTest, 246 | patchFiles, 247 | overrideVueVersion = '', 248 | } = options 249 | const dir = path.resolve( 250 | options.workspace, 251 | options.dir || repo.substring(repo.lastIndexOf('/') + 1), 252 | ) 253 | 254 | if (!skipGit) { 255 | await setupRepo({ repo, dir, branch, tag, commit }) 256 | } else { 257 | cd(dir) 258 | } 259 | 260 | if (options.agent == null) { 261 | const detectedAgent = await detect({ cwd: dir, autoInstall: false }) 262 | if (detectedAgent == null) { 263 | throw new Error(`Failed to detect package manager in ${dir}`) 264 | } 265 | options.agent = detectedAgent 266 | } 267 | if (!AGENTS.includes(options.agent)) { 268 | throw new Error( 269 | `Invalid agent ${options.agent}. Allowed values: ${AGENTS.join(', ')}`, 270 | ) 271 | } 272 | 273 | const overrides = options.overrides || {} 274 | const vuePackages = await getVuePackages() 275 | 276 | if (options.release) { 277 | // pkg.pr.new support 278 | for (const pkg of vuePackages) { 279 | let version = options.release 280 | if (options.release.startsWith('@')) { 281 | version = `https://pkg.pr.new/${pkg.name}@${options.release.slice(1)}` 282 | } 283 | if (overrides[pkg.name] && overrides[pkg.name] !== version) { 284 | throw new Error( 285 | `conflicting overrides[${pkg.name}]=${ 286 | overrides[pkg.name] 287 | } and --release=${ 288 | options.release 289 | } config. Use either one or the other`, 290 | ) 291 | } else { 292 | overrides[`${pkg.name}${overrideVueVersion}`] = version 293 | } 294 | } 295 | } else { 296 | for (const pkg of vuePackages) { 297 | overrides[pkg.name] ||= pkg.hashedVersion 298 | } 299 | } 300 | 301 | if (patchFiles) { 302 | for (const fileName in patchFiles) { 303 | const filePath = path.resolve(dir, fileName) 304 | const patchFn = patchFiles[fileName] 305 | const content = fs.readFileSync(filePath, 'utf-8') 306 | fs.writeFileSync(filePath, patchFn(content, overrides)) 307 | console.log(`patched file: ${fileName}`) 308 | } 309 | } 310 | 311 | const agent = options.agent 312 | const beforeInstallCommand = toCommand(beforeInstall, agent) 313 | const beforeBuildCommand = toCommand(beforeBuild, agent) 314 | const beforeTestCommand = toCommand(beforeTest, agent) 315 | const buildCommand = toCommand(build, agent) 316 | const testCommand = toCommand(test, agent) 317 | 318 | const pkgFile = path.join(dir, 'package.json') 319 | const pkg = JSON.parse(await fs.promises.readFile(pkgFile, 'utf-8')) 320 | 321 | await beforeInstallCommand?.(pkg.scripts) 322 | 323 | if (verify && test) { 324 | const frozenInstall = getCommand(agent, 'frozen') 325 | await $`${serializeCommand(frozenInstall)}` 326 | await beforeBuildCommand?.(pkg.scripts) 327 | await buildCommand?.(pkg.scripts) 328 | await beforeTestCommand?.(pkg.scripts) 329 | await testCommand?.(pkg.scripts) 330 | } 331 | 332 | await applyPackageOverrides(dir, pkg, overrides, options.release) 333 | await beforeBuildCommand?.(pkg.scripts) 334 | await buildCommand?.(pkg.scripts) 335 | if (test) { 336 | await beforeTestCommand?.(pkg.scripts) 337 | await testCommand?.(pkg.scripts) 338 | } 339 | return { dir } 340 | } 341 | 342 | export async function setupVueRepo(options: Partial) { 343 | const repo = options.repo || 'vuejs/core' 344 | await setupRepo({ 345 | repo, 346 | dir: vuePath, 347 | branch: 'main', 348 | shallow: true, 349 | ...options, 350 | }) 351 | } 352 | 353 | export async function getPermanentRef() { 354 | const _cwd = cwd 355 | cd(vuePath) 356 | try { 357 | const ref = await $$`git log -1 --pretty=format:%h` 358 | return ref 359 | } catch (e) { 360 | console.warn(`Failed to obtain perm ref. ${e}`) 361 | return undefined 362 | } finally { 363 | cd(_cwd) 364 | } 365 | } 366 | 367 | // FIXME: when running the first time and with `--release` option, the directory would be empty 368 | export async function getVuePackages() { 369 | // append the hash of the current commit to the version to avoid conflicts 370 | const commitHash = await getPermanentRef() 371 | 372 | return ( 373 | fs 374 | .readdirSync(`${vuePath}/packages`) 375 | // filter out non-directories 376 | .filter((name) => 377 | fs.statSync(`${vuePath}/packages/${name}`).isDirectory(), 378 | ) 379 | // parse package.json 380 | .map((name) => { 381 | const directory = `${vuePath}/packages/${name}` 382 | const packageJson = JSON.parse( 383 | fs.readFileSync(`${directory}/package.json`, 'utf-8'), 384 | ) 385 | return { 386 | dirName: name, 387 | directory, 388 | packageJson, 389 | } 390 | }) 391 | // filter out packages that has `"private": true` in `package.json` 392 | .filter(({ packageJson }) => { 393 | return !packageJson.private 394 | }) 395 | .map(({ dirName, packageJson, directory }) => ({ 396 | name: packageJson.name, 397 | dirName, 398 | version: packageJson.version, 399 | // if `build-vue` and `run-suites` are run separately, the version would already include commit hash 400 | hashedVersion: packageJson.version.includes(commitHash) 401 | ? packageJson.version 402 | : `${packageJson.version}-${commitHash}`, 403 | directory: directory, 404 | })) 405 | ) 406 | } 407 | 408 | function writeOrAppendNpmrc(dir: string, content: string) { 409 | const npmrcPath = path.join(dir, '.npmrc') 410 | if (fs.existsSync(npmrcPath)) { 411 | fs.appendFileSync(npmrcPath, `\n${content}`) 412 | } else { 413 | fs.writeFileSync(npmrcPath, content) 414 | } 415 | } 416 | 417 | export async function buildVue({ verify = false, publish = false }) { 418 | const packages = await getVuePackages() 419 | 420 | const hasBuilt = fs.existsSync(builtPath) 421 | 422 | if (!hasBuilt) { 423 | const s = performance.now() 424 | 425 | cd(vuePath) 426 | const install = getCommand('pnpm', 'install') 427 | const runBuild = getCommand('pnpm', 'run', ['build', '--release']) 428 | const runBuildDts = getCommand('pnpm', 'run', ['build-dts']) 429 | const runTest = getCommand('pnpm', 'run', ['test']) 430 | 431 | // Prefix with `corepack` because pnpm 7 & 8's lockfile formats differ 432 | await $`corepack ${serializeCommand(install)}` 433 | await $`${serializeCommand(runBuild)}` 434 | await $`${serializeCommand(runBuildDts)}` 435 | 436 | if (verify) { 437 | await $`${serializeCommand(runTest)}` 438 | } 439 | 440 | console.log() 441 | console.log(`Built in ${(performance.now() - s).toFixed(0)}ms`) 442 | console.log() 443 | } else { 444 | console.log() 445 | console.log(`Built packages found, copying...`) 446 | console.log() 447 | // copy built files into repo 448 | for (const pkg of packages) { 449 | const targetDir = path.join(pkg.directory, 'dist') 450 | const fromDir = path.join(builtPath, pkg.dirName, 'dist') 451 | const files = fs.readdirSync(fromDir) 452 | if (fs.existsSync(targetDir)) { 453 | fs.rmSync(targetDir, { recursive: true }) 454 | } 455 | fs.mkdirSync(targetDir) 456 | for (const f of files) { 457 | fs.copyFileSync(path.join(fromDir, f), path.join(targetDir, f)) 458 | } 459 | } 460 | } 461 | 462 | if (publish) { 463 | const s = performance.now() 464 | 465 | // TODO: prompt for `pnpm clean` if the same version already exists 466 | // TODO: it's better to update the release script in the core repo than hacking it here 467 | for (const pkg of packages) { 468 | cd(pkg.directory) 469 | 470 | // sync versions 471 | const packageJsonPath = path.join(pkg.directory, 'package.json') 472 | const packageJson = JSON.parse( 473 | await fs.promises.readFile(packageJsonPath, 'utf-8'), 474 | ) 475 | packageJson.version = pkg.hashedVersion 476 | for (const dep of packages) { 477 | if (packageJson.dependencies?.[dep.name]) { 478 | packageJson.dependencies[dep.name] = dep.hashedVersion 479 | } 480 | if (packageJson.devDependencies?.[dep.name]) { 481 | packageJson.devDependencies[dep.name] = dep.hashedVersion 482 | } 483 | if (packageJson.peerDependencies?.[dep.name]) { 484 | packageJson.peerDependencies[dep.name] = dep.hashedVersion 485 | } 486 | } 487 | await fs.promises.writeFile( 488 | packageJsonPath, 489 | JSON.stringify(packageJson, null, 2) + '\n', 490 | 'utf-8', 491 | ) 492 | 493 | writeOrAppendNpmrc( 494 | pkg.directory, 495 | `${REGISTRY_ADDRESS.replace('http://', '//')}:_authToken=dummy`, 496 | ) 497 | await $`pnpm publish --access public --registry ${REGISTRY_ADDRESS} --no-git-checks` 498 | } 499 | 500 | console.log() 501 | console.log(`Published in ${(performance.now() - s).toFixed(0)}ms`) 502 | console.log() 503 | } 504 | } 505 | 506 | export async function bisectVue( 507 | good: string, 508 | runSuite: () => Promise, 509 | ) { 510 | // sometimes vue build modifies files in git, e.g. LICENSE.md 511 | // this would stop bisect, so to reset those changes 512 | const resetChanges = async () => $`git reset --hard HEAD` 513 | 514 | try { 515 | cd(vuePath) 516 | await resetChanges() 517 | await $`git bisect start` 518 | await $`git bisect bad` 519 | await $`git bisect good ${good}` 520 | let bisecting = true 521 | while (bisecting) { 522 | const commitMsg = await $$`git log -1 --format=%s` 523 | const isNonCodeCommit = commitMsg.match(/^(?:release|docs)[:(]/) 524 | if (isNonCodeCommit) { 525 | await $`git bisect skip` 526 | continue // see if next commit can be skipped too 527 | } 528 | const error = await runSuite() 529 | cd(vuePath) 530 | await resetChanges() 531 | const bisectOut = await $$`git bisect ${error ? 'bad' : 'good'}` 532 | bisecting = bisectOut.substring(0, 10).toLowerCase() === 'bisecting:' // as long as git prints 'bisecting: ' there are more revisions to test 533 | } 534 | } catch (e) { 535 | console.log('error while bisecting', e) 536 | } finally { 537 | try { 538 | cd(vuePath) 539 | await $`git bisect reset` 540 | } catch (e) { 541 | console.log('Error while resetting bisect', e) 542 | } 543 | } 544 | } 545 | 546 | function isLocalOverride(v: string): boolean { 547 | if (!v.includes('/') || v.startsWith('@')) { 548 | // not path-like (either a version number or a package name) 549 | return false 550 | } 551 | try { 552 | return !!fs.lstatSync(v)?.isDirectory() 553 | } catch (e) { 554 | if (e.code !== 'ENOENT') { 555 | throw e 556 | } 557 | return false 558 | } 559 | } 560 | export async function applyPackageOverrides( 561 | dir: string, 562 | pkg: any, 563 | overrides: Overrides = {}, 564 | useReleasedVersion?: string, 565 | ) { 566 | const useFileProtocol = (v: string) => 567 | isLocalOverride(v) ? `file:${path.resolve(v)}` : v 568 | // remove boolean flags 569 | overrides = Object.fromEntries( 570 | Object.entries(overrides) 571 | //eslint-disable-next-line @typescript-eslint/no-unused-vars 572 | .filter(([key, value]) => typeof value === 'string') 573 | .map(([key, value]) => [key, useFileProtocol(value as string)]), 574 | ) 575 | await $`git clean -fdxq` // remove current install 576 | 577 | const agent = await detect({ cwd: dir, autoInstall: false }) 578 | 579 | // Remove version from agent string: 580 | // yarn@berry => yarn 581 | // pnpm@6, pnpm@7 => pnpm 582 | const pm = agent?.split('@')[0] 583 | 584 | if (pm === 'pnpm') { 585 | const version = await $$`pnpm --version` 586 | // avoid bug with peer dependency overrides in pnpm 10.0-10.1.0 587 | if (version === '10.0.0' || version === '10.1.0') { 588 | console.warn( 589 | `detected pnpm@${version}, changing pkg.packageManager and pkg.engines.pnpm to enforce use of pnpm@10.2.0`, 590 | ) 591 | // corepack reads this and uses pnpm 10.2.0 then 592 | pkg.packageManager = 'pnpm@10.2.0' 593 | if (!pkg.engines) { 594 | pkg.engines = {} 595 | } 596 | pkg.engines.pnpm = '10.2.0' 597 | } 598 | // if (!pkg.devDependencies) { 599 | // pkg.devDependencies = {} 600 | // } 601 | // pkg.devDependencies = { 602 | // ...pkg.devDependencies, 603 | // ...overrides, // overrides must be present in devDependencies or dependencies otherwise they may not work 604 | // } 605 | pkg.pnpm ||= {} 606 | pkg.pnpm.overrides = { 607 | ...pkg.pnpm.overrides, 608 | ...overrides, 609 | } 610 | pkg.pnpm.peerDependencyRules ||= {} 611 | pkg.pnpm.peerDependencyRules.allowedVersions = { 612 | ...pkg.pnpm.peerDependencyRules.allowedVersions, 613 | ...overrides, 614 | } 615 | } else if (pm === 'yarn') { 616 | pkg.resolutions = { 617 | ...pkg.resolutions, 618 | ...overrides, 619 | } 620 | } else if (pm === 'npm') { 621 | pkg.overrides = { 622 | ...pkg.overrides, 623 | ...overrides, 624 | } 625 | // npm does not allow overriding direct dependencies, force it by updating the blocks themselves 626 | for (const [name, version] of Object.entries(overrides)) { 627 | if (pkg.dependencies?.[name]) { 628 | pkg.dependencies[name] = version 629 | } 630 | if (pkg.devDependencies?.[name]) { 631 | pkg.devDependencies[name] = version 632 | } 633 | } 634 | } else { 635 | throw new Error(`unsupported package manager detected: ${pm}`) 636 | } 637 | const pkgFile = path.join(dir, 'package.json') 638 | await fs.promises.writeFile( 639 | pkgFile, 640 | JSON.stringify(pkg, null, 2) + '\n', 641 | 'utf-8', 642 | ) 643 | 644 | // While `--registry` works for the `install` command, 645 | // we still need to persist the registry in `.npmrc` for any possible 646 | // subsequent commands that needs to connect to the registry. 647 | // Skip this step if we are using a released version of the vue package to avoid the overhead 648 | if (!useReleasedVersion) { 649 | writeOrAppendNpmrc(dir, `registry=${REGISTRY_ADDRESS}\n`) 650 | } 651 | 652 | // use of `ni` command here could cause lockfile violation errors so fall back to native commands that avoid these 653 | if (pm === 'pnpm') { 654 | await $`pnpm install --no-frozen-lockfile --no-strict-peer-dependencies` 655 | } else if (pm === 'yarn') { 656 | await $`yarn install` 657 | } else if (pm === 'npm') { 658 | await $`npm install` 659 | } 660 | } 661 | 662 | export function dirnameFrom(url: string) { 663 | return path.dirname(fileURLToPath(url)) 664 | } 665 | 666 | export function parseVueVersion(vuePath: string): string { 667 | const content = fs.readFileSync( 668 | path.join(vuePath, 'packages', 'vue', 'package.json'), 669 | 'utf-8', 670 | ) 671 | const pkg = JSON.parse(content) 672 | return pkg.version 673 | } 674 | -------------------------------------------------------------------------------- /verdaccio.yaml: -------------------------------------------------------------------------------- 1 | storage: .verdaccio-cache 2 | 3 | auth: 4 | # use verdaccio-auth-memory plugin for testing 5 | # not sure if it's still needed after we make `publish` available to $all 6 | auth-memory: 7 | users: 8 | foo: 9 | name: foo 10 | password: s3cret 11 | 12 | uplinks: 13 | npmjs: 14 | url: https://registry.npmjs.org/ 15 | 16 | packages: 17 | # avoid proxying vue core packages 18 | 'vue': 19 | access: $all 20 | publish: $all 21 | storage: ./.local 22 | proxy: npmjs 23 | '@vue/compat': 24 | access: $all 25 | publish: $all 26 | storage: ./.local 27 | proxy: npmjs 28 | '@vue/compiler-*': 29 | access: $all 30 | publish: $all 31 | storage: ./.local 32 | proxy: npmjs 33 | '@vue/reactivity*': 34 | access: $all 35 | publish: $all 36 | storage: ./.local 37 | proxy: npmjs 38 | '@vue/runtime-*': 39 | access: $all 40 | publish: $all 41 | storage: ./.local 42 | proxy: npmjs 43 | '@vue/server-renderer': 44 | access: $all 45 | publish: $all 46 | storage: ./.local 47 | proxy: npmjs 48 | '@vue/shared': 49 | access: $all 50 | publish: $all 51 | storage: ./.local 52 | proxy: npmjs 53 | '@*/*': 54 | access: $all 55 | publish: $all 56 | proxy: npmjs 57 | '**': 58 | access: $all 59 | publish: $all 60 | proxy: npmjs 61 | 62 | log: { type: stdout, format: pretty, level: warn } 63 | logs: { type: stdout, format: pretty, level: warn } 64 | --------------------------------------------------------------------------------