mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 13:39:44 +02:00
Compare commits
1281 Commits
n8n@2.41.3
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
936349a9c7 | ||
|
|
842ec5b62d | ||
|
|
ec074519d9 | ||
|
|
6f3b397a1e | ||
|
|
21221adc78 | ||
|
|
532e8ef930 | ||
|
|
24621ad4ca | ||
|
|
8ee5cf1845 | ||
|
|
7f16040253 | ||
|
|
ddc97694b5 | ||
|
|
eaf47cfe39 | ||
|
|
986b82fa28 | ||
|
|
76cce99626 | ||
|
|
f34c093f08 | ||
|
|
158152496a | ||
|
|
1f223c85f8 | ||
|
|
6a015ecb18 | ||
|
|
033ec05bff | ||
|
|
dd79a610c5 | ||
|
|
a63a8edb55 | ||
|
|
f5dc06f753 | ||
|
|
b78913f75d | ||
|
|
dfec6dee81 | ||
|
|
f92b4c11d6 | ||
|
|
5120d25cbc | ||
|
|
a6719b8db7 | ||
|
|
7001886432 | ||
|
|
a906d2fdfc | ||
|
|
683fd11259 | ||
|
|
1b02863c00 | ||
|
|
d0cb582163 | ||
|
|
ea98bd0600 | ||
|
|
1405d4a351 | ||
|
|
f84413516a | ||
|
|
333a010112 | ||
|
|
b7b3d72737 | ||
|
|
5a3fc39972 | ||
|
|
d460eb28ca | ||
|
|
4a5fdd5b68 | ||
|
|
e7f0607bf1 | ||
|
|
64d8a3ad19 | ||
|
|
c70b5d49b3 | ||
|
|
c28d130c6c | ||
|
|
ca9661c091 | ||
|
|
a94f5dc8b7 | ||
|
|
fffea4c0c0 | ||
|
|
14e633d481 | ||
|
|
575eb6fa43 | ||
|
|
3ea0a3da98 | ||
|
|
fed8b1ec69 | ||
|
|
eba25caa70 | ||
|
|
fe3627914a | ||
|
|
aba5258930 | ||
|
|
aadbb7c498 | ||
|
|
8bee1eeb20 | ||
|
|
2eab299dfa | ||
|
|
fc9f9575e2 | ||
|
|
116a81e06e | ||
|
|
b36f6c0baf | ||
|
|
fa88c3e251 | ||
|
|
8120328661 | ||
|
|
1ee29b2875 | ||
|
|
f553331100 | ||
|
|
1a6b6a65d4 | ||
|
|
88a2426947 | ||
|
|
f5febab84d | ||
|
|
dde9871497 | ||
|
|
cc83b5bd77 | ||
|
|
1ea18e9ef9 | ||
|
|
1ba56b6579 | ||
|
|
baa38dbdc6 | ||
|
|
601f6ac0da | ||
|
|
e29c3a4085 | ||
|
|
447fc866bb | ||
|
|
860f1273eb | ||
|
|
deed9a2f32 | ||
|
|
a32c652c09 | ||
|
|
1903d53bc0 | ||
|
|
131e9c8679 | ||
|
|
236220b248 | ||
|
|
af0202d840 | ||
|
|
adc6d35388 | ||
|
|
b2b2a8641a | ||
|
|
952b1e73b2 | ||
|
|
f596162dc7 | ||
|
|
378847ae1e | ||
|
|
9e3b52cbbb | ||
|
|
054288e488 | ||
|
|
c839d6cd49 | ||
|
|
154cd63bbc | ||
|
|
3c447b42a7 | ||
|
|
1659fa3737 | ||
|
|
9806798e89 | ||
|
|
cdeb405948 | ||
|
|
e45a65a9bf | ||
|
|
b68bd3129b | ||
|
|
e8ded61fe1 | ||
|
|
d046047dd0 | ||
|
|
f483ebd9d1 | ||
|
|
fddef57734 | ||
|
|
ba97e46afc | ||
|
|
4cb7a6ee3d | ||
|
|
8ed8607a52 | ||
|
|
67c1981f2f | ||
|
|
a8880cb449 | ||
|
|
d411a44be0 | ||
|
|
0c0b38e3cb | ||
|
|
315e0a5377 | ||
|
|
efd4e21aad | ||
|
|
bab83634ce | ||
|
|
d6c9b9fba3 | ||
|
|
b36befcb75 | ||
|
|
ed2500ffb2 | ||
|
|
9c0b546a30 | ||
|
|
07595a0007 | ||
|
|
1e67d0315d | ||
|
|
338b0ca3fd | ||
|
|
48edbef643 | ||
|
|
5d79c16f51 | ||
|
|
40094e363b | ||
|
|
e2529a8494 | ||
|
|
50c4ced224 | ||
|
|
b7a5500d16 | ||
|
|
f6b24d0ae9 | ||
|
|
3c7dcf902d | ||
|
|
fbe743b9df | ||
|
|
f5924004ca | ||
|
|
b270d48686 | ||
|
|
3277f8c3b5 | ||
|
|
a57c8f3aba | ||
|
|
dcd312c5a9 | ||
|
|
a6d5b4de3f | ||
|
|
3968a60bf7 | ||
|
|
17fb3c0e17 | ||
|
|
b965e90307 | ||
|
|
38135e1f83 | ||
|
|
616062487a | ||
|
|
1950434a27 | ||
|
|
b3e8b7d9ce | ||
|
|
56eac665c3 | ||
|
|
8bc7161ab1 | ||
|
|
57e26feda4 | ||
|
|
9c42a76b1a | ||
|
|
ae374e79ab | ||
|
|
ef8d61bda7 | ||
|
|
caeaff7230 | ||
|
|
a3f47117cf | ||
|
|
9a524e7c21 | ||
|
|
adedbbf560 | ||
|
|
4eb63d6f41 | ||
|
|
b6d39e2d30 | ||
|
|
7920e6cf6b | ||
|
|
76c43094cd | ||
|
|
898701b05b | ||
|
|
f63111202f | ||
|
|
633eacaae6 | ||
|
|
1b0cdd5998 | ||
|
|
578eea403d | ||
|
|
7c38974070 | ||
|
|
d68ad7e97b | ||
|
|
0650b9cce0 | ||
|
|
df7b3a19c6 | ||
|
|
de42c9515d | ||
|
|
d26f914e3d | ||
|
|
8b5ea6d9e0 | ||
|
|
bfd4885026 | ||
|
|
3800bce812 | ||
|
|
aae8eb63d7 | ||
|
|
0b4c99f10d | ||
|
|
066480d8db | ||
|
|
b8780d8fee | ||
|
|
e47940eecd | ||
|
|
e3b2603d7b | ||
|
|
726a5829c8 | ||
|
|
54f1670791 | ||
|
|
b3e8634c83 | ||
|
|
1643a8598b | ||
|
|
9498088256 | ||
|
|
da3491fc87 | ||
|
|
d83f95b377 | ||
|
|
46761a60a9 | ||
|
|
ccf42e3213 | ||
|
|
94bdf85a8a | ||
|
|
29de1c63ae | ||
|
|
92f8eb51b3 | ||
|
|
146d7804af | ||
|
|
88de3cac20 | ||
|
|
04f4afe3dc | ||
|
|
6924027e93 | ||
|
|
ef183e632d | ||
|
|
ed1c1e561a | ||
|
|
a3a014422f | ||
|
|
f1fecf97e9 | ||
|
|
5da4d19e50 | ||
|
|
2019ea5593 | ||
|
|
4419fc4ba2 | ||
|
|
52aed7ac97 | ||
|
|
1d2c879fab | ||
|
|
bffe7d8392 | ||
|
|
d245b17b89 | ||
|
|
8977833ee2 | ||
|
|
acf5291d80 | ||
|
|
ebf078b64c | ||
|
|
9052f8ff3e | ||
|
|
e0fe04084e | ||
|
|
e859abaf18 | ||
|
|
a972eebe51 | ||
|
|
739c59e991 | ||
|
|
de090cb2a0 | ||
|
|
b97fe7c7d4 | ||
|
|
38e4aebfc8 | ||
|
|
d8a06666da | ||
|
|
60e0cb1e7b | ||
|
|
b09efb706c | ||
|
|
f090f53480 | ||
|
|
347385b07b | ||
|
|
bb45bca5ff | ||
|
|
3075b60576 | ||
|
|
4fcb628fca | ||
|
|
1ee379898b | ||
|
|
366ed6ed0e | ||
|
|
248c86355b | ||
|
|
e5866ac6c1 | ||
|
|
2bc3265e63 | ||
|
|
f6861fe494 | ||
|
|
632d8b9eb0 | ||
|
|
5549374241 | ||
|
|
cba16247b9 | ||
|
|
e528f948ce | ||
|
|
d3bc2754b4 | ||
|
|
ffda639b3f | ||
|
|
97e5ae56e4 | ||
|
|
bda9118168 | ||
|
|
96eff1efb9 | ||
|
|
eb66e1bde0 | ||
|
|
a8a028e2dc | ||
|
|
44c8a312c3 | ||
|
|
7bad46ee7e | ||
|
|
45eea1bc35 | ||
|
|
c8c8c3a81e | ||
|
|
e74f1c19da | ||
|
|
f16c1375b1 | ||
|
|
5146e3960f | ||
|
|
14c77900df | ||
|
|
bae368caca | ||
|
|
f3e2bd83b3 | ||
|
|
27fcb968fb | ||
|
|
2562744611 | ||
|
|
8d93e31d1d | ||
|
|
2596f3ac1c | ||
|
|
e2c4054cba | ||
|
|
2ca4e55502 | ||
|
|
cea974e770 | ||
|
|
ce20d1dea4 | ||
|
|
53d1fd5991 | ||
|
|
776f4e1944 | ||
|
|
3ff9c83bda | ||
|
|
89733cdb9a | ||
|
|
0209caafff | ||
|
|
0755255c35 | ||
|
|
36ae8f74ce | ||
|
|
160c126c6e | ||
|
|
d3deffbfa5 | ||
|
|
b542f66119 | ||
|
|
f0a38cfe58 | ||
|
|
27b43de85f | ||
|
|
7ceb84f06c | ||
|
|
2a51f38d55 | ||
|
|
e0958fe094 | ||
|
|
bc8b803471 | ||
|
|
26d93b601a | ||
|
|
c9cb3ee3ab | ||
|
|
22ff2a0636 | ||
|
|
7dae1b64de | ||
|
|
27aa660a74 | ||
|
|
6cd3539b1e | ||
|
|
97ca4c49e0 | ||
|
|
bd22105646 | ||
|
|
e16066c322 | ||
|
|
7cd9fcd3d1 | ||
|
|
2176819865 | ||
|
|
d038f89e91 | ||
|
|
3dd805d9ed | ||
|
|
16e29da52f | ||
|
|
b8b0387a31 | ||
|
|
8b6352a6fa | ||
|
|
839b841dda | ||
|
|
53a6019ad5 | ||
|
|
2a1fde3634 | ||
|
|
37799ad2f2 | ||
|
|
a1800105ef | ||
|
|
6b77613ee8 | ||
|
|
205c1c3ee5 | ||
|
|
65cc931018 | ||
|
|
3dd3efc42f | ||
|
|
ca9b10054c | ||
|
|
c9f54fab4d | ||
|
|
82d21934d3 | ||
|
|
1f27b98366 | ||
|
|
30d883e866 | ||
|
|
3ec89b24dc | ||
|
|
2e049bb3de | ||
|
|
8297b2e6d2 | ||
|
|
601304543d | ||
|
|
ceaa82cd4f | ||
|
|
444e4a9384 | ||
|
|
45938389b8 | ||
|
|
3301b2cc0f | ||
|
|
529b0d1c3b | ||
|
|
7424f0ca04 | ||
|
|
a33ecf7481 | ||
|
|
3085f45f71 | ||
|
|
ed4fbf4ed3 | ||
|
|
feed158ba2 | ||
|
|
9ff8d4fc73 | ||
|
|
5bc6021477 | ||
|
|
50c2ca0a27 | ||
|
|
d6370a0002 | ||
|
|
44a6370aba | ||
|
|
ef000908e7 | ||
|
|
cdf3787834 | ||
|
|
5e01906a1e | ||
|
|
f05b29abeb | ||
|
|
b33b0db040 | ||
|
|
d1422fb652 | ||
|
|
87c9b1c52a | ||
|
|
be402be262 | ||
|
|
cdc23c666f | ||
|
|
bbd35e281b | ||
|
|
55eb2ce442 | ||
|
|
4032797790 | ||
|
|
c7135451eb | ||
|
|
88a2531558 | ||
|
|
75de193d90 | ||
|
|
ee8a608a22 | ||
|
|
55def8cc2b | ||
|
|
85f690d3b9 | ||
|
|
c2e625bbb0 | ||
|
|
344dea5c77 | ||
|
|
b28dceb462 | ||
|
|
a7cba90a85 | ||
|
|
0d7df52a45 | ||
|
|
55200605dd | ||
|
|
cde7a9dbd9 | ||
|
|
ff7ef60841 | ||
|
|
c92141d141 | ||
|
|
9e1866736c | ||
|
|
22ba4e7bc5 | ||
|
|
033a9b512c | ||
|
|
e2b4fee0ec | ||
|
|
35078f1d04 | ||
|
|
a2ac242b91 | ||
|
|
e64dc46055 | ||
|
|
fd06e07efa | ||
|
|
21af799450 | ||
|
|
fd04f01e8e | ||
|
|
af98327a5e | ||
|
|
c21ed4f13a | ||
|
|
8b7065a876 | ||
|
|
8f9f673b86 | ||
|
|
a3391ba93c | ||
|
|
56e2802e67 | ||
|
|
aee0b23b8e | ||
|
|
d3173d3893 | ||
|
|
19337237ec | ||
|
|
d900743bce | ||
|
|
978f810bbe | ||
|
|
ae15984493 | ||
|
|
9f85b8379c | ||
|
|
24b9db41b3 | ||
|
|
503092ccca | ||
|
|
16a8db6675 | ||
|
|
bc0a9cbc11 | ||
|
|
c6f1e20d9e | ||
|
|
a249e20290 | ||
|
|
1c84af2e96 | ||
|
|
aca9033dca | ||
|
|
2efdd3e021 | ||
|
|
01da88958d | ||
|
|
6f5f79321b | ||
|
|
690fbb525a | ||
|
|
5480258fb5 | ||
|
|
bfebfd26c1 | ||
|
|
806a2aa94b | ||
|
|
13679fcb97 | ||
|
|
75a9cc7e12 | ||
|
|
03248c11bf | ||
|
|
23b6de7028 | ||
|
|
53d33b0f95 | ||
|
|
c4f84e2f78 | ||
|
|
3ba522910b | ||
|
|
6fc0aeda35 | ||
|
|
5b78e8b655 | ||
|
|
cef87cfcab | ||
|
|
a2c8142013 | ||
|
|
cabf0bf3b4 | ||
|
|
84d2fd6f3a | ||
|
|
ce1be847c8 | ||
|
|
fe26ba8d3a | ||
|
|
03f7c76acd | ||
|
|
e67e5a9ef1 | ||
|
|
3a37ec1dcc | ||
|
|
77a692a4bb | ||
|
|
6a41a5ed39 | ||
|
|
ea8417e29a | ||
|
|
5543023723 | ||
|
|
05a4abd700 | ||
|
|
740159411a | ||
|
|
346cbaa316 | ||
|
|
a83c438af4 | ||
|
|
49a3812bc3 | ||
|
|
9946cfe901 | ||
|
|
4c0902f6bf | ||
|
|
d7e9b00234 | ||
|
|
63dc285493 | ||
|
|
5a46327401 | ||
|
|
1ad3c54803 | ||
|
|
5d208c85ed | ||
|
|
209adf9a7e | ||
|
|
04e1e7fc47 | ||
|
|
2aa3fbf185 | ||
|
|
983f5ecea8 | ||
|
|
4e217aeac6 | ||
|
|
d904e7a247 | ||
|
|
f7e52a851e | ||
|
|
1e6da4e615 | ||
|
|
40c0525bed | ||
|
|
0f02b509be | ||
|
|
8ad9b5415e | ||
|
|
e11fe8afb0 | ||
|
|
fdc394819c | ||
|
|
74a8a37134 | ||
|
|
86877c557a | ||
|
|
b464dea23c | ||
|
|
d008d6cee9 | ||
|
|
96a8ff4d15 | ||
|
|
a195096733 | ||
|
|
aef216cb25 | ||
|
|
370b30f919 | ||
|
|
97c54f618c | ||
|
|
c133d6c893 | ||
|
|
a584136b6f | ||
|
|
986f6b49e3 | ||
|
|
1d63a8f3a1 | ||
|
|
f212d5b900 | ||
|
|
f4f7dcf13b | ||
|
|
6cdd1ffbbb | ||
|
|
81f34c2374 | ||
|
|
168a0ac860 | ||
|
|
727f2a74ea | ||
|
|
3b0337d894 | ||
|
|
3c33856997 | ||
|
|
6b8ac76d96 | ||
|
|
d138b8ff4b | ||
|
|
566a1dafc6 | ||
|
|
6bc5c93513 | ||
|
|
b28adf9789 | ||
|
|
7397073d86 | ||
|
|
1ea8397d69 | ||
|
|
86847d7f16 | ||
|
|
33cd4d5cc1 | ||
|
|
60ad29f242 | ||
|
|
9191cf3fb0 | ||
|
|
668e71d54a | ||
|
|
1e919c7c56 | ||
|
|
9d3eb0c637 | ||
|
|
bef5f3f97a | ||
|
|
c194d22b5c | ||
|
|
df8605e875 | ||
|
|
fbbbadc292 | ||
|
|
4b5b68ee29 | ||
|
|
09436b78f9 | ||
|
|
19e1f70ea6 | ||
|
|
0722ddbbd5 | ||
|
|
88b22abf15 | ||
|
|
e36093b11d | ||
|
|
2f4a3868fb | ||
|
|
3816c4afec | ||
|
|
48f05f92fe | ||
|
|
e2733c5b86 | ||
|
|
682d0920ba | ||
|
|
2863939580 | ||
|
|
35972133b0 | ||
|
|
0bbea438e1 | ||
|
|
6b4da93dcb | ||
|
|
e16566b313 | ||
|
|
1de45a1a9b | ||
|
|
1ef77564e3 | ||
|
|
4c8b76b6f0 | ||
|
|
00849c4dea | ||
|
|
a440967b12 | ||
|
|
1c63119756 | ||
|
|
022b23945b | ||
|
|
f31e1a83eb | ||
|
|
eac5d1d7b3 | ||
|
|
eaf8c25d1c | ||
|
|
741ba22943 | ||
|
|
e7d8533880 | ||
|
|
bb76c3c99b | ||
|
|
f26747bfc6 | ||
|
|
084f3b376a | ||
|
|
dc5c80f26a | ||
|
|
5ab2187416 | ||
|
|
dc33c29d1a | ||
|
|
4975ab82fe | ||
|
|
447e939b73 | ||
|
|
4e802ff11d | ||
|
|
0885af531a | ||
|
|
b1ec8e525c | ||
|
|
a694531a03 | ||
|
|
684b181101 | ||
|
|
df4af50125 | ||
|
|
02af7e7659 | ||
|
|
b7d7a18980 | ||
|
|
16dc6fe403 | ||
|
|
779ddcc517 | ||
|
|
e1025f35e4 | ||
|
|
3014c64264 | ||
|
|
4ae863717e | ||
|
|
3cff8dc4ec | ||
|
|
81cdc278c2 | ||
|
|
e500386a54 | ||
|
|
e882e8a483 | ||
|
|
66b382de52 | ||
|
|
3af98170d0 | ||
|
|
6ef70a7303 | ||
|
|
32d472fe76 | ||
|
|
6d1144cea3 | ||
|
|
0e1c754999 | ||
|
|
90b8205b62 | ||
|
|
883ade4b9d | ||
|
|
b36c2b506b | ||
|
|
ee1db0bc12 | ||
|
|
b0d1a30f50 | ||
|
|
77370af99a | ||
|
|
a13705aa8f | ||
|
|
76cab1e26d | ||
|
|
8059b63da8 | ||
|
|
e7f8fa38cf | ||
|
|
f72461d56d | ||
|
|
24dda0d435 | ||
|
|
8924a11fda | ||
|
|
0932e9572e | ||
|
|
830486a851 | ||
|
|
15d597c4ca | ||
|
|
2fe28ea0ee | ||
|
|
c2f6920794 | ||
|
|
2b685c33b6 | ||
|
|
62fd028089 | ||
|
|
fb11cc482e | ||
|
|
d5132d21e0 | ||
|
|
0bd0cd2873 | ||
|
|
e5c8a8bb62 | ||
|
|
611a6cccda | ||
|
|
38eea65993 | ||
|
|
c4112794fa | ||
|
|
3d8b3b99c6 | ||
|
|
fc1c26d867 | ||
|
|
5d3866775c | ||
|
|
c134edf473 | ||
|
|
3366d2b9b3 | ||
|
|
7c126fab32 | ||
|
|
72960562ce | ||
|
|
de72b7fa0d | ||
|
|
d767edfacd | ||
|
|
cf54433c14 | ||
|
|
a6f1e79394 | ||
|
|
3287ef1409 | ||
|
|
73dad06bea | ||
|
|
4cb98aea9f | ||
|
|
05a992d4f2 | ||
|
|
8b68b0fcdd | ||
|
|
958d23d81c | ||
|
|
3c18f58ee0 | ||
|
|
2d09135c6e | ||
|
|
b7960d7a85 | ||
|
|
71ffb4c8cc | ||
|
|
6ea9b1f3bb | ||
|
|
264a931de2 | ||
|
|
e82087bcc8 | ||
|
|
fe91413e5d | ||
|
|
f4c734e7e0 | ||
|
|
4b69c32541 | ||
|
|
39abed9124 | ||
|
|
f26d7fbfe7 | ||
|
|
9ee5d1ad7a | ||
|
|
7db7fa42c8 | ||
|
|
3f2144f062 | ||
|
|
a11777054a | ||
|
|
a448b8d2df | ||
|
|
b82ad639e8 | ||
|
|
439e8ba2e7 | ||
|
|
0280644e77 | ||
|
|
308ec2cc1c | ||
|
|
5c0aff8cbc | ||
|
|
314c430bfc | ||
|
|
f7cfb520bf | ||
|
|
a2fd4189f1 | ||
|
|
5f73c7cdcb | ||
|
|
02f08035b4 | ||
|
|
680f9582e1 | ||
|
|
f055d9cd11 | ||
|
|
b5fe251ba5 | ||
|
|
b7071c91c7 | ||
|
|
206fc6f317 | ||
|
|
01f072b520 | ||
|
|
548bd57802 | ||
|
|
4939dbc787 | ||
|
|
022f4a6477 | ||
|
|
8fa89ee02f | ||
|
|
8f13f2830a | ||
|
|
9183d0422c | ||
|
|
073239e6d5 | ||
|
|
7779f47a7d | ||
|
|
79924a5d93 | ||
|
|
1942447e90 | ||
|
|
44d9adfc7e | ||
|
|
fa8b8b243e | ||
|
|
c4c31a529c | ||
|
|
92ae1b336f | ||
|
|
e3aa15d7d7 | ||
|
|
360ec09cf2 | ||
|
|
1cac1e056d | ||
|
|
fe07e84d0f | ||
|
|
6610987057 | ||
|
|
94a49d08ce | ||
|
|
6a5aa7862e | ||
|
|
f47a2fe235 | ||
|
|
32d7c8ebf4 | ||
|
|
274639fb88 | ||
|
|
a9b0607111 | ||
|
|
b9695a7f6e | ||
|
|
b8cd29fde4 | ||
|
|
fb69206450 | ||
|
|
4ea06438db | ||
|
|
674fb57941 | ||
|
|
d931d6415a | ||
|
|
25831a8ac0 | ||
|
|
708e79264b | ||
|
|
5e28ff8c23 | ||
|
|
b0803dc450 | ||
|
|
72a4024161 | ||
|
|
e18afc84cc | ||
|
|
bec84a1dad | ||
|
|
e6bb9e0f3b | ||
|
|
d6a364c0c0 | ||
|
|
68ced97f81 | ||
|
|
a1370863e4 | ||
|
|
d6ec4c5546 | ||
|
|
be2ee6f356 | ||
|
|
388b4036d4 | ||
|
|
e77e30c7d9 | ||
|
|
e8b9821f4a | ||
|
|
ea9f451076 | ||
|
|
43fe82b10f | ||
|
|
f0ca6b24f4 | ||
|
|
944afe5c88 | ||
|
|
bae7656347 | ||
|
|
51c4770643 | ||
|
|
df27223a4e | ||
|
|
56d6e9da2c | ||
|
|
afd7cb4851 | ||
|
|
b8ff99f2cc | ||
|
|
3fe4612d93 | ||
|
|
dae811ddd9 | ||
|
|
ea38f3a3ee | ||
|
|
628ada76cd | ||
|
|
a30bb7d89d | ||
|
|
1043504c7a | ||
|
|
ea9021ed86 | ||
|
|
a3ae96e87b | ||
|
|
93c1291f6d | ||
|
|
6337b94d3c | ||
|
|
9675fc97fd | ||
|
|
1adc57b97b | ||
|
|
407cf23da7 | ||
|
|
1998011d90 | ||
|
|
e0c27dd34f | ||
|
|
03072faadc | ||
|
|
fd6c1af1a0 | ||
|
|
01d6a1019c | ||
|
|
387905148a | ||
|
|
51be26d7e2 | ||
|
|
064c3bf979 | ||
|
|
87ca4e8bf7 | ||
|
|
0ae0b6b3c2 | ||
|
|
69595f5821 | ||
|
|
aae8016c84 | ||
|
|
deaa9b1054 | ||
|
|
e79a2ba84b | ||
|
|
f6f71abef4 | ||
|
|
ff78115647 | ||
|
|
b22b6f4909 | ||
|
|
28bd124258 | ||
|
|
b4e5fa6eef | ||
|
|
0352a19654 | ||
|
|
89e88f87f2 | ||
|
|
900fe0c03a | ||
|
|
8e339d7051 | ||
|
|
9adaa38f22 | ||
|
|
9e5eb79cc4 | ||
|
|
37ed348665 | ||
|
|
c4d31db8d6 | ||
|
|
0a1ed4e09b | ||
|
|
93c0a5c0ff | ||
|
|
fc3954bfe2 | ||
|
|
d556c318f4 | ||
|
|
7576de5f26 | ||
|
|
c95e34f97a | ||
|
|
01e5ebf726 | ||
|
|
b9368f4db8 | ||
|
|
d262baceef | ||
|
|
f7e82abcba | ||
|
|
6438ead58d | ||
|
|
17fbae497b | ||
|
|
7d5730f688 | ||
|
|
1b294221b5 | ||
|
|
c5658ba4bf | ||
|
|
d09e6d65fc | ||
|
|
a969387ca9 | ||
|
|
fb083823bd | ||
|
|
925f3f54ac | ||
|
|
5bf4cae3b6 | ||
|
|
b48bb1f899 | ||
|
|
2d3e8140ce | ||
|
|
5763326a0a | ||
|
|
cbaea98521 | ||
|
|
093ae6f4db | ||
|
|
56aa3d8857 | ||
|
|
53b6a68e12 | ||
|
|
7675d2bcbd | ||
|
|
4a0865bb62 | ||
|
|
e144b337de | ||
|
|
33505d7faa | ||
|
|
b581d16a82 | ||
|
|
6769662ecf | ||
|
|
190d7c0dc0 | ||
|
|
88d6579459 | ||
|
|
efec7dc067 | ||
|
|
f82fa0c1af | ||
|
|
996dec3193 | ||
|
|
b41019a19a | ||
|
|
a70ff86667 | ||
|
|
f7c1bfa175 | ||
|
|
f2050832cf | ||
|
|
3d70eef3a3 | ||
|
|
9247a09214 | ||
|
|
59c6ab2057 | ||
|
|
d12cab0ed1 | ||
|
|
fa30358f84 | ||
|
|
d5937ed2b6 | ||
|
|
3faf54fea3 | ||
|
|
a4b87fccbe | ||
|
|
06b27d1461 | ||
|
|
58ddcd7bbb | ||
|
|
3745c835b2 | ||
|
|
5b499d512f | ||
|
|
1ee7233460 | ||
|
|
119e21752b | ||
|
|
0e3f6e996e | ||
|
|
f1d4017883 | ||
|
|
ee46834915 | ||
|
|
3a445ae2bf | ||
|
|
86969460f1 | ||
|
|
577df7a23b | ||
|
|
8dc0ac8011 | ||
|
|
681768e0bb | ||
|
|
1700702e52 | ||
|
|
f6a1224ee2 | ||
|
|
bdab0b1017 | ||
|
|
ae99934c21 | ||
|
|
ad6d94e568 | ||
|
|
0817b0e8cc | ||
|
|
fce9570711 | ||
|
|
3123fe20af | ||
|
|
d51c9a92ed | ||
|
|
e0b4928bc8 | ||
|
|
eeaf84f756 | ||
|
|
955c99dc26 | ||
|
|
6590765dc6 | ||
|
|
0976d925b9 | ||
|
|
1b1bb4698d | ||
|
|
e185c36c59 | ||
|
|
3248b75af8 | ||
|
|
5db0c39dde | ||
|
|
3f0c999440 | ||
|
|
0c8acfa274 | ||
|
|
54f634d5f9 | ||
|
|
cd1bfafe72 | ||
|
|
1ebd6265db | ||
|
|
0ddb89c007 | ||
|
|
a931dd8660 | ||
|
|
5e376b69ed | ||
|
|
45edf565df | ||
|
|
5576c785ce | ||
|
|
6da7f6b010 | ||
|
|
0f11660414 | ||
|
|
bd9e1d8947 | ||
|
|
902b039c4d | ||
|
|
5c1f8f7f6c | ||
|
|
202a52d206 | ||
|
|
b15ffa9542 | ||
|
|
256788294d | ||
|
|
2df4f3e1d0 | ||
|
|
9f514bc49d | ||
|
|
632a1f380e | ||
|
|
63c01bee24 | ||
|
|
cd9d1ac669 | ||
|
|
320cc2dda3 | ||
|
|
0c449919da | ||
|
|
4d931ca77e | ||
|
|
849ededd5b | ||
|
|
10e484f552 | ||
|
|
fa93a7f01f | ||
|
|
760e4e81d0 | ||
|
|
ad90c63923 | ||
|
|
9d11369db7 | ||
|
|
a19bf32e51 | ||
|
|
dcda65f7d9 | ||
|
|
3c1aaae176 | ||
|
|
9f0a8463a9 | ||
|
|
52db75973e | ||
|
|
99182c29be | ||
|
|
2f4bbfa7d2 | ||
|
|
506b99456f | ||
|
|
0732d03fa1 | ||
|
|
8f3574c48b | ||
|
|
41994bc6c7 | ||
|
|
0c4bf38033 | ||
|
|
57ef0d138f | ||
|
|
24178e51d3 | ||
|
|
799b92993a | ||
|
|
52dcb9252c | ||
|
|
d8dcc924b7 | ||
|
|
9a4a1dcf27 | ||
|
|
38f21c01f1 | ||
|
|
1694727548 | ||
|
|
fefb2da2b1 | ||
|
|
dafc10852f | ||
|
|
c83fee02fa | ||
|
|
191a22e759 | ||
|
|
c17c48043e | ||
|
|
31b6649d78 | ||
|
|
5d40d837f6 | ||
|
|
90e3385c15 | ||
|
|
373bf77f3a | ||
|
|
834202d124 | ||
|
|
6918fda018 | ||
|
|
f58f0ec1d0 | ||
|
|
d69b52e2c7 | ||
|
|
e13c480626 | ||
|
|
a5b00eee2d | ||
|
|
0cf969a05d | ||
|
|
04abd59fc6 | ||
|
|
7204df600d | ||
|
|
f9dbd2c8d6 | ||
|
|
ce295b6080 | ||
|
|
fc99aaf87b | ||
|
|
f0b70134a8 | ||
|
|
1153702d1a | ||
|
|
29c09c6f99 | ||
|
|
9ab649ff55 | ||
|
|
933113e375 | ||
|
|
3c896fcdea | ||
|
|
88d15ed051 | ||
|
|
adb42a31ff | ||
|
|
55fd9daa08 | ||
|
|
53b84d2baa | ||
|
|
05401103d2 | ||
|
|
995c101dba | ||
|
|
114a5737f5 | ||
|
|
55a87877b5 | ||
|
|
93b658c8db | ||
|
|
fe1bfdfd29 | ||
|
|
499d64181a | ||
|
|
bf6ba26a7f | ||
|
|
7e3a1037ff | ||
|
|
b09fd0dc69 | ||
|
|
7b12ec165d | ||
|
|
41a0ccaf16 | ||
|
|
6974abaea3 | ||
|
|
f3f660b745 | ||
|
|
d2b6e718d1 | ||
|
|
62cadddf28 | ||
|
|
1882cff76b | ||
|
|
8d4111dff8 | ||
|
|
0e5c9aaed6 | ||
|
|
a45162c5fa | ||
|
|
0811443909 | ||
|
|
54c351b92f | ||
|
|
0f63032916 | ||
|
|
188b03c486 | ||
|
|
df3945af8a | ||
|
|
212fbfe455 | ||
|
|
84da588c36 | ||
|
|
af2695407a | ||
|
|
ff98d9900f | ||
|
|
6cc8c0d448 | ||
|
|
c575f18c99 | ||
|
|
f931ea81d5 | ||
|
|
8c9733b5c6 | ||
|
|
f487613bfc | ||
|
|
d2eae41dd6 | ||
|
|
d92bc4d3ab | ||
|
|
84efbac6f1 | ||
|
|
599df45795 | ||
|
|
0f77ba5e7e | ||
|
|
682afd5aa9 | ||
|
|
529761ff03 | ||
|
|
3220953fb4 | ||
|
|
0cd66905e5 | ||
|
|
bab2978ccf | ||
|
|
a66221d24a | ||
|
|
c92f86ef71 | ||
|
|
0afda58804 | ||
|
|
46645c6d73 | ||
|
|
2bf9b94936 | ||
|
|
702bb42cbb | ||
|
|
86c23326c9 | ||
|
|
b613184d64 | ||
|
|
dfd7a52224 | ||
|
|
918bcddb4f | ||
|
|
5fe92a568a | ||
|
|
2b0719dc70 | ||
|
|
1630d4c894 | ||
|
|
8f29a36f16 | ||
|
|
8cd8cf04ac | ||
|
|
cdbb60e0fe | ||
|
|
9f5c752cd1 | ||
|
|
310635e7d5 | ||
|
|
a1360f42d3 | ||
|
|
3daa34dba0 | ||
|
|
0b92e12d9c | ||
|
|
af36326844 | ||
|
|
a41c8c46e7 | ||
|
|
6c1edac204 | ||
|
|
5676882e64 | ||
|
|
918512f62a | ||
|
|
3a91c09727 | ||
|
|
d0255c71ec | ||
|
|
d3e846eb53 | ||
|
|
c1d666301f | ||
|
|
bd2d361afc | ||
|
|
80a1596a3d | ||
|
|
ae2b8535a9 | ||
|
|
c59441323d | ||
|
|
37572b6c1a | ||
|
|
4a01756ea4 | ||
|
|
106f9824b0 | ||
|
|
697e0bb8a6 | ||
|
|
20807d2d3d | ||
|
|
41021e5e19 | ||
|
|
b3d4cfe311 | ||
|
|
d765bbd68e | ||
|
|
63ae22b42b | ||
|
|
41dbfd987b | ||
|
|
06c61d9437 | ||
|
|
b3ce351cde | ||
|
|
8336da3482 | ||
|
|
a392f4e18e | ||
|
|
acaa281c54 | ||
|
|
37c160eec0 | ||
|
|
ad68bfefd1 | ||
|
|
98a1fc1b94 | ||
|
|
a2a2638461 | ||
|
|
9e7bc6691c | ||
|
|
9bda02122d | ||
|
|
8ff280ed3d | ||
|
|
63ee800cf2 | ||
|
|
1a2958294d | ||
|
|
356eb8f093 | ||
|
|
bda9453953 | ||
|
|
1852295d9a | ||
|
|
68f856fb9d | ||
|
|
256e581aa7 | ||
|
|
b8cda55e57 | ||
|
|
194f13d30f | ||
|
|
e3b4ecb808 | ||
|
|
6272d465df | ||
|
|
7439d56c5c | ||
|
|
ce3d50b8f9 | ||
|
|
3584d15ddd | ||
|
|
a51434ff7d | ||
|
|
2157b35e24 | ||
|
|
e13178ac40 | ||
|
|
88d8390222 | ||
|
|
5d508ec1e1 | ||
|
|
ad2c9829fd | ||
|
|
8a4fd13423 | ||
|
|
6437496faf | ||
|
|
2ec6ac7c9c | ||
|
|
6752f1b5f7 | ||
|
|
de4fb8d6a3 | ||
|
|
1fe19db437 | ||
|
|
52365781a6 | ||
|
|
9af0f02a74 | ||
|
|
d899e8b846 | ||
|
|
3f0c7d5bda | ||
|
|
4f64f8c4a6 | ||
|
|
eb684d2256 | ||
|
|
3ec310db40 | ||
|
|
53dc251508 | ||
|
|
34f77c059b | ||
|
|
7bebbe4033 | ||
|
|
3a31c38751 | ||
|
|
7ed93eadae | ||
|
|
ee9dfd1a37 | ||
|
|
4c2f220971 | ||
|
|
2dbe4c265b | ||
|
|
5c6c581b35 | ||
|
|
c5825bcd81 | ||
|
|
c32827ccaf | ||
|
|
62f08a8b2e | ||
|
|
c408bace90 | ||
|
|
2f2bac4645 | ||
|
|
7cc0450ae4 | ||
|
|
f21d644e70 | ||
|
|
c1463d0e76 | ||
|
|
80d5be2814 | ||
|
|
a96980bcc2 | ||
|
|
a7f8f99084 | ||
|
|
2f5f267eda | ||
|
|
4263117b48 | ||
|
|
6c4135ddfb | ||
|
|
8a0ca48bb7 | ||
|
|
8ad818dc70 | ||
|
|
2742c2342f | ||
|
|
bc9faf230a | ||
|
|
d658d182e4 | ||
|
|
6b9c48fe4d | ||
|
|
070d9fea4e | ||
|
|
f459f3c400 | ||
|
|
ebbf129719 | ||
|
|
c53a5e4dd8 | ||
|
|
a9247247af | ||
|
|
7edf74bb15 | ||
|
|
be121813f0 | ||
|
|
6c57533958 | ||
|
|
88c3962423 | ||
|
|
3f4d63ca8a | ||
|
|
212f27505a | ||
|
|
8ccb6cd0a0 | ||
|
|
4d6ce95563 | ||
|
|
ea66d19c5e | ||
|
|
29b9421518 | ||
|
|
dda1166326 | ||
|
|
2a205c294f | ||
|
|
1333a2c130 | ||
|
|
94b501b657 | ||
|
|
95aa27ae60 | ||
|
|
da1e9070f2 | ||
|
|
893e97c18e | ||
|
|
8e065f9a78 | ||
|
|
b09676e34a | ||
|
|
679b5bda19 | ||
|
|
1b33016019 | ||
|
|
9b5ed1da6e | ||
|
|
160ca30590 | ||
|
|
2f98090707 | ||
|
|
4601565dbd | ||
|
|
e64291dc6d | ||
|
|
c88df6f9c7 | ||
|
|
11acaa3f2b | ||
|
|
f593c2eab4 | ||
|
|
10826925c9 | ||
|
|
7c54cc6a10 | ||
|
|
56d0b8fc63 | ||
|
|
0fa8d99934 | ||
|
|
42dc3991c1 | ||
|
|
44fef497f2 | ||
|
|
f364b38b74 | ||
|
|
38c92f4011 | ||
|
|
9f5436cd6d | ||
|
|
d5777232ad | ||
|
|
94d803f595 | ||
|
|
40fbf7c155 | ||
|
|
c6fb5737c1 | ||
|
|
e3fe95d228 | ||
|
|
1a4a91256d | ||
|
|
80e4084420 | ||
|
|
58e55b5898 | ||
|
|
a3aa6c0074 | ||
|
|
4d5c66375d | ||
|
|
a634154a22 | ||
|
|
8d48d90d72 | ||
|
|
aac074ab06 | ||
|
|
fdc6b9d892 | ||
|
|
44471d1c94 | ||
|
|
de238f3dde | ||
|
|
e838224264 | ||
|
|
3aa9179424 | ||
|
|
0fedb30182 | ||
|
|
f63195656a | ||
|
|
8bd4563087 | ||
|
|
f9b2987716 | ||
|
|
0347de1f22 | ||
|
|
9329d1f752 | ||
|
|
6c6123fb29 | ||
|
|
d9c9d6e616 | ||
|
|
a9bc9c57e7 | ||
|
|
d4999a2b2f | ||
|
|
8a0d833080 | ||
|
|
2b891f17a5 | ||
|
|
5ca15b3793 | ||
|
|
3ea51f9944 | ||
|
|
7edb12df2d | ||
|
|
edf112cd92 | ||
|
|
f2a10ff3ed | ||
|
|
fbb27a989c | ||
|
|
64d20004c7 | ||
|
|
6c592bdb33 | ||
|
|
482ffb5306 | ||
|
|
25eeb74fe9 | ||
|
|
5e65197df6 | ||
|
|
772d420dc4 | ||
|
|
d13b63054b | ||
|
|
7af6741bf2 | ||
|
|
dd4e7f62b2 | ||
|
|
099e832028 | ||
|
|
3628dc1f04 | ||
|
|
c829374f48 | ||
|
|
e5996eeaff | ||
|
|
b94a3e5d16 | ||
|
|
f49b611321 | ||
|
|
40b99348e8 | ||
|
|
9f89ecab7e | ||
|
|
e648610eae | ||
|
|
6aa0dda7ee | ||
|
|
b4106dcf63 | ||
|
|
a2370042f4 | ||
|
|
dfeb93cdc7 | ||
|
|
3b3ac393ba | ||
|
|
96735b20d9 | ||
|
|
fdd6f127c2 | ||
|
|
4dbe8de82d | ||
|
|
d1e5637929 | ||
|
|
b096147894 | ||
|
|
95c4070090 | ||
|
|
67b70ea814 | ||
|
|
7d1bae51fc | ||
|
|
aa89efd1e2 | ||
|
|
ea1e4ef249 | ||
|
|
f9982ef182 | ||
|
|
adf1eb245e | ||
|
|
bd2efe1c9e | ||
|
|
d23f949801 | ||
|
|
f9147709a6 | ||
|
|
82cd04c9a6 | ||
|
|
dc98f6483a | ||
|
|
0efb22a1dd | ||
|
|
972db68d6b | ||
|
|
49c61475de | ||
|
|
46be542125 | ||
|
|
9ee6a8f88b | ||
|
|
4e3d5c10f1 | ||
|
|
691bf44151 | ||
|
|
e0ee51f442 | ||
|
|
9dfcef628b | ||
|
|
5cc6640ae8 | ||
|
|
73bdae55c6 | ||
|
|
853f99919d | ||
|
|
487f4af04c | ||
|
|
18b174f9ab | ||
|
|
85963cfa5f | ||
|
|
8c720987cb | ||
|
|
84886e33b9 | ||
|
|
2e9aacfd9e | ||
|
|
098d1d365e | ||
|
|
a423a6b203 | ||
|
|
af838bc8ea | ||
|
|
183d769841 | ||
|
|
2a710313ff | ||
|
|
507a1eb419 | ||
|
|
1f873774ab | ||
|
|
eb5eef341f | ||
|
|
3b4da4f39b | ||
|
|
042d16718e | ||
|
|
80e29bf57c | ||
|
|
da49251bf3 | ||
|
|
25db401c9f | ||
|
|
c88d7a3820 | ||
|
|
2d7816a227 | ||
|
|
1e0519ad53 | ||
|
|
9a39b0ddba | ||
|
|
1e1f3cb711 | ||
|
|
dbc822ba8b | ||
|
|
98f012e268 | ||
|
|
fb357977b5 | ||
|
|
afa5fc3b1a | ||
|
|
1c82574f86 | ||
|
|
a182724136 | ||
|
|
a7d5ee01cd | ||
|
|
4eddcc43d4 | ||
|
|
0254343de3 | ||
|
|
34bec9c119 | ||
|
|
f3fea3b8b8 | ||
|
|
0827eb8eda | ||
|
|
27627bc793 | ||
|
|
1c48187ca1 | ||
|
|
28d1918c26 | ||
|
|
24d54e4db5 | ||
|
|
346989eda1 | ||
|
|
57d654f972 | ||
|
|
dbefd920b5 | ||
|
|
60bfb02db9 | ||
|
|
dc3283da19 | ||
|
|
bc779f2216 | ||
|
|
ed4e1ce391 | ||
|
|
46b77abeb2 | ||
|
|
aa4bd2cc11 | ||
|
|
db1e08fa66 | ||
|
|
50f89666fc | ||
|
|
72a14b078c | ||
|
|
d01ec72338 | ||
|
|
3f5aab992c | ||
|
|
522d8795b0 | ||
|
|
4dc6c80cac | ||
|
|
67fadbc64b | ||
|
|
fe0fad5743 | ||
|
|
b9b527af9d | ||
|
|
94b9627f75 | ||
|
|
724f1f7f90 | ||
|
|
8012f017bc | ||
|
|
5131af5ab7 | ||
|
|
25c730223f | ||
|
|
5ed678ae54 | ||
|
|
86a8ffe96a | ||
|
|
4f680fdfcb | ||
|
|
d9f65684f8 | ||
|
|
27bb095b45 | ||
|
|
dec7c712c7 | ||
|
|
22d18a7bc9 | ||
|
|
36688eface | ||
|
|
428bab3201 | ||
|
|
3c48cefad4 | ||
|
|
60d0598a69 | ||
|
|
da06957d53 | ||
|
|
230631c466 | ||
|
|
8325f12c33 | ||
|
|
f86d227dc5 | ||
|
|
3df5aa3f99 | ||
|
|
fb0c332006 | ||
|
|
f89687fd87 | ||
|
|
b6b4e54251 | ||
|
|
b16b705f5d | ||
|
|
cae5062e3a | ||
|
|
fc68bb6a8f | ||
|
|
4d8c29adbe | ||
|
|
40f6c9d253 | ||
|
|
6433934db7 | ||
|
|
1bb166dbaa | ||
|
|
dfe014a2ba | ||
|
|
b42f1dc56f | ||
|
|
02cd44e3aa | ||
|
|
dda9a56cb7 | ||
|
|
518a24dd64 | ||
|
|
43d7cc755e | ||
|
|
1a370b965c | ||
|
|
5b149495c5 | ||
|
|
340d36c860 | ||
|
|
870880963d | ||
|
|
1308aae1b7 | ||
|
|
2945c2d8d0 | ||
|
|
4b14fe447b | ||
|
|
7f3be81d6c | ||
|
|
d42b7621de | ||
|
|
196ed7f450 | ||
|
|
9142b47bdf | ||
|
|
cff3a1c9db | ||
|
|
5ba266db25 | ||
|
|
c114a18c87 | ||
|
|
3f76dccc32 | ||
|
|
48cea71beb | ||
|
|
6a678f02a0 | ||
|
|
f840c58934 | ||
|
|
07a741b595 | ||
|
|
620b549486 | ||
|
|
d1cd34b375 | ||
|
|
61ab76b6f5 | ||
|
|
ecfec194b4 | ||
|
|
bba3475deb |
|
|
@ -17,7 +17,7 @@ clear, when to stay silent, what never to say — lives once in
|
||||||
| `backend/` | Backend | `cli`, `@n8n/db`, `core`, `workflow`, node packages |
|
| `backend/` | Backend | `cli`, `@n8n/db`, `core`, `workflow`, node packages |
|
||||||
| `db-migrations/` | DB migrations | `@n8n/db` migrations + their tests in `cli` |
|
| `db-migrations/` | DB migrations | `@n8n/db` migrations + their tests in `cli` |
|
||||||
| `frontend/` | Frontend | `packages/frontend` |
|
| `frontend/` | Frontend | `packages/frontend` |
|
||||||
| `qa-dx/` | QA & DX | `.github`, `docker`, `scripts`, `patches`, `packages/testing`, the lint/test/TS config packages, baselines |
|
| `qa-dx/` | QA & DX | `.github`, `docker`, `scripts`, `patches`, `packages/quality`, the lint/test/TS config packages, baselines |
|
||||||
| `testing/` | Backend + Frontend | any package with a test suite |
|
| `testing/` | Backend + Frontend | any package with a test suite |
|
||||||
|
|
||||||
A directory maps to one agent unless, like `testing/`, the policy is identical
|
A directory maps to one agent unless, like `testing/`, the policy is identical
|
||||||
|
|
@ -27,7 +27,7 @@ never a copy per directory. Security and QA & DX deliberately don't link
|
||||||
agents shouldn't be able to produce.
|
agents shouldn't be able to produce.
|
||||||
|
|
||||||
All five slots are used, so a new domain now merges into an existing agent. QA &
|
All five slots are used, so a new domain now merges into an existing agent. QA &
|
||||||
DX covers the build, test, and CI surface — the same paths `.github/OWNERS`
|
DX covers the build, test, and CI surface — the same paths `OWNERS`
|
||||||
assigns to `@n8n-io/qa-dx`. Code-quality rules that happen to apply broadly
|
assigns to `@n8n-io/qa-dx`. Code-quality rules that happen to apply broadly
|
||||||
(error classes, `any`, lazy imports) are backend rules, not QA & DX ones. DB
|
(error classes, `any`, lazy imports) are backend rules, not QA & DX ones. DB
|
||||||
migrations is split out of Backend because a migration is permanent and runs
|
migrations is split out of Backend because a migration is permanent and runs
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,9 @@ Flag a diff that **adds** entries to any of these, and ask for the fix instead:
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `.code-health-baseline.json` | `@n8n/code-health` violations |
|
| `.code-health-baseline.json` | `@n8n/code-health` violations |
|
||||||
| `.boundaries-baseline.json` | `turbo boundaries` issue count |
|
| `.boundaries-baseline.json` | `turbo boundaries` issue count |
|
||||||
| `packages/testing/playwright/.janitor-baseline.json` | Playwright janitor findings |
|
| `packages/quality/testing/playwright/.janitor-baseline.json` | Playwright janitor findings |
|
||||||
| `packages/cli/eslint.config.mjs` | the `misplaced-n8n-typeorm-import` and public-API allowlists, each captioned "NEVER add to this list" |
|
| `.code-health-baseline.json` | the `typeorm-persistence-boundary` baseline, which only shrinks as business logic moves queries into repositories |
|
||||||
|
| `packages/cli/eslint.config.mjs` | the public-API allowlists captioned "NEVER add to this list" |
|
||||||
| `.code-health-baseline.json` (`lint-config-layering`) | package-wide rule downgrades left in package ESLint configs |
|
| `.code-health-baseline.json` (`lint-config-layering`) | package-wide rule downgrades left in package ESLint configs |
|
||||||
|
|
||||||
Removals are the healthy direction and need no comment.
|
Removals are the healthy direction and need no comment.
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ fed from a `needs:` list. Flag:
|
||||||
Jobs gated on a `ci-filter` filter only run when a matching file changed, so a
|
Jobs gated on a `ci-filter` filter only run when a matching file changed, so a
|
||||||
filter that is too narrow means the test quietly stops covering new code. When
|
filter that is too narrow means the test quietly stops covering new code. When
|
||||||
a PR adds a directory that an existing filter was meant to cover — a new package
|
a PR adds a directory that an existing filter was meant to cover — a new package
|
||||||
under `packages/testing/`, a new script under `.github/scripts/` — check the
|
under `packages/quality/`, a new script under `.github/scripts/` — check the
|
||||||
filter still matches it.
|
filter still matches it.
|
||||||
|
|
||||||
## Least privilege
|
## Least privilege
|
||||||
|
|
|
||||||
24
.agents/skills/agent-design-language/SKILL.md
Normal file
24
.agents/skills/agent-design-language/SKILL.md
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
---
|
||||||
|
name: n8n:agent-design-language
|
||||||
|
description: Design or review Agent Builder interfaces. Use for Agent configuration patterns, modal flows, responsive behavior, status treatments, and other Agent-only UI work.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Agent design language
|
||||||
|
|
||||||
|
Use this skill only for interface work in
|
||||||
|
`packages/frontend/editor-ui/src/features/agents`.
|
||||||
|
|
||||||
|
Before you change code:
|
||||||
|
|
||||||
|
1. Read `packages/frontend/editor-ui/src/features/agents/AGENTS.md`.
|
||||||
|
2. Read `packages/frontend/editor-ui/src/features/agents/agents-design-language.md`
|
||||||
|
completely.
|
||||||
|
3. Use [ui-design](../ui-design/SKILL.md) for visual implementation rules.
|
||||||
|
4. Use [content-design](../content-design/SKILL.md) when you add or change UI
|
||||||
|
text.
|
||||||
|
|
||||||
|
Keep Agent-specific components in the Agent feature. Do not change the Design
|
||||||
|
System unless the user explicitly asks for this change.
|
||||||
|
|
||||||
|
Update the design language document when you establish a reusable Agent
|
||||||
|
interface pattern. Add or update tests for the behavior that you change.
|
||||||
|
|
@ -113,7 +113,7 @@ Run when `AutoReject` is `null`. Full rules for each in `reference/checks.md`:
|
||||||
|
|
||||||
## Step 4 — Identify the responsible team
|
## Step 4 — Identify the responsible team
|
||||||
|
|
||||||
Run `node .github/scripts/owners.mjs` against the changed file list and map the winning GitHub team to a Linear team. Full mapping table, sub-agent fallback procedure, and label rules: see `reference/teams.md`.
|
Run `node .github/scripts/owners/owners.mjs` against the changed file list and map the winning GitHub team to a Linear team. Full mapping table, sub-agent fallback procedure, and label rules: see `reference/teams.md`.
|
||||||
|
|
||||||
## Step 5 — Extract the Linear ticket
|
## Step 5 — Extract the Linear ticket
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ Owner resolution, the GitHub-team → Linear-team → label mapping, and the Lin
|
||||||
|
|
||||||
## Identifying the owning team
|
## Identifying the owning team
|
||||||
|
|
||||||
Use the canonical owners script at `.github/scripts/owners.mjs`. It parses `.github/OWNERS` with last-match-wins semantics and returns allocations sorted by file count with a `share` percentage. Using the script keeps this skill consistent with whatever CI uses.
|
Use the canonical owners script at `.github/scripts/owners/owners.mjs`. It parses `OWNERS` with last-match-wins semantics and returns allocations sorted by file count with a `share` percentage. Using the script keeps this skill consistent with whatever CI uses.
|
||||||
|
|
||||||
1. Write the PR's changed file paths (from the `files` list) to a temp file, one per line:
|
1. Write the PR's changed file paths (from the `files` list) to a temp file, one per line:
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -24,7 +24,7 @@ Use the canonical owners script at `.github/scripts/owners.mjs`. It parses `.git
|
||||||
```
|
```
|
||||||
2. Run the script:
|
2. Run the script:
|
||||||
```bash
|
```bash
|
||||||
node .github/scripts/owners.mjs /tmp/pr-<number>-files.txt
|
node .github/scripts/owners/owners.mjs /tmp/pr-<number>-files.txt
|
||||||
```
|
```
|
||||||
3. The script prints JSON of the form:
|
3. The script prints JSON of the form:
|
||||||
```json
|
```json
|
||||||
|
|
@ -38,10 +38,10 @@ Use the canonical owners script at `.github/scripts/owners.mjs`. It parses `.git
|
||||||
```
|
```
|
||||||
Allocations are already sorted by `fileCount` descending — take the first entry as the winning team.
|
Allocations are already sorted by `fileCount` descending — take the first entry as the winning team.
|
||||||
4. Clean up: `rm /tmp/pr-<number>-files.txt`.
|
4. Clean up: `rm /tmp/pr-<number>-files.txt`.
|
||||||
5. Strip the `@n8n-io/` prefix from `allocations[0].team` — the GitHub team slug is `nodes`, `iam`, `ai`, etc. If `allocations` is empty (no file matched any rule, which is possible only if `.github/OWNERS` lost its catch-all), fall back to `catalysts`.
|
5. Strip the `@n8n-io/` prefix from `allocations[0].team` — the GitHub team slug is `nodes`, `iam`, `ai`, etc. If `allocations` is empty (no file matched any rule, which is possible only if `OWNERS` lost its catch-all), fall back to `catalysts`.
|
||||||
6. Map the GitHub team slug to its Linear team name and PR label using the table below. The `team` field in the JSON output is the **Linear team name**. If the resolved GitHub team slug has no entry in the table, fall back to `Engineering`.
|
6. Map the GitHub team slug to its Linear team name and PR label using the table below. The `team` field in the JSON output is the **Linear team name**. If the resolved GitHub team slug has no entry in the table, fall back to `Engineering`.
|
||||||
|
|
||||||
**Sub-agent fallback**: if `node` execution is denied by the sandbox, read `.github/OWNERS` directly and apply last-match-wins by hand. All the active rules fit on one screen.
|
**Sub-agent fallback**: if `node` execution is denied by the sandbox, read `OWNERS` directly and apply last-match-wins by hand. All the active rules fit on one screen.
|
||||||
|
|
||||||
## GitHub team → Linear team → GitHub label
|
## GitHub team → Linear team → GitHub label
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ Run this check before sourcing, drafting, or writing an eval. Run it from
|
||||||
`packages/@n8n/instance-ai`:
|
`packages/@n8n/instance-ai`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm exec dotenvx run -f ../../../.env.local -- \
|
pnpm exec dotenvx run -f ../../../.env.eval -- \
|
||||||
sh -c 'test -n "${LANGTRACER_URL:-}" && test -n "${LANGTRACER_API_KEY:-}"'
|
sh -c 'test -n "${LANGTRACER_URL:-}" && test -n "${LANGTRACER_API_KEY:-}"'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -28,7 +28,7 @@ If the check fails, stop before creating an eval file. Ask the user to:
|
||||||
|
|
||||||
1. Generate a key on the
|
1. Generate a key on the
|
||||||
[LangTracer API page](https://lang-tracer.n8n-maintenance.workers.dev/account?section=api).
|
[LangTracer API page](https://lang-tracer.n8n-maintenance.workers.dev/account?section=api).
|
||||||
2. Add these variables to the repository root `.env.local` file:
|
2. Add these variables to the repository root `.env.eval` file:
|
||||||
|
|
||||||
```env
|
```env
|
||||||
LANGTRACER_URL=https://lang-tracer.n8n-maintenance.workers.dev
|
LANGTRACER_URL=https://lang-tracer.n8n-maintenance.workers.dev
|
||||||
|
|
@ -45,9 +45,8 @@ value. Rerun the check after the user confirms. Continue only when it passes.
|
||||||
- Author the case at
|
- Author the case at
|
||||||
`packages/@n8n/instance-ai/evaluations/data/agents/<slug>.json`.
|
`packages/@n8n/instance-ai/evaluations/data/agents/<slug>.json`.
|
||||||
- Set `"datasets": ["agents"]`.
|
- Set `"datasets": ["agents"]`.
|
||||||
- Push general Agent Builder cases to
|
- Ask the driver for the target suite before you do anything else. See
|
||||||
[Instance AI capabilities — agents](https://lang-tracer.n8n-maintenance.workers.dev/suites/10).
|
"Ask for the target suite first" below. Do not assume `agents`.
|
||||||
Its suite slug is `agents`.
|
|
||||||
- Do not commit the case JSON. LangTracer is the durable source of truth.
|
- Do not commit the case JSON. LangTracer is the durable source of truth.
|
||||||
- Commit changes to this skill, the harness, and CI when applicable.
|
- Commit changes to this skill, the harness, and CI when applicable.
|
||||||
|
|
||||||
|
|
@ -70,9 +69,189 @@ or outcome expectations can prove the behavior.
|
||||||
Use the substitution test for every expectation. A correct alternative build
|
Use the substitution test for every expectation. A correct alternative build
|
||||||
must pass. A build that misses the requested behavior must fail.
|
must pass. A build that misses the requested behavior must fail.
|
||||||
|
|
||||||
|
## Write expectations about the Agent, not the builder's internals
|
||||||
|
|
||||||
|
Agent Builder runs as a delegated sub-agent architecture today. That design
|
||||||
|
can change. Do not write an expectation that depends on it. Follow
|
||||||
|
"Keep expectations free of internal mechanics" in
|
||||||
|
[create-instance-ai-eval](../create-instance-ai-eval/SKILL.md).
|
||||||
|
|
||||||
|
Do not write:
|
||||||
|
|
||||||
|
- "The sub-agent runs/tests the Agent before it responds."
|
||||||
|
- "Instance AI delegates to `build-agent`" or "calls `build-agent` once."
|
||||||
|
- "The builder calls `call_agent` to verify the Agent."
|
||||||
|
- "The sub-agent reads the skill, then sets the model."
|
||||||
|
|
||||||
|
Write what a user or a reviewer can observe:
|
||||||
|
|
||||||
|
| ❌ mechanical | ✅ intent |
|
||||||
|
|---|---|
|
||||||
|
| "The sub-agent tests the Agent before it reports" | "The final response says the Agent was tested only if a test ran, and it reports the result of that test honestly" |
|
||||||
|
| "`build-agent` is called with the Slack credential" | "The Agent uses the Slack credential the user has" |
|
||||||
|
| "The sub-agent adds a tool for the lookup" | "The Agent can look up the order status when a user asks for it" |
|
||||||
|
| "The builder sets `model` after the catalog lookup" | "The Agent uses a model that the connected credential supports" |
|
||||||
|
|
||||||
|
Put each one where it belongs:
|
||||||
|
|
||||||
|
- The Agent's configuration, tools, skills, and behavior go in
|
||||||
|
`outcomeExpectations`.
|
||||||
|
- What the user is told, asked, or shown goes in `processExpectations`.
|
||||||
|
- The final response is the place to check claims ("says it is ready",
|
||||||
|
"lists the tools it added"). Do not check which component made the claim.
|
||||||
|
|
||||||
|
The calibration steps below ask you to confirm that `build-agent` ran. That is
|
||||||
|
a check for you, to be sure the case reached Agent Builder. Do not add it to the
|
||||||
|
case.
|
||||||
|
|
||||||
For multi-turn, seeded, or capability-gap cases, follow the case-shape and
|
For multi-turn, seeded, or capability-gap cases, follow the case-shape and
|
||||||
calibration rules in [create-instance-ai-eval](../create-instance-ai-eval/SKILL.md).
|
calibration rules in [create-instance-ai-eval](../create-instance-ai-eval/SKILL.md).
|
||||||
This skill overrides its workflow directory and suite guidance for Agent cases.
|
This skill overrides its workflow directory and suite guidance for Agent cases.
|
||||||
|
The sections below add the Agent-specific parts. Do not skip the general rules
|
||||||
|
they point to.
|
||||||
|
|
||||||
|
## Ask for the target suite first
|
||||||
|
|
||||||
|
Follow "Ask for the target suite first" in
|
||||||
|
[create-instance-ai-eval](../create-instance-ai-eval/SKILL.md). Do it after the
|
||||||
|
LangTracer preflight and before you source or draft anything. If the request
|
||||||
|
already names a suite, state it and do not ask. Otherwise ask, in autonomous
|
||||||
|
mode too, in the same message as the autonomy question.
|
||||||
|
|
||||||
|
For Agent cases, recommend
|
||||||
|
[Instance AI capabilities — agents](https://lang-tracer.n8n-maintenance.workers.dev/suites/10)
|
||||||
|
(slug `agents`). Offer `baseline` as well: the Instance AI (INS) team monitors
|
||||||
|
it and it runs nightly. Get the full list from `list_suites`. Use the suite the
|
||||||
|
driver picks in every push command, in place of `agents` in the examples below.
|
||||||
|
Keep the `agents` dataset and the `data/agents/` directory. They do not depend
|
||||||
|
on the suite.
|
||||||
|
|
||||||
|
## Set the autonomy level first
|
||||||
|
|
||||||
|
Follow "Set the autonomy level first" in
|
||||||
|
[create-instance-ai-eval](../create-instance-ai-eval/SKILL.md). Ask the driver
|
||||||
|
for *autonomous* or *checkpoint* mode before you source or draft anything. The
|
||||||
|
four gates are the same: selection, shape and expectations, calibration, push.
|
||||||
|
|
||||||
|
## Source the case from a real thread
|
||||||
|
|
||||||
|
The best Agent cases come from a real conversation. Discover the conversation
|
||||||
|
with LangTracer. Author a synthetic case from what you learn.
|
||||||
|
|
||||||
|
1. List candidates: `list_conversations` with `usedAgentBuilder: true`. Do not
|
||||||
|
use `source: "agent-builder"`. That pool is a different product, has
|
||||||
|
unscrubbed payloads, and has no Agent snapshots.
|
||||||
|
2. List what the thread did to the Agent: `list_conversation_agent_snapshots`.
|
||||||
|
`target-resolved` is the state that a turn opened on. `config-updated` is the
|
||||||
|
state after the builder changed it.
|
||||||
|
3. Pick the shape from the snapshot sequence (next section).
|
||||||
|
4. Fetch the state to seed with `get_conversation_agent_snapshot`. Seed from the
|
||||||
|
`target-resolved` row of the turn under test.
|
||||||
|
5. Read the raw turn before you write any expectation. Follow
|
||||||
|
"First reproduce, then reclassify" in the general skill.
|
||||||
|
|
||||||
|
Two snapshot patterns to look for:
|
||||||
|
|
||||||
|
- A `target-resolved` hash that differs from the previous `config-updated` hash.
|
||||||
|
The user edited the Agent in the UI between turns. This is the best seed for a
|
||||||
|
repair or update case.
|
||||||
|
- A run of turns that each end in `config-updated`. The user added one rule per
|
||||||
|
turn. Use it for a "new rule does not erase old rules" case.
|
||||||
|
|
||||||
|
Credentials arrive as `[redacted]`. Instructions and skill bodies do not. Scrub
|
||||||
|
the prose before it enters a case. See the `agents` section in
|
||||||
|
[case-shapes.md](../create-instance-ai-eval/case-shapes.md) (the `agents` seed section).
|
||||||
|
|
||||||
|
## Pick the shape: create or update
|
||||||
|
|
||||||
|
| Shape | Question it answers | How to write it |
|
||||||
|
|---|---|---|
|
||||||
|
| **Create** | Does the builder make the right Agent from a fresh request? | One user turn. No seed. Assert on the Agent artifact. |
|
||||||
|
| **Update** | Does the builder change only what the user asked for? | `seed.agents` with the Agent before the change. One live turn with the change. Assert on the new behavior and that the untouched parts survive. |
|
||||||
|
| **Repair** | Does the builder find the cause of a failure and fix it? | Seed the broken Agent. The live turn reports the failure. Assert on the cause found and the fix applied. |
|
||||||
|
| **Incremental rules** | Do earlier rules survive when a new rule lands? | Seed the Agent after several rules. Add one conflicting or overlapping rule. Assert that the old rules are still in force. |
|
||||||
|
|
||||||
|
Rules that apply to all four:
|
||||||
|
|
||||||
|
- An update that rebuilds the Agent from scratch is a failure. Assert on a
|
||||||
|
specific untouched instruction, tool, or skill that must still be there.
|
||||||
|
- Write the conversation in English, even when the source thread is not.
|
||||||
|
- Trim to the fewest turns that reproduce the behavior. Do not copy a long
|
||||||
|
thread.
|
||||||
|
- Put only the live turn in `conversation`. Put earlier turns in `seed.messages`.
|
||||||
|
|
||||||
|
## Confirm the precondition fired
|
||||||
|
|
||||||
|
A pass can mean the builder did the right thing. It can also mean the situation
|
||||||
|
never happened. Check before you trust a green.
|
||||||
|
|
||||||
|
- For "the builder did not do X", confirm that it reached the turn where X was
|
||||||
|
possible. A builder that never edited the Agent passes "did not delete the old
|
||||||
|
rule".
|
||||||
|
- For a repair case, confirm that the seeded Agent really has the defect. Read
|
||||||
|
the seeded config in the rendered artifact.
|
||||||
|
- For an update case, compare the seeded config with the final config. Confirm
|
||||||
|
that the requested change is the only difference you expected.
|
||||||
|
- For a "say you cannot" case, confirm that the tool or data was really missing
|
||||||
|
in the run.
|
||||||
|
|
||||||
|
## A red is a finding
|
||||||
|
|
||||||
|
Follow "A red is signal" in the general skill. For Agent cases, add this:
|
||||||
|
|
||||||
|
1. Classify the red as a product gap, a harness limitation, or non-determinism.
|
||||||
|
2. Keep a product-gap red. Start the case `description` with
|
||||||
|
`Capability-gap finding:`.
|
||||||
|
3. **Propose a Linear ticket for the Agent team.** The Linear team is named
|
||||||
|
`Agent`. Propose the ticket. Do not create it unless the driver says go.
|
||||||
|
Follow "Capability gap → propose a Linear ticket" in the general skill. Also
|
||||||
|
check whether the gap already has a ticket.
|
||||||
|
- Write the ticket in English. Use Simplified Technical English, as
|
||||||
|
[AGENTS.md](../../../AGENTS.md) requires. Translate the user's words. Do
|
||||||
|
not paste them.
|
||||||
|
- This repository is public. Keep customer names, company names, and real
|
||||||
|
URLs out of the ticket. Describe the use case in neutral words.
|
||||||
|
- Link the LangTracer case and the source thread. Do not copy raw user
|
||||||
|
messages or agent instructions from the thread.
|
||||||
|
- Reproduce the gap first. This is a default, not a hard rule. Propose a
|
||||||
|
ticket only after a case is red on a real build. Quote the failing
|
||||||
|
expectation and the judge reason. Follow "Reproduce first" in the general
|
||||||
|
skill.
|
||||||
|
- The driver may approve an exception, for example when the harness cannot
|
||||||
|
reach the mechanism after about three attempts. Then the ticket must say
|
||||||
|
"Not reproduced in the eval. Based on trace analysis."
|
||||||
|
4. **Check the builder's own guidance for the gap.** Read the files that steer
|
||||||
|
Agent Builder:
|
||||||
|
`packages/cli/src/modules/agents/builder/agents-builder-prompts.ts`,
|
||||||
|
`packages/cli/src/modules/agents/builder/prompts/`, and
|
||||||
|
`packages/cli/src/modules/agents/builder/skills/`. Also read the Instance AI
|
||||||
|
orchestrator prompt in `packages/@n8n/instance-ai/src/agent/system-prompt.ts`.
|
||||||
|
If none of them covers the failing situation, add that to the ticket as a
|
||||||
|
proposed prompt or skill change.
|
||||||
|
5. Propose the smallest added line first. Do not delete existing guidance to fix a
|
||||||
|
gap. A measured case: adding one line fixed a routing gap, and deleting two
|
||||||
|
lines did not.
|
||||||
|
6. Every prompt or skill change needs a before and after measurement. The new
|
||||||
|
case is the "before" run. Run it again after the change. Use `--iterations 5`.
|
||||||
|
|
||||||
|
When the failure is in the Agent's runtime (for example a channel that cannot
|
||||||
|
report an error), the builder cannot see it. The right fix is often a builder
|
||||||
|
instruction to say so and stop guessing. Write that as a `processExpectations`
|
||||||
|
item on the final response.
|
||||||
|
|
||||||
|
## Publish and link
|
||||||
|
|
||||||
|
Follow "Share links, never bare ids" and "Link the pushed case to its source" in
|
||||||
|
the general skill. For a sourced Agent case:
|
||||||
|
|
||||||
|
- Report the case, the suite, and the source thread as links. Use the
|
||||||
|
`https://lang-tracer.n8n-maintenance.workers.dev/test-cases/<id>` form.
|
||||||
|
- Call `update_test_case` with `sourceThreadId`, `expectedBehavior`, and
|
||||||
|
`failurePattern`.
|
||||||
|
- Add a capability tag with `add_case_tags`.
|
||||||
|
- Push a case whose *build* is wrong with `--set-kind capability_gap` into a
|
||||||
|
suite of that kind. If no such Agent suite exists, tell the driver. Do not put
|
||||||
|
it in a `regression` suite.
|
||||||
|
|
||||||
## Draft the case
|
## Draft the case
|
||||||
|
|
||||||
|
|
@ -91,7 +270,7 @@ Start with this shape:
|
||||||
"The final response ..."
|
"The final response ..."
|
||||||
],
|
],
|
||||||
"outcomeExpectations": [
|
"outcomeExpectations": [
|
||||||
"A standalone Agent was created and no workflow was created.",
|
"A standalone Agent is the deliverable; any workflow created exists only as a tool the Agent calls.",
|
||||||
"The Agent ..."
|
"The Agent ..."
|
||||||
],
|
],
|
||||||
"datasets": ["agents"]
|
"datasets": ["agents"]
|
||||||
|
|
@ -161,14 +340,14 @@ credential such as `EVAL_OPENAI_API_KEY`. A build-only case does not need one.
|
||||||
4. Classify each red as a product gap, harness limitation, or non-determinism.
|
4. Classify each red as a product gap, harness limitation, or non-determinism.
|
||||||
5. Use `--iterations 5` before adding a case to a gating tier.
|
5. Use `--iterations 5` before adding a case to a gating tier.
|
||||||
6. Preview the LangTracer change.
|
6. Preview the LangTracer change.
|
||||||
7. Push it to `agents`.
|
7. Push it to the suite the driver chose (`<suite>` below).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm exec dotenvx run -f ../../../.env.local -- \
|
pnpm exec dotenvx run -f ../../../.env.eval -- \
|
||||||
pnpm eval:langtracer-push --suite agents --dry-run --changed
|
pnpm eval:langtracer-push --suite <suite> --dry-run --changed
|
||||||
|
|
||||||
pnpm exec dotenvx run -f ../../../.env.local -- \
|
pnpm exec dotenvx run -f ../../../.env.eval -- \
|
||||||
pnpm eval:langtracer-push --suite agents --changed
|
pnpm eval:langtracer-push --suite <suite> --changed
|
||||||
```
|
```
|
||||||
|
|
||||||
The push needs `LANGTRACER_URL` and `LANGTRACER_API_KEY`. Generate a key on the
|
The push needs `LANGTRACER_URL` and `LANGTRACER_API_KEY`. Generate a key on the
|
||||||
|
|
@ -179,12 +358,20 @@ after a successful push.
|
||||||
## Completion checklist
|
## Completion checklist
|
||||||
|
|
||||||
- The LangTracer environment preflight passed before authoring.
|
- The LangTracer environment preflight passed before authoring.
|
||||||
|
- The driver chose the target suite before any work, including in autonomous mode.
|
||||||
|
- The autonomy level was set and stated before any work.
|
||||||
|
- A sourced case links to its source thread, and the source turn was read raw.
|
||||||
|
- Each conditional expectation has proof that its precondition fired.
|
||||||
|
- A kept product-gap red has a proposed Linear ticket and a check of the builder guidance.
|
||||||
- The case is in `data/agents/` and uses the `agents` dataset.
|
- The case is in `data/agents/` and uses the `agents` dataset.
|
||||||
- The strict loader accepts it.
|
- The strict loader accepts it.
|
||||||
- A local run captures a standalone Agent.
|
- A local run captures a standalone Agent.
|
||||||
- The transcript proves each process expectation was exercised.
|
- The transcript proves each process expectation was exercised.
|
||||||
- The rendered Agent artifact proves each outcome expectation.
|
- The rendered Agent artifact proves each outcome expectation.
|
||||||
|
- No expectation names a sub-agent, an internal tool, or a delegation step.
|
||||||
|
Each one passes for a correct build from a different architecture.
|
||||||
- Repeated runs are stable enough for the selected tier.
|
- Repeated runs are stable enough for the selected tier.
|
||||||
- The case is pushed to `Instance AI capabilities — agents` with suite slug `agents`.
|
- The case is pushed to the suite the driver chose, and the report links to it.
|
||||||
- Agent Builder PR changes select the `agents` suite in CI.
|
- Agent Builder PR changes select the `agents` suite in CI. A case pushed to
|
||||||
|
another suite does not run in that lane.
|
||||||
- The local case JSON is not committed.
|
- The local case JSON is not committed.
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
Generate a key on the
|
Generate a key on the
|
||||||
[LangTracer API keys page](https://lang-tracer.n8n-maintenance.workers.dev/account?section=api).
|
[LangTracer API keys page](https://lang-tracer.n8n-maintenance.workers.dev/account?section=api).
|
||||||
Add it to `.env.local` at the repository root:
|
Add it to `.env.eval` at the repository root:
|
||||||
|
|
||||||
```env
|
```env
|
||||||
LANGTRACER_URL=https://lang-tracer.n8n-maintenance.workers.dev
|
LANGTRACER_URL=https://lang-tracer.n8n-maintenance.workers.dev
|
||||||
|
|
@ -16,7 +16,7 @@ that both variables exist before it creates an eval.
|
||||||
|
|
||||||
## Remaining environment
|
## Remaining environment
|
||||||
|
|
||||||
Create `.env.local` at the repository root. Use one Instance AI model provider
|
Create `.env.eval` at the repository root. Use one Instance AI model provider
|
||||||
and one sandbox provider.
|
and one sandbox provider.
|
||||||
|
|
||||||
```env
|
```env
|
||||||
|
|
@ -76,7 +76,7 @@ N8N_USER_FOLDER=<printed-temp-directory> \
|
||||||
E2E_TESTS=true \
|
E2E_TESTS=true \
|
||||||
N8N_ENABLED_MODULES=instance-ai,agents \
|
N8N_ENABLED_MODULES=instance-ai,agents \
|
||||||
N8N_AI_ENABLED=true \
|
N8N_AI_ENABLED=true \
|
||||||
pnpm exec dotenvx run -f ../../.env.local -- pnpm start
|
pnpm exec dotenvx run -f ../../.env.eval -- pnpm start
|
||||||
```
|
```
|
||||||
|
|
||||||
Use different ports when 5680 or 5681 is busy.
|
Use different ports when 5680 or 5681 is busy.
|
||||||
|
|
@ -96,7 +96,7 @@ curl -sf -X POST http://localhost:5680/rest/e2e/reset \
|
||||||
From `packages/@n8n/instance-ai`:
|
From `packages/@n8n/instance-ai`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm exec dotenvx run -f ../../../.env.local -- \
|
pnpm exec dotenvx run -f ../../../.env.eval -- \
|
||||||
env -u LANGSMITH_API_KEY -u LANGSMITH_TRACING \
|
env -u LANGSMITH_API_KEY -u LANGSMITH_TRACING \
|
||||||
pnpm eval:instance-ai \
|
pnpm eval:instance-ai \
|
||||||
--base-url http://localhost:5680 \
|
--base-url http://localhost:5680 \
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,9 @@ exhaustive field reference; this skill is the opinionated *how*.
|
||||||
> approach.** Author the file locally (uncommitted), calibrate it against a real
|
> approach.** Author the file locally (uncommitted), calibrate it against a real
|
||||||
> build, then **push it to a lang-tracer suite** with `eval:langtracer-push`
|
> build, then **push it to a lang-tracer suite** with `eval:langtracer-push`
|
||||||
> (see [Push to a lang-tracer suite](#push-to-a-lang-tracer-suite)) —
|
> (see [Push to a lang-tracer suite](#push-to-a-lang-tracer-suite)) —
|
||||||
> `--suite baseline` for the consolidated corpus n8n CI runs, or a dedicated
|
> the suite the driver chose (see
|
||||||
|
> [Ask for the target suite first](#ask-for-the-target-suite-first)) —
|
||||||
|
> `baseline` for the consolidated corpus n8n CI runs, or a dedicated
|
||||||
> capability suite like `agents`.
|
> capability suite like `agents`.
|
||||||
> The suite is the home for the case; the eval CLI reads it back via
|
> The suite is the home for the case; the eval CLI reads it back via
|
||||||
> `--source langtracer`. You still write the JSON file — it's just the input to
|
> `--source langtracer`. You still write the JSON file — it's just the input to
|
||||||
|
|
@ -38,6 +40,34 @@ exhaustive field reference; this skill is the opinionated *how*.
|
||||||
> pruned and has no durable home. Don't commit a replay case either — derive a
|
> pruned and has no durable home. Don't commit a replay case either — derive a
|
||||||
> synthetic case from it. See [`case-shapes.md`](case-shapes.md).
|
> synthetic case from it. See [`case-shapes.md`](case-shapes.md).
|
||||||
|
|
||||||
|
## Ask for the target suite first
|
||||||
|
|
||||||
|
**Before you source, draft, or run anything, ask the driver which LangTracer
|
||||||
|
suite receives every eval you create in this session.** Ask this in both
|
||||||
|
autonomy levels, including autonomous mode. Never pick the suite yourself and
|
||||||
|
never default silently. A case pushed to the wrong suite runs in the wrong CI
|
||||||
|
lane and alerts the wrong team.
|
||||||
|
|
||||||
|
Ask the suite question and the autonomy question together, in one message.
|
||||||
|
Recommend `baseline`:
|
||||||
|
|
||||||
|
- `baseline` (recommended): the general corpus. The Instance AI (INS) team
|
||||||
|
monitors it, and it runs nightly.
|
||||||
|
- Any other suite, such as `agents` or `node-gauntlet`: call `list_suites` and
|
||||||
|
offer the live list. Do not use a hardcoded list, because suites are added
|
||||||
|
and retired.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- The driver's answer applies to all cases in the session. Do not ask again per
|
||||||
|
case.
|
||||||
|
- If the driver already named a suite in the request, state it back in one line
|
||||||
|
and continue. Do not ask again.
|
||||||
|
- Check that the suite kind matches the case. Push a case with a wrong build
|
||||||
|
(`capability_gap`) only into a suite that allows that kind. If the suite does
|
||||||
|
not allow it, tell the driver and ask again.
|
||||||
|
- Put the chosen suite slug in the final decision log, as a link.
|
||||||
|
|
||||||
## Set the autonomy level first
|
## Set the autonomy level first
|
||||||
|
|
||||||
**Before you source, draft, or run anything, decide how hands-on the driver
|
**Before you source, draft, or run anything, decide how hands-on the driver
|
||||||
|
|
@ -140,7 +170,7 @@ discover → verify → encode workflow.
|
||||||
|
|
||||||
## Pick the case shape first
|
## Pick the case shape first
|
||||||
|
|
||||||
The corpus is four archetypes. Decide which you're writing before you draft — it
|
The corpus is five archetypes. Decide which you're writing before you draft — it
|
||||||
determines the fields, the grading, and how you validate. They compose (a seeded
|
determines the fields, the grading, and how you validate. They compose (a seeded
|
||||||
case can still assert outcome), but the primary shape drives the work.
|
case can still assert outcome), but the primary shape drives the work.
|
||||||
|
|
||||||
|
|
@ -150,10 +180,25 @@ case can still assert outcome), but the primary shape drives the work.
|
||||||
| **Behaviour / process** | Does the agent *converse* correctly (ask the right clarifying question, not re-ask, honour a correction, respect plan approval)? | `processExpectations` + multi-turn director script; often **build-only** |
|
| **Behaviour / process** | Does the agent *converse* correctly (ask the right clarifying question, not re-ask, honour a correction, respect plan approval)? | `processExpectations` + multi-turn director script; often **build-only** |
|
||||||
| **Credential** | Does the build behave correctly given a specific credential view? | `credentials[]` |
|
| **Credential** | Does the build behave correctly given a specific credential view? | `credentials[]` |
|
||||||
| **Seeded** | Start mid-thread, with prior work already in place, and drive the turn under test | `seed` (authored `mode: "inline"`; `"replay"` for a local check) |
|
| **Seeded** | Start mid-thread, with prior work already in place, and drive the turn under test | `seed` (authored `mode: "inline"`; `"replay"` for a local check) |
|
||||||
|
| **Context** | Does the agent still work once the conversation is long — does it reuse what it already read, and keep what matters after observational memory compacts the history? | `processExpectations` + a long `seed`; `requiresMemoryCompaction` for the compaction ones |
|
||||||
|
|
||||||
**Build** is documented in full below. The other three, the director-script
|
**Build** is documented in full below. The other four, the director-script
|
||||||
vocabulary, and the seeding modes are in [`case-shapes.md`](case-shapes.md).
|
vocabulary, and the seeding modes are in [`case-shapes.md`](case-shapes.md).
|
||||||
|
|
||||||
|
The judge also sees **token ground truth** for workflow-build cases: per-turn
|
||||||
|
input and output tokens inline in each transcript turn header, a build-wide
|
||||||
|
total, a cache read/write split, and the opening step's cost. So an expectation
|
||||||
|
may reference cost or consumption directly ("does not re-read the same node's
|
||||||
|
schema in turn 2"). The numbers cover the orchestrator's own LLM steps only. A
|
||||||
|
delegated Agent build runs in a sub-agent whose steps are not in the snapshot,
|
||||||
|
so do not write cost expectations on an Agent case. Those numbers come from run-debug snapshots, so they need
|
||||||
|
`N8N_INSTANCE_AI_RUN_DEBUG_ENABLED=true` on the instance under test; without it
|
||||||
|
the judge reads `(no run debug captured)` and cost expectations are ungradeable.
|
||||||
|
|
||||||
|
The judge also sees the thread's **observation rows** — what observational
|
||||||
|
memory kept after it compacted — so a memory case can grade the summary itself.
|
||||||
|
That block is a separate REST read and needs no flag.
|
||||||
|
|
||||||
## Core principle (all shapes)
|
## Core principle (all shapes)
|
||||||
|
|
||||||
**Write expectations from intent, then calibrate against a real build.** Decide
|
**Write expectations from intent, then calibrate against a real build.** Decide
|
||||||
|
|
@ -453,6 +498,16 @@ read the run assumes someone already owns it. So once a red is classified as a
|
||||||
real gap (and the driver has confirmed it, per the autonomy level), **propose a
|
real gap (and the driver has confirmed it, per the autonomy level), **propose a
|
||||||
Linear ticket for it.**
|
Linear ticket for it.**
|
||||||
|
|
||||||
|
**Reproduce first. This is a default, not a hard rule.** Propose a ticket only
|
||||||
|
after a case has reproduced the gap. That means the case is red on a real build,
|
||||||
|
the precondition was confirmed to fire, and you re-read the raw thread (see
|
||||||
|
[First reproduce, then reclassify](#first-reproduce-then-reclassify)). A gap that
|
||||||
|
comes from trace analysis alone is a hypothesis. A ticket for it costs the owning
|
||||||
|
team time. The driver may approve an exception, for example when the harness
|
||||||
|
cannot reach the mechanism after about three attempts. In that case the ticket
|
||||||
|
must say that the eval did not reproduce the gap. Never file an unreproduced gap
|
||||||
|
without the driver's approval.
|
||||||
|
|
||||||
**Propose, don't create.** Per [AGENTS.md](../../../AGENTS.md), never open a
|
**Propose, don't create.** Per [AGENTS.md](../../../AGENTS.md), never open a
|
||||||
Linear ticket unasked. Put the draft in front of the driver — interactively in
|
Linear ticket unasked. Put the draft in front of the driver — interactively in
|
||||||
checkpoint mode, in the decision log in autonomous mode — with a title, a team,
|
checkpoint mode, in the decision log in autonomous mode — with a title, a team,
|
||||||
|
|
@ -600,9 +655,16 @@ nodes get LLM-generated pin data). So:
|
||||||
static data** (`removeItemsSeenInPreviousExecutions`, `$getWorkflowStaticData`)
|
static data** (`removeItemsSeenInPreviousExecutions`, `$getWorkflowStaticData`)
|
||||||
is **not** seedable, because static data starts empty every run, so such a
|
is **not** seedable, because static data starts empty every run, so such a
|
||||||
scenario reds vacuously (it sees everything as "new"). To get a seedable
|
scenario reds vacuously (it sees everything as "new"). To get a seedable
|
||||||
change-detection scenario, steer the build toward a Data Table; otherwise
|
change-detection scenario, say "keep the state in a data table" in the prompt;
|
||||||
|
otherwise
|
||||||
accept the static-data red as a harness limit and carry the logic in
|
accept the static-data red as a harness limit and carry the logic in
|
||||||
`outcomeExpectations`. Note the agent may *choose* static-data dedup on its own.
|
`outcomeExpectations`. Note the agent may *choose* static-data dedup on its own.
|
||||||
|
- **Message an Agent runs the called Agent with its real model.** The harness
|
||||||
|
mocks the Agent's tools, MCP servers and web search, and records those calls
|
||||||
|
under the calling node. It turns off the Agent's memory, vector stores and chat
|
||||||
|
integrations. Only OpenAI models are supported today, and only when
|
||||||
|
`EVAL_OPENAI_API_KEY` is set (CI sets it): put the Agent on an `openai/*` model
|
||||||
|
and declare an `openAiApi` credential.
|
||||||
- Don't assert exact counts that depend on mock generation ("exactly 7 posts").
|
- Don't assert exact counts that depend on mock generation ("exactly 7 posts").
|
||||||
Say "fewer than the original 10".
|
Say "fewer than the original 10".
|
||||||
|
|
||||||
|
|
@ -696,8 +758,9 @@ Both are natural-language assertions graded by the same Sonnet judge, and each
|
||||||
They run everywhere, including prebuilt/MCP runs (no transcript needed).
|
They run everywhere, including prebuilt/MCP runs (no transcript needed).
|
||||||
- **`processExpectations`** — **how the agent behaved during the build**, judged
|
- **`processExpectations`** — **how the agent behaved during the build**, judged
|
||||||
from the transcript. Assert clarifying questions asked (or not re-asked),
|
from the transcript. Assert clarifying questions asked (or not re-asked),
|
||||||
tool-call behaviour, plan/approval handling, batching, honouring a correction,
|
user-visible behaviour, plan/approval handling, batching, honouring a
|
||||||
ordering. They need a transcript, so they're **skipped in prebuilt/MCP runs**.
|
correction, ordering. Do not assert internal mechanics such as sub-agents or
|
||||||
|
tool names (see "Keep expectations free of internal mechanics"). They need a transcript, so they're **skipped in prebuilt/MCP runs**.
|
||||||
|
|
||||||
Rule of thumb: an assertion about *the artifact* is an outcome expectation; an
|
Rule of thumb: an assertion about *the artifact* is an outcome expectation; an
|
||||||
assertion about *the conversation or the agent's choices along the way* is a
|
assertion about *the conversation or the agent's choices along the way* is a
|
||||||
|
|
@ -735,6 +798,54 @@ case, where the source and channel were **left unspecified**):
|
||||||
Put intent the conversation only *implied* (a preferred but unstated channel) in
|
Put intent the conversation only *implied* (a preferred but unstated channel) in
|
||||||
`processExpectations`, not `outcomeExpectations`.
|
`processExpectations`, not `outcomeExpectations`.
|
||||||
|
|
||||||
|
## Keep expectations free of internal mechanics
|
||||||
|
|
||||||
|
An expectation describes what the user or the artifact sees. It does not
|
||||||
|
describe how Instance AI is built inside. The internal architecture changes:
|
||||||
|
sub-agents, tool names, delegation, planning steps, and run ordering all move.
|
||||||
|
An expectation that names them goes red when the design changes, although the
|
||||||
|
behavior is still correct. It also stops proving the behavior: a build that
|
||||||
|
solves the problem by another route fails it.
|
||||||
|
|
||||||
|
**Do not name these in an expectation:**
|
||||||
|
|
||||||
|
- Sub-agents, delegation, or hand-off ("the sub-agent runs ...", "the
|
||||||
|
orchestrator delegates ...").
|
||||||
|
- Internal tool names or call counts ("calls `build-agent`", "calls
|
||||||
|
`search-nodes` twice").
|
||||||
|
- Internal stages, task lists, or step order that the user cannot see.
|
||||||
|
- Which component produced a result.
|
||||||
|
|
||||||
|
**Do name these:**
|
||||||
|
|
||||||
|
- What the user sees in the conversation: the question asked, the plan shown
|
||||||
|
for approval, the claim in the final response.
|
||||||
|
- What the artifact contains: the workflow or Agent, its configuration, and
|
||||||
|
how it behaves.
|
||||||
|
- A user-visible sequence, only when the user asked for it ("asks before it
|
||||||
|
builds").
|
||||||
|
|
||||||
|
Rewrite test. Delete the internal term. State the result the term produced.
|
||||||
|
|
||||||
|
| ❌ mechanical | ✅ intent |
|
||||||
|
|---|---|
|
||||||
|
| "The sub-agent runs the workflow before it reports success" | "The final response reports only results that the build verified; it does not claim an untested step works" |
|
||||||
|
| "The orchestrator calls `ask-user` before delegating" | "The agent asks for the missing Slack channel before it builds, and does not guess one" |
|
||||||
|
| "The builder calls `search-nodes` for the trigger" | "The workflow starts from a trigger that matches the requested schedule" |
|
||||||
|
|
||||||
|
Second test. Imagine the internals are replaced by one agent with different
|
||||||
|
tools. A correct build must still pass. If the expectation would fail or become
|
||||||
|
meaningless, rewrite it.
|
||||||
|
|
||||||
|
Some mechanics are a legitimate subject: a case whose purpose is to guard a
|
||||||
|
specific tool contract (for example, a regression in how a tool reports an
|
||||||
|
error). Keep these rare. State the reason in `description`. Do not mix them with
|
||||||
|
intent expectations in the same case.
|
||||||
|
|
||||||
|
**Calibration steps are not expectations.** Reading the transcript to confirm
|
||||||
|
that a build ran, or that a sub-agent was reached, is a good calibration check.
|
||||||
|
Keep it in your own run notes. Do not copy it into the case JSON.
|
||||||
|
|
||||||
## Robust design vs harness flakiness
|
## Robust design vs harness flakiness
|
||||||
|
|
||||||
Two different things — keep them apart:
|
Two different things — keep them apart:
|
||||||
|
|
@ -821,16 +932,24 @@ drifted, leaves the rest unchanged, and never prunes. It's the inverse of
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd packages/@n8n/instance-ai
|
cd packages/@n8n/instance-ai
|
||||||
|
# <suite> is the slug the driver chose (e.g. baseline)
|
||||||
# preview first — no writes:
|
# preview first — no writes:
|
||||||
pnpm exec dotenvx run -f .env.eval -- pnpm eval:langtracer-push --suite baseline --dry-run --changed
|
pnpm exec dotenvx run -f .env.eval -- pnpm eval:langtracer-push --suite <suite> --dry-run --changed
|
||||||
# then push (drop --dry-run):
|
# then push (drop --dry-run):
|
||||||
pnpm exec dotenvx run -f .env.eval -- pnpm eval:langtracer-push --suite baseline --changed
|
pnpm exec dotenvx run -f .env.eval -- pnpm eval:langtracer-push --suite <suite> --changed
|
||||||
```
|
```
|
||||||
|
|
||||||
- **Selectors** (at least one required — no accidental push-all): positional
|
- **Selectors** (at least one required — no accidental push-all): positional
|
||||||
`<slugs...>` (exact file slugs), `--changed` (new/untracked + staged + modified
|
`<slugs...>` (exact file slugs), `--changed` (new/untracked + staged + modified
|
||||||
`data/{workflows,agents}/*.json`, ideal right after authoring an uncommitted case),
|
`data/{workflows,agents}/*.json`, ideal right after authoring an uncommitted case),
|
||||||
`--filter`/`--tier` (with `--exclude` as a modifier).
|
`--filter`/`--tier` (with `--exclude` as a modifier).
|
||||||
|
- **Validation is selective.** Exact slugs and `--changed` read only the named
|
||||||
|
files, so an unrelated invalid file in `data/workflows` (a case authored on a
|
||||||
|
newer branch, a half-written draft) never blocks your push. `--filter` parses
|
||||||
|
only the files whose slug matches; `--tier` reads the tier from inside each
|
||||||
|
file, so it parses them all. An invalid file either one parses prints
|
||||||
|
`⚠ skipped invalid case file …` and the push continues without it. A file you
|
||||||
|
named still fails the push when it is invalid.
|
||||||
- **Multiple positional slugs? Skip pnpm — call the script directly.** `pnpm
|
- **Multiple positional slugs? Skip pnpm — call the script directly.** `pnpm
|
||||||
eval:langtracer-push … slugA slugB` forwards the slugs as one joined argument
|
eval:langtracer-push … slugA slugB` forwards the slugs as one joined argument
|
||||||
(`"slugA slugB"`), so no case file matches and nothing is pushed. Either use a
|
(`"slugA slugB"`), so no case file matches and nothing is pushed. Either use a
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Case shapes beyond the plain build
|
# Case shapes beyond the plain build
|
||||||
|
|
||||||
The [SKILL](SKILL.md) covers the **build** archetype. This file covers the other
|
The [SKILL](SKILL.md) covers the **build** archetype. This file covers the other
|
||||||
three — **behaviour/process**, **credential**, **seeded** — plus the
|
four — **behaviour/process**, **credential**, **seeded**, **context** — plus the
|
||||||
director-note vocabulary multi-turn cases rely on. Field-level docs live in the
|
director-note vocabulary multi-turn cases rely on. Field-level docs live in the
|
||||||
eval [README](../../../packages/@n8n/instance-ai/evaluations/README.md); this is
|
eval [README](../../../packages/@n8n/instance-ai/evaluations/README.md); this is
|
||||||
the opinionated *how* and the traps.
|
the opinionated *how* and the traps.
|
||||||
|
|
@ -662,6 +662,24 @@ really had one — and the agent has to guess from prose that deliberately names
|
||||||
nothing. It will list workflows and pick, or ask which one, and you will score a
|
nothing. It will list workflows and pick, or ask which one, and you will score a
|
||||||
clarification failure the real user never hit.
|
clarification failure the real user never hit.
|
||||||
|
|
||||||
|
#### Seeded faults must really fail
|
||||||
|
|
||||||
|
`seed.priorRuns[].hints` steer the mock for the staged run's outbound calls and
|
||||||
|
nothing else. The builder's own runs are never mocked, and Code, Set, IF, Filter
|
||||||
|
and Merge run for real. So:
|
||||||
|
|
||||||
|
- **The fault must fail in n8n.** A Code node that returns a plain object does not
|
||||||
|
fail; n8n wraps it as one item. Return an array of plain values, reference a
|
||||||
|
node that does not exist (`$('Missing')`), or throw.
|
||||||
|
- **The fault fires before any external call.** The builder's rerun reaches a
|
||||||
|
fixture host and dies there (DNS, 401), so a fault behind an HTTP node is never
|
||||||
|
reached. Never grade the rerun itself; grade the fix with `outcomeExpectations`
|
||||||
|
and the harness's `executionScenarios`.
|
||||||
|
- **An empty branch needs a Filter that keeps nothing.** Split Out over an empty
|
||||||
|
array still emits a placeholder item.
|
||||||
|
- **Walk the item flow before asserting a count.** A Slack node fed three items
|
||||||
|
posts three times.
|
||||||
|
|
||||||
#### Before you ship a seeded case — three checks
|
#### Before you ship a seeded case — three checks
|
||||||
|
|
||||||
1. **The defect still bites.** A seed whose workflow isn't broken any more makes the
|
1. **The defect still bites.** A seed whose workflow isn't broken any more makes the
|
||||||
|
|
@ -678,3 +696,139 @@ build". A seeded thread starts with an empty sandbox, so the agent re-reads the
|
||||||
workflow from the database and re-derives SDK source a real resumed session would
|
workflow from the database and re-derives SDK source a real resumed session would
|
||||||
still have on disk. The bias is *harder* than reality, so those numbers read worse
|
still have on disk. The bias is *harder* than reality, so those numbers read worse
|
||||||
for a reason that has nothing to do with the builder.
|
for a reason that has nothing to do with the builder.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Context cases (long conversations, token cost, compaction)
|
||||||
|
|
||||||
|
A context case asks whether the agent still works once the conversation is
|
||||||
|
long. Two questions, two mechanics.
|
||||||
|
|
||||||
|
### The judge already has the token numbers
|
||||||
|
|
||||||
|
Every case's judge context carries token ground truth, so no new field is
|
||||||
|
needed to grade cost:
|
||||||
|
|
||||||
|
- Each transcript turn header reads
|
||||||
|
`Turn 2 (3 steps, 47446 tokens in [41220 cached], 812 tokens out)`.
|
||||||
|
- A **Total** block sums every LLM step across the whole build, main run and
|
||||||
|
resumes, plus a cache read/write split and a **Fixed overhead** line — the
|
||||||
|
opening step's input, which is instructions and tool schemas plus one user
|
||||||
|
message, so it reads as the floor cost of any turn on that instance. Use it
|
||||||
|
to separate "the prompt got bigger" from "this conversation got expensive".
|
||||||
|
|
||||||
|
So `processExpectations` may reference consumption directly. Write them as
|
||||||
|
statements about *behaviour the numbers reveal*, not as thresholds:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"processExpectations": [
|
||||||
|
"The agent answers the second question without calling get_node_details on the Notion node again — it read that schema in turn 1 and the answer needs nothing new.",
|
||||||
|
"Turn 2's input tokens are mostly cache reads, which is what an unchanged prefix looks like. A turn that re-reads the history from scratch shows a cache-read share near zero."
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
Absolute token budgets rot: a prompt edit moves every number and the case goes
|
||||||
|
red for a reason that has nothing to do with the agent. Grade the *shape* —
|
||||||
|
what got re-read, what came from cache, which turn is the expensive one.
|
||||||
|
|
||||||
|
Two limits to know before you write a cost expectation:
|
||||||
|
|
||||||
|
- It needs `N8N_INSTANCE_AI_RUN_DEBUG_ENABLED=true` on the instance under test.
|
||||||
|
Without it the blocks render `(no run debug captured)` and the expectation is
|
||||||
|
unjudgeable — it fails or passes at random.
|
||||||
|
- The capture hooks **only the orchestrator's own stream**. A workflow build
|
||||||
|
runs inside that loop, so a workflow case's numbers are its real cost. A
|
||||||
|
delegated **Agent** build (the `build-agent` tool) runs on a separate stream
|
||||||
|
with no hooks, so its tokens are missing from every block. Don't write a cost
|
||||||
|
expectation on an `data/agents/` case.
|
||||||
|
|
||||||
|
### `requiresMemoryCompaction` — grade the post-compaction window
|
||||||
|
|
||||||
|
Observational memory compacts a long thread: an Observer summarises it into
|
||||||
|
observation rows, the summary is rendered into the system prompt, and the early
|
||||||
|
turns are masked out of the agent's window. A case that tests whether a decision
|
||||||
|
*survives* that has a setup problem — production compacts at 30k tokens of
|
||||||
|
visible message content, which is a conversation too long to hand-author.
|
||||||
|
|
||||||
|
Set the flag and the harness handles it:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"requiresMemoryCompaction": true
|
||||||
|
```
|
||||||
|
|
||||||
|
Two things happen, both per-thread, so no other case in the run is affected:
|
||||||
|
|
||||||
|
1. The harness sends a low observer threshold with every turn of that thread
|
||||||
|
only: **1,000 tokens** of visible message content, the request field's floor.
|
||||||
|
The seed plus the live turns must cross it, or the observer never runs — a
|
||||||
|
short seed does not test memory, it reports **not judged** (below). Size the
|
||||||
|
seed generously; a few real turns of tool output cross 1,000 tokens easily.
|
||||||
|
The Observer's prompt, masking and cursor logic are the production ones at
|
||||||
|
any threshold.
|
||||||
|
2. After the build, the harness checks the premise actually held. If it did
|
||||||
|
not, every expectation on the case is reported **not judged** — an
|
||||||
|
`incomplete` verdict, excluded from scoring, not a red.
|
||||||
|
|
||||||
|
The premise check is the point of the flag. Uncompacted, the raw early turns
|
||||||
|
are still in the window, so the agent answers off them and every expectation
|
||||||
|
passes for free — a green that tests nothing.
|
||||||
|
|
||||||
|
The evidence is structural. `GET /rest/instance-ai/eval/threads/:threadId/memory`
|
||||||
|
returns the observation rows and the compaction cursor from their own tables. A
|
||||||
|
cursor means the observer ran and everything up to `lastObservedMessageId` is
|
||||||
|
masked out; the rows are what replaced it. Missing either, the case had nothing
|
||||||
|
to test and is reported not judged. Nothing reads the system prompt, so this
|
||||||
|
needs **no debug flag** and a prompt or SDK rename cannot quietly turn "never
|
||||||
|
compacted" into the answer.
|
||||||
|
|
||||||
|
The judge never sees the premise check — it grades the conversation, not whether
|
||||||
|
the harness configured the scenario. A misconfigured lane must not read as a
|
||||||
|
quality regression.
|
||||||
|
|
||||||
|
### Grade the summary, not just the reply
|
||||||
|
|
||||||
|
The judge *does* see the observation rows, as ground truth:
|
||||||
|
|
||||||
|
```
|
||||||
|
## Observational memory after compaction (ground truth — do not recount)
|
||||||
|
|
||||||
|
- [CRITICAL] User ruled OUT the native Slack node; will post through the HTTP
|
||||||
|
Request node instead, because the workspace is pinned to a legacy Slack API
|
||||||
|
version. Do not revisit.
|
||||||
|
- [CRITICAL] Alert fires when queue depth goes above 4700 items.
|
||||||
|
```
|
||||||
|
|
||||||
|
So write expectations at **both** layers:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"processExpectations": [
|
||||||
|
"The observational-memory block retains the alert threshold as 4700 queued items. A summary that keeps the topic but loses the number is the specific failure this looks for.",
|
||||||
|
"In its reply to the final turn, the agent gives the alert threshold as 4700 queued items."
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
The first grades the memory system, the second grades the agent. Grading only
|
||||||
|
the reply is weaker than it looks: the agent can answer correctly from the
|
||||||
|
unmasked tail, or guess well, and the case goes green while memory dropped the
|
||||||
|
detail. The `[MARKER]` is the Observer's own priority label, so "retained as
|
||||||
|
CRITICAL" is checkable too.
|
||||||
|
|
||||||
|
### Writing the conversation
|
||||||
|
|
||||||
|
The shape that makes a compaction case real:
|
||||||
|
|
||||||
|
1. **State the anchors once, in a `user` turn near the start of the seed**, and
|
||||||
|
never restate them. Anything the agent repeats later lands *after* the
|
||||||
|
cursor and survives for the wrong reason.
|
||||||
|
2. **Make live turn 1 off-topic.** Its answer must not re-surface the anchors
|
||||||
|
into the post-cursor window. Compaction runs at the end of it.
|
||||||
|
3. **Ask for the anchors back in live turn 2.** That turn runs against the
|
||||||
|
compacted context.
|
||||||
|
4. **Force the antecedent** — a lost anchor must produce a *confident wrong*
|
||||||
|
answer, not a vague one. "Posting goes through HTTP Request, not the native
|
||||||
|
Slack node" works because the Slack node is the obvious guess. "The
|
||||||
|
threshold is 4700" works because a guess reaches for 5000. See
|
||||||
|
[negative expectations need a forced antecedent](SKILL.md#core-principle-all-shapes).
|
||||||
|
|
||||||
|
Tag these `context-consumption` and put them in a `context` dataset, so a
|
||||||
|
tier that needs run debug enabled can select them.
|
||||||
|
|
|
||||||
|
|
@ -218,6 +218,44 @@ deliberately does not re-sync tier-only edits to an existing case. **Only
|
||||||
promote to `pr` after `--iterations 5+` shows it's reliably green** — a flaky
|
promote to `pr` after `--iterations 5+` shows it's reliably green** — a flaky
|
||||||
case in the gate poisons it.
|
case in the gate poisons it.
|
||||||
|
|
||||||
|
## Calibrating a batch of new cases on the dispatchers
|
||||||
|
|
||||||
|
One case costs 10–20 minutes per iteration on a laptop, and docker lanes have
|
||||||
|
crashed it. A batch calibrates on the LangTracer dispatchers:
|
||||||
|
|
||||||
|
1. Author the JSON locally. Static checks only: a `--dry-run` push (schema) and
|
||||||
|
the similarity check against the verification suites.
|
||||||
|
2. Push the batch to its suite with the tag `calibration-pending`.
|
||||||
|
3. Dispatch one manual sweep for those case ids:
|
||||||
|
`gh workflow run eval-run.yml --repo n8n-io/lang-tracer -f image=n8nio/n8n:nightly -f case_ids=<ids> -f iterations=3 -f trigger=manual`.
|
||||||
|
`trigger=manual` keeps it out of the nightly baseline.
|
||||||
|
4. Read `list_eval_runs` → `get_eval_run`. Classify each red: **builder** (keep
|
||||||
|
it red, tag `capability-gap-finding`, propose a ticket), **harness** (fix it or
|
||||||
|
design around it; never leave it to charge the model), **authoring** (fix the
|
||||||
|
case).
|
||||||
|
5. Re-push the fixed cases and re-dispatch only those ids.
|
||||||
|
6. Swap `calibration-pending` for `calibrated` and stamp the evidence into the
|
||||||
|
description, e.g. "Calibration (Opus 4.8, N=3): sweep 174 3/4, sweep 176 4/4".
|
||||||
|
|
||||||
|
## Validating a harness change before merge
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gh workflow run test-evals-instance-ai.yml --repo n8n-io/n8n \
|
||||||
|
-f branch=<your-branch> -f cache-sha=$(git rev-parse origin/<your-branch>) \
|
||||||
|
-f suite=<suite> -f filter=<slug-substrings> \
|
||||||
|
-f iterations=3 -f experiment-name=<change-name>
|
||||||
|
```
|
||||||
|
|
||||||
|
`cache-sha` is what puts a backend change under test: without it the workflow
|
||||||
|
restores the n8n image cached for master's head, and only the eval CLI runs from
|
||||||
|
your branch. A harness-only change works either way.
|
||||||
|
|
||||||
|
CI reads cases from LangTracer only. A case that exists only on disk goes into a
|
||||||
|
throwaway suite first (`eval:langtracer-push --suite <scratch-suite> <slug>`).
|
||||||
|
Never pass `experiment-name=instance-ai-baseline`; that name refreshes the shared
|
||||||
|
baseline. Results: the run log's summary table and the
|
||||||
|
`instance-ai-workflow-eval-results` artifact (`eval-results.json` + HTML report).
|
||||||
|
|
||||||
## Baselines & regression
|
## Baselines & regression
|
||||||
|
|
||||||
When `LANGSMITH_API_KEY` is set, every run auto-compares against the most recent
|
When `LANGSMITH_API_KEY` is set, every run auto-compares against the most recent
|
||||||
|
|
|
||||||
|
|
@ -47,9 +47,20 @@ ids. Full table in
|
||||||
1. **Scan cluster themes** — `list_cluster_runs` / `get_latest_cluster_run`
|
1. **Scan cluster themes** — `list_cluster_runs` / `get_latest_cluster_run`
|
||||||
return capability-gap themes with a `label`, `summary`, and `mechanism`: the
|
return capability-gap themes with a `label`, `summary`, and `mechanism`: the
|
||||||
real, recurring failure modes.
|
real, recurring failure modes.
|
||||||
2. **Pull the conversations** — `list_conversations` (e.g. `verdict:"bad"`,
|
2. **Pull the conversations.** Start from what LangTracer already detected.
|
||||||
`analyzed:"yes"`) → `get_conversation` (raw trace) + `get_conversation_analysis`
|
`list_signal_rates` lists every code check and label rule with its rate over a
|
||||||
(findings). `get_linear_ticket_context` links a thread to its ticket. To target
|
window: what fails most right now. Then `list_conversations` with `signals`,
|
||||||
|
one token per signal, ANDed — `check:has_nodes`,
|
||||||
|
`label:task_outcome=agent-gave-up`, `analysis:bad`, `keeper:*`, `review:*` — puts
|
||||||
|
the matching entries on each row, each with its evidence (the verdict comment,
|
||||||
|
the judge's words, Keeper's reason) and its version. On a candidate,
|
||||||
|
`get_conversation_signals` shows what every source said and which ones agreed:
|
||||||
|
a thread flagged by a code check, the judge and Keeper is a stronger source
|
||||||
|
than one flagged by a single model pass. A source marked `not_run` never looked
|
||||||
|
at the thread, so read its silence as unknown, not as clean. The older path
|
||||||
|
still works: `list_conversations` (e.g. `verdict:"bad"`, `analyzed:"yes"`) →
|
||||||
|
`get_conversation` (raw trace) + `get_conversation_analysis` (findings).
|
||||||
|
`get_linear_ticket_context` links a thread to its ticket. To target
|
||||||
**execution failures**, add `funnelDrop:"05"` (built + launched an execution
|
**execution failures**, add `funnelDrop:"05"` (built + launched an execution
|
||||||
that never *succeeded*). **Caveat: the funnel drop is a *conversion* signal, not
|
that never *succeeded*). **Caveat: the funnel drop is a *conversion* signal, not
|
||||||
a build-quality one** — most `funnelDrop:"05"` threads are healthy builds that
|
a build-quality one** — most `funnelDrop:"05"` threads are healthy builds that
|
||||||
|
|
@ -61,7 +72,8 @@ ids. Full table in
|
||||||
(was `build-workflow` called on this turn?), which it reads reliably — but a
|
(was `build-workflow` called on this turn?), which it reads reliably — but a
|
||||||
content-dependent claim ("invented ID", "missing node") can be wrong when it
|
content-dependent claim ("invented ID", "missing node") can be wrong when it
|
||||||
couldn't see the built workflow. Confirm against the raw trace before building
|
couldn't see the built workflow. Confirm against the raw trace before building
|
||||||
a case around it.
|
a case around it. The same goes for a signal's `evidence`: it is what that
|
||||||
|
source saw, not the trace.
|
||||||
4. **Optionally record the selection as an observation.** Offer to save why this
|
4. **Optionally record the selection as an observation.** Offer to save why this
|
||||||
thread was selected and what the developer observed. This is a best-effort
|
thread was selected and what the developer observed. This is a best-effort
|
||||||
LangTracer note, not a gate: skip it when the developer declines or has not
|
LangTracer note, not a gate: skip it when the developer declines or has not
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ Creates GitHub PRs with titles that pass n8n's `check-pr-title` CI validation.
|
||||||
- `benchmark` - Benchmark CLI changes
|
- `benchmark` - Benchmark CLI changes
|
||||||
- `core` - Core/backend/private API
|
- `core` - Core/backend/private API
|
||||||
- `editor` - Editor UI changes
|
- `editor` - Editor UI changes
|
||||||
- `engine` - New workflow execution engine v2 (@n8n/engine package)
|
- `engine` - New workflow execution engine v2 (@n8n/engine package & engine-v2 module in cli)
|
||||||
- `* Node` - Specific node (e.g., `Slack Node`, `GitHub Node`)
|
- `* Node` - Specific node (e.g., `Slack Node`, `GitHub Node`)
|
||||||
|
|
||||||
### Summary Rules
|
### Summary Rules
|
||||||
|
|
@ -43,7 +43,7 @@ Creates GitHub PRs with titles that pass n8n's `check-pr-title` CI validation.
|
||||||
- Capitalize first letter
|
- Capitalize first letter
|
||||||
- No period at the end
|
- No period at the end
|
||||||
- No ticket IDs (e.g., N8N-1234)
|
- No ticket IDs (e.g., N8N-1234)
|
||||||
- Add `(no-changelog)` suffix to exclude from changelog
|
- Add `(no-changelog)` suffix to exclude from changelog, unless the type already excludes it
|
||||||
|
|
||||||
## Steps
|
## Steps
|
||||||
|
|
||||||
|
|
@ -78,11 +78,24 @@ Creates GitHub PRs with titles that pass n8n's `check-pr-title` CI validation.
|
||||||
body structure, then populate each section with actual content before
|
body structure, then populate each section with actual content before
|
||||||
creating the PR:
|
creating the PR:
|
||||||
- **Summary**: describe what the PR does
|
- **Summary**: describe what the PR does
|
||||||
|
- **Why**: explain why the change is needed (see Why below)
|
||||||
- **How to test**: describe how to test the changes. Include an example workflow if appropriate
|
- **How to test**: describe how to test the changes. Include an example workflow if appropriate
|
||||||
- **Related tickets**: add the Linear ticket URL (`https://linear.app/n8n/issue/[TICKET-ID]`) and any GitHub issue links
|
- **Related tickets**: add the Linear ticket URL (`https://linear.app/n8n/issue/[TICKET-ID]`) and any GitHub issue links
|
||||||
- **Checklist**: keep as-is from the template
|
- **Checklist**: keep as-is from the template
|
||||||
- Add a "🤖 PR Summary generated by AI" at the end of the body
|
- Add a "🤖 PR Summary generated by AI" at the end of the body
|
||||||
|
|
||||||
|
Before you create the PR, proofread the body with these checks. Rewrite
|
||||||
|
each sentence that fails a check:
|
||||||
|
1. The Summary and the Why together are under the word budget. If not,
|
||||||
|
first remove the details that the reviewer reads in the diff, then
|
||||||
|
shorten the Why.
|
||||||
|
2. Each sentence names who does what: a component, a process, or a person
|
||||||
|
as the subject, with a plain technical verb.
|
||||||
|
3. Each domain term has a plain definition where it first appears.
|
||||||
|
4. Each sentence is true for the diff.
|
||||||
|
|
||||||
|
When a later commit changes a fact in the body, update the body.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
gh pr create --draft --title "<type>(<scope>): <summary>" --body "$(cat <<'EOF'
|
gh pr create --draft --title "<type>(<scope>): <summary>" --body "$(cat <<'EOF'
|
||||||
<populated body based on pull_request_template.md>
|
<populated body based on pull_request_template.md>
|
||||||
|
|
@ -94,12 +107,43 @@ Creates GitHub PRs with titles that pass n8n's `check-pr-title` CI validation.
|
||||||
|
|
||||||
Based on `.github/pull_request_template.md`:
|
Based on `.github/pull_request_template.md`:
|
||||||
|
|
||||||
Write the PR body in ASD-STE100 Simplified Technical English: use short
|
Write for a reviewer who does not know this area of the code. The reviewer
|
||||||
sentences, the active voice, and one instruction for each sentence.
|
must understand the change before they open the diff. Use ASD-STE100
|
||||||
|
Simplified Technical English: short sentences, active voice.
|
||||||
|
1. Keep the Summary and the Why together under about 200 words. A small PR
|
||||||
|
needs much less.
|
||||||
|
2. Define each domain term in plain words where it first appears. Do not use
|
||||||
|
a ticket ID or an internal name as an explanation.
|
||||||
|
3. Leave out details that the reviewer reads in the diff: error cases, status
|
||||||
|
codes, renamed fields, the order of calls, file lists, test names. Mention
|
||||||
|
one only when it changes behaviour that a reviewer could miss. Also leave
|
||||||
|
out what reviewers already know (framework behaviour).
|
||||||
|
4. Use plain technical verbs, not metaphors ("passes the value", not
|
||||||
|
"hands the value" or "lets the value through").
|
||||||
|
|
||||||
### Summary Section
|
### Summary: what the PR does
|
||||||
- Describe what the PR does
|
1. Start with one to three sentences of background for a reader who is new to
|
||||||
- Include screenshots/videos for UI changes
|
the area. Then state what the PR does.
|
||||||
|
2. Write **prose** about behaviour, not a list of code edits. Name a class or
|
||||||
|
a function when it represents an essential/core concept and it helps
|
||||||
|
the reader to navigate the change.
|
||||||
|
3. If the PR changes no product code, say so in the first sentence
|
||||||
|
(for example: "This PR only adds tests.").
|
||||||
|
4. Do not justify the change here. The justification goes in the Why.
|
||||||
|
5. Include screenshots or videos for UI changes.
|
||||||
|
|
||||||
|
### Why: the reasoning
|
||||||
|
Always fill the `## Why` section. The Summary and the core of the
|
||||||
|
Why are what the reviewer needs most. Keep the core short:
|
||||||
|
1. Do not repeat the Summary.
|
||||||
|
2. State the problem or the constraint that makes the pre-existing code unfit.
|
||||||
|
3. If there are accepted gaps, or changes for existing code paths, state them
|
||||||
|
in a separate short paragraph.
|
||||||
|
|
||||||
|
Add rejected alternatives only when a reviewer would otherwise ask about them,
|
||||||
|
if any. Use one or two sentences for each alternative: state the concrete
|
||||||
|
reason it fails. Write an alternative that was never built in the conditional.
|
||||||
|
Together, the alternatives must be shorter than the core Why section.
|
||||||
|
|
||||||
### How to test Section
|
### How to test Section
|
||||||
- Explain how to test the changes
|
- Explain how to test the changes
|
||||||
|
|
@ -147,7 +191,7 @@ feat(API)!: Remove deprecated v1 endpoints
|
||||||
|
|
||||||
### No changelog entry
|
### No changelog entry
|
||||||
```
|
```
|
||||||
refactor(core): Simplify error handling (no-changelog)
|
feat(core): Experimental feature not enabled yet (no-changelog)
|
||||||
```
|
```
|
||||||
|
|
||||||
### No scope (affects multiple areas)
|
### No scope (affects multiple areas)
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,7 @@ Run through this before requesting review. Each item is a real, recurring review
|
||||||
- [ ] Identifiers go through **`escape.tableName(...)` / `escape.columnName(...)`**. Never hand-write `n8n_table` prefixes. — [Always escape identifiers](#always-escape-identifiers)
|
- [ ] Identifiers go through **`escape.tableName(...)` / `escape.columnName(...)`**. Never hand-write `n8n_table` prefixes. — [Always escape identifiers](#always-escape-identifiers)
|
||||||
- [ ] **Match column type to value semantics.** Native `uuid` for UUIDs, `timestampTimezone()` for timestamps, a numeric type for numbers, `bool` for booleans, `json` for structured data. Never `varchar` as a catch-all. — [Column types](#column-types)
|
- [ ] **Match column type to value semantics.** Native `uuid` for UUIDs, `timestampTimezone()` for timestamps, a numeric type for numbers, `bool` for booleans, `json` for structured data. Never `varchar` as a catch-all. — [Column types](#column-types)
|
||||||
- [ ] **Pick the narrowest sane type within that category:** `int`/`smallint` not `bigint` when range allows; `text` not `varchar(255)` for unbounded strings; never `double` for version numbers. — [Column types](#column-types)
|
- [ ] **Pick the narrowest sane type within that category:** `int`/`smallint` not `bigint` when range allows; `text` not `varchar(255)` for unbounded strings; never `double` for version numbers. — [Column types](#column-types)
|
||||||
|
- [ ] **A generated `id` key is an integer** (`int.primary.autoGenerate2`). A UUID key is a UUIDv7. For a UUID or string key, write the reason in the PR description. A join table uses its foreign-key columns as the key, not an `id` column. — [Primary Keys](#primary-keys)
|
||||||
- [ ] **Default `notNull`**, relax only when justified. PK is implicitly NOT NULL. Migration's `notNull` matches the entity's nullability. — [NOT NULL and entity parity](#not-null-and-entity-parity)
|
- [ ] **Default `notNull`**, relax only when justified. PK is implicitly NOT NULL. Migration's `notNull` matches the entity's nullability. — [NOT NULL and entity parity](#not-null-and-entity-parity)
|
||||||
- [ ] **Enum-like columns** carry `.withEnumCheck([...])` AND `.comment('explains values')`. Opaque IDs / unix timestamps / JSON shapes also get `.comment()`. — [Constrain enum-like strings](#constrain-enum-like-strings), [Add comments on columns](#add-comments-on-columns)
|
- [ ] **Enum-like columns** carry `.withEnumCheck([...])` AND `.comment('explains values')`. Opaque IDs / unix timestamps / JSON shapes also get `.comment()`. — [Constrain enum-like strings](#constrain-enum-like-strings), [Add comments on columns](#add-comments-on-columns)
|
||||||
- [ ] **Every reference column has an explicit FK** with deliberate `onDelete`. Name FKs explicitly when SQLite recreate cycles risk duplicating them. Avoid polymorphic `(typeCol, idCol)` patterns. — [Foreign Key Constraints](#foreign-key-constraints), [General Design Guidance](#general-design-guidance)
|
- [ ] **Every reference column has an explicit FK** with deliberate `onDelete`. Name FKs explicitly when SQLite recreate cycles risk duplicating them. Avoid polymorphic `(typeCol, idCol)` patterns. — [Foreign Key Constraints](#foreign-key-constraints), [General Design Guidance](#general-design-guidance)
|
||||||
|
|
@ -439,11 +440,32 @@ Don't set the default of a `status` column to a terminal value — `"running"` m
|
||||||
|
|
||||||
### Primary Keys
|
### Primary Keys
|
||||||
|
|
||||||
Every table needs a primary key. Choose the type in this order:
|
Every table needs a primary key. **Use an integer key by default.** Read the options below in order. Use the first option that fits. Do not skip an option only because you like a later one better.
|
||||||
|
|
||||||
1. **Integer** — `column('id').int.primary.autoGenerate2`. Preferred for new tables: compact, fast joins, no ordering surprises. `autoGenerate2` uses Postgres `IDENTITY` (preferred over the deprecated `serial`-based `autoGenerate`).
|
1. **Integer (default)** — `column('id').int.primary.autoGenerate2`. Use this for every new table. Use a different option only when one of its conditions is true. Integer keys are small and make joins fast. `autoGenerate2` uses Postgres `IDENTITY`. Do not use `autoGenerate`. It uses `serial`, which is deprecated.
|
||||||
2. **UUID** — `column('id').uuid.primary`. Use when IDs are generated client-side, exposed in URLs, or need to be unguessable. Generate UUIDs in application code via `randomUUID()` from `node:crypto`; do **not** chain `.autoGenerate2` on `.uuid` (the DSL throws — `DEFAULT uuid_generate_v4()` fails on managed Postgres like Supabase because it needs the `uuid-ossp` extension in `public`). Use `.uuid` instead of `.varchar(36)`.
|
2. **UUID** — `column('id').uuid.primary`. Use this only when one of these conditions is true:
|
||||||
3. **String** — `column('id').varchar(36).primary` for IDs whose format isn't a UUID (e.g. nanoid-style IDs). Convention: nanoid length 16 for entity IDs.
|
- The application must know the ID before the insert. For example, the client makes the ID.
|
||||||
|
- The ID is in a URL or a public API. Users must not see how many rows exist or guess other IDs.
|
||||||
|
|
||||||
|
**You must use UUIDv7.** Make the ID in application code with the `uuid` package: `import { v7 as uuidv7 } from 'uuid'`.
|
||||||
|
|
||||||
|
Why: a UUIDv7 starts with a timestamp. Thus, new rows go near the end of the index. A UUIDv4 is random, for example `randomUUID()` from `node:crypto`. New rows go to random positions in the index. This splits index pages and makes the index larger.
|
||||||
|
|
||||||
|
A UUIDv7 does not give an exact creation order. Two IDs made in the same millisecond can sort in the wrong order. Clock differences between hosts can also cause this. If you need the exact order, sort by a timestamp column.
|
||||||
|
|
||||||
|
Do **not** chain `.autoGenerate2` on `.uuid`. The DSL throws an error. The database default makes a UUIDv4. Also, `uuid_generate_v4()` needs the `uuid-ossp` extension. Some managed Postgres services, such as Supabase, do not have it. Use `.uuid`, not `.varchar(36)`.
|
||||||
|
3. **String** — `column('id').varchar(36).primary`. Use this only when one of these conditions is true:
|
||||||
|
- The entity already uses IDs in a format that is not a UUID. For example, the public API or the UI already shows nanoid IDs for this entity.
|
||||||
|
- The UI shows the ID to users. A UUID is too long to read, copy, or type.
|
||||||
|
|
||||||
|
Entity IDs are usually nanoids with 16 characters.
|
||||||
|
|
||||||
|
**Give a reason for every key that is not an integer.** Write the reason in the PR description. Reviewers will ask for it. These reasons are not sufficient:
|
||||||
|
- "Other tables use string IDs."
|
||||||
|
- "This module already does it this way."
|
||||||
|
- "We might need it later."
|
||||||
|
|
||||||
|
**These options apply to a generated `id` column only.** A join table that only links other tables does not need an `id` column. Use its foreign-key columns as a composite key (see below). A foreign-key column always has the same type as the key that it refers to.
|
||||||
|
|
||||||
**Keep ID-column types consistent across related tables.** Mixing `uuid` and `varchar(36)` for what is "the same kind of ID" creates JOIN footguns.
|
**Keep ID-column types consistent across related tables.** Mixing `uuid` and `varchar(36)` for what is "the same kind of ID" creates JOIN footguns.
|
||||||
|
|
||||||
|
|
@ -696,7 +718,7 @@ If only Postgres needs the change, just put the file in `postgresdb/`; don't wri
|
||||||
|
|
||||||
### Postgres-version-aware UUID generation
|
### Postgres-version-aware UUID generation
|
||||||
|
|
||||||
`gen_random_uuid()` requires Postgres ≥ 13. n8n dropped Postgres 12 — prefer it over `uuid_generate_v4()` (which needs the `uuid-ossp` extension and breaks on managed services like Supabase). For UUID PKs, generate at the application level with `randomUUID()` — see [Primary Keys](#primary-keys).
|
`gen_random_uuid()` requires Postgres ≥ 13. n8n dropped Postgres 12 — prefer it over `uuid_generate_v4()` (which needs the `uuid-ossp` extension and breaks on managed services like Supabase). For UUID PKs, make a UUIDv7 in application code — see [Primary Keys](#primary-keys).
|
||||||
|
|
||||||
### SQLite doesn't enforce `varchar(N)` length
|
### SQLite doesn't enforce `varchar(N)` length
|
||||||
|
|
||||||
|
|
|
||||||
158
.agents/skills/env-var-docs/SKILL.md
Normal file
158
.agents/skills/env-var-docs/SKILL.md
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
---
|
||||||
|
name: n8n:env-var-docs
|
||||||
|
description: >-
|
||||||
|
Writes and reviews the JSDoc on `@Env(...)` config properties. The docs team
|
||||||
|
extracts these comments from source into the public environment variable
|
||||||
|
reference, so they are user documentation, not code comments. Use when adding
|
||||||
|
or changing an `@Env` property, a config class, or reviewing a PR that touches
|
||||||
|
one.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Environment variable docs
|
||||||
|
|
||||||
|
The JSDoc above every `@Env('...')` property is exported almost verbatim into
|
||||||
|
the [environment variables reference](https://docs.n8n.io/deploy/host-n8n/configure-n8n/basic-configuration/use-environment-variables)
|
||||||
|
on docs.n8n.io. The generated table has four columns: variable name,
|
||||||
|
description, data type, default. Your JSDoc is the description column. The
|
||||||
|
reader is a self-hosting operator, not an n8n developer.
|
||||||
|
|
||||||
|
Follow the [n8n docs style guide](https://docs.n8n.io/contribute/contribution-guide-for-n8n-docs/style-guide-for-n8n-docs)
|
||||||
|
for these comments. It overrides the ASD-STE100 rule in `AGENTS.md` for this
|
||||||
|
one case. The rules below are the parts of that guide that matter for a
|
||||||
|
one-paragraph description.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- **Every `@Env` property has a JSDoc.** No exceptions. A missing comment
|
||||||
|
ships as an empty description cell.
|
||||||
|
- **Write for the operator.** Say what the setting does, when to change it,
|
||||||
|
and what happens when it's unset or wrong. Don't explain why the code is
|
||||||
|
written the way it is.
|
||||||
|
- **No code references.** No file names, class names, function names,
|
||||||
|
constants, license keys, or other internals the reader can't open. Values
|
||||||
|
the operator types into the variable are fine and often needed, for example
|
||||||
|
a credential type ID like `googleOAuth2Api` or a mode like `postgresdb`.
|
||||||
|
The test: would the operator paste this into a config file, or would they
|
||||||
|
need the source to know what it means? Details for developers go in a
|
||||||
|
regular `//` comment on the implementation, not in the JSDoc.
|
||||||
|
- **No attack scenarios.** Say what the setting protects, not how an
|
||||||
|
attacker would exploit it. This is a public page.
|
||||||
|
- **Don't repeat the default.** The default renders in its own column. Don't
|
||||||
|
write "Defaults to true" or "Default: 1 hour". Do say what the unset state
|
||||||
|
means when it's not obvious, for example "When unset, n8n uses the Redis
|
||||||
|
host".
|
||||||
|
- **State units and formats.** Say "in milliseconds", "in bytes", "a
|
||||||
|
comma-separated list", "a JSON object". The data type column only says
|
||||||
|
`Number` or `String`.
|
||||||
|
- **Use allowed values when the set is small.** "One of `sqlite`,
|
||||||
|
`postgresdb`." For a list, show one inline example in the sentence.
|
||||||
|
- **No JSDoc tags.** No `@example`, `@default`, `@see`, `@deprecated`. The
|
||||||
|
exporter copies the tag name into the docs. Put the example or the
|
||||||
|
deprecation notice in a sentence instead.
|
||||||
|
- **Product names:** "Gateway credits", not "n8n Connect" or "AI Gateway".
|
||||||
|
"Publish a workflow", not "activate". `n8n` is always lowercase.
|
||||||
|
- **Style:** present tense, active voice, second person. Use contractions.
|
||||||
|
No em dashes, use a comma or a new sentence. No "e.g." or "i.e.", write
|
||||||
|
"for example" and "that is". Spell out zero to nine, numerals from 10.
|
||||||
|
Values the operator types stay numerals, for example "Set to 0".
|
||||||
|
Sentence case. One idea per sentence. End every sentence with a period.
|
||||||
|
- **Plain words:** "use" not "utilize", "lets you" not "allows you to", cut
|
||||||
|
"powerful", "seamless", "simply", "it's important to note".
|
||||||
|
- **Length:** one to three sentences. If the setting needs more, the extra
|
||||||
|
belongs in a docs page the comment can't link to yet; keep the comment to
|
||||||
|
what fits a table cell.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
Bad, as found in the generated docs:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
/**
|
||||||
|
* Instance-level enablement for n8n Connect (AI Gateway). Licensed instances
|
||||||
|
* are enabled by default; false opts out. Budget / free allowance still comes
|
||||||
|
* from the license (`quota:aiGatewayBudget`).
|
||||||
|
*/
|
||||||
|
```
|
||||||
|
|
||||||
|
Wrong product name, a restated default, and an internal license key. Good:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
/**
|
||||||
|
* Whether Gateway credits are available on this instance. Set to false to
|
||||||
|
* turn them off on a licensed instance. The credit allowance comes from your
|
||||||
|
* license.
|
||||||
|
*/
|
||||||
|
```
|
||||||
|
|
||||||
|
Bad:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
/**
|
||||||
|
* Time to live in milliseconds for Redis cache entries. Set to 0 to disable
|
||||||
|
* expiry. Default: 1 hour.
|
||||||
|
*/
|
||||||
|
```
|
||||||
|
|
||||||
|
The default is already in the table. Good:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
/**
|
||||||
|
* Time to live in milliseconds for Redis cache entries. Set to 0 to keep
|
||||||
|
* entries until Redis evicts them.
|
||||||
|
*/
|
||||||
|
```
|
||||||
|
|
||||||
|
Bad:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
/**
|
||||||
|
* Comma-separated list of credential types for which overwrites are skipped
|
||||||
|
* when the credential has been customized.
|
||||||
|
* @example `N8N_SKIP_CREDENTIAL_OVERWRITE=zohoOAuth2Api,salesforceOAuth2Api`
|
||||||
|
*/
|
||||||
|
```
|
||||||
|
|
||||||
|
The `@example` tag lands in the description as literal text. Good:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
/**
|
||||||
|
* Comma-separated list of credential types to skip when applying credential
|
||||||
|
* overwrites, for example `zohoOAuth2Api,salesforceOAuth2Api`. n8n skips an
|
||||||
|
* overwrite only when a user has already changed one of its fields.
|
||||||
|
*/
|
||||||
|
```
|
||||||
|
|
||||||
|
Bad:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
/**
|
||||||
|
* Bind OAuth credential callbacks to the browser that initiated the flow.
|
||||||
|
* When enabled, a session-scoped `n8n-oauth-binding` cookie is set on flow
|
||||||
|
* initiation and verified on callback. Defeats phishing attacks where an
|
||||||
|
* attacker injects an OAuth authorize URL into a victim's browser to land
|
||||||
|
* tokens on the attacker's credential.
|
||||||
|
*/
|
||||||
|
```
|
||||||
|
|
||||||
|
Attack narrative and an internal cookie name on a public page. Good:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
/**
|
||||||
|
* Whether an OAuth credential connection must finish in the same browser
|
||||||
|
* that started it. When enabled, n8n rejects the callback if it arrives
|
||||||
|
* from a different browser.
|
||||||
|
*/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Review checklist
|
||||||
|
|
||||||
|
When you review a PR that adds or changes an `@Env` property, check each
|
||||||
|
comment against this list and quote the fix in the review:
|
||||||
|
|
||||||
|
1. JSDoc present.
|
||||||
|
2. No code identifiers, file names, or internal keys.
|
||||||
|
3. No default restated.
|
||||||
|
4. Units, format, or allowed values stated.
|
||||||
|
5. No JSDoc tags.
|
||||||
|
6. No em dashes, Latin abbreviations, or filler words.
|
||||||
|
7. Reads correctly as a sentence in a table cell with no surrounding context.
|
||||||
|
|
@ -13,11 +13,11 @@ the same commands from the repo via `pnpm nathan`.
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- **No tunnel setup needed.** Nathan replies asynchronously through a short-lived
|
- **No tunnel setup needed.** Nathan replies asynchronously through a short-lived
|
||||||
public tunnel that the script opens for you (`npx localtunnel`). A `deploy` to a
|
Cloudflare Quick Tunnel that the script opens for you (`npx cloudflared`). A
|
||||||
**new** `test-<name>` also polls that instance URL directly, so it still reports
|
`deploy` to a **new** `test-<name>` also polls that instance URL directly, so it
|
||||||
success even if the tunnel drops. (Redeploying a name that's *already up* skips
|
still reports success even if the tunnel drops. (Redeploying a name that's
|
||||||
the poll — it can't tell the old instance from the new — so it falls back to the
|
*already up* skips the poll — it can't tell the old instance from the new — so
|
||||||
tunnel; prefer a fresh name when you need the reliable report.)
|
it falls back to the tunnel; prefer a fresh name when you need the reliable report.)
|
||||||
- **A token in `~/.n8n/dev/nathan-token`.** If a command reports no token, **ask the
|
- **A token in `~/.n8n/dev/nathan-token`.** If a command reports no token, **ask the
|
||||||
user for one** — point them at the form
|
user for one** — point them at the form
|
||||||
(`https://internal.users.n8n.cloud/form/d6d34a2f-4899-4ee8-afc8-f8c41a8a243d`),
|
(`https://internal.users.n8n.cloud/form/d6d34a2f-4899-4ee8-afc8-f8c41a8a243d`),
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,7 @@ check the code, not license to drop a team default.
|
||||||
## Non-negotiable rules
|
## Non-negotiable rules
|
||||||
|
|
||||||
- New endpoints are `@PublicApiController` classes under `v1/controllers/`, one
|
- New endpoints are `@PublicApiController` classes under `v1/controllers/`, one
|
||||||
`*.public.controller.ts` per feature. A controller is a class — never
|
`*.public.controller.ts` per feature.
|
||||||
`export =` (the legacy tuple style; `require-public-api-controller` flags it).
|
|
||||||
- Public API and internal REST are separate HTTP surfaces. A public controller
|
- Public API and internal REST are separate HTTP surfaces. A public controller
|
||||||
never calls an internal controller/endpoint; both reuse the same service.
|
never calls an internal controller/endpoint; both reuse the same service.
|
||||||
- Controllers and handlers delegate to a service — never import a repository or
|
- Controllers and handlers delegate to a service — never import a repository or
|
||||||
|
|
@ -31,7 +30,6 @@ check the code, not license to drop a team default.
|
||||||
`@ApiResponse(Dto)`.
|
`@ApiResponse(Dto)`.
|
||||||
- Register each controller via a side-effect import in `v1/controllers/index.ts`
|
- Register each controller via a side-effect import in `v1/controllers/index.ts`
|
||||||
(`public-api-controllers.test.ts` fails otherwise).
|
(`public-api-controllers.test.ts` fails otherwise).
|
||||||
- Don't add business logic to legacy `express-openapi-validator` (EOV) handlers.
|
|
||||||
- Migrating a legacy endpoint must not change its public contract.
|
- Migrating a legacy endpoint must not change its public contract.
|
||||||
|
|
||||||
These are `n8n-local-rules` ESLint rules (see `packages/cli/eslint.config.mjs`)
|
These are `n8n-local-rules` ESLint rules (see `packages/cli/eslint.config.mjs`)
|
||||||
|
|
@ -41,6 +39,7 @@ add to it.
|
||||||
|
|
||||||
## Team defaults
|
## Team defaults
|
||||||
|
|
||||||
|
- Write code that acts as its own documentation. The schema, the decorator, and the test should make the rule clear on their own without a comment.
|
||||||
- List endpoints: cursor-based pagination (internal API uses both cursor- and
|
- List endpoints: cursor-based pagination (internal API uses both cursor- and
|
||||||
page-based — don't copy an internal endpoint's model).
|
page-based — don't copy an internal endpoint's model).
|
||||||
- Pagination args are always `offset` and `limit` — on service methods, handler
|
- Pagination args are always `offset` and `limit` — on service methods, handler
|
||||||
|
|
@ -108,10 +107,10 @@ model; reuse only what applies. Decorators, all from `@n8n/decorators`:
|
||||||
| `@Get/@Post/@Put/@Patch/@Delete('/path')` | Route method. |
|
| `@Get/@Post/@Put/@Patch/@Delete('/path')` | Route method. |
|
||||||
| `@ApiKeyScope('res:action')` | API-key grant check. |
|
| `@ApiKeyScope('res:action')` | API-key grant check. |
|
||||||
| `@ProjectScope/@GlobalScope('res:action')` | User RBAC check. |
|
| `@ProjectScope/@GlobalScope('res:action')` | User RBAC check. |
|
||||||
| `@ApiResponse(status)` / `@ApiResponse(status, Dto)` | Success status + (optional) output DTO; registry `.parse()`s + strips the return value. Exactly one per route — a second `@ApiResponse` throws. `204` can't carry a DTO — throws. |
|
| `@ApiResponse(status)` / `@ApiResponse(status, Dto)` | Success status + (optional) output DTO; registry `.parse()`s + strips the return value. Exactly one per route — a second `@ApiResponse` throws. `204` can't carry a DTO or a binary body — throws. For a binary response body (returned as `{ body, headers? }`), use `@ApiResponse(status, { mediaType, description?, headers? })` — see [Binary response bodies](reference.md#binary-response-bodies). |
|
||||||
| `@ApiErrorResponse(status)` | Declares an additional documented non-2xx status (e.g. `404`, `409`). Stack multiple for more than one. `400`/`401`/`403` are added automatically (body/query present, always, and `@ApiKeyScope` present, respectively) — don't declare those yourself. |
|
| `@ApiErrorResponse(status)` | Declares an additional documented non-2xx status (e.g. `404`, `409`). Stack multiple for more than one. `400`/`401`/`403` are added automatically (body/query present, always, and `@ApiKeyScope` present, respectively) — don't declare those yourself. |
|
||||||
| `@ApiSummary(text)` / `@ApiDescription(text)` / `@ApiTags([...])` | OpenAPI summary/description/tags. `@ApiTags` sorts alphabetically regardless of the order you pass. All optional but expected on every real route. |
|
| `@ApiSummary(text)` / `@ApiDescription(text)` / `@ApiTags([...])` | OpenAPI summary/description/tags. `@ApiTags` sorts alphabetically regardless of the order you pass. All optional but expected on every real route. |
|
||||||
| `@Query` / `@Body` / `@Param('name')` | Bind + validate via a `Z.class` DTO / path param. |
|
| `@Query` / `@Body` / `@Param('name')` | Bind + validate via a `Z.class` DTO / path param. `@Body` is JSON by default; `@Body({ mediaType: 'multipart/form-data', uploadLimits })` takes a `multipart/form-data` body instead — see [Request body media types](reference.md#request-body-media-types). |
|
||||||
| `@Licensed('feat')` | Gates the route on a single `BooleanLicenseFeature`; `PublicApiControllerRegistry` runs its own license middleware (after auth/`@ApiKeyScope`/`@ProjectScope`|`@GlobalScope`, before the handler) and 403s unlicensed requests. Only takes one feature — if the gate is an any-of/all-of combination (e.g. `LicenseState.isProvisioningLicensed()`, which is `feat:saml` OR `feat:oidc`), `@Licensed` can't express that; check manually in the handler instead, same as the internal `provisioning.controller.ee.ts`/`role-mapping-rule.controller.ee.ts` do today (throwing `ForbiddenError` on failure). |
|
| `@Licensed('feat')` | Gates the route on a single `BooleanLicenseFeature`; `PublicApiControllerRegistry` runs its own license middleware (after auth/`@ApiKeyScope`/`@ProjectScope`|`@GlobalScope`, before the handler) and 403s unlicensed requests. Only takes one feature — if the gate is an any-of/all-of combination (e.g. `LicenseState.isProvisioningLicensed()`, which is `feat:saml` OR `feat:oidc`), `@Licensed` can't express that; check manually in the handler instead, same as the internal `provisioning.controller.ee.ts`/`role-mapping-rule.controller.ee.ts` do today (throwing `ForbiddenError` on failure). |
|
||||||
|
|
||||||
## Authorization (easy to get wrong)
|
## Authorization (easy to get wrong)
|
||||||
|
|
@ -199,6 +198,5 @@ existing tests.
|
||||||
- [Test-before-save endpoints](reference.md#test-before-save-endpoints)
|
- [Test-before-save endpoints](reference.md#test-before-save-endpoints)
|
||||||
- [Errors](reference.md#errors)
|
- [Errors](reference.md#errors)
|
||||||
- [Testing matrix](reference.md#testing-matrix)
|
- [Testing matrix](reference.md#testing-matrix)
|
||||||
- [Migrating legacy EOV endpoints](reference.md#migrating-legacy-eov-endpoints)
|
|
||||||
- [Verifying a migration](reference.md#verifying-a-migration)
|
- [Verifying a migration](reference.md#verifying-a-migration)
|
||||||
- [CI and merging](reference.md#ci-and-merging)
|
- [CI and merging](reference.md#ci-and-merging)
|
||||||
|
|
|
||||||
|
|
@ -103,55 +103,76 @@ scope, RBAC denial. Add whichever apply, matching the nearest existing tests:
|
||||||
unchanged. Tests alone cannot show this — see
|
unchanged. Tests alone cannot show this — see
|
||||||
[Verifying a migration](#verifying-a-migration).
|
[Verifying a migration](#verifying-a-migration).
|
||||||
|
|
||||||
## Migrating legacy EOV endpoints
|
## Request body media types
|
||||||
|
|
||||||
Legacy `express-openapi-validator` endpoints live under
|
`@Body` defaults to `application/json`; the body is already parsed by the
|
||||||
`v1/handlers/`, wired through `openapi.yml` with `x-eov-operation-*` and request
|
app-wide `bodyParser` before the registry sees it. Declaring
|
||||||
types in `packages/cli/src/public-api/types.ts`. Treat these as migration targets,
|
`@Body({ mediaType: 'multipart/form-data', uploadLimits })` instead takes a
|
||||||
not templates.
|
`multipart/form-data` body:
|
||||||
|
|
||||||
- Prefer migrating to `@PublicApiController` over extending the handler.
|
- `uploadLimits: () => MultipartUploadLimits` is a thunk, read once the route
|
||||||
- Preserve the public contract: path, method, scopes, status codes, response
|
handles its first request (not at startup) — read live config inside it
|
||||||
shape, and pagination.
|
(e.g. `Container.get(GlobalConfig)`), don't inline a literal.
|
||||||
- Move HTTP concerns into the controller and business orchestration into the
|
- The route's `@Body` DTO validates text fields merged with uploaded files,
|
||||||
shared service; keep the controller a thin HTTP boundary. If the legacy
|
not `req.body` alone. A text field and a file share the same flat object;
|
||||||
handler was itself already just a thin wrapper around an internal
|
a file field uses `publicApiUploadedFileSchema` (`@n8n/api-types`) — a
|
||||||
`@RestController` (calling its methods directly, e.g.
|
multer-file-shaped schema that documents itself as `{ type: 'string',
|
||||||
`Container.get(SomeController).createThing(req, res, payload)`), the new
|
format: 'binary' }`. Several files under the same field name become an
|
||||||
public controller can call that same internal controller directly — no need
|
array.
|
||||||
to duplicate its validation/business logic.
|
- An unknown field (one the DTO doesn't declare, on a `{ strict: true }` DTO)
|
||||||
- A route must be served by either the EOV handler or a controller, not both —
|
fails with `Unexpected form field "<name>"` — not the default "unrecognized
|
||||||
the build's `mergeDecoratorDocument` (`v1/openapi-gen/generate.ts`) throws on
|
keys" wording.
|
||||||
a path+method declared by both sides. Remove the legacy wiring (its path's
|
- Multer parsing errors map to these statuses: `413` for a size or count limit,
|
||||||
`$ref` entry in `openapi.yml`, the `x-eov-operation-*` handler, and its
|
`500` for `LIMIT_UNEXPECTED_FILE`, `400` for any other multer error, and `500`
|
||||||
`handler.ts`) only after the new controller is registered, `pnpm build`
|
for anything else (a malformed body). The `500` is not masked, because it is a
|
||||||
regenerates the spec cleanly, and tests are updated.
|
`ResponseError`. A missing boundary gives `400` with `multipart file(s) required`.
|
||||||
- Fully delete the migrated legacy files: the handler's `.ts`, its
|
- The body is parsed **after** every auth/scope/license/quota gate and
|
||||||
`spec/paths/*.yml` and `spec/schemas/*.yml`, and any now-dead request type
|
**before** controller/route middlewares — a caller those gates would reject
|
||||||
in `packages/cli/src/public-api/types.ts`. Then check
|
never has their (possibly huge) body read off the socket, and a middleware
|
||||||
`v1/shared/spec/schemas/_index.yml` and `v1/shared/spec/parameters/_index.yml`
|
that reads `req.body` sees it already parsed.
|
||||||
for entries that `$ref` one of the deleted schema/parameter files — those are
|
- `/discover` shows no request schema for a multipart route (same as a legacy
|
||||||
separate from the path's own `$ref` in `openapi.yml` and are easy to miss;
|
multipart route today) — a client can't assume a JSON schema it never gets.
|
||||||
left dangling, the next bundle fails on a broken `$ref`.
|
- The generator documents the body under the `multipart/form-data` content
|
||||||
- If the legacy handler gated on a license (`isLicensed('feat:x')` middleware),
|
key (not `application/json`) and adds `413` alongside `415` to the route's
|
||||||
`@Licensed('feat:x')` now replicates that for a controller route (see the
|
documented responses automatically.
|
||||||
decorator table in [SKILL.md](SKILL.md#declaring-a-controller)) — but only for
|
|
||||||
a single feature. If the legacy check was an any-of/all-of over several flags
|
Everything above lives in `packages/cli/src/public-api/media-types/`, one
|
||||||
(e.g. `LicenseState.isProvisioningLicensed()`), `@Licensed` can't express
|
handler per media type (`REQUEST_BODY_HANDLERS` in
|
||||||
that; replicate it manually in the controller instead, don't drop it - this
|
`media-types/request-body/index.ts`). Adding a further media type (e.g.
|
||||||
is exactly what the internal `provisioning.controller.ee.ts` and
|
`application/octet-stream`) means: add it to `RequestBodyMediaOptions` in
|
||||||
`role-mapping-rule.controller.ee.ts` already do, since neither uses
|
`@n8n/decorators`'s `controller/types.ts`, write a handler implementing
|
||||||
`@Licensed` for that reason.
|
`RequestBodyHandler` in `media-types/request-body/`, and register it in
|
||||||
- As a legacy file drops repository access / the `export =` tuple, remove its
|
`REQUEST_BODY_HANDLERS` — the registry, resolver, generator and `/discover`
|
||||||
entry from the `off` allowlists for `no-repository-in-public-api-handler` and
|
need no change, since they all read the handler, not the media type.
|
||||||
`require-public-api-controller` in `packages/cli/eslint.config.mjs` (shrink-only
|
|
||||||
— never extend them).
|
## Binary response bodies
|
||||||
- For complex legacy-only, multipart, or non-standard endpoints, study the
|
|
||||||
nearest existing handler first.
|
`@ApiResponse(status, { mediaType, description?, headers? })` declares a
|
||||||
- Keep each field in its original position when you extract a request shape shared
|
success body that the controller method returns as `{ body, headers? }`, for
|
||||||
by two routes, and destructure out the ones a route doesn't take. The generator
|
example an `application/gzip` stream. The framework treats options whose
|
||||||
emits properties in shape order, so a moved field rewrites the `*.generated.yml`
|
`mediaType` is in `BINARY_RESPONSE_MEDIA_TYPES` as a binary response.
|
||||||
of a route the PR wasn't changing.
|
`mediaType` accepts only those types.
|
||||||
|
|
||||||
|
- `body` is a `Buffer` or a `Readable`. The method returns the result and never
|
||||||
|
writes to `res`.
|
||||||
|
- `headers` holds the response headers. Every header declared in `headers` must
|
||||||
|
be in the result. A missing header fails the request with a `500`. Matching is
|
||||||
|
case-insensitive.
|
||||||
|
- The registry checks the headers, then sets the declared status, the headers,
|
||||||
|
and `Content-Type: <mediaType>`. A header in the result cannot replace the
|
||||||
|
`Content-Type`.
|
||||||
|
- A stream's first chunk is read before any header is set. So a stream that
|
||||||
|
fails before its first chunk gives the normal JSON error response, with no
|
||||||
|
binary `Content-Type`. A method that throws before it returns does the same.
|
||||||
|
- After the first chunk is sent, an error ends the response. A client that
|
||||||
|
disconnects is not an error, and the framework destroys the stream.
|
||||||
|
- A method that returns no binary body fails with a `500`.
|
||||||
|
- The generator documents the body as `{ type: string, format: binary }`
|
||||||
|
under the `mediaType` content key, with the description and headers.
|
||||||
|
The description defaults to `Operation successful.`.
|
||||||
|
|
||||||
|
The runtime part is `sendBinaryResponse` in
|
||||||
|
`packages/cli/src/public-api/media-types/binary-response.ts`.
|
||||||
|
|
||||||
## Verifying a migration
|
## Verifying a migration
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ Based on the affected area, pick the test layer and pattern:
|
||||||
| CLI / API | Vitest integration | setupTestServer + supertest | `packages/cli/test/integration/` |
|
| CLI / API | Vitest integration | setupTestServer + supertest | `packages/cli/test/integration/` |
|
||||||
| Config | Vitest unit | GlobalConfig + Container | `packages/@n8n/config/src/configs/__tests__/` |
|
| Config | Vitest unit | GlobalConfig + Container | `packages/@n8n/config/src/configs/__tests__/` |
|
||||||
| Editor UI | Vitest | Vue Test Utils + Pinia | `packages/frontend/editor-ui/src/**/__tests__/` |
|
| Editor UI | Vitest | Vue Test Utils + Pinia | `packages/frontend/editor-ui/src/**/__tests__/` |
|
||||||
| E2E / Canvas | Playwright | Test containers + composables | `packages/testing/playwright/` |
|
| E2E / Canvas | Playwright | Test containers + composables | `packages/quality/testing/playwright/` |
|
||||||
|
|
||||||
## Step 3: Locate Source Files
|
## Step 3: Locate Source Files
|
||||||
|
|
||||||
|
|
|
||||||
2
.aikido
2
.aikido
|
|
@ -8,7 +8,7 @@ exclude:
|
||||||
# Machine-generated instance-AI expectation traces. Contain ephemeral
|
# Machine-generated instance-AI expectation traces. Contain ephemeral
|
||||||
# credential reference IDs (nanoids) that re-trip the secrets scanner
|
# credential reference IDs (nanoids) that re-trip the secrets scanner
|
||||||
# on every regeneration. No secret material.
|
# on every regeneration. No secret material.
|
||||||
- packages/testing/playwright/expectations
|
- packages/quality/testing/playwright/expectations
|
||||||
|
|
||||||
# Test code repo-wide. Fixtures routinely contain synthetic PEM blocks,
|
# Test code repo-wide. Fixtures routinely contain synthetic PEM blocks,
|
||||||
# basic-auth strings, and deliberately fake keys (including the secret
|
# basic-auth strings, and deliberately fake keys (including the secret
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
{
|
{
|
||||||
"issues": 163
|
"issues": 110
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1
.claude/plugins/n8n/skills/agent-design-language
Symbolic link
1
.claude/plugins/n8n/skills/agent-design-language
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
../../../../.agents/skills/agent-design-language
|
||||||
1
.claude/plugins/n8n/skills/env-var-docs
Symbolic link
1
.claude/plugins/n8n/skills/env-var-docs
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
../../../../.agents/skills/env-var-docs
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -4,6 +4,7 @@ ARG NODE_VERSION=24
|
||||||
FROM node:${NODE_VERSION}-bookworm
|
FROM node:${NODE_VERSION}-bookworm
|
||||||
|
|
||||||
ARG PLAYWRIGHT_VERSION=1.62.1
|
ARG PLAYWRIGHT_VERSION=1.62.1
|
||||||
|
ARG OPENCODE_VERSION=1.18.25
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
tmux sudo less jq ripgrep postgresql-client \
|
tmux sudo less jq ripgrep postgresql-client \
|
||||||
|
|
@ -14,7 +15,7 @@ RUN npx -y playwright@${PLAYWRIGHT_VERSION} install-deps chromium
|
||||||
|
|
||||||
# Agent harnesses. Self-update is off: the image is the update mechanism
|
# Agent harnesses. Self-update is off: the image is the update mechanism
|
||||||
ENV DISABLE_AUTOUPDATER=1
|
ENV DISABLE_AUTOUPDATER=1
|
||||||
RUN npm install -g @anthropic-ai/claude-code opencode-ai
|
RUN npm install -g @anthropic-ai/claude-code opencode-ai@${OPENCODE_VERSION}
|
||||||
|
|
||||||
COPY codespaces-env.sh /usr/local/lib/codespaces-env.sh
|
COPY codespaces-env.sh /usr/local/lib/codespaces-env.sh
|
||||||
COPY codespaces-secrets.sh /etc/profile.d/codespaces-secrets.sh
|
COPY codespaces-secrets.sh /etc/profile.d/codespaces-secrets.sh
|
||||||
|
|
@ -37,4 +38,7 @@ ENV PNPM_HOME=/home/node/.local/share/pnpm
|
||||||
ENV PATH=$PNPM_HOME/bin:$PATH
|
ENV PATH=$PNPM_HOME/bin:$PATH
|
||||||
|
|
||||||
USER node
|
USER node
|
||||||
RUN mkdir -p $PNPM_HOME ~/.pnpm-store && pnpm config set store-dir ~/.pnpm-store --global
|
# The store must sit on the same filesystem as the worktrees (/workspaces is its own
|
||||||
|
# mount), or pnpm copies node_modules into every worktree instead of hardlinking.
|
||||||
|
# The directory is created on the first install: Codespaces mounts /workspaces at runtime.
|
||||||
|
RUN mkdir -p $PNPM_HOME && pnpm config set store-dir /workspaces/.pnpm-store --global
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
Run long-lived, human-steered agent sessions (Claude Code / OpenCode) on a
|
Run long-lived, human-steered agent sessions (Claude Code / OpenCode) on a
|
||||||
GitHub Codespace instead of your laptop: start a task, close the lid, steer it
|
GitHub Codespace instead of your laptop: start a task, close the lid, steer it
|
||||||
from anywhere with a terminal, resume tomorrow.
|
from anywhere, resume tomorrow.
|
||||||
|
|
||||||
This is a separate devcontainer config from the laptop one in
|
This is a separate devcontainer config from the laptop one in
|
||||||
`.devcontainer/` — it ships both agent CLIs, `tmux` for session persistence,
|
`.devcontainer/` — it ships both agent CLIs, `tmux` for session persistence,
|
||||||
|
|
@ -11,26 +11,73 @@ Playwright system deps, and Docker-in-Docker (for testcontainers and
|
||||||
|
|
||||||
## One-time setup (~5 min)
|
## One-time setup (~5 min)
|
||||||
|
|
||||||
1. Add provider keys at [github.com/settings/codespaces](https://github.com/settings/codespaces).
|
1. Give the GitHub CLI the codespace scope:
|
||||||
Add `ANTHROPIC_API_KEY` for Claude Code. Add `OPENROUTER_API_KEY` for OpenCode.
|
|
||||||
Give both secrets access to `n8n-io/n8n`.
|
|
||||||
(Alternative for Max subscriptions: `CLAUDE_CODE_OAUTH_TOKEN` from `claude setup-token`.)
|
|
||||||
2. Give the GitHub CLI the codespace scope:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
gh auth refresh -h github.com -s codespace
|
gh auth refresh -h github.com -s codespace
|
||||||
```
|
```
|
||||||
|
|
||||||
## Daily flow
|
2. Add provider keys at [github.com/settings/codespaces](https://github.com/settings/codespaces)
|
||||||
|
if your client needs them. Give each secret access to `n8n-io/n8n`.
|
||||||
|
- `ANTHROPIC_API_KEY` for Claude Code in the terminal (`pnpm session`).
|
||||||
|
Alternative for Max subscriptions: `CLAUDE_CODE_OAUTH_TOKEN` from
|
||||||
|
`claude setup-token`. The desktop app does not need either secret.
|
||||||
|
- `OPENROUTER_API_KEY` for OpenCode.
|
||||||
|
|
||||||
|
## Choose a client
|
||||||
|
|
||||||
|
All clients run the agent, its tools, and builds in the Codespace. Your laptop
|
||||||
|
shows the conversation only.
|
||||||
|
|
||||||
|
| Client | Command | Use it when |
|
||||||
|
|---|---|---|
|
||||||
|
| Claude Code desktop app | `pnpm session ssh-config` once | You want the full desktop experience. |
|
||||||
|
| Claude Code in VS Code | Open the codespace in VS Code or the browser | You already work in VS Code. |
|
||||||
|
| Claude Code in a terminal | `pnpm session` | You want a plain terminal, or you are on a remote machine. |
|
||||||
|
| OpenCode | `pnpm session:opencode` | You use OpenCode. See [Local OpenCode clients](#local-opencode-clients). |
|
||||||
|
|
||||||
|
### Claude Code desktop app
|
||||||
|
|
||||||
|
1. From a local checkout, add an SSH host for your codespace:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm session ssh-config
|
||||||
|
```
|
||||||
|
|
||||||
|
The command creates or starts the codespace. It writes the host
|
||||||
|
`n8n-codespace` to `~/.ssh/n8n-codespace.conf` and includes that file from
|
||||||
|
`~/.ssh/config`.
|
||||||
|
2. In the desktop app, add an SSH connection to the host `n8n-codespace`.
|
||||||
|
3. Open the folder `/workspaces/n8n`, or a worktree under `/workspaces`.
|
||||||
|
|
||||||
|
The desktop app installs its own Claude Code on the codespace and signs in
|
||||||
|
with your desktop account. Sessions continue when you close the app. They stop
|
||||||
|
when the codespace stops.
|
||||||
|
|
||||||
|
- **Run `pnpm session ssh-config` again after you recreate the codespace.**
|
||||||
|
- **If the connection times out**, the codespace is probably starting. Run
|
||||||
|
`pnpm session ssh-config`, then connect again.
|
||||||
|
- **Test the connection** with `ssh n8n-codespace`.
|
||||||
|
|
||||||
|
### Claude Code in VS Code
|
||||||
|
|
||||||
|
The dev container installs the Claude Code extension. Open the codespace in
|
||||||
|
VS Code or the browser, then open the Claude Code panel. Sign in from the
|
||||||
|
extension if you did not add `ANTHROPIC_API_KEY` or `CLAUDE_CODE_OAUTH_TOKEN`.
|
||||||
|
|
||||||
|
### Claude Code in a terminal
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm session # attach Claude Code (creates everything on first run)
|
pnpm session # attach Claude Code (creates everything on first run)
|
||||||
pnpm session:shell # open a shell in the default checkout
|
pnpm session:shell # open a shell in the default checkout
|
||||||
pnpm session:opencode # attach OpenCode in auto mode
|
pnpm session:opencode # connect the local OpenCode TUI
|
||||||
pnpm session:opencode fix-flaky # OpenCode in a separate worktree
|
pnpm session:opencode fix-flaky # use a separate remote worktree
|
||||||
|
pnpm session:opencode --web # open OpenCode in the local browser
|
||||||
|
pnpm session:opencode --legacy # use the remote TUI in tmux
|
||||||
pnpm session fix-flaky # Claude Code in a separate worktree
|
pnpm session fix-flaky # Claude Code in a separate worktree
|
||||||
pnpm session ls # what's running
|
pnpm session ls # what's running
|
||||||
pnpm session tunnel # forward n8n ports (default 5678, 8080); Ctrl-C to stop
|
pnpm session tunnel # forward n8n ports (default 5678, 8080); Ctrl-C to stop
|
||||||
|
pnpm session ssh-config # add the n8n-codespace SSH host for the desktop app
|
||||||
pnpm session stop # end of day: billing stops, disk survives
|
pnpm session stop # end of day: billing stops, disk survives
|
||||||
pnpm session rm # delete the codespace
|
pnpm session rm # delete the codespace
|
||||||
```
|
```
|
||||||
|
|
@ -38,15 +85,18 @@ pnpm session rm # delete the codespace
|
||||||
The dev container supports Codespaces with 2, 4, or 8 cores. The session commands
|
The dev container supports Codespaces with 2, 4, or 8 cores. The session commands
|
||||||
create an 8-core Codespace by default.
|
create an 8-core Codespace by default.
|
||||||
|
|
||||||
- **Detach** with `Ctrl-b d` — the agent keeps working without you.
|
- **Detach from tmux** with `Ctrl-b d` — the agent keeps working without you.
|
||||||
- **Scroll** with the mouse wheel (tmux mouse mode is on). To use the
|
Local OpenCode connections use the exit controls described below.
|
||||||
|
- **Scroll in tmux** with the mouse wheel (tmux mouse mode is on). To use the
|
||||||
terminal's own text selection, hold **Shift** and drag.
|
terminal's own text selection, hold **Shift** and drag.
|
||||||
- **Reattach** by running the same session command from any machine.
|
- **Reattach** by running the same session command from any machine.
|
||||||
- Each named session gets its own worktree (`/workspaces/wt-<name>`, branch
|
- Each named session gets its own worktree (`/workspaces/wt-<name>`, branch
|
||||||
`session/<name>`), so parallel agents never touch each other's tree. Builds
|
`session/<name>`), so parallel agents never touch each other's tree. A new
|
||||||
|
branch starts from the latest `origin/master`. Builds
|
||||||
in fresh worktrees are cache-hits via a shared turbo cache.
|
in fresh worktrees are cache-hits via a shared turbo cache.
|
||||||
- First codespace creation takes ~20 min uncached (image + full build). After
|
- First codespace creation takes ~20 min uncached (image + full build). After
|
||||||
that, sessions attach instantly; new worktrees cost a `pnpm install` (~1–2 min).
|
that, sessions attach instantly; new worktrees cost a `pnpm install` (~10 s: the
|
||||||
|
store lives on `/workspaces`, so `node_modules` is hardlinked, not copied).
|
||||||
|
|
||||||
## Configure the local OpenCode harness
|
## Configure the local OpenCode harness
|
||||||
|
|
||||||
|
|
@ -61,6 +111,111 @@ The command verifies the release checksum. It caches the bundle under
|
||||||
`~/.config/opencode/plugins`. Run the command again after the lock changes.
|
`~/.config/opencode/plugins`. Run the command again after the lock changes.
|
||||||
Restart OpenCode after the command completes.
|
Restart OpenCode after the command completes.
|
||||||
|
|
||||||
|
## Local OpenCode clients
|
||||||
|
|
||||||
|
Run these commands from a local checkout. The TUI runs on your laptop. The
|
||||||
|
OpenCode server, repository, tools, and builds run in the Codespace. Browser
|
||||||
|
mode opens the remote web interface through a local connection.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm session:opencode fix-flaky # resume that workspace's latest conversation
|
||||||
|
pnpm session:opencode fix-flaky --web # open it in a browser
|
||||||
|
pnpm session:opencode fix-flaky --new # start a new conversation in that worktree
|
||||||
|
pnpm session:opencode --web --port 4100 # override the default browser port
|
||||||
|
pnpm session:opencode --help
|
||||||
|
```
|
||||||
|
|
||||||
|
For the TUI, install the same OpenCode version as the remote server. The
|
||||||
|
`OPENCODE_VERSION` argument in the [Dockerfile](Dockerfile) pins the version
|
||||||
|
for new images:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install -g opencode-ai@<version>
|
||||||
|
```
|
||||||
|
|
||||||
|
Use npm, not pnpm. The package gets its binary from a `postinstall` script.
|
||||||
|
pnpm does not run that script by default.
|
||||||
|
|
||||||
|
The launcher checks both versions. It reports a mismatch with both version
|
||||||
|
numbers before it opens the TUI. An existing Codespace can have a different
|
||||||
|
version. Install that version locally, or use `--web`. Browser mode does not
|
||||||
|
need a local OpenCode install.
|
||||||
|
The launcher sends its bootstrap code over SSH, so an existing Codespace does
|
||||||
|
not need a rebuild to use this connection method.
|
||||||
|
The remote server loads the shared OpenCode harness that the Codespace
|
||||||
|
post-start command installs. The local harness setup above is not required for
|
||||||
|
these commands.
|
||||||
|
|
||||||
|
The command prepares the worktree, starts or reuses one server, opens an SSH
|
||||||
|
tunnel, and connects the client. Each workspace opens its own conversation: the
|
||||||
|
most recently updated one in that worktree. A conversation moves to the front
|
||||||
|
when it receives a message. Switching to a conversation without sending a
|
||||||
|
message does not move it. Switching to another workspace's conversation does
|
||||||
|
not change what this workspace opens next. `--new` starts a new conversation
|
||||||
|
instead. It preserves the worktree and the old conversation.
|
||||||
|
|
||||||
|
- **Exit the TUI** with `/exit` or its quit shortcut. The launcher closes its
|
||||||
|
tunnel. The remote server stays running.
|
||||||
|
- **Disconnect browser mode** with `Ctrl-C` in the launcher terminal. Closing
|
||||||
|
the browser tab does not close the tunnel. Keep the launcher running while
|
||||||
|
you use the browser.
|
||||||
|
- **Reconnect** with the same command after a network interruption. After a
|
||||||
|
Codespace stop, the command restarts the server and opens the workspace's
|
||||||
|
most recently updated conversation.
|
||||||
|
A stop terminates running tools. It does not resume interrupted commands.
|
||||||
|
- **Browser mode uses local port 4096 by default.** Use `--port` to override it.
|
||||||
|
TUI mode selects an available port unless you specify one.
|
||||||
|
A fixed browser port preserves the browser origin across runs.
|
||||||
|
An occupied fixed port causes an error. It does not stop the existing listener.
|
||||||
|
- **Use model and permission controls in the client.** The new connection uses
|
||||||
|
the remote OpenCode configuration. It does not force the legacy `--auto` mode.
|
||||||
|
For the old CLI flags, use `pnpm session:opencode fix-flaky --legacy --model <model>`.
|
||||||
|
|
||||||
|
Both server and tunnel bind to `127.0.0.1`. The server uses a generated password.
|
||||||
|
The TUI receives it through its environment. Browser mode uses a local proxy
|
||||||
|
that adds authentication and rejects requests from other browser origins. The
|
||||||
|
proxy supports streamed responses, attachments, and terminal WebSockets. The
|
||||||
|
password does not appear in the URL or terminal output. Other processes on
|
||||||
|
your laptop can access the local browser proxy while it runs. Do not forward
|
||||||
|
this proxy or the OpenCode server port to other machines.
|
||||||
|
|
||||||
|
The server enables only OpenRouter. It reads `OPENROUTER_API_KEY` when it starts.
|
||||||
|
It enables OpenCode code mode by default to reduce the initial tool context.
|
||||||
|
Browser mode opens the workspace's most recently updated conversation directly.
|
||||||
|
The web UI stores opened projects in browser storage. If a new-session page
|
||||||
|
shows **New project**, open `/workspaces/n8n` there once. Keep the same browser
|
||||||
|
port when you reconnect to preserve this selection.
|
||||||
|
|
||||||
|
The server runs in the detached tmux session `n8n-opencode-server`. Its log is
|
||||||
|
`/workspaces/.n8n-opencode/server.log`. That directory also holds the server
|
||||||
|
credentials. It is readable only by its owner.
|
||||||
|
An unhealthy server produces an error without stopping active work. Inspect
|
||||||
|
the log through `pnpm session:shell`. To restart it after checking active work,
|
||||||
|
run `tmux kill-session -t '=n8n-opencode-server'` in that shell. Then reconnect.
|
||||||
|
Restart the server after changing provider secrets, server configuration, or
|
||||||
|
the pinned harness release.
|
||||||
|
|
||||||
|
### Limits
|
||||||
|
|
||||||
|
- The launcher supports macOS and Linux. On Windows, run it in WSL. If the
|
||||||
|
browser does not open automatically, open the printed URL yourself.
|
||||||
|
- Local clipboard and attachment controls depend on the client, terminal,
|
||||||
|
file type, and model. A laptop file path does not copy a file to the Codespace.
|
||||||
|
Use a supported attachment control or copy the file with `gh codespace cp`.
|
||||||
|
- Tools and local MCP processes run on the Codespace. Laptop configuration,
|
||||||
|
browser sessions, and files do not sync automatically.
|
||||||
|
- Workspace names identify worktrees. Two agents that use the same name share
|
||||||
|
files. Separate conversations alone do not isolate edits.
|
||||||
|
- The server survives a client disconnect while the Codespace stays running.
|
||||||
|
Codespaces idle timeouts still apply. An open tunnel is not a guarantee that
|
||||||
|
the Codespace will stay awake. Use `pnpm session stop` to stop compute billing.
|
||||||
|
- Existing tmux OpenCode conversations are not migrated automatically. Use
|
||||||
|
`--legacy` to return to them.
|
||||||
|
|
||||||
|
For upstream behavior, see the [OpenCode CLI](https://opencode.ai/docs/cli/),
|
||||||
|
[server](https://opencode.ai/docs/server/), and [web](https://opencode.ai/docs/web/)
|
||||||
|
documentation.
|
||||||
|
|
||||||
## PR previews (a running instance of someone else's PR)
|
## PR previews (a running instance of someone else's PR)
|
||||||
|
|
||||||
A preview is a codespace that serves one pull request, so a reviewer can use the
|
A preview is a codespace that serves one pull request, so a reviewer can use the
|
||||||
|
|
@ -222,11 +377,15 @@ session rarely needs a cold `pnpm install` or a full `pnpm build`. Both are slow
|
||||||
Claude sessions and the headless OpenCode worker get the `flaky` MCP server automatically: Currents
|
Claude sessions and the headless OpenCode worker get the `flaky` MCP server automatically: Currents
|
||||||
flaky/quarantine data, the `qa_*` BigQuery dataset, Sentry RCA, live Linear,
|
flaky/quarantine data, the `qa_*` BigQuery dataset, Sentry RCA, live Linear,
|
||||||
and repo investigation. The worker keeps the token in its environment and puts
|
and repo investigation. The worker keeps the token in its environment and puts
|
||||||
only an environment reference in the OpenCode config. Claude login registers
|
only an environment reference in the OpenCode config. Forks have no secrets and
|
||||||
the same server without writing the token to disk. Forks have no secrets and
|
|
||||||
skip it. Tell the agent to call `get_flaky_context` first — it returns the rules
|
skip it. Tell the agent to call `get_flaky_context` first — it returns the rules
|
||||||
the tools assume.
|
the tools assume.
|
||||||
|
|
||||||
|
`post-start.mjs` registers the server for Claude Code on each container start.
|
||||||
|
Its `headersHelper` reads the token from the secrets file on each connect, so
|
||||||
|
the server works in every client. The token is not copied into the Claude Code
|
||||||
|
MCP configuration.
|
||||||
|
|
||||||
## Quality and security skills (Claude plugins)
|
## Quality and security skills (Claude plugins)
|
||||||
|
|
||||||
Claude sessions can also load the private skills from the
|
Claude sessions can also load the private skills from the
|
||||||
|
|
@ -288,7 +447,8 @@ to pull the skills into context.
|
||||||
|
|
||||||
## Viewing the dev UI locally
|
## Viewing the dev UI locally
|
||||||
|
|
||||||
Two terminal windows:
|
`pnpm session tunnel` works with every client. With the terminal client, use
|
||||||
|
two terminal windows:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm session # window 1: attach the agent session
|
pnpm session # window 1: attach the agent session
|
||||||
|
|
@ -318,7 +478,7 @@ partial mapping breaks it.
|
||||||
| Event | Running processes | Disk (checkout, worktrees, chat history) |
|
| Event | Running processes | Disk (checkout, worktrees, chat history) |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Detach / close laptop / network drop | ✅ keep running | ✅ |
|
| Detach / close laptop / network drop | ✅ keep running | ✅ |
|
||||||
| Stop, or idle timeout (default 30 min, max 4 h) | ❌ killed | ✅ |
|
| Stop, or idle timeout (2 h for a codespace that `pnpm session` creates, max 4 h) | ❌ killed | ✅ |
|
||||||
| Delete (`pnpm session rm`) | ❌ | ❌ (push your branches first) |
|
| Delete (`pnpm session rm`) | ❌ | ❌ (push your branches first) |
|
||||||
|
|
||||||
After a stop, `pnpm session <name>` restarts the codespace (~30–60 s); run
|
After a stop, `pnpm session <name>` restarts the codespace (~30–60 s); run
|
||||||
|
|
@ -338,10 +498,10 @@ After a stop, `pnpm session <name>` restarts the codespace (~30–60 s); run
|
||||||
agent injects them into VS Code sessions only; they're delivered
|
agent injects them into VS Code sessions only; they're delivered
|
||||||
base64-encoded to `/workspaces/.codespaces/shared/.env-secrets`. The image
|
base64-encoded to `/workspaces/.codespaces/shared/.env-secrets`. The image
|
||||||
sources `/usr/local/lib/codespaces-env.sh` in login shells (profile.d), in
|
sources `/usr/local/lib/codespaces-env.sh` in login shells (profile.d), in
|
||||||
interactive shells (bashrc), and in the `pnpm session` prelude. If Claude
|
interactive shells (bashrc), and in the `pnpm session` prelude. Desktop app
|
||||||
Code shows `Missing environment variables: FLAKY_MCP_TOKEN`, the shell that
|
sessions do not source it. `git`, `gh`, `scripts/codespace-env.mjs`, and the
|
||||||
started Claude did not source the file. Run
|
`flaky` MCP entry read the file when they run, so they work everywhere. New
|
||||||
`. /usr/local/lib/codespaces-env.sh` and start Claude again.
|
code that needs a secret must do the same.
|
||||||
- **Do not read `CODESPACE_NAME` or `GITHUB_USER` from the process env** — use
|
- **Do not read `CODESPACE_NAME` or `GITHUB_USER` from the process env** — use
|
||||||
`scripts/codespace-env.mjs`. Codespaces gives these variables to VS Code
|
`scripts/codespace-env.mjs`. Codespaces gives these variables to VS Code
|
||||||
sessions only. Other processes read them from `codespaces-env.sh`, and a
|
sessions only. Other processes read them from `codespaces-env.sh`, and a
|
||||||
|
|
@ -350,7 +510,7 @@ After a stop, `pnpm session <name>` restarts the codespace (~30–60 s); run
|
||||||
polled correctly as its owner while `dev:up` in the same session saw an empty
|
polled correctly as its owner while `dev:up` in the same session saw an empty
|
||||||
box name, printed the localhost URL, and did not share the port. The helper
|
box name, printed the localhost URL, and did not share the port. The helper
|
||||||
reads `/workspaces/.codespaces/shared`, which is always correct.
|
reads `/workspaces/.codespaces/shared`, which is always correct.
|
||||||
- **You cannot paste images into a remote Claude session.** Image paste reads
|
- **You cannot paste images into a terminal Claude session.** Image paste reads
|
||||||
the clipboard of the machine where `claude` runs — the codespace, not your
|
the clipboard of the machine where `claude` runs — the codespace, not your
|
||||||
laptop. Drag the file into the VS Code explorer (or
|
laptop. Drag the file into the VS Code explorer (or
|
||||||
`gh codespace cp shot.png remote:/workspaces/n8n/`) and give Claude the
|
`gh codespace cp shot.png remote:/workspaces/n8n/`) and give Claude the
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,14 @@ if (args[0] === 'codespace' && args[1] === 'list') {
|
||||||
`,
|
`,
|
||||||
);
|
);
|
||||||
chmodSync(mockGh, 0o755);
|
chmodSync(mockGh, 0o755);
|
||||||
|
for (const [name, script] of Object.entries({
|
||||||
|
infocmp: '[ "$1" = "$SUPPORTED_TERM" ]',
|
||||||
|
tmux: 'printf "%s" "$TERM"',
|
||||||
|
})) {
|
||||||
|
const executable = join(fixtureDir, name);
|
||||||
|
writeFileSync(executable, `#!/bin/sh\n${script}\n`);
|
||||||
|
chmodSync(executable, 0o755);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
after(() => rmSync(fixtureDir, { recursive: true, force: true }));
|
after(() => rmSync(fixtureDir, { recursive: true, force: true }));
|
||||||
|
|
@ -58,19 +66,59 @@ function remoteCommand(args) {
|
||||||
return invocationArgs[6];
|
return invocationArgs[6];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const { name, supportedTerm, expectedTerm } of [
|
||||||
|
{
|
||||||
|
name: 'uses xterm-256color when the remote terminal definition is missing',
|
||||||
|
supportedTerm: 'xterm-256color',
|
||||||
|
expectedTerm: 'xterm-256color',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'keeps the terminal type when the remote definition is available',
|
||||||
|
supportedTerm: 'xterm-ghostty',
|
||||||
|
expectedTerm: 'xterm-ghostty',
|
||||||
|
},
|
||||||
|
]) {
|
||||||
|
test(name, () => {
|
||||||
|
const result = spawnSync('/bin/sh', ['-c', remoteCommand(['--opencode', '--legacy'])], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
PATH: `${fixtureDir}:${process.env.PATH}`,
|
||||||
|
TERM: 'xterm-ghostty',
|
||||||
|
SUPPORTED_TERM: supportedTerm,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.status, 0, result.stderr);
|
||||||
|
assert.equal(result.stdout, expectedTerm);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
test('starts a named OpenCode session in a worktree', () => {
|
test('starts a named OpenCode session in a worktree', () => {
|
||||||
const command = remoteCommand(['--opencode', 'fix-flaky', '--model', 'test']);
|
const command = remoteCommand(['--opencode', 'fix-flaky', '--legacy', '--model', 'test']);
|
||||||
|
|
||||||
assert.match(command, /tmux new -As fix-flaky-opencode/);
|
assert.match(command, /tmux new -As fix-flaky-opencode/);
|
||||||
assert.match(command, /unset AGENT_WORKER_TOKEN N8N_DEQUEUE_URL SLACK_BOT_TOKEN/);
|
assert.match(command, /unset AGENT_WORKER_TOKEN N8N_DEQUEUE_URL SLACK_BOT_TOKEN/);
|
||||||
assert.match(command, /OPENCODE_CONFIG_CONTENT/);
|
assert.match(command, /OPENCODE_CONFIG_CONTENT/);
|
||||||
assert.match(command, /export N8N_AGENT_RUNTIME=sandbox; unset N8N_AGENT_PROFILE/);
|
assert.match(command, /export N8N_AGENT_RUNTIME=sandbox; unset N8N_AGENT_PROFILE/);
|
||||||
assert.match(command, /git -C \/workspaces\/n8n worktree add "\/workspaces\/wt-fix-flaky"/);
|
assert.match(
|
||||||
|
command,
|
||||||
|
/git -C \/workspaces\/n8n fetch origin master; git -C \/workspaces\/n8n worktree add --no-track -b "session\/fix-flaky" "\/workspaces\/wt-fix-flaky" origin\/master/,
|
||||||
|
);
|
||||||
assert.match(command, /cd "\/workspaces\/wt-fix-flaky" && opencode --auto --model test/);
|
assert.match(command, /cd "\/workspaces\/wt-fix-flaky" && opencode --auto --model test/);
|
||||||
assert.doesNotMatch(command, /unset OPENROUTER_API_KEY/);
|
assert.doesNotMatch(command, /unset OPENROUTER_API_KEY/);
|
||||||
assert.doesNotMatch(command, /claude plugin/);
|
assert.doesNotMatch(command, /claude plugin/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('forwards legacy flags without a workspace name to the main checkout', () => {
|
||||||
|
for (const flags of [['--help'], ['--model', 'test']]) {
|
||||||
|
const command = remoteCommand(['--opencode', '--legacy', ...flags]);
|
||||||
|
assert.match(command, /tmux new -As agent-opencode/);
|
||||||
|
assert.ok(command.includes(`cd /workspaces/n8n && opencode --auto ${flags.join(' ')}`));
|
||||||
|
assert.doesNotMatch(command, /git .*worktree add/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('keeps removed credentials out of the login shell', () => {
|
test('keeps removed credentials out of the login shell', () => {
|
||||||
const command = remoteCommand(['--shell']);
|
const command = remoteCommand(['--shell']);
|
||||||
const profile = readFileSync(new URL('./codespaces-secrets.sh', import.meta.url), 'utf8');
|
const profile = readFileSync(new URL('./codespaces-secrets.sh', import.meta.url), 'utf8');
|
||||||
|
|
|
||||||
|
|
@ -12,14 +12,6 @@
|
||||||
'{"hasCompletedOnboarding":true,"theme":"dark","projects":{"/workspaces/n8n":{"hasTrustDialogAccepted":true}}}' \
|
'{"hasCompletedOnboarding":true,"theme":"dark","projects":{"/workspaces/n8n":{"hasTrustDialogAccepted":true}}}' \
|
||||||
>"$HOME/.claude.json"
|
>"$HOME/.claude.json"
|
||||||
|
|
||||||
# Register the Flaky MCP server for Claude Code. The config keeps a literal
|
|
||||||
# ${FLAKY_MCP_TOKEN}; Claude Code expands it at connect time, so the token is
|
|
||||||
# not written to disk. Forks have no repo secrets and skip this.
|
|
||||||
if [ -n "$FLAKY_MCP_URL" ] && ! grep -qs '"flaky"' "$HOME/.claude.json" && command -v claude >/dev/null 2>&1; then
|
|
||||||
claude mcp add --scope user --transport http flaky "$FLAKY_MCP_URL" \
|
|
||||||
--header 'Authorization: Bearer ${FLAKY_MCP_TOKEN}' >/dev/null 2>&1 || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Register the credential helper in the user config, because Codespaces
|
# Register the credential helper in the user config, because Codespaces
|
||||||
# regenerates the managed /etc/gitconfig. When the env token is missing, the
|
# regenerates the managed /etc/gitconfig. When the env token is missing, the
|
||||||
# system helper exits 0 without output, and git falls through to ours.
|
# system helper exits 0 without output, and git falls through to ours.
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,13 @@
|
||||||
"ghcr.io/devcontainers/features/docker-in-docker:2": {}
|
"ghcr.io/devcontainers/features/docker-in-docker:2": {}
|
||||||
},
|
},
|
||||||
"forwardPorts": [8080, 5678],
|
"forwardPorts": [8080, 5678],
|
||||||
|
"customizations": {
|
||||||
|
// Claude Code in VS Code or the browser editor. It runs in the codespace.
|
||||||
|
"vscode": {
|
||||||
|
"extensions": ["anthropic.claude-code"]
|
||||||
|
},
|
||||||
// Grant the Codespace token read access to the private skills and harness repositories.
|
// Grant the Codespace token read access to the private skills and harness repositories.
|
||||||
// Each user authorizes this access once when they create the Codespace.
|
// Each user authorizes this access once when they create the Codespace.
|
||||||
"customizations": {
|
|
||||||
"codespaces": {
|
"codespaces": {
|
||||||
"repositories": {
|
"repositories": {
|
||||||
"n8n-io/n8n-agent-skills": { "permissions": { "contents": "read" } },
|
"n8n-io/n8n-agent-skills": { "permissions": { "contents": "read" } },
|
||||||
|
|
|
||||||
36
.devcontainer/codespaces/fake-bin.mjs
Normal file
36
.devcontainer/codespaces/fake-bin.mjs
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
// Fake executables for tests. Each shim is a Node script. It receives `args`, `root`,
|
||||||
|
// `file(name)` under the root, and `log(event)`, which appends to calls.jsonl in the root.
|
||||||
|
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
const COMMON = `
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const root = process.env.TEST_ROOT;
|
||||||
|
const file = (name) => path.join(root, name);
|
||||||
|
const log = (event) => fs.appendFileSync(file('calls.jsonl'), JSON.stringify(event) + '\\n');
|
||||||
|
`;
|
||||||
|
|
||||||
|
export function fakeBinaries(prefix) {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), prefix));
|
||||||
|
const binDir = join(root, 'bin');
|
||||||
|
mkdirSync(binDir);
|
||||||
|
const logFile = join(root, 'calls.jsonl');
|
||||||
|
writeFileSync(logFile, '');
|
||||||
|
return {
|
||||||
|
root,
|
||||||
|
env: { PATH: `${binDir}:${process.env.PATH}`, TEST_ROOT: root },
|
||||||
|
bin: (name, body) =>
|
||||||
|
writeFileSync(join(binDir, name), `#!${process.execPath}\n${COMMON}\n${body}`, {
|
||||||
|
mode: 0o755,
|
||||||
|
}),
|
||||||
|
calls: () =>
|
||||||
|
readFileSync(logFile, 'utf8')
|
||||||
|
.trim()
|
||||||
|
.split('\n')
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((line) => JSON.parse(line)),
|
||||||
|
};
|
||||||
|
}
|
||||||
224
.devcontainer/codespaces/opencode-client.test.mjs
Normal file
224
.devcontainer/codespaces/opencode-client.test.mjs
Normal file
|
|
@ -0,0 +1,224 @@
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { rmSync, readFileSync } from 'node:fs';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import { setTimeout as delay } from 'node:timers/promises';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
import { fakeBinaries } from './fake-bin.mjs';
|
||||||
|
import { parseOpenCodeArgs } from '../../scripts/cloud-session-opencode.mjs';
|
||||||
|
|
||||||
|
const script = fileURLToPath(new URL('../../scripts/cloud-session.mjs', import.meta.url));
|
||||||
|
const secret = 'a'.repeat(64);
|
||||||
|
|
||||||
|
function fixture(t, env = {}) {
|
||||||
|
const { root, bin, calls, env: binEnv } = fakeBinaries('opencode-client-');
|
||||||
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||||
|
bin(
|
||||||
|
'gh',
|
||||||
|
`
|
||||||
|
log({ command: 'gh', args });
|
||||||
|
if (args[0] === 'codespace' && args[1] === 'list') {
|
||||||
|
console.log(JSON.stringify([
|
||||||
|
{ name: 'dev-box', state: 'Available' },
|
||||||
|
]));
|
||||||
|
} else if (args.includes('-N')) {
|
||||||
|
if (process.env.TEST_TUNNEL_FAIL) process.exit(1);
|
||||||
|
const mapping = args[args.indexOf('-L') + 1];
|
||||||
|
const port = +mapping.split(':')[1];
|
||||||
|
const ssh = require('node:child_process').spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore' });
|
||||||
|
log({ event: 'ssh-child', pid: ssh.pid });
|
||||||
|
const server = require('node:http').createServer((req, res) => {
|
||||||
|
const expected = 'Basic ' + Buffer.from('opencode:' + '${secret}').toString('base64');
|
||||||
|
if (process.env.TEST_UNAUTHORIZED || req.headers.authorization !== expected) { res.writeHead(401).end(); return; }
|
||||||
|
res.setHeader('content-type', 'application/json');
|
||||||
|
res.end(JSON.stringify({ healthy: true, version: process.env.TEST_SERVER_VERSION || '1.2.3' }));
|
||||||
|
});
|
||||||
|
server.listen(port, '127.0.0.1');
|
||||||
|
if (process.env.TEST_TUNNEL_DROP) setTimeout(() => process.exit(1), 500);
|
||||||
|
process.on('SIGTERM', () => { log({ event: 'tunnel-stopped' }); server.closeAllConnections(); server.close(() => process.exit(0)); });
|
||||||
|
} else if (args.at(-1).includes('node --input-type=module')) {
|
||||||
|
let source = '';
|
||||||
|
process.stdin.on('data', chunk => source += chunk);
|
||||||
|
process.stdin.on('end', () => {
|
||||||
|
log({ event: 'bootstrap', hasSource: source.includes('prepareOpenCode') });
|
||||||
|
if (process.env.TEST_BOOTSTRAP_FAIL) process.exit(1);
|
||||||
|
const parts = args.at(-1).split(' ');
|
||||||
|
const name = parts.at(-3);
|
||||||
|
const fresh = parts.at(-2) === 'true';
|
||||||
|
const web = parts.at(-1) === 'true';
|
||||||
|
console.log(JSON.stringify({ port: 4242, password: '${secret}',
|
||||||
|
...(fresh && !web ? {} : { sessionID: 'ses_saved' }),
|
||||||
|
directory: name === 'agent' ? '/workspaces/n8n' : '/workspaces/wt-' + name }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
bin(
|
||||||
|
'opencode',
|
||||||
|
`
|
||||||
|
if (args[0] === '--version') {
|
||||||
|
if (process.env.TEST_NO_CLIENT) process.exit(1);
|
||||||
|
console.log(process.env.TEST_CLIENT_VERSION || '1.2.3');
|
||||||
|
} else {
|
||||||
|
log({ command: 'opencode', args, password: process.env.OPENCODE_SERVER_PASSWORD });
|
||||||
|
if (process.env.TEST_CLIENT_WAIT) setInterval(() => {}, 1000);
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
for (const opener of ['open', 'xdg-open']) bin(opener, `log({ command: 'browser', args });`);
|
||||||
|
return {
|
||||||
|
calls,
|
||||||
|
start(args) {
|
||||||
|
const child = spawn(process.execPath, [script, '--opencode', ...args], {
|
||||||
|
env: { ...process.env, ...binEnv, ...env },
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
let output = '';
|
||||||
|
child.stdout.on('data', (chunk) => {
|
||||||
|
output += chunk;
|
||||||
|
});
|
||||||
|
child.stderr.on('data', (chunk) => {
|
||||||
|
output += chunk;
|
||||||
|
});
|
||||||
|
const done = new Promise((resolve) => child.on('exit', (code) => resolve({ code, output })));
|
||||||
|
t.after(() => child.kill('SIGKILL'));
|
||||||
|
return { child, done };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitFor(check) {
|
||||||
|
for (let attempt = 0; attempt < 100; attempt++) {
|
||||||
|
const result = check();
|
||||||
|
if (result) return result;
|
||||||
|
await delay(50);
|
||||||
|
}
|
||||||
|
throw new Error('Fixture did not become ready.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// An orphaned fixture process can linger as a zombie where PID 1 does not reap
|
||||||
|
// it, and kill(pid, 0) still succeeds for one. Read the process state where the
|
||||||
|
// kernel exposes it; without /proc only the signal error can tell.
|
||||||
|
function stopped(pid) {
|
||||||
|
try {
|
||||||
|
process.kill(pid, 0);
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return readFileSync(`/proc/${pid}/stat`, 'utf8').split(' ')[2] === 'Z';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('parses options before or after the workspace and rejects unsupported flags', () => {
|
||||||
|
assert.equal(parseOpenCodeArgs(['--web']).port, 4096);
|
||||||
|
assert.equal(parseOpenCodeArgs([]).port, 0);
|
||||||
|
assert.equal(parseOpenCodeArgs(['--port', '4100', '--web']).port, 4100);
|
||||||
|
assert.deepEqual(parseOpenCodeArgs(['--web', 'fix-flaky', '--new', '--port', '4100']), {
|
||||||
|
name: 'fix-flaky',
|
||||||
|
web: true,
|
||||||
|
fresh: true,
|
||||||
|
port: 4100,
|
||||||
|
});
|
||||||
|
for (const args of [
|
||||||
|
['--port'],
|
||||||
|
['--port', '0'],
|
||||||
|
['--port', '65536'],
|
||||||
|
['--model', 'test'],
|
||||||
|
['a/b'],
|
||||||
|
['one', 'two'],
|
||||||
|
]) {
|
||||||
|
assert.throws(() => parseOpenCodeArgs(args));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'attaches the local TUI to the selected workspace and closes the whole tunnel',
|
||||||
|
{ timeout: 15000 },
|
||||||
|
async (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
const result = await f.start(['fix-flaky']).done;
|
||||||
|
assert.equal(result.code, 0, result.output);
|
||||||
|
const calls = f.calls();
|
||||||
|
assert.ok(calls.find((call) => call.event === 'bootstrap').hasSource);
|
||||||
|
assert.ok(
|
||||||
|
calls
|
||||||
|
.filter((call) => call.command === 'gh' && call.args[1] === 'ssh')
|
||||||
|
.every((call) => call.args[3] === 'dev-box'),
|
||||||
|
);
|
||||||
|
const tunnel = calls.find((call) => call.command === 'gh' && call.args.includes('-N'));
|
||||||
|
assert.match(tunnel.args.at(-1), /^127\.0\.0\.1:\d+:127\.0\.0\.1:4242$/);
|
||||||
|
const client = calls.find((call) => call.command === 'opencode');
|
||||||
|
assert.deepEqual(client.args.slice(2), [
|
||||||
|
'--dir',
|
||||||
|
'/workspaces/wt-fix-flaky',
|
||||||
|
'--session',
|
||||||
|
'ses_saved',
|
||||||
|
]);
|
||||||
|
assert.equal(client.password, secret);
|
||||||
|
assert.ok(!result.output.includes(secret));
|
||||||
|
assert.ok(calls.some((call) => call.event === 'tunnel-stopped'));
|
||||||
|
const pid = calls.find((call) => call.event === 'ssh-child').pid;
|
||||||
|
await waitFor(() => stopped(pid));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('starts a new conversation for --new instead of continuing', { timeout: 15000 }, async (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
const result = await f.start(['fix-flaky', '--new']).done;
|
||||||
|
assert.equal(result.code, 0, result.output);
|
||||||
|
const client = f.calls().find((call) => call.command === 'opencode');
|
||||||
|
assert.deepEqual(client.args.slice(2), ['--dir', '/workspaces/wt-fix-flaky']);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const signal of ['SIGINT', 'SIGHUP']) {
|
||||||
|
test(
|
||||||
|
`opens the newest web conversation and cleans up on ${signal}`,
|
||||||
|
{ timeout: 15000 },
|
||||||
|
async (t) => {
|
||||||
|
const f = fixture(t, { TEST_NO_CLIENT: '1' });
|
||||||
|
const run = f.start(['--web', 'fix-flaky']);
|
||||||
|
const browser = await waitFor(() => f.calls().find((call) => call.command === 'browser'));
|
||||||
|
const url = new URL(browser.args[0]);
|
||||||
|
assert.equal(
|
||||||
|
url.pathname,
|
||||||
|
`/${Buffer.from('/workspaces/wt-fix-flaky').toString('base64url')}/session/ses_saved`,
|
||||||
|
);
|
||||||
|
assert.equal(url.password, '');
|
||||||
|
const response = await fetch(`${url.origin}/global/health`);
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
await response.text();
|
||||||
|
run.child.kill(signal);
|
||||||
|
const result = await run.done;
|
||||||
|
assert.equal(result.code, 0, result.output);
|
||||||
|
assert.ok(f.calls().some((call) => call.event === 'tunnel-stopped'));
|
||||||
|
await assert.rejects(fetch(url.origin));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [label, env, message] of [
|
||||||
|
[
|
||||||
|
'a version mismatch',
|
||||||
|
{ TEST_SERVER_VERSION: '1.14.22' },
|
||||||
|
/versions differ.*npm install -g opencode-ai@1\.14\.22/,
|
||||||
|
],
|
||||||
|
['an authentication failure', { TEST_UNAUTHORIZED: '1' }, /health check failed \(401\)/],
|
||||||
|
['a tunnel startup failure', { TEST_TUNNEL_FAIL: '1' }, /SSH tunnel closed/],
|
||||||
|
['a remote preparation failure', { TEST_BOOTSTRAP_FAIL: '1' }, /Could not prepare/],
|
||||||
|
['a missing local client', { TEST_NO_CLIENT: '1' }, /Install OpenCode locally/],
|
||||||
|
['a connection loss', { TEST_CLIENT_WAIT: '1', TEST_TUNNEL_DROP: '1' }, /SSH connection closed/],
|
||||||
|
]) {
|
||||||
|
test(`reports ${label} without printing credentials`, { timeout: 15000 }, async (t) => {
|
||||||
|
const f = fixture(t, env);
|
||||||
|
const result = await f.start([]).done;
|
||||||
|
assert.equal(result.code, 1, result.output);
|
||||||
|
assert.match(result.output, message);
|
||||||
|
assert.ok(!result.output.includes(secret));
|
||||||
|
if (label !== 'a connection loss')
|
||||||
|
assert.ok(!f.calls().some((call) => call.command === 'opencode'));
|
||||||
|
});
|
||||||
|
}
|
||||||
147
.devcontainer/codespaces/opencode-proxy.test.mjs
Normal file
147
.devcontainer/codespaces/opencode-proxy.test.mjs
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { createServer, request } from 'node:http';
|
||||||
|
import { connect } from 'node:net';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
|
||||||
|
import { openCodeProxy } from '../../scripts/cloud-session-opencode-proxy.mjs';
|
||||||
|
|
||||||
|
test('proxies uploads and event streams with authentication and rejects other origins', async (t) => {
|
||||||
|
const requests = [];
|
||||||
|
const backend = createServer(async (req, res) => {
|
||||||
|
requests.push(req.headers);
|
||||||
|
res.writeHead(200, { 'content-type': 'text/event-stream' });
|
||||||
|
for await (const chunk of req) res.write(chunk);
|
||||||
|
res.end();
|
||||||
|
});
|
||||||
|
await new Promise((resolve) => backend.listen(0, '127.0.0.1', resolve));
|
||||||
|
t.after(() => {
|
||||||
|
backend.closeAllConnections();
|
||||||
|
backend.close();
|
||||||
|
});
|
||||||
|
const proxy = await openCodeProxy({ password: 'test-secret' });
|
||||||
|
proxy.targetPort = backend.address().port;
|
||||||
|
t.after(proxy.close);
|
||||||
|
const response = await fetch(`${proxy.origin}/session`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: 'data: attached image\n\n',
|
||||||
|
headers: { origin: proxy.origin },
|
||||||
|
});
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
assert.equal(await response.text(), 'data: attached image\n\n');
|
||||||
|
assert.equal(
|
||||||
|
requests[0].authorization,
|
||||||
|
`Basic ${Buffer.from('opencode:test-secret').toString('base64')}`,
|
||||||
|
);
|
||||||
|
assert.ok(!proxy.origin.includes('test-secret'));
|
||||||
|
for (const headers of [
|
||||||
|
{ origin: 'https://example.com' },
|
||||||
|
{ host: 'example.com' },
|
||||||
|
{ 'sec-fetch-site': 'cross-site' },
|
||||||
|
]) {
|
||||||
|
const status = await new Promise((resolve, reject) => {
|
||||||
|
const req = request(proxy.origin, { headers }, (response) => {
|
||||||
|
response.resume();
|
||||||
|
resolve(response.statusCode);
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
assert.equal(status, 403);
|
||||||
|
}
|
||||||
|
assert.equal(requests.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('forwards WebSocket upgrades and closes their sockets', async (t) => {
|
||||||
|
const peers = new Set();
|
||||||
|
const backend = createServer();
|
||||||
|
backend.on('upgrade', (req, socket) => {
|
||||||
|
assert.equal(
|
||||||
|
req.headers.authorization,
|
||||||
|
`Basic ${Buffer.from('opencode:test-secret').toString('base64')}`,
|
||||||
|
);
|
||||||
|
peers.add(socket);
|
||||||
|
socket.on('close', () => peers.delete(socket));
|
||||||
|
socket.write(
|
||||||
|
'HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n',
|
||||||
|
);
|
||||||
|
socket.pipe(socket);
|
||||||
|
});
|
||||||
|
await new Promise((resolve) => backend.listen(0, '127.0.0.1', resolve));
|
||||||
|
t.after(() => {
|
||||||
|
for (const peer of peers) peer.destroy();
|
||||||
|
backend.close();
|
||||||
|
});
|
||||||
|
const proxy = await openCodeProxy({ password: 'test-secret' });
|
||||||
|
proxy.targetPort = backend.address().port;
|
||||||
|
t.after(proxy.close);
|
||||||
|
const socket = await new Promise((resolve, reject) => {
|
||||||
|
const req = request(`${proxy.origin}/pty/test/connect`, {
|
||||||
|
headers: { origin: proxy.origin, connection: 'Upgrade', upgrade: 'websocket' },
|
||||||
|
});
|
||||||
|
req.on('upgrade', (_response, peer) => resolve(peer));
|
||||||
|
req.on('error', reject);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
t.after(() => socket.destroy());
|
||||||
|
const echoed = new Promise((resolve) => socket.once('data', (data) => resolve(data.toString())));
|
||||||
|
socket.write('terminal input');
|
||||||
|
assert.equal(await echoed, 'terminal input');
|
||||||
|
const closed = new Promise((resolve) => socket.once('close', resolve));
|
||||||
|
proxy.close();
|
||||||
|
await closed;
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'closes the browser response when the upstream stream disconnects',
|
||||||
|
{ timeout: 5000 },
|
||||||
|
async (t) => {
|
||||||
|
const backend = createServer((_req, res) => {
|
||||||
|
res.writeHead(200, { 'content-type': 'text/event-stream' });
|
||||||
|
res.write('data: partial\n\n');
|
||||||
|
setTimeout(() => res.destroy(), 50);
|
||||||
|
});
|
||||||
|
await new Promise((resolve) => backend.listen(0, '127.0.0.1', resolve));
|
||||||
|
t.after(() => {
|
||||||
|
backend.closeAllConnections();
|
||||||
|
backend.close();
|
||||||
|
});
|
||||||
|
const proxy = await openCodeProxy({ password: 'test' });
|
||||||
|
proxy.targetPort = backend.address().port;
|
||||||
|
t.after(proxy.close);
|
||||||
|
const signal = AbortSignal.timeout(3000);
|
||||||
|
const response = await fetch(proxy.origin, { signal });
|
||||||
|
await assert.rejects(response.text());
|
||||||
|
assert.equal(
|
||||||
|
signal.aborted,
|
||||||
|
false,
|
||||||
|
'The proxy must close the response before the client times out.',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('rejects an occupied browser port and reports a missing or closed upstream', async (t) => {
|
||||||
|
const proxy = await openCodeProxy({ password: 'test' });
|
||||||
|
t.after(proxy.close);
|
||||||
|
await assert.rejects(openCodeProxy({ password: 'test', port: +new URL(proxy.origin).port }), {
|
||||||
|
code: 'EADDRINUSE',
|
||||||
|
});
|
||||||
|
const waiting = await fetch(proxy.origin);
|
||||||
|
assert.equal(waiting.status, 503);
|
||||||
|
await waiting.text();
|
||||||
|
proxy.targetPort = 1;
|
||||||
|
const response = await fetch(proxy.origin);
|
||||||
|
assert.equal(response.status, 502);
|
||||||
|
await response.text();
|
||||||
|
const rejection = await new Promise((resolve) => {
|
||||||
|
const socket = connect(+new URL(proxy.origin).port, '127.0.0.1', () => {
|
||||||
|
socket.write(
|
||||||
|
'GET /pty/test/connect HTTP/1.1\r\nHost: example.com\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
socket.once('data', (data) => {
|
||||||
|
socket.destroy();
|
||||||
|
resolve(data.toString());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
assert.match(rejection, /403 Forbidden/);
|
||||||
|
});
|
||||||
228
.devcontainer/codespaces/opencode-server.mjs
Normal file
228
.devcontainer/codespaces/opencode-server.mjs
Normal file
|
|
@ -0,0 +1,228 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
// The laptop sends this file over SSH as one standalone module. Do not import sibling files.
|
||||||
|
// It also works in an older Codespace checkout.
|
||||||
|
import { execFileSync, spawnSync } from 'node:child_process';
|
||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
|
import { createServer } from 'node:net';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { setTimeout as delay } from 'node:timers/promises';
|
||||||
|
|
||||||
|
const quote = (value) => `'${value.replaceAll("'", "'\\''")}'`;
|
||||||
|
|
||||||
|
function readJson(file) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(readFileSync(file, 'utf8'));
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'ENOENT') return undefined;
|
||||||
|
throw new Error(`Cannot read ${file}. Restore or remove this state file, then retry.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveJson(file, value) {
|
||||||
|
writeFileSync(`${file}.tmp`, JSON.stringify(value), { mode: 0o600 });
|
||||||
|
renameSync(`${file}.tmp`, file);
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(command, args, options = {}) {
|
||||||
|
const result = spawnSync(command, args, {
|
||||||
|
stdio: ['ignore', 'inherit', 'inherit'],
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
if (result.error || result.status !== 0) {
|
||||||
|
throw new Error(`${command} failed. Check the output above, then retry.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function freePort() {
|
||||||
|
const server = createServer();
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
server.once('error', reject);
|
||||||
|
server.listen(0, '127.0.0.1', resolve);
|
||||||
|
});
|
||||||
|
const { port } = server.address();
|
||||||
|
await new Promise((resolve) => server.close(resolve));
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request(server, path, directory, options = {}, timeout = 60_000) {
|
||||||
|
return await fetch(`http://127.0.0.1:${server.port}${path}`, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
authorization: `Basic ${Buffer.from(`opencode:${server.password}`).toString('base64')}`,
|
||||||
|
'x-opencode-directory': encodeURIComponent(directory),
|
||||||
|
'content-type': 'application/json',
|
||||||
|
},
|
||||||
|
signal: AbortSignal.timeout(timeout),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function health(server, directory) {
|
||||||
|
try {
|
||||||
|
const response = await request(server, '/global/health', directory, {}, 3000);
|
||||||
|
if (!response.ok) return undefined;
|
||||||
|
const result = await response.json();
|
||||||
|
return result.healthy === true && typeof result.version === 'string' ? result : undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverFile = (stateDir) => join(stateDir, 'server.json');
|
||||||
|
|
||||||
|
async function runningServer({ stateDir, mainDirectory }) {
|
||||||
|
const previous = readJson(serverFile(stateDir));
|
||||||
|
return previous && (await health(previous, mainDirectory)) ? previous : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startServer({ stateDir, mainDirectory, workspaces }) {
|
||||||
|
const tmuxSession = 'n8n-opencode-server';
|
||||||
|
if (spawnSync('tmux', ['has-session', '-t', `=${tmuxSession}`]).status === 0) {
|
||||||
|
throw new Error(
|
||||||
|
`OpenCode is still starting or is unhealthy. Check ${stateDir}/server.log, then retry. The running server was not stopped.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = { port: await freePort(), password: randomBytes(32).toString('hex') };
|
||||||
|
const launcher = join(stateDir, 'serve.sh');
|
||||||
|
// Read secrets when the server starts. Do not store provider keys in the launcher.
|
||||||
|
writeFileSync(
|
||||||
|
launcher,
|
||||||
|
[
|
||||||
|
'#!/bin/bash',
|
||||||
|
'. /usr/local/lib/codespaces-env.sh 2>/dev/null || true',
|
||||||
|
'unset AGENT_WORKER_TOKEN N8N_DEQUEUE_URL SLACK_BOT_TOKEN',
|
||||||
|
`export TURBO_CACHE_DIR=${quote(join(workspaces, '.turbo-cache'))}`,
|
||||||
|
`[ -d "$TURBO_CACHE_DIR" ] || cp -r ${quote(join(mainDirectory, '.turbo/cache'))} "$TURBO_CACHE_DIR" 2>/dev/null || mkdir -p "$TURBO_CACHE_DIR"`,
|
||||||
|
`export OPENCODE_SERVER_USERNAME=opencode OPENCODE_SERVER_PASSWORD=${quote(server.password)}`,
|
||||||
|
'export OPENCODE_EXPERIMENTAL_CODE_MODE=true',
|
||||||
|
`export OPENCODE_CONFIG_CONTENT=${quote(
|
||||||
|
JSON.stringify({
|
||||||
|
enabled_providers: ['openrouter'],
|
||||||
|
provider: { openrouter: { options: { apiKey: '{env:OPENROUTER_API_KEY}' } } },
|
||||||
|
}),
|
||||||
|
)}`,
|
||||||
|
// Interactive sessions use the harness sandbox runtime without a profile.
|
||||||
|
'export N8N_AGENT_RUNTIME=sandbox; unset N8N_AGENT_PROFILE',
|
||||||
|
`cd ${quote(mainDirectory)} || exit 1`,
|
||||||
|
`exec opencode serve --hostname 127.0.0.1 --port ${server.port} >> ${quote(join(stateDir, 'server.log'))} 2>&1`,
|
||||||
|
'',
|
||||||
|
].join('\n'),
|
||||||
|
{ mode: 0o600 },
|
||||||
|
);
|
||||||
|
saveJson(serverFile(stateDir), server);
|
||||||
|
run('tmux', ['new-session', '-d', '-s', tmuxSession, `bash ${quote(launcher)}`]);
|
||||||
|
console.error('Starting the OpenCode server…');
|
||||||
|
for (let attempt = 0; attempt < 60; attempt++) {
|
||||||
|
if (await health(server, mainDirectory)) return server;
|
||||||
|
await delay(500);
|
||||||
|
}
|
||||||
|
throw new Error(`OpenCode did not become ready. Check ${stateDir}/server.log.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function prepareWorkspace({ name, directory, mainDirectory, stateDir }) {
|
||||||
|
if (!existsSync(directory)) {
|
||||||
|
console.error(`Creating worktree ${directory}…`);
|
||||||
|
const branch = `session/${name}`;
|
||||||
|
const exists =
|
||||||
|
spawnSync('git', [
|
||||||
|
'-C',
|
||||||
|
mainDirectory,
|
||||||
|
'show-ref',
|
||||||
|
'--verify',
|
||||||
|
'--quiet',
|
||||||
|
`refs/heads/${branch}`,
|
||||||
|
]).status === 0;
|
||||||
|
if (!exists)
|
||||||
|
spawnSync('git', ['-C', mainDirectory, 'fetch', 'origin', 'master'], {
|
||||||
|
stdio: ['ignore', 2, 2],
|
||||||
|
});
|
||||||
|
run(
|
||||||
|
'git',
|
||||||
|
[
|
||||||
|
'-C',
|
||||||
|
mainDirectory,
|
||||||
|
'worktree',
|
||||||
|
'add',
|
||||||
|
...(exists ? [] : ['--no-track', '-b', branch]),
|
||||||
|
directory,
|
||||||
|
exists ? branch : 'origin/master',
|
||||||
|
],
|
||||||
|
{ stdio: ['ignore', 2, 2] },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// A failed install must be retried even if it left a node_modules directory.
|
||||||
|
const installed = join(stateDir, `${name}.installed`);
|
||||||
|
const installing = join(stateDir, `${name}.installing`);
|
||||||
|
if (
|
||||||
|
!existsSync(join(directory, 'node_modules')) ||
|
||||||
|
existsSync(installing) ||
|
||||||
|
(name !== 'agent' && !existsSync(installed))
|
||||||
|
) {
|
||||||
|
console.error(`Installing dependencies in ${directory}…`);
|
||||||
|
writeFileSync(installing, '', { mode: 0o600 });
|
||||||
|
run('pnpm', ['install'], { cwd: directory, stdio: ['ignore', 2, 2] });
|
||||||
|
writeFileSync(installed, '', { mode: 0o600 });
|
||||||
|
rmSync(installing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenCode's own resume is scoped to the repository project, so all worktrees
|
||||||
|
// share one pool. Scope the selection to the workspace directory instead, so
|
||||||
|
// each worktree resumes its own most recently updated root conversation.
|
||||||
|
async function resolveSession({ name, fresh, directory, server }) {
|
||||||
|
if (!fresh) {
|
||||||
|
const query = new URLSearchParams({ directory, roots: 'true' });
|
||||||
|
const response = await request(server, `/session?${query}`, directory);
|
||||||
|
if (!response.ok) throw new Error(`Cannot list OpenCode sessions (${response.status}).`);
|
||||||
|
const sessions = await response.json();
|
||||||
|
const latest = Array.isArray(sessions)
|
||||||
|
? sessions.find((entry) => typeof entry?.id === 'string' && entry.id.length > 0)
|
||||||
|
: undefined;
|
||||||
|
if (latest) return latest.id;
|
||||||
|
}
|
||||||
|
const response = await request(server, '/session', directory, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ title: `n8n: ${name}` }),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`Cannot create OpenCode session (${response.status}).`);
|
||||||
|
const session = await response.json();
|
||||||
|
if (typeof session.id !== 'string' || session.directory !== directory)
|
||||||
|
throw new Error('OpenCode returned an invalid session.');
|
||||||
|
return session.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function prepareOpenCode({
|
||||||
|
name = 'agent',
|
||||||
|
fresh = false,
|
||||||
|
web = false,
|
||||||
|
workspaces = '/workspaces',
|
||||||
|
} = {}) {
|
||||||
|
const stateDir = join(workspaces, '.n8n-opencode');
|
||||||
|
const mainDirectory = join(workspaces, 'n8n');
|
||||||
|
if (!/^\w[\w-]*$/.test(name)) throw new Error('Invalid OpenCode workspace name.');
|
||||||
|
mkdirSync(stateDir, { recursive: true, mode: 0o700 });
|
||||||
|
const directory = name === 'agent' ? mainDirectory : join(workspaces, `wt-${name}`);
|
||||||
|
let server = await runningServer({ stateDir, mainDirectory });
|
||||||
|
// On a cold start, fail before the worktree install when OpenCode is missing on the Codespace.
|
||||||
|
if (!server) execFileSync('opencode', ['--version'], { stdio: ['ignore', 'pipe', 'inherit'] });
|
||||||
|
prepareWorkspace({ name, directory, mainDirectory, stateDir });
|
||||||
|
server ??= await startServer({ stateDir, mainDirectory, workspaces });
|
||||||
|
const state = { ...server, directory };
|
||||||
|
// A fresh TUI conversation needs no record. The TUI creates it on the first message.
|
||||||
|
if (fresh && !web) return state;
|
||||||
|
// Only the parent process reads stdout. Never send this record to terminal output.
|
||||||
|
return { ...state, sessionID: await resolveSession({ name, fresh, directory, server }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv[1] === '-') {
|
||||||
|
try {
|
||||||
|
const [name, fresh, web] = process.argv.slice(2);
|
||||||
|
console.log(
|
||||||
|
JSON.stringify(await prepareOpenCode({ name, fresh: fresh === 'true', web: web === 'true' })),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error.message);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
275
.devcontainer/codespaces/opencode-server.test.mjs
Normal file
275
.devcontainer/codespaces/opencode-server.test.mjs
Normal file
|
|
@ -0,0 +1,275 @@
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import { setTimeout as delay } from 'node:timers/promises';
|
||||||
|
|
||||||
|
import { fakeBinaries } from './fake-bin.mjs';
|
||||||
|
import { prepareOpenCode } from './opencode-server.mjs';
|
||||||
|
|
||||||
|
// An orphaned fixture process can linger as a zombie where PID 1 does not reap
|
||||||
|
// it, and kill(pid, 0) still succeeds for one. Read the process state where the
|
||||||
|
// kernel exposes it; without /proc only the signal error can tell.
|
||||||
|
function stopped(pid) {
|
||||||
|
try {
|
||||||
|
process.kill(pid, 0);
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return readFileSync(`/proc/${pid}/stat`, 'utf8').split(' ')[2] === 'Z';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fixture(t) {
|
||||||
|
const fake = fakeBinaries('opencode-server-');
|
||||||
|
const dir = fake.root;
|
||||||
|
mkdirSync(join(dir, 'n8n', 'node_modules'), { recursive: true });
|
||||||
|
const savedEnv = { ...process.env };
|
||||||
|
// prepareOpenCode spawns the shims in this process. Log every call before the shim body runs.
|
||||||
|
Object.assign(process.env, fake.env, {
|
||||||
|
AGENT_WORKER_TOKEN: 'test-worker',
|
||||||
|
N8N_DEQUEUE_URL: 'test-queue',
|
||||||
|
SLACK_BOT_TOKEN: 'test-slack',
|
||||||
|
});
|
||||||
|
const bin = (name, body) =>
|
||||||
|
fake.bin(name, `log({ command: ${JSON.stringify(name)}, args });\n${body}`);
|
||||||
|
bin(
|
||||||
|
'git',
|
||||||
|
`
|
||||||
|
if (args.includes('show-ref')) process.exit(fs.existsSync(file('branch-exists')) ? 0 : 1);
|
||||||
|
if (args.includes('fetch')) process.exit(0);
|
||||||
|
fs.mkdirSync(args.at(-2), { recursive: true });
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
bin(
|
||||||
|
'pnpm',
|
||||||
|
`
|
||||||
|
fs.mkdirSync(path.join(process.cwd(), 'node_modules'), {recursive:true});
|
||||||
|
if (process.env.TEST_INSTALL_FAIL) process.exit(1);
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
bin(
|
||||||
|
'tmux',
|
||||||
|
`
|
||||||
|
if (args[0] === 'has-session') {
|
||||||
|
const pid = +fs.readFileSync(file('pid'), 'utf8');
|
||||||
|
let gone = false;
|
||||||
|
try { process.kill(pid, 0); } catch { gone = true; }
|
||||||
|
if (!gone) {
|
||||||
|
try { gone = fs.readFileSync('/proc/' + pid + '/stat', 'utf8').split(' ')[2] === 'Z'; } catch {}
|
||||||
|
}
|
||||||
|
if (gone) process.exit(1);
|
||||||
|
} else {
|
||||||
|
const child = require('node:child_process').spawn('bash', ['-c', args.at(-1)], { detached:true, stdio:'ignore' });
|
||||||
|
fs.writeFileSync(file('pid'), String(child.pid)); child.unref();
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
bin(
|
||||||
|
'opencode',
|
||||||
|
`
|
||||||
|
if (args[0] === '--version') { console.log('1.2.3'); process.exit(0); }
|
||||||
|
fs.writeFileSync(file('server-env.json'), JSON.stringify({
|
||||||
|
worker: !!process.env.AGENT_WORKER_TOKEN, queue: !!process.env.N8N_DEQUEUE_URL, slack: !!process.env.SLACK_BOT_TOKEN,
|
||||||
|
cache: process.env.TURBO_CACHE_DIR, config: JSON.parse(process.env.OPENCODE_CONFIG_CONTENT),
|
||||||
|
runtime: process.env.N8N_AGENT_RUNTIME, profile: process.env.N8N_AGENT_PROFILE ?? null,
|
||||||
|
}));
|
||||||
|
let sessions = fs.existsSync(file('sessions.json')) ? JSON.parse(fs.readFileSync(file('sessions.json'), 'utf8')) : {};
|
||||||
|
const server = require('node:http').createServer(async (req, res) => {
|
||||||
|
res.setHeader('content-type', 'application/json');
|
||||||
|
const expected = 'Basic ' + Buffer.from('opencode:' + process.env.OPENCODE_SERVER_PASSWORD).toString('base64');
|
||||||
|
if (req.headers.authorization !== expected) { res.writeHead(401).end('{}'); return; }
|
||||||
|
const directory = decodeURIComponent(req.headers['x-opencode-directory']);
|
||||||
|
if (req.url === '/global/health') { res.end(JSON.stringify({ healthy:true, version:'1.2.3' })); return; }
|
||||||
|
if (req.method === 'POST' && req.url === '/session') {
|
||||||
|
if (fs.existsSync(file('reject-create'))) { res.writeHead(503).end('{}'); return; }
|
||||||
|
let body = '';
|
||||||
|
for await (const chunk of req) body += chunk;
|
||||||
|
const id = 'ses_' + (Object.keys(sessions).length + 1);
|
||||||
|
sessions[id] = { id, directory, title: JSON.parse(body).title };
|
||||||
|
fs.writeFileSync(file('sessions.json'), JSON.stringify(sessions));
|
||||||
|
res.end(JSON.stringify(sessions[id])); return;
|
||||||
|
}
|
||||||
|
if (fs.existsSync(file('reject-session'))) { res.writeHead(503).end('{}'); return; }
|
||||||
|
const url = new URL(req.url, 'http://localhost');
|
||||||
|
if (url.pathname === '/session') {
|
||||||
|
// Newest first, like the real server. Filter on the query, not the header,
|
||||||
|
// so the test fails when the launcher omits either parameter. The
|
||||||
|
// child-session file models a subagent conversation that the roots query
|
||||||
|
// flag must exclude.
|
||||||
|
const directory = url.searchParams.get('directory');
|
||||||
|
const roots = url.searchParams.get('roots') === 'true';
|
||||||
|
const stored = Object.values(sessions);
|
||||||
|
if (fs.existsSync(file('child-session')))
|
||||||
|
stored.push({ id: 'ses_child', directory, title: 'child', parentID: 'ses_1' });
|
||||||
|
const list = stored
|
||||||
|
.filter(s => s.directory === directory && (!roots || !s.parentID))
|
||||||
|
.reverse();
|
||||||
|
if (fs.existsSync(file('extra-session'))) list.unshift({ id: 'ses_extra', directory, title: 'TUI session' });
|
||||||
|
res.end(JSON.stringify(list)); return;
|
||||||
|
}
|
||||||
|
const session = sessions[req.url.split('/').at(-1)];
|
||||||
|
if (!session) { res.writeHead(404).end('{}'); return; }
|
||||||
|
res.end(JSON.stringify(session));
|
||||||
|
});
|
||||||
|
server.listen(+args[args.indexOf('--port') + 1], '127.0.0.1');
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
const stop = async () => {
|
||||||
|
try {
|
||||||
|
const pid = +readFileSync(join(dir, 'pid'), 'utf8');
|
||||||
|
process.kill(-pid, 'SIGTERM');
|
||||||
|
for (let count = 0; count < 100; count++) {
|
||||||
|
if (stopped(pid)) return;
|
||||||
|
await delay(20);
|
||||||
|
}
|
||||||
|
throw new Error('Fixture server did not stop.');
|
||||||
|
} catch (error) {
|
||||||
|
if (!['ENOENT', 'ESRCH'].includes(error.code)) throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
t.after(async () => {
|
||||||
|
await stop();
|
||||||
|
for (const key of Object.keys(process.env)) if (!(key in savedEnv)) delete process.env[key];
|
||||||
|
Object.assign(process.env, savedEnv);
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
dir,
|
||||||
|
stop,
|
||||||
|
prepare: (options = {}) => prepareOpenCode({ workspaces: dir, ...options }),
|
||||||
|
commands: fake.calls,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test(
|
||||||
|
'reuses the server, serves each workspace its newest conversation, and resumes after restart',
|
||||||
|
{ timeout: 15000 },
|
||||||
|
async (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
const first = await f.prepare({ name: 'fix-flaky' });
|
||||||
|
assert.equal(first.directory, join(f.dir, 'wt-fix-flaky'));
|
||||||
|
const git = f.commands().filter((entry) => entry.command === 'git');
|
||||||
|
assert.deepEqual(
|
||||||
|
git.map((entry) => entry.args.slice(2)),
|
||||||
|
[
|
||||||
|
['show-ref', '--verify', '--quiet', 'refs/heads/session/fix-flaky'],
|
||||||
|
['fetch', 'origin', 'master'],
|
||||||
|
[
|
||||||
|
'worktree',
|
||||||
|
'add',
|
||||||
|
'--no-track',
|
||||||
|
'-b',
|
||||||
|
'session/fix-flaky',
|
||||||
|
first.directory,
|
||||||
|
'origin/master',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert.equal(first.sessionID, 'ses_1');
|
||||||
|
assert.deepEqual(await f.prepare({ name: 'fix-flaky' }), first);
|
||||||
|
const second = await f.prepare({ name: 'another-task' });
|
||||||
|
assert.equal(second.port, first.port);
|
||||||
|
assert.equal(second.sessionID, 'ses_2');
|
||||||
|
const webbed = await f.prepare({ name: 'fix-flaky', web: true });
|
||||||
|
assert.equal(webbed.sessionID, 'ses_1');
|
||||||
|
const created = JSON.parse(readFileSync(join(f.dir, 'sessions.json'), 'utf8'));
|
||||||
|
assert.equal(created.ses_1.title, 'n8n: fix-flaky');
|
||||||
|
assert.equal(created.ses_1.directory, join(f.dir, 'wt-fix-flaky'));
|
||||||
|
assert.equal((await f.prepare({ name: 'fix-flaky', web: true })).sessionID, 'ses_1');
|
||||||
|
assert.equal((await f.prepare({ name: 'another-task', web: true })).sessionID, 'ses_2');
|
||||||
|
writeFileSync(join(f.dir, 'extra-session'), '');
|
||||||
|
assert.equal((await f.prepare({ name: 'fix-flaky', web: true })).sessionID, 'ses_extra');
|
||||||
|
rmSync(join(f.dir, 'extra-session'));
|
||||||
|
writeFileSync(join(f.dir, 'child-session'), '');
|
||||||
|
assert.equal((await f.prepare({ name: 'fix-flaky', web: true })).sessionID, 'ses_1');
|
||||||
|
rmSync(join(f.dir, 'child-session'));
|
||||||
|
const fresh = await f.prepare({ name: 'fix-flaky', web: true, fresh: true });
|
||||||
|
assert.notEqual(fresh.sessionID, 'ses_1');
|
||||||
|
assert.equal((await f.prepare({ name: 'fix-flaky', web: true })).sessionID, fresh.sessionID);
|
||||||
|
const env = JSON.parse(readFileSync(join(f.dir, 'server-env.json'), 'utf8'));
|
||||||
|
assert.deepEqual([env.worker, env.queue, env.slack], [false, false, false]);
|
||||||
|
assert.equal(env.cache, join(f.dir, '.turbo-cache'));
|
||||||
|
assert.equal(env.config.provider.openrouter.options.apiKey, '{env:OPENROUTER_API_KEY}');
|
||||||
|
assert.deepEqual(env.config.enabled_providers, ['openrouter']);
|
||||||
|
assert.deepEqual([env.runtime, env.profile], ['sandbox', null]);
|
||||||
|
assert.equal(statSync(join(f.dir, '.n8n-opencode')).mode & 0o777, 0o700);
|
||||||
|
for (const file of ['serve.sh', 'server.json']) {
|
||||||
|
assert.equal(statSync(join(f.dir, '.n8n-opencode', file)).mode & 0o777, 0o600);
|
||||||
|
}
|
||||||
|
assert.ok(!f.commands().some((entry) => JSON.stringify(entry.args).includes(first.password)));
|
||||||
|
assert.equal(f.commands().filter((entry) => entry.command === 'pnpm').length, 2);
|
||||||
|
await f.stop();
|
||||||
|
const restarted = await f.prepare({ name: 'fix-flaky', web: true });
|
||||||
|
assert.equal(restarted.sessionID, fresh.sessionID);
|
||||||
|
assert.notEqual(restarted.password, first.password);
|
||||||
|
rmSync(first.directory, { recursive: true });
|
||||||
|
writeFileSync(join(f.dir, 'branch-exists'), '');
|
||||||
|
assert.equal((await f.prepare({ name: 'fix-flaky' })).directory, join(f.dir, 'wt-fix-flaky'));
|
||||||
|
const worktree = f
|
||||||
|
.commands()
|
||||||
|
.filter((entry) => entry.command === 'git' && entry.args.includes('add'))
|
||||||
|
.at(-1);
|
||||||
|
assert.deepEqual(worktree.args, [
|
||||||
|
'-C',
|
||||||
|
join(f.dir, 'n8n'),
|
||||||
|
'worktree',
|
||||||
|
'add',
|
||||||
|
first.directory,
|
||||||
|
'session/fix-flaky',
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'retries a failed worktree install before starting the server',
|
||||||
|
{ timeout: 10000 },
|
||||||
|
async (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
process.env.TEST_INSTALL_FAIL = '1';
|
||||||
|
await assert.rejects(f.prepare({ name: 'retry' }), /pnpm failed/);
|
||||||
|
assert.ok(!f.commands().some((entry) => entry.command === 'tmux'));
|
||||||
|
delete process.env.TEST_INSTALL_FAIL;
|
||||||
|
assert.equal((await f.prepare({ name: 'retry' })).directory, join(f.dir, 'wt-retry'));
|
||||||
|
assert.equal(f.commands().filter((entry) => entry.command === 'pnpm').length, 2);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'retries a failed main checkout install even when node_modules exists',
|
||||||
|
{ timeout: 10000 },
|
||||||
|
async (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
rmSync(join(f.dir, 'n8n', 'node_modules'), { recursive: true });
|
||||||
|
process.env.TEST_INSTALL_FAIL = '1';
|
||||||
|
await assert.rejects(f.prepare(), /pnpm failed/);
|
||||||
|
delete process.env.TEST_INSTALL_FAIL;
|
||||||
|
assert.equal((await f.prepare()).directory, join(f.dir, 'n8n'));
|
||||||
|
assert.equal(f.commands().filter((entry) => entry.command === 'pnpm').length, 2);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'reports session API errors and creates conversations only when none exist',
|
||||||
|
{ timeout: 10000 },
|
||||||
|
async (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
const first = await f.prepare({ web: true });
|
||||||
|
assert.equal(first.sessionID, 'ses_1');
|
||||||
|
writeFileSync(join(f.dir, 'reject-session'), '');
|
||||||
|
await assert.rejects(f.prepare({ web: true }), /Cannot list OpenCode sessions \(503\)/);
|
||||||
|
assert.ok(JSON.parse(readFileSync(join(f.dir, 'sessions.json'), 'utf8')).ses_1);
|
||||||
|
rmSync(join(f.dir, 'reject-session'));
|
||||||
|
assert.equal((await f.prepare({ web: true })).sessionID, first.sessionID);
|
||||||
|
writeFileSync(join(f.dir, 'reject-create'), '');
|
||||||
|
await assert.rejects(
|
||||||
|
f.prepare({ name: 'fresh-task', web: true }),
|
||||||
|
/Cannot create OpenCode session \(503\)/,
|
||||||
|
);
|
||||||
|
rmSync(join(f.dir, 'reject-create'));
|
||||||
|
assert.equal((await f.prepare({ name: 'fresh-task', web: true })).sessionID, 'ses_2');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
// Runs on each Codespace start. Installs the skills and harness, then starts the worker.
|
// Runs on each Codespace start. Installs the skills and harness, then starts the worker.
|
||||||
import { execFileSync } from 'node:child_process';
|
import { execFileSync } from 'node:child_process';
|
||||||
import { rmSync, writeFileSync } from 'node:fs';
|
import { readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { installAgentHarness } from '../../scripts/agent-harness.mjs';
|
import { installAgentHarness } from '../../scripts/agent-harness.mjs';
|
||||||
|
import { codespaceSecret } from '../../scripts/codespace-env.mjs';
|
||||||
import { MARKETPLACE, PLUGINS } from './plugins.mjs';
|
import { MARKETPLACE, PLUGINS } from './plugins.mjs';
|
||||||
|
|
||||||
const STATUS_FILE = '/tmp/post-start-status.json';
|
const STATUS_FILE = '/tmp/post-start-status.json';
|
||||||
|
|
@ -49,6 +50,42 @@ function addMarketplace() {
|
||||||
return add('marketplace add (retry)');
|
return add('marketplace add (retry)');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Claude Code reads the Flaky token when it connects. An env reference such as
|
||||||
|
// ${FLAKY_MCP_TOKEN} fails in processes that did not load the secrets file, for
|
||||||
|
// example the desktop app's SSH server. The helper reads the file on each connect,
|
||||||
|
// so it also gets a rotated token. The token is not copied into ~/.claude.json.
|
||||||
|
const FLAKY_HEADERS_HELPER =
|
||||||
|
'. /usr/local/lib/codespaces-env.sh; printf %s "{\\"Authorization\\":\\"Bearer $FLAKY_MCP_TOKEN\\"}"';
|
||||||
|
|
||||||
|
function registerFlakyMcp() {
|
||||||
|
// Forks have no repository secrets.
|
||||||
|
const url = codespaceSecret('FLAKY_MCP_URL');
|
||||||
|
if (!url || !codespaceSecret('FLAKY_MCP_TOKEN')) return;
|
||||||
|
|
||||||
|
let current;
|
||||||
|
try {
|
||||||
|
current = JSON.parse(readFileSync(join(homedir(), '.claude.json'), 'utf8')).mcpServers?.flaky;
|
||||||
|
} catch {
|
||||||
|
// No readable config yet: register the server below.
|
||||||
|
}
|
||||||
|
if (current?.url === url && current.headersHelper === FLAKY_HEADERS_HELPER && !current.headers) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current) tryRun('flaky mcp remove', 'claude', ['mcp', 'remove', '--scope', 'user', 'flaky']);
|
||||||
|
const config = { type: 'http', url, headersHelper: FLAKY_HEADERS_HELPER };
|
||||||
|
tryRun('flaky mcp add', 'claude', [
|
||||||
|
'mcp',
|
||||||
|
'add-json',
|
||||||
|
'--scope',
|
||||||
|
'user',
|
||||||
|
'flaky',
|
||||||
|
JSON.stringify(config),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
registerFlakyMcp();
|
||||||
|
|
||||||
tryRun('skills repo reachable', 'git', ['ls-remote', `https://github.com/${MARKETPLACE}`, 'HEAD']);
|
tryRun('skills repo reachable', 'git', ['ls-remote', `https://github.com/${MARKETPLACE}`, 'HEAD']);
|
||||||
|
|
||||||
const installed = [];
|
const installed = [];
|
||||||
|
|
|
||||||
32
.devcontainer/codespaces/ssh-config.test.mjs
Normal file
32
.devcontainer/codespaces/ssh-config.test.mjs
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
|
||||||
|
import { aliasHostConfig, withInclude } from '../../scripts/cloud-session-ssh-config.mjs';
|
||||||
|
|
||||||
|
const GH_CONFIG = `Host cs.ominous-doodle.master
|
||||||
|
User node
|
||||||
|
ProxyCommand gh cs ssh -c ominous-doodle --stdio
|
||||||
|
UserKnownHostsFile=/dev/null
|
||||||
|
`;
|
||||||
|
|
||||||
|
test('replaces the gh host name with the fixed alias', () => {
|
||||||
|
assert.equal(
|
||||||
|
aliasHostConfig(GH_CONFIG),
|
||||||
|
GH_CONFIG.replace('cs.ominous-doodle.master', 'n8n-codespace'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects gh output without exactly one proxied host', () => {
|
||||||
|
assert.throws(() => aliasHostConfig(''));
|
||||||
|
assert.throws(() => aliasHostConfig(`${GH_CONFIG}Host other\n`));
|
||||||
|
assert.throws(() => aliasHostConfig('Host cs.x\n\tUser node\n'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('adds the Include line at the top once', () => {
|
||||||
|
const include = 'Include ~/.ssh/n8n-codespace.conf';
|
||||||
|
assert.equal(withInclude(''), `${include}\n`);
|
||||||
|
|
||||||
|
const updated = withInclude('Host github.com\n\tUser git\n');
|
||||||
|
assert.equal(updated, `${include}\n\nHost github.com\n\tUser git\n`);
|
||||||
|
assert.equal(withInclude(updated), undefined);
|
||||||
|
});
|
||||||
|
|
@ -26,7 +26,7 @@
|
||||||
!docker/images/runners
|
!docker/images/runners
|
||||||
!docker/images/runners/n8n-task-runners.json
|
!docker/images/runners/n8n-task-runners.json
|
||||||
|
|
||||||
# === benchmark image (packages/@n8n/benchmark/Dockerfile) ===
|
# === benchmark image (packages/quality/efficiency/scale/benchmark/Dockerfile) ===
|
||||||
!package.json
|
!package.json
|
||||||
!pnpm-lock.yaml
|
!pnpm-lock.yaml
|
||||||
!pnpm-workspace.yaml
|
!pnpm-workspace.yaml
|
||||||
|
|
@ -34,8 +34,10 @@
|
||||||
!patches/**
|
!patches/**
|
||||||
!scripts
|
!scripts
|
||||||
!scripts/**
|
!scripts/**
|
||||||
!packages/@n8n/benchmark
|
!packages/quality
|
||||||
!packages/@n8n/benchmark/**
|
!packages/quality/efficiency
|
||||||
|
!packages/quality/efficiency/scale
|
||||||
|
!packages/quality/efficiency/scale/benchmark
|
||||||
|
!packages/quality/efficiency/scale/benchmark/**
|
||||||
!packages/@n8n/typescript-config
|
!packages/@n8n/typescript-config
|
||||||
!packages/@n8n/typescript-config/**
|
!packages/@n8n/typescript-config/**
|
||||||
|
|
||||||
|
|
|
||||||
36
.githooks/check-pnpm.mjs
Normal file
36
.githooks/check-pnpm.mjs
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
// Git hook helper (post-merge, post-rewrite, post-checkout): warn when the
|
||||||
|
// installed pnpm major differs from the one pinned in package.json.
|
||||||
|
// Usage: node .githooks/check-pnpm.mjs [checkout-type]
|
||||||
|
// For post-checkout, pass lefthook's {3}: "0" is a file checkout, which we skip.
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
if (process.argv[2] === '0') process.exit(0);
|
||||||
|
|
||||||
|
const packageJsonPath = fileURLToPath(new URL('../package.json', import.meta.url));
|
||||||
|
const { packageManager = '' } = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
|
||||||
|
const wanted = /^pnpm@(\d+)/.exec(packageManager)?.[1];
|
||||||
|
|
||||||
|
if (!wanted) process.exit(0);
|
||||||
|
|
||||||
|
// Run from the home dir so pnpm reports the installed version,
|
||||||
|
// not the version pinned by this repo.
|
||||||
|
// `shell` lets Windows resolve pnpm.cmd. The command has no user input.
|
||||||
|
const result = spawnSync('pnpm --version', { cwd: homedir(), shell: true, encoding: 'utf8' });
|
||||||
|
const current = result.status === 0 ? result.stdout.trim().split('.')[0] : '';
|
||||||
|
if (wanted === current) process.exit(0);
|
||||||
|
|
||||||
|
const installCommand =
|
||||||
|
process.platform === 'win32'
|
||||||
|
? 'powershell -c "irm https://get.pnpm.io/install.ps1 | iex"'
|
||||||
|
: 'curl -fsSL https://get.pnpm.io/install.sh | sh -';
|
||||||
|
|
||||||
|
console.log(`
|
||||||
|
⚠ This repo now pins pnpm ${wanted} (you have ${current || 'none'}).
|
||||||
|
pnpm should auto-download it on the next install command.
|
||||||
|
If that fails (some pnpm 10 versions can't install v12), reinstall:
|
||||||
|
${installCommand}
|
||||||
|
`);
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
#!/bin/sh
|
|
||||||
# .githooks/check-pnpm — symlink or call from post-merge, post-rewrite, post-checkout
|
|
||||||
|
|
||||||
WANTED=$(sed -n 's/.*"packageManager": *"pnpm@\([0-9]*\).*/\1/p' package.json)
|
|
||||||
CURRENT=$(cd ~ && pnpm --version 2>/dev/null | cut -d. -f1)
|
|
||||||
[ -z "$WANTED" ] || [ "$WANTED" = "$CURRENT" ] && exit 0
|
|
||||||
echo ""
|
|
||||||
echo "⚠ This repo now pins pnpm $WANTED (you have ${CURRENT:-none})."
|
|
||||||
echo " pnpm should auto-download it on the next install command."
|
|
||||||
echo " If that fails (some pnpm 10 versions can't install v12), reinstall:"
|
|
||||||
echo " curl -fsSL https://get.pnpm.io/install.sh | sh -"
|
|
||||||
echo ""
|
|
||||||
exit 0
|
|
||||||
7
.github/CI-TELEMETRY.md
vendored
7
.github/CI-TELEMETRY.md
vendored
|
|
@ -81,11 +81,12 @@ return 'blacksmith';
|
||||||
|
|
||||||
| Telemetry | Source | Metrics |
|
| Telemetry | Source | Metrics |
|
||||||
|-----------|--------|---------|
|
|-----------|--------|---------|
|
||||||
| Playwright perf/benchmark | `packages/testing/playwright/reporters/metrics-reporter.ts` | Any metric attached via `attachMetric()` |
|
| Playwright perf/benchmark | `packages/quality/testing/playwright/reporters/metrics-reporter.ts` | Any metric attached via `attachMetric()` |
|
||||||
| Accessibility buckets | `packages/testing/playwright/reporters/a11y-reporter.ts` | Per-bucket axe score, violated rules, violating elements |
|
| Accessibility buckets | `packages/quality/testing/playwright/reporters/a11y-reporter.ts` | Per-bucket axe score, violated rules, violating elements |
|
||||||
| Build stats | `.github/scripts/send-build-stats.mjs` | Per-package build duration, cache hit/miss, run total |
|
| Build stats | `.github/scripts/send-build-stats.mjs` | Per-package build duration, cache hit/miss, run total |
|
||||||
| Docker stats | `.github/scripts/send-docker-stats.mjs` | Image size per platform, docker build duration |
|
| Docker stats | `.github/scripts/send-docker-stats.mjs` | Image size per platform, docker build duration |
|
||||||
| Container stack | `packages/testing/containers/telemetry.ts` | E2E stack startup times per service |
|
| Dive image efficiency | `packages/quality/efficiency/images/image-efficiency.spec.ts` | Layer content size, estimated waste, efficiency score, and layer count through the shared Playwright reporter |
|
||||||
|
| Container stack | `packages/quality/environments/containers/telemetry.ts` | E2E stack startup times per service |
|
||||||
|
|
||||||
## Secrets
|
## Secrets
|
||||||
|
|
||||||
|
|
|
||||||
11
.github/CODEOWNERS
vendored
11
.github/CODEOWNERS
vendored
|
|
@ -1,11 +0,0 @@
|
||||||
# Temporary: kept side by side with the top-level OWNERS file while the
|
|
||||||
# "Required Reviews" enforcement is on trial. Do not add entries here;
|
|
||||||
# add them to OWNERS. See "Transition from CODEOWNERS" in WORKFLOWS.md.
|
|
||||||
packages/@n8n/db/src/migrations/ @n8n-io/migrations-review
|
|
||||||
/OWNERS @n8n-io/qa-dx
|
|
||||||
.github/workflows @n8n-io/qa-dx
|
|
||||||
.github/scripts @n8n-io/qa-dx
|
|
||||||
.github/actions @n8n-io/qa-dx
|
|
||||||
.github/poutine-rules @n8n-io/qa-dx
|
|
||||||
docker/get-n8n.sh @n8n-io/qa-dx
|
|
||||||
.code-health-baseline.json @n8n-io/qa-dx
|
|
||||||
125
.github/WORKFLOWS.md
vendored
125
.github/WORKFLOWS.md
vendored
|
|
@ -10,7 +10,6 @@ Complete reference for n8n's `.github/` folder.
|
||||||
.github/
|
.github/
|
||||||
├── WORKFLOWS.md # This document
|
├── WORKFLOWS.md # This document
|
||||||
├── CI-TELEMETRY.md # Telemetry & metrics guide
|
├── CI-TELEMETRY.md # Telemetry & metrics guide
|
||||||
├── CODEOWNERS # Temporary, side by side with OWNERS during the trial
|
|
||||||
├── pull_request_template.md # PR description template
|
├── pull_request_template.md # PR description template
|
||||||
├── pull_request_title_conventions.md # Title format rules (Angular)
|
├── pull_request_title_conventions.md # Title format rules (Angular)
|
||||||
├── actionlint.yml # Workflow linter config
|
├── actionlint.yml # Workflow linter config
|
||||||
|
|
@ -178,7 +177,7 @@ These only run if specific files changed:
|
||||||
| `docker/images/n8n-base/Dockerfile` | `build-base-image.yml` | any |
|
| `docker/images/n8n-base/Dockerfile` | `build-base-image.yml` | any |
|
||||||
| `**/package.json`, `**/turbo.json` | `build-windows.yml` | master |
|
| `**/package.json`, `**/turbo.json` | `build-windows.yml` | master |
|
||||||
| `packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/**` | `test-evals-python.yml` | any |
|
| `packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/**` | `test-evals-python.yml` | any |
|
||||||
| `packages/@n8n/benchmark/**` | `build-benchmark-image.yml` | master |
|
| `packages/quality/efficiency/scale/benchmark/**` | `build-benchmark-image.yml` | master |
|
||||||
| `packages/cli/src/public-api/**/*.yml`, `packages/cli/src/public-api/**/*.yaml`, `packages/cli/src/public-api/**/*.css`, `packages/cli/src/public-api/v1/openapi-gen/**/*.ts`, `packages/cli/scripts/build.mjs`, `packages/cli/package.json` | `util-publish-api-schema.yml` | master |
|
| `packages/cli/src/public-api/**/*.yml`, `packages/cli/src/public-api/**/*.yaml`, `packages/cli/src/public-api/**/*.css`, `packages/cli/src/public-api/v1/openapi-gen/**/*.ts`, `packages/cli/scripts/build.mjs`, `packages/cli/package.json` | `util-publish-api-schema.yml` | master |
|
||||||
| `packages/@n8n/instance-ai/src/**`, `packages/@n8n/instance-ai/skills/**`, `packages/@n8n/instance-ai/knowledge-base/**`, `packages/@n8n/instance-ai/evaluations/**`, `packages/cli/src/modules/instance-ai/**`, `packages/core/src/execution-engine/eval-mock-helpers.ts`, `packages/@n8n/agents/src/**` | `ci-instance-ai-evals.yml` | on PR `opened` / `reopened` / `ready_for_review` |
|
| `packages/@n8n/instance-ai/src/**`, `packages/@n8n/instance-ai/skills/**`, `packages/@n8n/instance-ai/knowledge-base/**`, `packages/@n8n/instance-ai/evaluations/**`, `packages/cli/src/modules/instance-ai/**`, `packages/core/src/execution-engine/eval-mock-helpers.ts`, `packages/@n8n/agents/src/**` | `ci-instance-ai-evals.yml` | on PR `opened` / `reopened` / `ready_for_review` |
|
||||||
| `docker/get-n8n.sh`, `docker/get-n8n-compose.yml`, `docker/test-get-n8n.sh` | `test-get-n8n.yml` | any |
|
| `docker/get-n8n.sh`, `docker/get-n8n-compose.yml`, `docker/test-get-n8n.sh` | `test-get-n8n.yml` | any |
|
||||||
|
|
@ -237,14 +236,6 @@ parallelism). See the `--build-via-mcp` section in
|
||||||
| PR merged to `release/*` | `release-publish.yml` |
|
| PR merged to `release/*` | `release-publish.yml` |
|
||||||
| PR closed with `codespace-preview` | `util-codespace-preview.yml` |
|
| PR closed with `codespace-preview` | `util-codespace-preview.yml` |
|
||||||
|
|
||||||
### Manual Triggers (PR Comments)
|
|
||||||
|
|
||||||
| Command | Workflow | Permissions |
|
|
||||||
|--------------------|------------------------------|---------------------|
|
|
||||||
| `/test-workflows` | `test-workflows-callable.yml`| admin/write/maintain|
|
|
||||||
|
|
||||||
**Why:** Re-run tests without pushing commits. Useful for flaky test investigation.
|
|
||||||
|
|
||||||
### Label Triggers
|
### Label Triggers
|
||||||
|
|
||||||
| Label | Workflow | Effect |
|
| Label | Workflow | Effect |
|
||||||
|
|
@ -420,7 +411,9 @@ Run-workflow dropdown, which only a user with write access can pick.
|
||||||
|-----------------------------|---------------------------------------------------------|
|
|-----------------------------|---------------------------------------------------------|
|
||||||
| `util-codespace-preview.yml`| Wake, re-serve or delete a PR preview instance by hand |
|
| `util-codespace-preview.yml`| Wake, re-serve or delete a PR preview instance by hand |
|
||||||
| `util-data-tooling.yml` | SQLite/PostgreSQL export/import validation (manual) |
|
| `util-data-tooling.yml` | SQLite/PostgreSQL export/import validation (manual) |
|
||||||
|
| `test-image-efficiency.yml` | Measure n8n and runners image size and layer waste with Dive |
|
||||||
| `util-probe-registry.yml` | Diagnose slow npm metadata fetches (temporary) |
|
| `util-probe-registry.yml` | Diagnose slow npm metadata fetches (temporary) |
|
||||||
|
| `sec-sync-bundle-branches.yml` | Sync both bundle branches on demand, including before a bundle cut |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -454,10 +447,6 @@ test-sbom-nightly.yml
|
||||||
|
|
||||||
test-workflows-nightly.yml (manual dispatch only — nightly schedule disabled, DEVP-544)
|
test-workflows-nightly.yml (manual dispatch only — nightly schedule disabled, DEVP-544)
|
||||||
└──────────────────────────▶ test-workflows-callable.yml
|
└──────────────────────────▶ test-workflows-callable.yml
|
||||||
|
|
||||||
PR Comment Dispatchers (triggered by /command in PR comments):
|
|
||||||
test-workflows-pr-comment.yml
|
|
||||||
└──────────────────────────▶ test-workflows-callable.yml
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -575,14 +564,15 @@ out: `npm deprecate n8n@X.Y.Z "Failed release, use X.Y.(Z+1)"`.
|
||||||
|
|
||||||
## ci-master.yml
|
## ci-master.yml
|
||||||
|
|
||||||
Runs on push to `master` or `1.x`:
|
Runs on push to `master`:
|
||||||
|
|
||||||
```
|
```
|
||||||
Push to master/1.x
|
Push to master
|
||||||
├─ build-github (populate cache)
|
├─ build-and-format (Blacksmith: build, then format check; populate master cache)
|
||||||
├─ unit-test (matrix: Node 22.23.2, 24.18.1)
|
├─ unit-test (matrix: Node 24.18.1, 26.5.1)
|
||||||
│ └─ Coverage only on 24.18.1
|
│ └─ Coverage only on 24.18.1
|
||||||
├─ lint
|
├─ lint
|
||||||
|
├─ (performance: CodSpeed benchmarks, paused until the plugin supports Vitest 5)
|
||||||
├─ verify-single-instance-npm (advisory; packages changed by this push)
|
├─ verify-single-instance-npm (advisory; packages changed by this push)
|
||||||
└─ notify-on-failure (Slack #alerts-build)
|
└─ notify-on-failure (Slack #alerts-build)
|
||||||
```
|
```
|
||||||
|
|
@ -597,7 +587,7 @@ Push to master/1.x
|
||||||
| Daily 03:00 | `sec-sync-bundle-branches.yml` | Merge the base into `bundle/*` |
|
| Daily 03:00 | `sec-sync-bundle-branches.yml` | Merge the base into `bundle/*` |
|
||||||
| Daily 00:00 | `docker-build-push.yml` | Nightly Docker images |
|
| Daily 00:00 | `docker-build-push.yml` | Nightly Docker images |
|
||||||
| Daily 00:00 | `test-db.yml` | Database compatibility |
|
| Daily 00:00 | `test-db.yml` | Database compatibility |
|
||||||
| Daily 00:00 | `test-e2e-performance-reusable.yml`| Performance E2E |
|
| Daily 00:00 | `test-e2e-performance-reusable.yml`| Browser performance and idle memory baselines |
|
||||||
| Daily 00:00 | `release-storybook.yml` | Storybook deploy |
|
| Daily 00:00 | `release-storybook.yml` | Storybook deploy |
|
||||||
| Daily 00:00 | `release-chromatic.yml` | Visual regression |
|
| Daily 00:00 | `release-chromatic.yml` | Visual regression |
|
||||||
| Daily 00:00 | `util-check-docs-urls.yml` | Doc link validation |
|
| Daily 00:00 | `util-check-docs-urls.yml` | Doc link validation |
|
||||||
|
|
@ -610,6 +600,7 @@ Push to master/1.x
|
||||||
| Daily 08:00 | `build-v3-nightly.yml` | Nightly v3 Docker images |
|
| Daily 08:00 | `build-v3-nightly.yml` | Nightly v3 Docker images |
|
||||||
| Monday 00:00 | `util-update-node-popularity.yml` | Node usage stats |
|
| Monday 00:00 | `util-update-node-popularity.yml` | Node usage stats |
|
||||||
| Monday 02:00 | `test-e2e-coverage-weekly.yml` | Weekly E2E coverage |
|
| Monday 02:00 | `test-e2e-coverage-weekly.yml` | Weekly E2E coverage |
|
||||||
|
| Wednesday 04:00 | `util-update-get-n8n-versions.yml` | get-n8n stable versions |
|
||||||
| Saturday 22:00 | `test-evals-ai.yml` | AI workflow evals |
|
| Saturday 22:00 | `test-evals-ai.yml` | AI workflow evals |
|
||||||
| 1st of month 04:00 | `util-refresh-cubic-schema.yml` | Refresh vendored cubic schema |
|
| 1st of month 04:00 | `util-refresh-cubic-schema.yml` | Refresh vendored cubic schema |
|
||||||
|
|
||||||
|
|
@ -849,9 +840,8 @@ See **[CI-TELEMETRY.md](CI-TELEMETRY.md)** for:
|
||||||
|
|
||||||
## OWNERS
|
## OWNERS
|
||||||
|
|
||||||
Team ownership lives in the top-level `OWNERS` file (this replaces the
|
Team ownership lives in the top-level `OWNERS` file. The scripts that consume
|
||||||
GitHub-native `CODEOWNERS` file; see the transition note below). The scripts
|
it live in `.github/scripts/owners/`. Line format:
|
||||||
that consume it live in `.github/scripts/owners/`. Line format:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
<pattern> <@org/team> [required]
|
<pattern> <@org/team> [required]
|
||||||
|
|
@ -883,8 +873,10 @@ The file drives four workflows:
|
||||||
### Required reviews
|
### Required reviews
|
||||||
|
|
||||||
An entry with the `required` option makes team approval mandatory: when a PR
|
An entry with the `required` option makes team approval mandatory: when a PR
|
||||||
changes a file whose winning entry carries `required`, a member of each listed
|
changes a non-test file whose winning entry carries `required`, a member of each
|
||||||
team must approve the PR. `ci-owners-required-reviews.yml` evaluates this on
|
listed team must approve the PR. Test files match the shared patterns in
|
||||||
|
`test-files.mjs` and do not trigger required reviews.
|
||||||
|
`ci-owners-required-reviews.yml` evaluates this on
|
||||||
PR changes and review events, and reports a commit status
|
PR changes and review events, and reports a commit status
|
||||||
named **Required Reviews** on the head SHA. A missing approval reports
|
named **Required Reviews** on the head SHA. A missing approval reports
|
||||||
`pending` ("Waiting for approval from: …"), not `failure`, so an unreviewed PR
|
`pending` ("Waiting for approval from: …"), not `failure`, so an unreviewed PR
|
||||||
|
|
@ -914,6 +906,13 @@ so only `master` is trusted input. A PR cannot lift its own review
|
||||||
requirement. A retarget re-evaluates the PR, so a verdict computed against the
|
requirement. A retarget re-evaluates the PR, so a verdict computed against the
|
||||||
old base does not carry over.
|
old base does not carry over.
|
||||||
|
|
||||||
|
Members of the `large-scale-changes` GitHub team can make the **Required
|
||||||
|
Reviews** status succeed without required OWNERS team approvals. A team member
|
||||||
|
must apply the `large-scale-change` label. The gate checks the actor from the
|
||||||
|
label event and verifies their current team membership. The label alone is not
|
||||||
|
sufficient. Label changes re-evaluate the status. Removing the label removes
|
||||||
|
the exemption.
|
||||||
|
|
||||||
Every path that writes the status runs in the base repository context, because
|
Every path that writes the status runs in the base repository context, because
|
||||||
a fork-context run has no secrets and a read-only token. PR changes arrive
|
a fork-context run has no secrets and a read-only token. PR changes arrive
|
||||||
through `pull_request_target`, which is safe here because no step checks out
|
through `pull_request_target`, which is safe here because no step checks out
|
||||||
|
|
@ -926,16 +925,6 @@ contribution whose runs still wait for approval gets no review-event
|
||||||
re-evaluation until a maintainer approves the runs; `workflow_dispatch` with
|
re-evaluation until a maintainer approves the runs; `workflow_dispatch` with
|
||||||
the PR number is the manual fallback.
|
the PR number is the manual fallback.
|
||||||
|
|
||||||
### Transition from CODEOWNERS
|
|
||||||
|
|
||||||
During a trial period, `.github/CODEOWNERS` stays in place next to OWNERS:
|
|
||||||
GitHub's native code-owner enforcement keeps gating merges while the
|
|
||||||
"Required Reviews" status runs side by side. The two must agree — CODEOWNERS
|
|
||||||
holds exactly the `required` entries of OWNERS (plus the OWNERS file itself)
|
|
||||||
and must not gain new entries; new ownership goes into OWNERS. After the
|
|
||||||
trial, delete `.github/CODEOWNERS`, remove "Require review from Code Owners"
|
|
||||||
from the master ruleset, and delete this section (tracked in DEVP-887).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Runner Selection
|
## Runner Selection
|
||||||
|
|
@ -959,7 +948,7 @@ from the master ruleset, and delete this section (tracked in DEVP-887).
|
||||||
|
|
||||||
**`blacksmith-4vcpu-ubuntu-2204`** - Unit tests (parallelized), linting (parallel file processing), typechecking (CPU-intensive), E2E test shards
|
**`blacksmith-4vcpu-ubuntu-2204`** - Unit tests (parallelized), linting (parallel file processing), typechecking (CPU-intensive), E2E test shards
|
||||||
|
|
||||||
**`blacksmith-8vcpu-ubuntu-2204`** - Heavy parallel workloads
|
**`blacksmith-8vcpu-ubuntu-2204`** - Heavy parallel workloads. The PR `install-and-build` job uses it with the default runner provider. Most PR jobs wait for that job, and a cold build keeps all 4 vCPUs of a smaller runner busy.
|
||||||
|
|
||||||
### Runner Provider Toggle
|
### Runner Provider Toggle
|
||||||
|
|
||||||
|
|
@ -1152,40 +1141,28 @@ open the PR there. That PR **must stay a single-parent squash** — the publish
|
||||||
posts to `#alerts-build` when that gate fails on a PR opened *from* `bundle/2.x` or
|
posts to `#alerts-build` when that gate fails on a PR opened *from* `bundle/2.x` or
|
||||||
`bundle/1.x` (link only, no PR title, since the branch is embargoed).
|
`bundle/1.x` (link only, no PR title, since the branch is embargoed).
|
||||||
|
|
||||||
`sec-sync-bundle-branches.yml` keeps those branches current, daily plus whenever a PR is
|
`sec-sync-bundle-branches.yml` keeps those branches current nightly at 03:00 UTC and on
|
||||||
merged into one (and on `workflow_dispatch`). It **merges the base into** the bundle branch
|
`workflow_dispatch`, including before a bundle cut. It **merges the public base into** the
|
||||||
via [`scripts/sync-bundle-branch.mjs`](scripts/sync-bundle-branch.mjs) and pushes without
|
bundle branch via
|
||||||
forcing. Every push is verified to carry exactly the tree a merge of the two sides would
|
[`scripts/sync-bundle-branch.mjs`](scripts/sync-bundle-branch.mjs) and pushes without forcing.
|
||||||
produce (`git merge-tree`); a mismatch, or a conflict marker, fails the run instead of pushing.
|
Every push is verified to carry exactly the tree a merge of the two sides would produce (`git
|
||||||
|
merge-tree`); a mismatch, or a conflict marker, fails the run instead of pushing.
|
||||||
|
|
||||||
**A bundle branch is only ever built on published history.** Before it creates or merges
|
**A bundle branch is only ever built on published history.** The sync fetches `master` or `1.x`
|
||||||
anything, the sync fetches the same-named branch from `https://github.com/n8n-io/n8n.git`
|
directly from `https://github.com/n8n-io/n8n.git`. It creates a missing bundle branch at that
|
||||||
(anonymously — its token is scoped to the private repo) and checks that the private base tip is
|
exact public SHA and merges that public SHA into an existing bundle branch. It never reads the
|
||||||
contained in it. This matters because the `chore: Bundle/*` squash on private `master` is
|
private base branch. This matters because the `chore: Bundle/*` squash on private `master` is
|
||||||
*private-only*: the mirror above discards it in favour of the public cherry-pick of the same
|
private-only until publication. The mirror can replace that commit with the public copy after
|
||||||
changes. A sync in that window would root the branch on a commit that is about to disappear,
|
the bundle branch is deleted. The next bundle branch must not inherit the replaced commit.
|
||||||
leaving it carrying two commits for one set of fixes — and every fix PR cut from it inherits the
|
|
||||||
dead one. The window is real: this workflow's daily cron and the mirror's hourly cron both fire
|
|
||||||
at `:00` with nothing ordering them, which is why the check lives in the script rather than in
|
|
||||||
step ordering.
|
|
||||||
|
|
||||||
An unpublished cut is a **skip**, not a failure: the mirror discards that commit every hour
|
|
||||||
regardless, so the run exits green with the reason in the log and the next one proceeds. A
|
|
||||||
missing branch also self-heals, because the mirror re-dispatches this workflow while one is
|
|
||||||
absent. A base ahead of public for **any other reason** fails the run — the mirror is stuck and
|
|
||||||
will not clear it on its own, so fix that first (remove the commit, or dispatch **Security: Sync
|
|
||||||
from Public** with `force`) and re-run. To sync a bundle branch immediately, dispatch the mirror
|
|
||||||
and then this workflow. The blocking commits are listed in the run log; the failure annotation
|
|
||||||
carries only a count, since a subject hints at the fix.
|
|
||||||
|
|
||||||
Deleting a bundle branch takes its open PRs with it: GitHub moves each one onto the deleted
|
Deleting a bundle branch takes its open PRs with it: GitHub moves each one onto the deleted
|
||||||
branch's own base, and re-creating the branch does not move them back. So the sync then calls
|
branch's own base, and re-creating the branch does not move them back. The next nightly or manual
|
||||||
|
sync re-creates the branch and then calls
|
||||||
[`sec-sync-retarget-prs.yml`](workflows/sec-sync-retarget-prs.yml), which moves every open PR
|
[`sec-sync-retarget-prs.yml`](workflows/sec-sync-retarget-prs.yml), which moves every open PR
|
||||||
on `master` onto `bundle/2.x` and every one on `1.x` onto `bundle/1.x`. It skips a bundle
|
on `master` onto `bundle/2.x` and every one on `1.x` onto `bundle/1.x`. It skips a bundle
|
||||||
branch that does not exist, and skips PRs whose *head* is `bundle/*` — those are the
|
branch that does not exist, and skips PRs whose *head* is `bundle/*` — those are the
|
||||||
`chore: Bundle/*` cut PRs, which target the base on purpose. It is also dispatchable on its
|
`chore: Bundle/*` cut PRs, which target the base on purpose. It is also dispatchable on its
|
||||||
own. `sec-sync-public-to-private.yml` only *dispatches* the bundle sync when it finds a branch
|
own. The retarget runs inside the bundle sync after a missing branch is recreated.
|
||||||
missing; the retarget runs inside that dispatched run, once the branch exists.
|
|
||||||
|
|
||||||
**`bundle/*` is append-only — never rebase it, never force-push it.** These branches receive
|
**`bundle/*` is append-only — never rebase it, never force-push it.** These branches receive
|
||||||
PRs, and rewriting a branch that receives PRs orphans the copies of its commits that the open
|
PRs, and rewriting a branch that receives PRs orphans the copies of its commits that the open
|
||||||
|
|
@ -1198,21 +1175,19 @@ itself. To refresh a fix branch, use GitHub's **Update branch** button or
|
||||||
sibling PR's merge base untouched, which is why only a rewrite breaks this.
|
sibling PR's merge base untouched, which is why only a rewrite breaks this.
|
||||||
|
|
||||||
The costs of merging are deliberate and paid for: a merge commit per run, and fixes that have
|
The costs of merging are deliberate and paid for: a merge commit per run, and fixes that have
|
||||||
already been published staying in the branch's log (the old rebase dropped them as empty
|
already been published staying in the branch's log. Neither reaches anything downstream,
|
||||||
commits). Neither reaches anything downstream, because a bundle publishes as one squashed
|
because a bundle publishes as one squashed commit taken from the tree rather than the history —
|
||||||
commit taken from the tree rather than the history — the `chore: Bundle/*` PR's **diff** stays
|
the `chore: Bundle/*` PR's **diff** stays
|
||||||
exactly the pending fixes even when its commit list does not. For a list of what a bundle
|
exactly the pending fixes even when its commit list does not. For a list of what a bundle
|
||||||
actually carries, read the fix PRs merged into the branch since the last cut, not
|
actually carries, read the fix PRs merged into the branch since the last cut, not `base..bundle`.
|
||||||
`base..bundle`. A lower cadence than the base's is fine too: a base push never re-triggered
|
|
||||||
CI on the fix PRs, so syncing more often bought them nothing.
|
|
||||||
|
|
||||||
There is **one job per bundle branch**. A conflict is detected from the merge tree before the
|
There is **one job per bundle branch**. A conflict is detected from the merge tree before the
|
||||||
working tree is touched, so the branch is left exactly as it was, that job **fails** (no
|
working tree is touched, so the branch is left exactly as it was, that job **fails** (no
|
||||||
green runs hiding a stalled branch) and `#alerts-security` gets a run link — while the other
|
green runs hiding a stalled branch) and `#alerts-security` gets a run link — while the other
|
||||||
branch still syncs. Recovery is deliberate: merge the base into the branch locally, resolve,
|
branch still syncs. Recovery is deliberate: merge the public base into the branch locally,
|
||||||
push, then re-run the workflow — and that resolution then lives in the merge commit instead of
|
resolve, push, then re-run the workflow — and that resolution then lives in the merge commit
|
||||||
being re-litigated on every later run. The sync never resolves a conflict itself, unlike
|
instead of being re-litigated on every later run. The sync never resolves a conflict itself,
|
||||||
`util-sync-master-to-3x.yml`.
|
unlike `util-sync-master-to-3x.yml`.
|
||||||
|
|
||||||
See **[`../AGENTS.md`](../AGENTS.md)** ("Security Fix Hygiene") for the naming rules that
|
See **[`../AGENTS.md`](../AGENTS.md)** ("Security Fix Hygiene") for the naming rules that
|
||||||
keep the vulnerability out of public branch names, commits, and test descriptions.
|
keep the vulnerability out of public branch names, commits, and test descriptions.
|
||||||
|
|
@ -1282,12 +1257,6 @@ Adding a new channel requires inviting the bot first; the first run otherwise fa
|
||||||
|
|
||||||
## Future Vision
|
## Future Vision
|
||||||
|
|
||||||
### Redundancy Review
|
|
||||||
|
|
||||||
Comment trigger (`/test-workflows`) is a workaround.
|
|
||||||
|
|
||||||
Long-term: Main CI should be reliable enough to not need these.
|
|
||||||
|
|
||||||
### Workflow Testability
|
### Workflow Testability
|
||||||
|
|
||||||
- Tools like `act` for local testing
|
- Tools like `act` for local testing
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,15 @@ describe('matchGlob', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('backend module pattern matches packaged module files', () => {
|
||||||
|
assert.ok(
|
||||||
|
matchGlob(
|
||||||
|
'packages/modules/insights/backend/src/insights.service.ts',
|
||||||
|
'packages/modules/*/backend/**',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('* matches single-level only', () => {
|
it('* matches single-level only', () => {
|
||||||
assert.ok(matchGlob('README.md', '*.md'));
|
assert.ok(matchGlob('README.md', '*.md'));
|
||||||
assert.ok(!matchGlob('docs/README.md', '*.md'));
|
assert.ok(!matchGlob('docs/README.md', '*.md'));
|
||||||
|
|
@ -242,7 +251,7 @@ describe('runtime filter', () => {
|
||||||
'!**/CHANGELOG.md',
|
'!**/CHANGELOG.md',
|
||||||
'!**/*.test.ts',
|
'!**/*.test.ts',
|
||||||
'!**/*.spec.ts',
|
'!**/*.spec.ts',
|
||||||
'!packages/testing/playwright/**',
|
'!packages/quality/testing/playwright/**',
|
||||||
'!packages/frontend/@n8n/storybook/**',
|
'!packages/frontend/@n8n/storybook/**',
|
||||||
'!scripts/agent-setup.mjs',
|
'!scripts/agent-setup.mjs',
|
||||||
'!scripts/backend-module/**',
|
'!scripts/backend-module/**',
|
||||||
|
|
@ -265,7 +274,7 @@ describe('runtime filter', () => {
|
||||||
|
|
||||||
it('does not trigger on playwright tests', () => {
|
it('does not trigger on playwright tests', () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
evaluateFilter(['packages/testing/playwright/tests/x.spec.ts'], runtimePatterns),
|
evaluateFilter(['packages/quality/testing/playwright/tests/x.spec.ts'], runtimePatterns),
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
22
.github/actions/ci-filter/ci-filter.mjs
vendored
22
.github/actions/ci-filter/ci-filter.mjs
vendored
|
|
@ -143,8 +143,8 @@ export function getAddedFiles(baseRef, headRef = 'HEAD') {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deepen the shallow clone until the merge base between the fetched base ref
|
* Fetch the base ref, then deepen only if its merge base with headRef is not
|
||||||
* (FETCH_HEAD) and headRef is reliably reachable.
|
* reliably reachable from the checkout.
|
||||||
*
|
*
|
||||||
* A single fixed deepen is not enough for stale PRs whose divergence point is
|
* A single fixed deepen is not enough for stale PRs whose divergence point is
|
||||||
* older than the shallow boundary. We fetch with an exponentially growing
|
* older than the shallow boundary. We fetch with an exponentially growing
|
||||||
|
|
@ -159,7 +159,12 @@ export function getAddedFiles(baseRef, headRef = 'HEAD') {
|
||||||
function fetchUntilMergeBase(baseRef, headRef) {
|
function fetchUntilMergeBase(baseRef, headRef) {
|
||||||
let step = Number(process.env.CI_FILTER_DEEPEN_STEP) || 200;
|
let step = Number(process.env.CI_FILTER_DEEPEN_STEP) || 200;
|
||||||
const maxDeepen = Number(process.env.CI_FILTER_MAX_DEEPEN) || 20_000;
|
const maxDeepen = Number(process.env.CI_FILTER_MAX_DEEPEN) || 20_000;
|
||||||
deepenFetch(baseRef, step, maxDeepen);
|
// A PR merge checkout already has the base commit as its first parent.
|
||||||
|
const hasBaseCommit = headRef === 'HEAD^2' && hasCommit('HEAD^1');
|
||||||
|
const depth = isShallow() && !hasBaseCommit ? `--depth=${step} ` : '';
|
||||||
|
execSync(`git fetch --no-tags --prune --filter=blob:none ${depth}origin ${baseRef}`, {
|
||||||
|
stdio: 'pipe',
|
||||||
|
});
|
||||||
|
|
||||||
while (!hasReliableMergeBase(headRef)) {
|
while (!hasReliableMergeBase(headRef)) {
|
||||||
if (!isShallow()) {
|
if (!isShallow()) {
|
||||||
|
|
@ -167,8 +172,17 @@ function fetchUntilMergeBase(baseRef, headRef) {
|
||||||
`No merge base between FETCH_HEAD and ${headRef} after fetching the full history of "${baseRef}" (unrelated histories).`,
|
`No merge base between FETCH_HEAD and ${headRef} after fetching the full history of "${baseRef}" (unrelated histories).`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
step *= 2;
|
|
||||||
deepenFetch(baseRef, step, maxDeepen);
|
deepenFetch(baseRef, step, maxDeepen);
|
||||||
|
step *= 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasCommit(ref) {
|
||||||
|
try {
|
||||||
|
execSync(`git cat-file -e ${ref}^{commit}`, { stdio: 'pipe' });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
2
.github/actions/setup-nodejs/action.yml
vendored
2
.github/actions/setup-nodejs/action.yml
vendored
|
|
@ -171,7 +171,7 @@ runs:
|
||||||
|
|
||||||
- name: Restore pnpm metadata cache
|
- name: Restore pnpm metadata cache
|
||||||
if: ${{ inputs.install-command != '' }}
|
if: ${{ inputs.install-command != '' }}
|
||||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||||
with:
|
with:
|
||||||
path: ${{ steps.pnpm-metadata-cache.outputs.path }}
|
path: ${{ steps.pnpm-metadata-cache.outputs.path }}
|
||||||
key: pnpm-metadata-v1-${{ runner.os }}-${{ runner.arch }}-${{ steps.pnpm-metadata-cache.outputs.version }}-${{ hashFiles(inputs.cache-dependency-path) }}
|
key: pnpm-metadata-v1-${{ runner.os }}-${{ runner.arch }}-${{ steps.pnpm-metadata-cache.outputs.version }}-${{ hashFiles(inputs.cache-dependency-path) }}
|
||||||
|
|
|
||||||
2
.github/docker-compose.yml
vendored
2
.github/docker-compose.yml
vendored
|
|
@ -1,6 +1,6 @@
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: ${TEST_IMAGE_POSTGRES:?set it from packages/testing/containers/postgres-versions.json}
|
image: ${TEST_IMAGE_POSTGRES:?set it from packages/quality/environments/containers/postgres-versions.json}
|
||||||
restart: always
|
restart: always
|
||||||
environment:
|
environment:
|
||||||
- POSTGRES_DB=n8n
|
- POSTGRES_DB=n8n
|
||||||
|
|
|
||||||
11
.github/pull_request_template.md
vendored
11
.github/pull_request_template.md
vendored
|
|
@ -1,8 +1,15 @@
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Describe what the PR does.
|
Start with 1 to 3 sentences of background for a new reader. Then describe what the PR does, in prose, not as a list of edits.
|
||||||
Photos and videos are recommended.
|
Add photos or videos for UI changes.
|
||||||
|
-->
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
<!--
|
||||||
|
State the problem or the constraint that makes the current code unfit. Do not repeat the Summary.
|
||||||
|
Add a rejected alternative only when a reviewer would ask about it, in one or two sentences.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
## How to test
|
## How to test
|
||||||
|
|
|
||||||
13
.github/scripts/db-test-matrix.mjs
vendored
13
.github/scripts/db-test-matrix.mjs
vendored
|
|
@ -7,11 +7,16 @@ import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||||
|
|
||||||
export const POSTGRES_VERSIONS_PATH = 'packages/testing/containers/postgres-versions.json';
|
export const POSTGRES_VERSIONS_PATH = 'packages/quality/environments/containers/postgres-versions.json';
|
||||||
|
|
||||||
/** The matrix wall is bounded by its slowest leg, so a larger runner buys nothing. */
|
/** The matrix wall is bounded by its slowest leg, so a larger runner buys nothing. */
|
||||||
const RUNNER = 'blacksmith-4vcpu-ubuntu-2204';
|
const RUNNER = 'blacksmith-4vcpu-ubuntu-2204';
|
||||||
|
|
||||||
|
// Each leg runs one suite on its own runner, so use every CPU. The CI default
|
||||||
|
// (half the CPUs) is for jobs that run many suites at once. Every test file
|
||||||
|
// gets its own database, so more workers do not share state.
|
||||||
|
const MAX_WORKERS = '--maxWorkers=100%';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {Object} PostgresVersions
|
* @typedef {Object} PostgresVersions
|
||||||
* @property {string} primary
|
* @property {string} primary
|
||||||
|
|
@ -86,7 +91,7 @@ export function buildMatrix(versions, scope = 'full') {
|
||||||
{
|
{
|
||||||
name: 'SQLite Pooled',
|
name: 'SQLite Pooled',
|
||||||
runner: RUNNER,
|
runner: RUNNER,
|
||||||
'test-cmd': 'pnpm test:sqlite',
|
'test-cmd': `pnpm test:sqlite ${MAX_WORKERS}`,
|
||||||
'migration-cmd': 'pnpm test:sqlite:migrations',
|
'migration-cmd': 'pnpm test:sqlite:migrations',
|
||||||
'schema-check-cmd': 'pnpm --filter=@n8n/db schema:check:sqlite',
|
'schema-check-cmd': 'pnpm --filter=@n8n/db schema:check:sqlite',
|
||||||
TEST_IMAGE_POSTGRES: undefined,
|
TEST_IMAGE_POSTGRES: undefined,
|
||||||
|
|
@ -95,8 +100,8 @@ export function buildMatrix(versions, scope = 'full') {
|
||||||
...postgresLegs.map(({ major, image }) => ({
|
...postgresLegs.map(({ major, image }) => ({
|
||||||
name: `Postgres ${major}`,
|
name: `Postgres ${major}`,
|
||||||
runner: RUNNER,
|
runner: RUNNER,
|
||||||
'test-cmd': 'pnpm test:postgres:integration:tc',
|
'test-cmd': `pnpm test:postgres:integration:tc ${MAX_WORKERS}`,
|
||||||
'migration-cmd': 'pnpm test:postgres:migrations:tc',
|
'migration-cmd': `pnpm test:postgres:migrations:tc ${MAX_WORKERS}`,
|
||||||
'schema-check-cmd': image === primary ? 'pnpm --filter=@n8n/db schema:check:postgres' : '',
|
'schema-check-cmd': image === primary ? 'pnpm --filter=@n8n/db schema:check:postgres' : '',
|
||||||
TEST_IMAGE_POSTGRES: image,
|
TEST_IMAGE_POSTGRES: image,
|
||||||
collectCoverage: scope === 'pr' && image === primary ? 'true' : 'false',
|
collectCoverage: scope === 'pr' && image === primary ? 'true' : 'false',
|
||||||
|
|
|
||||||
17
.github/scripts/github-helpers.mjs
vendored
17
.github/scripts/github-helpers.mjs
vendored
|
|
@ -490,6 +490,23 @@ export async function getPrReviews(pullRequestNumber) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all events for a PR, including label changes and their actors.
|
||||||
|
*
|
||||||
|
* @param { number } pullRequestNumber
|
||||||
|
* @returns { Promise<Array<{ event: string, actor: { login: string } | null, label?: { name?: string } | null, created_at?: string }>> }
|
||||||
|
* */
|
||||||
|
export async function getPrEvents(pullRequestNumber) {
|
||||||
|
const { octokit, owner, repo } = initGithub();
|
||||||
|
|
||||||
|
return await octokit.paginate(octokit.rest.issues.listEvents, {
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
issue_number: pullRequestNumber,
|
||||||
|
per_page: 100,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Test whether a user is an active member of an org team.
|
* Test whether a user is an active member of an org team.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
28
.github/scripts/github-helpers.test.mjs
vendored
28
.github/scripts/github-helpers.test.mjs
vendored
|
|
@ -20,6 +20,7 @@ mock.module('@actions/github', {
|
||||||
|
|
||||||
const {
|
const {
|
||||||
findCommentByMarker,
|
findCommentByMarker,
|
||||||
|
getPrEvents,
|
||||||
initGithub,
|
initGithub,
|
||||||
postOrUpdateComment,
|
postOrUpdateComment,
|
||||||
setOctokit,
|
setOctokit,
|
||||||
|
|
@ -29,6 +30,33 @@ const {
|
||||||
|
|
||||||
const ORIGINAL_ENV = { ...process.env };
|
const ORIGINAL_ENV = { ...process.env };
|
||||||
|
|
||||||
|
describe('getPrEvents', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.GITHUB_REPOSITORY = 'n8n-io/n8n';
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
setOctokit(null);
|
||||||
|
process.env = { ...ORIGINAL_ENV };
|
||||||
|
});
|
||||||
|
|
||||||
|
it('paginates issue events for the pull request', async () => {
|
||||||
|
const events = [{ event: 'labeled', actor: { login: 'ana' } }];
|
||||||
|
const listEvents = {};
|
||||||
|
const paginate = mock.fn(async () => events);
|
||||||
|
setOctokit({ paginate, rest: { issues: { listEvents } } });
|
||||||
|
|
||||||
|
assert.equal(await getPrEvents(42), events);
|
||||||
|
assert.equal(paginate.mock.calls[0].arguments[0], listEvents);
|
||||||
|
assert.deepEqual(paginate.mock.calls[0].arguments[1], {
|
||||||
|
owner: 'n8n-io',
|
||||||
|
repo: 'n8n',
|
||||||
|
issue_number: 42,
|
||||||
|
per_page: 100,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('findCommentByMarker', () => {
|
describe('findCommentByMarker', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
process.env.GITHUB_TOKEN = 'token';
|
process.env.GITHUB_TOKEN = 'token';
|
||||||
|
|
|
||||||
10
.github/scripts/glob.test.mjs
vendored
10
.github/scripts/glob.test.mjs
vendored
|
|
@ -30,9 +30,9 @@ describe('matchesGlob', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('matches a prefix pattern', () => {
|
it('matches a prefix pattern', () => {
|
||||||
assert.equal(matchesGlob('packages/testing/playwright/a.ts', 'packages/testing/**'), true);
|
assert.equal(matchesGlob('packages/quality/testing/playwright/a.ts', 'packages/quality/**'), true);
|
||||||
assert.equal(matchesGlob('packages/testing', 'packages/testing/**'), false);
|
assert.equal(matchesGlob('packages/quality', 'packages/quality/**'), false);
|
||||||
assert.equal(matchesGlob('packages/cli/a.ts', 'packages/testing/**'), false);
|
assert.equal(matchesGlob('packages/cli/a.ts', 'packages/quality/**'), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('matches a literal path only exactly', () => {
|
it('matches a literal path only exactly', () => {
|
||||||
|
|
@ -55,8 +55,8 @@ describe('matchesGlob', () => {
|
||||||
'packages/cli/__snapshots__/a.ts.snap',
|
'packages/cli/__snapshots__/a.ts.snap',
|
||||||
'packages/nodes-base/nodes/Foo/test/fixtures/a.json',
|
'packages/nodes-base/nodes/Foo/test/fixtures/a.json',
|
||||||
'packages/frontend/editor-ui/src/__mocks__/a.ts',
|
'packages/frontend/editor-ui/src/__mocks__/a.ts',
|
||||||
'packages/testing/playwright/a.ts',
|
'packages/quality/testing/playwright/a.ts',
|
||||||
'packages/testing',
|
'packages/quality',
|
||||||
'.github/scripts/glob.test.mjs',
|
'.github/scripts/glob.test.mjs',
|
||||||
'.github/workflows/ci.yml',
|
'.github/workflows/ci.yml',
|
||||||
'a/.hidden/b.spec.js',
|
'a/.hidden/b.spec.js',
|
||||||
|
|
|
||||||
6
.github/scripts/owners/owners.mjs
vendored
6
.github/scripts/owners/owners.mjs
vendored
|
|
@ -1,6 +1,8 @@
|
||||||
import { readFileSync, statSync } from "node:fs";
|
import { readFileSync, statSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
import { isTestFile } from "../test-files.mjs";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef OwnersEntry
|
* @typedef OwnersEntry
|
||||||
* @property { string } pattern
|
* @property { string } pattern
|
||||||
|
|
@ -236,7 +238,7 @@ export function assignOwnership(files, entries) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determine which teams must approve the changeset: a team is required when
|
* Determine which teams must approve the changeset: a team is required when
|
||||||
* its `required` entry wins (last-match) for a changed file.
|
* its `required` entry wins (last-match) for a changed non-test file.
|
||||||
*
|
*
|
||||||
* @param { Set<string> } files
|
* @param { Set<string> } files
|
||||||
* @param { OwnersEntry[] } entries
|
* @param { OwnersEntry[] } entries
|
||||||
|
|
@ -247,6 +249,8 @@ export function resolveRequiredTeams(files, entries) {
|
||||||
const teamToFiles = new Map();
|
const teamToFiles = new Map();
|
||||||
|
|
||||||
for (const file of [...files].sort()) {
|
for (const file of [...files].sort()) {
|
||||||
|
if (isTestFile(file)) continue;
|
||||||
|
|
||||||
const entry = findOwningEntry(file, entries);
|
const entry = findOwningEntry(file, entries);
|
||||||
if (!entry?.required) continue;
|
if (!entry?.required) continue;
|
||||||
|
|
||||||
|
|
|
||||||
16
.github/scripts/owners/owners.test.mjs
vendored
16
.github/scripts/owners/owners.test.mjs
vendored
|
|
@ -293,6 +293,22 @@ describe('resolveRequiredTeams', () => {
|
||||||
assert.equal(result.size, 0);
|
assert.equal(result.size, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('ignores test files whose winning entry is required', () => {
|
||||||
|
const result = resolveRequiredTeams(
|
||||||
|
new Set([
|
||||||
|
'.github/scripts/check.test.mjs',
|
||||||
|
'.github/workflows/__tests__/check.ts',
|
||||||
|
'.github/workflows/ci.yml',
|
||||||
|
]),
|
||||||
|
[
|
||||||
|
entry({ pattern: '.github/scripts/', team: '@n8n-io/qa-dx', required: true }),
|
||||||
|
entry({ pattern: '.github/workflows/', team: '@n8n-io/qa-dx', required: true }),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(result.get('@n8n-io/qa-dx'), ['.github/workflows/ci.yml']);
|
||||||
|
});
|
||||||
|
|
||||||
it('a later non-required entry overrides an earlier required one', () => {
|
it('a later non-required entry overrides an earlier required one', () => {
|
||||||
const result = resolveRequiredTeams(new Set(['db/migrations/1-init.ts']), [
|
const result = resolveRequiredTeams(new Set(['db/migrations/1-init.ts']), [
|
||||||
entry({ pattern: 'db/', team: '@n8n-io/migrations-review', required: true }),
|
entry({ pattern: 'db/', team: '@n8n-io/migrations-review', required: true }),
|
||||||
|
|
|
||||||
48
.github/scripts/owners/required-reviews.mjs
vendored
48
.github/scripts/owners/required-reviews.mjs
vendored
|
|
@ -1,15 +1,16 @@
|
||||||
/**
|
/**
|
||||||
* Required-review enforcement for OWNERS entries marked `required`.
|
* Required-review enforcement for OWNERS entries marked `required`.
|
||||||
*
|
*
|
||||||
* Computes the teams whose approval the changeset needs (per OWNERS)
|
* Computes the teams whose approval the non-test changes need (per OWNERS)
|
||||||
* and reports the verdict as a "Required Reviews" commit status on the PR
|
* and reports the verdict as a "Required Reviews" commit status on the PR
|
||||||
* head SHA. A ruleset that lists this status as a required check blocks the
|
* head SHA. A ruleset that lists this status as a required check blocks the
|
||||||
* merge until a member of each required team has approved the PR. Merge-queue
|
* merge until a member of each required team has approved the PR. Merge-queue
|
||||||
* runs do not reach this script: the workflow reports success on the queue
|
* runs do not reach this script: the workflow reports success on the queue
|
||||||
* head directly, because entering the queue already required a green status.
|
* head directly, because entering the queue already required a green status.
|
||||||
*
|
*
|
||||||
* Every base branch is enforced unless the PR matches a route in
|
* Every base branch is enforced unless the PR matches a trusted exemption.
|
||||||
* REQUIRED_REVIEW_EXEMPTIONS; an exempt PR reports success without evaluation.
|
* Exemptions include configured routes and an authorized large-scale-change
|
||||||
|
* label. An exempt PR reports success without approval evaluation.
|
||||||
*
|
*
|
||||||
* The job itself always succeeds when the evaluation runs; the commit status
|
* The job itself always succeeds when the evaluation runs; the commit status
|
||||||
* carries the verdict. The status is set to pending before the evaluation
|
* carries the verdict. The status is set to pending before the evaluation
|
||||||
|
|
@ -20,15 +21,19 @@
|
||||||
import {
|
import {
|
||||||
getChangedFiles,
|
getChangedFiles,
|
||||||
getEventFromGithubEventPath,
|
getEventFromGithubEventPath,
|
||||||
|
getPrEvents,
|
||||||
getPrReviews,
|
getPrReviews,
|
||||||
getPullRequestById,
|
getPullRequestById,
|
||||||
isTeamMember,
|
isTeamMember,
|
||||||
listOpenPullRequestsByHead,
|
listOpenPullRequestsByHead,
|
||||||
|
readPrLabels,
|
||||||
setCommitStatus,
|
setCommitStatus,
|
||||||
} from '../github-helpers.mjs';
|
} from '../github-helpers.mjs';
|
||||||
import { parseOwnersFile, resolveRequiredTeams, teamHandleToSlug } from './owners.mjs';
|
import { parseOwnersFile, resolveRequiredTeams, teamHandleToSlug } from './owners.mjs';
|
||||||
|
|
||||||
export const STATUS_CONTEXT = 'Required Reviews';
|
export const STATUS_CONTEXT = 'Required Reviews';
|
||||||
|
export const LARGE_SCALE_CHANGE_LABEL = 'large-scale-change';
|
||||||
|
export const LARGE_SCALE_CHANGES_TEAM = 'large-scale-changes';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PR routes that skip the evaluation. Each entry is `<head> -> <base>` or
|
* PR routes that skip the evaluation. Each entry is `<head> -> <base>` or
|
||||||
|
|
@ -160,6 +165,32 @@ export function collectApprovers(reviews) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the team member who applied the active large-scale-change label.
|
||||||
|
* The current membership check makes the exemption expire when the actor
|
||||||
|
* leaves the team.
|
||||||
|
*
|
||||||
|
* @param { number } pullRequestNumber
|
||||||
|
* @param {{ labels?: Array<string | { name: string }> }} pullRequest
|
||||||
|
* @returns { Promise<string | undefined> }
|
||||||
|
*/
|
||||||
|
export async function findLargeScaleChangeExemption(pullRequestNumber, pullRequest) {
|
||||||
|
if (!readPrLabels(pullRequest).includes(LARGE_SCALE_CHANGE_LABEL)) return undefined;
|
||||||
|
|
||||||
|
const labelEvents = (await getPrEvents(pullRequestNumber)).filter(
|
||||||
|
(event) =>
|
||||||
|
['labeled', 'unlabeled'].includes(event.event) &&
|
||||||
|
event.label?.name === LARGE_SCALE_CHANGE_LABEL,
|
||||||
|
);
|
||||||
|
const latestEvent = labelEvents.at(-1);
|
||||||
|
if (latestEvent?.event !== 'labeled') return undefined;
|
||||||
|
|
||||||
|
const actor = latestEvent.actor?.login;
|
||||||
|
if (!actor) return undefined;
|
||||||
|
|
||||||
|
return (await isTeamMember(LARGE_SCALE_CHANGES_TEAM, actor)) ? actor : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param { string[] } missingTeams Team handles without an approving member.
|
* @param { string[] } missingTeams Team handles without an approving member.
|
||||||
* @param { number } requiredCount Total number of required teams.
|
* @param { number } requiredCount Total number of required teams.
|
||||||
|
|
@ -258,9 +289,20 @@ export async function run() {
|
||||||
let status;
|
let status;
|
||||||
try {
|
try {
|
||||||
const exemption = findExemption(pullRequest);
|
const exemption = findExemption(pullRequest);
|
||||||
|
const largeScaleChangeActor = exemption
|
||||||
|
? undefined
|
||||||
|
: await findLargeScaleChangeExemption(pullRequestNumber, pullRequest);
|
||||||
if (exemption) {
|
if (exemption) {
|
||||||
console.log(`PR #${pullRequestNumber} matches exempt route "${exemption.source}"; skipping the evaluation.`);
|
console.log(`PR #${pullRequestNumber} matches exempt route "${exemption.source}"; skipping the evaluation.`);
|
||||||
status = { state: 'success', description: `Exempt route: ${exemption.source}` };
|
status = { state: 'success', description: `Exempt route: ${exemption.source}` };
|
||||||
|
} else if (largeScaleChangeActor) {
|
||||||
|
console.log(
|
||||||
|
`PR #${pullRequestNumber} has an authorized ${LARGE_SCALE_CHANGE_LABEL} label from ${largeScaleChangeActor}; skipping the evaluation.`,
|
||||||
|
);
|
||||||
|
status = {
|
||||||
|
state: 'success',
|
||||||
|
description: `Large-scale change exemption by @${largeScaleChangeActor}`,
|
||||||
|
};
|
||||||
} else {
|
} else {
|
||||||
status = await evaluateRequiredReviews(pullRequestNumber);
|
status = await evaluateRequiredReviews(pullRequestNumber);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
157
.github/scripts/owners/required-reviews.test.mjs
vendored
157
.github/scripts/owners/required-reviews.test.mjs
vendored
|
|
@ -15,6 +15,8 @@ let getPullRequestByIdImpl = async () => ({});
|
||||||
let getChangedFilesImpl = async () => new Set();
|
let getChangedFilesImpl = async () => new Set();
|
||||||
/** @type {(pullRequestNumber: number) => Promise<any[]>} */
|
/** @type {(pullRequestNumber: number) => Promise<any[]>} */
|
||||||
let getPrReviewsImpl = async () => [];
|
let getPrReviewsImpl = async () => [];
|
||||||
|
/** @type {(pullRequestNumber: number) => Promise<any[]>} */
|
||||||
|
let getPrEventsImpl = async () => [];
|
||||||
/** @type {(teamSlug: string, username: string) => Promise<boolean>} */
|
/** @type {(teamSlug: string, username: string) => Promise<boolean>} */
|
||||||
let isTeamMemberImpl = async () => false;
|
let isTeamMemberImpl = async () => false;
|
||||||
/** @type {(sha: string, status: any) => Promise<void>} */
|
/** @type {(sha: string, status: any) => Promise<void>} */
|
||||||
|
|
@ -27,11 +29,14 @@ mock.module('../github-helpers.mjs', {
|
||||||
getEventFromGithubEventPath: () => eventImpl(),
|
getEventFromGithubEventPath: () => eventImpl(),
|
||||||
getPullRequestById: (n) => getPullRequestByIdImpl(n),
|
getPullRequestById: (n) => getPullRequestByIdImpl(n),
|
||||||
getChangedFiles: (n) => getChangedFilesImpl(n),
|
getChangedFiles: (n) => getChangedFilesImpl(n),
|
||||||
|
getPrEvents: (n) => getPrEventsImpl(n),
|
||||||
getPrReviews: (n) => getPrReviewsImpl(n),
|
getPrReviews: (n) => getPrReviewsImpl(n),
|
||||||
isTeamMember: (slug, username) => isTeamMemberImpl(slug, username),
|
isTeamMember: (slug, username) => isTeamMemberImpl(slug, username),
|
||||||
setCommitStatus: (sha, status) => setCommitStatusImpl(sha, status),
|
setCommitStatus: (sha, status) => setCommitStatusImpl(sha, status),
|
||||||
listOpenPullRequestsByHead: (headOwner, headBranch) =>
|
listOpenPullRequestsByHead: (headOwner, headBranch) =>
|
||||||
listOpenPullRequestsByHeadImpl(headOwner, headBranch),
|
listOpenPullRequestsByHeadImpl(headOwner, headBranch),
|
||||||
|
readPrLabels: (pullRequest) =>
|
||||||
|
(pullRequest.labels ?? []).map((label) => (typeof label === 'string' ? label : label.name)),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -51,9 +56,12 @@ mock.module('./owners.mjs', {
|
||||||
|
|
||||||
const {
|
const {
|
||||||
REQUIRED_REVIEW_EXEMPTIONS,
|
REQUIRED_REVIEW_EXEMPTIONS,
|
||||||
|
LARGE_SCALE_CHANGE_LABEL,
|
||||||
|
LARGE_SCALE_CHANGES_TEAM,
|
||||||
STATUS_CONTEXT,
|
STATUS_CONTEXT,
|
||||||
buildStatus,
|
buildStatus,
|
||||||
collectApprovers,
|
collectApprovers,
|
||||||
|
findLargeScaleChangeExemption,
|
||||||
findExemption,
|
findExemption,
|
||||||
latestReviewStates,
|
latestReviewStates,
|
||||||
matchesBranchPattern,
|
matchesBranchPattern,
|
||||||
|
|
@ -246,6 +254,116 @@ describe('collectApprovers', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('findLargeScaleChangeExemption', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
getPrEventsImpl = async () => [];
|
||||||
|
isTeamMemberImpl = async () => false;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the team member who applied the active label', async () => {
|
||||||
|
getPrEventsImpl = async () => [
|
||||||
|
{ event: 'labeled', label: { name: LARGE_SCALE_CHANGE_LABEL }, actor: { login: 'ana' } },
|
||||||
|
];
|
||||||
|
isTeamMemberImpl = async (slug, username) =>
|
||||||
|
slug === LARGE_SCALE_CHANGES_TEAM && username === 'ana';
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
await findLargeScaleChangeExemption(42, { labels: [{ name: LARGE_SCALE_CHANGE_LABEL }] }),
|
||||||
|
'ana',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a label applied by someone outside the team', async () => {
|
||||||
|
getPrEventsImpl = async () => [
|
||||||
|
{ event: 'labeled', label: { name: LARGE_SCALE_CHANGE_LABEL }, actor: { login: 'outsider' } },
|
||||||
|
];
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
await findLargeScaleChangeExemption(42, { labels: [{ name: LARGE_SCALE_CHANGE_LABEL }] }),
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects the exemption when the label event is not available', async () => {
|
||||||
|
assert.equal(
|
||||||
|
await findLargeScaleChangeExemption(42, { labels: [{ name: LARGE_SCALE_CHANGE_LABEL }] }),
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects the exemption when the label event has no actor', async () => {
|
||||||
|
getPrEventsImpl = async () => [
|
||||||
|
{ event: 'labeled', label: { name: LARGE_SCALE_CHANGE_LABEL }, actor: null },
|
||||||
|
];
|
||||||
|
const isTeamMember = mock.fn(async () => true);
|
||||||
|
isTeamMemberImpl = isTeamMember;
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
await findLargeScaleChangeExemption(42, { labels: [{ name: LARGE_SCALE_CHANGE_LABEL }] }),
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
assert.equal(isTeamMember.mock.calls.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores events for other labels', async () => {
|
||||||
|
getPrEventsImpl = async () => [
|
||||||
|
{ event: 'labeled', label: { name: LARGE_SCALE_CHANGE_LABEL }, actor: { login: 'ana' } },
|
||||||
|
{ event: 'labeled', label: { name: 'bug' }, actor: { login: 'outsider' } },
|
||||||
|
{ event: 'unlabeled', label: { name: 'bug' }, actor: { login: 'outsider' } },
|
||||||
|
];
|
||||||
|
isTeamMemberImpl = async (slug, username) =>
|
||||||
|
slug === LARGE_SCALE_CHANGES_TEAM && username === 'ana';
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
await findLargeScaleChangeExemption(42, { labels: [{ name: LARGE_SCALE_CHANGE_LABEL }] }),
|
||||||
|
'ana',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the actor from the latest label application', async () => {
|
||||||
|
getPrEventsImpl = async () => [
|
||||||
|
{ event: 'labeled', label: { name: LARGE_SCALE_CHANGE_LABEL }, actor: { login: 'ana' } },
|
||||||
|
{
|
||||||
|
event: 'unlabeled',
|
||||||
|
label: { name: LARGE_SCALE_CHANGE_LABEL },
|
||||||
|
actor: { login: 'outsider' },
|
||||||
|
},
|
||||||
|
{ event: 'labeled', label: { name: LARGE_SCALE_CHANGE_LABEL }, actor: { login: 'outsider' } },
|
||||||
|
];
|
||||||
|
isTeamMemberImpl = async (slug, username) => username === 'ana';
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
await findLargeScaleChangeExemption(42, { labels: [{ name: LARGE_SCALE_CHANGE_LABEL }] }),
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects the exemption when the latest event removed the label', async () => {
|
||||||
|
getPrEventsImpl = async () => [
|
||||||
|
{ event: 'labeled', label: { name: LARGE_SCALE_CHANGE_LABEL }, actor: { login: 'ana' } },
|
||||||
|
{
|
||||||
|
event: 'unlabeled',
|
||||||
|
label: { name: LARGE_SCALE_CHANGE_LABEL },
|
||||||
|
actor: { login: 'outsider' },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
isTeamMemberImpl = async (slug, username) => username === 'ana';
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
await findLargeScaleChangeExemption(42, { labels: [{ name: LARGE_SCALE_CHANGE_LABEL }] }),
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not inspect events when the label is absent', async () => {
|
||||||
|
const getPrEvents = mock.fn(async () => []);
|
||||||
|
getPrEventsImpl = getPrEvents;
|
||||||
|
|
||||||
|
assert.equal(await findLargeScaleChangeExemption(42, { labels: [] }), undefined);
|
||||||
|
assert.equal(getPrEvents.mock.calls.length, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('buildStatus', () => {
|
describe('buildStatus', () => {
|
||||||
it('succeeds when nothing is required', () => {
|
it('succeeds when nothing is required', () => {
|
||||||
const status = buildStatus([], 0);
|
const status = buildStatus([], 0);
|
||||||
|
|
@ -283,6 +401,7 @@ describe('run', () => {
|
||||||
});
|
});
|
||||||
getChangedFilesImpl = async () => new Set(['a.ts']);
|
getChangedFilesImpl = async () => new Set(['a.ts']);
|
||||||
getPrReviewsImpl = async () => [];
|
getPrReviewsImpl = async () => [];
|
||||||
|
getPrEventsImpl = async () => [];
|
||||||
isTeamMemberImpl = async () => false;
|
isTeamMemberImpl = async () => false;
|
||||||
parseOwnersFileImpl = () => [];
|
parseOwnersFileImpl = () => [];
|
||||||
resolveRequiredTeamsImpl = () => new Map();
|
resolveRequiredTeamsImpl = () => new Map();
|
||||||
|
|
@ -431,6 +550,44 @@ describe('run', () => {
|
||||||
assert.equal(status.description, 'Exempt route: sync/master-to-3x -> 3.x');
|
assert.equal(status.description, 'Exempt route: sync/master-to-3x -> 3.x');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('reports success without evaluating an authorized large-scale change', async () => {
|
||||||
|
getPullRequestByIdImpl = async () => ({
|
||||||
|
...pullRequestFor('feature/x', 'master'),
|
||||||
|
labels: [{ name: LARGE_SCALE_CHANGE_LABEL }],
|
||||||
|
});
|
||||||
|
getPrEventsImpl = async () => [
|
||||||
|
{ event: 'labeled', label: { name: LARGE_SCALE_CHANGE_LABEL }, actor: { login: 'ana' } },
|
||||||
|
];
|
||||||
|
isTeamMemberImpl = async (slug, username) =>
|
||||||
|
slug === LARGE_SCALE_CHANGES_TEAM && username === 'ana';
|
||||||
|
const getChangedFiles = mock.fn(async () => new Set(['a.ts']));
|
||||||
|
getChangedFilesImpl = getChangedFiles;
|
||||||
|
|
||||||
|
await run();
|
||||||
|
|
||||||
|
assert.equal(getChangedFiles.mock.calls.length, 0);
|
||||||
|
const [, status] = setCommitStatus.mock.calls.at(-1).arguments;
|
||||||
|
assert.equal(status.state, 'success');
|
||||||
|
assert.equal(status.description, 'Large-scale change exemption by @ana');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('evaluates a large-scale label applied outside the team', async () => {
|
||||||
|
getPullRequestByIdImpl = async () => ({
|
||||||
|
...pullRequestFor('feature/x', 'master'),
|
||||||
|
labels: [{ name: LARGE_SCALE_CHANGE_LABEL }],
|
||||||
|
});
|
||||||
|
getPrEventsImpl = async () => [
|
||||||
|
{ event: 'labeled', label: { name: LARGE_SCALE_CHANGE_LABEL }, actor: { login: 'outsider' } },
|
||||||
|
];
|
||||||
|
resolveRequiredTeamsImpl = () => new Map([['@n8n-io/qa-dx', ['a.ts']]]);
|
||||||
|
|
||||||
|
await run();
|
||||||
|
|
||||||
|
const [, status] = setCommitStatus.mock.calls.at(-1).arguments;
|
||||||
|
assert.equal(status.state, 'pending');
|
||||||
|
assert.match(status.description, /Waiting for approval from: qa-dx/);
|
||||||
|
});
|
||||||
|
|
||||||
it('does not exempt a fork branch that is named like an exempt route', async () => {
|
it('does not exempt a fork branch that is named like an exempt route', async () => {
|
||||||
getPullRequestByIdImpl = async () => pullRequestFor('sync/master-to-3x', '3.x', 'someone/n8n');
|
getPullRequestByIdImpl = async () => pullRequestFor('sync/master-to-3x', '3.x', 'someone/n8n');
|
||||||
resolveRequiredTeamsImpl = () => new Map([['@n8n-io/qa-dx', ['a.ts']]]);
|
resolveRequiredTeamsImpl = () => new Map([['@n8n-io/qa-dx', ['a.ts']]]);
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,38 @@ import {
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const BOT_MARKER = '<!-- pr-recommendations -->';
|
const BOT_MARKER = '<!-- pr-recommendations -->';
|
||||||
|
const REQUIRED_FILES_DISPLAY_LIMIT = 5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param { string } value
|
||||||
|
* @returns { string }
|
||||||
|
*/
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return value
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('|', '|');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param { string[] } files
|
||||||
|
* @returns { string }
|
||||||
|
*/
|
||||||
|
function formatRequiredFiles(files) {
|
||||||
|
const displayedFiles = files
|
||||||
|
.slice(0, REQUIRED_FILES_DISPLAY_LIMIT)
|
||||||
|
.map((file) => `<code>${escapeHtml(file)}</code>`);
|
||||||
|
const omittedFileCount = files.length - displayedFiles.length;
|
||||||
|
|
||||||
|
if (omittedFileCount > 0) {
|
||||||
|
displayedFiles.push(
|
||||||
|
`<em>${omittedFileCount} more file${omittedFileCount === 1 ? '' : 's'}</em>`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return displayedFiles.join('<br>');
|
||||||
|
}
|
||||||
|
|
||||||
function createEmptyLineStats() {
|
function createEmptyLineStats() {
|
||||||
return {
|
return {
|
||||||
|
|
@ -210,9 +242,11 @@ export function buildRequiredReviewsSection(requiredTeamFiles) {
|
||||||
'',
|
'',
|
||||||
'Some changed files have a `required` owner in `OWNERS`. A member of each of these teams must approve this PR before it can merge:',
|
'Some changed files have a `required` owner in `OWNERS`. A member of each of these teams must approve this PR before it can merge:',
|
||||||
'',
|
'',
|
||||||
'| Team | Files |',
|
'| Team | Files owned | Owned files |',
|
||||||
'| --- | ---: |',
|
'| --- | ---: | --- |',
|
||||||
...[...requiredTeamFiles].map(([team, files]) => `| ${team} | ${files.length} |`),
|
...[...requiredTeamFiles].map(
|
||||||
|
([team, files]) => `| ${team} | ${files.length} | ${formatRequiredFiles(files)} |`,
|
||||||
|
),
|
||||||
'',
|
'',
|
||||||
`Request a review from the team${plural ? 's' : ''} — GitHub assigns reviewers according to the team's review settings. The \`Auto-assign reviewers\` label does this for all owning teams.`,
|
`Request a review from the team${plural ? 's' : ''} — GitHub assigns reviewers according to the team's review settings. The \`Auto-assign reviewers\` label does this for all owning teams.`,
|
||||||
].join('\n');
|
].join('\n');
|
||||||
|
|
|
||||||
|
|
@ -217,7 +217,7 @@ describe('computeLineStats', () => {
|
||||||
['*.snap extension', 'src/foo.snap'],
|
['*.snap extension', 'src/foo.snap'],
|
||||||
['fixtures/ directory', 'src/fixtures/data.json'],
|
['fixtures/ directory', 'src/fixtures/data.json'],
|
||||||
['__mocks__/ directory', 'src/__mocks__/axios.ts'],
|
['__mocks__/ directory', 'src/__mocks__/axios.ts'],
|
||||||
['packages/testing/**', 'packages/testing/playwright/spec.ts'],
|
['packages/quality/**', 'packages/quality/testing/playwright/spec.ts'],
|
||||||
['dot-directory *.test.mjs', '.github/scripts/owners/owners.test.mjs'],
|
['dot-directory *.test.mjs', '.github/scripts/owners/owners.test.mjs'],
|
||||||
]) {
|
]) {
|
||||||
it(`classifies ${label} as testFiles`, () => {
|
it(`classifies ${label} as testFiles`, () => {
|
||||||
|
|
@ -322,7 +322,7 @@ describe('buildRequiredReviewsSection', () => {
|
||||||
assert.equal(buildRequiredReviewsSection(new Map()), null);
|
assert.equal(buildRequiredReviewsSection(new Map()), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('lists each required team with its file count', () => {
|
it('lists each required team with its file count and owned files', () => {
|
||||||
const section = buildRequiredReviewsSection(
|
const section = buildRequiredReviewsSection(
|
||||||
new Map([
|
new Map([
|
||||||
['@n8n-io/qa-dx', ['a.yml', 'b.yml']],
|
['@n8n-io/qa-dx', ['a.yml', 'b.yml']],
|
||||||
|
|
@ -331,8 +331,30 @@ describe('buildRequiredReviewsSection', () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.match(section, /### Required reviews/);
|
assert.match(section, /### Required reviews/);
|
||||||
assert.match(section, /\| @n8n-io\/qa-dx \| 2 \|/);
|
assert.match(
|
||||||
assert.match(section, /\| @n8n-io\/migrations-review \| 1 \|/);
|
section,
|
||||||
|
/\| @n8n-io\/qa-dx \| 2 \| <code>a\.yml<\/code><br><code>b\.yml<\/code> \|/,
|
||||||
|
);
|
||||||
|
assert.match(section, /\| @n8n-io\/migrations-review \| 1 \| <code>m\.ts<\/code> \|/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows no more than five files for each team', () => {
|
||||||
|
const section = buildRequiredReviewsSection(
|
||||||
|
new Map([['@n8n-io/qa-dx', ['a.yml', 'b.yml', 'c.yml', 'd.yml', 'e.yml', 'f.yml', 'g.yml']]]),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.match(section, /<code>a\.yml<\/code>/);
|
||||||
|
assert.match(section, /<code>e\.yml<\/code>/);
|
||||||
|
assert.doesNotMatch(section, /<code>f\.yml<\/code>/);
|
||||||
|
assert.match(section, /<em>2 more files<\/em>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escapes file paths before adding them to the table', () => {
|
||||||
|
const section = buildRequiredReviewsSection(
|
||||||
|
new Map([['@n8n-io/qa-dx', ['packages/a|b/<script>.ts']]]),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.match(section, /<code>packages\/a|b\/<script>\.ts<\/code>/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('prompts to request review from the team, plural when several teams are required', () => {
|
it('prompts to request review from the team, plural when several teams are required', () => {
|
||||||
|
|
@ -447,7 +469,7 @@ describe('run', () => {
|
||||||
|
|
||||||
const body = postOrUpdateComment.mock.calls[0].arguments[1];
|
const body = postOrUpdateComment.mock.calls[0].arguments[1];
|
||||||
assert.match(body, /### Required reviews/);
|
assert.match(body, /### Required reviews/);
|
||||||
assert.match(body, /\| @n8n-io\/qa-dx \| 1 \|/);
|
assert.match(body, /\| @n8n-io\/qa-dx \| 1 \| <code>\.github\/workflows\/ci\.yml<\/code> \|/);
|
||||||
assert.match(body, /Request a review from the team —/);
|
assert.match(body, /Request a review from the team —/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
8
.github/scripts/pnpm-lock.yaml
vendored
8
.github/scripts/pnpm-lock.yaml
vendored
|
|
@ -747,8 +747,8 @@ packages:
|
||||||
fast-deep-equal@3.1.3:
|
fast-deep-equal@3.1.3:
|
||||||
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||||
|
|
||||||
fast-uri@3.1.5:
|
fast-uri@3.1.8:
|
||||||
resolution: {integrity: sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==}
|
resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==}
|
||||||
|
|
||||||
fd-package-json@2.0.0:
|
fd-package-json@2.0.0:
|
||||||
resolution: {integrity: sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==}
|
resolution: {integrity: sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==}
|
||||||
|
|
@ -1694,7 +1694,7 @@ snapshots:
|
||||||
ajv@8.20.0:
|
ajv@8.20.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
fast-deep-equal: 3.1.3
|
fast-deep-equal: 3.1.3
|
||||||
fast-uri: 3.1.5
|
fast-uri: 3.1.8
|
||||||
json-schema-traverse: 1.0.0
|
json-schema-traverse: 1.0.0
|
||||||
require-from-string: 2.0.2
|
require-from-string: 2.0.2
|
||||||
|
|
||||||
|
|
@ -1994,7 +1994,7 @@ snapshots:
|
||||||
|
|
||||||
fast-deep-equal@3.1.3: {}
|
fast-deep-equal@3.1.3: {}
|
||||||
|
|
||||||
fast-uri@3.1.5: {}
|
fast-uri@3.1.8: {}
|
||||||
|
|
||||||
fd-package-json@2.0.0:
|
fd-package-json@2.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
|
||||||
27
.github/scripts/quality/check-pr-size.mjs
vendored
27
.github/scripts/quality/check-pr-size.mjs
vendored
|
|
@ -14,32 +14,13 @@
|
||||||
|
|
||||||
import { initGithub, getEventFromGithubEventPath } from '../github-helpers.mjs';
|
import { initGithub, getEventFromGithubEventPath } from '../github-helpers.mjs';
|
||||||
import { matchesGlob } from '../glob.mjs';
|
import { matchesGlob } from '../glob.mjs';
|
||||||
|
import { isTestFile, TEST_PATTERNS } from '../test-files.mjs';
|
||||||
|
|
||||||
|
export { isTestFile, TEST_PATTERNS };
|
||||||
|
|
||||||
export const SIZE_LIMIT = 1000;
|
export const SIZE_LIMIT = 1000;
|
||||||
export const OVERRIDE_COMMAND = '/size-limit-override';
|
export const OVERRIDE_COMMAND = '/size-limit-override';
|
||||||
|
|
||||||
export const TEST_PATTERNS = [
|
|
||||||
// Test files (by extension)
|
|
||||||
'**/*.test.ts',
|
|
||||||
'**/*.test.js',
|
|
||||||
'**/*.test.mjs',
|
|
||||||
'**/*.spec.ts',
|
|
||||||
'**/*.spec.js',
|
|
||||||
'**/*.spec.mjs',
|
|
||||||
// Test directories
|
|
||||||
'**/test/**',
|
|
||||||
'**/tests/**',
|
|
||||||
'**/__tests__/**',
|
|
||||||
// Snapshots
|
|
||||||
'**/__snapshots__/**',
|
|
||||||
'**/*.snap',
|
|
||||||
// Fixtures and mocks
|
|
||||||
'**/fixtures/**',
|
|
||||||
'**/__mocks__/**',
|
|
||||||
// Dedicated testing package
|
|
||||||
'packages/testing/**',
|
|
||||||
];
|
|
||||||
|
|
||||||
export const MISC_PATTERNS = [
|
export const MISC_PATTERNS = [
|
||||||
// Lock file (can produce massive diffs on dependency changes)
|
// Lock file (can produce massive diffs on dependency changes)
|
||||||
'pnpm-lock.yaml',
|
'pnpm-lock.yaml',
|
||||||
|
|
@ -56,7 +37,7 @@ export const EXCLUDE_PATTERNS = [...TEST_PATTERNS, ...MISC_PATTERNS];
|
||||||
* @returns { 'testFiles' | 'misc' | 'sourceCode' }
|
* @returns { 'testFiles' | 'misc' | 'sourceCode' }
|
||||||
*/
|
*/
|
||||||
export function categorizeFile(filename) {
|
export function categorizeFile(filename) {
|
||||||
if (TEST_PATTERNS.some((pattern) => matchesGlob(filename, pattern))) return 'testFiles';
|
if (isTestFile(filename)) return 'testFiles';
|
||||||
if (MISC_PATTERNS.some((pattern) => matchesGlob(filename, pattern))) return 'misc';
|
if (MISC_PATTERNS.some((pattern) => matchesGlob(filename, pattern))) return 'misc';
|
||||||
return 'sourceCode';
|
return 'sourceCode';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
15
.github/scripts/quality/check-pr-size.test.mjs
vendored
15
.github/scripts/quality/check-pr-size.test.mjs
vendored
|
|
@ -203,14 +203,21 @@ describe('countFilteredAdditions', () => {
|
||||||
assert.equal(countFilteredAdditions(files, EXCLUDE_PATTERNS), 50);
|
assert.equal(countFilteredAdditions(files, EXCLUDE_PATTERNS), 50);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('applies EXCLUDE_PATTERNS to packages/testing and pnpm-lock.yaml', () => {
|
it('excludes former testing workspaces but counts benchmark CLI source', () => {
|
||||||
const files = [
|
const files = [
|
||||||
{ filename: 'packages/cli/src/service.ts', additions: 50 },
|
{ filename: 'packages/cli/src/service.ts', additions: 50 },
|
||||||
{ filename: 'packages/testing/playwright/tests/workflow.spec.ts', additions: 100 },
|
{ filename: 'packages/quality/testing/playwright/tests/workflow.spec.ts', additions: 100 },
|
||||||
{ filename: 'packages/testing/playwright/pages/CanvasPage.ts', additions: 100 },
|
{ filename: 'packages/quality/testing/playwright/pages/CanvasPage.ts', additions: 100 },
|
||||||
|
{ filename: 'packages/quality/policy/code-health/src/cli.ts', additions: 100 },
|
||||||
|
{ filename: 'packages/quality/environments/containers/service-stack.ts', additions: 100 },
|
||||||
|
{
|
||||||
|
filename: 'packages/quality/efficiency/microbenchmarks/benchmarks/bench-options.ts',
|
||||||
|
additions: 100,
|
||||||
|
},
|
||||||
|
{ filename: 'packages/quality/efficiency/scale/benchmark/src/commands/list.ts', additions: 100 },
|
||||||
{ filename: 'pnpm-lock.yaml', additions: 500 },
|
{ filename: 'pnpm-lock.yaml', additions: 500 },
|
||||||
];
|
];
|
||||||
assert.equal(countFilteredAdditions(files, EXCLUDE_PATTERNS), 50);
|
assert.equal(countFilteredAdditions(files, EXCLUDE_PATTERNS), 150);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('applies EXCLUDE_PATTERNS to markdown files', () => {
|
it('applies EXCLUDE_PATTERNS to markdown files', () => {
|
||||||
|
|
|
||||||
77
.github/scripts/quality/cubic-config.schema.json
vendored
77
.github/scripts/quality/cubic-config.schema.json
vendored
|
|
@ -5,7 +5,9 @@
|
||||||
"description": "JSON schema for cubic.yaml (and the legacy .cubic.yaml) so editors can validate and autocomplete repository settings.",
|
"description": "JSON schema for cubic.yaml (and the legacy .cubic.yaml) so editors can validate and autocomplete repository settings.",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": true,
|
"additionalProperties": true,
|
||||||
"required": ["version"],
|
"required": [
|
||||||
|
"version"
|
||||||
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
"version": {
|
"version": {
|
||||||
"const": 1,
|
"const": 1,
|
||||||
|
|
@ -16,13 +18,26 @@
|
||||||
"description": "AI review behavior and optional YAML-defined custom agents.",
|
"description": "AI review behavior and optional YAML-defined custom agents.",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"senior_reviewers": {
|
||||||
|
"type": "array",
|
||||||
|
"maxItems": 5,
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$"
|
||||||
|
},
|
||||||
|
"description": "GitHub usernames to learn review patterns from. Overrides dashboard selections; an empty list disables senior reviewer learning."
|
||||||
|
},
|
||||||
"enabled": {
|
"enabled": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"description": "Master toggle for AI reviews on this repository."
|
"description": "Master toggle for AI reviews on this repository."
|
||||||
},
|
},
|
||||||
"sensitivity": {
|
"sensitivity": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": ["low", "medium", "high"],
|
"enum": [
|
||||||
|
"low",
|
||||||
|
"medium",
|
||||||
|
"high"
|
||||||
|
],
|
||||||
"description": "Controls how strictly Cubic flags issues."
|
"description": "Controls how strictly Cubic flags issues."
|
||||||
},
|
},
|
||||||
"incremental_commits": {
|
"incremental_commits": {
|
||||||
|
|
@ -63,12 +78,21 @@
|
||||||
},
|
},
|
||||||
"auto_approve_behavior": {
|
"auto_approve_behavior": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": ["disabled", "shadow", "live"],
|
"enum": [
|
||||||
|
"disabled",
|
||||||
|
"shadow",
|
||||||
|
"live"
|
||||||
|
],
|
||||||
"description": "Controls whether auto-approval is disabled, simulated in shadow mode, or submitted live to GitHub."
|
"description": "Controls whether auto-approval is disabled, simulated in shadow mode, or submitted live to GitHub."
|
||||||
},
|
},
|
||||||
"auto_approve": {
|
"auto_approve": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": ["disabled", "always", "low_risk_only", "custom"],
|
"enum": [
|
||||||
|
"disabled",
|
||||||
|
"always",
|
||||||
|
"low_risk_only",
|
||||||
|
"custom"
|
||||||
|
],
|
||||||
"description": "Auto-approve mode: disabled, always approve clean runs, use low-risk heuristics, or use a custom prompt."
|
"description": "Auto-approve mode: disabled, always approve clean runs, use low-risk heuristics, or use a custom prompt."
|
||||||
},
|
},
|
||||||
"auto_approve_custom_prompt": {
|
"auto_approve_custom_prompt": {
|
||||||
|
|
@ -80,12 +104,20 @@
|
||||||
},
|
},
|
||||||
"ultrareview": {
|
"ultrareview": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": ["disabled", "manual", "automatic"],
|
"enum": [
|
||||||
|
"disabled",
|
||||||
|
"manual",
|
||||||
|
"automatic"
|
||||||
|
],
|
||||||
"description": "Master switch for ultrareviews. disabled blocks all ultrareviews (manual and automatic), manual allows only manual triggers (the default), automatic also enables the automatic triggers."
|
"description": "Master switch for ultrareviews. disabled blocks all ultrareviews (manual and automatic), manual allows only manual triggers (the default), automatic also enables the automatic triggers."
|
||||||
},
|
},
|
||||||
"auto_ultrareview": {
|
"auto_ultrareview": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": ["disabled", "high_risk_only", "custom"],
|
"enum": [
|
||||||
|
"disabled",
|
||||||
|
"high_risk_only",
|
||||||
|
"custom"
|
||||||
|
],
|
||||||
"description": "Auto-trigger ultrareview mode: disabled, let cubic decide per PR using built-in high-risk criteria, or use a custom prompt."
|
"description": "Auto-trigger ultrareview mode: disabled, let cubic decide per PR using built-in high-risk criteria, or use a custom prompt."
|
||||||
},
|
},
|
||||||
"auto_ultrareview_custom_prompt": {
|
"auto_ultrareview_custom_prompt": {
|
||||||
|
|
@ -159,7 +191,10 @@
|
||||||
},
|
},
|
||||||
"coding_agent_provider": {
|
"coding_agent_provider": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": ["cubic", "cursor_cloud_agent"],
|
"enum": [
|
||||||
|
"cubic",
|
||||||
|
"cursor_cloud_agent"
|
||||||
|
],
|
||||||
"description": "Which coding agent applies automatic fixes. 'cubic' uses cubic's built-in agent; 'cursor_cloud_agent' dispatches a Cursor cloud agent (requires Cursor configuration)."
|
"description": "Which coding agent applies automatic fixes. 'cubic' uses cubic's built-in agent; 'cursor_cloud_agent' dispatches a Cursor cloud agent (requires Cursor configuration)."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -222,8 +257,20 @@
|
||||||
},
|
},
|
||||||
"exclude_external_contributors": {
|
"exclude_external_contributors": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"default": false,
|
"default": true,
|
||||||
"description": "If true, public-repository PRs from external contributors are reviewed but never auto-approved."
|
"description": "If true, public-repository PRs from external contributors, or from authors cubic cannot verify, are reviewed but never auto-approved."
|
||||||
|
},
|
||||||
|
"allow_open_issues": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"none",
|
||||||
|
"p3",
|
||||||
|
"p2_and_p3",
|
||||||
|
"p1_to_p3",
|
||||||
|
"all"
|
||||||
|
],
|
||||||
|
"default": "none",
|
||||||
|
"description": "Open cubic findings that don't block auto-approval, by priority. none blocks on any open finding; p3, p2_and_p3, and p1_to_p3 allow that range; all allows every open finding. A finding without a recorded priority blocks unless this is all. Every other auto-approval rule still applies."
|
||||||
},
|
},
|
||||||
"exclude_authors": {
|
"exclude_authors": {
|
||||||
"$ref": "#/$defs/globList",
|
"$ref": "#/$defs/globList",
|
||||||
|
|
@ -271,13 +318,19 @@
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "Definition of a YAML-managed custom agent.",
|
"description": "Definition of a YAML-managed custom agent.",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"required": ["name"],
|
"required": [
|
||||||
|
"name"
|
||||||
|
],
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
{
|
{
|
||||||
"required": ["description"]
|
"required": [
|
||||||
|
"description"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"required": ["file_paths"]
|
"required": [
|
||||||
|
"file_paths"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|
|
||||||
68
.github/scripts/split-jscpd-report.mjs
vendored
Normal file
68
.github/scripts/split-jscpd-report.mjs
vendored
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
// Splits a jscpd JSON report's `duplicates` array into N bucket files so a
|
||||||
|
// caller can download and sample one small file instead of the whole report
|
||||||
|
// (which can run into tens of MB on a large monorepo). Round-robin, not
|
||||||
|
// contiguous chunks, so a run of similarly-sized clusters in scan order
|
||||||
|
// doesn't pile up in one bucket.
|
||||||
|
|
||||||
|
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
export function splitDuplicates(duplicates, bucketCount) {
|
||||||
|
const buckets = Array.from({ length: bucketCount }, () => []);
|
||||||
|
duplicates.forEach((dup, i) => buckets[i % bucketCount].push(dup));
|
||||||
|
return buckets;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeBuckets(buckets, outputDir, deps = {}) {
|
||||||
|
const mkdirImpl = deps.mkdir ?? mkdirSync;
|
||||||
|
const writeImpl = deps.write ?? writeFileSync;
|
||||||
|
mkdirImpl(outputDir, { recursive: true });
|
||||||
|
return buckets.map((duplicates, i) => {
|
||||||
|
const path = join(outputDir, `bucket-${i}.json`);
|
||||||
|
writeImpl(path, JSON.stringify({ duplicates }));
|
||||||
|
return path;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseBucketCount(raw) {
|
||||||
|
const count = Number(raw ?? 20);
|
||||||
|
if (!Number.isInteger(count) || count <= 0) {
|
||||||
|
throw new Error(`BUCKET_COUNT must be a positive integer, got '${raw}'`);
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadDuplicates(reportPath, deps = {}) {
|
||||||
|
const readImpl = deps.read ?? readFileSync;
|
||||||
|
const report = JSON.parse(readImpl(reportPath, 'utf8'));
|
||||||
|
if (!Array.isArray(report.duplicates)) {
|
||||||
|
throw new Error('report.duplicates must be an array');
|
||||||
|
}
|
||||||
|
return report.duplicates;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exits non-zero on any failure. The workflow step already runs with
|
||||||
|
// continue-on-error, so this can't fail the job — it only stops a broken
|
||||||
|
// split (zero buckets written) from reporting success.
|
||||||
|
function main() {
|
||||||
|
const reportPath = process.env.REPORT_PATH;
|
||||||
|
const outputDir = process.env.OUTPUT_DIR;
|
||||||
|
if (!reportPath || !outputDir) {
|
||||||
|
process.stderr.write('split-jscpd-report: REPORT_PATH and OUTPUT_DIR are required\n');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const bucketCount = parseBucketCount(process.env.BUCKET_COUNT);
|
||||||
|
const duplicates = loadDuplicates(reportPath);
|
||||||
|
const buckets = splitDuplicates(duplicates, bucketCount);
|
||||||
|
writeBuckets(buckets, outputDir);
|
||||||
|
} catch (err) {
|
||||||
|
process.stderr.write(`split-jscpd-report: ${err.message}\n`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||||
|
main();
|
||||||
|
}
|
||||||
111
.github/scripts/split-jscpd-report.test.mjs
vendored
Normal file
111
.github/scripts/split-jscpd-report.test.mjs
vendored
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
/**
|
||||||
|
* Run these tests by running
|
||||||
|
*
|
||||||
|
* node --test .github/scripts/split-jscpd-report.test.mjs
|
||||||
|
* */
|
||||||
|
|
||||||
|
import { describe, it } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { splitDuplicates, writeBuckets, parseBucketCount, loadDuplicates } from './split-jscpd-report.mjs';
|
||||||
|
|
||||||
|
describe('splitDuplicates', () => {
|
||||||
|
it('distributes entries round-robin across buckets', () => {
|
||||||
|
const duplicates = [1, 2, 3, 4, 5, 6];
|
||||||
|
const buckets = splitDuplicates(duplicates, 3);
|
||||||
|
assert.deepEqual(buckets, [
|
||||||
|
[1, 4],
|
||||||
|
[2, 5],
|
||||||
|
[3, 6],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves later buckets empty when there are fewer entries than buckets', () => {
|
||||||
|
const buckets = splitDuplicates([1, 2], 5);
|
||||||
|
assert.equal(buckets.length, 5);
|
||||||
|
assert.deepEqual(buckets[0], [1]);
|
||||||
|
assert.deepEqual(buckets[1], [2]);
|
||||||
|
assert.deepEqual(buckets[2], []);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns bucketCount empty arrays for an empty report', () => {
|
||||||
|
const buckets = splitDuplicates([], 4);
|
||||||
|
assert.deepEqual(buckets, [[], [], [], []]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('writeBuckets', () => {
|
||||||
|
it('writes one JSON file per bucket, named by index', () => {
|
||||||
|
const written = [];
|
||||||
|
const paths = writeBuckets([[{ a: 1 }], [{ b: 2 }]], '/tmp/buckets', {
|
||||||
|
mkdir: () => {},
|
||||||
|
write: (path, contents) => written.push({ path, contents }),
|
||||||
|
});
|
||||||
|
assert.deepEqual(paths, ['/tmp/buckets/bucket-0.json', '/tmp/buckets/bucket-1.json']);
|
||||||
|
assert.deepEqual(JSON.parse(written[0].contents), { duplicates: [{ a: 1 }] });
|
||||||
|
assert.deepEqual(JSON.parse(written[1].contents), { duplicates: [{ b: 2 }] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates the output directory before writing', () => {
|
||||||
|
let mkdirCalledWith = null;
|
||||||
|
writeBuckets([[]], '/tmp/buckets', {
|
||||||
|
mkdir: (dir, opts) => {
|
||||||
|
mkdirCalledWith = { dir, opts };
|
||||||
|
},
|
||||||
|
write: () => {},
|
||||||
|
});
|
||||||
|
assert.deepEqual(mkdirCalledWith, { dir: '/tmp/buckets', opts: { recursive: true } });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseBucketCount', () => {
|
||||||
|
it('parses a numeric string', () => {
|
||||||
|
assert.equal(parseBucketCount('20'), 20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to 20 when unset', () => {
|
||||||
|
assert.equal(parseBucketCount(undefined), 20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects zero', () => {
|
||||||
|
assert.throws(() => parseBucketCount('0'), /positive integer/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a negative count', () => {
|
||||||
|
assert.throws(() => parseBucketCount('-1'), /positive integer/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a non-integer', () => {
|
||||||
|
assert.throws(() => parseBucketCount('1.5'), /positive integer/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a non-numeric value', () => {
|
||||||
|
assert.throws(() => parseBucketCount('abc'), /positive integer/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('loadDuplicates', () => {
|
||||||
|
it('returns the duplicates array from a valid report', () => {
|
||||||
|
const duplicates = loadDuplicates('/tmp/report.json', {
|
||||||
|
read: () => JSON.stringify({ duplicates: [{ a: 1 }] }),
|
||||||
|
});
|
||||||
|
assert.deepEqual(duplicates, [{ a: 1 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a report missing the duplicates array', () => {
|
||||||
|
assert.throws(
|
||||||
|
() => loadDuplicates('/tmp/report.json', { read: () => JSON.stringify({}) }),
|
||||||
|
/duplicates must be an array/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a report whose duplicates field is not an array', () => {
|
||||||
|
assert.throws(
|
||||||
|
() => loadDuplicates('/tmp/report.json', { read: () => JSON.stringify({ duplicates: 'x' }) }),
|
||||||
|
/duplicates must be an array/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('propagates a JSON parse failure', () => {
|
||||||
|
assert.throws(() => loadDuplicates('/tmp/report.json', { read: () => 'not json' }), SyntaxError);
|
||||||
|
});
|
||||||
|
});
|
||||||
88
.github/scripts/sync-bundle-branch.mjs
vendored
88
.github/scripts/sync-bundle-branch.mjs
vendored
|
|
@ -12,7 +12,7 @@
|
||||||
* why only a rewrite breaks this.
|
* why only a rewrite breaks this.
|
||||||
*
|
*
|
||||||
* The cost of merging is a merge commit per run, plus already-published fixes staying in the
|
* The cost of merging is a merge commit per run, plus already-published fixes staying in the
|
||||||
* branch's log (the rebase used to drop them as empty). Neither reaches anything downstream:
|
* branch's log. Neither reaches anything downstream:
|
||||||
* a bundle is published as one squashed, deliberately obfuscated commit, and the squash is
|
* a bundle is published as one squashed, deliberately obfuscated commit, and the squash is
|
||||||
* taken from the tree, not the history. For a list of what a bundle actually carries, read
|
* taken from the tree, not the history. For a list of what a bundle actually carries, read
|
||||||
* the fix PRs merged into the branch since the last cut, not `base..bundle`.
|
* the fix PRs merged into the branch since the last cut, not `base..bundle`.
|
||||||
|
|
@ -26,17 +26,13 @@
|
||||||
* of being re-litigated on every later run. Contrast `sync-master-to-3x.mjs`, which
|
* of being re-litigated on every later run. Contrast `sync-master-to-3x.mjs`, which
|
||||||
* auto-resolves mechanical files and opens a conflict PR.
|
* auto-resolves mechanical files and opens a conflict PR.
|
||||||
*
|
*
|
||||||
* PUBLISHED HISTORY ONLY: the base is synced in only once the public repo already has it. A cut
|
* PUBLIC HISTORY ONLY: the base is fetched directly from the public repo. Private master can
|
||||||
* squash-merges the bundle branch into private master as `chore: Bundle …`, and the hourly
|
* temporarily contain a bundle cut that public master does not have yet. Using the private base
|
||||||
* public→private mirror then DISCARDS that commit in favour of the public cherry-pick of the same
|
* would strand that commit on a recreated bundle branch after the private mirror replaces it.
|
||||||
* changes. Building on it would strand the discarded commit on a branch that fix PRs are cut
|
|
||||||
* from, and the replacement gets merged in on top of it later. An unpublished cut is a SKIP (the
|
|
||||||
* mirror clears it within the hour, whatever the cut is, so waiting cannot deadlock); a base
|
|
||||||
* ahead of public for any other reason is a FAILURE, because the mirror will not clear it.
|
|
||||||
*
|
*
|
||||||
* Runs from a full checkout (fetch-depth 0) of any branch. Assumes credentials are NOT
|
* Runs from a full checkout (fetch-depth 0) of any branch. Assumes credentials are NOT
|
||||||
* persisted by checkout — every private fetch and the push go through an explicit token URL,
|
* persisted by checkout. The bundle fetch and push use an explicit token URL. The public base
|
||||||
* which matters because the repository is private. The public fetch is anonymous.
|
* fetch is anonymous.
|
||||||
*
|
*
|
||||||
* Env: BUNDLE_BRANCH (e.g. bundle/2.x), BASE_BRANCH (e.g. master),
|
* Env: BUNDLE_BRANCH (e.g. bundle/2.x), BASE_BRANCH (e.g. master),
|
||||||
* GH_TOKEN (installation token with contents:write),
|
* GH_TOKEN (installation token with contents:write),
|
||||||
|
|
@ -60,10 +56,6 @@ const BOT_EMAIL = 'n8n-assistant[bot]@users.noreply.github.com';
|
||||||
// installation token this script holds is scoped to the private repo.
|
// installation token this script holds is scoped to the private repo.
|
||||||
const PUBLIC_REMOTE = 'https://github.com/n8n-io/n8n.git';
|
const PUBLIC_REMOTE = 'https://github.com/n8n-io/n8n.git';
|
||||||
|
|
||||||
// What a cut lands on private base before it is published. Anything else ahead of public means
|
|
||||||
// the mirror is stuck, not that a cut is in flight.
|
|
||||||
const BUNDLE_SUBJECT = /^chore: Bundle/;
|
|
||||||
|
|
||||||
function required(env, name) {
|
function required(env, name) {
|
||||||
const value = env[name];
|
const value = env[name];
|
||||||
if (!value) throw new Error(`${name} env var is required`);
|
if (!value) throw new Error(`${name} env var is required`);
|
||||||
|
|
@ -76,49 +68,8 @@ export function annotation(title, message) {
|
||||||
return `::error title=${title}::${message.replace(/\n/g, '%0A')}`;
|
return `::error title=${title}::${message.replace(/\n/g, '%0A')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether the base tip is still private-only, and what to do about it.
|
|
||||||
*
|
|
||||||
* `null` means the public repo already has it, so the sync may proceed. The predicate is
|
|
||||||
* containment in public rather than "is not a bundle commit", so a direct push that bypassed
|
|
||||||
* ci-restrict-private-merges.yml is caught too; the subjects only decide how to react.
|
|
||||||
*/
|
|
||||||
function unconvergedBase({ git, log, bundle, base, baseSha, publicSha }) {
|
|
||||||
if (isAncestor(git, baseSha, publicSha)) return null;
|
|
||||||
|
|
||||||
const ahead = git(['log', '--format=%h %s', `${publicSha}..${baseSha}`])
|
|
||||||
.split('\n')
|
|
||||||
.filter(Boolean);
|
|
||||||
const subjects = ahead.map((line) => line.slice(line.indexOf(' ') + 1));
|
|
||||||
|
|
||||||
// All-or-nothing: one unexplained commit means the mirror will not clear the base on its own.
|
|
||||||
// An empty range here means unrelated histories, which `every` treats as a skip — the safe
|
|
||||||
// direction, since forcing past an ancestry check we could not evaluate is never right.
|
|
||||||
if (subjects.every((subject) => BUNDLE_SUBJECT.test(subject))) {
|
|
||||||
log(
|
|
||||||
`${base} ${baseSha} has not reached public ${base} ${publicSha} yet; leaving ${bundle} ` +
|
|
||||||
'untouched. The hourly public → private sync clears this.',
|
|
||||||
);
|
|
||||||
return { status: 'unconverged' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subjects go to the run log, which is private. The annotation stays a count: it is the
|
|
||||||
// headline of a failed run, and a subject hints at the fix.
|
|
||||||
log(ahead.join('\n'));
|
|
||||||
log(
|
|
||||||
annotation(
|
|
||||||
`${base} is ahead of public`,
|
|
||||||
`${base} carries ${ahead.length} commit(s) that public does not have and that are not ` +
|
|
||||||
`bundle cuts, so the mirror cannot clear them. Fix the mirror, then re-run this workflow.`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
throw new Error(`${base} is ahead of public by commits that are not bundle cuts.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Takes `baseSha` explicitly. This path never fetches the bundle branch, so FETCH_HEAD still
|
|
||||||
// holds the PUBLIC tip here, not the base — never swap the argument for FETCH_HEAD.
|
|
||||||
function createBundleBranch({ git, log, bundle, base, baseSha, remote }) {
|
function createBundleBranch({ git, log, bundle, base, baseSha, remote }) {
|
||||||
log(`${bundle} does not exist yet; creating it at ${base} ${baseSha}.`);
|
log(`${bundle} does not exist yet; creating it at public ${base} ${baseSha}.`);
|
||||||
git(['checkout', '--force', '-B', bundle, baseSha]);
|
git(['checkout', '--force', '-B', bundle, baseSha]);
|
||||||
|
|
||||||
const push = attempt(git, ['push', remote, `HEAD:refs/heads/${bundle}`]);
|
const push = attempt(git, ['push', remote, `HEAD:refs/heads/${bundle}`]);
|
||||||
|
|
@ -132,28 +83,15 @@ function createBundleBranch({ git, log, bundle, base, baseSha, remote }) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One fetch-merge-push cycle. `status: 'rejected'` means the branch moved under us — the
|
* One fetch-merge-push cycle. `status: 'rejected'` means the branch moved under us — the
|
||||||
* caller re-runs from a fresh fetch rather than forcing anything. `status: 'unconverged'` means
|
* caller re-runs from a fresh fetch rather than forcing anything.
|
||||||
* the base is not published yet and nothing was touched.
|
|
||||||
*/
|
*/
|
||||||
function mergeBaseIntoBundle({ git, log, bundle, base, remote }) {
|
function mergeBaseIntoBundle({ git, log, bundle, base, remote }) {
|
||||||
// Pin every side to the SHA fetched for it — fetching by URL never updates the origin/*
|
// Pin the base to the public SHA. Private master can temporarily contain an unpublished cut.
|
||||||
// tracking refs, so FETCH_HEAD is the only handle, and each of the three fetches below
|
|
||||||
// overwrites the last. Read FETCH_HEAD on the line after its own fetch; never separate a
|
|
||||||
// fetch from the read that consumes it.
|
|
||||||
git(['fetch', remote, base]);
|
|
||||||
const baseSha = git(['rev-parse', 'FETCH_HEAD']);
|
|
||||||
|
|
||||||
// The base branch is named the same on both sides (master, 1.x). If that ever stops being
|
|
||||||
// true, map it here rather than at the call site.
|
|
||||||
const publicFetch = attempt(git, ['fetch', PUBLIC_REMOTE, base]);
|
const publicFetch = attempt(git, ['fetch', PUBLIC_REMOTE, base]);
|
||||||
if (!publicFetch.ok) {
|
if (!publicFetch.ok) {
|
||||||
// A guard that cannot be evaluated must stop the run, not be assumed satisfied.
|
|
||||||
throw new Error(`Could not fetch ${base} from the public repo:\n${publicFetch.out}`);
|
throw new Error(`Could not fetch ${base} from the public repo:\n${publicFetch.out}`);
|
||||||
}
|
}
|
||||||
const publicSha = git(['rev-parse', 'FETCH_HEAD']);
|
const baseSha = git(['rev-parse', 'FETCH_HEAD']);
|
||||||
|
|
||||||
const unconverged = unconvergedBase({ git, log, bundle, base, baseSha, publicSha });
|
|
||||||
if (unconverged) return unconverged;
|
|
||||||
|
|
||||||
const listed = attempt(git, ['ls-remote', '--heads', remote, `refs/heads/${bundle}`]);
|
const listed = attempt(git, ['ls-remote', '--heads', remote, `refs/heads/${bundle}`]);
|
||||||
if (!listed.ok) {
|
if (!listed.ok) {
|
||||||
|
|
@ -179,7 +117,9 @@ function mergeBaseIntoBundle({ git, log, bundle, base, remote }) {
|
||||||
'--format=%h %s',
|
'--format=%h %s',
|
||||||
`${baseSha}..${preHead}`,
|
`${baseSha}..${preHead}`,
|
||||||
]);
|
]);
|
||||||
log(`Merging ${base} ${baseSha} into ${bundle}. Commits on ${bundle}:\n${pending || '(none)'}`);
|
log(
|
||||||
|
`Merging public ${base} ${baseSha} into ${bundle}. Commits on ${bundle}:\n${pending || '(none)'}`,
|
||||||
|
);
|
||||||
|
|
||||||
// The content the merge must produce — and whether the two sides conflict at all. Computed
|
// The content the merge must produce — and whether the two sides conflict at all. Computed
|
||||||
// without touching the working tree, so a conflict costs the branch nothing.
|
// without touching the working tree, so a conflict costs the branch nothing.
|
||||||
|
|
@ -211,7 +151,7 @@ function mergeBaseIntoBundle({ git, log, bundle, base, remote }) {
|
||||||
if (push.out) log(push.out);
|
if (push.out) log(push.out);
|
||||||
return { status: 'rejected' };
|
return { status: 'rejected' };
|
||||||
}
|
}
|
||||||
log(`Merged ${base} into ${bundle} — nothing rewritten.`);
|
log(`Merged public ${base} into ${bundle}; nothing was rewritten.`);
|
||||||
return { status: 'merged' };
|
return { status: 'merged' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
136
.github/scripts/sync-bundle-branch.test.mjs
vendored
136
.github/scripts/sync-bundle-branch.test.mjs
vendored
|
|
@ -28,7 +28,6 @@ const fail =
|
||||||
};
|
};
|
||||||
|
|
||||||
const PRE_HEAD = 'PREHEAD';
|
const PRE_HEAD = 'PREHEAD';
|
||||||
const BASE = 'BASESHA';
|
|
||||||
const PUBLIC = 'PUBLICSHA';
|
const PUBLIC = 'PUBLICSHA';
|
||||||
const MERGE_TREE = 'MERGETREEOID';
|
const MERGE_TREE = 'MERGETREEOID';
|
||||||
const REMOTE = 'https://x-access-token:tok@github.com/n8n-io/n8n-private.git';
|
const REMOTE = 'https://x-access-token:tok@github.com/n8n-io/n8n-private.git';
|
||||||
|
|
@ -52,37 +51,27 @@ const conflictedMergeTree = (...paths) =>
|
||||||
fail(`${MERGE_TREE}\n${paths.join('\n')}\n\nCONFLICT (content): Merge conflict in ${paths[0]}`);
|
fail(`${MERGE_TREE}\n${paths.join('\n')}\n\nCONFLICT (content): Merge conflict in ${paths[0]}`);
|
||||||
|
|
||||||
// FETCH_HEAD is one slot holding whatever the last fetch brought down, so the stub has to answer
|
// FETCH_HEAD is one slot holding whatever the last fetch brought down, so the stub has to answer
|
||||||
// by looking back at that fetch. Resolving both fetches to the same SHA would let the
|
// by looking back at that fetch.
|
||||||
// convergence guard pass by accident and assert nothing.
|
|
||||||
const fetchHead = (_args, calls) => {
|
const fetchHead = (_args, calls) => {
|
||||||
const [, url, ref] = [...calls].reverse().find((a) => a[0] === 'fetch') ?? [];
|
const [, url, ref] = [...calls].reverse().find((a) => a[0] === 'fetch') ?? [];
|
||||||
if (url === PUBLIC_REMOTE) return PUBLIC;
|
if (url === PUBLIC_REMOTE) return PUBLIC;
|
||||||
if (ref?.startsWith('bundle/')) return PRE_HEAD;
|
if (ref?.startsWith('bundle/')) return PRE_HEAD;
|
||||||
return BASE;
|
return '';
|
||||||
};
|
};
|
||||||
|
|
||||||
// Routes shared by every path: base fetched and already published, the bundle branch hasn't
|
// Routes shared by every path: the bundle branch has not absorbed the public base yet, the
|
||||||
// absorbed it yet, the merge tree is computable, and the merge lands on exactly that tree. git
|
// merge tree is computable, and the merge lands on exactly that tree. git grep exits non-zero
|
||||||
// grep exits non-zero => no markers.
|
// => no markers.
|
||||||
const baseGitRoutes = [
|
const baseGitRoutes = [
|
||||||
[(a) => a[0] === 'ls-remote', `${PRE_HEAD}\trefs/heads/bundle/2.x`],
|
[(a) => a[0] === 'ls-remote', `${PRE_HEAD}\trefs/heads/bundle/2.x`],
|
||||||
[(a) => a[0] === 'rev-parse' && a[1] === 'FETCH_HEAD', fetchHead],
|
[(a) => a[0] === 'rev-parse' && a[1] === 'FETCH_HEAD', fetchHead],
|
||||||
[(a) => a[0] === 'rev-parse' && a[1] === 'HEAD', PRE_HEAD],
|
[(a) => a[0] === 'rev-parse' && a[1] === 'HEAD', PRE_HEAD],
|
||||||
// Split by descendant: the base has reached public, but the bundle branch has not absorbed
|
|
||||||
// it. One `merge-base` matcher would answer both and decide convergence by accident.
|
|
||||||
[(a) => a[0] === 'merge-base' && a[3] === PUBLIC, ''],
|
|
||||||
[(a) => a[0] === 'merge-base' && a[3] === PRE_HEAD, fail()],
|
[(a) => a[0] === 'merge-base' && a[3] === PRE_HEAD, fail()],
|
||||||
[(a) => a[0] === 'merge-tree', MERGE_TREE],
|
[(a) => a[0] === 'merge-tree', MERGE_TREE],
|
||||||
[(a) => a[0] === 'rev-parse' && a[1] === 'HEAD^{tree}', MERGE_TREE],
|
[(a) => a[0] === 'rev-parse' && a[1] === 'HEAD^{tree}', MERGE_TREE],
|
||||||
[(a) => a[0] === 'grep', fail()],
|
[(a) => a[0] === 'grep', fail()],
|
||||||
];
|
];
|
||||||
|
|
||||||
// A base that is ahead of public by the commits given, so the guard trips.
|
|
||||||
const unpublishedRoutes = (...lines) => [
|
|
||||||
[(a) => a[0] === 'merge-base' && a[3] === PUBLIC, fail()],
|
|
||||||
[(a) => a[0] === 'log' && a[1] === '--format=%h %s', lines.join('\n')],
|
|
||||||
];
|
|
||||||
|
|
||||||
const silent = () => {};
|
const silent = () => {};
|
||||||
|
|
||||||
test('a missing branch env var fails before any git command runs', () => {
|
test('a missing branch env var fails before any git command runs', () => {
|
||||||
|
|
@ -107,14 +96,14 @@ test('a clean sync merges the base in and pushes without forcing', () => {
|
||||||
'--no-edit',
|
'--no-edit',
|
||||||
'-m',
|
'-m',
|
||||||
'Merge master into bundle/2.x',
|
'Merge master into bundle/2.x',
|
||||||
BASE,
|
PUBLIC,
|
||||||
]);
|
]);
|
||||||
// No lease and no --force: the branch is append-only, so a non-fast-forward must be
|
// No lease and no --force: the branch is append-only, so a non-fast-forward must be
|
||||||
// refused by git rather than resolved by overwriting whatever landed.
|
// refused by git rather than resolved by overwriting whatever landed.
|
||||||
assert.deepEqual(git.calls.find(isPush), ['push', REMOTE, 'HEAD:refs/heads/bundle/2.x']);
|
assert.deepEqual(git.calls.find(isPush), ['push', REMOTE, 'HEAD:refs/heads/bundle/2.x']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the private fetches are authenticated and the public one is not', () => {
|
test('the bundle fetch is authenticated and the public base fetch is not', () => {
|
||||||
const git = makeStub(baseGitRoutes);
|
const git = makeStub(baseGitRoutes);
|
||||||
syncBundleBranch({ git, env, log: silent });
|
syncBundleBranch({ git, env, log: silent });
|
||||||
|
|
||||||
|
|
@ -122,7 +111,6 @@ test('the private fetches are authenticated and the public one is not', () => {
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
git.calls.filter((a) => a[0] === 'fetch'),
|
git.calls.filter((a) => a[0] === 'fetch'),
|
||||||
[
|
[
|
||||||
['fetch', REMOTE, 'master'],
|
|
||||||
['fetch', PUBLIC_REMOTE, 'master'],
|
['fetch', PUBLIC_REMOTE, 'master'],
|
||||||
['fetch', REMOTE, 'bundle/2.x'],
|
['fetch', REMOTE, 'bundle/2.x'],
|
||||||
],
|
],
|
||||||
|
|
@ -145,8 +133,6 @@ test('the public tip is read straight after its own fetch', () => {
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
git.calls.filter((a) => a[0] === 'fetch' || (a[0] === 'rev-parse' && a[1] === 'FETCH_HEAD')),
|
git.calls.filter((a) => a[0] === 'fetch' || (a[0] === 'rev-parse' && a[1] === 'FETCH_HEAD')),
|
||||||
[
|
[
|
||||||
['fetch', REMOTE, 'master'],
|
|
||||||
['rev-parse', 'FETCH_HEAD'],
|
|
||||||
['fetch', PUBLIC_REMOTE, 'master'],
|
['fetch', PUBLIC_REMOTE, 'master'],
|
||||||
['rev-parse', 'FETCH_HEAD'],
|
['rev-parse', 'FETCH_HEAD'],
|
||||||
['fetch', REMOTE, 'bundle/2.x'],
|
['fetch', REMOTE, 'bundle/2.x'],
|
||||||
|
|
@ -156,8 +142,6 @@ test('the public tip is read straight after its own fetch', () => {
|
||||||
|
|
||||||
test('a bundle branch that already contains its base is left alone', () => {
|
test('a bundle branch that already contains its base is left alone', () => {
|
||||||
const git = makeStub([
|
const git = makeStub([
|
||||||
// Only the bundle-branch ancestry is overridden; the convergence probe keeps its own
|
|
||||||
// answer from baseGitRoutes, or this would pass without the guard being exercised.
|
|
||||||
[(a) => a[0] === 'merge-base' && a[3] === PRE_HEAD, ''], // --is-ancestor succeeds
|
[(a) => a[0] === 'merge-base' && a[3] === PRE_HEAD, ''], // --is-ancestor succeeds
|
||||||
...baseGitRoutes,
|
...baseGitRoutes,
|
||||||
]);
|
]);
|
||||||
|
|
@ -226,9 +210,8 @@ test('a fix landing mid-run is retried from a fresh fetch, never forced', () =>
|
||||||
|
|
||||||
assert.deepEqual(result, { status: 'merged' });
|
assert.deepEqual(result, { status: 'merged' });
|
||||||
assert.equal(pushes, 2);
|
assert.equal(pushes, 2);
|
||||||
// The retry re-fetches rather than reusing the tip it already merged onto — including the
|
// The retry re-fetches both tips instead of reusing the merge that lost the push race.
|
||||||
// public tip, so the convergence guard is re-evaluated instead of carried over.
|
assert.equal(git.calls.filter((a) => a[0] === 'fetch').length, 4);
|
||||||
assert.equal(git.calls.filter((a) => a[0] === 'fetch').length, 6);
|
|
||||||
assert.equal(git.calls.filter((a) => a[1] === PUBLIC_REMOTE).length, 2);
|
assert.equal(git.calls.filter((a) => a[1] === PUBLIC_REMOTE).length, 2);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
git.calls.every((a) => !a.some((arg) => String(arg).includes('force-with-lease'))),
|
git.calls.every((a) => !a.some((arg) => String(arg).includes('force-with-lease'))),
|
||||||
|
|
@ -244,22 +227,19 @@ test('a branch that keeps moving fails instead of forcing', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a bundle branch that does not exist yet is created at its base', () => {
|
test('a bundle branch that does not exist yet is created at the public base', () => {
|
||||||
const git = makeStub([[(a) => a[0] === 'ls-remote', ''], ...baseGitRoutes]);
|
const git = makeStub([[(a) => a[0] === 'ls-remote', ''], ...baseGitRoutes]);
|
||||||
const result = syncBundleBranch({ git, env, log: silent });
|
const result = syncBundleBranch({ git, env, log: silent });
|
||||||
|
|
||||||
assert.deepEqual(result, { status: 'created' });
|
assert.deepEqual(result, { status: 'created' });
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
git.calls.find((a) => a[0] === 'checkout'),
|
git.calls.find((a) => a[0] === 'checkout'),
|
||||||
['checkout', '--force', '-B', 'bundle/2.x', BASE],
|
['checkout', '--force', '-B', 'bundle/2.x', PUBLIC],
|
||||||
);
|
);
|
||||||
assert.equal(git.calls.some(isMerge), false);
|
assert.equal(git.calls.some(isMerge), false);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
git.calls.filter((a) => a[0] === 'fetch'),
|
git.calls.filter((a) => a[0] === 'fetch'),
|
||||||
[
|
[['fetch', PUBLIC_REMOTE, 'master']],
|
||||||
['fetch', REMOTE, 'master'],
|
|
||||||
['fetch', PUBLIC_REMOTE, 'master'],
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
assert.deepEqual(git.calls.find(isPush), ['push', REMOTE, 'HEAD:refs/heads/bundle/2.x']);
|
assert.deepEqual(git.calls.find(isPush), ['push', REMOTE, 'HEAD:refs/heads/bundle/2.x']);
|
||||||
});
|
});
|
||||||
|
|
@ -301,97 +281,6 @@ test('annotation keeps a multi-line message on one line', () => {
|
||||||
assert.equal(annotation('t', 'a\nb'), '::error title=t::a%0Ab');
|
assert.equal(annotation('t', 'a\nb'), '::error title=t::a%0Ab');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a base that has not reached the public repo is left alone', () => {
|
|
||||||
const git = makeStub([
|
|
||||||
...unpublishedRoutes('abc1234 chore: Bundle 2.x (#123)'),
|
|
||||||
...baseGitRoutes,
|
|
||||||
]);
|
|
||||||
const logs = [];
|
|
||||||
|
|
||||||
const result = syncBundleBranch({ git, env, log: (m) => logs.push(m) });
|
|
||||||
|
|
||||||
assert.deepEqual(result, { status: 'unconverged' });
|
|
||||||
// Nothing about the bundle branch is even asked: no existence probe, no fetch, no checkout.
|
|
||||||
assert.equal(
|
|
||||||
git.calls.some((a) => a[0] === 'ls-remote'),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
git.calls.some((a) => a[0] === 'checkout'),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
assert.equal(git.calls.some(isMerge), false);
|
|
||||||
assert.equal(git.calls.some(isPush), false);
|
|
||||||
assert.equal(git.calls.filter((a) => a[0] === 'fetch').length, 2);
|
|
||||||
assert.match(logs.join('\n'), /has not reached public master/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a missing bundle branch is not created from an unpublished base', () => {
|
|
||||||
const git = makeStub([
|
|
||||||
[(a) => a[0] === 'ls-remote', ''],
|
|
||||||
...unpublishedRoutes('abc1234 chore: Bundle/1.x'),
|
|
||||||
...baseGitRoutes,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// One guard covers both paths: the branch must not be conjured onto a doomed commit either.
|
|
||||||
assert.deepEqual(syncBundleBranch({ git, env, log: silent }), { status: 'unconverged' });
|
|
||||||
assert.equal(
|
|
||||||
git.calls.some((a) => a[0] === 'checkout'),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
assert.equal(git.calls.some(isPush), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a base ahead of public by a non-bundle commit fails loudly', () => {
|
|
||||||
const git = makeStub([
|
|
||||||
...unpublishedRoutes('abc1234 fix(core): Something a human pushed directly'),
|
|
||||||
...baseGitRoutes,
|
|
||||||
]);
|
|
||||||
const logs = [];
|
|
||||||
|
|
||||||
assert.throws(() => syncBundleBranch({ git, env, log: (m) => logs.push(m) }), {
|
|
||||||
message: /master is ahead of public by commits that are not bundle cuts/,
|
|
||||||
});
|
|
||||||
assert.equal(git.calls.some(isPush), false);
|
|
||||||
|
|
||||||
// The annotation is the headline of a failed run: a count, never a subject.
|
|
||||||
const annotations = logs.filter((m) => m.startsWith('::error'));
|
|
||||||
assert.match(annotations.join('\n'), /carries 1 commit\(s\)/);
|
|
||||||
assert.doesNotMatch(annotations.join('\n'), /Something a human pushed directly/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('one stray commit among bundle cuts still fails', () => {
|
|
||||||
const git = makeStub([
|
|
||||||
...unpublishedRoutes('abc1234 chore: Bundle 2.x (#123)', 'def5678 fix(core): Stray'),
|
|
||||||
...baseGitRoutes,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// All-or-nothing: a single unexplained commit means the mirror cannot clear the base.
|
|
||||||
assert.throws(() => syncBundleBranch({ git, env, log: silent }), {
|
|
||||||
message: /not bundle cuts/,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('an unrelated history skips rather than forcing anything', () => {
|
|
||||||
const git = makeStub([...unpublishedRoutes(), ...baseGitRoutes]);
|
|
||||||
|
|
||||||
// Ancestry fails but the range is empty, so there is nothing to classify. Skipping is the
|
|
||||||
// only safe default here.
|
|
||||||
assert.deepEqual(syncBundleBranch({ git, env, log: silent }), { status: 'unconverged' });
|
|
||||||
assert.equal(git.calls.some(isPush), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('an unconverged base is not retried as a push race', () => {
|
|
||||||
const git = makeStub([...unpublishedRoutes('abc1234 chore: Bundle 2.x'), ...baseGitRoutes]);
|
|
||||||
const logs = [];
|
|
||||||
|
|
||||||
syncBundleBranch({ git, env, log: (m) => logs.push(m) });
|
|
||||||
|
|
||||||
// Only 'rejected' earns a second attempt; the mirror runs hourly, not within this run.
|
|
||||||
assert.equal(git.calls.filter((a) => a[0] === 'fetch').length, 2);
|
|
||||||
assert.doesNotMatch(logs.join('\n'), /retrying/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a public fetch that fails stops the run instead of guessing', () => {
|
test('a public fetch that fails stops the run instead of guessing', () => {
|
||||||
const git = makeStub([
|
const git = makeStub([
|
||||||
[(a) => a[0] === 'fetch' && a[1] === PUBLIC_REMOTE, fail('could not read from remote')],
|
[(a) => a[0] === 'fetch' && a[1] === PUBLIC_REMOTE, fail('could not read from remote')],
|
||||||
|
|
@ -423,7 +312,6 @@ test('the 1.x bundle branch is checked against public 1.x', () => {
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
git.calls.filter((a) => a[0] === 'fetch'),
|
git.calls.filter((a) => a[0] === 'fetch'),
|
||||||
[
|
[
|
||||||
['fetch', REMOTE, '1.x'],
|
|
||||||
['fetch', PUBLIC_REMOTE, '1.x'],
|
['fetch', PUBLIC_REMOTE, '1.x'],
|
||||||
['fetch', REMOTE, 'bundle/1.x'],
|
['fetch', REMOTE, 'bundle/1.x'],
|
||||||
],
|
],
|
||||||
|
|
|
||||||
34
.github/scripts/test-files.mjs
vendored
Normal file
34
.github/scripts/test-files.mjs
vendored
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { matchesGlob } from './glob.mjs';
|
||||||
|
|
||||||
|
export const TEST_PATTERNS = [
|
||||||
|
// Test files (by extension)
|
||||||
|
'**/*.test.ts',
|
||||||
|
'**/*.test.js',
|
||||||
|
'**/*.test.mjs',
|
||||||
|
'**/*.spec.ts',
|
||||||
|
'**/*.spec.js',
|
||||||
|
'**/*.spec.mjs',
|
||||||
|
// Test directories
|
||||||
|
'**/test/**',
|
||||||
|
'**/tests/**',
|
||||||
|
'**/__tests__/**',
|
||||||
|
// Snapshots
|
||||||
|
'**/__snapshots__/**',
|
||||||
|
'**/*.snap',
|
||||||
|
// Fixtures and mocks
|
||||||
|
'**/fixtures/**',
|
||||||
|
'**/__mocks__/**',
|
||||||
|
// Former packages/testing workspaces
|
||||||
|
'packages/quality/policy/**',
|
||||||
|
'packages/quality/testing/**',
|
||||||
|
'packages/quality/environments/**',
|
||||||
|
'packages/quality/efficiency/microbenchmarks/**',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param { string } filename
|
||||||
|
* @returns { boolean }
|
||||||
|
*/
|
||||||
|
export function isTestFile(filename) {
|
||||||
|
return TEST_PATTERNS.some((pattern) => matchesGlob(filename, pattern));
|
||||||
|
}
|
||||||
2
.github/test-metrics/e2e-impact-map.json
vendored
2
.github/test-metrics/e2e-impact-map.json
vendored
File diff suppressed because one or more lines are too long
1608
.github/test-metrics/playwright.json
vendored
1608
.github/test-metrics/playwright.json
vendored
File diff suppressed because it is too large
Load Diff
2
.github/workflows/backport.yml
vendored
2
.github/workflows/backport.yml
vendored
|
|
@ -38,7 +38,7 @@ jobs:
|
||||||
app-id: ${{ secrets.N8N_ASSISTANT_APP_ID }}
|
app-id: ${{ secrets.N8N_ASSISTANT_APP_ID }}
|
||||||
private-key: ${{ secrets.N8N_ASSISTANT_PRIVATE_KEY }}
|
private-key: ${{ secrets.N8N_ASSISTANT_PRIVATE_KEY }}
|
||||||
|
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
token: ${{ steps.generate-token.outputs.token }}
|
token: ${{ steps.generate-token.outputs.token }}
|
||||||
|
|
||||||
|
|
|
||||||
12
.github/workflows/build-base-image.yml
vendored
12
.github/workflows/build-base-image.yml
vendored
|
|
@ -32,13 +32,13 @@ jobs:
|
||||||
# when updating.
|
# when updating.
|
||||||
include:
|
include:
|
||||||
- node_version: '22'
|
- node_version: '22'
|
||||||
dhi_ref: dhi.io/node:22.23.2-alpine3.24-dev@sha256:155a95d59244a71e9479e191ff718f03a7197d6829612e0707608be92988cb87
|
dhi_ref: dhi.io/node:22.23.3-alpine3.24-dev@sha256:23ee07ace431ea7d14e22c0e52fb52b0bd1853dabdacd89ba75f937f6dff8a8d
|
||||||
- node_version: '24.19.0'
|
- node_version: '24.21.0'
|
||||||
dhi_ref: dhi.io/node:24.19.0-alpine3.24-dev@sha256:dc2989ad23938772abaf549ed7bde5d61d70377005229bb55bef94bff40f8bbd
|
dhi_ref: dhi.io/node:24.21.0-alpine3.24-dev@sha256:d22ba0e9ab2a54609ae533df4ebfd6f36c1f233b662a4c547f5a3195e8c21937
|
||||||
- node_version: '26.7.0'
|
- node_version: '26.10.0'
|
||||||
dhi_ref: dhi.io/node:26.7.0-alpine3.24-dev@sha256:4b494d89fb26c950ce97865acf45b480dc7a6868fdc2b81c2d66599702eeac3f
|
dhi_ref: dhi.io/node:26.10.0-alpine3.24-dev@sha256:8da859df5dc853c923ace53c4cfe994a909a28917f7fcfc8508869beab8cf132
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||||
|
|
|
||||||
6
.github/workflows/build-benchmark-image.yml
vendored
6
.github/workflows/build-benchmark-image.yml
vendored
|
|
@ -6,7 +6,7 @@ on:
|
||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
paths:
|
paths:
|
||||||
- 'packages/@n8n/benchmark/**'
|
- 'packages/quality/efficiency/scale/benchmark/**'
|
||||||
- 'pnpm-lock.yaml'
|
- 'pnpm-lock.yaml'
|
||||||
- 'pnpm-workspace.yaml'
|
- 'pnpm-workspace.yaml'
|
||||||
- '.github/workflows/build-benchmark-image.yml'
|
- '.github/workflows/build-benchmark-image.yml'
|
||||||
|
|
@ -16,7 +16,7 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||||
|
|
@ -33,7 +33,7 @@ jobs:
|
||||||
DOCKER_BUILD_SUMMARY: false
|
DOCKER_BUILD_SUMMARY: false
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./packages/@n8n/benchmark/Dockerfile
|
file: ./packages/quality/efficiency/scale/benchmark/Dockerfile
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
provenance: false
|
provenance: false
|
||||||
push: true
|
push: true
|
||||||
|
|
|
||||||
6
.github/workflows/build-node-pc-image.yml
vendored
6
.github/workflows/build-node-pc-image.yml
vendored
|
|
@ -25,7 +25,7 @@ on:
|
||||||
type: boolean
|
type: boolean
|
||||||
|
|
||||||
env:
|
env:
|
||||||
NODE_VERSION: '26.7.0'
|
NODE_VERSION: '26.10.0'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
|
|
@ -42,7 +42,7 @@ jobs:
|
||||||
runner: blacksmith-32vcpu-ubuntu-2204-arm
|
runner: blacksmith-32vcpu-ubuntu-2204-arm
|
||||||
arch: arm64
|
arch: arm64
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||||
|
|
@ -100,7 +100,7 @@ jobs:
|
||||||
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.push == true)
|
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.push == true)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
|
||||||
- name: Login to Docker registries
|
- name: Login to Docker registries
|
||||||
uses: ./.github/actions/docker-registry-login
|
uses: ./.github/actions/docker-registry-login
|
||||||
|
|
|
||||||
2
.github/workflows/build-v3-nightly.yml
vendored
2
.github/workflows/build-v3-nightly.yml
vendored
|
|
@ -80,7 +80,7 @@ jobs:
|
||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
|
|
|
||||||
3
.github/workflows/build-windows.yml
vendored
3
.github/workflows/build-windows.yml
vendored
|
|
@ -23,6 +23,7 @@ on:
|
||||||
paths:
|
paths:
|
||||||
- '**/package.json'
|
- '**/package.json'
|
||||||
- '**/turbo.json'
|
- '**/turbo.json'
|
||||||
|
- 'packages/cli/scripts/build.mjs'
|
||||||
- '.github/workflows/build-windows.yml'
|
- '.github/workflows/build-windows.yml'
|
||||||
- '.github/actions/setup-nodejs/**'
|
- '.github/actions/setup-nodejs/**'
|
||||||
|
|
||||||
|
|
@ -32,7 +33,7 @@ jobs:
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
|
||||||
- name: Setup Node.js and Build
|
- name: Setup Node.js and Build
|
||||||
uses: ./.github/actions/setup-nodejs
|
uses: ./.github/actions/setup-nodejs
|
||||||
|
|
|
||||||
3
.github/workflows/ci-cla-check.yml
vendored
3
.github/workflows/ci-cla-check.yml
vendored
|
|
@ -69,7 +69,8 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
# Defaults for the CLA endpoints, status context, comment marker and label
|
# Defaults for the CLA endpoints, status context, comment marker and label
|
||||||
# are already n8n's production values.
|
# are already n8n's production values.
|
||||||
- uses: n8n-io/github-actions/cla-check@931777df53ace1bdb31d2bf5c63c9f2d27451c35 # cla-check/v1.0.0
|
- uses: n8n-io/github-actions/cla-check@ea082925743265a61d9fd0a69a32b211551f88f0 # v1.0.2
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.N8N_ASSISTANT_APP_ID }}
|
app-id: ${{ secrets.N8N_ASSISTANT_APP_ID }}
|
||||||
private-key: ${{ secrets.N8N_ASSISTANT_PRIVATE_KEY }}
|
private-key: ${{ secrets.N8N_ASSISTANT_PRIVATE_KEY }}
|
||||||
|
pr-number: ${{ github.event_name == 'workflow_dispatch' && inputs.pr_number || github.event.pull_request.number || github.event.issue.number }}
|
||||||
|
|
|
||||||
2
.github/workflows/ci-detect-new-packages.yml
vendored
2
.github/workflows/ci-detect-new-packages.yml
vendored
|
|
@ -14,7 +14,7 @@ jobs:
|
||||||
if: github.event.pull_request.merged == true
|
if: github.event.pull_request.merged == true
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: ./.github/actions/setup-nodejs
|
uses: ./.github/actions/setup-nodejs
|
||||||
|
|
|
||||||
56
.github/workflows/ci-instance-ai-evals.yml
vendored
56
.github/workflows/ci-instance-ai-evals.yml
vendored
|
|
@ -1,10 +1,15 @@
|
||||||
name: 'Instance AI Evals: PR Gate'
|
name: 'Instance AI Evals: PR Gate'
|
||||||
run-name: "${{ github.event_name == 'workflow_dispatch' && format('Instance AI Evals: PR #{0} (tier={1})', inputs.pr, inputs.tier || 'auto') || '' }}"
|
run-name: "${{ github.event_name == 'workflow_dispatch' && format('Instance AI Evals: PR #{0} (tier={1})', inputs.pr, inputs.tier || 'pr') || '' }}"
|
||||||
|
|
||||||
# The PR gate for Instance AI workflow and Agent evals. Auto-runs on PR open/reopen/ready
|
# The PR gate for Instance AI workflow and Agent evals. Auto-runs on PR open/reopen/ready
|
||||||
# (non-draft, non-fork, path-filtered); manual dispatch re-runs a specific PR
|
# (non-draft, non-fork, path-filtered); manual dispatch re-runs a specific PR
|
||||||
# and posts results back. Pushes don't re-trigger (no `synchronize`).
|
# and posts results back. Pushes don't re-trigger (no `synchronize`).
|
||||||
#
|
#
|
||||||
|
# Every run uses the `pr` tier: a hand-picked set of stable cases. A PR that
|
||||||
|
# touches the Agent Builder sub-agent runs the `pr`-tagged cases of the
|
||||||
|
# LangTracer `agents` suite; every other PR runs the `pr`-tagged cases of
|
||||||
|
# `baseline`. Full suites belong to the nightly LangTracer runs, never here.
|
||||||
|
#
|
||||||
# This workflow is deliberately NOT the experimentation surface. For baselines,
|
# This workflow is deliberately NOT the experimentation surface. For baselines,
|
||||||
# model experiments, or arbitrary branch runs, dispatch test-evals-instance-ai.yml
|
# model experiments, or arbitrary branch runs, dispatch test-evals-instance-ai.yml
|
||||||
# ("Instance AI Evals: Experiments") directly: it exposes the full knob set
|
# ("Instance AI Evals: Experiments") directly: it exposes the full knob set
|
||||||
|
|
@ -28,7 +33,7 @@ on:
|
||||||
description: 'PR number to re-run against its latest commit. Resolves the PR head at dispatch time and posts results back. For branch/experiment runs use "Instance AI Evals: Experiments" instead.'
|
description: 'PR number to re-run against its latest commit. Resolves the PR head at dispatch time and posts results back. For branch/experiment runs use "Instance AI Evals: Experiments" instead.'
|
||||||
required: true
|
required: true
|
||||||
tier:
|
tier:
|
||||||
description: 'Test-case dataset for the re-run. Empty = `agents` for Agent Builder changes or `pr` otherwise.'
|
description: 'Test-case dataset for the re-run. Empty = `pr`.'
|
||||||
required: false
|
required: false
|
||||||
default: ''
|
default: ''
|
||||||
|
|
||||||
|
|
@ -60,7 +65,7 @@ jobs:
|
||||||
revision_sha: ${{ steps.resolve.outputs.revision_sha }}
|
revision_sha: ${{ steps.resolve.outputs.revision_sha }}
|
||||||
head_ref: ${{ steps.resolve.outputs.head_ref }}
|
head_ref: ${{ steps.resolve.outputs.head_ref }}
|
||||||
pr_number: ${{ steps.resolve.outputs.pr_number }}
|
pr_number: ${{ steps.resolve.outputs.pr_number }}
|
||||||
suite: ${{ steps.resolve.outputs.suite }}
|
suite: ${{ fromJSON(steps.ci-filter.outputs.results)['agent-builder'] == true && 'agents' || 'baseline' }}
|
||||||
tier: ${{ steps.resolve.outputs.tier }}
|
tier: ${{ steps.resolve.outputs.tier }}
|
||||||
steps:
|
steps:
|
||||||
- name: Resolve target ref
|
- name: Resolve target ref
|
||||||
|
|
@ -74,6 +79,7 @@ jobs:
|
||||||
INPUT_TIER: ${{ inputs.tier }}
|
INPUT_TIER: ${{ inputs.tier }}
|
||||||
EVENT_PR_NUMBER: ${{ github.event.pull_request.number }}
|
EVENT_PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||||
EVENT_HEAD_REF: ${{ github.event.pull_request.head.ref }}
|
EVENT_HEAD_REF: ${{ github.event.pull_request.head.ref }}
|
||||||
|
EVENT_BASE_REF: ${{ github.event.pull_request.base.ref }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
if [ -n "$INPUT_PR" ]; then
|
if [ -n "$INPUT_PR" ]; then
|
||||||
|
|
@ -93,6 +99,7 @@ jobs:
|
||||||
fi
|
fi
|
||||||
head_sha=$(echo "$pr_json" | jq -r '.head.sha')
|
head_sha=$(echo "$pr_json" | jq -r '.head.sha')
|
||||||
head_ref=$(echo "$pr_json" | jq -r '.head.ref')
|
head_ref=$(echo "$pr_json" | jq -r '.head.ref')
|
||||||
|
base_ref=$(echo "$pr_json" | jq -r '.base.ref')
|
||||||
merge_sha=$(echo "$pr_json" | jq -r '.merge_commit_sha // empty')
|
merge_sha=$(echo "$pr_json" | jq -r '.merge_commit_sha // empty')
|
||||||
tested_sha="$head_sha"
|
tested_sha="$head_sha"
|
||||||
if [ -n "$merge_sha" ] && \
|
if [ -n "$merge_sha" ] && \
|
||||||
|
|
@ -104,37 +111,56 @@ jobs:
|
||||||
echo "PR #$INPUT_PR: testing head $head_sha (merge ref stale/unavailable; image rebuilt)"
|
echo "PR #$INPUT_PR: testing head $head_sha (merge ref stale/unavailable; image rebuilt)"
|
||||||
fi
|
fi
|
||||||
branch="$tested_sha"; cache_sha="$tested_sha"; revision_sha="$tested_sha"
|
branch="$tested_sha"; cache_sha="$tested_sha"; revision_sha="$tested_sha"
|
||||||
pr_number="$INPUT_PR"; tier="${INPUT_TIER:-pr}"
|
classify_sha="$head_sha"; pr_number="$INPUT_PR"; tier="${INPUT_TIER:-pr}"
|
||||||
elif [ "$EVENT_NAME" = "pull_request" ]; then
|
elif [ "$EVENT_NAME" = "pull_request" ]; then
|
||||||
# PR open/reopen/ready: unchanged - test the merge commit, which also
|
# PR open/reopen/ready: unchanged - test the merge commit, which also
|
||||||
# matches the SHA-keyed prepare-docker image cache.
|
# matches the SHA-keyed prepare-docker image cache.
|
||||||
branch="$GH_SHA"; cache_sha="$GH_SHA"; revision_sha="$GH_SHA"
|
branch="$GH_SHA"; cache_sha="$GH_SHA"; revision_sha="$GH_SHA"
|
||||||
head_ref="$EVENT_HEAD_REF"; pr_number="$EVENT_PR_NUMBER"; tier="${INPUT_TIER:-pr}"
|
classify_sha="$GH_SHA"
|
||||||
|
head_ref="$EVENT_HEAD_REF"; base_ref="$EVENT_BASE_REF"; pr_number="$EVENT_PR_NUMBER"; tier="${INPUT_TIER:-pr}"
|
||||||
else
|
else
|
||||||
# workflow_dispatch enforces `pr` as required; this is unreachable.
|
# workflow_dispatch enforces `pr` as required; this is unreachable.
|
||||||
echo "::error::manual dispatch requires the pr input"
|
echo "::error::manual dispatch requires the pr input"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
changed_files=$(gh api --paginate "repos/$REPO/pulls/$pr_number/files" --jq '.[].filename')
|
|
||||||
suite=baseline
|
|
||||||
if grep -q '^packages/cli/src/modules/agents/' <<< "$changed_files"; then
|
|
||||||
suite=agents
|
|
||||||
if [ -n "$INPUT_TIER" ] && [ "$INPUT_TIER" != "agents" ]; then
|
|
||||||
echo "::notice::Agent Builder changes always run the agents tier. Ignoring tier '$INPUT_TIER'."
|
|
||||||
fi
|
|
||||||
tier=agents
|
|
||||||
fi
|
|
||||||
{
|
{
|
||||||
echo "branch=$branch"
|
echo "branch=$branch"
|
||||||
echo "cache_sha=$cache_sha"
|
echo "cache_sha=$cache_sha"
|
||||||
echo "revision_sha=$revision_sha"
|
echo "revision_sha=$revision_sha"
|
||||||
|
echo "classify_sha=$classify_sha"
|
||||||
echo "head_ref=$head_ref"
|
echo "head_ref=$head_ref"
|
||||||
|
echo "base_ref=$base_ref"
|
||||||
echo "pr_number=$pr_number"
|
echo "pr_number=$pr_number"
|
||||||
echo "suite=$suite"
|
|
||||||
echo "tier=$tier"
|
echo "tier=$tier"
|
||||||
} >> "$GITHUB_OUTPUT"
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
# Same classifier as the rest of CI. On pull_request the checkout is the
|
||||||
|
# test-merge commit and ci-filter diffs its PR-head parent against the
|
||||||
|
# merge base; on dispatch it diffs the PR head, so a stacked PR is not
|
||||||
|
# charged with files its parent branch changed since the merge commit.
|
||||||
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with:
|
||||||
|
ref: ${{ steps.resolve.outputs.classify_sha }}
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
# Agent Builder sub-agent paths pick the `agents` suite. The rest of the
|
||||||
|
# agents module (runtime, channels, sessions) is not exercised by the
|
||||||
|
# builder cases, so it stays on `baseline`.
|
||||||
|
- name: Classify changed files
|
||||||
|
id: ci-filter
|
||||||
|
uses: ./.github/actions/ci-filter
|
||||||
|
with:
|
||||||
|
mode: filter
|
||||||
|
base-ref: ${{ steps.resolve.outputs.base_ref }}
|
||||||
|
filters: |
|
||||||
|
agent-builder:
|
||||||
|
packages/cli/src/modules/agents/builder/**
|
||||||
|
packages/cli/src/modules/agents/json-config/**
|
||||||
|
packages/cli/src/modules/agents/adapters/**
|
||||||
|
packages/cli/src/modules/agents/instance-ai-builder-delegate.adapter.ts
|
||||||
|
packages/@n8n/instance-ai/skills/agent-builder/**
|
||||||
|
|
||||||
run-evals:
|
run-evals:
|
||||||
name: Instance AI Workflow Evals
|
name: Instance AI Workflow Evals
|
||||||
needs: resolve
|
needs: resolve
|
||||||
|
|
|
||||||
29
.github/workflows/ci-master.yml
vendored
29
.github/workflows/ci-master.yml
vendored
|
|
@ -7,17 +7,21 @@ on:
|
||||||
paths-ignore:
|
paths-ignore:
|
||||||
- packages/@n8n/task-runner-python/**
|
- packages/@n8n/task-runner-python/**
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-github:
|
build-and-format:
|
||||||
name: Build for Github Cache
|
name: Build & format
|
||||||
runs-on: ubuntu-latest
|
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||||
env:
|
env:
|
||||||
|
NODE_OPTIONS: '--max-old-space-size=7168'
|
||||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||||
QA_METRICS_WEBHOOK_URL: ${{ secrets.QA_METRICS_WEBHOOK_URL }}
|
QA_METRICS_WEBHOOK_URL: ${{ secrets.QA_METRICS_WEBHOOK_URL }}
|
||||||
QA_METRICS_WEBHOOK_USER: ${{ secrets.QA_METRICS_WEBHOOK_USER }}
|
QA_METRICS_WEBHOOK_USER: ${{ secrets.QA_METRICS_WEBHOOK_USER }}
|
||||||
QA_METRICS_WEBHOOK_PASSWORD: ${{ secrets.QA_METRICS_WEBHOOK_PASSWORD }}
|
QA_METRICS_WEBHOOK_PASSWORD: ${{ secrets.QA_METRICS_WEBHOOK_PASSWORD }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
- uses: useblacksmith/checkout@bcec731f1eb1367240608d1c889a75b96db6ec53 # v1.5.0
|
||||||
- name: Setup and Build
|
- name: Setup and Build
|
||||||
uses: ./.github/actions/setup-nodejs
|
uses: ./.github/actions/setup-nodejs
|
||||||
with:
|
with:
|
||||||
|
|
@ -27,6 +31,9 @@ jobs:
|
||||||
- name: Report build critical path
|
- name: Report build critical path
|
||||||
run: node scripts/turbo-critical-path.mjs >> "$GITHUB_STEP_SUMMARY"
|
run: node scripts/turbo-critical-path.mjs >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
|
||||||
|
- name: Run format check
|
||||||
|
run: pnpm format:check
|
||||||
|
|
||||||
unit-test:
|
unit-test:
|
||||||
name: Unit tests
|
name: Unit tests
|
||||||
uses: ./.github/workflows/test-unit-reusable.yml
|
uses: ./.github/workflows/test-unit-reusable.yml
|
||||||
|
|
@ -46,13 +53,9 @@ jobs:
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.sha }}
|
ref: ${{ github.sha }}
|
||||||
|
|
||||||
# CodSpeed benchmarks run post-merge only. On PRs they failed for reasons
|
# The CodSpeed `performance` job (test-bench-reusable.yml) is removed for now:
|
||||||
# unrelated to the change, so the red check gave authors no usable signal.
|
# @codspeed/vitest-plugin does not support Vitest 5 yet. Add the job back, and
|
||||||
performance:
|
# add it to `notify-on-failure.needs`, when the plugin supports Vitest 5.
|
||||||
name: Performance
|
|
||||||
uses: ./.github/workflows/test-bench-reusable.yml
|
|
||||||
with:
|
|
||||||
ref: ${{ github.sha }}
|
|
||||||
|
|
||||||
# Scoped to the packages this push touched, so it stays cheap enough to run on every commit.
|
# Scoped to the packages this push touched, so it stays cheap enough to run on every commit.
|
||||||
verify-single-instance-npm:
|
verify-single-instance-npm:
|
||||||
|
|
@ -67,10 +70,10 @@ jobs:
|
||||||
notify-on-failure:
|
notify-on-failure:
|
||||||
name: Notify Slack on failure
|
name: Notify Slack on failure
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [unit-test, lint, performance, build-github]
|
needs: [unit-test, lint, build-and-format]
|
||||||
if: ${{ always() && contains(needs.*.result, 'failure') }}
|
if: ${{ always() && contains(needs.*.result, 'failure') }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
sparse-checkout: .github/scripts/slack
|
sparse-checkout: .github/scripts/slack
|
||||||
sparse-checkout-cone-mode: false
|
sparse-checkout-cone-mode: false
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ jobs:
|
||||||
|
|
||||||
# The script reads only `.github/` and the root OWNERS file, which cone
|
# The script reads only `.github/` and the root OWNERS file, which cone
|
||||||
# mode always includes.
|
# mode always includes.
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
sparse-checkout: .github
|
sparse-checkout: .github
|
||||||
|
|
||||||
|
|
|
||||||
32
.github/workflows/ci-owners-required-reviews.yml
vendored
32
.github/workflows/ci-owners-required-reviews.yml
vendored
|
|
@ -19,11 +19,11 @@ run-name: "${{ github.event_name == 'workflow_dispatch' && format('CI: Owners Re
|
||||||
# success status.
|
# success status.
|
||||||
#
|
#
|
||||||
# Triggers
|
# Triggers
|
||||||
# - pull_request_target (opened/synchronize/reopened/ready_for_review, and
|
# - pull_request_target (opened/synchronize/reopened/ready_for_review,
|
||||||
# edited when the base branch changes): re-evaluates whenever the
|
# labeled/unlabeled, and edited when the base branch changes): re-evaluates
|
||||||
# changeset can change, for same-repo and fork heads alike. A retargeted
|
# whenever the changeset or large-scale exemption can change, for same-repo
|
||||||
# PR keeps its head SHA, so a verdict computed against the old base must
|
# and fork heads alike. A retargeted PR keeps its head SHA, so a verdict
|
||||||
# not survive the retarget.
|
# computed against the old base must not survive the retarget.
|
||||||
# - pull_request_review (submitted/dismissed): re-evaluates when approvals
|
# - pull_request_review (submitted/dismissed): re-evaluates when approvals
|
||||||
# change on a same-repo PR. A fork PR's review event runs without secrets
|
# change on a same-repo PR. A fork PR's review event runs without secrets
|
||||||
# and with a read-only token, so this path skips fork heads.
|
# and with a read-only token, so this path skips fork heads.
|
||||||
|
|
@ -43,9 +43,11 @@ run-name: "${{ github.event_name == 'workflow_dispatch' && format('CI: Owners Re
|
||||||
# The checkout below always pins master, never the base branch or the PR
|
# The checkout below always pins master, never the base branch or the PR
|
||||||
# merge ref: the scripts, the OWNERS file and the exemption routes are trusted
|
# merge ref: the scripts, the OWNERS file and the exemption routes are trusted
|
||||||
# input, so a PR cannot lift its own review requirement or reach the
|
# input, so a PR cannot lift its own review requirement or reach the
|
||||||
# status-writing token. Any branch can be a base, and anyone with push access
|
# status-writing token. The large-scale-change label is trusted only when its
|
||||||
# can edit a branch, so only master is trusted. This is also what makes
|
# actor belongs to the large-scale-changes team. Any branch can be a base, and
|
||||||
# pull_request_target safe here: no step checks out or runs PR code.
|
# anyone with push access can edit a branch, so only master is trusted. This is
|
||||||
|
# also what makes pull_request_target safe here: no step checks out or runs PR
|
||||||
|
# code.
|
||||||
#
|
#
|
||||||
# Output
|
# Output
|
||||||
# - A commit status named "Required Reviews" on the PR head SHA (or
|
# - A commit status named "Required Reviews" on the PR head SHA (or
|
||||||
|
|
@ -58,7 +60,7 @@ run-name: "${{ github.event_name == 'workflow_dispatch' && format('CI: Owners Re
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request_target:
|
pull_request_target:
|
||||||
types: [opened, synchronize, reopened, ready_for_review, edited]
|
types: [opened, synchronize, reopened, ready_for_review, edited, labeled, unlabeled]
|
||||||
pull_request_review:
|
pull_request_review:
|
||||||
types: [submitted, dismissed]
|
types: [submitted, dismissed]
|
||||||
workflow_run:
|
workflow_run:
|
||||||
|
|
@ -111,13 +113,17 @@ jobs:
|
||||||
required-reviews:
|
required-reviews:
|
||||||
name: Check required reviews
|
name: Check required reviews
|
||||||
# Of the edited events only a base change matters; title and body edits
|
# Of the edited events only a base change matters; title and body edits
|
||||||
# do not change the verdict. Review events split by head: same-repo
|
# do not change the verdict. Of the label events only large-scale-change
|
||||||
# heads come in through pull_request_review, fork heads through the
|
# can change the exemption. Review events split by head: same-repo heads
|
||||||
# workflow_run of the review workflow.
|
# come in through pull_request_review, fork heads through the workflow_run
|
||||||
|
# of the review workflow.
|
||||||
if: >-
|
if: >-
|
||||||
github.event_name != 'merge_group' &&
|
github.event_name != 'merge_group' &&
|
||||||
(github.event_name != 'pull_request_target' ||
|
(github.event_name != 'pull_request_target' ||
|
||||||
github.event.action != 'edited' || github.event.changes.base) &&
|
github.event.action != 'edited' || github.event.changes.base) &&
|
||||||
|
(github.event_name != 'pull_request_target' ||
|
||||||
|
(github.event.action != 'labeled' && github.event.action != 'unlabeled') ||
|
||||||
|
github.event.label.name == 'large-scale-change') &&
|
||||||
(github.event_name != 'pull_request_review' ||
|
(github.event_name != 'pull_request_review' ||
|
||||||
(github.event.pull_request.head.repo.full_name == github.repository &&
|
(github.event.pull_request.head.repo.full_name == github.repository &&
|
||||||
github.event.review.state != 'commented')) &&
|
github.event.review.state != 'commented')) &&
|
||||||
|
|
@ -173,7 +179,7 @@ jobs:
|
||||||
# the OWNERS file, the scripts and the exemption routes must be trusted
|
# the OWNERS file, the scripts and the exemption routes must be trusted
|
||||||
# input, and any writable branch can be a base. The script reads only
|
# input, and any writable branch can be a base. The script reads only
|
||||||
# `.github/` and the root files, which cone mode always includes.
|
# `.github/` and the root files, which cone mode always includes.
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
ref: master
|
ref: master
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ jobs:
|
||||||
|
|
||||||
# The script reads only `.github/` and the root OWNERS file, which cone
|
# The script reads only `.github/` and the root OWNERS file, which cone
|
||||||
# mode always includes.
|
# mode always includes.
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
sparse-checkout: .github
|
sparse-checkout: .github
|
||||||
|
|
||||||
|
|
|
||||||
2
.github/workflows/ci-owners-validation.yml
vendored
2
.github/workflows/ci-owners-validation.yml
vendored
|
|
@ -31,7 +31,7 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
|
|
|
||||||
16
.github/workflows/ci-pr-quality.yml
vendored
16
.github/workflows/ci-pr-quality.yml
vendored
|
|
@ -31,7 +31,7 @@ jobs:
|
||||||
pull-requests: read
|
pull-requests: read
|
||||||
steps:
|
steps:
|
||||||
# The script reads nothing outside `.github/`.
|
# The script reads nothing outside `.github/`.
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
sparse-checkout: .github
|
sparse-checkout: .github
|
||||||
|
|
||||||
|
|
@ -64,7 +64,7 @@ jobs:
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
steps:
|
steps:
|
||||||
# The script reads nothing outside `.github/`.
|
# The script reads nothing outside `.github/`.
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
sparse-checkout: .github
|
sparse-checkout: .github
|
||||||
|
|
||||||
|
|
@ -90,7 +90,7 @@ jobs:
|
||||||
changed-files: ${{ steps.filter.outputs.changed-files }}
|
changed-files: ${{ steps.filter.outputs.changed-files }}
|
||||||
added-files: ${{ steps.filter.outputs.added-files }}
|
added-files: ${{ steps.filter.outputs.added-files }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
|
||||||
- name: Detect changed paths
|
- name: Detect changed paths
|
||||||
id: filter
|
id: filter
|
||||||
|
|
@ -99,8 +99,8 @@ jobs:
|
||||||
mode: filter
|
mode: filter
|
||||||
filters: |
|
filters: |
|
||||||
janitor:
|
janitor:
|
||||||
packages/testing/playwright/**
|
packages/quality/testing/playwright/**
|
||||||
packages/testing/janitor/**
|
packages/quality/testing/janitor/**
|
||||||
|
|
||||||
check-static-analysis:
|
check-static-analysis:
|
||||||
name: Static Analysis
|
name: Static Analysis
|
||||||
|
|
@ -111,7 +111,7 @@ jobs:
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: ./.github/actions/setup-nodejs
|
uses: ./.github/actions/setup-nodejs
|
||||||
|
|
@ -138,7 +138,7 @@ jobs:
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
|
|
@ -174,7 +174,7 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
sparse-checkout: .github/actions/ci-filter
|
sparse-checkout: .github/actions/ci-filter
|
||||||
sparse-checkout-cone-mode: false
|
sparse-checkout-cone-mode: false
|
||||||
|
|
|
||||||
4
.github/workflows/ci-pull-request-review.yml
vendored
4
.github/workflows/ci-pull-request-review.yml
vendored
|
|
@ -22,7 +22,7 @@ jobs:
|
||||||
design_system: ${{ fromJSON(steps.ci-filter.outputs.results)['design-system'] == true }}
|
design_system: ${{ fromJSON(steps.ci-filter.outputs.results)['design-system'] == true }}
|
||||||
commit_sha: ${{ steps.commit-sha.outputs.sha }}
|
commit_sha: ${{ steps.commit-sha.outputs.sha }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
ref: refs/pull/${{ github.event.pull_request.number }}/merge
|
ref: refs/pull/${{ github.event.pull_request.number }}/merge
|
||||||
|
|
||||||
|
|
@ -63,7 +63,7 @@ jobs:
|
||||||
if: always()
|
if: always()
|
||||||
runs-on: ubuntu-slim
|
runs-on: ubuntu-slim
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
sparse-checkout: .github/actions/ci-filter
|
sparse-checkout: .github/actions/ci-filter
|
||||||
sparse-checkout-cone-mode: false
|
sparse-checkout-cone-mode: false
|
||||||
|
|
|
||||||
213
.github/workflows/ci-pull-requests.yml
vendored
213
.github/workflows/ci-pull-requests.yml
vendored
|
|
@ -11,7 +11,7 @@ concurrency:
|
||||||
jobs:
|
jobs:
|
||||||
install-and-build:
|
install-and-build:
|
||||||
name: Install & Build
|
name: Install & Build
|
||||||
runs-on: ${{ vars.RUNNER_PROVIDER == 'github' && 'ubuntu-latest' || 'blacksmith-4vcpu-ubuntu-2204' }}
|
runs-on: ${{ vars.RUNNER_PROVIDER == 'github' && 'ubuntu-latest' || 'blacksmith-8vcpu-ubuntu-2204' }}
|
||||||
env:
|
env:
|
||||||
NODE_OPTIONS: '--max-old-space-size=7168'
|
NODE_OPTIONS: '--max-old-space-size=7168'
|
||||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||||
|
|
@ -31,12 +31,16 @@ jobs:
|
||||||
db_migrations: ${{ fromJSON(steps.ci-filter.outputs.results)['db-migrations'] == true }}
|
db_migrations: ${{ fromJSON(steps.ci-filter.outputs.results)['db-migrations'] == true }}
|
||||||
e2e_performance: ${{ fromJSON(steps.ci-filter.outputs.results)['e2e-performance'] == true }}
|
e2e_performance: ${{ fromJSON(steps.ci-filter.outputs.results)['e2e-performance'] == true }}
|
||||||
engine_e2e: ${{ fromJSON(steps.ci-filter.outputs.results)['engine-e2e'] == true }}
|
engine_e2e: ${{ fromJSON(steps.ci-filter.outputs.results)['engine-e2e'] == true }}
|
||||||
|
# The main E2E suite runs. Read by e2e-matrix and e2e.
|
||||||
|
run_e2e: >-
|
||||||
|
${{ github.repository == 'n8n-io/n8n' && github.event_name != 'merge_group' &&
|
||||||
|
(fromJSON(steps.ci-filter.outputs.results).runtime == true
|
||||||
|
|| fromJSON(steps.ci-filter.outputs.results).e2e == true
|
||||||
|
|| contains(github.event.pull_request.labels.*.name, 'force-e2e')) }}
|
||||||
instance_ai_workflow_eval: ${{ fromJSON(steps.ci-filter.outputs.results)['instance-ai-workflow-eval'] == true }}
|
instance_ai_workflow_eval: ${{ fromJSON(steps.ci-filter.outputs.results)['instance-ai-workflow-eval'] == true }}
|
||||||
commit_sha: ${{ steps.commit-sha.outputs.sha }}
|
commit_sha: ${{ steps.commit-sha.outputs.sha }}
|
||||||
merge_base: ${{ steps.ci-filter.outputs.merge-base }}
|
merge_base: ${{ steps.ci-filter.outputs.merge-base }}
|
||||||
matrix: ${{ steps.generate-matrix.outputs.matrix }}
|
|
||||||
db_test_matrix: ${{ steps.generate-db-test-matrix.outputs.db_test_matrix }}
|
db_test_matrix: ${{ steps.generate-db-test-matrix.outputs.db_test_matrix }}
|
||||||
skip_tests: ${{ steps.generate-matrix.outputs.skip-tests }}
|
|
||||||
affected_packages: ${{ steps.affected-packages.outputs.list }}
|
affected_packages: ${{ steps.affected-packages.outputs.list }}
|
||||||
changed_files: ${{ steps.ci-filter.outputs.changed-files }}
|
changed_files: ${{ steps.ci-filter.outputs.changed-files }}
|
||||||
currents_record_key: ${{ secrets.CURRENTS_RECORD_KEY != '' && 'present' || '' }}
|
currents_record_key: ${{ secrets.CURRENTS_RECORD_KEY != '' && 'present' || '' }}
|
||||||
|
|
@ -45,6 +49,8 @@ jobs:
|
||||||
with:
|
with:
|
||||||
# Use merge_group SHA when in merge queue, otherwise PR merge ref
|
# Use merge_group SHA when in merge queue, otherwise PR merge ref
|
||||||
ref: ${{ github.event_name == 'merge_group' && github.event.merge_group.head_sha || format('refs/pull/{0}/merge', github.event.pull_request.number) }}
|
ref: ${{ github.event_name == 'merge_group' && github.event.merge_group.head_sha || format('refs/pull/{0}/merge', github.event.pull_request.number) }}
|
||||||
|
# Include recent history so the PR filter can reuse the checkout.
|
||||||
|
fetch-depth: 200
|
||||||
|
|
||||||
- name: Capture commit SHA for cache consistency
|
- name: Capture commit SHA for cache consistency
|
||||||
id: commit-sha
|
id: commit-sha
|
||||||
|
|
@ -73,7 +79,8 @@ jobs:
|
||||||
!**/CHANGELOG.md
|
!**/CHANGELOG.md
|
||||||
!**/*.test.ts
|
!**/*.test.ts
|
||||||
!**/*.spec.ts
|
!**/*.spec.ts
|
||||||
!packages/testing/playwright/**
|
!packages/quality/testing/playwright/**
|
||||||
|
!packages/quality/efficiency/**
|
||||||
!packages/frontend/@n8n/storybook/**
|
!packages/frontend/@n8n/storybook/**
|
||||||
!.devcontainer/**
|
!.devcontainer/**
|
||||||
!scripts/agent-setup.mjs
|
!scripts/agent-setup.mjs
|
||||||
|
|
@ -86,8 +93,9 @@ jobs:
|
||||||
unit:
|
unit:
|
||||||
**
|
**
|
||||||
!packages/@n8n/task-runner-python/**
|
!packages/@n8n/task-runner-python/**
|
||||||
!packages/testing/playwright/**
|
!packages/quality/testing/playwright/**
|
||||||
!.github/**
|
!.github/**
|
||||||
|
.github/workflows/test-unit-reusable.yml
|
||||||
!**/*.md
|
!**/*.md
|
||||||
!**/LICENSE
|
!**/LICENSE
|
||||||
!**/CHANGELOG.md
|
!**/CHANGELOG.md
|
||||||
|
|
@ -104,8 +112,8 @@ jobs:
|
||||||
.github/workflows/prepare-docker-reusable.yml
|
.github/workflows/prepare-docker-reusable.yml
|
||||||
.github/actions/build-n8n-docker/**
|
.github/actions/build-n8n-docker/**
|
||||||
.github/actions/load-n8n-docker/**
|
.github/actions/load-n8n-docker/**
|
||||||
packages/testing/playwright/**
|
packages/quality/testing/playwright/**
|
||||||
packages/testing/containers/**
|
packages/quality/environments/containers/**
|
||||||
dev-server-smoke:
|
dev-server-smoke:
|
||||||
packages/frontend/editor-ui/vite.config.mts
|
packages/frontend/editor-ui/vite.config.mts
|
||||||
# If this job becomes too slow, use a smaller glob. Do not delete the assertion.
|
# If this job becomes too slow, use a smaller glob. Do not delete the assertion.
|
||||||
|
|
@ -113,25 +121,25 @@ jobs:
|
||||||
pnpm-workspace.yaml
|
pnpm-workspace.yaml
|
||||||
packages/@n8n/*/package.json
|
packages/@n8n/*/package.json
|
||||||
packages/frontend/@n8n/frontend-vite-config/**
|
packages/frontend/@n8n/frontend-vite-config/**
|
||||||
packages/testing/playwright/tests/dev-server-smoke/**
|
packages/quality/testing/playwright/tests/dev-server-smoke/**
|
||||||
packages/testing/playwright/playwright.config.ts
|
packages/quality/testing/playwright/playwright.config.ts
|
||||||
packages/testing/playwright/playwright-projects.ts
|
packages/quality/testing/playwright/playwright-projects.ts
|
||||||
packages/testing/playwright/package.json
|
packages/quality/testing/playwright/package.json
|
||||||
.github/workflows/test-dev-server-smoke-reusable.yml
|
.github/workflows/test-dev-server-smoke-reusable.yml
|
||||||
frontend-declarations:
|
frontend-declarations:
|
||||||
packages/frontend/@n8n/design-system/**
|
packages/frontend/@n8n/design-system/**
|
||||||
packages/frontend/@n8n/chat/**
|
packages/frontend/@n8n/chat/**
|
||||||
packages/frontend/@n8n/frontend-vite-config/**
|
packages/frontend/@n8n/frontend-vite-config/**
|
||||||
turbo.json
|
turbo.json
|
||||||
.github/workflows/ci-pull-requests.yml
|
|
||||||
.github/workflows/test-frontend-declarations-reusable.yml
|
.github/workflows/test-frontend-declarations-reusable.yml
|
||||||
.github/actions/setup-nodejs/**
|
.github/actions/setup-nodejs/**
|
||||||
workflows: .github/**
|
workflows: .github/**
|
||||||
workflow-scripts:
|
workflow-scripts:
|
||||||
.github/scripts/**
|
.github/scripts/**
|
||||||
.devcontainer/codespaces/agent-worker*.mjs
|
.devcontainer/codespaces/*.mjs
|
||||||
OWNERS
|
OWNERS
|
||||||
scripts/*.test.mjs
|
scripts/*.test.mjs
|
||||||
|
scripts/cloud-session*.mjs
|
||||||
scripts/codespace-env.mjs
|
scripts/codespace-env.mjs
|
||||||
scripts/codespace-preview/**
|
scripts/codespace-preview/**
|
||||||
scripts/licenses/**
|
scripts/licenses/**
|
||||||
|
|
@ -139,29 +147,38 @@ jobs:
|
||||||
cubic.yaml
|
cubic.yaml
|
||||||
.agents/review-rules/**
|
.agents/review-rules/**
|
||||||
e2e-performance:
|
e2e-performance:
|
||||||
packages/testing/playwright/tests/performance/**
|
packages/quality/efficiency/memory/idle/**
|
||||||
packages/testing/playwright/utils/performance-helper.ts
|
packages/quality/efficiency/playwright.config.ts
|
||||||
packages/testing/containers/**
|
packages/quality/efficiency/package.json
|
||||||
|
packages/quality/testing/playwright/package.json
|
||||||
|
packages/quality/testing/playwright/quality-*.ts
|
||||||
|
packages/quality/testing/playwright/fixtures/**
|
||||||
|
packages/quality/testing/playwright/tests/performance/**
|
||||||
|
packages/quality/testing/playwright/utils/performance-helper.ts
|
||||||
|
packages/quality/environments/containers/**
|
||||||
.github/workflows/test-e2e-performance-reusable.yml
|
.github/workflows/test-e2e-performance-reusable.yml
|
||||||
# Whole directories, not the `engine-*` files alone. Engine 2.0
|
.github/workflows/test-e2e-reusable.yml
|
||||||
|
# Whole directories, not the `engine-*` files alone. Engine v2
|
||||||
# routing lives in ordinary control plane code: `workflow-runner.ts`
|
# routing lives in ordinary control plane code: `workflow-runner.ts`
|
||||||
# and the webhook and trigger paths decide whether a run reaches the
|
# and the webhook and trigger paths decide whether a run reaches the
|
||||||
# data plane, and the executions code merges v1 and v2 rows. A path
|
# data plane, and the executions code merges v1 and v2 rows. A path
|
||||||
# this list misses skips `e2e-engine`, and `required-checks` counts
|
# this list misses skips `e2e-engine`, and `required-checks` counts
|
||||||
# a skipped job as a pass, so the gap would be silent.
|
# a skipped job as a pass, so the gap would be silent.
|
||||||
|
# Use force-engine-e2e when changing the engine job in this workflow.
|
||||||
engine-e2e:
|
engine-e2e:
|
||||||
packages/@n8n/engine/**
|
packages/@n8n/engine/**
|
||||||
packages/@n8n/node-engine-compatibility/**
|
packages/@n8n/node-engine-compatibility/**
|
||||||
packages/@n8n/config/src/configs/engine.config.ts
|
packages/@n8n/config/src/configs/engine.config.ts
|
||||||
packages/cli/src/modules/engine-v2/**
|
packages/cli/src/modules/engine-v2/**
|
||||||
|
packages/cli/src/commands/engine.ts
|
||||||
|
packages/cli/src/commands/base-command.ts
|
||||||
packages/cli/src/services/engine-*
|
packages/cli/src/services/engine-*
|
||||||
packages/cli/src/executions/**
|
packages/cli/src/executions/**
|
||||||
packages/cli/src/webhooks/**
|
packages/cli/src/webhooks/**
|
||||||
packages/cli/src/workflows/triggers/**
|
packages/cli/src/workflows/triggers/**
|
||||||
packages/cli/src/workflow-runner.ts
|
packages/cli/src/workflow-runner.ts
|
||||||
packages/testing/containers/**
|
packages/quality/environments/containers/**
|
||||||
packages/testing/playwright/**
|
packages/quality/testing/playwright/**
|
||||||
.github/workflows/ci-pull-requests.yml
|
|
||||||
instance-ai-workflow-eval:
|
instance-ai-workflow-eval:
|
||||||
packages/@n8n/instance-ai/src/**
|
packages/@n8n/instance-ai/src/**
|
||||||
packages/@n8n/instance-ai/evaluations/**
|
packages/@n8n/instance-ai/evaluations/**
|
||||||
|
|
@ -169,6 +186,7 @@ jobs:
|
||||||
packages/core/src/execution-engine/eval-mock-helpers.ts
|
packages/core/src/execution-engine/eval-mock-helpers.ts
|
||||||
.github/workflows/test-evals-discovery.yml
|
.github/workflows/test-evals-discovery.yml
|
||||||
db:
|
db:
|
||||||
|
packages/modules/*/backend/**
|
||||||
packages/cli/src/databases/**
|
packages/cli/src/databases/**
|
||||||
packages/cli/src/modules/*/database/**
|
packages/cli/src/modules/*/database/**
|
||||||
packages/cli/src/modules/**/*.entity.ts
|
packages/cli/src/modules/**/*.entity.ts
|
||||||
|
|
@ -176,18 +194,18 @@ jobs:
|
||||||
packages/cli/test/integration/**
|
packages/cli/test/integration/**
|
||||||
packages/cli/test/migration/**
|
packages/cli/test/migration/**
|
||||||
packages/cli/test/shared/db/**
|
packages/cli/test/shared/db/**
|
||||||
packages/cli/test/setup-testcontainers.js
|
packages/cli/test/setup-testcontainers.ts
|
||||||
packages/cli/test/teardown-testcontainers.js
|
|
||||||
packages/@n8n/db/**
|
packages/@n8n/db/**
|
||||||
packages/@n8n/backend-test-utils/**
|
packages/@n8n/backend-test-utils/**
|
||||||
packages/@n8n/scheduler/src/**
|
packages/@n8n/scheduler/src/**
|
||||||
packages/cli/src/**/*.integration.test.ts
|
packages/cli/src/**/*.integration.test.ts
|
||||||
packages/cli/src/modules/instance-registry/**
|
packages/cli/src/modules/instance-registry/**
|
||||||
packages/cli/vitest.*
|
packages/cli/vitest.*
|
||||||
packages/testing/containers/services/postgres.ts
|
packages/quality/environments/containers/services/postgres.ts
|
||||||
packages/testing/containers/services/redis.ts
|
packages/quality/environments/containers/services/redis.ts
|
||||||
packages/testing/containers/postgres-versions.json
|
packages/quality/environments/containers/test-db-global-setup.ts
|
||||||
packages/testing/containers/test-containers.ts
|
packages/quality/environments/containers/postgres-versions.json
|
||||||
|
packages/quality/environments/containers/test-containers.ts
|
||||||
.github/workflows/test-db-reusable.yml
|
.github/workflows/test-db-reusable.yml
|
||||||
.github/scripts/db-test-matrix.mjs
|
.github/scripts/db-test-matrix.mjs
|
||||||
docs/generated/**
|
docs/generated/**
|
||||||
|
|
@ -200,15 +218,14 @@ jobs:
|
||||||
packages/@n8n/backend-test-utils/**
|
packages/@n8n/backend-test-utils/**
|
||||||
packages/cli/test/migration/**
|
packages/cli/test/migration/**
|
||||||
packages/cli/test/shared/db/**
|
packages/cli/test/shared/db/**
|
||||||
packages/cli/test/setup-testcontainers.js
|
packages/cli/test/setup-testcontainers.ts
|
||||||
packages/cli/test/teardown-testcontainers.js
|
|
||||||
packages/cli/vitest.config.base.ts
|
packages/cli/vitest.config.base.ts
|
||||||
packages/cli/vitest.config.migration.ts
|
packages/cli/vitest.config.migration.ts
|
||||||
packages/cli/vitest.config.migration.testcontainers.ts
|
packages/cli/vitest.config.migration.testcontainers.ts
|
||||||
packages/testing/containers/services/postgres.ts
|
packages/quality/environments/containers/services/postgres.ts
|
||||||
packages/testing/containers/services/redis.ts
|
packages/quality/environments/containers/services/redis.ts
|
||||||
packages/testing/containers/postgres-versions.json
|
packages/quality/environments/containers/postgres-versions.json
|
||||||
packages/testing/containers/test-containers.ts
|
packages/quality/environments/containers/test-containers.ts
|
||||||
.github/workflows/test-db-reusable.yml
|
.github/workflows/test-db-reusable.yml
|
||||||
.github/scripts/db-test-matrix.mjs
|
.github/scripts/db-test-matrix.mjs
|
||||||
|
|
||||||
|
|
@ -222,24 +239,6 @@ jobs:
|
||||||
if: fromJSON(steps.ci-filter.outputs.results).ci
|
if: fromJSON(steps.ci-filter.outputs.results).ci
|
||||||
run: node scripts/turbo-critical-path.mjs >> "$GITHUB_STEP_SUMMARY"
|
run: node scripts/turbo-critical-path.mjs >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
|
||||||
- name: Run format check
|
|
||||||
if: fromJSON(steps.ci-filter.outputs.results).ci
|
|
||||||
run: pnpm format:check
|
|
||||||
|
|
||||||
- name: Generate shard matrix
|
|
||||||
id: generate-matrix
|
|
||||||
if: fromJSON(steps.ci-filter.outputs.results).ci || fromJSON(steps.ci-filter.outputs.results).e2e
|
|
||||||
env:
|
|
||||||
CHANGED_FILES: ${{ steps.ci-filter.outputs.changed-files }}
|
|
||||||
MERGE_BASE: ${{ steps.ci-filter.outputs.merge-base }}
|
|
||||||
PLAYWRIGHT_GREP_INVERT: ${{ github.event.pull_request.head.repo.fork == true && '@licensed' || '' }}
|
|
||||||
PLAYWRIGHT_PROJECT: ${{ github.event.pull_request.head.repo.fork == true && 'sqlite:e2e' || 'multi-main:e2e' }}
|
|
||||||
run: |
|
|
||||||
FILES_CSV=$(echo "$CHANGED_FILES" | tr '\n' ',' | sed 's/,$//')
|
|
||||||
MATRIX=$(node packages/testing/playwright/scripts/distribute-tests.mjs --matrix 20 --orchestrate --impact "--project=$PLAYWRIGHT_PROJECT" "--files=$FILES_CSV" "--base=$MERGE_BASE")
|
|
||||||
echo "matrix=$MATRIX" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "skip-tests=$(node -e "process.stdout.write(JSON.parse(process.argv[1])[0]?.skip === true ? 'true' : 'false')" "$MATRIX")" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
# Generated here because a reusable workflow cannot resolve its own matrix.
|
# Generated here because a reusable workflow cannot resolve its own matrix.
|
||||||
# PRs run SQLite plus the primary Postgres leg. The merge queue runs the
|
# PRs run SQLite plus the primary Postgres leg. The merge queue runs the
|
||||||
# full Postgres range, so every merge still gates on the older majors.
|
# full Postgres range, so every merge still gates on the older majors.
|
||||||
|
|
@ -256,15 +255,38 @@ jobs:
|
||||||
env:
|
env:
|
||||||
CHANGED_FILES: ${{ steps.ci-filter.outputs.changed-files }}
|
CHANGED_FILES: ${{ steps.ci-filter.outputs.changed-files }}
|
||||||
run: |
|
run: |
|
||||||
PACKAGES=$(node packages/testing/janitor/dist/cli.js affected-packages | tr '\n' ' ' | sed 's/ *$//')
|
PACKAGES=$(node packages/quality/testing/janitor/dist/cli.js affected-packages | tr '\n' ' ' | sed 's/ *$//')
|
||||||
echo "Affected packages: $PACKAGES"
|
echo "Affected packages: $PACKAGES"
|
||||||
echo "list=$PACKAGES" >> "$GITHUB_OUTPUT"
|
echo "list=$PACKAGES" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
format-check:
|
||||||
|
name: Format check
|
||||||
|
runs-on: ${{ vars.RUNNER_PROVIDER == 'github' && 'ubuntu-latest' || 'blacksmith-2vcpu-ubuntu-2204' }}
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- uses: useblacksmith/checkout@bcec731f1eb1367240608d1c889a75b96db6ec53 # v1.5.0
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event_name == 'merge_group' && github.event.merge_group.head_sha || format('refs/pull/{0}/merge', github.event.pull_request.number) }}
|
||||||
|
|
||||||
|
- name: Install format tools
|
||||||
|
uses: ./.github/actions/setup-nodejs
|
||||||
|
with:
|
||||||
|
install-command: pnpm install --frozen-lockfile --filter n8n-monorepo --ignore-scripts
|
||||||
|
build-command: ''
|
||||||
|
# Keep the partial pnpm store separate from the full install cache.
|
||||||
|
cache-dependency-path: |
|
||||||
|
pnpm-lock.yaml
|
||||||
|
package.json
|
||||||
|
|
||||||
|
- name: Run format check
|
||||||
|
run: pnpm format:check
|
||||||
|
|
||||||
unit-test:
|
unit-test:
|
||||||
name: Unit tests
|
name: Unit tests
|
||||||
if: needs.install-and-build.outputs.unit == 'true'
|
if: needs.install-and-build.outputs.unit == 'true'
|
||||||
uses: ./.github/workflows/test-unit-reusable.yml
|
uses: ./.github/workflows/test-unit-reusable.yml
|
||||||
needs: install-and-build
|
needs: [install-and-build, format-check]
|
||||||
with:
|
with:
|
||||||
ref: ${{ needs.install-and-build.outputs.commit_sha }}
|
ref: ${{ needs.install-and-build.outputs.commit_sha }}
|
||||||
collectCoverage: ${{ github.event_name != 'merge_group' }}
|
collectCoverage: ${{ github.event_name != 'merge_group' }}
|
||||||
|
|
@ -276,7 +298,7 @@ jobs:
|
||||||
name: Typecheck
|
name: Typecheck
|
||||||
if: needs.install-and-build.outputs.ci == 'true'
|
if: needs.install-and-build.outputs.ci == 'true'
|
||||||
runs-on: ${{ vars.RUNNER_PROVIDER == 'github' && 'ubuntu-latest' || 'blacksmith-4vcpu-ubuntu-2204' }}
|
runs-on: ${{ vars.RUNNER_PROVIDER == 'github' && 'ubuntu-latest' || 'blacksmith-4vcpu-ubuntu-2204' }}
|
||||||
needs: install-and-build
|
needs: [install-and-build, format-check]
|
||||||
env:
|
env:
|
||||||
# Inherited by every worker turbo spawns, so the real budget is
|
# Inherited by every worker turbo spawns, so the real budget is
|
||||||
# concurrency x cap: 2 x 6 GB fits the runner's 15.2 GB.
|
# concurrency x cap: 2 x 6 GB fits the runner's 15.2 GB.
|
||||||
|
|
@ -297,7 +319,7 @@ jobs:
|
||||||
name: Lint
|
name: Lint
|
||||||
if: needs.install-and-build.outputs.ci == 'true'
|
if: needs.install-and-build.outputs.ci == 'true'
|
||||||
uses: ./.github/workflows/test-linting-reusable.yml
|
uses: ./.github/workflows/test-linting-reusable.yml
|
||||||
needs: install-and-build
|
needs: [install-and-build, format-check]
|
||||||
with:
|
with:
|
||||||
ref: ${{ needs.install-and-build.outputs.commit_sha }}
|
ref: ${{ needs.install-and-build.outputs.commit_sha }}
|
||||||
|
|
||||||
|
|
@ -305,7 +327,7 @@ jobs:
|
||||||
name: Check packaging
|
name: Check packaging
|
||||||
if: needs.install-and-build.outputs.ci == 'true'
|
if: needs.install-and-build.outputs.ci == 'true'
|
||||||
runs-on: ${{ vars.RUNNER_PROVIDER == 'github' && 'ubuntu-latest' || 'blacksmith-4vcpu-ubuntu-2204' }}
|
runs-on: ${{ vars.RUNNER_PROVIDER == 'github' && 'ubuntu-latest' || 'blacksmith-4vcpu-ubuntu-2204' }}
|
||||||
needs: install-and-build
|
needs: [install-and-build, format-check]
|
||||||
steps:
|
steps:
|
||||||
- uses: useblacksmith/checkout@bcec731f1eb1367240608d1c889a75b96db6ec53 # v1.5.0
|
- uses: useblacksmith/checkout@bcec731f1eb1367240608d1c889a75b96db6ec53 # v1.5.0
|
||||||
with:
|
with:
|
||||||
|
|
@ -323,8 +345,8 @@ jobs:
|
||||||
# (each of which invokes prepare-docker internally) short-circuit to a
|
# (each of which invokes prepare-docker internally) short-circuit to a
|
||||||
# cache hit instead of racing to rebuild.
|
# cache hit instead of racing to rebuild.
|
||||||
prepare-docker:
|
prepare-docker:
|
||||||
name: Prepare Docker
|
name: Prepare E2E
|
||||||
needs: install-and-build
|
needs: [install-and-build, format-check]
|
||||||
if: >-
|
if: >-
|
||||||
github.repository == 'n8n-io/n8n' &&
|
github.repository == 'n8n-io/n8n' &&
|
||||||
github.event_name != 'merge_group' &&
|
github.event_name != 'merge_group' &&
|
||||||
|
|
@ -340,36 +362,77 @@ jobs:
|
||||||
branch: ${{ needs.install-and-build.outputs.commit_sha }}
|
branch: ${{ needs.install-and-build.outputs.commit_sha }}
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|
||||||
|
# Runs next to prepare-docker, so the matrix is off the path of the jobs that
|
||||||
|
# wait for install-and-build. Only the e2e job reads it.
|
||||||
|
e2e-matrix:
|
||||||
|
name: Prepare E2E / Shard matrix
|
||||||
|
needs: install-and-build
|
||||||
|
if: needs.install-and-build.outputs.run_e2e == 'true'
|
||||||
|
runs-on: ${{ vars.RUNNER_PROVIDER == 'github' && 'ubuntu-latest' || 'blacksmith-2vcpu-ubuntu-2204' }}
|
||||||
|
outputs:
|
||||||
|
matrix: ${{ steps.generate-matrix.outputs.matrix }}
|
||||||
|
skip_tests: ${{ steps.generate-matrix.outputs.skip-tests }}
|
||||||
|
steps:
|
||||||
|
- uses: useblacksmith/checkout@bcec731f1eb1367240608d1c889a75b96db6ec53 # v1.5.0
|
||||||
|
with:
|
||||||
|
ref: ${{ needs.install-and-build.outputs.commit_sha }}
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
|
# Impact selection reads package.json at the merge base.
|
||||||
|
- name: Fetch merge base
|
||||||
|
env:
|
||||||
|
MERGE_BASE: ${{ needs.install-and-build.outputs.merge_base }}
|
||||||
|
run: |
|
||||||
|
if [ -n "$MERGE_BASE" ] && ! git cat-file -e "$MERGE_BASE^{commit}" 2>/dev/null; then
|
||||||
|
git fetch --no-tags --depth=1 origin "$MERGE_BASE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: ./.github/actions/setup-nodejs
|
||||||
|
with:
|
||||||
|
build-command: pnpm turbo run build --filter=@n8n/playwright-janitor --filter=@n8n/workflow-sdk...
|
||||||
|
|
||||||
|
- name: Generate shard matrix
|
||||||
|
id: generate-matrix
|
||||||
|
env:
|
||||||
|
CHANGED_FILES: ${{ needs.install-and-build.outputs.changed_files }}
|
||||||
|
MERGE_BASE: ${{ needs.install-and-build.outputs.merge_base }}
|
||||||
|
PLAYWRIGHT_GREP_INVERT: ${{ github.event.pull_request.head.repo.fork == true && '@licensed' || '' }}
|
||||||
|
PLAYWRIGHT_PROJECT: ${{ github.event.pull_request.head.repo.fork == true && 'sqlite:e2e' || 'multi-main:e2e' }}
|
||||||
|
run: |
|
||||||
|
FILES_CSV=$(echo "$CHANGED_FILES" | tr '\n' ',' | sed 's/,$//')
|
||||||
|
MATRIX=$(node packages/quality/testing/playwright/scripts/distribute-tests.mjs --matrix 20 --orchestrate --impact "--project=$PLAYWRIGHT_PROJECT" "--files=$FILES_CSV" "--base=$MERGE_BASE")
|
||||||
|
echo "matrix=$MATRIX" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "skip-tests=$(node -e "process.stdout.write(JSON.parse(process.argv[1])[0]?.skip === true ? 'true' : 'false')" "$MATRIX")" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
# Full e2e run. Internal PRs run multi-main (postgres + redis + caddy + 2 mains + 1 worker).
|
# Full e2e run. Internal PRs run multi-main (postgres + redis + caddy + 2 mains + 1 worker).
|
||||||
# Fork PRs run sqlite-only and skip @licensed tests (no enterprise license secrets on forks).
|
# Fork PRs run sqlite-only and skip @licensed tests (no enterprise license secrets on forks).
|
||||||
e2e:
|
e2e:
|
||||||
name: E2E
|
name: E2E
|
||||||
needs: [install-and-build, prepare-docker]
|
needs: [install-and-build, prepare-docker, e2e-matrix]
|
||||||
if: >-
|
if: >-
|
||||||
needs.prepare-docker.result == 'success' &&
|
needs.prepare-docker.result == 'success' &&
|
||||||
(needs.install-and-build.outputs.runtime == 'true'
|
needs.e2e-matrix.result == 'success' &&
|
||||||
|| needs.install-and-build.outputs.e2e == 'true'
|
needs.install-and-build.outputs.run_e2e == 'true' &&
|
||||||
|| contains(github.event.pull_request.labels.*.name, 'force-e2e')) &&
|
needs.e2e-matrix.outputs.skip_tests != 'true'
|
||||||
needs.install-and-build.outputs.skip_tests != 'true' &&
|
|
||||||
github.event_name != 'merge_group'
|
|
||||||
uses: ./.github/workflows/test-e2e-reusable.yml
|
uses: ./.github/workflows/test-e2e-reusable.yml
|
||||||
with:
|
with:
|
||||||
branch: ${{ needs.install-and-build.outputs.commit_sha }}
|
branch: ${{ needs.install-and-build.outputs.commit_sha }}
|
||||||
test-mode: docker-artifact
|
test-mode: docker-artifact
|
||||||
test-command: ${{ github.event.pull_request.head.repo.fork == true && 'pnpm --filter=n8n-playwright test:container:sqlite:e2e --grep-invert=@licensed' || 'pnpm --filter=n8n-playwright test:container:multi-main:e2e' }}
|
test-command: ${{ github.event.pull_request.head.repo.fork == true && 'pnpm --filter=n8n-playwright test:container:sqlite:e2e --grep-invert=@licensed' || 'pnpm --filter=n8n-playwright test:container:multi-main:e2e' }}
|
||||||
workers: '1'
|
workers: '1'
|
||||||
pre-generated-matrix: ${{ needs.install-and-build.outputs.matrix }}
|
pre-generated-matrix: ${{ needs.e2e-matrix.outputs.matrix }}
|
||||||
artifact-prefix: e2e
|
artifact-prefix: e2e
|
||||||
currents-record-key: ${{ needs.install-and-build.outputs.currents_record_key }}
|
currents-record-key: ${{ needs.install-and-build.outputs.currents_record_key }}
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|
||||||
# Engine 2.0 parity: runs the `@engine:*` specs against a main that routes
|
# Engine v2 parity: runs the `@engine:*` specs against a main that routes
|
||||||
# every workflow to the new engine. Runs, and blocks, when engine code changes
|
# every workflow to the new engine. Runs, and blocks, when engine code changes
|
||||||
# or when the PR carries the `force-engine-e2e` label; skipped otherwise. One
|
# or when the PR carries the `force-engine-e2e` label; skipped otherwise. One
|
||||||
# shard is enough while the tagged set is small. Internal PRs only: this
|
# shard is enough while the tagged set is small. Internal PRs only: this
|
||||||
# project has no fork variant yet.
|
# project has no fork variant yet.
|
||||||
e2e-engine:
|
e2e-engine:
|
||||||
name: E2E on engine 2.0
|
name: E2E on engine v2
|
||||||
needs: [install-and-build, prepare-docker]
|
needs: [install-and-build, prepare-docker]
|
||||||
if: >-
|
if: >-
|
||||||
needs.prepare-docker.result == 'success' &&
|
needs.prepare-docker.result == 'success' &&
|
||||||
|
|
@ -392,7 +455,7 @@ jobs:
|
||||||
# the production-bundle e2e job bundles around.
|
# the production-bundle e2e job bundles around.
|
||||||
dev-server-smoke:
|
dev-server-smoke:
|
||||||
name: Dev-server boot smoke
|
name: Dev-server boot smoke
|
||||||
needs: install-and-build
|
needs: [install-and-build, format-check]
|
||||||
if: needs.install-and-build.outputs.dev_server_smoke == 'true' && github.event_name != 'merge_group'
|
if: needs.install-and-build.outputs.dev_server_smoke == 'true' && github.event_name != 'merge_group'
|
||||||
uses: ./.github/workflows/test-dev-server-smoke-reusable.yml
|
uses: ./.github/workflows/test-dev-server-smoke-reusable.yml
|
||||||
with:
|
with:
|
||||||
|
|
@ -405,7 +468,7 @@ jobs:
|
||||||
# release.
|
# release.
|
||||||
frontend-declarations:
|
frontend-declarations:
|
||||||
name: Frontend declarations
|
name: Frontend declarations
|
||||||
needs: install-and-build
|
needs: [install-and-build, format-check]
|
||||||
if: needs.install-and-build.outputs.frontend_declarations == 'true'
|
if: needs.install-and-build.outputs.frontend_declarations == 'true'
|
||||||
uses: ./.github/workflows/test-frontend-declarations-reusable.yml
|
uses: ./.github/workflows/test-frontend-declarations-reusable.yml
|
||||||
with:
|
with:
|
||||||
|
|
@ -413,13 +476,14 @@ jobs:
|
||||||
|
|
||||||
db-tests:
|
db-tests:
|
||||||
name: DB Tests
|
name: DB Tests
|
||||||
needs: install-and-build
|
needs: [install-and-build, format-check]
|
||||||
if: needs.install-and-build.outputs.db == 'true'
|
if: needs.install-and-build.outputs.db == 'true'
|
||||||
uses: ./.github/workflows/test-db-reusable.yml
|
uses: ./.github/workflows/test-db-reusable.yml
|
||||||
with:
|
with:
|
||||||
ref: ${{ needs.install-and-build.outputs.commit_sha }}
|
ref: ${{ needs.install-and-build.outputs.commit_sha }}
|
||||||
matrix: ${{ needs.install-and-build.outputs.db_test_matrix }}
|
matrix: ${{ needs.install-and-build.outputs.db_test_matrix }}
|
||||||
run-migrations: ${{ needs.install-and-build.outputs.db_migrations == 'true' }}
|
run-migrations: ${{ needs.install-and-build.outputs.db_migrations == 'true' }}
|
||||||
|
changed-files: ${{ needs.install-and-build.outputs.changed_files }}
|
||||||
|
|
||||||
e2e-performance:
|
e2e-performance:
|
||||||
name: E2E Performance
|
name: E2E Performance
|
||||||
|
|
@ -438,7 +502,7 @@ jobs:
|
||||||
|
|
||||||
security-checks:
|
security-checks:
|
||||||
name: Security Checks
|
name: Security Checks
|
||||||
needs: install-and-build
|
needs: [install-and-build, format-check]
|
||||||
if: needs.install-and-build.outputs.workflows == 'true'
|
if: needs.install-and-build.outputs.workflows == 'true'
|
||||||
uses: ./.github/workflows/sec-ci-reusable.yml
|
uses: ./.github/workflows/sec-ci-reusable.yml
|
||||||
with:
|
with:
|
||||||
|
|
@ -447,7 +511,7 @@ jobs:
|
||||||
|
|
||||||
workflow-scripts:
|
workflow-scripts:
|
||||||
name: Workflow scripts
|
name: Workflow scripts
|
||||||
needs: install-and-build
|
needs: [install-and-build, format-check]
|
||||||
if: needs.install-and-build.outputs.workflow_scripts == 'true'
|
if: needs.install-and-build.outputs.workflow_scripts == 'true'
|
||||||
uses: ./.github/workflows/test-workflow-scripts-reusable.yml
|
uses: ./.github/workflows/test-workflow-scripts-reusable.yml
|
||||||
with:
|
with:
|
||||||
|
|
@ -459,10 +523,11 @@ jobs:
|
||||||
# heavy workflow eval. Non-blocking initially; promote to required after stability.
|
# heavy workflow eval. Non-blocking initially; promote to required after stability.
|
||||||
instance-ai-discovery-evals:
|
instance-ai-discovery-evals:
|
||||||
name: Instance AI Discovery Evals
|
name: Instance AI Discovery Evals
|
||||||
needs: install-and-build
|
needs: [install-and-build, format-check]
|
||||||
if: >-
|
if: >-
|
||||||
!cancelled() &&
|
!cancelled() &&
|
||||||
needs.install-and-build.result == 'success' &&
|
needs.install-and-build.result == 'success' &&
|
||||||
|
needs.format-check.result == 'success' &&
|
||||||
needs.install-and-build.outputs.instance_ai_workflow_eval == 'true' &&
|
needs.install-and-build.outputs.instance_ai_workflow_eval == 'true' &&
|
||||||
github.repository == 'n8n-io/n8n' &&
|
github.repository == 'n8n-io/n8n' &&
|
||||||
github.event_name != 'merge_group' &&
|
github.event_name != 'merge_group' &&
|
||||||
|
|
@ -479,10 +544,12 @@ jobs:
|
||||||
needs:
|
needs:
|
||||||
[
|
[
|
||||||
install-and-build,
|
install-and-build,
|
||||||
|
format-check,
|
||||||
unit-test,
|
unit-test,
|
||||||
typecheck,
|
typecheck,
|
||||||
lint,
|
lint,
|
||||||
check-packaging,
|
check-packaging,
|
||||||
|
e2e-matrix,
|
||||||
e2e,
|
e2e,
|
||||||
e2e-engine,
|
e2e-engine,
|
||||||
dev-server-smoke,
|
dev-server-smoke,
|
||||||
|
|
@ -516,7 +583,7 @@ jobs:
|
||||||
(github.head_ref == 'bundle/2.x' || github.head_ref == 'bundle/1.x')
|
(github.head_ref == 'bundle/2.x' || github.head_ref == 'bundle/1.x')
|
||||||
runs-on: ubuntu-slim
|
runs-on: ubuntu-slim
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
sparse-checkout: .github/scripts/slack
|
sparse-checkout: .github/scripts/slack
|
||||||
|
|
|
||||||
2
.github/workflows/ci-python.yml
vendored
2
.github/workflows/ci-python.yml
vendored
|
|
@ -18,7 +18,7 @@ jobs:
|
||||||
working-directory: packages/@n8n/task-runner-python
|
working-directory: packages/@n8n/task-runner-python
|
||||||
steps:
|
steps:
|
||||||
- name: Check out project
|
- name: Check out project
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@6ee6290f1cbc4156c0bdd66691b2c144ef8df19a # v7.4.0
|
uses: astral-sh/setup-uv@6ee6290f1cbc4156c0bdd66691b2c144ef8df19a # v7.4.0
|
||||||
|
|
|
||||||
4
.github/workflows/ci-terraform.yml
vendored
4
.github/workflows/ci-terraform.yml
vendored
|
|
@ -3,7 +3,7 @@ name: 'CI: Terraform'
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- packages/@n8n/benchmark/infra/**
|
- packages/quality/efficiency/scale/benchmark/infra/**
|
||||||
- .github/workflows/ci-terraform.yml
|
- .github/workflows/ci-terraform.yml
|
||||||
|
|
||||||
# The job only reads the repo and runs terraform. It writes nothing.
|
# The job only reads the repo and runs terraform. It writes nothing.
|
||||||
|
|
@ -16,7 +16,7 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: packages/@n8n/benchmark/infra
|
working-directory: packages/quality/efficiency/scale/benchmark/infra
|
||||||
steps:
|
steps:
|
||||||
- name: Check out project
|
- name: Check out project
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
|
|
||||||
2
.github/workflows/clean-stale-branches.yml
vendored
2
.github/workflows/clean-stale-branches.yml
vendored
|
|
@ -29,7 +29,7 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: ./.github/actions/setup-nodejs
|
uses: ./.github/actions/setup-nodejs
|
||||||
|
|
|
||||||
12
.github/workflows/docker-build-push.yml
vendored
12
.github/workflows/docker-build-push.yml
vendored
|
|
@ -8,7 +8,7 @@ run-name: "${{ github.event_name == 'workflow_dispatch' && format('Docker: Build
|
||||||
|
|
||||||
env:
|
env:
|
||||||
NODE_OPTIONS: '--max-old-space-size=7168'
|
NODE_OPTIONS: '--max-old-space-size=7168'
|
||||||
NODE_VERSION: '26.7.0'
|
NODE_VERSION: '26.10.0'
|
||||||
|
|
||||||
on:
|
on:
|
||||||
schedule:
|
schedule:
|
||||||
|
|
@ -98,7 +98,7 @@ jobs:
|
||||||
short_sha: ${{ steps.sha.outputs.short_sha }}
|
short_sha: ${{ steps.sha.outputs.short_sha }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ inputs.ref }}
|
ref: ${{ inputs.ref }}
|
||||||
|
|
||||||
|
|
@ -188,7 +188,7 @@ jobs:
|
||||||
runners_distroless_date_manifest_tag: ${{ steps.determine-tags.outputs.runners_distroless_date_primary_tag }}
|
runners_distroless_date_manifest_tag: ${{ steps.determine-tags.outputs.runners_distroless_date_primary_tag }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ inputs.ref }}
|
ref: ${{ inputs.ref }}
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
@ -294,7 +294,7 @@ jobs:
|
||||||
runners_distroless_image: ${{ steps.get-digests.outputs.runners_distroless_image }}
|
runners_distroless_image: ${{ steps.get-digests.outputs.runners_distroless_image }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout CI helpers
|
- name: Checkout CI helpers
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
sparse-checkout: .github
|
sparse-checkout: .github
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
@ -614,7 +614,7 @@ jobs:
|
||||||
packages: write
|
packages: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
|
||||||
- name: Install Cosign
|
- name: Install Cosign
|
||||||
uses: sigstore/cosign-installer@7e8b541eb2e61bf99390e1afd4be13a184e9ebc5 # v3.10.1
|
uses: sigstore/cosign-installer@7e8b541eb2e61bf99390e1afd4be13a184e9ebc5 # v3.10.1
|
||||||
|
|
@ -809,7 +809,7 @@ jobs:
|
||||||
always() && github.event_name == 'schedule' &&
|
always() && github.event_name == 'schedule' &&
|
||||||
(contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled'))
|
(contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled'))
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
sparse-checkout: .github/scripts/slack
|
sparse-checkout: .github/scripts/slack
|
||||||
sparse-checkout-cone-mode: false
|
sparse-checkout-cone-mode: false
|
||||||
|
|
|
||||||
10
.github/workflows/docker-build-smoke.yml
vendored
10
.github/workflows/docker-build-smoke.yml
vendored
|
|
@ -12,7 +12,7 @@ env:
|
||||||
# The images copy native bindings from the host build, so both have to use the same
|
# The images copy native bindings from the host build, so both have to use the same
|
||||||
# Node. dockerize-n8n.mjs reads this env for the image builds. Keep in sync with
|
# Node. dockerize-n8n.mjs reads this env for the image builds. Keep in sync with
|
||||||
# NODE_VERSION in docker-build-push.yml, which is what the published images use.
|
# NODE_VERSION in docker-build-push.yml, which is what the published images use.
|
||||||
NODE_VERSION: '26.7.0'
|
NODE_VERSION: '26.10.0'
|
||||||
# Also build the distroless runners image and exec-check
|
# Also build the distroless runners image and exec-check
|
||||||
# both runners images' interpreters, so a broken runtime assembly (e.g. a
|
# both runners images' interpreters, so a broken runtime assembly (e.g. a
|
||||||
# missing shared library after a Node bump) fails at PR time. The smoke
|
# missing shared library after a Node bump) fails at PR time. The smoke
|
||||||
|
|
@ -57,7 +57,7 @@ jobs:
|
||||||
outputs:
|
outputs:
|
||||||
build: ${{ steps.gate.outputs.build }}
|
build: ${{ steps.gate.outputs.build }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
# HEAD is the PR merge commit, so HEAD^1 is the base tip.
|
# HEAD is the PR merge commit, so HEAD^1 is the base tip.
|
||||||
fetch-depth: 2
|
fetch-depth: 2
|
||||||
|
|
@ -93,7 +93,7 @@ jobs:
|
||||||
needs: gate
|
needs: gate
|
||||||
if: ${{ !github.event.pull_request.head.repo.fork && needs.gate.outputs.build == 'true' }}
|
if: ${{ !github.event.pull_request.head.repo.fork && needs.gate.outputs.build == 'true' }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
|
||||||
- name: Login to DHI Registry (for base image)
|
- name: Login to DHI Registry (for base image)
|
||||||
uses: ./.github/actions/docker-registry-login
|
uses: ./.github/actions/docker-registry-login
|
||||||
|
|
@ -113,7 +113,7 @@ jobs:
|
||||||
# would write an empty snapshot over the release layers.
|
# would write an empty snapshot over the release layers.
|
||||||
docker-cache-key: docker-smoke
|
docker-cache-key: docker-smoke
|
||||||
|
|
||||||
- uses: azure/setup-helm@b9e51907a09c216f16ebe8536097933489208112 # v4.3.0
|
- uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5.0.1
|
||||||
|
|
||||||
- id: cloud-token
|
- id: cloud-token
|
||||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
|
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
|
||||||
|
|
@ -134,7 +134,7 @@ jobs:
|
||||||
needs: [docker-smoke-test]
|
needs: [docker-smoke-test]
|
||||||
if: needs.docker-smoke-test.result == 'failure' && github.event_name == 'schedule'
|
if: needs.docker-smoke-test.result == 'failure' && github.event_name == 'schedule'
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
sparse-checkout: .github/scripts/slack
|
sparse-checkout: .github/scripts/slack
|
||||||
sparse-checkout-cone-mode: false
|
sparse-checkout-cone-mode: false
|
||||||
|
|
|
||||||
8
.github/workflows/docker-sbom-probe.yml
vendored
8
.github/workflows/docker-sbom-probe.yml
vendored
|
|
@ -19,7 +19,7 @@ on:
|
||||||
default: false
|
default: false
|
||||||
|
|
||||||
env:
|
env:
|
||||||
NODE_VERSION: '26.7.0'
|
NODE_VERSION: '26.10.0'
|
||||||
SCRATCH_TAG: sbom-probe-${{ github.run_id }}
|
SCRATCH_TAG: sbom-probe-${{ github.run_id }}
|
||||||
# A separate package. These are throwaway fixtures (one is a two-layer
|
# A separate package. These are throwaway fixtures (one is a two-layer
|
||||||
# alpine), and ghcr.io/<repo> is where the real release tags live - they
|
# alpine), and ghcr.io/<repo> is where the real release tags live - they
|
||||||
|
|
@ -46,7 +46,7 @@ jobs:
|
||||||
- platform: arm64
|
- platform: arm64
|
||||||
runner: blacksmith-8vcpu-ubuntu-2204-arm
|
runner: blacksmith-8vcpu-ubuntu-2204-arm
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
|
|
@ -87,7 +87,7 @@ jobs:
|
||||||
contents: read
|
contents: read
|
||||||
packages: write
|
packages: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
|
|
@ -153,7 +153,7 @@ jobs:
|
||||||
sbom: 'true'
|
sbom: 'true'
|
||||||
oci_mediatypes: 'false'
|
oci_mediatypes: 'false'
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
|
|
|
||||||
12
.github/workflows/encryption-upgrade-test.yml
vendored
12
.github/workflows/encryption-upgrade-test.yml
vendored
|
|
@ -29,10 +29,10 @@ on:
|
||||||
# verify themselves (both modes) before the merge.
|
# verify themselves (both modes) before the merge.
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- 'packages/testing/playwright/tests/infrastructure/encryption/**'
|
- 'packages/quality/testing/playwright/tests/infrastructure/encryption/**'
|
||||||
- 'packages/testing/containers/services/n8n.ts'
|
- 'packages/quality/environments/containers/services/n8n.ts'
|
||||||
- 'packages/testing/containers/services/types.ts'
|
- 'packages/quality/environments/containers/services/types.ts'
|
||||||
- 'packages/testing/containers/stack.ts'
|
- 'packages/quality/environments/containers/stack.ts'
|
||||||
- '.github/workflows/encryption-upgrade-test.yml'
|
- '.github/workflows/encryption-upgrade-test.yml'
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
|
|
@ -64,7 +64,7 @@ jobs:
|
||||||
# Not a hidden dir: upload-artifact skips hidden paths by default.
|
# Not a hidden dir: upload-artifact skips hidden paths by default.
|
||||||
WORK_ROOT: ${{ github.workspace }}/upgrade-test-work
|
WORK_ROOT: ${{ github.workspace }}/upgrade-test-work
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
|
|
@ -140,7 +140,7 @@ jobs:
|
||||||
needs: [encryption-cycle]
|
needs: [encryption-cycle]
|
||||||
if: ${{ always() && github.event_name == 'schedule' && contains(needs.*.result, 'failure') }}
|
if: ${{ always() && github.event_name == 'schedule' && contains(needs.*.result, 'failure') }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
sparse-checkout: .github/scripts/slack
|
sparse-checkout: .github/scripts/slack
|
||||||
|
|
|
||||||
309
.github/workflows/jscpd.yml
vendored
Normal file
309
.github/workflows/jscpd.yml
vendored
Normal file
|
|
@ -0,0 +1,309 @@
|
||||||
|
# 🍝 jscpd — deterministic copy-paste/duplication scan.
|
||||||
|
#
|
||||||
|
# Runs jscpd against this repo and uploads the full JSON+HTML report, plus
|
||||||
|
# the report split into BUCKET_COUNT small JSON files, so a caller can
|
||||||
|
# download one small bucket at random per invocation instead of the full
|
||||||
|
# report (which can run into tens of MB on a monorepo this size).
|
||||||
|
#
|
||||||
|
# Reporters/ignores/threshold come from this repo's own .jscpd.json; this
|
||||||
|
# workflow passes none of those flags itself.
|
||||||
|
|
||||||
|
name: 🍝 jscpd
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
env:
|
||||||
|
# Number of small sample files the report is split into (split-jscpd-report.mjs).
|
||||||
|
# Fixed, not an input: changing it means adding/removing the matching
|
||||||
|
# per-bucket upload steps below, so it's a code change, not a dispatch option.
|
||||||
|
BUCKET_COUNT: '20'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
jscpd:
|
||||||
|
runs-on: ${{ vars.RUNNER_PROVIDER == 'github' && 'ubuntu-latest' || 'blacksmith-4vcpu-ubuntu-2204' }}
|
||||||
|
timeout-minutes: 30
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Setup Node.js and install dependencies
|
||||||
|
uses: ./.github/actions/setup-nodejs
|
||||||
|
with:
|
||||||
|
build-command: ''
|
||||||
|
|
||||||
|
- name: Run jscpd
|
||||||
|
id: jscpd
|
||||||
|
continue-on-error: true
|
||||||
|
run: |
|
||||||
|
mkdir -p "$RUNNER_TEMP/jscpd-report"
|
||||||
|
pnpm jscpd -- . --output "$RUNNER_TEMP/jscpd-report" --silent
|
||||||
|
|
||||||
|
- name: Upload jscpd report
|
||||||
|
id: report-artifact
|
||||||
|
if: always()
|
||||||
|
continue-on-error: true
|
||||||
|
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||||
|
with:
|
||||||
|
name: jscpd-report-${{ github.run_id }}-${{ github.run_attempt }}
|
||||||
|
path: ${{ runner.temp }}/jscpd-report
|
||||||
|
retention-days: 31
|
||||||
|
if-no-files-found: warn
|
||||||
|
|
||||||
|
# jscpd exits non-zero once duplication crosses .jscpd.json's threshold,
|
||||||
|
# but still writes the report first. Gating the split on the report
|
||||||
|
# file existing (not on steps.jscpd.outcome) means an over-threshold
|
||||||
|
# run still gets its buckets instead of silently dropping all of them.
|
||||||
|
- name: Check jscpd report exists
|
||||||
|
id: report-exists
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
if [ -f "$RUNNER_TEMP/jscpd-report/jscpd-report.json" ]; then
|
||||||
|
echo "exists=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "exists=false" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Splits `duplicates` into BUCKET_COUNT files so a caller can download
|
||||||
|
# one small sample instead of the full report (tens of MB on a large
|
||||||
|
# monorepo). Logic + tests live in split-jscpd-report.mjs.
|
||||||
|
- name: Split report into buckets
|
||||||
|
id: split
|
||||||
|
if: always() && steps.report-exists.outputs.exists == 'true'
|
||||||
|
continue-on-error: true
|
||||||
|
env:
|
||||||
|
REPORT_PATH: ${{ runner.temp }}/jscpd-report/jscpd-report.json
|
||||||
|
OUTPUT_DIR: ${{ runner.temp }}/jscpd-buckets
|
||||||
|
run: node .github/scripts/split-jscpd-report.mjs
|
||||||
|
|
||||||
|
# BUCKET_COUNT static upload steps, one per bucket file: GitHub Actions
|
||||||
|
# has no per-job step loop, and each bucket needs its own small
|
||||||
|
# artifact (not one artifact holding all of them) so a caller can
|
||||||
|
# fetch a single sample without pulling the rest. Keep this block's
|
||||||
|
# step count in sync with BUCKET_COUNT above.
|
||||||
|
- name: Upload jscpd bucket 0
|
||||||
|
id: bucket-artifact-0
|
||||||
|
if: always() && steps.split.outcome == 'success'
|
||||||
|
continue-on-error: true
|
||||||
|
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||||
|
with:
|
||||||
|
name: jscpd-bucket-${{ github.run_id }}-${{ github.run_attempt }}-0
|
||||||
|
path: ${{ runner.temp }}/jscpd-buckets/bucket-0.json
|
||||||
|
retention-days: 31
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
- name: Upload jscpd bucket 1
|
||||||
|
id: bucket-artifact-1
|
||||||
|
if: always() && steps.split.outcome == 'success'
|
||||||
|
continue-on-error: true
|
||||||
|
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||||
|
with:
|
||||||
|
name: jscpd-bucket-${{ github.run_id }}-${{ github.run_attempt }}-1
|
||||||
|
path: ${{ runner.temp }}/jscpd-buckets/bucket-1.json
|
||||||
|
retention-days: 31
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
- name: Upload jscpd bucket 2
|
||||||
|
id: bucket-artifact-2
|
||||||
|
if: always() && steps.split.outcome == 'success'
|
||||||
|
continue-on-error: true
|
||||||
|
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||||
|
with:
|
||||||
|
name: jscpd-bucket-${{ github.run_id }}-${{ github.run_attempt }}-2
|
||||||
|
path: ${{ runner.temp }}/jscpd-buckets/bucket-2.json
|
||||||
|
retention-days: 31
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
- name: Upload jscpd bucket 3
|
||||||
|
id: bucket-artifact-3
|
||||||
|
if: always() && steps.split.outcome == 'success'
|
||||||
|
continue-on-error: true
|
||||||
|
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||||
|
with:
|
||||||
|
name: jscpd-bucket-${{ github.run_id }}-${{ github.run_attempt }}-3
|
||||||
|
path: ${{ runner.temp }}/jscpd-buckets/bucket-3.json
|
||||||
|
retention-days: 31
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
- name: Upload jscpd bucket 4
|
||||||
|
id: bucket-artifact-4
|
||||||
|
if: always() && steps.split.outcome == 'success'
|
||||||
|
continue-on-error: true
|
||||||
|
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||||
|
with:
|
||||||
|
name: jscpd-bucket-${{ github.run_id }}-${{ github.run_attempt }}-4
|
||||||
|
path: ${{ runner.temp }}/jscpd-buckets/bucket-4.json
|
||||||
|
retention-days: 31
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
- name: Upload jscpd bucket 5
|
||||||
|
id: bucket-artifact-5
|
||||||
|
if: always() && steps.split.outcome == 'success'
|
||||||
|
continue-on-error: true
|
||||||
|
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||||
|
with:
|
||||||
|
name: jscpd-bucket-${{ github.run_id }}-${{ github.run_attempt }}-5
|
||||||
|
path: ${{ runner.temp }}/jscpd-buckets/bucket-5.json
|
||||||
|
retention-days: 31
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
- name: Upload jscpd bucket 6
|
||||||
|
id: bucket-artifact-6
|
||||||
|
if: always() && steps.split.outcome == 'success'
|
||||||
|
continue-on-error: true
|
||||||
|
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||||
|
with:
|
||||||
|
name: jscpd-bucket-${{ github.run_id }}-${{ github.run_attempt }}-6
|
||||||
|
path: ${{ runner.temp }}/jscpd-buckets/bucket-6.json
|
||||||
|
retention-days: 31
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
- name: Upload jscpd bucket 7
|
||||||
|
id: bucket-artifact-7
|
||||||
|
if: always() && steps.split.outcome == 'success'
|
||||||
|
continue-on-error: true
|
||||||
|
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||||
|
with:
|
||||||
|
name: jscpd-bucket-${{ github.run_id }}-${{ github.run_attempt }}-7
|
||||||
|
path: ${{ runner.temp }}/jscpd-buckets/bucket-7.json
|
||||||
|
retention-days: 31
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
- name: Upload jscpd bucket 8
|
||||||
|
id: bucket-artifact-8
|
||||||
|
if: always() && steps.split.outcome == 'success'
|
||||||
|
continue-on-error: true
|
||||||
|
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||||
|
with:
|
||||||
|
name: jscpd-bucket-${{ github.run_id }}-${{ github.run_attempt }}-8
|
||||||
|
path: ${{ runner.temp }}/jscpd-buckets/bucket-8.json
|
||||||
|
retention-days: 31
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
- name: Upload jscpd bucket 9
|
||||||
|
id: bucket-artifact-9
|
||||||
|
if: always() && steps.split.outcome == 'success'
|
||||||
|
continue-on-error: true
|
||||||
|
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||||
|
with:
|
||||||
|
name: jscpd-bucket-${{ github.run_id }}-${{ github.run_attempt }}-9
|
||||||
|
path: ${{ runner.temp }}/jscpd-buckets/bucket-9.json
|
||||||
|
retention-days: 31
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
- name: Upload jscpd bucket 10
|
||||||
|
id: bucket-artifact-10
|
||||||
|
if: always() && steps.split.outcome == 'success'
|
||||||
|
continue-on-error: true
|
||||||
|
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||||
|
with:
|
||||||
|
name: jscpd-bucket-${{ github.run_id }}-${{ github.run_attempt }}-10
|
||||||
|
path: ${{ runner.temp }}/jscpd-buckets/bucket-10.json
|
||||||
|
retention-days: 31
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
- name: Upload jscpd bucket 11
|
||||||
|
id: bucket-artifact-11
|
||||||
|
if: always() && steps.split.outcome == 'success'
|
||||||
|
continue-on-error: true
|
||||||
|
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||||
|
with:
|
||||||
|
name: jscpd-bucket-${{ github.run_id }}-${{ github.run_attempt }}-11
|
||||||
|
path: ${{ runner.temp }}/jscpd-buckets/bucket-11.json
|
||||||
|
retention-days: 31
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
- name: Upload jscpd bucket 12
|
||||||
|
id: bucket-artifact-12
|
||||||
|
if: always() && steps.split.outcome == 'success'
|
||||||
|
continue-on-error: true
|
||||||
|
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||||
|
with:
|
||||||
|
name: jscpd-bucket-${{ github.run_id }}-${{ github.run_attempt }}-12
|
||||||
|
path: ${{ runner.temp }}/jscpd-buckets/bucket-12.json
|
||||||
|
retention-days: 31
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
- name: Upload jscpd bucket 13
|
||||||
|
id: bucket-artifact-13
|
||||||
|
if: always() && steps.split.outcome == 'success'
|
||||||
|
continue-on-error: true
|
||||||
|
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||||
|
with:
|
||||||
|
name: jscpd-bucket-${{ github.run_id }}-${{ github.run_attempt }}-13
|
||||||
|
path: ${{ runner.temp }}/jscpd-buckets/bucket-13.json
|
||||||
|
retention-days: 31
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
- name: Upload jscpd bucket 14
|
||||||
|
id: bucket-artifact-14
|
||||||
|
if: always() && steps.split.outcome == 'success'
|
||||||
|
continue-on-error: true
|
||||||
|
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||||
|
with:
|
||||||
|
name: jscpd-bucket-${{ github.run_id }}-${{ github.run_attempt }}-14
|
||||||
|
path: ${{ runner.temp }}/jscpd-buckets/bucket-14.json
|
||||||
|
retention-days: 31
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
- name: Upload jscpd bucket 15
|
||||||
|
id: bucket-artifact-15
|
||||||
|
if: always() && steps.split.outcome == 'success'
|
||||||
|
continue-on-error: true
|
||||||
|
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||||
|
with:
|
||||||
|
name: jscpd-bucket-${{ github.run_id }}-${{ github.run_attempt }}-15
|
||||||
|
path: ${{ runner.temp }}/jscpd-buckets/bucket-15.json
|
||||||
|
retention-days: 31
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
- name: Upload jscpd bucket 16
|
||||||
|
id: bucket-artifact-16
|
||||||
|
if: always() && steps.split.outcome == 'success'
|
||||||
|
continue-on-error: true
|
||||||
|
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||||
|
with:
|
||||||
|
name: jscpd-bucket-${{ github.run_id }}-${{ github.run_attempt }}-16
|
||||||
|
path: ${{ runner.temp }}/jscpd-buckets/bucket-16.json
|
||||||
|
retention-days: 31
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
- name: Upload jscpd bucket 17
|
||||||
|
id: bucket-artifact-17
|
||||||
|
if: always() && steps.split.outcome == 'success'
|
||||||
|
continue-on-error: true
|
||||||
|
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||||
|
with:
|
||||||
|
name: jscpd-bucket-${{ github.run_id }}-${{ github.run_attempt }}-17
|
||||||
|
path: ${{ runner.temp }}/jscpd-buckets/bucket-17.json
|
||||||
|
retention-days: 31
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
- name: Upload jscpd bucket 18
|
||||||
|
id: bucket-artifact-18
|
||||||
|
if: always() && steps.split.outcome == 'success'
|
||||||
|
continue-on-error: true
|
||||||
|
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||||
|
with:
|
||||||
|
name: jscpd-bucket-${{ github.run_id }}-${{ github.run_attempt }}-18
|
||||||
|
path: ${{ runner.temp }}/jscpd-buckets/bucket-18.json
|
||||||
|
retention-days: 31
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
- name: Upload jscpd bucket 19
|
||||||
|
id: bucket-artifact-19
|
||||||
|
if: always() && steps.split.outcome == 'success'
|
||||||
|
continue-on-error: true
|
||||||
|
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||||
|
with:
|
||||||
|
name: jscpd-bucket-${{ github.run_id }}-${{ github.run_attempt }}-19
|
||||||
|
path: ${{ runner.temp }}/jscpd-buckets/bucket-19.json
|
||||||
|
retention-days: 31
|
||||||
|
if-no-files-found: error
|
||||||
10
.github/workflows/prepare-docker-reusable.yml
vendored
10
.github/workflows/prepare-docker-reusable.yml
vendored
|
|
@ -9,6 +9,16 @@ name: 'Prepare n8n Docker (reusable)'
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
|
secrets:
|
||||||
|
QA_METRICS_WEBHOOK_URL:
|
||||||
|
description: 'QA metrics webhook URL.'
|
||||||
|
required: false
|
||||||
|
QA_METRICS_WEBHOOK_USER:
|
||||||
|
description: 'QA metrics webhook username.'
|
||||||
|
required: false
|
||||||
|
QA_METRICS_WEBHOOK_PASSWORD:
|
||||||
|
description: 'QA metrics webhook password.'
|
||||||
|
required: false
|
||||||
inputs:
|
inputs:
|
||||||
build-variant:
|
build-variant:
|
||||||
description: 'standard, coverage, or pc'
|
description: 'standard, coverage, or pc'
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,7 @@ jobs:
|
||||||
environment: [dev, prod]
|
environment: [dev, prod]
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
|
||||||
- name: Setup Node.js and build
|
- name: Setup Node.js and build
|
||||||
uses: ./.github/actions/setup-nodejs
|
uses: ./.github/actions/setup-nodejs
|
||||||
|
|
|
||||||
2
.github/workflows/release-chromatic.yml
vendored
2
.github/workflows/release-chromatic.yml
vendored
|
|
@ -24,7 +24,7 @@ jobs:
|
||||||
chromatic:
|
chromatic:
|
||||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
ref: ${{ inputs.ref }}
|
ref: ${{ inputs.ref }}
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ jobs:
|
||||||
private-key: ${{ secrets.N8N_ASSISTANT_PRIVATE_KEY }}
|
private-key: ${{ secrets.N8N_ASSISTANT_PRIVATE_KEY }}
|
||||||
|
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
token: ${{ steps.generate_token.outputs.token }}
|
token: ${{ steps.generate_token.outputs.token }}
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@ jobs:
|
||||||
private-key: ${{ secrets.N8N_ASSISTANT_PRIVATE_KEY }}
|
private-key: ${{ secrets.N8N_ASSISTANT_PRIVATE_KEY }}
|
||||||
|
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
token: ${{ steps.generate_token.outputs.token }}
|
token: ${{ steps.generate_token.outputs.token }}
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@ jobs:
|
||||||
if: needs.create-release-pr.result == 'success' && needs.create-release-pr.outputs.pull-request-number != ''
|
if: needs.create-release-pr.result == 'success' && needs.create-release-pr.outputs.pull-request-number != ''
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
sparse-checkout: .github/scripts/slack
|
sparse-checkout: .github/scripts/slack
|
||||||
sparse-checkout-cone-mode: false
|
sparse-checkout-cone-mode: false
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ jobs:
|
||||||
should_create_pr: ${{ steps.determine-branch.outputs.should_create_pr }}
|
should_create_pr: ${{ steps.determine-branch.outputs.should_create_pr }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
|
|
@ -79,7 +79,7 @@ jobs:
|
||||||
if: needs.create-release-pr.result == 'success' && needs.create-release-pr.outputs.pull-request-number != ''
|
if: needs.create-release-pr.result == 'success' && needs.create-release-pr.outputs.pull-request-number != ''
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
sparse-checkout: .github/scripts/slack
|
sparse-checkout: .github/scripts/slack
|
||||||
sparse-checkout-cone-mode: false
|
sparse-checkout-cone-mode: false
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user