From cf1f67a823032580aa8a47fc5cf050df75bf4c88 Mon Sep 17 00:00:00 2001 From: NuklearRabbit <145918611+NuklearRabbit@users.noreply.github.com> Date: Sat, 25 Jul 2026 01:07:54 +0200 Subject: [PATCH] Release ForgeFlow 0.5.2 --- CHANGELOG.md | 23 ++++++++++++- Publish-ForgeFlow-Release.ps1 | 49 +++++++++++++++++++++++++++ README.md | 24 +++++++++++-- SOURCE_MANIFEST.txt | 38 +++++++++++---------- docs/RELEASE_NOTES_0.5.0.md | 14 ++++++++ docs/RELEASE_NOTES_0.5.1.md | 16 +++++++++ docs/RELEASE_NOTES_0.5.2.md | 11 ++++++ docs/UPDATING.md | 5 +++ package-lock.json | 10 +++--- package.json | 15 +++++---- preload.cjs | 4 +++ scripts/verify.mjs | 31 ++++++++++++----- src/main/git-service.cjs | 52 ++++++++++++++++++++++++++-- src/main/ipc.cjs | 50 +++++++++++++++++++++------ src/main/process-runner.cjs | 17 ++++++++-- src/renderer/app.js | 45 +++++++++++++++++++++---- src/renderer/mock-bridge.js | 10 ++++-- src/renderer/styles.css | 30 ++++++++++++++--- src/shared/shell-verification.cjs | 56 ++++++++++++++++++++++++++++++- tests/git-integration.test.mjs | 26 ++++++++++++++ tests/renderer-workflow.test.mjs | 32 ++++++++++++++++++ tests/shell-verification.test.mjs | 56 ++++++++++++++++++++++++++++++- tests/tool-invocation.test.mjs | 8 +++++ 23 files changed, 550 insertions(+), 72 deletions(-) create mode 100644 Publish-ForgeFlow-Release.ps1 create mode 100644 docs/RELEASE_NOTES_0.5.0.md create mode 100644 docs/RELEASE_NOTES_0.5.1.md create mode 100644 docs/RELEASE_NOTES_0.5.2.md diff --git a/CHANGELOG.md b/CHANGELOG.md index da34959..f6bb197 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,25 @@ -## 0.4.4 +# Changelog + +## 0.5.2 + +- Made release verification and built-in update validation independent of Windows Bash shims. +- Added portable structural safety validation for the Unraid deployment script. +- Kept GNU Bash syntax validation on Linux and other non-Windows systems. + + +## 0.5.1 + +- Fixed Windows publication quality gate by validating Bash syntax through standard input. +- Added regression coverage for path-independent shell validation. + +## 0.5.0 + +- Viewport-safe scrollable dialogs with persistent actions. +- NUL-delimited Git pathspec transport for large selections. +- Per-repository mutation serialization and stale lock repair. +- Bulk normalization of legacy Gitea origins. +- Expanded regression coverage. + - Correctly stage deleted and renamed paths. - Preserve and surface local commits when push fails. diff --git a/Publish-ForgeFlow-Release.ps1 b/Publish-ForgeFlow-Release.ps1 new file mode 100644 index 0000000..13010c1 --- /dev/null +++ b/Publish-ForgeFlow-Release.ps1 @@ -0,0 +1,49 @@ +param( + [string]$Remote = "git@gitea.itworx.tech:Jens/ForgeFlow.git", + [string]$Branch = "main" +) + +$ErrorActionPreference = "Stop" +$source = $PSScriptRoot +$manifest = Get-Content (Join-Path $source "package.json") -Raw | ConvertFrom-Json +if ($manifest.name -ne "forgeflow") { throw "Run this script from an extracted ForgeFlow source release." } +$version = [string]$manifest.version +$temp = Join-Path ([IO.Path]::GetTempPath()) ("forgeflow-publish-" + [guid]::NewGuid().ToString("N")) +$clone = Join-Path $temp "ForgeFlow" + +try { + Write-Host "Validating ForgeFlow $version before publishing..." -ForegroundColor Cyan + Push-Location $source + try { + & cmd.exe /d /s /c "npm install --no-audit --no-fund" + if ($LASTEXITCODE -ne 0) { throw "npm install failed." } + & cmd.exe /d /s /c "npm run check" + if ($LASTEXITCODE -ne 0) { throw "ForgeFlow quality gate failed." } + } finally { Pop-Location } + + New-Item -ItemType Directory -Force -Path $temp | Out-Null + Write-Host "Cloning $Remote..." -ForegroundColor Cyan + & git clone --branch $Branch --single-branch $Remote $clone + if ($LASTEXITCODE -ne 0) { throw "Could not clone the ForgeFlow update repository." } + + & robocopy.exe $source $clone /MIR /R:2 /W:1 /NFL /NDL /NJH /NJS /NP /XD .git node_modules dist /XF *.zip *.sha256 + if ($LASTEXITCODE -gt 7) { throw "Robocopy failed with exit code $LASTEXITCODE." } + + Push-Location $clone + try { + & git add -A + $changes = & git status --porcelain + if (-not $changes) { + Write-Host "Gitea already contains ForgeFlow $version; nothing to publish." -ForegroundColor Yellow + exit 0 + } + & git commit -m "Release ForgeFlow $version" + if ($LASTEXITCODE -ne 0) { throw "Could not create the release commit." } + & git push origin $Branch + if ($LASTEXITCODE -ne 0) { throw "Could not push ForgeFlow $version to Gitea." } + Write-Host "ForgeFlow $version is now available on Gitea for the built-in updater." -ForegroundColor Green + } finally { Pop-Location } +} +finally { + Remove-Item -LiteralPath $temp -Recurse -Force -ErrorAction SilentlyContinue +} diff --git a/README.md b/README.md index 5838817..54e8145 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,18 @@ # ForgeFlow +## Publish this release to the built-in updater repository + +Extract the full source ZIP to a folder under Downloads and run: + +```powershell +Set-ExecutionPolicy -Scope Process Bypass +.\Publish-ForgeFlow-Release.ps1 +``` + +The script runs the complete quality gate, clones `git@gitea.itworx.tech:Jens/ForgeFlow.git` into a temporary folder, mirrors the verified source without `.git`, `node_modules` or release ZIPs, commits it on `main` and pushes it. A running older ForgeFlow source installation can then update through **Settings → Updates**. + + + ForgeFlow is a desktop release cockpit that turns the complete path from a local code change to a verified server deployment into one guided flow: @@ -13,9 +26,16 @@ credentials on the user's own computer.  -## Current status: v0.4.2 integrated desktop test release +## Current status: v0.5.2 Windows publication reliability release -### v0.4.2 workflow and deployment expansion +### v0.5.2 publication reliability and v0.5.0 workflow hardening + +- viewport-safe deployment and server dialogs with persistent action buttons; +- NUL-delimited Git pathspec transport for thousands of selected files; +- per-repository Git mutation queues and explicit stale-lock repair; +- one-click normalization of legacy Gitea remotes; +- corrected password capture before the loading overlay re-renders the server form; +- a verified publishing script for testing the built-in updater. - large changed-file sets scroll independently; - commit actions explain the missing prerequisite and auto-stage selected files; diff --git a/SOURCE_MANIFEST.txt b/SOURCE_MANIFEST.txt index 15e63e2..2f0cc7d 100644 --- a/SOURCE_MANIFEST.txt +++ b/SOURCE_MANIFEST.txt @@ -1,7 +1,8 @@ 755f4db7d76bfec0963ef051748a82810c0d58acd4ffd823aa6928a5167fceb4 .gitignore -af25ba17d5e51503943c84366c724bf6f32f4b61e4985e4ffd78e1bb54197493 CHANGELOG.md +330b5ef8c603a39bc04e2eb5b67351c957d43fb8b60b595e3c055ea5facc2162 CHANGELOG.md 4a561ead5ba7cdfaf4efce91842a4308c5f2a77980205879d83835efb8a579db LICENSE -f1a69461c2ea8edec2b0fecc23db196cafccd730a309f0ad84acd5c5110b45ef README.md +7769cb56a09533305837c155ce8212c55a5f12a933a9e5f72d6f69853c66e274 Publish-ForgeFlow-Release.ps1 +2460ec9580231f9786a5f3dfdfdc58e3aecdc57d0b43dfdec3344445275d0350 README.md e5414be56177664a12d31f8d668d6273628c62ae1a608b9f61580c2f9359bbf1 START_HERE.md 8f36b542736f2933bad8b9464ad7fa37b68196009c81cf702ce3b677cd637dea UPDATE_FROM_0.3.2.md 7fbfbba99e6f38029b9a77c8fad8a8e5a91c186306e9e167aae5357c666aab2e build/icon-128.png @@ -28,6 +29,9 @@ e2d67c816a919f00f9e26bf59cf29e5e8cf894536b743d282075c646c5accc96 docs/RELEASE_N 1aef74fb109541903c4dbc4d9c48d2bd63507420eaf8cceb31890797a5e4f5fd docs/RELEASE_NOTES_0.4.3.md 85fecec65f7687e1382547166eff62777613825a8d81960dfcb4ae16aa15c8be docs/RELEASE_NOTES_0.4.4.md 5cd0cffecdce942fb1024a0410568174e7704af9bacbe42f81891693a1817a19 docs/RELEASE_NOTES_0.4.5.md +f9554c10f56d41d916330f06175b07f099c9ed1534f00abc9ea7f94be70f4a97 docs/RELEASE_NOTES_0.5.0.md +11a932e2c401c53d117175aa312b6d20508918b12192d37c5084bd54a5ea3a72 docs/RELEASE_NOTES_0.5.1.md +d7d007e4c2807698db07b2ebe1cb48c36bd162bf4daad77c9d299096c9654d5a docs/RELEASE_NOTES_0.5.2.md c465f1a9c4454c9a18f38f68a243037b8897c2c9929077a586604acd4ff26d35 docs/ROADMAP.md 322624242d246d07180cc719e14c91e8fb69e123676a02e5046f4e576cca1ca1 docs/SECURITY.md c79123aa4c718ac3ab0d79771f2967710c28f939b58fca0094b02e3172f2c024 docs/SETUP_GUIDE.md @@ -35,7 +39,7 @@ b5ba1f7580e47e1f01900964b866d9f15b973a9e9dcccf2650f403595020e949 docs/SSH_UNRAI b6a178215dab054006aae4944b8ffcbe7f6100691c30f08e221e3a2dbff4cd42 docs/STATUS_ENDPOINT.md 0adfeabb98168a7fc0b02bae8d4af436d3c59459012fb05b2216e02265190128 docs/STITCH_REVIEW.md 08640f1b5e26048b5ae501909f415d2426b07cc316a0bd2178023f2457aa7a2a docs/TEST_MATRIX.md -a620b634532b3cc13c17a5e7de35efab1eb34673279d64de86df9b369cf7d65b docs/UPDATING.md +6794a12b10ce7f35223862b422c202e5ba0308eabedf03339dea0d54f8f3d191 docs/UPDATING.md 1ccde232c060395d7aedce27e89a7647b77afe28ab71de0a5a3efeded57369d3 docs/screenshots/deploy-confirmation.png b39506254ffa2c73c389fb4795b3a745368bbeb7d8514cc47a636316d6d9a6aa docs/screenshots/deployment-run.png 070e6700bdae8c628c907ba181bbf0dde0bbbbb4208f7a875503f933ff1b882e docs/screenshots/deployment-success.png @@ -49,57 +53,57 @@ c230b931abf2293d2d44b7a69b94c35f1142c093cc46b88739a0de5cbd6d1896 examples/gitea 106538d4a14a5a7b13419f9520c582b19809e8fafe2cb8c7dce2bc3e600dd10a examples/server/nginx-forgeflow-status.conf 2dff25fb39ce8fc7844026a50524b23f241bec5b614eb05371c7f908a080f69a examples/server/status-example.json 6e4ef7ec12358d756d2ee6105420a5a440ba8d207a7ee26a35c873c24661d84d main.cjs -e5bf62b9bef693dfed707a7771d7a25dab3e781ed52223beadf3322b9c1f53be package.json -1e9cfc496c61702a9b083265f08ce451008d5ce8829bb4a447429e6abea3dc89 preload.cjs +a8f32272f09ca019c6e9b3c92298b47558c1731ea761ae00d93ae77501b91a7f package.json +97af4d20a26dbf4f9643c4ff007bb48840231a245b02dee9c004fcd1fc6d888f preload.cjs a39ab8ac36fc81c37c1718ec620d4e590a1c404d01264e07e3c904a5189bdc27 scripts/apply-source-update.ps1 f427dfcd7b5ee7079de13633c8d7d22a91115e0bbc4f2a9a96246f42176d4880 scripts/doctor.mjs 444b397d515d65a7ee59d3088cba869cbb812d2b8cc18fc5d255105e3edb58c2 scripts/serve-demo.mjs -13d27b4dc4217a7e77d69056f23c46382b2bcd6b0642c2f25a743ace8283e5dc scripts/verify.mjs +3d6b25c37c92607dbc08b4b6303addb6fb2a28dc2845a5d4eeb9c48deeae7818 scripts/verify.mjs 92524adae60aced3af23f8afe82c011873ae9f1e53d854e4d12a94e8d1be1aa9 setup-windows.ps1 366c1edbc90a00fcbf660002e55291ba234d896e7afbe24002d9d6db84b9f44c src/main/config-store.cjs a970ff3f47d1641bf1ab9611e1122349aa65ff8fee4789585e078431368b8c6b src/main/deployment-service.cjs c157640e76d558906a9aa9881eda811196623ef1c65fa3467f32f0f84b0ddd0c src/main/diagnostics-service.cjs -a921c1c3a70ffff78f208b69431c525393bdb524d61ab484e85372f1b7d176dc src/main/git-service.cjs +1558fccc76d4eb563940e51b5d483edfe5e0c7987a35ad1dbfcd5f406eb4d44d src/main/git-service.cjs ab7344b1951c87e982cab5c293891bc45dad48a48a3b4b63991b0e76ef785ba6 src/main/gitea-service.cjs -2af3674ca1faaae24a8858ec6dc2f4ccb271112de1534e5d49c00ff50047e731 src/main/ipc.cjs +2d273442b55d7e3ddf0b1cd4c606a162a4ead03c44028e149976a9e1f4f0470a src/main/ipc.cjs 62f2c80c8210e19370b8556b1f296cbae50dae6b758a39e209f8fb461691fd4c src/main/log-redaction.cjs 958595a99fb242c127f475f3d8622bdba4c07b2d658703f69fe3992227a9107e src/main/preflight-service.cjs -9e35f867b1be78cb850c7872456b2fc00c837b8552c6244057862b7166c26661 src/main/process-runner.cjs +1dc0c997bd2d837f3d27dff58a9443888597b7981c8a1dd1eaa4487176ef716c src/main/process-runner.cjs e89b54e7e3174b4b0a1dcd9058d8344e29431f9d16d0e6bb8d11559b691440a0 src/main/repository-monitor.cjs eca26673564284fce8715bd74201e59fa390800926d968d642d07eec5cc3af66 src/main/repository-service.cjs 65db01a05d842c40bb784c34560870b2e9c3b973c088fcc5ce0db654a585b146 src/main/ssh-service.cjs db7fa63d85cec92afc9017207495571e7c5ec215aca02cf2aa18e6b8e265ba27 src/main/unraid-deployment-service.cjs 2b39c0c1e84da52026dc95c9962c4976b7f69bc636b186dc8a41f9d4904f208a src/main/update-service.cjs -63e5b88b5b0a1801ec21d889a7c29ac70767877599c3155e2b06e548f35c4d9e src/renderer/app.js +7ec82a1d4f6d74b44f4bf4a9641f66195c07d82937fa3c89c681ba9d751370fb src/renderer/app.js 2f3448ddaa016105769d20cbe30c461fd7ba5d3bd105865b750aa0ef6b66e1af src/renderer/assets/itworx-mark.png 37f7da5a438b88be731c45c027a0fd88d08bd1af3150afa787836fa7baadbc48 src/renderer/assets/itworx-wordmark.png 0fc26fbc70918e92586098fb0ee5c2f9946758020f930a08a005b270794b5998 src/renderer/index.html -c25dd7ee946fc7973fe3e9431cb39b22ee4ad608f9c5056f778788c7a387c2b9 src/renderer/mock-bridge.js -7fcd281b5307ed9dccbf2fee9b917943d9b02bb0bd3a8577fa607f62ea66d1d5 src/renderer/styles.css +37b2dce2a55befd968f589d3ff29b66bb407162c9d0de5e3ffe833ac5c7556ec src/renderer/mock-bridge.js +9908a81d4f5d23313eaea042a3593d8107984272698d131ebe4ea246a12f013c src/renderer/styles.css 0a1e9d9d6cd4d190eb7f85dbc6668d80600b1cf2749cc0c2c51cc428f506f20d src/shared/clone-target.cjs 029e600229714d033c28e2dcb77817aa8269847001782ae0012960e83ffd183f src/shared/git-status.cjs 2778ebcbdf60fdc1cb0749f15565e0e1bd66f3a0d31eb70ae7942a7511a3de75 src/shared/repository-match.cjs 7f4d057a3c8e8d22eda9477eea7b144237824ef0f514737831d1881ff8e7f4a4 src/shared/semver.cjs -c3135dea1c0d35ad3b4cda9597eac3335d1f16a556f8fb96694df4f9d3b0bf6f src/shared/shell-verification.cjs +ede2c95bb045c0005a3931709a0116d9fbcb3faa5f609848a0066c6ba382ca0b src/shared/shell-verification.cjs 2daa98fd421598bfe5fc9757c9b6f4d82c31d1bfece15829928473581d5d2639 src/shared/tool-invocation.cjs a97c83b8023d6c0cf49d6f2d5b626ef2341f02670f0de170e840026d28fd1f0e src/shared/validation.cjs 13b731c38863b1007b0312fd9d89562401b7cce875c952f52429bde74f77a8af src/shared/zip-writer.cjs 454edeaccb2bd41043bc918d3e3a6127db14339031d6a1c1562ac855e90455d2 tests/clone-target.test.mjs abb65b39f285da518a48be41aff40d89ceb9c5b0e6091772c2bde171f65daf9b tests/deployment-status.test.mjs fae3634bae871abade4d487b94b4741b50e787804dbd6135249f634fdd83c6d0 tests/diagnostics.test.mjs -64f3736e8576536221307a0571ea2769893106a6b6b9887f51f5279312b0a0eb tests/git-integration.test.mjs +625d600edf89dadc63e7c7989227d6509bd6f43a298f88804e2ee42afe2036c3 tests/git-integration.test.mjs 5ea94c6b241a02060d531fad94e449eecd3772eed2137581d4e2babfb09e56db tests/git-status.test.mjs 681ab7bcd02c4dd98d1d8d2092a3521c489d941131e7ffe5903971b940046474 tests/git-workflows.test.mjs e914b2bcafbd674c06adfd9bd851ca04e134210691b7f91cd3de26cee37ef5f3 tests/gitea-actions.test.mjs caf98cbd9de9b119dae610ee53fa333a7a11214f34762247452fbb85e8bbf725 tests/log-redaction.test.mjs c0f8f5a3784835f19d9ff1015185ccb385840b6fa1c9ec19f233393a7d952b65 tests/preflight.test.mjs -035c18b6801313f9fbdb5d8c5a26e12beae37d33b18c8433b96bd6cb65910a36 tests/renderer-workflow.test.mjs +462fffc71845d6e79f07e1298ef9de7648a8d680087db377ed4901d9ae96a732 tests/renderer-workflow.test.mjs 2b4956fa4df4624a04117737e57ba74020564330ff71303b5746d8ccc881e880 tests/repository-matching.test.mjs f679072548554a64974f0452337ce5e7b0c567343c287223770cc0974b905348 tests/repository-monitor.test.mjs 3c71aa5fb30d9c6fbc4b0ccfcf5112f45cbcc2a60cb990e4551a8813f7155505 tests/security-validation.test.mjs ecfdad2a03c24898c822fcf05abac89c8f8fe452a05b16fdafc0236a64c27a23 tests/semver.test.mjs -60ce18a6e538acb29191b0857f45b78ce71102440655f5e070251325261a8151 tests/shell-verification.test.mjs -0f80df71dda957d18a7dabc8286e117a01b3c0de73dc06458beb6e9fc23af0da tests/tool-invocation.test.mjs +020eccfa9c4aef7a4ac4736d9af90518fcb6d1ad75aedcfaa1c92832a9e3d6d8 tests/shell-verification.test.mjs +8a6a8477eb94b85ccef18cddd2640afb0d1eafa679c96bc7de20428d5d69e1be tests/tool-invocation.test.mjs 80f748687bc3fb72812388faf34732c8e71cbccd1f9258bc95b7f32c34eb3a84 tests/unraid-deployment.test.mjs cfc143a618be64456512313f0b244c1310e9c79ce94b7dacb1b627d9de26f750 tests/update-service.test.mjs 4d1f0a4c46190ca72b51fddf79ec6d4d02e65fa6f42ef3755de5d414f7da75bb tests/validation.test.mjs diff --git a/docs/RELEASE_NOTES_0.5.0.md b/docs/RELEASE_NOTES_0.5.0.md new file mode 100644 index 0000000..ef5d694 --- /dev/null +++ b/docs/RELEASE_NOTES_0.5.0.md @@ -0,0 +1,14 @@ +# ForgeFlow 0.5.0 + +## Reliability and viewport release + +- All dialogs are constrained to the visible desktop viewport. Long deployment configuration and preflight content scrolls independently while the action footer remains available. +- Large partial selections use Git's NUL-delimited `--pathspec-from-file` interface instead of thousands of command-line arguments. This removes Windows `ENAMETOOLONG` failures. +- Mutating Git operations are serialized per repository, preventing ForgeFlow background actions from competing for `.git/index.lock`. +- Added explicit stale index-lock inspection and repair APIs. +- Added one-click normalization of every linked repository origin to the current Gitea SSH URL, replacing legacy aliases and renamed owners without changing files or commits. +- Source updater remains exact-commit pinned and runs the full quality gate before restart. + +## Upgrade test + +Push the extracted source to `Jens/ForgeFlow` with package version `0.5.0`. A running 0.4.5 source installation can then use Settings → Updates → Check now → Download update → Apply & restart. diff --git a/docs/RELEASE_NOTES_0.5.1.md b/docs/RELEASE_NOTES_0.5.1.md new file mode 100644 index 0000000..b0719c8 --- /dev/null +++ b/docs/RELEASE_NOTES_0.5.1.md @@ -0,0 +1,16 @@ +# ForgeFlow 0.5.1 + +## Windows publication reliability + +- Validates the server deployment shell script through Bash standard input instead of passing a Windows working directory to Bash. +- Removes the Git Bash versus WSL path ambiguity that caused a blank-error quality-gate failure from Downloads. +- Adds regression coverage proving shell validation no longer depends on a Windows path or a path containing spaces. +- Retains all v0.5.0 viewport, Git batching, remote normalization, repository serialization and password-form fixes. + +Publish the extracted source to `Jens/ForgeFlow` with package version `0.5.1`. A running older source installation can then discover and apply it through the built-in updater. + +## Carried forward from 0.5.0 + +- Responsive viewport handling keeps long modals and their actions reachable. +- Large Git selections continue to use `--pathspec-from-file` with NUL separation. +- Mutating Git work remains serialized per repository. diff --git a/docs/RELEASE_NOTES_0.5.2.md b/docs/RELEASE_NOTES_0.5.2.md new file mode 100644 index 0000000..293494f --- /dev/null +++ b/docs/RELEASE_NOTES_0.5.2.md @@ -0,0 +1,11 @@ +# ForgeFlow 0.5.2 + +## Windows publication and update reliability + +- Removes the external Bash executable as a Windows publication/update prerequisite. +- Always performs deterministic structural validation of the Linux/Unraid deployment script. +- Runs GNU Bash `-n` syntax validation on non-Windows hosts and Linux CI. +- Prevents Git Bash, WSL launcher, or another `bash.exe` shim from blocking a valid Windows release. +- Keeps all ForgeFlow 0.5.0 and 0.5.1 viewport, Git batching, remote normalization, serialized per repository, lock handling, SSH form, and updater improvements. + +The release retains the viewport fixes, Git `--pathspec-from-file` batching, and Git mutations serialized per repository from 0.5.0/0.5.1. diff --git a/docs/UPDATING.md b/docs/UPDATING.md index 8141b08..3a905fd 100644 --- a/docs/UPDATING.md +++ b/docs/UPDATING.md @@ -53,3 +53,8 @@ The updater deliberately checks the semantic version stored in the remote `package.json`. Merely pushing a new commit without increasing that version does not present an update. Publish the complete validated ForgeFlow source to the configured repository and bump the version for every release. + + +## Publishing v0.5.1 from a Downloads folder + +Extract the complete release and run `Publish-ForgeFlow-Release.ps1`. The script validates the source, clones `Jens/ForgeFlow` into a temporary directory, mirrors the verified source, commits it and pushes `main`. Keep the currently running older ForgeFlow source folder untouched; use its **Settings → Updates** screen to test the exact-commit download, validation, rollback and restart path. diff --git a/package-lock.json b/package-lock.json index 7a3edfe..16ac3cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "forgeflow", - "version": "0.4.5", + "version": "0.5.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "forgeflow", - "version": "0.4.5", + "version": "0.5.2", "dependencies": { "ssh2": "1.17.0" }, @@ -3121,9 +3121,9 @@ } }, "node_modules/sax": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", - "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", + "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", "dev": true, "license": "BlueOak-1.0.0", "engines": { diff --git a/package.json b/package.json index 983171a..e3510c2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "forgeflow", - "version": "0.4.5", + "version": "0.5.2", "private": true, "description": "Desktop release cockpit for local Git, Gitea Actions and controlled exact-commit deployments.", "main": "main.cjs", @@ -51,7 +51,13 @@ "docs/SSH_UNRAID_DEPLOYMENT.md", "docs/RELEASE_NOTES_0.4.1.md", "docs/RELEASE_NOTES_0.4.2.md", - "docs/RELEASE_NOTES_0.4.3.md" + "docs/RELEASE_NOTES_0.4.3.md", + "docs/RELEASE_NOTES_0.4.4.md", + "docs/RELEASE_NOTES_0.4.5.md", + "docs/RELEASE_NOTES_0.5.0.md", + "docs/RELEASE_NOTES_0.5.1.md", + "Publish-ForgeFlow-Release.ps1", + "docs/RELEASE_NOTES_0.5.2.md" ], "directories": { "output": "dist" @@ -85,8 +91,5 @@ "author": "Jens", "dependencies": { "ssh2": "1.17.0" - }, - "files": [ - "docs/RELEASE_NOTES_0.4.4.md" - ] + } } diff --git a/preload.cjs b/preload.cjs index 1508325..6915a4a 100644 --- a/preload.cjs +++ b/preload.cjs @@ -62,6 +62,10 @@ contextBridge.exposeInMainWorld('forgeflow', Object.freeze({ stash: (localPath, message) => invoke('repository:stash', { localPath, message }), stashList: (localPath) => invoke('repository:stash-list', { localPath }), popStash: (localPath, ref) => invoke('repository:stash-pop', { localPath, ref }), + indexLockInfo: (localPath) => invoke('repository:index-lock', { localPath }), + repairIndexLock: (localPath) => invoke('repository:repair-index-lock', { localPath }), + setOrigin: (localPath, remoteUrl) => invoke('repository:set-origin', { localPath, remoteUrl }), + normalizeOrigins: () => invoke('repositories:normalize-origins'), cloneRepository: (fullName, mode = 'default') => invoke('repository:clone', { fullName, mode }), openPath: (localPath) => invoke('repository:open-path', { localPath }), openExternal: (url) => invoke('external:open', { url }), diff --git a/scripts/verify.mjs b/scripts/verify.mjs index 0c2d566..6ec2899 100644 --- a/scripts/verify.mjs +++ b/scripts/verify.mjs @@ -18,7 +18,9 @@ const required = [ 'setup-windows.ps1', 'update-windows.ps1', 'build-windows.ps1', 'UPDATE_FROM_0.3.2.md', 'scripts/apply-source-update.ps1', 'docs/ARCHITECTURE.md', 'docs/SECURITY.md', 'docs/ROADMAP.md', 'docs/SETUP_GUIDE.md', 'docs/UPDATING.md', 'docs/DIAGNOSTICS.md', 'docs/DEPLOYMENT_SETUP.md', 'docs/SSH_UNRAID_DEPLOYMENT.md', - 'docs/LUMAOPS_SERVER_AUDIT.md', 'docs/STATUS_ENDPOINT.md', 'docs/TEST_MATRIX.md', 'docs/RELEASE_NOTES_0.4.0.md', 'docs/RELEASE_NOTES_0.4.1.md', 'docs/RELEASE_NOTES_0.4.2.md', 'docs/RELEASE_NOTES_0.4.3.md', 'docs/RELEASE_NOTES_0.4.4.md', 'docs/RELEASE_NOTES_0.4.5.md', + 'docs/LUMAOPS_SERVER_AUDIT.md', 'docs/STATUS_ENDPOINT.md', 'docs/TEST_MATRIX.md', 'docs/RELEASE_NOTES_0.4.0.md', 'docs/RELEASE_NOTES_0.4.1.md', 'docs/RELEASE_NOTES_0.4.2.md', 'docs/RELEASE_NOTES_0.4.3.md', + 'docs/RELEASE_NOTES_0.5.0.md', 'docs/RELEASE_NOTES_0.5.1.md', 'docs/RELEASE_NOTES_0.5.2.md', + 'Publish-ForgeFlow-Release.ps1', 'docs/RELEASE_NOTES_0.4.4.md', 'docs/RELEASE_NOTES_0.4.5.md', 'examples/gitea-actions/deploy.yml', 'examples/gitea-actions/rollback.yml', 'examples/server/forgeflow-deploy', 'examples/server/forgeflow-targets.conf', 'examples/server/forgeflow-runner.sudoers', 'examples/server/status-example.json', @@ -28,7 +30,7 @@ const required = [ for (const file of required) await access(path.join(root, file)); const packageJson = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8')); -if (packageJson.version !== '0.4.5') throw new Error(`Expected package version 0.4.5, got ${packageJson.version}.`); +if (packageJson.version !== '0.5.2') throw new Error(`Expected package version 0.5.2, got ${packageJson.version}.`); for (const group of ['dependencies', 'devDependencies']) { for (const [name, version] of Object.entries(packageJson[group] || {})) { if (/^[~^*]/.test(version)) throw new Error(`${group} dependency ${name} must be pinned exactly, got ${version}.`); @@ -58,16 +60,29 @@ for (const file of javascriptFiles) { if (result.status !== 0) throw new Error(`${path.relative(root, file)} failed syntax validation:\n${result.stderr}`); } -const bashCheck = shellVerification.bashSyntaxCheckInvocation(root); -const shell = spawnSync(bashCheck.command, bashCheck.args, bashCheck.options); -if (shell.error) throw new Error(`Unable to start Bash for server deployment syntax validation: ${shell.error.message}`); -if (shell.status !== 0) throw new Error(`Server deployment example failed bash syntax validation:\n${shell.stderr}`); +const deploymentScript = await readFile(path.join(root, 'examples/server/forgeflow-deploy'), 'utf8'); +shellVerification.validateShellScriptStructure(deploymentScript); + +// The server deployment script targets Linux/Unraid. On Windows, different tools may +// register themselves as bash.exe (Git Bash, WSL launcher, MSYS), and several of +// those cannot reliably accept a script over stdin from Node. Publishing and applying +// a desktop update therefore never depend on a Windows Bash shim. Portable structural +// validation always runs; GNU Bash syntax validation additionally runs on non-Windows. +if (shellVerification.shouldRunExternalBash(process.platform)) { + const bashCheck = shellVerification.bashSyntaxCheckFromTextInvocation(deploymentScript); + const shell = spawnSync(bashCheck.command, bashCheck.args, bashCheck.options); + if (shell.error) throw new Error(`Unable to start Bash for server deployment syntax validation: ${shell.error.message}`); + if (shell.status !== 0) throw new Error(`Server deployment example failed bash syntax validation: +${shell.stderr || shell.stdout || 'Bash returned a non-zero status.'}`); +} else { + console.log('Windows: external Bash syntax validation skipped; portable server-script validation passed.'); +} JSON.parse(await readFile(path.join(root, 'examples/server/status-example.json'), 'utf8')); const setupGuide = await readFile(path.join(root, 'docs/SETUP_GUIDE.md'), 'utf8'); const sshGuide = await readFile(path.join(root, 'docs/SSH_UNRAID_DEPLOYMENT.md'), 'utf8'); const audit = await readFile(path.join(root, 'docs/LUMAOPS_SERVER_AUDIT.md'), 'utf8'); -const releaseNotes = await readFile(path.join(root, 'docs/RELEASE_NOTES_0.4.5.md'), 'utf8'); +const releaseNotes = await readFile(path.join(root, 'docs/RELEASE_NOTES_0.5.2.md'), 'utf8'); if (!setupGuide.includes('Gitea access token') || !setupGuide.includes('diagnostic bundle')) { throw new Error('Setup guide is missing required connection or diagnostics instructions.'); } @@ -77,7 +92,7 @@ if (!sshGuide.includes('/mnt/user/appdata') || !sshGuide.includes('host-key fing if (!audit.includes('d42d4a7f08240c478d07466e3fabec654dc71367') || !audit.includes('source/')) { throw new Error('LumaOps audit is missing the exact matching SHA or nested repository finding.'); } -for (const phrase of ['already staged', 'git add -A', 'silent-zebra-glow.zip']) { +for (const phrase of ['viewport', '--pathspec-from-file', 'serialized per repository']) { if (!releaseNotes.includes(phrase)) throw new Error(`Release notes are missing: ${phrase}`); } const renderer = await readFile(path.join(root, 'src/renderer/app.js'), 'utf8'); diff --git a/src/main/git-service.cjs b/src/main/git-service.cjs index c51d6b6..299feab 100644 --- a/src/main/git-service.cjs +++ b/src/main/git-service.cjs @@ -57,6 +57,50 @@ class GitService { return result.stdout.trim(); } + + pathspecInput(paths) { + const selected = assertRepositoryRelativePaths(paths); + return selected.length ? `${selected.join('\0')}\0` : ''; + } + + async runWithPathspec(root, args, paths, options = {}) { + const selected = assertRepositoryRelativePaths(paths); + if (!selected.length) return run('git', args, { cwd: root, ...options }); + return run('git', [...args, '--pathspec-from-file=-', '--pathspec-file-nul'], { + cwd: root, + input: this.pathspecInput(selected), + ...options + }); + } + + async getIndexLockInfo(repoPath) { + const root = await this.ensureRepository(repoPath); + const lockPath = path.join(root, '.git', 'index.lock'); + const stat = await fs.stat(lockPath).catch(() => null); + return stat ? { exists: true, lockPath, ageMs: Math.max(0, Date.now() - stat.mtimeMs) } : { exists: false, lockPath, ageMs: 0 }; + } + + async removeStaleIndexLock(repoPath, minimumAgeMs = 30_000) { + const info = await this.getIndexLockInfo(repoPath); + if (!info.exists) return { removed: false, reason: 'missing', ...info }; + if (info.ageMs < minimumAgeMs) { + const error = new Error('The Git index lock is recent. Close other Git tools and try again before removing it.'); + error.code = 'INDEX_LOCK_RECENT'; + throw error; + } + await fs.rm(info.lockPath, { force: true }); + return { removed: true, ...info }; + } + + async setRemoteUrl(repoPath, remoteUrl, remote = 'origin') { + const root = await this.ensureRepository(repoPath); + const safeRemote = assertCloneRemote(remoteUrl); + const name = String(remote || 'origin').trim(); + if (!/^[A-Za-z0-9._-]+$/.test(name)) throw new Error('Invalid Git remote name.'); + await run('git', ['remote', 'set-url', name, safeRemote], { cwd: root, timeout: 30_000 }); + return this.status(root); + } + async diff(repoPath, filePath, staged = false) { const root = await this.ensureRepository(repoPath); const safeFile = filePath ? assertRepositoryRelativePath(filePath) : ''; @@ -109,7 +153,7 @@ class GitService { // renames are already ready for commit and must therefore be left alone. const selected = await this.expandSelectedPaths(root, requested, { unstagedOnly: true }); if (selected.length) { - await run('git', ['add', '-A', '--', ...selected], { cwd: root, timeout: 60_000 }); + await this.runWithPathspec(root, ['add', '-A'], selected, { timeout: 120_000 }); } return this.status(root); } @@ -119,9 +163,11 @@ class GitService { const selected = await this.expandSelectedPaths(root, files); const hasHead = await run('git', ['rev-parse', '--verify', 'HEAD'], { cwd: root, allowExitCodes: [128] }); if (hasHead.exitCode === 0) { - await run('git', selected.length ? ['restore', '--staged', '--', ...selected] : ['restore', '--staged', '.'], { cwd: root }); + if (selected.length) await this.runWithPathspec(root, ['restore', '--staged'], selected, { timeout: 120_000 }); + else await run('git', ['restore', '--staged', '.'], { cwd: root }); } else { - await run('git', selected.length ? ['rm', '--cached', '--ignore-unmatch', '--', ...selected] : ['rm', '--cached', '-r', '.'], { cwd: root, allowExitCodes: [1] }); + if (selected.length) await this.runWithPathspec(root, ['rm', '--cached', '--ignore-unmatch'], selected, { timeout: 120_000, allowExitCodes: [1] }); + else await run('git', ['rm', '--cached', '-r', '.'], { cwd: root, allowExitCodes: [1] }); } return this.status(root); } diff --git a/src/main/ipc.cjs b/src/main/ipc.cjs index 01c1f7f..bdb860a 100644 --- a/src/main/ipc.cjs +++ b/src/main/ipc.cjs @@ -55,11 +55,20 @@ function register(channel, handler) { function registerIpc({ store, git, gitea, repositories, deployments, unraid, ssh, updates, preflight, diagnostics, monitor }) { diagnosticsService = diagnostics; + const repositoryMutations = new Map(); const withRepositoryPause = async (localPath, action) => { monitor?.pause(localPath); try { return await action(); } finally { monitor?.resume(localPath); } }; + const withRepositoryMutation = async (localPath, action) => { + const key = path.resolve(localPath); + const previous = repositoryMutations.get(key) || Promise.resolve(); + const current = previous.catch(() => {}).then(() => withRepositoryPause(key, action)); + repositoryMutations.set(key, current); + try { return await current; } + finally { if (repositoryMutations.get(key) === current) repositoryMutations.delete(key); } + }; const canonicalPath = async (value) => { const resolved = path.resolve(String(value || '')); @@ -262,20 +271,39 @@ function registerIpc({ store, git, gitea, repositories, deployments, unraid, ssh register('repository:status', async ({ localPath }) => git.status(await assertKnownRepositoryPath(localPath))); register('repository:diff', async ({ localPath, filePath, staged }) => git.diff(await assertKnownRepositoryPath(localPath), filePath, staged)); - register('repository:stage', async ({ localPath, files }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.stage(safePath, files)); }); - register('repository:unstage', async ({ localPath, files }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.unstage(safePath, files)); }); - register('repository:commit', async ({ localPath, message, files }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.commit(safePath, message, files)); }); - register('repository:commit-push', async ({ localPath, message, files }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.commitAndPush(safePath, message, files)); }); - register('repository:push', async ({ localPath }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.push(safePath)); }); - register('repository:fetch', async ({ localPath }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.fetch(safePath)); }); - register('repository:pull', async ({ localPath }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.pullFastForward(safePath)); }); + register('repository:stage', async ({ localPath, files }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.stage(safePath, files)); }); + register('repository:unstage', async ({ localPath, files }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.unstage(safePath, files)); }); + register('repository:commit', async ({ localPath, message, files }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.commit(safePath, message, files)); }); + register('repository:commit-push', async ({ localPath, message, files }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.commitAndPush(safePath, message, files)); }); + register('repository:push', async ({ localPath }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.push(safePath)); }); + register('repository:fetch', async ({ localPath }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.fetch(safePath)); }); + register('repository:pull', async ({ localPath }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.pullFastForward(safePath)); }); register('repository:history', async ({ localPath, limit }) => git.history(await assertKnownRepositoryPath(localPath), limit)); register('repository:branches', async ({ localPath }) => git.branches(await assertKnownRepositoryPath(localPath))); - register('repository:checkout-branch', async ({ localPath, branch }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.checkoutBranch(safePath, branch)); }); - register('repository:create-branch', async ({ localPath, branch }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.createBranch(safePath, branch)); }); - register('repository:stash', async ({ localPath, message }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.stash(safePath, message)); }); + register('repository:checkout-branch', async ({ localPath, branch }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.checkoutBranch(safePath, branch)); }); + register('repository:create-branch', async ({ localPath, branch }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.createBranch(safePath, branch)); }); + register('repository:stash', async ({ localPath, message }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.stash(safePath, message)); }); register('repository:stash-list', async ({ localPath }) => git.stashList(await assertKnownRepositoryPath(localPath))); - register('repository:stash-pop', async ({ localPath, ref }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.popStash(safePath, ref)); }); + register('repository:stash-pop', async ({ localPath, ref }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.popStash(safePath, ref)); }); + register('repository:index-lock', async ({ localPath }) => git.getIndexLockInfo(await assertKnownRepositoryPath(localPath))); + register('repository:repair-index-lock', async ({ localPath }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.removeStaleIndexLock(safePath)); }); + register('repository:set-origin', async ({ localPath, remoteUrl }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.setRemoteUrl(safePath, remoteUrl)); }); + + register('repositories:normalize-origins', async () => { + const current = await repositories.refresh(); + const changes = []; + for (const repository of current) { + if (!repository.localPath || !repository.sshUrl) continue; + const actual = await git.getRemoteUrl(repository.localPath).catch(() => ''); + if (actual === repository.sshUrl) continue; + await withRepositoryMutation(repository.localPath, () => git.setRemoteUrl(repository.localPath, repository.sshUrl)); + changes.push({ fullName: repository.fullName, previous: actual, next: repository.sshUrl }); + } + const refreshed = await repositories.refresh(); + monitor?.setPaths(repositories.getWatchPaths()); + await diagnostics.info('repositories.origins.normalized', { count: changes.length, changes }); + return { changes, repositories: refreshed }; + }); register('repository:clone', async ({ fullName, mode = 'default' }) => { if (!['default', 'custom'].includes(mode)) throw new Error('Unsupported clone location mode.'); diff --git a/src/main/process-runner.cjs b/src/main/process-runner.cjs index a7ed6c2..4f9070c 100644 --- a/src/main/process-runner.cjs +++ b/src/main/process-runner.cjs @@ -8,11 +8,12 @@ function run(command, args = [], options = {}) { timeout = 60_000, maxBuffer = 8 * 1024 * 1024, env, + input = null, allowExitCodes = [] } = options; return new Promise((resolve, reject) => { - execFile(command, args, { + const child = execFile(command, args, { cwd, timeout, maxBuffer, @@ -21,8 +22,16 @@ function run(command, args = [], options = {}) { env: { ...process.env, ...(env || {}) } }, (error, stdout, stderr) => { if (error && !allowExitCodes.includes(error.code)) { - const wrapped = new Error((stderr || stdout || error.message).trim()); + const message = (stderr || stdout || error.message).trim(); + const wrapped = new Error(message); wrapped.code = error.code; + if (/\.git[\\/]index\.lock[\s\S]*File exists/i.test(message) || /Unable to create .*index\.lock/i.test(message)) { + wrapped.code = 'GIT_INDEX_LOCKED'; + wrapped.recoverable = true; + } else if (error.code === 'ENAMETOOLONG') { + wrapped.code = 'GIT_ARGUMENT_LIST_TOO_LONG'; + wrapped.recoverable = true; + } wrapped.stdout = stdout; wrapped.stderr = stderr; wrapped.command = `${command} ${args.join(' ')}`; @@ -31,6 +40,10 @@ function run(command, args = [], options = {}) { } resolve({ stdout: stdout || '', stderr: stderr || '', exitCode: error?.code || 0 }); }); + if (input !== null && input !== undefined) { + child.stdin.on('error', () => {}); + child.stdin.end(input); + } }); } diff --git a/src/renderer/app.js b/src/renderer/app.js index 84933d4..bba063b 100644 --- a/src/renderer/app.js +++ b/src/renderer/app.js @@ -453,7 +453,10 @@ function renderGitTools(repository) { function renderRepositorySettings(repository) { const automaticTarget = displayCloneTarget(repository); - return `
No SSH server configured. Add your Unraid server before creating an SSH deployment profile.
This replaces legacy aliases and renamed owners only after an explicit click. Local commits and files are not changed.
The first folder is the default clone destination. ForgeFlow automatically creates one subfolder per repository.