g1t's own CI runs its light jobs on the 2-core machine, and a pull request skips the desktop check and the site build when it doesn't touch them
Since ubuntu-latest became the 4-core machine in this public repository (#169), every job ran at $0.007 a minute, jobs that build nothing included. - Jobs that build nothing name g1t-2core ($0.004 a minute): Deploy's check, plan, migrations and smoke test; Caches' look and npm; Cache tidy; and the publish jobs of the plugin, action, desktop, CLI and runner releases. Builds, tests and Deploy's stages stay on ubuntu-latest (4 vCPU). Conformance stays on ubuntu-latest, being the same file GitHub runs. - `ci-affected.mjs --job desktop|build` says whether a pull request needs the job. Desktop runs for the app's folder, its release scripts and the build's own files (DESKTOP). Build (the site, sudo and the docs) runs for anything but a change of Rust source alone. A job that isn't needed passes without running, so CI still reports every job. TypeScript runs on every pull request, because its tests and the conformance suite read Rust source and manifests throughout. main's runs run everything, as before. - The prune check in repository_workflows.rs accepts the desktop job's gate before its cache-hit condition. Measured over 100 runs today: Rust, Deploy's builds and TypeScript are ~70% of the minutes and need the 4-core machine; the light jobs were ~8%, and Desktop and Build ~16%. Checked: node --test scripts/deploy/ci-affected.test.mjs; cargo test -p g1t-actions --test repository_workflows; prettier; git diff --check. Closes #342
- G1t-Session:
- ses_01m5gahajbescr7pk21fqg3y0q
| 53 | 53 | jobs: | |
| 54 | 54 | publish: | |
| 55 | 55 | name: Publish g1t/<action> | |
| 56 | − | runs-on: ubuntu-latest | |
| 56 | + | runs-on: g1t-2core # builds nothing: the 2-vCPU machine is enough | |
| 57 | 57 | timeout-minutes: 15 | |
| 58 | 58 | steps: | |
| 59 | 59 | - uses: actions/checkout@v5 |
| 24 | 24 | jobs: | |
| 25 | 25 | tidy: | |
| 26 | 26 | name: Replaced entries | |
| 27 | − | runs-on: ubuntu-latest | |
| 27 | + | runs-on: g1t-2core # builds nothing: the 2-vCPU machine is enough | |
| 28 | 28 | timeout-minutes: 10 | |
| 29 | 29 | steps: | |
| 30 | 30 | - uses: actions/checkout@v5 |
| 46 | 46 | jobs: | |
| 47 | 47 | look: | |
| 48 | 48 | name: What is missing | |
| 49 | − | runs-on: ubuntu-latest | |
| 49 | + | runs-on: g1t-2core # builds nothing: the 2-vCPU machine is enough | |
| 50 | 50 | timeout-minutes: 10 | |
| 51 | 51 | outputs: | |
| 52 | 52 | npm: ${{ steps.npm.outputs.cache-hit != 'true' }} | |
| ⋯ | |||
| 174 | 174 | name: npm's downloads | |
| 175 | 175 | needs: look | |
| 176 | 176 | if: ${{ needs.look.outputs.npm == 'true' }} | |
| 177 | − | runs-on: ubuntu-latest | |
| 177 | + | runs-on: g1t-2core # builds nothing: the 2-vCPU machine is enough | |
| 178 | 178 | timeout-minutes: 20 | |
| 179 | 179 | steps: | |
| 180 | 180 | - uses: actions/checkout@v5 | |
| 7 | 7 | # typescript type checks and tests of the apps and TS services, the | |
| 8 | 8 | # deploy and ops scripts, and the deploy manifest | |
| 9 | 9 | # build the site, sudo and the docs build as they deploy | |
| 10 | + | # desktop the desktop app's checks and tests, and the release scripts' | |
| 11 | + | # | |
| 12 | + | # On a pull request, `rust`, `build` and `desktop` run only for what it | |
| 13 | + | # changes (scripts/deploy/ci-affected.mjs) and pass without running | |
| 14 | + | # otherwise; `typescript` always runs. | |
| 10 | 15 | # | |
| 11 | 16 | # On a push to main `rust`, `typescript` and `desktop` run, keeping main's | |
| 12 | 17 | # caches current: | |
| ⋯ | |||
| 230 | 235 | runs-on: ubuntu-latest | |
| 231 | 236 | timeout-minutes: 30 | |
| 232 | 237 | steps: | |
| 238 | + | # Two commits deep, as for the Rust parts: what the pull request | |
| 239 | + | # changes. A change of only Rust source, which none of the three | |
| 240 | + | # reads, passes without building (ci-affected.mjs JOBS). | |
| 233 | 241 | - uses: actions/checkout@v5 | |
| 242 | + | with: | |
| 243 | + | fetch-depth: 2 | |
| 244 | + | - name: Whether this pull request needs it | |
| 245 | + | id: affected | |
| 246 | + | run: node scripts/deploy/ci-affected.mjs --job build | |
| 234 | 247 | # npm's downloads, kept between runs: an install reads them first | |
| 235 | 248 | # and asks the registry only for what changed, so a dropped | |
| 236 | 249 | # connection to it rarely stops the job. | |
| 237 | 250 | - name: Cache npm's downloads | |
| 251 | + | if: ${{ steps.affected.outputs.run == 'true' }} | |
| 238 | 252 | uses: actions/cache@v4 | |
| 239 | 253 | with: | |
| 240 | 254 | path: ~/.npm | |
| 241 | 255 | key: npm-full-${{ runner.os }}-${{ hashFiles('package-lock.json') }} | |
| 242 | 256 | restore-keys: npm-full-${{ runner.os }}- | |
| 243 | 257 | - name: Install | |
| 258 | + | if: ${{ steps.affected.outputs.run == 'true' }} | |
| 244 | 259 | run: node scripts/deploy/npm-ci.mjs --prefer-offline --no-audit --no-fund | |
| 245 | 260 | - name: The site, sudo and the docs | |
| 261 | + | if: ${{ steps.affected.outputs.run == 'true' }} | |
| 246 | 262 | run: node scripts/deploy.mjs build --only web,sudo,docs | |
| 247 | 263 | ||
| 248 | 264 | desktop: | |
| ⋯ | |||
| 252 | 268 | runs-on: ubuntu-latest | |
| 253 | 269 | timeout-minutes: 30 | |
| 254 | 270 | steps: | |
| 271 | + | # A pull request that changes none of the app's files, its release | |
| 272 | + | # scripts or the build's own (ci-affected.mjs DESKTOP) passes without | |
| 273 | + | # building; main's runs build it, keeping its cache. | |
| 255 | 274 | - uses: actions/checkout@v5 | |
| 275 | + | with: | |
| 276 | + | fetch-depth: 2 | |
| 277 | + | - name: Whether this pull request needs it | |
| 278 | + | id: affected | |
| 279 | + | run: node scripts/deploy/ci-affected.mjs --job desktop | |
| 256 | 280 | - name: Cache keys | |
| 257 | 281 | id: keys | |
| 282 | + | if: ${{ steps.affected.outputs.run == 'true' }} | |
| 258 | 283 | run: node scripts/deploy/ci-cache.mjs keys | |
| 259 | 284 | # g1t's runner image has Tauri's system packages, so on it the two | |
| 260 | 285 | # steps after this are skipped (they took 68 s a run: a minute to | |
| ⋯ | |||
| 262 | 287 | # them (self-hosted, or an older image) installs them as before. | |
| 263 | 288 | - name: Is what Tauri needs on Linux installed | |
| 264 | 289 | id: tauri-apt | |
| 290 | + | if: ${{ steps.affected.outputs.run == 'true' }} | |
| 265 | 291 | run: bash scripts/apt-install.sh tauri --check | |
| 266 | 292 | # apt's lists and downloads (scripts/apt-install.sh): 110 s of every | |
| 267 | 293 | # run before, mostly fetching 335 MB. Saved here, on main; the desktop | |
| 268 | 294 | # release's Linux build restores the same entry. | |
| 269 | 295 | - name: Cache apt's downloads | |
| 270 | − | if: ${{ steps.tauri-apt.outputs.installed != 'true' }} | |
| 296 | + | if: ${{ steps.affected.outputs.run == 'true' && steps.tauri-apt.outputs.installed != 'true' }} | |
| 271 | 297 | uses: actions/cache@v4 | |
| 272 | 298 | with: | |
| 273 | 299 | path: ~/.cache/apt/tauri | |
| 274 | 300 | key: apt-tauri-${{ runner.os }}-${{ hashFiles('scripts/apt-install.sh') }} | |
| 275 | 301 | - name: What Tauri needs on Linux | |
| 276 | − | if: ${{ steps.tauri-apt.outputs.installed != 'true' }} | |
| 302 | + | if: ${{ steps.affected.outputs.run == 'true' && steps.tauri-apt.outputs.installed != 'true' }} | |
| 277 | 303 | run: bash scripts/apt-install.sh tauri | |
| 278 | 304 | - name: Cache the desktop app's build | |
| 279 | 305 | id: desktop-build | |
| 306 | + | if: ${{ steps.affected.outputs.run == 'true' }} | |
| 280 | 307 | uses: actions/cache@v4 | |
| 281 | 308 | with: | |
| 282 | 309 | path: | | |
| ⋯ | |||
| 289 | 316 | # Outside the workspace on purpose (Cargo.toml says why): checked and | |
| 290 | 317 | # tested here, built into installers by desktop-release.yml. | |
| 291 | 318 | - name: Check and test | |
| 319 | + | if: ${{ steps.affected.outputs.run == 'true' }} | |
| 292 | 320 | run: cargo test --locked --manifest-path apps/desktop/src-tauri/Cargo.toml --quiet | |
| 293 | 321 | - name: Only this build's files are saved | |
| 294 | − | if: ${{ steps.desktop-build.outputs.cache-hit != 'true' }} | |
| 322 | + | if: ${{ steps.affected.outputs.run == 'true' && steps.desktop-build.outputs.cache-hit != 'true' }} | |
| 295 | 323 | run: node scripts/deploy/ci-cache.mjs prune apps/desktop/src-tauri/target/debug -- test --locked --manifest-path apps/desktop/src-tauri/Cargo.toml --no-run | |
| 296 | 324 | - name: The release scripts' tests | |
| 325 | + | if: ${{ steps.affected.outputs.run == 'true' }} | |
| 297 | 326 | run: node --test scripts/desktop-release.test.mjs scripts/mobile-release.test.mjs scripts/cli-release.test.mjs | |
| 85 | 85 | publish: | |
| 86 | 86 | name: Sign and publish | |
| 87 | 87 | needs: build | |
| 88 | − | runs-on: ubuntu-latest | |
| 88 | + | runs-on: g1t-2core # builds nothing: the 2-vCPU machine is enough | |
| 89 | 89 | # Production's secrets, without a deployment: a release is not a deploy | |
| 90 | 90 | # of g1t.sh. | |
| 91 | 91 | environment: |
| 84 | 84 | jobs: | |
| 85 | 85 | check: | |
| 86 | 86 | name: Check | |
| 87 | − | runs-on: ubuntu-latest | |
| 87 | + | runs-on: g1t-2core # builds nothing: the 2-vCPU machine is enough | |
| 88 | 88 | timeout-minutes: 20 | |
| 89 | 89 | steps: | |
| 90 | 90 | - uses: actions/checkout@v5 | |
| ⋯ | |||
| 109 | 109 | # Runs beside check: nothing deploys until both have succeeded. | |
| 110 | 110 | plan: | |
| 111 | 111 | name: Plan | |
| 112 | − | runs-on: ubuntu-latest | |
| 112 | + | runs-on: g1t-2core # builds nothing: the 2-vCPU machine is enough | |
| 113 | 113 | # Production's secrets, without a deployment: planning deploys nothing. | |
| 114 | 114 | environment: | |
| 115 | 115 | name: production | |
| ⋯ | |||
| 271 | 271 | name: Migrations | |
| 272 | 272 | needs: [check, plan] | |
| 273 | 273 | if: ${{ needs.plan.outputs.migrate == 'true' && inputs.dry_run != true }} | |
| 274 | − | runs-on: ubuntu-latest | |
| 274 | + | runs-on: g1t-2core # builds nothing: the 2-vCPU machine is enough | |
| 275 | 275 | environment: | |
| 276 | 276 | name: production | |
| 277 | 277 | url: https://g1t.sh | |
| ⋯ | |||
| 453 | 453 | name: Smoke | |
| 454 | 454 | needs: [check, plan, core, edge, front] | |
| 455 | 455 | if: ${{ !failure() && !cancelled() && inputs.dry_run != true && (needs.plan.outputs.has_core == 'true' || needs.plan.outputs.has_edge == 'true' || needs.plan.outputs.has_front == 'true') }} | |
| 456 | − | runs-on: ubuntu-latest | |
| 456 | + | runs-on: g1t-2core # builds nothing: the 2-vCPU machine is enough | |
| 457 | 457 | timeout-minutes: 5 | |
| 458 | 458 | steps: | |
| 459 | 459 | - uses: actions/checkout@v5 | |
| 47 | 47 | jobs: | |
| 48 | 48 | version: | |
| 49 | 49 | name: The tag names this version | |
| 50 | − | runs-on: ubuntu-latest | |
| 50 | + | runs-on: g1t-2core # builds nothing: the 2-vCPU machine is enough | |
| 51 | 51 | outputs: | |
| 52 | 52 | version: ${{ steps.read.outputs.version }} | |
| 53 | 53 | steps: | |
| ⋯ | |||
| 292 | 292 | && contains(fromJSON('["success","skipped"]'), needs.windows.result) | |
| 293 | 293 | && contains(fromJSON('["success","skipped"]'), needs.windows_cross.result) | |
| 294 | 294 | && contains(fromJSON('["success","skipped"]'), needs.macos.result) | |
| 295 | − | runs-on: ubuntu-latest | |
| 295 | + | runs-on: g1t-2core # builds nothing: the 2-vCPU machine is enough | |
| 296 | 296 | # Production's secrets, without a deployment: a release is not a deploy of | |
| 297 | 297 | # g1t.sh, and would otherwise show on its card as one in progress. | |
| 298 | 298 | environment: | |
| 50 | 50 | jobs: | |
| 51 | 51 | publish: | |
| 52 | 52 | name: Publish flagon-io/g1t-plugins | |
| 53 | − | runs-on: ubuntu-latest | |
| 53 | + | runs-on: g1t-2core # builds nothing: the 2-vCPU machine is enough | |
| 54 | 54 | timeout-minutes: 10 | |
| 55 | 55 | steps: | |
| 56 | 56 | - uses: actions/checkout@v5 |
| 231 | 231 | # The binaries go out even when an image job failed: the manifest then | |
| 232 | 232 | # names no sandbox images, and computers keep the sandbox they have. | |
| 233 | 233 | if: ${{ always() && needs.binaries.result == 'success' }} | |
| 234 | − | runs-on: ubuntu-latest | |
| 234 | + | runs-on: g1t-2core # builds nothing: the 2-vCPU machine is enough | |
| 235 | 235 | environment: | |
| 236 | 236 | name: production | |
| 237 | 237 | deployment: false |
| 489 | 489 | // restores, and a save step of the same entry after the prune saves | |
| 490 | 490 | // when it runs. | |
| 491 | 491 | if saves(&job.steps[at]) { | |
| 492 | − | assert_eq!( | |
| 493 | − | condition, | |
| 494 | − | format!("${{{{ steps.{cache}.outputs.cache-hit != 'true' }}}}"), | |
| 495 | − | "{file} {id}" | |
| 492 | + | // A job a pull request may pass without running (ci-affected.mjs | |
| 493 | + | // --job) asks that too: skipped, the cache step says no hit. | |
| 494 | + | let hit = format!("steps.{cache}.outputs.cache-hit != 'true' }}}}"); | |
| 495 | + | assert!( | |
| 496 | + | [ | |
| 497 | + | format!("${{{{ {hit}"), | |
| 498 | + | format!("${{{{ steps.affected.outputs.run == 'true' && {hit}"), | |
| 499 | + | ] | |
| 500 | + | .contains(&condition.to_string()), | |
| 501 | + | "{file} {id}: {condition}" | |
| 496 | 502 | ); | |
| 497 | 503 | } else { | |
| 498 | 504 | let key = &job.steps[at].raw["with"]["key"]; |
| 5 | 5 | // and a part compiled and tested all of its own whatever changed. | |
| 6 | 6 | // | |
| 7 | 7 | // node scripts/deploy/ci-affected.mjs -p <crate>... | |
| 8 | + | // node scripts/deploy/ci-affected.mjs --job desktop|build | |
| 9 | + | // | |
| 10 | + | // With --job it says whether a pull request needs one of CI's other jobs | |
| 11 | + | // (JOBS), as the output `run` (true or false), on the same terms: not a | |
| 12 | + | // pull request, or anything failing, runs it. | |
| 8 | 13 | // | |
| 9 | 14 | // It takes the part's `-p` arguments and writes to the step's outputs | |
| 10 | 15 | // (GITHUB_OUTPUT): | |
| ⋯ | |||
| 81 | 86 | ["crates/scan/fixtures/", ["g1t-security"]], | |
| 82 | 87 | ]; | |
| 83 | 88 | ||
| 89 | + | /** | |
| 90 | + | * The desktop job's files: the app, outside the workspace with a lockfile | |
| 91 | + | * of its own and no path dependency on it; the release scripts whose tests | |
| 92 | + | * the job runs, and the CLI's manifest cli-release.mjs reads its version | |
| 93 | + | * from; what installs Tauri's packages; and the toolchain and this | |
| 94 | + | * workflow's own files. | |
| 95 | + | */ | |
| 96 | + | export const DESKTOP = [ | |
| 97 | + | "apps/desktop/", | |
| 98 | + | "scripts/desktop-release.mjs", | |
| 99 | + | "scripts/desktop-release.test.mjs", | |
| 100 | + | "scripts/mobile-release.mjs", | |
| 101 | + | "scripts/mobile-release.test.mjs", | |
| 102 | + | "scripts/cli-release.mjs", | |
| 103 | + | "scripts/cli-release.test.mjs", | |
| 104 | + | "crates/g1t/Cargo.toml", | |
| 105 | + | "scripts/apt-install.sh", | |
| 106 | + | "rust-toolchain", | |
| 107 | + | "rust-toolchain.toml", | |
| 108 | + | ".cargo/", | |
| 109 | + | ".g1t/workflows/ci.yml", | |
| 110 | + | "scripts/deploy/ci-affected.mjs", | |
| 111 | + | "scripts/deploy/ci-cache.mjs", | |
| 112 | + | ]; | |
| 113 | + | ||
| 114 | + | /** | |
| 115 | + | * CI's other jobs a pull request may pass without running, and whether a | |
| 116 | + | * changed file needs each: `desktop` its own files (DESKTOP), `build` (the | |
| 117 | + | * site, sudo and the docs) any file but Rust source, which none of them | |
| 118 | + | * reads. TypeScript runs on every pull request: its tests and the | |
| 119 | + | * conformance suite read Rust source and manifests throughout. | |
| 120 | + | */ | |
| 121 | + | export const JOBS = { | |
| 122 | + | desktop: (file) => DESKTOP.some((entry) => under(file, entry)), | |
| 123 | + | build: (file) => !file.endsWith(".rs"), | |
| 124 | + | }; | |
| 125 | + | ||
| 126 | + | /** Whether a change to `files` needs `job` (a key of JOBS) to run. */ | |
| 127 | + | export function needs(job, files) { | |
| 128 | + | return files.some(JOBS[job]); | |
| 129 | + | } | |
| 130 | + | ||
| 84 | 131 | /** Whether `path` is `entry`, or under it when `entry` is a folder. */ | |
| 85 | 132 | function under(path, entry) { | |
| 86 | 133 | return entry.endsWith("/") ? path.startsWith(entry) : path === entry; | |
| ⋯ | |||
| 155 | 202 | return { files }; | |
| 156 | 203 | } | |
| 157 | 204 | ||
| 205 | + | /** `--job <name>`: writes `run`, whether this run needs the job. */ | |
| 206 | + | function mainJob(job) { | |
| 207 | + | if (!JOBS[job]) throw new Error(`no job ${job}: one of ${Object.keys(JOBS).join(", ")}`); | |
| 208 | + | let run = true; | |
| 209 | + | try { | |
| 210 | + | const { files, why } = changedFiles(); | |
| 211 | + | if (files) { | |
| 212 | + | run = needs(job, files); | |
| 213 | + | console.log(`${files.length} files changed; ${run ? `the ${job} job's among them` : `none of them the ${job} job's, so it passes without running`}`); | |
| 214 | + | } else { | |
| 215 | + | console.log(why.replace("tests every crate", `runs the ${job} job`)); | |
| 216 | + | } | |
| 217 | + | } catch (error) { | |
| 218 | + | console.log(`::warning title=Affected files::${error.message.split("\n")[0]}; the ${job} job runs`); | |
| 219 | + | run = true; | |
| 220 | + | } | |
| 221 | + | if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, `run=${run}\n`); | |
| 222 | + | } | |
| 223 | + | ||
| 158 | 224 | function main(args) { | |
| 225 | + | if (args[0] === "--job") return mainJob(args[1]); | |
| 159 | 226 | const part = partCrates(args); | |
| 160 | 227 | let test = part; | |
| 161 | 228 | try { | |
| 3 | 3 | import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; | |
| 4 | 4 | import { dirname, join, relative, resolve, sep } from "node:path"; | |
| 5 | 5 | import { fileURLToPath } from "node:url"; | |
| 6 | − | import { EVERYTHING, READS, affected, partCrates, workspace } from "./ci-affected.mjs"; | |
| 6 | + | import { DESKTOP, EVERYTHING, READS, affected, needs, partCrates, workspace } from "./ci-affected.mjs"; | |
| 7 | 7 | ||
| 8 | 8 | const ROOT = fileURLToPath(new URL("../..", import.meta.url)); | |
| 9 | 9 | ||
| ⋯ | |||
| 50 | 50 | assert.deepEqual(affected(CRATES, []), []); | |
| 51 | 51 | }); | |
| 52 | 52 | ||
| 53 | + | test("the desktop job runs for its own files and the build's, and for nothing else", () => { | |
| 54 | + | for (const file of [ | |
| 55 | + | "apps/desktop/src-tauri/src/main.rs", | |
| 56 | + | "apps/desktop/package.json", | |
| 57 | + | "scripts/cli-release.test.mjs", | |
| 58 | + | "scripts/apt-install.sh", | |
| 59 | + | ".g1t/workflows/ci.yml", | |
| 60 | + | ]) { | |
| 61 | + | assert.equal(needs("desktop", ["crates/kit/src/lib.rs", file]), true, file); | |
| 62 | + | } | |
| 63 | + | assert.equal(needs("desktop", ["crates/kit/src/lib.rs", "apps/web/app/root.tsx", "Cargo.lock", "apps/desktopper/x.ts"]), false); | |
| 64 | + | }); | |
| 65 | + | ||
| 66 | + | test("the site, sudo and the docs build for any change but Rust source alone", () => { | |
| 67 | + | assert.equal(needs("build", ["crates/kit/src/lib.rs", "services/work/src/items.rs"]), false); | |
| 68 | + | assert.equal(needs("build", ["crates/kit/src/lib.rs", "crates/kit/Cargo.toml"]), true); | |
| 69 | + | assert.equal(needs("build", ["apps/docs/src/content/docs/index.md"]), true); | |
| 70 | + | assert.equal(needs("build", []), false); | |
| 71 | + | }); | |
| 72 | + | ||
| 73 | + | test("every file the desktop job names is there", () => { | |
| 74 | + | // The toolchain's files, like EVERYTHING's, are there only when pinned. | |
| 75 | + | for (const entry of DESKTOP.filter((e) => !EVERYTHING.includes(e))) assert.ok(existsSync(join(ROOT, entry)), entry); | |
| 76 | + | // Its tests run each release script's test file. | |
| 77 | + | const ci = readFileSync(join(ROOT, ".g1t/workflows/ci.yml"), "utf8"); | |
| 78 | + | const tests = ci.match(/node --test scripts\/desktop-release\.test\.mjs.*/)[0]; | |
| 79 | + | for (const test of tests.match(/scripts\/[\w-]+\.test\.mjs/g)) assert.ok(DESKTOP.includes(test), test); | |
| 80 | + | // Outside the workspace, with no path dependency on it. | |
| 81 | + | assert.doesNotMatch(readFileSync(join(ROOT, "apps/desktop/src-tauri/Cargo.toml"), "utf8"), /\bpath\s*=/); | |
| 82 | + | }); | |
| 83 | + | ||
| 53 | 84 | test("a part's crates are read from its -p arguments", () => { | |
| 54 | 85 | assert.deepEqual(partCrates(["-p", "g1t-kit", "-p", "g1t"]), ["g1t-kit", "g1t"]); | |
| 55 | 86 | assert.throws(() => partCrates(["-p", "g1t-kit", "g1t"])); | |
Comments
Sign in to comment.