Skip to content
Open
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
7 changes: 7 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@ inputs:
path:
description: "The path at which to mount the sticky disk"
required: true
outputs:
expose-id:
description: "The expose ID for the mounted disk (pass to stickydisk/commit)"
key:
description: "The sticky disk key (pass-through)"
path:
description: "The mount path (pass-through)"
runs:
using: "node24"
main: "dist/index.js"
Expand Down
19 changes: 19 additions & 0 deletions commit/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
name: "Blacksmith Sticky Disk — Commit"
author: Aditya Maru
description: "Unmount and commit a Blacksmith sticky disk. Run as a regular step after stickydisk/mount."
branding:
icon: folder-plus
color: black
inputs:
expose-id:
description: "The expose ID from the mount step"
required: true
key:
description: "The sticky disk key (must match the mount step)"
required: true
path:
description: "The mount path (must match the mount step, tilde OK)"
required: true
runs:
using: "node24"
main: "../dist/commit/index.js"
3 changes: 3 additions & 0 deletions dist/index.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

23 changes: 23 additions & 0 deletions mount/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
name: "Blacksmith Sticky Disk — Mount"
author: Aditya Maru
description: "Mount a Blacksmith sticky disk without auto-committing on teardown. Use stickydisk/commit to commit explicitly."
branding:
icon: folder-plus
color: black
inputs:
key:
description: "A unique key to identify the sticky disk"
required: true
path:
description: "The path at which to mount the sticky disk"
required: true
outputs:
expose-id:
description: "The expose ID for the mounted disk (pass to stickydisk/commit)"
key:
description: "The sticky disk key (pass-through for convenience)"
path:
description: "The mount path (pass-through for convenience)"
runs:
using: "node24"
main: "../dist/index.js"
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"private": true,
"type": "module",
"scripts": {
"build": "ncc build src/main.ts -o dist && ncc build src/post.ts -o dist/post",
"build": "ncc build src/main.ts -o dist && ncc build src/post.ts -o dist/post && ncc build src/commit-main.ts -o dist/commit",
"format": "prettier --write --cache . !dist",
"lint": "eslint",
"test": "jest"
Expand Down
158 changes: 158 additions & 0 deletions src/commit-main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
/**
* Standalone commit entry point for `stickydisk/commit`.
*
* Reads expose-id, key, and path from action inputs (not state),
* then runs the same unmount → flush → commit logic as post.ts.
*/
import * as core from "@actions/core";
import { promisify } from "util";
import { exec } from "child_process";
import { createStickyDiskClient } from "./utils";

const execAsync = promisify(exec);

async function commitStickydisk(
exposeId: string,
stickyDiskKey: string,
fsDiskUsageBytes: number | null,
): Promise<void> {
core.info(
`Committing sticky disk ${stickyDiskKey} with expose ID ${exposeId}`,
);

try {
const client = await createStickyDiskClient();

const commitRequest: Record<string, unknown> = {
exposeId,
stickyDiskKey,
vmId: process.env.BLACKSMITH_VM_ID || "",
shouldCommit: true,
repoName: process.env.GITHUB_REPO_NAME || "",
stickyDiskToken: process.env.BLACKSMITH_STICKYDISK_TOKEN || "",
};

if (fsDiskUsageBytes !== null && fsDiskUsageBytes > 0) {
commitRequest.fsDiskUsageBytes = BigInt(fsDiskUsageBytes);
}

await client.commitStickyDisk(commitRequest, { timeoutMs: 30000 });
core.info(
`Successfully committed sticky disk ${stickyDiskKey} with expose ID ${exposeId}`,
);
} catch (error) {
core.warning(
`Error committing sticky disk: ${error instanceof Error ? error.message : String(error)}`,
);
}
}

async function getDeviceFromMount(mountPoint: string): Promise<string | null> {
try {
const { stdout } = await execAsync(`findmnt -n -o SOURCE "${mountPoint}"`);
const device = stdout.trim();
if (device) return device;
} catch {
/* fall through */
}
try {
const { stdout } = await execAsync(`mount | grep " ${mountPoint} "`);
const match = stdout.match(/^(\/dev\/\S+)/);
if (match) return match[1];
} catch {
/* fall through */
}
return null;
}

const FLUSH_TIMEOUT_SECS = 10;

async function flushBlockDevice(devicePath: string): Promise<void> {
const deviceName = devicePath.replace("/dev/", "");
if (!deviceName) return;

const startTime = Date.now();
try {
await execAsync(
`timeout ${FLUSH_TIMEOUT_SECS} sudo blockdev --flushbufs ${devicePath}`,
);
core.info(`guest flush duration: ${Date.now() - startTime}ms, device: ${devicePath}`);
} catch {
core.info(`guest flush failed for ${devicePath} after ${Date.now() - startTime}ms`);
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicated functions between commit-main.ts and post.ts

Medium Severity

commitStickydisk, getDeviceFromMount, and flushBlockDevice are duplicated between commit-main.ts and post.ts. The copies already diverge — commit-main.ts is missing the exposeId/stickyDiskKey validation guard from post.ts's commitStickydisk, and its flushBlockDevice omits block device stat collection and exit-code parsing. Future bug fixes applied to one file risk being missed in the other. These shared functions belong in a common module like utils.ts.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 8f34fb4. Configure here.


/** Resolve leading `~` to $HOME since mount paths are always expanded. */
function resolveTilde(p: string): string {
if (p === "~" || p.startsWith("~/")) {
return (process.env.HOME ?? "/home/runner") + p.slice(1);
}
return p;
}

async function run(): Promise<void> {
const stickyDiskPath = resolveTilde(core.getInput("path", { required: true }));
const exposeId = core.getInput("expose-id", { required: true });
const stickyDiskKey = core.getInput("key", { required: true });

core.info(`Committing stickydisk: path=${stickyDiskPath} key=${stickyDiskKey} expose-id=${exposeId}`);

try {
// Verify mount
const { stdout: mountOutput } = await execAsync(
`mount | grep "${stickyDiskPath}"`,
);
if (!mountOutput) {
core.warning(`${stickyDiskPath} is not mounted, skipping commit`);
return;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing try-catch around grep makes mount check dead code

Medium Severity

The mount | grep call on line 102 is not wrapped in its own try-catch. When the path is not mounted, grep returns exit code 1, which causes execAsync to reject the promise. This means lines 105–108 (if (!mountOutput)) are dead code — they'll never execute. Instead, the rejection bubbles up to the outer catch at line 151, producing a misleading "Failed to commit sticky disk" warning instead of the intended "is not mounted, skipping commit" message. The equivalent code in post.ts (lines 201–219) correctly wraps this in a nested try-catch.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 8f34fb4. Configure here.


const devicePath = await getDeviceFromMount(stickyDiskPath);

// Sync and measure usage
await execAsync("sync");
let fsDiskUsageBytes: number | null = null;
try {
const { stdout } = await execAsync(
`df -B1 --output=used "${stickyDiskPath}" | tail -n1`,
);
const parsed = parseInt(stdout.trim(), 10);
if (!isNaN(parsed) && parsed > 0) {
fsDiskUsageBytes = parsed;
core.info(`Filesystem usage: ${fsDiskUsageBytes} bytes`);
}
} catch {
/* non-fatal */
}

// Drop caches for clean unmount
await execAsync("sudo sh -c 'echo 3 > /proc/sys/vm/drop_caches'");

// Unmount with retries
for (let attempt = 1; attempt <= 10; attempt++) {
try {
await execAsync(`sudo umount "${stickyDiskPath}"`);
core.info(`Successfully unmounted ${stickyDiskPath}`);
break;
} catch (error) {
if (attempt === 10) throw error;
core.warning(`Unmount failed, retrying (${attempt}/10)...`);
await new Promise((resolve) => setTimeout(resolve, 300));
}
}

// Flush block device
if (devicePath) {
await flushBlockDevice(devicePath);
}

// Commit
await commitStickydisk(exposeId, stickyDiskKey, fsDiskUsageBytes);
} catch (error) {
core.warning(
`Failed to commit sticky disk at ${stickyDiskPath}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}

run();
3 changes: 3 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,9 @@ async function run(): Promise<void> {
controller,
));
saveState("STICKYDISK_EXPOSE_ID", exposeId);
core.setOutput("expose-id", exposeId);
core.setOutput("key", stickyDiskKey);
core.setOutput("path", stickyDiskPath);
core.debug(`Sticky disk mounted to ${device}, expose ID: ${exposeId}`);
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
Expand Down