Skip to content

Commit b5fd636

Browse files
JohnMcLearclaude
andcommitted
fix(updater): mark a stale pending-verification verified on its own
If the status is still pending-verification once the boot health check is over and Etherpad is already running the target version, the update worked. Record it as verified when the status is read or the version check runs, instead of asking the admin to acknowledge it. Acknowledge now only shows for a pending verification of another version, where the update didn't take. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V4cYEvi9Y4BTPntnkV9tzh
1 parent 3e5efc3 commit b5fd636

5 files changed

Lines changed: 62 additions & 8 deletions

File tree

‎admin/src/pages/UpdatePage.tsx‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -137,11 +137,14 @@ export const UpdatePage = () => {
137137
&& !us.lockHeld
138138
&& !upToDate;
139139
const showCancel = status === 'preflight' || status === 'draining' || status === 'scheduled';
140-
// pending-verification is included so a status stuck there after the
141-
// server is back up can be cleared (#8412); the server refuses while the
142-
// boot health check is still armed.
140+
// A stuck pending-verification for the version that is running resolves
141+
// itself server-side (#8412). One for another version means the update
142+
// didn't take, so let the admin clear it.
143+
const pendingTag = status === 'pending-verification'
144+
? (us.execution as {targetTag: string}).targetTag : null;
143145
const showAcknowledge = status === 'preflight-failed' || status === 'rolled-back' ||
144-
status === 'rollback-failed' || status === 'pending-verification';
146+
status === 'rollback-failed' ||
147+
(pendingTag != null && pendingTag.replace(/^v/, '') !== us.currentVersion);
145148

146149
// Optional-chain the execution lookup: some integration-test stubs of
147150
// /admin/update/status omit Tier 2/3 fields entirely (see

‎doc/admin/updates.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ Etherpad applies an update by **exiting with code 75** so a process supervisor r
154154
| `pnpm install` or `pnpm run build:ui` fails | `rolled-back` | Same as above. The lockfile and SHA are restored. |
155155
| `/health` doesn't come up within `rollbackHealthCheckSeconds` | `rolled-back` | Same — RollbackHandler restores the previous SHA + lockfile and exits 75 again. |
156156
| The new version crashes at boot more than twice (`bootCount > 2`) | `rolled-back` | Crash-loop guard kicks in regardless of the health-check timer. |
157-
| Status stays on `pending-verification` although the server is up and serving | `pending-verification` | Click **Acknowledge** to return to `idle`; Apply is refused until then. |
157+
| Status stays on `pending-verification` although the server is up and serving | `verified` or `pending-verification` | If the running version is the target, the status changes to `verified` on its own. If not, the update didn't take: click **Acknowledge** to return to `idle`. |
158158
| Rollback itself fails (e.g., `pnpm install` errors restoring old lockfile) | `rollback-failed` | **Manual intervention required.** The admin banner switches to a strong red alert. Restore the install by hand, then click **Acknowledge** to clear the lock and re-allow Tier 2 attempts. |
159159

160160
### Endpoints

‎src/node/hooks/express/updateStatus.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import path from 'node:path';
44
import {LRUCache} from 'lru-cache';
55
import {ArgsExpressType} from '../../types/ArgsExpressType';
66
import settings, {getEpVersion} from '../../utils/Settings';
7-
import {getDetectedInstallMethod, stateFilePath} from '../../updater';
7+
import {getDetectedInstallMethod, resolveStalePendingVerification, stateFilePath} from '../../updater';
88
import {evaluatePolicy} from '../../updater/UpdatePolicy';
99
import {isMinorOrMoreBehind} from '../../updater/versionCompare';
1010
import {loadState} from '../../updater/state';
@@ -166,7 +166,7 @@ export const expressCreateServer = (
166166
if (!user) return res.status(401).send('Authentication required');
167167
if (!user.is_admin) return res.status(403).send('Forbidden');
168168
}
169-
const state = await loadState(stateFilePath());
169+
const state = await resolveStalePendingVerification();
170170
const current = getEpVersion();
171171
const installMethod = getDetectedInstallMethod();
172172
const policy = state.latest

‎src/node/updater/index.ts‎

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,33 @@ export const getCurrentState = (): Promise<UpdateState> => loadState(stateFilePa
5757
export const isVerificationArmed = (): boolean =>
5858
bootRollbackInFlight || pendingVerification?.armed === true;
5959

60+
/**
61+
* A `pending-verification` that is still there once the boot health check is
62+
* over can't resolve on its own. If this process is already running the
63+
* target version the update worked, so record it as verified instead of
64+
* making the admin clear it by hand (#8412). Returns the current state.
65+
*/
66+
export const resolveStalePendingVerification = async (): Promise<UpdateState> => {
67+
const state = await getCurrentState();
68+
const exec = state.execution;
69+
if (exec.status !== 'pending-verification' || isVerificationArmed() ||
70+
exec.targetTag.replace(/^v/, '') !== getEpVersion()) {
71+
return state;
72+
}
73+
const at = new Date().toISOString();
74+
const verified: UpdateState = {
75+
...state,
76+
execution: {status: 'verified', targetTag: exec.targetTag, verifiedAt: at},
77+
lastResult: {
78+
targetTag: exec.targetTag, fromSha: exec.fromSha, outcome: 'verified', reason: null, at,
79+
},
80+
bootCount: 0,
81+
};
82+
await saveState(stateFilePath(), verified);
83+
logger.info(`updater: ${exec.targetTag} is running; marked the stale pending verification as verified`);
84+
return verified;
85+
};
86+
6087
export const getDetectedInstallMethod = () => detectedMethod;
6188

6289
/**
@@ -136,7 +163,7 @@ const performCheck = async (): Promise<void> => {
136163
// getCurrentState() can throw on a non-ENOENT fs error from loadState();
137164
// it must run inside the try/finally so checkInFlight is always cleared,
138165
// otherwise a one-time permission error permanently disables polling.
139-
const before = await getCurrentState();
166+
const before = await resolveStalePendingVerification();
140167
const result = await checkLatestRelease({
141168
fetcher: realFetcher,
142169
prevEtag: before.lastEtag,

‎src/tests/backend/specs/updateActions.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,30 @@ describe(__filename, function () {
220220
});
221221
});
222222

223+
describe('stale pending-verification (#8412)', function () {
224+
const pending = (targetTag: string) => saveState(statePath(), {
225+
...EMPTY_STATE,
226+
execution: {
227+
status: 'pending-verification', targetTag, fromSha: 'x', deadlineAt: '2026-05-08T00:01:00Z',
228+
},
229+
bootCount: 1,
230+
});
231+
232+
it('marks it verified when the running version is the target', async () => {
233+
const {getEpVersion} = require('../../../node/utils/Settings');
234+
await pending(`v${getEpVersion()}`);
235+
const status = await agent.get('/admin/update/status').expect(200);
236+
assert.equal(status.body.execution.status, 'verified');
237+
assert.equal(status.body.lastResult.outcome, 'verified');
238+
});
239+
240+
it('leaves it for the admin when another version is running', async () => {
241+
await pending('v99.0.0');
242+
const status = await agent.get('/admin/update/status').expect(200);
243+
assert.equal(status.body.execution.status, 'pending-verification');
244+
});
245+
});
246+
223247
describe('version check vs. state written by other paths (#8412)', function () {
224248
let origFetch: typeof globalThis.fetch;
225249
beforeEach(() => {

0 commit comments

Comments
 (0)