Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions .github/scripts/ci-shell.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { readFileSync } from "node:fs";
import test from "node:test";

const workflow = readFileSync(new URL("../workflows/ci.yml", import.meta.url), "utf8");

test("cross-platform build and test jobs retain fail-fast bash", () => {
for (const name of ["build", "test"]) {
const job = workflow.match(new RegExp(`^ ${name}:\\n([\\s\\S]*?)(?=^ [\\w-]+:|$(?![\\s\\S]))`, "m"))?.[1];
assert.ok(job, `missing ${name} job`);
assert.match(job, /^ defaults:\n run:\n shell: bash$/m);
const steps = job.split(/^ steps:\n/m)[1];
assert.ok(steps, `missing ${name} steps`);
assert.doesNotMatch(steps, /^ shell:/m, "steps must not override the fail-fast shell");
}
});

// GitHub's documented shell:bash invocation, including Git for Windows.
// Run this on every matrix OS, not just a simulated Windows platform value.
function run(script) {
const result = spawnSync("bash", ["--noprofile", "--norc", "-eo", "pipefail", "-c", script], {
encoding: "utf8",
timeout: 10_000,
});
assert.ifError(result.error);
return result;
}

test("a failed native command cannot be hidden by a later successful command", () => {
const result = run('node -e "process.exit(23)"\nnode -e "console.log(\'must-not-run\')"');
assert.equal(result.status, 23);
assert.doesNotMatch(result.stdout, /must-not-run/);
});

test("a failed pipeline command cannot be hidden by its successful consumer", () => {
const result = run('node -e "process.exit(23)" | node -e "process.exit(0)"\nnode -e "console.log(\'must-not-run\')"');
assert.equal(result.status, 23);
assert.doesNotMatch(result.stdout, /must-not-run/);
});

test("successful command sequences still complete normally", () => {
const result = run('node -e "console.log(\'first\')"\nnode -e "console.log(\'second\')"');
assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, /first\s+second/);
});
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ jobs:

build:
name: Build (${{ matrix.os }})
# Explicit bash stops on any failed native command, also on Windows.
defaults:
run:
shell: bash
strategy:
fail-fast: false
matrix:
Expand Down Expand Up @@ -138,6 +142,9 @@ jobs:

test:
name: Test (${{ matrix.os }})
defaults:
run:
shell: bash
strategy:
fail-fast: false
matrix:
Expand All @@ -152,6 +159,8 @@ jobs:
cache-dependency-path: |
backend/package-lock.json
frontend/package-lock.json
- name: Verify native-command failure propagation
run: node --test .github/scripts/ci-shell.test.mjs
- name: Backend — install & test
working-directory: backend
run: |
Expand Down
26 changes: 22 additions & 4 deletions backend/src/services/execution/backends/localDocker.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, vi, type MockInstance } from "vitest";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
Expand Down Expand Up @@ -113,18 +113,36 @@ describe("ensureNoSymlinkInPath", () => {

it("preserves existing bind paths and applies the requested mode only to directories it creates", async () => {
tmp = await fs.mkdtemp(path.join(os.tmpdir(), "runner-mode-"));
let chmod: MockInstance<typeof fs.chmod> | undefined;
try {
await fs.chmod(tmp, 0o700);
await fs.mkdir(path.join(tmp, "a"), { mode: 0o711 });
const rootMode = (await fs.stat(tmp)).mode;
const parentMode = (await fs.stat(path.join(tmp, "a"))).mode;
// Pass through to the real filesystem while checking the no-rechmod
// contract on every OS, including Windows without POSIX mode bits.
chmod = vi.spyOn(fs, "chmod");
const target = path.join(tmp, "a", "b");
await ensureNoSymlinkInPath(tmp, target, 0o707);
// Existing bind-mount paths may be reported as UID 0 by Docker Desktop
// and reject chmod from the UID-1100 backend. Re-validating their type
// without mutating their mode keeps repeated snapshots idempotent.
expect((await fs.stat(tmp)).mode & 0o777).toBe(0o700);
expect((await fs.stat(path.join(tmp, "a"))).mode & 0o777).toBe(0o711);
expect((await fs.stat(target)).mode & 0o777).toBe(0o707);
expect((await fs.stat(tmp)).mode).toBe(rootMode);
expect((await fs.stat(path.join(tmp, "a"))).mode).toBe(parentMode);
expect((await fs.stat(target)).isDirectory()).toBe(true);
expect(chmod).toHaveBeenCalledExactlyOnceWith(target, 0o707);
// Node on Windows supports write permission, not owner/group/other
// POSIX modes. Keep exact mode enforcement on Linux and macOS.
if (process.platform !== "win32") {
expect(rootMode & 0o777).toBe(0o700);
expect(parentMode & 0o777).toBe(0o711);
expect((await fs.stat(target)).mode & 0o777).toBe(0o707);
}
chmod.mockClear();
await ensureNoSymlinkInPath(tmp, target, 0o707);
expect(chmod).not.toHaveBeenCalled();
} finally {
chmod?.mockRestore();
await fs.rm(tmp, { recursive: true, force: true });
}
});
Expand Down
Loading
Loading