Skip to content

fix(install): replace a stale backup folder in the signed-release installer - #560

Merged
rubenvdlinde merged 8 commits into
developmentfrom
fix/signed-install-stale-backup
Nov 2, 2026
Merged

rubenvdlinde merged 8 commits into
developmentfrom
fix/signed-install-stale-backup

Conversation

@rubenvdlinde

Copy link
Copy Markdown
Contributor

What

The signed-release installer (SelectedReleaseInstallerService) failed every install with "Could not backup existing app folder before replacement." whenever a <app>.appversion-backup folder was left behind by an earlier interrupted run: rename() onto a non-empty folder fails. The external-release installer already removes such a stale backup first. Decision of 3 Nov: the signed installer does the same.

Five commits:

  1. test(install): a regression test with a stale backup folder present. On the old code all three cases fail with the backup error. On the new code: the install succeeds and no backup folder remains; a copy failure and a finalize failure both restore the previous files (not the stale ones), report restore: clean, and leave no backup folder.
  2. fix(install): the stale backup is removed before the live folder is moved aside. The golden is re-recorded for the one scenario that changes: old backup present, dry run false now installs instead of failing. Every other scenario is byte-identical.
  3. refactor(install): both installers carried identical copies of seven filesystem helpers (backupExisting, revertCopy, markFilesystemUpdated, restoreFromBackup, findSingleDirectory, copyRecursive, rmdirr). They now use one ReplacesAppFolder trait, so the stale-backup handling cannot drift apart again. No behaviour change: both goldens pass unchanged.
  4. fix(pat): PatValidator::validate marks $token as #[\SensitiveParameter] (on its own line), so a stack trace through it does not carry the token. A test pins the attribute.
  5. test(install): pins the signature check's !== 1. openssl_verify() returns -1 when it cannot check a signature at all; the test feeds bytes that are no DER signature under an EC certificate and expects the install to stop with the old files in place.

Review requested (install path): this changes what the signed installer does with an existing folder on disk, and moves the shared backup/restore helpers into a trait.

Verification (local)

  • Regression test: fails on the old code (3 of 3), passes on the new code. Golden plain re-run after recording: green.
  • Capped mutants (prlimit --as=1.5G timeout 120), no-op control survives in every run:
    • stale-backup removal in ReplacesAppFolder::backupExisting: removed, made unreachable, moved after the rename, restore skipped: 4 of 4 killed (selected-release tests); removed and moved: 2 of 2 killed (external-release golden).
    • signature check !== 1 turned into === 0, == false, !$result: 3 of 3 killed with the new test, 0 of 3 killed without it (the gap RW27 found).
  • Installer tests under pcov (beStrictAboutCoverageMetadata): 71 tests OK, no risky (the trait is listed in @covers of both goldens).
  • Full unit-only suite (capped, --bootstrap tests/bootstrap-unit-only.php config): 768 tests, 2,745 assertions, OK (exit 0).
  • psalm level 1 (full project): no errors (exit 0).
  • php-cs-fixer (full project, dry run): my files clean; two inherited files (lib/Repair/RemoveRetiredCronJobs.php and its test) are reported fixable on development too.
  • development merged in before the checks' final tree; the tested tree equals the merge result.

SIG numbers (touched files, gate 122 unit reader)

check development this branch
LOC in these units 1,228 1,137
size >15 51.2% 49.9%
size >30 13.7% 14.8%
McCabe >5 34.4% 30.4%
McCabe >10 8.8% 6.9%
params >=3 19.0% 19.9%
params >=5 3.6% 3.9%

The params and size >30 shares rise only because 91 duplicated LOC of small helpers left the denominator: no unit gained a parameter, and the one 3-parameter unit touched (backupExistingFolder) went from 17 to 10 LOC. About 100 LOC of literal duplication between the two installers is gone.

Also green: hydra-gates --scope-to-diff --base origin/development (23 of 23 applicable gates, exit 0), gate 119 count 0 on both refs.

Inherited, not changed: php-cs-fixer reports lib/Repair/RemoveRetiredCronJobs.php and tests/unit/Repair/RemoveRetiredCronJobsTest.php; nothing on the touched lines.

AI assistance: prepared with Claude Code (claude-opus-5-5); commits carry an Assisted-by trailer.

…older present

The install must replace a backup folder left by an earlier run, restore
the previous files on a copy or finalize failure, and leave no backup
behind. Fails on the current code, which stops with "Could not backup
existing app folder before replacement."

Assisted-by: ClaudeCode:claude-opus-5-5
…taller

A .appversion-backup folder left by an earlier interrupted run made every
later signed-release install fail with "Could not backup existing app
folder before replacement." The installer now removes it before moving
the live folder aside, as the external-release installer already does.
The golden is re-recorded for the one scenario that changes: "old backup
present, dry run false" now installs.

Assisted-by: ClaudeCode:claude-opus-5-5
The signed-release and external-release installers carried identical
copies of the backup, copy, restore and cleanup helpers. They now use one
ReplacesAppFolder trait, so the stale-backup handling cannot drift apart
again. Behaviour is unchanged: both goldens pass as recorded.

Assisted-by: ClaudeCode:claude-opus-5-5
Marks the $token parameter #[\SensitiveParameter], on its own line, so a
trace through validate() shows a SensitiveParameterValue instead of the
token. A test pins the attribute.

Assisted-by: ClaudeCode:claude-opus-5-5
openssl_verify() returns -1 when it cannot check a signature at all. The
test feeds bytes that are no DER signature under an EC certificate and
expects "Release signature verification failed." with the old files in
place, so a check that only rejects 0 (or any falsy value) fails it.

Assisted-by: ClaudeCode:claude-opus-5-5
@rubenvdlinde
rubenvdlinde marked this pull request as ready for review November 2, 2026 09:49
@rubenvdlinde
rubenvdlinde merged commit e023618 into development Nov 2, 2026
8 checks passed
@github-actions

github-actions Bot commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/versioniq @ 76cbf7b

Check PHP Vue Security License Tests
lint ✅
phpcs ✅
phpmd ✅
psalm ✅
phpstan ✅
phpmetrics ✅
eslint ✅
stylelint ✅
build ✅
composer ✅ ✅ 32/32
npm ❌ ✅ 302/302
app:check-code ⏭️
info.xml ✅
REUSE ❌
lockfile sync ✅
PHPUnit ✅ 762/762 · 6 skipped (8 legs)
Newman ⏭️
Playwright ⏭️ deferred: E2E runs locally and on the promotion path only. This pull request targets development, so the suite is asked once per promotion into beta and main rather than once per push per open pull request. Run it locally with npx playwright test, or from the Actions tab on a branch with no open pull request into development.
Hydra gates ✅

Quality workflow — 2026-11-02 11:09 UTC

Download the full PDF report from the workflow artifacts.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant