diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml deleted file mode 100644 index 8641cab3..00000000 --- a/.github/actionlint.yaml +++ /dev/null @@ -1,3 +0,0 @@ -self-hosted-runner: - labels: - - blacksmith-* diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 5c2e998b..d3b25c46 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -15,7 +15,7 @@ jobs: # PR/manual checks use macOS because static.ts needs plutil. Direct pushes # require mise run verify locally; a green release job does not prove tests. if: ${{ github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' }} - runs-on: blacksmith-6vcpu-macos-latest + runs-on: macos-26 timeout-minutes: 20 permissions: contents: read @@ -49,7 +49,7 @@ jobs: release: name: GitHub Release if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && !contains(github.event.head_commit.message, '[skip ci]') }} - runs-on: blacksmith-2vcpu-ubuntu-2404-arm + runs-on: ubuntu-24.04-arm timeout-minutes: 15 environment: release permissions: diff --git a/docs/github-pipelines.md b/docs/github-pipelines.md index 5ef58676..d0fa9901 100644 --- a/docs/github-pipelines.md +++ b/docs/github-pipelines.md @@ -7,9 +7,15 @@ creates tag-only GitHub Releases. | Workflow | Trigger | Contract | | --- | --- | --- | -| Verify | Push to `main`, pull request, manual dispatch | Run every deterministic domain in parallel through `./scripts/verify/run.ts --skip-security` on macOS. Pushes to `main` skip this job and run only release evaluation. | +| Verify | Push to `main`, pull request, manual dispatch | Run every deterministic domain with bounded concurrency through `./scripts/verify/run.ts --skip-security` on macOS. Pushes to `main` skip this job and run only release evaluation. | | Scan | Pull request, weekly schedule, manual dispatch | Call the shared `uinaf/.github` scan workflow: Gitleaks, TruffleHog, Actionlint, and Zizmor against full Git history. | +This public repository uses standard GitHub-hosted runners: `macos-26` for +native macOS repository checks and `ubuntu-24.04-arm` for release evaluation. +Both jobs retain ARM64 execution. The verification runner admits at most four +checks at once, reserving one logical CPU where available because checks spawn +their own workers. + CI does not use path filters. Repository checks and secret scans do not run on push: pull requests verify and scan before merge, and the weekly schedule scans history. Direct pushes require `mise run verify` locally before pushing. diff --git a/scripts/verify/run.ts b/scripts/verify/run.ts index 0e80b11a..f6ad136c 100755 --- a/scripts/verify/run.ts +++ b/scripts/verify/run.ts @@ -3,6 +3,7 @@ import { NodeServices } from "@effect/platform-node"; import { Clock, Console, Effect, FileSystem, Schema } from "effect"; import { dirname, resolve } from "node:path"; +import { availableParallelism } from "node:os"; import { fileURLToPath } from "node:url"; import { CommandRunner } from "../lib/command.ts"; import { CliFailure, fail, runMain } from "../lib/program.ts"; @@ -44,7 +45,7 @@ const usageText = `Usage: scripts/verify/run.ts [--skip-security] [--domain NAME ...] scripts/verify/run.ts --list [--json] -Without --domain, runs every deterministic check in parallel. The full-history +Without --domain, runs every deterministic check with bounded concurrency. The full-history secret scan runs afterwards unless --skip-security is set. A focused domain omits complete-only parity checks; request --domain security explicitly for secret scans. @@ -126,7 +127,10 @@ const runCheck = Effect.fn("runCheck")(function*(check: Check, timeoutMs = 300_0 return { check, durationMs: finished - started, ...result }; }); -export const runChecks = Effect.fn("runChecks")(function*(checks: readonly Check[], timeoutMs = 300_000) { +// Checks can spawn their own workers; reserve capacity for their children and reporting. +const checkConcurrency = Math.max(1, Math.min(4, availableParallelism() - 1)); + +export const runChecks = Effect.fn("runChecks")(function*(checks: readonly Check[], timeoutMs = 300_000, concurrency = checkConcurrency) { const results = yield* Effect.forEach(checks, (check) => runCheck(check, timeoutMs).pipe( Effect.tap((result) => Effect.gen(function*() { const seconds = (result.durationMs / 1000).toFixed(2); @@ -140,7 +144,7 @@ export const runChecks = Effect.fn("runChecks")(function*(checks: readonly Check process.stderr.write(`FAILED: ${result.check.id} exited ${result.status} (${seconds}s)\n`); }); })), - ), { concurrency: "unbounded" }); + ), { concurrency }); return results.every((result) => result.status === 0); }); diff --git a/scripts/verify/runner.test.ts b/scripts/verify/runner.test.ts index 07eff244..4ec6aac8 100644 --- a/scripts/verify/runner.test.ts +++ b/scripts/verify/runner.test.ts @@ -39,7 +39,7 @@ test("verification reports completed checks before a stalled child and cleans it `], output: "failure" }, { id: "success", domain: "static", command: [process.execPath, "-e", ""], output: "success" }, { id: "stalled", domain: "static", command: [process.execPath, "-e", `require('node:fs').writeFileSync(${JSON.stringify(pidFile)}, String(process.pid)); process.stdout.write('progress before stall\\n'); process.stderr.write('warning before stall\\n'); setTimeout(() => process.exit(8), 9000)`], output: "timeout" }, - ], 1500).pipe(Effect.provide(CommandRunner.layer), Effect.provide(NodeServices.layer))); + ], 1500, 3).pipe(Effect.provide(CommandRunner.layer), Effect.provide(NodeServices.layer))); assert.equal(result, false); assert.equal(failedBeforeTermination, true); assert.ok(output.join("").indexOf("FAILED: quick") < output.join("").indexOf("FAILED: stalled")); @@ -55,3 +55,29 @@ test("verification reports completed checks before a stalled child and cleans it rmSync(root, { recursive: true, force: true }); } }); + +test("verification bounds active checks and drains queued checks after failure", async () => { + let active = 0; + let peak = 0; + const completed: string[] = []; + const runner = CommandRunner.of({ + run: (command) => Effect.gen(function*() { + active += 1; + peak = Math.max(peak, active); + yield* Effect.yieldNow; + active -= 1; + completed.push(command); + return { status: command === "first" ? 7 : 0, stdout: "", stderr: "" }; + }), + }); + const checks = ["first", "second", "third", "fourth"].map((id) => ({ + id, domain: "static", command: [id] as [string], output: id, + })); + const result = await Effect.runPromise(runChecks(checks, 300_000, 2).pipe( + Effect.provideService(CommandRunner, runner), + )); + assert.equal(result, false); + assert.equal(peak, 2); + assert.equal(active, 0); + assert.deepEqual(completed.sort(), ["first", "fourth", "second", "third"]); +});