├── .eslintignore ├── .eslintrc.json ├── .gitattributes ├── .github ├── dependabot.yml └── workflows │ ├── ci.yml │ ├── release.yml │ └── support │ └── test │ └── action.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── .vscode ├── launch.json ├── settings.json └── tasks.json ├── action.yml ├── bin └── node ├── changelog.md ├── dist ├── index.js ├── index.js.map ├── licenses.txt ├── post.mjs └── sourcemap-register.js ├── jest.config.js ├── license ├── package-lock.json ├── package.json ├── post └── main.mjs ├── readme.md ├── spec ├── action │ └── vm_file_system_synchronizer.spec.ts ├── architecture.spec.ts ├── helpers │ └── typescript.js ├── operating_systems │ ├── freebsd │ │ ├── freebsd.spec.ts │ │ └── qemu_vm.spec.ts │ ├── haiku │ │ ├── haiku.spec.ts │ │ └── qemu_vm.spec.ts │ └── netbsd │ │ ├── netbsd.spec.ts │ │ └── qemu_vm.spec.ts ├── support │ └── jasmine.json ├── utility.spec.ts └── xhuve_vm.spec.ts ├── src ├── action │ ├── action.ts │ ├── input.ts │ ├── shell.ts │ └── sync_direction.ts ├── architecture.ts ├── host.ts ├── host_qemu.ts ├── hypervisor.ts ├── main.ts ├── operating_system.ts ├── operating_systems │ ├── factory.ts │ ├── freebsd │ │ ├── factory.ts │ │ ├── freebsd.ts │ │ ├── qemu_vm.ts │ │ └── xhyve_vm.ts │ ├── haiku │ │ ├── factory.ts │ │ ├── haiku.ts │ │ └── qemu_vm.ts │ ├── kind.ts │ ├── netbsd │ │ ├── factory.ts │ │ ├── netbsd.ts │ │ └── qemu_vm.ts │ ├── openbsd │ │ ├── factory.ts │ │ ├── openbsd.ts │ │ ├── qemu_vm.ts │ │ └── xhyve_vm.ts │ ├── qemu.ts │ ├── qemu_factory.ts │ └── resource_urls.ts ├── qemu_vm.ts ├── resource_disk.ts ├── utility.ts ├── version.ts ├── vm.ts ├── vm_file_system_synchronizer.ts ├── wait.ts └── xhyve_vm.ts ├── test ├── http │ └── cross-platform-actions │ │ ├── freebsd-builder │ │ └── releases │ │ │ └── download │ │ │ └── v0.3.0 │ │ │ └── .gitkeep │ │ ├── netbsd-builder │ │ └── releases │ │ │ └── download │ │ │ └── v0.1.0 │ │ │ └── .gitkeep │ │ ├── openbsd-builder │ │ └── releases │ │ │ └── download │ │ │ └── v0.5.0 │ │ │ └── .gitkeep │ │ └── resources │ │ └── releases │ │ └── download │ │ └── v0.7.0 │ │ └── .gitkeep └── workflows │ └── ci.yml.example └── tsconfig.json /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | lib/ 3 | node_modules/ 4 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["jasmine", "@typescript-eslint"], 3 | "extends": ["plugin:github/recommended", "plugin:jasmine/recommended"], 4 | "parser": "@typescript-eslint/parser", 5 | "parserOptions": { 6 | "ecmaVersion": 9, 7 | "sourceType": "module", 8 | "project": "./tsconfig.json" 9 | }, 10 | "rules": { 11 | "eslint-comments/no-use": "off", 12 | "import/no-namespace": "off", 13 | "no-unused-vars": "off", 14 | "@typescript-eslint/no-unused-vars": "error", 15 | "@typescript-eslint/explicit-member-accessibility": ["error", {"accessibility": "no-public"}], 16 | "@typescript-eslint/no-require-imports": "error", 17 | "@typescript-eslint/array-type": "error", 18 | "@typescript-eslint/await-thenable": "error", 19 | "@typescript-eslint/ban-ts-comment": "error", 20 | "camelcase": "off", 21 | "@typescript-eslint/consistent-type-assertions": "error", 22 | "@typescript-eslint/explicit-function-return-type": ["error", {"allowExpressions": true}], 23 | "@typescript-eslint/func-call-spacing": ["error", "never"], 24 | "@typescript-eslint/no-array-constructor": "error", 25 | "@typescript-eslint/no-empty-interface": "error", 26 | "@typescript-eslint/no-explicit-any": "error", 27 | "@typescript-eslint/no-extraneous-class": "error", 28 | "@typescript-eslint/no-for-in-array": "error", 29 | "@typescript-eslint/no-inferrable-types": "error", 30 | "@typescript-eslint/no-misused-new": "error", 31 | "@typescript-eslint/no-namespace": "error", 32 | "@typescript-eslint/no-non-null-assertion": "warn", 33 | "@typescript-eslint/no-unnecessary-qualifier": "error", 34 | "@typescript-eslint/no-unnecessary-type-assertion": "error", 35 | "@typescript-eslint/no-useless-constructor": "error", 36 | "@typescript-eslint/no-var-requires": "error", 37 | "@typescript-eslint/prefer-for-of": "warn", 38 | "@typescript-eslint/prefer-function-type": "warn", 39 | "@typescript-eslint/prefer-includes": "error", 40 | "@typescript-eslint/prefer-string-starts-ends-with": "error", 41 | "@typescript-eslint/promise-function-async": "error", 42 | "@typescript-eslint/require-array-sort-compare": "error", 43 | "@typescript-eslint/restrict-plus-operands": "error", 44 | "semi": "off", 45 | "@typescript-eslint/semi": ["error", "never"], 46 | "@typescript-eslint/type-annotation-spacing": "error", 47 | "@typescript-eslint/unbound-method": "error", 48 | "no-shadow": "off", 49 | "@typescript-eslint/no-shadow": "error", 50 | "i18n-text/no-en": "off", 51 | "sort-imports": "off", 52 | "filenames/match-regex": [2, "^[a-z_]+$", true] 53 | }, 54 | "env": { 55 | "node": true, 56 | "es6": true, 57 | "jasmine": true 58 | } 59 | } -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | dist/** -diff linguist-generated=true -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Enable version updates for npm 4 | - package-ecosystem: 'npm' 5 | # Look for `package.json` and `lock` files in the `root` directory 6 | directory: '/' 7 | # Check the npm registry for updates every day (weekdays) 8 | schedule: 9 | interval: 'daily' 10 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: '*' 6 | tags: v* 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | build: # make sure build/ci work properly 13 | runs-on: ubuntu-latest 14 | timeout-minutes: 2 15 | steps: 16 | - uses: actions/checkout@v4 17 | - run: npm install 18 | - run: npm run all 19 | 20 | FreeBSD: # make sure the action works on a clean machine without building 21 | name: FreeBSD ${{ matrix.architecture.name }} ${{ matrix.version }} on ${{ matrix.host.name }} 22 | runs-on: ${{ matrix.host.name }} 23 | timeout-minutes: 5 24 | strategy: 25 | fail-fast: false 26 | matrix: 27 | version: 28 | - '13.4' 29 | - '13.3' 30 | - '13.2' 31 | - '13.1' 32 | - '13.0' 33 | - '12.4' 34 | - '12.2' 35 | 36 | architecture: 37 | - name: arm64 38 | - name: x86-64 39 | uname: amd64 40 | 41 | host: 42 | - name: ubuntu-latest 43 | # /home is symlinked to /usr/home. pwd will return the resolved path. 44 | work_directory: /usr/home/runner/work/action/action 45 | 46 | - name: macos-13 47 | work_directory: /Users/runner/work/action/action 48 | 49 | exclude: 50 | - version: '12.2' 51 | architecture: { name: arm64 } 52 | 53 | - architecture: { name: arm64 } 54 | host: { name: macos-13 } 55 | 56 | include: 57 | - version: '14.2' 58 | architecture: { name: arm64 } 59 | host: 60 | name: ubuntu-latest 61 | work_directory: /home/runner/work/action/action 62 | 63 | - version: '14.2' 64 | architecture: 65 | name: x86-64 66 | uname: amd64 67 | host: 68 | name: ubuntu-latest 69 | work_directory: /home/runner/work/action/action 70 | 71 | - version: '14.1' 72 | architecture: { name: arm64 } 73 | host: 74 | name: ubuntu-latest 75 | work_directory: /home/runner/work/action/action 76 | 77 | - version: '14.1' 78 | architecture: 79 | name: x86-64 80 | uname: amd64 81 | host: 82 | name: ubuntu-latest 83 | work_directory: /home/runner/work/action/action 84 | 85 | - version: '14.0' 86 | architecture: { name: arm64 } 87 | host: 88 | name: ubuntu-latest 89 | work_directory: /home/runner/work/action/action 90 | 91 | - version: '14.0' 92 | architecture: 93 | name: x86-64 94 | uname: amd64 95 | host: 96 | name: ubuntu-latest 97 | work_directory: /home/runner/work/action/action 98 | 99 | - version: '14.0' 100 | architecture: 101 | name: x86-64 102 | uname: amd64 103 | host: 104 | name: macos-13 105 | work_directory: /Users/runner/work/action/action 106 | 107 | steps: 108 | - name: Checkout 109 | uses: actions/checkout@v4 110 | with: 111 | persist-credentials: false 112 | 113 | - name: test 114 | uses: ./.github/workflows/support/test 115 | with: 116 | name: FreeBSD 117 | architecture: ${{ matrix.architecture.name }} 118 | version: ${{ matrix.version }} 119 | uname_release: ${{ matrix.version}}-RELEASE 120 | uname_hardware: ${{ matrix.architecture.uname || matrix.architecture.name }} 121 | work_directory: ${{ matrix.host.work_directory }} 122 | 123 | Haiku: 124 | name: Haiku ${{ matrix.architecture.name }} ${{ matrix.version.name }} on ${{ matrix.host.name }} 125 | runs-on: ${{ matrix.host.name }} 126 | timeout-minutes: 5 127 | strategy: 128 | fail-fast: false 129 | matrix: 130 | version: 131 | - name: 'r1beta5' 132 | uname: hrev57937 133 | 134 | architecture: 135 | - name: x86-64 136 | uname: x86_64 137 | 138 | host: 139 | - name: ubuntu-latest 140 | work_directory: /home/runner/work/action/action 141 | 142 | steps: 143 | - name: Checkout 144 | uses: actions/checkout@v4 145 | with: 146 | persist-credentials: false 147 | 148 | - name: test 149 | uses: ./.github/workflows/support/test 150 | with: 151 | name: Haiku 152 | architecture: ${{ matrix.architecture.name }} 153 | version: ${{ matrix.version.name }} 154 | uname_version: ${{ matrix.version.uname }} 155 | uname_hardware: ${{ matrix.architecture.uname || matrix.architecture.name }} 156 | work_directory: ${{ matrix.host.work_directory }} 157 | 158 | OpenBSD: 159 | name: OpenBSD ${{ matrix.architecture.name }} ${{ matrix.version }} on ${{ matrix.host.name }} 160 | runs-on: ${{ matrix.host.name }} 161 | timeout-minutes: 5 162 | strategy: 163 | fail-fast: false 164 | matrix: 165 | version: 166 | - '7.7' 167 | - '7.6' 168 | - '7.5' 169 | - '7.4' 170 | - '7.3' 171 | - '7.2' 172 | - '7.1' 173 | - '6.9' 174 | - '6.8' 175 | 176 | architecture: 177 | - name: arm64 178 | - name: x86-64 179 | uname: amd64 180 | 181 | host: 182 | - name: ubuntu-latest 183 | work_directory: /home/runner/work/action/action 184 | 185 | - name: macos-13 186 | work_directory: /Users/runner/work/action/action 187 | 188 | exclude: 189 | - version: '6.8' 190 | architecture: { name: arm64 } 191 | 192 | - architecture: { name: arm64 } 193 | host: { name: macos-13 } 194 | 195 | steps: 196 | - name: Checkout 197 | uses: actions/checkout@v4 198 | with: 199 | persist-credentials: false 200 | 201 | - name: test 202 | uses: ./.github/workflows/support/test 203 | with: 204 | name: OpenBSD 205 | architecture: ${{ matrix.architecture.name }} 206 | version: ${{ matrix.version }} 207 | uname_hardware: ${{ matrix.architecture.uname || matrix.architecture.name }} 208 | work_directory: ${{ matrix.host.work_directory }} 209 | 210 | NetBSD: 211 | name: NetBSD ${{ matrix.architecture.name }} ${{ matrix.version }} on ${{ matrix.host.name }} 212 | runs-on: ${{ matrix.host.name }} 213 | timeout-minutes: 5 214 | strategy: 215 | fail-fast: false 216 | matrix: 217 | version: 218 | - '10.1' 219 | - '10.0' 220 | - '9.4' 221 | - '9.3' 222 | - '9.2' 223 | 224 | architecture: 225 | - name: x86-64 226 | uname: amd64 227 | 228 | - name: arm64 229 | uname: aarch64 230 | 231 | host: 232 | - name: ubuntu-latest 233 | work_directory: /home/runner/work/action/action 234 | 235 | - name: macos-13 236 | work_directory: /Users/runner/work/action/action 237 | 238 | exclude: 239 | - version: '9.4' 240 | architecture: { name: arm64 } 241 | 242 | - version: '9.3' 243 | architecture: { name: arm64 } 244 | 245 | - version: '9.2' 246 | architecture: { name: arm64 } 247 | 248 | - architecture: { name: arm64 } 249 | host: { name: macos-13 } 250 | 251 | steps: 252 | - name: Checkout 253 | uses: actions/checkout@v4 254 | with: 255 | persist-credentials: false 256 | 257 | - name: test 258 | uses: ./.github/workflows/support/test 259 | with: 260 | name: NetBSD 261 | architecture: ${{ matrix.architecture.name }} 262 | version: ${{ matrix.version }} 263 | uname_hardware: ${{ matrix.architecture.uname || matrix.architecture.name }} 264 | work_directory: ${{ matrix.host.work_directory }} 265 | 266 | test-no-env: 267 | timeout-minutes: 5 268 | name: Test without environment variables 269 | runs-on: ubuntu-latest 270 | 271 | steps: 272 | - name: Checkout 273 | uses: actions/checkout@v4 274 | with: 275 | persist-credentials: false 276 | 277 | - name: Test 278 | uses: ./ 279 | with: 280 | operating_system: freebsd 281 | architecture: x86-64 282 | version: '13.0' 283 | shutdown_vm: false 284 | run: env | sort 285 | 286 | test-cpu-count-config: 287 | timeout-minutes: 5 288 | name: Test configuring CPU count 289 | runs-on: ubuntu-latest 290 | 291 | steps: 292 | - name: Checkout 293 | uses: actions/checkout@v4 294 | with: 295 | persist-credentials: false 296 | 297 | - name: Test 298 | uses: ./ 299 | with: 300 | operating_system: freebsd 301 | architecture: x86-64 302 | version: '13.1' 303 | cpu_count: 8 304 | shutdown_vm: false 305 | run: | 306 | sysctl hw.ncpu 307 | [ `sysctl -n hw.ncpu` = 8 ] 308 | 309 | test-hypervisor-config: 310 | timeout-minutes: 5 311 | name: Test configuring hypervisor 312 | runs-on: macos-13 313 | 314 | steps: 315 | - name: Checkout 316 | uses: actions/checkout@v4 317 | with: 318 | persist-credentials: false 319 | 320 | - name: Test 321 | uses: ./ 322 | with: 323 | operating_system: freebsd 324 | architecture: x86-64 325 | version: '13.1' 326 | hypervisor: qemu 327 | shutdown_vm: false 328 | run: sysctl hw.model 329 | 330 | - name: Hypervisor should still be running, verify it's QEMU 331 | run: ps aux | grep -v grep | grep -q qemu 332 | 333 | test-custom-vm-image: 334 | timeout-minutes: 5 335 | name: Test custom VM image 336 | runs-on: macos-13 337 | 338 | steps: 339 | - name: Checkout 340 | uses: actions/checkout@v4 341 | with: 342 | persist-credentials: false 343 | 344 | - name: Test 345 | uses: ./ 346 | with: 347 | operating_system: openbsd 348 | architecture: x86-64 349 | version: '7.3' 350 | image_url: https://github.com/cross-platform-actions/test-custom-image-builder/releases/download/v1.0.0/openbsd-7.3-x86-64.qcow2 351 | shutdown_vm: false 352 | run: test -f /foo 353 | 354 | test-cpu-features: 355 | timeout-minutes: 5 356 | name: Test CPU features 357 | runs-on: ubuntu-latest 358 | 359 | steps: 360 | - name: Checkout 361 | uses: actions/checkout@v4 362 | with: 363 | persist-credentials: false 364 | 365 | - name: Test 366 | uses: ./ 367 | with: 368 | operating_system: freebsd 369 | architecture: x86-64 370 | version: '13.2' 371 | hypervisor: qemu 372 | shutdown_vm: false 373 | run: dmesg | grep -i avx2 374 | 375 | test-no-vm-shutdown: 376 | timeout-minutes: 5 377 | name: Test not shutting down the VM 378 | runs-on: ubuntu-latest 379 | 380 | steps: 381 | - name: Checkout 382 | uses: actions/checkout@v4 383 | with: 384 | persist-credentials: false 385 | 386 | - name: Test 387 | uses: ./ 388 | with: 389 | operating_system: freebsd 390 | architecture: x86-64 391 | version: '13.2' 392 | hypervisor: qemu 393 | shutdown_vm: false 394 | run: true 395 | 396 | - name: Verify VM is still running 397 | run: ps aux | grep -v grep | grep -q qemu 398 | 399 | test-vm-shutdown: 400 | timeout-minutes: 5 401 | name: Test shutting down the VM 402 | runs-on: ubuntu-latest 403 | 404 | steps: 405 | - name: Checkout 406 | uses: actions/checkout@v4 407 | with: 408 | persist-credentials: false 409 | 410 | - name: Test 411 | uses: ./ 412 | with: 413 | operating_system: freebsd 414 | architecture: x86-64 415 | version: '13.2' 416 | hypervisor: qemu 417 | shutdown_vm: true 418 | run: true 419 | 420 | - name: Verify VM is not running 421 | run: ps aux | grep -v grep | grep -q -v qemu 422 | 423 | test-sync-files: 424 | timeout-minutes: 5 425 | name: 'Test sync files: ${{ matrix.data.direction }}' 426 | runs-on: ubuntu-latest 427 | strategy: 428 | fail-fast: false 429 | matrix: 430 | data: 431 | - direction: runner-to-vm 432 | run: test -f foo.txt && touch bar.txt 433 | run_after: '! test -f bar.txt' # The new files from the VM should not sync back 434 | 435 | - direction: vm-to-runner 436 | run: '[ ! -f foo.txt ] && touch bar.txt' 437 | run_after: 'test -f bar.txt' # The new files from the VM should sync back 438 | 439 | - direction: false 440 | run: '[ ! -f foo.txt ] && touch bar.txt' 441 | run_after: '! test -f bar.txt' # The new files from the VM should not sync back 442 | 443 | - direction: true 444 | run: test -f foo.txt && touch bar.txt 445 | run_after: test -f bar.txt 446 | 447 | steps: 448 | - name: Checkout 449 | uses: actions/checkout@v4 450 | with: 451 | persist-credentials: false 452 | 453 | - run: touch foo.txt 454 | 455 | - name: Test 456 | uses: ./ 457 | with: 458 | operating_system: freebsd 459 | architecture: x86-64 460 | version: '13.2' 461 | sync_files: ${{ matrix.data.direction }} 462 | shutdown_vm: false 463 | run: ${{ matrix.data.run }} 464 | 465 | - name: Run after 466 | run: ${{ matrix.data.run_after }} 467 | 468 | multiple-steps: 469 | timeout-minutes: 5 470 | name: Test running the action multiple times 471 | runs-on: macos-13 472 | 473 | strategy: 474 | fail-fast: false 475 | matrix: 476 | hypervisor: [qemu, xhyve] 477 | 478 | steps: 479 | - name: Checkout 480 | uses: actions/checkout@v4 481 | with: 482 | persist-credentials: false 483 | 484 | - name: Run action first time 485 | uses: ./ 486 | with: 487 | operating_system: freebsd 488 | architecture: x86-64 489 | version: '13.2' 490 | hypervisor: ${{ matrix.hypervisor }} 491 | shutdown_vm: false 492 | run: touch foo.txt 493 | 494 | - name: Verify VM is still running 495 | run: ps aux | grep -v grep | grep -q -v qemu 496 | 497 | - name: Verify file is synced back 498 | run: test -f foo.txt 499 | 500 | - name: Run action second time 501 | uses: ./ 502 | with: 503 | operating_system: freebsd 504 | architecture: x86-64 505 | version: '13.2' 506 | hypervisor: qemu 507 | shutdown_vm: true 508 | run: test -f foo.txt 509 | 510 | openbsd-qemu-macos: 511 | timeout-minutes: 5 512 | name: Test OpenBSD with QEMU on macOS runner 513 | runs-on: macos-13 514 | 515 | steps: 516 | - name: Checkout 517 | uses: actions/checkout@v4 518 | with: 519 | persist-credentials: false 520 | 521 | - name: Test 522 | uses: ./ 523 | with: 524 | operating_system: openbsd 525 | architecture: x86-64 526 | version: '7.4' 527 | hypervisor: qemu 528 | sync_files: false 529 | shutdown_vm: false 530 | run: true 531 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | workflow_run: 5 | workflows: [CI] 6 | types: [completed] 7 | 8 | jobs: 9 | release: 10 | runs-on: ubuntu-latest 11 | timeout-minutes: 2 12 | if: github.event.workflow_run.conclusion == 'success' 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v4 16 | with: 17 | persist-credentials: false 18 | 19 | - name: Validate version 20 | id: version 21 | run: | 22 | set -x 23 | tag='${{ github.event.workflow_run.head_branch }}' 24 | 25 | if echo "$tag" | grep -Pq '^v\d+\.\d+\.\d+(-.+)?'; then 26 | echo valid=true >> "$GITHUB_OUTPUT" 27 | echo version="$(echo "$tag" | sed 's/^v//')" >> "$GITHUB_OUTPUT" 28 | fi 29 | 30 | - name: Extract changelog 31 | uses: sean0x42/markdown-extract@v2 32 | id: extract_changelog 33 | with: 34 | file: changelog.md 35 | pattern: '\[${{ steps.version.outputs.version }}\].+' 36 | no-print-matched-heading: true 37 | 38 | - name: Create release 39 | if: steps.version.outputs.valid 40 | uses: softprops/action-gh-release@v1 41 | with: 42 | name: Cross Platform Action ${{ steps.version.outputs.version }} 43 | draft: true 44 | body: ${{ steps.extract_changelog.outputs.markdown }} 45 | env: 46 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 47 | -------------------------------------------------------------------------------- /.github/workflows/support/test/action.yml: -------------------------------------------------------------------------------- 1 | inputs: 2 | name: 3 | description: "The name of the operating system" 4 | architecture: 5 | description: "The architecture of the operating system" 6 | version: 7 | description: "The version of the operating system" 8 | uname_release: 9 | description: "The release as reported by uname" 10 | uname_version: 11 | description: "The version as reported by uname" 12 | uname_hardware: 13 | description: "The hardware as reported by uname" 14 | work_directory: 15 | description: "The working directory" 16 | 17 | runs: 18 | using: "composite" 19 | steps: 20 | # - name: Setup tmate session 21 | # uses: mxschmitt/action-tmate@v3 22 | # with: 23 | # limit-access-to-actor: true 24 | 25 | # - name: Setup SSH session 26 | # uses: lhotari/action-upterm@v1 27 | 28 | - name: test 29 | uses: ./ 30 | env: 31 | FOO: A 32 | BAR: B 33 | with: 34 | environment_variables: FOO BAR 35 | operating_system: ${{ inputs.name }} 36 | architecture: ${{ inputs.architecture }} 37 | version: '${{ inputs.version }}' 38 | shutdown_vm: false 39 | run: | 40 | uname -a 41 | uname -s 42 | uname -r 43 | uname -v 44 | uname -m 45 | uname -p 46 | pwd 47 | echo $SHELL 48 | ls -lah 49 | whoami 50 | env | sort 51 | [ "`uname -s`" = '${{ inputs.name }}' ] 52 | [ "`uname -r`" = '${{ inputs.uname_release || inputs.version }}' ] || uname -v | grep -q '${{ inputs.uname_version }}' 53 | [ "`uname -m`" = '${{ inputs.uname_hardware }}' ] || [ "`uname -p`" = '${{ inputs.uname_hardware }}' ] 54 | [ "`pwd`" = '${{ inputs.work_directory }}' ] 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependency directory 2 | node_modules 3 | 4 | # Rest pulled from https://github.com/github/gitignore/blob/master/Node.gitignore 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | lerna-debug.log* 12 | 13 | # Diagnostic reports (https://nodejs.org/api/report.html) 14 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 15 | 16 | # Runtime data 17 | pids 18 | *.pid 19 | *.seed 20 | *.pid.lock 21 | 22 | # Directory for instrumented libs generated by jscoverage/JSCover 23 | lib-cov 24 | 25 | # Coverage directory used by tools like istanbul 26 | coverage 27 | *.lcov 28 | 29 | # nyc test coverage 30 | .nyc_output 31 | 32 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 33 | .grunt 34 | 35 | # Bower dependency directory (https://bower.io/) 36 | bower_components 37 | 38 | # node-waf configuration 39 | .lock-wscript 40 | 41 | # Compiled binary addons (https://nodejs.org/api/addons.html) 42 | build/Release 43 | 44 | # Dependency directories 45 | jspm_packages/ 46 | 47 | # TypeScript v1 declaration files 48 | typings/ 49 | 50 | # TypeScript cache 51 | *.tsbuildinfo 52 | 53 | # Optional npm cache directory 54 | .npm 55 | 56 | # Optional eslint cache 57 | .eslintcache 58 | 59 | # Optional REPL history 60 | .node_repl_history 61 | 62 | # Output of 'npm pack' 63 | *.tgz 64 | 65 | # Yarn Integrity file 66 | .yarn-integrity 67 | 68 | # dotenv environment variables file 69 | .env 70 | .env.test 71 | 72 | # parcel-bundler cache (https://parceljs.org/) 73 | .cache 74 | 75 | # next.js build output 76 | .next 77 | 78 | # nuxt.js build output 79 | .nuxt 80 | 81 | # vuepress build output 82 | .vuepress/dist 83 | 84 | # Serverless directories 85 | .serverless/ 86 | 87 | # FuseBox cache 88 | .fusebox/ 89 | 90 | # DynamoDB Local files 91 | .dynamodb/ 92 | 93 | # OS metadata 94 | .DS_Store 95 | Thumbs.db 96 | 97 | # Ignore built ts files 98 | lib/**/* 99 | 100 | test/http/* 101 | test/workflows/ci.yml 102 | 103 | ### VisualStudioCode ### 104 | .vscode/**/* 105 | !.vscode/settings.json 106 | !.vscode/tasks.json 107 | !.vscode/launch.json 108 | !.vscode/extensions.json -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | lib/ 3 | node_modules/ -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "semi": false, 6 | "singleQuote": true, 7 | "trailingComma": "none", 8 | "bracketSpacing": false, 9 | "arrowParens": "avoid" 10 | } 11 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Test", 9 | "type": "pwa-node", 10 | "request": "launch", 11 | "sourceMaps": true, 12 | "preLaunchTask": "npm: build", 13 | "runtimeExecutable": "npm", 14 | "runtimeArgs": ["test"], 15 | "skipFiles": ["/**"], 16 | "outFiles": ["${workspaceFolder}/**/*.js"] 17 | } 18 | ] 19 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.options": { 3 | "parserOptions": { 4 | } 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "type": "npm", 6 | "script": "build", 7 | "problemMatcher": [ 8 | "$tsc" 9 | ], 10 | "group": "build", 11 | "label": "npm: build", 12 | "detail": "tsc" 13 | }, 14 | { 15 | "type": "npm", 16 | "script": "lint", 17 | "problemMatcher": [ 18 | "$eslint-stylish" 19 | ], 20 | "label": "npm: lint", 21 | "detail": "eslint src/**/*.ts" 22 | } 23 | ] 24 | } -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: Cross Platform Action 2 | description: Provides cross platform runner 3 | author: Jacob Carlborg 4 | inputs: 5 | run: 6 | required: true 7 | description: | 8 | Runs command-line programs using the operating system's shell. 9 | This will be executed inside the virtual machine. 10 | operating_system: 11 | required: true 12 | description: The type of operating system to run the job on 13 | architecture: 14 | required: false 15 | description: The architecture of the operating system. 16 | default: x86-64 17 | version: 18 | required: true 19 | description: The version of the operating system to use 20 | shell: 21 | required: false 22 | description: | 23 | The shell to use to execute the commands. Defaults to the default shell 24 | for the given operating system. 25 | default: default 26 | environment_variables: 27 | required: false 28 | description: | 29 | A list of environment variables to forward to the virtual machine. 30 | The list should be separated with spaces. 31 | default: '' 32 | memory: 33 | required: false 34 | description: The amout of memory for the virtual machine 35 | cpu_count: 36 | required: false 37 | description: The number of CPU cores for the virtual machine 38 | hypervisor: 39 | required: false 40 | description: The hypervisor to use when starting the virtual machine 41 | deprecationMessage: > 42 | The Xhyve hypervisor has been deprecated and will be removed in a future 43 | update. QEMU will be the only available hypervisor, making this input 44 | parameter redundant. 45 | image_url: 46 | required: false 47 | description: URL for running the action with a custom image. 48 | default: '' 49 | sync_files: 50 | required: false 51 | description: | 52 | Specifies if the local files should be synchronized to virtual 53 | machine and in which direction. Valid values are `true`, `false`, 54 | `runner-to-vm` and `vm-to-runner`. 55 | default: 'true' 56 | shutdown_vm: 57 | required: false 58 | description: | 59 | Specifies if the VM should be shutdown after the action has been run. 60 | default: true 61 | 62 | runs: 63 | using: node20 64 | main: dist/index.js 65 | post: dist/post.mjs 66 | 67 | branding: 68 | icon: play-circle 69 | color: green 70 | -------------------------------------------------------------------------------- /bin/node: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # This script is a wrapper around the `node` command. It's used to handle both 4 | # newer versions that does type stripping by default and older versions which 5 | # does not suppor type stripping. 6 | 7 | set -eu 8 | 9 | # If we have the `--no-experimental-strip-types` use it, otherwise we don't. 10 | # We need to pass the `--help` flag otherwise it will enter the interactive 11 | # mode. 12 | if node --no-experimental-strip-types --help > /dev/null 2>&1; then 13 | node --no-experimental-strip-types "$@" 14 | else 15 | node "$@" 16 | fi 17 | -------------------------------------------------------------------------------- /changelog.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [Unreleased] 8 | 9 | ## [0.28.0] - 2025-05-19 10 | ### Added 11 | - Add support for FreeBSD 13.5 ([#99](https://github.com/cross-platform-actions/action/issues/99)) 12 | - Add support for OpenBSD 7.7 ([openbsd-builder#21](https://github.com/cross-platform-actions/openbsd-builder/pull/21)) 13 | - Add support for Haiku ([#30](https://github.com/cross-platform-actions/action/issues/30)) 14 | 15 | ## [0.27.0] - 2025-01-22 16 | ### Added 17 | - Add support for NetBSD 10.1 ([#95](https://github.com/cross-platform-actions/action/issues/95)) 18 | 19 | ## [0.26.0] - 2024-04-12 20 | ### Added 21 | - Add support for FreeBSD 13.4 22 | - Add support for OpenBSD 7.6 ([openbsd-builder#20](https://github.com/cross-platform-actions/openbsd-builder/issues/20)) 23 | - Add support for FreeBSD 14.2 24 | 25 | ## [0.25.0] - 2024-07-11 26 | ### Added 27 | - Add support for NetBSD 9.4 28 | - Add support for FreeBSD 14.1 29 | 30 | ### Deprecated 31 | - Support for macOS runners has been deprecated and will be removed in a future 32 | release. The reason for using macOS runners in the past has been because of 33 | the support for hardware accelerated nested virtualization using the 34 | Hypervisor framework. Since the creation of this action, the Ubuntu runners 35 | have been upgraded with better performance and added support for hardware 36 | accelerated nested virtualization using KVM. QEMU is also more stable when 37 | using KVM compared to the Hypervisor framework. Please use the 38 | `ubuntu-latest` runner instead. 39 | 40 | - The Xhyve hypervisor has been deprecated and will be removed in a future 41 | release. QEMU will be the only available hypervisor. The reason being 42 | maintenance of the Xhyve hypervisor seemed to have stopped. It's also 43 | starting to become next to impossible to build on later versions of macOS. 44 | Please switch to the QEMU hypervisor by switching to the `ubuntu-latest` 45 | runner. 46 | 47 | - The `hypervisor` input parameter has been deprecated will be removed in a 48 | future release. The reason being support for the Xhyve hypervisor has been 49 | deprecated, making this input parameter redundant. Please remove the use of 50 | the `hypervisor` input parameter and switch to the `ubuntu-latest` runner. 51 | 52 | ## [0.24.0] - 2024-04-12 53 | ### Added 54 | - Add support for FreeBSD 13.3 55 | - Add support for NetBSD 10.0 56 | - Add support for NetBSD ARM64 ([#55](https://github.com/cross-platform-actions/action/issues/55)) 57 | - Add support for OpenBSD 7.5 ([openbsd-builder#16](https://github.com/cross-platform-actions/openbsd-builder/issues/16)) 58 | 59 | ## [0.23.0] - 2024-02-18 60 | ### Added 61 | - Add support for FreeBSD 14.0 ([#74](https://github.com/cross-platform-actions/action/issues/74)) 62 | - Add post run step that prints the VM output 63 | - Support hardware accelerated virtualization on Linux runners ([#47](https://github.com/cross-platform-actions/action/issues/47)) 64 | 65 | ### Fixed 66 | - OpenBSD VM fails during "Initializing VM" with QEMU on macOS ([#73](https://github.com/cross-platform-actions/action/issues/73)) 67 | - Use same options for rsync in both directions ([#76](https://github.com/cross-platform-actions/action/issues/76)) 68 | 69 | ### Changed 70 | - Update qemu to 8.2.0 for CVTPS2PD fix ([#78](https://github.com/cross-platform-actions/action/issues/78)) 71 | 72 | ## [0.22.0] - 2023-12-27 73 | ### Added 74 | - Added support for using the action in multiple steps in the same job ([#26](https://github.com/cross-platform-actions/action/issues/26)). 75 | All the inputs need to be the same for all steps, except for the following 76 | inputs: `sync_files`, `shutdown_vm` and `run`. 77 | 78 | - Added support for specifying that the VM should not shutdown after the action 79 | has run. This adds a new input parameter: `shutdown_vm`. When set to `false`, 80 | this will hopefully mitigate very frequent freezing of VM during teardown ([#61](https://github.com/cross-platform-actions/action/issues/61), [#72](https://github.com/cross-platform-actions/action/issues/72)). 81 | 82 | ### Changed 83 | - Always terminate VM instead of shutting down. This is more efficient and this 84 | will hopefully mitigate very frequent freezing of VM during teardown 85 | ([#61](https://github.com/cross-platform-actions/action/issues/61), 86 | [#72](https://github.com/cross-platform-actions/action/issues/72)). 87 | 88 | - Use `unsafe` as the cache mode for QEMU disks. This should improve performance ([#67](https://github.com/cross-platform-actions/action/issues/67)). 89 | 90 | ## [0.21.1] - 2023-11-03 91 | ### Fixed 92 | - FreeBSD jobs occasionally fail when ejecting the disk ([#64](https://github.com/cross-platform-actions/action/issues/64)) 93 | 94 | ## [0.21.0] - 2023-10-26 95 | ### Added 96 | - Add support for OpenBSD 7.4 ([openbsd-builder#15](https://github.com/cross-platform-actions/openbsd-builder/issues/15)) 97 | 98 | ## [0.20.0] - 2023-10-24 99 | ### Added 100 | - Add support for disabling file syncing ([#65](https://github.com/cross-platform-actions/action/issues/65)). 101 | This adds a new input parameter, `sync_files`. It allows to specify 102 | which directions files should be synced. From the runner to the VM, 103 | from the VM to the runner, both or none. 104 | 105 | ## [0.19.1] - 2023-10-07 106 | ### Fixed 107 | - NetBSD - VM doesn't start ([#62](https://github.com/cross-platform-actions/action/issues/62)) 108 | 109 | ## [0.19.0] - 2023-08-17 110 | ### Changed 111 | - VMs running via QEMU only expose SSE and SSE2 CPU features ([#60](https://github.com/cross-platform-actions/action/issues/60)). 112 | This changes the machine to `q35` and the cpu to `max`, for x86-64 using 113 | the QEMU hypervisor. This adds more CPU features like AVX and AVX2. 114 | 115 | ## [0.18.0] - 2023-08-04 116 | ### Added 117 | - Add support for custom image URLs ([#13](https://github.com/cross-platform-actions/action/pull/13)) 118 | - Add architecture alias for x86-64: x64 ([#58](https://github.com/cross-platform-actions/action/issues/58)) 119 | 120 | ## [0.17.0] - 2023-07-25 121 | ### Changed 122 | - Bump QEMU to 8.0.3 ([resources#3](https://github.com/cross-platform-actions/resources/pull/4)) 123 | 124 | ## [0.16.0] - 2023-07-21 125 | ### Added 126 | - Add support for FreeBSD ARM64 ([#55](https://github.com/cross-platform-actions/action/issues/55)) 127 | 128 | ## [0.15.0] - 2023-06-12 129 | ### Changed 130 | - Bump QEMU to 8.0.2 ([resources#3](https://github.com/cross-platform-actions/resources/pull/3)) 131 | 132 | ## [0.14.0] - 2023-04-31 133 | ### Added 134 | - Add support for NetBSD 9.3 ([#53](https://github.com/cross-platform-actions/action/issues/53)) 135 | 136 | ## [0.13.0] - 2023-04-28 137 | ### Added 138 | - Add support for FreeBSD 13.2 ([freebsd-builder#3](https://github.com/cross-platform-actions/freebsd-builder/pull/3)) 139 | 140 | ## [0.12.0] - 2023-04-15 141 | ### Added 142 | - Add support for OpenBSD 7.3 143 | 144 | ## [0.11.0] - 2023-04-03 145 | ### Added 146 | - Add support for selecting hypervisor ([#50](https://github.com/cross-platform-actions/action/issues/50)) 147 | - Add support for NetBSD on macOS runners ([#28](https://github.com/cross-platform-actions/action/issues/28)) 148 | - Support for configuring memory ([#16](https://github.com/cross-platform-actions/action/issues/16)) 149 | - Support for configuring CPU core count ([#16](https://github.com/cross-platform-actions/action/issues/17)) 150 | 151 | ### Changed 152 | - Use output groups to hide all output except the run command 153 | (No output is removed, just hidden by default) ([#49](https://github.com/cross-platform-actions/action/issues/49)) 154 | - Remove support for IPv6 for NetBSD ([#46](https://github.com/cross-platform-actions/action/issues/46)) 155 | - Increased default memory to 13GB on macOS runner and to 6GB on Linux runners ([#16](https://github.com/cross-platform-actions/action/issues/16)) 156 | - Increased default CPU core count to 3 on macOS runner and to 2 on Linux runners ([#16](https://github.com/cross-platform-actions/action/issues/17)) 157 | - Changed from two CPU sockets to one CPU socket ([#16](https://github.com/cross-platform-actions/action/issues/17)) 158 | 159 | ### Fixed 160 | - NetBSD - very slow network ([#46](https://github.com/cross-platform-actions/action/issues/46)) 161 | - Action doesn't terminate when command fails ([#21](https://github.com/cross-platform-actions/action/issues/21)) 162 | 163 | ## [0.10.0] - 2023-01-24 164 | ### Added 165 | - Bundle all X11 sets for NetBSD ([netbsd-builder#3](https://github.com/cross-platform-actions/netbsd-builder/issues/3)) 166 | 167 | ## [0.9.0] - 2023-01-16 168 | ### Added 169 | - Add support for FreeBSD 13.1 170 | - Add support for FreeBSD 12.4 171 | 172 | ## [0.8.0] - 2023-01-13 173 | ### Added 174 | - Add support for OpenBSD 7.2 ([openbsd-builder#13](https://github.com/cross-platform-actions/openbsd-builder/issues/13)) 175 | 176 | ### Changed 177 | - Bump QEMU to 7.2 178 | 179 | ## [0.7.0] - 2022-12-25 180 | ### Added 181 | - Add support for OpenBSD ARM64 182 | - Add support for running on macOS 12 hosts 183 | 184 | ### Changed 185 | - Run action using Node 16. This fixes a deprecation message 186 | - Strip resource binaries to reduce space 187 | 188 | ### Fixed 189 | - Error in /home/runner/.ssh/config ([#14](https://github.com/cross-platform-actions/action/issues/14)) 190 | 191 | ## [0.6.2] - 2022-07-06 192 | ### Fixed 193 | - v0.6.1 only works if debug mode is enabled ([#12](https://github.com/cross-platform-actions/action/issues/12)) 194 | 195 | ## [0.6.1] - 2022-07-03 196 | ### Changed 197 | - Only print files synced in debug mode ([#11](https://github.com/cross-platform-actions/action/issues/11)) 198 | 199 | ## [0.6.0] - 2022-06-14 200 | ### Added 201 | - Add support for OpenBSD 7.1 ([openbsd-builder#9](https://github.com/cross-platform-actions/openbsd-builder/pull/9)) 202 | 203 | ## [0.5.0] - 2022-05-31 204 | ### Added 205 | - Add support for running OpenBSD on Linux ([#9](https://github.com/cross-platform-actions/action/issues/9)) 206 | 207 | ## [0.4.0] - 2022-05-10 208 | ### Added 209 | - Add support for running FreeBSD on Linux ([#8](https://github.com/cross-platform-actions/action/issues/8)) 210 | 211 | ## [0.3.1] - 2021-12-06 212 | ### Fixed 213 | - Missing QEMU dependency glib ([#5](https://github.com/cross-platform-actions/action/issues/5)) 214 | 215 | ## [0.3.0] - 2021-11-13 216 | ### Added 217 | - Added support for NetBSD ([#1](https://github.com/cross-platform-actions/action/issues/1)) 218 | 219 | ## [0.2.0] - 2021-09-04 220 | ### Added 221 | - Added support for FreeBSD 13.0 222 | - Added support for OpenBSD 6.9 223 | 224 | ## [0.0.2] - 2021-06-22 225 | ### Added 226 | - Added branding to the GitHub action in the marketplace 227 | 228 | ## [0.0.1] - 2021-06-02 229 | ### Added 230 | - Initial release 231 | 232 | [Unreleased]: https://github.com/cross-platform-actions/action/compare/v0.28.0...HEAD 233 | 234 | [0.28.0]: https://github.com/cross-platform-actions/action/compare/v0.27.0...v0.28.0 235 | [0.27.0]: https://github.com/cross-platform-actions/action/compare/v0.26.0...v0.27.0 236 | [0.26.0]: https://github.com/cross-platform-actions/action/compare/v0.25.0...v0.26.0 237 | [0.25.0]: https://github.com/cross-platform-actions/action/compare/v0.24.0...v0.25.0 238 | [0.24.0]: https://github.com/cross-platform-actions/action/compare/v0.23.0...v0.24.0 239 | [0.23.0]: https://github.com/cross-platform-actions/action/compare/v0.22.0...v0.23.0 240 | [0.22.0]: https://github.com/cross-platform-actions/action/compare/v0.21.1...v0.22.0 241 | [0.21.1]: https://github.com/cross-platform-actions/action/compare/v0.21.0...v0.21.1 242 | [0.21.0]: https://github.com/cross-platform-actions/action/compare/v0.20.0...v0.21.0 243 | [0.20.0]: https://github.com/cross-platform-actions/action/compare/v0.19.1...v0.20.0 244 | [0.19.1]: https://github.com/cross-platform-actions/action/compare/v0.19.0...v0.19.1 245 | [0.19.0]: https://github.com/cross-platform-actions/action/compare/v0.18.0...v0.19.0 246 | [0.18.0]: https://github.com/cross-platform-actions/action/compare/v0.17.0...v0.18.0 247 | [0.17.0]: https://github.com/cross-platform-actions/action/compare/v0.16.0...v0.17.0 248 | [0.16.0]: https://github.com/cross-platform-actions/action/compare/v0.15.0...v0.16.0 249 | [0.15.0]: https://github.com/cross-platform-actions/action/compare/v0.14.0...v0.15.0 250 | [0.14.0]: https://github.com/cross-platform-actions/action/compare/v0.13.0...v0.14.0 251 | [0.13.0]: https://github.com/cross-platform-actions/action/compare/v0.12.0...v0.13.0 252 | [0.12.0]: https://github.com/cross-platform-actions/action/compare/v0.11.0...v0.12.0 253 | [0.11.0]: https://github.com/cross-platform-actions/action/compare/v0.10.0...v0.11.0 254 | [0.10.0]: https://github.com/cross-platform-actions/action/compare/v0.9.0...v0.10.0 255 | [0.9.0]: https://github.com/cross-platform-actions/action/compare/v0.8.0...v0.9.0 256 | [0.8.0]: https://github.com/cross-platform-actions/action/compare/v0.7.0...v0.8.0 257 | [0.7.0]: https://github.com/cross-platform-actions/action/compare/v0.6.0...v0.7.0 258 | [0.6.2]: https://github.com/cross-platform-actions/action/compare/v0.6.1...v0.6.2 259 | [0.6.1]: https://github.com/cross-platform-actions/action/compare/v0.6.0...v0.6.1 260 | [0.6.0]: https://github.com/cross-platform-actions/action/compare/v0.5.0...v0.6.0 261 | [0.5.0]: https://github.com/cross-platform-actions/action/compare/v0.4.0...v0.5.0 262 | [0.4.0]: https://github.com/cross-platform-actions/action/compare/v0.3.1...v0.4.0 263 | [0.3.1]: https://github.com/cross-platform-actions/action/compare/v0.3.0...v0.3.1 264 | [0.3.0]: https://github.com/cross-platform-actions/action/compare/v0.2.0...v0.3.0 265 | [0.2.0]: https://github.com/cross-platform-actions/action/compare/v0.0.2...v0.2.0 266 | [0.0.2]: https://github.com/cross-platform-actions/action/compare/v0.0.1...v0.0.2 267 | [0.0.1]: https://github.com/cross-platform-actions/action/releases/tag/v0.0.1 268 | -------------------------------------------------------------------------------- /dist/licenses.txt: -------------------------------------------------------------------------------- 1 | @actions/core 2 | MIT 3 | The MIT License (MIT) 4 | 5 | Copyright 2019 GitHub 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 8 | 9 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 10 | 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 12 | 13 | @actions/exec 14 | MIT 15 | 16 | @actions/http-client 17 | MIT 18 | Actions Http Client for Node.js 19 | 20 | Copyright (c) GitHub, Inc. 21 | 22 | All rights reserved. 23 | 24 | MIT License 25 | 26 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and 27 | associated documentation files (the "Software"), to deal in the Software without restriction, 28 | including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, 29 | and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, 30 | subject to the following conditions: 31 | 32 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 33 | 34 | THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT 35 | LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN 36 | NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 37 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 38 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 39 | 40 | 41 | @actions/io 42 | MIT 43 | 44 | @actions/tool-cache 45 | MIT 46 | The MIT License (MIT) 47 | 48 | Copyright 2019 GitHub 49 | 50 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 51 | 52 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 53 | 54 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 55 | 56 | array.prototype.flatmap 57 | MIT 58 | MIT License 59 | 60 | Copyright (c) 2017 ECMAScript Shims 61 | 62 | Permission is hereby granted, free of charge, to any person obtaining a copy 63 | of this software and associated documentation files (the "Software"), to deal 64 | in the Software without restriction, including without limitation the rights 65 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 66 | copies of the Software, and to permit persons to whom the Software is 67 | furnished to do so, subject to the following conditions: 68 | 69 | The above copyright notice and this permission notice shall be included in all 70 | copies or substantial portions of the Software. 71 | 72 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 73 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 74 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 75 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 76 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 77 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 78 | SOFTWARE. 79 | 80 | 81 | call-bind 82 | MIT 83 | MIT License 84 | 85 | Copyright (c) 2020 Jordan Harband 86 | 87 | Permission is hereby granted, free of charge, to any person obtaining a copy 88 | of this software and associated documentation files (the "Software"), to deal 89 | in the Software without restriction, including without limitation the rights 90 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 91 | copies of the Software, and to permit persons to whom the Software is 92 | furnished to do so, subject to the following conditions: 93 | 94 | The above copyright notice and this permission notice shall be included in all 95 | copies or substantial portions of the Software. 96 | 97 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 98 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 99 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 100 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 101 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 102 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 103 | SOFTWARE. 104 | 105 | 106 | define-properties 107 | MIT 108 | The MIT License (MIT) 109 | 110 | Copyright (C) 2015 Jordan Harband 111 | 112 | Permission is hereby granted, free of charge, to any person obtaining a copy 113 | of this software and associated documentation files (the "Software"), to deal 114 | in the Software without restriction, including without limitation the rights 115 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 116 | copies of the Software, and to permit persons to whom the Software is 117 | furnished to do so, subject to the following conditions: 118 | 119 | The above copyright notice and this permission notice shall be included in 120 | all copies or substantial portions of the Software. 121 | 122 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 123 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 124 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 125 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 126 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 127 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 128 | THE SOFTWARE. 129 | 130 | es-abstract 131 | MIT 132 | The MIT License (MIT) 133 | 134 | Copyright (C) 2015 Jordan Harband 135 | 136 | Permission is hereby granted, free of charge, to any person obtaining a copy 137 | of this software and associated documentation files (the "Software"), to deal 138 | in the Software without restriction, including without limitation the rights 139 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 140 | copies of the Software, and to permit persons to whom the Software is 141 | furnished to do so, subject to the following conditions: 142 | 143 | The above copyright notice and this permission notice shall be included in 144 | all copies or substantial portions of the Software. 145 | 146 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 147 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 148 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 149 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 150 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 151 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 152 | THE SOFTWARE. 153 | 154 | 155 | es-to-primitive 156 | MIT 157 | The MIT License (MIT) 158 | 159 | Copyright (c) 2015 Jordan Harband 160 | 161 | Permission is hereby granted, free of charge, to any person obtaining a copy 162 | of this software and associated documentation files (the "Software"), to deal 163 | in the Software without restriction, including without limitation the rights 164 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 165 | copies of the Software, and to permit persons to whom the Software is 166 | furnished to do so, subject to the following conditions: 167 | 168 | The above copyright notice and this permission notice shall be included in all 169 | copies or substantial portions of the Software. 170 | 171 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 172 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 173 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 174 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 175 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 176 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 177 | SOFTWARE. 178 | 179 | 180 | 181 | function-bind 182 | MIT 183 | Copyright (c) 2013 Raynos. 184 | 185 | Permission is hereby granted, free of charge, to any person obtaining a copy 186 | of this software and associated documentation files (the "Software"), to deal 187 | in the Software without restriction, including without limitation the rights 188 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 189 | copies of the Software, and to permit persons to whom the Software is 190 | furnished to do so, subject to the following conditions: 191 | 192 | The above copyright notice and this permission notice shall be included in 193 | all copies or substantial portions of the Software. 194 | 195 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 196 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 197 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 198 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 199 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 200 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 201 | THE SOFTWARE. 202 | 203 | 204 | 205 | get-intrinsic 206 | MIT 207 | MIT License 208 | 209 | Copyright (c) 2020 Jordan Harband 210 | 211 | Permission is hereby granted, free of charge, to any person obtaining a copy 212 | of this software and associated documentation files (the "Software"), to deal 213 | in the Software without restriction, including without limitation the rights 214 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 215 | copies of the Software, and to permit persons to whom the Software is 216 | furnished to do so, subject to the following conditions: 217 | 218 | The above copyright notice and this permission notice shall be included in all 219 | copies or substantial portions of the Software. 220 | 221 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 222 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 223 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 224 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 225 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 226 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 227 | SOFTWARE. 228 | 229 | 230 | has 231 | MIT 232 | Copyright (c) 2013 Thiago de Arruda 233 | 234 | Permission is hereby granted, free of charge, to any person 235 | obtaining a copy of this software and associated documentation 236 | files (the "Software"), to deal in the Software without 237 | restriction, including without limitation the rights to use, 238 | copy, modify, merge, publish, distribute, sublicense, and/or sell 239 | copies of the Software, and to permit persons to whom the 240 | Software is furnished to do so, subject to the following 241 | conditions: 242 | 243 | The above copyright notice and this permission notice shall be 244 | included in all copies or substantial portions of the Software. 245 | 246 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 247 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 248 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 249 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 250 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 251 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 252 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 253 | OTHER DEALINGS IN THE SOFTWARE. 254 | 255 | 256 | has-property-descriptors 257 | MIT 258 | MIT License 259 | 260 | Copyright (c) 2022 Inspect JS 261 | 262 | Permission is hereby granted, free of charge, to any person obtaining a copy 263 | of this software and associated documentation files (the "Software"), to deal 264 | in the Software without restriction, including without limitation the rights 265 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 266 | copies of the Software, and to permit persons to whom the Software is 267 | furnished to do so, subject to the following conditions: 268 | 269 | The above copyright notice and this permission notice shall be included in all 270 | copies or substantial portions of the Software. 271 | 272 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 273 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 274 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 275 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 276 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 277 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 278 | SOFTWARE. 279 | 280 | 281 | has-symbols 282 | MIT 283 | MIT License 284 | 285 | Copyright (c) 2016 Jordan Harband 286 | 287 | Permission is hereby granted, free of charge, to any person obtaining a copy 288 | of this software and associated documentation files (the "Software"), to deal 289 | in the Software without restriction, including without limitation the rights 290 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 291 | copies of the Software, and to permit persons to whom the Software is 292 | furnished to do so, subject to the following conditions: 293 | 294 | The above copyright notice and this permission notice shall be included in all 295 | copies or substantial portions of the Software. 296 | 297 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 298 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 299 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 300 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 301 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 302 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 303 | SOFTWARE. 304 | 305 | 306 | has-tostringtag 307 | MIT 308 | MIT License 309 | 310 | Copyright (c) 2021 Inspect JS 311 | 312 | Permission is hereby granted, free of charge, to any person obtaining a copy 313 | of this software and associated documentation files (the "Software"), to deal 314 | in the Software without restriction, including without limitation the rights 315 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 316 | copies of the Software, and to permit persons to whom the Software is 317 | furnished to do so, subject to the following conditions: 318 | 319 | The above copyright notice and this permission notice shall be included in all 320 | copies or substantial portions of the Software. 321 | 322 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 323 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 324 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 325 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 326 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 327 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 328 | SOFTWARE. 329 | 330 | 331 | is-callable 332 | MIT 333 | The MIT License (MIT) 334 | 335 | Copyright (c) 2015 Jordan Harband 336 | 337 | Permission is hereby granted, free of charge, to any person obtaining a copy 338 | of this software and associated documentation files (the "Software"), to deal 339 | in the Software without restriction, including without limitation the rights 340 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 341 | copies of the Software, and to permit persons to whom the Software is 342 | furnished to do so, subject to the following conditions: 343 | 344 | The above copyright notice and this permission notice shall be included in all 345 | copies or substantial portions of the Software. 346 | 347 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 348 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 349 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 350 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 351 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 352 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 353 | SOFTWARE. 354 | 355 | 356 | 357 | is-date-object 358 | MIT 359 | The MIT License (MIT) 360 | 361 | Copyright (c) 2015 Jordan Harband 362 | 363 | Permission is hereby granted, free of charge, to any person obtaining a copy 364 | of this software and associated documentation files (the "Software"), to deal 365 | in the Software without restriction, including without limitation the rights 366 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 367 | copies of the Software, and to permit persons to whom the Software is 368 | furnished to do so, subject to the following conditions: 369 | 370 | The above copyright notice and this permission notice shall be included in all 371 | copies or substantial portions of the Software. 372 | 373 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 374 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 375 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 376 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 377 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 378 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 379 | SOFTWARE. 380 | 381 | 382 | 383 | is-regex 384 | MIT 385 | The MIT License (MIT) 386 | 387 | Copyright (c) 2014 Jordan Harband 388 | 389 | Permission is hereby granted, free of charge, to any person obtaining a copy of 390 | this software and associated documentation files (the "Software"), to deal in 391 | the Software without restriction, including without limitation the rights to 392 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 393 | the Software, and to permit persons to whom the Software is furnished to do so, 394 | subject to the following conditions: 395 | 396 | The above copyright notice and this permission notice shall be included in all 397 | copies or substantial portions of the Software. 398 | 399 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 400 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 401 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 402 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 403 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 404 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 405 | 406 | 407 | is-symbol 408 | MIT 409 | The MIT License (MIT) 410 | 411 | Copyright (c) 2015 Jordan Harband 412 | 413 | Permission is hereby granted, free of charge, to any person obtaining a copy 414 | of this software and associated documentation files (the "Software"), to deal 415 | in the Software without restriction, including without limitation the rights 416 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 417 | copies of the Software, and to permit persons to whom the Software is 418 | furnished to do so, subject to the following conditions: 419 | 420 | The above copyright notice and this permission notice shall be included in all 421 | copies or substantial portions of the Software. 422 | 423 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 424 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 425 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 426 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 427 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 428 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 429 | SOFTWARE. 430 | 431 | 432 | 433 | object-inspect 434 | MIT 435 | MIT License 436 | 437 | Copyright (c) 2013 James Halliday 438 | 439 | Permission is hereby granted, free of charge, to any person obtaining a copy 440 | of this software and associated documentation files (the "Software"), to deal 441 | in the Software without restriction, including without limitation the rights 442 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 443 | copies of the Software, and to permit persons to whom the Software is 444 | furnished to do so, subject to the following conditions: 445 | 446 | The above copyright notice and this permission notice shall be included in all 447 | copies or substantial portions of the Software. 448 | 449 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 450 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 451 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 452 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 453 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 454 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 455 | SOFTWARE. 456 | 457 | 458 | object-keys 459 | MIT 460 | The MIT License (MIT) 461 | 462 | Copyright (C) 2013 Jordan Harband 463 | 464 | Permission is hereby granted, free of charge, to any person obtaining a copy 465 | of this software and associated documentation files (the "Software"), to deal 466 | in the Software without restriction, including without limitation the rights 467 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 468 | copies of the Software, and to permit persons to whom the Software is 469 | furnished to do so, subject to the following conditions: 470 | 471 | The above copyright notice and this permission notice shall be included in 472 | all copies or substantial portions of the Software. 473 | 474 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 475 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 476 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 477 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 478 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 479 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 480 | THE SOFTWARE. 481 | 482 | semver 483 | ISC 484 | The ISC License 485 | 486 | Copyright (c) Isaac Z. Schlueter and Contributors 487 | 488 | Permission to use, copy, modify, and/or distribute this software for any 489 | purpose with or without fee is hereby granted, provided that the above 490 | copyright notice and this permission notice appear in all copies. 491 | 492 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 493 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 494 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 495 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 496 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 497 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR 498 | IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 499 | 500 | 501 | tunnel 502 | MIT 503 | The MIT License (MIT) 504 | 505 | Copyright (c) 2012 Koichi Kobayashi 506 | 507 | Permission is hereby granted, free of charge, to any person obtaining a copy 508 | of this software and associated documentation files (the "Software"), to deal 509 | in the Software without restriction, including without limitation the rights 510 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 511 | copies of the Software, and to permit persons to whom the Software is 512 | furnished to do so, subject to the following conditions: 513 | 514 | The above copyright notice and this permission notice shall be included in 515 | all copies or substantial portions of the Software. 516 | 517 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 518 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 519 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 520 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 521 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 522 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 523 | THE SOFTWARE. 524 | 525 | 526 | uuid 527 | MIT 528 | The MIT License (MIT) 529 | 530 | Copyright (c) 2010-2020 Robert Kieffer and other contributors 531 | 532 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 533 | 534 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 535 | 536 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 537 | -------------------------------------------------------------------------------- /dist/post.mjs: -------------------------------------------------------------------------------- 1 | import {existsSync} from 'fs' 2 | import {spawnSync} from 'child_process' 3 | 4 | if (existsSync('/tmp/cross-platform-actions.log')) 5 | spawnSync('sudo', ['cat', '/tmp/cross-platform-actions.log'], {stdio: 'inherit'}) 6 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | clearMocks: true, 3 | moduleFileExtensions: ['js', 'ts'], 4 | testEnvironment: 'node', 5 | testMatch: ['**/*.test.ts'], 6 | testRunner: 'jest-circus/runner', 7 | transform: { 8 | '^.+\\.ts$': 'ts-jest' 9 | }, 10 | verbose: true 11 | } -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2021 Jacob Carlborg 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cross-platform-action", 3 | "version": "0.0.1", 4 | "private": true, 5 | "description": "Cross platform GitHub action", 6 | "main": "lib/main.js", 7 | "scripts": { 8 | "build": "tsc", 9 | "format": "prettier --write '**/*.ts'", 10 | "format-check": "prettier --check '**/*.ts'", 11 | "lint": "eslint src/**/*.ts", 12 | "package": "npm run package:main && npm run package:post", 13 | "package:main": "ncc build --source-map --license licenses.txt", 14 | "package:post": "cp post/main.mjs dist/post.mjs", 15 | "test": "./bin/node ./node_modules/jasmine/bin/jasmine.js", 16 | "all": "npm run build && npm run format && npm run lint && npm run package && npm test" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "git+https://github.com/cross-platform-actions/action.git" 21 | }, 22 | "keywords": [ 23 | "actions", 24 | "cross", 25 | "platfrom", 26 | "cross platfrom", 27 | "cross-platform" 28 | ], 29 | "author": "", 30 | "license": "MIT", 31 | "dependencies": { 32 | "@actions/core": "^1.2.6", 33 | "@actions/exec": "^1.0.4", 34 | "@actions/tool-cache": "^1.6.0", 35 | "array.prototype.flatmap": "^1.2.4" 36 | }, 37 | "devDependencies": { 38 | "@types/array.prototype.flatmap": "^1.2.2", 39 | "@types/jasmine": "^4.3.1", 40 | "@types/node": "^14.14.9", 41 | "@typescript-eslint/eslint-plugin": "5.37.0", 42 | "@typescript-eslint/parser": "^5.37.0", 43 | "@vercel/ncc": "^0.36.1", 44 | "eslint": "^8.23.1", 45 | "eslint-plugin-github": "^4.3.7", 46 | "eslint-plugin-jasmine": "^4.1.3", 47 | "http-server": "^14.1.0", 48 | "jasmine": "^4.5.0", 49 | "jasmine-ts": "^0.4.0", 50 | "js-yaml": "^3.14.0", 51 | "prettier": "2.4.1", 52 | "typescript": "^4.4.3" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /post/main.mjs: -------------------------------------------------------------------------------- 1 | import {existsSync} from 'fs' 2 | import {spawnSync} from 'child_process' 3 | 4 | if (existsSync('/tmp/cross-platform-actions.log')) 5 | spawnSync('sudo', ['cat', '/tmp/cross-platform-actions.log'], {stdio: 'inherit'}) 6 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Cross-Platform GitHub Action 2 | 3 | This project provides a GitHub action for running GitHub Actions workflows on 4 | multiple platforms, including platforms that GitHub Actions doesn't currently natively support. 5 | 6 | ## `Features` 7 | 8 | Some of the features that this action supports include: 9 | 10 | - Multiple operating systems with one single action 11 | - Multiple versions of each operating system 12 | - Non-x86_64 architectures 13 | - Allows to use default shell or Bash shell 14 | - Low boot overhead 15 | - Fast execution 16 | - Using the action in multiple steps in the same job 17 | 18 | ## `Usage` 19 | 20 | ### Minimal Example 21 | 22 | Here's a sample workflow file which will run the given commands on FreeBSD 14.0. 23 | 24 | ```yaml 25 | name: CI 26 | 27 | on: [push] 28 | 29 | jobs: 30 | test: 31 | runs-on: ubuntu-latest 32 | steps: 33 | - uses: actions/checkout@v4 34 | 35 | - name: Test 36 | uses: cross-platform-actions/action@v0.26.0 37 | with: 38 | operating_system: freebsd 39 | version: '14.2' 40 | run: | 41 | uname -a 42 | echo $SHELL 43 | pwd 44 | ls -lah 45 | whoami 46 | env | sort 47 | ``` 48 | 49 | ### Full Example 50 | 51 | Here's a sample workflow file which will set up a matrix resulting in four 52 | jobs. One which will run on FreeBSD 14.0, one which runs OpenBSD 7.7, one which 53 | runs NetBSD 10.0, one which runs OpenBSD 7.7 on ARM64, one which runs NetBSD 54 | 10.1 on ARM64 and one which runs Haiku R1/beta5 on x86-64. 55 | 56 | ```yaml 57 | name: CI 58 | 59 | on: [push] 60 | 61 | jobs: 62 | test: 63 | runs-on: ubuntu-latest 64 | strategy: 65 | matrix: 66 | os: 67 | - name: freebsd 68 | architecture: x86-64 69 | version: '14.2' 70 | 71 | - name: openbsd 72 | architecture: x86-64 73 | version: '7.7' 74 | 75 | - name: openbsd 76 | architecture: arm64 77 | version: '7.7' 78 | 79 | - name: netbsd 80 | architecture: x86-64 81 | version: '10.1' 82 | 83 | - name: netbsd 84 | architecture: arm64 85 | version: '10.1' 86 | 87 | - name: haiku 88 | architecture: x86-64 89 | version: 'r1beta5' 90 | 91 | steps: 92 | - uses: actions/checkout@v4 93 | 94 | - name: Test on ${{ matrix.os.name }} 95 | uses: cross-platform-actions/action@v0.27.0 96 | env: 97 | MY_ENV1: MY_ENV1 98 | MY_ENV2: MY_ENV2 99 | with: 100 | environment_variables: MY_ENV1 MY_ENV2 101 | operating_system: ${{ matrix.os.name }} 102 | architecture: ${{ matrix.os.architecture }} 103 | version: ${{ matrix.os.version }} 104 | shell: bash 105 | memory: 5G 106 | cpu_count: 4 107 | run: | 108 | uname -a 109 | echo $SHELL 110 | pwd 111 | ls -lah 112 | whoami 113 | env | sort 114 | ``` 115 | 116 | Different platforms need to run on different runners, so see the 117 | [Runners](#runners) section below. 118 | 119 | ### Inputs 120 | 121 | This section lists the available inputs for the action. 122 | 123 | | Input | Required | Default Value | Type | Description | 124 | |-------------------------|----------|-------------------|---------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| 125 | | `run` | ✅ | ❌ | string | Runs command-line programs using the operating system's shell. This will be executed inside the virtual machine. | 126 | | `operating_system` | ✅ | ❌ | string | The type of operating system to run the job on. See [Supported Platforms](#supported-platforms). | 127 | | `architecture` | ❌ | `x86-64` | string | The architecture of the operating system. See [Supported Platforms](#supported-platforms). | 128 | | `version` | ✅ | ❌ | string | The version of the operating system to use. See [Supported Platforms](#supported-platforms). | 129 | | `shell` | ❌ | `default` | string | The shell to use to execute the commands. Defaults to the default shell for the given operating system. Allowed values are: `default`, `sh` and `bash` | 130 | | `environment_variables` | ❌ | `""` | string | A list of environment variables to forward to the virtual machine. The list should be separated with spaces. The `CI` and any environment variables starting with `GITHUB_` are forwarded automatically. | 131 | | `memory` | ❌ | `6G` | string | The amount of memory for the virtual machine. | 132 | | `cpu_count` | ❌ | `2` | integer | The number of CPU cores for the virtual machine. | 133 | | `image_url` | ❌ | ❌ | string | URL a custom VM image that should be used in place of the default ones. | 134 | | `sync_files` | ❌ | `true` | string | Specifies if the local files should be synchronized to the virtual machine and in which direction. Valid values are `true`, `false`, `runner-to-vm` and `vm-to-runner`. `true` synchronizes files in both directions. `false` disables file synchronization. | 135 | | `shutdown_vm` | ❌ | `true` | boolean | Specifies if the VM should be shutdown after the action has been run. | 136 | 137 | All inputs are expected to be of the specified type. It's especially important 138 | that you specify `version` as a string, using single or 139 | double quotes. Otherwise YAML might interpet the value as a numeric value 140 | instead of a string, which leads to some unexpected behavior. If the 141 | version is specified as `version: 13.0`, YAML will interpet `13.0` as a 142 | floating point number, drop the fraction part (because `13` and `13.0` are the 143 | same) and the GitHub action will only see `13` instead of `13.0`. The solution 144 | is to explicitly state that a string is required by using quotes: `version: 145 | '13.0'`. 146 | 147 | #### Custom VM Image (`image_url`) 148 | 149 | With the `image_url` input it's possible to specify a custom virtual machine 150 | image. The main reason for this feature is to do additional custom 151 | provisioning, like installing additional packages. This allows to pre-install 152 | everything that is needed for a CI job beforhand, which can save time later 153 | when the job is run. 154 | 155 | Only existing operating systems, architectures and versions are supported. 156 | 157 | ##### Building a Custom VM Image 158 | 159 | 1. Fork one of the existing [*builder repositories ](https://github.com/cross-platform-actions/?q=builder) 160 | 1. Add the additional provisioning to the `resources/custom.sh` script. Don't 161 | remove any existing provisioning scripts. 162 | 1. Adjust the CI workflow to remove any unwanted architectures or versions 163 | 1. Create and push a new tag 164 | 1. This will launch the CI workflow, build the image(s) and create a draft 165 | GitHub release. The VM image(s) are automatically attached to the release 166 | 1. Edit the release to publish it 167 | 1. Copy the URL for the VM image 168 | 1. Use the URL with the `image_url` input 169 | 170 | ## `Supported Platforms` 171 | 172 | This sections lists the currently supported platforms by operating system. Each 173 | operating system will list which versions are supported. 174 | 175 | ### [OpenBSD][openbsd_builder] (`openbsd`) 176 | 177 | | Version | x86-64 | arm64 | 178 | | ------- | ------ | ------ | 179 | | 7.7 | ✅ | ✅ | 180 | | 7.6 | ✅ | ✅ | 181 | | 7.5 | ✅ | ✅ | 182 | | 7.4 | ✅ | ✅ | 183 | | 7.3 | ✅ | ✅ | 184 | | 7.2 | ✅ | ✅ | 185 | | 7.1 | ✅ | ✅ | 186 | | 6.9 | ✅ | ✅ | 187 | | 6.8 | ✅ | ❌ | 188 | 189 | ### [FreeBSD][freebsd_builder] (`freebsd`) 190 | 191 | | Version | x86-64 | arm64 | 192 | | ------- | ------ | ------ | 193 | | 14.2 | ✅ | ✅ | 194 | | 14.1 | ✅ | ✅ | 195 | | 14.0 | ✅ | ✅ | 196 | | 13.5 | ✅ | ✅ | 197 | | 13.4 | ✅ | ✅ | 198 | | 13.3 | ✅ | ✅ | 199 | | 13.2 | ✅ | ✅ | 200 | | 13.1 | ✅ | ✅ | 201 | | 13.0 | ✅ | ✅ | 202 | | 12.4 | ✅ | ✅ | 203 | | 12.2 | ✅ | ❌ | 204 | 205 | ### [NetBSD][netbsd_builder] (`netbsd`) 206 | 207 | | Version | x86-64 | arm64 | 208 | |---------|--------|-------| 209 | | 10.1 | ✅ | ✅ | 210 | | 10.0 | ✅ | ✅ | 211 | | 9.4 | ✅ | ❌ | 212 | | 9.3 | ✅ | ❌ | 213 | | 9.2 | ✅ | ❌ | 214 | 215 | ### [Haiku][haiku_builder] (`haiku`) 216 | 217 | Note, Haiku is a single user system. That means the user that runs the the job 218 | is the default (and only) user, `user`, instead of `runner`, as for the other 219 | operating systems. 220 | 221 | | Version | x86-64 | 222 | |---------|--------| 223 | | r1beta5 | ✅ | 224 | 225 | ### Architectures 226 | 227 | This section lists the supported architectures and any aliases. All the names 228 | are case insensitive. For a combination of supported architectures and 229 | operating systems, see the sections for each operating system above. 230 | 231 | | Architecture | Aliases | 232 | |--------------|-----------------| 233 | | `arm64` | `aarch64` | 234 | | `x86-64` | `x86_64`, `x64` | 235 | | | | 236 | 237 | ### Hypervisors 238 | 239 | This section lists the available hypervisors, which platforms they can run and 240 | which runners they can run on. 241 | 242 | | Hypervisor | Linux Runner | FreeBSD | OpenBSD | Other Platforms | 243 | |------------|--------------|---------|---------|-----------------| 244 | | `qemu` | ✅ | ✅ | ✅ | ✅ | 245 | 246 | ### Runners 247 | 248 | This section lists the different combinations of platforms and on which runners 249 | they can run. 250 | 251 | | Runner | OpenBSD | FreeBSD | NetBSD | ARM64 | 252 | | ----------------------------------------------| ------- | ------- | ------ | ----- | 253 | | **Linux** | ✅ | ✅ | ✅ | ✅ | 254 | 255 | ## `Linux on Non-x86 Architectures` 256 | 257 | There are currently no plans to add support for Linux. Instead it's very easy 258 | to support Linux on non-x86 architectures using the QEMU support in Docker with the 259 | [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) action: 260 | 261 | ```yaml 262 | - name: Set up QEMU 263 | uses: docker/setup-qemu-action@v3 264 | with: 265 | platforms: linux/riscv64 266 | 267 | - name: Run Command in Docker 268 | run: | 269 | docker run \ 270 | --rm \ 271 | -v $(pwd):/${{ github.workspace }} \ 272 | -w ${{ github.workspace }} \ 273 | --platform linux/riscv64 \ 274 | debian:unstable-slim \ 275 | 276 | ``` 277 | 278 | For those not familiar with Docker, here's an explanation of the above command: 279 | 280 | * `run` - Runs a Docker container 281 | * `--rm` - Removes the container after it exits 282 | * `-v` - Mounts a local directory into the container. In this case the current 283 | directory is mounted to the same path in the container 284 | * `-w` - Specifies the working directory inside the container 285 | * `--platform` - Specifies the platform/architecture 286 | * `debian:unstable-slim` - Specifies with image to create the container from. 287 | Basically the Linux distribution to use 288 | * `` - The command you want to run inside the container 289 | 290 | ## `Common Issues` 291 | 292 | ### FreeBSD Operating System Version Mismatch 293 | 294 | #### Issue 295 | 296 | When installing packages on FreeBSD you might see an error related to 297 | mismatching of operating system or kernel version. This occurs because FreeBSD 298 | only supports one minor version of the previous major version. Therefore 299 | FreeBSD only has one package repository for each **major** version, not each 300 | **minor** version. When a new minor version is released, all packages in the 301 | repository are rebuilt targeting this new minor version. If you're on an older 302 | minor version of the operating system the package manager will give you an 303 | error. 304 | 305 | For more information, see: https://www.freebsd.org/security/#sup and 306 | https://www.freebsd.org/releases. 307 | 308 | #### Solution 309 | 310 | ##### Alternative 1 311 | 312 | The best solution is to upgrade to the latest supported minor version. 313 | 314 | ##### Alternative 2 315 | 316 | If Alternative 1 is not possible, you can ignore the operating system version 317 | mismatch by setting the `IGNORE_OSVERSION` environment variable with the value 318 | `yes`. Ignoring the operating system version mismatch can lead to runtime 319 | issues if the package depends on features or libraries only present in the 320 | newer operating system version. Example: 321 | 322 | ``` 323 | env IGNORE_OSVERSION=yes pkg install 324 | ``` 325 | 326 | Where `` is the name of the package to install. 327 | 328 | ## `Under the Hood` 329 | 330 | GitHub Actions currently only support macOS, Linux, and Windows. To be able to 331 | run other platforms, this GitHub action runs the commands inside a virtual 332 | machine (VM). If the host platform is macOS or Linux the hypervisor can take 333 | advantage of nested virtualization. 334 | 335 | All platforms run on the [QEMU][qemu] hypervisor. QEMU is a general purpose 336 | hypervisor and emulator that runs on most host platforms and supports most 337 | guest systems. 338 | 339 | The VM images running inside the hypervisor are built using [Packer][packer]. 340 | It's a tool for automatically creating VM images, installing the guest 341 | operating system and doing any final provisioning. 342 | 343 | The GitHub action uses SSH to communicate and execute commands inside the VM. 344 | It uses [rsync][rsync] to share files between the guest VM and the host. To 345 | authenticate the SSH connection a unique key pair is used. This pair is 346 | generated each time the action is run. The public key is added to the VM image 347 | and the host stores the private key. A secondary hard drive, which is backed by 348 | a file, is created. The public key is stored on this hard drive, which the VM 349 | then mounts. At boot time, the secondary hard drive will be identified and the 350 | public key will be copied to the appropriate location. 351 | 352 | To reduce the time it takes for the GitHub action to start executing the 353 | commands specified by the user, it aims to boot the guest operating systems as 354 | fast as possible. This is achieved in a couple of ways: 355 | 356 | - By downloading [resources][resources], like the hypervisor and a few other 357 | tools, instead of installing them through a package manager 358 | 359 | - The resources that are downloaded use no compression. The size is 360 | small enough anyway and it's faster to download the uncompressed data than 361 | it is to download compressed data and then uncompress it. 362 | 363 | - It leverages `async`/`await` to perform tasks asynchronously. Like 364 | downloading the VM image and other resources at the same time 365 | 366 | - It performs as much as possible of the setup ahead of time when the VM image 367 | is provisioned 368 | 369 | ## `Local Development` 370 | 371 | ### Prerequisites 372 | 373 | * [NodeJS](https://nodejs.org) 374 | * [npm](https://github.com/npm/cli) 375 | * [git](https://git-scm.com) 376 | 377 | ### Instructions 378 | 379 | 1. Install the above prerequisites 380 | 1. Clone the repository by running: 381 | 382 | ``` 383 | git clone https://github.com/cross-platform-actions/action 384 | ``` 385 | 386 | 1. Navigate to the newly cloned repository: `cd action` 387 | 1. Install the dependencies by running: `npm install` 388 | 1. Run any of the below npm commands 389 | 390 | ### npm Commands 391 | 392 | The following npm commands are available: 393 | 394 | * `build` - Build the GitHub action 395 | * `format` - Reformat the code 396 | * `lint` - Lint the code 397 | * `package` - Package the GitHub action for distribution and end to end testing 398 | * `test` - Run unit tests 399 | * `all` - Will run all of the above commands 400 | 401 | ### Running End to End Tests 402 | 403 | The end to end tests can be run locally by running it through [Act][act]. By 404 | default, resources and VM images will be downloaded from github.com. By running 405 | a local HTTP server it's possible to point the GitHub action to local resources. 406 | 407 | #### Prerequisites 408 | 409 | * [Docker](https://docker.com) 410 | * [Act][act] 411 | 412 | #### Instructions 413 | 414 | 1. Install the above prerequisites 415 | 1. Copy [`test/workflows/ci.yml.example`](test/workflows/ci.yml.example) to 416 | `test/workflows/ci.yml` 417 | 418 | 1. Make any changes you like to `test/workflows/ci.yml`, this is file ignored by 419 | Git 420 | 421 | 1. Build the GitHub action by running: `npm run build` 422 | 1. Package the GitHub action by running: `npm run package` 423 | 1. Run the GitHub action by running: `act --privileged -W test/workflows` 424 | 425 | #### Providing Resources Locally 426 | 427 | The GitHub action includes a development dependency on a HTTP server. The 428 | [`test/http`](test/http) directory contains a skeleton of a directory structure 429 | which matches the URLs that the GitHub action uses to download resources. All 430 | files within the [`test/http`](test/http) are ignore by Git. 431 | 432 | 1. Add resources as necessary to the [`test/http`](test/http) directory 433 | 1. In one shell, run the following command to start the HTTP server: 434 | 435 | ``` 436 | ./node_modules/http-server/bin/http-server test/http -a 127.0.0.1 437 | ``` 438 | 439 | The `-a` flag configures the HTTP server to only listen for incoming 440 | connections from localhost, no external computers will be able to connect. 441 | 442 | 1. In another shell, run the GitHub action by running: 443 | 444 | ``` 445 | act --privileged -W test/workflows --env CPA_RESOURCE_URL= 446 | ``` 447 | 448 | Where `` is the URL inside Docker that points to localhost of the host 449 | machine, for macOS, this is `http://host.docker.internal:8080`. By default, 450 | the HTTP server is listening on port `8080`. 451 | 452 | [qemu]: https://www.qemu.org 453 | [rsync]: https://en.wikipedia.org/wiki/Rsync 454 | [resources]: https://github.com/cross-platform-actions/resources 455 | [packer]: https://www.packer.io 456 | [openbsd_builder]: https://github.com/cross-platform-actions/openbsd-builder 457 | [freebsd_builder]: https://github.com/cross-platform-actions/freebsd-builder 458 | [netbsd_builder]: https://github.com/cross-platform-actions/netbsd-builder 459 | [haiku_builder]: https://github.com/cross-platform-actions/haiku-builder 460 | [act]: https://github.com/nektos/act 461 | -------------------------------------------------------------------------------- /spec/action/vm_file_system_synchronizer.spec.ts: -------------------------------------------------------------------------------- 1 | import {Input} from '../../src/action/input' 2 | import {SyncDirection} from '../../src/action/sync_direction' 3 | import {DefaultVmFileSystemSynchronizer} from '../../src/vm_file_system_synchronizer' 4 | import {Executor} from '../../src/utility' 5 | 6 | describe('DefaultVmFileSystemSynchronizer', () => { 7 | let synchronizer: DefaultVmFileSystemSynchronizer 8 | let executor: Executor 9 | 10 | beforeEach(() => { 11 | let input = jasmine.createSpyObj( 12 | 'Input', 13 | {}, 14 | {syncFiles: SyncDirection.both} 15 | ) 16 | 17 | executor = jasmine.createSpyObj('Executor', ['execute']) 18 | 19 | synchronizer = new DefaultVmFileSystemSynchronizer({ 20 | input, 21 | executor, 22 | user: 'user', 23 | workingDirectory: '/home/runner/runner/work', 24 | isDebug: false 25 | }) 26 | }) 27 | 28 | describe('synchronizePaths', () => { 29 | it('should synchronize all paths to the VM', async () => { 30 | await synchronizer.synchronizePaths('that_that_is_excluded') 31 | 32 | expect(executor.execute).toHaveBeenCalledWith('rsync', [ 33 | '-auz', 34 | '--exclude', 35 | '_actions/cross-platform-actions/action', 36 | '--exclude', 37 | 'that_that_is_excluded', 38 | '/home/runner/', 39 | 'user@cross_platform_actions_host:work' 40 | ]) 41 | }) 42 | }) 43 | 44 | describe('synchronizeBack', () => { 45 | it('should synchronize all paths to the host', async () => { 46 | await synchronizer.synchronizeBack() 47 | 48 | expect(executor.execute).toHaveBeenCalledWith('rsync', [ 49 | '-auz', 50 | 'user@cross_platform_actions_host:work/', 51 | '/home/runner' 52 | ]) 53 | }) 54 | }) 55 | }) 56 | -------------------------------------------------------------------------------- /spec/architecture.spec.ts: -------------------------------------------------------------------------------- 1 | import * as architecture from '../src/architecture' 2 | import {Architecture} from '../src/architecture' 3 | import {Host} from '../src/host' 4 | import * as os from '../src/operating_systems/kind' 5 | import {Qemu, QemuEfi, Xhyve} from '../src/hypervisor' 6 | 7 | let context = describe 8 | 9 | describe('Architecture', () => { 10 | describe('efiHypervisor', () => { 11 | context('x86_64', () => { 12 | let kind = architecture.Kind.x86_64 13 | 14 | context('macOS host', () => { 15 | let host = Host.create('darwin') 16 | 17 | context('QEMU hypervisor', () => { 18 | let selectedHypervisor = new Qemu() 19 | 20 | context('OpenBSD', () => { 21 | let osKind = os.Kind.for('openbsd') 22 | let arch = Architecture.for(kind, host, osKind, selectedHypervisor) 23 | 24 | it('returns the QEMU EFI hypervisor', () => { 25 | expect(arch.efiHypervisor).toBeInstanceOf(QemuEfi) 26 | }) 27 | }) 28 | }) 29 | 30 | context('Xhyve hypervisor', () => { 31 | let selectedHypervisor = new Xhyve() 32 | 33 | context('OpenBSD', () => { 34 | let osKind = os.Kind.for('openbsd') 35 | let arch = Architecture.for(kind, host, osKind, selectedHypervisor) 36 | 37 | it('returns the Xhyve hypervisor', () => { 38 | expect(arch.efiHypervisor).toBeInstanceOf(Xhyve) 39 | }) 40 | }) 41 | }) 42 | }) 43 | 44 | context('Linux host', () => { 45 | let host = Host.create('linux') 46 | 47 | context('QEMU hypervisor', () => { 48 | let selectedHypervisor = new Qemu() 49 | 50 | context('OpenBSD', () => { 51 | let osKind = os.Kind.for('openbsd') 52 | let arch = Architecture.for(kind, host, osKind, selectedHypervisor) 53 | 54 | it('returns the QEMU EFI hypervisor', () => { 55 | expect(arch.efiHypervisor).toBeInstanceOf(QemuEfi) 56 | }) 57 | }) 58 | }) 59 | }) 60 | }) 61 | }) 62 | 63 | describe('hypervisor', () => { 64 | context('x86_64', () => { 65 | let kind = architecture.Kind.x86_64 66 | 67 | context('macOS host', () => { 68 | let host = Host.create('darwin') 69 | 70 | context('QEMU hypervisor', () => { 71 | let selectedHypervisor = new Qemu() 72 | 73 | context('OpenBSD', () => { 74 | let osKind = os.Kind.for('openbsd') 75 | let arch = Architecture.for(kind, host, osKind, selectedHypervisor) 76 | 77 | it('returns the QEMU EFI hypervisor', () => { 78 | expect(arch.hypervisor).toBeInstanceOf(Qemu) 79 | }) 80 | }) 81 | }) 82 | 83 | context('Xhyve hypervisor', () => { 84 | let selectedHypervisor = new Xhyve() 85 | 86 | context('OpenBSD', () => { 87 | let osKind = os.Kind.for('openbsd') 88 | let arch = Architecture.for(kind, host, osKind, selectedHypervisor) 89 | 90 | it('returns the Xhyve hypervisor', () => { 91 | expect(arch.hypervisor).toBeInstanceOf(Xhyve) 92 | }) 93 | }) 94 | }) 95 | }) 96 | 97 | context('Linux host', () => { 98 | let host = Host.create('linux') 99 | 100 | context('QEMU hypervisor', () => { 101 | let selectedHypervisor = new Qemu() 102 | 103 | context('OpenBSD', () => { 104 | let osKind = os.Kind.for('openbsd') 105 | let arch = Architecture.for(kind, host, osKind, selectedHypervisor) 106 | 107 | it('returns the QEMU EFI hypervisor', () => { 108 | expect(arch.hypervisor).toBeInstanceOf(Qemu) 109 | }) 110 | }) 111 | }) 112 | }) 113 | }) 114 | }) 115 | }) 116 | 117 | describe('toKind', () => { 118 | describe('arm64', () => { 119 | it('returns the arm64 architecture', () => { 120 | expect(architecture.toKind('arm64')).toBe(architecture.Kind.arm64) 121 | }) 122 | }) 123 | 124 | describe('ARM64', () => { 125 | it('returns the arm64 architecture', () => { 126 | expect(architecture.toKind('ARM64')).toBe(architecture.Kind.arm64) 127 | }) 128 | }) 129 | 130 | describe('x86-64', () => { 131 | it('returns the x86_64 architecture', () => { 132 | expect(architecture.toKind('x86-64')).toBe(architecture.Kind.x86_64) 133 | }) 134 | }) 135 | 136 | describe('x86_64', () => { 137 | it('returns the x86_64 architecture', () => { 138 | expect(architecture.toKind('x86_64')).toBe(architecture.Kind.x86_64) 139 | }) 140 | }) 141 | 142 | describe('X64', () => { 143 | it('returns the x86_64 architecture', () => { 144 | expect(architecture.toKind('x64')).toBe(architecture.Kind.x86_64) 145 | }) 146 | }) 147 | 148 | describe('X86_64', () => { 149 | it('returns the x86_64 architecture', () => { 150 | expect(architecture.toKind('X86_64')).toBe(architecture.Kind.x86_64) 151 | }) 152 | }) 153 | 154 | describe('invalid architecture', () => { 155 | it('returns undefined', () => { 156 | expect(architecture.toKind('null')).toBeUndefined() 157 | }) 158 | }) 159 | }) 160 | -------------------------------------------------------------------------------- /spec/helpers/typescript.js: -------------------------------------------------------------------------------- 1 | const { register } = require('ts-node') 2 | 3 | register({ 4 | project: 'tsconfig.json' 5 | }) -------------------------------------------------------------------------------- /spec/operating_systems/freebsd/freebsd.spec.ts: -------------------------------------------------------------------------------- 1 | import {basename} from 'path' 2 | 3 | import FreeBsd from '../../../src/operating_systems/freebsd/freebsd' 4 | import hostModule from '../../../src/host' 5 | import * as arch from '../../../src/architecture' 6 | import * as os from '../../../src/operating_systems/kind' 7 | import * as hypervisor from '../../../src/hypervisor' 8 | import {Input} from '../../../src/action/input' 9 | import {Host} from '../../../src/host' 10 | 11 | describe('FreeBSD OperatingSystem', () => { 12 | let host = Host.create('linux') 13 | let osKind = os.Kind.for('freebsd') 14 | let vmm = host.hypervisor 15 | let architecture = arch.Architecture.for(arch.Kind.x86_64, host, osKind, vmm) 16 | let freebsd = new FreeBsd(architecture, '0.0.0') 17 | let hypervisorDirectory = 'hypervisor/directory' 18 | let resourcesDirectory = 'resources/directory' 19 | let firmwareDirectory = 'firmware/directory' 20 | let input = new Input() 21 | 22 | let config = { 23 | memory: '4G', 24 | cpuCount: 7, 25 | diskImage: '', 26 | resourcesDiskImage: '', 27 | userboot: '' 28 | } 29 | 30 | describe('createVirtualMachine', () => { 31 | let vmModule = jasmine.createSpy('vmModule') 32 | 33 | beforeEach(() => { 34 | spyOnProperty(hostModule, 'host').and.returnValue(host) 35 | }) 36 | 37 | it('creates a virtual machine with the correct configuration', () => { 38 | spyOn(vmm, 'resolve').and.returnValue(vmModule) 39 | 40 | freebsd.createVirtualMachine( 41 | hypervisorDirectory, 42 | resourcesDirectory, 43 | firmwareDirectory, 44 | input, 45 | config 46 | ) 47 | 48 | expect(vmModule).toHaveBeenCalledOnceWith( 49 | hypervisorDirectory, 50 | resourcesDirectory, 51 | architecture, 52 | input, 53 | { 54 | ...config, 55 | ssHostPort: 2847, 56 | cpu: 'max', 57 | machineType: 'q35', 58 | uuid: '864ED7F0-7876-4AA7-8511-816FABCFA87F', 59 | firmware: `${firmwareDirectory}/share/qemu/bios-256k.bin` 60 | } 61 | ) 62 | }) 63 | 64 | describe('when the given hypervisor is Xhyve', () => { 65 | it('creates a virtual machine using the Xhyve hypervisor', () => { 66 | let archObject = arch.Architecture.for( 67 | arch.Kind.x86_64, 68 | host, 69 | osKind, 70 | new hypervisor.Xhyve() 71 | ) 72 | let freebsd = new FreeBsd(archObject, '0.0.0') 73 | const vm = freebsd.createVirtualMachine( 74 | hypervisorDirectory, 75 | resourcesDirectory, 76 | firmwareDirectory, 77 | input, 78 | config 79 | ) 80 | 81 | const hypervisorBinary = basename(vm.hypervisorPath.toString()) 82 | expect(hypervisorBinary).toEqual('xhyve') 83 | }) 84 | }) 85 | 86 | describe('when the given hypervisor is Qemu', () => { 87 | it('creates a virtual machine using the Qemu hypervisor', () => { 88 | let archObject = arch.Architecture.for( 89 | arch.Kind.x86_64, 90 | host, 91 | osKind, 92 | new hypervisor.Qemu() 93 | ) 94 | let freebsd = new FreeBsd(archObject, '0.0.0') 95 | const vm = freebsd.createVirtualMachine( 96 | hypervisorDirectory, 97 | resourcesDirectory, 98 | firmwareDirectory, 99 | input, 100 | config 101 | ) 102 | 103 | const hypervisorBinary = basename(vm.hypervisorPath.toString()) 104 | expect(hypervisorBinary).toEqual('qemu') 105 | }) 106 | }) 107 | }) 108 | }) 109 | -------------------------------------------------------------------------------- /spec/operating_systems/freebsd/qemu_vm.spec.ts: -------------------------------------------------------------------------------- 1 | import {QemuVm} from '../../../src/operating_systems/freebsd/qemu_vm' 2 | import * as arch from '../../../src/architecture' 3 | import {host} from '../../../src/host' 4 | import * as os from '../../../src/operating_systems/kind' 5 | import '../../../src/operating_systems/freebsd/freebsd' 6 | import {Input} from '../../../src/action/input' 7 | 8 | describe('FreeBSD QemuVm', () => { 9 | let memory = '5G' 10 | let cpuCount = 10 11 | let ssHostPort = 1234 12 | 13 | let osKind = os.Kind.for('freebsd') 14 | let architecture = arch.Architecture.for( 15 | arch.Kind.x86_64, 16 | host, 17 | osKind, 18 | host.hypervisor 19 | ) 20 | let input = new Input() 21 | let config = { 22 | memory: memory, 23 | cpuCount: cpuCount, 24 | diskImage: '', 25 | ssHostPort: ssHostPort, 26 | cpu: '', 27 | machineType: '', 28 | uuid: '', 29 | resourcesDiskImage: '', 30 | userboot: '', 31 | firmware: '' 32 | } 33 | let vm = new QemuVm('', '', architecture, input, config) 34 | 35 | let getFlagValue = (flag: string) => vm.command[vm.command.indexOf(flag) + 1] 36 | let actualMemory = () => getFlagValue('-m') 37 | let actualSmp = () => getFlagValue('-smp') 38 | let actualNetDevice = () => getFlagValue('-netdev') 39 | 40 | describe('command', () => { 41 | it('constucts a command with the correct memory configuration', () => { 42 | expect(actualMemory()).toEqual(memory) 43 | }) 44 | 45 | it('constucts a command with the correct SMP configuration', () => { 46 | expect(actualSmp()).toEqual(cpuCount.toString()) 47 | }) 48 | 49 | it('constucts a command with the IPv6 disabled for the net device', () => { 50 | expect(actualNetDevice()).toEqual( 51 | `user,id=user.0,hostfwd=tcp::${ssHostPort}-:22` 52 | ) 53 | }) 54 | }) 55 | }) 56 | -------------------------------------------------------------------------------- /spec/operating_systems/haiku/haiku.spec.ts: -------------------------------------------------------------------------------- 1 | import Haiku from '../../../src/operating_systems/haiku/haiku' 2 | import * as hostModule from '../../../src/host' 3 | import * as arch from '../../../src/architecture' 4 | import * as os from '../../../src/operating_systems/kind' 5 | import HostQemu from '../../../src/host_qemu' 6 | import * as hypervisor from '../../../src/hypervisor' 7 | import * as qemu from '../../../src/qemu_vm' 8 | import * as xhyve from '../../../src/xhyve_vm' 9 | import * as haikuQemuVm from '../../../src/operating_systems/haiku/qemu_vm' 10 | import {Input} from '../../../src/action/input' 11 | 12 | describe('Haiku OperatingSystem', () => { 13 | class Host extends hostModule.Host { 14 | get vmModule(): typeof xhyve | typeof qemu { 15 | return qemu 16 | } 17 | 18 | override get qemu(): HostQemu { 19 | return new HostQemu.LinuxHostQemu() 20 | } 21 | 22 | override get hypervisor(): hypervisor.Hypervisor { 23 | return new hypervisor.Qemu() 24 | } 25 | 26 | override get efiHypervisor(): hypervisor.Hypervisor { 27 | return new hypervisor.QemuEfi() 28 | } 29 | 30 | override get defaultMemory(): string { 31 | return '6G' 32 | } 33 | 34 | override get defaultCpuCount(): number { 35 | return 6 36 | } 37 | 38 | override validateHypervisor(_kind: hypervisor.Kind): void {} 39 | } 40 | 41 | let host = new Host() 42 | let osKind = os.Kind.for('haiku') 43 | let architecture = arch.Architecture.for( 44 | arch.Kind.x86_64, 45 | host, 46 | osKind, 47 | host.hypervisor 48 | ) 49 | let haiku = new Haiku(architecture, '0.0.0') 50 | 51 | let hypervisorDirectory = 'hypervisor/directory' 52 | let resourcesDirectory = 'resources/directory' 53 | let firmwareDirectory = 'firmware/directory' 54 | let input = new Input() 55 | 56 | let config = { 57 | memory: '4G', 58 | cpuCount: 7, 59 | diskImage: '', 60 | resourcesDiskImage: '', 61 | userboot: '' 62 | } 63 | 64 | describe('createVirtualMachine', () => { 65 | it('creates a virtual machine with the correct configuration', () => { 66 | let qemuVmSpy = spyOn(haikuQemuVm, 'Vm') 67 | 68 | haiku.createVirtualMachine( 69 | hypervisorDirectory, 70 | resourcesDirectory, 71 | firmwareDirectory, 72 | input, 73 | config 74 | ) 75 | 76 | expect(qemuVmSpy).toHaveBeenCalledOnceWith( 77 | hypervisorDirectory, 78 | resourcesDirectory, 79 | architecture, 80 | input, 81 | { 82 | ...config, 83 | ssHostPort: 2847, 84 | cpu: 'max', 85 | machineType: 'q35', 86 | uuid: '864ED7F0-7876-4AA7-8511-816FABCFA87F', 87 | firmware: `${firmwareDirectory}/share/qemu/bios-256k.bin` 88 | } 89 | ) 90 | }) 91 | }) 92 | }) 93 | -------------------------------------------------------------------------------- /spec/operating_systems/haiku/qemu_vm.spec.ts: -------------------------------------------------------------------------------- 1 | import {Vm} from '../../../src/operating_systems/haiku/qemu_vm' 2 | import * as arch from '../../../src/architecture' 3 | import {host} from '../../../src/host' 4 | import * as os from '../../../src/operating_systems/kind' 5 | import '../../../src/operating_systems/haiku/haiku' 6 | import {Input} from '../../../src/action/input' 7 | import {Executor} from '../../../src/utility' 8 | 9 | describe('Haiku QemuVm', () => { 10 | let memory = '5G' 11 | let cpuCount = 10 12 | let ssHostPort = 1234 13 | 14 | let osKind = os.Kind.for('haiku') 15 | let architecture = arch.Architecture.for( 16 | arch.Kind.x86_64, 17 | host, 18 | osKind, 19 | host.hypervisor 20 | ) 21 | let input = new Input() 22 | let config = { 23 | memory: memory, 24 | cpuCount: cpuCount, 25 | diskImage: '', 26 | ssHostPort: ssHostPort, 27 | cpu: '', 28 | machineType: '', 29 | uuid: '', 30 | resourcesDiskImage: '', 31 | userboot: '', 32 | firmware: '' 33 | } 34 | let executor = jasmine.createSpyObj('executor', ['execute']) 35 | let vm = new Vm('', '', architecture, input, config, executor) 36 | 37 | let getFlagValue = (flag: string) => vm.command[vm.command.indexOf(flag) + 1] 38 | let actualMemory = () => getFlagValue('-m') 39 | let actualSmp = () => getFlagValue('-smp') 40 | let actualNetworkBackend = () => getFlagValue('-netdev') 41 | 42 | beforeEach(() => { 43 | executor = jasmine.createSpyObj('executor', ['execute']) 44 | vm = new Vm('', '', architecture, input, config, executor) 45 | }) 46 | 47 | describe('command', () => { 48 | it('constucts a command with the correct memory configuration', () => { 49 | expect(actualMemory()).toEqual(memory) 50 | }) 51 | 52 | it('constucts a command with the correct SMP configuration', () => { 53 | expect(actualSmp()).toEqual(cpuCount.toString()) 54 | }) 55 | 56 | it('constucts a command with the IPv6 disabled for the network backend', () => { 57 | expect(actualNetworkBackend()).toEqual( 58 | `user,id=user.0,hostfwd=tcp::${ssHostPort}-:22,ipv6=off` 59 | ) 60 | }) 61 | 62 | it('constucts a command with the network device set to "e1000"', () => { 63 | expect(vm.command).toContain('e1000,netdev=user.0') 64 | }) 65 | }) 66 | 67 | describe('execute', () => { 68 | let buffer = Buffer.from('foo') 69 | 70 | it('executes the given command', async () => { 71 | await vm.execute('foo') 72 | 73 | expect(executor.execute).toHaveBeenCalledOnceWith( 74 | 'ssh', 75 | ['-t', 'user@cross_platform_actions_host'], 76 | jasmine.objectContaining({input: buffer}) 77 | ) 78 | }) 79 | }) 80 | 81 | describe('execute2', () => { 82 | let buffer = Buffer.from('') 83 | 84 | it('executes the given command', async () => { 85 | await vm.execute2(['foo'], buffer) 86 | 87 | expect(executor.execute).toHaveBeenCalledOnceWith( 88 | 'ssh', 89 | ['-t', 'user@cross_platform_actions_host', 'foo'], 90 | jasmine.objectContaining({input: buffer}) 91 | ) 92 | }) 93 | }) 94 | 95 | describe('setupWorkDirectory', () => { 96 | it('sets up the working directory', async () => { 97 | let homeDirectory = '/home/runner/work' 98 | let workDirectory = '/home/runner/work/repo/repo' 99 | let buffer = Buffer.from( 100 | undent` 101 | mkdir -p '/home/runner/work/repo/repo' && \ 102 | ln -sf '/boot/home/' '/home/runner/work' 103 | ` 104 | ) 105 | 106 | await vm.setupWorkDirectory(homeDirectory, workDirectory) 107 | 108 | expect(executor.execute).toHaveBeenCalledWith( 109 | 'ssh', 110 | ['-t', 'user@cross_platform_actions_host'], 111 | jasmine.objectContaining({input: buffer}) 112 | ) 113 | }) 114 | }) 115 | }) 116 | 117 | function undent(strings: TemplateStringsArray): string { 118 | const fullString = strings.join('') 119 | const match = fullString.match(/^[ \t]*(?=\S)/gm) 120 | const minIndent = match ? Math.min(...match.map(x => x.length)) : 0 121 | 122 | return fullString 123 | .replace(new RegExp(`^[ \\t]{${minIndent}}`, 'gm'), '') 124 | .replace(/ {2,}/g, ' ') 125 | .trim() 126 | } 127 | -------------------------------------------------------------------------------- /spec/operating_systems/netbsd/netbsd.spec.ts: -------------------------------------------------------------------------------- 1 | import NetBsd from '../../../src/operating_systems/netbsd/netbsd' 2 | import * as hostModule from '../../../src/host' 3 | import * as arch from '../../../src/architecture' 4 | import * as os from '../../../src/operating_systems/kind' 5 | import HostQemu from '../../../src/host_qemu' 6 | import * as hypervisor from '../../../src/hypervisor' 7 | import * as qemu from '../../../src/qemu_vm' 8 | import * as xhyve from '../../../src/xhyve_vm' 9 | import * as netbsdQemuVm from '../../../src/operating_systems/netbsd/qemu_vm' 10 | import {Input} from '../../../src/action/input' 11 | 12 | describe('NetBSD OperatingSystem', () => { 13 | class Host extends hostModule.Host { 14 | get vmModule(): typeof xhyve | typeof qemu { 15 | return qemu 16 | } 17 | 18 | override get qemu(): HostQemu { 19 | return new HostQemu.LinuxHostQemu() 20 | } 21 | 22 | override get hypervisor(): hypervisor.Hypervisor { 23 | return new hypervisor.Qemu() 24 | } 25 | 26 | override get efiHypervisor(): hypervisor.Hypervisor { 27 | return new hypervisor.QemuEfi() 28 | } 29 | 30 | override get defaultMemory(): string { 31 | return '6G' 32 | } 33 | 34 | override get defaultCpuCount(): number { 35 | return 6 36 | } 37 | 38 | override validateHypervisor(_kind: hypervisor.Kind): void {} 39 | } 40 | 41 | let host = new Host() 42 | let osKind = os.Kind.for('netbsd') 43 | let architecture = arch.Architecture.for( 44 | arch.Kind.x86_64, 45 | host, 46 | osKind, 47 | host.hypervisor 48 | ) 49 | let netbsd = new NetBsd(architecture, '0.0.0') 50 | let hypervisorDirectory = 'hypervisor/directory' 51 | let resourcesDirectory = 'resources/directory' 52 | let firmwareDirectory = 'firmware/directory' 53 | let input = new Input() 54 | 55 | let config = { 56 | memory: '4G', 57 | cpuCount: 7, 58 | diskImage: '', 59 | resourcesDiskImage: '', 60 | userboot: '' 61 | } 62 | 63 | describe('createVirtualMachine', () => { 64 | it('creates a virtual machine with the correct configuration', () => { 65 | let qemuVmSpy = spyOn(netbsdQemuVm, 'Vm') 66 | 67 | netbsd.createVirtualMachine( 68 | hypervisorDirectory, 69 | resourcesDirectory, 70 | firmwareDirectory, 71 | input, 72 | config 73 | ) 74 | 75 | expect(qemuVmSpy).toHaveBeenCalledOnceWith( 76 | hypervisorDirectory, 77 | resourcesDirectory, 78 | architecture, 79 | input, 80 | { 81 | ...config, 82 | ssHostPort: 2847, 83 | cpu: 'max', 84 | machineType: 'q35', 85 | uuid: '864ED7F0-7876-4AA7-8511-816FABCFA87F', 86 | firmware: `${firmwareDirectory}/share/qemu/bios-256k.bin` 87 | } 88 | ) 89 | }) 90 | 91 | describe('when on a macOS host', () => { 92 | class Host extends hostModule.Host { 93 | get vmModule(): typeof xhyve | typeof qemu { 94 | return xhyve 95 | } 96 | 97 | override get qemu(): HostQemu { 98 | return new HostQemu.MacosHostQemu() 99 | } 100 | 101 | override get hypervisor(): hypervisor.Hypervisor { 102 | return new hypervisor.Xhyve() 103 | } 104 | 105 | override get efiHypervisor(): hypervisor.Hypervisor { 106 | return this.hypervisor 107 | } 108 | 109 | override get defaultMemory(): string { 110 | return '13G' 111 | } 112 | 113 | override get defaultCpuCount(): number { 114 | return 3 115 | } 116 | 117 | override validateHypervisor(_kind: hypervisor.Kind): void {} 118 | } 119 | 120 | let host = new Host() 121 | let osKind = os.Kind.for('netbsd') 122 | const vmm = new hypervisor.Qemu() 123 | let architecture = arch.Architecture.for( 124 | arch.Kind.x86_64, 125 | host, 126 | osKind, 127 | vmm 128 | ) 129 | let netbsd = new NetBsd(architecture, '0.0.0') 130 | 131 | it('creates a virtual machine configured with BIOS firmware', () => { 132 | let qemuVmSpy = spyOn(netbsdQemuVm, 'Vm') 133 | 134 | netbsd.createVirtualMachine( 135 | hypervisorDirectory, 136 | resourcesDirectory, 137 | firmwareDirectory, 138 | input, 139 | config 140 | ) 141 | 142 | expect(qemuVmSpy).toHaveBeenCalledOnceWith( 143 | hypervisorDirectory, 144 | resourcesDirectory, 145 | architecture, 146 | input, 147 | { 148 | ...config, 149 | ssHostPort: 2847, 150 | cpu: 'max', 151 | machineType: 'q35', 152 | uuid: '864ED7F0-7876-4AA7-8511-816FABCFA87F', 153 | firmware: `${firmwareDirectory}/share/qemu/bios-256k.bin` 154 | } 155 | ) 156 | }) 157 | }) 158 | }) 159 | }) 160 | -------------------------------------------------------------------------------- /spec/operating_systems/netbsd/qemu_vm.spec.ts: -------------------------------------------------------------------------------- 1 | import {Vm} from '../../../src/operating_systems/netbsd/qemu_vm' 2 | import * as arch from '../../../src/architecture' 3 | import {host} from '../../../src/host' 4 | import * as os from '../../../src/operating_systems/kind' 5 | import '../../../src/operating_systems/netbsd/netbsd' 6 | import {Input} from '../../../src/action/input' 7 | 8 | describe('NetBSD QemuVm', () => { 9 | let memory = '5G' 10 | let cpuCount = 10 11 | let ssHostPort = 1234 12 | 13 | let osKind = os.Kind.for('netbsd') 14 | let architecture = arch.Architecture.for( 15 | arch.Kind.x86_64, 16 | host, 17 | osKind, 18 | host.hypervisor 19 | ) 20 | let input = new Input() 21 | let config = { 22 | memory: memory, 23 | cpuCount: cpuCount, 24 | diskImage: '', 25 | ssHostPort: ssHostPort, 26 | cpu: '', 27 | machineType: '', 28 | uuid: '', 29 | resourcesDiskImage: '', 30 | userboot: '', 31 | firmware: '' 32 | } 33 | let vm = new Vm('', '', architecture, input, config) 34 | 35 | let getFlagValue = (flag: string) => vm.command[vm.command.indexOf(flag) + 1] 36 | let actualMemory = () => getFlagValue('-m') 37 | let actualSmp = () => getFlagValue('-smp') 38 | let actualNetDevice = () => getFlagValue('-netdev') 39 | 40 | describe('command', () => { 41 | it('constucts a command with the correct memory configuration', () => { 42 | expect(actualMemory()).toEqual(memory) 43 | }) 44 | 45 | it('constucts a command with the correct SMP configuration', () => { 46 | expect(actualSmp()).toEqual(cpuCount.toString()) 47 | }) 48 | 49 | it('constucts a command with the IPv6 disabled for the net device', () => { 50 | expect(actualNetDevice()).toEqual( 51 | `user,id=user.0,hostfwd=tcp::${ssHostPort}-:22,ipv6=off` 52 | ) 53 | }) 54 | }) 55 | }) 56 | -------------------------------------------------------------------------------- /spec/support/jasmine.json: -------------------------------------------------------------------------------- 1 | { 2 | "spec_dir": "spec", 3 | "spec_files": [ 4 | "**/*[sS]pec.ts" 5 | ], 6 | "helpers": [ 7 | "helpers/**/*.[tj]s" 8 | ], 9 | "env": { 10 | "stopSpecOnExpectationFailure": false, 11 | "random": true 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /spec/utility.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | execWithOutput, 3 | getOrDefaultOrThrow, 4 | getOrThrow, 5 | getImplementation 6 | } from '../src/utility' 7 | 8 | describe('execWithOutput', () => { 9 | it('returns the output of the executed process', async () => { 10 | const result = await execWithOutput('ls') 11 | expect(result.length).toBeGreaterThan(0) 12 | }) 13 | }) 14 | 15 | describe('getOrDefaultOrThrow', () => { 16 | it('returns the value of the given key', () => { 17 | let record = {foo: 4} 18 | expect(getOrDefaultOrThrow(record, 'foo')).toBe(4) 19 | }) 20 | 21 | describe("when the key doesn't exists", () => { 22 | describe('when a default value is specified', () => { 23 | it('retusn the default value', () => { 24 | let record = {foo: 4, default: 5} 25 | expect(getOrDefaultOrThrow(record, 'bar')).toBe(5) 26 | }) 27 | }) 28 | 29 | describe('when a default value is not specified', () => { 30 | it('throws an error', () => { 31 | let record = {foo: 4} 32 | expect(() => getOrDefaultOrThrow(record, 'bar')).toThrowError( 33 | /^Missing key and no default key/ 34 | ) 35 | }) 36 | }) 37 | }) 38 | }) 39 | 40 | describe('getOrThrow', () => { 41 | it('returns the value of the given key', () => { 42 | let map = new Map([['foo', 3]]) 43 | expect(getOrThrow(map, 'foo')).toBe(3) 44 | }) 45 | 46 | describe("when the given key doesn't exist", () => { 47 | it('throws an error', () => { 48 | let map = new Map([['foo', 3]]) 49 | expect(() => getOrThrow(map, 'bar')).toThrowError(/^Key not found/) 50 | }) 51 | }) 52 | }) 53 | 54 | describe('getImplementation', () => { 55 | describe('when the implementation matches', () => { 56 | it('returns the value of the implementation', () => { 57 | class Foo {} 58 | expect(getImplementation(new Foo(), {foo: 3})).toBe(3) 59 | }) 60 | }) 61 | 62 | describe("when the implementation doesn't match", () => { 63 | describe('when a default implementation is provided', () => { 64 | it('returns teh default implementation', () => { 65 | class Foo {} 66 | expect(getImplementation(new Foo(), {bar: 3, default: 4})).toBe(4) 67 | }) 68 | }) 69 | 70 | describe('when no default implementation is provided', () => { 71 | it('throws an error', () => { 72 | class Foo {} 73 | expect(() => getImplementation(new Foo(), {bar: 3})).toThrowError 74 | }) 75 | }) 76 | }) 77 | }) 78 | -------------------------------------------------------------------------------- /spec/xhuve_vm.spec.ts: -------------------------------------------------------------------------------- 1 | import * as xhyve from '../src/xhyve_vm' 2 | 3 | describe('extractIpAddress', () => { 4 | const macAddress = '4e:70:ef:c2:f2:ed' 5 | 6 | it('returns the IP address', () => { 7 | const ipAddress = '192.168.64.2' 8 | const arpOutput = [ 9 | '? (10.40.0.1) at fc:bd:67:63:12:69 on en0 ifscope [ethernet]', 10 | '? (10.40.0.4) at 0:50:56:82:34:8a on en0 ifscope [ethernet]', 11 | '? (10.40.0.9) at 0:50:56:9e:98:51 on en0 ifscope [ethernet]', 12 | '? (10.40.0.12) at 0:50:56:af:3a:b7 on en0 ifscope [ethernet]', 13 | '? (10.40.0.64) at 0:50:56:82:67:bd on en0 ifscope [ethernet]', 14 | '? (10.40.0.83) at 0:50:56:af:bd:60 on en0 ifscope [ethernet]', 15 | '? (10.40.0.86) at 0:50:56:af:f2:c2 on en0 ifscope [ethernet]', 16 | '? (10.40.0.89) at 0:50:56:82:de:5a on en0 ifscope [ethernet]', 17 | '? (10.40.0.112) at 0:50:56:82:1a:74 on en0 ifscope [ethernet]', 18 | '? (10.40.0.113) at 0:50:56:82:5b:f2 on en0 ifscope [ethernet]', 19 | '? (10.40.0.116) at 0:50:56:9e:d9:30 on en0 ifscope [ethernet]', 20 | '? (10.40.0.124) at 0:50:56:82:3a:90 on en0 ifscope [ethernet]', 21 | '? (10.40.0.127) at ff:ff:ff:ff:ff:ff on en0 ifscope [ethernet]', 22 | `? (${ipAddress}) at ${macAddress} on bridge100 ifscope [bridge]`, 23 | '? (224.0.0.251) at 1:0:5e:0:0:fb on en0 ifscope permanent [ethernet]' 24 | ].join('\n') 25 | 26 | expect(xhyve.extractIpAddress(arpOutput, macAddress)).toBe(ipAddress) 27 | }) 28 | 29 | describe('when no IP address is found', () => { 30 | it('returns undefined', () => { 31 | const arpOutput = [ 32 | '? (0.0.0.0) at 00:00:00:00:00:00 on en2 ifscope [ethernet]', 33 | '? (0.0.0.1) at 00:00:00:00:00:01 on en1 ifscope [ethernet]' 34 | ].join('\n') 35 | 36 | expect(xhyve.extractIpAddress(arpOutput, macAddress)).toBe(undefined) 37 | }) 38 | }) 39 | }) 40 | -------------------------------------------------------------------------------- /src/action/action.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs' 2 | import * as path from 'path' 3 | import * as process from 'process' 4 | 5 | import * as cache from '@actions/tool-cache' 6 | import * as core from '@actions/core' 7 | import * as exec from '@actions/exec' 8 | 9 | import * as architecture from '../architecture' 10 | import * as hostModule from '../host' 11 | import * as os from '../operating_system' 12 | import * as os_factory from '../operating_systems/factory' 13 | import ResourceDisk from '../resource_disk' 14 | import * as vmModule from '../vm' 15 | import * as input from './input' 16 | import * as shell from './shell' 17 | import * as utility from '../utility' 18 | 19 | import {execSync} from 'child_process' 20 | 21 | export class Action { 22 | readonly tempPath: string 23 | readonly host: hostModule.Host 24 | readonly operatingSystem: os.OperatingSystem 25 | readonly inputHashPath = '/tmp/cross-platform-actions-input-hash' 26 | readonly input = new input.Input() 27 | 28 | private readonly cpaHost: string 29 | private readonly resourceDisk: ResourceDisk 30 | private readonly sshDirectory: string 31 | private readonly privateSshKey: fs.PathLike 32 | private readonly publicSshKey: fs.PathLike 33 | private readonly privateSshKeyName = 'id_ed25519' 34 | private readonly targetDiskName = 'disk.raw' 35 | 36 | constructor() { 37 | this.cpaHost = vmModule.Vm.cpaHost 38 | this.host = hostModule.Host.create() 39 | this.tempPath = fs.mkdtempSync('/tmp/resources') 40 | const arch = architecture.Architecture.for( 41 | this.input.architecture, 42 | this.host, 43 | this.input.operatingSystem, 44 | this.input.hypervisor 45 | ) 46 | 47 | this.operatingSystem = this.createOperatingSystem(arch) 48 | this.resourceDisk = ResourceDisk.for(this) 49 | 50 | this.sshDirectory = path.join(this.getHomeDirectory(), '.ssh') 51 | this.privateSshKey = path.join(this.tempPath, this.privateSshKeyName) 52 | this.publicSshKey = `${this.privateSshKey}.pub` 53 | } 54 | 55 | async run(): Promise { 56 | core.startGroup('Setting up VM') 57 | core.debug('Running action') 58 | const runPreparer = this.createRunPreparer() 59 | runPreparer.createInputHash() 60 | runPreparer.validateInputHash() 61 | 62 | const [diskImagePath, hypervisorArchivePath, resourcesArchivePath] = 63 | await Promise.all([...runPreparer.download(), runPreparer.setupSSHKey()]) 64 | 65 | const [firmwareDirectory, resourcesDirectory] = await Promise.all( 66 | runPreparer.unarchive(hypervisorArchivePath, resourcesArchivePath) 67 | ) 68 | 69 | const hypervisorDirectory = path.join(firmwareDirectory, 'bin') 70 | const excludes = [ 71 | resourcesArchivePath, 72 | resourcesDirectory, 73 | hypervisorArchivePath, 74 | hypervisorDirectory, 75 | firmwareDirectory, 76 | diskImagePath 77 | ].map(p => p.slice(this.homeDirectory.length + 1)) 78 | 79 | const vm = this.creareVm( 80 | hypervisorDirectory, 81 | firmwareDirectory, 82 | resourcesDirectory, 83 | { 84 | memory: this.input.memory, 85 | cpuCount: this.input.cpuCount 86 | } 87 | ) 88 | 89 | const implementation = this.getImplementation(vm) 90 | await implementation.prepareDisk(diskImagePath, resourcesDirectory) 91 | 92 | await implementation.init() 93 | try { 94 | await implementation.run() 95 | implementation.configSSH(vm.ipAddress) 96 | await implementation.wait(240) 97 | await implementation.setupWorkDirectory( 98 | this.homeDirectory, 99 | this.workDirectory 100 | ) 101 | await vm.synchronizePaths( 102 | this.targetDiskName, 103 | this.resourceDisk.diskPath, 104 | ...excludes 105 | ) 106 | core.info('VM is ready') 107 | try { 108 | core.endGroup() 109 | await this.runCommand(vm) 110 | } finally { 111 | core.startGroup('Tearing down VM') 112 | await vm.synchronizeBack() 113 | } 114 | } finally { 115 | try { 116 | if (this.input.shutdownVm) { 117 | await vm.terminate() 118 | } 119 | } finally { 120 | core.endGroup() 121 | } 122 | } 123 | } 124 | 125 | async downloadDiskImage(): Promise { 126 | const imageURL = 127 | this.input.imageURL !== '' 128 | ? this.input.imageURL 129 | : this.operatingSystem.virtualMachineImageUrl 130 | core.info(`Downloading disk image: ${imageURL}`) 131 | const result = await cache.downloadTool(imageURL) 132 | core.info(`Downloaded file: ${result}`) 133 | 134 | return result 135 | } 136 | 137 | async download(type: string, url: string): Promise { 138 | core.info(`Downloading ${type}: ${url}`) 139 | const result = await cache.downloadTool(url) 140 | core.info(`Downloaded file: ${result}`) 141 | 142 | return result 143 | } 144 | 145 | creareVm( 146 | hypervisorDirectory: string, 147 | firmwareDirectory: string, 148 | resourcesDirectory: string, 149 | config: os.ExternalVmConfiguration 150 | ): vmModule.Vm { 151 | return this.operatingSystem.createVirtualMachine( 152 | hypervisorDirectory, 153 | resourcesDirectory, 154 | firmwareDirectory, 155 | this.input, 156 | { 157 | ...config, 158 | diskImage: path.join(resourcesDirectory, this.targetDiskName), 159 | 160 | // xhyve 161 | resourcesDiskImage: this.resourceDisk.diskPath, 162 | userboot: path.join(firmwareDirectory, 'userboot.so') 163 | } 164 | ) 165 | } 166 | 167 | async unarchive(type: string, archivePath: string): Promise { 168 | core.info(`Unarchiving ${type}: ${archivePath}`) 169 | return cache.extractTar(archivePath, undefined, '-x') 170 | } 171 | 172 | async unarchiveHypervisor(archivePath: string): Promise { 173 | const hypervisorDirectory = await this.unarchive('hypervisor', archivePath) 174 | return path.join(hypervisorDirectory) 175 | } 176 | 177 | private get customSendEnv(): string { 178 | const env = this.input.environmentVariables 179 | return env ? `SendEnv ${env}` : '' 180 | } 181 | 182 | private async setupSSHKey(): Promise { 183 | const mountPath = this.resourceDisk.create() 184 | await exec.exec('ssh-keygen', [ 185 | '-t', 186 | 'ed25519', 187 | '-f', 188 | this.privateSshKey.toString(), 189 | '-q', 190 | '-N', 191 | '' 192 | ]) 193 | fs.copyFileSync(this.publicSshKey, path.join(await mountPath, 'keys')) 194 | this.resourceDisk.unmount() 195 | } 196 | 197 | private get homeDirectory(): string { 198 | const components = this.workDirectory.split(path.sep).slice(0, -2) 199 | return path.join('/', ...components) 200 | } 201 | 202 | private get workDirectory(): string { 203 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 204 | return process.env['GITHUB_WORKSPACE']! 205 | } 206 | 207 | private async runCommand(vm: vmModule.Vm): Promise { 208 | utility.group('Running command', () => core.info(this.input.run)) 209 | 210 | const sh = 211 | this.input.shell === shell.Shell.default 212 | ? '$SHELL' 213 | : shell.toString(this.input.shell) 214 | await vm.execute2( 215 | ['sh', '-c', `'cd "${this.workDirectory}" && exec "${sh}" -e'`], 216 | Buffer.from(this.input.run) 217 | ) 218 | } 219 | 220 | private getHomeDirectory(): string { 221 | const homeDirectory = process.env['HOME'] 222 | 223 | if (homeDirectory === undefined) 224 | throw Error('Failed to get the home direcory') 225 | 226 | return homeDirectory 227 | } 228 | 229 | private createOperatingSystem( 230 | arch: architecture.Architecture 231 | ): os.OperatingSystem { 232 | return os_factory.Factory.for(this.input.operatingSystem, arch).create( 233 | this.input.version, 234 | this.input.hypervisor 235 | ) 236 | } 237 | 238 | private getImplementation(vm: vmModule.Vm): Implementation { 239 | const cls = implementationFor({isRunning: vmModule.Vm.isRunning}) 240 | core.debug(`Using action implementation: ${cls.name}`) 241 | return new cls(this, vm) 242 | } 243 | 244 | private createRunPreparer(): RunPreparer { 245 | const cls = runPreparerFor({isRunning: vmModule.Vm.isRunning}) 246 | core.debug(`Using run preparer: ${cls.name}`) 247 | return new cls(this, this.operatingSystem) 248 | } 249 | } 250 | 251 | function implementationFor({ 252 | isRunning 253 | }: { 254 | isRunning: boolean 255 | }): utility.Class { 256 | return isRunning ? LiveImplementation : InitialImplementation 257 | } 258 | 259 | function runPreparerFor({ 260 | isRunning 261 | }: { 262 | isRunning: boolean 263 | }): utility.Class { 264 | return isRunning ? LiveRunPreparer : InitialRunPreparer 265 | } 266 | 267 | interface RunPreparer { 268 | createInputHash(): void 269 | validateInputHash(): void 270 | download(): [Promise, Promise, Promise] 271 | setupSSHKey(): Promise 272 | unarchive( 273 | hypervisorArchivePath: string, 274 | resourcesArchivePath: string 275 | ): [Promise, Promise] 276 | } 277 | 278 | // Used when the VM is not running 279 | class InitialRunPreparer implements RunPreparer { 280 | private readonly action: Action 281 | private readonly operatingSystem: os.OperatingSystem 282 | 283 | constructor(action: Action, operatingSystem: os.OperatingSystem) { 284 | this.action = action 285 | this.operatingSystem = operatingSystem 286 | } 287 | 288 | createInputHash(): void { 289 | const hash = this.action['input'].toHash() 290 | core.debug(`Input hash: ${hash}`) 291 | fs.writeFileSync(this.action.inputHashPath, hash) 292 | } 293 | 294 | validateInputHash(): void { 295 | // noop 296 | } 297 | 298 | download(): [Promise, Promise, Promise] { 299 | return [ 300 | this.action.downloadDiskImage(), 301 | this.action.download('hypervisor', this.operatingSystem.hypervisorUrl), 302 | this.action.download('resources', this.operatingSystem.resourcesUrl) 303 | ] 304 | } 305 | 306 | async setupSSHKey(): Promise { 307 | await this.action['setupSSHKey']() 308 | } 309 | 310 | unarchive( 311 | hypervisorArchivePath: string, 312 | resourcesArchivePath: string 313 | ): [Promise, Promise] { 314 | return [ 315 | this.action.unarchiveHypervisor(hypervisorArchivePath), 316 | this.action.unarchive('resources', resourcesArchivePath) 317 | ] 318 | } 319 | } 320 | 321 | // Used when the VM is already running 322 | class LiveRunPreparer implements RunPreparer { 323 | private readonly action: Action 324 | 325 | constructor(action: Action) { 326 | this.action = action 327 | } 328 | 329 | createInputHash(): void { 330 | // noop 331 | } 332 | 333 | validateInputHash(): void { 334 | const hash = this.action.input.toHash() 335 | core.debug(`Input hash: ${hash}`) 336 | const initialHash = fs.readFileSync(this.action.inputHashPath, 'utf8') 337 | 338 | if (hash !== initialHash) 339 | throw Error("The inputs don't match the initial invocation of the action") 340 | } 341 | 342 | download(): [Promise, Promise, Promise] { 343 | return [Promise.resolve(''), Promise.resolve(''), Promise.resolve('')] 344 | } 345 | 346 | async setupSSHKey(): Promise { 347 | // noop 348 | } 349 | 350 | unarchive(): [Promise, Promise] { 351 | return [Promise.resolve(''), Promise.resolve('')] 352 | } 353 | } 354 | 355 | interface Implementation { 356 | prepareDisk(diskImagePath: string, resourcesDirectory: string): Promise 357 | init(): Promise 358 | run(): Promise 359 | wait(timeout: number): Promise 360 | 361 | setupWorkDirectory( 362 | homeDirectory: string, 363 | workDirectory: string 364 | ): Promise 365 | 366 | configSSH(ipAddress: string): void 367 | } 368 | 369 | class LiveImplementation implements Implementation { 370 | async prepareDisk( 371 | _diskImagePath: string, // eslint-disable-line @typescript-eslint/no-unused-vars 372 | _resourcesDirectory: string // eslint-disable-line @typescript-eslint/no-unused-vars 373 | ): Promise { 374 | // noop 375 | } 376 | 377 | async init(): Promise { 378 | // noop 379 | } 380 | 381 | async run(): Promise { 382 | // noop 383 | } 384 | 385 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 386 | async wait(_timeout: number): Promise { 387 | // noop 388 | } 389 | 390 | async setupWorkDirectory( 391 | _homeDirectory: string, // eslint-disable-line @typescript-eslint/no-unused-vars 392 | _workDirectory: string // eslint-disable-line @typescript-eslint/no-unused-vars 393 | ): Promise { 394 | // noop 395 | } 396 | 397 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 398 | configSSH(_ipAddress: string): void { 399 | // noop 400 | } 401 | } 402 | 403 | class InitialImplementation implements Implementation { 404 | private readonly action: Action 405 | private readonly vm: vmModule.Vm 406 | 407 | constructor(action: Action, vm: vmModule.Vm) { 408 | this.action = action 409 | this.vm = vm 410 | } 411 | 412 | async prepareDisk( 413 | diskImagePath: string, 414 | resourcesDirectory: string 415 | ): Promise { 416 | await this.action.operatingSystem.prepareDisk( 417 | diskImagePath, 418 | this.targetDiskName, 419 | resourcesDirectory 420 | ) 421 | } 422 | 423 | async init(): Promise { 424 | await this.vm.init() 425 | } 426 | 427 | async run(): Promise { 428 | await this.vm.run() 429 | } 430 | 431 | async wait(timeout: number): Promise { 432 | await this.vm.wait(timeout) 433 | } 434 | 435 | async setupWorkDirectory( 436 | homeDirectory: string, 437 | workDirectory: string 438 | ): Promise { 439 | await this.vm.setupWorkDirectory(homeDirectory, workDirectory) 440 | } 441 | 442 | private get targetDiskName(): string { 443 | return this.action['targetDiskName'] 444 | } 445 | 446 | configSSH(ipAddress: string): void { 447 | core.debug('Configuring SSH') 448 | 449 | this.createSSHConfig() 450 | this.setupAuthorizedKeys() 451 | this.setupHostname(ipAddress) 452 | } 453 | 454 | private createSSHConfig(): void { 455 | if (!fs.existsSync(this.sshDirectory)) 456 | fs.mkdirSync(this.sshDirectory, {recursive: true, mode: 0o700}) 457 | 458 | const lines = [ 459 | 'StrictHostKeyChecking=accept-new', 460 | `Host ${this.cpaHost}`, 461 | `Port ${this.operatingSystem.ssHostPort}`, 462 | `IdentityFile ${this.privateSshKey}`, 463 | 'SendEnv CI GITHUB_*', 464 | this.customSendEnv, 465 | 'PasswordAuthentication no' 466 | ].join('\n') 467 | 468 | fs.appendFileSync(path.join(this.sshDirectory, 'config'), `${lines}\n`) 469 | } 470 | 471 | private setupAuthorizedKeys(): void { 472 | const authorizedKeysPath = path.join(this.sshDirectory, 'authorized_keys') 473 | const publicKeyContent = fs.readFileSync(this.publicSshKey) 474 | fs.appendFileSync(authorizedKeysPath, publicKeyContent) 475 | } 476 | 477 | private get publicSshKey(): fs.PathLike { 478 | return this.action['publicSshKey'] 479 | } 480 | 481 | private get sshDirectory(): string { 482 | return this.action['sshDirectory'] 483 | } 484 | 485 | private get cpaHost(): string { 486 | return this.action['cpaHost'] 487 | } 488 | 489 | private get operatingSystem(): os.OperatingSystem { 490 | return this.action['operatingSystem'] 491 | } 492 | 493 | private get privateSshKey(): fs.PathLike { 494 | return this.action['privateSshKey'] 495 | } 496 | 497 | private get customSendEnv(): string { 498 | return this.action['customSendEnv'] 499 | } 500 | 501 | private setupHostname(ipAddress: string): void { 502 | if (ipAddress === 'localhost') ipAddress = '127.0.0.1' 503 | 504 | execSync( 505 | `sudo bash -c 'printf "${ipAddress} ${this.cpaHost}\n" >> /etc/hosts'` 506 | ) 507 | } 508 | } 509 | -------------------------------------------------------------------------------- /src/action/input.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | import * as architecture from '../architecture' 3 | import {Shell, toShell} from './shell' 4 | import * as os from '../operating_systems/kind' 5 | import {Host, host as defaultHost} from '../host' 6 | import * as hypervisor from '../hypervisor' 7 | import { 8 | SyncDirection, 9 | toSyncDirection, 10 | validSyncDirections 11 | } from './sync_direction' 12 | import {createHash} from 'crypto' 13 | 14 | export class Input { 15 | private readonly host: Host 16 | private run_?: string 17 | private operatingSystem_?: os.Kind 18 | private version_?: string 19 | private imageURL_?: string 20 | private shell_?: Shell 21 | private environmentVariables_?: string 22 | private architecture_?: architecture.Kind 23 | private memory_?: string 24 | private cpuCount_?: number 25 | private hypervisor_?: hypervisor.Hypervisor 26 | private syncDirection_?: SyncDirection 27 | private shutdownVm_?: boolean 28 | 29 | constructor(host: Host = defaultHost) { 30 | this.host = host 31 | } 32 | 33 | get version(): string { 34 | if (this.version_ !== undefined) return this.version_ 35 | return (this.version_ = core.getInput('version', { 36 | required: true 37 | })) 38 | } 39 | 40 | get imageURL(): string { 41 | if (this.imageURL_ !== undefined) return this.imageURL_ 42 | return (this.imageURL_ = core.getInput('image_url')) 43 | } 44 | 45 | get operatingSystem(): os.Kind { 46 | if (this.operatingSystem_ !== undefined) return this.operatingSystem_ 47 | const input = core.getInput('operating_system', {required: true}) 48 | core.debug(`operating_system input: '${input}'`) 49 | 50 | return (this.operatingSystem_ = os.Kind.for(input)) 51 | } 52 | 53 | get run(): string { 54 | if (this.run_ !== undefined) return this.run_ 55 | return (this.run_ = core.getInput('run', {required: true})) 56 | } 57 | 58 | get shell(): Shell { 59 | if (this.shell_ !== undefined) return this.shell_ 60 | const input = core.getInput('shell') 61 | const shell = input ? toShell(input) : Shell.default 62 | if (shell === undefined) throw Error(`Invalid shell: ${input}`) 63 | return (this.shell_ = shell) 64 | } 65 | 66 | get environmentVariables(): string { 67 | if (this.environmentVariables_ !== undefined) 68 | return this.environmentVariables_ 69 | 70 | return (this.environmentVariables_ = core.getInput('environment_variables')) 71 | } 72 | 73 | get architecture(): architecture.Kind { 74 | if (this.architecture_ !== undefined) return this.architecture_ 75 | 76 | const input = core.getInput('architecture') 77 | core.debug(`architecture input: '${input}'`) 78 | if (input === undefined || input === '') 79 | return (this.architecture_ = architecture.Kind.x86_64) 80 | 81 | const kind = architecture.toKind(input) 82 | if (kind === undefined) throw Error(`Invalid architecture: ${input}`) 83 | core.debug(`architecture kind: '${architecture.Kind[kind]}'`) 84 | 85 | return (this.architecture_ = kind) 86 | } 87 | 88 | get memory(): string { 89 | if (this.memory_ !== undefined) return this.memory_ 90 | 91 | const memory = core.getInput('memory') 92 | core.debug(`memory input: '${memory}'`) 93 | if (memory === undefined || memory === '') 94 | return (this.memory_ = this.host.defaultMemory) 95 | 96 | return (this.memory_ = memory) 97 | } 98 | 99 | get cpuCount(): number { 100 | if (this.cpuCount_ !== undefined) return this.cpuCount_ 101 | 102 | const cpuCount = core.getInput('cpu_count') 103 | core.debug(`cpuCount input: '${cpuCount}'`) 104 | if (cpuCount === undefined || cpuCount === '') 105 | return (this.cpuCount_ = this.host.defaultCpuCount) 106 | 107 | const parsedCpuCount = parseInt(cpuCount) 108 | 109 | if (Number.isNaN(parsedCpuCount)) 110 | throw Error(`Invalid CPU count: ${cpuCount}`) 111 | 112 | return (this.cpuCount_ = parsedCpuCount) 113 | } 114 | 115 | get hypervisor(): hypervisor.Hypervisor { 116 | if (this.hypervisor_ !== undefined) return this.hypervisor_ 117 | 118 | const input = core.getInput('hypervisor') 119 | core.debug(`hypervisor input: '${input}'`) 120 | if (input === undefined || input === '') 121 | return (this.hypervisor_ = this.host.hypervisor) 122 | 123 | const kind = hypervisor.toKind(input) 124 | if (kind === undefined) throw Error(`Invalid hypervisor: ${input}`) 125 | core.debug(`hypervisor kind: '${hypervisor.Kind[kind]}'`) 126 | 127 | const hypervisorClass = hypervisor.toHypervisor(kind) 128 | return (this.hypervisor_ = new hypervisorClass()) 129 | } 130 | 131 | get syncFiles(): SyncDirection { 132 | if (this.syncDirection_ !== undefined) return this.syncDirection_ 133 | 134 | const input = core.getInput('sync_files') 135 | core.debug(`sync_files input: '${input}'`) 136 | if (input === undefined || input === '') 137 | return (this.syncDirection_ = SyncDirection.both) 138 | 139 | const syncDirection = toSyncDirection(input) 140 | core.debug(`syncDirection: '${syncDirection}'`) 141 | 142 | if (syncDirection === undefined) { 143 | const values = validSyncDirections.join(', ') 144 | 145 | throw Error( 146 | `Invalid sync-files: ${input}\nValid sync-files values are: ${values}` 147 | ) 148 | } 149 | 150 | return (this.syncDirection_ = syncDirection) 151 | } 152 | 153 | get shutdownVm(): boolean { 154 | if (this.shutdownVm_ !== undefined) return this.shutdownVm_ 155 | 156 | const input = core.getBooleanInput('shutdown_vm') 157 | core.debug(`shutdown_vm input: '${input}'`) 158 | 159 | return (this.shutdownVm_ = input) 160 | } 161 | 162 | toHash(): string { 163 | const components = [ 164 | this.operatingSystem, 165 | this.version, 166 | this.imageURL, 167 | this.shell, 168 | this.environmentVariables, 169 | this.architecture, 170 | this.memory, 171 | this.cpuCount, 172 | this.hypervisor 173 | ] 174 | 175 | const hash = createHash('sha256') 176 | for (const component of components) hash.update(component.toString()) 177 | 178 | return hash.digest('hex') 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /src/action/shell.ts: -------------------------------------------------------------------------------- 1 | export enum Shell { 2 | default, 3 | bash, 4 | sh 5 | } 6 | const stringToShell: ReadonlyMap = (() => { 7 | const map = new Map() 8 | map.set('default', Shell.default) 9 | map.set('bash', Shell.bash) 10 | map.set('sh', Shell.sh) 11 | return map 12 | })() 13 | 14 | export function toShell(value: string): Shell | undefined { 15 | return stringToShell.get(value.toLowerCase()) 16 | } 17 | 18 | export function toString(shell: Shell): string { 19 | for (const [key, value] of stringToShell) { 20 | if (value === shell) return key 21 | } 22 | 23 | throw Error(`Unreachable: missing Shell.${shell} in 'stringToShell'`) 24 | } 25 | -------------------------------------------------------------------------------- /src/action/sync_direction.ts: -------------------------------------------------------------------------------- 1 | export enum SyncDirection { 2 | runner_to_vm, 3 | vm_to_runner, 4 | both, 5 | none 6 | } 7 | 8 | export function toSyncDirection(input: string): SyncDirection | undefined { 9 | return syncDirectionMap[input.toLowerCase()] 10 | } 11 | 12 | const syncDirectionMap: Record = { 13 | 'runner-to-vm': SyncDirection.runner_to_vm, 14 | 'vm-to-runner': SyncDirection.vm_to_runner, 15 | true: SyncDirection.both, 16 | false: SyncDirection.none 17 | } as const 18 | 19 | export const validSyncDirections = Object.keys(syncDirectionMap) 20 | -------------------------------------------------------------------------------- /src/architecture.ts: -------------------------------------------------------------------------------- 1 | import {Host} from './host' 2 | import HostQemu from './host_qemu' 3 | import * as hypervisor from './hypervisor' 4 | import {ResourceUrls} from './operating_systems/resource_urls' 5 | import * as os from './operating_systems/kind' 6 | import OpenBsd from './operating_systems/openbsd/openbsd' 7 | import {getOrThrow, getOrDefaultOrThrow} from './utility' 8 | 9 | export enum Kind { 10 | arm64, 11 | x86_64 12 | } 13 | 14 | export abstract class Architecture { 15 | readonly kind: Kind 16 | readonly host: Host 17 | 18 | protected readonly resourceBaseUrl = ResourceUrls.create().resourceBaseUrl 19 | 20 | private selectedHypervisor: hypervisor.Hypervisor 21 | 22 | constructor(kind: Kind, host: Host, hypervisor: hypervisor.Hypervisor) { 23 | this.kind = kind 24 | this.host = host 25 | this.selectedHypervisor = hypervisor 26 | } 27 | 28 | static for( 29 | kind: Kind, 30 | host: Host, 31 | operating_system: os.Kind, 32 | hypervisor: hypervisor.Hypervisor 33 | ): Architecture { 34 | if (operating_system.is(OpenBsd)) { 35 | if (kind == Kind.x86_64) 36 | return new Architecture.X86_64OpenBsd(kind, host, hypervisor) 37 | else if (kind == Kind.arm64) 38 | return new Architecture.Arm64OpenBsd(kind, host, hypervisor) 39 | } 40 | 41 | return new (getOrThrow(Architecture.architectureMap, kind))( 42 | kind, 43 | host, 44 | hypervisor 45 | ) 46 | } 47 | 48 | abstract get name(): string 49 | abstract get resourceUrl(): string 50 | abstract get cpu(): string 51 | abstract get machineType(): string 52 | abstract get hypervisor(): hypervisor.Hypervisor 53 | abstract get efiHypervisor(): hypervisor.Hypervisor 54 | 55 | get networkDevice(): string { 56 | return 'virtio-net' 57 | } 58 | 59 | get resolveName(): string { 60 | return this.constructor.name 61 | } 62 | 63 | resolve(implementation: Record): T { 64 | const name = this.resolveName.toLocaleLowerCase() 65 | return getOrDefaultOrThrow(implementation, name) 66 | } 67 | 68 | validateHypervisor(kind: hypervisor.Kind): void { 69 | this.host.validateHypervisor(kind) 70 | } 71 | 72 | protected get hostString(): string { 73 | return this.host.toString() 74 | } 75 | 76 | protected get hostQemu(): HostQemu { 77 | return this.host.qemu 78 | } 79 | 80 | static readonly Arm64 = class extends Architecture { 81 | override get name(): string { 82 | return 'arm64' 83 | } 84 | 85 | override get resolveName(): string { 86 | return 'arm64' 87 | } 88 | 89 | override get resourceUrl(): string { 90 | return `${this.resourceBaseUrl}/qemu-system-aarch64-${this.hostString}.tar` 91 | } 92 | 93 | override get cpu(): string { 94 | return 'cortex-a57' 95 | } 96 | 97 | override get machineType(): string { 98 | return 'virt' 99 | } 100 | 101 | override get hypervisor(): hypervisor.Hypervisor { 102 | return this.host.efiHypervisor 103 | } 104 | 105 | override get efiHypervisor(): hypervisor.Hypervisor { 106 | return new hypervisor.QemuEfi() 107 | } 108 | 109 | override validateHypervisor(kind: hypervisor.Kind): void { 110 | switch (kind) { 111 | case hypervisor.Kind.qemu: 112 | break 113 | case hypervisor.Kind.xhyve: 114 | throw new Error( 115 | 'Unsupported hypervisor for architecture ARM64: xhyve' 116 | ) 117 | default: 118 | throw new Error(`Internal Error: Unhandled hypervisor kind: ${kind}`) 119 | } 120 | } 121 | } 122 | 123 | private static readonly X86_64 = class extends Architecture { 124 | override get name(): string { 125 | return 'x86-64' 126 | } 127 | 128 | override get resolveName(): string { 129 | return 'x86_64' 130 | } 131 | 132 | override get resourceUrl(): string { 133 | return `${this.resourceBaseUrl}/qemu-system-x86_64-${this.hostString}.tar` 134 | } 135 | 136 | override get cpu(): string { 137 | return this.hostQemu.cpu 138 | } 139 | 140 | override get machineType(): string { 141 | return 'q35' 142 | } 143 | 144 | override get hypervisor(): hypervisor.Hypervisor { 145 | return this.selectedHypervisor 146 | } 147 | 148 | override get efiHypervisor(): hypervisor.Hypervisor { 149 | return this.selectedHypervisor.efi 150 | } 151 | } 152 | 153 | private static readonly X86_64OpenBsd = class extends this.X86_64 { 154 | override get networkDevice(): string { 155 | return 'e1000' 156 | } 157 | } 158 | 159 | private static readonly Arm64OpenBsd = class extends this.Arm64 { 160 | override get efiHypervisor(): hypervisor.Hypervisor { 161 | return new Architecture.Arm64OpenBsd.QemuEfi() 162 | } 163 | 164 | private static readonly QemuEfi = class extends hypervisor.QemuEfi { 165 | override get firmwareFile(): string { 166 | return `${this.firmwareDirectory}/linaro_uefi.fd` 167 | } 168 | } 169 | } 170 | 171 | private static readonly architectureMap: ReadonlyMap< 172 | Kind, 173 | typeof Architecture.X86_64 174 | > = new Map([ 175 | [Kind.arm64, Architecture.Arm64], 176 | [Kind.x86_64, Architecture.X86_64] 177 | ]) 178 | } 179 | 180 | export function toKind(value: string): Kind | undefined { 181 | return architectureMap[value.toLocaleLowerCase()] 182 | } 183 | 184 | const architectureMap: Record = { 185 | arm64: Kind.arm64, 186 | aarch64: Kind.arm64, 187 | 'x86-64': Kind.x86_64, 188 | x86_64: Kind.x86_64, 189 | x64: Kind.x86_64 190 | } as const 191 | -------------------------------------------------------------------------------- /src/host.ts: -------------------------------------------------------------------------------- 1 | import * as process from 'process' 2 | 3 | import * as core from '@actions/core' 4 | 5 | import HostQemu from './host_qemu' 6 | import * as hypervisor from './hypervisor' 7 | import * as qemu from './qemu_vm' 8 | import {getImplementation} from './utility' 9 | import * as xhyve from './xhyve_vm' 10 | 11 | class Module { 12 | private static host_: Module.Host | undefined 13 | 14 | static get host(): Module.Host { 15 | return this.host_ ? this.host_ : (this.host_ = Module.Host.create()) 16 | } 17 | } 18 | 19 | export = Module 20 | 21 | // The reason for this namesapce is to allow a global getter (`host`, see above). 22 | // See https://stackoverflow.com/questions/28834873/getter-setter-on-a-module-in-typescript 23 | namespace Module { 24 | export abstract class Host { 25 | static create(platform: string = process.platform): Host { 26 | switch (platform) { 27 | case 'darwin': 28 | return new MacOs() 29 | case 'linux': 30 | return new Linux() 31 | default: 32 | throw Error(`Unhandled host platform: ${platform}`) 33 | } 34 | } 35 | 36 | abstract get vmModule(): typeof xhyve | typeof qemu 37 | abstract get qemu(): HostQemu 38 | abstract get hypervisor(): hypervisor.Hypervisor 39 | abstract get efiHypervisor(): hypervisor.Hypervisor 40 | abstract get defaultMemory(): string 41 | abstract get defaultCpuCount(): number 42 | abstract validateHypervisor(kind: hypervisor.Kind): void 43 | 44 | resolve(implementation: Record): T { 45 | return getImplementation(this, implementation) 46 | } 47 | 48 | toString(): string { 49 | return this.constructor.name.toLocaleLowerCase() 50 | } 51 | } 52 | 53 | class MacOs extends Host { 54 | constructor() { 55 | super() 56 | 57 | core.warning( 58 | 'Support for macOS runners has been deprecated and will be removed in' + 59 | 'a future update. Please use the `ubuntu-latest` runner instead.' 60 | ) 61 | } 62 | 63 | get vmModule(): typeof xhyve | typeof qemu { 64 | return xhyve 65 | } 66 | 67 | override get qemu(): HostQemu { 68 | return new HostQemu.MacosHostQemu() 69 | } 70 | 71 | override get hypervisor(): hypervisor.Hypervisor { 72 | return new hypervisor.Xhyve() 73 | } 74 | 75 | override get efiHypervisor(): hypervisor.Hypervisor { 76 | return this.hypervisor 77 | } 78 | 79 | override get defaultMemory(): string { 80 | return '13G' 81 | } 82 | 83 | override get defaultCpuCount(): number { 84 | return 3 85 | } 86 | 87 | override validateHypervisor(kind: hypervisor.Kind): void { 88 | switch (kind) { 89 | case hypervisor.Kind.qemu: 90 | break 91 | case hypervisor.Kind.xhyve: 92 | break 93 | default: 94 | throw new Error(`Internal Error: Unhandled hypervisor kind: ${kind}`) 95 | } 96 | } 97 | } 98 | 99 | class Linux extends Host { 100 | get vmModule(): typeof xhyve | typeof qemu { 101 | return qemu 102 | } 103 | 104 | override get qemu(): HostQemu { 105 | return new HostQemu.LinuxHostQemu() 106 | } 107 | 108 | override get hypervisor(): hypervisor.Hypervisor { 109 | return new hypervisor.Qemu() 110 | } 111 | 112 | override get efiHypervisor(): hypervisor.Hypervisor { 113 | return new hypervisor.QemuEfi() 114 | } 115 | 116 | override get defaultMemory(): string { 117 | return '6G' 118 | } 119 | 120 | override get defaultCpuCount(): number { 121 | return 2 122 | } 123 | 124 | override validateHypervisor(kind: hypervisor.Kind): void { 125 | switch (kind) { 126 | case hypervisor.Kind.qemu: 127 | break 128 | case hypervisor.Kind.xhyve: 129 | throw new Error('Unsupported hypervisor on Linux hosts: xhyve') 130 | default: 131 | throw new Error(`Internal Error: Unhandled hypervisor kind: ${kind}`) 132 | } 133 | } 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/host_qemu.ts: -------------------------------------------------------------------------------- 1 | // Contains host specific QEMU properties 2 | export default abstract class HostQemu { 3 | abstract get cpu(): string 4 | 5 | static readonly LinuxHostQemu = class extends HostQemu { 6 | override get cpu(): string { 7 | return 'max' 8 | } 9 | } 10 | 11 | static readonly MacosHostQemu = class extends HostQemu { 12 | override get cpu(): string { 13 | return 'max' 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/hypervisor.ts: -------------------------------------------------------------------------------- 1 | import {Architecture} from './architecture' 2 | import {ResourceUrls} from './operating_systems/resource_urls' 3 | import {Vm as QemuVm} from './qemu_vm' 4 | import {Vm as XhyveVm} from './xhyve_vm' 5 | import {getOrDefaultOrThrow} from './utility' 6 | 7 | export enum Kind { 8 | xhyve, 9 | qemu 10 | } 11 | 12 | export function toKind(value: string): Kind | undefined { 13 | return architectureMap[value.toLocaleLowerCase()] 14 | } 15 | 16 | const architectureMap: Record = { 17 | xhyve: Kind.xhyve, 18 | qemu: Kind.qemu 19 | } as const 20 | 21 | export interface Hypervisor { 22 | get kind(): Kind 23 | get sshPort(): number 24 | get firmwareFile(): string 25 | get vmModule(): typeof QemuVm | typeof XhyveVm 26 | get efi(): Hypervisor 27 | getResourceUrl(architecture: Architecture): string 28 | resolve(implementation: Record): T 29 | } 30 | 31 | export class Xhyve implements Hypervisor { 32 | get kind(): Kind { 33 | return Kind.xhyve 34 | } 35 | 36 | get sshPort(): number { 37 | return 22 38 | } 39 | 40 | get firmwareFile(): string { 41 | return 'uefi.fd' 42 | } 43 | 44 | get vmModule(): typeof XhyveVm { 45 | return XhyveVm 46 | } 47 | 48 | get efi(): Hypervisor { 49 | return this 50 | } 51 | 52 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 53 | getResourceUrl(_architecture: Architecture): string { 54 | return `${ResourceUrls.create().resourceBaseUrl}/xhyve-macos.tar` 55 | } 56 | 57 | resolve(implementation: Record): T { 58 | return getOrDefaultOrThrow(implementation, 'xhyve') 59 | } 60 | } 61 | 62 | export class Qemu implements Hypervisor { 63 | protected readonly firmwareDirectory = 'share/qemu' 64 | 65 | get kind(): Kind { 66 | return Kind.qemu 67 | } 68 | 69 | get sshPort(): number { 70 | return 2847 71 | } 72 | 73 | get firmwareFile(): string { 74 | return `${this.firmwareDirectory}/bios-256k.bin` 75 | } 76 | 77 | get vmModule(): typeof QemuVm { 78 | return QemuVm 79 | } 80 | 81 | get efi(): Hypervisor { 82 | return new QemuEfi() 83 | } 84 | 85 | getResourceUrl(architecture: Architecture): string { 86 | return architecture.resourceUrl 87 | } 88 | 89 | resolve(implementation: Record): T { 90 | return getOrDefaultOrThrow(implementation, 'qemu') 91 | } 92 | } 93 | 94 | export class QemuEfi extends Qemu { 95 | override get firmwareFile(): string { 96 | return `${this.firmwareDirectory}/uefi.fd` 97 | } 98 | } 99 | 100 | export function toHypervisor(kind: Kind): typeof Xhyve | typeof Qemu { 101 | return hypervisorMap[kind] 102 | } 103 | 104 | const hypervisorMap: Record = { 105 | [Kind.xhyve]: Xhyve, 106 | [Kind.qemu]: Qemu 107 | } as const 108 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | import {Action} from './action/action' 3 | 4 | import './operating_systems/freebsd/factory' 5 | import './operating_systems/haiku/factory' 6 | import './operating_systems/netbsd/factory' 7 | import './operating_systems/openbsd/factory' 8 | 9 | async function main(): Promise { 10 | if (core.isDebug()) { 11 | await new Action().run() 12 | } else { 13 | try { 14 | await new Action().run() 15 | } catch (error: unknown) { 16 | const err = error as Error 17 | core.setFailed(err.message) 18 | } 19 | } 20 | } 21 | 22 | main() 23 | -------------------------------------------------------------------------------- /src/operating_system.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs' 2 | import * as path from 'path' 3 | 4 | import * as core from '@actions/core' 5 | import * as exec from '@actions/exec' 6 | 7 | import * as architecture from './architecture' 8 | import * as vmModule from './vm' 9 | import {Input} from './action/input' 10 | import {host} from './host' 11 | import {ResourceUrls} from './operating_systems/resource_urls' 12 | import {LinuxDiskFileCreator, LinuxDiskDeviceCreator} from './resource_disk' 13 | import * as hypervisor from './hypervisor' 14 | 15 | export interface ExternalVmConfiguration { 16 | memory: string 17 | cpuCount: number 18 | } 19 | 20 | export interface VmConfiguration extends ExternalVmConfiguration { 21 | cpuCount: number 22 | diskImage: fs.PathLike 23 | resourcesDiskImage: fs.PathLike 24 | userboot: fs.PathLike 25 | } 26 | 27 | export abstract class OperatingSystem { 28 | readonly resourcesUrl: string 29 | 30 | readonly architecture: architecture.Architecture 31 | 32 | private static readonly resourceUrls = ResourceUrls.create() 33 | protected readonly xhyveHypervisorUrl = `${OperatingSystem.resourceUrls.resourceBaseUrl}/xhyve-macos.tar` 34 | 35 | private readonly version: string 36 | 37 | constructor(arch: architecture.Architecture, version: string) { 38 | const hostString = host.toString() 39 | this.resourcesUrl = `${OperatingSystem.resourceUrls.resourceBaseUrl}/resources-${hostString}.tar` 40 | this.version = version 41 | this.architecture = arch 42 | } 43 | 44 | abstract get virtualMachineImageReleaseVersion(): string 45 | abstract get hypervisorUrl(): string 46 | abstract get ssHostPort(): number 47 | 48 | get hypervisor(): hypervisor.Hypervisor { 49 | return this.architecture.hypervisor 50 | } 51 | 52 | get virtualMachineImageUrl(): string { 53 | return [ 54 | OperatingSystem.resourceUrls.baseUrl, 55 | `${this.name}-builder`, 56 | 'releases', 57 | 'download', 58 | this.virtualMachineImageReleaseVersion, 59 | this.imageName 60 | ].join('/') 61 | } 62 | 63 | get linuxDiskFileCreator(): LinuxDiskFileCreator { 64 | return new LinuxDiskFileCreator.NoopDiskFileCreator() 65 | } 66 | 67 | get linuxDiskDeviceCreator(): LinuxDiskDeviceCreator { 68 | return new LinuxDiskDeviceCreator.FullDiskDeviceCreator() 69 | } 70 | 71 | get name(): string { 72 | return this.constructor.name.toLocaleLowerCase() 73 | } 74 | 75 | abstract createVirtualMachine( 76 | hypervisorDirectory: fs.PathLike, 77 | resourcesDirectory: fs.PathLike, 78 | firmwareDirectory: fs.PathLike, 79 | intput: Input, 80 | configuration: VmConfiguration 81 | ): vmModule.Vm 82 | 83 | async prepareDisk( 84 | diskImage: fs.PathLike, 85 | targetDiskName: fs.PathLike, 86 | resourcesDirectory: fs.PathLike 87 | ): Promise { 88 | core.debug('Converting qcow2 image to raw') 89 | const resDir = resourcesDirectory.toString() 90 | await exec.exec(path.join(resDir, 'qemu-img'), [ 91 | 'convert', 92 | '-f', 93 | 'qcow2', 94 | '-O', 95 | 'raw', 96 | diskImage.toString(), 97 | path.join(resDir, targetDiskName.toString()) 98 | ]) 99 | } 100 | 101 | protected get uuid(): string { 102 | return '864ED7F0-7876-4AA7-8511-816FABCFA87F' 103 | } 104 | 105 | private get imageName(): string { 106 | const encodedVersion = encodeURIComponent(this.version) 107 | return `${this.name}-${encodedVersion}-${this.architecture.name}.qcow2` 108 | } 109 | } 110 | 111 | export async function convertToRawDisk( 112 | diskImage: fs.PathLike, 113 | targetDiskName: fs.PathLike, 114 | resourcesDirectory: fs.PathLike 115 | ): Promise { 116 | core.debug('Converting qcow2 image to raw') 117 | const resDir = resourcesDirectory.toString() 118 | await exec.exec(path.join(resDir, 'qemu-img'), [ 119 | 'convert', 120 | '-f', 121 | 'qcow2', 122 | '-O', 123 | 'raw', 124 | diskImage.toString(), 125 | path.join(resDir, targetDiskName.toString()) 126 | ]) 127 | } 128 | -------------------------------------------------------------------------------- /src/operating_systems/factory.ts: -------------------------------------------------------------------------------- 1 | import * as architecture from '../architecture' 2 | import type {OperatingSystem} from '../operating_system' 3 | import * as os from '../operating_systems/kind' 4 | import {getOrThrow, Class} from '../utility' 5 | import * as hypervisor from '../hypervisor' 6 | 7 | export abstract class Factory { 8 | readonly architecture: architecture.Architecture 9 | 10 | constructor(arch: architecture.Architecture) { 11 | this.architecture = arch 12 | } 13 | 14 | static for(kind: os.Kind, arch: architecture.Architecture): Factory { 15 | const cls = getOrThrow(factories, `${kind.name}factory`) 16 | return new cls(arch) 17 | } 18 | 19 | create(version: string, vmm: hypervisor.Hypervisor): OperatingSystem { 20 | this.validateHypervisor(vmm.kind) 21 | return this.createImpl(version, vmm) 22 | } 23 | 24 | abstract createImpl( 25 | version: string, 26 | hypervisor: hypervisor.Hypervisor 27 | ): OperatingSystem 28 | 29 | protected validateHypervisor(kind: hypervisor.Kind): void { 30 | switch (kind) { 31 | case hypervisor.Kind.qemu: 32 | break 33 | case hypervisor.Kind.xhyve: 34 | throw new Error( 35 | `Unsupported hypervisor for this operating system: xhyve` 36 | ) 37 | default: 38 | throw new Error(`Internal Error: Unhandled hypervisor kind: ${kind}`) 39 | } 40 | } 41 | } 42 | 43 | export function operatingSystem(classObject: Class): void { 44 | const name = classObject.name.toLocaleLowerCase() 45 | register(name, classObject) 46 | } 47 | 48 | export function factory(classObject: Class): void { 49 | const name = classObject.name.toLocaleLowerCase() 50 | registerFactory(name, classObject) 51 | } 52 | 53 | export function isValid(name: string): boolean { 54 | return operatingSystems.has(name) 55 | } 56 | 57 | function register(name: string, type: Class): void { 58 | operatingSystems.set(name, type) 59 | } 60 | 61 | const operatingSystems: Map> = new Map< 62 | string, 63 | Class 64 | >() 65 | 66 | function registerFactory(name: string, type: Class): void { 67 | factories.set(name, type) 68 | } 69 | 70 | const factories: Map> = new Map>() 71 | -------------------------------------------------------------------------------- /src/operating_systems/freebsd/factory.ts: -------------------------------------------------------------------------------- 1 | import {Kind as HypervisorKind} from '../../hypervisor' 2 | import {OperatingSystem} from '../../operating_system' 3 | import {factory, Factory as BaseFactory} from '../factory' 4 | import FreeBsd from './freebsd' 5 | 6 | @factory 7 | //@ts-ignore 8 | class FreeBsdFactory extends BaseFactory { 9 | override createImpl(version: string): OperatingSystem { 10 | return new FreeBsd(this.architecture, version) 11 | } 12 | 13 | override validateHypervisor(kind: HypervisorKind): void { 14 | this.architecture.validateHypervisor(kind) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/operating_systems/freebsd/freebsd.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs' 2 | import * as path from 'path' 3 | 4 | import * as core from '@actions/core' 5 | 6 | import {operatingSystem} from '../factory' 7 | import * as vmModule from '../../vm' 8 | import {QemuVm} from './qemu_vm' 9 | import * as os from '../../operating_system' 10 | import {LinuxDiskFileCreator, LinuxDiskDeviceCreator} from '../../resource_disk' 11 | import versions from '../../version' 12 | import {XhyveVm} from './xhyve_vm' 13 | import {Input} from '../../action/input' 14 | 15 | @operatingSystem 16 | export default class FreeBsd extends os.OperatingSystem { 17 | get hypervisorUrl(): string { 18 | return this.hypervisor.getResourceUrl(this.architecture) 19 | } 20 | 21 | get ssHostPort(): number { 22 | return this.hypervisor.sshPort 23 | } 24 | 25 | get virtualMachineImageReleaseVersion(): string { 26 | return versions.operating_system.freebsd 27 | } 28 | 29 | override get linuxDiskFileCreator(): LinuxDiskFileCreator { 30 | return new LinuxDiskFileCreator.FdiskDiskFileCreator() 31 | } 32 | 33 | override get linuxDiskDeviceCreator(): LinuxDiskDeviceCreator { 34 | return new LinuxDiskDeviceCreator.FdiskDiskDeviceCreator() 35 | } 36 | 37 | createVirtualMachine( 38 | hypervisorDirectory: fs.PathLike, 39 | resourcesDirectory: fs.PathLike, 40 | firmwareDirectory: fs.PathLike, 41 | input: Input, 42 | configuration: os.VmConfiguration 43 | ): vmModule.Vm { 44 | core.debug('Creating FreeBSD VM') 45 | 46 | const config: vmModule.Configuration = { 47 | ...configuration, 48 | 49 | ssHostPort: this.ssHostPort, 50 | firmware: path.join( 51 | firmwareDirectory.toString(), 52 | this.architecture.hypervisor.firmwareFile 53 | ), 54 | 55 | // qemu 56 | cpu: this.architecture.cpu, 57 | machineType: this.architecture.machineType, 58 | 59 | // xhyve 60 | uuid: this.uuid 61 | } 62 | 63 | const cls = this.hypervisor.resolve({qemu: QemuVm, xhyve: XhyveVm}) 64 | return new cls( 65 | hypervisorDirectory, 66 | resourcesDirectory, 67 | this.architecture, 68 | input, 69 | config 70 | ) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/operating_systems/freebsd/qemu_vm.ts: -------------------------------------------------------------------------------- 1 | import {Vm} from '../../qemu_vm' 2 | 3 | export class QemuVm extends Vm { 4 | protected get hardDriverFlags(): string[] { 5 | // prettier-ignore 6 | return [ 7 | '-device', 'virtio-blk-pci,drive=drive0,bootindex=0', 8 | '-drive', `if=none,file=${this.configuration.diskImage},id=drive0,cache=unsafe,discard=ignore,format=raw`, 9 | 10 | '-device', 'virtio-blk-pci,drive=drive1,bootindex=1', 11 | '-drive', `if=none,file=${this.configuration.resourcesDiskImage},id=drive1,cache=unsafe,discard=ignore,format=raw`, 12 | ] 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/operating_systems/freebsd/xhyve_vm.ts: -------------------------------------------------------------------------------- 1 | import {Vm} from '../../xhyve_vm' 2 | 3 | export class XhyveVm extends Vm { 4 | override get command(): string[] { 5 | // prettier-ignore 6 | return super.command.concat( 7 | '-f', `fbsd,${this.configuration.userboot},${this.configuration.diskImage},` 8 | ) 9 | } 10 | 11 | protected get networkDevice(): string { 12 | return 'virtio-net' 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/operating_systems/haiku/factory.ts: -------------------------------------------------------------------------------- 1 | import {OperatingSystem} from '../../operating_system' 2 | import {factory} from '../factory' 3 | import QemuFactory from '../qemu_factory' 4 | import Haiku from './haiku' 5 | 6 | @factory 7 | //@ts-ignore 8 | class HaikuFactory extends QemuFactory { 9 | override createImpl(version: string): OperatingSystem { 10 | return new Haiku(this.architecture, version) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/operating_systems/haiku/haiku.ts: -------------------------------------------------------------------------------- 1 | import {operatingSystem} from '../factory' 2 | import versions from '../../version' 3 | import {Qemu} from '../qemu' 4 | import * as qemu_vm from './qemu_vm' 5 | import {Class} from '../../utility' 6 | import {Vm as QemuVm} from '../../qemu_vm' 7 | 8 | @operatingSystem 9 | export default class Haiku extends Qemu { 10 | get virtualMachineImageReleaseVersion(): string { 11 | return versions.operating_system.haiku 12 | } 13 | 14 | get vmClass(): Class { 15 | return qemu_vm.Vm 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/operating_systems/haiku/qemu_vm.ts: -------------------------------------------------------------------------------- 1 | import {Vm as QemuVm} from '../../qemu_vm' 2 | 3 | export class Vm extends QemuVm { 4 | override async setupWorkDirectory( 5 | homeDirectory: string, 6 | workDirectory: string 7 | ): Promise { 8 | await this.execute( 9 | `mkdir -p '${workDirectory}' && ` + 10 | `ln -sf '/boot/home/' '${homeDirectory}'` 11 | ) 12 | } 13 | 14 | protected get hardDriverFlags(): string[] { 15 | return this.defaultHardDriveFlags 16 | } 17 | 18 | protected override get ipv6(): string { 19 | return 'ipv6=off' 20 | } 21 | 22 | protected override get netDevive(): string { 23 | return 'e1000' 24 | } 25 | 26 | protected override get user(): string { 27 | return 'user' 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/operating_systems/kind.ts: -------------------------------------------------------------------------------- 1 | import {OperatingSystem} from '../operating_system' 2 | import {Class} from '../utility' 3 | import {isValid} from './factory' 4 | 5 | export class Kind { 6 | readonly name: string 7 | 8 | private constructor(name: string) { 9 | this.name = name 10 | } 11 | 12 | static for(name: string): Kind { 13 | const canonicalizeName = Kind.canonicalize(name) 14 | 15 | if (!isValid(canonicalizeName)) 16 | throw Error(`Unrecognized operating system: ${name}`) 17 | 18 | return new Kind(canonicalizeName) 19 | } 20 | 21 | is(classObject: Class): boolean { 22 | return this.name === Kind.canonicalize(classObject.name) 23 | } 24 | 25 | private static canonicalize(name: string): string { 26 | return name.toLocaleLowerCase() 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/operating_systems/netbsd/factory.ts: -------------------------------------------------------------------------------- 1 | import {OperatingSystem} from '../../operating_system' 2 | import {factory} from '../factory' 3 | import QemuFactory from '../qemu_factory' 4 | import NetBsd from './netbsd' 5 | 6 | @factory 7 | //@ts-ignore 8 | class NetBsdFactory extends QemuFactory { 9 | override createImpl(version: string): OperatingSystem { 10 | return new NetBsd(this.architecture, version) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/operating_systems/netbsd/netbsd.ts: -------------------------------------------------------------------------------- 1 | import {operatingSystem} from '../factory' 2 | import versions from '../../version' 3 | import {Qemu} from '../qemu' 4 | import * as qemu_vm from './qemu_vm' 5 | import {Class} from '../../utility' 6 | import {Vm as QemuVm} from '../../qemu_vm' 7 | 8 | @operatingSystem 9 | export default class NetBsd extends Qemu { 10 | get virtualMachineImageReleaseVersion(): string { 11 | return versions.operating_system.netbsd 12 | } 13 | 14 | get vmClass(): Class { 15 | return qemu_vm.Vm 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/operating_systems/netbsd/qemu_vm.ts: -------------------------------------------------------------------------------- 1 | import {Vm as QemuVm} from '../../qemu_vm' 2 | 3 | export class Vm extends QemuVm { 4 | protected get hardDriverFlags(): string[] { 5 | return this.defaultHardDriveFlags 6 | } 7 | 8 | protected override get ipv6(): string { 9 | return 'ipv6=off' 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/operating_systems/openbsd/factory.ts: -------------------------------------------------------------------------------- 1 | import {Hypervisor, Kind as HypervisorKind} from '../../hypervisor' 2 | import {OperatingSystem} from '../../operating_system' 3 | import {factory, Factory as BaseFactory} from '../factory' 4 | import OpenBsd from './openbsd' 5 | 6 | @factory 7 | //@ts-ignore 8 | class OpenBsdFactory extends BaseFactory { 9 | override createImpl( 10 | version: string, 11 | _hypervisor: Hypervisor 12 | ): OperatingSystem { 13 | return new OpenBsd(this.architecture, version) 14 | } 15 | 16 | override validateHypervisor(kind: HypervisorKind): void { 17 | this.architecture.validateHypervisor(kind) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/operating_systems/openbsd/openbsd.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs' 2 | import * as path from 'path' 3 | 4 | import * as core from '@actions/core' 5 | 6 | import {operatingSystem} from '../factory' 7 | import * as vmModule from '../../vm' 8 | import {QemuVm, QemuVmX86_64} from './qemu_vm' 9 | import * as os from '../../operating_system' 10 | import versions from '../../version' 11 | import {XhyveVm} from './xhyve_vm' 12 | import {Input} from '../../action/input' 13 | 14 | @operatingSystem 15 | export default class OpenBsd extends os.OperatingSystem { 16 | get hypervisorUrl(): string { 17 | return this.hypervisor.getResourceUrl(this.architecture) 18 | } 19 | 20 | get ssHostPort(): number { 21 | return this.hypervisor.sshPort 22 | } 23 | 24 | get virtualMachineImageReleaseVersion(): string { 25 | return versions.operating_system.openbsd 26 | } 27 | 28 | createVirtualMachine( 29 | hypervisorDirectory: fs.PathLike, 30 | resourcesDirectory: fs.PathLike, 31 | firmwareDirectory: fs.PathLike, 32 | input: Input, 33 | configuration: os.VmConfiguration 34 | ): vmModule.Vm { 35 | core.debug('Creating OpenBSD VM') 36 | 37 | const config: vmModule.Configuration = { 38 | ...configuration, 39 | 40 | ssHostPort: this.ssHostPort, 41 | firmware: path.join( 42 | firmwareDirectory.toString(), 43 | this.architecture.efiHypervisor.firmwareFile 44 | ), 45 | 46 | // qemu 47 | cpu: this.architecture.cpu, 48 | machineType: this.architecture.machineType, 49 | 50 | // xhyve 51 | uuid: this.uuid 52 | } 53 | 54 | let qemuVmClass = this.architecture.resolve({ 55 | x86_64: QemuVmX86_64, 56 | default: QemuVm 57 | }) 58 | 59 | const cls = this.hypervisor.resolve({qemu: qemuVmClass, xhyve: XhyveVm}) 60 | 61 | return new cls( 62 | hypervisorDirectory, 63 | resourcesDirectory, 64 | this.architecture, 65 | input, 66 | config 67 | ) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/operating_systems/openbsd/qemu_vm.ts: -------------------------------------------------------------------------------- 1 | import {Vm} from '../../qemu_vm' 2 | 3 | export class QemuVm extends Vm { 4 | protected get hardDriverFlags(): string[] { 5 | return this.defaultHardDriveFlags 6 | } 7 | 8 | protected override get netDevive(): string { 9 | return this.architecture.networkDevice 10 | } 11 | 12 | protected override get accelerators(): string[] { 13 | return this.input.version.startsWith('6') 14 | ? ['hvf', 'tcg'] // KVM doesn't work with versions older than 7.0 15 | : ['hvf', 'kvm', 'tcg'] 16 | } 17 | } 18 | 19 | export class QemuVmX86_64 extends QemuVm { 20 | protected override get cpuidFlags(): string[] { 21 | // disable huge pages, otherwise OpenBSD will not boot: https://gitlab.com/qemu-project/qemu/-/issues/1091 22 | return ['-pdpe1gb'] 23 | } 24 | 25 | protected override get firmwareFlags(): string[] { 26 | return [ 27 | '-drive', 28 | `if=pflash,format=raw,unit=0,file=${this.configuration.firmware},readonly=on` 29 | ] 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/operating_systems/openbsd/xhyve_vm.ts: -------------------------------------------------------------------------------- 1 | import {Vm} from '../../xhyve_vm' 2 | 3 | export class XhyveVm extends Vm { 4 | override get command(): string[] { 5 | // prettier-ignore 6 | return super.command.concat( 7 | '-l', `bootrom,${this.configuration.firmware}`, 8 | '-w' 9 | ) 10 | } 11 | 12 | protected get networkDevice(): string { 13 | return 'e1000' 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/operating_systems/qemu.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs' 2 | import * as path from 'path' 3 | 4 | import * as core from '@actions/core' 5 | 6 | import * as vmModule from '../vm' 7 | import * as os from '../operating_system' 8 | import {Input} from '../action/input' 9 | import {Class} from '../utility' 10 | import {Vm as QemuVm} from '../qemu_vm' 11 | 12 | import { 13 | Hypervisor, 14 | Qemu as QemuHypervisor, 15 | QemuEfi as QemuEfiHypervisor 16 | } from '../hypervisor' 17 | 18 | export abstract class Qemu extends os.OperatingSystem { 19 | abstract get vmClass(): Class 20 | 21 | get hypervisorUrl(): string { 22 | return this.architecture.resourceUrl 23 | } 24 | 25 | override get hypervisor(): Hypervisor { 26 | const cls = this.architecture.resolve({ 27 | arm64: QemuEfiHypervisor, 28 | x86_64: QemuHypervisor 29 | }) 30 | 31 | return new cls() 32 | } 33 | 34 | get ssHostPort(): number { 35 | return 2847 36 | } 37 | 38 | createVirtualMachine( 39 | hypervisorDirectory: fs.PathLike, 40 | resourcesDirectory: fs.PathLike, 41 | firmwareDirectory: fs.PathLike, 42 | input: Input, 43 | configuration: os.VmConfiguration 44 | ): vmModule.Vm { 45 | core.debug(`Creating ${this.name} VM`) 46 | 47 | const config: vmModule.Configuration = { 48 | ...configuration, 49 | 50 | ssHostPort: this.ssHostPort, 51 | firmware: path.join( 52 | firmwareDirectory.toString(), 53 | this.hypervisor.firmwareFile 54 | ), 55 | 56 | // qemu 57 | cpu: this.architecture.cpu, 58 | machineType: this.architecture.machineType, 59 | 60 | // xhyve 61 | uuid: this.uuid 62 | } 63 | 64 | return new this.vmClass( 65 | hypervisorDirectory, 66 | resourcesDirectory, 67 | this.architecture, 68 | input, 69 | config 70 | ) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/operating_systems/qemu_factory.ts: -------------------------------------------------------------------------------- 1 | import {Kind} from '../hypervisor' 2 | import {Factory as BaseFactory} from './factory' 3 | 4 | export default abstract class QemuFactory extends BaseFactory { 5 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 6 | protected override validateHypervisor(_kind: Kind): void {} 7 | } 8 | -------------------------------------------------------------------------------- /src/operating_systems/resource_urls.ts: -------------------------------------------------------------------------------- 1 | import version from '../version' 2 | 3 | export class ResourceUrls { 4 | static create(): ResourceUrls { 5 | const domain = this.resourceUrl || this.defaultDomain 6 | return new ResourceUrls(domain) 7 | } 8 | 9 | private static readonly defaultDomain = 'https://github.com' 10 | private readonly domain: string 11 | 12 | private constructor(domain: string) { 13 | this.domain = domain 14 | } 15 | 16 | get baseUrl(): string { 17 | return `${this.domain}/cross-platform-actions` 18 | } 19 | 20 | get resourceBaseUrl(): string { 21 | return `${this.baseUrl}/resources/releases/download/${version.resources}` 22 | } 23 | 24 | private static get resourceUrl(): string | undefined { 25 | return process.env['CPA_RESOURCE_URL'] 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/qemu_vm.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs' 2 | import * as architecture from './architecture' 3 | import {ExecExecutor, Executor, getOrDefaultOrThrow} from './utility' 4 | import * as vm from './vm' 5 | import {Input} from './action/input' 6 | 7 | export abstract class Vm extends vm.Vm { 8 | static readonly sshPort = 2847 9 | 10 | constructor( 11 | hypervisorDirectory: fs.PathLike, 12 | resourcesDirectory: fs.PathLike, 13 | architecture: architecture.Architecture, 14 | input: Input, 15 | configuration: vm.Configuration, 16 | executor: Executor = new ExecExecutor() 17 | ) { 18 | super( 19 | hypervisorDirectory, 20 | resourcesDirectory, 21 | 'qemu', 22 | architecture, 23 | input, 24 | configuration, 25 | executor 26 | ) 27 | } 28 | 29 | protected override async getIpAddress(): Promise { 30 | return 'localhost' 31 | } 32 | 33 | override get command(): string[] { 34 | const accelerators = this.accelerators.join(':') 35 | 36 | // prettier-ignore 37 | return [ 38 | this.hypervisorPath.toString(), 39 | '-daemonize', 40 | '-machine', `type=${this.configuration.machineType},accel=${accelerators}`, 41 | '-cpu', this.cpuFlagValue, 42 | '-smp', this.configuration.cpuCount.toString(), 43 | '-m', this.configuration.memory, 44 | 45 | '-device', `${this.netDevive},netdev=user.0`, 46 | '-netdev', this.netdev, 47 | 48 | '-display', 'none', 49 | '-monitor', 'none', 50 | '-serial', `file:${this.logFile}`, 51 | // '-nographic', 52 | 53 | '-boot', 'strict=off', 54 | ...this.firmwareFlags, 55 | ...this.hardDriverFlags 56 | ] 57 | } 58 | 59 | protected abstract get hardDriverFlags(): string[] 60 | 61 | protected get defaultHardDriveFlags(): string[] { 62 | // prettier-ignore 63 | return [ 64 | '-device', 'virtio-scsi-pci', 65 | 66 | '-device', 'scsi-hd,drive=drive0,bootindex=0', 67 | '-drive', `if=none,file=${this.configuration.diskImage},id=drive0,cache=unsafe,discard=ignore,format=raw`, 68 | 69 | '-device', 'scsi-hd,drive=drive1,bootindex=1', 70 | '-drive', `if=none,file=${this.configuration.resourcesDiskImage},id=drive1,cache=unsafe,discard=ignore,format=raw`, 71 | ] 72 | } 73 | 74 | protected get netDevive(): string { 75 | return 'virtio-net' 76 | } 77 | 78 | protected get ipv6(): string { 79 | return '' 80 | } 81 | 82 | protected get cpuidFlags(): string[] { 83 | return [] 84 | } 85 | 86 | protected get firmwareFlags(): string[] { 87 | return ['-bios', this.configuration.firmware!.toString()] 88 | } 89 | 90 | protected get accelerators(): string[] { 91 | return ['hvf', 'kvm', 'tcg'] 92 | } 93 | 94 | private get netdev(): string { 95 | return [ 96 | 'user', 97 | 'id=user.0', 98 | `hostfwd=tcp::${this.configuration.ssHostPort}-:22`, 99 | this.ipv6 100 | ] 101 | .filter(e => e !== '') 102 | .join(',') 103 | } 104 | 105 | private get cpuFlagValue(): string { 106 | return [this.configuration.cpu, ...this.cpuidFlags].join(',') 107 | } 108 | } 109 | 110 | export function resolve(implementation: Record): T { 111 | return getOrDefaultOrThrow(implementation, 'qemu') 112 | } 113 | -------------------------------------------------------------------------------- /src/resource_disk.ts: -------------------------------------------------------------------------------- 1 | import {promises as fs} from 'fs' 2 | import path from 'path' 3 | import * as os from 'os' 4 | 5 | import * as core from '@actions/core' 6 | import * as exec from '@actions/exec' 7 | 8 | import {Action} from './action/action' 9 | import {execWithOutput} from './utility' 10 | import type {OperatingSystem} from './operating_system' 11 | import {wait} from './wait' 12 | 13 | export default abstract class ResourceDisk { 14 | protected readonly operatingSystem: OperatingSystem 15 | 16 | private readonly mountName = 'RES' 17 | private readonly tempPath: string 18 | private mountPath!: string 19 | private devicePath!: string 20 | 21 | protected constructor(action: Action) { 22 | this.tempPath = action.tempPath 23 | this.operatingSystem = action.operatingSystem 24 | } 25 | 26 | static for(action: Action): ResourceDisk { 27 | const implementationClass = action.host.resolve({ 28 | macos: MacOs, 29 | linux: Linux 30 | }) 31 | 32 | return new implementationClass(action) 33 | } 34 | 35 | get diskPath(): string { 36 | return path.join(this.tempPath, 'res.raw') 37 | } 38 | 39 | async create(): Promise { 40 | core.debug('Creating resource disk') 41 | 42 | await this.createDiskFile('40m', this.diskPath) 43 | this.devicePath = await this.createDiskDevice(this.diskPath) 44 | await this.partitionDisk(this.devicePath, this.mountName) 45 | this.mountPath = await this.mountDisk(this.devicePath, this.baseMountPath) 46 | 47 | return this.mountPath 48 | } 49 | 50 | async unmount(): Promise { 51 | await this.unmountDisk() 52 | await this.detachDevice(this.devicePath) 53 | } 54 | 55 | protected abstract createDiskFile( 56 | size: string, 57 | diskPath: string 58 | ): Promise 59 | 60 | protected abstract createDiskDevice(diskPath: string): Promise 61 | 62 | protected abstract partitionDisk( 63 | devicePath: string, 64 | mountName: string 65 | ): Promise 66 | 67 | protected abstract mountDisk( 68 | devicePath: string, 69 | mountPath: string 70 | ): Promise 71 | 72 | protected abstract detachDevice(devicePath: string): Promise 73 | 74 | private get baseMountPath(): string { 75 | return path.join(this.tempPath, `mount/${this.mountName}`) 76 | } 77 | 78 | private async unmountDisk(): Promise { 79 | core.debug('Unmounting disk') 80 | await exec.exec('sudo', ['umount', this.mountPath]) 81 | } 82 | } 83 | 84 | class MacOs extends ResourceDisk { 85 | override async createDiskFile(size: string, diskPath: string): Promise { 86 | await exec.exec('mkfile', ['-n', size, diskPath]) 87 | } 88 | 89 | override async createDiskDevice(diskPath: string): Promise { 90 | const devicePath = await execWithOutput( 91 | 'hdiutil', 92 | [ 93 | 'attach', 94 | '-imagekey', 95 | 'diskimage-class=CRawDiskImage', 96 | '-nomount', 97 | diskPath 98 | ], 99 | {silent: true} 100 | ) 101 | 102 | return devicePath.trim() 103 | } 104 | 105 | override async partitionDisk( 106 | devicePath: string, 107 | mountName: string 108 | ): Promise { 109 | await exec.exec('diskutil', [ 110 | 'partitionDisk', 111 | devicePath, 112 | '1', 113 | 'GPT', 114 | 'fat32', 115 | mountName, 116 | '100%' 117 | ]) 118 | } 119 | 120 | override async mountDisk( 121 | _devicePath: string, 122 | mountPath: string 123 | ): Promise { 124 | return path.join('/Volumes', path.basename(mountPath)) 125 | } 126 | 127 | override async detachDevice(devicePath: string): Promise { 128 | const maxRetries = 150 129 | const waitTimeSeconds = 1 130 | 131 | for (let i = 0; i < maxRetries; i++) { 132 | try { 133 | await exec.exec('hdiutil', ['detach', devicePath]) 134 | return 135 | } catch (error: unknown) { 136 | const err = error as Error 137 | core.debug(`Failed to detach device: ${err.message}`) 138 | await wait(waitTimeSeconds * 1000) 139 | } 140 | } 141 | 142 | core.error(`Failed to detach device after ${maxRetries} retries`) 143 | } 144 | } 145 | 146 | class Linux extends ResourceDisk { 147 | override async createDiskFile(size: string, diskPath: string): Promise { 148 | core.debug('Creating disk file') 149 | await this.operatingSystem.linuxDiskFileCreator.create(size, diskPath) 150 | } 151 | 152 | override async createDiskDevice(diskPath: string): Promise { 153 | core.debug('Creating disk device') 154 | return await this.operatingSystem.linuxDiskDeviceCreator.create(diskPath) 155 | } 156 | 157 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 158 | async partitionDisk(devicePath: string, _mountName: string): Promise { 159 | core.debug('Partitioning disk') 160 | await exec.exec('sudo', ['mkfs.fat', '-F32', devicePath]) 161 | } 162 | 163 | async mountDisk(devicePath: string, mountPath: string): Promise { 164 | core.debug('Mounting disk') 165 | 166 | await fs.mkdir(mountPath, {recursive: true}) 167 | const uid = os.userInfo().uid 168 | await exec.exec('sudo', [ 169 | 'mount', 170 | '-o', 171 | `uid=${uid}`, 172 | devicePath, 173 | mountPath 174 | ]) 175 | 176 | return mountPath 177 | } 178 | 179 | async detachDevice(devicePath: string): Promise { 180 | core.debug('Detaching device') 181 | await exec.exec('sudo', ['losetup', '-d', devicePath]) 182 | } 183 | } 184 | 185 | export abstract class LinuxDiskFileCreator { 186 | async create(size: string, diskPath: string): Promise { 187 | await exec.exec('truncate', ['-s', size, diskPath]) 188 | await this.partition(diskPath) 189 | } 190 | 191 | /*protected*/ abstract partition(diskPath: string): Promise 192 | 193 | static readonly FdiskDiskFileCreator = class extends LinuxDiskFileCreator { 194 | /*protected*/ override async partition(diskPath: string): Promise { 195 | const input = Buffer.from('n\np\n1\n\n\nw\n') 196 | await exec.exec('fdisk', [diskPath], {input}) 197 | } 198 | } 199 | 200 | static readonly NoopDiskFileCreator = class extends LinuxDiskFileCreator { 201 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 202 | /*protected*/ override async partition(_diskPath: string): Promise {} 203 | } 204 | } 205 | 206 | export abstract class LinuxDiskDeviceCreator { 207 | async create(diskPath: string): Promise { 208 | // prettier-ignore 209 | const devicePath = await execWithOutput( 210 | 'sudo', 211 | [ 212 | 'losetup', 213 | '-f', 214 | '--show', 215 | '--offset', this.offset, 216 | '--sizelimit', this.sizeLimit, 217 | diskPath 218 | ], 219 | {silent: false} 220 | ) 221 | 222 | return devicePath.trim() 223 | } 224 | 225 | /*protected*/ abstract get offset(): string 226 | /*protected*/ abstract get sizeLimit(): string 227 | 228 | static readonly FdiskDiskDeviceCreator = class extends LinuxDiskDeviceCreator { 229 | // the offset and size limit are retrieved by running: 230 | // `sfdisk -d ${diskPath}` and multiply the start and size by 512. 231 | // https://checkmk.com/linux-knowledge/mounting-partition-loop-device 232 | 233 | /*protected*/ override get offset(): string { 234 | return '1048576' 235 | } 236 | 237 | override get sizeLimit(): string { 238 | return '40894464' 239 | } 240 | } 241 | 242 | static readonly FullDiskDeviceCreator = class extends LinuxDiskDeviceCreator { 243 | /*protected*/ override get offset(): string { 244 | return '0' 245 | } 246 | 247 | /*protected*/ override get sizeLimit(): string { 248 | return '0' 249 | } 250 | } 251 | } 252 | -------------------------------------------------------------------------------- /src/utility.ts: -------------------------------------------------------------------------------- 1 | import * as exec from '@actions/exec' 2 | import * as core from '@actions/core' 3 | 4 | export type Class = new (...args: any[]) => T 5 | 6 | export interface ExecuteOptions { 7 | log?: boolean 8 | ignoreReturnCode?: boolean 9 | silent?: boolean 10 | } 11 | 12 | export async function execWithOutput( 13 | commandLine: string, 14 | args?: string[], 15 | options: ExecuteOptions = {} 16 | ): Promise { 17 | let output = '' 18 | 19 | const exitCode = await exec.exec(commandLine, args, { 20 | silent: options.silent, 21 | ignoreReturnCode: options.ignoreReturnCode, 22 | listeners: { 23 | stdout: buffer => (output += buffer.toString()) 24 | } 25 | }) 26 | 27 | if (exitCode !== 0) 28 | throw Error(`Failed to executed command: ${commandLine} ${args?.join(' ')}`) 29 | 30 | return output.toString() 31 | } 32 | 33 | export function getOrDefaultOrThrow( 34 | record: Record, 35 | key: string 36 | ): V { 37 | const value = record[key] ?? record['default'] 38 | 39 | if (value === undefined) throw Error(`Missing key and no default key: ${key}`) 40 | 41 | return value 42 | } 43 | 44 | export function getOrThrow( 45 | map: ReadonlyMap, 46 | key: Key 47 | ): Value { 48 | const value = map.get(key) 49 | 50 | if (value === undefined) throw new Error(`Key not found: ${key}`) 51 | 52 | return value 53 | } 54 | 55 | export function getImplementation( 56 | object: Object, 57 | implementation: Record 58 | ): T { 59 | const name = object.constructor.name.toLocaleLowerCase() 60 | return getOrDefaultOrThrow(implementation, name) 61 | } 62 | 63 | export function group(name: string, block: () => void): void { 64 | try { 65 | core.startGroup(name) 66 | block() 67 | } finally { 68 | core.endGroup() 69 | } 70 | } 71 | 72 | export interface Executor { 73 | execute( 74 | commandLine: string, 75 | args?: string[], 76 | options?: exec.ExecOptions 77 | ): Promise 78 | } 79 | 80 | export class ExecExecutor implements Executor { 81 | async execute( 82 | commandLine: string, 83 | args?: string[], 84 | options?: exec.ExecOptions 85 | ): Promise { 86 | return await exec.exec(commandLine, args, options) 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/version.ts: -------------------------------------------------------------------------------- 1 | const version = { 2 | operating_system: { 3 | freebsd: 'v0.11.0', 4 | haiku: 'v0.0.1', 5 | netbsd: 'v0.5.0', 6 | openbsd: 'v0.10.0' 7 | }, 8 | 9 | resources: 'v0.11.0' 10 | } 11 | 12 | export default version 13 | -------------------------------------------------------------------------------- /src/vm.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs' 2 | import * as path from 'path' 3 | import {spawn} from 'child_process' 4 | 5 | import * as core from '@actions/core' 6 | import * as exec from '@actions/exec' 7 | 8 | import * as vm from './vm' 9 | import {ExecuteOptions, ExecExecutor, Executor} from './utility' 10 | import {wait} from './wait' 11 | import * as architecture from './architecture' 12 | import {Input} from './action/input' 13 | import { 14 | DefaultVmFileSystemSynchronizer, 15 | VmFileSystemSynchronizer 16 | } from './vm_file_system_synchronizer' 17 | 18 | export interface Configuration { 19 | memory: string 20 | cpuCount: number 21 | diskImage: fs.PathLike 22 | ssHostPort: number 23 | 24 | // qemu 25 | cpu: string 26 | machineType: string 27 | 28 | // xhyve 29 | uuid: string 30 | resourcesDiskImage: fs.PathLike 31 | userboot: fs.PathLike 32 | firmware?: fs.PathLike 33 | } 34 | 35 | interface Process { 36 | readonly pid: number 37 | readonly exitCode: number | null 38 | 39 | unref(): void 40 | } 41 | 42 | class LiveProcess implements Process { 43 | readonly exitCode: number | null = null 44 | private _pid?: number 45 | 46 | get pid(): number { 47 | if (this._pid !== undefined) return this._pid 48 | 49 | return (this._pid = +fs.readFileSync(Vm.pidfile, 'utf8')) 50 | } 51 | 52 | unref(): void { 53 | // noop 54 | } 55 | } 56 | 57 | export abstract class Vm { 58 | ipAddress!: string 59 | 60 | static readonly user = 'runner' 61 | static readonly cpaHost = 'cross_platform_actions_host' 62 | static readonly pidfile = '/tmp/cross-platform-actions.pid' 63 | private static _isRunning?: boolean 64 | 65 | readonly hypervisorPath: fs.PathLike 66 | protected readonly logFile: fs.PathLike = '/tmp/cross-platform-actions.log' 67 | protected vmProcess: Process = new LiveProcess() 68 | protected readonly architecture: architecture.Architecture 69 | protected readonly configuration: vm.Configuration 70 | protected readonly hypervisorDirectory: fs.PathLike 71 | protected readonly resourcesDirectory: fs.PathLike 72 | 73 | protected readonly input: Input 74 | 75 | private readonly executor: Executor 76 | private readonly vmFileSystemSynchronizer: VmFileSystemSynchronizer 77 | 78 | constructor( 79 | hypervisorDirectory: fs.PathLike, 80 | resourcesDirectory: fs.PathLike, 81 | hypervisorBinary: fs.PathLike, 82 | architecture: architecture.Architecture, 83 | input: Input, 84 | configuration: vm.Configuration, 85 | executor: Executor = new ExecExecutor() 86 | ) { 87 | this.hypervisorDirectory = hypervisorDirectory 88 | this.resourcesDirectory = resourcesDirectory 89 | this.architecture = architecture 90 | this.input = input 91 | this.configuration = configuration 92 | this.hypervisorPath = path.join( 93 | hypervisorDirectory.toString(), 94 | hypervisorBinary.toString() 95 | ) 96 | this.executor = executor 97 | this.vmFileSystemSynchronizer = new DefaultVmFileSystemSynchronizer({ 98 | input, 99 | user: this.user, 100 | executor 101 | }) 102 | } 103 | 104 | static get isRunning(): boolean { 105 | if (this._isRunning !== undefined) return this._isRunning 106 | 107 | return (this._isRunning = fs.existsSync(Vm.pidfile)) 108 | } 109 | 110 | async init(): Promise { 111 | core.info('Initializing VM') 112 | } 113 | 114 | async run(): Promise { 115 | core.info('Booting VM of type: ' + this.constructor.name) 116 | core.debug(this.command.join(' ')) 117 | this.vmProcess = spawn('sudo', this.command, { 118 | detached: false, 119 | stdio: ['ignore', 'inherit', 'inherit'] 120 | }) 121 | 122 | if (this.vmProcess.exitCode) { 123 | throw Error( 124 | `Failed to start VM process, exit code: ${this.vmProcess.exitCode}` 125 | ) 126 | } 127 | 128 | fs.writeFileSync(Vm.pidfile, this.vmProcess.pid.toString()) 129 | 130 | if (!this.input.shutdownVm) { 131 | this.vmProcess.unref() 132 | } 133 | 134 | this.ipAddress = await this.getIpAddress() 135 | } 136 | 137 | async wait(timeout: number): Promise { 138 | for (let index = 0; index < timeout; index++) { 139 | core.info('Waiting for VM to be ready...') 140 | 141 | const result = await this.execute('true', { 142 | /*log: false, 143 | silent: true,*/ 144 | ignoreReturnCode: true 145 | }) 146 | 147 | if (result === 0) { 148 | core.info('VM is ready') 149 | return 150 | } 151 | await wait(1000) 152 | } 153 | 154 | throw Error( 155 | `Waiting for VM to become ready timed out after ${timeout} seconds` 156 | ) 157 | } 158 | 159 | async terminate(): Promise { 160 | core.info('Terminating VM') 161 | return await exec.exec( 162 | 'sudo', 163 | ['kill', '-s', 'TERM', this.vmProcess.pid.toString()], 164 | {ignoreReturnCode: true} 165 | ) 166 | } 167 | 168 | async setupWorkDirectory( 169 | homeDirectory: string, 170 | workDirectory: string 171 | ): Promise { 172 | const homeDirectoryLinuxHost = `/home/${Vm.user}/work` 173 | 174 | await this.execute( 175 | `rm -rf '${homeDirectoryLinuxHost}' && ` + 176 | `sudo mkdir -p '${workDirectory}' && ` + 177 | `sudo chown -R '${Vm.user}' '${homeDirectory}' && ` + 178 | `ln -sf '${homeDirectory}/' '${homeDirectoryLinuxHost}'` 179 | ) 180 | } 181 | 182 | async execute( 183 | command: string, 184 | options: ExecuteOptions = {} 185 | ): Promise { 186 | const defaultOptions = {log: true} 187 | options = {...defaultOptions, ...options} 188 | if (options.log) core.info(`Executing command inside VM: ${command}`) 189 | const buffer = Buffer.from(command) 190 | 191 | return await this.executor.execute('ssh', ['-t', this.sshTarget], { 192 | input: buffer, 193 | silent: options.silent, 194 | ignoreReturnCode: options.ignoreReturnCode 195 | }) 196 | } 197 | 198 | async execute2(args: string[], intput: Buffer): Promise { 199 | return await this.executor.execute( 200 | 'ssh', 201 | ['-t', this.sshTarget].concat(args), 202 | { 203 | input: intput 204 | } 205 | ) 206 | } 207 | 208 | async synchronizePaths(...excludePaths: string[]): Promise { 209 | await this.vmFileSystemSynchronizer.synchronizePaths(...excludePaths) 210 | } 211 | 212 | async synchronizeBack(): Promise { 213 | await this.vmFileSystemSynchronizer.synchronizeBack() 214 | } 215 | 216 | protected async getIpAddress(): Promise { 217 | throw Error('Not implemented') 218 | } 219 | 220 | protected abstract get command(): string[] 221 | 222 | protected get user(): string { 223 | return 'runner' 224 | } 225 | 226 | private get sshTarget(): string { 227 | return `${this.user}@${Vm.cpaHost}` 228 | } 229 | } 230 | -------------------------------------------------------------------------------- /src/vm_file_system_synchronizer.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | import path from 'path' 3 | 4 | import flatMap from 'array.prototype.flatmap' 5 | 6 | import {Executor, ExecExecutor} from './utility' 7 | import {Input} from './action/input' 8 | import {SyncDirection} from './action/sync_direction' 9 | import * as vm from './vm' 10 | 11 | export interface VmFileSystemSynchronizer { 12 | synchronizePaths(...excludePaths: string[]): Promise 13 | synchronizeBack(): Promise 14 | } 15 | 16 | export class DefaultVmFileSystemSynchronizer 17 | implements VmFileSystemSynchronizer 18 | { 19 | private readonly executor: Executor 20 | private readonly input: Input 21 | private readonly cpaHost = vm.Vm.cpaHost 22 | private readonly workingDirectory: string 23 | private readonly user: string 24 | private readonly isDebug: boolean 25 | 26 | constructor({ 27 | input, 28 | user, 29 | executor = new ExecExecutor(), 30 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 31 | workingDirectory = process.env['GITHUB_WORKSPACE']!, 32 | isDebug = core.isDebug() 33 | }: { 34 | input: Input 35 | user: string 36 | executor?: Executor 37 | workingDirectory?: string 38 | isDebug?: boolean 39 | }) { 40 | this.input = input 41 | this.user = user 42 | this.executor = executor 43 | this.workingDirectory = workingDirectory 44 | this.isDebug = isDebug 45 | } 46 | 47 | async synchronizePaths(...excludePaths: string[]): Promise { 48 | if (!this.shouldSyncFiles) { 49 | return 50 | } 51 | 52 | core.debug(`Syncing files to VM, excluding: ${excludePaths}`) 53 | // prettier-ignore 54 | await this.executor.execute('rsync', [ 55 | this.rsyncFlags, 56 | '--exclude', '_actions/cross-platform-actions/action', 57 | ...flatMap(excludePaths, p => ['--exclude', p]), 58 | `${this.homeDirectory}/`, 59 | this.rsyncTarget 60 | ]) 61 | } 62 | 63 | async synchronizeBack(): Promise { 64 | if (!this.shouldSyncBack) return 65 | 66 | core.info('Syncing back files') 67 | // prettier-ignore 68 | await this.executor.execute('rsync', [ 69 | this.rsyncFlags, 70 | `${this.rsyncTarget}/`, 71 | this.homeDirectory 72 | ]) 73 | } 74 | 75 | private get shouldSyncFiles(): boolean { 76 | return ( 77 | this.input.syncFiles === SyncDirection.runner_to_vm || 78 | this.input.syncFiles === SyncDirection.both 79 | ) 80 | } 81 | 82 | private get shouldSyncBack(): boolean { 83 | return ( 84 | this.input.syncFiles === SyncDirection.vm_to_runner || 85 | this.input.syncFiles === SyncDirection.both 86 | ) 87 | } 88 | 89 | private get rsyncFlags(): string { 90 | return `-auz${this.syncVerboseFlag}` 91 | } 92 | 93 | private get syncVerboseFlag(): string { 94 | return this.isDebug ? 'v' : '' 95 | } 96 | 97 | private get homeDirectory(): string { 98 | const components = this.workingDirectory.split(path.sep).slice(0, -2) 99 | return path.join('/', ...components) 100 | } 101 | 102 | get rsyncTarget(): string { 103 | return `${this.user}@${this.cpaHost}:work` 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/wait.ts: -------------------------------------------------------------------------------- 1 | export async function wait(milliseconds: number): Promise { 2 | return new Promise(resolve => { 3 | if (isNaN(milliseconds)) { 4 | throw new Error('milliseconds not a number') 5 | } 6 | 7 | setTimeout(() => resolve('done!'), milliseconds) 8 | }) 9 | } 10 | -------------------------------------------------------------------------------- /src/xhyve_vm.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs' 2 | 3 | import * as core from '@actions/core' 4 | 5 | import * as vm from './vm' 6 | import {execWithOutput} from './utility' 7 | import {wait} from './wait' 8 | import * as architecture from './architecture' 9 | import {Input} from './action/input' 10 | 11 | export abstract class Vm extends vm.Vm { 12 | static readonly sshPort = 22 13 | macAddress!: string 14 | 15 | constructor( 16 | hypervisorDirectory: fs.PathLike, 17 | resourcesDirectory: fs.PathLike, 18 | architecture: architecture.Architecture, 19 | input: Input, 20 | configuration: vm.Configuration 21 | ) { 22 | super( 23 | hypervisorDirectory, 24 | resourcesDirectory, 25 | 'xhyve', 26 | architecture, 27 | input, 28 | configuration 29 | ) 30 | } 31 | 32 | override async init(): Promise { 33 | super.init() 34 | this.macAddress = await this.getMacAddress() 35 | } 36 | 37 | protected override async getIpAddress(): Promise { 38 | return getIpAddressFromArp(this.macAddress) 39 | } 40 | 41 | async getMacAddress(): Promise { 42 | core.debug('Getting MAC address') 43 | this.macAddress = ( 44 | await execWithOutput('sudo', this.command.concat('-M'), { 45 | silent: !core.isDebug() 46 | }) 47 | ) 48 | .trim() 49 | .slice(5) 50 | core.debug(`Found MAC address: '${this.macAddress}'`) 51 | return this.macAddress 52 | } 53 | 54 | /*override*/ get command(): string[] { 55 | const config = this.configuration 56 | 57 | // prettier-ignore 58 | return [ 59 | this.hypervisorPath.toString(), 60 | '-U', config.uuid, 61 | '-A', 62 | '-H', 63 | '-m', config.memory, 64 | '-c', config.cpuCount.toString(), 65 | '-s', '0:0,hostbridge', 66 | '-s', `2:0,${this.networkDevice}`, 67 | '-s', `4:0,virtio-blk,${config.diskImage}`, 68 | '-s', `4:1,virtio-blk,${config.resourcesDiskImage}`, 69 | '-s', '31,lpc', 70 | '-l', 'com1,stdio' 71 | ] 72 | } 73 | 74 | protected abstract get networkDevice(): string 75 | } 76 | 77 | export function extractIpAddress( 78 | arpOutput: string, 79 | macAddress: string 80 | ): string | undefined { 81 | core.debug('Extracing IP address') 82 | const matchResult = arpOutput 83 | .split('\n') 84 | .find(e => e.includes(macAddress)) 85 | ?.match(/\((.+)\)/) 86 | 87 | const ipAddress = matchResult ? matchResult[1] : undefined 88 | 89 | if (ipAddress !== undefined) core.info(`Found IP address: '${ipAddress}'`) 90 | 91 | return ipAddress 92 | } 93 | 94 | async function getIpAddressFromArp(macAddress: string): Promise { 95 | core.info(`Getting IP address for MAC address: ${macAddress}`) 96 | for (let i = 0; i < 500; i++) { 97 | core.info('Waiting for IP to become available...') 98 | const arpOutput = await execWithOutput('arp', ['-a', '-n'], {silent: true}) 99 | const ipAddress = extractIpAddress(arpOutput, macAddress) 100 | 101 | if (ipAddress !== undefined) return ipAddress 102 | 103 | await wait(1_000) 104 | } 105 | 106 | throw Error(`Failed to get IP address for MAC address: ${macAddress}`) 107 | } 108 | -------------------------------------------------------------------------------- /test/http/cross-platform-actions/freebsd-builder/releases/download/v0.3.0/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cross-platform-actions/action/4e93d86cd7b0c3bc2f6654d74d6ea45fbae526d3/test/http/cross-platform-actions/freebsd-builder/releases/download/v0.3.0/.gitkeep -------------------------------------------------------------------------------- /test/http/cross-platform-actions/netbsd-builder/releases/download/v0.1.0/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cross-platform-actions/action/4e93d86cd7b0c3bc2f6654d74d6ea45fbae526d3/test/http/cross-platform-actions/netbsd-builder/releases/download/v0.1.0/.gitkeep -------------------------------------------------------------------------------- /test/http/cross-platform-actions/openbsd-builder/releases/download/v0.5.0/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cross-platform-actions/action/4e93d86cd7b0c3bc2f6654d74d6ea45fbae526d3/test/http/cross-platform-actions/openbsd-builder/releases/download/v0.5.0/.gitkeep -------------------------------------------------------------------------------- /test/http/cross-platform-actions/resources/releases/download/v0.7.0/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cross-platform-actions/action/4e93d86cd7b0c3bc2f6654d74d6ea45fbae526d3/test/http/cross-platform-actions/resources/releases/download/v0.7.0/.gitkeep -------------------------------------------------------------------------------- /test/workflows/ci.yml.example: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: '*' 6 | tags: v* 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | test: # make sure the action works on a clean machine without building 13 | name: ${{ matrix.os.name }} ${{ matrix.os.version }} 14 | runs-on: ${{ matrix.os.host }} 15 | strategy: 16 | fail-fast: false 17 | matrix: 18 | os: 19 | - name: FreeBSD 20 | version: '12.2' 21 | host: ubuntu-latest 22 | workDirectory: /home/runner/work/action/action 23 | uname: 24 | hardware: amd64 25 | release: 12.2-RELEASE 26 | 27 | steps: 28 | - name: Checkout 29 | uses: actions/checkout@v2 30 | with: 31 | persist-credentials: false 32 | 33 | - run: | 34 | sudo apt update 35 | sudo apt install -y dosfstools rsync 36 | mkdir -p ${{ matrix.os.workDirectory }} 37 | mkdir -p /home/runner/work 38 | touch ${{ matrix.os.workDirectory }}/foo.txt 39 | 40 | - name: ${{ matrix.os.name }} 41 | uses: ./ 42 | env: 43 | FOO: A 44 | BAR: B 45 | with: 46 | environment_variables: FOO BAR 47 | operating_system: ${{ matrix.os.name }} 48 | version: '${{ matrix.os.version }}' 49 | run: | 50 | uname -a 51 | echo $SHELL 52 | pwd 53 | ls -lah 54 | whoami 55 | env | sort 56 | [ "`uname -s`" = '${{ matrix.os.name }}' ] 57 | [ "`uname -r`" = '${{ matrix.os.uname.release || matrix.os.version }}' ] 58 | [ "`uname -m`" = '${{ matrix.os.uname.hardware }}' ] 59 | [ "`pwd`" = '${{ matrix.os.workDirectory }}' ] 60 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */ 4 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ 5 | "outDir": "./lib", /* Redirect output structure to the directory. */ 6 | "rootDir": "./src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 7 | "strict": true, /* Enable all strict type-checking options. */ 8 | "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 9 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 10 | "sourceMap": true, 11 | "noImplicitOverride": true, 12 | "noFallthroughCasesInSwitch": true, 13 | "noImplicitReturns": true, 14 | "noPropertyAccessFromIndexSignature": true, 15 | "noUnusedLocals": true, 16 | "noUnusedParameters": true, 17 | "experimentalDecorators": true 18 | }, 19 | "exclude": ["node_modules", "**/*.spec.ts"] 20 | } 21 | --------------------------------------------------------------------------------