feat: harden release signing and coverage gate
ForgeFlow quality gate / quality (push) Canceled after 0s
ForgeFlow quality gate / quality (push) Canceled after 0s
This commit is contained in:
+28
-16
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 };
|
||||
@@ -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}.`);
|
||||
@@ -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 ''; }
|
||||
}
|
||||
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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: '',
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user