feat: harden release signing and coverage gate
ForgeFlow quality gate / quality (push) Canceled after 0s

This commit is contained in:
NuklearRabbit
2026-07-29 22:54:51 +02:00
parent aa4895912a
commit 18f42621c2
13 changed files with 496 additions and 39 deletions
+28 -16
View File
@@ -2,8 +2,13 @@ ForgeFlow 0.10.0 source manifest
SHA-256 BYTES PATH
(The manifest excludes itself, dependencies and generated release artifacts.)
cedceb71eb846d99c7c4019031833c1c7f93b84a1c6073aec7d2435dc744ca3d 703 .gitea/workflows/quality.yml
0182dfbbf5b9fc5e62f064f094f5412bf245f9a67ec712e8807f2ce4da443717 88 .gitignore
4a9e8a955ad8c9fa7ba3f8f89cf9920ac1d28c6e5b344782e12d02c3b0fab1ee 105 .gitignore
f14b4987904bcb5814e4459a057ed4d20f58a633152288a761214dcd28780b56 3 .nvmrc
d0b1bd421359311871224f9fa1cff5a802000933668017d9e42e5190f8d2d8e5 152 .playwright-mcp/page-2026-07-29T17-41-03-014Z.yml
528fe408ad4b49c621dd57dd831cecf7ec00b8865069f6ed3b80fefc2e0b7823 8267 .playwright-mcp/page-2026-07-29T17-41-26-269Z.yml
804c6dac953c5094671784919ff35ebda756306a04ef34fe01cd7cf7cd50b2dc 16909 .playwright-mcp/page-2026-07-29T17-41-46-590Z.yml
0f17fdea98e15ebcf7f3ed356d31b0fc89be62f7bc1d25b26c8a41e4b5deca68 160 .playwright-mcp/page-2026-07-29T17-47-10-112Z.yml
5f348bcf74baa845958884bd2258e1ce4121d386c945777b0e80912602e5b5f6 17227 .playwright-mcp/page-2026-07-29T17-47-19-366Z.yml
89545860bd6f7566da81edc8328cd2a1ebf33e81a4b0dcf2cec74338c05e8cac 1753 build-windows.ps1
0970821475a4452aa19e447e9397a95db836791f16890a1a83fd748ac033dc86 8830 build/icon-128.png
09112c1425ca953d8dd8b2bcfd221e5a84b9f81752f7168f360e295030cbc8f2 521 build/icon-16.png
@@ -17,6 +22,7 @@ ca32a76e708d565c4af659f0f4d2615fc32114c3f75aec1454862a3ed1e72c41 2263
5f4aca19a35cbcaffa1a6993ce96b7d66052ec2b286022f2af74594e8a310568 15712 CHANGELOG.md
c612fcc44ff222db0c9a4cfd11a4076fafe080e4ada31e689a08739a4f14e74f 1650 docs/ACCEPTANCE.md
a17f95d96d3c9fbc69d870874e6fbb7472091adefc454b24f835db1279511d72 8296 docs/ARCHITECTURE.md
e05458ee2696e3c57e2475bb42ae1f914f6a36e01768d7a26a3199f1fffed490 1157 docs/COVERAGE_POLICY.md
72e846f591c47a0291e7466e58e052d3d5afcf551c4e6c848632ac3c552a1244 3043 docs/CURRENT_STATE.md
8ea655d1912ac2e17f8834e33a566a8b14461b396ec4268c396ca189a1749b94 2205 docs/DEPENDENCY_AUDIT.md
30a92bcf5daadb019efa2f82cb820ea302490dd1d68fb772674dc3faccd3e594 2045 docs/DEPLOYMENT_SETUP.md
@@ -24,6 +30,7 @@ eb42f979666e05d51c587e4223282914926a2b9b1ade9f3fb75525019ce7f738 4616
1d8aa3c688a9d330b9a5303b09e9e3049c50bf04f97807c7ff659607baa34c32 1464 docs/ERROR_CODES.md
a0cd06a96f23a94e118feb012be0fa1ac51345951cb2ba8e67fb8c889c4c342a 5007 docs/LUMAOPS_SERVER_AUDIT.md
648dd6bca6b45668fb86eb3e1f6c5898dd8da0291b990f1bb5105cfd79421343 1301 docs/MUTATION_MODEL.md
0baec6f5152b332589bcdc589c2f5a32a8e3e29afd4cfe125403b77eb6c78095 3716 docs/PRODUCTION_READINESS_1.0.md
f79908fb3dad98c38030c6e6be7c79a1999e0478ed9c2496923891954438daa1 4581 docs/RELEASE_AUDIT_0.6.0.md
979a0b8e129979be6b265e8571d0a3c1e9ddd4ddb6b0bf55ae748d3478e51854 2296 docs/RELEASE_NOTES_0.10.0.md
a0c00ff76acd1682bb5e0e8dcf6589c9480da436c9c6d30780a1ed58b4dad94f 1770 docs/RELEASE_NOTES_0.2.0.md
@@ -60,7 +67,7 @@ ed40e08bac8792f95970bc05e49bce3cc9e288a08d11565a1bd156d787360a3b 720
25169225d73d22b9d884ab3b5c1625f03fd44e53c7a7a4c4067775e80482c9f8 2182 docs/RELEASE_NOTES_0.9.3.md
720506842e0aeb30c9fc635f86a52a5545556f092e678cf37f08436243244c3d 933 docs/RELEASE_NOTES_0.9.4.md
dd90c81a375f97dfb7fa8f7808db03b19d7e7dafe3818a93537397f57eaae829 2109 docs/RELEASE_NOTES_0.9.5.md
ed9af435be5986cc597f75d8de89cd51dd4cc8860c8fcbb4cd8eafdd4438857a 1364 docs/RELEASING.md
beea33f1dcaf21ef2eda1cc0fbd48cb26811d5eacdb9facf11a9153845fe2fbb 4106 docs/RELEASING.md
2b631b9d6d973bdd70869d84886ff339da351e29e17598970b3b27915674661d 4175 docs/ROADMAP.md
1ccde232c060395d7aedce27e89a7647b77afe28ab71de0a5a3efeded57369d3 140415 docs/screenshots/deploy-confirmation.png
b39506254ffa2c73c389fb4795b3a745368bbeb7d8514cc47a636316d6d9a6aa 107166 docs/screenshots/deployment-run.png
@@ -74,7 +81,7 @@ c8a5e80bb9fd2d442d2d23d30e6ac1528cf2330e6e19492b7c6799e2d1508b53 112868
2fd71e9bcaeb4cb10c3fa2496b7e52fedf70c5b7f871cd587e22dc060c399079 4421 docs/SSH_UNRAID_DEPLOYMENT.md
b6a178215dab054006aae4944b8ffcbe7f6100691c30f08e221e3a2dbff4cd42 2147 docs/STATUS_ENDPOINT.md
0adfeabb98168a7fc0b02bae8d4af436d3c59459012fb05b2216e02265190128 3139 docs/STITCH_REVIEW.md
bcfef2c5a180e3665df3c08517d5251339682ca5bcc90e4681b5bbf4f9c7ba55 6296 docs/TEST_MATRIX.md
4983414a980075e6faae687b0d71c8e57bfe53fcb4cadb8b979b8abca636fe95 6654 docs/TEST_MATRIX.md
dbbd9fa96988e7543e98c85da864adaadd3057815f18d20a3b3ccb5c540a169d 4558 docs/UPDATING.md
4bffda594058697345569d937d7a524f094ac85a0f338f0ef18fcf3f94d8c299 1292 eslint.config.js
c230b931abf2293d2d44b7a69b94c35f1142c093cc46b88739a0de5cbd6d1896 1532 examples/gitea-actions/deploy.yml
@@ -88,15 +95,15 @@ c230b931abf2293d2d44b7a69b94c35f1142c093cc46b88739a0de5cbd6d1896 1532
1f0f388df4397e548887bbc7579fd3c864581b86469c01703201ece7a6cbf931 13667 main.cjs
91a984a89dd57a084b9a2331763cacdb061582fb590f13df379d92c1a77a2ee1 352 OVERLAY-INSTRUCTIONS.md
bf3f507e676b3fd834240d72b945be4cd3b4018e5ebe570594eb27b8047c3969 179806 package-lock.json
e0b7a2add01750396a149830204bf2f50ddf86c6612fe08e2dd984f5a777cbfb 5097 package.json
8debf4523a801760c7943d5a3caea59d54add47d84c95cacac6b9fe80f8493b8 5606 package.json
2a597a5704c576783b8a72407fbc377fa7506b36a4596ea7f7bce126e394f837 1326 playwright.config.mjs
69318fdf054be7aa2fe86ead9847da9da65745d8d5de548c8346f3ba0afc4892 12175 preload.cjs
abe5dd6fd68f2970cd19ef134094907c67219061d8fe9a1a08324c78de4ad437 484 PUBLISH-AND-ENABLE-UPDATE.cmd
f018383f755352ca448e2ebb1e19b1dba412a3eb793d61e64b02953e300754fd 10538 Publish-ForgeFlow-Release.ps1
688fff7d2c989adb97ebb7fae38962656b70304a0aa5d27433c56adf7f136de0 4196 Publish-Missing-Binary-Release.ps1
794bbe1937077788f34c64398fd73dc9a3c43d095084aa32673f3e968b115de2 9150 README.md
c2054a7d246423270c1bb2a73c1406fcb37de132b515780bcf51fa755c30641b 14329 reports/architecture-audit.json
121f3f78eee25b8a896fdad8d2adb85e9be5a1469b4510cd481d1ca8a4e2c106 1114 reports/architecture-audit.md
3b2572a4d3a8aa3101bae6af49617e10062c43d72430314e1a31b04bc2933902 14329 reports/architecture-audit.json
285dace9a76c800cca8d1222c9322690575c0a92c2a0d507d1b9910b02864b7c 1114 reports/architecture-audit.md
509c7bcff5280349bd9f45ed6151f70372bad7010a9ea582c13e2ccab91fe0cd 6272 scripts/acceptance.mjs
00d57bda5af8c8eda294b72d18b318f024a307b81b0d9205a0821f5240151e31 3814 scripts/apply-binary-update.ps1
f8359a69d20deb2dfe10042d1bec7b12a95e76e58e36bc5f265f073c3111d056 10287 scripts/apply-source-update.ps1
@@ -107,13 +114,16 @@ fca922d7d1de598a0153f3828300c7d0327e0189c06a53a23d9fc33b36d45d91 4741
842436680521311594e798848b050ae4e488d0595f0de57315f6ec081c049fb9 1266 scripts/prune-dist.mjs
b83d443f5724ac15393567f3a688aed8315fbe3e5966832c864a9466e0669464 8102 scripts/publish-binary-release.cjs
444b397d515d65a7ee59d3088cba869cbb812d2b8cc18fc5d255105e3edb58c2 1468 scripts/serve-demo.mjs
b547dcb3c32f1c63185c13b899730cf2d7cde6c6e439a2ae60a58b740bb33f6f 819 scripts/signed-electron-builder-config.cjs
c76507857292c5713e1c699cf02e24b80265da39af2cecd148034bdb874adbb6 5246 scripts/test-authenticode-chain.ps1
4393f7dc5f417e6d601a68238f4e26791799a3634acec228fe4d79deaee85eb5 3109 scripts/validate-installed-connections.cjs
d2dd98055e50f11b4e1484531e43fb5ac7f876bea4b9cf5bca2cb0a15022b60a 1913 scripts/verify-release-signatures.mjs
aad97c5452d35ad5f49c67124fda012ab4f89b778d0f3e9e3f553d2977ef81d0 1565 scripts/validate-signing-environment.mjs
dd1d59fde63ac1450d26c837adfb4b0ff760ef2e2817bc50fcbf40b1f404409a 2089 scripts/verify-release-signatures.mjs
e8c5d00737d4c5e2c37ff683e569e8d7a0510be233db3840460c78be69f2c4bc 17163 scripts/verify.mjs
0b9f03ba3c67ff7cdb2916a902ad8ce25e81a7c90b210e4ae52d2ad029efabf3 2353 scripts/write-release-checksums.mjs
619515f524cb89960370ffcbd3fafd3c0e178b95f69c5868b1dd44777f23ec1e 2081 setup-windows.ps1
dd613d04b366f2cd071a1685a414016a5fb008082ed1b4cb8b24b79c100f640a 2412 src/main/audit-service.cjs
04b97b9b02ad8746d2229d40ba0585088118ecd18e1b5f3a1911a4f3b63831d6 32271 src/main/config-store.cjs
92856d698d0a5cc0a3e112e9dc05eb6de473809e9f82e7b08dd21f13f4ec1af8 32309 src/main/config-store.cjs
2fb04b1494b39f5d7c0720fa5fd298cd46fa85dc1b696d77657592347fcf1819 2731 src/main/configuration-backup.cjs
86e9fc2eda66b4b563f6c4bbb87d3e8514340d484fb503b73137e63b6b05c3c9 14597 src/main/deploy-key-lifecycle-service.cjs
7cbfe51973d6607203cb197652ed7f296a3f6b6b644df876957117866a47d802 2159 src/main/deployment-identity.cjs
@@ -166,7 +176,7 @@ a84da5aecbb16ce7983dba1f6d6aab1bf47b2e9a87c2933fa1afb8123f7ef7d6 1497
4b0a64610da0c446f15a43e4753b26f29de72e07793e2fa7b7322d4f07cf8050 27806 src/renderer/mock-deployment-bridge.js
26065ffa2359cd27b9c9b5b9fb67bcad83ba0f118960c7c024e7e9392dbb16a3 20032 src/renderer/mock-repository-bridge.js
94fa265c2fe9ca8d644f0ce9b620b6f85d9b25dca5802c4e9195b66dcbe80120 6522 src/renderer/operations.js
45692591428575b518678a6b548c25d3de95f568541e5648b0f06f42c48b5bbf 77872 src/renderer/styles.css
abe196f5ecdd73e7b6ca67a41c90e55bfc507e084f786227264cc780b1ce83a3 78001 src/renderer/styles.css
1703e64533b7e2717b27c5776296c7dd76331e6f97e8005aea9fd688f1aee3ae 94834 src/renderer/views.js
0a1e9d9d6cd4d190eb7f85dbc6668d80600b1cf2749cc0c2c51cc428f506f20d 1121 src/shared/clone-target.cjs
5d425d5c2f939d0f6beebee7ebb0c77146cb7e318535ba7286ec7081a4dc2269 2497 src/shared/deployment-policy.cjs
@@ -183,10 +193,11 @@ f5b0ea887fcdeadec78c1ad49b0ec7979723562f5c0b730703acb77a37281ee0 1009
a4e5947204ff6878e601e32477bc85b53cd0153baf95a161c8935b6e5466c257 1155 tests/audit-service.test.mjs
33bc892963e89b868235b959498308a456b1285057bda524ba7c1d5a9ee2159e 8763 tests/browser/forgeflow.spec.mjs
454edeaccb2bd41043bc918d3e3a6127db14339031d6a1c1562ac855e90455d2 4318 tests/clone-target.test.mjs
ac17f8bbe9e388b80abef7792c8b184a1fd482c93f13d23a478e433961020f75 17214 tests/config-store.test.mjs
f1463326aee79842d265687ae628189ce54e92544600f2bd14073780287cfb14 2502 tests/configuration-backup.test.mjs
b7e009fed4171d6dd6b4c3154ba1d3f7198e98f5b79b298687841fc8169447cd 9354 tests/deploy-key-lifecycle.test.mjs
1dc6477bd07de78be189e6e8195ec339eb9d75820c4dbd5b073b8520ee21f6b5 1938 tests/deployment-policy.test.mjs
bf68c4dc91a2604235c6a7848088bcd9566fbeaa089b86ec1e0a4fcfc54ca9d2 7677 tests/deployment-status.test.mjs
50e90cd41dae952a14903c40c0cb1fd191d7b875cfeb730f06a454e755fad7ce 9076 tests/deployment-status.test.mjs
fae3634bae871abade4d487b94b4741b50e787804dbd6135249f634fdd83c6d0 3800 tests/diagnostics.test.mjs
dd121d96ca265a027cd415a52064500a4541b2f8a662f4f4b25f2f996d52b5da 762 tests/external-tools.test.mjs
e7aebcc0d484a6a59d463d5cb26c11b3ad56e28f6535e7c38a0fe166a41565ea 13690 tests/git-integration.test.mjs
@@ -194,24 +205,25 @@ e7aebcc0d484a6a59d463d5cb26c11b3ad56e28f6535e7c38a0fe166a41565ea 13690
c00bbd8eae5cef7856c8283d6b40dedb81083bf57ad762e89ab79e0f312da351 3271 tests/git-validator-policy.test.mjs
73d00729696e5067ba33dd6d43b018d89ce7fdd561a60ab66648d3283fb54d21 5370 tests/git-validator.test.mjs
681ab7bcd02c4dd98d1d8d2092a3521c489d941131e7ffe5903971b940046474 2403 tests/git-workflows.test.mjs
771eeb4fa5443d581991cedb4107d7c414ce1b7d2e14bac7cf719ec8ba131647 8969 tests/gitea-actions.test.mjs
ca2c2c47b2532a74c7a77b9473ff417e0a34f0dbd8801936fd0e301a38f06a9a 18128 tests/gitea-actions.test.mjs
8f260f35aaf162999ddcd0f851a4f215222d9de0880d602b8322facdaa4c2cb0 9190 tests/inventory-classifier.test.mjs
9643622a03ea0a88fb7d72ce43e469ff4f814902f4b3d2a672990637d66ef075 2009 tests/ipc-contract.test.mjs
caf98cbd9de9b119dae610ee53fa333a7a11214f34762247452fbb85e8bbf725 2392 tests/log-redaction.test.mjs
96432a97d313f331694900bf0a2c21e38c20eac96d59147977aeed9055a9e3ad 2287 tests/partial-staging.test.mjs
c0f8f5a3784835f19d9ff1015185ccb385840b6fa1c9ec19f233393a7d952b65 3718 tests/preflight.test.mjs
1b6c920e18a248f78acaed6187197c88ec8d911b62d5e2a9f8ad57b91ae80499 11827 tests/preflight.test.mjs
f89643919df44232b2b112cdf68fe332b438d3d39c7ecab976d74836de286788 6526 tests/production-acceptance.test.mjs
629ba26395c0b49cc5fdee6b0646d75369eb6338e1cc7b59509938f97eea08ec 9601 tests/renderer-workflow.test.mjs
2b4956fa4df4624a04117737e57ba74020564330ff71303b5746d8ccc881e880 854 tests/repository-matching.test.mjs
f679072548554a64974f0452337ce5e7b0c567343c287223770cc0974b905348 1068 tests/repository-monitor.test.mjs
75b5b83836c75675bb9a48fe4363fcb8a24fc425e6af6f822d7955c6f3c79eac 2265 tests/repository-service.test.mjs
ebd3c0825bc9e2f1690cfd51e93a96bd33e939eb9c546aa373dd948b8cf71a69 7781 tests/repository-service.test.mjs
d49c772e3c7ddaa12dc5a1d4fc4cb474a4d99ae06fa5dab5a6cf1c44acb9ed6f 3463 tests/security-validation.test.mjs
bab853feb0e22aa25af17989baaa632c01efa636533ea67407fecfdd973c7024 627 tests/semver.test.mjs
e631e9ca49a5bac7075860aac2ff4d377a32a78377b70e06ecf833f0f192fd5f 11552 tests/server-inventory-branches.test.mjs
020eccfa9c4aef7a4ac4736d9af90518fcb6d1ad75aedcfaa1c92832a9e3d6d8 4609 tests/shell-verification.test.mjs
2571128f0b8e650071df17755baa09c4dfc441af0c20a7a4e9aa445b59e87d11 1654 tests/ssh-service.test.mjs
0d1bc4d623ce299337736c577ec61c8ffd6974ebe20335b72838d10eae35ecb1 7993 tests/ssh-service.test.mjs
8a6a8477eb94b85ccef18cddd2640afb0d1eafa679c96bc7de20428d5d69e1be 1794 tests/tool-invocation.test.mjs
fb5213c5f8ae8e98deed620eb93eb27a2317ef5a1ea671dd5ea548f0fd072362 44283 tests/unraid-deployment.test.mjs
861bad3f118c89bd17acf4373170c208c6e29c89af1d40fb2cf010f587a5016f 19008 tests/update-service.test.mjs
4182b61e395aff310b9a964c973a43c3566df0b44c454054c3abdd9459e86e3b 49134 tests/unraid-deployment.test.mjs
603c305301eaf0955574b6eb8b393140a3d3f0eabcee558b817130e2191c72bf 19880 tests/update-service.test.mjs
9cea5c1d5ba3e0972a0b5c7236cf1f7c5616373e0a39ea4a492ecebf70452e40 948 tests/validation.test.mjs
7ef4d4b9f5f3e6979293b29d571ce0e39f83197f3cade2d999a9cea7bacdd84d 1781 tests/zip-writer.test.mjs
8f36b542736f2933bad8b9464ad7fa37b68196009c81cf702ce3b677cd637dea 767 UPDATE_FROM_0.3.2.md
+9 -11
View File
@@ -1,20 +1,18 @@
# Coverage policy
ForgeFlow treats coverage as release evidence, not as a target to game. `npm run coverage`
enforces 75% statements, 75% lines, 75% functions and 60% branches globally.
enforces 75% statements, 75% lines, 75% functions and 65% branches globally.
The July 2026 hardening pass raised the measured baseline from 69.74% statements/lines,
68.82% functions and 55.38% branches to at least 78% statements/lines, 79% functions and
60% branches. The requested 65% global branch target was investigated but is not used as
the release gate yet. Node/V8 discovers additional branch counters when previously
unexecuted functions become covered; the denominator grew from 2,537 to more than 3,100
while the new tests added hundreds of asserted branches. Raising the number by excluding
command builders, platform guards or error adapters would make the result look better
without increasing deployment safety.
68.82% functions and 55.38% branches to 81.48% statements/lines, 82.07% functions and
65.59% branches. Node/V8 discovered additional branch counters when previously unexecuted
functions became covered; the denominator grew from 2,537 to 3,473 while the new tests
added hundreds of asserted decisions. No command builders, platform guards or error
adapters were excluded to improve the result cosmetically.
The 60% global gate is therefore paired with scenario-level evidence for the critical
The 65% global gate is paired with scenario-level evidence for the critical
boundaries: deploy-key rollback, deployment verification, Gitea authentication and
redirects, SSH host identity and output limits, inventory reconciliation, stale plans,
configuration recovery, release integrity and updater failure modes. New code must not
reduce the global baseline. A future increase to 65% should come from additional asserted
failure scenarios, not ignore comments or source exclusions.
reduce the global baseline. Future increases must come from additional asserted failure
scenarios, not ignore comments or source exclusions.
+2 -3
View File
@@ -45,9 +45,8 @@ console events, DOM, fixture details and test identity.
## 7. Coverage and dependencies
Coverage increased from 69.74% statements/lines, 68.82% functions and 55.38%
branches to at least 78.75%, 79.68% and 59.25%, respectively, before the last
ConfigStore tests. The enforced gates are 75/75/75/60; the rationale and 65%
follow-up are in `COVERAGE_POLICY.md`. Production dependencies have zero known
branches to 81.48%, 82.07% and 65.59%, respectively. The enforced gates are now
75/75/75/65 and are documented in `COVERAGE_POLICY.md`. Production dependencies have zero known
audit vulnerabilities. Remaining development findings belong to current upstream
ESLint/electron-builder toolchains and are assessed in `DEPENDENCY_AUDIT.md`.
+49 -1
View File
@@ -18,9 +18,57 @@ certificate through its supported CSC environment variables, then set:
```powershell
$env:FORGEFLOW_SIGNED_RELEASE = '1'
$env:FORGEFLOW_EXPECTED_PUBLISHER = 'CN=Exact Legal Publisher, O=Exact Legal Organization, C=BE'
npm run dist:win
npm run dist:win:signed
```
The signed command requires signed-release mode, an exact publisher subject and
either a classic `WIN_CSC_LINK` certificate configuration or complete Azure
credentials. It enables electron-builder's `forceCodeSigning` gate, so missing
signing material cannot silently produce a production candidate.
### Recommended: Azure Artifact Signing
1. Create an Azure Artifact Signing account and identity-validation certificate
profile for the legal ForgeFlow publisher.
2. Create an Entra app registration and give its service principal the
`Artifact Signing Certificate Profile Signer` role on that account.
3. Store the following as protected CI variables—never in Git:
```powershell
$env:AZURE_TENANT_ID = '<tenant id>'
$env:AZURE_CLIENT_ID = '<application/client id>'
$env:AZURE_CLIENT_SECRET = '<secret value>'
$env:FORGEFLOW_AZURE_SIGNING_ENDPOINT = 'https://<region>.codesigning.azure.net/'
$env:FORGEFLOW_AZURE_SIGNING_ACCOUNT = '<artifact signing account>'
$env:FORGEFLOW_AZURE_CERTIFICATE_PROFILE = '<certificate profile>'
$env:FORGEFLOW_SIGNED_RELEASE = '1'
$env:FORGEFLOW_EXPECTED_PUBLISHER = 'CN=<exact subject from Get-AuthenticodeSignature>'
npm run dist:win:signed
```
The generated configuration uses SHA-256 and Microsoft's RFC 3161 timestamp
service. `FORGEFLOW_EXPECTED_PUBLISHER` must still contain the complete subject
reported by the resulting certificate, even though Azure's builder option uses
its CN component.
### Alternative: classic CA certificate
When a CA supplies a CI-compatible PFX or hardware/cloud connector supported by
electron-builder, configure its protected values and use the same command:
```powershell
$env:WIN_CSC_LINK = 'C:\secure\forgeflow-signing.pfx'
$env:WIN_CSC_KEY_PASSWORD = '<secret password>'
$env:FORGEFLOW_SIGNED_RELEASE = '1'
$env:FORGEFLOW_EXPECTED_PUBLISHER = 'CN=<exact legal subject>, O=<organization>, C=BE'
npm run dist:win:signed
```
Do not purchase a certificate before the CA or cloud service confirms the exact
legal subject and that its key-storage method works with the intended Windows CI
runner. An ordinary OV certificate can still accumulate SmartScreen reputation;
EV or Azure Artifact Signing provides immediate publisher trust.
Both installer and portable executable must have a valid Authenticode signature,
the expected publisher and a timestamp. The build also creates SHA-256 files, a
CycloneDX SBOM and a provenance document containing commit and build ID.
+3 -3
View File
@@ -5,9 +5,9 @@
The quality chain contains more than 230 Node and browser acceptance cases. The
latest Windows source run completed without failures and retains one explicitly
Bash-dependent skip. `npm run coverage` enforces 75% lines/statements/functions
and 60% branches; the measured hardening baseline is 78.75% statements/lines,
79.68% functions and 59.25% branches before the final ConfigStore additions.
See `COVERAGE_POLICY.md` for the non-gamed branch policy.
and 65% branches; the measured hardening baseline is 81.48% statements/lines,
82.07% functions and 65.59% branches. See `COVERAGE_POLICY.md` for the
non-gamed branch policy.
`npm run quality` is the local equivalent of `.gitea/workflows/quality.yml` and
runs source verification, ESLint, the complete suite and coverage on Node 22 LTS.
+2 -1
View File
@@ -11,9 +11,10 @@
"demo": "node scripts/serve-demo.mjs",
"test": "node --test tests/*.test.mjs",
"lint": "eslint .",
"coverage": "c8 --check-coverage --lines 75 --functions 75 --branches 60 --statements 75 node --test tests/*.test.mjs",
"coverage": "c8 --check-coverage --lines 75 --functions 75 --branches 65 --statements 75 node --test tests/*.test.mjs",
"verify": "node scripts/verify.mjs",
"dist:win": "electron-builder --win nsis portable && node scripts/write-release-checksums.mjs && node scripts/verify-release-signatures.mjs && node scripts/prune-dist.mjs",
"dist:win:signed": "node scripts/validate-signing-environment.mjs && electron-builder --config scripts/signed-electron-builder-config.cjs --win nsis portable && node scripts/write-release-checksums.mjs && node scripts/verify-release-signatures.mjs && node scripts/prune-dist.mjs",
"dist:linux": "electron-builder --linux AppImage && node scripts/prune-dist.mjs",
"dist:mac": "electron-builder --mac dmg && node scripts/prune-dist.mjs",
"doctor": "node scripts/doctor.mjs",
@@ -0,0 +1,22 @@
"use strict";
const pkg = require("../package.json");
const expectedPublisher = String(process.env.FORGEFLOW_EXPECTED_PUBLISHER || "").trim();
const commonName = expectedPublisher.match(/^CN=([^,]+)/i)?.[1]?.trim();
const useAzure = Boolean(String(process.env.FORGEFLOW_AZURE_SIGNING_ENDPOINT || "").trim());
const win = { ...pkg.build.win, forceCodeSigning: true };
if (useAzure) {
win.azureSignOptions = {
publisherName: commonName,
endpoint: process.env.FORGEFLOW_AZURE_SIGNING_ENDPOINT,
codeSigningAccountName: process.env.FORGEFLOW_AZURE_SIGNING_ACCOUNT,
certificateProfileName: process.env.FORGEFLOW_AZURE_CERTIFICATE_PROFILE,
fileDigest: "SHA256",
timestampDigest: "SHA256",
timestampRfc3161: "http://timestamp.acs.microsoft.com",
};
}
module.exports = { ...pkg.build, win };
+17
View File
@@ -0,0 +1,17 @@
const signedRelease = process.env.FORGEFLOW_SIGNED_RELEASE === "1";
const publisher = String(process.env.FORGEFLOW_EXPECTED_PUBLISHER || "").trim();
const classicCertificate = String(process.env.WIN_CSC_LINK || process.env.CSC_LINK || "").trim();
const azureNames = ["AZURE_TENANT_ID", "AZURE_CLIENT_ID", "AZURE_CLIENT_SECRET", "FORGEFLOW_AZURE_SIGNING_ENDPOINT", "FORGEFLOW_AZURE_SIGNING_ACCOUNT", "FORGEFLOW_AZURE_CERTIFICATE_PROFILE"];
const azureValues = azureNames.map((name) => String(process.env[name] || "").trim());
const azure = azureValues.every(Boolean);
const partialAzure = azureValues.some(Boolean) && !azure;
if (!signedRelease) throw new Error("FORGEFLOW_SIGNED_RELEASE=1 is required for the production signing build.");
if (!/^CN=.+/i.test(publisher)) throw new Error("FORGEFLOW_EXPECTED_PUBLISHER must be the exact certificate subject beginning with CN=.");
if (partialAzure) throw new Error(`Azure Artifact Signing is incomplete. Configure: ${azureNames.filter((_, index) => !azureValues[index]).join(", ")}.`);
if (!classicCertificate && !azure) throw new Error("Configure WIN_CSC_LINK/CSC_LINK or all Azure Artifact Signing credentials before building a signed release.");
if (classicCertificate && !String(process.env.WIN_CSC_KEY_PASSWORD || process.env.CSC_KEY_PASSWORD || "").trim()) {
throw new Error("WIN_CSC_KEY_PASSWORD or CSC_KEY_PASSWORD is required for classic certificate signing.");
}
console.log(`Production ${azure ? "Azure Artifact Signing" : "classic certificate"} environment accepted for exact publisher ${publisher}.`);
+4 -4
View File
@@ -202,7 +202,7 @@ class ConfigStore {
return { persistent: true, preserved: false };
}
if (safeStorage.isEncryptionAvailable()) {
if (safeStorage?.isEncryptionAvailable?.()) {
this.data.gitea.encryptedToken = safeStorage.encryptString(value).toString('base64');
this.sessionToken = null;
return { persistent: true, preserved: false };
@@ -217,7 +217,7 @@ class ConfigStore {
if (this.sessionToken) return this.sessionToken;
if (!this.data.gitea.encryptedToken) return '';
try {
return safeStorage.decryptString(Buffer.from(this.data.gitea.encryptedToken, 'base64'));
return safeStorage?.decryptString?.(Buffer.from(this.data.gitea.encryptedToken, 'base64')) || '';
} catch {
return '';
}
@@ -227,7 +227,7 @@ class ConfigStore {
encryptSecret(value) {
const text = String(value || '');
if (!text) return null;
if (!safeStorage.isEncryptionAvailable()) {
if (!safeStorage?.isEncryptionAvailable?.()) {
const error = new Error('Secure credential storage is unavailable. ForgeFlow will not persist server passwords or key passphrases.');
error.code = 'SECURE_STORAGE_UNAVAILABLE';
throw error;
@@ -237,7 +237,7 @@ class ConfigStore {
decryptSecret(value) {
if (!value) return '';
try { return safeStorage.decryptString(Buffer.from(value, 'base64')); }
try { return safeStorage?.decryptString?.(Buffer.from(value, 'base64')) || ''; }
catch { return ''; }
}
+88
View File
@@ -165,3 +165,91 @@ test("profile, review and operation lookups return safe empty values", async (t)
await store.deleteInventoryReviewDecision("missing", "workload");
await store.deleteDeploymentProfile("missing/repo", "profile");
});
test("session credentials preserve, replace and clear safely when OS encryption is unavailable", async (t) => {
const { store } = await storeFixture(t);
assert.deepEqual(store.setToken(" session-token "), { persistent: false, preserved: false });
assert.equal(store.getToken(), "session-token");
assert.deepEqual(store.setToken("", { preserveExisting: true }), { persistent: false, preserved: true });
assert.equal(store.getToken(), "session-token");
assert.deepEqual(store.setToken("replacement"), { persistent: false, preserved: false });
assert.equal(store.getToken(), "replacement");
assert.deepEqual(store.setToken(""), { persistent: true, preserved: false });
assert.equal(store.getToken(), "");
assert.throws(() => store.encryptSecret("password"), (error) => error.code === "SECURE_STORAGE_UNAVAILABLE");
assert.equal(store.encryptSecret(""), null);
assert.equal(store.decryptSecret(null), "");
assert.equal(store.decryptSecret("not-base64-encrypted-data"), "");
});
test("setup, Gitea updates and generic patches retain normalized public state", async (t) => {
const { store } = await storeFixture(t);
const completed = await store.completeSetup({
baseUrl: "https://gitea.test", token: "token", user: { login: "jens" },
workspaceRoots: [" C:/Projects ", "C:/Projects", ""]
});
assert.equal(completed.state.setupComplete, true);
assert.equal(completed.state.gitea.hasToken, true);
assert.deepEqual(completed.state.workspaceRoots, ["C:/Projects"]);
const update = await store.updateGitea({ baseUrl: "https://new.test", token: "", user: null });
assert.equal(update.preserved, true);
assert.equal(store.data.gitea.user.login, "jens");
const patched = await store.patch({ appearance: "light", workspaceRoots: ["D:/Code", "D:/Code"] });
assert.equal(patched.appearance, "light");
assert.deepEqual(patched.workspaceRoots, ["D:/Code"]);
assert.equal("encryptedToken" in patched.gitea, false);
});
test("server saves reject absent credentials before mutating configuration", async (t) => {
const { store } = await storeFixture(t);
await assert.rejects(
store.saveServer({ host: "unraid", username: "root", authType: "password", basePath: "/mnt/apps" }),
/password is required/i
);
await assert.rejects(
store.saveServer({ host: "unraid", username: "root", authType: "privateKey", basePath: "/mnt/apps", privateKeyPath: "" }),
/select a private key/i
);
assert.deepEqual(store.data.servers, []);
});
test("deployment profile normalization covers safe defaults and every optional Unraid control", async (t) => {
const { store } = await storeFixture(t);
const actions = store.normalizeDeploymentProfile({ environment: "qa", statusUrl: "https://app.test/status" });
assert.equal(actions.provider, "gitea-actions");
assert.equal(actions.name, "qa");
assert.equal(actions.branch, "main");
assert.equal(actions.workflowFile, "deploy.yml");
assert.equal(actions.rollbackWorkflowFile, "");
assert.equal(actions.confirmationRequired, true);
const unraid = store.normalizeDeploymentProfile({
id: "all-options", name: " Server ", environment: "production", provider: "ssh-unraid", branch: "release",
serverId: " server ", remoteFolder: "apps/App", deploymentMode: "monitor-only", generatedCompose: true,
composeFiles: [], composeServices: ["WEB", "Worker"], composeProject: "App.prod", composeWorkingDir: "/mnt/apps/App",
containerName: "Visible.App", cloneUrl: "https://gitea.test/Owner/App.git", alignRemote: true,
hostPort: -2, containerPort: 70000, webUiUrl: "http://[IP]:[PORT:3000]/", iconMode: "upload",
iconFilePath: "C:/icon.png", dockerShell: "/bin/bash", preservePaths: [], adoptedFromServer: true,
serverSourceOfTruth: true, manageDockerMan: true, forceRecreate: true, removeOrphans: true,
workloadIdentity: { workloadId: "one" }, serverGitAccess: { configured: true, deployKeyId: "invalid", keyFingerprint: "", hostFingerprint: "", configuredAt: "now" },
provenance: { remoteFolder: "server" }, detectedMetadata: { source: "docker" }, serverIconReference: " icon ",
deploymentPolicy: { frozen: true, freezeReason: " maintenance ", requireNote: true, maintenanceWindows: [{ days: [0, 0, 6, 7, "bad"], start: "01:00", end: "02:00" }] }
});
assert.equal(unraid.name, "Server");
assert.equal(unraid.serverId, "server");
assert.equal(unraid.composeFile, "docker-compose.yml");
assert.deepEqual(unraid.composeServices, ["web", "worker"]);
assert.equal(unraid.hostPort, 1);
assert.equal(unraid.containerPort, 65535);
assert.equal(unraid.iconMode, "upload");
assert.equal(unraid.dockerShell, "/bin/bash");
assert.equal(unraid.serverGitAccess.deployKeyId, null);
assert.equal(unraid.serverGitAccess.keyFingerprint, null);
assert.deepEqual(unraid.deploymentPolicy.maintenanceWindows[0].days, [0, 6]);
assert.equal(unraid.serverIconReference, "icon");
assert.throws(() => store.normalizeDeploymentProfile({ provider: "ssh-unraid", remoteFolder: "app", environment: "prod", composeService: "app", composeServices: ["bad service"] }), /Compose services/);
assert.throws(() => store.normalizeDeploymentProfile({ provider: "ssh-unraid", remoteFolder: "app", environment: "prod", composeProject: "bad project!" }), /Compose project/);
assert.throws(() => store.normalizeDeploymentProfile({ provider: "ssh-unraid", remoteFolder: "app", environment: "prod", composeWorkingDir: "relative" }), /working directory/);
assert.throws(() => store.normalizeDeploymentProfile({ provider: "ssh-unraid", remoteFolder: "app", environment: "prod", containerName: "bad name" }), /Container name/);
});
+112
View File
@@ -63,3 +63,115 @@ test('deployment preflight verifies exact Git, workflow, Actions and server prer
assert.equal(result.checks.filter((item) => item.status === 'fail').length, 0);
assert.equal(result.head, sha);
});
test('system preflight reports unavailable Git, storage, roots and rejected Gitea credentials', async (t) => {
const root = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-preflight-failures-'));
t.after(() => rm(root, { recursive: true, force: true }));
const ordinaryFile = path.join(root, 'not-a-directory');
await writeFile(ordinaryFile, 'file');
const events = [];
const service = new PreflightService({
store: { data: { gitea: { baseUrl: 'https://stored.test' } }, getToken: () => 'stored-token' },
git: { isAvailable: async () => ({ available: false, error: 'git missing' }) },
gitea: { validateConnection: async () => { throw new Error('token rejected'); } },
deployments: {}, diagnostics: { logDirectory: path.join(root, 'logs'), info: async (...args) => events.push(args) },
userDataPath: path.join(root, 'data'), secureStorageAvailable: () => false
});
service.writableDirectory = async (directory) => {
if (directory.endsWith('data')) throw new Error('read only');
return true;
};
const result = await service.runSystem({ roots: [ordinaryFile, path.join(root, 'missing'), ordinaryFile, ''] });
assert.equal(result.checks.find((item) => item.id === 'git.available').status, 'fail');
assert.equal(result.checks.find((item) => item.id === 'storage.userdata').status, 'fail');
assert.equal(result.checks.find((item) => item.id === 'storage.diagnostics').status, 'pass');
assert.equal(result.checks.find((item) => item.id === 'storage.credentials').status, 'warning');
assert.equal(result.checks.find((item) => item.id === 'workspace.root.0').status, 'fail');
assert.equal(result.checks.find((item) => item.id === 'workspace.root.1').status, 'fail');
assert.equal(result.checks.find((item) => item.id === 'gitea.connection').status, 'fail');
assert.equal(result.summary.ready, false);
assert.equal(events[0][0], 'preflight.system.completed');
});
test('system preflight warns on incomplete Git identity and accepts unknown Gitea version', async (t) => {
const root = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-preflight-identity-'));
t.after(() => rm(root, { recursive: true, force: true }));
const service = new PreflightService({
store: { data: { gitea: { baseUrl: '' } }, getToken: () => '' },
git: { isAvailable: async () => ({ available: true, version: 'git' }) },
gitea: { validateConnection: async () => ({ version: null, user: null, repositoryCount: 0 }) }, deployments: {},
diagnostics: { logDirectory: path.join(root, 'logs'), info: async () => {} }, userDataPath: path.join(root, 'data')
});
service.gitIdentity = async () => ({ name: '', email: '' });
const result = await service.runSystem({ baseUrl: 'https://gitea.test', token: 'token', roots: [] });
assert.equal(result.checks.find((item) => item.id === 'git.identity').status, 'warning');
assert.match(result.checks.find((item) => item.id === 'gitea.connection').detail, /unknown version.*user/i);
assert.equal(result.checks.find((item) => item.id === 'gitea.repositories').status, 'pass');
assert.equal(result.checks.find((item) => item.id === 'workspace.roots').status, 'warning');
service.gitIdentity = async () => { throw new Error('identity lookup failed'); };
const second = await service.runSystem();
assert.match(second.checks.find((item) => item.id === 'git.identity').detail, /lookup failed/i);
});
test('deployment preflight fails fast for invalid identity, profile and missing local link', async () => {
const diagnostics = [];
const service = new PreflightService({
store: { getDeploymentProfile: (_name, id) => id === 'known' ? { id: 'known', name: 'Production' } : null },
git: {}, gitea: {}, deployments: {}, diagnostics: { info: async (...args) => diagnostics.push(args) }, userDataPath: ''
});
await assert.rejects(service.runDeployment({ repository: null, profileId: 'known' }), /identity is required/i);
await assert.rejects(service.runDeployment({ repository: { fullName: 'owner/app' }, profileId: 'missing' }), /profile not found/i);
const result = await service.runDeployment({ repository: { fullName: 'owner/app', localPath: '' }, profileId: 'known' });
assert.deepEqual(result.summary.blocking, ['repository.linked']);
assert.equal(diagnostics[0][0], 'preflight.deployment.completed');
});
test('deployment preflight preserves actionable evidence across Git, workflow and endpoint failures', async (t) => {
const root = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-preflight-degraded-'));
t.after(() => rm(root, { recursive: true, force: true }));
const profile = { id: 'production', name: 'Production', environment: 'production', branch: 'main', workflowFile: 'deploy.yml', rollbackWorkflowFile: 'rollback.yml' };
const service = new PreflightService({
store: { getDeploymentProfile: () => profile },
git: {
status: async () => ({ root, head: 'b'.repeat(40), clean: false, counts: { changed: 4 }, branch: { head: '', upstream: '', ahead: 2, behind: 3 } }),
verifyCommitOnRemoteBranch: async () => { throw new Error('commit not published'); }
},
gitea: { repositoryFileExists: async () => false, listWorkflowRuns: async () => { throw new Error('Actions disabled'); } },
deployments: {}, diagnostics: { info: async () => {} }, userDataPath: root
});
const result = await service.runDeployment({ repository: { fullName: 'owner/app', localPath: root }, profileId: profile.id });
for (const id of ['git.branch', 'git.clean', 'git.upstream', 'git.sync', 'git.remote-sha', 'workflow.deploy.local', 'workflow.deploy.remote', 'gitea.actions', 'server.status.configured']) {
assert.equal(result.checks.find((item) => item.id === id).status, 'fail', id);
}
assert.equal(result.checks.find((item) => item.id === 'workflow.rollback.local').status, 'warning');
assert.equal(result.checks.find((item) => item.id === 'server.health').status, 'warning');
assert.equal(result.head, 'b'.repeat(40));
});
test('deployment preflight distinguishes unreachable and mismatched status evidence', async (t) => {
const root = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-preflight-status-'));
t.after(() => rm(root, { recursive: true, force: true }));
await mkdir(path.join(root, '.gitea', 'workflows'), { recursive: true });
await writeFile(path.join(root, '.gitea', 'workflows', 'deploy.yml'), 'name: deploy\n');
const profile = { id: 'production', name: 'Production', environment: 'production', branch: 'main', workflowFile: 'deploy.yml', statusUrl: 'https://app/status', healthcheckUrl: 'https://app/health' };
let status = { reachable: false, ok: false, status: 503, error: '' };
const service = new PreflightService({
store: { getDeploymentProfile: () => profile },
git: { status: async () => { throw new Error('checkout corrupt'); } },
gitea: { repositoryFileExists: async () => { throw new Error('Gitea offline'); } },
deployments: { readStatusEndpoint: async () => status, checkHealth: async () => ({ healthy: false, status: 500, error: '' }) },
diagnostics: { info: async () => {} }, userDataPath: root
});
const unreachable = await service.runDeployment({ repository: { fullName: 'owner/app', localPath: root }, profileId: profile.id });
assert.match(unreachable.checks.find((item) => item.id === 'server.status.reachable').detail, /HTTP 503/i);
assert.match(unreachable.checks.find((item) => item.id === 'server.health').detail, /HTTP 500/i);
assert.match(unreachable.checks.find((item) => item.id === 'git.repository').detail, /checkout corrupt/i);
status = { reachable: true, ok: true, repository: 'other/app', environment: 'staging', liveSha: null };
const mismatch = await service.runDeployment({ repository: { fullName: 'owner/app', localPath: root }, profileId: profile.id });
const identity = mismatch.checks.find((item) => item.id === 'server.status.identity');
assert.equal(identity.status, 'fail');
assert.equal(identity.required, true);
assert.match(mismatch.checks.find((item) => item.id === 'server.status.reachable').detail, /no live SHA/i);
});
+144
View File
@@ -0,0 +1,144 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const {
parseServerInventory, buildWorkloadInventory, inventoryContainerMatch, remoteIdentity,
stableWorkloadId, profileMatchesWorkload, sanitizeLegacyContainer, safeRelativeToBase,
canonicalServerAppdataPath, deploymentRootCandidate,
} = require('../src/main/server-inventory.cjs');
const b64 = (value) => Buffer.from(String(value)).toString('base64');
test('inventory parser handles every evidence record and ignores malformed payloads', () => {
const legacy = { Id: 'legacy', Name: '/App', Config: { Image: 'app:1', Labels: { 'com.docker.compose.project': 'app' } }, State: { Running: true, Status: 'running', Health: { Status: 'healthy' } }, Mounts: null };
const safe = { id: 'safe', name: '/Safe', running: false, labels: null, mounts: null, ports: null, networks: null };
const projects = [{ Name: 'app', Status: 'running(1)', ConfigFiles: '/mnt/user/appdata/App/compose.yml,/mnt/user/appdata/App/extra.yml' }, { name: '', configFiles: [] }];
const output = [
'noise', '__FORGEFLOW_INVENTORY__',
`H\ttrue\tfalse\ttrue\ttrue\tfalse\ttrue\t${b64('Compose v2')}\t${b64('Linux')}`,
`R\t${b64('/mnt/user/appdata/App')}\t${b64('git@gitea.test:Owner/App.git')}\t${'a'.repeat(40)}\t${b64('main')}`,
`C\t${b64(JSON.stringify([legacy, null]))}`,
`C\t${b64(JSON.stringify(safe))}`,
`C\t${b64('{bad json')}`,
`D\t${b64('App')}\t${b64('/templates/App.xml')}\t${b64('http://app')}\t${b64('/icon.png')}\t${b64('/bin/bash')}\t${b64('app:1')}\t${b64('bridge')}`,
`P\t${b64(JSON.stringify(projects))}`,
`P\t${b64(JSON.stringify({ name: 'single', status: 'exited', config_files: ['single.yml'] }))}`,
`Y\t${b64('/mnt/user/appdata/App/')}\t${b64('compose.yml\ncompose.prod.yml\n')}\t${b64('app')}\t${b64('web\nworker')}\t${b64('app:1')}\ttrue\t${b64('')}`,
`W\t${b64('partial docker inspect failure')}`,
'UNKNOWN\tignored',
].join('\n');
const parsed = parseServerInventory(output);
assert.deepEqual(parsed.capabilities, { docker: true, compose: false, git: true, tar: true, checksum: false, baseWritable: true, composeVersion: 'Compose v2', platform: 'Linux' });
assert.equal(parsed.checkouts.length, 1);
assert.equal(parsed.containers.length, 2);
assert.equal(parsed.containers[0].health, 'healthy');
assert.deepEqual(parsed.containers[1].labels, {});
assert.equal(parsed.dockerMan[0].templatePath, '/templates/App.xml');
assert.equal(parsed.composeProjects.length, 2);
assert.deepEqual(parsed.composeProjects[0].configFiles, ['/mnt/user/appdata/App/compose.yml', '/mnt/user/appdata/App/extra.yml']);
assert.deepEqual(parsed.composeDefinitions[0].services, ['web', 'worker']);
assert.deepEqual(parsed.warnings, ['partial docker inspect failure']);
assert.throws(() => parseServerInventory('ordinary output'), /did not return/i);
});
test('server path normalization keeps deployments inside canonical appdata', () => {
assert.equal(safeRelativeToBase('/mnt/user/appdata/', '/mnt/user/appdata/App/'), 'App');
for (const value of ['', '/mnt/user/appdata', '/mnt/user/appdata/../etc', '/other/App']) assert.equal(safeRelativeToBase('/mnt/user/appdata', value), '');
assert.equal(canonicalServerAppdataPath('/mnt/user/appdata', '/mnt/cache/appdata/App'), '/mnt/user/appdata/App');
assert.equal(canonicalServerAppdataPath('/mnt/user/appdata', '/mnt/disk2/appdata/App/data'), '/mnt/user/appdata/App/data');
assert.equal(canonicalServerAppdataPath('', '/mnt/user/appdata/App'), '/mnt/user/appdata/App');
assert.equal(canonicalServerAppdataPath('/custom', '/outside/path'), '/outside/path');
assert.equal(canonicalServerAppdataPath('/custom', ''), '');
assert.equal(deploymentRootCandidate('App/source-pre-abcdef1/source'), 'App');
assert.equal(deploymentRootCandidate('App/.forgeflow/incoming'), 'App');
});
test('profile matching requires the same server and accepts each stable identity form', () => {
const workload = { serverId: 'server', workloadId: 'workload', selector: { kind: 'compose', composeProject: 'app' }, compose: { project: 'app', workingDir: '/apps/App' }, remoteFolderCandidate: 'App', containers: [{ name: 'app-web' }] };
assert.equal(profileMatchesWorkload(null, workload), false);
assert.equal(profileMatchesWorkload({ provider: 'gitea-actions', serverId: 'server' }, workload), false);
assert.equal(profileMatchesWorkload({ provider: 'ssh-unraid', serverId: 'other' }, workload), false);
assert.equal(profileMatchesWorkload({ provider: 'ssh-unraid', serverId: 'server', workloadIdentity: { workloadId: 'workload' } }, workload), true);
assert.equal(profileMatchesWorkload({ provider: 'ssh-unraid', serverId: 'server', workloadIdentity: { selector: workload.selector } }, workload), true);
assert.equal(profileMatchesWorkload({ provider: 'ssh-unraid', serverId: 'server', composeProject: 'app' }, workload), true);
assert.equal(profileMatchesWorkload({ provider: 'ssh-unraid', serverId: 'server', composeProject: 'app', composeWorkingDir: '/other' }, workload), false);
assert.equal(profileMatchesWorkload({ provider: 'ssh-unraid', serverId: 'server', remoteFolder: 'App' }, workload), true);
assert.equal(profileMatchesWorkload({ provider: 'ssh-unraid', serverId: 'server', containerName: 'app-web' }, workload), true);
assert.equal(stableWorkloadId('server', workload.selector), stableWorkloadId('server', workload.selector));
});
test('container matching prioritizes working directory, mounts, provenance and stable names', () => {
const checkout = { root: '/apps/App', remote: 'git@gitea.test:Owner/App.git' };
const repository = { name: 'App' };
const base = { running: true, labels: {}, mounts: [], name: 'different' };
assert.equal(inventoryContainerMatch(checkout, repository, { ...base, labels: { 'com.docker.compose.project.working_dir': '/apps/App/' } }), 100);
assert.equal(inventoryContainerMatch(checkout, repository, { ...base, mounts: [{ Source: '/apps/App/data' }] }), 90);
assert.equal(inventoryContainerMatch(checkout, repository, { ...base, labels: { 'org.opencontainers.image.source': 'https://gitea.test/Owner/App' } }), 85);
assert.equal(inventoryContainerMatch(checkout, repository, { ...base, labels: { 'com.docker.compose.project': 'app' } }), 70);
assert.equal(inventoryContainerMatch(checkout, repository, { ...base, name: '/APP' }), 60);
assert.equal(inventoryContainerMatch(checkout, repository, { ...base, running: false }), 0);
assert.equal(inventoryContainerMatch(checkout, repository, base), 0);
assert.equal(remoteIdentity(''), '');
});
test('workload builder merges runtime, Compose file and DockerMan evidence without backups', () => {
const labels = {
'com.docker.compose.project': 'app',
'com.docker.compose.project.working_dir': '/mnt/cache/appdata/App',
'com.docker.compose.project.config_files': '/mnt/cache/appdata/App/compose.yml',
'com.docker.compose.service': 'web',
'tech.itworx.forgeflow.repository': 'git@gitea.test:Owner/App.git',
'tech.itworx.forgeflow.commit': 'b'.repeat(40),
'tech.itworx.forgeflow.branch': 'main',
};
const inventory = {
containers: [
{ id: 'web', name: 'app-web', image: 'registry/app:1', imageId: 'image', running: true, status: 'running', health: 'unhealthy', labels, ports: { '8080/tcp': null, '3000/udp': [{ HostIp: '0.0.0.0', HostPort: '3000' }] }, mounts: [{ Type: 'bind', Source: '/mnt/disk1/appdata/App/data', Destination: '/data', RW: false }], networks: { frontend: {} }, restartPolicy: 'always' },
{ id: 'worker', name: 'app-worker', image: 'registry/worker:1', imageId: 'worker', running: false, status: 'exited', health: null, labels: { ...labels, 'com.docker.compose.service': 'worker' }, ports: {}, mounts: [], networks: {}, restartPolicy: '' },
],
checkouts: [{ root: '/mnt/user/appdata/App', remote: 'git@gitea.test:Owner/App.git', liveSha: 'c'.repeat(40), branch: 'release' }],
composeProjects: [{ name: 'app', status: 'running', configFiles: [] }, { name: 'headless', status: 'exited', configFiles: ['/mnt/user/appdata/Headless/compose.yml'] }],
composeDefinitions: [
{ workingDir: '/mnt/user/appdata/App', configFiles: ['/mnt/user/appdata/App/compose.yml'], projectName: 'app', services: ['web', 'worker'], images: ['registry/app:1'], valid: true, error: '' },
{ workingDir: '/mnt/user/appdata/Backup/.forgeflow/releases/one', configFiles: ['compose.yml'], projectName: 'backup', services: [], images: [], valid: true },
{ workingDir: '/mnt/user/appdata/Standalone', configFiles: ['/mnt/user/appdata/Standalone/compose.yml'], projectName: '', services: ['api'], images: ['standalone:1'], valid: false, error: 'invalid compose' },
],
dockerMan: [
{ name: 'app-web', templatePath: '/templates/app.xml', webUiUrl: 'http://app', iconUrl: '/app.png', shell: '/bin/bash', repository: 'registry/app:1', network: 'frontend' },
{ name: 'template-only', templatePath: '/templates/template.xml', webUiUrl: '', iconUrl: '', shell: '', repository: 'template:1', network: 'bridge' },
], warnings: [], capabilities: {},
};
const repository = { fullName: 'Owner/App', name: 'App', cloneUrl: 'https://gitea.test/Owner/App.git' };
const workloads = buildWorkloadInventory({ inventory, server: { id: 'server', name: 'Unraid', basePath: '/mnt/user/appdata' }, repositories: [repository], profiles: [{ id: 'profile', provider: 'ssh-unraid', serverId: 'server', composeProject: 'app', repositoryFullName: 'Owner/App', adoptedFromServer: true }] });
assert.equal(workloads.some((workload) => workload.displayName === 'backup'), false);
const app = workloads.find((workload) => workload.displayName === 'app');
assert.equal(app.status, 'linked');
assert.equal(app.runtime.running, true);
assert.equal(app.runtime.allRunning, false);
assert.equal(app.runtime.health, 'unhealthy');
assert.equal(app.runtime.ports.length, 2);
assert.equal(app.containers[0].mounts[0].readOnly, true);
assert.equal(app.remoteFolderCandidate, 'App');
assert.equal(app.candidates[0].exact, true);
assert.equal(app.link.source, 'automatic');
const standalone = workloads.find((workload) => workload.displayName === 'Standalone');
assert.equal(standalone.metadata.composeDefinitionValid, false);
assert.equal(standalone.metadata.composeDefinitionError, 'invalid compose');
const template = workloads.find((workload) => workload.displayName === 'template-only');
assert.equal(template.kind, 'dockerman-container');
assert.equal(template.runtime.running, false);
assert.equal(template.metadata.shell, '/bin/sh');
});
test('legacy container sanitizer applies safe defaults to partial Docker inspect data', () => {
assert.deepEqual(sanitizeLegacyContainer(null), {
id: '', name: '', image: '', imageId: '', running: false, status: '', health: null,
labels: {
'com.docker.compose.project': '', 'com.docker.compose.project.working_dir': '', 'com.docker.compose.project.config_files': '', 'com.docker.compose.service': '',
'org.opencontainers.image.source': '', 'org.opencontainers.image.revision': '', 'tech.itworx.forgeflow.repository': '', 'tech.itworx.forgeflow.commit': '',
'tech.itworx.forgeflow.branch': '', 'net.unraid.docker.webui': '', 'net.unraid.docker.icon': '', 'net.unraid.docker.shell': '', 'net.unraid.docker.managed': '',
}, ports: {}, mounts: [], networks: {}, restartPolicy: '',
});
});
+16
View File
@@ -528,6 +528,22 @@ test("Windows release pipeline fails closed on signatures and emits provenance p
assert.match(publisher, /sbom\.cdx\.json/);
});
test("production signing build supports classic and Azure identities but always fails closed", async () => {
const [pkg, validator, signedConfig] = await Promise.all([
readFile(new URL("../package.json", import.meta.url), "utf8"),
readFile(new URL("../scripts/validate-signing-environment.mjs", import.meta.url), "utf8"),
readFile(new URL("../scripts/signed-electron-builder-config.cjs", import.meta.url), "utf8"),
]);
assert.match(pkg, /dist:win:signed/);
assert.match(validator, /FORGEFLOW_SIGNED_RELEASE/);
assert.match(validator, /WIN_CSC_LINK/);
assert.match(validator, /FORGEFLOW_AZURE_CERTIFICATE_PROFILE/);
assert.match(validator, /exact certificate subject/);
assert.match(signedConfig, /forceCodeSigning:\s*true/);
assert.match(signedConfig, /azureSignOptions/);
assert.match(signedConfig, /timestamp\.acs\.microsoft\.com/);
});
test("binary update helper verifies, waits, applies and records restart state", async () => {
const helper = await readFile(
new URL("../scripts/apply-binary-update.ps1", import.meta.url),