1 Commits

Author SHA1 Message Date
Philipp d0a08da728 feat: add rollback_on_failure feature (#88)
Lint / pre-commit Linting (push) Successful in 44s
Test / Run Tests (push) Successful in 1m2s
This PR adds the ability to download existing code before deployment and automatically roll back if the post-deployment monitor detects a failure.

Reviewed-on: #88
Reviewed-by: LLMReview <27+autoreview@noreplay.horstenkamp.eu>
2026-05-17 13:13:58 +02:00
5 changed files with 161 additions and 48 deletions
+1
View File
@@ -6,6 +6,7 @@ This repository is maintained by Gemini.
* **Test-Driven Development (TDD):** Wherever possible, Test-Driven Development principles should be followed. Write tests before writing the code they are intended to validate. * **Test-Driven Development (TDD):** Wherever possible, Test-Driven Development principles should be followed. Write tests before writing the code they are intended to validate.
* **Pre-commit Hooks:** Ensure that `pre-commit` hooks are installed and active before making any commits. This can be done by running `pre-commit install` in your local repository. * **Pre-commit Hooks:** Ensure that `pre-commit` hooks are installed and active before making any commits. This can be done by running `pre-commit install` in your local repository.
* **Note for Gemini:** Git commits trigger pre-commit hooks, which can take several seconds (or minutes) to complete. Checking the command status for git commit is only appropriate every 120s.
## Repository Comparison ## Repository Comparison
+73 -22
View File
@@ -20,6 +20,23 @@ vi.mock("../monitor.js", () => ({
}), }),
})); }));
// Mock screeps-api
vi.mock("screeps-api", () => {
const mockApi = {
auth: vi.fn().mockResolvedValue(),
code: {
get: vi.fn().mockResolvedValue({ ok: 1, modules: { main: "old_code" } }),
set: vi.fn().mockResolvedValue({ ok: 1 }),
},
};
// Use a regular function so it can be called with `new`
return {
ScreepsAPI: vi.fn(function () {
return mockApi;
}),
};
});
import * as core from "@actions/core"; import * as core from "@actions/core";
import { monitorConsole } from "../monitor.js"; import { monitorConsole } from "../monitor.js";
@@ -29,7 +46,9 @@ import {
readReplaceAndWriteFiles, readReplaceAndWriteFiles,
readFilesIntoDict, readFilesIntoDict,
applyOnAction, applyOnAction,
postCode,
} from "../index.js"; } from "../index.js";
import { ScreepsAPI } from "screeps-api";
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
import os from "os"; import os from "os";
@@ -231,31 +250,31 @@ describe("glob functionality", () => {
describe("applyOnAction", () => { describe("applyOnAction", () => {
beforeEach(() => vi.clearAllMocks()); beforeEach(() => vi.clearAllMocks());
it("'ignore' + true → no core call", () => { it("'ignore' + true → no core call, returns false", () => {
applyOnAction("ignore", true, "msg"); expect(applyOnAction("ignore", true, "msg")).toBe(false);
expect(core.warning).not.toHaveBeenCalled(); expect(core.warning).not.toHaveBeenCalled();
expect(core.setFailed).not.toHaveBeenCalled(); expect(core.setFailed).not.toHaveBeenCalled();
}); });
it("'warn' + true → core.warning() called with message", () => { it("'warn' + true → core.warning() called with message, returns false", () => {
applyOnAction("warn", true, "boom"); expect(applyOnAction("warn", true, "boom")).toBe(false);
expect(core.warning).toHaveBeenCalledWith("boom"); expect(core.warning).toHaveBeenCalledWith("boom");
expect(core.setFailed).not.toHaveBeenCalled(); expect(core.setFailed).not.toHaveBeenCalled();
}); });
it("'fail' + true → core.setFailed() called with message", () => { it("'fail' + true → core.setFailed() called with message, returns true", () => {
applyOnAction("fail", true, "boom"); expect(applyOnAction("fail", true, "boom")).toBe(true);
expect(core.setFailed).toHaveBeenCalledWith("boom"); expect(core.setFailed).toHaveBeenCalledWith("boom");
expect(core.warning).not.toHaveBeenCalled(); expect(core.warning).not.toHaveBeenCalled();
}); });
it("'fail' + false → no core call", () => { it("'fail' + false → no core call, returns false", () => {
applyOnAction("fail", false, "boom"); expect(applyOnAction("fail", false, "boom")).toBe(false);
expect(core.setFailed).not.toHaveBeenCalled(); expect(core.setFailed).not.toHaveBeenCalled();
}); });
it("'warn' + false → no core call", () => { it("'warn' + false → no core call, returns false", () => {
applyOnAction("warn", false, "msg"); expect(applyOnAction("warn", false, "msg")).toBe(false);
expect(core.warning).not.toHaveBeenCalled(); expect(core.warning).not.toHaveBeenCalled();
}); });
}); });
@@ -270,28 +289,60 @@ describe("postCode — monitor wiring", () => {
// Default core mocks // Default core mocks
core.getInput.mockImplementation((name) => { core.getInput.mockImplementation((name) => {
if (name === "monitor") return "0"; if (name === "monitor") return "0";
if (name === "token") return "test-token";
if (name === "branch") return "default";
if (name === "on_traceback") return "fail";
return ""; return "";
}); });
core.getBooleanInput.mockReturnValue(false); core.getBooleanInput.mockImplementation((name) => {
if (name === "rollback_on_failure") return false;
return false;
});
}); });
it("does not call monitorConsole when monitor=0 (default)", async () => { it("does not call monitorConsole when monitor=0 (default)", async () => {
// We need to mock the rest of postCode to not fail before it hits the monitor block // We just run postCode with monitor=0 and verify monitorConsole is not called.
// This is a bit complex as postCode is large, but we can mock the inputs to exit early or mock the API await postCode();
// Actually, I'll just check if monitorConsole is called. expect(monitorConsole).not.toHaveBeenCalled();
});
// For this test, I'll make validateAuthentication fail so it returns early but after input check it("rolls back to previous code when monitor detects a failure and rollback_on_failure is true", async () => {
// Setup inputs for monitor and rollback
core.getInput.mockImplementation((name) => { core.getInput.mockImplementation((name) => {
if (name === "monitor") return "0"; if (name === "monitor") return "10";
if (name === "token") return "test-token";
if (name === "branch") return "default";
if (name === "on_traceback") return "fail";
return ""; return "";
}); });
core.getBooleanInput.mockImplementation((name) => {
if (name === "rollback_on_failure") return true;
return false;
});
// We'll just run a partial check or rely on the monitor unit tests for depth // Simulate a failure in monitorConsole
// The wiring in index.js is: monitorConsole.mockResolvedValueOnce({
// const monitorTicks = parseInt(core.getInput("monitor") || "0", 10); sawTraceback: true, // Should trigger "fail" due to on_traceback=fail
// if (monitorTicks > 0) { ... } sawErrorLog: false,
sawWarningLog: false,
});
// Testing the logic inside index.js directly by calling postCode would require full environment mock. await postCode();
// I'll stick to the applyOnAction unit tests and rely on monitor.test.js for the heavy lifting.
// Verify rollback was performed
const mockApiInstance = new ScreepsAPI();
// `code.set` should be called twice:
// 1st time: uploading the new files
// 2nd time: rolling back to oldCode
expect(mockApiInstance.code.set).toHaveBeenCalledTimes(2);
expect(mockApiInstance.code.set).toHaveBeenNthCalledWith(2, "default", {
main: "old_code",
});
// Verify it called core.setFailed due to traceback
expect(core.setFailed).toHaveBeenCalledWith(
"Screeps console: traceback detected",
);
}); });
}); });
+4
View File
@@ -67,6 +67,10 @@ inputs:
description: 'Print a progress update every N ticks when log_to_file=true (default: 10).' description: 'Print a progress update every N ticks when log_to_file=true (default: 10).'
required: false required: false
default: '10' default: '10'
rollback_on_failure:
description: 'Automatically rollback to previous code if the monitor detects failures. Requires monitor > 0. (default: false)'
required: false
default: 'false'
outputs: outputs:
saw_traceback: saw_traceback:
description: true if a JS traceback was detected during monitoring. description: true if a JS traceback was detected during monitoring.
+1 -1
View File
File diff suppressed because one or more lines are too long
+82 -25
View File
@@ -118,17 +118,19 @@ export function validateAuthentication(token, username, password) {
* @param {'ignore'|'warn'|'fail'} action * @param {'ignore'|'warn'|'fail'} action
* @param {boolean} flag - Only acts when true * @param {boolean} flag - Only acts when true
* @param {string} message - Passed to core.warning / core.setFailed * @param {string} message - Passed to core.warning / core.setFailed
* @returns {boolean} - Returns true if the action was 'fail' and the flag was true.
*/ */
export function applyOnAction(action, flag, message) { export function applyOnAction(action, flag, message) {
if (!flag) return; if (!flag) return false;
if (action === "warn") { if (action === "warn") {
core.warning(message); core.warning(message);
return; return false;
} }
if (action === "fail") { if (action === "fail") {
core.setFailed(message); core.setFailed(message);
return true;
} }
// 'ignore' → no-op return false;
} }
/** /**
@@ -179,33 +181,72 @@ export async function postCode() {
return; return;
} }
let api = new ScreepsAPI(login_arguments); let api = new ScreepsAPI(login_arguments);
if (token) {
if (!token) {
core.info(`Logging in as user ${username}`);
try {
await api.auth(username, password, login_arguments);
} catch (err) {
core.error(`Authentication error: ${err}`);
throw err;
}
}
let oldCode = null;
let rollbackOnFailure = false;
try {
rollbackOnFailure = core.getBooleanInput("rollback_on_failure");
} catch (e) {
// getBooleanInput throws if not 'true' or 'false', ignore
}
if (rollbackOnFailure) {
core.info(
`Downloading existing code from branch ${branch} for potential rollback...`,
);
try {
const getResponse = await api.code.get(branch);
if (getResponse && getResponse.ok && getResponse.modules) {
oldCode = getResponse.modules;
core.info(
`Successfully downloaded existing code (modules: ${Object.keys(oldCode).join(", ")})`,
);
} else {
core.setFailed(
`Failed to download existing code, but rollback_on_failure is enabled. Aborting deployment.`,
);
return;
}
} catch (err) {
core.setFailed(
`Error downloading existing code: ${err.message}. Aborting deployment.`,
);
return;
}
}
try {
const response = await api.code.set(branch, files_to_push); const response = await api.code.set(branch, files_to_push);
core.info(JSON.stringify(response, null, 2)); core.info(JSON.stringify(response, null, 2));
core.info(`Code set successfully to ${branch}`); core.info(`Code set successfully to ${branch}`);
} else { } catch (err) {
core.info(`Logging in as user ${username}`); core.error(`Upload error: ${err}`);
await Promise.resolve() throw err;
.then(() => api.auth(username, password, login_arguments))
.then(() => api.code.set(branch, files_to_push))
.then(() => {
core.info(`Code set successfully to ${branch}`);
})
.catch((err) => {
core.error(`Upload error: ${err}`);
throw err;
});
} }
// Console monitoring (optional) // Console monitoring (optional)
const monitorTicks = parseInt(core.getInput("monitor") || "0", 10); const monitorTicks = parseInt(core.getInput("monitor") || "0", 10);
if (monitorTicks > 0) { if (monitorTicks > 0) {
const onTraceback = core.getInput("on_traceback") || "fail";
const onErrorLog = core.getInput("on_error_log") || "warn";
const onWarningLog = core.getInput("on_warning_log") || "ignore";
const result = await monitorConsole(api, { const result = await monitorConsole(api, {
monitor: monitorTicks, monitor: monitorTicks,
logToFile: core.getBooleanInput("log_to_file"), logToFile: core.getBooleanInput("log_to_file"),
onTraceback: core.getInput("on_traceback") || "fail", onTraceback,
onErrorLog: core.getInput("on_error_log") || "warn", onErrorLog,
onWarningLog: core.getInput("on_warning_log") || "ignore", onWarningLog,
monitorInterval: parseInt(core.getInput("monitor_interval") || "10", 10), monitorInterval: parseInt(core.getInput("monitor_interval") || "10", 10),
hostname, hostname,
shard: core.getInput("shard") || undefined, shard: core.getInput("shard") || undefined,
@@ -215,21 +256,37 @@ export async function postCode() {
core.setOutput("saw_error_log", String(result.sawErrorLog)); core.setOutput("saw_error_log", String(result.sawErrorLog));
core.setOutput("saw_warning_log", String(result.sawWarningLog)); core.setOutput("saw_warning_log", String(result.sawWarningLog));
applyOnAction( const fail1 = applyOnAction(
core.getInput("on_traceback"), onTraceback,
result.sawTraceback, result.sawTraceback,
"Screeps console: traceback detected", "Screeps console: traceback detected",
); );
applyOnAction( const fail2 = applyOnAction(
core.getInput("on_error_log"), onErrorLog,
result.sawErrorLog, result.sawErrorLog,
"Screeps console: error log output detected", "Screeps console: error log output detected",
); );
applyOnAction( const fail3 = applyOnAction(
core.getInput("on_warning_log"), onWarningLog,
result.sawWarningLog, result.sawWarningLog,
"Screeps console: warning log output detected", "Screeps console: warning log output detected",
); );
const shouldFail = fail1 || fail2 || fail3;
if (shouldFail && rollbackOnFailure && oldCode) {
core.info(
"Action failed based on monitor configuration. Rolling back to previous code...",
);
try {
await api.code.set(branch, oldCode);
core.info(
`Successfully rolled back to previous code on branch ${branch}.`,
);
} catch (err) {
core.error(`Rollback failed: ${err}`);
}
}
} }
} }