feat: add rollback_on_failure feature (#88)
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>
This commit was merged in pull request #88.
This commit is contained in:
@@ -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
@@ -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",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -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}`);
|
|
||||||
await Promise.resolve()
|
|
||||||
.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}`);
|
core.error(`Upload error: ${err}`);
|
||||||
throw 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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user