Skip to content

Commit ef294c1

Browse files
Add support for configurable squash update delay and improve squash & restart logic in webhook
1 parent af68fb0 commit ef294c1

9 files changed

Lines changed: 105 additions & 64 deletions

File tree

README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ It provides a unified interface for managing containerized applications, and aut
2525
- 🌓 **Modern UX**: Automatic light and dark mode (based on system settings)
2626
- 📊 **Job Tracking**: Monitor update jobs with detailed logs and retry capabilities
2727
- 🔐 **Security**: Secure SSH connections, webhook signature verification and OIDC authentication
28-
- 🗜️ **Optional Git Commit Squashing**: Automatically squash multiple dependency update commits to keep Git history clean
28+
- 🗜️ **Optional Git Commit Squashing**: Automatically squash multiple dependency update commits to keep Git history clean - 15 minutes after last merge (configurable) to prevent PR conflicts
2929

3030
## Screenshot
3131

@@ -39,7 +39,7 @@ The app can be started using the following `compose.yml`:
3939
services:
4040
containers-up:
4141
# https://github.com/DigitallyRefined/containers-up/releases
42-
image: ghcr.io/digitallyrefined/containers-up:1.4.2
42+
image: ghcr.io/digitallyrefined/containers-up:1.4.3
4343
restart: unless-stopped
4444
ports:
4545
- 3000:3000
@@ -63,7 +63,7 @@ Optional system wide configuration can be changed by copying `.env.default` to `
6363
services:
6464
containers-up:
6565
# https://github.com/DigitallyRefined/containers-up/releases
66-
image: ghcr.io/digitallyrefined/containers-up:1.4.2
66+
image: ghcr.io/digitallyrefined/containers-up:1.4.3
6767
restart: unless-stopped
6868
volumes:
6969
- ./containers-up/storage:/storage
@@ -286,5 +286,6 @@ All environment variables are _optional_ and can be set in the `compose.yml` fil
286286
| `CONTAINER_REGISTRY_USERNAME` | Custom container image registry username | |
287287
| `CONTAINER_REGISTRY_TOKEN` | Custom container image registry token | |
288288
| `SQUASH_UPDATE_MESSAGE` | Commit message prefix used when squashing dependency update commits (requires squash updates enabled per host) | `Update dependencies` |
289+
| `SQUASH_UPDATE_DELAY_MINUTES` | Minutes to wait before running squash updates; new attempts reset the timer | `15` |
289290
| `SQUASH_DAYS_AGO` | Number of days before considering a commit too old to squash with newer dependency updates | `5` |
290291
| `SQUASH_MAX_UPDATE_COMMITS` | Maximum number of dependency update commits to keep before squashing the oldest two together | `5` |

biome.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"$schema": "https://biomejs.dev/schemas/2.3.13/schema.json",
2+
"$schema": "https://biomejs.dev/schemas/2.3.14/schema.json",
33
"vcs": {
44
"enabled": true,
55
"clientKind": "git",

bun.lock

Lines changed: 12 additions & 12 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

compose.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
services:
22
containers-up:
33
# https://github.com/DigitallyRefined/containers-up/releases
4-
image: ghcr.io/digitallyrefined/containers-up:1.4.2
4+
image: ghcr.io/digitallyrefined/containers-up:1.4.3
55
container_name: containers-up
66
restart: unless-stopped
77
volumes:

package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "containers-up",
3-
"version": "1.4.2",
3+
"version": "1.4.3",
44
"description": "Containers Up! a web based platform designed to manage and update containers",
55
"author": "DigitallyRefined",
66
"license": "ISC",
@@ -49,12 +49,12 @@
4949
"zod": "^4.3.6"
5050
},
5151
"devDependencies": {
52-
"@biomejs/biome": "2.3.13",
52+
"@biomejs/biome": "2.3.14",
5353
"@tailwindcss/cli": "^4.1.18",
5454
"@types/bun": "^1.3.8",
5555
"@types/pino": "^7.0.5",
5656
"@types/pino-pretty": "^5.0.0",
57-
"@types/react": "^19.2.10",
57+
"@types/react": "^19.2.11",
5858
"@types/react-dom": "^19.2.3"
5959
}
6060
}

src/backend/endpoints/webhook/pull-restart.ts

Lines changed: 42 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ import { createDockerExec } from '@/backend/utils/docker';
77
import { createExec } from '@/backend/utils/exec';
88
import { squashUpdates } from '@/backend/utils/git';
99

10+
const SQUASH_UPDATE_DELAY_MINUTES = Number.parseInt(
11+
process.env.SQUASH_UPDATE_DELAY_MINUTES || '15'
12+
);
13+
const SQUASH_UPDATE_DELAY_MS = SQUASH_UPDATE_DELAY_MINUTES * 60 * 1000;
14+
const squashUpdateTimers = new Map<number, ReturnType<typeof setTimeout>>();
15+
1016
const folderExcluded = (folder: string, excludeFolders: string | null): boolean => {
1117
if (!excludeFolders || excludeFolders === 'null') return false;
1218
const regex = new RegExp(excludeFolders);
@@ -24,19 +30,40 @@ export const pullRestartUpdatedContainers = async (
2430
const dockerExec = createDockerExec(logger);
2531
const sshRun = (cmd: string) => exec.sshRun(name, host, `cd ${workingFolder} && ${cmd}`);
2632

27-
const composePullDownUp = async (composeFolder: string) => {
33+
const restartComposeIfNotExcluded = async (composeFolder: string) => {
34+
if (folderExcluded(composeFolder, excludeFolders)) {
35+
logger.info(`Compose folder excluded: ${composeFolder}`);
36+
return;
37+
}
38+
2839
logger.info(`Restarting services in: ${composeFolder}`);
2940
const response = dockerExec.restartCompose(name, host, composeFolder, true);
3041
await response.text();
3142
};
3243

3344
const checkAndSquashUpdates = async () => {
3445
if (repoConfig.squashUpdates && (await jobDb.getIncompleteJobsWithPr(id)).length <= 1) {
35-
try {
36-
await squashUpdates(sshRun, logger);
37-
} catch (err) {
38-
logger.error({ err }, 'Failed to squash update commits');
46+
const existingTimer = squashUpdateTimers.get(id);
47+
if (existingTimer) {
48+
clearTimeout(existingTimer);
3949
}
50+
51+
logger.info(
52+
`Scheduling squash updates in ${SQUASH_UPDATE_DELAY_MINUTES} minutes for host ${name}`
53+
);
54+
55+
const timer = setTimeout(() => {
56+
squashUpdateTimers.delete(id);
57+
void (async () => {
58+
try {
59+
await squashUpdates(sshRun, id, repoConfig.id);
60+
} catch (err) {
61+
logger.error({ err }, `Failed to squash update commits for host ${name}`);
62+
}
63+
})();
64+
}, SQUASH_UPDATE_DELAY_MS);
65+
66+
squashUpdateTimers.set(id, timer);
4067
}
4168
};
4269

@@ -52,28 +79,21 @@ export const pullRestartUpdatedContainers = async (
5279
}
5380

5481
if (composeFolderExists === 'exists') {
55-
if (!folderExcluded(containerFolder, excludeFolders)) {
56-
await composePullDownUp(containerFolder);
57-
} else {
58-
logger.info(`Compose folder excluded: ${containerFolder}`);
59-
}
60-
await checkAndSquashUpdates();
82+
await restartComposeIfNotExcluded(containerFolder);
6183
} else {
6284
// Single compose file watch (via git diff)
6385
const { stdout: changedFilesStdout } = await sshRun(`git diff --name-only HEAD~1 HEAD`);
6486
const changedFiles = changedFilesStdout.split('\n').filter(Boolean);
65-
if (changedFiles.some((f: string) => isComposeFilename(f))) {
66-
for (const changedFile of changedFiles) {
67-
const composeFolder = path.dirname(path.join(workingFolder, changedFile));
68-
if (!folderExcluded(composeFolder, excludeFolders)) {
69-
await composePullDownUp(composeFolder);
70-
} else {
71-
logger.info(`Compose folder excluded: ${composeFolder}`);
72-
}
73-
}
74-
await checkAndSquashUpdates();
75-
} else {
87+
88+
if (!changedFiles.some((f: string) => isComposeFilename(f))) {
7689
logger.info('No compose YAML files have been changed');
90+
return;
91+
}
92+
93+
for (const changedFile of changedFiles) {
94+
const composeFolder = path.dirname(path.join(workingFolder, changedFile));
95+
await restartComposeIfNotExcluded(composeFolder);
7796
}
7897
}
98+
await checkAndSquashUpdates();
7999
};

src/backend/endpoints/webhook/request-handler.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,17 @@ export const handleWebhookRequest = async (
8787
}
8888
}
8989

90-
options.handler(webhookEvent, selectedHost);
90+
void options.handler(webhookEvent, selectedHost).catch((err) => {
91+
const msg = `${options.name} webhook handler failed for host: ${selectedHost.name}`;
92+
mainLogger.error({ err }, msg);
93+
void logDb.create({
94+
hostId: selectedHost.id,
95+
level: 50,
96+
time: Date.now(),
97+
event: `${options.name} webhook error`,
98+
msg,
99+
});
100+
});
91101

92102
return Response.json({ message: 'webhook received' });
93103
};

src/backend/utils/git.ts

Lines changed: 27 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
import type { Logger } from 'pino';
22

3+
import { log as logDb } from '@/backend/db/log';
4+
import { getLogs, mainLogger } from '@/backend/utils/logger';
5+
6+
const event = 'git-squash-updates';
7+
const logger = mainLogger.child({ event });
8+
39
const SQUASH_UPDATE_MESSAGE = process.env.SQUASH_UPDATE_MESSAGE || 'Update dependencies';
4-
const SQUASH__DEPS_DAYS_AGO = Number.parseInt(process.env.SQUASH_DAYS_AGO || '5');
10+
const SQUASH_DEPS_DAYS_AGO = Number.parseInt(process.env.SQUASH_DAYS_AGO || '5');
511
const SQUASH_MAX_UPDATE_COMMITS = Number.parseInt(process.env.SQUASH_MAX_UPDATE_COMMITS || '5');
612

713
const buildMessage = (subject: string, body?: string) => {
@@ -143,7 +149,8 @@ const pushToRemote = async (
143149

144150
export const squashUpdates = async (
145151
sshRun: (cmd: string) => Promise<{ stdout: string }>,
146-
logger: Logger
152+
jobId: number,
153+
hostId: number
147154
) => {
148155
logger.info('Squashing dependency update commits');
149156

@@ -153,12 +160,10 @@ export const squashUpdates = async (
153160
return;
154161
}
155162

156-
// Keep squashing while the previous commit is from an automated author
157-
let continueSquashing = true;
158163
let loopCount = 0;
159164
const MAX_SQUASH_LOOPS = 50; // To prevent infinite loops
160165

161-
while (continueSquashing && loopCount < MAX_SQUASH_LOOPS) {
166+
while (loopCount < MAX_SQUASH_LOOPS) {
162167
loopCount++;
163168
logger.info(`Squash loop iteration ${loopCount}`);
164169

@@ -169,11 +174,12 @@ export const squashUpdates = async (
169174
const prevCommit = await getCommitMetadata(sshRun, 1);
170175

171176
const isAutomatedAuthor = /dependabot|renovate/i.test(prevCommit.authorName);
177+
const X_DAYS_AGO = Date.now() - SQUASH_DEPS_DAYS_AGO * 24 * 60 * 60 * 1000;
172178

173-
// If previous commit is not from automated author, stop squashing
174-
if (!isAutomatedAuthor) {
175-
continueSquashing = false;
176-
} else {
179+
if (
180+
isAutomatedAuthor ||
181+
(prevCommit.timestamp > X_DAYS_AGO && prevCommit.subject.includes(SQUASH_UPDATE_MESSAGE))
182+
) {
177183
// Previous commit is from automated author, squash them together
178184
logger.info(`Squashing last 2 commits together (previous author: ${prevCommit.authorName})`);
179185
await squashLastTwoCommits(
@@ -183,25 +189,21 @@ export const squashUpdates = async (
183189
prevCommit.body,
184190
prevCommit.authorDate
185191
);
192+
continue;
186193
}
194+
195+
if (!lastCommit.subject.includes(SQUASH_UPDATE_MESSAGE)) {
196+
logger.info('Updating current commit message only (previous is old or not an update commit)');
197+
await updateCurrentCommit(sshRun, lastCommit.subject, lastCommit.body);
198+
}
199+
200+
break;
187201
}
188202

189203
if (loopCount >= MAX_SQUASH_LOOPS) {
190204
logger.warn(`Reached maximum squash loop limit of ${MAX_SQUASH_LOOPS}`);
191205
}
192206

193-
// Get current commit info after squashing
194-
const lastCommit = await getCommitMetadata(sshRun, 0);
195-
const prevCommit = await getCommitMetadata(sshRun, 1);
196-
197-
const DAYS_AGO = Date.now() - SQUASH__DEPS_DAYS_AGO * 24 * 60 * 60 * 1000;
198-
199-
// If previous commit is old or doesn't start with "Update dependencies", just update current commit
200-
if (prevCommit.timestamp < DAYS_AGO || !prevCommit.subject.includes(SQUASH_UPDATE_MESSAGE)) {
201-
logger.info('Updating current commit message only (previous is old or not an update commit)');
202-
await updateCurrentCommit(sshRun, lastCommit.subject, lastCommit.body);
203-
}
204-
205207
// After squashing, check if we've exceeded the max number of update commits
206208
const checkCommitCount = SQUASH_MAX_UPDATE_COMMITS + 1;
207209
const recentCommits = await sshRun(`git log -${checkCommitCount} --format="%s"`);
@@ -219,4 +221,8 @@ export const squashUpdates = async (
219221

220222
await pushToRemote(sshRun, logger);
221223
logger.info('Finished squashing dependency update commits');
224+
225+
getLogs(event).map(async (log) => {
226+
await logDb.create({ jobId, hostId, ...log });
227+
});
222228
};

src/frontend/components/Host/Form.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,10 @@ export const HostForm = ({
299299
When enabled, multiple dependency update commits will be squashed into a single
300300
commit after pulling updates.
301301
</p>
302+
<p>
303+
This happens automatically 15 minutes after the last merge (configurable) to prevent
304+
PR conflicts.
305+
</p>
302306
<p>
303307
Note: Requires repositories to use the Git pull request squash merge strategy and
304308
may not work in all scenarios.

0 commit comments

Comments
 (0)