Compare commits
335 Commits
sqlserver-
...
python-sec
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ae1585c7e | ||
|
|
b0b11dca91 | ||
|
|
8984233763 | ||
|
|
8ab2269f04 | ||
|
|
4385d33640 | ||
|
|
64588d02fb | ||
|
|
20b1d51e6b | ||
|
|
838d5a7a50 | ||
|
|
5fb83b02e7 | ||
|
|
31b35fb452 | ||
|
|
f82bdbdd52 | ||
|
|
2819934fdc | ||
|
|
ce264b2c85 | ||
|
|
07f62f33c3 | ||
|
|
adf574b57b | ||
|
|
43d61f4514 | ||
|
|
12ce1e67e7 | ||
|
|
89abda4d66 | ||
|
|
911fe173f2 | ||
|
|
8705016252 | ||
|
|
fc7d1ec13b | ||
|
|
6108e0182e | ||
|
|
479d7ff67b | ||
|
|
3e6612f075 | ||
|
|
ce8a99bf08 | ||
|
|
ed3aa72e65 | ||
|
|
4081105624 | ||
|
|
37d96787a1 | ||
|
|
e823ccc4d7 | ||
|
|
4674a3610f | ||
|
|
1f0d8d6b34 | ||
|
|
5616e61f7a | ||
|
|
3aa548b966 | ||
|
|
cb49c63e0c | ||
|
|
99661b47e3 | ||
|
|
98daa6fbf8 | ||
|
|
30e4219082 | ||
|
|
eed6ef7980 | ||
|
|
b21e9dcba1 | ||
|
|
8771f8eb73 | ||
|
|
e5a9978cac | ||
|
|
039504f1e9 | ||
|
|
09e0cf1718 | ||
|
|
ba4c6a87c3 | ||
|
|
1921483a0d | ||
|
|
0f97c3e87b | ||
|
|
62cdfb57c2 | ||
|
|
ba9e327dd0 | ||
|
|
8706236c94 | ||
|
|
5598559520 | ||
|
|
8d47578032 | ||
|
|
7cc5a515f1 | ||
|
|
bf028c04dd | ||
|
|
b2d652c6ab | ||
|
|
e29d97eb9e | ||
|
|
83215dcacd | ||
|
|
f0a65273fb | ||
|
|
0d59548a1e | ||
|
|
16a8399a5c | ||
|
|
45940c8ff1 | ||
|
|
88474b9d2a | ||
|
|
bb8b550a21 | ||
|
|
373b755b50 | ||
|
|
03a8afdba8 | ||
|
|
aaa84da26f | ||
|
|
611ef3690b | ||
|
|
559301b57b | ||
|
|
803f470a7c | ||
|
|
cb41874c74 | ||
|
|
1a8a17229a | ||
|
|
972c96b58d | ||
|
|
4c9497c75c | ||
|
|
4e5bb2daa8 | ||
|
|
9cd10f877b | ||
|
|
9a5a4e2587 | ||
|
|
7b3ed42a61 | ||
|
|
bf26412ac5 | ||
|
|
12835e1889 | ||
|
|
d65b0f5661 | ||
|
|
a77339d049 | ||
|
|
fcd561b654 | ||
|
|
d3a8d67430 | ||
|
|
bd6c7a3ef6 | ||
|
|
973bddf906 | ||
|
|
ace59d6c1c | ||
|
|
5246357b5c | ||
|
|
2ca895407f | ||
|
|
85485ed925 | ||
|
|
8d3584593a | ||
|
|
6547e398ee | ||
|
|
a6b0d2c7d6 | ||
|
|
4d04a9743c | ||
|
|
7712c474dd | ||
|
|
dbedcbfbd4 | ||
|
|
25eebabf11 | ||
|
|
30daa26a7b | ||
|
|
ebea514c84 | ||
|
|
622629ef6c | ||
|
|
d60573c199 | ||
|
|
71e305b6ab | ||
|
|
e7211002cf | ||
|
|
cf56c33cdd | ||
|
|
8eea1fc50b | ||
|
|
a420372ba0 | ||
|
|
0b7aa11b15 | ||
|
|
bc0d27253e | ||
|
|
e3791b88e8 | ||
|
|
6c25c78740 | ||
|
|
b3cfb8d510 | ||
|
|
2765c8d027 | ||
|
|
dad63b3da3 | ||
|
|
8ef62d8612 | ||
|
|
e2e10c2867 | ||
|
|
ee29a82916 | ||
|
|
ffdd62caaa | ||
|
|
6e6bca18d0 | ||
|
|
5a6a2d84e2 | ||
|
|
669dc959ff | ||
|
|
3554cc36fb | ||
|
|
5340d25eea | ||
|
|
7a39244afe | ||
|
|
f9dce94f95 | ||
|
|
2d7ed54076 | ||
|
|
f4d3f69010 | ||
|
|
88c9058a51 | ||
|
|
bd2df5059b | ||
|
|
4a12356ec8 | ||
|
|
8e13d485ec | ||
|
|
4d849db7f8 | ||
|
|
5a024bd2ff | ||
|
|
fe4a72d76f | ||
|
|
31e79dc02d | ||
|
|
64dfbf0bb3 | ||
|
|
9a6f10f9dc | ||
|
|
1ad3f2b114 | ||
|
|
98424e1a61 | ||
|
|
4d47327087 | ||
|
|
29993aa6db | ||
|
|
733e1c6f3e | ||
|
|
97f82c0792 | ||
|
|
6188ef843f | ||
|
|
544a0c2ba8 | ||
|
|
836e12b526 | ||
|
|
ba83271710 | ||
|
|
6b855cbe81 | ||
|
|
b8673198cd | ||
|
|
87f0514519 | ||
|
|
dea12ca75c | ||
|
|
71863190b1 | ||
|
|
5a4f82063e | ||
|
|
9197dc9057 | ||
|
|
0b9e1c0be4 | ||
|
|
46d217cf64 | ||
|
|
f9ecaf7f92 | ||
|
|
ae56aaf2b2 | ||
|
|
ab9a3e191a | ||
|
|
9f42bcf323 | ||
|
|
130888462d | ||
|
|
32a869b1e6 | ||
|
|
f7f8697947 | ||
|
|
960d036afd | ||
|
|
60e1b995d3 | ||
|
|
15195d006a | ||
|
|
5bd7fa6247 | ||
|
|
6eff4789f0 | ||
|
|
fe6bc8a408 | ||
|
|
117020fc2e | ||
|
|
b9bba24cfd | ||
|
|
a817ed88a6 | ||
|
|
0a3932e9cf | ||
|
|
44dc41a7fa | ||
|
|
f0ef171d25 | ||
|
|
5373a65352 | ||
|
|
a169d4ab75 | ||
|
|
31a0379d5d | ||
|
|
950f453535 | ||
|
|
7a20b77fbd | ||
|
|
7d1f271280 | ||
|
|
a9b9b7bb59 | ||
|
|
368ed03818 | ||
|
|
ffbddbbc6f | ||
|
|
c6a0c72b01 | ||
|
|
22725a83b9 | ||
|
|
a9e1b0edf7 | ||
|
|
6912a50e85 | ||
|
|
5f56057bfe | ||
|
|
e5bd3a34eb | ||
|
|
6097aa447b | ||
|
|
ff2b145e37 | ||
|
|
21f2c65b15 | ||
|
|
1c90ab2c31 | ||
|
|
82b65c8496 | ||
|
|
da4e98d389 | ||
|
|
4701e5f8dc | ||
|
|
18f5a74df7 | ||
|
|
339d2c91b9 | ||
|
|
51e50d157c | ||
|
|
c16d08579d | ||
|
|
07c824b6fb | ||
|
|
6aa06eaa23 | ||
|
|
7876a74202 | ||
|
|
c09cdbf49c | ||
|
|
23825aec8e | ||
|
|
eca47dd2dc | ||
|
|
8a7b431057 | ||
|
|
384f6fb9b7 | ||
|
|
b7fa7d73d2 | ||
|
|
9d523b4604 | ||
|
|
951be524ec | ||
|
|
c518575acc | ||
|
|
206ba7ee99 | ||
|
|
e78a7c57e6 | ||
|
|
892974e74e | ||
|
|
1bec6455d3 | ||
|
|
0149067bd7 | ||
|
|
54a2825522 | ||
|
|
ca6033b698 | ||
|
|
f6be96587f | ||
|
|
1bc18a3626 | ||
|
|
837a454620 | ||
|
|
3127b4e8c3 | ||
|
|
56879decb0 | ||
|
|
d81c75451c | ||
|
|
b48d367c21 | ||
|
|
5c6e3e71fe | ||
|
|
5e05b04e53 | ||
|
|
5a39e1adf8 | ||
|
|
ff5e3b6973 | ||
|
|
486296ff36 | ||
|
|
7f6472209d | ||
|
|
f57fdc8258 | ||
|
|
705bda8519 | ||
|
|
a91da664b9 | ||
|
|
631e2c157f | ||
|
|
01fa952fa7 | ||
|
|
d9ea875545 | ||
|
|
3512caf5ee | ||
|
|
63716689ad | ||
|
|
a7e7e5ac5b | ||
|
|
6b5c699c22 | ||
|
|
2a8bbb797c | ||
|
|
c16bdc6c32 | ||
|
|
c1e5037657 | ||
|
|
aae7c76e84 | ||
|
|
c8ede8c5c5 | ||
|
|
1b244fdd43 | ||
|
|
c109d4fbc6 | ||
|
|
16eefb159d | ||
|
|
931afd85c5 | ||
|
|
79002d4666 | ||
|
|
a7b48d8ca8 | ||
|
|
843ddfa348 | ||
|
|
b7262df217 | ||
|
|
8bb5162d89 | ||
|
|
0605e0352f | ||
|
|
fc54b654f4 | ||
|
|
2a8b0fdefe | ||
|
|
851cb65a11 | ||
|
|
62e3bb098e | ||
|
|
6bf214f2c2 | ||
|
|
77eb75ecd0 | ||
|
|
2080e7c13f | ||
|
|
78e968bd69 | ||
|
|
636276120b | ||
|
|
b0682e0144 | ||
|
|
4682a713c8 | ||
|
|
eae64a014a | ||
|
|
2c6e6b806d | ||
|
|
2f98715cbc | ||
|
|
69c3756253 | ||
|
|
344861dd67 | ||
|
|
3487a7801f | ||
|
|
1fbea58471 | ||
|
|
605c237a1a | ||
|
|
b2d81611cc | ||
|
|
af81edb346 | ||
|
|
dc91702b6b | ||
|
|
b8d634be90 | ||
|
|
471df8492b | ||
|
|
e0ad467236 | ||
|
|
eac68e3fe9 | ||
|
|
d141f2f00e | ||
|
|
5c0455b3ec | ||
|
|
a9ceb5f20d | ||
|
|
df0986902c | ||
|
|
9b3fe8e0ea | ||
|
|
7ed335db9c | ||
|
|
b83ac24d1e | ||
|
|
c32c55c589 | ||
|
|
84b27f02a5 | ||
|
|
863d48e920 | ||
|
|
06d3dd8cb4 | ||
|
|
bfdce241fe | ||
|
|
3777617772 | ||
|
|
06ed290471 | ||
|
|
7afc643c0c | ||
|
|
960b9fa2d1 | ||
|
|
6c81e71958 | ||
|
|
caf298bc5a | ||
|
|
71c929d0d9 | ||
|
|
34296d1975 | ||
|
|
4dcb2a7f86 | ||
|
|
f19e78d9fc | ||
|
|
44570e07cd | ||
|
|
b4b8e57722 | ||
|
|
1fe4641c3e | ||
|
|
bc91754508 | ||
|
|
2828ecaa8b | ||
|
|
8c937d249b | ||
|
|
b27f04d153 | ||
|
|
8d56144be1 | ||
|
|
2833fa63b2 | ||
|
|
7098c5576a | ||
|
|
03b9522551 | ||
|
|
eec1d1b0d5 | ||
|
|
a3596d04cf | ||
|
|
68f276b5a1 | ||
|
|
2e2aae3f62 | ||
|
|
2e33d53027 | ||
|
|
132a7e674a | ||
|
|
a1a4a037fd | ||
|
|
914130d706 | ||
|
|
2ed2645f5b | ||
|
|
e73f7ecf1b | ||
|
|
91bdbd3f21 | ||
|
|
9160419f64 | ||
|
|
0cdbb874ac | ||
|
|
6cc57802d4 | ||
|
|
f8cb4ca1bf | ||
|
|
d3ee87fab5 | ||
|
|
2d635fadf9 | ||
|
|
5c475f1d06 | ||
|
|
fe2715ef6b | ||
|
|
3d8b6293b8 | ||
|
|
b4f0b23363 |
@@ -102,3 +102,7 @@ breakage keeps happening, *that* is the signal a comment was warranted.
|
||||
- End all files with a newline
|
||||
- When editing tabular code, where the columns line up, try to keep them aligned
|
||||
- Spaces on a line with nothing after it is not allowed
|
||||
- After changing module boundaries (adding/removing/renaming a `src` namespace, a cross-module `require`
|
||||
or `:model/X` reference, or a new module), run `./bin/mage fix-modules-config` to regenerate
|
||||
`.clj-kondo/config/modules/config.edn` and keep `metabase.core.modules-test` green. No-op when nothing
|
||||
drifted; see "Module Boundaries" in the project `CLAUDE.md`.
|
||||
|
||||
@@ -257,7 +257,7 @@ Remove the old styled file entirely. Remove all imports of it from other files.
|
||||
|
||||
- Text: `"text-primary"`, `"text-secondary"`, `"text-disabled"`
|
||||
- Background: `"background_page-primary"`, `"background_page-secondary"`, `"background_surface-hover"`
|
||||
- Brand: `"core-brand"`, `"error"`, `"success"`, `"warning"`
|
||||
- Brand: `"core-brand"`, `"feedback-negative"`, `"feedback-positive"`, `"feedback-warning"`
|
||||
|
||||
**Typography** (`fz`, `fw`, `lh`, `ta`, `ff`):
|
||||
|
||||
@@ -283,7 +283,7 @@ Remove the old styled file entirely. Remove all imports of it from other files.
|
||||
- `--mb-color-background_page-primary`, `--mb-color-background_page-secondary`, `--mb-color-background_surface-hover`
|
||||
- `--mb-color-border`, `--mb-color-border-strong`, `--mb-color-border-subtle`
|
||||
- `--mb-color-core-brand`, `--mb-color-core-brand-hover`
|
||||
- `--mb-color-error`, `--mb-color-success`, `--mb-color-warning`
|
||||
- `--mb-color-feedback-negative`, `--mb-color-feedback-positive`, `--mb-color-feedback-warning`
|
||||
- `--mb-color-shadow`, `--mb-color-focus`
|
||||
- `--mb-color-accent0` through `--mb-color-accent7`
|
||||
|
||||
|
||||
@@ -179,6 +179,7 @@
|
||||
clojure.core.async/take!
|
||||
clojure.core.async/to-chan!
|
||||
clojure.core.async/to-chan!!
|
||||
metabase.ai-tracing.core/record!
|
||||
metabase.api.macros.defendpoint.tools-manifest/assert-optional-fields-nullable!
|
||||
metabase.channel.core/send!
|
||||
metabase.channel.render.pdf/draw-too-large!
|
||||
@@ -187,10 +188,17 @@
|
||||
metabase.channel.render.pdf.font/load-fonts!
|
||||
metabase.warehouses.models.database/assert-not-h2!
|
||||
metabase.driver.sql-jdbc.execute/execute-prepared-statement!
|
||||
metabase.mcp.usage/record-mcp-session!
|
||||
metabase.mcp.usage/record-mcp-session-end!
|
||||
metabase.mcp.usage/record-mcp-tool-call!
|
||||
metabase.notification.models/create-notification!
|
||||
metabase.oauth-server.core/reset-provider!
|
||||
metabase.pulse.send/send-pulse!
|
||||
metabase-enterprise.transforms-python.python-runner-test/execute!
|
||||
;; MCP usage tests isolate their rows by unique keys (session id / tool name / duration /
|
||||
;; client version) and clean up only those, so these deletes are safe in parallel.
|
||||
metabase-enterprise.mcp.usage-test/cleanup!
|
||||
metabase-enterprise.mcp.usage-test/cleanup-calls!
|
||||
;; Workspace isolation functions are safe in parallel tests because they operate on
|
||||
;; isolated, test-specific workspaces and databases that don't affect other tests
|
||||
metabase-enterprise.workspaces.isolation/destroy-workspace-isolation!
|
||||
@@ -252,9 +260,6 @@
|
||||
;; misc config for built-in linters
|
||||
;;
|
||||
|
||||
:invalid-arity {:skip-args [metabase.util.match/match-one
|
||||
metabase.util.match/match-many
|
||||
metabase.util.match/replace]} ; TODO (cam): can we fix these?
|
||||
:refer-all {:level :warning, :exclude [clojure.test]}
|
||||
:unused-referred-var {:exclude {compojure.core [GET DELETE POST PUT]}}
|
||||
|
||||
@@ -449,6 +454,9 @@
|
||||
metabase-enterprise.action-v2.test-util/with-test-tables! clojure.core/let
|
||||
metabase-enterprise.content-translation.routes-test/with-new-secret-key! clojure.core/fn
|
||||
metabase-enterprise.entity-retrieval.reconcile-test/with-isolated-index clojure.core/fn
|
||||
metabase-enterprise.mfa.enrollment-flow-test/with-fresh-user-session! clojure.core/fn
|
||||
metabase-enterprise.mfa.login-flow-test/with-enrolled-rasta! clojure.core/fn
|
||||
metabase-enterprise.mfa.recovery-codes-test/with-confirmed-enrollment! clojure.core/fn
|
||||
metabase-enterprise.serialization.metadata-file-import.wire-format-test/with-tiny-fixture! clojure.core/fn
|
||||
metabase-enterprise.serialization.test-util/with-dbs clojure.core/fn
|
||||
metabase-enterprise.serialization.test-util/with-random-dump-dir clojure.core/let
|
||||
@@ -581,7 +589,7 @@
|
||||
metabase.api.common.internal-test/with-jetty-server hooks.common/with-one-binding
|
||||
metabase.api.macros/defendpoint hooks.metabase.api.macros/defendpoint
|
||||
metabase.app-db.schema-migrations-test.impl/test-migrations hooks.metabase.app-db.schema-migrations-test.impl/test-migrations
|
||||
metabase.channel.render.js.svg/with-static-viz-context hooks.common/with-one-top-level-binding
|
||||
metabase.channel.render.js.pool/with-static-viz-context hooks.common/with-one-top-level-binding
|
||||
metabase.channel.template.handlebars-helper/defhelper hooks.metabase.channel.template.handlebars-helper/defhelper
|
||||
metabase.channel.template.handlebars-test/with-temp-template! hooks.common/with-vec-first-binding
|
||||
metabase.classloader.core/require hooks.clojure.core.require/lint-require
|
||||
@@ -617,6 +625,9 @@
|
||||
metabase.model-persistence.api-test/with-persistence-setup! hooks.common/with-one-top-level-binding
|
||||
metabase.model-persistence.api-test/with-setup! hooks.common/with-one-top-level-binding
|
||||
metabase.model-persistence.test-util/with-persistence-enabled! hooks.metabase.model-persistence.test-util/with-persistence-enabled!
|
||||
metabase.mq.core/with-queue hooks.common/let-second
|
||||
metabase.mq.test-util/with-test-mq hooks.common/with-one-binding
|
||||
metabase.mq.core/def-listener! hooks.metabase.mq.impl/def-listener
|
||||
metabase.models.interface/define-batched-hydration-method hooks.metabase.models.interface/define-hydration-method
|
||||
metabase.models.interface/define-simple-hydration-method hooks.metabase.models.interface/define-hydration-method
|
||||
metabase.permissions.core/allow-changing-all-users-group-members hooks.common/do*
|
||||
@@ -708,6 +719,10 @@
|
||||
metabase.util.log/warn hooks.metabase.util.log/info
|
||||
metabase.util.log/warnf hooks.metabase.util.log/infof
|
||||
metabase.util.malli.registry/def hooks.metabase.util.malli.registry/def
|
||||
metabase.util.match/match-one hooks.metabase.util.match/match-one
|
||||
metabase.util.match/match-many hooks.metabase.util.match/match-many
|
||||
metabase.util.match/replace hooks.metabase.util.match/replace
|
||||
metabase.util.match/replace-in hooks.metabase.util.match/replace-in
|
||||
metabase.util/case-enum hooks.metabase.util/case-enum
|
||||
metabase.util/format-color hooks.metabase.util/format-color
|
||||
metabase.xrays.api.automagic-dashboards-test/with-indexed-model! hooks.metabase.xrays.api.automagic-dashboards-test/with-indexed-model!
|
||||
@@ -751,6 +766,7 @@
|
||||
metabase.test/with-temporary-raw-setting-values macros.metabase.test.util/with-temporary-raw-setting-values
|
||||
metabase.users.models.user-test/with-groups! macros.metabase.users.models.user-test/with-groups!
|
||||
metabase.util.namespaces/import-fn macros.metabase.util.namespaces/import-fn
|
||||
metabase.util.namespaces/import-macro macros.metabase.util.namespaces/import-macro
|
||||
metabase.xrays.related-test/with-world macros.metabase.xrays.related-test/with-world}}
|
||||
|
||||
:config-in-comment
|
||||
|
||||
@@ -159,7 +159,8 @@
|
||||
{:team "Metabot"
|
||||
:api #{metabase.agent-api.api
|
||||
metabase.agent-api.init}
|
||||
:uses #{api
|
||||
:uses #{ai-tracing
|
||||
api
|
||||
auth-identity
|
||||
channel
|
||||
collections
|
||||
@@ -180,6 +181,8 @@
|
||||
:model/Collection
|
||||
:model/Dashboard
|
||||
:model/DashboardCard
|
||||
:model/DashboardTab
|
||||
:model/Database
|
||||
:model/Table
|
||||
:model/User}}
|
||||
|
||||
@@ -192,6 +195,15 @@
|
||||
models
|
||||
util}}
|
||||
|
||||
ai-tracing
|
||||
{:team "Metabot"
|
||||
:api #{metabase.ai-tracing.api
|
||||
metabase.ai-tracing.core
|
||||
metabase.ai-tracing.init}
|
||||
:uses #{api
|
||||
settings
|
||||
util}}
|
||||
|
||||
analytics
|
||||
{:team "UX West"
|
||||
:api #{metabase.analytics.api
|
||||
@@ -828,7 +840,6 @@
|
||||
metabase.driver.common
|
||||
metabase.driver.connection
|
||||
metabase.driver.ddl.interface
|
||||
metabase.driver.h2
|
||||
metabase.driver.impl
|
||||
metabase.driver.init
|
||||
metabase.driver.mysql
|
||||
@@ -1064,6 +1075,27 @@
|
||||
:model-exports #{:model/ModelIndex :model/ModelIndexValue}
|
||||
:model-imports #{:model/Card :model/Collection}}
|
||||
|
||||
indexes
|
||||
{:team "Gadget"
|
||||
:api #{metabase.indexes.models.table-index
|
||||
metabase.indexes.reconcile
|
||||
metabase.indexes.schema}
|
||||
:uses #{driver
|
||||
models
|
||||
util}
|
||||
:model-exports #{:model/TableIndex}}
|
||||
|
||||
indexes-rest
|
||||
{:team "Gadget"
|
||||
:api #{metabase.indexes-rest.api}
|
||||
:uses #{api
|
||||
indexes
|
||||
transforms-base
|
||||
util}
|
||||
:model-imports #{:model/Database
|
||||
:model/TableIndex
|
||||
:model/Transform}}
|
||||
|
||||
initialization-status
|
||||
{:team "UX West"
|
||||
:api #{metabase.initialization-status.core}
|
||||
@@ -1103,6 +1135,7 @@
|
||||
metabase.lib.metadata
|
||||
metabase.lib.metadata.protocols
|
||||
metabase.lib.options
|
||||
metabase.lib.pivot
|
||||
metabase.lib.schema
|
||||
metabase.lib.schema.actions
|
||||
metabase.lib.schema.aggregation
|
||||
@@ -1226,13 +1259,19 @@
|
||||
:api #{metabase.mcp.api
|
||||
metabase.mcp.callback-api
|
||||
metabase.mcp.core
|
||||
metabase.mcp.init}
|
||||
metabase.mcp.init
|
||||
metabase.mcp.usage}
|
||||
:model-exports #{:model/McpSessionLog
|
||||
:model/McpToolCallLog}
|
||||
:uses #{agent-api
|
||||
ai-tracing
|
||||
api
|
||||
app-db
|
||||
config
|
||||
llm
|
||||
metabot
|
||||
oauth-server
|
||||
premium-features
|
||||
request
|
||||
server
|
||||
session
|
||||
@@ -1271,6 +1310,7 @@
|
||||
slackbot}
|
||||
:uses #{activity-feed
|
||||
agent-lib
|
||||
ai-tracing
|
||||
analytics
|
||||
analytics-interface
|
||||
api
|
||||
@@ -1302,7 +1342,6 @@
|
||||
settings
|
||||
slackbot
|
||||
sql-tools
|
||||
store-api
|
||||
sync
|
||||
system
|
||||
task
|
||||
@@ -1318,6 +1357,8 @@
|
||||
:model-imports #{:model/Card
|
||||
:model/Collection
|
||||
:model/Dashboard
|
||||
:model/DashboardCard
|
||||
:model/DashboardTab
|
||||
:model/Database
|
||||
:model/Field
|
||||
:model/Glossary
|
||||
@@ -1407,6 +1448,17 @@
|
||||
:model/Table
|
||||
:model/User}}
|
||||
|
||||
mq
|
||||
{:team "UX West"
|
||||
:api #{metabase.mq.init}
|
||||
:uses #{analytics-interface
|
||||
app-db
|
||||
classloader
|
||||
settings
|
||||
startup
|
||||
task
|
||||
util}}
|
||||
|
||||
native-query-snippets
|
||||
{:team "Graphy"
|
||||
:api #{metabase.native-query-snippets.api
|
||||
@@ -1850,6 +1902,7 @@
|
||||
cache
|
||||
config
|
||||
dashboards
|
||||
database-routing
|
||||
driver
|
||||
events
|
||||
formatter
|
||||
@@ -1943,20 +1996,12 @@
|
||||
{:team "UX West"
|
||||
:api #{metabase.sample-data.core
|
||||
metabase.sample-data.init}
|
||||
:uses #{config
|
||||
models
|
||||
permissions
|
||||
:uses #{driver
|
||||
plugins
|
||||
settings
|
||||
sync
|
||||
util}
|
||||
:model-imports #{:model/Card
|
||||
:model/Dashboard
|
||||
:model/DashboardCard
|
||||
:model/DataPermissions
|
||||
:model/Database
|
||||
:model/Field
|
||||
:model/Table}}
|
||||
:model-imports #{:model/DataPermissions :model/Database :model/Table}}
|
||||
|
||||
search
|
||||
{:team "UX West"
|
||||
@@ -2070,6 +2115,7 @@
|
||||
channel
|
||||
config
|
||||
events
|
||||
premium-features
|
||||
request
|
||||
settings
|
||||
sso
|
||||
@@ -2167,8 +2213,7 @@
|
||||
sql-parsing
|
||||
{:team "DevEx"
|
||||
:api #{metabase.sql-parsing.core}
|
||||
:uses #{analytics-interface
|
||||
util}}
|
||||
:uses #{analytics-interface util}}
|
||||
|
||||
sql-tools
|
||||
{:team "DevEx"
|
||||
@@ -2307,9 +2352,11 @@
|
||||
config
|
||||
lib
|
||||
lib-be
|
||||
mcp
|
||||
permissions
|
||||
premium-features
|
||||
search
|
||||
session
|
||||
util}
|
||||
:model-imports #{:model/AiUsageLog
|
||||
:model/Card
|
||||
@@ -2374,6 +2421,7 @@
|
||||
database-routing
|
||||
driver
|
||||
events
|
||||
indexes
|
||||
lib
|
||||
lib-be
|
||||
models
|
||||
@@ -2403,6 +2451,7 @@
|
||||
:model/Database
|
||||
:model/Field
|
||||
:model/Table
|
||||
:model/TableIndex
|
||||
:model/User}}
|
||||
|
||||
transforms-base
|
||||
@@ -2416,11 +2465,13 @@
|
||||
:model-imports #{:model/Database
|
||||
:model/Field
|
||||
:model/Table
|
||||
:model/TableIndex
|
||||
:model/Transform
|
||||
:model/TransformRun}
|
||||
:uses #{database-routing
|
||||
driver
|
||||
events
|
||||
indexes
|
||||
lib
|
||||
lib-be
|
||||
premium-features
|
||||
@@ -2496,7 +2547,8 @@
|
||||
:model/QueryExecution
|
||||
:model/Segment
|
||||
:model/Table}
|
||||
:uses #{lib
|
||||
:uses #{app-db
|
||||
lib
|
||||
lib-be
|
||||
models
|
||||
settings
|
||||
@@ -2846,6 +2898,7 @@
|
||||
:uses #{driver
|
||||
premium-features
|
||||
enterprise/advanced-config
|
||||
enterprise/mfa
|
||||
enterprise/scim
|
||||
enterprise/semantic-search
|
||||
enterprise/sso}
|
||||
@@ -2871,7 +2924,6 @@
|
||||
app-db
|
||||
audit-app
|
||||
classloader
|
||||
config
|
||||
driver
|
||||
lib
|
||||
models
|
||||
@@ -3080,7 +3132,6 @@
|
||||
{:team "UX West"
|
||||
:api #{metabase-enterprise.database-routing.api}
|
||||
:uses #{api
|
||||
config
|
||||
database-routing
|
||||
driver
|
||||
events
|
||||
@@ -3253,6 +3304,18 @@
|
||||
premium-features}
|
||||
:model-imports #{:model/Card :model/Collection :model/Table}}
|
||||
|
||||
enterprise/mcp
|
||||
{:team "Metabot"
|
||||
:api #{metabase-enterprise.mcp.init}
|
||||
:uses #{analytics
|
||||
mcp
|
||||
metabot
|
||||
premium-features
|
||||
task
|
||||
util}
|
||||
:model-imports #{:model/McpSessionLog
|
||||
:model/McpToolCallLog}}
|
||||
|
||||
enterprise/memoize-monitor
|
||||
{:team "Graphy"
|
||||
:api #{metabase-enterprise.memoize-monitor.init}
|
||||
@@ -3306,6 +3369,22 @@
|
||||
util}
|
||||
:model-imports #{:model/MetabotConversation :model/MetabotFeedback :model/MetabotMessage}}
|
||||
|
||||
enterprise/mfa
|
||||
{:team "Graphy"
|
||||
:api #{metabase-enterprise.mfa.core
|
||||
metabase-enterprise.mfa.init
|
||||
metabase-enterprise.mfa.routes}
|
||||
:uses #{api
|
||||
appearance
|
||||
channel
|
||||
config
|
||||
events
|
||||
premium-features
|
||||
settings
|
||||
sso
|
||||
util}
|
||||
:model-imports #{:model/AuthIdentity :model/User}}
|
||||
|
||||
enterprise/permission-debug
|
||||
{:team "UX West"
|
||||
:api #{metabase-enterprise.permission-debug.api}
|
||||
@@ -3485,6 +3564,7 @@
|
||||
config
|
||||
connection-pool
|
||||
events
|
||||
lib-be
|
||||
llm
|
||||
metabot
|
||||
models
|
||||
@@ -3513,7 +3593,6 @@
|
||||
app-db
|
||||
appearance
|
||||
collections
|
||||
config
|
||||
events
|
||||
lib
|
||||
logger
|
||||
@@ -3609,7 +3688,9 @@
|
||||
{:team "Graphy"
|
||||
:api #{metabase-enterprise.transforms.api}
|
||||
:uses #{premium-features
|
||||
enterprise/transforms-inspector}}
|
||||
util
|
||||
enterprise/transforms-inspector}
|
||||
:model-imports #{:model/Transform}}
|
||||
|
||||
enterprise/transforms-inspector
|
||||
{:team "Gadget"
|
||||
|
||||
@@ -36,6 +36,9 @@
|
||||
;; Definitely thread safe
|
||||
metabase.test.util.dynamic-redefs/patch-vars!
|
||||
|
||||
;; test-only cleanup of in-memory throttle state (atom resets)
|
||||
metabase.session.api/reset-throttlers-for-testing!
|
||||
|
||||
;; TODO: most of these symbols shouldn't be here, we should go through them and
|
||||
;; find the functions/macros that use them and make sure their names end with !
|
||||
;; best way to do this is try remove each of these and rely on kondo output to find places where it's used
|
||||
|
||||
@@ -6,3 +6,8 @@
|
||||
`(def ~(-> sym name symbol) "docstring" ~sym))
|
||||
([sym name]
|
||||
`(def ~name "docstring" ~sym)))
|
||||
|
||||
(defmacro import-macro
|
||||
"Kondo macro replacement for [[metabase.util.namespaces/import-macro]]."
|
||||
[sym]
|
||||
`(def ~(-> sym name symbol) "docstring" ~sym))
|
||||
|
||||
18
.clj-kondo/src/hooks/metabase/mq/impl.clj
Normal file
@@ -0,0 +1,18 @@
|
||||
(ns hooks.metabase.mq.impl
|
||||
(:require
|
||||
[clj-kondo.hooks-api :as hooks]))
|
||||
|
||||
(defn def-listener
|
||||
"Kondo hook for `mq/def-listener`. Rewrites to a `fn` form so kondo can analyze bindings.
|
||||
|
||||
Form:
|
||||
(mq/def-listener! :queue/bar [msgs] body...)"
|
||||
[{:keys [node]}]
|
||||
(let [[_ _channel-name bindings & body] (:children node)
|
||||
;; rewrite as: (fn [bindings...] body...)
|
||||
new-node (hooks/list-node
|
||||
(list*
|
||||
(hooks/token-node 'fn)
|
||||
bindings
|
||||
body))]
|
||||
{:node new-node}))
|
||||
132
.clj-kondo/src/hooks/metabase/util/match.clj
Normal file
@@ -0,0 +1,132 @@
|
||||
(ns hooks.metabase.util.match
|
||||
(:require [clj-kondo.hooks-api :as api]))
|
||||
|
||||
(defn- token-node? [node token-name]
|
||||
(and (api/token-node? node)
|
||||
(= (str (:value node)) token-name)))
|
||||
|
||||
(defn- keyword-node? [node k]
|
||||
(and (api/keyword-node? node)
|
||||
(= (:k node) k)))
|
||||
|
||||
(defn- binding-symbol?
|
||||
"True when the token is a plain symbol that should be treated as a binding
|
||||
(not a keyword, not a boolean literal, not nil, not a number, not `_`)."
|
||||
[node]
|
||||
(and (api/token-node? node)
|
||||
(let [v (:value node)]
|
||||
(and (symbol? v)
|
||||
(not (contains? '#{_ nil true false} v))))))
|
||||
|
||||
;; To establish fake let bindings, we need to bind them to something. This "something" has to be vague enough to trick
|
||||
;; kondo into not looking too much into it. E.g., if we bind everything to `nil`, Kondo linters will get fired up.
|
||||
(def opaque-node (api/list-node (list (api/token-node 'rand-nth) (api/list-node []))))
|
||||
|
||||
(defn collect-bindings
|
||||
"Return a list of token nodes (symbols) introduced as bindings by `pattern`."
|
||||
[pattern]
|
||||
(cond
|
||||
(binding-symbol? pattern)
|
||||
[[pattern opaque-node]]
|
||||
|
||||
;; Lists: (:or ...), (:and ...), (sym :guard pred ...), or a quoted symbol.
|
||||
(api/list-node? pattern)
|
||||
(let [[head & rst] (:children pattern)]
|
||||
(cond (or (keyword-node? head :or)
|
||||
(keyword-node? head :and))
|
||||
(mapcat collect-bindings rst)
|
||||
|
||||
(api/token-node? head)
|
||||
(cond-> []
|
||||
(binding-symbol? head) (conj [head opaque-node])
|
||||
(keyword-node? (first rst) :guard) (conj [(api/token-node '_) (second rst)]))
|
||||
|
||||
;; anything else (e.g. a function call used as predicate) — no bindings
|
||||
:else []))
|
||||
|
||||
(api/vector-node? pattern)
|
||||
(mapcat (fn [node]
|
||||
(when-not (token-node? node "&")
|
||||
(collect-bindings node)))
|
||||
(:children pattern))
|
||||
|
||||
;; Maps: keys are literal selectors, values are sub-patterns.
|
||||
;; Special value-symbols &truthy and _ are NOT user bindings.
|
||||
(api/map-node? pattern)
|
||||
(let [children (:children pattern)
|
||||
pairs (partition 2 children)]
|
||||
(mapcat (fn [[_k v]]
|
||||
(when-not (or (token-node? v "&truthy")
|
||||
(token-node? v "_"))
|
||||
(collect-bindings v)))
|
||||
pairs))
|
||||
|
||||
;; For sets, make sure to use them because they may reference some of the outer local bindings, so if we don't
|
||||
;; include them, Kondo will complain about those bindings being unused.
|
||||
(api/set-node? pattern)
|
||||
[[(api/token-node '_) pattern]]
|
||||
|
||||
;; Anything else — no bindings.
|
||||
:else []))
|
||||
|
||||
(defn- dedupe-bindings
|
||||
"Prevent Kondo complaining that we have multiple fake bindings."
|
||||
[pairs]
|
||||
(loop [[[sym v] & r] pairs, seen #{}, result []]
|
||||
(cond (nil? sym) result
|
||||
(and (seen sym) (not (token-node? sym "_"))) (recur r seen result)
|
||||
:else (recur r (conj seen sym) (conj result sym v)))))
|
||||
|
||||
(defn match-one
|
||||
"clj-kondo :analyze-call hook for match-one.
|
||||
|
||||
Rewrites
|
||||
|
||||
(match-one value
|
||||
pat1 ret1
|
||||
pat2 ret2
|
||||
...)
|
||||
|
||||
into a `let` form that declares all binding symbols introduced by the patterns, so clj-kondo knows they are
|
||||
legitimate and won't warn about unresolved/unused symbols:
|
||||
|
||||
(let [<all-bindings> (rand-nth []) ...]
|
||||
(or ret1 ret2 ...))"
|
||||
[{:keys [node]}]
|
||||
(let [[_ value & clauses] (:children node)]
|
||||
(when (odd? (count clauses))
|
||||
(api/reg-finding!
|
||||
{:message "match-one/match-many/replace requires an even number of pattern/result pairs"
|
||||
:type :metabase/match-odd-clauses
|
||||
:row (:row node)
|
||||
:col (:col node)
|
||||
:filename (-> node meta :filename)}))
|
||||
(let [pairs (partition 2 clauses)
|
||||
all-bindings (->> pairs
|
||||
(mapcat (fn [[pat _ret]] (collect-bindings pat)))
|
||||
dedupe-bindings)
|
||||
special-bindings [(api/token-node '_) value
|
||||
(api/token-node '&match) opaque-node
|
||||
(api/token-node '&parents) opaque-node
|
||||
(api/token-node '&recur) opaque-node
|
||||
;; Pretend to use &match, &parents, and &recur so that Kondo doesn't complain about them
|
||||
;; if they are not used by the user code.
|
||||
(api/token-node '_) (api/token-node '&match)
|
||||
(api/token-node '_) (api/token-node '&parents)
|
||||
(api/token-node '_) (api/token-node '&recur)]
|
||||
binding-nodes (into special-bindings all-bindings)
|
||||
result-exprs (map second pairs)
|
||||
new-node (api/list-node
|
||||
(list
|
||||
(api/token-node 'let)
|
||||
(api/vector-node binding-nodes)
|
||||
(api/list-node
|
||||
(concat [(api/token-node 'clojure.core/or)] result-exprs [opaque-node]))))]
|
||||
{:node new-node})))
|
||||
|
||||
(def match-many match-one)
|
||||
(def replace match-one)
|
||||
|
||||
(defn replace-in [context]
|
||||
;; Everything is the same as in match-one, just drop the middle argument.
|
||||
(match-one (update-in context [:node :children] (fn [[sym value path & clauses]] (list* sym value clauses)))))
|
||||
59
.clj-kondo/test/hooks/metabase/util/match_test.clj
Normal file
@@ -0,0 +1,59 @@
|
||||
(ns hooks.metabase.util.match-test
|
||||
(:require
|
||||
[clj-kondo.hooks-api :as hooks]
|
||||
[clj-kondo.impl.utils]
|
||||
[clojure.test :refer :all]
|
||||
[hooks.metabase.util.match :as sut]))
|
||||
|
||||
(defn- expand [src]
|
||||
(-> (sut/match-one {:node (hooks/parse-string (pr-str src))})
|
||||
:node
|
||||
hooks/sexpr))
|
||||
|
||||
(defn- lint [src]
|
||||
(binding [clj-kondo.impl.utils/*ctx* {:config {:linters {:metabase/match-odd-clauses {:level :warning}}}
|
||||
:ignores (atom nil)
|
||||
:findings (atom [])
|
||||
:namespaces (atom {})}]
|
||||
(expand src)
|
||||
@(:findings clj-kondo.impl.utils/*ctx*)))
|
||||
|
||||
(deftest odd-clauses-triggers-warning-test
|
||||
(is (=? [{:type :metabase/match-odd-clauses}]
|
||||
(lint '(metabase.util.match/match-one [] [a b c])))))
|
||||
|
||||
(deftest expansion-test
|
||||
(testing "simple expansion example"
|
||||
(is (= '(let [_ []
|
||||
&match (rand-nth ())
|
||||
&parents (rand-nth ())
|
||||
&recur (rand-nth ())
|
||||
_ &match
|
||||
_ &parents
|
||||
_ &recur
|
||||
a (rand-nth ())
|
||||
b (rand-nth ())
|
||||
c (rand-nth ())]
|
||||
(clojure.core/or (+ a b) (rand-nth ())))
|
||||
(expand '(match-one [] [a b c] (+ a b))))))
|
||||
(testing "complicated expansion example that uses most match-one macro features, and also binding deduplication by the rewriter"
|
||||
(is (= '(let [_ []
|
||||
&match (rand-nth ())
|
||||
&parents (rand-nth ())
|
||||
&recur (rand-nth ())
|
||||
_ &match
|
||||
_ &parents
|
||||
_ &recur
|
||||
val1 (rand-nth ())
|
||||
a (rand-nth ())
|
||||
_ #{maybe important}
|
||||
rst (rand-nth ())
|
||||
x (rand-nth ())
|
||||
_ (= x 3)
|
||||
b (rand-nth ())]
|
||||
(clojure.core/or (+ a val1) (rand-nth ())))
|
||||
(expand '(match-one []
|
||||
(:or [{:key1 val1, :key2 &truthy, :key3 _} _ a #{maybe important} & rst]
|
||||
(:and (x :guard (= x 3))
|
||||
[a b]))
|
||||
(+ a val1)))))))
|
||||
28
.coderabbit.yaml
Normal file
@@ -0,0 +1,28 @@
|
||||
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
|
||||
reviews:
|
||||
high_level_summary: false
|
||||
review_status: false
|
||||
|
||||
auto_review:
|
||||
enabled: false
|
||||
drafts: true
|
||||
labels:
|
||||
- "coderabbit"
|
||||
|
||||
path_filters:
|
||||
- "!**/*.snap"
|
||||
- "!**/*.po"
|
||||
- "!**/bun.lock"
|
||||
- "!**/package-lock.json"
|
||||
|
||||
tools:
|
||||
eslint:
|
||||
enabled: false
|
||||
|
||||
knowledge_base:
|
||||
code_guidelines:
|
||||
filePatterns:
|
||||
- ".claude/skills/clojure-review/SKILL.md"
|
||||
- ".claude/skills/typescript-review/SKILL.md"
|
||||
- ".claude/skills/e2e-test-review/SKILL.md"
|
||||
- ".claude/skills/docs-review/SKILL.md"
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM mcr.microsoft.com/vscode/devcontainers/java:21
|
||||
FROM mcr.microsoft.com/vscode/devcontainers/java:25
|
||||
|
||||
# Set up nodesource, install node, bun, fontconfig for static viz, rlwrap for dev ergonomics
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
.git
|
||||
.babel_cache
|
||||
target/*
|
||||
target
|
||||
.circleci
|
||||
.cpcache
|
||||
.devcontainer
|
||||
@@ -15,6 +16,7 @@ test_config/*
|
||||
test_modules/*
|
||||
test_resources/*
|
||||
node_modules
|
||||
**/node_modules
|
||||
**metabase.jar
|
||||
|
||||
*.db
|
||||
|
||||
87
.github/actions/create-test-plan/action.yml
vendored
@@ -17,6 +17,9 @@ inputs:
|
||||
loki-infra-touched:
|
||||
description: Whether Loki infra changed.
|
||||
required: true
|
||||
e2e-infra-touched:
|
||||
description: Whether e2e harness/support code changed.
|
||||
required: true
|
||||
shared-sources-touched:
|
||||
description: Whether cljc/cljs/package.json changed.
|
||||
required: true
|
||||
@@ -40,6 +43,34 @@ runs:
|
||||
steps:
|
||||
- name: Prepare Node + Bun
|
||||
uses: ./.github/actions/prepare-node-bun
|
||||
- name: Resolve closest-ancestor e2e coverage manifest
|
||||
id: manifest
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: | # js
|
||||
const { pathToFileURL } = require("url");
|
||||
const { resolveCoverageManifest } = await import(
|
||||
pathToFileURL(
|
||||
`${process.env.GITHUB_WORKSPACE}/.github/scripts/resolve-coverage-manifest.mjs`,
|
||||
).href,
|
||||
);
|
||||
const result = await resolveCoverageManifest({
|
||||
github,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
headSha: context.payload.pull_request?.head?.sha ?? context.sha,
|
||||
});
|
||||
core.setOutput("path", result?.path ?? "");
|
||||
- name: Build dependency graph
|
||||
shell: bash
|
||||
run: |
|
||||
# Quiet on success; surface output only if install fails.
|
||||
bun install --frozen-lockfile > /tmp/bun-install.log 2>&1 \
|
||||
|| { echo "bun install issues (continuing):"; tail -20 /tmp/bun-install.log; }
|
||||
bunx depcruise frontend/src enterprise/frontend/src \
|
||||
--config .dependency-cruiser.cjs \
|
||||
--output-type json > dependency-graph.json || true
|
||||
|
||||
- name: Create test plan
|
||||
id: compute
|
||||
shell: bash
|
||||
@@ -49,18 +80,52 @@ runs:
|
||||
BE_CHANGED_FILES: ${{ inputs.be-changed-files }}
|
||||
UNIT_INFRA_TOUCHED: ${{ inputs.unit-infra-touched }}
|
||||
LOKI_INFRA_TOUCHED: ${{ inputs.loki-infra-touched }}
|
||||
E2E_INFRA_TOUCHED: ${{ inputs.e2e-infra-touched }}
|
||||
SHARED_SOURCES_TOUCHED: ${{ inputs.shared-sources-touched }}
|
||||
DEP_GRAPH_JSON: dependency-graph.json
|
||||
E2E_SPEC_MANIFEST: ${{ steps.manifest.outputs.path }}
|
||||
run: |
|
||||
# on failure create-test-plan falls back to the rules graph.
|
||||
bun install --frozen-lockfile || true
|
||||
bunx depcruise frontend/src enterprise/frontend/src \
|
||||
--config .dependency-cruiser.cjs \
|
||||
--output-type json > dependency-graph.json || true
|
||||
# stdout is the plan JSON; the planner's own info logs go to stderr.
|
||||
bun .github/scripts/create-test-plan.ts > test-plan.json
|
||||
echo "stats=$(jq -c '.stats' test-plan.json)" >> "$GITHUB_OUTPUT"
|
||||
echo "fe_unit_specs_to_run=$(jq -c '.fe_unit_specs_to_run' test-plan.json)" >> "$GITHUB_OUTPUT"
|
||||
echo "loki_stories_to_run=$(jq -c '.loki_stories_to_run' test-plan.json)" >> "$GITHUB_OUTPUT"
|
||||
echo "e2e_specs_to_run=$(jq -c '.e2e_specs_to_run' test-plan.json)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
OUTPUT=$(bun .github/scripts/create-test-plan.ts)
|
||||
echo "$OUTPUT" | jq '.stats'
|
||||
echo "stats=$(echo "$OUTPUT" | jq -c '.stats')" >> "$GITHUB_OUTPUT"
|
||||
echo "fe_unit_specs_to_run=$(echo "$OUTPUT" | jq -c '.fe_unit_specs_to_run')" >> "$GITHUB_OUTPUT"
|
||||
echo "loki_stories_to_run=$(echo "$OUTPUT" | jq -c '.loki_stories_to_run')" >> "$GITHUB_OUTPUT"
|
||||
echo "e2e_specs_to_run=$(echo "$OUTPUT" | jq -c '.e2e_specs_to_run')" >> "$GITHUB_OUTPUT"
|
||||
- name: Report test plan
|
||||
shell: bash
|
||||
run: |
|
||||
plan=test-plan.json
|
||||
row() { # name, selected, total
|
||||
if [ "$2" -eq "$3" ]; then echo "| $1 | all ($3) | $3 |"; else echo "| $1 | $2 | $3 |"; fi
|
||||
}
|
||||
{
|
||||
echo "## Affected test plan"
|
||||
echo
|
||||
echo "| Suite | Selected | Total |"
|
||||
echo "|---|---:|---:|"
|
||||
row "Unit" "$(jq '.stats.unit_specs_to_run_usage' "$plan")" "$(jq '.stats.unit_specs_all' "$plan")"
|
||||
row "Loki" "$(jq '.stats.loki_stories_to_run_usage' "$plan")" "$(jq '.stats.loki_stories_all' "$plan")"
|
||||
row "E2E" "$(jq '.stats.e2e_specs_to_run_usage' "$plan")" "$(jq '.stats.e2e_specs_all' "$plan")"
|
||||
echo
|
||||
echo "Changed: $(jq '.stats.fe_files_changed' "$plan") FE / $(jq '.stats.be_files_changed' "$plan") BE file(s) · $(jq '.stats.fe_modules_affected_usage' "$plan") FE module(s) affected"
|
||||
echo
|
||||
e2e_run=$(jq '.stats.e2e_specs_to_run_usage' "$plan")
|
||||
e2e_all=$(jq '.stats.e2e_specs_all' "$plan")
|
||||
if [ "$e2e_run" -eq "$e2e_all" ]; then
|
||||
echo "**E2E: full run** — no manifest, or a change the coverage manifest can't scope (backend, e2e infra, cljc/cljs)."
|
||||
else
|
||||
echo "<details><summary><b>E2E specs to run ($e2e_run)</b></summary>"
|
||||
echo
|
||||
jq -r '.e2e_specs_to_run[]' "$plan" | sed 's/^/- /'
|
||||
echo
|
||||
echo "</details>"
|
||||
fi
|
||||
echo
|
||||
echo "<details><summary>Full stats (rules vs usage graph)</summary>"
|
||||
echo
|
||||
echo '```json'
|
||||
jq '.stats' "$plan"
|
||||
echo '```'
|
||||
echo "</details>"
|
||||
} | tee -a "${GITHUB_STEP_SUMMARY:-/dev/null}"
|
||||
|
||||
2
.github/actions/prepare-backend/action.yml
vendored
@@ -4,7 +4,7 @@ description: |
|
||||
inputs:
|
||||
java-version:
|
||||
required: true
|
||||
default: "21"
|
||||
default: "25"
|
||||
clojure-version:
|
||||
required: true
|
||||
default: "1.12.0.1488"
|
||||
|
||||
52
.github/actions/prepare-ci-conductor/action.yml
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
name: Prepare CI Conductor
|
||||
description: >-
|
||||
Make the ci-conductor sources runnable in the workspace.
|
||||
Unless the checkout is headed for master, they are overlaid from master, so every
|
||||
branch runs the latest scripts while its workflow wiring stays branch-historical.
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Detect bun
|
||||
id: bun
|
||||
shell: bash
|
||||
run: |
|
||||
if command -v bun >/dev/null; then
|
||||
echo "present=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "present=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Install bun
|
||||
if: steps.bun.outputs.present != 'true'
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version-file: "package.json"
|
||||
token: ${{ github.token }}
|
||||
|
||||
# Anything headed for master runs master's own sources, so a PR can test its own changes.
|
||||
- name: Fetch ci-conductor sources from master
|
||||
if: ${{ github.base_ref != 'master' && github.ref_name != 'master' }}
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
repository: metabase/metabase
|
||||
ref: master
|
||||
path: .ci-conductor-master
|
||||
persist-credentials: false
|
||||
sparse-checkout-cone-mode: false
|
||||
sparse-checkout: |
|
||||
release/ci-conductor
|
||||
e2e/support/ci_conductor.ts
|
||||
|
||||
# e2e/support/config.js and e2e/support/ci_conductor.ts resolve these by relative
|
||||
# path at bundle time, so the sources have to land where the branch expects them.
|
||||
- name: Overlay ci-conductor sources from master
|
||||
if: ${{ github.base_ref != 'master' && github.ref_name != 'master' }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
rm -rf release/ci-conductor
|
||||
mv .ci-conductor-master/release/ci-conductor release/ci-conductor
|
||||
mv .ci-conductor-master/e2e/support/ci_conductor.ts e2e/support/ci_conductor.ts
|
||||
rm -rf .ci-conductor-master
|
||||
echo "Overlaid ci-conductor sources from master."
|
||||
4
.github/actions/prepare-cypress/action.yml
vendored
@@ -15,6 +15,10 @@ outputs:
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
# e2e/support/config.js imports the reporter, so it has to be in place before Cypress bundles.
|
||||
- name: Prepare CI Conductor
|
||||
uses: ./.github/actions/prepare-ci-conductor
|
||||
|
||||
- name: Install Chrome v144
|
||||
uses: browser-actions/setup-chrome@2e1d749697dd1612b833dba4a722266286fbefcd # v2.1.2
|
||||
with:
|
||||
|
||||
2
.github/actions/prepare-node-bun/action.yml
vendored
@@ -16,5 +16,5 @@ runs:
|
||||
- name: Install bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version: "1.3.14"
|
||||
bun-version-file: 'package.json'
|
||||
token: ${{ github.token }}
|
||||
|
||||
@@ -6,7 +6,7 @@ runs:
|
||||
steps:
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version: "1.3.14"
|
||||
bun-version-file: 'package.json'
|
||||
token: ${{ github.token }}
|
||||
|
||||
- name: Build release scripts
|
||||
|
||||
38
.github/actions/quarantine-gate/action.yml
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
name: CI Conductor quarantine gate
|
||||
description: >-
|
||||
Check this job's test failures against ci-conductor's quarantine list.
|
||||
Either enforces the quarantine or only prints a verdict (dry run).
|
||||
Fails closed when enforcing but the `CI_CONDUCTOR_BASE_URL` is missing or unreachable.
|
||||
|
||||
inputs:
|
||||
adapter:
|
||||
description: Which gate to run — one of `backend`, `frontend`, `e2e`.
|
||||
required: true
|
||||
test-suite:
|
||||
description: >-
|
||||
Suite label to gate (CI_CONDUCTOR_TEST_SUITE). MUST match the label this
|
||||
job reports under, or the gate checks the wrong quarantine list.
|
||||
required: true
|
||||
enforce-quarantine:
|
||||
description: >-
|
||||
Whether the gate actually gates the job. When "false", it only prints a verdict.
|
||||
required: false
|
||||
default: "false"
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Prepare CI Conductor
|
||||
uses: ./.github/actions/prepare-ci-conductor
|
||||
- name: Check failures against the quarantine list
|
||||
shell: bash
|
||||
env:
|
||||
CI_CONDUCTOR_TEST_SUITE: ${{ inputs.test-suite }}
|
||||
ADAPTER: ${{ inputs.adapter }}
|
||||
QUARANTINE_DRY_RUN: ${{ inputs.enforce-quarantine != 'true' }}
|
||||
run: |
|
||||
case "$ADAPTER" in
|
||||
backend|frontend|e2e) ;;
|
||||
*) echo "::error::quarantine-gate: unknown adapter '$ADAPTER' (expected backend|frontend|e2e)"; exit 1 ;;
|
||||
esac
|
||||
bun "release/ci-conductor/src/check-${ADAPTER}-quarantine.ts"
|
||||
@@ -15,21 +15,8 @@ inputs:
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Detect bun
|
||||
id: bun
|
||||
shell: bash
|
||||
run: |
|
||||
if command -v bun >/dev/null; then
|
||||
echo "present=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "present=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- name: Install bun
|
||||
if: steps.bun.outputs.present != 'true'
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version: "1.3.14"
|
||||
token: ${{ github.token }}
|
||||
- name: Prepare CI Conductor
|
||||
uses: ./.github/actions/prepare-ci-conductor
|
||||
- name: Report failed tests to ci-conductor
|
||||
shell: bash
|
||||
env:
|
||||
|
||||
106
.github/actions/sdk-bundle-size-check/action.yml
vendored
@@ -1,15 +1,17 @@
|
||||
name: Embedding SDK Bundle Size Check
|
||||
description: |
|
||||
Downloads the current branch's uberjar and builds the base ref's
|
||||
embedding-sdk.js from source, compares their sizes,
|
||||
and sets output if the size change exceeds the threshold.
|
||||
Measures app + embedding SDK bundle sizes from the current and base-ref
|
||||
uberjars (reusing the builds that already ran) plus their bundle-stats
|
||||
artifacts, and flags changes to the chunked SDK's reachable, gzipped size
|
||||
beyond the threshold. Skips when the base-ref artifacts aren't available
|
||||
rather than rebuilding from source (which would compare across pipelines).
|
||||
|
||||
inputs:
|
||||
edition:
|
||||
description: 'Metabase edition (ee or oss)'
|
||||
required: true
|
||||
base-ref-commit:
|
||||
description: 'Commit SHA of the base ref (e.g. merge base).'
|
||||
description: 'Commit SHA of the base ref.'
|
||||
required: true
|
||||
threshold-percent:
|
||||
description: 'Percent threshold for bundle size change'
|
||||
@@ -26,51 +28,73 @@ outputs:
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Prepare backend
|
||||
uses: ./.github/actions/prepare-backend
|
||||
- name: Prepare frontend
|
||||
uses: ./.github/actions/prepare-frontend
|
||||
- name: Download current uberjar
|
||||
uses: ./.github/actions/fetch-artifact
|
||||
with:
|
||||
commit: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
edition: ${{ inputs.edition }}
|
||||
extract-path: temp/current-uberjar
|
||||
- name: Extract embedding-sdk bundles from current uberjar
|
||||
shell: bash
|
||||
run: |
|
||||
# Extract preserving the jar's internal paths under resources/ so the
|
||||
# shared measure script sees the same layout as a source build. Both the
|
||||
# new (embedding-sdk/legacy + chunks) and old (embedding-sdk.js) layouts
|
||||
# are pulled; whichever exists is what gets measured.
|
||||
mkdir -p temp/current/resources
|
||||
unzip -o temp/current-uberjar/metabase.jar \
|
||||
"frontend_client/app/embedding-sdk/*" \
|
||||
-d temp/current/resources 2>/dev/null || true
|
||||
unzip -o temp/current-uberjar/metabase.jar \
|
||||
"frontend_client/app/embedding-sdk.js" \
|
||||
-d temp/current/resources 2>/dev/null || true
|
||||
- name: Checkout base ref
|
||||
uses: actions/checkout@v6
|
||||
- name: Download base-ref uberjar
|
||||
id: base-jar
|
||||
continue-on-error: true
|
||||
uses: ./.github/actions/fetch-artifact
|
||||
with:
|
||||
ref: ${{ inputs.base-ref-commit }}
|
||||
path: temp/base-ref-src
|
||||
- name: Build base-ref embedding-sdk.js
|
||||
shell: bash
|
||||
run: |
|
||||
cd temp/base-ref-src
|
||||
bun install
|
||||
MB_EDITION=${{ inputs.edition }} bun run build-release
|
||||
- name: Measure current and base bundle sizes
|
||||
shell: bash
|
||||
env:
|
||||
MEASURE_BUNDLES: embedding-sdk-legacy,embedding-sdk-chunked
|
||||
run: |
|
||||
MEASURE_ROOT=temp/current node ./.github/scripts/measure-bundle-sizes.js > temp/current-sizes.json
|
||||
MEASURE_ROOT=temp/base-ref-src node ./.github/scripts/measure-bundle-sizes.js > temp/base-sizes.json
|
||||
- name: Compare sizes and set output
|
||||
commit: ${{ inputs.base-ref-commit }}
|
||||
edition: ${{ inputs.edition }}
|
||||
extract-path: temp/base-uberjar
|
||||
- name: Measure and compare bundle sizes
|
||||
id: compare
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
EDITION: ${{ inputs.edition }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
BASE_SHA: ${{ inputs.base-ref-commit }}
|
||||
THRESHOLD: ${{ inputs.threshold-percent }}
|
||||
run: node ./.github/actions/sdk-bundle-size-check/compare.js temp/current-sizes.json temp/base-sizes.json
|
||||
MEASURE_BUNDLES: app,embedding-sdk-legacy,embedding-sdk-chunked
|
||||
run: |
|
||||
set -uo pipefail
|
||||
|
||||
skip() {
|
||||
echo "::notice::Embedding SDK bundle-size check skipped: $1"
|
||||
echo "status=stable" >> "$GITHUB_OUTPUT"
|
||||
echo "size_change_percent=0" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
}
|
||||
|
||||
# The base ref needs an already-built uberjar. Rebuilding it from source
|
||||
# would compare across two build pipelines (the bug this check used to
|
||||
# have), so skip instead when it — or its stats — isn't available.
|
||||
[ -f temp/base-uberjar/metabase.jar ] || skip "base-ref uberjar not found for ${BASE_SHA}"
|
||||
|
||||
extract_js() { # $1=jar $2=dest-root
|
||||
mkdir -p "$2/resources"
|
||||
unzip -o "$1" \
|
||||
"frontend_client/app/dist/*" \
|
||||
"frontend_client/app/embed.js" \
|
||||
"frontend_client/app/embedding-sdk/*" \
|
||||
-d "$2/resources" >/dev/null || true
|
||||
}
|
||||
|
||||
fetch_stats() { # $1=sha $2=dest-dir
|
||||
local name="metabase-bundle-stats-${EDITION}-$1"
|
||||
local id
|
||||
id=$(gh api "/repos/${GITHUB_REPOSITORY}/actions/artifacts?name=${name}" --jq '.artifacts[0].id // empty' 2>/dev/null)
|
||||
[ -n "${id}" ] || return 1
|
||||
mkdir -p "$2"
|
||||
gh api "/repos/${GITHUB_REPOSITORY}/actions/artifacts/${id}/zip" > "$2.zip" 2>/dev/null || return 1
|
||||
unzip -o "$2.zip" -d "$2" >/dev/null || return 1
|
||||
}
|
||||
|
||||
extract_js temp/current-uberjar/metabase.jar temp/current
|
||||
extract_js temp/base-uberjar/metabase.jar temp/base
|
||||
|
||||
fetch_stats "${HEAD_SHA}" temp/current/artifacts || skip "bundle stats not found for current ${HEAD_SHA}"
|
||||
fetch_stats "${BASE_SHA}" temp/base/artifacts || skip "bundle stats not found for base ${BASE_SHA}"
|
||||
|
||||
MEASURE_ROOT=temp/current node ./.github/scripts/measure-bundle-sizes.js > temp/current-sizes.json \
|
||||
|| { echo "::error::Failed to measure current bundle sizes"; exit 1; }
|
||||
MEASURE_ROOT=temp/base node ./.github/scripts/measure-bundle-sizes.js > temp/base-sizes.json \
|
||||
|| { echo "::error::Failed to measure base bundle sizes"; exit 1; }
|
||||
|
||||
node ./.github/actions/sdk-bundle-size-check/compare.js temp/current-sizes.json temp/base-sizes.json
|
||||
|
||||
126
.github/actions/sdk-bundle-size-check/compare.js
vendored
@@ -1,16 +1,72 @@
|
||||
/* eslint-disable import/no-commonjs */
|
||||
// Diffs the embedding SDK bundle sizes produced by measure-bundle-sizes.js for
|
||||
// the current build and the base ref. The gate is driven by the legacy
|
||||
// (monolithic) bundle's raw bytes; the chunked delivery is reported for context.
|
||||
// Diffs the bundle sizes produced by measure-bundle-sizes.js for the current
|
||||
// build and the base ref. Every (bundle, kind) is compared strictly against the
|
||||
// same (bundle, kind) — never one bundle type against another — using gzipped
|
||||
// (served) bytes. The gate is driven by the chunked SDK's reachable total, which
|
||||
// is what the SDK actually delivers.
|
||||
//
|
||||
// compareBundles is pure (data in, data out) so it can be unit-tested; main()
|
||||
// is the thin I/O wrapper that reads the size files, prints the report and sets
|
||||
// the step outputs / exit code.
|
||||
const fs = require("fs");
|
||||
|
||||
const [, , currentPath, basePath] = process.argv;
|
||||
const threshold = Number(process.env.THRESHOLD ?? 2);
|
||||
|
||||
const current = JSON.parse(fs.readFileSync(currentPath, "utf8"));
|
||||
const base = JSON.parse(fs.readFileSync(basePath, "utf8"));
|
||||
|
||||
const pick = (rows, bundle, kind) => rows.find(row => row.bundle === bundle && row.kind === kind);
|
||||
const keyOf = row => `${row.bundle}/${row.kind}`;
|
||||
const mb = bytes => (bytes / 1024 / 1024).toFixed(2);
|
||||
const percentOf = (currentBytes, baseBytes) =>
|
||||
baseBytes ? Math.trunc(((currentBytes - baseBytes) * 100) / baseBytes) : 0;
|
||||
|
||||
/**
|
||||
* Compare two measurement sets and decide the gate. Returns a human-readable
|
||||
* `report` (always), and exactly one of:
|
||||
* - `gate` { status, percent }: the chunked SDK total moved by `percent`.
|
||||
* - `skip` reason: the gate is non-actionable (report stable, no comment).
|
||||
* - `error` reason: the gate inputs are missing (hard failure).
|
||||
*/
|
||||
function compareBundles({ current, base, threshold }) {
|
||||
const report = ["=== Bundle sizes (gzipped, current vs base) ==="];
|
||||
const keys = [...new Set([...current, ...base].map(keyOf))].sort();
|
||||
for (const key of keys) {
|
||||
const [bundle, kind] = key.split("/");
|
||||
const currentRow = pick(current, bundle, kind);
|
||||
const baseRow = pick(base, bundle, kind);
|
||||
if (!currentRow || !baseRow) {
|
||||
report.push(`${key}: present only in ${currentRow ? "current" : "base"} build`);
|
||||
continue;
|
||||
}
|
||||
const diff = currentRow.gzipBytes - baseRow.gzipBytes;
|
||||
const percent = percentOf(currentRow.gzipBytes, baseRow.gzipBytes);
|
||||
report.push(`${key}: ${mb(currentRow.gzipBytes)}MB vs ${mb(baseRow.gzipBytes)}MB (${percent}%, ${diff} bytes)`);
|
||||
}
|
||||
|
||||
// Gate on the chunked SDK's reachable total (gzipped) — what the SDK ships.
|
||||
const gateCurrent = pick(current, "embedding-sdk-chunked", "total");
|
||||
const gateBase = pick(base, "embedding-sdk-chunked", "total");
|
||||
if (!gateCurrent || !gateBase || !gateBase.gzipBytes) {
|
||||
return { report, error: "Could not find embedding-sdk-chunked total (gzip) in both builds" };
|
||||
}
|
||||
|
||||
// Both sides must measure "total" the same way. A base ref built before the
|
||||
// reachableAssets enrichment collapses its total to the initial set, so
|
||||
// comparing it against the current reachable total reports a phantom jump
|
||||
// (~30%). Skip until the base ref also carries reachable stats (i.e. once
|
||||
// this change is on the base).
|
||||
if (Boolean(gateCurrent.reachable) !== Boolean(gateBase.reachable)) {
|
||||
return {
|
||||
report,
|
||||
skip:
|
||||
`chunked "total" measured differently on each side ` +
|
||||
`(current reachable=${Boolean(gateCurrent.reachable)}, base reachable=${Boolean(gateBase.reachable)}); ` +
|
||||
`the base ref predates the reachable-chunk stats. Resolves once this change is on the base ref.`,
|
||||
};
|
||||
}
|
||||
|
||||
const percent = percentOf(gateCurrent.gzipBytes, gateBase.gzipBytes);
|
||||
const status = percent > threshold ? "increased" : percent < -threshold ? "decreased" : "stable";
|
||||
return { report, gate: { status, percent } };
|
||||
}
|
||||
|
||||
module.exports = { compareBundles };
|
||||
|
||||
const setOutput = (name, value) => {
|
||||
if (process.env.GITHUB_OUTPUT) {
|
||||
@@ -18,37 +74,37 @@ const setOutput = (name, value) => {
|
||||
}
|
||||
};
|
||||
|
||||
const mb = bytes => (bytes / 1024 / 1024).toFixed(2);
|
||||
function main() {
|
||||
const [, , currentPath, basePath] = process.argv;
|
||||
const threshold = Number(process.env.THRESHOLD ?? 2);
|
||||
|
||||
const legacyCurrent = pick(current, "embedding-sdk-legacy", "total");
|
||||
const legacyBase = pick(base, "embedding-sdk-legacy", "total");
|
||||
const current = JSON.parse(fs.readFileSync(currentPath, "utf8"));
|
||||
const base = JSON.parse(fs.readFileSync(basePath, "utf8"));
|
||||
|
||||
if (!legacyCurrent || !legacyBase || !legacyBase.rawBytes) {
|
||||
console.error("::error::Could not find embedding-sdk-legacy size in both builds");
|
||||
process.exit(1);
|
||||
}
|
||||
const { report, gate, skip, error } = compareBundles({ current, base, threshold });
|
||||
console.log(report.join("\n"));
|
||||
|
||||
const diff = legacyCurrent.rawBytes - legacyBase.rawBytes;
|
||||
const percent = Math.trunc((diff * 100) / legacyBase.rawBytes);
|
||||
if (error) {
|
||||
console.error(`::error::${error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const status = percent > threshold ? "increased" : percent < -threshold ? "decreased" : "stable";
|
||||
if (skip) {
|
||||
// Treat the gate as non-actionable: report stable so no regression comment
|
||||
// is posted, mirroring the shell skip() for missing artifacts.
|
||||
console.log(`::notice::Embedding SDK bundle-size gate skipped: ${skip}`);
|
||||
setOutput("status", "stable");
|
||||
setOutput("size_change_percent", "0");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log("=== Embedding SDK legacy (monolithic) bundle ===");
|
||||
console.log(`Current: ${legacyCurrent.rawBytes} bytes raw / ${mb(legacyCurrent.gzipBytes)}MB gzip`);
|
||||
console.log(`Base ref: ${legacyBase.rawBytes} bytes raw / ${mb(legacyBase.gzipBytes)}MB gzip`);
|
||||
console.log(`Difference: ${diff} bytes (${percent}%), threshold ${threshold}%`);
|
||||
console.log(`Status: ${status}`);
|
||||
|
||||
const chunkedCurrent = pick(current, "embedding-sdk-chunked", "total");
|
||||
const chunkedBase = pick(base, "embedding-sdk-chunked", "total");
|
||||
if (chunkedCurrent && chunkedBase) {
|
||||
const chunkedDiff = chunkedCurrent.rawBytes - chunkedBase.rawBytes;
|
||||
console.log("");
|
||||
console.log("=== Embedding SDK chunked (informational) ===");
|
||||
console.log(`Current: ${chunkedCurrent.rawBytes} bytes raw / ${mb(chunkedCurrent.gzipBytes)}MB gzip`);
|
||||
console.log(`Base ref: ${chunkedBase.rawBytes} bytes raw / ${mb(chunkedBase.gzipBytes)}MB gzip`);
|
||||
console.log(`Difference: ${chunkedDiff} bytes`);
|
||||
console.log(`Gate: embedding-sdk-chunked total ${gate.percent}% (threshold ${threshold}%) → ${gate.status}`);
|
||||
|
||||
setOutput("status", gate.status);
|
||||
setOutput("size_change_percent", gate.percent);
|
||||
}
|
||||
|
||||
setOutput("status", status);
|
||||
setOutput("size_change_percent", percent);
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
|
||||
65
.github/actions/sdk-bundle-size-check/compare.unit.spec.js
vendored
Normal file
@@ -0,0 +1,65 @@
|
||||
const { compareBundles } = require("./compare");
|
||||
|
||||
const total = (gzipBytes, over = {}) => ({
|
||||
bundle: "embedding-sdk-chunked",
|
||||
kind: "total",
|
||||
gzipBytes,
|
||||
reachable: true,
|
||||
...over,
|
||||
});
|
||||
|
||||
describe("compareBundles", () => {
|
||||
it("gates on the chunked SDK total and flags an increase past the threshold", () => {
|
||||
const { gate, report } = compareBundles({
|
||||
current: [total(110)],
|
||||
base: [total(100)],
|
||||
threshold: 2,
|
||||
});
|
||||
|
||||
expect(gate).toEqual({ status: "increased", percent: 10 });
|
||||
expect(report[0]).toContain("current vs base");
|
||||
});
|
||||
|
||||
it("flags a decrease and stays stable within the threshold", () => {
|
||||
expect(compareBundles({ current: [total(90)], base: [total(100)], threshold: 2 }).gate).toEqual({
|
||||
status: "decreased",
|
||||
percent: -10,
|
||||
});
|
||||
expect(compareBundles({ current: [total(101)], base: [total(100)], threshold: 2 }).gate).toEqual({
|
||||
status: "stable",
|
||||
percent: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("skips when the two sides measured total differently (reachable mismatch)", () => {
|
||||
const result = compareBundles({
|
||||
current: [total(130, { reachable: true })],
|
||||
base: [total(100, { reachable: false })],
|
||||
threshold: 2,
|
||||
});
|
||||
|
||||
expect(result.gate).toBeUndefined();
|
||||
expect(result.skip).toContain("predates the reachable-chunk stats");
|
||||
});
|
||||
|
||||
it("errors when the gate bundle is missing on either side", () => {
|
||||
const result = compareBundles({
|
||||
current: [{ bundle: "app", kind: "total", gzipBytes: 1 }],
|
||||
base: [total(100)],
|
||||
threshold: 2,
|
||||
});
|
||||
|
||||
expect(result.gate).toBeUndefined();
|
||||
expect(result.error).toContain("embedding-sdk-chunked total");
|
||||
});
|
||||
|
||||
it("reports a bundle/kind present on only one side without erroring", () => {
|
||||
const { report } = compareBundles({
|
||||
current: [total(100), { bundle: "app", kind: "total", gzipBytes: 50 }],
|
||||
base: [total(100)],
|
||||
threshold: 2,
|
||||
});
|
||||
|
||||
expect(report.some(line => line.includes("present only in current build"))).toBe(true);
|
||||
});
|
||||
});
|
||||
46
.github/actions/test-driver/action.yml
vendored
@@ -14,8 +14,15 @@ inputs:
|
||||
description: Suffix to append to the test logs artifact name to avoid conflicts in matrix jobs
|
||||
edition:
|
||||
required: false
|
||||
default: 'ee'
|
||||
default: "ee"
|
||||
description: Edition to test (oss or ee)
|
||||
quarantine-engine:
|
||||
required: false
|
||||
driver-status:
|
||||
required: false
|
||||
default: ""
|
||||
description: >-
|
||||
Configured status for this driver ("info", "skip", "required", or empty), as reported by `mage driver-decisions`.
|
||||
|
||||
outputs:
|
||||
test-outcome:
|
||||
@@ -39,28 +46,25 @@ runs:
|
||||
run: bun run build-static-viz
|
||||
shell: bash
|
||||
env:
|
||||
MB_EDITION: ee
|
||||
MB_EDITION: ${{ inputs.edition }}
|
||||
|
||||
- name: Test database driver
|
||||
run: clojure -X:dev:ci:${{ inputs.edition }}:${{ inputs.edition }}-dev:drivers:drivers-dev:test ${{ inputs.test-args }}
|
||||
shell: bash
|
||||
id: run-test
|
||||
# Trunk Quarantine controls the final state of the run in the CI!
|
||||
# Even if this step fails we let it pass, and then `trunk-io/analytics-uploader` determines
|
||||
# whether to fail or to pass the whole job, based on the quarantine list.
|
||||
# Keep this step continue-on-error because the quarantine logic controls whether it ultimately fails.
|
||||
# Driver "info" status can affect this logic. See below.
|
||||
continue-on-error: true
|
||||
|
||||
- name: Report failed tests to ci-conductor
|
||||
# Positioned right after the test run (before the report/upload steps) so it
|
||||
# reads pristine JUnit. Gated to non-clean runs + continue-on-error so it can
|
||||
# never change the job outcome — Trunk Quarantine remains the sole arbiter.
|
||||
# junit-name is the per-job identity label (e.g. be-tests-postgres-ee); only
|
||||
# workflows that also set CI_CONDUCTOR_BASE_URL actually POST. See DEV-2224 /
|
||||
# DEV-2235.
|
||||
if: ${{ always() && steps.run-test.outcome != 'success' }}
|
||||
if: ${{ !cancelled() && steps.run-test.outcome == 'failure' }}
|
||||
# This step should NEVER fail the job!
|
||||
continue-on-error: true
|
||||
uses: ./.github/actions/report-failures-to-ci-conductor
|
||||
with:
|
||||
test-suite: ${{ inputs.junit-name }}
|
||||
adapter: backend
|
||||
|
||||
- name: Publish Test Report (JUnit)
|
||||
uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0
|
||||
# As per the documentation: https://docs.github.com/en/actions/reference/workflows-and-actions/contexts#steps-context
|
||||
@@ -74,6 +78,7 @@ runs:
|
||||
list-suites: failed
|
||||
list-tests: failed
|
||||
fail-on-error: false
|
||||
|
||||
- name: Upload Logs on Failure
|
||||
if: always() && steps.run-test.outcome != 'success'
|
||||
uses: actions/upload-artifact@v7
|
||||
@@ -83,3 +88,20 @@ runs:
|
||||
retention-days: 1
|
||||
# Failing to upload the artifact should never affect the outcome of the whole test run.
|
||||
continue-on-error: true
|
||||
|
||||
# Last, because this is the only step allowed to fail the job: everything above it reports the
|
||||
# failure (JUnit report, logs, ci-conductor) and must run regardless of the verdict below.
|
||||
- name: Quarantine gate
|
||||
# Off master and release branches an "info" driver is data-collection only - the test always passes!
|
||||
# On master and release branches "info" carries no weight - the quarantine logic applies.
|
||||
if: |
|
||||
!cancelled()
|
||||
&& steps.run-test.outcome == 'failure'
|
||||
&& !(inputs.driver-status == 'info'
|
||||
&& !(github.ref_name == 'master' || startsWith(github.ref_name, 'release-x')))
|
||||
continue-on-error: ${{ inputs.quarantine-engine != 'ci-conductor' }}
|
||||
uses: ./.github/actions/quarantine-gate
|
||||
with:
|
||||
adapter: backend
|
||||
test-suite: ${{ inputs.junit-name }}
|
||||
enforce-quarantine: ${{ inputs.quarantine-engine == 'ci-conductor' }}
|
||||
|
||||
14
.github/actions/upload-test-results/action.yml
vendored
@@ -34,6 +34,10 @@ inputs:
|
||||
description: >-
|
||||
Optional label disambiguating matrix legs of the same job, so the info-driver
|
||||
job summary and PR comment stay distinct per leg.
|
||||
run-trunk:
|
||||
required: false
|
||||
default: "true"
|
||||
description: Whether to run the Trunk step at all.
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
@@ -60,13 +64,11 @@ runs:
|
||||
aws s3 cp ${OUTPUT_FILE}.zip s3://$BUCKET/$DATE/$GITHUB_RUN_ID/$GITHUB_RUN_ATTEMPT/
|
||||
|
||||
- name: Upload results to Trunk
|
||||
if: ${{ always() && github.repository_owner == 'metabase' && inputs.run-trunk == 'true' }}
|
||||
# Important!
|
||||
# 1. This step must always run because it controls job's exit code.
|
||||
# 2. There's no point in running this outside the organization
|
||||
# 3. For "info" drivers off master/release branches, our own status takes precedence: we let this
|
||||
# step pass so the job is reported as passing regardless of the real outcome (still uploaded).
|
||||
# On master and release branches, info status is ignored and the real outcome gates the job.
|
||||
if: always() && github.repository_owner == 'metabase'
|
||||
# For "info" drivers off master/release branches, our own status takes precedence: we let this
|
||||
# step pass so the job is reported as passing regardless of the real outcome (still uploaded).
|
||||
# On master and release branches, info status is ignored and the real outcome gates the job.
|
||||
continue-on-error: ${{ inputs.driver-status == 'info' && !(github.ref_name == 'master' || startsWith(github.ref_name, 'release-x')) }}
|
||||
uses: trunk-io/analytics-uploader@1198efcc0976501147b10a7e343c3fa069527526 # v2.0.9
|
||||
env:
|
||||
|
||||
10
.github/file-paths.yaml
vendored
@@ -7,6 +7,10 @@ default: &default
|
||||
- "locales/**"
|
||||
- "bin/**/!(*.md)"
|
||||
|
||||
actionlint:
|
||||
- ".github/workflows/*.yml"
|
||||
- ".github/workflows/*.yaml"
|
||||
|
||||
shared_sources: &shared_sources
|
||||
- "src/**/*.cljc"
|
||||
- "src/**/*.cljs"
|
||||
@@ -95,6 +99,12 @@ frontend_loki_infra: &frontend_loki_infra
|
||||
- *shared_ci
|
||||
- *frontend_loki_ci
|
||||
|
||||
# e2e harness/support code (commands, support file, runner). Not in the
|
||||
# instrumented bundle, so it maps to no covered module — a change here forces a
|
||||
# full e2e run (see create-test-plan / e2eInfraTouched).
|
||||
e2e_infra: &e2e_infra
|
||||
- *shared_e2e
|
||||
|
||||
backend_ci: &backend_ci
|
||||
- *shared_ci
|
||||
- ".github/actions/test-driver/**"
|
||||
|
||||
15
.github/scripts/affected-modules.ts
vendored
@@ -1,3 +1,5 @@
|
||||
import micromatch from "micromatch";
|
||||
|
||||
export type ModuleDef = { type: string; pattern: string };
|
||||
export type Rule = { from: string[]; allow: string[] };
|
||||
|
||||
@@ -5,21 +7,12 @@ export type ModuleNode = { type: string; regex: RegExp };
|
||||
|
||||
export type FileDependency = { source: string; dependencies: string[] };
|
||||
|
||||
/**
|
||||
* Compiles a glob pattern to an anchored RegExp.
|
||||
*/
|
||||
function globToRegex(glob: string): RegExp {
|
||||
const escapeSegment = (seg: string) =>
|
||||
seg.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*");
|
||||
const body = glob.split("**").map(escapeSegment).join(".*");
|
||||
return new RegExp(`^${body}$`);
|
||||
}
|
||||
|
||||
/** First match wins, so element order matters for nested patterns. */
|
||||
export function buildNodes(elements: ModuleDef[]): ModuleNode[] {
|
||||
return elements.map((el) => ({
|
||||
type: el.type,
|
||||
regex: globToRegex(el.pattern),
|
||||
// dot:true so files in dot-directories (e.g. .storybook) still match.
|
||||
regex: micromatch.makeRe(el.pattern, { dot: true }),
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
111
.github/scripts/affected-tests.ts
vendored
@@ -1,5 +1,7 @@
|
||||
// Builds the affected-tests plan from raw inputs: computes the rules and usage
|
||||
// affected-module sets and selects, per suite, which specs to run under each.
|
||||
import { getFeatureModules } from "../../frontend/lint/module-boundaries.mjs";
|
||||
|
||||
import {
|
||||
type FileDependency,
|
||||
type ModuleDef,
|
||||
@@ -14,7 +16,13 @@ import {
|
||||
|
||||
export type TestPlanStats = {
|
||||
fe_files_changed: number;
|
||||
fe_files_total: number;
|
||||
be_files_changed: number;
|
||||
be_files_total: number;
|
||||
unit_infra_touched: boolean;
|
||||
loki_infra_touched: boolean;
|
||||
shared_sources_touched: boolean;
|
||||
fe_modules_total: number;
|
||||
fe_modules_changed: number;
|
||||
fe_modules_affected_rules: number;
|
||||
fe_modules_affected_usage: number;
|
||||
@@ -43,11 +51,15 @@ export type CreateTestPlanInput = {
|
||||
// Parsed dependency-cruiser edges, or null to fall back to the rules graph.
|
||||
fileDependencies: FileDependency[] | null;
|
||||
testFilesBySuite: { unit: string[]; loki: string[]; e2e: string[] };
|
||||
e2eSpecFiles: Record<string, string[]> | null;
|
||||
unitInfraTouched: boolean;
|
||||
lokiInfraTouched: boolean;
|
||||
e2eInfraTouched: boolean;
|
||||
sharedSourcesTouched: boolean;
|
||||
feFilesChanged: number;
|
||||
beFilesChanged: number;
|
||||
feFilesTotal: number;
|
||||
beFilesTotal: number;
|
||||
};
|
||||
|
||||
// Tests whose owning module is affected.
|
||||
@@ -62,17 +74,73 @@ export function filterAffectedTests(
|
||||
});
|
||||
}
|
||||
|
||||
// Collapse each spec's covered files to the *feature* modules it exercises,
|
||||
// once, for reuse across the rules/usage selects. Non-feature tiers
|
||||
// (shared/lib/...) are intentionally dropped: the manifest is a stale nightly
|
||||
// artifact, so its coupling to the module graph stays minimal ("does this spec
|
||||
// touch feature X?"). Accuracy comes from the fresh `affected` set, which
|
||||
// already expands a shared/infra change into the feature modules that depend on
|
||||
// it.
|
||||
export function specFeatureModules(
|
||||
nodes: ModuleNode[],
|
||||
featureModules: Set<string>,
|
||||
specFiles: Record<string, string[]>,
|
||||
): Map<string, Set<string>> {
|
||||
const moduleCache = new Map<string, string | null>();
|
||||
const moduleOf = (file: string): string | null => {
|
||||
let module = moduleCache.get(file);
|
||||
if (module === undefined) {
|
||||
module = mapFileToModule(nodes, file);
|
||||
moduleCache.set(file, module);
|
||||
}
|
||||
return module;
|
||||
};
|
||||
|
||||
const result = new Map<string, Set<string>>();
|
||||
for (const [spec, files] of Object.entries(specFiles)) {
|
||||
const features = new Set<string>();
|
||||
for (const file of files) {
|
||||
const module = moduleOf(file);
|
||||
if (module !== null && featureModules.has(module)) {
|
||||
features.add(module);
|
||||
}
|
||||
}
|
||||
result.set(spec, features);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// A spec runs if any feature module it covers is affected.
|
||||
// A spec doesn't map to any feature module always runs because its scope is unknown
|
||||
export function filterAffectedE2eSpecs(
|
||||
specFeatures: Map<string, Set<string>>,
|
||||
affected: Set<string>,
|
||||
e2eSpecs: string[],
|
||||
): string[] {
|
||||
return e2eSpecs.filter((spec) => {
|
||||
const features = specFeatures.get(spec);
|
||||
if (!features || features.size === 0) {
|
||||
return true;
|
||||
}
|
||||
return [...features].some((module) => affected.has(module));
|
||||
});
|
||||
}
|
||||
|
||||
export function createTestPlan({
|
||||
elements,
|
||||
rules,
|
||||
changedFiles,
|
||||
fileDependencies,
|
||||
testFilesBySuite,
|
||||
e2eSpecFiles,
|
||||
unitInfraTouched,
|
||||
lokiInfraTouched,
|
||||
e2eInfraTouched,
|
||||
sharedSourcesTouched,
|
||||
feFilesChanged,
|
||||
beFilesChanged,
|
||||
feFilesTotal,
|
||||
beFilesTotal,
|
||||
}: CreateTestPlanInput): TestPlan {
|
||||
const rulesGraph = buildModuleGraph(elements, rules);
|
||||
const usageGraph = fileDependencies
|
||||
@@ -80,14 +148,29 @@ export function createTestPlan({
|
||||
: rulesGraph;
|
||||
|
||||
const nodes = rulesGraph.nodes;
|
||||
// Distinct module types (an element type can span several patterns).
|
||||
const totalModules = new Set(elements.map((el) => el.type)).size;
|
||||
const changedModules = getChangedModules(nodes, changedFiles);
|
||||
const rulesAffected = getAffectedModules(rulesGraph, changedFiles);
|
||||
const usageAffected = getAffectedModules(usageGraph, changedFiles);
|
||||
|
||||
// The coarse "feature" tier is the only set the e2e manifest is ever
|
||||
// collapsed to (see filterAffectedE2eSpecs).
|
||||
const featureModules = new Set(getFeatureModules(elements));
|
||||
|
||||
// cljc/cljs compile into the FE bundle, so they force a full run that module
|
||||
// selection can't narrow — same effect as a suite's own infra changing.
|
||||
const unitForceAll = unitInfraTouched || sharedSourcesTouched;
|
||||
const lokiForceAll = lokiInfraTouched || sharedSourcesTouched;
|
||||
// e2e is integration-level, so anything the FE-coverage manifest can't see
|
||||
// forces a full run: cljc/cljs in the bundle (sharedSourcesTouched), a backend
|
||||
// change that can break the UI (beFilesChanged), an e2e harness/support change
|
||||
// (e2eInfraTouched), or no manifest at all.
|
||||
const e2eForceAll =
|
||||
sharedSourcesTouched ||
|
||||
e2eInfraTouched ||
|
||||
beFilesChanged > 0 ||
|
||||
e2eSpecFiles === null;
|
||||
|
||||
const select = (forceAll: boolean, affected: Set<string>, files: string[]) =>
|
||||
forceAll ? files : filterAffectedTests(nodes, affected, files);
|
||||
@@ -97,14 +180,36 @@ export function createTestPlan({
|
||||
const unitUsage = select(unitForceAll, usageAffected, unit);
|
||||
const lokiRules = select(lokiForceAll, rulesAffected, loki);
|
||||
const lokiUsage = select(lokiForceAll, usageAffected, loki);
|
||||
// No e2e-spec -> module mapping exists yet, so e2e always runs in full.
|
||||
const e2eRules = e2e;
|
||||
const e2eUsage = e2e;
|
||||
|
||||
// Precompute spec -> feature modules once (null when e2e runs in full).
|
||||
const specFeatures =
|
||||
e2eForceAll || e2eSpecFiles === null
|
||||
? null
|
||||
: specFeatureModules(nodes, featureModules, e2eSpecFiles);
|
||||
// A spec that was itself edited always runs, even when no app module changed.
|
||||
const changedSet = new Set(changedFiles);
|
||||
const selectE2e = (affected: Set<string>): string[] => {
|
||||
if (specFeatures === null) {
|
||||
return e2e;
|
||||
}
|
||||
const narrowed = new Set(
|
||||
filterAffectedE2eSpecs(specFeatures, affected, e2e),
|
||||
);
|
||||
return e2e.filter((spec) => narrowed.has(spec) || changedSet.has(spec));
|
||||
};
|
||||
const e2eRules = selectE2e(rulesAffected);
|
||||
const e2eUsage = selectE2e(usageAffected);
|
||||
|
||||
return {
|
||||
stats: {
|
||||
fe_files_changed: feFilesChanged,
|
||||
fe_files_total: feFilesTotal,
|
||||
be_files_changed: beFilesChanged,
|
||||
be_files_total: beFilesTotal,
|
||||
unit_infra_touched: unitInfraTouched,
|
||||
loki_infra_touched: lokiInfraTouched,
|
||||
shared_sources_touched: sharedSourcesTouched,
|
||||
fe_modules_total: totalModules,
|
||||
fe_modules_changed: changedModules.size,
|
||||
fe_modules_affected_rules: rulesAffected.size,
|
||||
fe_modules_affected_usage: usageAffected.size,
|
||||
|
||||
119
.github/scripts/affected-tests.unit.spec.ts
vendored
@@ -28,11 +28,15 @@ const baseInput = {
|
||||
rules: RULES,
|
||||
fileDependencies: null,
|
||||
testFilesBySuite: { unit: UNIT_FILES, loki: LOKI_FILES, e2e: E2E_FILES },
|
||||
e2eSpecFiles: null,
|
||||
unitInfraTouched: false,
|
||||
lokiInfraTouched: false,
|
||||
e2eInfraTouched: false,
|
||||
sharedSourcesTouched: false,
|
||||
feFilesChanged: 0,
|
||||
beFilesChanged: 0,
|
||||
feFilesTotal: 0,
|
||||
beFilesTotal: 0,
|
||||
};
|
||||
|
||||
describe("createTestPlan", () => {
|
||||
@@ -93,7 +97,7 @@ describe("createTestPlan", () => {
|
||||
expect(plan.stats.loki_stories_to_run_rules).toBe(0);
|
||||
});
|
||||
|
||||
it("always runs the full e2e suite", () => {
|
||||
it("runs the full e2e suite when no coverage manifest is available", () => {
|
||||
const plan = createTestPlan({ ...baseInput, changedFiles: ["src/foo/x.ts"] });
|
||||
|
||||
expect(plan.stats.e2e_specs_to_run_rules).toBe(E2E_FILES.length);
|
||||
@@ -101,6 +105,119 @@ describe("createTestPlan", () => {
|
||||
expect(plan.e2e_specs_to_run).toEqual(E2E_FILES);
|
||||
});
|
||||
|
||||
it("narrows e2e to specs covering affected feature modules", () => {
|
||||
const e2e = [
|
||||
"e2e/test/scenarios/foo.cy.spec.ts",
|
||||
"e2e/test/scenarios/bar.cy.spec.ts",
|
||||
"e2e/test/scenarios/new.cy.spec.ts",
|
||||
];
|
||||
const plan = createTestPlan({
|
||||
...baseInput,
|
||||
changedFiles: ["src/foo/x.ts"], // affects feature/foo only
|
||||
testFilesBySuite: { unit: UNIT_FILES, loki: LOKI_FILES, e2e },
|
||||
e2eSpecFiles: {
|
||||
"e2e/test/scenarios/foo.cy.spec.ts": ["src/foo/a.ts"], // feature/foo
|
||||
"e2e/test/scenarios/bar.cy.spec.ts": ["src/bar/b.ts"], // feature/bar
|
||||
// new.cy.spec.ts intentionally absent from the manifest
|
||||
},
|
||||
});
|
||||
|
||||
// foo's feature module is affected; bar's is not; the unmapped new spec
|
||||
// always runs.
|
||||
expect(plan.e2e_specs_to_run.sort()).toEqual([
|
||||
"e2e/test/scenarios/foo.cy.spec.ts",
|
||||
"e2e/test/scenarios/new.cy.spec.ts",
|
||||
]);
|
||||
expect(plan.stats.e2e_specs_all).toBe(3);
|
||||
});
|
||||
|
||||
it("runs a spec that maps to no feature module (unknown scope)", () => {
|
||||
const e2e = ["e2e/test/scenarios/lib-only.cy.spec.ts"];
|
||||
const plan = createTestPlan({
|
||||
...baseInput,
|
||||
changedFiles: ["src/foo/x.ts"], // affects feature/foo, NOT lib/utils
|
||||
testFilesBySuite: { unit: UNIT_FILES, loki: LOKI_FILES, e2e },
|
||||
e2eSpecFiles: {
|
||||
// Covers only a non-feature module (e.g. a home/auth spec whose feature
|
||||
// the baseline subtracted), so its feature scope is unknown.
|
||||
"e2e/test/scenarios/lib-only.cy.spec.ts": ["src/utils/colors.ts"],
|
||||
},
|
||||
});
|
||||
|
||||
// No feature module to gate on -> can't bound scope -> always runs.
|
||||
expect(plan.e2e_specs_to_run).toEqual(e2e);
|
||||
});
|
||||
|
||||
it("force-runs all e2e on a backend change (invisible to FE coverage)", () => {
|
||||
const e2e = ["e2e/test/scenarios/foo.cy.spec.ts"];
|
||||
const plan = createTestPlan({
|
||||
...baseInput,
|
||||
changedFiles: ["src/metabase/api/card.clj"],
|
||||
beFilesChanged: 1,
|
||||
testFilesBySuite: { unit: UNIT_FILES, loki: LOKI_FILES, e2e },
|
||||
e2eSpecFiles: { "e2e/test/scenarios/bar.cy.spec.ts": ["src/bar/b.ts"] },
|
||||
});
|
||||
|
||||
expect(plan.e2e_specs_to_run).toEqual(e2e);
|
||||
});
|
||||
|
||||
it("force-runs all e2e when e2e infra (support/runner) changes", () => {
|
||||
const e2e = ["e2e/test/scenarios/foo.cy.spec.ts"];
|
||||
const plan = createTestPlan({
|
||||
...baseInput,
|
||||
changedFiles: ["e2e/support/commands/api/createQuestion.js"],
|
||||
e2eInfraTouched: true,
|
||||
testFilesBySuite: { unit: UNIT_FILES, loki: LOKI_FILES, e2e },
|
||||
e2eSpecFiles: { "e2e/test/scenarios/bar.cy.spec.ts": ["src/bar/b.ts"] },
|
||||
});
|
||||
|
||||
expect(plan.e2e_specs_to_run).toEqual(e2e);
|
||||
});
|
||||
|
||||
it("runs an edited spec even when no app module changed", () => {
|
||||
const spec = "e2e/test/scenarios/foo.cy.spec.ts";
|
||||
const e2e = [spec, "e2e/test/scenarios/bar.cy.spec.ts"];
|
||||
const plan = createTestPlan({
|
||||
...baseInput,
|
||||
changedFiles: [spec], // only the spec itself changed
|
||||
testFilesBySuite: { unit: UNIT_FILES, loki: LOKI_FILES, e2e },
|
||||
e2eSpecFiles: {
|
||||
[spec]: ["src/bar/b.ts"], // covers feature/bar, which is NOT affected
|
||||
"e2e/test/scenarios/bar.cy.spec.ts": ["src/bar/b.ts"],
|
||||
},
|
||||
});
|
||||
|
||||
// No module is affected, but the edited spec must still run.
|
||||
expect(plan.e2e_specs_to_run).toEqual([spec]);
|
||||
});
|
||||
|
||||
it("runs a spec whose coverage was fully baseline-subtracted (empty files)", () => {
|
||||
const e2e = ["e2e/test/scenarios/empty.cy.spec.ts"];
|
||||
const plan = createTestPlan({
|
||||
...baseInput,
|
||||
changedFiles: ["src/foo/x.ts"], // affects feature/foo
|
||||
testFilesBySuite: { unit: UNIT_FILES, loki: LOKI_FILES, e2e },
|
||||
e2eSpecFiles: { "e2e/test/scenarios/empty.cy.spec.ts": [] },
|
||||
});
|
||||
|
||||
// Empty coverage means unknown scope, so the spec runs.
|
||||
expect(plan.e2e_specs_to_run).toEqual(e2e);
|
||||
});
|
||||
|
||||
it("forces a full e2e run when shared (cljc) sources change", () => {
|
||||
const e2e = ["e2e/test/scenarios/foo.cy.spec.ts"];
|
||||
const plan = createTestPlan({
|
||||
...baseInput,
|
||||
changedFiles: ["src/foo/x.ts"],
|
||||
sharedSourcesTouched: true,
|
||||
testFilesBySuite: { unit: UNIT_FILES, loki: LOKI_FILES, e2e },
|
||||
e2eSpecFiles: { "e2e/test/scenarios/bar.cy.spec.ts": ["src/bar/b.ts"] },
|
||||
});
|
||||
|
||||
// Manifest is present but cljc is invisible to JS coverage, so run all.
|
||||
expect(plan.e2e_specs_to_run).toEqual(e2e);
|
||||
});
|
||||
|
||||
it("passes the FE/BE file counts through to stats", () => {
|
||||
const plan = createTestPlan({
|
||||
...baseInput,
|
||||
|
||||
152
.github/scripts/bundle-size-stats-prepare.js
vendored
Normal file
@@ -0,0 +1,152 @@
|
||||
/* eslint-disable import/no-commonjs */
|
||||
// Prepares the rows the bundle-size stats logger uploads to stats.metabase.com.
|
||||
//
|
||||
// For each measured (bundle, kind) it computes the delta — raw, gzipped and
|
||||
// brotli (as-served) bytes plus percent — against the previously *plotted* data
|
||||
// point (restored from a rolling cache), so the deltas can be charted directly.
|
||||
// It also decides whether this commit is worth recording at all: a point is kept
|
||||
// when the as-served size moved by at least MIN_DELTA_PERCENT, or when a new
|
||||
// bundle/kind appears. Only master builds run through here — releases form
|
||||
// non-linear lines whose deltas are meaningless, so they're not recorded (their
|
||||
// historical sizes are backfilled separately).
|
||||
//
|
||||
// buildStatsRows is pure (data in, data out) so it can be unit-tested; main()
|
||||
// is the thin I/O wrapper that reads env/files and writes the rows, cache and
|
||||
// step outputs.
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const deltaPercent = (current, base) =>
|
||||
base ? Math.round(((current - base) * 10000) / base) / 100 : "";
|
||||
|
||||
/**
|
||||
* Turn the current measurements + the last plotted point into upload rows, the
|
||||
* slim cache rows for the next diff, and the keep/skip decision.
|
||||
*
|
||||
* The significance threshold tracks the as-served size: brotli where the build
|
||||
* ships a precompressed .br, otherwise gzip. Current measurements always carry
|
||||
* brotliBytes (estimated when no .br shipped), but a cached base point recorded
|
||||
* before brotli logging may not, so the delta still falls back to gzip per row.
|
||||
*/
|
||||
function buildStatsRows({ measurements, previous, threshold, date, commit, commitMessage, version }) {
|
||||
const previousOf = (bundle, kind) => previous.find(row => row.bundle === bundle && row.kind === kind);
|
||||
|
||||
let maxServedDeltaPercent = 0;
|
||||
let hasNewSeries = false;
|
||||
|
||||
const rows = measurements.map(measurement => {
|
||||
const base = previousOf(measurement.bundle, measurement.kind);
|
||||
const hasBrotli = measurement.brotliBytes != null && base?.brotliBytes != null;
|
||||
const gzipDeltaPercent = deltaPercent(measurement.gzipBytes, base?.gzipBytes);
|
||||
const brotliDeltaPercent = hasBrotli ? deltaPercent(measurement.brotliBytes, base.brotliBytes) : "";
|
||||
if (!base) {
|
||||
hasNewSeries = true;
|
||||
} else {
|
||||
const servedDeltaPercent = hasBrotli ? brotliDeltaPercent : gzipDeltaPercent;
|
||||
maxServedDeltaPercent = Math.max(maxServedDeltaPercent, Math.abs(servedDeltaPercent));
|
||||
}
|
||||
return {
|
||||
"Date": date,
|
||||
"Version": version,
|
||||
"Commit": commit,
|
||||
"Bundle": measurement.bundle,
|
||||
"Kind": measurement.kind, // "initial" or "total"
|
||||
// The stats table carries a free-text Description column; append-csv
|
||||
// requires every table column to be present. Populate it with the commit
|
||||
// subject so the chart's points are self-describing.
|
||||
"Description": commitMessage,
|
||||
"Raw bytes": measurement.rawBytes,
|
||||
"Gzip bytes": measurement.gzipBytes,
|
||||
"Brotli bytes": measurement.brotliBytes, // as-served (shipped .br, else estimated)
|
||||
"File count": measurement.fileCount,
|
||||
"Raw bytes delta": base ? measurement.rawBytes - base.rawBytes : "",
|
||||
"Gzip bytes delta": base ? measurement.gzipBytes - base.gzipBytes : "",
|
||||
"Brotli bytes delta": hasBrotli ? measurement.brotliBytes - base.brotliBytes : "",
|
||||
"Raw delta %": deltaPercent(measurement.rawBytes, base?.rawBytes),
|
||||
"Gzip delta %": gzipDeltaPercent,
|
||||
"Brotli delta %": brotliDeltaPercent,
|
||||
};
|
||||
});
|
||||
|
||||
const firstPoint = previous.length === 0;
|
||||
const significant = firstPoint || hasNewSeries || maxServedDeltaPercent >= threshold;
|
||||
|
||||
// Slim rows the next run diffs against. The workflow only persists this to the
|
||||
// cache when we actually push, so the cached reference always stays the last
|
||||
// *plotted* point (cumulative drift is caught).
|
||||
const cacheRows = measurements.map(({ bundle, kind, rawBytes, gzipBytes, brotliBytes }) => ({
|
||||
bundle,
|
||||
kind,
|
||||
rawBytes,
|
||||
gzipBytes,
|
||||
brotliBytes,
|
||||
}));
|
||||
|
||||
const reason = firstPoint
|
||||
? "first point"
|
||||
: hasNewSeries
|
||||
? "new bundle/kind series"
|
||||
: `max served Δ ${maxServedDeltaPercent.toFixed(2)}% (threshold ${threshold}%)`;
|
||||
|
||||
return { rows, cacheRows, significant, reason, maxServedDeltaPercent, firstPoint, hasNewSeries };
|
||||
}
|
||||
|
||||
module.exports = { buildStatsRows };
|
||||
|
||||
const readJson = filePath => (filePath && fs.existsSync(filePath) ? JSON.parse(fs.readFileSync(filePath, "utf8")) : null);
|
||||
const writeJson = (filePath, value) => {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify(value));
|
||||
};
|
||||
const setOutput = (env, name, value) => {
|
||||
if (env.GITHUB_OUTPUT) {
|
||||
fs.appendFileSync(env.GITHUB_OUTPUT, `${name}=${value}\n`);
|
||||
}
|
||||
};
|
||||
|
||||
// version.properties (extracted from the uberjar) carries the build's tag.
|
||||
// Non-release (master) builds report tag=vUNKNOWN, so leave Version empty there.
|
||||
function readVersion(versionPropsPath) {
|
||||
const raw =
|
||||
versionPropsPath && fs.existsSync(versionPropsPath)
|
||||
? (fs.readFileSync(versionPropsPath, "utf8").match(/^tag=(.*)$/m)?.[1]?.trim() ?? "")
|
||||
: "";
|
||||
return raw === "vUNKNOWN" ? "" : raw;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const env = process.env;
|
||||
|
||||
const measurements = readJson(env.CURRENT);
|
||||
if (!Array.isArray(measurements) || measurements.length === 0) {
|
||||
console.error(`::error::No bundle measurements found at ${env.CURRENT}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const result = buildStatsRows({
|
||||
measurements,
|
||||
// The last plotted point, as slim rows: [{ bundle, kind, rawBytes, gzipBytes, brotliBytes }].
|
||||
previous: readJson(env.LAST) || [],
|
||||
threshold: Number(env.MIN_DELTA_PERCENT ?? 1),
|
||||
date: new Date().toISOString().slice(0, 10), // YYYY-MM-DD
|
||||
commit: (env.HEAD_SHA || "").slice(0, 12),
|
||||
commitMessage: (env.COMMIT_MESSAGE || "").split("\n")[0], // subject line only
|
||||
version: readVersion(env.VERSION_PROPS),
|
||||
});
|
||||
|
||||
writeJson(env.ROWS_OUT, result.rows);
|
||||
writeJson(env.CACHE_OUT, result.cacheRows);
|
||||
|
||||
console.log(`${result.significant ? "RECORD" : "SKIP"} — ${result.reason}`);
|
||||
|
||||
setOutput(env, "significant", result.significant ? "true" : "false");
|
||||
setOutput(
|
||||
env,
|
||||
"max_delta_percent",
|
||||
result.firstPoint || result.hasNewSeries ? "" : result.maxServedDeltaPercent.toFixed(2),
|
||||
);
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
87
.github/scripts/bundle-size-stats-prepare.unit.spec.js
vendored
Normal file
@@ -0,0 +1,87 @@
|
||||
const { buildStatsRows } = require("./bundle-size-stats-prepare");
|
||||
|
||||
const meta = { date: "2026-06-30", commit: "abc123", commitMessage: "Fix the thing (#123)", version: "" };
|
||||
const measurement = (over = {}) => ({
|
||||
bundle: "embedding-sdk-chunked",
|
||||
kind: "total",
|
||||
rawBytes: 1000,
|
||||
gzipBytes: 400,
|
||||
brotliBytes: 300,
|
||||
fileCount: 3,
|
||||
...over,
|
||||
});
|
||||
|
||||
describe("buildStatsRows", () => {
|
||||
it("records the first point and leaves every delta empty", () => {
|
||||
const result = buildStatsRows({
|
||||
...meta,
|
||||
measurements: [measurement()],
|
||||
previous: [],
|
||||
threshold: 1,
|
||||
});
|
||||
|
||||
expect(result.firstPoint).toBe(true);
|
||||
expect(result.significant).toBe(true);
|
||||
expect(result.reason).toBe("first point");
|
||||
expect(result.rows[0]["Gzip bytes delta"]).toBe("");
|
||||
expect(result.rows[0]["Brotli delta %"]).toBe("");
|
||||
expect(result.rows[0]["Description"]).toBe("Fix the thing (#123)");
|
||||
expect(result.cacheRows[0]).toEqual({
|
||||
bundle: "embedding-sdk-chunked",
|
||||
kind: "total",
|
||||
rawBytes: 1000,
|
||||
gzipBytes: 400,
|
||||
brotliBytes: 300,
|
||||
});
|
||||
});
|
||||
|
||||
it("records a never-seen bundle/kind as a new series", () => {
|
||||
const result = buildStatsRows({
|
||||
...meta,
|
||||
measurements: [measurement({ bundle: "app", kind: "initial" })],
|
||||
previous: [{ bundle: "app", kind: "total", rawBytes: 1, gzipBytes: 1, brotliBytes: 1 }],
|
||||
threshold: 99,
|
||||
});
|
||||
|
||||
expect(result.hasNewSeries).toBe(true);
|
||||
expect(result.significant).toBe(true);
|
||||
expect(result.reason).toBe("new bundle/kind series");
|
||||
});
|
||||
|
||||
it("gates significance on the brotli (as-served) delta when both sides have it", () => {
|
||||
const previous = [{ bundle: "embedding-sdk-chunked", kind: "total", rawBytes: 1000, gzipBytes: 400, brotliBytes: 300 }];
|
||||
|
||||
const big = buildStatsRows({
|
||||
...meta,
|
||||
measurements: [measurement({ brotliBytes: 315, gzipBytes: 400 })], // brotli +5%, gzip 0%
|
||||
previous,
|
||||
threshold: 1,
|
||||
});
|
||||
expect(big.maxServedDeltaPercent).toBeCloseTo(5);
|
||||
expect(big.significant).toBe(true);
|
||||
expect(big.rows[0]["Brotli delta %"]).toBeCloseTo(5);
|
||||
|
||||
const small = buildStatsRows({
|
||||
...meta,
|
||||
measurements: [measurement({ brotliBytes: 301, gzipBytes: 999 })], // brotli +0.33%, gzip ignored
|
||||
previous,
|
||||
threshold: 1,
|
||||
});
|
||||
expect(small.significant).toBe(false);
|
||||
expect(small.reason).toContain("threshold 1%");
|
||||
});
|
||||
|
||||
it("falls back to the gzip delta when the cached base predates brotli logging", () => {
|
||||
const result = buildStatsRows({
|
||||
...meta,
|
||||
measurements: [measurement({ gzipBytes: 440, brotliBytes: 300 })], // gzip +10%
|
||||
previous: [{ bundle: "embedding-sdk-chunked", kind: "total", rawBytes: 1000, gzipBytes: 400 }],
|
||||
threshold: 1,
|
||||
});
|
||||
|
||||
expect(result.maxServedDeltaPercent).toBeCloseTo(10);
|
||||
expect(result.significant).toBe(true);
|
||||
expect(result.rows[0]["Brotli bytes delta"]).toBe(""); // no base brotli to diff against
|
||||
expect(result.rows[0]["Gzip delta %"]).toBeCloseTo(10);
|
||||
});
|
||||
});
|
||||
74
.github/scripts/create-test-plan.ts
vendored
@@ -4,45 +4,36 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
|
||||
import micromatch from "micromatch";
|
||||
|
||||
import { elements, rules } from "../../frontend/lint/module-boundaries.mjs";
|
||||
|
||||
import { type FileDependency, parseCruiseModules } from "./affected-modules";
|
||||
import { createTestPlan } from "./affected-tests";
|
||||
import { listSpecFiles } from "./e2e-spec-globs.mjs";
|
||||
|
||||
const UNIT_ROOTS = ["frontend/src", "enterprise/frontend/src"];
|
||||
const UNIT_GLOBS = [
|
||||
"frontend/src/**/*.unit.spec.js",
|
||||
"frontend/src/**/*.unit.spec.jsx",
|
||||
"frontend/src/**/*.unit.spec.ts",
|
||||
"frontend/src/**/*.unit.spec.tsx",
|
||||
"enterprise/frontend/src/**/*.unit.spec.js",
|
||||
"enterprise/frontend/src/**/*.unit.spec.jsx",
|
||||
"enterprise/frontend/src/**/*.unit.spec.ts",
|
||||
"enterprise/frontend/src/**/*.unit.spec.tsx",
|
||||
"frontend/src/**/*.unit.spec.{js,jsx,ts,tsx}",
|
||||
"enterprise/frontend/src/**/*.unit.spec.{js,jsx,ts,tsx}",
|
||||
];
|
||||
|
||||
const STORY_ROOTS = ["frontend", "enterprise/frontend"];
|
||||
const STORY_GLOBS = [
|
||||
"frontend/**/*.stories.js",
|
||||
"frontend/**/*.stories.jsx",
|
||||
"frontend/**/*.stories.ts",
|
||||
"frontend/**/*.stories.tsx",
|
||||
"enterprise/frontend/**/*.stories.js",
|
||||
"enterprise/frontend/**/*.stories.jsx",
|
||||
"enterprise/frontend/**/*.stories.ts",
|
||||
"enterprise/frontend/**/*.stories.tsx",
|
||||
"frontend/**/*.stories.{js,jsx,ts,tsx}",
|
||||
"enterprise/frontend/**/*.stories.{js,jsx,ts,tsx}",
|
||||
];
|
||||
|
||||
const E2E_GLOBS = [
|
||||
"e2e/test/scenarios/**/*.cy.spec.js",
|
||||
"e2e/test/scenarios/**/*.cy.spec.jsx",
|
||||
"e2e/test/scenarios/**/*.cy.spec.ts",
|
||||
"e2e/test/scenarios/**/*.cy.spec.tsx",
|
||||
];
|
||||
|
||||
function listFiles(globs: string[]): string[] {
|
||||
return execFileSync("git", ["ls-files", "--", ...globs], { encoding: "utf8" })
|
||||
// Returns the tracked files under `roots` that match `globs`. The `dot: true`
|
||||
// option means files inside dot-directories such as `.storybook` are included.
|
||||
function listFiles(roots: string[], globs: string[]): string[] {
|
||||
const tracked = execFileSync("git", ["ls-files", "--", ...roots], {
|
||||
encoding: "utf8",
|
||||
})
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
return micromatch(tracked, globs, { dot: true });
|
||||
}
|
||||
|
||||
// dorny outputs comma-separated lists; CHANGED_FILES is `all_changed_files`.
|
||||
@@ -74,21 +65,48 @@ function readFileDependencies(): FileDependency[] | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Reads the nightly coverage manifest (E2E_SPEC_MANIFEST): { builtAt, specs:
|
||||
// { spec -> files } }. Returns the specs map, or null so e2e falls back to a
|
||||
// full run when the manifest is missing/unparseable.
|
||||
function readE2eSpecFiles(): Record<string, string[]> | null {
|
||||
const path = process.env.E2E_SPEC_MANIFEST;
|
||||
if (path && existsSync(path)) {
|
||||
try {
|
||||
const { specs } = JSON.parse(readFileSync(path, "utf8"));
|
||||
if (specs && typeof specs === "object") {
|
||||
process.stderr.write(`Using e2e coverage manifest from ${path}.\n`);
|
||||
return specs;
|
||||
}
|
||||
} catch (error) {
|
||||
process.stderr.write(
|
||||
`Failed to read ${path}; e2e will run in full: ${error}\n`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
process.stderr.write("No E2E_SPEC_MANIFEST found; e2e will run in full.\n");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const testPlan = createTestPlan({
|
||||
elements,
|
||||
rules,
|
||||
changedFiles: csvToList(process.env.CHANGED_FILES),
|
||||
fileDependencies: readFileDependencies(),
|
||||
testFilesBySuite: {
|
||||
unit: listFiles(UNIT_GLOBS),
|
||||
loki: listFiles(STORY_GLOBS),
|
||||
e2e: listFiles(E2E_GLOBS),
|
||||
unit: listFiles(UNIT_ROOTS, UNIT_GLOBS),
|
||||
loki: listFiles(STORY_ROOTS, STORY_GLOBS),
|
||||
e2e: listSpecFiles(),
|
||||
},
|
||||
e2eSpecFiles: readE2eSpecFiles(),
|
||||
unitInfraTouched: process.env.UNIT_INFRA_TOUCHED === "true",
|
||||
lokiInfraTouched: process.env.LOKI_INFRA_TOUCHED === "true",
|
||||
e2eInfraTouched: process.env.E2E_INFRA_TOUCHED === "true",
|
||||
sharedSourcesTouched: process.env.SHARED_SOURCES_TOUCHED === "true",
|
||||
feFilesChanged: csvToList(process.env.FE_CHANGED_FILES).length,
|
||||
beFilesChanged: csvToList(process.env.BE_CHANGED_FILES).length,
|
||||
feFilesTotal: listFiles(["frontend", "enterprise/frontend"], ["**"]).length,
|
||||
beFilesTotal: listFiles(["src", "enterprise/backend"], ["**"]).length,
|
||||
});
|
||||
|
||||
process.stdout.write(JSON.stringify(testPlan) + "\n");
|
||||
|
||||
80
.github/scripts/csv-to-mb.js
vendored
@@ -19,23 +19,73 @@ function jsonToCsvFormData(jsonData) {
|
||||
return formData;
|
||||
}
|
||||
|
||||
async function uploadCsvToMb({ baseUrl, tableId, jsonData, mode = 'append' }) {
|
||||
const formData = jsonToCsvFormData(jsonData);
|
||||
const operation = mode === 'replace' ? 'replace-csv' : 'append-csv';
|
||||
// Transient failures worth retrying: throttling and server-side errors. A 4xx
|
||||
// (bad request, auth) won't succeed on retry, so those fail fast.
|
||||
const RETRYABLE_STATUS = new Set([429, 500, 502, 503, 504]);
|
||||
|
||||
const response = await fetch(`${baseUrl}/api/table/${tableId}/${operation}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
"x-api-key": process.env.API_KEY
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => '');
|
||||
throw new Error(`Upload failed: ${response.status} ${response.statusText} ${body}`.trim());
|
||||
class UploadError extends Error {
|
||||
constructor(message, { retryable }) {
|
||||
super(message);
|
||||
this.retryable = retryable;
|
||||
}
|
||||
}
|
||||
|
||||
async function attemptUpload(url, jsonData, timeoutMs) {
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
"x-api-key": process.env.API_KEY
|
||||
},
|
||||
// Rebuilt per attempt: a consumed request body can't be reused.
|
||||
body: jsonToCsvFormData(jsonData),
|
||||
// Without this the request can hang indefinitely on a stalled server,
|
||||
// eating the whole job timeout. An abort surfaces below as a retryable
|
||||
// network error, so it retries with backoff instead.
|
||||
signal: AbortSignal.timeout(timeoutMs)
|
||||
});
|
||||
} catch (networkError) {
|
||||
throw new UploadError(networkError.message, { retryable: true });
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const body = await response.text().catch(() => '');
|
||||
throw new UploadError(
|
||||
`Upload failed: ${response.status} ${response.statusText} ${body}`.trim(),
|
||||
{ retryable: RETRYABLE_STATUS.has(response.status) }
|
||||
);
|
||||
}
|
||||
|
||||
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
/**
|
||||
* Uploads jsonData as CSV, retrying transient API errors with exponential
|
||||
* backoff. `retries` is the number of extra attempts after the first (so the
|
||||
* default 2 means up to 3 tries). `timeoutMs` is a per-attempt total deadline
|
||||
* (default 60s) that aborts a stalled request. Throws the last error once
|
||||
* attempts are exhausted or the error is non-retryable.
|
||||
*/
|
||||
async function uploadCsvToMb({ baseUrl, tableId, jsonData, mode = 'append', retries = 2, retryDelayMs = 1000, timeoutMs = 60_000 }) {
|
||||
const operation = mode === 'replace' ? 'replace-csv' : 'append-csv';
|
||||
const url = `${baseUrl}/api/table/${tableId}/${operation}`;
|
||||
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
await attemptUpload(url, jsonData, timeoutMs);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (!error.retryable || attempt >= retries) {
|
||||
throw error;
|
||||
}
|
||||
const delay = retryDelayMs * 2 ** attempt;
|
||||
console.warn(`Upload attempt ${attempt + 1} failed (${error.message}); retrying in ${delay}ms`);
|
||||
await sleep(delay);
|
||||
}
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
module.exports = { uploadCsvToMb };
|
||||
|
||||
50
.github/scripts/csv-to-mb.unit.spec.js
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
const { uploadCsvToMb } = require("./csv-to-mb");
|
||||
|
||||
const ok = () => ({ ok: true });
|
||||
const fail = status => ({ ok: false, status, statusText: "err", text: async () => "" });
|
||||
|
||||
const upload = (over = {}) =>
|
||||
uploadCsvToMb({
|
||||
baseUrl: "https://stats.example.com",
|
||||
tableId: 1,
|
||||
jsonData: [{ a: 1 }],
|
||||
retryDelayMs: 1,
|
||||
...over,
|
||||
});
|
||||
|
||||
describe("uploadCsvToMb retries", () => {
|
||||
afterEach(() => {
|
||||
delete global.fetch;
|
||||
});
|
||||
|
||||
it("retries transient server errors then succeeds", async () => {
|
||||
global.fetch = jest.fn().mockResolvedValueOnce(fail(503)).mockResolvedValueOnce(fail(500)).mockResolvedValueOnce(ok());
|
||||
|
||||
await expect(upload({ retries: 2 })).resolves.toEqual({ success: true });
|
||||
expect(global.fetch).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("retries network errors", async () => {
|
||||
global.fetch = jest
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("ECONNRESET"))
|
||||
.mockResolvedValueOnce(ok());
|
||||
|
||||
await expect(upload({ retries: 2 })).resolves.toEqual({ success: true });
|
||||
expect(global.fetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("gives up after exhausting retries and throws the last error", async () => {
|
||||
global.fetch = jest.fn().mockResolvedValue(fail(500));
|
||||
|
||||
await expect(upload({ retries: 2 })).rejects.toThrow("Upload failed: 500");
|
||||
expect(global.fetch).toHaveBeenCalledTimes(3); // 1 initial + 2 retries
|
||||
});
|
||||
|
||||
it("does not retry a non-transient 4xx", async () => {
|
||||
global.fetch = jest.fn().mockResolvedValue(fail(400));
|
||||
|
||||
await expect(upload({ retries: 2 })).rejects.toThrow("Upload failed: 400");
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
32
.github/scripts/e2e-spec-globs.mjs
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
import micromatch from "micromatch";
|
||||
|
||||
const SPEC_DIR = "e2e/test/scenarios";
|
||||
|
||||
// This is the nightly-only baseline helper that runs in the instrumented pass to
|
||||
// capture boot-time coverage. It is not a product spec, so it is excluded from
|
||||
// the test plan universe and from the manifest backfill reconciliation.
|
||||
export const BASELINE_SPEC = `${SPEC_DIR}/coverage-baseline.cy.spec.js`;
|
||||
|
||||
// This glob set defines the spec "universe". The test planner
|
||||
// (create-test-plan.ts) selects which specs to run from it, and the coverage
|
||||
// manifest builder (build-coverage-manifest.mjs) reconciles against it when
|
||||
// backfilling specs that did not run.
|
||||
export const E2E_SPEC_GLOBS = [`${SPEC_DIR}/**/*.cy.spec.{js,jsx,ts,tsx}`];
|
||||
|
||||
// Returns the tracked e2e specs as repo-relative paths, excluding the baseline
|
||||
// helper.
|
||||
export function listSpecFiles(cwd = process.cwd()) {
|
||||
const tracked = execFileSync("git", ["ls-files", "--", SPEC_DIR], {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
})
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
return micromatch(tracked, E2E_SPEC_GLOBS, {
|
||||
ignore: [BASELINE_SPEC],
|
||||
dot: true,
|
||||
});
|
||||
}
|
||||
323
.github/scripts/measure-bundle-sizes.js
vendored
@@ -1,68 +1,58 @@
|
||||
/* eslint-disable import/no-commonjs */
|
||||
// Measures the front-end bundles emitted into an extracted uberjar tree.
|
||||
//
|
||||
// Every bundle reports one or both of:
|
||||
// - "initial": JS the browser downloads on first paint (the entry chunk plus
|
||||
// its initial/sync split chunks). Never excludes initial chunks.
|
||||
// - "total": all *reachable* JS (initial plus async chunks that are actually
|
||||
// loadable at runtime). Never includes unreachable/never-fetched chunks.
|
||||
// Reachability needs the per-entry asset list from the rspack stats file, so the
|
||||
// app and chunked SDK bundles require their stats file to be present.
|
||||
//
|
||||
// The pure pieces — the asset-name selection (initial vs reachable, runtime
|
||||
// filtering, the async-loadable flag) — are split out as select*Assets so they
|
||||
// can be unit-tested against a stats object. The measure* functions and main()
|
||||
// keep all the filesystem and env I/O, threading an explicit `config`.
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const zlib = require("zlib");
|
||||
|
||||
// MEASURE_ROOT lets callers point at an extracted tree (e.g. the PR bundle-size
|
||||
// check measures a current and a base-ref tree); defaults to cwd for the stats
|
||||
// workflow. MEASURE_BUNDLES restricts which bundles are measured (comma-separated).
|
||||
const baseDir = process.env.MEASURE_ROOT ? path.resolve(process.env.MEASURE_ROOT) : process.cwd();
|
||||
const resolve = (...relativeParts) => path.resolve(baseDir, ...relativeParts);
|
||||
const bundleFilter = process.env.MEASURE_BUNDLES
|
||||
? new Set(process.env.MEASURE_BUNDLES.split(",").map(bundle => bundle.trim()))
|
||||
: null;
|
||||
const isBundleWanted = bundle => !bundleFilter || bundleFilter.has(bundle);
|
||||
|
||||
const APP_DIST = "resources/frontend_client/app/dist";
|
||||
const SDK_ROOT = "resources/frontend_client/app/embedding-sdk";
|
||||
|
||||
/**
|
||||
* Async SDK chunks are reachable now that the runtime publicPath is set, so the
|
||||
* bootstrap can fetch import() chunks on demand. "total" therefore includes the
|
||||
* lazy chunks while "initial" stays the eager first-load set. There's no reliable
|
||||
* way to detect loadability from the build artifacts, so this stays a manual flag:
|
||||
* it was flipped to true alongside the publicPath fix.
|
||||
*/
|
||||
const SDK_ASYNC_CHUNKS_LOADABLE = true;
|
||||
|
||||
// The chunk runtime is inlined into the bootstrap, never sent as a standalone file.
|
||||
const SDK_RUNTIME_RE = /embedding-sdk-chunk-runtime\./;
|
||||
const notRuntime = name => !SDK_RUNTIME_RE.test(name);
|
||||
|
||||
/**
|
||||
* Simple targets measured straight from emitted files.
|
||||
* - `dir`/`file`/`altFile`: what to measure (altFile is a fallback single file).
|
||||
* - `stats`: entrypoint whose initial assets are the "initial" download.
|
||||
* - `optional`: skip (don't fail) when the output is absent.
|
||||
* The hosted SDK is split into embedding-sdk-legacy (the self-contained
|
||||
* monolith) and embedding-sdk-chunked (handled separately below).
|
||||
*/
|
||||
const simpleTargets = [
|
||||
{
|
||||
bundle: "app",
|
||||
dir: "resources/frontend_client/app/dist",
|
||||
stats: { file: "artifacts/stats-main.json", entrypoint: "app-main" },
|
||||
},
|
||||
{
|
||||
bundle: "embedding-sdk-legacy",
|
||||
dir: `${SDK_ROOT}/legacy`,
|
||||
altFile: `${SDK_ROOT}.js`,
|
||||
optional: true,
|
||||
},
|
||||
{
|
||||
bundle: "embedding-sdk-package",
|
||||
dir: "resources/embedding-sdk/dist",
|
||||
optional: true,
|
||||
},
|
||||
{
|
||||
bundle: "embed-js",
|
||||
file: "resources/frontend_client/app/embed.js",
|
||||
},
|
||||
];
|
||||
const assetFiles = (root, names) => names.map(name => path.join(root, name));
|
||||
const unique = files => [...new Set(files)];
|
||||
|
||||
// MEASURE_ROOT lets callers point at an extracted tree (e.g. the PR bundle-size
|
||||
// check measures a current and a base-ref tree); defaults to cwd for the stats
|
||||
// workflow. MEASURE_BUNDLES restricts which bundles are measured (comma-separated).
|
||||
// SDK_ASYNC_CHUNKS_LOADABLE (see selectChunkedSdkAssets) toggles whether async
|
||||
// SDK chunks count toward "total".
|
||||
function buildConfig(env) {
|
||||
const baseDir = env.MEASURE_ROOT ? path.resolve(env.MEASURE_ROOT) : process.cwd();
|
||||
const bundleFilter = env.MEASURE_BUNDLES
|
||||
? new Set(env.MEASURE_BUNDLES.split(",").map(bundle => bundle.trim()))
|
||||
: null;
|
||||
return {
|
||||
resolve: (...relativeParts) => path.resolve(baseDir, ...relativeParts),
|
||||
isBundleWanted: bundle => !bundleFilter || bundleFilter.has(bundle),
|
||||
// Async SDK chunks are reachable once output.publicPath is set so import()
|
||||
// chunks are actually fetched at runtime — which landed in master. Defaults
|
||||
// to true to match master; set SDK_ASYNC_CHUNKS_LOADABLE=false to measure an
|
||||
// older commit built before the publicPath fix (async unreachable, so
|
||||
// "total" collapses to "initial").
|
||||
sdkAsyncChunksLoadable: env.SDK_ASYNC_CHUNKS_LOADABLE !== "false",
|
||||
};
|
||||
}
|
||||
|
||||
function sizeOf(files) {
|
||||
let rawBytes = 0;
|
||||
let gzipBytes = 0;
|
||||
let brotliBytes = 0;
|
||||
let anyBrotli = false;
|
||||
for (const file of files) {
|
||||
const contents = fs.readFileSync(file);
|
||||
rawBytes += contents.length;
|
||||
@@ -70,20 +60,17 @@ function sizeOf(files) {
|
||||
gzipBytes += zlib.gzipSync(contents).length;
|
||||
// brotli: as-served — count the precompressed .br the build ships; files
|
||||
// without one (static-viz, sub-threshold chunks) are served compressed on
|
||||
// the fly, so estimate with brotli. Null when no .br shipped at all.
|
||||
// the fly, so estimate with brotli. Always reported (like gzip).
|
||||
const brotliPath = `${file}.br`;
|
||||
if (fs.existsSync(brotliPath)) {
|
||||
anyBrotli = true;
|
||||
brotliBytes += fs.statSync(brotliPath).size;
|
||||
} else {
|
||||
brotliBytes += zlib.brotliCompressSync(contents).length;
|
||||
}
|
||||
brotliBytes += fs.existsSync(brotliPath)
|
||||
? fs.statSync(brotliPath).size
|
||||
: zlib.brotliCompressSync(contents).length;
|
||||
}
|
||||
return {
|
||||
fileCount: files.length,
|
||||
rawBytes,
|
||||
gzipBytes,
|
||||
brotliBytes: anyBrotli ? brotliBytes : null,
|
||||
brotliBytes,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -102,104 +89,170 @@ function collectJsFiles(absolutePath) {
|
||||
});
|
||||
}
|
||||
|
||||
function readStats(statsFile) {
|
||||
const statsPath = resolve(statsFile);
|
||||
function readStats(config, statsFile) {
|
||||
const statsPath = config.resolve(statsFile);
|
||||
return fs.existsSync(statsPath) ? JSON.parse(fs.readFileSync(statsPath, "utf8")) : null;
|
||||
}
|
||||
|
||||
function entrypointJsAssets(stats, name) {
|
||||
// An entrypoint's .js assets for a given field: "assets" = its initial
|
||||
// (synchronous) load; "reachableAssets" = initial plus everything it can lazily
|
||||
// import (the chunk graph walked at build time in bundle-stats.js).
|
||||
function entrypointJsAssets(stats, name, field = "assets") {
|
||||
const entry = stats && stats.entrypoints && stats.entrypoints[name];
|
||||
if (!entry) {
|
||||
if (!entry || !entry[field]) {
|
||||
return null;
|
||||
}
|
||||
return entry.assets.map(asset => asset.name || asset).filter(name => name.endsWith(".js"));
|
||||
}
|
||||
|
||||
function initialJsFiles({ file: statsFile, entrypoint, root }, joinRoot) {
|
||||
const stats = readStats(statsFile);
|
||||
if (!stats) {
|
||||
console.warn(`Stats file missing (${statsFile}); reporting initial == total.`);
|
||||
return null;
|
||||
}
|
||||
const assets = entrypointJsAssets(stats, entrypoint);
|
||||
if (!assets) {
|
||||
console.warn(`Entrypoint "${entrypoint}" missing in ${statsFile}; reporting initial == total.`);
|
||||
return null;
|
||||
}
|
||||
const base = root ? resolve(root) : joinRoot;
|
||||
return assets.map(name => path.join(base, name));
|
||||
}
|
||||
|
||||
function measureSimpleTarget({ bundle, dir, file, altFile, stats, optional }) {
|
||||
const candidates = [dir, file, altFile].filter(Boolean);
|
||||
const chosen = candidates.find(c => fs.existsSync(resolve(c)));
|
||||
if (!chosen) {
|
||||
if (optional) {
|
||||
console.warn(`Skipping "${bundle}": output not found (${candidates.join(", ")})`);
|
||||
return [];
|
||||
}
|
||||
throw new Error(`Bundle output not found for "${bundle}": ${candidates.join(", ")}`);
|
||||
}
|
||||
|
||||
const root = resolve(chosen);
|
||||
const total = sizeOf(collectJsFiles(root));
|
||||
const initialFiles = stats ? initialJsFiles(stats, root) : null;
|
||||
const initial = initialFiles ? sizeOf(initialFiles) : total;
|
||||
|
||||
return [
|
||||
{ bundle, kind: "initial", ...initial },
|
||||
{ bundle, kind: "total", ...total },
|
||||
];
|
||||
return entry[field].map(asset => asset.name || asset).filter(assetName => assetName.endsWith(".js"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Chunked SDK delivery. "Reachable" = what the bootstrap actually sends: the
|
||||
* bootstrap (with its inlined runtime) + the chunked entry's initial chunks,
|
||||
* minus the standalone runtime file. Async chunks are excluded (initial == total)
|
||||
* unless SDK_ASYNC_CHUNKS_LOADABLE is set, at which point "total" includes them.
|
||||
* Main app = the app-main entrypoint only (app/dist also holds other pages like
|
||||
* app-public, app-embed*, which are not the app). `reachableAssets` is null on
|
||||
* older stats built before the chunk-graph enrichment — the caller then falls
|
||||
* back to sizing the whole app/dist.
|
||||
*/
|
||||
function measureChunkedSdk(statsFile) {
|
||||
const chunksDir = resolve(SDK_ROOT, "chunks");
|
||||
function selectAppAssets(stats) {
|
||||
const initialAssets = entrypointJsAssets(stats, "app-main", "assets");
|
||||
if (!initialAssets) {
|
||||
throw new Error('app: stats-main.json / "app-main" entrypoint missing');
|
||||
}
|
||||
return { initialAssets, reachableAssets: entrypointJsAssets(stats, "app-main", "reachableAssets") };
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy SDK: the "embedding-sdk" entry, a single eager file in legacy/. It still
|
||||
* emits on-demand chunks (jspdf, echarts, leaflet, ...) into the shared chunks/
|
||||
* dir via dynamic import(). initial = the eager legacy file; total = initial plus
|
||||
* its reachable async chunks, but only when those are loadable at runtime
|
||||
* (sdkAsyncChunksLoadable), otherwise total collapses to initial. Returns null
|
||||
* when the stats lack the entry (older layout) so the caller can fall back.
|
||||
*/
|
||||
function selectLegacySdkAssets(stats, { sdkAsyncChunksLoadable }) {
|
||||
const initialNames = entrypointJsAssets(stats, "embedding-sdk", "assets");
|
||||
if (!initialNames) {
|
||||
return null;
|
||||
}
|
||||
const reachableNames = entrypointJsAssets(stats, "embedding-sdk", "reachableAssets") || initialNames;
|
||||
const initial = unique(initialNames).filter(notRuntime);
|
||||
return {
|
||||
initialNames: initial,
|
||||
reachableNames: sdkAsyncChunksLoadable ? unique(reachableNames).filter(notRuntime) : initial,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Chunked SDK delivery. initial = bootstrap (minus its inlined runtime) + the
|
||||
* chunked entry's initial chunks. total = the chunked entry's reachable set
|
||||
* (initial + async children, from the chunk graph). includesAsyncChunks is true
|
||||
* only when the stats carry the reachable chunk graph AND those chunks are
|
||||
* loadable at runtime (sdkAsyncChunksLoadable). A base ref built before the
|
||||
* reachableAssets enrichment has no graph, so its "total" must collapse to
|
||||
* "initial" — the flag lets the comparison avoid measuring a reachable total
|
||||
* against a collapsed one (an apples-to-oranges ~30% phantom).
|
||||
*/
|
||||
function selectChunkedSdkAssets(stats, { sdkAsyncChunksLoadable }) {
|
||||
const chunkedInitial = entrypointJsAssets(stats, "embedding-sdk-chunked", "assets");
|
||||
if (!chunkedInitial) {
|
||||
throw new Error('embedding-sdk-chunked: stats-embedding-sdk.json / "embedding-sdk-chunked" entrypoint missing');
|
||||
}
|
||||
const bootstrapInitial = entrypointJsAssets(stats, "embedding-sdk-bootstrap", "assets") || [];
|
||||
const chunkedReachableAssets = entrypointJsAssets(stats, "embedding-sdk-chunked", "reachableAssets");
|
||||
const chunkedReachable = chunkedReachableAssets || chunkedInitial;
|
||||
const bootstrapReachable = entrypointJsAssets(stats, "embedding-sdk-bootstrap", "reachableAssets") || bootstrapInitial;
|
||||
return {
|
||||
initialNames: unique([...bootstrapInitial, ...chunkedInitial]).filter(notRuntime),
|
||||
reachableNames: unique([...bootstrapReachable, ...chunkedReachable]).filter(notRuntime),
|
||||
includesAsyncChunks: chunkedReachableAssets != null && sdkAsyncChunksLoadable,
|
||||
};
|
||||
}
|
||||
|
||||
function measureApp(config) {
|
||||
const dist = config.resolve(APP_DIST);
|
||||
if (!fs.existsSync(dist)) {
|
||||
throw new Error(`Bundle output not found for "app": ${dist}`);
|
||||
}
|
||||
const { initialAssets, reachableAssets } = selectAppAssets(readStats(config, "artifacts/stats-main.json"));
|
||||
if (!reachableAssets) {
|
||||
console.warn("app: stats has no reachableAssets; app total falls back to the whole app/dist");
|
||||
}
|
||||
const totalFiles = reachableAssets ? assetFiles(dist, reachableAssets) : collectJsFiles(dist);
|
||||
return [
|
||||
{ bundle: "app", kind: "initial", ...sizeOf(assetFiles(dist, initialAssets)) },
|
||||
{ bundle: "app", kind: "total", ...sizeOf(totalFiles) },
|
||||
];
|
||||
}
|
||||
|
||||
function measureLegacySdk(config) {
|
||||
const absRoot = config.resolve(SDK_ROOT);
|
||||
const selected = selectLegacySdkAssets(readStats(config, "artifacts/stats-embedding-sdk.json"), config);
|
||||
if (!selected) {
|
||||
// No stats / older layout: fall back to whatever sits in the legacy dir.
|
||||
const candidates = [`${SDK_ROOT}/legacy`, `${SDK_ROOT}.js`];
|
||||
const chosen = candidates.find(candidate => fs.existsSync(config.resolve(candidate)));
|
||||
if (!chosen) {
|
||||
console.warn(`Skipping "embedding-sdk-legacy": output not found (${candidates.join(", ")})`);
|
||||
return [];
|
||||
}
|
||||
return [{ bundle: "embedding-sdk-legacy", kind: "total", ...sizeOf(collectJsFiles(config.resolve(chosen))) }];
|
||||
}
|
||||
return [
|
||||
{ bundle: "embedding-sdk-legacy", kind: "initial", ...sizeOf(assetFiles(absRoot, selected.initialNames)) },
|
||||
{ bundle: "embedding-sdk-legacy", kind: "total", ...sizeOf(assetFiles(absRoot, selected.reachableNames)) },
|
||||
];
|
||||
}
|
||||
|
||||
function measureChunkedSdk(config) {
|
||||
const chunksDir = config.resolve(SDK_ROOT, "chunks");
|
||||
if (!fs.existsSync(chunksDir)) {
|
||||
// pre-chunked layout (monolith only) — nothing to measure here
|
||||
return [];
|
||||
}
|
||||
|
||||
const absRoot = resolve(SDK_ROOT);
|
||||
const everything = collectJsFiles(chunksDir).filter(f => !SDK_RUNTIME_RE.test(f));
|
||||
|
||||
const stats = readStats(statsFile);
|
||||
const chunked = entrypointJsAssets(stats, "embedding-sdk-chunked");
|
||||
let reachableFiles;
|
||||
if (chunked) {
|
||||
const bootstrap = entrypointJsAssets(stats, "embedding-sdk-bootstrap") || [];
|
||||
const names = [...new Set([...bootstrap, ...chunked])].filter(n => !SDK_RUNTIME_RE.test(n));
|
||||
reachableFiles = names.map(n => path.join(absRoot, n));
|
||||
} else {
|
||||
console.warn(`embedding-sdk-chunked: entrypoint missing in ${statsFile}; counting whole chunks/ dir`);
|
||||
reachableFiles = everything;
|
||||
}
|
||||
|
||||
const initial = sizeOf(reachableFiles);
|
||||
const total = SDK_ASYNC_CHUNKS_LOADABLE ? sizeOf(everything) : sizeOf(reachableFiles);
|
||||
|
||||
const absRoot = config.resolve(SDK_ROOT);
|
||||
const { initialNames, reachableNames, includesAsyncChunks } = selectChunkedSdkAssets(
|
||||
readStats(config, "artifacts/stats-embedding-sdk.json"),
|
||||
config,
|
||||
);
|
||||
const initial = sizeOf(assetFiles(absRoot, initialNames));
|
||||
const total = includesAsyncChunks ? sizeOf(assetFiles(absRoot, reachableNames)) : initial;
|
||||
return [
|
||||
{ bundle: "embedding-sdk-chunked", kind: "initial", ...initial },
|
||||
{ bundle: "embedding-sdk-chunked", kind: "total", ...total },
|
||||
{ bundle: "embedding-sdk-chunked", kind: "total", reachable: includesAsyncChunks, ...total },
|
||||
];
|
||||
}
|
||||
|
||||
function measureBundleSizes() {
|
||||
return [
|
||||
...simpleTargets.filter(target => isBundleWanted(target.bundle)).flatMap(measureSimpleTarget),
|
||||
...(isBundleWanted("embedding-sdk-chunked")
|
||||
? measureChunkedSdk("artifacts/stats-embedding-sdk.json")
|
||||
: []),
|
||||
];
|
||||
// Single-file/dir bundles loaded in one shot — total only.
|
||||
function measureWholeDir(config, bundle, candidates) {
|
||||
const chosen = candidates.find(candidate => fs.existsSync(config.resolve(candidate)));
|
||||
if (!chosen) {
|
||||
console.warn(`Skipping "${bundle}": output not found (${candidates.join(", ")})`);
|
||||
return [];
|
||||
}
|
||||
return [{ bundle, kind: "total", ...sizeOf(collectJsFiles(config.resolve(chosen))) }];
|
||||
}
|
||||
|
||||
module.exports = { measureBundleSizes };
|
||||
const bundles = {
|
||||
app: measureApp,
|
||||
"embedding-sdk-legacy": measureLegacySdk,
|
||||
"embedding-sdk-chunked": measureChunkedSdk,
|
||||
"embedding-sdk-package": config => measureWholeDir(config, "embedding-sdk-package", ["resources/embedding-sdk/dist"]),
|
||||
"embed-js": config => measureWholeDir(config, "embed-js", ["resources/frontend_client/app/embed.js"]),
|
||||
};
|
||||
|
||||
function measureBundleSizes(config) {
|
||||
return Object.entries(bundles)
|
||||
.filter(([bundle]) => config.isBundleWanted(bundle))
|
||||
.flatMap(([, measure]) => measure(config));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
measureBundleSizes,
|
||||
buildConfig,
|
||||
entrypointJsAssets,
|
||||
selectAppAssets,
|
||||
selectLegacySdkAssets,
|
||||
selectChunkedSdkAssets,
|
||||
};
|
||||
|
||||
if (require.main === module) {
|
||||
console.log(JSON.stringify(measureBundleSizes(), null, 2));
|
||||
console.log(JSON.stringify(measureBundleSizes(buildConfig(process.env)), null, 2));
|
||||
}
|
||||
|
||||
87
.github/scripts/measure-bundle-sizes.unit.spec.js
vendored
Normal file
@@ -0,0 +1,87 @@
|
||||
const {
|
||||
entrypointJsAssets,
|
||||
selectAppAssets,
|
||||
selectLegacySdkAssets,
|
||||
selectChunkedSdkAssets,
|
||||
} = require("./measure-bundle-sizes");
|
||||
|
||||
const RUNTIME = "embedding-sdk-chunk-runtime.deadbeef.js";
|
||||
|
||||
const stats = entrypoints => ({ entrypoints });
|
||||
|
||||
describe("entrypointJsAssets", () => {
|
||||
it("returns the .js asset names for a field, accepting strings or {name}", () => {
|
||||
const s = stats({ app: { assets: [{ name: "a.js" }, "b.js", { name: "c.css" }] } });
|
||||
expect(entrypointJsAssets(s, "app", "assets")).toEqual(["a.js", "b.js"]);
|
||||
});
|
||||
|
||||
it("returns null when the entrypoint or field is absent", () => {
|
||||
expect(entrypointJsAssets(stats({}), "missing")).toBeNull();
|
||||
expect(entrypointJsAssets(null, "app")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectAppAssets", () => {
|
||||
it("returns the initial set and a null reachable set on pre-enrichment stats", () => {
|
||||
const s = stats({ "app-main": { assets: ["main.js"] } });
|
||||
expect(selectAppAssets(s)).toEqual({ initialAssets: ["main.js"], reachableAssets: null });
|
||||
});
|
||||
|
||||
it("throws when the app-main entrypoint is missing", () => {
|
||||
expect(() => selectAppAssets(stats({}))).toThrow("app-main");
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectChunkedSdkAssets", () => {
|
||||
const base = {
|
||||
"embedding-sdk-chunked": { assets: ["a.js", RUNTIME], reachableAssets: ["a.js", "b.js", RUNTIME] },
|
||||
"embedding-sdk-bootstrap": { assets: ["boot.js"], reachableAssets: ["boot.js"] },
|
||||
};
|
||||
|
||||
it("merges bootstrap + chunked, drops the inlined runtime, and counts async chunks", () => {
|
||||
const result = selectChunkedSdkAssets(stats(base), { sdkAsyncChunksLoadable: true });
|
||||
expect(result.initialNames).toEqual(["boot.js", "a.js"]);
|
||||
expect(result.reachableNames).toEqual(["boot.js", "a.js", "b.js"]);
|
||||
expect(result.includesAsyncChunks).toBe(true);
|
||||
});
|
||||
|
||||
it("collapses total to initial when async chunks are not loadable", () => {
|
||||
const result = selectChunkedSdkAssets(stats(base), { sdkAsyncChunksLoadable: false });
|
||||
expect(result.includesAsyncChunks).toBe(false);
|
||||
});
|
||||
|
||||
it("collapses total to initial when the stats carry no reachable graph", () => {
|
||||
const noGraph = { "embedding-sdk-chunked": { assets: ["a.js"] } };
|
||||
const result = selectChunkedSdkAssets(stats(noGraph), { sdkAsyncChunksLoadable: true });
|
||||
expect(result.reachableNames).toEqual(["a.js"]);
|
||||
expect(result.includesAsyncChunks).toBe(false);
|
||||
});
|
||||
|
||||
it("throws when the chunked entrypoint is missing", () => {
|
||||
expect(() => selectChunkedSdkAssets(stats({}), { sdkAsyncChunksLoadable: true })).toThrow(
|
||||
"embedding-sdk-chunked",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectLegacySdkAssets", () => {
|
||||
it("returns null when the legacy entry is absent so the caller can fall back", () => {
|
||||
expect(selectLegacySdkAssets(stats({}), { sdkAsyncChunksLoadable: true })).toBeNull();
|
||||
});
|
||||
|
||||
it("expands total to the reachable async set when loadable", () => {
|
||||
const s = stats({ "embedding-sdk": { assets: ["legacy.js"], reachableAssets: ["legacy.js", "chunk.js"] } });
|
||||
expect(selectLegacySdkAssets(s, { sdkAsyncChunksLoadable: true })).toEqual({
|
||||
initialNames: ["legacy.js"],
|
||||
reachableNames: ["legacy.js", "chunk.js"],
|
||||
});
|
||||
});
|
||||
|
||||
it("collapses total to initial when async chunks are not loadable", () => {
|
||||
const s = stats({ "embedding-sdk": { assets: ["legacy.js"], reachableAssets: ["legacy.js", "chunk.js"] } });
|
||||
expect(selectLegacySdkAssets(s, { sdkAsyncChunksLoadable: false })).toEqual({
|
||||
initialNames: ["legacy.js"],
|
||||
reachableNames: ["legacy.js"],
|
||||
});
|
||||
});
|
||||
});
|
||||
94
.github/scripts/resolve-coverage-manifest.mjs
vendored
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Resolves the nightly e2e coverage manifest closest to a commit's point in
|
||||
* history, so the test planner can narrow the e2e run.
|
||||
*
|
||||
* The whole flow lives here so callers "just get a manifest": list the retained
|
||||
* manifest artifacts, find the one whose nightly commit is the nearest ancestor
|
||||
* of `headSha` (via the GitHub compare API — no deep clone), download and unzip
|
||||
* just that one, and return its parsed contents.
|
||||
*
|
||||
* `github` is an authenticated Octokit, as injected by actions/github-script.
|
||||
* Returns { builtAt, specs, path, behindBy } or null when nothing is an
|
||||
* ancestor (caller then runs e2e in full). Never throws: any API/IO failure is
|
||||
* logged and resolves to null, because coverage resolution must not break the
|
||||
* test plan.
|
||||
*/
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
|
||||
const ARTIFACT_NAME = "spec-file-manifest";
|
||||
const EXTRACT_DIR = "e2e/coverage";
|
||||
const MANIFEST_PATH = `${EXTRACT_DIR}/${ARTIFACT_NAME}.json`;
|
||||
|
||||
export async function resolveCoverageManifest({
|
||||
github,
|
||||
owner,
|
||||
repo,
|
||||
headSha,
|
||||
log = console.log,
|
||||
}) {
|
||||
try {
|
||||
// Every retained manifest artifact, newest first. Each carries its
|
||||
// nightly's head_sha — the commit it was built from (== builtAt).
|
||||
const artifacts = await github.paginate(
|
||||
github.rest.actions.listArtifactsForRepo,
|
||||
{ owner, repo, name: ARTIFACT_NAME, per_page: 100 },
|
||||
);
|
||||
const candidates = artifacts
|
||||
.filter((artifact) => !artifact.expired && artifact.workflow_run?.head_sha)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
|
||||
);
|
||||
|
||||
// First candidate that is an ancestor of head is the closest in history
|
||||
// (master is linear, candidates are chronological). compare base...head:
|
||||
// behind_by === 0 ⇒ base is an ancestor of head; ahead_by is the distance.
|
||||
let chosen = null;
|
||||
for (const artifact of candidates) {
|
||||
const base = artifact.workflow_run.head_sha;
|
||||
// A stale candidate (force-pushed/GC'd commit) makes compare 404 — skip
|
||||
// it rather than lose the optimization for everyone.
|
||||
let comparison;
|
||||
try {
|
||||
({ data: comparison } =
|
||||
await github.rest.repos.compareCommitsWithBasehead({
|
||||
owner,
|
||||
repo,
|
||||
basehead: `${base}...${headSha}`,
|
||||
}));
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (comparison.behind_by === 0) {
|
||||
chosen = { artifact, base, behindBy: comparison.ahead_by };
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!chosen) {
|
||||
log("No ancestor coverage manifest found; e2e will run in full.");
|
||||
return null;
|
||||
}
|
||||
|
||||
const download = await github.rest.actions.downloadArtifact({
|
||||
owner,
|
||||
repo,
|
||||
artifact_id: chosen.artifact.id,
|
||||
archive_format: "zip",
|
||||
});
|
||||
fs.writeFileSync(`${ARTIFACT_NAME}.zip`, Buffer.from(download.data));
|
||||
execFileSync("unzip", ["-o", `${ARTIFACT_NAME}.zip`, "-d", EXTRACT_DIR]);
|
||||
|
||||
const manifest = JSON.parse(fs.readFileSync(MANIFEST_PATH, "utf8"));
|
||||
log(
|
||||
`Using coverage manifest ${chosen.base.slice(0, 12)} ` +
|
||||
`(${chosen.behindBy} commits behind HEAD).`,
|
||||
);
|
||||
return { ...manifest, path: MANIFEST_PATH, behindBy: chosen.behindBy };
|
||||
} catch (error) {
|
||||
log(`Coverage manifest resolution failed; e2e will run in full: ${error}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
3
.github/scripts/setup-python-runner.sh
vendored
@@ -19,7 +19,8 @@ while [[ $# -gt 0 ]]; do
|
||||
done
|
||||
|
||||
echo "Cloning python-runner repository..."
|
||||
git clone --depth 1 https://${METABASE_AUTOMATION_USER_TOKEN}@github.com/metabase/python-runner-container.git python-runner
|
||||
git clone --depth 1 https://${METABASE_AUTOMATION_USER_TOKEN}@github.com/metabase/python-runner-container.git --branch seccomp python-runner
|
||||
|
||||
|
||||
if [ "$SKIP_START" = false ]; then
|
||||
echo "Starting python-runner..."
|
||||
|
||||
90
.github/scripts/upload-affected-tests-stats.js
vendored
@@ -5,44 +5,60 @@
|
||||
const { uploadCsvToMb } = require("./csv-to-mb.js");
|
||||
|
||||
// "FE Affected Tests" uploaded table on stats.metabase.com.
|
||||
const TABLE_ID = 79841;
|
||||
const TABLE_ID = 81818;
|
||||
|
||||
const s = JSON.parse(process.env.STATS_JSON);
|
||||
console.log("stats", s);
|
||||
async function main() {
|
||||
// create-test-plan doesn't always produce stats (e.g. on release branches
|
||||
// running an older planner), leaving STATS_JSON empty. Nothing to upload.
|
||||
const raw = (process.env.STATS_JSON || "").trim();
|
||||
if (!raw || raw === "null") {
|
||||
console.log("No STATS_JSON to upload; skipping.");
|
||||
return;
|
||||
}
|
||||
|
||||
const row = {
|
||||
Date: new Date().toISOString(),
|
||||
// "pr_update" or "merge_to_master". ("Trigger" is a reserved SQL word.)
|
||||
"Triggered By": process.env.TRIGGERED_BY,
|
||||
PR: Number(process.env.PR_NUMBER),
|
||||
"Head SHA": process.env.HEAD_SHA,
|
||||
"Base SHA": process.env.BASE_SHA,
|
||||
"FE Files Changed": s.fe_files_changed,
|
||||
"BE Files Changed": s.be_files_changed,
|
||||
"FE Modules Changed": s.fe_modules_changed,
|
||||
"FE Modules Affected (Rules)": s.fe_modules_affected_rules,
|
||||
"FE Modules Affected (Usage)": s.fe_modules_affected_usage,
|
||||
"Unit Specs All": s.unit_specs_all,
|
||||
"Unit Specs To Run (Rules)": s.unit_specs_to_run_rules,
|
||||
"Unit Specs To Run (Usage)": s.unit_specs_to_run_usage,
|
||||
"Loki Stories All": s.loki_stories_all,
|
||||
"Loki Stories To Run (Rules)": s.loki_stories_to_run_rules,
|
||||
"Loki Stories To Run (Usage)": s.loki_stories_to_run_usage,
|
||||
"E2E Specs All": s.e2e_specs_all,
|
||||
"E2E Specs To Run (Rules)": s.e2e_specs_to_run_rules,
|
||||
"E2E Specs To Run (Usage)": s.e2e_specs_to_run_usage,
|
||||
};
|
||||
const s = JSON.parse(raw);
|
||||
console.log("stats", s);
|
||||
|
||||
uploadCsvToMb({
|
||||
baseUrl: "https://stats.metabase.com",
|
||||
tableId: TABLE_ID,
|
||||
jsonData: [row],
|
||||
mode: "append",
|
||||
})
|
||||
.then(() => {
|
||||
console.log("Data uploaded successfully");
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error uploading data:", error);
|
||||
process.exit(1);
|
||||
const row = {
|
||||
Date: new Date().toISOString(),
|
||||
// "pr_update" or "merge_to_master". ("Trigger" is a reserved SQL word.)
|
||||
"Triggered By": process.env.TRIGGERED_BY,
|
||||
PR: Number(process.env.PR_NUMBER),
|
||||
"Head SHA": process.env.HEAD_SHA,
|
||||
"Base SHA": process.env.BASE_SHA,
|
||||
"FE Files Changed": s.fe_files_changed,
|
||||
"FE Files Total": s.fe_files_total,
|
||||
"BE Files Changed": s.be_files_changed,
|
||||
"BE Files Total": s.be_files_total,
|
||||
"Unit Infra Touched": s.unit_infra_touched,
|
||||
"Loki Infra Touched": s.loki_infra_touched,
|
||||
"Shared Sources Touched": s.shared_sources_touched,
|
||||
"FE Modules Total": s.fe_modules_total,
|
||||
"FE Modules Changed": s.fe_modules_changed,
|
||||
"FE Modules Affected (Rules)": s.fe_modules_affected_rules,
|
||||
"FE Modules Affected (Usage)": s.fe_modules_affected_usage,
|
||||
"Unit Specs All": s.unit_specs_all,
|
||||
"Unit Specs To Run (Rules)": s.unit_specs_to_run_rules,
|
||||
"Unit Specs To Run (Usage)": s.unit_specs_to_run_usage,
|
||||
"Loki Stories All": s.loki_stories_all,
|
||||
"Loki Stories To Run (Rules)": s.loki_stories_to_run_rules,
|
||||
"Loki Stories To Run (Usage)": s.loki_stories_to_run_usage,
|
||||
"E2E Specs All": s.e2e_specs_all,
|
||||
"E2E Specs To Run (Rules)": s.e2e_specs_to_run_rules,
|
||||
"E2E Specs To Run (Usage)": s.e2e_specs_to_run_usage,
|
||||
};
|
||||
|
||||
await uploadCsvToMb({
|
||||
baseUrl: "https://stats.metabase.com",
|
||||
tableId: TABLE_ID,
|
||||
jsonData: [row],
|
||||
mode: "append",
|
||||
});
|
||||
console.log("Data uploaded successfully");
|
||||
}
|
||||
|
||||
// Best-effort: never let a telemetry failure turn the job red.
|
||||
main().catch((error) => {
|
||||
console.error("Skipping stats upload after error:", error);
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
6
.github/team.json
vendored
@@ -47,7 +47,8 @@
|
||||
"members": [
|
||||
"fraserdrops",
|
||||
"nemanjaglumac",
|
||||
"romeovs"
|
||||
"romeovs",
|
||||
"ChrisVandoo"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -92,7 +93,8 @@
|
||||
"deniskaber",
|
||||
"andsalves",
|
||||
"kevinkelleher12",
|
||||
"AndreyChernykh"
|
||||
"AndreyChernykh",
|
||||
"bowbahdoe"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
42
.github/workflows/actionlint.yml
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
name: Actionlint
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'master'
|
||||
- 'release-**'
|
||||
paths:
|
||||
- '.github/workflows/**'
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/**'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref && github.ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
actionlint:
|
||||
name: Lint Github Actions Workflows
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Get changed Github actions
|
||||
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
||||
id: changes
|
||||
with:
|
||||
token: ${{ github.token }}
|
||||
filters: .github/file-paths.yaml
|
||||
list-files: shell
|
||||
- name: Setup actionlint
|
||||
id: download
|
||||
shell: bash
|
||||
run: |
|
||||
if command -v actionlint; then
|
||||
echo "executable=actionlint" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
# script sets executable output, for details see: https://github.com/rhysd/actionlint/blob/main/docs/usage.md#use-actionlint-on-github-actions
|
||||
bash <(curl -fsSL https://raw.githubusercontent.com/rhysd/actionlint/v1.7.12/scripts/download-actionlint.bash)
|
||||
fi
|
||||
- name: Run actionlint
|
||||
run: ${{ steps.download.outputs.executable }} --color --verbose ${{ steps.changes.outputs.actionlint_files }}
|
||||
12
.github/workflows/app-db.yml
vendored
@@ -76,7 +76,7 @@ jobs:
|
||||
github.event_name == 'push' &&
|
||||
(
|
||||
github.ref_name == 'master' ||
|
||||
startsWith(github.ref_name, 'release-')
|
||||
startsWith(github.ref_name, 'release-x')
|
||||
) &&
|
||||
''
|
||||
) ||
|
||||
@@ -120,6 +120,7 @@ jobs:
|
||||
test-args: >-
|
||||
${{ matrix.job.test-args }}
|
||||
:exclude-tags '[ ${{ matrix.job.exclude-tag }} ${{ env.__ADDITIONAL_EXCLUDED_TAG__ }}]'
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
@@ -131,6 +132,7 @@ jobs:
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
trunk-api-token: ${{ secrets.TRUNK_API_TOKEN }}
|
||||
previous-step-outcome: ${{ steps.test-driver.outputs.test-outcome }}
|
||||
run-trunk: ${{ vars.QUARANTINE_ENGINE != 'ci-conductor' }}
|
||||
|
||||
be-tests-mysql:
|
||||
if: ${{ !inputs.skip }}
|
||||
@@ -191,7 +193,7 @@ jobs:
|
||||
github.event_name == 'push' &&
|
||||
(
|
||||
github.ref_name == 'master' ||
|
||||
startsWith(github.ref_name, 'release-')
|
||||
startsWith(github.ref_name, 'release-x')
|
||||
) &&
|
||||
''
|
||||
) ||
|
||||
@@ -243,6 +245,7 @@ jobs:
|
||||
test-args: >-
|
||||
${{ matrix.job.test-args }}
|
||||
:exclude-tags '[ ${{ matrix.job.exclude-tag }} ${{ env.__ADDITIONAL_EXCLUDED_TAG__ }}]'
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
@@ -254,6 +257,7 @@ jobs:
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
trunk-api-token: ${{ secrets.TRUNK_API_TOKEN }}
|
||||
previous-step-outcome: ${{ steps.test-driver.outputs.test-outcome }}
|
||||
run-trunk: ${{ vars.QUARANTINE_ENGINE != 'ci-conductor' }}
|
||||
|
||||
be-tests-postgres:
|
||||
if: ${{ !inputs.skip }}
|
||||
@@ -307,7 +311,7 @@ jobs:
|
||||
github.event_name == 'push' &&
|
||||
(
|
||||
github.ref_name == 'master' ||
|
||||
startsWith(github.ref_name, 'release-')
|
||||
startsWith(github.ref_name, 'release-x')
|
||||
) &&
|
||||
''
|
||||
) ||
|
||||
@@ -353,6 +357,7 @@ jobs:
|
||||
test-args: >-
|
||||
${{ matrix.job.test-args }}
|
||||
:exclude-tags '[ ${{ matrix.job.exclude-tag }} ${{ env.__ADDITIONAL_EXCLUDED_TAG__ }}]'
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
@@ -364,6 +369,7 @@ jobs:
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
trunk-api-token: ${{ secrets.TRUNK_API_TOKEN }}
|
||||
previous-step-outcome: ${{ steps.test-driver.outputs.test-outcome }}
|
||||
run-trunk: ${{ vars.QUARANTINE_ENGINE != 'ci-conductor' }}
|
||||
|
||||
app-db-tests-result:
|
||||
needs:
|
||||
|
||||
32
.github/workflows/backend.yml
vendored
@@ -45,7 +45,7 @@ jobs:
|
||||
|
||||
be-linter-eastwood-test:
|
||||
# Master / release safety net: lint every backend test namespace.
|
||||
if: ${{ !inputs.skip && github.event_name == 'push' && (github.ref_name == 'master' || startsWith(github.ref_name, 'release-')) }}
|
||||
if: ${{ !inputs.skip && github.event_name == 'push' && (github.ref_name == 'master' || startsWith(github.ref_name, 'release-x')) }}
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 45
|
||||
name: Eastwood (tests only)
|
||||
@@ -121,7 +121,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
java-version: [21]
|
||||
java-version: [25]
|
||||
job:
|
||||
- name: Enterprise Tests
|
||||
edition: ee
|
||||
@@ -167,7 +167,7 @@ jobs:
|
||||
github.event_name == 'push' &&
|
||||
(
|
||||
github.ref_name == 'master' ||
|
||||
startsWith(github.ref_name, 'release-')
|
||||
startsWith(github.ref_name, 'release-x')
|
||||
) &&
|
||||
''
|
||||
) ||
|
||||
@@ -197,26 +197,31 @@ jobs:
|
||||
clojure -X:dev:ci:test:${{ matrix.job.edition }}:${{ matrix.job.edition }}-dev
|
||||
:exclude-tags '[:mb/driver-tests ${{ env.__ADDITIONAL_EXCLUDED_TAG__ }}]'
|
||||
${{ matrix.job.test-args }}
|
||||
# Trunk Quarantine controls the final state of the run in the CI!
|
||||
# Even if this step fails we let it pass, and then `trunk-io/analytics-uploader` determines
|
||||
# whether to fail or to pass the whole job, based on the quarantine list.
|
||||
# Keep this step continue-on-error because the quarantine logic controls whether it ultimately fails.
|
||||
continue-on-error: true
|
||||
|
||||
- name: Report failed tests to ci-conductor
|
||||
# Positioned between the test run and "Upload Test Results" so it reads
|
||||
# pristine JUnit (before Trunk quarantine processing). Gated to non-clean
|
||||
# runs + continue-on-error so it can never change the job outcome — Trunk
|
||||
# Quarantine remains the sole arbiter. See DEV-2224 / DEV-2235.
|
||||
if: ${{ always() && steps.run-java-tests.outcome != 'success' }}
|
||||
if: ${{ !cancelled() && steps.run-java-tests.outcome == 'failure' }}
|
||||
# This step should NEVER fail the job!
|
||||
continue-on-error: true
|
||||
uses: ./.github/actions/report-failures-to-ci-conductor
|
||||
with:
|
||||
test-suite: be-tests-java-${{ matrix.java-version }}-${{ matrix.job.test-group-name }}
|
||||
adapter: backend
|
||||
|
||||
- name: Quarantine gate
|
||||
if: ${{ !cancelled() && steps.run-java-tests.outcome == 'failure' }}
|
||||
continue-on-error: ${{ vars.QUARANTINE_ENGINE != 'ci-conductor' }}
|
||||
timeout-minutes: 3
|
||||
uses: ./.github/actions/quarantine-gate
|
||||
with:
|
||||
adapter: backend
|
||||
test-suite: be-tests-java-${{ matrix.java-version }}-${{ matrix.job.test-group-name }}
|
||||
enforce-quarantine: ${{ vars.QUARANTINE_ENGINE == 'ci-conductor' }}
|
||||
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always() && (steps.run-java-tests.conclusion != 'skipped' || steps.run-java-tests-21.conclusion != 'skipped')
|
||||
if: always()
|
||||
with:
|
||||
input-path: ./target/junit/
|
||||
output-name: be-tests-java-${{ matrix.java-version }}-${{ matrix.job.edition }}
|
||||
@@ -225,6 +230,7 @@ jobs:
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
trunk-api-token: ${{ secrets.TRUNK_API_TOKEN }}
|
||||
previous-step-outcome: ${{ steps.run-java-tests.outcome }}
|
||||
run-trunk: ${{ vars.QUARANTINE_ENGINE != 'ci-conductor' }}
|
||||
|
||||
- name: Publish Test Report (JUnit)
|
||||
uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0
|
||||
@@ -253,7 +259,7 @@ jobs:
|
||||
timeout-minutes: 10
|
||||
strategy:
|
||||
matrix:
|
||||
java-version: [21]
|
||||
java-version: [25]
|
||||
name: "Check Java ${{ matrix.java-version }}"
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
89
.github/workflows/bundle-size-stats.yml
vendored
@@ -8,6 +8,8 @@ on:
|
||||
workflow_run:
|
||||
workflows: ["Build Uberjar"]
|
||||
types: [completed]
|
||||
# Master only: releases form non-linear lines whose deltas are meaningless,
|
||||
# so their sizes are backfilled separately rather than logged here.
|
||||
branches: [master]
|
||||
|
||||
concurrency:
|
||||
@@ -21,6 +23,10 @@ jobs:
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
# Only record a data point when a bundle's as-served size (brotli where the
|
||||
# build ships .br, else gzip) moved by at least this percent vs the last
|
||||
# plotted point, keeping the chart to real changes.
|
||||
MIN_DELTA_PERCENT: "0.5"
|
||||
steps:
|
||||
- name: Check out the code
|
||||
uses: actions/checkout@v6
|
||||
@@ -78,9 +84,30 @@ jobs:
|
||||
mkdir -p artifacts resources/embedding-sdk
|
||||
if [ -n "$STATS_ZIP" ]; then unzip -o "$STATS_ZIP" -d artifacts; fi
|
||||
if [ -n "$PKG_ZIP" ]; then unzip -o "$PKG_ZIP" -d resources/embedding-sdk; fi
|
||||
- name: Restore last plotted bundle sizes
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: bundle-size-cache
|
||||
key: bundle-size-last-${{ github.run_id }}
|
||||
restore-keys: |
|
||||
bundle-size-last-
|
||||
- name: Measure bundle sizes
|
||||
run: node ./.github/scripts/measure-bundle-sizes.js | tee artifacts/bundle-sizes.json
|
||||
- name: Upload sizes to Stats
|
||||
- name: Compute deltas and decide whether to record this point
|
||||
id: prepare
|
||||
env:
|
||||
CURRENT: artifacts/bundle-sizes.json
|
||||
LAST: bundle-size-cache/last.json
|
||||
CACHE_OUT: bundle-size-cache/last.json
|
||||
ROWS_OUT: artifacts/upload-rows.json
|
||||
VERSION_PROPS: artifacts/version.properties
|
||||
# Passed via env (not interpolated into the shell) so the commit
|
||||
# message can't inject commands; the script keeps only the subject.
|
||||
COMMIT_MESSAGE: ${{ github.event.workflow_run.head_commit.message }}
|
||||
run: node ./.github/scripts/bundle-size-stats-prepare.js
|
||||
- name: Upload sizes (with deltas) to Stats
|
||||
id: upload
|
||||
if: steps.prepare.outputs.significant == 'true'
|
||||
uses: actions/github-script@v9
|
||||
env:
|
||||
API_KEY: ${{ secrets.STATS_API_KEY }}
|
||||
@@ -89,43 +116,33 @@ jobs:
|
||||
const fs = require("fs");
|
||||
const { uploadCsvToMb } = require("${{ github.workspace }}/.github/scripts/csv-to-mb.js");
|
||||
|
||||
const measurements = JSON.parse(
|
||||
fs.readFileSync("${{ github.workspace }}/artifacts/bundle-sizes.json", "utf8"),
|
||||
const data = JSON.parse(
|
||||
fs.readFileSync("${{ github.workspace }}/artifacts/upload-rows.json", "utf8"),
|
||||
);
|
||||
|
||||
const date = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
|
||||
const commit = process.env.HEAD_SHA.slice(0, 12);
|
||||
|
||||
// version.properties (extracted from the uberjar) carries the build's tag.
|
||||
// Non-release (master) builds report tag=vUNKNOWN, so leave Version empty there.
|
||||
const versionProps = "${{ github.workspace }}/artifacts/version.properties";
|
||||
const rawVersion = fs.existsSync(versionProps)
|
||||
? (fs.readFileSync(versionProps, "utf8").match(/^tag=(.*)$/m)?.[1]?.trim() ?? "")
|
||||
: "";
|
||||
const version = rawVersion === "vUNKNOWN" ? "" : rawVersion;
|
||||
|
||||
const data = measurements.map(measurement => ({
|
||||
"Date": date,
|
||||
"Version": version,
|
||||
"Commit": commit,
|
||||
"Bundle": measurement.bundle,
|
||||
"Kind": measurement.kind, // "initial" or "total"
|
||||
"Raw bytes": measurement.rawBytes,
|
||||
"Gzip bytes": measurement.gzipBytes,
|
||||
"Brotli bytes": measurement.brotliBytes ?? "", // as-served; empty when no .br shipped
|
||||
"File count": measurement.fileCount,
|
||||
}));
|
||||
|
||||
console.table(data);
|
||||
|
||||
uploadCsvToMb({
|
||||
baseUrl: "https://stats.metabase.com",
|
||||
tableId: 79417,
|
||||
jsonData: data,
|
||||
mode: "append",
|
||||
}).then(() => {
|
||||
try {
|
||||
await uploadCsvToMb({
|
||||
baseUrl: "https://stats.metabase.com",
|
||||
tableId: 79702,
|
||||
jsonData: data,
|
||||
mode: "append",
|
||||
});
|
||||
console.log("Bundle sizes uploaded successfully");
|
||||
}).catch(error => {
|
||||
console.error("Error uploading bundle sizes:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
core.setOutput("uploaded", "true");
|
||||
} catch (error) {
|
||||
// Stats logging is best-effort: if stats.metabase.com is still
|
||||
// unreachable after the client's retries, keep the run green. The
|
||||
// last-plotted cache is only saved on a successful upload (below),
|
||||
// so this point is re-attempted on the next master build and
|
||||
// nothing is silently dropped.
|
||||
core.warning(`Bundle-size upload failed after retries; leaving run green: ${error.message}`);
|
||||
core.setOutput("uploaded", "false");
|
||||
}
|
||||
- name: Remember this as the last plotted point
|
||||
if: steps.upload.outputs.uploaded == 'true'
|
||||
uses: actions/cache/save@v4
|
||||
with:
|
||||
path: bundle-size-cache
|
||||
key: bundle-size-last-${{ github.run_id }}
|
||||
|
||||
14
.github/workflows/cross-branch-migrations.yml
vendored
@@ -26,7 +26,7 @@ concurrency:
|
||||
jobs:
|
||||
migrations-h2-ee:
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 10
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
CI: 'true'
|
||||
MB_DB_TYPE: h2
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
|
||||
migrations-postgres-14-ee:
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 10
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
CI: 'true'
|
||||
MB_DB_TYPE: postgres
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
|
||||
migrations-postgres-latest-ee:
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 10
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
CI: 'true'
|
||||
MB_DB_TYPE: postgres
|
||||
@@ -92,7 +92,7 @@ jobs:
|
||||
|
||||
migrations-mysql-8-0-ee:
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 10
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
CI: 'true'
|
||||
MB_DB_TYPE: mysql
|
||||
@@ -117,7 +117,7 @@ jobs:
|
||||
|
||||
migrations-mysql-latest-ee:
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 10
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
CI: 'true'
|
||||
MB_DB_TYPE: mysql
|
||||
@@ -143,7 +143,7 @@ jobs:
|
||||
|
||||
migrations-mariadb-10-6-ee:
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 10
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
CI: 'true'
|
||||
MB_DB_TYPE: mysql
|
||||
@@ -168,7 +168,7 @@ jobs:
|
||||
|
||||
migrations-mariadb-latest-ee:
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 10
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
CI: 'true'
|
||||
MB_DB_TYPE: mysql
|
||||
|
||||
22
.github/workflows/docs-generate-63.yml
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
name: Auto-update documentation (release)
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 23 * * 1-5" # Every Monday through Friday at 11 PM UTC
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
target_branch:
|
||||
description: "Target branch for documentation update"
|
||||
required: false
|
||||
default: "release-x.63.x"
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
update-release-docs:
|
||||
if: ${{ github.event_name != 'schedule' || github.repository == 'metabase/metabase' }}
|
||||
name: Call documentation update for release branch
|
||||
uses: ./.github/workflows/docs-generate-base.yml
|
||||
with:
|
||||
target_branch: ${{ inputs.target_branch || 'release-x.63.x' }}
|
||||
secrets:
|
||||
automation_token: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
|
||||
152
.github/workflows/drivers.yml
vendored
@@ -46,6 +46,7 @@ jobs:
|
||||
sqlserver-should-run: ${{ steps.driver-decisions.outputs.sqlserver-should-run }}
|
||||
vertica-should-run: ${{ steps.driver-decisions.outputs.vertica-should-run }}
|
||||
# Per-driver configured status from ci-test-config (skip | info | required)
|
||||
h2-status: ${{ steps.driver-decisions.outputs.h2-status }}
|
||||
athena-status: ${{ steps.driver-decisions.outputs.athena-status }}
|
||||
bigquery-status: ${{ steps.driver-decisions.outputs.bigquery-status }}
|
||||
clickhouse-status: ${{ steps.driver-decisions.outputs.clickhouse-status }}
|
||||
@@ -56,6 +57,7 @@ jobs:
|
||||
mongo-sharded-cluster-status: ${{ steps.driver-decisions.outputs.mongo-sharded-cluster-status }}
|
||||
mysql-mariadb-status: ${{ steps.driver-decisions.outputs.mysql-mariadb-status }}
|
||||
oracle-status: ${{ steps.driver-decisions.outputs.oracle-status }}
|
||||
postgres-status: ${{ steps.driver-decisions.outputs.postgres-status }}
|
||||
presto-jdbc-status: ${{ steps.driver-decisions.outputs.presto-jdbc-status }}
|
||||
redshift-status: ${{ steps.driver-decisions.outputs.redshift-status }}
|
||||
snowflake-status: ${{ steps.driver-decisions.outputs.snowflake-status }}
|
||||
@@ -136,7 +138,7 @@ jobs:
|
||||
./bin/mage -driver-decisions "${MAGE_ARGS[@]}" --github-output-only >> $GITHUB_OUTPUT
|
||||
env:
|
||||
BASE_REF: ${{ github.event.pull_request.base.ref || github.ref_name }}
|
||||
IS_MASTER_OR_RELEASE: ${{ github.ref_name == 'master' || startsWith(github.ref_name, 'release-') }}
|
||||
IS_MASTER_OR_RELEASE: ${{ github.ref_name == 'master' || startsWith(github.ref_name, 'release-x') }}
|
||||
shell: bash
|
||||
|
||||
be-tests-athena-ee:
|
||||
@@ -177,6 +179,8 @@ jobs:
|
||||
test-args: >-
|
||||
:only-tags [:mb/driver-tests]
|
||||
:exclude-tags [:mb/transforms-python-test]
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
driver-status: ${{ needs.determine-driver-skips.outputs.athena-status }}
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
@@ -190,6 +194,7 @@ jobs:
|
||||
previous-step-outcome: ${{ steps.test-driver.outputs.test-outcome }}
|
||||
driver-status: ${{ needs.determine-driver-skips.outputs.athena-status }}
|
||||
report-variant: ${{ matrix.job.name }}
|
||||
run-trunk: ${{ vars.QUARANTINE_ENGINE != 'ci-conductor' }}
|
||||
|
||||
be-tests-bigquery-cloud-sdk-ee:
|
||||
needs: determine-driver-skips
|
||||
@@ -253,6 +258,8 @@ jobs:
|
||||
junit-name: 'be-tests-bigquery-cloud-sdk-ee'
|
||||
test-args: ${{ matrix.job.test-args }}
|
||||
logs-artifact-suffix: ${{ matrix.job.name }}
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
driver-status: ${{ needs.determine-driver-skips.outputs.bigquery-status }}
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
@@ -266,6 +273,7 @@ jobs:
|
||||
previous-step-outcome: ${{ steps.test-driver.outputs.test-outcome }}
|
||||
driver-status: ${{ needs.determine-driver-skips.outputs.bigquery-status }}
|
||||
report-variant: ${{ matrix.job.name }}
|
||||
run-trunk: ${{ vars.QUARANTINE_ENGINE != 'ci-conductor' }}
|
||||
- name: Cleanup python-runner
|
||||
if: ${{ always() && matrix.job.needs-python-runner == true }}
|
||||
uses: ./.github/actions/cleanup-python-runner
|
||||
@@ -338,6 +346,8 @@ jobs:
|
||||
junit-name: 'be-tests-clickhouse-ee'
|
||||
test-args: ${{ matrix.job.test-args }}
|
||||
logs-artifact-suffix: ${{ matrix.job.name }}
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
driver-status: ${{ needs.determine-driver-skips.outputs.clickhouse-status }}
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
@@ -349,6 +359,8 @@ jobs:
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
trunk-api-token: ${{ secrets.TRUNK_API_TOKEN }}
|
||||
previous-step-outcome: ${{ steps.test-driver.outputs.test-outcome }}
|
||||
driver-status: ${{ needs.determine-driver-skips.outputs.clickhouse-status }}
|
||||
run-trunk: ${{ vars.QUARANTINE_ENGINE != 'ci-conductor' }}
|
||||
- name: Cleanup python-runner
|
||||
if: ${{ always() && matrix.job.needs-python-runner == true }}
|
||||
uses: ./.github/actions/cleanup-python-runner
|
||||
@@ -392,6 +404,8 @@ jobs:
|
||||
test-args: >-
|
||||
:only-tags [:mb/driver-tests]
|
||||
:exclude-tags [:mb/transforms-python-test]
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
driver-status: ${{ needs.determine-driver-skips.outputs.druid-jdbc-status }}
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
@@ -403,12 +417,39 @@ jobs:
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
trunk-api-token: ${{ secrets.TRUNK_API_TOKEN }}
|
||||
previous-step-outcome: ${{ steps.test-driver.outputs.test-outcome }}
|
||||
driver-status: ${{ needs.determine-driver-skips.outputs.druid-jdbc-status }}
|
||||
run-trunk: ${{ vars.QUARANTINE_ENGINE != 'ci-conductor' }}
|
||||
|
||||
be-tests-databricks-ee:
|
||||
needs: determine-driver-skips
|
||||
if: ${{ needs.determine-driver-skips.outputs.databricks-should-run == 'true' || needs.determine-driver-skips.outputs.databricks-quarantine-conflict == 'true' }}
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 40
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
job:
|
||||
- name: Driver Tests Part 1
|
||||
test-args: >-
|
||||
:only-tags [:mb/driver-tests]
|
||||
:exclude-tags [:mb/transforms-python-test]
|
||||
:fail-fast? true
|
||||
:partition/total 3
|
||||
:partition/index 0
|
||||
- name: Driver Tests Part 2
|
||||
test-args: >-
|
||||
:only-tags [:mb/driver-tests]
|
||||
:exclude-tags [:mb/transforms-python-test]
|
||||
:fail-fast? true
|
||||
:partition/total 3
|
||||
:partition/index 1
|
||||
- name: Driver Tests Part 3
|
||||
test-args: >-
|
||||
:only-tags [:mb/driver-tests]
|
||||
:exclude-tags [:mb/transforms-python-test]
|
||||
:fail-fast? true
|
||||
:partition/total 3
|
||||
:partition/index 2
|
||||
env:
|
||||
CI: 'true'
|
||||
DRIVERS: databricks
|
||||
@@ -437,9 +478,9 @@ jobs:
|
||||
uses: ./.github/actions/test-driver
|
||||
with:
|
||||
junit-name: 'be-tests-databricks-ee'
|
||||
test-args: >-
|
||||
:only-tags [:mb/driver-tests]
|
||||
:exclude-tags [:mb/transforms-python-test]
|
||||
test-args: ${{ matrix.job.test-args }}
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
driver-status: ${{ needs.determine-driver-skips.outputs.databricks-status }}
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
@@ -453,12 +494,38 @@ jobs:
|
||||
previous-step-outcome: ${{ steps.test-driver.outputs.test-outcome }}
|
||||
driver-status: ${{ needs.determine-driver-skips.outputs.databricks-status }}
|
||||
report-variant: ${{ matrix.job.name }}
|
||||
run-trunk: ${{ vars.QUARANTINE_ENGINE != 'ci-conductor' }}
|
||||
|
||||
be-tests-databricks-multi-catalog-ee:
|
||||
needs: determine-driver-skips
|
||||
if: ${{ needs.determine-driver-skips.outputs.databricks-should-run == 'true' || needs.determine-driver-skips.outputs.databricks-quarantine-conflict == 'true' }}
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 40
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
job:
|
||||
- name: Driver Tests Part 1
|
||||
test-args: >-
|
||||
:only-tags [:mb/driver-tests]
|
||||
:exclude-tags [:mb/transforms-python-test]
|
||||
:fail-fast? true
|
||||
:partition/total 3
|
||||
:partition/index 0
|
||||
- name: Driver Tests Part 2
|
||||
test-args: >-
|
||||
:only-tags [:mb/driver-tests]
|
||||
:exclude-tags [:mb/transforms-python-test]
|
||||
:fail-fast? true
|
||||
:partition/total 3
|
||||
:partition/index 1
|
||||
- name: Driver Tests Part 3
|
||||
test-args: >-
|
||||
:only-tags [:mb/driver-tests]
|
||||
:exclude-tags [:mb/transforms-python-test]
|
||||
:fail-fast? true
|
||||
:partition/total 3
|
||||
:partition/index 2
|
||||
env:
|
||||
CI: 'true'
|
||||
DRIVERS: databricks
|
||||
@@ -488,9 +555,9 @@ jobs:
|
||||
uses: ./.github/actions/test-driver
|
||||
with:
|
||||
junit-name: 'be-tests-databricks-multi-catalog-ee'
|
||||
test-args: >-
|
||||
:only-tags [:mb/driver-tests]
|
||||
:exclude-tags [:mb/transforms-python-test]
|
||||
test-args: ${{ matrix.job.test-args }}
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
driver-status: ${{ needs.determine-driver-skips.outputs.databricks-status }}
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
@@ -504,6 +571,7 @@ jobs:
|
||||
previous-step-outcome: ${{ steps.test-driver.outputs.test-outcome }}
|
||||
driver-status: ${{ needs.determine-driver-skips.outputs.databricks-status }}
|
||||
report-variant: ${{ matrix.job.name }}
|
||||
run-trunk: ${{ vars.QUARANTINE_ENGINE != 'ci-conductor' }}
|
||||
|
||||
be-tests-h2:
|
||||
needs: determine-driver-skips
|
||||
@@ -523,6 +591,8 @@ jobs:
|
||||
test-args: >-
|
||||
:only-tags [:mb/driver-tests]
|
||||
:exclude-tags [:mb/transforms-python-test]
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
driver-status: ${{ needs.determine-driver-skips.outputs.h2-status }}
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
@@ -534,6 +604,8 @@ jobs:
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
trunk-api-token: ${{ secrets.TRUNK_API_TOKEN }}
|
||||
previous-step-outcome: ${{ steps.test-driver.outputs.test-outcome }}
|
||||
driver-status: ${{ needs.determine-driver-skips.outputs.h2-status }}
|
||||
run-trunk: ${{ vars.QUARANTINE_ENGINE != 'ci-conductor' }}
|
||||
|
||||
be-tests-h2-oss:
|
||||
needs: determine-driver-skips
|
||||
@@ -554,6 +626,8 @@ jobs:
|
||||
test-args: >-
|
||||
:only-tags [:mb/driver-tests]
|
||||
:exclude-tags [:mb/transforms-python-test]
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
driver-status: ${{ needs.determine-driver-skips.outputs.h2-status }}
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
@@ -565,6 +639,8 @@ jobs:
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
trunk-api-token: ${{ secrets.TRUNK_API_TOKEN }}
|
||||
previous-step-outcome: ${{ steps.test-driver.outputs.test-outcome }}
|
||||
driver-status: ${{ needs.determine-driver-skips.outputs.h2-status }}
|
||||
run-trunk: ${{ vars.QUARANTINE_ENGINE != 'ci-conductor' }}
|
||||
|
||||
be-tests-mysql-mariadb:
|
||||
needs: determine-driver-skips
|
||||
@@ -627,7 +703,7 @@ jobs:
|
||||
github.event_name == 'push' &&
|
||||
(
|
||||
github.ref_name == 'master' ||
|
||||
startsWith(github.ref_name, 'release-')
|
||||
startsWith(github.ref_name, 'release-x')
|
||||
) &&
|
||||
''
|
||||
) ||
|
||||
@@ -684,6 +760,8 @@ jobs:
|
||||
test-args: >-
|
||||
${{ matrix.job.test-args }}
|
||||
:exclude-tags '[ ${{ matrix.job.exclude-tag }} ${{ env.__ADDITIONAL_EXCLUDED_TAG__ }}]'
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
driver-status: ${{ needs.determine-driver-skips.outputs.mysql-mariadb-status }}
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
@@ -695,6 +773,8 @@ jobs:
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
trunk-api-token: ${{ secrets.TRUNK_API_TOKEN }}
|
||||
previous-step-outcome: ${{ steps.test-driver.outputs.test-outcome }}
|
||||
driver-status: ${{ needs.determine-driver-skips.outputs.mysql-mariadb-status }}
|
||||
run-trunk: ${{ vars.QUARANTINE_ENGINE != 'ci-conductor' }}
|
||||
- name: Cleanup python-runner
|
||||
if: always()
|
||||
uses: ./.github/actions/cleanup-python-runner
|
||||
@@ -764,6 +844,8 @@ jobs:
|
||||
junit-name: ${{ matrix.version.junit-name }}
|
||||
test-args: ${{ matrix.job.test-args }}
|
||||
logs-artifact-suffix: ${{ matrix.job.name }}
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
driver-status: ${{ needs.determine-driver-skips.outputs.mongo-status }}
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
@@ -775,6 +857,8 @@ jobs:
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
trunk-api-token: ${{ secrets.TRUNK_API_TOKEN }}
|
||||
previous-step-outcome: ${{ steps.test-driver.outputs.test-outcome }}
|
||||
driver-status: ${{ needs.determine-driver-skips.outputs.mongo-status }}
|
||||
run-trunk: ${{ vars.QUARANTINE_ENGINE != 'ci-conductor' }}
|
||||
- name: Cleanup python-runner
|
||||
if: ${{ always() && matrix.job.needs-python-runner == true }}
|
||||
uses: ./.github/actions/cleanup-python-runner
|
||||
@@ -846,6 +930,8 @@ jobs:
|
||||
test-args: >-
|
||||
:exclude-tags '[:mongo-sharded-cluster-tests :mb/transforms-python-test]'
|
||||
:only-tags [:mb/driver-tests]
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
driver-status: ${{ needs.determine-driver-skips.outputs.mongo-ssl-status }}
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
@@ -857,6 +943,8 @@ jobs:
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
trunk-api-token: ${{ secrets.TRUNK_API_TOKEN }}
|
||||
previous-step-outcome: ${{ steps.test-driver.outputs.test-outcome }}
|
||||
driver-status: ${{ needs.determine-driver-skips.outputs.mongo-ssl-status }}
|
||||
run-trunk: ${{ vars.QUARANTINE_ENGINE != 'ci-conductor' }}
|
||||
|
||||
be-tests-mongo-sharded-cluster-ee:
|
||||
needs: determine-driver-skips
|
||||
@@ -891,6 +979,8 @@ jobs:
|
||||
junit-name: 'be-tests-mongo-sharded-cluster-ee'
|
||||
test-args: >-
|
||||
:only metabase.driver.mongo.sharded-cluster-test
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
driver-status: ${{ needs.determine-driver-skips.outputs.mongo-sharded-cluster-status }}
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
@@ -902,6 +992,8 @@ jobs:
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
trunk-api-token: ${{ secrets.TRUNK_API_TOKEN }}
|
||||
previous-step-outcome: ${{ steps.test-driver.outputs.test-outcome }}
|
||||
driver-status: ${{ needs.determine-driver-skips.outputs.mongo-sharded-cluster-status }}
|
||||
run-trunk: ${{ vars.QUARANTINE_ENGINE != 'ci-conductor' }}
|
||||
|
||||
be-tests-oracle:
|
||||
needs: determine-driver-skips
|
||||
@@ -976,6 +1068,8 @@ jobs:
|
||||
test-args: >-
|
||||
:only-tags [:mb/driver-tests]
|
||||
:exclude-tags [:mb/transforms-python-test]
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
driver-status: ${{ needs.determine-driver-skips.outputs.oracle-status }}
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
@@ -987,6 +1081,8 @@ jobs:
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
trunk-api-token: ${{ secrets.TRUNK_API_TOKEN }}
|
||||
previous-step-outcome: ${{ steps.test-driver.outputs.test-outcome }}
|
||||
driver-status: ${{ needs.determine-driver-skips.outputs.oracle-status }}
|
||||
run-trunk: ${{ vars.QUARANTINE_ENGINE != 'ci-conductor' }}
|
||||
|
||||
be-tests-postgres:
|
||||
needs: determine-driver-skips
|
||||
@@ -1029,7 +1125,7 @@ jobs:
|
||||
github.event_name == 'push' &&
|
||||
(
|
||||
github.ref_name == 'master' ||
|
||||
startsWith(github.ref_name, 'release-')
|
||||
startsWith(github.ref_name, 'release-x')
|
||||
) &&
|
||||
''
|
||||
) ||
|
||||
@@ -1080,6 +1176,8 @@ jobs:
|
||||
test-args: >-
|
||||
${{ matrix.job.test-args }}
|
||||
:exclude-tags '[ ${{ matrix.job.exclude-tag }} ${{ env.__ADDITIONAL_EXCLUDED_TAG__ }}]'
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
driver-status: ${{ needs.determine-driver-skips.outputs.postgres-status }}
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
@@ -1091,6 +1189,9 @@ jobs:
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
trunk-api-token: ${{ secrets.TRUNK_API_TOKEN }}
|
||||
previous-step-outcome: ${{ steps.test-driver.outputs.test-outcome }}
|
||||
driver-status: ${{ needs.determine-driver-skips.outputs.postgres-status }}
|
||||
report-variant: ${{ matrix.version.name }}-${{ matrix.job.name }}
|
||||
run-trunk: ${{ vars.QUARANTINE_ENGINE != 'ci-conductor' }}
|
||||
- name: Cleanup python-runner
|
||||
if: always()
|
||||
uses: ./.github/actions/cleanup-python-runner
|
||||
@@ -1167,6 +1268,8 @@ jobs:
|
||||
test-args: >-
|
||||
:only-tags [:mb/driver-tests]
|
||||
:exclude-tags [:mb/transforms-python-test]
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
driver-status: ${{ needs.determine-driver-skips.outputs.presto-jdbc-status }}
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
@@ -1178,6 +1281,8 @@ jobs:
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
trunk-api-token: ${{ secrets.TRUNK_API_TOKEN }}
|
||||
previous-step-outcome: ${{ steps.test-driver.outputs.test-outcome }}
|
||||
driver-status: ${{ needs.determine-driver-skips.outputs.presto-jdbc-status }}
|
||||
run-trunk: ${{ vars.QUARANTINE_ENGINE != 'ci-conductor' }}
|
||||
|
||||
be-tests-redshift-ee:
|
||||
needs: determine-driver-skips
|
||||
@@ -1251,6 +1356,8 @@ jobs:
|
||||
test-args: >-
|
||||
${{ matrix.job.test-args }}
|
||||
logs-artifact-suffix: ${{ matrix.job.name }}
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
driver-status: ${{ needs.determine-driver-skips.outputs.redshift-status }}
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: ${{ !matrix.job.skip && !cancelled() }}
|
||||
@@ -1264,6 +1371,7 @@ jobs:
|
||||
previous-step-outcome: ${{ steps.test-driver.outputs.test-outcome }}
|
||||
driver-status: ${{ needs.determine-driver-skips.outputs.redshift-status }}
|
||||
report-variant: ${{ matrix.job.name }}
|
||||
run-trunk: ${{ vars.QUARANTINE_ENGINE != 'ci-conductor' }}
|
||||
- name: Cleanup python-runner
|
||||
if: ${{ always() && matrix.job.needs-python-runner == true }}
|
||||
uses: ./.github/actions/cleanup-python-runner
|
||||
@@ -1283,8 +1391,11 @@ jobs:
|
||||
- name: Driver Tests
|
||||
test-args: >-
|
||||
:only-tags [:mb/driver-tests]
|
||||
:exclude-tags [:mb/transforms-python-test]
|
||||
:exclude-tags '[:mb/upload-tests :mb/transforms-python-test]'
|
||||
:fail-fast? true
|
||||
- name: Upload Tests
|
||||
test-args: >-
|
||||
:only-tags [:mb/upload-tests]
|
||||
- name: Transforms Python Tests
|
||||
test-args: >-
|
||||
:only-tags [:mb/transforms-python-test]
|
||||
@@ -1333,6 +1444,8 @@ jobs:
|
||||
junit-name: 'be-tests-snowflake-ee'
|
||||
test-args: ${{ matrix.job.test-args }}
|
||||
logs-artifact-suffix: ${{ matrix.job.name }}
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
driver-status: ${{ needs.determine-driver-skips.outputs.snowflake-status }}
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
@@ -1346,6 +1459,7 @@ jobs:
|
||||
previous-step-outcome: ${{ steps.test-driver.outputs.test-outcome }}
|
||||
driver-status: ${{ needs.determine-driver-skips.outputs.snowflake-status }}
|
||||
report-variant: ${{ matrix.job.name }}
|
||||
run-trunk: ${{ vars.QUARANTINE_ENGINE != 'ci-conductor' }}
|
||||
- name: Cleanup python-runner
|
||||
if: ${{ always() && matrix.job.needs-python-runner == true }}
|
||||
uses: ./.github/actions/cleanup-python-runner
|
||||
@@ -1387,6 +1501,8 @@ jobs:
|
||||
test-args: >-
|
||||
:only-tags [:mb/driver-tests]
|
||||
:exclude-tags [:mb/transforms-python-test]
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
driver-status: ${{ needs.determine-driver-skips.outputs.sparksql-status }}
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
@@ -1398,6 +1514,8 @@ jobs:
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
trunk-api-token: ${{ secrets.TRUNK_API_TOKEN }}
|
||||
previous-step-outcome: ${{ steps.test-driver.outputs.test-outcome }}
|
||||
driver-status: ${{ needs.determine-driver-skips.outputs.sparksql-status }}
|
||||
run-trunk: ${{ vars.QUARANTINE_ENGINE != 'ci-conductor' }}
|
||||
|
||||
be-tests-sqlite-ee:
|
||||
needs: determine-driver-skips
|
||||
@@ -1428,6 +1546,8 @@ jobs:
|
||||
test-args: >-
|
||||
:only-tags [:mb/driver-tests]
|
||||
:exclude-tags [:mb/transforms-python-test]
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
driver-status: ${{ needs.determine-driver-skips.outputs.sqlite-status }}
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
@@ -1439,6 +1559,8 @@ jobs:
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
trunk-api-token: ${{ secrets.TRUNK_API_TOKEN }}
|
||||
previous-step-outcome: ${{ steps.test-driver.outputs.test-outcome }}
|
||||
driver-status: ${{ needs.determine-driver-skips.outputs.sqlite-status }}
|
||||
run-trunk: ${{ vars.QUARANTINE_ENGINE != 'ci-conductor' }}
|
||||
|
||||
be-tests-sqlserver:
|
||||
needs: determine-driver-skips
|
||||
@@ -1504,6 +1626,8 @@ jobs:
|
||||
junit-name: ${{ matrix.version.junit-name }}
|
||||
test-args: ${{ matrix.job.test-args }}
|
||||
logs-artifact-suffix: ${{ matrix.version.name }}
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
driver-status: ${{ needs.determine-driver-skips.outputs.sqlserver-status }}
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
@@ -1515,6 +1639,8 @@ jobs:
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
trunk-api-token: ${{ secrets.TRUNK_API_TOKEN }}
|
||||
previous-step-outcome: ${{ steps.test-driver.outputs.test-outcome }}
|
||||
driver-status: ${{ needs.determine-driver-skips.outputs.sqlserver-status }}
|
||||
run-trunk: ${{ vars.QUARANTINE_ENGINE != 'ci-conductor' }}
|
||||
- name: Cleanup python-runner
|
||||
if: ${{ always() && matrix.job.needs-python-runner == true }}
|
||||
uses: ./.github/actions/cleanup-python-runner
|
||||
@@ -1629,6 +1755,8 @@ jobs:
|
||||
test-args: >-
|
||||
:only-tags [:mb/driver-tests]
|
||||
:exclude-tags [:mb/transforms-python-test]
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
driver-status: ${{ needs.determine-driver-skips.outputs.vertica-status }}
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
@@ -1640,6 +1768,8 @@ jobs:
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
trunk-api-token: ${{ secrets.TRUNK_API_TOKEN }}
|
||||
previous-step-outcome: ${{ steps.test-driver.outputs.test-outcome }}
|
||||
driver-status: ${{ needs.determine-driver-skips.outputs.vertica-status }}
|
||||
run-trunk: ${{ vars.QUARANTINE_ENGINE != 'ci-conductor' }}
|
||||
|
||||
drivers-tests-result:
|
||||
needs:
|
||||
@@ -1693,11 +1823,13 @@ jobs:
|
||||
"be-tests-clickhouse-ee": "${{ needs.determine-driver-skips.outputs.clickhouse-status }}",
|
||||
"be-tests-databricks-ee": "${{ needs.determine-driver-skips.outputs.databricks-status }}",
|
||||
"be-tests-druid-jdbc-ee": "${{ needs.determine-driver-skips.outputs.druid-jdbc-status }}",
|
||||
"be-tests-h2": "${{ needs.determine-driver-skips.outputs.h2-status }}",
|
||||
"be-tests-mongo": "${{ needs.determine-driver-skips.outputs.mongo-status }}",
|
||||
"be-tests-mongo-ssl": "${{ needs.determine-driver-skips.outputs.mongo-ssl-status }}",
|
||||
"be-tests-mongo-sharded-cluster-ee": "${{ needs.determine-driver-skips.outputs.mongo-sharded-cluster-status }}",
|
||||
"be-tests-mysql-mariadb": "${{ needs.determine-driver-skips.outputs.mysql-mariadb-status }}",
|
||||
"be-tests-oracle": "${{ needs.determine-driver-skips.outputs.oracle-status }}",
|
||||
"be-tests-postgres": "${{ needs.determine-driver-skips.outputs.postgres-status }}",
|
||||
"be-tests-presto-jdbc-ee": "${{ needs.determine-driver-skips.outputs.presto-jdbc-status }}",
|
||||
"be-tests-redshift-ee": "${{ needs.determine-driver-skips.outputs.redshift-status }}",
|
||||
"be-tests-snowflake-ee": "${{ needs.determine-driver-skips.outputs.snowflake-status }}",
|
||||
|
||||
145
.github/workflows/e2e-coverage-manifest.yml
vendored
Normal file
@@ -0,0 +1,145 @@
|
||||
name: E2E Coverage Manifest
|
||||
|
||||
# Nightly: builds an instrumented frontend, runs the e2e suite sharded to
|
||||
# record which source files each spec exercises, and publishes a spec -> files
|
||||
# manifest used for selective e2e runs on PRs.
|
||||
#
|
||||
# Isolated from the PR e2e path on purpose - the per-shard worker
|
||||
# (e2e-coverage-shard.yml) is a dedicated, trimmed copy so coverage runs can
|
||||
# never affect PR test results.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Nightly, after the day's merges have settled (UTC).
|
||||
- cron: "0 7 * * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
edition:
|
||||
description: "Edition to instrument"
|
||||
type: choice
|
||||
options: ["ee", "oss"]
|
||||
default: "ee"
|
||||
|
||||
concurrency:
|
||||
group: e2e-coverage-manifest-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-instrumented:
|
||||
name: Build instrumented uberjar
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
MB_EDITION: ${{ inputs.edition || 'ee' }}
|
||||
INSTRUMENT_COVERAGE: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Prepare front-end environment
|
||||
uses: ./.github/actions/prepare-frontend
|
||||
- name: Prepare back-end environment
|
||||
uses: ./.github/actions/prepare-backend
|
||||
# build.clj forwards INSTRUMENT_COVERAGE to the rspack build, so only the
|
||||
# bundled FE is instrumented; everything else builds normally.
|
||||
- name: Build instrumented uberjar
|
||||
run: ./bin/build.sh
|
||||
- name: Upload instrumented uberjar
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: instrumented-uberjar
|
||||
path: target/uberjar/metabase.jar
|
||||
retention-days: 1
|
||||
|
||||
# Fixed-N sharding — coverage doesn't need the PR matrix-builder's tag/edition
|
||||
# groups, just an even split of the regular spec set. The shard count lives in
|
||||
# one place (SHARDS below); the matrix is generated from it.
|
||||
setup:
|
||||
name: Build shard matrix
|
||||
runs-on: ubuntu-22.04
|
||||
outputs:
|
||||
matrix: ${{ steps.gen.outputs.matrix }}
|
||||
count: ${{ steps.gen.outputs.count }}
|
||||
steps:
|
||||
- id: gen
|
||||
env:
|
||||
SHARDS: 100
|
||||
run: |
|
||||
matrix=$(node -e "console.log(JSON.stringify({split_index:[...Array(Number(process.env.SHARDS)).keys()]}))")
|
||||
echo "matrix=$matrix" >> "$GITHUB_OUTPUT"
|
||||
echo "count=$SHARDS" >> "$GITHUB_OUTPUT"
|
||||
|
||||
shards:
|
||||
name: Shard
|
||||
needs: [build-instrumented, setup]
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJson(needs.setup.outputs.matrix) }}
|
||||
uses: ./.github/workflows/e2e-coverage-shard.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
edition: ${{ inputs.edition || 'ee' }}
|
||||
total_chunks: ${{ fromJson(needs.setup.outputs.count) }}
|
||||
split_index: ${{ matrix.split_index }}
|
||||
|
||||
merge:
|
||||
name: Merge and publish manifest
|
||||
needs: shards
|
||||
if: always()
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 15
|
||||
# github-script lists/downloads the previous manifest artifact.
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Prepare front-end environment
|
||||
uses: ./.github/actions/prepare-frontend
|
||||
# merge-multiple unions every shard's per-spec dir into one tree; the
|
||||
# per-spec file design means no conflict resolution is needed.
|
||||
- name: Download all shard coverage
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: coverage-shard-*
|
||||
path: e2e/coverage-manifest-raw
|
||||
merge-multiple: true
|
||||
# Backfill source: the newest existing manifest is the previous nightly
|
||||
# (this run hasn't uploaded yet). build-coverage-manifest.mjs carries over
|
||||
# any still-existing spec that didn't run today.
|
||||
- name: Download previous manifest for backfill
|
||||
id: prev
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: | # js
|
||||
const fs = require("fs");
|
||||
const { owner, repo } = context.repo;
|
||||
const { data } = await github.rest.actions.listArtifactsForRepo({
|
||||
owner, repo, name: "spec-file-manifest", per_page: 20,
|
||||
});
|
||||
const art = (data.artifacts ?? [])
|
||||
.filter((a) => !a.expired)
|
||||
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at))[0];
|
||||
if (!art) {
|
||||
console.log("No previous manifest; nothing to backfill.");
|
||||
return;
|
||||
}
|
||||
const dl = await github.rest.actions.downloadArtifact({
|
||||
owner, repo, artifact_id: art.id, archive_format: "zip",
|
||||
});
|
||||
fs.writeFileSync("prev-manifest.zip", Buffer.from(dl.data));
|
||||
core.setOutput("found", "true");
|
||||
- name: Unzip previous manifest
|
||||
if: steps.prev.outputs.found == 'true'
|
||||
run: unzip -o prev-manifest.zip -d prev-manifest
|
||||
# GITHUB_SHA (the master commit this nightly built from) is stamped into
|
||||
# the manifest as `builtAt`, so a PR can later pick the manifest closest
|
||||
# to its own point in history. See resolve-coverage-manifest.mjs.
|
||||
- name: Build coverage manifest
|
||||
env:
|
||||
PREV_MANIFEST: ${{ steps.prev.outputs.found == 'true' && 'prev-manifest/spec-file-manifest.json' || '' }}
|
||||
run: node e2e/coverage/build-coverage-manifest.mjs
|
||||
- name: Upload manifest
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: spec-file-manifest
|
||||
path: e2e/coverage/spec-file-manifest.json
|
||||
retention-days: 30
|
||||
112
.github/workflows/e2e-coverage-shard.yml
vendored
Normal file
@@ -0,0 +1,112 @@
|
||||
name: E2E Coverage Shard
|
||||
|
||||
# Reusable per-shard worker for the coverage manifest run. Trimmed extract of
|
||||
# e2e-test.yml: runs one cypress-split shard against an instrumented build and
|
||||
# uploads that shard's per-spec coverage. Intentionally omits the PR-only
|
||||
# concerns (failed-spec retry, Trunk quarantine, S3 result upload, timing
|
||||
# output) since this is a nightly, non-blocking job.
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
edition:
|
||||
type: string
|
||||
default: ee
|
||||
total_chunks:
|
||||
type: number
|
||||
required: true
|
||||
split_index:
|
||||
type: number
|
||||
required: true
|
||||
|
||||
env:
|
||||
DISPLAY: ""
|
||||
TERM: xterm
|
||||
TZ: US/Pacific
|
||||
INSTRUMENT_COVERAGE: "true"
|
||||
|
||||
jobs:
|
||||
run:
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 60
|
||||
name: coverage-shard-${{ inputs.split_index }}
|
||||
env:
|
||||
MB_EDITION: ${{ inputs.edition }}
|
||||
CYPRESS_MB_ALL_FEATURES_TOKEN: ${{ secrets.STAGING_MB_ALL_FEATURES_TOKEN }}
|
||||
CYPRESS_MB_STARTER_CLOUD_TOKEN: ${{ secrets.STAGING_MB_STARTER_CLOUD_TOKEN }}
|
||||
CYPRESS_MB_PRO_CLOUD_TOKEN: ${{ secrets.STAGING_MB_PRO_CLOUD_TOKEN }}
|
||||
CYPRESS_MB_PRO_SELF_HOSTED_TOKEN: ${{ secrets.STAGING_MB_PRO_SELF_HOSTED_TOKEN }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: true
|
||||
token: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
|
||||
|
||||
- name: Prepare Docker containers
|
||||
uses: ./.github/actions/e2e-prepare-containers
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
maildev: true
|
||||
openldap: true
|
||||
webhook: true
|
||||
snowplow: true
|
||||
postgres: true
|
||||
mysql: true
|
||||
mongo: true
|
||||
|
||||
# The orchestrator built and uploaded this in an earlier job of the same run.
|
||||
- name: Download instrumented uberjar
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: instrumented-uberjar
|
||||
path: target/uberjar
|
||||
|
||||
- name: Prepare front-end environment
|
||||
uses: ./.github/actions/prepare-frontend
|
||||
- name: Prepare back-end environment
|
||||
uses: ./.github/actions/prepare-backend
|
||||
- name: Prepare Cypress environment
|
||||
id: cypress-prep
|
||||
uses: ./.github/actions/prepare-cypress
|
||||
|
||||
- name: Compile CLJS
|
||||
run: bun run build-pure:cljs
|
||||
|
||||
- name: Run Metabase
|
||||
run: node e2e/runner/run_cypress_ci.js start
|
||||
|
||||
- name: Generate database snapshots
|
||||
env:
|
||||
CYPRESS_BROWSER: ${{ steps.cypress-prep.outputs.chrome-path }}
|
||||
run: node e2e/runner/run_cypress_ci.js snapshot
|
||||
|
||||
# cypress-split divides the spec set by timing data; INSTRUMENT_COVERAGE
|
||||
# makes the support hook drain __coverage__ into e2e/coverage-manifest-raw/.
|
||||
- name: Run shard ${{ inputs.split_index }}
|
||||
# No SPLIT_FILE: cypress-split shards evenly by spec count using
|
||||
# SPLIT/SPLIT_INDEX alone. Omitting it means no timings are read or
|
||||
# written, which avoids cypress-split's after:run writeback (it would
|
||||
# otherwise try to overwrite ./e2e/support/timings.json from a cwd where
|
||||
# that subpath isn't reachable, crashing the run). Even-by-count is fine
|
||||
# here — every spec runs exactly once across the shards.
|
||||
env:
|
||||
SPLIT: ${{ inputs.total_chunks }}
|
||||
SPLIT_INDEX: ${{ inputs.split_index }}
|
||||
SPLIT_SUMMARY: false
|
||||
CYPRESS_BROWSER: ${{ steps.cypress-prep.outputs.chrome-path }}
|
||||
run: |
|
||||
node e2e/runner/run_cypress_ci.js e2e \
|
||||
--expose grepTags="-@mongo+-@python+-@OSS+-@skip"
|
||||
# Don't let a single failing/flaky spec abort the shard — we still want
|
||||
# coverage from the specs that did run.
|
||||
continue-on-error: true
|
||||
|
||||
- name: Upload shard coverage
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: coverage-shard-${{ inputs.split_index }}
|
||||
path: e2e/coverage-manifest-raw/
|
||||
retention-days: 7
|
||||
if-no-files-found: warn
|
||||
2
.github/workflows/e2e-matrix-builder.yml
vendored
@@ -46,7 +46,7 @@ jobs:
|
||||
const { buildMatrix } = require('${{ github.workspace }}/.github/scripts/build-e2e-matrix.js');
|
||||
const inputSpecs = "${{ inputs.specs }}";
|
||||
const inputChunks = Number(process.env.chunks);
|
||||
const java = 21;
|
||||
const java = 25;
|
||||
const defaultRunner = "${{ vars.DEFAULT_RUNNER_KEY }}";
|
||||
const options = {
|
||||
java,
|
||||
|
||||
33
.github/workflows/e2e-test.yml
vendored
@@ -164,9 +164,7 @@ jobs:
|
||||
node e2e/runner/run_cypress_ci.js e2e \
|
||||
--expose grepTags="-@mongo+-@python+-@OSS+-@skip" \
|
||||
--spec "$SPEC_PATH"
|
||||
# Trunk Quarantine controls the final state of the run in the CI!
|
||||
# Even if this step fails we let it pass, and then `trunk-io/analytics-uploader` determines
|
||||
# whether to fail or to pass the whole job, based on the quarantine list.
|
||||
# Keep this step continue-on-error because the quarantine logic controls whether it ultimately fails.
|
||||
continue-on-error: true
|
||||
|
||||
- name: Run Tagged EE Cypress tests on ${{ inputs.name }}
|
||||
@@ -181,9 +179,7 @@ jobs:
|
||||
node e2e/runner/run_cypress_ci.js e2e \
|
||||
--expose grepTags="${{inputs.tags && format('{0}+-@skip', inputs.tags) || '-@mongo+-@python+-@OSS+-@skip'}}" \
|
||||
--spec "$SPEC_PATH"
|
||||
# Trunk Quarantine controls the final state of the run in the CI!
|
||||
# Even if this step fails we let it pass, and then `trunk-io/analytics-uploader` determines
|
||||
# whether to fail or to pass the whole job, based on the quarantine list.
|
||||
# Keep this step continue-on-error because the quarantine logic controls whether it ultimately fails.
|
||||
continue-on-error: true
|
||||
|
||||
- name: Rewrite JUnit hook failures
|
||||
@@ -213,21 +209,18 @@ jobs:
|
||||
variant: e2e-tests
|
||||
trunk-api-token: ${{ secrets.TRUNK_API_TOKEN }}
|
||||
previous-step-outcome: ${{ inputs.specs && steps.tagged-e2e.outcome || steps.split-e2e.outcome }}
|
||||
run-trunk: ${{ vars.QUARANTINE_ENGINE != 'ci-conductor' }}
|
||||
|
||||
# Quarantine gate (DEV-2082). Compare this job's ultimate test failures
|
||||
# (recorded by after:spec into ./target/quarantine-failures.jsonl) against
|
||||
# ci-conductor's quarantine list and decide whether the job should pass:
|
||||
# pass if every failure is quarantined, fail if any isn't. This mirrors
|
||||
# what Trunk's analytics-uploader does for us today. Runs in dry-run mode
|
||||
# for now (QUARANTINE_DRY_RUN defaults to true) — it only prints the
|
||||
# verdict and never fails the job. `continue-on-error` is belt-and-braces
|
||||
# while it's observational; drop both it and the dry run to enforce.
|
||||
- name: Quarantine gate (dry run)
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
timeout-minutes: 2
|
||||
shell: bash
|
||||
run: bun e2e/support/check-quarantine.ts
|
||||
- name: Quarantine gate
|
||||
# Only one of the two run steps executes; the other reports `skipped`. Only gate on actual failure.
|
||||
if: ${{ !cancelled() && (steps.split-e2e.outcome == 'failure' || steps.tagged-e2e.outcome == 'failure') }}
|
||||
continue-on-error: ${{ vars.QUARANTINE_ENGINE != 'ci-conductor' }}
|
||||
timeout-minutes: 3
|
||||
uses: ./.github/actions/quarantine-gate
|
||||
with:
|
||||
adapter: e2e
|
||||
test-suite: e2e
|
||||
enforce-quarantine: ${{ vars.QUARANTINE_ENGINE == 'ci-conductor' }}
|
||||
|
||||
- name: Upload Cypress Artifacts upon failure
|
||||
uses: actions/upload-artifact@v7
|
||||
|
||||
@@ -8,6 +8,12 @@ env:
|
||||
TERM: xterm
|
||||
TZ: US/Pacific # to make Node match the instance tz
|
||||
MB_EDITION: "ee"
|
||||
CI_CONDUCTOR_BASE_URL: ${{ secrets.CI_CONDUCTOR_BASE_URL }}
|
||||
CI_CONDUCTOR_WEBHOOK_SECRET: ${{ secrets.CI_CONDUCTOR_WEBHOOK_SECRET }}
|
||||
REPO_ID: ${{ github.event.repository.id }}
|
||||
COMMIT_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
TARGET_BRANCH: ${{ github.event.pull_request.base.ref || github.ref_name }}
|
||||
QUARANTINE_FAILURES_FILE: ${{ github.workspace }}/target/quarantine-failures.jsonl
|
||||
|
||||
jobs:
|
||||
sdk-component-tests:
|
||||
@@ -19,6 +25,7 @@ jobs:
|
||||
react-version: [18, 19]
|
||||
fail-fast: false
|
||||
env:
|
||||
CI_CONDUCTOR_TEST_SUITE: embedding-sdk-component-react-${{ matrix.react-version }}
|
||||
# Sensitive env vars (tokens, secrets) starting with `CYPRESS_` are accessed via async `cy.env()`
|
||||
# Example: `cy.env(["FOO"]).then(({ FOO }) => ...)`
|
||||
# For non-sensitive config, use `Cypress.expose()` in cypress config instead (synchronous)
|
||||
@@ -69,6 +76,8 @@ jobs:
|
||||
- name: Run Metabase
|
||||
run: node e2e/runner/run_cypress_ci.js start
|
||||
|
||||
- uses: ./.github/actions/resolve-job-id
|
||||
|
||||
- name: Make app db snapshot
|
||||
env:
|
||||
CYPRESS_BROWSER: ${{ steps.cypress-prep.outputs.chrome-path }}
|
||||
@@ -86,6 +95,8 @@ jobs:
|
||||
env:
|
||||
CYPRESS_BROWSER: ${{ steps.cypress-prep.outputs.chrome-path }}
|
||||
run: node e2e/runner/run_cypress_ci.js component
|
||||
# Keep this step continue-on-error because the quarantine logic controls whether it ultimately fails.
|
||||
continue-on-error: true
|
||||
|
||||
- name: Rewrite JUnit hook failures
|
||||
if: always()
|
||||
@@ -111,10 +122,21 @@ jobs:
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
trunk-api-token: ${{ secrets.TRUNK_API_TOKEN }}
|
||||
previous-step-outcome: ${{ steps.run-tests.outcome }}
|
||||
run-trunk: ${{ vars.QUARANTINE_ENGINE != 'ci-conductor' }}
|
||||
|
||||
- name: Quarantine gate
|
||||
if: ${{ !cancelled() && steps.run-tests.outcome == 'failure' }}
|
||||
continue-on-error: ${{ vars.QUARANTINE_ENGINE != 'ci-conductor' }}
|
||||
timeout-minutes: 3
|
||||
uses: ./.github/actions/quarantine-gate
|
||||
with:
|
||||
adapter: e2e
|
||||
test-suite: ${{ env.CI_CONDUCTOR_TEST_SUITE }}
|
||||
enforce-quarantine: ${{ vars.QUARANTINE_ENGINE == 'ci-conductor' }}
|
||||
|
||||
- name: "SDK component tests > Upload Cypress Artifacts upon failure"
|
||||
uses: actions/upload-artifact@v7
|
||||
if: failure()
|
||||
if: ${{ !cancelled() && steps.run-tests.outcome == 'failure' }}
|
||||
with:
|
||||
name: cypress-recording-embedding-sdk-react-${{ matrix.react-version }}
|
||||
path: |
|
||||
@@ -123,7 +145,7 @@ jobs:
|
||||
if-no-files-found: ignore
|
||||
|
||||
- name: "SDK component tests > Publish Summary"
|
||||
if: failure()
|
||||
if: ${{ !cancelled() && steps.run-tests.outcome == 'failure' }}
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: | #js
|
||||
@@ -168,6 +190,7 @@ jobs:
|
||||
env:
|
||||
MB_RUN_MODE: "e2e"
|
||||
METASTORE_DEV_SERVER_URL: ${{ secrets.METASTORE_DEV_SERVER_URL }}
|
||||
CI_CONDUCTOR_TEST_SUITE: embedding-sdk-component-backward-compatibility
|
||||
# Sensitive env vars (tokens, secrets) starting with `CYPRESS_` are accessed via async `cy.env()`
|
||||
# Example: `cy.env(["FOO"]).then(({ FOO }) => ...)`
|
||||
# For non-sensitive config, use `Cypress.expose()` in cypress config instead (synchronous)
|
||||
@@ -237,6 +260,8 @@ jobs:
|
||||
- name: Run Metabase
|
||||
run: node e2e/runner/run_cypress_ci.js start
|
||||
|
||||
- uses: ./.github/actions/resolve-job-id
|
||||
|
||||
- name: Make app db snapshot
|
||||
env:
|
||||
CYPRESS_BROWSER: ${{ steps.cypress-prep.outputs.chrome-path }}
|
||||
@@ -252,6 +277,8 @@ jobs:
|
||||
run: |
|
||||
node e2e/runner/run_cypress_ci.js component \
|
||||
--expose grepTags="-@skip-backward-compatibility"
|
||||
# Keep this step continue-on-error because the quarantine logic controls whether it ultimately fails.
|
||||
continue-on-error: true
|
||||
|
||||
- name: Rewrite JUnit hook failures
|
||||
if: always()
|
||||
@@ -277,10 +304,21 @@ jobs:
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
trunk-api-token: ${{ secrets.TRUNK_API_TOKEN }}
|
||||
previous-step-outcome: ${{ steps.run-tests.outcome }}
|
||||
run-trunk: ${{ vars.QUARANTINE_ENGINE != 'ci-conductor' }}
|
||||
|
||||
- name: Quarantine gate
|
||||
if: ${{ !cancelled() && steps.run-tests.outcome == 'failure' }}
|
||||
continue-on-error: ${{ vars.QUARANTINE_ENGINE != 'ci-conductor' }}
|
||||
timeout-minutes: 3
|
||||
uses: ./.github/actions/quarantine-gate
|
||||
with:
|
||||
adapter: e2e
|
||||
test-suite: ${{ env.CI_CONDUCTOR_TEST_SUITE }}
|
||||
enforce-quarantine: ${{ vars.QUARANTINE_ENGINE == 'ci-conductor' }}
|
||||
|
||||
- name: Upload Cypress Artifacts upon failure
|
||||
uses: actions/upload-artifact@v7
|
||||
if: failure()
|
||||
if: ${{ !cancelled() && steps.run-tests.outcome == 'failure' }}
|
||||
with:
|
||||
name: cypress-recording-embedding-sdk-cross-version
|
||||
path: |
|
||||
@@ -289,7 +327,7 @@ jobs:
|
||||
if-no-files-found: ignore
|
||||
|
||||
- name: Publish Summary
|
||||
if: failure()
|
||||
if: ${{ !cancelled() && steps.run-tests.outcome == 'failure' }}
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: | #js
|
||||
|
||||
@@ -171,6 +171,8 @@ jobs:
|
||||
- name: Log free disk space
|
||||
run: df -h /
|
||||
|
||||
- uses: ./.github/actions/resolve-job-id
|
||||
|
||||
- name: Run e2e tests for Sample App
|
||||
id: run-e2e-tests
|
||||
if: ${{ steps.check-sample-app-branch.outputs.sample_app_branch_exists == 'true' }}
|
||||
@@ -238,6 +240,8 @@ jobs:
|
||||
shoppy-datadog-application-id: ${{ secrets.SHOPPY_METABASE_APP_DATADOG_APPLICATION_ID }}
|
||||
shoppy-datadog-client-token: ${{ secrets.SHOPPY_METABASE_APP_DATADOG_CLIENT_TOKEN }}
|
||||
|
||||
- uses: ./.github/actions/resolve-job-id
|
||||
|
||||
- name: Run e2e tests for Shoppy
|
||||
id: run-e2e-tests
|
||||
if: ${{ steps.shoppy-prep.outputs.sample_app_branch_exists == 'true' }}
|
||||
@@ -316,6 +320,8 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- uses: ./.github/actions/resolve-job-id
|
||||
|
||||
- name: Run synthetic monitoring tests
|
||||
id: run-e2e-tests
|
||||
if: ${{ steps.shoppy-prep.outputs.sample_app_branch_exists == 'true' }}
|
||||
@@ -363,6 +369,19 @@ jobs:
|
||||
timeout-minutes: 45
|
||||
name: e2e-host-app-${{ matrix.test_suite }}-tests-${{ matrix.environment }}-bundle
|
||||
env:
|
||||
# Host apps are the only suite here running through Metabase's Cypress config,
|
||||
# whose `after:spec` hook reports failures and writes QUARANTINE_FAILURES_FILE.
|
||||
# Sample-app suites load their own config (no-op), so ci-conductor is scoped to this job.
|
||||
CI_CONDUCTOR_BASE_URL: ${{ secrets.CI_CONDUCTOR_BASE_URL }}
|
||||
CI_CONDUCTOR_WEBHOOK_SECRET: ${{ secrets.CI_CONDUCTOR_WEBHOOK_SECRET }}
|
||||
CI_CONDUCTOR_TEST_SUITE: embedding-sdk-host-app
|
||||
REPO_ID: ${{ github.event.repository.id }}
|
||||
COMMIT_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
TARGET_BRANCH: ${{ github.event.pull_request.base.ref || github.ref_name }}
|
||||
QUARANTINE_FAILURES_FILE: ${{ github.workspace }}/target/quarantine-failures.jsonl
|
||||
# `Fail job if tests failed` is the arbiter until ci-conductor takes over.
|
||||
# Exactly one of the two gates the job.
|
||||
CI_CONDUCTOR_QUARANTINE_ACTIVE: ${{ vars.QUARANTINE_ENGINE == 'ci-conductor' }}
|
||||
CYPRESS_MB_ALL_FEATURES_TOKEN: ${{ secrets.STAGING_MB_ALL_FEATURES_TOKEN }}
|
||||
HOST_APP_ENVIRONMENT: ${{ matrix.environment }}
|
||||
permissions:
|
||||
@@ -399,6 +418,8 @@ jobs:
|
||||
- name: Run Metabase
|
||||
run: node e2e/runner/run_cypress_ci.js start
|
||||
|
||||
- uses: ./.github/actions/resolve-job-id
|
||||
|
||||
- name: Generate database snapshots
|
||||
run: |
|
||||
node e2e/runner/run_cypress_ci.js snapshot \
|
||||
@@ -416,14 +437,24 @@ jobs:
|
||||
|
||||
- name: Upload Cypress Artifacts upon failure
|
||||
uses: actions/upload-artifact@v7
|
||||
if: ${{ steps.run-e2e-tests.outcome != 'success' }}
|
||||
if: ${{ !cancelled() && steps.run-e2e-tests.outcome == 'failure' }}
|
||||
with:
|
||||
name: cypress-recording-host-app-${{ matrix.test_suite }}-${{ matrix.environment }}-latest
|
||||
path: |
|
||||
./cypress
|
||||
if-no-files-found: ignore
|
||||
|
||||
- name: Quarantine gate
|
||||
if: ${{ !cancelled() && steps.run-e2e-tests.outcome == 'failure' }}
|
||||
continue-on-error: ${{ env.CI_CONDUCTOR_QUARANTINE_ACTIVE != 'true' }}
|
||||
timeout-minutes: 3
|
||||
uses: ./.github/actions/quarantine-gate
|
||||
with:
|
||||
adapter: e2e
|
||||
test-suite: ${{ env.CI_CONDUCTOR_TEST_SUITE }}
|
||||
enforce-quarantine: ${{ env.CI_CONDUCTOR_QUARANTINE_ACTIVE }}
|
||||
|
||||
- name: Fail job if tests failed
|
||||
if: ${{ steps.run-e2e-tests.outcome != 'success' }}
|
||||
if: ${{ !cancelled() && steps.run-e2e-tests.outcome == 'failure' && env.CI_CONDUCTOR_QUARANTINE_ACTIVE != 'true' }}
|
||||
run: |
|
||||
exit 1
|
||||
|
||||
6
.github/workflows/embedding-sdk.yml
vendored
@@ -150,7 +150,7 @@ jobs:
|
||||
with:
|
||||
remove-previous-comment: true
|
||||
add-new-comment: true
|
||||
message: "⚠️ Embedding SDK bundle size increased by ${{ steps.compare.outputs.size_change_percent }}% compared to the ${{ github.base_ref }} branch (threshold: ${{ env.BUNDLE_SIZE_THRESHOLD_PERCENT }}%). Please contact Embedding team."
|
||||
message: "⚠️ Embedding SDK chunked bundle size (gzipped) increased by ${{ steps.compare.outputs.size_change_percent }}% compared to the ${{ github.base_ref }} branch (threshold: ${{ env.BUNDLE_SIZE_THRESHOLD_PERCENT }}%). Please contact Embedding team."
|
||||
include-log: false
|
||||
|
||||
- name: Send notification to Slack via Zapier
|
||||
@@ -165,7 +165,7 @@ jobs:
|
||||
PR_URL: ${{ github.event.pull_request.html_url }}
|
||||
run: |
|
||||
curl -s -X POST "$ZAPIER_WEBHOOK_URL" \
|
||||
-d "⚠️ Embedding SDK bundle size increased by ${SIZE_CHANGE_PERCENT}% (threshold: ${BUNDLE_SIZE_THRESHOLD_PERCENT}%)
|
||||
-d "⚠️ Embedding SDK chunked bundle size (gzipped) increased by ${SIZE_CHANGE_PERCENT}% (threshold: ${BUNDLE_SIZE_THRESHOLD_PERCENT}%)
|
||||
|
||||
PR #${PR_NUMBER} ${PR_TITLE} (${PR_URL})"
|
||||
|
||||
@@ -175,7 +175,7 @@ jobs:
|
||||
with:
|
||||
remove-previous-comment: true
|
||||
add-new-comment: true
|
||||
message: "🎉 Embedding SDK bundle size decreased by ${{ steps.compare.outputs.size_change_percent }}% compared to the ${{ github.base_ref }} branch. Nice work!"
|
||||
message: "🎉 Embedding SDK chunked bundle size (gzipped) decreased by ${{ steps.compare.outputs.size_change_percent }}% compared to the ${{ github.base_ref }} branch. Nice work!"
|
||||
include-log: false
|
||||
|
||||
embedding-sdk-tests-result:
|
||||
|
||||
21
.github/workflows/frontend.yml
vendored
@@ -110,21 +110,25 @@ jobs:
|
||||
- name: Run frontend unit tests
|
||||
id: fe-tests
|
||||
run: bun run test-unit --silent --shard=${{ matrix.shard }}/${{ env.SHARDS }}
|
||||
# Trunk Quarantine controls the final state of the run in the CI!
|
||||
# Even if this step fails we let it pass, and then `trunk-io/analytics-uploader` determines
|
||||
# whether to fail or to pass the whole job, based on the quarantine list.
|
||||
# Keep this step continue-on-error because the quarantine logic controls whether it ultimately fails.
|
||||
continue-on-error: true
|
||||
- name: Report failed tests to ci-conductor
|
||||
# Positioned between the test run and "Upload Test Results" so it reads
|
||||
# pristine JUnit (before Trunk quarantine processing). Gated to non-clean
|
||||
# runs + continue-on-error so it can never change the job outcome — Trunk
|
||||
# Quarantine remains the sole arbiter. See DEV-2224 / DEV-2235.
|
||||
if: ${{ always() && steps.fe-tests.outcome != 'success' }}
|
||||
if: ${{ !cancelled() && steps.fe-tests.outcome == 'failure' }}
|
||||
# This step should NEVER fail the job!
|
||||
continue-on-error: true
|
||||
uses: ./.github/actions/report-failures-to-ci-conductor
|
||||
with:
|
||||
test-suite: fe-tests-unit
|
||||
adapter: frontend
|
||||
- name: Quarantine gate
|
||||
if: ${{ !cancelled() && steps.fe-tests.outcome == 'failure' }}
|
||||
continue-on-error: ${{ vars.QUARANTINE_ENGINE != 'ci-conductor' }}
|
||||
timeout-minutes: 3
|
||||
uses: ./.github/actions/quarantine-gate
|
||||
with:
|
||||
adapter: frontend
|
||||
test-suite: fe-tests-unit
|
||||
enforce-quarantine: ${{ vars.QUARANTINE_ENGINE == 'ci-conductor' }}
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always() && github.event_name != 'workflow_dispatch'
|
||||
@@ -136,6 +140,7 @@ jobs:
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
trunk-api-token: ${{ secrets.TRUNK_API_TOKEN }}
|
||||
previous-step-outcome: ${{ steps.fe-tests.outcome }}
|
||||
run-trunk: ${{ vars.QUARANTINE_ENGINE != 'ci-conductor' }}
|
||||
|
||||
fe-tests-timezones:
|
||||
if: ${{ !inputs.skip }}
|
||||
|
||||
@@ -13,6 +13,8 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version-file: 'package.json'
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
- name: get module boundary stats
|
||||
|
||||
2
.github/workflows/pre-release.yml
vendored
@@ -122,7 +122,7 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
edition: [oss, ee]
|
||||
java-version: [21]
|
||||
java-version: [25]
|
||||
steps:
|
||||
- name: Prepare JRE (Java Run-time Environment)
|
||||
uses: actions/setup-java@v5.2.0
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
- name: Install bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version: "1.3.14"
|
||||
bun-version-file: 'package.json'
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Prepare build scripts
|
||||
run: bun install --cwd release --frozen-lockfile && bun run --cwd release build
|
||||
|
||||
8
.github/workflows/release-tests.yml
vendored
@@ -39,7 +39,7 @@ jobs:
|
||||
- name: Install bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version: "1.3.14"
|
||||
bun-version-file: 'package.json'
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: prepare dependencies in release directory
|
||||
run: bun install --cwd release --frozen-lockfile
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
- name: Install bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version: "1.3.14"
|
||||
bun-version-file: 'package.json'
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: prepare dependencies in release directory
|
||||
run: bun install --cwd release --frozen-lockfile
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
- name: Install bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version: "1.3.14"
|
||||
bun-version-file: 'package.json'
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Install ci-conductor dependencies
|
||||
run: bun install --cwd release/ci-conductor --frozen-lockfile
|
||||
@@ -98,7 +98,7 @@ jobs:
|
||||
- name: Install bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version: "1.3.14"
|
||||
bun-version-file: 'package.json'
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: prepare dependencies in release directory
|
||||
run: bun install --cwd release --frozen-lockfile
|
||||
|
||||
1
.github/workflows/run-tests.yml
vendored
@@ -62,6 +62,7 @@ jobs:
|
||||
be-changed-files: ${{ steps.changes.outputs.backend_sources_files }}
|
||||
unit-infra-touched: ${{ steps.changes.outputs.frontend_unit_infra }}
|
||||
loki-infra-touched: ${{ steps.changes.outputs.frontend_loki_infra }}
|
||||
e2e-infra-touched: ${{ steps.changes.outputs.e2e_infra }}
|
||||
shared-sources-touched: ${{ steps.changes.outputs.shared_sources }}
|
||||
|
||||
upload-affected-tests-stats:
|
||||
|
||||
83
.github/workflows/semantic-search.yml
vendored
@@ -34,7 +34,7 @@ jobs:
|
||||
- name: Semantic Search Tests
|
||||
build-static-viz: false
|
||||
test-args: >-
|
||||
:only '["enterprise/backend/test/metabase_enterprise/semantic_search" "enterprise/backend/test/metabase_enterprise/entity_retrieval" "enterprise/backend/test/metabase_enterprise/metabot_v3/tools/semantic_search_test.clj"]'
|
||||
:only '["enterprise/backend/test/metabase_enterprise/semantic_search" "enterprise/backend/test/metabase_enterprise/entity_retrieval" "enterprise/backend/test/metabase_enterprise/metabot/tools/semantic_search_test.clj"]'
|
||||
exclude-tag: ':mb/driver-tests'
|
||||
|
||||
services:
|
||||
@@ -61,6 +61,7 @@ jobs:
|
||||
test-args: >-
|
||||
${{ matrix.job.test-args }}
|
||||
:exclude-tags '[${{ matrix.job.exclude-tag }}]'
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
@@ -72,6 +73,7 @@ jobs:
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
trunk-api-token: ${{ secrets.TRUNK_API_TOKEN }}
|
||||
previous-step-outcome: ${{ steps.test-driver.outputs.test-outcome }}
|
||||
run-trunk: ${{ vars.QUARANTINE_ENGINE != 'ci-conductor' }}
|
||||
|
||||
semantic-search-tests-mariadb:
|
||||
if: ${{ !inputs.skip }}
|
||||
@@ -93,7 +95,7 @@ jobs:
|
||||
- name: Semantic Search Tests
|
||||
build-static-viz: false
|
||||
test-args: >-
|
||||
:only '["enterprise/backend/test/metabase_enterprise/semantic_search" "enterprise/backend/test/metabase_enterprise/entity_retrieval" "enterprise/backend/test/metabase_enterprise/metabot_v3/tools/semantic_search_test.clj"]'
|
||||
:only '["enterprise/backend/test/metabase_enterprise/semantic_search" "enterprise/backend/test/metabase_enterprise/entity_retrieval" "enterprise/backend/test/metabase_enterprise/metabot/tools/semantic_search_test.clj"]'
|
||||
exclude-tag: ':mb/driver-tests'
|
||||
|
||||
services:
|
||||
@@ -134,6 +136,7 @@ jobs:
|
||||
test-args: >-
|
||||
${{ matrix.job.test-args }}
|
||||
:exclude-tags '[${{ matrix.job.exclude-tag }}]'
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
@@ -145,6 +148,7 @@ jobs:
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
trunk-api-token: ${{ secrets.TRUNK_API_TOKEN }}
|
||||
previous-step-outcome: ${{ steps.test-driver.outputs.test-outcome }}
|
||||
run-trunk: ${{ vars.QUARANTINE_ENGINE != 'ci-conductor' }}
|
||||
|
||||
semantic-search-tests-mysql:
|
||||
if: ${{ !inputs.skip }}
|
||||
@@ -166,7 +170,7 @@ jobs:
|
||||
- name: Semantic Search Tests
|
||||
build-static-viz: false
|
||||
test-args: >-
|
||||
:only '["enterprise/backend/test/metabase_enterprise/semantic_search" "enterprise/backend/test/metabase_enterprise/entity_retrieval" "enterprise/backend/test/metabase_enterprise/metabot_v3/tools/semantic_search_test.clj"]'
|
||||
:only '["enterprise/backend/test/metabase_enterprise/semantic_search" "enterprise/backend/test/metabase_enterprise/entity_retrieval" "enterprise/backend/test/metabase_enterprise/metabot/tools/semantic_search_test.clj"]'
|
||||
exclude-tag: ':mb/driver-tests'
|
||||
|
||||
services:
|
||||
@@ -207,6 +211,7 @@ jobs:
|
||||
test-args: >-
|
||||
${{ matrix.job.test-args }}
|
||||
:exclude-tags '[${{ matrix.job.exclude-tag }}]'
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
@@ -218,6 +223,7 @@ jobs:
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
trunk-api-token: ${{ secrets.TRUNK_API_TOKEN }}
|
||||
previous-step-outcome: ${{ steps.test-driver.outputs.test-outcome }}
|
||||
run-trunk: ${{ vars.QUARANTINE_ENGINE != 'ci-conductor' }}
|
||||
|
||||
semantic-search-tests-postgres:
|
||||
if: ${{ !inputs.skip }}
|
||||
@@ -239,7 +245,7 @@ jobs:
|
||||
- name: Semantic Search Tests
|
||||
build-static-viz: false
|
||||
test-args: >-
|
||||
:only '["enterprise/backend/test/metabase_enterprise/semantic_search" "enterprise/backend/test/metabase_enterprise/entity_retrieval" "enterprise/backend/test/metabase_enterprise/metabot_v3/tools/semantic_search_test.clj"]'
|
||||
:only '["enterprise/backend/test/metabase_enterprise/semantic_search" "enterprise/backend/test/metabase_enterprise/entity_retrieval" "enterprise/backend/test/metabase_enterprise/metabot/tools/semantic_search_test.clj"]'
|
||||
exclude-tag: ':mb/driver-tests'
|
||||
|
||||
services:
|
||||
@@ -281,6 +287,7 @@ jobs:
|
||||
test-args: >-
|
||||
${{ matrix.job.test-args }}
|
||||
:exclude-tags '[${{ matrix.job.exclude-tag }}]'
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
@@ -292,6 +299,73 @@ jobs:
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
trunk-api-token: ${{ secrets.TRUNK_API_TOKEN }}
|
||||
previous-step-outcome: ${{ steps.test-driver.outputs.test-outcome }}
|
||||
run-trunk: ${{ vars.QUARANTINE_ENGINE != 'ci-conductor' }}
|
||||
|
||||
# pgvector-on-the-app-db mode: the app db itself is a pgvector-capable Postgres and
|
||||
# MB_PGVECTOR_DB_URL is deliberately unset. The dedicated-harness tests gate themselves off; the
|
||||
# appdb-pgvector-mode round trip (and the URL-independent unit tests) run for real.
|
||||
semantic-search-tests-appdb-mode:
|
||||
if: ${{ !inputs.skip }}
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 40
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
version:
|
||||
- name: pgvector pg17 as app db, no dedicated pgvector
|
||||
junit-name: semantic-search-tests-appdb-mode-pg17
|
||||
appdb-image: pgvector/pgvector:pg17
|
||||
job:
|
||||
- name: Semantic Search Tests
|
||||
build-static-viz: false
|
||||
test-args: >-
|
||||
:only '["enterprise/backend/test/metabase_enterprise/semantic_search" "enterprise/backend/test/metabase_enterprise/entity_retrieval" "enterprise/backend/test/metabase_enterprise/metabot/tools/semantic_search_test.clj"]'
|
||||
exclude-tag: ':mb/driver-tests'
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: ${{ matrix.version.appdb-image }}
|
||||
ports:
|
||||
- "5432:5432"
|
||||
env:
|
||||
POSTGRES_USER: mb_test
|
||||
POSTGRES_DB: mb_test
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
env:
|
||||
CI: 'true'
|
||||
MB_DB_TYPE: postgres
|
||||
MB_DB_PORT: 5432
|
||||
MB_DB_HOST: localhost
|
||||
MB_DB_DBNAME: mb_test
|
||||
MB_DB_USER: mb_test
|
||||
# opts this job into the destructive appdb-pgvector-mode round trip, which then FAILS (rather
|
||||
# than silently skipping) if the app db can't actually host pgvector
|
||||
MB_APPDB_PGVECTOR_MODE_TEST: 'true'
|
||||
name: "${{ matrix.version.name }} ${{ matrix.job.name }}"
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Test ${{ matrix.version.name }}
|
||||
id: test-driver
|
||||
uses: ./.github/actions/test-driver
|
||||
with:
|
||||
build-static-viz: ${{ matrix.job.build-static-viz }}
|
||||
junit-name: ${{ matrix.version.junit-name }}
|
||||
test-args: >-
|
||||
${{ matrix.job.test-args }}
|
||||
:exclude-tags '[${{ matrix.job.exclude-tag }}]'
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
with:
|
||||
input-path: ./target/junit/
|
||||
output-name: ${{ github.job }}
|
||||
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
|
||||
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
trunk-api-token: ${{ secrets.TRUNK_API_TOKEN }}
|
||||
previous-step-outcome: ${{ steps.test-driver.outputs.test-outcome }}
|
||||
run-trunk: ${{ vars.QUARANTINE_ENGINE != 'ci-conductor' }}
|
||||
|
||||
semantic-search-tests-result:
|
||||
needs:
|
||||
@@ -299,6 +373,7 @@ jobs:
|
||||
- semantic-search-tests-mariadb
|
||||
- semantic-search-tests-mysql
|
||||
- semantic-search-tests-postgres
|
||||
- semantic-search-tests-appdb-mode
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 5
|
||||
name: semantic-search-tests-result
|
||||
|
||||
2
.github/workflows/snowplow.yml
vendored
@@ -32,7 +32,7 @@ jobs:
|
||||
uses: actions/setup-java@v5.2.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
java-version: 25
|
||||
- name: Set up Babashka
|
||||
uses: turtlequeue/setup-babashka@d78ec6570aea3b614bc695cafcceb82eb45c2af9 # v1.8.0
|
||||
with:
|
||||
|
||||
2
.github/workflows/snyk.yml
vendored
@@ -53,7 +53,7 @@ jobs:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version: "1.3.14"
|
||||
bun-version-file: 'package.json'
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Snyk doesn't support bun.lock yet, so we generate yarn.lock and scan that.
|
||||
# Bun has a bug where it normalizes "< 3.0.0" to "< 3" in alias entries
|
||||
|
||||
2
.github/workflows/tag-for-release.yml
vendored
@@ -201,7 +201,7 @@ jobs:
|
||||
uses: actions/setup-java@v5.2.0
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: 21
|
||||
java-version: 25
|
||||
|
||||
- name: Retrieve test build uberjar artifact for ${{ matrix.edition }}
|
||||
uses: ./.github/actions/fetch-artifact
|
||||
|
||||
@@ -1,3 +1,15 @@
|
||||
# .github/workflows/team-issues-slack-notification.yml
|
||||
#
|
||||
# Posts newly team-labeled bugs to the team's Slack channel (e.g.
|
||||
# #team-graphy-bugs), now including a link to the synced Linear issue.
|
||||
#
|
||||
# Changes from the current version:
|
||||
# - Checks out .github/scripts so we can reuse find-linear-issue.js
|
||||
# - The message-building step now queries Linear (via attachmentsForURL)
|
||||
# with a short retry, and appends "Linear: <link|ID>" when found
|
||||
# - Degrades gracefully: if no Linear issue is attached (or the API is
|
||||
# down), the message posts without the Linear issue link
|
||||
|
||||
name: Team Issues Slack Notification
|
||||
|
||||
on:
|
||||
@@ -14,18 +26,58 @@ jobs:
|
||||
( contains(github.event.issue.labels.*.name, 'Type:Bug') ||
|
||||
contains(github.event.issue.labels.*.name, 'flaky-test-fix') )
|
||||
steps:
|
||||
- name: Setting title
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: .github/scripts
|
||||
|
||||
- name: Build Slack message (with Linear link if available)
|
||||
uses: actions/github-script@v9
|
||||
id: vars
|
||||
env:
|
||||
LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }}
|
||||
with:
|
||||
script: |
|
||||
core.setOutput('issue_title', JSON.stringify(${{ toJson(github.event.issue.title) }} + '\n' + ${{ toJson(github.event.issue.html_url) }}));
|
||||
const { findLinearIssue } = require('./.github/scripts/find-linear-issue.js');
|
||||
|
||||
const title = context.payload.issue.title;
|
||||
const issueUrl = context.payload.issue.html_url;
|
||||
|
||||
let text = title + '\n' + issueUrl;
|
||||
|
||||
// The Linear issue is usually already synced by the time the
|
||||
// team label lands at triage, but retry briefly just in case.
|
||||
try {
|
||||
const delaysSeconds = [0, 15, 30];
|
||||
let result = null;
|
||||
|
||||
for (const delay of delaysSeconds) {
|
||||
if (delay) await new Promise((r) => setTimeout(r, delay * 1000));
|
||||
result = await findLinearIssue({
|
||||
githubIssueUrl: issueUrl,
|
||||
linearApiKey: process.env.LINEAR_API_KEY,
|
||||
});
|
||||
if (result.success) break;
|
||||
}
|
||||
|
||||
if (result && result.success) {
|
||||
const id = result.issue; // e.g. "GIT-1234"
|
||||
text += `\nLinear: <https://linear.app/metabase/issue/${id}|${id}>`;
|
||||
} else {
|
||||
core.info(`No Linear issue found for ${issueUrl}; posting without link.`);
|
||||
}
|
||||
} catch (e) {
|
||||
// Never block the notification on a Linear lookup failure
|
||||
core.warning(`Linear lookup failed: ${e.message}`);
|
||||
}
|
||||
|
||||
core.setOutput('message', JSON.stringify(text));
|
||||
|
||||
- name: Extracting team name
|
||||
id: team_name
|
||||
env:
|
||||
LABEL_NAME: ${{ github.event.label.name }}
|
||||
run: |
|
||||
team_label=$(sed 's/&/\$/g' <<< "$LABEL_NAME")
|
||||
team_label="${LABEL_NAME//&/$}"
|
||||
echo "$team_label"
|
||||
team_name=${team_label#*.Team/}
|
||||
echo "$team_name"
|
||||
@@ -55,7 +107,7 @@ jobs:
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": ${{ steps.vars.outputs.issue_title }}
|
||||
"text": ${{ steps.vars.outputs.message }}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
9
.github/workflows/uberjar.yml
vendored
@@ -128,8 +128,9 @@ jobs:
|
||||
MB_EDITION: ${{ matrix.edition }}
|
||||
INTERACTIVE: false
|
||||
SKIP_LICENSES: ${{ github.event_name == 'pull_request' }} # faster builds for dev
|
||||
# Emit bundle stats on trunk/release builds so they can be tracked over time
|
||||
EMIT_BUNDLE_STATS: ${{ (github.ref_name == 'master' || startsWith(github.ref_name, 'release-')) && 'true' || 'false' }}
|
||||
# Emit bundle stats on trunk/release builds (tracked over time) and on PRs
|
||||
# (the bundle-size check reads them for both the PR head and its base ref).
|
||||
EMIT_BUNDLE_STATS: ${{ (github.ref_name == 'master' || startsWith(github.ref_name, 'release-') || github.event_name == 'pull_request') && 'true' || 'false' }}
|
||||
steps:
|
||||
- name: Check out the code
|
||||
uses: actions/checkout@v6
|
||||
@@ -142,7 +143,7 @@ jobs:
|
||||
- name: Prepare back-end environment
|
||||
uses: ./.github/actions/prepare-backend
|
||||
with:
|
||||
java-version: "${{ needs.get-build-requirements.outputs.java_version || 21 }}"
|
||||
java-version: "${{ needs.get-build-requirements.outputs.java_version || 25 }}"
|
||||
- name: Build
|
||||
run: ./bin/build.sh
|
||||
- name: Upload bundle stats
|
||||
@@ -173,7 +174,7 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
edition: ["ee", "oss"]
|
||||
java-version: [21]
|
||||
java-version: [25]
|
||||
steps:
|
||||
- name: Check out the code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
11
.gitignore
vendored
@@ -57,6 +57,16 @@ cypress.env.json
|
||||
/e2e/tmp/db_dumps/*
|
||||
!/e2e/tmp/db_dumps/.gitkeep
|
||||
/e2e/support/assets/example_custom_viz_plugin*.tgz
|
||||
# Coverage artifacts
|
||||
/e2e/coverage-manifest-raw
|
||||
/e2e/coverage-manifest
|
||||
/e2e/coverage/analysis
|
||||
/e2e/coverage/spec-file-manifest.json
|
||||
/e2e/support/.nyc_output
|
||||
/e2e/support/coverage
|
||||
/e2e/support/e2e
|
||||
/.nyc_output
|
||||
|
||||
/lein-plugins/*/target
|
||||
/local
|
||||
/locales/metabase-*.pot
|
||||
@@ -194,3 +204,4 @@ __pycache__/
|
||||
|
||||
# dependency-cruiser output
|
||||
dependency-graph.json
|
||||
.playwright-cli/
|
||||
|
||||
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 398 KiB After Width: | Height: | Size: 392 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 6.6 KiB After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 6.6 KiB After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 185 KiB After Width: | Height: | Size: 185 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |