Compare commits
260 Commits
openapi
...
api-contra
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
857d0f6664 | ||
|
|
dd8832fbe0 | ||
|
|
45726156ec | ||
|
|
790277c67f | ||
|
|
08cdd16ac0 | ||
|
|
86d5a9ce59 | ||
|
|
0ce28365f9 | ||
|
|
52963911b9 | ||
|
|
df1c45b9c9 | ||
|
|
67ff2124d6 | ||
|
|
0da1e0277c | ||
|
|
6f07d1961f | ||
|
|
00ccbb1d90 | ||
|
|
e2a309532f | ||
|
|
21e421b17c | ||
|
|
4ad350f5a0 | ||
|
|
dc8cb502f4 | ||
|
|
5d92535558 | ||
|
|
27674a24f4 | ||
|
|
c3e31b4a7b | ||
|
|
31b7141928 | ||
|
|
6505257288 | ||
|
|
4294651045 | ||
|
|
aa99372b46 | ||
|
|
07422617b7 | ||
|
|
7ee5c320bb | ||
|
|
881ce14e8d | ||
|
|
71684483b3 | ||
|
|
7499fc6477 | ||
|
|
74de3a2076 | ||
|
|
fe3adda536 | ||
|
|
1452fcc5b2 | ||
|
|
beef430928 | ||
|
|
2ca57eeaf3 | ||
|
|
0970a036cf | ||
|
|
6d50a3991c | ||
|
|
7feffe3afe | ||
|
|
01de838f28 | ||
|
|
fc81e50c34 | ||
|
|
b9d217cbb4 | ||
|
|
15082c54ff | ||
|
|
99f918928c | ||
|
|
c505a9a785 | ||
|
|
79546eb55f | ||
|
|
75bd580d91 | ||
|
|
ca1674fb4e | ||
|
|
4d0744a820 | ||
|
|
236a1cc9bd | ||
|
|
236c7c98f3 | ||
|
|
34ff3598e3 | ||
|
|
6931416988 | ||
|
|
90e121c475 | ||
|
|
7c6dad4ec9 | ||
|
|
7078a59606 | ||
|
|
4f02379e40 | ||
|
|
062d281192 | ||
|
|
9d022339f5 | ||
|
|
14fe567687 | ||
|
|
daceb36e65 | ||
|
|
1c561d056c | ||
|
|
dd9d8d0579 | ||
|
|
d0cbe49c68 | ||
|
|
3e83533d06 | ||
|
|
eff3209a34 | ||
|
|
93aabef5d3 | ||
|
|
1a8883d604 | ||
|
|
0cf4be2fd2 | ||
|
|
615cfdb48b | ||
|
|
19989ab207 | ||
|
|
efb95e2e18 | ||
|
|
3ad97e619d | ||
|
|
5b7c89d8ee | ||
|
|
05d2f186a0 | ||
|
|
c8f77a0925 | ||
|
|
1626b31be7 | ||
|
|
2659b87941 | ||
|
|
b2c923e496 | ||
|
|
cc1fa36312 | ||
|
|
498dfc7435 | ||
|
|
6780dbbf52 | ||
|
|
86f64c3d9f | ||
|
|
ca17b89463 | ||
|
|
38b7b8c0b7 | ||
|
|
94e58da90d | ||
|
|
6a035e234c | ||
|
|
b75caae5f3 | ||
|
|
a5af036433 | ||
|
|
1c5ba041d6 | ||
|
|
86c23eaf5e | ||
|
|
1447d9cf97 | ||
|
|
90688e49a7 | ||
|
|
60f2668137 | ||
|
|
503c3bf068 | ||
|
|
a6de42f467 | ||
|
|
c998859487 | ||
|
|
a491bd11be | ||
|
|
0a30b63176 | ||
|
|
f1ce332568 | ||
|
|
1690028d0f | ||
|
|
0c76874681 | ||
|
|
a6be073fb2 | ||
|
|
cb4541b965 | ||
|
|
463fb9355b | ||
|
|
791b649cc0 | ||
|
|
085418edb8 | ||
|
|
1118c3e897 | ||
|
|
6e74e41eaf | ||
|
|
38404fd9df | ||
|
|
8a7cd79f47 | ||
|
|
f3685652d3 | ||
|
|
08497ded2f | ||
|
|
bfe0b19738 | ||
|
|
a155c1f4ee | ||
|
|
9f48230b57 | ||
|
|
503349c6ae | ||
|
|
4d60c4bcf5 | ||
|
|
d9f905b7da | ||
|
|
36f273d523 | ||
|
|
38cda45f69 | ||
|
|
fc18a66b6c | ||
|
|
433561ad5b | ||
|
|
3c23f921e2 | ||
|
|
7f4883cc49 | ||
|
|
667d038d46 | ||
|
|
19df422bbd | ||
|
|
9fff12013e | ||
|
|
4bc6ccb00c | ||
|
|
81049d6069 | ||
|
|
85ce065045 | ||
|
|
dcb85ae924 | ||
|
|
3403f82c0a | ||
|
|
95306c12fc | ||
|
|
5d1e1ad8f1 | ||
|
|
6465693b78 | ||
|
|
4e4cbac853 | ||
|
|
9762d0e164 | ||
|
|
13e00d55b9 | ||
|
|
ad2d269251 | ||
|
|
a22958cee3 | ||
|
|
e92dfab50e | ||
|
|
7c4159eedd | ||
|
|
6dc53c3773 | ||
|
|
86d1bf3ec7 | ||
|
|
fe4e2beffa | ||
|
|
e096a361d8 | ||
|
|
8b58b48375 | ||
|
|
ecde907aff | ||
|
|
d1934fb897 | ||
|
|
03a39f47c8 | ||
|
|
ad22a8730c | ||
|
|
3d145ccb73 | ||
|
|
1b93876984 | ||
|
|
283b194a38 | ||
|
|
28b2ad9eb6 | ||
|
|
5b705231d7 | ||
|
|
10f53bdcec | ||
|
|
2e1a0c1a2c | ||
|
|
e49b98186b | ||
|
|
f74d32c39a | ||
|
|
6431bc412b | ||
|
|
40a15bc485 | ||
|
|
b0c8103cdb | ||
|
|
4b88b2ae79 | ||
|
|
d3c7576ccb | ||
|
|
7dbf078a40 | ||
|
|
c0949b1d38 | ||
|
|
ebdcd6c743 | ||
|
|
66038ba6d0 | ||
|
|
97d871c78a | ||
|
|
6bb98bce1d | ||
|
|
c908146299 | ||
|
|
2d80c0e360 | ||
|
|
7f589be657 | ||
|
|
12e5f16918 | ||
|
|
979b344073 | ||
|
|
4558ce654d | ||
|
|
e584e95d48 | ||
|
|
6ed9837a03 | ||
|
|
e4cc4a61ff | ||
|
|
c1805286bb | ||
|
|
22b2577799 | ||
|
|
2a35f6390b | ||
|
|
f508214657 | ||
|
|
a24ac528b3 | ||
|
|
e0c40554da | ||
|
|
c9c2552964 | ||
|
|
b57475ab38 | ||
|
|
758aade121 | ||
|
|
5db23f4182 | ||
|
|
0efc4c2161 | ||
|
|
f4b04b6966 | ||
|
|
83ed9b6c3d | ||
|
|
0cd9134160 | ||
|
|
d273dc56b6 | ||
|
|
2d6db97c9d | ||
|
|
3fb5cbcb74 | ||
|
|
f52a94984a | ||
|
|
8170a2ecdd | ||
|
|
ca2c341522 | ||
|
|
3390d69763 | ||
|
|
adf3bd7254 | ||
|
|
2357c1b843 | ||
|
|
e85fa9c446 | ||
|
|
250868257e | ||
|
|
27752c76e0 | ||
|
|
15106f17fd | ||
|
|
4b2cfdc623 | ||
|
|
3439faff23 | ||
|
|
18f216de52 | ||
|
|
e354134858 | ||
|
|
34da51c654 | ||
|
|
55f3c0f268 | ||
|
|
4cb99413ed | ||
|
|
beb22cd1b5 | ||
|
|
eb7bcaa06f | ||
|
|
7e11715582 | ||
|
|
c7a5060ca8 | ||
|
|
b7500371ca | ||
|
|
bf0186730c | ||
|
|
6ca683c042 | ||
|
|
c16a8abfa6 | ||
|
|
074e967be7 | ||
|
|
47bc44f775 | ||
|
|
06d46ec0c2 | ||
|
|
28a72bb30c | ||
|
|
b57dc3f98e | ||
|
|
ada3520e32 | ||
|
|
33ebc8e671 | ||
|
|
f1ea6b4ad5 | ||
|
|
0de35e5dd3 | ||
|
|
0ed863ba11 | ||
|
|
da793ea976 | ||
|
|
7e6c62a105 | ||
|
|
3ba6cb1479 | ||
|
|
3c07580bcc | ||
|
|
38fcd8e057 | ||
|
|
c573e659f6 | ||
|
|
f6ea5b6092 | ||
|
|
69256a104a | ||
|
|
ff7b417ce6 | ||
|
|
0e7e969925 | ||
|
|
1dfc2d1c90 | ||
|
|
78d3c10b00 | ||
|
|
bbef4a31a2 | ||
|
|
ace7aa5dd0 | ||
|
|
f531a39d14 | ||
|
|
dbc843ce23 | ||
|
|
e6216478c6 | ||
|
|
42a8e9f77d | ||
|
|
6fdf223404 | ||
|
|
70a3d8cb4a | ||
|
|
8e3b90894e | ||
|
|
a8db179087 | ||
|
|
ee9cd6a54e | ||
|
|
96c905f643 | ||
|
|
2a92db58c0 | ||
|
|
cbfbeb3ce1 | ||
|
|
234568bf20 | ||
|
|
4be1fd71c9 | ||
|
|
374e182461 |
@@ -81,7 +81,7 @@ Everything else in the module is **internal** and not allowed to be referenced f
|
||||
|
||||
`dev/src/dev/modularization_help.clj` — `potemkin-ns!` helper that generates `.core` re-export boilerplate from a target namespace.
|
||||
|
||||
`dev/test/metabase/core/modules_test.clj` — runs on config changes; validates structure, team assignments, API and model boundaries.
|
||||
`test/metabase/core/modules_test.clj` — runs on config changes; validates structure, team assignments, API and model boundaries.
|
||||
|
||||
### API Design Inside a Module
|
||||
|
||||
@@ -119,7 +119,7 @@ When the linter complains, the answer is **always refactoring**, never suppressi
|
||||
- `dev/src/dev/module_score.clj` — quality scoring
|
||||
- `dev/src/dev/modularization_help.clj` — Potemkin re-export helper
|
||||
- `dev/src/dev/model_boundary_config.clj` + `dev/src/dev/model_tracking.clj` — model-level enforcement
|
||||
- `dev/test/metabase/core/modules_test.clj` — config validation tests
|
||||
- `test/metabase/core/modules_test.clj` — config validation tests
|
||||
- `src/metabase/core/init.clj` — OSS module init aggregation
|
||||
- `enterprise/backend/src/metabase_enterprise/core/init.clj` — EE module init aggregation
|
||||
- `src/metabase/models/resolution.clj` — `:model/X` keyword → namespace map
|
||||
@@ -198,6 +198,6 @@ Use the `clojure-eval` skill (preferred) or `clj-nrepl-eval` to drive the dev to
|
||||
(dev.module-score/scores (dev.module-score/deps) (dev.module-score/config))
|
||||
```
|
||||
|
||||
For tests of the config itself, use `./bin/mage project-tests modules`. After editing Clojure files, run `clj-paren-repair`. After editing `config.edn`, re-run the modules-test.
|
||||
For tests of the config itself, use `./bin/test-agent :only '[metabase.core.modules-test]'`. After editing Clojure files, run `clj-paren-repair`. After editing `config.edn`, re-run the modules-test.
|
||||
|
||||
**Update your agent memory** as you discover idioms: which modules use `:friends` and why, which models are most-imported (suggesting they belong in their own module), which settings are accessed cross-module via `(setting/get …)` instead of `:uses` deps, and which refactors the team is actively running.
|
||||
|
||||
@@ -304,13 +304,12 @@ Tag response Malli `:map` schemas with `{:closed true}` so the generated OpenAPI
|
||||
|
||||
**Linter Suppressions:**
|
||||
|
||||
The right shape is a **reader-discard map** placed directly above the form being silenced, listing the
|
||||
rule keywords explicitly and including the required explanation:
|
||||
The right shape is a **reader-discard map** placed directly above the form being silenced, listing the rule keywords explicitly:
|
||||
|
||||
```clojure
|
||||
;; Loaded by name from the plugin registry.
|
||||
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
|
||||
(defn plugin-entrypoint ...)
|
||||
#_{:clj-kondo/ignore [:metabase/validate-defendpoint-route-uses-kebab-case
|
||||
:metabase/validate-defendpoint-has-response-schema]}
|
||||
(api.macros/defendpoint :post "/api_key" ...)
|
||||
```
|
||||
|
||||
For a single require entry, hang it as metadata on that entry:
|
||||
@@ -321,12 +320,7 @@ For a single require entry, hang it as metadata on that entry:
|
||||
[metabase.query-processor.store :as qp.store])
|
||||
```
|
||||
|
||||
Use the map form, not the keyword form (`#_:clj-kondo/ignore`), so searches can identify the suppressed
|
||||
rules. Keep `:clj-kondo/ignore` as the map's first key; the ratchet scanner rejects other orderings.
|
||||
|
||||
Fix the warning when possible. Adding a suppression is a last resort and requires approval. If one is
|
||||
necessary, add an explanatory `;;` comment directly above it or at the end of the same line. CI enforces
|
||||
these requirements with `./bin/mage kondo-ratchets`.
|
||||
Common Metabase-specific rules you'll see: `:metabase/modules`, `:metabase/validate-defendpoint-route-uses-kebab-case`, `:metabase/validate-defendpoint-has-response-schema`, `:metabase/prefer-with-dynamic-fn-redefs`, `:deprecated-namespace`, `:discouraged-namespace`. Don't use the keyword form (`#_:clj-kondo/ignore`); always the map form so it's grep-able by which rule(s) are suppressed.
|
||||
|
||||
**Configurable Options:**
|
||||
|
||||
|
||||
@@ -148,15 +148,17 @@ ms/PositiveInt ;; Positive integer
|
||||
ms/NonBlankString ;; Non-empty string
|
||||
ms/BooleanValue ;; String "true"/"false" or boolean
|
||||
ms/MaybeBooleanValue ;; BooleanValue or nil
|
||||
ms/TemporalString ;; ISO-8601 date/time string (for REQUEST params only!)
|
||||
ms/Map ;; Any map
|
||||
ms/JSONString ;; JSON-encoded string
|
||||
ms/TemporalString ;; ISO-8601 date/time string, commonly a request parameter
|
||||
ms/TemporalInstant ;; Java time value, documented as a date-time string in OpenAPI
|
||||
ms/OpaqueJSONObject ;; JSON object whose keys are not ours to declare; string-keyed
|
||||
ms/PositiveNum ;; Positive number
|
||||
ms/IntGreaterThanOrEqualToZero ;; 0 or positive
|
||||
```
|
||||
|
||||
**IMPORTANT:** For response schemas, use `:any` for temporal fields, not `ms/TemporalString`!
|
||||
Response schemas validate BEFORE JSON serialization, so they see Java Time objects.
|
||||
Response schemas validate before JSON serialization. For response timestamps that are Java time objects,
|
||||
use `ms/TemporalInstant`: it accepts the Java value and describes its JSON representation as a date-time string
|
||||
in OpenAPI. `:any` loses that information and produces an unconstrained generated type. Use `ms/TemporalString`
|
||||
when the value is already a string at validation time, as in the formatted response in the example above.
|
||||
|
||||
### Built-in Malli Types
|
||||
|
||||
@@ -307,12 +309,17 @@ Avoid using sequence schemas unless completely necessary.
|
||||
;; WRONG - Java Time objects aren't strings yet
|
||||
[:date_joined ms/TemporalString]
|
||||
|
||||
;; RIGHT - schemas validate BEFORE JSON serialization
|
||||
[:date_joined :any] ;; Java Time object, serialized to string by middleware
|
||||
[:last_login [:maybe :any]] ;; Java Time object or nil
|
||||
;; WRONG - loses the timestamp type in OpenAPI
|
||||
[:date_joined :any]
|
||||
|
||||
;; RIGHT - accepts Java time objects and describes their JSON representation
|
||||
[:date_joined ms/TemporalInstant]
|
||||
[:last_login [:maybe ms/TemporalInstant]]
|
||||
```
|
||||
|
||||
**Why:** Response schemas validate the internal Clojure data structures BEFORE they are serialized to JSON. Java Time objects like `OffsetDateTime` get converted to ISO-8601 strings by the JSON middleware, so the schema needs to accept the raw Java objects.
|
||||
**Why:** Response schemas validate the internal Clojure data structures before JSON serialization.
|
||||
`ms/TemporalInstant` accepts Java time objects such as `OffsetDateTime` and supplies a
|
||||
`:json-schema` override describing their serialized form as `{:type "string" :format "date-time"}`.
|
||||
|
||||
### Don't: Use `[:sequential X]` when the data is actually a set
|
||||
|
||||
@@ -375,7 +382,7 @@ Tests often show the expected response structure.
|
||||
### Response Schemas
|
||||
- Validate BEFORE JSON serialization
|
||||
- Data is still in Clojure format (Java Time objects, sets, keywords)
|
||||
- Use `:any` for Java Time objects
|
||||
- Use `ms/TemporalInstant` for Java time response timestamps
|
||||
- Use `[:set X]` for sets
|
||||
- Use `[:enum :keyword]` for keyword enums
|
||||
|
||||
|
||||
@@ -111,12 +111,7 @@ liability it usually is.
|
||||
### Miscellaneous
|
||||
|
||||
- [ ] Example data is bird-themed when possible
|
||||
- [ ] Kondo suppressions use a reader-discard map, not `#_:clj-kondo/ignore`, with
|
||||
`:clj-kondo/ignore` as the first key
|
||||
- [ ] Each new suppression is approved and has a `;;` comment explaining why the warning cannot be fixed
|
||||
- [ ] Any ratchet budget increase is explained in the PR
|
||||
- [ ] PRs that remove ignores leave ratchet budgets unchanged; the shrink workflow records reductions on
|
||||
`master`
|
||||
- [ ] Kondo linter suppressions use proper format (not `#_:clj-kondo/ignore` keyword form)
|
||||
|
||||
## Pattern matching table
|
||||
|
||||
|
||||
@@ -123,12 +123,5 @@ breakage keeps happening, *that* is the signal a comment was warranted.
|
||||
- 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`, then run `./bin/mage project-tests modules`. The fixer is a no-op when
|
||||
nothing drifted; see "Module Boundaries" in the project `CLAUDE.md`.
|
||||
- Fix kondo warnings instead of suppressing them. Add a suppression only as a last resort, with approval
|
||||
and a `;;` comment explaining why it is necessary.
|
||||
- Do not lower budgets in `.clj-kondo/ratchets.edn` after removing ignores. The shrink workflow records
|
||||
reductions on `master`.
|
||||
- If `.clj-kondo/ratchets.edn` conflicts during a merge, rebase, or restack, run
|
||||
`./bin/merge-kondo-ratchets`. It preserves one-sided changes, chooses the policy that allows fewer
|
||||
suppressions when both sides change the same entry, and stops when the conflict needs a human decision.
|
||||
`.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`.
|
||||
|
||||
@@ -171,7 +171,7 @@ Create `ComponentName.module.css` alongside the component file:
|
||||
/* Use design system tokens — NEVER raw color/spacing values */
|
||||
.root {
|
||||
border: 1px solid var(--mb-color-border-neutral);
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
background-color: var(--mb-color-background_page-primary);
|
||||
}
|
||||
|
||||
@@ -246,8 +246,7 @@ Remove the old styled file entirely. Remove all imports of it from other files.
|
||||
|
||||
**Spacing** (`p`, `px`, `py`, `pt`, `pb`, `pl`, `pr`, `m`, `mx`, `my`, `mt`, `mb`, `ml`, `mr`):
|
||||
|
||||
- `"xxxs"` = 0.125rem, `"xxs"` = 0.25rem, `"xs"` = 0.375rem, `"sm"` = 0.5rem, `"md"` = 0.75rem, `"lg"` = 1rem, `"xl"` = 1.5rem, `"xxl"` = 2rem, `"xxxl"` = 2.5rem
|
||||
- Use numeric `0` for zero spacing.
|
||||
- `"xs"` = 4px, `"sm"` = 8px, `"md"` = 16px, `"lg"` = 24px, `"xl"` = 32px
|
||||
- Custom: `rem(48)` for non-standard values (import `rem` from `metabase/ui`)
|
||||
|
||||
**Dimensions** (`w`, `h`, `maw`, `mah`, `miw`, `mih`):
|
||||
@@ -290,16 +289,11 @@ Remove the old styled file entirely. Remove all imports of it from other files.
|
||||
|
||||
**Mantine spacing** — `var(--mantine-spacing-<size>)`:
|
||||
|
||||
- `--mantine-spacing-xxxs` (0.125rem), `--mantine-spacing-xxs` (0.25rem), `--mantine-spacing-xs` (0.375rem), `--mantine-spacing-sm` (0.5rem), `--mantine-spacing-md` (0.75rem), `--mantine-spacing-lg` (1rem), `--mantine-spacing-xl` (1.5rem), `--mantine-spacing-xxl` (2rem), `--mantine-spacing-xxxl` (2.5rem)
|
||||
- `--mantine-spacing-xs` (4px), `--mantine-spacing-sm` (8px), `--mantine-spacing-md` (16px), `--mantine-spacing-lg` (24px), `--mantine-spacing-xl` (32px)
|
||||
|
||||
**Mantine radius** — `var(--mantine-radius-<size>)`:
|
||||
|
||||
- `--mantine-radius-xxxs` (0.125rem), `--mantine-radius-xxs` (0.25rem), `--mantine-radius-xs` (0.375rem), `--mantine-radius-sm` (0.5rem), `--mantine-radius-md` (0.75rem), `--mantine-radius-lg` (1rem), `--mantine-radius-xl` (1.5rem)
|
||||
|
||||
**Mantine shadows (elevation)** — `var(--mantine-shadow-<size>)`:
|
||||
|
||||
- `--mantine-shadow-xs`, `--mantine-shadow-xs_outline`, `--mantine-shadow-sm`, `--mantine-shadow-sm_outline`, `--mantine-shadow-lg_outline`
|
||||
- Light and dark modes use different shadow values (defined in `frontend/src/metabase/ui/theme.ts`)
|
||||
- `--mantine-radius-xs` (4px), `--mantine-radius-sm` (6px), `--mantine-radius-md` (8px), `--mantine-radius-xl` (40px)
|
||||
|
||||
**Mantine font sizes** — `var(--mantine-font-size-<size>)`:
|
||||
|
||||
@@ -319,36 +313,24 @@ When migrating, **never carry over hardcoded `rem`/`px` values** from the origin
|
||||
|
||||
**Spacing** — snap to the nearest Mantine spacing token:
|
||||
|
||||
| Hardcoded value | Nearest token | Style prop | CSS variable |
|
||||
| --------------------------------- | ------------- | ---------- | ------------------------------- |
|
||||
| `0` (0px) | — | `0` | `0` |
|
||||
| `0.125rem` (2px) | **xxxs** | `"xxxs"` | `var(--mantine-spacing-xxxs)` |
|
||||
| `0.2rem` (3.2px), `0.25rem` (4px) | **xxs** | `"xxs"` | `var(--mantine-spacing-xxs)` |
|
||||
| `0.375rem` (6px) | **xs** | `"xs"` | `var(--mantine-spacing-xs)` |
|
||||
| `0.5rem` (8px) | **sm** | `"sm"` | `var(--mantine-spacing-sm)` |
|
||||
| `0.75rem` (12px) | **md** | `"md"` | `var(--mantine-spacing-md)` |
|
||||
| `1rem` (16px) | **lg** | `"lg"` | `var(--mantine-spacing-lg)` |
|
||||
| `1.5rem` (24px) | **xl** | `"xl"` | `var(--mantine-spacing-xl)` |
|
||||
| `2rem` (32px) | **xxl** | `"xxl"` | `var(--mantine-spacing-xxl)` |
|
||||
| `2.5rem` (40px) | **xxxl** | `"xxxl"` | `var(--mantine-spacing-xxxl)` |
|
||||
|
||||
Values between steps: keep the literal (e.g. `rem(10)`), don't round to a token.
|
||||
| Hardcoded value | Nearest token | Style prop | CSS variable |
|
||||
| --------------------------------- | ------------- | ------------ | ------------------------------ |
|
||||
| `0.125rem` (2px) | `2px` | `2` (number) | `2px` (keep literal, no token) |
|
||||
| `0.2rem` (3.2px), `0.25rem` (4px) | **xs** (4px) | `"xs"` | `var(--mantine-spacing-xs)` |
|
||||
| `0.5rem` (8px) | **sm** (8px) | `"sm"` | `var(--mantine-spacing-sm)` |
|
||||
| `0.75rem` (12px) | between sm/md | `rem(12)` | `0.75rem` (no exact token) |
|
||||
| `1rem` (16px) | **md** (16px) | `"md"` | `var(--mantine-spacing-md)` |
|
||||
| `1.5rem` (24px) | **lg** (24px) | `"lg"` | `var(--mantine-spacing-lg)` |
|
||||
| `2rem` (32px) | **xl** (32px) | `"xl"` | `var(--mantine-spacing-xl)` |
|
||||
|
||||
**Border radius** — snap to the nearest Mantine radius token:
|
||||
|
||||
| Hardcoded value | Nearest token | CSS variable |
|
||||
| ---------------- | ------------- | ----------------------------- |
|
||||
| `0` (0px) | — | `0` |
|
||||
| `0.125rem` (2px) | **xxxs** | `var(--mantine-radius-xxxs)` |
|
||||
| `0.25rem` (4px) | **xxs** | `var(--mantine-radius-xxs)` |
|
||||
| `0.375rem` (6px) | **xs** | `var(--mantine-radius-xs)` |
|
||||
| `0.5rem` (8px) | **sm** | `var(--mantine-radius-sm)` |
|
||||
| `0.75rem` (12px) | **md** | `var(--mantine-radius-md)` |
|
||||
| `1rem` (16px) | **lg** | `var(--mantine-radius-lg)` |
|
||||
| `1.5rem` (24px) | **xl** | `var(--mantine-radius-xl)` |
|
||||
|
||||
Fully-round pills/badges (radius ≥ half the element height): keep a literal
|
||||
such as `9999px`.
|
||||
| Hardcoded value | Nearest token | CSS variable |
|
||||
| ---------------- | ----------------------------- | -------------------------- |
|
||||
| `0.25rem` (4px) | **xs** (4px) | `var(--mantine-radius-xs)` |
|
||||
| `0.375rem` (6px) | **sm** (6px) | `var(--mantine-radius-sm)` |
|
||||
| `0.5rem` (8px) | **md** (8px) | `var(--mantine-radius-md)` |
|
||||
| `1rem`+ (16px+) | **xl** (40px) or keep literal | `var(--mantine-radius-xl)` |
|
||||
|
||||
**Font sizes** — snap to the nearest Mantine font size token:
|
||||
|
||||
|
||||
90
.claude/skills/fix-api-contract/SKILL.md
Normal file
@@ -0,0 +1,90 @@
|
||||
---
|
||||
name: fix-api-contract
|
||||
description: Investigate and fix API contract checker diagnostics, or bring a selected Metabase RTK endpoint under enforcement.
|
||||
---
|
||||
|
||||
# Fix an API contract
|
||||
|
||||
Start from an endpoint name, route, or pasted checker diagnostic. Keep the work scoped to the selected
|
||||
contract and affected consumers. Generated declarations are verification inputs under `.tmp/openapi`;
|
||||
application code keeps its handwritten types.
|
||||
|
||||
## Establish the current contract
|
||||
|
||||
Unless this session already generated a snapshot from the current backend source, run:
|
||||
|
||||
```sh
|
||||
bun run api-contract-check
|
||||
```
|
||||
|
||||
This generates the Enterprise spec and TypeScript declarations, then checks the frontend contracts.
|
||||
It requires the backend JDK/Clojure environment and built CLJS dependencies. Run
|
||||
`bun run build-pure:cljs` if those dependencies are missing. If generation fails, resolve that failure
|
||||
before interpreting an existing report. Run one generation command at a time per worktree.
|
||||
|
||||
Inspect the selected checks, including existing exemptions:
|
||||
|
||||
```sh
|
||||
bun run api-contract-check-pure --explain <endpointName>
|
||||
```
|
||||
|
||||
The JSON report is `.tmp/openapi/contracts-report.json`. Use its full check IDs when endpoint names
|
||||
are ambiguous. `--explain` filters the displayed results but still runs the complete baseline gate.
|
||||
The `-pure` command reuses the snapshot; regenerate after backend edits or switching branches.
|
||||
Ordinary `type-check` does not generate or check backend contracts.
|
||||
|
||||
## Find which layer is wrong
|
||||
|
||||
Follow the diagnostic's field path through the RTK definition, its handwritten types, the Malli schema,
|
||||
handler, hydration and serialization, and relevant endpoint tests. Neither declaration is automatically
|
||||
correct. Existing behaviour is evidence but may itself contain a bug.
|
||||
|
||||
- Response values must be assignable from backend to frontend. Every frontend response field,
|
||||
including optional nested fields, must be supported by the backend schema; omitting backend fields is allowed.
|
||||
- Requests compare the expressions actually sent in URL parameters, `params` and `body`.
|
||||
The RTK argument type may differ from the wire request because the query function transforms it.
|
||||
- Distinguish absent keys from nullable values. A key always emitted with a possible null value
|
||||
is required and nullable, not optional. Check conditional responses and error variants too.
|
||||
- An `unverified` result can mean a missing or loose schema, an unresolved frontend type, or an
|
||||
unsupported mapping such as `transformResponse` or `queryFn`. It does not establish a type mismatch.
|
||||
|
||||
## Fix the contract
|
||||
|
||||
Before editing Malli schemas, read [add-malli-schemas](../add-malli-schemas/SKILL.md).
|
||||
Response validation sees Clojure values before JSON serialization. In particular, response timestamps
|
||||
that are Java time objects need `ms/TemporalInstant`, not a blanket `:any` or string schema.
|
||||
|
||||
Correct inaccurate schemas or handwritten declarations based on the intended API behaviour, then fix
|
||||
affected consumers. TypeScript follows aliases structurally, so keep compatible existing names and
|
||||
compositions. A nested mismatch does not require copying the generated entity graph.
|
||||
|
||||
Fields added by frontend normalization or store hydration belong to frontend models, not invented
|
||||
backend response fields. Separate the raw response contract from that model where necessary.
|
||||
Do not silence diagnostics with casts, `any`, unconstrained types, or application imports of generated
|
||||
declarations. Do not edit generated output. If the checker cannot represent a valid mapping, fix it with
|
||||
a regression test when in scope, or report the unsupported case as remaining work.
|
||||
|
||||
## Remove exemptions and verify
|
||||
|
||||
`frontend/build/openapi/baseline.json` is an array of endpoint IDs, such as
|
||||
`tableApi:getTablePublishingInfo`. IDs use enclosing API/factory declaration names and endpoint names;
|
||||
file paths and line numbers are diagnostic locations, not exemption identities. An exemption covers
|
||||
every request and response check on that endpoint, regardless of its current failures or statuses.
|
||||
The checker still reports all results for exempt endpoints. Unexempted endpoints fail the gate for
|
||||
any mismatch or unverified check.
|
||||
|
||||
A zero exit status can mean the endpoint is still exempted. To adopt an endpoint, resolve all of its
|
||||
mismatches and unverified checks, remove its endpoint ID from the baseline, and rerun the gate.
|
||||
Intentionally ignored responses remain visible as coverage limitations. Do not bulk-refresh the
|
||||
baseline or add exemptions to claim a contract fix.
|
||||
|
||||
Fixed or removed endpoints may leave stale exemptions; these are informational and never fail the
|
||||
gate. They appear in `staleExemptions` in the report. Remove them when reviewing adoption or cleanup.
|
||||
`bun run api-contract-check-pure --update-baseline` replaces the list with all currently failing
|
||||
endpoint IDs, removing stale entries and accepting existing failures. Use it for an intentional
|
||||
baseline refresh and review the diff; it does not fix the underlying contracts.
|
||||
|
||||
Run the full contract command after backend changes, or `api-contract-check-pure` after frontend-only
|
||||
changes against the current snapshot. Run `bun run type-check-pure` for affected frontend code and the
|
||||
relevant backend tests for schema or handler changes. Regenerate after the final backend edit.
|
||||
Report the checks fixed, exemptions removed, validation results, and any remaining failures separately.
|
||||
@@ -205,7 +205,7 @@ Per `docs/developers-guide/frontend.md` ("Colors"): every color must be a **sema
|
||||
|
||||
The same discipline applies to **dimensional** values — radius, spacing/padding/gap, elevation/shadow, and typography (font size, line-height, weight). Prefer the codebase's existing scale variable over a literal: `--mantine-radius-*`, `--mantine-spacing-*`, `--mantine-shadow-*`, and the Mantine font tokens.
|
||||
|
||||
But Figma's scales don't always line up with the codebase's. When a Figma value **has no matching step** in the corresponding scale — e.g. Figma `radius/md = 16px` while `--mantine-radius-md` is `0.75rem`, or a Figma spacing step that has no Mantine equivalent — **don't silently bake in a literal.** Flag it to the user the same way you flag primitive colors: name the property, the Figma value, and the nearest codebase token, so it can be resolved with design / the scale owners (align the value, or add a scale step). Only fall back to a literal if the user confirms it's an accepted one-off, and leave a short comment in the code explaining why. The codebase scales are defined in `frontend/src/metabase/ui/theme.ts` (`SPACING_SCALE`, `RADIUS_SCALE`, `LIGHT_SHADOWS`/`DARK_SHADOWS`).
|
||||
But Figma's scales don't always line up with the codebase's. When a Figma value **has no matching step** in the corresponding scale — e.g. Figma `radius/md = 12px` while `--mantine-radius-md` is `8px`, or a Figma `xxxs` spacing step with no Mantine equivalent — **don't silently bake in a literal.** Flag it to the user the same way you flag primitive colors: name the property, the Figma value, and the nearest codebase token, so it can be resolved with design / the scale owners (align the value, or add a scale step). Only fall back to a literal if the user confirms it's an accepted one-off, and leave a short comment in the code explaining why.
|
||||
|
||||
### Mantine implementation notes (reference)
|
||||
|
||||
|
||||
@@ -17,10 +17,10 @@ Adherence to `typescript-write` is the **highest-priority** review dimension: ra
|
||||
|
||||
Review in this priority order:
|
||||
|
||||
1. **Violations of [`typescript-write`](../typescript-write/SKILL.md) provisions** — no-`any`, type tightening, type modeling, null/undefined handling, naming, structure, comments. Highest priority; block on the no-`any` rule.
|
||||
1. **Violations of [`typescript-write`](../typescript-write/SKILL.md) provisions** — no-`any`, type tightening, type modeling, function signatures, null/undefined handling, naming, structure, comments. Highest priority; block on the no-`any` rule. Apply conditional guidance in context: explain the unsupported type guarantee or concrete readability problem, rather than treating every preference as a blanket ban.
|
||||
2. Compliance with `frontend/CLAUDE.md`.
|
||||
3. Readability and maintainability.
|
||||
4. Appropriate test coverage.
|
||||
4. Appropriate test coverage. For internal typed callers, avoid requesting tests solely for inputs the type system excludes. External API data, deserialised values, storage and JavaScript callers can violate annotations: test runtime validation and nontrivial assumptions at those boundaries. Types do not replace behavioural, security or data-integrity tests.
|
||||
|
||||
## Blind spots — act as the missing reviewer
|
||||
|
||||
@@ -31,3 +31,4 @@ These rarely surface in team reviews, so this skill should raise them. They are
|
||||
- **Security.** Evaluate potential security issues in new code.
|
||||
- **Bundle size.** Flag new large dependencies, default imports from icon or util libs, and heavy modules imported at route-load time.
|
||||
- **Analytics.** User-facing flows should emit tracking events. If a PR adds a new flow (button, modal, navigation) without a tracking event, ask whether one is expected.
|
||||
- **Public API surface** (embedding SDK). Consumers should be able to use public signatures and name types they need to import. Export those types deliberately and document public behaviour, including `@deprecated` for deprecated APIs; a referenced structural type does not automatically need its own named export.
|
||||
|
||||
@@ -20,20 +20,27 @@ description: Write TypeScript and JavaScript code following Metabase coding stan
|
||||
- **Avoid type casts and loose `unknown`** — fix the signature instead.
|
||||
- **If a function only needs one field of a wide object, accept that field** — not the wide object. The cast often disappears once the signature is right.
|
||||
- **Reach for `Partial<T>`, `Pick<T, K>`, `Record<K, V>`, and generics** before reaching for a cast.
|
||||
- **Match dictionary keys to the data.** Use a finite key union when the keys are known. Open dictionaries can use an index signature, `Record<string, T>`, or `Map`; index signatures still constrain values, and changing their spelling to `Record<string, T>` does not make missing-key access safe.
|
||||
- **Prefer making props/components generic** (`<T>`) when a value flows through unchanged and the caller knows the type.
|
||||
- **Prefer `unknown` over loose typing** and narrow before use — an `unknown` value forces a guard at the point of use.
|
||||
- **`satisfies` for object literals** that must conform without widening (config objects, lookup maps, discriminated literals) — better than `: T` (widens) or `as T` (unsafe).
|
||||
- **Avoid non-null assertions (`!`)**. Prefer a guard, early return, or `?.`. Use `!` only when non-nullness is provably true and localized, with a comment.
|
||||
- **Guard indexed lookups that may miss.** Arrays and dictionaries can return `undefined` even when the inferred type omits it. Use an iteration form that preserves the key/value relationship, and only assert `keyof` when the runtime keys are known to belong to the declared type.
|
||||
- **No redundant runtime coercion** — don't wrap already-typed values in `Number()` / `String()` / `Boolean()`.
|
||||
- **Type guards belong in `frontend/src/metabase-types/guards/`**. Do not redefine them locally.
|
||||
- **A cast you can't avoid needs a real justification comment.** The `metabase/no-unjustified-type-casts` rule accepts any preceding comment — state the actual reason the cast is safe. NEVER write the legacy `// Unjustified type cast. FIXME` placeholder; it exists only on casts that predated the rule, and copying it sneaks an unjustified cast past the linter. If you can't articulate why the cast is correct, the cast is wrong — fix the types.
|
||||
- **Keep unavoidable assertions local.** Isolate a repeated or complex assertion behind a helper when that makes its invariant easier to enforce. Test nontrivial runtime assumptions that justify it; a trivial assertion does not automatically need a new helper or test. Do not weaken a public signature just to silence implementation errors.
|
||||
|
||||
## Type modeling
|
||||
|
||||
- **Reuse existing types; don't re-declare them.** Use canonical IDs and domain entity types from `metabase-types/api` (`FieldId`, `TableId`, `ConcreteTableId`, `SchemaName`, …) and key data structures by them (`new Map<ConcreteTableId, …>()`). Don't duplicate generated/API types — compose or derive (`Pick`, `Omit`, indexed access `SomeType["field"]`, `ReturnType`).
|
||||
- **Use generics to allow TypeScript to infer correct types** when creating functions and components that need to be reusable and type-safe. Don't hesitate to introduce complex generics if they allow to derive types automatically instead of manual narrowing.
|
||||
- **Generics must make promises the implementation can keep.** A caller-selected `get<T>(): T` must not disguise an unchecked assertion about external data. Return `unknown` and validate, or accept a validator that establishes `T`. A factory such as `function empty<T>(): T[] { return []; }` is valid; judge the implementation, not how often `T` appears in the signature.
|
||||
- **Prefer an honest type over false precision.** Use generics when they express a real relationship. If a complex type cannot model the behaviour accurately, choose a simpler type or `unknown` with narrowing instead of asserting an unsupported guarantee.
|
||||
- **Model the actual data contract; keep types narrow.** Optional `field?: T` for a key that may be absent, `field: T | undefined` only when the key is always present but the value may be undefined, `| null` for explicit API nulls. Prefer domain unions over broad `string` / `number` / loose `Record`.
|
||||
- **Refer to API implementation** when defining or refining types to ensure they match the actual data structure. When considering a type cast, first consider if the type should be refined to match the actual data structure.
|
||||
- **Optionality must reflect absence.** Keep required fields required and optional fields optional. Normalise input when the application has a meaningful default, not merely to remove a type error. Preserve the actual wire shape in API types.
|
||||
- **Represent related absence together when modelling internal state.** If several fields exist or disappear together, consider a nullable containing object or a discriminated union. Do not reshape a raw API declaration unless the data actually has that shape.
|
||||
- **Prefer explicit special states when designing a format.** Use nullability or named union variants when sentinel values such as `-1` hide meaning. Preserve established protocol sentinel values unless the behaviour is deliberately changed, or normalise them at an explicit boundary.
|
||||
- **Discriminated unions for variant state, with exhaustive checks.** Model "one of N shapes" as a union with a literal discriminant rather than a bag of optional fields, and exhaust it with ts-pattern's `.exhaustive()` so adding a variant becomes a compile error:
|
||||
```ts
|
||||
import { match } from "ts-pattern";
|
||||
@@ -45,20 +52,30 @@ description: Write TypeScript and JavaScript code following Metabase coding stan
|
||||
.exhaustive(); // Compile-time guarantee all cases handled
|
||||
```
|
||||
- **Derive union types from constants** (`as const` + `typeof`/`keyof`) so the type and the values can't drift.
|
||||
- **`readonly` / immutability where mutation isn't intended** — component props, shared constants, exported config. Prefer `readonly T[]` / `ReadonlyArray<T>` for inputs you don't mutate.
|
||||
- **`readonly` / immutability where mutation isn't intended** — component props, shared constants, exported config, and unmutated parameters. Prefer `readonly T[]` / `ReadonlyArray<T>` for inputs you don't mutate. Functions that mutate caller-owned data should make that behaviour explicit.
|
||||
- **Construct complete, well-typed objects where practical.** Prefer an object expression when it avoids partially initialised state or assertions. Incremental construction is fine when it is clearer and maintains the type's invariants.
|
||||
- **Treat `metabase-lib` opaque types as black boxes.** Use `metabase-lib` functions to work with types such as `Lib.Query`, rather than casting into their internal representation.
|
||||
- **Type async and error states explicitly** (a discriminated union or the data-layer's typed result) — never leave loading/error/empty implicit.
|
||||
|
||||
## Function signatures
|
||||
|
||||
- **Make public contracts explicit where it improves stability and clarity.** Annotate parameters and return types at shared boundaries when useful; let local values infer. Use `satisfies` or an annotation when a declaration needs an explicit shape check.
|
||||
- **Accept the inputs the operation supports and return the most precise honest result.** Narrow avoidable uncertainty inside the function, but preserve meaningful nullability and union variants in its return type.
|
||||
- **Use named options when positional arguments are easy to confuse.** Consecutive arguments with the same type are a useful warning sign, not an automatic requirement to rewrite a clear API.
|
||||
- **Use `async`/`await` when it clarifies control flow or error handling.** Returning an existing promise directly is also valid; a Promise return type alone does not require adding `async`.
|
||||
|
||||
## Null and undefined
|
||||
|
||||
- **Narrow at the source**. If a value is optional only in a corner case, don't thread `undefined` through every layer — guard at the producer.
|
||||
- **Sensible defaults for optional values**. Use `?.` and `??` at the consumer.
|
||||
- **Lists should be filtered** before being used in a map or other iteration.
|
||||
- **Narrow nullable list elements when the operation requires present values.** Filter with a type guard when missing entries should be discarded; preserve them when their absence or position carries meaning.
|
||||
- **Avoid non-strict null comparisons** (`X != null`) when `X` can never be `null` — use a strict check or narrow the type. Use `checkNotNull` where necessary.
|
||||
- **Check actual nullability against API implementation**. Find the API endpoint implementation and check if the field can actually be null.
|
||||
|
||||
## Naming
|
||||
|
||||
- **Names describe the entity, not the mechanism**. A name must reflect what the value holds.
|
||||
- **Use domain vocabulary and include units where useful** (`timeoutMs`, `widthPx`, `temperatureC`). Prefer a more specific name when `Info`, `Data`, or `Entity` obscures the meaning, while keeping established terminology when it is clear.
|
||||
- **Align sibling concepts**: keep verb conventions consistent across a related API.
|
||||
- **No names that encode implementation history** rather than current meaning. Suffixes like `Base`, `New`, `Old`, `Initial` need a real semantic distinction, otherwise drop them.
|
||||
- **Avoid cryptic identifiers** (`v`, `n`, `$n`) for domain values; short names are fine only in tiny conventional contexts (loop index `i`, coordinates `x`/`y`, generic params `T`/`K`/`V`).
|
||||
@@ -78,3 +95,7 @@ description: Write TypeScript and JavaScript code following Metabase coding stan
|
||||
## Verify before done
|
||||
|
||||
- **Run the project type-check** when finished (see the shared TypeScript commands above).
|
||||
|
||||
## Related skills
|
||||
|
||||
- **[fix-api-contract](../fix-api-contract/SKILL.md)** - Investigate API contract checker diagnostics or bring an RTK endpoint under enforcement.
|
||||
|
||||
@@ -72,6 +72,7 @@
|
||||
:metabase/namespace-name {:level :warning}
|
||||
:metabase/no-jsqlparser-imports {:level :warning}
|
||||
:metabase/prefer-with-dynamic-fn-redefs {:level :warning}
|
||||
:metabase/t2-query-namespace {:level :warning}
|
||||
:metabase/toucan-model-ns {:level :warning}
|
||||
:metabase/require-shape-checker {:level :warning}
|
||||
:metabase/tests-must-live-in-known-modules {:level :warning}
|
||||
@@ -180,10 +181,16 @@
|
||||
clojure.core.async/take!
|
||||
clojure.core.async/to-chan!
|
||||
clojure.core.async/to-chan!!
|
||||
;; only writes .clj-kondo/ratchets.edn, from a :once fixture that finishes before the parallel tests
|
||||
;; writes the ratchet files from a :once fixture that finishes before the parallel tests
|
||||
dev.kondo-ratchet/fix!
|
||||
metabase.ai-tracing.core/record!
|
||||
metabase.api.macros.defendpoint.tools-manifest/assert-optional-fields-nullable!
|
||||
;; MCP v2 tool tests: call-tool! dispatches a tool with no shared mutable state across tests
|
||||
metabase.mcp.v2.tools.alert-test/call-tool!
|
||||
metabase.mcp.v2.tools.definitions-test/call-tool!
|
||||
metabase.mcp.v2.tools.metric-test/call-tool!
|
||||
;; the `!` is for the throw (a teaching error), not a mutation; no shared mutable state
|
||||
metabase.mcp.v2.tools.browse/validate-args-for-action!
|
||||
metabase.channel.core/send!
|
||||
metabase.channel.render.pdf/draw-too-large!
|
||||
metabase.channel.render.pdf/draw-no-results!
|
||||
@@ -217,7 +224,9 @@
|
||||
;; Safe in tests, where we typically use a private, temporary index per thread.
|
||||
metabase.search.core/reindex!
|
||||
metabase.util/run-count!
|
||||
next.jdbc/execute!}}
|
||||
next.jdbc/execute!
|
||||
;; pure validator: parses a keyword and throws on a bad one, touches no state
|
||||
metabase.mcp.v2.tools.question/check-semantic-type!}}
|
||||
|
||||
:metabase/i-like-making-cams-eyes-bleed-with-horrifically-long-tests
|
||||
{:level :warning
|
||||
@@ -280,10 +289,6 @@
|
||||
instaparse.core/transform
|
||||
(metabase.server.streaming-response/streaming-response)
|
||||
(metabase.driver.druid.query-processor-test/druid-query-returning-rows)
|
||||
(metabase.util.match/match-one)
|
||||
(metabase.util.match/match-many)
|
||||
(metabase.util.match/replace-in)
|
||||
(metabase.util.match/replace)
|
||||
(metabase.driver-api.core/match-one)
|
||||
(metabase.driver-api.core/match-many)
|
||||
(metabase.driver-api.core/replace)
|
||||
@@ -469,6 +474,7 @@
|
||||
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
|
||||
metabase-enterprise.tenants.pulse-api-test/with-tenant-pulse-fixture! clojure.core/fn
|
||||
metabase-enterprise.workspaces.test-util/with-resources! clojure.core/let
|
||||
metabase-enterprise.workspaces.test-util/with-workspaces! clojure.core/let
|
||||
metabase.actions.test-util/with-actions clojure.core/let
|
||||
@@ -493,6 +499,7 @@
|
||||
metabase.driver.sql-jdbc.connection/with-connection-spec-for-testing-connection clojure.core/let
|
||||
metabase.driver.sql-jdbc.execute.diagnostic/capturing-diagnostic-info clojure.core/fn
|
||||
metabase.driver-api.core/defsetting hooks.metabase.settings.models.setting/defsetting
|
||||
metabase.mcp.v2.registry/deftool clojure.core/defn
|
||||
metabase.models.json-migration/def-json-migration clj-kondo.lint-as/def-catch-all
|
||||
metabase.models.util.spec-update/define-spec clojure.core/def
|
||||
metabase.notification.test-util/with-card-notification clojure.core/let
|
||||
@@ -632,10 +639,50 @@
|
||||
metabase.lib.schema.mbql-clause/define-tuple-mbql-clause hooks.metabase.lib.schema.mbql-clause/define-mbql-clause
|
||||
methodical.core/defmethod hooks.metabase.toucan.table-name/lint-defmethod
|
||||
methodical.macros/defmethod hooks.metabase.toucan.table-name/lint-defmethod
|
||||
metabase.app-db.core/query hooks.metabase.toucan.db-ns/lint-query-call
|
||||
metabase.app-db.core/streaming-reducible-query hooks.metabase.toucan.db-ns/lint-query-call
|
||||
metabase.app-db.core/select-or-insert! hooks.metabase.toucan.db-ns/lint-query-call
|
||||
metabase.app-db.core/update-or-insert! hooks.metabase.toucan.db-ns/lint-query-call
|
||||
metabase.app-db.core/current-timestamp-string hooks.metabase.toucan.db-ns/lint-query-call
|
||||
metabase.app-db.query/query hooks.metabase.toucan.db-ns/lint-query-call
|
||||
metabase.app-db.query/streaming-reducible-query hooks.metabase.toucan.db-ns/lint-query-call
|
||||
metabase.app-db.query/select-or-insert! hooks.metabase.toucan.db-ns/lint-query-call
|
||||
metabase.app-db.query/update-or-insert! hooks.metabase.toucan.db-ns/lint-query-call
|
||||
metabase.app-db.query/current-timestamp-string hooks.metabase.toucan.db-ns/lint-query-call
|
||||
toucan2.core/count hooks.metabase.toucan.db-ns/lint-query-call
|
||||
toucan2.core/delete! hooks.metabase.toucan.db-ns/lint-query-call
|
||||
toucan2.core/exists? hooks.metabase.toucan.db-ns/lint-query-call
|
||||
toucan2.core/insert! hooks.metabase.toucan.db-ns/lint-query-call
|
||||
toucan2.core/insert-returning-instance! hooks.metabase.toucan.db-ns/lint-query-call
|
||||
toucan2.core/insert-returning-instances! hooks.metabase.toucan.db-ns/lint-query-call
|
||||
toucan2.core/insert-returning-pk! hooks.metabase.toucan.db-ns/lint-query-call
|
||||
toucan2.core/insert-returning-pks! hooks.metabase.toucan.db-ns/lint-query-call
|
||||
toucan2.core/query hooks.metabase.toucan.db-ns/lint-query-call
|
||||
toucan2.core/query-one hooks.metabase.toucan.db-ns/lint-query-call
|
||||
toucan2.core/reducible-query hooks.metabase.toucan.db-ns/lint-query-call
|
||||
toucan2.core/reducible-select hooks.metabase.toucan.db-ns/lint-query-call
|
||||
toucan2.core/reducible-update hooks.metabase.toucan.db-ns/lint-query-call
|
||||
toucan2.core/reducible-update-returning-pks hooks.metabase.toucan.db-ns/lint-query-call
|
||||
toucan2.core/save! hooks.metabase.toucan.db-ns/lint-query-call
|
||||
toucan2.core/select hooks.metabase.toucan.db-ns/lint-query-call
|
||||
toucan2.core/select-fn->fn hooks.metabase.toucan.db-ns/lint-query-call
|
||||
toucan2.core/select-fn->pk hooks.metabase.toucan.db-ns/lint-query-call
|
||||
toucan2.core/select-fn-reducible hooks.metabase.toucan.db-ns/lint-query-call
|
||||
toucan2.core/select-fn-set hooks.metabase.toucan.db-ns/lint-query-call
|
||||
toucan2.core/select-fn-vec hooks.metabase.toucan.db-ns/lint-query-call
|
||||
toucan2.core/select-one hooks.metabase.toucan.db-ns/lint-query-call
|
||||
toucan2.core/select-one-fn hooks.metabase.toucan.db-ns/lint-query-call
|
||||
toucan2.core/select-one-pk hooks.metabase.toucan.db-ns/lint-query-call
|
||||
toucan2.core/select-pk->fn hooks.metabase.toucan.db-ns/lint-query-call
|
||||
toucan2.core/select-pks-set hooks.metabase.toucan.db-ns/lint-query-call
|
||||
toucan2.core/select-pks-vec hooks.metabase.toucan.db-ns/lint-query-call
|
||||
toucan2.core/update! hooks.metabase.toucan.db-ns/lint-query-call
|
||||
toucan2.core/update-returning-pks! hooks.metabase.toucan.db-ns/lint-query-call
|
||||
metabase.lib.test-util.macros/$ids hooks.metabase.test.data/$ids
|
||||
metabase.lib.test-util.macros/mbql-query hooks.metabase.test.data/mbql-query
|
||||
metabase.lib.test-util.macros/mbql-5-query hooks.metabase.test.data/mbql-query
|
||||
metabase.lib.test-util.macros/with-testing-against-standard-queries hooks.metabase.lib.test-util.macros/with-testing-against-standard-queries
|
||||
metabase.mcp.v2.tools.parameters-test/with-fixtures hooks.common/with-one-binding
|
||||
metabase.metrics.api-dimension-test/with-seeded-metric hooks.common/with-one-binding
|
||||
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
|
||||
@@ -735,6 +782,7 @@
|
||||
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/matches? hooks.metabase.util.match/matches?
|
||||
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
|
||||
@@ -803,7 +851,7 @@
|
||||
:name test-namespaces}
|
||||
{:pattern "^(?!.*-test(?:\\..*)?$)(?!.*test-util)(?!^metabase\\.test.*).*$"
|
||||
:name source-namespaces}
|
||||
{:pattern "(?:(?:^metabase\\.cmd.*)|(?:^build.*)|(?:^metabuild-common.*)|(?:^release.*)|(?:^i18n.*)|(?:^lint-migrations-file$))|(?:^mage.*)|(?:^load-namespaces.*)"
|
||||
{:pattern "(?:(?:^metabase\\.cmd.*)|(?:^build.*)|(?:^metabuild-common.*)|(?:^release.*)|(?:^i18n.*)|(?:^lint-migrations-file$))|(?:^mage.*)|(?:^load-namespaces.*)|(?:^dev\\..*)"
|
||||
:name printable-namespaces}
|
||||
{:pattern "^metabase\\.(?:(?:lib)|(?:legacy-mbql))\\..*"
|
||||
:name metabase-lib}
|
||||
@@ -840,7 +888,13 @@
|
||||
{:pattern "^metabase(?:((?:-enterprise\\.sandbox)?\\.query-processor)|(?:\\.driver\\.sql(?!server)(?!ite))).*-test$"
|
||||
:name general-qp-and-driver-test-namespaces}
|
||||
{:pattern "(?:^mage.*)"
|
||||
:name mage-namespaces}]
|
||||
:name mage-namespaces}
|
||||
;;
|
||||
;; the app-db module is the application database layer itself (connections, migrations, encryption), so it gets to
|
||||
;; run Toucan 2 queries anywhere.
|
||||
;;
|
||||
{:pattern "^metabase\\.app-db\\..*"
|
||||
:name app-db-namespaces}]
|
||||
|
||||
:config-in-ns
|
||||
{test-namespaces
|
||||
@@ -850,6 +904,7 @@
|
||||
:private-call {:level :off}
|
||||
:metabase/defsetting-must-specify-export {:level :off}
|
||||
:metabase/defsetting-namespace {:level :off}
|
||||
:metabase/t2-query-namespace {:level :off}
|
||||
:metabase/test-helpers-use-non-thread-safe-functions {:level :warning} ; more config in `.clj-kondo/config/test-helpers/config.edn`
|
||||
|
||||
:discouraged-var
|
||||
@@ -857,6 +912,9 @@
|
||||
metabase.lib.convert/->legacy-MBQL {:level :off}
|
||||
metabase.lib.core/->legacy-MBQL {:level :off}}}}
|
||||
|
||||
app-db-namespaces
|
||||
{:linters {:metabase/t2-query-namespace {:level :off}}}
|
||||
|
||||
;; .cljc tests run in both Clojure and ClojureScript — `with-dynamic-fn-redefs` is JVM-only,
|
||||
;; so leave these alone.
|
||||
metabase.lib.aggregation-test
|
||||
|
||||
7
.clj-kondo/config/modules/ratchets.edn
Normal file
@@ -0,0 +1,7 @@
|
||||
;; Budgets for escape hatches in config.edn. Counts may fall but not exceed these values.
|
||||
;; Leave reductions to the post-merge shrink workflow; explain increases in the PR.
|
||||
{:api-any 1
|
||||
:friend-edges 1
|
||||
:model-imports-bypass 2
|
||||
:ns-prefixes 81
|
||||
:uses-any 4}
|
||||
@@ -5,7 +5,7 @@
|
||||
;; Any ignore outside :comment-exempt needs an explanatory comment directly above or trailing on its line.
|
||||
;; `./bin/mage kondo-ratchets-shrink` lowers budgets unless `--seed` explicitly adds or raises one.
|
||||
;; The workflow runs it on master, so feature branches do not need to record reductions.
|
||||
;; Raising or adding a budget (`--seed` for inline ignores, a manual edit for config) or widening the
|
||||
;; Raising or adding a budget (`--seed` for inline ignores, a manual edit otherwise) or widening the
|
||||
;; exemptions must be explained in the PR.
|
||||
;; :all is the vector-less ignore form, which suppresses every linter on the next form.
|
||||
{:ignore-counts {:aliased-namespace-symbol 3
|
||||
@@ -14,25 +14,25 @@
|
||||
:consistent-alias 2
|
||||
:def-fn 1
|
||||
:deprecated-namespace 95
|
||||
:deprecated-var 86
|
||||
:deprecated-var 96
|
||||
:discouraged-java-method 4
|
||||
:discouraged-namespace 71
|
||||
:discouraged-var 130
|
||||
:duplicate-require 1
|
||||
:equals-true 1
|
||||
:main-without-gen-class 1
|
||||
:metabase/defsetting-namespace 2
|
||||
:metabase/defsetting-namespace 1
|
||||
:metabase/disallow-hardcoded-driver-names-in-tests 34
|
||||
:metabase/discourage-millis-duration 1
|
||||
:metabase/i-like-making-cams-eyes-bleed-with-horrifically-long-tests 13
|
||||
:metabase/modules 23
|
||||
:metabase/modules 24
|
||||
:metabase/namespace-name 4
|
||||
:metabase/prefer-with-dynamic-fn-redefs 28
|
||||
:metabase/test-helpers-use-non-thread-safe-functions 18
|
||||
:metabase/validate-defendpoint-has-response-schema 439
|
||||
:metabase/validate-defendpoint-query-params-use-kebab-case 50
|
||||
:metabase/validate-defendpoint-has-response-schema 427
|
||||
:metabase/validate-defendpoint-query-params-use-kebab-case 48
|
||||
:metabase/validate-defendpoint-route-uses-kebab-case 44
|
||||
:metabase/validate-deftest 10
|
||||
:metabase/validate-deftest 9
|
||||
:missing-docstring 12
|
||||
:missing-protocol-method 3
|
||||
:non-arg-vec-return-type-hint 1
|
||||
@@ -61,6 +61,7 @@
|
||||
:metabase/defsetting-must-specify-export 1
|
||||
:metabase/defsetting-namespace 1
|
||||
:metabase/prefer-with-dynamic-fn-redefs 3
|
||||
:metabase/t2-query-namespace 2
|
||||
:missing-docstring 1
|
||||
:private-call 1
|
||||
:redundant-ignore 1
|
||||
@@ -68,7 +69,7 @@
|
||||
:refer-all 1
|
||||
:type-mismatch 1
|
||||
:unexpected-recur 1
|
||||
:unresolved-symbol 19
|
||||
:unresolved-symbol 15
|
||||
:unresolved-var 1
|
||||
:unused-referred-var 4}
|
||||
:comment-exempt #{:deprecated-namespace
|
||||
|
||||
@@ -92,7 +92,7 @@
|
||||
(defn- lint-modules [ns-form-node config]
|
||||
(let [ns-symb (ns-form-node->ns-symb ns-form-node)]
|
||||
(when-not (modules/ignored-namespace? config ns-symb)
|
||||
(when-let [current-module (modules/module ns-symb)]
|
||||
(when-let [current-module (modules/module config ns-symb)]
|
||||
(let [required-namespace-symb-nodes (-> ns-form-node
|
||||
ns-form-node->require-node
|
||||
require-node->namespace-symb-nodes)]
|
||||
|
||||
@@ -11,10 +11,10 @@
|
||||
(every? simple-symbol? required-namespaces)]}
|
||||
(when-not (modules/ignored-namespace? config current-ns)
|
||||
;; ignore namespaces outside of the module system
|
||||
(when-let [current-module (modules/module current-ns)]
|
||||
(when-let [current-module (modules/module config current-ns)]
|
||||
(doseq [required-namespace required-namespaces]
|
||||
;; ignore namespaces outside of the module system.
|
||||
(when (modules/module required-namespace)
|
||||
(when (modules/module config required-namespace)
|
||||
(when-let [error (modules/usage-error config current-module required-namespace)]
|
||||
(hooks/reg-finding! (assoc (meta node)
|
||||
:message error
|
||||
@@ -22,7 +22,7 @@
|
||||
|
||||
(defn- lint-dynamic-require* [node current-ns config]
|
||||
(when (and (not (modules/ignored-namespace? config current-ns))
|
||||
(modules/module current-ns))
|
||||
(modules/module config current-ns))
|
||||
(hooks/reg-finding! (assoc (meta node)
|
||||
:message "Module dependency cannot be statically determined from a dynamic require"
|
||||
:type :metabase/modules))))
|
||||
|
||||
@@ -194,7 +194,7 @@
|
||||
(not (contains? @warned-namespaces ns-symb)))
|
||||
(swap! warned-namespaces conj ns-symb)
|
||||
(let [known-modules (set (keys (:metabase/modules (modules/config input))))
|
||||
current-module (modules/module ns-symb)]
|
||||
current-module (modules/module (modules/config input) ns-symb)]
|
||||
(when (or (not current-module)
|
||||
(not (contains? known-modules current-module)))
|
||||
(hooks/reg-finding! (assoc (meta node)
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
(ns hooks.common.modules
|
||||
"Resolves namespaces to modules and enforces module boundaries.
|
||||
|
||||
The Kondo linter, config generator, and CI tooling share this namespace so
|
||||
they apply the same rules."
|
||||
(:require
|
||||
[clojure.string :as str]))
|
||||
|
||||
@@ -12,25 +16,135 @@
|
||||
(merge (get-in config [:linters :metabase/modules])
|
||||
(select-keys config [:metabase/modules])))
|
||||
|
||||
(defn module
|
||||
;;;; Module tree. These functions take the `:metabase/modules` map, or the prefix map built from it.
|
||||
;;;;
|
||||
;;;; Dots express nesting: `lib.schema` is a child of `lib`. A declared
|
||||
;;;; `enterprise/X` module is also a child of OSS module `X`. A namespace
|
||||
;;;; belongs to the module with the most specific matching `:ns-prefix`.
|
||||
|
||||
(defn default-ns-prefix
|
||||
"Namespace prefix a module owns unless it sets `:ns-prefix`: `metabase.lib.schema` for `lib.schema`,
|
||||
`metabase-enterprise.foo` for `enterprise/foo`."
|
||||
[module]
|
||||
(str (if (= "enterprise" (namespace module)) "metabase-enterprise." "metabase.") (name module)))
|
||||
|
||||
(defn module-ns-prefix
|
||||
"Namespace prefix `module` owns: its `:ns-prefix`, else [[default-ns-prefix]]."
|
||||
[modules module]
|
||||
(or (get-in modules [module :ns-prefix])
|
||||
(default-ns-prefix module)))
|
||||
|
||||
(defn build-prefix->module
|
||||
"Map each declared namespace prefix to its module, for [[declared-module]]."
|
||||
[modules]
|
||||
(into {}
|
||||
(map (fn [module] [(module-ns-prefix modules module) module]))
|
||||
(keys modules)))
|
||||
|
||||
(defn declared-module
|
||||
"Resolve `ns-symb` to its declared module, or `nil`.
|
||||
|
||||
Chooses the most specific dotted prefix and ignores a trailing `-test`."
|
||||
[prefix->module ns-symb]
|
||||
(loop [candidate (str/replace (str ns-symb) #"-test$" "")]
|
||||
(or (get prefix->module candidate)
|
||||
(when-let [dot (str/last-index-of candidate ".")]
|
||||
(recur (subs candidate 0 dot))))))
|
||||
|
||||
(defn resolve-module
|
||||
"E.g.
|
||||
|
||||
(module 'metabase.qp.middleware.wow) => 'qp
|
||||
(module 'metabase-enterprise.whatever.core) => enterprise/whatever"
|
||||
[ns-symb]
|
||||
(resolve-module prefix->module 'metabase.qp.middleware.wow) => 'qp
|
||||
(resolve-module prefix->module 'metabase-enterprise.whatever.core) => enterprise/whatever
|
||||
|
||||
An unmatched Metabase namespace resolves to its first segment so the linter can report an undeclared module."
|
||||
[prefix->module ns-symb]
|
||||
{:pre [(simple-symbol? ns-symb)]}
|
||||
;; treat something like `metabase.driver-test` (for a module that hasn't fully been updated to use `.core`
|
||||
;; namespaces) as being in the `driver` module
|
||||
(let [ns-symb (if (str/ends-with? (name ns-symb) "-test")
|
||||
(symbol (str/replace (name ns-symb) #"-test$" ""))
|
||||
ns-symb)]
|
||||
(or (some->> (re-find #"^metabase-enterprise\.([^.]+)" (str ns-symb))
|
||||
(or (declared-module prefix->module ns-symb)
|
||||
(some->> (re-find #"^metabase-enterprise\.([^.]+)" (str ns-symb))
|
||||
second
|
||||
(symbol "enterprise"))
|
||||
(some-> (re-find #"^metabase\.([^.]+)" (str ns-symb))
|
||||
second
|
||||
symbol))))
|
||||
|
||||
(defn parent-module
|
||||
"The direct parent of `module`, or `nil` when it is top-level."
|
||||
[modules module]
|
||||
(let [module-name (name module)]
|
||||
(if-let [dot (str/last-index-of module-name ".")]
|
||||
(symbol (namespace module) (subs module-name 0 dot))
|
||||
(when (= "enterprise" (namespace module))
|
||||
(let [oss (symbol module-name)]
|
||||
(when (contains? modules oss)
|
||||
oss))))))
|
||||
|
||||
(defn descendant-of?
|
||||
"Whether `module` is `ancestor` or one of its descendants."
|
||||
[modules module ancestor]
|
||||
(boolean (some #{ancestor} (take-while some? (iterate #(parent-module modules %) module)))))
|
||||
|
||||
(defn- rest-module? [module]
|
||||
(re-find #"[.-]rest$" (str module)))
|
||||
|
||||
(defn- exports-child?
|
||||
[modules parent child]
|
||||
(or (contains? (set (get-in modules [parent :module-exports])) child)
|
||||
;; A `.rest` child is its parent's public HTTP surface.
|
||||
(rest-module? child)
|
||||
;; OSS module `X` implicitly exports its `enterprise/X` companion.
|
||||
(and (not= (namespace parent) (namespace child))
|
||||
(contains? modules child))))
|
||||
|
||||
(defn- blocking-export
|
||||
"The first missing export from `module` toward the root.
|
||||
|
||||
Returns `{:ancestor parent, :child child}`, or `nil` when every link is
|
||||
exported."
|
||||
[modules module]
|
||||
(loop [child module]
|
||||
(when-let [ancestor (parent-module modules child)]
|
||||
(if (exports-child? modules ancestor child)
|
||||
(recur ancestor)
|
||||
{:ancestor ancestor, :child child}))))
|
||||
|
||||
(defn namability-error
|
||||
"Explain why `caller` may not refer to `target` in `:uses`, or return `nil` if it may.
|
||||
|
||||
A nested module is private to the nearest ancestor with a missing export.
|
||||
Exporting every link makes it public."
|
||||
[modules caller target]
|
||||
(when-let [{:keys [ancestor child]} (blocking-export modules target)]
|
||||
(when-not (descendant-of? modules caller ancestor)
|
||||
(format (str "Module %s is nested and not exported by its ancestors; %s may not use it. Add %s to %s's "
|
||||
":module-exports, or move the caller into the %s subtree. [:metabase/modules %s :module-exports]")
|
||||
target caller child ancestor ancestor ancestor))))
|
||||
|
||||
;;;; Lint rules. These functions take the full linter config.
|
||||
|
||||
(defonce ^:private prefix->module-cache
|
||||
(atom nil))
|
||||
|
||||
(defn- prefix->module
|
||||
"[[build-prefix->module]] for `config`, cached because Kondo hands every hook call the same `:metabase/modules` map."
|
||||
[config]
|
||||
(let [modules (:metabase/modules config)
|
||||
cached @prefix->module-cache]
|
||||
(if (identical? modules (:modules cached))
|
||||
(:prefix->module cached)
|
||||
(:prefix->module (reset! prefix->module-cache {:modules modules
|
||||
:prefix->module (build-prefix->module modules)})))))
|
||||
|
||||
(defn module
|
||||
"Resolve `ns-symb` to its module under `config`; see [[resolve-module]]."
|
||||
[config ns-symb]
|
||||
(resolve-module (prefix->module config) ns-symb))
|
||||
|
||||
(defn- module-api-namespaces
|
||||
"Set of API namespace symbols for a given module. `:any` means you can use anything, there are no API namespaces for
|
||||
this module (yet). If unspecified, the default is just the `<module>.core` namespace."
|
||||
@@ -44,9 +158,7 @@
|
||||
module-config
|
||||
|
||||
:else
|
||||
(let [ns-prefix (if (= (namespace module) "enterprise")
|
||||
(str "metabase-enterprise." (name module))
|
||||
(name module))]
|
||||
(let [ns-prefix (module-ns-prefix (:metabase/modules config) module)]
|
||||
#{(symbol (str ns-prefix ".api"))
|
||||
(symbol (str ns-prefix ".core"))
|
||||
(symbol (str ns-prefix ".init"))}))))
|
||||
@@ -68,15 +180,14 @@
|
||||
(contains? (set allowed-modules) required-module))))
|
||||
|
||||
(defn- allowed-module-namespace? [config current-module ns-symb]
|
||||
(let [module (module ns-symb)
|
||||
(let [module (module config ns-symb)
|
||||
module-api-namespaces (module-api-namespaces config module)
|
||||
module-friends (module-friends config module)]
|
||||
(or (nil? module-api-namespaces)
|
||||
(contains? module-api-namespaces ns-symb)
|
||||
(contains? module-friends current-module))))
|
||||
|
||||
(defn- rest-module? [module]
|
||||
(str/ends-with? module "-rest"))
|
||||
(contains? module-friends current-module)
|
||||
;; a child may use its ancestors' internals; a parent still goes through its child's `:api`
|
||||
(descendant-of? (:metabase/modules config) current-module module))))
|
||||
|
||||
(defn- routes-module? [module]
|
||||
(str/ends-with? module "-routes"))
|
||||
@@ -89,7 +200,7 @@
|
||||
error type if there is one, otherwise `nil` if there are no errors."
|
||||
[config current-module required-namespace]
|
||||
;; ignore stuff not in a module i.e. non-Metabase stuff.
|
||||
(when-let [required-module (module required-namespace)]
|
||||
(when-let [required-module (module config required-namespace)]
|
||||
(when-not (= current-module required-module)
|
||||
(cond
|
||||
(not (allowed-module? config current-module required-module))
|
||||
@@ -113,4 +224,8 @@
|
||||
required-module
|
||||
current-module
|
||||
required-module
|
||||
(symbol (str/replace required-module #"-rest$" "")))))))
|
||||
(symbol (str/replace required-module #"[.-]rest$" "")))
|
||||
|
||||
;; Config tests check explicit `:uses`; wildcard callers are checked here.
|
||||
(= :any (allowed-modules config current-module))
|
||||
(namability-error (:metabase/modules config) current-module required-module)))))
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
[hooks.common :as common]))
|
||||
|
||||
(defn lint-defsetting-namespace [node context]
|
||||
(when-not (re-matches #"^metabase(?:-enterprise)?\.[^\.]+\.settings$" (name (:ns context)))
|
||||
(when-not (re-matches #"^metabase(?:-enterprise)?\.([^\.]+\.)+settings$" (name (:ns context)))
|
||||
(hooks/reg-finding! (assoc (meta node)
|
||||
:message "All defsettings should live in metabase[-enterprise].<module>.settings namespaces"
|
||||
:message "All defsettings should live in a namespace ending in .settings under metabase[-enterprise]"
|
||||
:type :metabase/defsetting-namespace))))
|
||||
|
||||
;;; TODO -- move this into a Kondo config file in `.clj-kondo/config/`
|
||||
|
||||
45
.clj-kondo/src/hooks/metabase/toucan/db_ns.clj
Normal file
@@ -0,0 +1,45 @@
|
||||
(ns hooks.metabase.toucan.db-ns
|
||||
"Lint that application database query calls -- Toucan 2's `t2/select`, `t2/query`, `t2/insert!`, `t2/update!`,
|
||||
`t2/delete!` and friends, plus the `metabase.app-db.core` wrappers around them such as `mdb/query` and
|
||||
`mdb/update-or-insert!` -- live in a module's `db` namespace, i.e. `metabase[-enterprise].<module>.db` (or `metabase.driver.<driver>.db` for
|
||||
driver modules). Every other namespace in a module goes through those functions instead of talking to the
|
||||
application database directly.
|
||||
|
||||
Registered as an `:analyze-call` hook on each of those functions in `.clj-kondo/config.edn`. The hook
|
||||
returns its input unchanged so Kondo's normal analysis of the call (arity, var usage) still runs."
|
||||
(:require
|
||||
[clj-kondo.hooks-api :as hooks]
|
||||
[hooks.common.modules :as modules]))
|
||||
|
||||
(def ^:private driver-db-namespace
|
||||
"Matches `metabase.driver.<driver>.db`; individual drivers are not modules."
|
||||
#"^metabase\.driver\.[^.]+\.db$")
|
||||
|
||||
(defn- db-namespace? [config ns-sym]
|
||||
(let [ns-str (name ns-sym)]
|
||||
(boolean
|
||||
;; Resolve ownership first: a nested `.db` name alone does not make a module.
|
||||
(or (when-let [module (modules/module config ns-sym)]
|
||||
(= ns-str (str (modules/module-ns-prefix (:metabase/modules config) module) ".db")))
|
||||
(re-matches driver-db-namespace ns-str)))))
|
||||
|
||||
(defn- test-file?
|
||||
"Whether `filename` is in a test source tree. Test namespaces are exempt through the `test-namespaces` group in
|
||||
`config.edn`, but helper namespaces like `metabase.sso.test-helpers` don't match that group's pattern."
|
||||
[filename]
|
||||
(boolean (and filename (re-find #"(?:^|/)test/" filename))))
|
||||
|
||||
(defn lint-query-call
|
||||
"Register a `:metabase/t2-query-namespace` finding when a Toucan 2 query call appears outside a `<module>.db`
|
||||
namespace."
|
||||
[{:keys [node ns filename] :as input}]
|
||||
(when (and ns
|
||||
(not (db-namespace? (modules/config input) ns))
|
||||
(not (test-file? filename)))
|
||||
(let [fn-node (first (:children node))]
|
||||
(hooks/reg-finding!
|
||||
(assoc (meta fn-node)
|
||||
:message (format "Application database query calls like `%s` must live in metabase[-enterprise].<module>.db (or metabase.driver.<driver>.db) namespaces"
|
||||
(hooks/sexpr fn-node))
|
||||
:type :metabase/t2-query-namespace))))
|
||||
input)
|
||||
@@ -124,6 +124,25 @@
|
||||
(concat [(api/token-node 'clojure.core/or)] result-exprs [opaque-node]))))]
|
||||
{:node new-node})))
|
||||
|
||||
(defn matches?
|
||||
"clj-kondo :analyze-call hook for matches?. Similar to `match-one`, but supports just a single clause with no result."
|
||||
[{:keys [node]}]
|
||||
(let [[_ value pat] (:children node)]
|
||||
(let [all-bindings (->> (collect-bindings pat)
|
||||
dedupe-bindings)
|
||||
special-bindings [(api/token-node '_) value
|
||||
(api/token-node '&match) opaque-node
|
||||
;; Pretend to use &match so that Kondo doesn't complain about it.
|
||||
(api/token-node '_) (api/token-node '&match)]
|
||||
binding-nodes (into special-bindings all-bindings)
|
||||
new-node (api/list-node
|
||||
(list
|
||||
(api/token-node 'let)
|
||||
(api/vector-node binding-nodes)
|
||||
(api/list-node
|
||||
(list opaque-node))))]
|
||||
{:node new-node})))
|
||||
|
||||
(def match-many match-one)
|
||||
(def replace match-one)
|
||||
|
||||
|
||||
@@ -5,6 +5,6 @@
|
||||
|
||||
(deftest ^:parallel module-test
|
||||
(are [symb expected] (= expected
|
||||
(hooks.common.modules/module symb))
|
||||
(hooks.common.modules/module {} symb))
|
||||
'metabase.qp.middleware.wow 'qp
|
||||
'metabase-enterprise.whatever.core 'enterprise/whatever))
|
||||
|
||||
73
.clj-kondo/test/hooks/metabase/toucan/db_ns_test.clj
Normal file
@@ -0,0 +1,73 @@
|
||||
(ns hooks.metabase.toucan.db-ns-test
|
||||
(:require
|
||||
[clj-kondo.hooks-api :as hooks]
|
||||
[clj-kondo.impl.utils]
|
||||
[clojure.test :refer :all]
|
||||
[hooks.metabase.toucan.db-ns :as toucan.db-ns]))
|
||||
|
||||
(defn- lint-query-call [form ns-sym & [filename modules]]
|
||||
(binding [clj-kondo.impl.utils/*ctx* {:config {:linters {:metabase/t2-query-namespace {:level :warning}}}
|
||||
:ignores (atom nil)
|
||||
:findings (atom [])
|
||||
:namespaces (atom {})}]
|
||||
(let [input {:node (hooks/parse-string (pr-str form))
|
||||
:ns ns-sym
|
||||
:filename (or filename "src/metabase/foo/bar.clj")
|
||||
:config {:metabase/modules modules}}
|
||||
output (toucan.db-ns/lint-query-call input)]
|
||||
(is (identical? (:node input) (:node output))
|
||||
"the hook must return the node unchanged so Kondo's normal analysis still runs")
|
||||
@(:findings clj-kondo.impl.utils/*ctx*))))
|
||||
|
||||
(deftest ^:parallel t2-query-must-live-in-db-namespace-test
|
||||
(testing "a query call outside a db namespace is flagged"
|
||||
(is (=? [{:type :metabase/t2-query-namespace
|
||||
:message #".*`t2/select-one`.*"}]
|
||||
(lint-query-call '(t2/select-one :model/Card :id 1) 'metabase.queries.models.card))))
|
||||
(testing "metabase.<module>.db is allowed"
|
||||
(is (empty? (lint-query-call '(t2/select-one :model/Card :id 1) 'metabase.queries.db))))
|
||||
(testing "metabase-enterprise.<module>.db is allowed"
|
||||
(is (empty? (lint-query-call '(t2/select-one :model/Card :id 1) 'metabase-enterprise.sandbox.db))))
|
||||
(testing "metabase.driver.<driver>.db is allowed for driver modules"
|
||||
(is (empty? (lint-query-call '(t2/select-one :model/Database :id 1) 'metabase.driver.bigquery-cloud-sdk.db))))
|
||||
(testing "a nested db namespace is not a module db namespace"
|
||||
(is (=? [{:type :metabase/t2-query-namespace}]
|
||||
(lint-query-call '(t2/query {:select [:*]}) 'metabase.queries.models.db))))
|
||||
(testing "a namespace merely ending in db is not a module db namespace"
|
||||
(is (=? [{:type :metabase/t2-query-namespace}]
|
||||
(lint-query-call '(t2/delete! :model/Card :id 1) 'metabase.queries.dashboards-db))))
|
||||
(testing "files in a test source tree are exempt even when the namespace name doesn't look like a test"
|
||||
(is (empty? (lint-query-call '(t2/update! :model/User 1 {:sso_source nil}) 'metabase.sso.test-helpers
|
||||
"test/metabase/sso/test_helpers.clj")))
|
||||
(is (empty? (lint-query-call '(t2/select :model/Card) 'metabase-enterprise.remote-sync.test-helpers
|
||||
"/repo/enterprise/backend/test/metabase_enterprise/remote_sync/test_helpers.clj"))))
|
||||
(testing "app-db wrappers around Toucan 2 are reported the same way"
|
||||
(is (=? [{:type :metabase/t2-query-namespace
|
||||
:message #".*`mdb/update-or-insert!`.*"}]
|
||||
(lint-query-call '(mdb/update-or-insert! :model/Foo {:id 1}) 'metabase.foo.models.foo))))
|
||||
(testing "fully-qualified calls are reported by their written name"
|
||||
(is (=? [{:type :metabase/t2-query-namespace
|
||||
:message #".*`toucan2.core/insert!`.*"}]
|
||||
(lint-query-call '(toucan2.core/insert! :model/Card {}) 'metabase.queries.models.card)))))
|
||||
|
||||
(deftest ^:parallel t2-query-namespace-follows-the-module-tree-test
|
||||
(let [modules '{queries {}
|
||||
metabot {}
|
||||
metabot.llm {}
|
||||
lib.be {:ns-prefix "metabase.lib-be"}
|
||||
enterprise/sandbox {}}]
|
||||
(testing "a nested module owns its own db namespace"
|
||||
(is (empty? (lint-query-call '(t2/select :model/Card) 'metabase.metabot.llm.db nil modules))))
|
||||
(testing "the parent's db namespace stays its own"
|
||||
(is (empty? (lint-query-call '(t2/select :model/Card) 'metabase.metabot.db nil modules))))
|
||||
(testing "a module with an explicit :ns-prefix is resolved through it, not its dotted name"
|
||||
(is (empty? (lint-query-call '(t2/select :model/Card) 'metabase.lib-be.db nil modules)))
|
||||
(is (=? [{:type :metabase/t2-query-namespace}]
|
||||
(lint-query-call '(t2/select :model/Card) 'metabase.lib.be.db nil modules))))
|
||||
(testing "a db namespace under a directory naming no module is still a finding"
|
||||
(is (=? [{:type :metabase/t2-query-namespace}]
|
||||
(lint-query-call '(t2/query {:select [:*]}) 'metabase.queries.models.db nil modules)))
|
||||
(is (=? [{:type :metabase/t2-query-namespace}]
|
||||
(lint-query-call '(t2/query {:select [:*]}) 'metabase.metabot.llm.models.db nil modules))))
|
||||
(testing "enterprise modules resolve through the metabase-enterprise root"
|
||||
(is (empty? (lint-query-call '(t2/select :model/Card) 'metabase-enterprise.sandbox.db nil modules))))))
|
||||
1
.gitattributes
vendored
@@ -12,4 +12,3 @@
|
||||
# CI automation, not edited by hand. Mark generated so tools/humans don't review or diff them as source.
|
||||
docs/api.json linguist-generated
|
||||
docs/api.html linguist-generated
|
||||
resources/openapi/openapi.json linguist-generated
|
||||
|
||||
3
.github/actionlint.yaml
vendored
@@ -14,3 +14,6 @@ paths:
|
||||
.github/workflows/lint.yml:
|
||||
ignore:
|
||||
- 'step must run script with "run" section or run action with "uses" section'
|
||||
.github/workflows/pr.checks.yml:
|
||||
ignore:
|
||||
- 'step must run script with "run" section or run action with "uses" section'
|
||||
|
||||
@@ -15,6 +15,10 @@ inputs:
|
||||
description: OpenLDAP container
|
||||
required: true
|
||||
default: false
|
||||
mocksaml:
|
||||
description: Mock SAML container
|
||||
required: true
|
||||
default: false
|
||||
postgres:
|
||||
description: Postgres container
|
||||
required: true
|
||||
@@ -73,6 +77,7 @@ runs:
|
||||
[ "${{ inputs.mongo }}" = true ] && SERVICES+=(mongo-sample)
|
||||
[ "${{ inputs.maildev }}" = true ] && SERVICES+=(maildev maildev-ssl)
|
||||
[ "${{ inputs.openldap }}" = true ] && SERVICES+=(ldap)
|
||||
[ "${{ inputs.mocksaml }}" = true ] && SERVICES+=(saml-idp)
|
||||
[ "${{ inputs.webhook }}" = true ] && SERVICES+=(webhook-tester)
|
||||
[ "${{ inputs.snowplow }}" = true ] && SERVICES+=(snowplow-micro)
|
||||
|
||||
@@ -109,6 +114,12 @@ runs:
|
||||
echo -e "${G}Openldap is up and running!"
|
||||
fi
|
||||
|
||||
if ${{ inputs.mocksaml }}; then
|
||||
echo -e "${Y}checking mock saml container..." &&
|
||||
while ! nc -z localhost 8090; do sleep 1; done &&
|
||||
echo -e "${G}Mock SAML is up and running!"
|
||||
fi
|
||||
|
||||
if ${{ inputs.postgres }}; then
|
||||
echo -e "${Y}checking postgres container..." &&
|
||||
while ! nc -z localhost 5404; do sleep 1; done &&
|
||||
|
||||
31
.github/actions/prepare-shoppy-e2e/action.yml
vendored
@@ -14,21 +14,6 @@ inputs:
|
||||
staging-mb-all-features-token:
|
||||
description: "Staging MB all features token"
|
||||
required: true
|
||||
shoppy-iam-role:
|
||||
description: "Shoppy IAM role for AWS credentials"
|
||||
required: true
|
||||
shoppy-metabase-app-db-s3-bucket:
|
||||
description: "S3 bucket for Shoppy app DB dump"
|
||||
required: true
|
||||
shoppy-metabase-app-db-s3-key:
|
||||
description: "S3 key for Shoppy app DB dump"
|
||||
required: true
|
||||
shoppy-metabase-app-db-user:
|
||||
description: "Shoppy Metabase app DB user"
|
||||
required: true
|
||||
shoppy-metabase-app-db-database:
|
||||
description: "Shoppy Metabase app DB database"
|
||||
required: true
|
||||
metastore-dev-server-url:
|
||||
description: "Metastore dev server URL"
|
||||
required: true
|
||||
@@ -93,20 +78,6 @@ runs:
|
||||
echo "sample_app_branch_exists=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Configure AWS credentials
|
||||
if: ${{ steps.check-sample-app-branch.outputs.sample_app_branch_exists == 'true' }}
|
||||
uses: aws-actions/configure-aws-credentials@d979d5b3a71173a29b74b5b88418bfda9437d885
|
||||
with:
|
||||
role-to-assume: ${{ inputs.shoppy-iam-role }}
|
||||
role-session-name: GitHub_to_AWS_via_FederatedOIDC
|
||||
aws-region: us-east-1
|
||||
|
||||
- name: Download Shoppy's App DB PostgreSQL dump from S3
|
||||
if: ${{ steps.check-sample-app-branch.outputs.sample_app_branch_exists == 'true' }}
|
||||
shell: bash
|
||||
run: |
|
||||
aws s3 cp s3://${{ inputs.shoppy-metabase-app-db-s3-bucket }}/${{ inputs.shoppy-metabase-app-db-s3-key }} ./e2e/tmp/db_dumps/shoppy_metabase_app_db_dump.sql
|
||||
|
||||
- name: Prepare and launch Shoppy containers
|
||||
if: ${{ steps.check-sample-app-branch.outputs.sample_app_branch_exists == 'true' }}
|
||||
shell: bash
|
||||
@@ -115,8 +86,6 @@ runs:
|
||||
SAMPLE_APP_ENVIRONMENT: ${{ inputs.sample-app-environment }}
|
||||
CYPRESS_MB_ALL_FEATURES_TOKEN: ${{ inputs.staging-mb-all-features-token }}
|
||||
METASTORE_DEV_SERVER_URL: ${{ inputs.metastore-dev-server-url }}
|
||||
METABASE_APP_DB_USER: ${{ inputs.shoppy-metabase-app-db-user }}
|
||||
METABASE_APP_DB: ${{ inputs.shoppy-metabase-app-db-database }}
|
||||
SHOPPY_DATADOG_APPLICATION_ID: ${{ inputs.shoppy-datadog-application-id }}
|
||||
SHOPPY_DATADOG_CLIENT_TOKEN: ${{ inputs.shoppy-datadog-client-token }}
|
||||
SHOPPY_DATADOG_SITE: "us5.datadoghq.com"
|
||||
|
||||
42
.github/actions/test-driver/action.yml
vendored
@@ -20,8 +20,30 @@ inputs:
|
||||
required: false
|
||||
default: "ee"
|
||||
description: Edition to test (oss or ee)
|
||||
quarantine-engine:
|
||||
runner-index:
|
||||
required: false
|
||||
description: >-
|
||||
The index of this runner among those sharing a partitioned suite. See
|
||||
`be-tests-bigquery-cloud-sdk-ee` in `.github/workflows/drivers.yml` for an example.
|
||||
runner-count:
|
||||
required: false
|
||||
description: >-
|
||||
How many runners share the suite. Recommended to set to `strategy.job-total` rather
|
||||
than a literal so that it doesn't need updating if the `runner-index` matrix changes.
|
||||
jvms-per-runner:
|
||||
required: false
|
||||
default: "1"
|
||||
description: >-
|
||||
Concurrent JVMs per runner. Recommended to raise above 1 for drivers whose tests
|
||||
spend a lot of time waiting on a remote warehouse.
|
||||
heap-per-jvm:
|
||||
required: false
|
||||
default: "4g"
|
||||
description: >-
|
||||
`-Xmx` per JVM, applied only when `jvms-per-runner` is more than 1 (otherwise the
|
||||
the `:ci` alias's `-Xmx12g` is used). To size it: reserve ~1G for the OS and ~2G
|
||||
of non-heap per JVM, then divide what's left. On a 16G runner that's ~4G each for
|
||||
two JVMs.
|
||||
granular-rerun:
|
||||
required: false
|
||||
default: "true"
|
||||
@@ -100,6 +122,7 @@ runs:
|
||||
# Otherwise run exactly as before. Quote the file so the whole `[ns/var ...]`
|
||||
# vector reaches clojure -X as a single argument.
|
||||
run: |
|
||||
ALIASES="dev:ci:${{ inputs.edition }}:${{ inputs.edition }}-dev:drivers:drivers-dev:test"
|
||||
BASE="clojure -X:dev:ci:${{ inputs.edition }}:${{ inputs.edition }}-dev:drivers:drivers-dev:test"
|
||||
# `:profile-tests` overrides `:test`'s exec-fn, so it has to come last. See dev.profile-tests.
|
||||
# The `profiled` output is what the collection steps below key off, so that the decision is
|
||||
@@ -117,12 +140,19 @@ runs:
|
||||
$BASE :only "$ONLY"
|
||||
elif [ "${{ inputs.granular-rerun }}" = "true" ] && [ "${{ github.run_attempt }}" != "1" ] && [ -s target/failed-tests ]; then
|
||||
echo "Granular rerun of previously-failed tests: $(cat target/failed-tests)"
|
||||
$BASE :only "$(cat target/failed-tests)"
|
||||
clojure -X:"$ALIASES" :only "$(cat target/failed-tests)"
|
||||
elif [ -n "$TEST_RUNNER_INDEX" ]; then
|
||||
CLOJURE_ALIASES="$ALIASES" ./.github/scripts/run-backend-test-jvms.sh ${{ inputs.test-args }}
|
||||
else
|
||||
$BASE ${{ inputs.test-args }}
|
||||
clojure -X:"$ALIASES" ${{ inputs.test-args }}
|
||||
fi
|
||||
shell: bash
|
||||
id: run-test
|
||||
env:
|
||||
TEST_RUNNER_INDEX: ${{ inputs.runner-index }}
|
||||
TEST_RUNNER_COUNT: ${{ inputs.runner-count }}
|
||||
TEST_JVMS_PER_RUNNER: ${{ inputs.jvms-per-runner }}
|
||||
TEST_HEAP_PER_JVM: ${{ inputs.heap-per-jvm }}
|
||||
# Keep this step continue-on-error because the quarantine gate below controls whether it
|
||||
# ultimately fails.
|
||||
continue-on-error: true
|
||||
@@ -186,7 +216,9 @@ runs:
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ format('test-logs-{0}{1}', github.job, inputs.artifact-suffix && format('-{0}', inputs.artifact-suffix) || '') }}
|
||||
path: logs/test-log.json
|
||||
# Glob: a single-JVM run writes `logs/test-log.json`; a multi-JVM run merges one
|
||||
# `logs/test-log-p<index>.json` per JVM.
|
||||
path: logs/test-log*.json
|
||||
retention-days: 1
|
||||
# Failing to upload the artifact should never affect the outcome of the whole test run.
|
||||
continue-on-error: true
|
||||
@@ -199,4 +231,4 @@ runs:
|
||||
with:
|
||||
adapter: backend
|
||||
test-suite: ${{ inputs.junit-name }}
|
||||
enforce-quarantine: ${{ inputs.quarantine-engine == 'ci-conductor' }}
|
||||
enforce-quarantine: "true"
|
||||
|
||||
32
.github/file-paths.yaml
vendored
@@ -115,10 +115,10 @@ backend_sources: &backend_sources
|
||||
- "src/**"
|
||||
- "modules/drivers/{*,*/{*,!(test)/**}}"
|
||||
- "resources/**"
|
||||
# The ratchets file drives project_ratchet_checks alone, so the automation PR that lowers the budgets
|
||||
# runs the Project tests job by itself. Patterns are OR-ed, so a separate "!..." entry would match
|
||||
# Ratchet files drive project_ratchet_checks alone, so automation PRs that lower budgets
|
||||
# run the Project tests job by themselves. Patterns are OR-ed, so a separate "!..." entry would match
|
||||
# every other path instead of excluding this one.
|
||||
- ".clj-kondo/{!(ratchets.edn),*/**}"
|
||||
- ".clj-kondo/**/!(ratchets.edn)"
|
||||
|
||||
backend_specs: &backend_specs
|
||||
- *shared_specs
|
||||
@@ -159,6 +159,8 @@ project_backend_checks:
|
||||
- "modules/**/*.cljc"
|
||||
- "modules/**/*.cljs"
|
||||
- "**deps.edn"
|
||||
- ".clj-kondo/{src,test}/**/*.{clj,cljc,cljs}"
|
||||
- "{resources,test_resources,test_config}/**/*.{clj,cljc,cljs}"
|
||||
- ".clj-kondo/config.edn"
|
||||
- ".clj-kondo/config/modules/config.edn"
|
||||
- ".github/team.json"
|
||||
@@ -167,6 +169,7 @@ project_backend_checks:
|
||||
# Only the ratchet check reads the budgets, so a diff that touches nothing else runs that suite alone.
|
||||
project_ratchet_checks:
|
||||
- ".clj-kondo/ratchets.edn"
|
||||
- ".clj-kondo/config/modules/ratchets.edn"
|
||||
|
||||
project_migration_checks:
|
||||
- *project_tests_infra
|
||||
@@ -293,14 +296,17 @@ serialization_baseline:
|
||||
- ".github/workflows/serialization.yml"
|
||||
- "test_resources/serialization_baseline/**"
|
||||
|
||||
openapi:
|
||||
- ".github/workflows/lint.yml"
|
||||
- "src/metabase/api/**"
|
||||
- "src/metabase/api_routes/**"
|
||||
- "src/metabase/*_rest/api.clj"
|
||||
- "src/metabase/*/api.clj"
|
||||
- "src/metabase/*/api/*.clj"
|
||||
- "enterprise/backend/src/metabase_enterprise/**/api.clj"
|
||||
- "enterprise/backend/src/metabase_enterprise/**/api/*.clj"
|
||||
- "resources/openapi/**"
|
||||
api_contracts:
|
||||
- ".github/file-paths.yaml"
|
||||
- ".github/workflows/api-contracts.yml"
|
||||
- "frontend/**"
|
||||
- "enterprise/frontend/**"
|
||||
- "src/**"
|
||||
- "enterprise/backend/src/**"
|
||||
- "**/deps.edn"
|
||||
- "resources/data_readers.clj"
|
||||
- "resources/locales.clj"
|
||||
- "resources/version.properties"
|
||||
- "tsconfig*.json"
|
||||
- "package.json"
|
||||
- "bun.lock"
|
||||
|
||||
6
.github/scripts/create-test-plan.ts
vendored
@@ -24,11 +24,17 @@ const STORY_GLOBS = [
|
||||
"enterprise/frontend/**/*.stories.{js,jsx,ts,tsx}",
|
||||
];
|
||||
|
||||
// `git ls-files -- frontend enterprise/frontend` already prints just over a
|
||||
// megabyte of paths, and node's default maxBuffer is exactly 1 MiB: past that
|
||||
// the spawn dies with ENOBUFS. Give the listings room to grow.
|
||||
const LS_FILES_MAX_BUFFER = 64 * 1024 * 1024;
|
||||
|
||||
// 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",
|
||||
maxBuffer: LS_FILES_MAX_BUFFER,
|
||||
})
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
|
||||
11
.github/scripts/file-paths.unit.spec.ts
vendored
@@ -29,7 +29,7 @@ describe("file-paths.yaml", () => {
|
||||
// dorny/paths-filter ORs the patterns in a filter, so a "!..." entry does not subtract from the
|
||||
// other entries - it matches every path they don't, turning the filter permanently on. Excluding
|
||||
// a path means writing the exclusion inside one pattern, the way backend_sources keeps
|
||||
// ratchets.edn out with ".clj-kondo/{!(ratchets.edn),*/**}".
|
||||
// ratchet files out with ".clj-kondo/**/!(ratchets.edn)".
|
||||
it("never excludes a path with a standalone negation", () => {
|
||||
const negated = Object.entries(filters).flatMap(([name, filter]) =>
|
||||
patterns(filter)
|
||||
@@ -44,6 +44,9 @@ describe("file-paths.yaml", () => {
|
||||
"%s skips a ratchets-only change and still follows the rest of .clj-kondo",
|
||||
(name) => {
|
||||
expect(matches(name, ".clj-kondo/ratchets.edn")).toBe(false);
|
||||
expect(
|
||||
matches(name, ".clj-kondo/config/modules/ratchets.edn"),
|
||||
).toBe(false);
|
||||
expect(matches(name, ".clj-kondo/config.edn")).toBe(true);
|
||||
expect(matches(name, ".clj-kondo/config/modules/config.edn")).toBe(true);
|
||||
},
|
||||
@@ -53,5 +56,11 @@ describe("file-paths.yaml", () => {
|
||||
expect(matches("project_ratchet_checks", ".clj-kondo/ratchets.edn")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
matches(
|
||||
"project_ratchet_checks",
|
||||
".clj-kondo/config/modules/ratchets.edn",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
160
.github/scripts/run-backend-test-jvms.sh
vendored
Executable file
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Run this runner's share of a partitioned backend test suite as one or more concurrent
|
||||
# JVMs. The caller says how many runners share the suite and how many JVMs each gets.
|
||||
#
|
||||
# WHY MULTIPLE JVMS PER RUNNER
|
||||
# ----------------------------
|
||||
# Driver tests spend most of their wall time waiting on the remote warehouse, and they
|
||||
# can't use the idle CPU by using more threads because some tests mutate global state.
|
||||
# A second JVM gets that isolation for free.
|
||||
#
|
||||
# Two paths are NOT safe to share:
|
||||
# * `target/junit` — hawk deletes the whole directory at `:begin-test-run`, so a JVM
|
||||
# starting late would wipe an earlier one's results.
|
||||
# * `logs/test-log.json` — the log4j2 File appender truncates it on open.
|
||||
# So each JVM runs in its own hardlinked copy of the checkout (inodes, not disk) and
|
||||
# outputs are merged back into the workspace afterwards.
|
||||
#
|
||||
# INPUTS (environment)
|
||||
# --------------------
|
||||
# CLOJURE_ALIASES alias string for `clojure -X`, e.g. dev:ci:ee:ee-dev:drivers:drivers-dev:test
|
||||
# TEST_RUNNER_INDEX index of this runner among those sharing the suite
|
||||
# TEST_RUNNER_COUNT how many runners share the suite
|
||||
# TEST_JVMS_PER_RUNNER concurrent JVMs per runner (default 1)
|
||||
# TEST_HEAP_PER_JVM -Xmx per JVM, applied only when running more than one
|
||||
#
|
||||
# Exits non-zero if any JVM fails.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
: "${CLOJURE_ALIASES:?CLOJURE_ALIASES is required}"
|
||||
: "${TEST_RUNNER_INDEX:?TEST_RUNNER_INDEX is required}"
|
||||
: "${TEST_RUNNER_COUNT:?TEST_RUNNER_COUNT is required}"
|
||||
TEST_ARGS=("$@")
|
||||
TEST_JVMS_PER_RUNNER="${TEST_JVMS_PER_RUNNER:-1}"
|
||||
TEST_HEAP_PER_JVM="${TEST_HEAP_PER_JVM:-4g}"
|
||||
|
||||
die() {
|
||||
echo "::error::run-backend-test-jvms.sh: $1"
|
||||
exit 1
|
||||
}
|
||||
|
||||
[[ "$TEST_RUNNER_INDEX" =~ ^[0-9]+$ ]] || die "TEST_RUNNER_INDEX must be a non-negative integer, got '$TEST_RUNNER_INDEX'"
|
||||
[[ "$TEST_RUNNER_COUNT" =~ ^[0-9]+$ ]] || die "TEST_RUNNER_COUNT must be a positive integer, got '$TEST_RUNNER_COUNT'"
|
||||
[[ "$TEST_JVMS_PER_RUNNER" =~ ^[0-9]+$ ]] || die "TEST_JVMS_PER_RUNNER must be a positive integer, got '$TEST_JVMS_PER_RUNNER'"
|
||||
[ "$TEST_RUNNER_COUNT" -ge 1 ] || die "TEST_RUNNER_COUNT must be at least 1"
|
||||
[ "$TEST_JVMS_PER_RUNNER" -ge 1 ] || die "TEST_JVMS_PER_RUNNER must be at least 1"
|
||||
[ "$TEST_RUNNER_INDEX" -lt "$TEST_RUNNER_COUNT" ] ||
|
||||
die "runner index $TEST_RUNNER_INDEX is out of range for $TEST_RUNNER_COUNT runner(s) — a matrix entry was added without raising runner-count"
|
||||
|
||||
# Partition indexes are split across runners rather than handed out in contiguous
|
||||
# blocks. E.g. with 2 runners and 2 JVMs the first runner gets partition 0 and 2, and
|
||||
# the second runner gets partitions 1 and 3. Hawk partitions in namespace order, so
|
||||
# neighbouring partitions have similar runtimes — contiguous blocks would group the slow
|
||||
# ones together onto one runner.
|
||||
PARTITION_TOTAL=$((TEST_RUNNER_COUNT * TEST_JVMS_PER_RUNNER))
|
||||
INDEXES=()
|
||||
for ((k = 0; k < TEST_JVMS_PER_RUNNER; k++)); do
|
||||
INDEXES+=($((TEST_RUNNER_INDEX + k * TEST_RUNNER_COUNT)))
|
||||
done
|
||||
|
||||
# One JVM: run directly in the workspace with no heap override, exactly as a
|
||||
# partitioned run behaved before this script existed.
|
||||
if [ "$TEST_JVMS_PER_RUNNER" -eq 1 ]; then
|
||||
echo "Running partition ${INDEXES[0]} of $PARTITION_TOTAL"
|
||||
exec clojure -X:"$CLOJURE_ALIASES" "${TEST_ARGS[@]}" \
|
||||
:partition/total "$PARTITION_TOTAL" \
|
||||
:partition/index "${INDEXES[0]}"
|
||||
fi
|
||||
|
||||
|
||||
WORKSPACE="$PWD"
|
||||
# Sibling of the checkout: same filesystem (so `cp -al` can hardlink) but outside the
|
||||
# repo, so one JVM's directory never gets copied into another's.
|
||||
JVM_DIRS_ROOT="$(dirname "$WORKSPACE")/backend-test-jvms"
|
||||
STATUS_DIR="$JVM_DIRS_ROOT/status"
|
||||
|
||||
# Merge every JVM's results into the workspace the rest of the job reads from.
|
||||
#
|
||||
# Each partition's junit XML goes into its own subdirectory: `mb_hawk_var_less_errors.xml`
|
||||
# has a fixed name, so a flat copy would silently overwrite it. Nesting is safe because
|
||||
# both consumers (dorny/test-reporter and ci-conductor's backend adapter) walk
|
||||
# `target/junit` recursively and match on filename suffix.
|
||||
#
|
||||
# Runs from an EXIT trap so a cancelled or timed-out job still publishes whatever
|
||||
# finished.
|
||||
# shellcheck disable=SC2329 # invoked by the EXIT trap below, which shellcheck can't see.
|
||||
merge_outputs() {
|
||||
mkdir -p "$WORKSPACE/target/junit" "$WORKSPACE/logs"
|
||||
local idx jvm_dir
|
||||
for idx in "${INDEXES[@]}"; do
|
||||
jvm_dir="$JVM_DIRS_ROOT/partition-$idx"
|
||||
if [ -d "$jvm_dir/target/junit" ]; then
|
||||
mkdir -p "$WORKSPACE/target/junit/p$idx"
|
||||
cp -a "$jvm_dir/target/junit/." "$WORKSPACE/target/junit/p$idx/" || true
|
||||
fi
|
||||
# `logs/test-log*.json` is what the action uploads on failure.
|
||||
if [ -f "$jvm_dir/logs/test-log.json" ]; then
|
||||
cp -a "$jvm_dir/logs/test-log.json" "$WORKSPACE/logs/test-log-p$idx.json" || true
|
||||
fi
|
||||
done
|
||||
}
|
||||
trap merge_outputs EXIT
|
||||
trap 'exit 143' TERM
|
||||
trap 'exit 130' INT
|
||||
|
||||
# Hardlinked copy of the checkout. Skips the paths a run writes to (`target`, `logs`, `.cpcache`)
|
||||
# and the two most expensive trees nothing on the test classpath reads (`.git`, `node_modules`).
|
||||
prepare_jvm_dir() {
|
||||
local jvm_dir="$1" entry
|
||||
rm -rf "$jvm_dir"
|
||||
mkdir -p "$jvm_dir"
|
||||
for entry in "$WORKSPACE"/* "$WORKSPACE"/.[!.]*; do
|
||||
[ -e "$entry" ] || continue
|
||||
case "$(basename "$entry")" in
|
||||
.git | node_modules | target | logs | .cpcache) continue ;;
|
||||
esac
|
||||
# Fall back to a real copy if the filesystem refuses the hardlink.
|
||||
cp -al "$entry" "$jvm_dir/" 2>/dev/null || cp -a "$entry" "$jvm_dir/"
|
||||
done
|
||||
}
|
||||
|
||||
# Prefix output with the partition index so interleaved JVM logs stay readable.
|
||||
run_partition() {
|
||||
local idx="$1" jvm_dir="$2" rc line
|
||||
(
|
||||
cd "$jvm_dir" || exit 1
|
||||
# `-J` options land after the aliases' `:jvm-opts`, overriding the `:ci` alias's
|
||||
# `-Xms12g -Xmx12g`. Xms stays small so JVMs sharing a runner grow into memory on
|
||||
# demand rather than each committing its ceiling up front.
|
||||
exec clojure -J-Xms512m -J-Xmx"$TEST_HEAP_PER_JVM" -X:"$CLOJURE_ALIASES" \
|
||||
"${TEST_ARGS[@]}" \
|
||||
:partition/total "$PARTITION_TOTAL" \
|
||||
:partition/index "$idx"
|
||||
) 2>&1 | while IFS= read -r line || [ -n "$line" ]; do printf '[p%s] %s\n' "$idx" "$line"; done
|
||||
rc="${PIPESTATUS[0]}"
|
||||
echo "$rc" > "$STATUS_DIR/p$idx"
|
||||
}
|
||||
|
||||
rm -rf "$JVM_DIRS_ROOT"
|
||||
mkdir -p "$STATUS_DIR"
|
||||
|
||||
echo "Runner $TEST_RUNNER_INDEX of $TEST_RUNNER_COUNT: running $TEST_JVMS_PER_RUNNER concurrent JVMs for partition(s) ${INDEXES[*]} of $PARTITION_TOTAL (-Xmx$TEST_HEAP_PER_JVM each)"
|
||||
|
||||
for idx in "${INDEXES[@]}"; do
|
||||
prepare_jvm_dir "$JVM_DIRS_ROOT/partition-$idx"
|
||||
done
|
||||
|
||||
for idx in "${INDEXES[@]}"; do
|
||||
run_partition "$idx" "$JVM_DIRS_ROOT/partition-$idx" &
|
||||
done
|
||||
wait
|
||||
|
||||
overall=0
|
||||
for idx in "${INDEXES[@]}"; do
|
||||
rc="$(cat "$STATUS_DIR/p$idx" 2>/dev/null || echo 1)"
|
||||
echo "partition $idx exited $rc"
|
||||
[ "$rc" -eq 0 ] || overall=1
|
||||
done
|
||||
exit "$overall"
|
||||
97
.github/scripts/upload-bundle-load-stats.ts
vendored
Normal file
@@ -0,0 +1,97 @@
|
||||
// Appends the measured load times to the "Bundle Load Times" table in
|
||||
// eng-stats-importer. Reads ROWS (the JSON matrix.js prints) and API_KEY from
|
||||
// env, and stamps each row with the commit it came from.
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import { importStats } from "./stats-import";
|
||||
|
||||
/** One condition, as `frontend/test/bench/matrix.ts` reports it. */
|
||||
interface Condition {
|
||||
network: string;
|
||||
networkMbps: number;
|
||||
latencyMs: number;
|
||||
cpu: string;
|
||||
cpuThrottle: number;
|
||||
coldMs: number;
|
||||
warmMs: number;
|
||||
steadyMs: number;
|
||||
coldSpreadPercent: number;
|
||||
coldTtfbMs: number;
|
||||
coldFirstPaintMs: number;
|
||||
coldAppMountedMs: number;
|
||||
coldLargestPaintMs: number;
|
||||
coldPageReadyMs: number;
|
||||
warmPageReadyMs: number;
|
||||
scripts: number;
|
||||
scriptKb: number;
|
||||
runs: number;
|
||||
}
|
||||
|
||||
interface CommitStamp {
|
||||
sha: string;
|
||||
subject: string;
|
||||
}
|
||||
|
||||
type LoadTimeRow = Record<string, string | number>;
|
||||
|
||||
function buildRows(
|
||||
conditions: Condition[],
|
||||
{ sha, subject }: CommitStamp,
|
||||
): LoadTimeRow[] {
|
||||
return conditions.map((condition) => ({
|
||||
Date: new Date().toISOString().slice(0, 10),
|
||||
// Truncated the same way the bundle-size table does, so the two join.
|
||||
Commit: sha.slice(0, 12),
|
||||
// The stats table carries a free-text Description column. Populate it with
|
||||
// the commit subject so the chart's points are self-describing.
|
||||
Description: subject.split("\n")[0],
|
||||
Network: condition.network,
|
||||
"Network mbps": condition.networkMbps,
|
||||
"Latency ms": condition.latencyMs,
|
||||
CPU: condition.cpu,
|
||||
"CPU throttle": condition.cpuThrottle,
|
||||
"Cold ms": condition.coldMs,
|
||||
"Warm ms": condition.warmMs,
|
||||
"Steady ms": condition.steadyMs,
|
||||
"Cold spread %": condition.coldSpreadPercent,
|
||||
// The cold load broken up. A zero means the browser or the route never
|
||||
// reported that one, rather than that it happened at time zero.
|
||||
"Cold ttfb ms": condition.coldTtfbMs,
|
||||
"Cold first paint ms": condition.coldFirstPaintMs,
|
||||
"Cold app mounted ms": condition.coldAppMountedMs,
|
||||
"Cold largest paint ms": condition.coldLargestPaintMs,
|
||||
"Cold page ready ms": condition.coldPageReadyMs,
|
||||
"Warm page ready ms": condition.warmPageReadyMs,
|
||||
Scripts: condition.scripts,
|
||||
"Script kb": condition.scriptKb,
|
||||
Runs: condition.runs,
|
||||
}));
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const path = process.env.ROWS || "artifacts/load-times.json";
|
||||
// matrix.js wrote this file, and Condition is the shape it prints.
|
||||
const conditions = JSON.parse(readFileSync(path, "utf8")) as Condition[];
|
||||
|
||||
const rows = buildRows(conditions, {
|
||||
sha: process.env.HEAD_SHA || "",
|
||||
subject: process.env.COMMIT_MESSAGE || "",
|
||||
});
|
||||
|
||||
console.table(rows);
|
||||
|
||||
try {
|
||||
await importStats({ table: "bundle_load_times", rows });
|
||||
console.log("Load times uploaded successfully");
|
||||
} catch (error) {
|
||||
// Stats logging is best-effort. A point lost to an unreachable importer is
|
||||
// worth less than a red build on master, and the next commit plots another.
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.log(
|
||||
`::warning::Load-time upload failed after retries; leaving run green: ${message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
6
.github/team.json
vendored
@@ -63,7 +63,8 @@
|
||||
"crisptrutski",
|
||||
"undrfined",
|
||||
"andreis",
|
||||
"prascuna"
|
||||
"prascuna",
|
||||
"krever"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -112,7 +113,8 @@
|
||||
"metatyb",
|
||||
"olehk-mb",
|
||||
"annatomka",
|
||||
"brybacki"
|
||||
"brybacki",
|
||||
"BillyBobFry"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
71
.github/workflows/ai-benchmark.yml
vendored
@@ -1,5 +1,5 @@
|
||||
name: AI Benchmark
|
||||
run-name: "Evals · ${{ github.event_name == 'pull_request' && 'label' || 'manual' }} · ${{ github.event.pull_request.number || inputs.pr || inputs.ref || github.sha }}"
|
||||
run-name: "Evals · ${{ github.event_name == 'pull_request' && github.event.label.name || format('manual · {0}', inputs.scope) }} · ${{ github.event.pull_request.number || inputs.pr || inputs.ref || github.sha }}"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
@@ -18,7 +18,16 @@ on:
|
||||
description: "Scope"
|
||||
required: true
|
||||
type: choice
|
||||
options: [smoke, full, "all (EXPENSIVE)"]
|
||||
options:
|
||||
- smoke
|
||||
- full
|
||||
- embarrassing-plain
|
||||
- embarrassing-human
|
||||
- embarrassing-all
|
||||
- goldens-plain
|
||||
- goldens-human
|
||||
- goldens-all
|
||||
- "all (EXPENSIVE)"
|
||||
default: smoke
|
||||
sample:
|
||||
description: "Sample (0 = full suite)"
|
||||
@@ -29,15 +38,15 @@ on:
|
||||
default: main
|
||||
|
||||
concurrency:
|
||||
group: evals-dispatch-${{ (github.event.label.name == 'evals/smoke' && github.event.pull_request.number) || github.run_id }}
|
||||
group: evals-dispatch-${{ github.event_name == 'pull_request' && format('{0}-{1}', github.event.pull_request.number, github.event.label.name) || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
if: >-
|
||||
github.event.pull_request.head.repo.full_name == github.repository
|
||||
&& github.event_name == 'pull_request'
|
||||
&& github.event.label.name == 'evals/smoke'
|
||||
(github.event_name == 'pull_request'
|
||||
&& github.event.pull_request.head.repo.full_name == github.repository
|
||||
&& startsWith(github.event.label.name, 'evals:'))
|
||||
|| github.event_name == 'workflow_dispatch'
|
||||
runs-on: ${{ vars.SLIM_RUNNER_KEY }}
|
||||
timeout-minutes: 5
|
||||
@@ -46,6 +55,7 @@ jobs:
|
||||
ref: ${{ steps.prepare.outputs.ref }}
|
||||
scope: ${{ steps.prepare.outputs.scope }}
|
||||
sample: ${{ steps.prepare.outputs.sample }}
|
||||
label: ${{ steps.prepare.outputs.label }}
|
||||
evals_branch: ${{ steps.prepare.outputs.evals_branch }}
|
||||
steps:
|
||||
- name: Prepare
|
||||
@@ -53,6 +63,7 @@ jobs:
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
LABEL_PR: ${{ github.event.pull_request.number }}
|
||||
LABEL: ${{ github.event.label.name }}
|
||||
INPUT_PR: ${{ inputs.pr }}
|
||||
INPUT_REF: ${{ inputs.ref }}
|
||||
INPUT_SCOPE: ${{ inputs.scope }}
|
||||
@@ -62,14 +73,24 @@ jobs:
|
||||
set -euo pipefail
|
||||
# The two paths are mutually exclusive, and `on:` admits no other trigger.
|
||||
if [[ "$EVENT_NAME" == 'pull_request' ]]; then
|
||||
PR="$LABEL_PR"; REF=''; SCOPE='smoke'
|
||||
SAMPLE='5'; EVALS_BRANCH='main'
|
||||
SCOPE="${LABEL#evals:}"
|
||||
case "$SCOPE" in
|
||||
embarrassing-plain|embarrassing-human|embarrassing-all|goldens-plain|goldens-human|goldens-all)
|
||||
SAMPLE='0' ;;
|
||||
smoke|full)
|
||||
SAMPLE='5' ;;
|
||||
*)
|
||||
echo "::error::'${LABEL}' is not a known evals trigger label; add its scope here and to the workflow's \`scope\` options, or remove the label"
|
||||
exit 1 ;;
|
||||
esac
|
||||
PR="$LABEL_PR"; REF=''; EVALS_BRANCH='main'
|
||||
else
|
||||
PR="$INPUT_PR"; REF="$INPUT_REF"; SCOPE="$INPUT_SCOPE"
|
||||
SAMPLE="${INPUT_SAMPLE:-5}"; EVALS_BRANCH="$INPUT_EVALS_BRANCH"
|
||||
LABEL='' # nothing to consume: a manual dispatch was not triggered by a label
|
||||
fi
|
||||
# None of these is ever multi-line; a newline would forge extra output entries.
|
||||
for v in PR REF SCOPE SAMPLE EVALS_BRANCH; do
|
||||
for v in PR REF SCOPE SAMPLE LABEL EVALS_BRANCH; do
|
||||
if [[ "${!v}" == *$'\n'* ]]; then
|
||||
echo "::error::${v} must be a single line"
|
||||
exit 1
|
||||
@@ -80,6 +101,7 @@ jobs:
|
||||
echo "ref=$REF"
|
||||
echo "scope=$SCOPE"
|
||||
echo "sample=$SAMPLE"
|
||||
echo "label=$LABEL"
|
||||
echo "evals_branch=$EVALS_BRANCH"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
@@ -93,18 +115,26 @@ jobs:
|
||||
steps:
|
||||
- name: Comment (building)
|
||||
id: post
|
||||
env:
|
||||
SCOPE: ${{ needs.prepare.outputs.scope }}
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
github-token: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
|
||||
script: |
|
||||
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
|
||||
const body = `🤖 **Evals smoke** — ⏳ building the image… ([logs](${runUrl}))`;
|
||||
const prefix = `🤖 **Evals (${process.env.SCOPE})**`;
|
||||
const body = `${prefix} — ⏳ building the image… ([logs](${runUrl}))`;
|
||||
const { owner, repo } = context.repo;
|
||||
const issue_number = context.payload.pull_request.number;
|
||||
// Drop earlier verdicts first: they describe shas that are no longer head, and a stale
|
||||
// green one reads as this PR passing. The prefix survives eval-run's rewrites.
|
||||
// Drop this scope's earlier verdicts first: they describe shas that are no longer head,
|
||||
// and a stale green one reads as this PR passing. Only this scope's, though — a sibling
|
||||
// scope's comment is a live report on a run still going.
|
||||
const existing = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number });
|
||||
for (const c of existing.filter(c => c.body.startsWith('🤖 **Evals'))) {
|
||||
// The second form is what the single `evals/smoke` trigger wrote before the labels were
|
||||
// split per scope; nothing writes it any more, so this clause is only here to tidy PRs
|
||||
// that predate the split and can go once none are open.
|
||||
const stale = c => c.body.startsWith(prefix) || c.body.startsWith('🤖 **Evals smoke**');
|
||||
for (const c of existing.filter(stale)) {
|
||||
await github.rest.issues.deleteComment({ owner, repo, comment_id: c.id }).catch(() => {});
|
||||
}
|
||||
const { data } = await github.rest.issues.createComment({ owner, repo, issue_number, body });
|
||||
@@ -178,8 +208,11 @@ jobs:
|
||||
SHA: ${{ needs.resolve.outputs.sha }}
|
||||
SCOPE: ${{ needs.prepare.outputs.scope }}
|
||||
SAMPLE: ${{ needs.prepare.outputs.sample }}
|
||||
LABEL: ${{ needs.prepare.outputs.label }}
|
||||
COMMENT_ID: ${{ needs.announce.outputs.comment_id }}
|
||||
run: |
|
||||
# `label` is the one eval-run consumes once it has taken the run over; empty on a manual
|
||||
# dispatch, where there is nothing to consume.
|
||||
gh api repos/metabase/evals/actions/workflows/eval-run.yml/dispatches \
|
||||
-f ref="$EVALS_BRANCH" \
|
||||
-f "inputs[pr]=$PR" \
|
||||
@@ -187,16 +220,19 @@ jobs:
|
||||
-f "inputs[scope]=$SCOPE" \
|
||||
-f "inputs[surfaces]=all" \
|
||||
-f "inputs[sample]=$SAMPLE" \
|
||||
-f "inputs[label]=$LABEL" \
|
||||
-f "inputs[evals_branch]=$EVALS_BRANCH" \
|
||||
-f "inputs[comment_id]=$COMMENT_ID"
|
||||
|
||||
finalize:
|
||||
needs: [announce, resolve, image, dispatch]
|
||||
needs: [prepare, announce, resolve, image, dispatch]
|
||||
if: always() && needs.announce.result == 'success' && needs.dispatch.result != 'success'
|
||||
runs-on: ${{ vars.SLIM_RUNNER_KEY }}
|
||||
timeout-minutes: 5
|
||||
env:
|
||||
COMMENT_ID: ${{ needs.announce.outputs.comment_id }}
|
||||
SCOPE: ${{ needs.prepare.outputs.scope }}
|
||||
LABEL: ${{ needs.prepare.outputs.label }}
|
||||
NEEDS_JSON: ${{ toJSON(needs) }}
|
||||
steps:
|
||||
- name: Comment (build/dispatch failed)
|
||||
@@ -207,9 +243,10 @@ jobs:
|
||||
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
|
||||
const cancelled = Object.values(JSON.parse(process.env.NEEDS_JSON))
|
||||
.some(j => j.result === 'cancelled');
|
||||
const prefix = `🤖 **Evals (${process.env.SCOPE})**`;
|
||||
const body = cancelled
|
||||
? `🤖 **Evals** — ⚪ cancelled before evals started ([logs](${runUrl}))`
|
||||
: `🤖 **Evals** — ❌ build or dispatch failed before evals started ([logs](${runUrl}))`;
|
||||
? `${prefix} — ⚪ cancelled before evals started ([logs](${runUrl}))`
|
||||
: `${prefix} — ❌ build or dispatch failed before evals started ([logs](${runUrl}))`;
|
||||
// The replacement run deletes this comment on its way in, so a concurrency cancel can
|
||||
// land here with nothing to patch. Losing the stamp is fine; failing the job isn't.
|
||||
await github.rest.issues.updateComment({
|
||||
@@ -222,5 +259,5 @@ jobs:
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner, repo: context.repo.repo,
|
||||
issue_number: context.payload.pull_request.number,
|
||||
name: 'evals/smoke',
|
||||
name: process.env.LABEL,
|
||||
}).catch(() => {});
|
||||
|
||||
98
.github/workflows/api-contracts.yml
vendored
Normal file
@@ -0,0 +1,98 @@
|
||||
name: API Contracts
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'master'
|
||||
- 'release-**'
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref && github.ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
files-changed:
|
||||
runs-on: ${{ vars.SLIM_RUNNER_KEY }}
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
api_contracts: ${{ steps.changes.outputs.api_contracts }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Test which files changed
|
||||
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
||||
id: changes
|
||||
with:
|
||||
token: ${{ github.token }}
|
||||
filters: .github/file-paths.yaml
|
||||
|
||||
api-contracts:
|
||||
needs: files-changed
|
||||
if: needs.files-changed.outputs.api_contracts == 'true'
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Prepare front-end environment
|
||||
uses: ./.github/actions/prepare-frontend
|
||||
- name: Prepare back-end environment
|
||||
uses: ./.github/actions/prepare-backend
|
||||
- name: Build CLJS
|
||||
run: bun run build-pure:cljs
|
||||
- name: Generate OpenAPI spec
|
||||
run: bun run generate-openapi
|
||||
- name: Validate OpenAPI spec
|
||||
run: bun run lint-openapi
|
||||
- name: Generate TypeScript declarations
|
||||
run: bun run types:generate
|
||||
- name: Check API contracts
|
||||
run: bun run api-contract-check-pure
|
||||
- name: Upload API contracts report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: api-contracts-report
|
||||
path: .tmp/openapi/contracts-report.json
|
||||
include-hidden-files: true
|
||||
retention-days: 7
|
||||
|
||||
api-contracts-result:
|
||||
needs:
|
||||
- files-changed
|
||||
- api-contracts
|
||||
if: always() && !cancelled()
|
||||
runs-on: ${{ vars.SLIM_RUNNER_KEY }}
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check API contracts job status
|
||||
uses: actions/github-script@v9
|
||||
env:
|
||||
needs: ${{ toJson(needs) }}
|
||||
with:
|
||||
script: | # js
|
||||
const needs = JSON.parse(process.env.needs);
|
||||
const jobs = Object.entries(needs).map(
|
||||
([jobName, jobValues]) => ({
|
||||
name: jobName,
|
||||
result: jobValues.result
|
||||
}));
|
||||
|
||||
if (jobs.every(job => (job.result === 'skipped' || job.result === 'success'))) {
|
||||
console.log('All jobs are skipped or successful');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log('Some API contracts jobs failed');
|
||||
jobs.forEach((job) => {
|
||||
if (job.result !== 'success') {
|
||||
console.log(`${job.name} - ${job.result}`);
|
||||
}
|
||||
});
|
||||
process.exit(1);
|
||||
3
.github/workflows/app-db.yml
vendored
@@ -217,7 +217,6 @@ jobs:
|
||||
test-args: >-
|
||||
${{ matrix.job.test-args }}
|
||||
:exclude-tags '[ ${{ matrix.job.exclude-tag }} ${{ env.__ADDITIONAL_EXCLUDED_TAG__ }}]'
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
|
||||
be-tests-mysql:
|
||||
needs: app-db-matrix
|
||||
@@ -311,7 +310,6 @@ jobs:
|
||||
test-args: >-
|
||||
${{ matrix.job.test-args }}
|
||||
:exclude-tags '[ ${{ matrix.job.exclude-tag }} ${{ env.__ADDITIONAL_EXCLUDED_TAG__ }}]'
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
|
||||
be-tests-postgres:
|
||||
needs: app-db-matrix
|
||||
@@ -388,7 +386,6 @@ jobs:
|
||||
test-args: >-
|
||||
${{ matrix.job.test-args }}
|
||||
:exclude-tags '[ ${{ matrix.job.exclude-tag }} ${{ env.__ADDITIONAL_EXCLUDED_TAG__ }}]'
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
|
||||
app-db-tests-result:
|
||||
needs:
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
name: Block Master Branch PRs
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
|
||||
jobs:
|
||||
check-source-branch:
|
||||
if: github.head_ref == 'master'
|
||||
runs-on: ${{ vars.SLIM_RUNNER_KEY }}
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Block master branch PRs
|
||||
run: |
|
||||
echo "PRs from \`master\` branches are not allowed, please use a new branch name for your PR."
|
||||
exit 1
|
||||
43
.github/workflows/bundle-size-stats.yml
vendored
@@ -4,25 +4,54 @@ name: Bundle Size Stats
|
||||
# emitted JS) plus the bundle-stats and embedding-sdk artifacts, measures
|
||||
# initial/total bundle sizes, and appends them to eng-stats-importer.
|
||||
|
||||
# Triggered by "Run tests", which is the workflow that builds the uberjar on a
|
||||
# master merge.
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Build Uberjar"]
|
||||
workflows: ["Run tests"]
|
||||
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]
|
||||
workflow_call:
|
||||
inputs:
|
||||
commit:
|
||||
description: "Commit SHA to measure."
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
concurrency:
|
||||
group: bundle-size-stats-${{ github.event.workflow_run.head_sha }}
|
||||
group: bundle-size-stats-${{ inputs.commit || github.event.workflow_run.head_sha }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# "Run tests" concludes `failure` on master more often than `success`, from
|
||||
# jobs that have nothing to do with the jar, so its conclusion is not a usable
|
||||
# gate. The artifact is one: the uberjar job publishes it, and a docs-only
|
||||
# merge that skipped that job publishes none.
|
||||
uberjar-built:
|
||||
if: ${{ github.repository == 'metabase/metabase' }}
|
||||
runs-on: ${{ vars.SLIM_RUNNER_KEY }}
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
found: ${{ steps.probe.outputs.fetch-hit }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- id: probe
|
||||
uses: ./.github/actions/fetch-artifact
|
||||
with:
|
||||
commit: ${{ inputs.commit || github.event.workflow_run.head_sha }}
|
||||
edition: ee
|
||||
download: false
|
||||
|
||||
bundle-size-stats:
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' && github.repository == 'metabase/metabase' }}
|
||||
needs: [uberjar-built]
|
||||
if: ${{ needs.uberjar-built.outputs.found == 'true' }}
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
HEAD_SHA: ${{ inputs.commit || 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.
|
||||
@@ -31,12 +60,12 @@ jobs:
|
||||
- name: Check out the code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event.workflow_run.head_sha }}
|
||||
ref: ${{ env.HEAD_SHA }}
|
||||
- uses: ./.github/actions/prepare-node-bun
|
||||
- name: Download EE uberjar
|
||||
uses: ./.github/actions/fetch-artifact
|
||||
with:
|
||||
commit: ${{ github.event.workflow_run.head_sha }}
|
||||
commit: ${{ env.HEAD_SHA }}
|
||||
edition: ee
|
||||
extract-path: temp/uberjar
|
||||
- name: Extract front-end bundles from uberjar
|
||||
@@ -108,7 +137,7 @@ jobs:
|
||||
run: bun ./.github/scripts/bundle-size-stats-prepare.ts
|
||||
- name: Import sizes (with deltas) into Stats
|
||||
id: upload
|
||||
if: steps.prepare.outputs.significant == 'true'
|
||||
if: ${{ steps.prepare.outputs.significant == 'true' && inputs.commit == '' }}
|
||||
env:
|
||||
ROWS: artifacts/upload-rows.json
|
||||
ENG_STATS_URL: ${{ secrets.ENG_STATS_URL }}
|
||||
|
||||
21
.github/workflows/check-backport-script.yml
vendored
@@ -1,21 +0,0 @@
|
||||
name: Prevent backport.sh from being merged
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
jobs:
|
||||
check-backport-script:
|
||||
runs-on: ${{ vars.SLIM_RUNNER_KEY }}
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
- name: Check for backport.sh file
|
||||
run: |
|
||||
if [ -f "./backport.sh" ]; then
|
||||
echo "backport.sh file exists. Please remove it before merging."
|
||||
exit 1
|
||||
else
|
||||
echo "All good."
|
||||
fi
|
||||
3
.github/workflows/docs-generate-base.yml
vendored
@@ -77,6 +77,9 @@ jobs:
|
||||
|
||||
- name: Generate AI provider documentation
|
||||
id: generate_ai_provider_docs
|
||||
# The generator only exists on branches that ship the multi-provider registry (master / 64+), but
|
||||
# this reusable workflow always runs from master, so skip the step on release branches without it.
|
||||
if: hashFiles('src/metabase/cmd/ai_provider_dox.clj') != ''
|
||||
run: |
|
||||
set -e # Exit on any error
|
||||
echo "Generating AI provider documentation..."
|
||||
|
||||
6
.github/workflows/docs-links.yml
vendored
@@ -32,3 +32,9 @@ jobs:
|
||||
fail: true
|
||||
args: docs --config ./.lychee/config.toml --offline
|
||||
format: markdown
|
||||
|
||||
# lychee can't read the YAML nav, so this script renders it as markdown first. lychee is on PATH
|
||||
# from the step above.
|
||||
- name: Check nav.yml links
|
||||
if: ${{ !cancelled() }}
|
||||
run: docs/util/check-links.sh nav
|
||||
|
||||
90
.github/workflows/drivers.yml
vendored
@@ -74,6 +74,7 @@ jobs:
|
||||
# All decision logic is consolidated in mage driver-decisions.
|
||||
determine-driver-skips:
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
# All driver run decisions (from mage driver-decisions)
|
||||
h2-should-run: ${{ steps.driver-decisions.outputs.h2-should-run }}
|
||||
@@ -162,35 +163,16 @@ jobs:
|
||||
test-args: >-
|
||||
:only-tags [:mb/driver-tests]
|
||||
:exclude-tags [:mb/transforms-python-test]
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
|
||||
be-tests-bigquery-cloud-sdk-ee:
|
||||
needs: determine-driver-skips
|
||||
if: ${{ needs.determine-driver-skips.outputs.bigquery-should-run == 'true' }}
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 70
|
||||
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 2
|
||||
: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 2
|
||||
:partition/index 1
|
||||
- name: Transforms Python Tests
|
||||
test-args: >-
|
||||
:only-tags [:mb/transforms-python-test]
|
||||
needs-python-runner: true
|
||||
runner-index: [0, 1]
|
||||
env:
|
||||
CI: 'true'
|
||||
DRIVERS: bigquery-cloud-sdk
|
||||
@@ -201,11 +183,43 @@ jobs:
|
||||
MB_BIGQUERY_TEST_REFRESH_TOKEN: ${{ secrets.MB_BIGQUERY_TEST_REFRESH_TOKEN }}
|
||||
MB_BIGQUERY_CLOUD_SDK_TEST_SERVICE_ACCOUNT_JSON: ${{ secrets.MB_BIGQUERY_CLOUD_SDK_TEST_SERVICE_ACCOUNT_JSON }}
|
||||
MB_BIGQUERY_CLOUD_SDK_TEST_SERVICE_ACCOUNT_JSON_ROUTING: ${{ secrets.MB_BIGQUERY_CLOUD_SDK_TEST_SERVICE_ACCOUNT_JSON_ROUTING }}
|
||||
name: BigQuery ${{ matrix.job.name }}
|
||||
name: BigQuery Driver Tests ${{ matrix.runner-index }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Test BigQuery Cloud SDK driver
|
||||
id: test-driver
|
||||
uses: ./.github/actions/test-driver
|
||||
with:
|
||||
junit-name: 'be-tests-bigquery-cloud-sdk-ee'
|
||||
test-args: >-
|
||||
:only-tags [:mb/driver-tests]
|
||||
:exclude-tags [:mb/transforms-python-test]
|
||||
:fail-fast? true
|
||||
runner-index: ${{ matrix.runner-index }}
|
||||
runner-count: ${{ strategy.job-total }}
|
||||
jvms-per-runner: '2'
|
||||
heap-per-jvm: '4g'
|
||||
artifact-suffix: runner-${{ matrix.runner-index }}
|
||||
|
||||
be-tests-bigquery-transforms-python-ee:
|
||||
needs: determine-driver-skips
|
||||
if: ${{ needs.determine-driver-skips.outputs.bigquery-should-run == 'true' }}
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
CI: 'true'
|
||||
DRIVERS: bigquery-cloud-sdk
|
||||
MB_BIGQUERY_TEST_PROJECT_ID: ${{ secrets.BIGQUERY_TEST_PROJECT_ID }}
|
||||
MB_BIGQUERY_TEST_CLIENT_ID: ${{ secrets.MB_BIGQUERY_TEST_CLIENT_ID }}
|
||||
MB_BIGQUERY_TEST_CLIENT_SECRET: ${{ secrets.MB_BIGQUERY_TEST_CLIENT_SECRET }}
|
||||
MB_BIGQUERY_TEST_ACCESS_TOKEN: ${{ secrets.MB_BIGQUERY_TEST_ACCESS_TOKEN }}
|
||||
MB_BIGQUERY_TEST_REFRESH_TOKEN: ${{ secrets.MB_BIGQUERY_TEST_REFRESH_TOKEN }}
|
||||
MB_BIGQUERY_CLOUD_SDK_TEST_SERVICE_ACCOUNT_JSON: ${{ secrets.MB_BIGQUERY_CLOUD_SDK_TEST_SERVICE_ACCOUNT_JSON }}
|
||||
MB_BIGQUERY_CLOUD_SDK_TEST_SERVICE_ACCOUNT_JSON_ROUTING: ${{ secrets.MB_BIGQUERY_CLOUD_SDK_TEST_SERVICE_ACCOUNT_JSON_ROUTING }}
|
||||
name: BigQuery Transforms Python Tests
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Setup python-runner
|
||||
if: ${{ matrix.job.needs-python-runner == true }}
|
||||
uses: ./.github/actions/setup-python-runner
|
||||
with:
|
||||
metabase-automation-token: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
|
||||
@@ -213,16 +227,17 @@ jobs:
|
||||
id: test-driver
|
||||
uses: ./.github/actions/test-driver
|
||||
with:
|
||||
# This is the same as the cloud-sdk job above to preserve quarantine history in ci-conductor.
|
||||
junit-name: 'be-tests-bigquery-cloud-sdk-ee'
|
||||
test-args: ${{ matrix.job.test-args }}
|
||||
artifact-suffix: ${{ matrix.job.name }}
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
test-args: >-
|
||||
:only-tags [:mb/transforms-python-test]
|
||||
artifact-suffix: transforms-python
|
||||
- name: Cleanup python-runner
|
||||
if: ${{ always() && matrix.job.needs-python-runner == true }}
|
||||
if: ${{ always() }}
|
||||
uses: ./.github/actions/cleanup-python-runner
|
||||
with:
|
||||
upload-logs: ${{ steps.test-driver.outcome == 'failure' }}
|
||||
logs-artifact-suffix: ${{ matrix.job.name }}
|
||||
logs-artifact-suffix: transforms-python
|
||||
|
||||
be-tests-clickhouse-ee:
|
||||
needs: determine-driver-skips
|
||||
@@ -279,7 +294,6 @@ jobs:
|
||||
junit-name: 'be-tests-clickhouse-ee'
|
||||
test-args: ${{ matrix.job.test-args }}
|
||||
artifact-suffix: ${{ matrix.job.name }}
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
- name: Cleanup python-runner
|
||||
if: ${{ always() && matrix.job.needs-python-runner == true }}
|
||||
uses: ./.github/actions/cleanup-python-runner
|
||||
@@ -313,7 +327,6 @@ jobs:
|
||||
test-args: >-
|
||||
:only-tags [:mb/driver-tests]
|
||||
:exclude-tags [:mb/transforms-python-test]
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
|
||||
be-tests-databricks-ee:
|
||||
needs: determine-driver-skips
|
||||
@@ -376,7 +389,6 @@ jobs:
|
||||
junit-name: 'be-tests-databricks-ee'
|
||||
artifact-suffix: ${{ matrix.job.name }}
|
||||
test-args: ${{ matrix.job.test-args }}
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
|
||||
be-tests-h2:
|
||||
needs: determine-driver-skips
|
||||
@@ -396,7 +408,6 @@ jobs:
|
||||
test-args: >-
|
||||
:only-tags [:mb/driver-tests]
|
||||
:exclude-tags [:mb/transforms-python-test]
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
|
||||
be-tests-h2-oss:
|
||||
needs: determine-driver-skips
|
||||
@@ -417,7 +428,6 @@ jobs:
|
||||
test-args: >-
|
||||
:only-tags [:mb/driver-tests]
|
||||
:exclude-tags [:mb/transforms-python-test]
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
|
||||
be-tests-mysql-mariadb:
|
||||
needs: determine-driver-skips
|
||||
@@ -536,7 +546,6 @@ jobs:
|
||||
test-args: >-
|
||||
${{ matrix.job.test-args }}
|
||||
:exclude-tags '[ ${{ matrix.job.exclude-tag }} ${{ env.__ADDITIONAL_EXCLUDED_TAG__ }}]'
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
- name: Cleanup python-runner
|
||||
if: always()
|
||||
uses: ./.github/actions/cleanup-python-runner
|
||||
@@ -596,7 +605,6 @@ jobs:
|
||||
junit-name: ${{ matrix.version.junit-name }}
|
||||
test-args: ${{ matrix.job.test-args }}
|
||||
artifact-suffix: ${{ matrix.version.name }}-${{ matrix.job.name }}
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
- name: Cleanup python-runner
|
||||
if: ${{ always() && matrix.job.needs-python-runner == true }}
|
||||
uses: ./.github/actions/cleanup-python-runner
|
||||
@@ -658,7 +666,6 @@ jobs:
|
||||
test-args: >-
|
||||
:exclude-tags '[:mongo-sharded-cluster-tests :mb/transforms-python-test]'
|
||||
:only-tags [:mb/driver-tests]
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
|
||||
be-tests-mongo-sharded-cluster-ee:
|
||||
needs: determine-driver-skips
|
||||
@@ -683,7 +690,6 @@ jobs:
|
||||
junit-name: 'be-tests-mongo-sharded-cluster-ee'
|
||||
test-args: >-
|
||||
:only metabase.driver.mongo.sharded-cluster-test
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
|
||||
be-tests-oracle:
|
||||
needs: determine-driver-skips
|
||||
@@ -748,7 +754,6 @@ jobs:
|
||||
test-args: >-
|
||||
:only-tags [:mb/driver-tests]
|
||||
:exclude-tags [:mb/transforms-python-test]
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
|
||||
be-tests-postgres:
|
||||
needs: determine-driver-skips
|
||||
@@ -841,7 +846,6 @@ jobs:
|
||||
test-args: >-
|
||||
${{ matrix.job.test-args }}
|
||||
:exclude-tags '[ ${{ matrix.job.exclude-tag }} ${{ env.__ADDITIONAL_EXCLUDED_TAG__ }}]'
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
- name: Cleanup python-runner
|
||||
if: always()
|
||||
uses: ./.github/actions/cleanup-python-runner
|
||||
@@ -908,7 +912,6 @@ jobs:
|
||||
test-args: >-
|
||||
:only-tags [:mb/driver-tests]
|
||||
:exclude-tags [:mb/transforms-python-test]
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
|
||||
be-tests-redshift-ee:
|
||||
needs: determine-driver-skips
|
||||
@@ -999,7 +1002,6 @@ jobs:
|
||||
test-args: >-
|
||||
${{ matrix.job.test-args }}
|
||||
artifact-suffix: ${{ matrix.job.name }}
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
- name: Cleanup python-runner
|
||||
if: ${{ always() && matrix.job.needs-python-runner == true }}
|
||||
uses: ./.github/actions/cleanup-python-runner
|
||||
@@ -1062,7 +1064,6 @@ jobs:
|
||||
junit-name: 'be-tests-snowflake-ee'
|
||||
test-args: ${{ matrix.job.test-args }}
|
||||
artifact-suffix: ${{ matrix.job.name }}
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
- name: Cleanup python-runner
|
||||
if: ${{ always() && matrix.job.needs-python-runner == true }}
|
||||
uses: ./.github/actions/cleanup-python-runner
|
||||
@@ -1094,7 +1095,6 @@ jobs:
|
||||
test-args: >-
|
||||
:only-tags [:mb/driver-tests]
|
||||
:exclude-tags [:mb/transforms-python-test]
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
|
||||
be-tests-sqlite-ee:
|
||||
needs: determine-driver-skips
|
||||
@@ -1115,7 +1115,6 @@ jobs:
|
||||
test-args: >-
|
||||
:only-tags [:mb/driver-tests]
|
||||
:exclude-tags [:mb/transforms-python-test]
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
|
||||
be-tests-sqlserver:
|
||||
needs: determine-driver-skips
|
||||
@@ -1171,7 +1170,6 @@ jobs:
|
||||
junit-name: ${{ matrix.version.junit-name }}
|
||||
test-args: ${{ matrix.job.test-args }}
|
||||
artifact-suffix: ${{ matrix.version.name }}-${{ matrix.job.name }}
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
- name: Cleanup python-runner
|
||||
if: ${{ always() && matrix.job.needs-python-runner == true }}
|
||||
uses: ./.github/actions/cleanup-python-runner
|
||||
@@ -1268,7 +1266,6 @@ jobs:
|
||||
test-args: >-
|
||||
:only-tags [:mb/driver-tests]
|
||||
:exclude-tags [:mb/transforms-python-test]
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
|
||||
drivers-tests-result:
|
||||
needs:
|
||||
@@ -1276,6 +1273,7 @@ jobs:
|
||||
# if you add a driver job, you must add it to this list for it to be a required check
|
||||
- be-tests-athena-ee
|
||||
- be-tests-bigquery-cloud-sdk-ee
|
||||
- be-tests-bigquery-transforms-python-ee
|
||||
- be-tests-clickhouse-ee
|
||||
- be-tests-databricks-ee
|
||||
- be-tests-druid-jdbc-ee
|
||||
|
||||
18
.github/workflows/e2e-coverage-manifest.yml
vendored
@@ -48,17 +48,15 @@ jobs:
|
||||
name: instrumented-uberjar
|
||||
path: target/uberjar/metabase.jar
|
||||
retention-days: 1
|
||||
# Regenerate the OpenAPI spec from this SHA's defendpoint definitions so
|
||||
# route normalization in the merge job matches against the endpoints
|
||||
# that actually served this run, not the checked-in file (which is only
|
||||
# refreshed by the label-gated openapi-check workflow and drifts).
|
||||
# Generate routes from the same revision that served this run.
|
||||
- name: Generate OpenAPI route table
|
||||
run: bun run generate-openapi
|
||||
- name: Upload OpenAPI route table
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: openapi-spec
|
||||
path: resources/openapi/openapi.json
|
||||
path: .tmp/openapi/openapi.json
|
||||
include-hidden-files: true
|
||||
retention-days: 1
|
||||
|
||||
# Fixed-N sharding — coverage doesn't need the PR matrix-builder's tag/edition
|
||||
@@ -143,15 +141,13 @@ jobs:
|
||||
- name: Unzip previous manifest
|
||||
if: steps.prev.outputs.found == 'true'
|
||||
run: unzip -o prev-manifest.zip -d prev-manifest
|
||||
# Overwrites the checked-out resources/openapi/openapi.json with the one
|
||||
# generated from this run's SHA. If the artifact is somehow missing, the
|
||||
# checked-in file is still there and the builder falls back to it.
|
||||
# A missing artifact leaves route normalisation using its regex fallback.
|
||||
- name: Download OpenAPI route table
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: openapi-spec
|
||||
path: resources/openapi
|
||||
path: .tmp/openapi
|
||||
# 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.
|
||||
@@ -166,8 +162,8 @@ jobs:
|
||||
# step expects the manifests at the archive root.
|
||||
- name: Include OpenAPI spec in artifact
|
||||
run: |
|
||||
if [ -f resources/openapi/openapi.json ]; then
|
||||
cp resources/openapi/openapi.json e2e/coverage/openapi.json
|
||||
if [ -f .tmp/openapi/openapi.json ]; then
|
||||
cp .tmp/openapi/openapi.json e2e/coverage/openapi.json
|
||||
fi
|
||||
# All files share one artifact so the per-test manifest always travels
|
||||
# with the spec manifest it was built from (backfill and PR-side
|
||||
|
||||
32
.github/workflows/e2e-test.yml
vendored
@@ -64,6 +64,7 @@ jobs:
|
||||
# workspace where the gate step runs; an absolute path anchored to the
|
||||
# workspace makes both agree regardless of cwd.
|
||||
QUARANTINE_FAILURES_FILE: ${{ github.workspace }}/target/quarantine-failures.jsonl
|
||||
RERUN_ARTIFACT_BASE: failed-tests-${{ inputs.name }}-${{ inputs.edition }}
|
||||
# Cypress records video for every spec but the passing specs' videos are
|
||||
# deleted in after:spec so recording is ~5-10% slowdown per shard and almost
|
||||
# never yields an artifact. Failure screenshots are unaffected, and the
|
||||
@@ -101,6 +102,7 @@ jobs:
|
||||
postgres: true
|
||||
mysql: true
|
||||
mongo: true
|
||||
mocksaml: true
|
||||
|
||||
- name: Retrieve uberjar artifact for ${{ inputs.edition }}
|
||||
uses: ./.github/actions/fetch-artifact
|
||||
@@ -119,17 +121,28 @@ jobs:
|
||||
id: cypress-prep
|
||||
uses: ./.github/actions/prepare-cypress
|
||||
|
||||
- name: Download failed test artifacts
|
||||
uses: actions/download-artifact@v8
|
||||
if: github.run_attempt != '1'
|
||||
- name: Resolve previous attempt
|
||||
if: ${{ github.run_attempt != '1' }}
|
||||
run: echo "RERUN_PREV_ATTEMPT=$((GITHUB_RUN_ATTEMPT - 1))" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Restore previously-failed specs
|
||||
if: ${{ github.run_attempt != '1' }}
|
||||
continue-on-error: true
|
||||
id: failed-specs
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: failed-tests-${{ inputs.name }}-${{ inputs.edition }}
|
||||
pattern: ${{ env.RERUN_ARTIFACT_BASE }}-attempt-${{ env.RERUN_PREV_ATTEMPT }}
|
||||
path: target/rerun
|
||||
|
||||
- name: Get failed specs
|
||||
if: github.run_attempt != '1'
|
||||
run: echo "specs=$(cat failed-specs)" >> "$GITHUB_ENV"
|
||||
if: ${{ github.run_attempt != '1' }}
|
||||
run: |
|
||||
if [ -s target/rerun/failed-specs ]; then
|
||||
echo "Re-running only the specs that failed on attempt $RERUN_PREV_ATTEMPT:"
|
||||
cat target/rerun/failed-specs
|
||||
echo "specs=$(cat target/rerun/failed-specs)" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "No failed-spec list from attempt $RERUN_PREV_ATTEMPT; running the full suite."
|
||||
fi
|
||||
|
||||
# run-tests.yml compiles this in parallel with the uberjar build. Downloading it is ~10x faster than compiling it here.
|
||||
# Callers without that job (stress tests, coverage shards, cross-run re-runs) fall through to compiling as before.
|
||||
@@ -232,7 +245,7 @@ jobs:
|
||||
if: |
|
||||
!cancelled() && (steps.split-e2e.outcome == 'failure' || steps.tagged-e2e.outcome == 'failure')
|
||||
with:
|
||||
name: cypress-recording-${{ inputs.name }}-${{ inputs.edition }}
|
||||
name: cypress-recording-${{ inputs.name }}-${{ inputs.edition }}-attempt-${{ github.run_attempt }}
|
||||
path: |
|
||||
./cypress
|
||||
./logs/test.log
|
||||
@@ -242,8 +255,9 @@ jobs:
|
||||
uses: actions/upload-artifact@v7
|
||||
if: |
|
||||
!cancelled() && (steps.split-e2e.outcome == 'failure' || steps.tagged-e2e.outcome == 'failure')
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: failed-tests-${{ inputs.name }}-${{ inputs.edition }}
|
||||
name: ${{ env.RERUN_ARTIFACT_BASE }}-attempt-${{ github.run_attempt }}
|
||||
path: ./cypress/test-results
|
||||
if-no-files-found: ignore
|
||||
|
||||
|
||||
@@ -26,6 +26,9 @@ jobs:
|
||||
fail-fast: false
|
||||
env:
|
||||
CI_CONDUCTOR_TEST_SUITE: embedding-sdk-component-react-${{ matrix.react-version }}
|
||||
# Base name of this leg's granular-rerun artifact. Each matrix leg needs its own, or a
|
||||
# leg would restore the other leg's failed set. `-attempt-N` is appended below.
|
||||
RERUN_ARTIFACT_BASE: failed-specs-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)
|
||||
@@ -73,6 +76,30 @@ jobs:
|
||||
id: cypress-prep
|
||||
uses: ./.github/actions/prepare-cypress
|
||||
|
||||
- name: Resolve previous attempt
|
||||
if: ${{ github.run_attempt != '1' }}
|
||||
run: echo "RERUN_PREV_ATTEMPT=$((GITHUB_RUN_ATTEMPT - 1))" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Restore previously-failed specs
|
||||
if: ${{ github.run_attempt != '1' }}
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
# use pattern instead of name since this should still pass if the artifact doesn't exist
|
||||
pattern: ${{ env.RERUN_ARTIFACT_BASE }}-attempt-${{ env.RERUN_PREV_ATTEMPT }}
|
||||
path: target/rerun
|
||||
|
||||
- name: Get failed specs
|
||||
if: ${{ github.run_attempt != '1' }}
|
||||
run: |
|
||||
if [ -s target/rerun/failed-specs ]; then
|
||||
echo "Re-running failed specs from attempt $RERUN_PREV_ATTEMPT:"
|
||||
cat target/rerun/failed-specs
|
||||
echo "specs=$(cat target/rerun/failed-specs)" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "No failed-spec list from attempt $RERUN_PREV_ATTEMPT; running the full suite."
|
||||
fi
|
||||
|
||||
# esbuild resolves every import in the spec bundle's graph, even the ones tree shaking then drops.
|
||||
# The cljs imports resolve into target/cljs_dev, so the compiled CLJS has to exist before Cypress runs.
|
||||
# run-tests.yml compiles it in parallel with the uberjar build, so downloading is ~10x faster than compiling here.
|
||||
@@ -107,9 +134,18 @@ jobs:
|
||||
|
||||
- name: Run component tests for Embedding SDK
|
||||
id: run-tests
|
||||
shell: bash
|
||||
env:
|
||||
CYPRESS_BROWSER: ${{ steps.cypress-prep.outputs.chrome-path }}
|
||||
run: node e2e/runner/run_cypress_ci.js component
|
||||
SPECS: ${{ env.specs }}
|
||||
# record video for re-runs of previously failed specs
|
||||
CYPRESS_VIDEO: ${{ env.specs && 'true' || 'false' }}
|
||||
run: |
|
||||
SPEC_ARGS=()
|
||||
if [ -n "$SPECS" ]; then
|
||||
SPEC_ARGS=(--spec "$SPECS")
|
||||
fi
|
||||
node e2e/runner/run_cypress_ci.js component "${SPEC_ARGS[@]}"
|
||||
# Keep this step continue-on-error because the quarantine logic controls whether it ultimately fails.
|
||||
continue-on-error: true
|
||||
|
||||
@@ -140,12 +176,21 @@ jobs:
|
||||
uses: actions/upload-artifact@v7
|
||||
if: ${{ !cancelled() && steps.run-tests.outcome == 'failure' }}
|
||||
with:
|
||||
name: cypress-recording-embedding-sdk-react-${{ matrix.react-version }}
|
||||
name: cypress-recording-embedding-sdk-react-${{ matrix.react-version }}-attempt-${{ github.run_attempt }}
|
||||
path: |
|
||||
./cypress
|
||||
./logs/test.log
|
||||
if-no-files-found: ignore
|
||||
|
||||
- name: "SDK component tests > Upload failed specs for rerun"
|
||||
uses: actions/upload-artifact@v7
|
||||
if: ${{ !cancelled() && steps.run-tests.outcome == 'failure' }}
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: ${{ env.RERUN_ARTIFACT_BASE }}-attempt-${{ github.run_attempt }}
|
||||
path: ./cypress/test-results
|
||||
if-no-files-found: ignore
|
||||
|
||||
- name: "SDK component tests > Publish Summary"
|
||||
if: ${{ !cancelled() && steps.run-tests.outcome == 'failure' }}
|
||||
uses: actions/github-script@v9
|
||||
@@ -195,6 +240,7 @@ jobs:
|
||||
MB_WAREHOUSE_ALLOWED_NETWORKS: "allow-all"
|
||||
METASTORE_DEV_SERVER_URL: ${{ secrets.METASTORE_DEV_SERVER_URL }}
|
||||
CI_CONDUCTOR_TEST_SUITE: embedding-sdk-component-backward-compatibility
|
||||
RERUN_ARTIFACT_BASE: failed-specs-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)
|
||||
@@ -261,6 +307,30 @@ jobs:
|
||||
id: cypress-prep
|
||||
uses: ./.github/actions/prepare-cypress
|
||||
|
||||
- name: Resolve previous attempt
|
||||
if: ${{ github.run_attempt != '1' }}
|
||||
run: echo "RERUN_PREV_ATTEMPT=$((GITHUB_RUN_ATTEMPT - 1))" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Restore previously-failed specs
|
||||
if: ${{ github.run_attempt != '1' }}
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
# use pattern instead of name since this should still pass if the artifact doesn't exist
|
||||
pattern: ${{ env.RERUN_ARTIFACT_BASE }}-attempt-${{ env.RERUN_PREV_ATTEMPT }}
|
||||
path: target/rerun
|
||||
|
||||
- name: Get failed specs
|
||||
if: ${{ github.run_attempt != '1' }}
|
||||
run: |
|
||||
if [ -s target/rerun/failed-specs ]; then
|
||||
echo "Re-running only the specs that failed on attempt $RERUN_PREV_ATTEMPT:"
|
||||
cat target/rerun/failed-specs
|
||||
echo "specs=$(cat target/rerun/failed-specs)" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "No failed-spec list from attempt $RERUN_PREV_ATTEMPT; running the full suite."
|
||||
fi
|
||||
|
||||
# esbuild resolves every import in the spec bundle's graph, even the ones tree shaking then drops.
|
||||
# The cljs imports resolve into target/cljs_dev, so the compiled CLJS has to exist before Cypress runs.
|
||||
# The checked-out tree is the previous commit, which the current run's CLJS artifact was not built from,
|
||||
@@ -283,11 +353,20 @@ jobs:
|
||||
|
||||
- name: Run component tests for Embedding SDK
|
||||
id: run-tests
|
||||
shell: bash
|
||||
env:
|
||||
CYPRESS_BROWSER: ${{ steps.cypress-prep.outputs.chrome-path }}
|
||||
SPECS: ${{ env.specs }}
|
||||
# record video for re-runs of previously failed specs
|
||||
CYPRESS_VIDEO: ${{ env.specs && 'true' || 'false' }}
|
||||
run: |
|
||||
SPEC_ARGS=()
|
||||
if [ -n "$SPECS" ]; then
|
||||
SPEC_ARGS=(--spec "$SPECS")
|
||||
fi
|
||||
node e2e/runner/run_cypress_ci.js component \
|
||||
--expose grepTags="-@skip-backward-compatibility"
|
||||
--expose grepTags="-@skip-backward-compatibility" \
|
||||
"${SPEC_ARGS[@]}"
|
||||
# Keep this step continue-on-error because the quarantine logic controls whether it ultimately fails.
|
||||
continue-on-error: true
|
||||
|
||||
@@ -318,12 +397,21 @@ jobs:
|
||||
uses: actions/upload-artifact@v7
|
||||
if: ${{ !cancelled() && steps.run-tests.outcome == 'failure' }}
|
||||
with:
|
||||
name: cypress-recording-embedding-sdk-cross-version
|
||||
name: cypress-recording-embedding-sdk-cross-version-attempt-${{ github.run_attempt }}
|
||||
path: |
|
||||
./cypress
|
||||
./logs/test.log
|
||||
if-no-files-found: ignore
|
||||
|
||||
- name: Upload failed specs for rerun
|
||||
uses: actions/upload-artifact@v7
|
||||
if: ${{ !cancelled() && steps.run-tests.outcome == 'failure' }}
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: ${{ env.RERUN_ARTIFACT_BASE }}-attempt-${{ github.run_attempt }}
|
||||
path: ./cypress/test-results
|
||||
if-no-files-found: ignore
|
||||
|
||||
- name: Publish Summary
|
||||
if: ${{ !cancelled() && steps.run-tests.outcome == 'failure' }}
|
||||
uses: actions/github-script@v9
|
||||
|
||||
@@ -203,9 +203,7 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
environment: ["production", "development"]
|
||||
# TEMPORARILY DISABLED: re-enable by removing the `false &&` below.
|
||||
if: |
|
||||
false &&
|
||||
!cancelled() && needs.get-sample-apps-data.result == 'success' &&
|
||||
(github.ref == 'refs/heads/master' || github.base_ref == 'master')
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
@@ -235,11 +233,6 @@ jobs:
|
||||
sample-app-branch-name: ${{ needs.get-sample-apps-data.outputs.branch_name }}
|
||||
sample-app-environment: ${{ matrix.environment }}
|
||||
staging-mb-all-features-token: ${{ secrets.STAGING_MB_ALL_FEATURES_TOKEN }}
|
||||
shoppy-iam-role: ${{ secrets.SHOPPY_IAM_ROLE }}
|
||||
shoppy-metabase-app-db-s3-bucket: ${{ secrets.SHOPPY_METABASE_APP_DB_S3_BUCKET }}
|
||||
shoppy-metabase-app-db-s3-key: ${{ secrets.SHOPPY_METABASE_APP_DB_S3_KEY }}
|
||||
shoppy-metabase-app-db-user: ${{ secrets.SHOPPY_METABASE_APP_DB_USER }}
|
||||
shoppy-metabase-app-db-database: ${{ secrets.SHOPPY_METABASE_APP_DB_DATABASE }}
|
||||
metastore-dev-server-url: ${{ secrets.METASTORE_DEV_SERVER_URL }}
|
||||
shoppy-datadog-application-id: ${{ secrets.SHOPPY_METABASE_APP_DATADOG_APPLICATION_ID }}
|
||||
shoppy-datadog-client-token: ${{ secrets.SHOPPY_METABASE_APP_DATADOG_CLIENT_TOKEN }}
|
||||
@@ -272,9 +265,7 @@ jobs:
|
||||
# They use the shoppy production environment and include performance measurement tests.
|
||||
e2e-shoppy-synthetic-tests:
|
||||
needs: [get-sample-apps-data]
|
||||
# TEMPORARILY DISABLED: re-enable by removing the `false &&` below.
|
||||
if: |
|
||||
false &&
|
||||
!cancelled() && needs.get-sample-apps-data.result == 'success' &&
|
||||
github.event_name == 'push' &&
|
||||
github.ref == 'refs/heads/master'
|
||||
@@ -305,11 +296,6 @@ jobs:
|
||||
sample-app-branch-name: ${{ needs.get-sample-apps-data.outputs.branch_name }}
|
||||
sample-app-environment: production
|
||||
staging-mb-all-features-token: ${{ secrets.STAGING_MB_ALL_FEATURES_TOKEN }}
|
||||
shoppy-iam-role: ${{ secrets.SHOPPY_IAM_ROLE }}
|
||||
shoppy-metabase-app-db-s3-bucket: ${{ secrets.SHOPPY_METABASE_APP_DB_S3_BUCKET }}
|
||||
shoppy-metabase-app-db-s3-key: ${{ secrets.SHOPPY_METABASE_APP_DB_S3_KEY }}
|
||||
shoppy-metabase-app-db-user: ${{ secrets.SHOPPY_METABASE_APP_DB_USER }}
|
||||
shoppy-metabase-app-db-database: ${{ secrets.SHOPPY_METABASE_APP_DB_DATABASE }}
|
||||
metastore-dev-server-url: ${{ secrets.METASTORE_DEV_SERVER_URL }}
|
||||
shoppy-datadog-application-id: ${{ secrets.SHOPPY_METABASE_APP_DATADOG_APPLICATION_ID }}
|
||||
shoppy-datadog-client-token: ${{ secrets.SHOPPY_METABASE_APP_DATADOG_CLIENT_TOKEN }}
|
||||
@@ -438,15 +424,38 @@ jobs:
|
||||
if: steps.prebuilt-cljs.outcome != 'success'
|
||||
run: bun run build-pure:cljs
|
||||
|
||||
# The data-app suite synchronizes real actions, and Metabase only permits
|
||||
# those against a writable warehouse, which comes from the `qa-db`
|
||||
# snapshot below. That snapshot creator builds every QA database in one
|
||||
# pass — Mongo and MySQL included — so all three have to be up even though
|
||||
# only `postgres-writable` is restored. The other host apps read the sample
|
||||
# database, so they skip the pull entirely.
|
||||
- name: Prepare Docker containers
|
||||
if: ${{ matrix.test_suite == 'vite-6-data-app-host-app-e2e' }}
|
||||
uses: ./.github/actions/e2e-prepare-containers
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
postgres: true
|
||||
mysql: true
|
||||
mongo: true
|
||||
|
||||
- name: Run Metabase
|
||||
run: node e2e/runner/run_cypress_ci.js start
|
||||
|
||||
- uses: ./.github/actions/resolve-job-id
|
||||
|
||||
# Without `--spec` the snapshot config picks up `qa-db.cy.snap.js` too,
|
||||
# which is what creates the `postgres-writable` snapshot the data-app
|
||||
# action tests restore.
|
||||
- name: Generate database snapshots
|
||||
run: |
|
||||
node e2e/runner/run_cypress_ci.js snapshot \
|
||||
--spec "e2e/snapshot-creators/default.cy.snap.js"
|
||||
if [ "${{ matrix.test_suite }}" = "vite-6-data-app-host-app-e2e" ]; then
|
||||
node e2e/runner/run_cypress_ci.js snapshot
|
||||
else
|
||||
node e2e/runner/run_cypress_ci.js snapshot \
|
||||
--spec "e2e/snapshot-creators/default.cy.snap.js"
|
||||
fi
|
||||
|
||||
- name: Prepare and launch Host App
|
||||
run: npx tsx ./e2e/runner/embedding-sdk/host-apps/start-ci.ts ${{ matrix.test_suite }}
|
||||
|
||||
@@ -24,6 +24,8 @@ jobs:
|
||||
# unrelated to embedding (EMB-765)
|
||||
echo "::remove-matcher owner=tsc::"
|
||||
bun run build-embedding-sdk-package
|
||||
- name: Smoke test the packed SDK CLI
|
||||
run: node bin/embedding-sdk/test-sdk-package.mjs
|
||||
- name: Prepare Embedding SDK artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
|
||||
@@ -1,29 +1,68 @@
|
||||
name: CI for PR Review ENV Destroy
|
||||
run-name: Destroying Dynamic PR Environment for ${{ github.ref_name }} by @${{ github.actor }}
|
||||
name: Cleanup PR Environment
|
||||
run-name: Destroying Dynamic PR Environment for PR ${{ inputs.pr_number || github.event.number }} by @${{ github.actor }}
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
pull_request:
|
||||
types: [ closed, unlabeled ]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: 'PR number to destroy environment for'
|
||||
required: true
|
||||
type: string
|
||||
self_hosting:
|
||||
description: 'Self-hosting stack'
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
pr_number:
|
||||
description: 'PR number (required for manual dispatch)'
|
||||
required: false
|
||||
type: string
|
||||
|
||||
env:
|
||||
PR_NUMBER: ${{ inputs.pr_number || github.event.number }}
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ inputs.pr_number || github.event.number }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
destroy_pr:
|
||||
# Manual runs go through the pr-env-admin environment for approval.
|
||||
# Skipped for pull_request events, which are already scoped to the PR's own labels.
|
||||
approve:
|
||||
if: ${{ github.event_name == 'workflow_dispatch' }}
|
||||
runs-on: ${{ vars.SLIM_RUNNER_KEY }}
|
||||
timeout-minutes: 5
|
||||
environment: pr-env-admin
|
||||
steps:
|
||||
- name: Approved
|
||||
env:
|
||||
PR_NUMBER: ${{ inputs.pr_number }}
|
||||
run: |
|
||||
echo "Destroying PR environment for PR #${PR_NUMBER}"
|
||||
|
||||
cleanup:
|
||||
needs: [ approve ]
|
||||
# Runs when dispatched manually (after approval), when a PR carrying a PR-Env
|
||||
# label is closed, or when one of those labels is removed.
|
||||
if: >-
|
||||
${{ !cancelled()
|
||||
&& (needs.approve.result == 'success' || needs.approve.result == 'skipped')
|
||||
&& (github.event_name == 'workflow_dispatch'
|
||||
|| (github.event.action == 'closed'
|
||||
&& (contains(github.event.pull_request.labels.*.name, 'PR-Env')
|
||||
|| contains(github.event.pull_request.labels.*.name, 'PR-Env-self-hosting')
|
||||
|| contains(github.event.pull_request.labels.*.name, 'PR-Env-analytics-dev')
|
||||
|| contains(github.event.pull_request.labels.*.name, 'PR-Env-Stats-Dump')))
|
||||
|| (github.event.action == 'unlabeled'
|
||||
&& (github.event.label.name == 'PR-Env'
|
||||
|| github.event.label.name == 'PR-Env-self-hosting'
|
||||
|| github.event.label.name == 'PR-Env-analytics-dev'
|
||||
|| github.event.label.name == 'PR-Env-Stats-Dump'))) }}
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
name: PR Review ENV Destroy
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
env:
|
||||
PR_NUMBER: ${{ inputs.pr_number || github.event.number }}
|
||||
SELF_HOSTING: >-
|
||||
${{ (github.event_name == 'workflow_dispatch' && inputs.self_hosting)
|
||||
|| (github.event.action == 'unlabeled' && github.event.label.name == 'PR-Env-self-hosting')
|
||||
|| (github.event.action == 'closed' && contains(github.event.pull_request.labels.*.name, 'PR-Env-self-hosting')) }}
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v6
|
||||
@@ -69,17 +108,17 @@ jobs:
|
||||
namespace: default
|
||||
user: oidc-token
|
||||
- name: Destroy PR Review ENV
|
||||
if: ${{ !inputs.self_hosting }}
|
||||
if: ${{ env.SELF_HOSTING != 'true' }}
|
||||
run: |
|
||||
kubectl delete metabase -n hosting-pr${{ env.PR_NUMBER }} hosting-pr${{ env.PR_NUMBER }}
|
||||
kubectl delete ns hosting-pr${{ env.PR_NUMBER }}
|
||||
- name: Setup Helm
|
||||
if: ${{ inputs.self_hosting }}
|
||||
if: ${{ env.SELF_HOSTING == 'true' }}
|
||||
uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # v5.0.0
|
||||
with:
|
||||
version: 'latest'
|
||||
- name: Destroy PR Review ENV (self-hosting)
|
||||
if: ${{ inputs.self_hosting }}
|
||||
if: ${{ env.SELF_HOSTING == 'true' }}
|
||||
run: |
|
||||
helm uninstall hosting-pr${{ env.PR_NUMBER }} --namespace hosting-pr${{ env.PR_NUMBER }} || true
|
||||
kubectl delete ns hosting-pr${{ env.PR_NUMBER }} || true
|
||||
30
.github/workflows/kondo-ratchets-shrink.yml
vendored
@@ -1,9 +1,9 @@
|
||||
name: Shrink ratchets
|
||||
|
||||
# Lowers the budgets in .clj-kondo/ratchets.edn to match master and delivers the result through one
|
||||
# Lowers the ratchet budgets to match master and delivers the result through one
|
||||
# automation PR, because the rulesets require a pull request for every change to master. The PR is
|
||||
# approved by github-actions[bot] and merges itself once the ratchet check passes. A merge of that PR
|
||||
# touches only ratchets.edn, which matches `.clj-kondo/**` below, so it triggers one more run that finds
|
||||
# touches only ratchet files, which match `.clj-kondo/**` below, so it triggers one more run that finds
|
||||
# nothing to lower and stops: a single no-op, not a loop.
|
||||
|
||||
on:
|
||||
@@ -32,7 +32,8 @@ concurrency:
|
||||
|
||||
env:
|
||||
AUTOMATION_BRANCH: automation/shrink-ratchets
|
||||
RATCHETS_FILE: .clj-kondo/ratchets.edn
|
||||
KONDO_RATCHETS_FILE: .clj-kondo/ratchets.edn
|
||||
MODULE_RATCHETS_FILE: .clj-kondo/config/modules/ratchets.edn
|
||||
|
||||
jobs:
|
||||
shrink:
|
||||
@@ -56,9 +57,10 @@ jobs:
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 25
|
||||
- name: Shrink ignore limits
|
||||
- name: Shrink ratchets
|
||||
run: |
|
||||
cp "$RATCHETS_FILE" "$RUNNER_TEMP/ratchets-before.edn"
|
||||
cp "$KONDO_RATCHETS_FILE" "$RUNNER_TEMP/kondo-ratchets-before.edn"
|
||||
cp "$MODULE_RATCHETS_FILE" "$RUNNER_TEMP/module-ratchets-before.edn"
|
||||
./bin/mage kondo-ratchets-shrink
|
||||
# Two independent outputs: `push` says the branch needs a new commit, `pr` names the open automation
|
||||
# PR if there is one. Approval and auto-merge key on `pr` alone, so a run that failed after opening
|
||||
@@ -77,23 +79,23 @@ jobs:
|
||||
--jq '.[0].number // empty')
|
||||
echo "pr=$open_pr" >> "$GITHUB_OUTPUT"
|
||||
|
||||
if git diff --quiet -- "$RATCHETS_FILE"; then
|
||||
if git diff --quiet -- "$KONDO_RATCHETS_FILE" "$MODULE_RATCHETS_FILE"; then
|
||||
echo "Ratchet budgets already match master $(git rev-parse --short HEAD)"
|
||||
[ -z "$open_pr" ] || echo "The open automation PR #$open_pr is re-approved below"
|
||||
echo "push=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# The ratchet file is the whole payload, so an open PR that already carries this result is left
|
||||
# The ratchet files are the whole payload, so an open PR that already carries this result is left
|
||||
# alone. Rebasing it onto every master push would restart its CI and churn the diff without
|
||||
# changing what it delivers. Compare the open PR with the working tree, which contains the
|
||||
# shrunk ratchets file produced above, to determine whether the PR would deliver the same file.
|
||||
# shrunk ratchet files produced above, to determine whether the PR would deliver the same result.
|
||||
#
|
||||
# A conflicted PR is the exception: only a fresh commit clears it. Mergeability is computed
|
||||
# lazily, so a pending UNKNOWN reads as not conflicted and the next run settles it.
|
||||
if [ -n "$open_pr" ] \
|
||||
&& git fetch --quiet --depth=1 origin "refs/heads/$AUTOMATION_BRANCH" 2>/dev/null \
|
||||
&& git diff --quiet FETCH_HEAD -- "$RATCHETS_FILE" \
|
||||
&& git diff --quiet FETCH_HEAD -- "$KONDO_RATCHETS_FILE" "$MODULE_RATCHETS_FILE" \
|
||||
&& [ "$(gh pr view "$open_pr" --repo "$GITHUB_REPOSITORY" \
|
||||
--json mergeable --jq .mergeable)" != CONFLICTING ]; then
|
||||
echo "The automation PR #$open_pr already carries this result"
|
||||
@@ -102,7 +104,7 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git diff --stat -- "$RATCHETS_FILE"
|
||||
git diff --stat -- "$KONDO_RATCHETS_FILE" "$MODULE_RATCHETS_FILE"
|
||||
echo "push=true" >> "$GITHUB_OUTPUT"
|
||||
- name: Update the automation PR
|
||||
id: deliver
|
||||
@@ -116,14 +118,16 @@ jobs:
|
||||
OPEN_PR: ${{ steps.compare.outputs.pr }}
|
||||
run: |
|
||||
./bin/mage -kondo-ratchets-pr-body \
|
||||
"$RUNNER_TEMP/ratchets-before.edn" \
|
||||
"$RATCHETS_FILE" \
|
||||
"$RUNNER_TEMP/kondo-ratchets-before.edn" \
|
||||
"$KONDO_RATCHETS_FILE" \
|
||||
"$RUNNER_TEMP/module-ratchets-before.edn" \
|
||||
"$MODULE_RATCHETS_FILE" \
|
||||
"${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \
|
||||
> "$RUNNER_TEMP/ratchets-pr-body.md"
|
||||
|
||||
git config user.name github-actions
|
||||
git config user.email github-actions@github.com
|
||||
git add -- "$RATCHETS_FILE"
|
||||
git add -- "$KONDO_RATCHETS_FILE" "$MODULE_RATCHETS_FILE"
|
||||
git commit --quiet -m "Tighten ratchets"
|
||||
git push --force --quiet \
|
||||
"https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" \
|
||||
|
||||
25
.github/workflows/linear-pr-links.yml
vendored
@@ -1,25 +0,0 @@
|
||||
name: Add Issue References to PR
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, edited, synchronize]
|
||||
|
||||
jobs:
|
||||
add-issue-references:
|
||||
runs-on: ${{ vars.SLIM_RUNNER_KEY }}
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
- name: Run the link script
|
||||
uses: actions/github-script@v9
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
REPO_OWNER: ${{ github.repository_owner }}
|
||||
REPO_NAME: ${{ github.event.repository.name }}
|
||||
with:
|
||||
script: |
|
||||
const { link_issues } = require('${{ github.workspace }}/.github/scripts/update-pr-with-issues.js');
|
||||
await link_issues(github)
|
||||
154
.github/workflows/lint.security.yml
vendored
@@ -36,6 +36,7 @@ jobs:
|
||||
uses: github/codeql-action/analyze@v4
|
||||
|
||||
trivy:
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -68,8 +69,157 @@ jobs:
|
||||
sarif_file: trivy-results-${{ matrix.edition }}.sarif
|
||||
category: trivy-${{ matrix.edition }}
|
||||
|
||||
# Generate pom.xml files for all Maven projects
|
||||
snyk-generate-poms:
|
||||
name: Generate pom.xml files
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Prepare back-end environment
|
||||
uses: ./.github/actions/prepare-backend
|
||||
- name: Generate all pom.xml
|
||||
run: .github/scripts/write-poms.sh
|
||||
- name: Upload pom files
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: pom-files
|
||||
path: |
|
||||
pom.xml
|
||||
modules/drivers/*/pom.xml
|
||||
|
||||
# Yarn/NPM projects that don't need pom files
|
||||
snyk-frontend-projects:
|
||||
name: ${{ matrix.project.name }}
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
matrix:
|
||||
project:
|
||||
- name: Main Frontend (bun w/ yarn.lock)
|
||||
file: yarn.lock
|
||||
package-manager: yarn
|
||||
category: main-frontend-bun
|
||||
- name: Release Helper
|
||||
file: release/yarn.lock
|
||||
package-manager: yarn
|
||||
category: release-helper
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
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
|
||||
# but not in dependency specs, causing Snyk to fail matching dependencies.
|
||||
# The sed commands fix this inconsistency. Bun also names aliased entries
|
||||
# after the aliased package instead of the alias, which the script fixes.
|
||||
# Both continue on error to let Snyk report parsing issues directly rather
|
||||
# than failing on the workaround.
|
||||
- name: Generate yarn.lock
|
||||
if: matrix.project.file == 'yarn.lock'
|
||||
run: |
|
||||
bun install --yarn
|
||||
sed -i -E 's/< ([0-9]+)\.0\.0"/< \1"/g; s/< ([0-9]+)\.0"/< \1"/g' yarn.lock || true
|
||||
bun ${{ github.workspace }}/.github/scripts/fix-yarn-lock-aliases.ts || true
|
||||
- name: Generate release/yarn.lock
|
||||
if: matrix.project.file == 'release/yarn.lock'
|
||||
run: |
|
||||
bun install --yarn
|
||||
sed -i -E 's/< ([0-9]+)\.0\.0"/< \1"/g; s/< ([0-9]+)\.0"/< \1"/g' yarn.lock || true
|
||||
bun ${{ github.workspace }}/.github/scripts/fix-yarn-lock-aliases.ts || true
|
||||
working-directory: release
|
||||
- uses: snyk/actions/setup@9adf32b1121593767fc3c057af55b55db032dc04 # v1.0.0
|
||||
- name: Run snyk test
|
||||
env:
|
||||
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
|
||||
continue-on-error: true
|
||||
run: snyk test --org="metabase-AnD9iS4Wfu4eBYMsG2DZ5N" --remote-repo-url="https://github.com/metabase/metabase" --file=${{ matrix.project.file }} --package-manager=${{ matrix.project.package-manager }} --sarif-file-output=snyk-${{ matrix.project.category }}.sarif
|
||||
- name: Upload results
|
||||
uses: github/codeql-action/upload-sarif@v4
|
||||
with:
|
||||
sarif_file: snyk-${{ matrix.project.category }}.sarif
|
||||
category: ${{ matrix.project.category }}
|
||||
|
||||
# Maven projects that need pom files
|
||||
snyk-maven-projects:
|
||||
needs: snyk-generate-poms
|
||||
name: ${{ matrix.project.name }}
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
matrix:
|
||||
project:
|
||||
- name: Main Backend (maven)
|
||||
file: pom.xml
|
||||
category: main-backend-maven
|
||||
- name: Driver - Athena
|
||||
file: modules/drivers/athena/pom.xml
|
||||
category: driver-athena
|
||||
- name: Driver - BigQuery Cloud SDK
|
||||
file: modules/drivers/bigquery-cloud-sdk/pom.xml
|
||||
category: driver-bigquery-cloud-sdk
|
||||
- name: Driver - ClickHouse
|
||||
file: modules/drivers/clickhouse/pom.xml
|
||||
category: driver-clickhouse
|
||||
- name: Driver - Databricks
|
||||
file: modules/drivers/databricks/pom.xml
|
||||
category: driver-databricks
|
||||
- name: Driver - Druid JDBC
|
||||
file: modules/drivers/druid-jdbc/pom.xml
|
||||
category: driver-druid-jdbc
|
||||
- name: Driver - Hive-like
|
||||
file: modules/drivers/hive-like/pom.xml
|
||||
category: driver-hive-like
|
||||
- name: Driver - MongoDB
|
||||
file: modules/drivers/mongo/pom.xml
|
||||
category: driver-mongo
|
||||
- name: Driver - Oracle
|
||||
file: modules/drivers/oracle/pom.xml
|
||||
category: driver-oracle
|
||||
- name: Driver - Presto JDBC
|
||||
file: modules/drivers/presto-jdbc/pom.xml
|
||||
category: driver-presto-jdbc
|
||||
- name: Driver - Redshift
|
||||
file: modules/drivers/redshift/pom.xml
|
||||
category: driver-redshift
|
||||
- name: Driver - Snowflake
|
||||
file: modules/drivers/snowflake/pom.xml
|
||||
category: driver-snowflake
|
||||
- name: Driver - SparkSQL
|
||||
file: modules/drivers/sparksql/pom.xml
|
||||
category: driver-sparksql
|
||||
- name: Driver - SQL Server
|
||||
file: modules/drivers/sqlserver/pom.xml
|
||||
category: driver-sqlserver
|
||||
- name: Driver - Starburst
|
||||
file: modules/drivers/starburst/pom.xml
|
||||
category: driver-starburst
|
||||
- name: Driver - Vertica
|
||||
file: modules/drivers/vertica/pom.xml
|
||||
category: driver-vertica
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: snyk/actions/setup@9adf32b1121593767fc3c057af55b55db032dc04 # v1.0.0
|
||||
- name: Download pom files
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: pom-files
|
||||
- name: Run snyk test
|
||||
env:
|
||||
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
|
||||
continue-on-error: true
|
||||
run: snyk test --org="metabase-AnD9iS4Wfu4eBYMsG2DZ5N" --remote-repo-url="https://github.com/metabase/metabase" --file=${{ matrix.project.file }} --package-manager=maven --sarif-file-output=snyk-${{ matrix.project.category }}.sarif
|
||||
|
||||
- name: Upload results
|
||||
uses: github/codeql-action/upload-sarif@v4
|
||||
with:
|
||||
sarif_file: snyk-${{ matrix.project.category }}.sarif
|
||||
category: ${{ matrix.project.category }}
|
||||
|
||||
notify:
|
||||
needs: [codeql, trivy]
|
||||
needs: [codeql, trivy, snyk-frontend-projects, snyk-maven-projects]
|
||||
if: ${{ failure() && github.repository == 'metabase/metabase' }}
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 5
|
||||
@@ -94,6 +244,8 @@ jobs:
|
||||
const scans = [
|
||||
['CodeQL', '${{ needs.codeql.result }}'],
|
||||
['Trivy', '${{ needs.trivy.result }}'],
|
||||
['Snyk (frontend)', '${{ needs.snyk-frontend-projects.result }}'],
|
||||
['Snyk (maven)', '${{ needs.snyk-maven-projects.result }}'],
|
||||
];
|
||||
const failed = scans.filter(([, result]) => result === 'failure').map(([name]) => name);
|
||||
|
||||
|
||||
19
.github/workflows/lint.yml
vendored
@@ -18,7 +18,6 @@ jobs:
|
||||
outputs:
|
||||
codeql: ${{ steps.changes.outputs.codeql }}
|
||||
i18n: ${{ steps.changes.outputs.i18n }}
|
||||
openapi: ${{ steps.changes.outputs.openapi }}
|
||||
snowplow: ${{ steps.changes.outputs.snowplow }}
|
||||
workflows: ${{ steps.changes.outputs.workflows }}
|
||||
workflows_files: ${{ steps.changes.outputs.workflows_files }}
|
||||
@@ -42,12 +41,10 @@ jobs:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup frontend env
|
||||
if: |
|
||||
needs.files-changed.outputs.yaml == 'true' ||
|
||||
needs.files-changed.outputs.openapi == 'true'
|
||||
if: needs.files-changed.outputs.yaml == 'true'
|
||||
uses: ./.github/actions/prepare-frontend
|
||||
|
||||
# Always required by whitespace linter, also used by OpenAPI spec validation
|
||||
# Always required by whitespace linter
|
||||
- name: Setup backend env
|
||||
uses: ./.github/actions/prepare-backend
|
||||
|
||||
@@ -88,18 +85,6 @@ jobs:
|
||||
if: needs.files-changed.outputs.workflows == 'true'
|
||||
run: .github/scripts/check-job-timeouts.sh ${{ needs.files-changed.outputs.workflows_files }}
|
||||
|
||||
# Generate and Lint OpenAPI spec
|
||||
- name: Generate OpenAPI spec
|
||||
if: needs.files-changed.outputs.openapi == 'true'
|
||||
run: bun run generate-openapi
|
||||
|
||||
# Parallel steps run asynchronously, and explicitly waiting for a parallel step means "if" statements are ignored.
|
||||
# Move OpenAPI validation outside the parallel block so that spec generation happens asynchronously without requiring
|
||||
# an explicit "wait".
|
||||
- name: Validate OpenAPI spec
|
||||
if: needs.files-changed.outputs.openapi == 'true'
|
||||
run: bun run lint-openapi
|
||||
|
||||
i18n:
|
||||
needs: files-changed
|
||||
if: needs.files-changed.outputs.i18n == 'true'
|
||||
|
||||
42
.github/workflows/openapi-check.yml
vendored
@@ -1,42 +0,0 @@
|
||||
name: OpenAPI Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, labeled, unlabeled]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check-openapi-specs:
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 5
|
||||
if: contains(github.event.pull_request.labels.*.name, 'openapi-self-healing')
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
# we're intentionally using METABASE_AUTOMATION_USER_TOKEN to trigger the whole CI pipeline again
|
||||
# after the self-healing commit is created
|
||||
# if we used GITHUB_TOKEN, the self-healing commit would not trigger the CI pipeline again
|
||||
token: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
|
||||
# fetch full git history to allow fetching the latest base branch commit and comparing changes
|
||||
fetch-depth: 0
|
||||
- name: Prepare Node + Bun environment
|
||||
uses: ./.github/actions/prepare-node-bun
|
||||
- name: Prepare back-end environment
|
||||
uses: ./.github/actions/prepare-backend
|
||||
- name: Generate version.properties
|
||||
run: ./bin/build.sh :steps [:version]
|
||||
- name: Get latest base branch commit
|
||||
run: |
|
||||
git fetch origin ${{ github.event.pull_request.base.ref }}
|
||||
echo "LATEST_BASE_SHA=$(git rev-parse origin/${{ github.event.pull_request.base.ref }})" >> "$GITHUB_ENV"
|
||||
- name: Check OpenAPI specs are up to date
|
||||
env:
|
||||
AUTO_COMMIT: "true"
|
||||
PR_BRANCH: ${{ github.event.pull_request.head.ref }}
|
||||
BASE_SHA: ${{ env.LATEST_BASE_SHA }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
run: ./bin/openapi-check.sh
|
||||
38
.github/workflows/pr-env-close-unlabel.yml
vendored
@@ -1,38 +0,0 @@
|
||||
name: PR Closed or Unlabeled
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [ closed, unlabeled ]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
pr-env:
|
||||
if: ${{ (github.event.action == 'closed' && contains(github.event.pull_request.labels.*.name, 'PR-Env') && !contains(github.event.pull_request.labels.*.name, 'PR-Env-self-hosting') && !contains(github.event.pull_request.labels.*.name, 'PR-Env-analytics-dev') && !contains(github.event.pull_request.labels.*.name, 'PR-Env-Stats-Dump')) || github.event.label.name == 'PR-Env' }}
|
||||
uses: ./.github/workflows/pr-env-destroy.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
self_hosting: false
|
||||
|
||||
pr-env-self-hosting:
|
||||
if: ${{ (github.event.action == 'closed' && contains(github.event.pull_request.labels.*.name, 'PR-Env-self-hosting')) || github.event.label.name == 'PR-Env-self-hosting' }}
|
||||
uses: ./.github/workflows/pr-env-destroy.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
self_hosting: true
|
||||
|
||||
pr-env-analytics-dev:
|
||||
if: ${{ (github.event.action == 'closed' && contains(github.event.pull_request.labels.*.name, 'PR-Env-analytics-dev')) || github.event.label.name == 'PR-Env-analytics-dev' }}
|
||||
uses: ./.github/workflows/pr-env-destroy.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
self_hosting: false
|
||||
|
||||
pr-env-stats-dump:
|
||||
if: ${{ (github.event.action == 'closed' && contains(github.event.pull_request.labels.*.name, 'PR-Env-Stats-Dump')) || github.event.label.name == 'PR-Env-Stats-Dump' }}
|
||||
uses: ./.github/workflows/pr-env-destroy.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
self_hosting: false
|
||||
33
.github/workflows/pr-env-destroy-manual.yml
vendored
@@ -1,33 +0,0 @@
|
||||
name: PR Env Destroy (Manual)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: 'PR number to destroy environment for'
|
||||
required: true
|
||||
type: string
|
||||
self_hosting:
|
||||
description: 'Self-hosting stack'
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
jobs:
|
||||
approve:
|
||||
runs-on: ${{ vars.SLIM_RUNNER_KEY }}
|
||||
timeout-minutes: 5
|
||||
environment: pr-env-admin
|
||||
steps:
|
||||
- name: Approved
|
||||
env:
|
||||
PR_NUMBER: ${{ inputs.pr_number }}
|
||||
run: |
|
||||
echo "Destroying PR environment for PR #${PR_NUMBER}"
|
||||
|
||||
destroy:
|
||||
needs: approve
|
||||
uses: ./.github/workflows/pr-env-destroy.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
self_hosting: ${{ inputs.self_hosting }}
|
||||
pr_number: ${{ inputs.pr_number }}
|
||||
52
.github/workflows/pr.checks.yml
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
# Helpful (and quick) checks that run on PRs.
|
||||
|
||||
name: PR Checks
|
||||
|
||||
on: pull_request
|
||||
|
||||
jobs:
|
||||
checks:
|
||||
runs-on: ${{ vars.SLIM_RUNNER_KEY }}
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
pull-requests: write
|
||||
issues: read
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
sparse-checkout: |
|
||||
/.github/scripts/update-pr-with-issues.js
|
||||
/backport.sh
|
||||
sparse-checkout-cone-mode: false
|
||||
# download repo structure without file contents, fetched on demand since we only need a few files
|
||||
# NOTE: this gets slow if we start accessing more files, remove this filter in that case.
|
||||
filter: blob:none
|
||||
|
||||
- parallel:
|
||||
- name: Block master branch PRs (from forks)
|
||||
if: github.head_ref == 'master'
|
||||
run: |
|
||||
echo "PRs from \`master\` branches are not allowed, please use a new branch name for your PR."
|
||||
exit 1
|
||||
|
||||
- name: Link PR and issues
|
||||
uses: actions/github-script@v9
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
REPO_OWNER: ${{ github.repository_owner }}
|
||||
REPO_NAME: ${{ github.event.repository.name }}
|
||||
with:
|
||||
script: |
|
||||
const { link_issues } = require('${{ github.workspace }}/.github/scripts/update-pr-with-issues.js');
|
||||
await link_issues(github)
|
||||
|
||||
- name: Prevent backport.sh from being merged
|
||||
run: |
|
||||
if [ -f "./backport.sh" ]; then
|
||||
echo "backport.sh file exists. Please remove it before merging."
|
||||
exit 1
|
||||
else
|
||||
echo "All good."
|
||||
fi
|
||||
45
.github/workflows/pull-request-reminders.yml
vendored
@@ -1,45 +0,0 @@
|
||||
name: Pull Request Reminders
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened]
|
||||
branches:
|
||||
- "master"
|
||||
|
||||
jobs:
|
||||
external-reminder:
|
||||
name: External PR Helper
|
||||
if: ${{ github.event.pull_request.head.repo.full_name != github.repository }}
|
||||
runs-on: ${{ vars.SLIM_RUNNER_KEY }}
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
pull-requests: write
|
||||
issues: read
|
||||
steps:
|
||||
- uses: actions/github-script@v9
|
||||
with:
|
||||
script: | # js
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
|
||||
const pullNumber = '${{ github.event.pull_request.number }}';
|
||||
const author = '${{ github.event.pull_request.user.login }}';
|
||||
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pullNumber,
|
||||
labels: ["External"],
|
||||
});
|
||||
|
||||
const guideLink = "https://www.notion.so/metabase/Third-Party-PRs-External-contributions-5954061854424c1ea6391d98746646e0";
|
||||
const message = `@${author} Thanks for your contribution! ❤️ We'll try to review it as soon as we can.\n\n
|
||||
Reviewers, make sure to check out our [External PR Review Guide](${guideLink}) when reviewing.
|
||||
`;
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pullNumber,
|
||||
body: message,
|
||||
});
|
||||
3
.github/workflows/release-embedding-sdk.yml
vendored
@@ -382,6 +382,9 @@ jobs:
|
||||
- name: Generate SDK package.json in the build directory
|
||||
run: bun run embedding-sdk:generate-package
|
||||
|
||||
- name: Smoke test the packed SDK CLI
|
||||
run: node bin/embedding-sdk/test-sdk-package.mjs
|
||||
|
||||
- name: Upload built SDK package as artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
|
||||
128
.github/workflows/report.update-e2e-timings.yml
vendored
Normal file
@@ -0,0 +1,128 @@
|
||||
name: Update E2E Timings
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
days_back:
|
||||
description: "Number of days to look back for timing artifacts"
|
||||
type: number
|
||||
default: 7
|
||||
required: false
|
||||
schedule:
|
||||
# Run weekly on Sundays at 6 AM UTC
|
||||
- cron: "0 6 * * 0"
|
||||
|
||||
jobs:
|
||||
collect-and-average-timings:
|
||||
if: ${{ github.event_name != 'schedule' || github.repository == 'metabase/metabase' }}
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
DAYS_BACK: ${{ inputs.days_back || 7 }}
|
||||
steps:
|
||||
- name: Generate GitHub App Token
|
||||
uses: actions/create-github-app-token@v1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.METABASE_BOT_APP_ID }}
|
||||
private-key: ${{ secrets.METABASE_BOT_APP_PRIVATE_KEY }}
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Collect and average timing data
|
||||
id: collect-timings
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const { collectAndAverageTimings } = require('${{ github.workspace }}/.github/scripts/average-e2e-timings.js');
|
||||
const result = await collectAndAverageTimings({
|
||||
github,
|
||||
context,
|
||||
token: '${{ steps.app-token.outputs.token }}',
|
||||
daysBack: ${{ env.DAYS_BACK }},
|
||||
existingTimingsPath: 'e2e/support/timings.json'
|
||||
});
|
||||
|
||||
core.setOutput('artifact-count', result.artifactCount);
|
||||
|
||||
- name: Configure git and commit changes
|
||||
id: commit
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
git add e2e/support/timings.json
|
||||
# Check if anything changed before commit/push
|
||||
if git diff --cached --quiet; then
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
echo "Timing data is unchanged; skipping the pull request"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
DATE=$(date +%Y%m%d-%H%M%S)
|
||||
BRANCH_NAME="update-e2e-timings-$DATE"
|
||||
echo "BRANCH_NAME=$BRANCH_NAME" >> "$GITHUB_ENV"
|
||||
echo "Branch name will be: $BRANCH_NAME"
|
||||
|
||||
git checkout -b "$BRANCH_NAME"
|
||||
git commit -m "update e2e timings $DATE" --no-verify
|
||||
git push -u origin "$BRANCH_NAME"
|
||||
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create Pull Request
|
||||
id: create_pr
|
||||
if: steps.commit.outputs.changed == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
|
||||
run: |
|
||||
BODY=$(cat <<'EOF'
|
||||
## Summary
|
||||
This PR updates the E2E test timing data with averaged values from recent test runs.
|
||||
|
||||
## Details
|
||||
- Collected data from the last **${{ env.DAYS_BACK }} days** of test runs
|
||||
- Averaged timing data from **${{ steps.collect-timings.outputs.artifact-count }}** test runs
|
||||
|
||||
## Generated automatically
|
||||
🤖 This PR was created automatically by the `update-e2e-timings` workflow.
|
||||
The timing data helps cypress-split optimize test distribution across parallel jobs.
|
||||
|
||||
Co-Authored-By: GitHub Actions <actions@github.com>
|
||||
EOF
|
||||
)
|
||||
|
||||
URL=$(gh pr create \
|
||||
--base master \
|
||||
--head "$BRANCH_NAME" \
|
||||
--title "[chore] Update E2E test timings with recent data" \
|
||||
--body "$BODY" \
|
||||
--label ".CI & Tests" \
|
||||
--label "no-backport" \
|
||||
--reviewer metabase/devexp)
|
||||
|
||||
PR_NUMBER=${URL##*/}
|
||||
|
||||
echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Approve and Merge the PR
|
||||
if: steps.commit.outputs.changed == 'true'
|
||||
env:
|
||||
# uses a different token than the token that created the PR so we can approve and merge it
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ steps.create_pr.outputs.pr_number }}
|
||||
shell: bash
|
||||
run: |
|
||||
gh pr review --approve "$PR_NUMBER"
|
||||
gh pr merge --squash --auto "$PR_NUMBER"
|
||||
2
.github/workflows/schedule-patch.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
// Nightly patches cover the current supported major and the one
|
||||
// before it. release-auto.yml releases one major per dispatch.
|
||||
const currentRelease = Number('${{ vars.CURRENT_VERSION }}');
|
||||
const majors = [currentRelease - 1, currentRelease];
|
||||
const majors = [currentRelease];
|
||||
|
||||
await Promise.all(majors.map(version =>
|
||||
github.rest.actions.createWorkflowDispatch({
|
||||
|
||||
5
.github/workflows/semantic-search.yml
vendored
@@ -96,7 +96,6 @@ jobs:
|
||||
test-args: >-
|
||||
${{ matrix.job.test-args }}
|
||||
:exclude-tags '[${{ matrix.job.exclude-tag }}]'
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
|
||||
semantic-search-tests-mariadb:
|
||||
if: ${{ !inputs.skip && (inputs.suite == '' || inputs.suite == 'mariadb') }}
|
||||
@@ -162,7 +161,6 @@ jobs:
|
||||
test-args: >-
|
||||
${{ matrix.job.test-args }}
|
||||
:exclude-tags '[${{ matrix.job.exclude-tag }}]'
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
|
||||
semantic-search-tests-mysql:
|
||||
if: ${{ !inputs.skip && (inputs.suite == '' || inputs.suite == 'mysql') }}
|
||||
@@ -235,7 +233,6 @@ jobs:
|
||||
test-args: >-
|
||||
${{ matrix.job.test-args }}
|
||||
:exclude-tags '[${{ matrix.job.exclude-tag }}]'
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
|
||||
semantic-search-tests-postgres:
|
||||
if: ${{ !inputs.skip && (inputs.suite == '' || inputs.suite == 'postgres') }}
|
||||
@@ -300,7 +297,6 @@ jobs:
|
||||
test-args: >-
|
||||
${{ matrix.job.test-args }}
|
||||
:exclude-tags '[${{ matrix.job.exclude-tag }}]'
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
|
||||
# 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
|
||||
@@ -355,7 +351,6 @@ jobs:
|
||||
test-args: >-
|
||||
${{ matrix.job.test-args }}
|
||||
:exclude-tags '[${{ matrix.job.exclude-tag }}]'
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
|
||||
semantic-search-tests-result:
|
||||
needs:
|
||||
|
||||
165
.github/workflows/snyk.yml
vendored
@@ -1,165 +0,0 @@
|
||||
name: "Snyk"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "master"
|
||||
- "release-**"
|
||||
paths:
|
||||
- "**/deps.edn"
|
||||
- "**/package.json"
|
||||
- ".github/workflows/snyk.yml"
|
||||
- ".github/scripts/write-poms.sh"
|
||||
- ".github/scripts/fix-yarn-lock-aliases.ts"
|
||||
schedule:
|
||||
- cron: "0 5 * * *"
|
||||
|
||||
jobs:
|
||||
# Generate pom.xml files for all Maven projects
|
||||
generate-poms:
|
||||
if: ${{ github.event_name != 'schedule' || github.repository == 'metabase/metabase' }}
|
||||
name: Generate pom.xml files
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Prepare back-end environment
|
||||
uses: ./.github/actions/prepare-backend
|
||||
- name: Generate all pom.xml
|
||||
run: .github/scripts/write-poms.sh
|
||||
- name: Upload pom files
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: pom-files
|
||||
path: |
|
||||
pom.xml
|
||||
modules/drivers/*/pom.xml
|
||||
|
||||
# Yarn/NPM projects that don't need pom files
|
||||
frontend-projects:
|
||||
if: ${{ github.event_name != 'schedule' || github.repository == 'metabase/metabase' }}
|
||||
name: ${{ matrix.project.name }}
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
strategy:
|
||||
matrix:
|
||||
project:
|
||||
- name: Main Frontend (bun w/ yarn.lock)
|
||||
file: yarn.lock
|
||||
package-manager: yarn
|
||||
category: main-frontend-bun
|
||||
- name: Release Helper
|
||||
file: release/yarn.lock
|
||||
package-manager: yarn
|
||||
category: release-helper
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
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
|
||||
# but not in dependency specs, causing Snyk to fail matching dependencies.
|
||||
# The sed commands fix this inconsistency. Bun also names aliased entries
|
||||
# after the aliased package instead of the alias, which the script fixes.
|
||||
# Both continue on error to let Snyk report parsing issues directly rather
|
||||
# than failing on the workaround.
|
||||
- name: Generate yarn.lock
|
||||
if: matrix.project.file == 'yarn.lock'
|
||||
run: |
|
||||
bun install --yarn
|
||||
sed -i -E 's/< ([0-9]+)\.0\.0"/< \1"/g; s/< ([0-9]+)\.0"/< \1"/g' yarn.lock || true
|
||||
bun ${{ github.workspace }}/.github/scripts/fix-yarn-lock-aliases.ts || true
|
||||
- name: Generate release/yarn.lock
|
||||
if: matrix.project.file == 'release/yarn.lock'
|
||||
run: |
|
||||
bun install --yarn
|
||||
sed -i -E 's/< ([0-9]+)\.0\.0"/< \1"/g; s/< ([0-9]+)\.0"/< \1"/g' yarn.lock || true
|
||||
bun ${{ github.workspace }}/.github/scripts/fix-yarn-lock-aliases.ts || true
|
||||
working-directory: release
|
||||
- uses: snyk/actions/setup@9adf32b1121593767fc3c057af55b55db032dc04 # v1.0.0
|
||||
- name: Run snyk test
|
||||
env:
|
||||
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
|
||||
continue-on-error: true
|
||||
run: snyk test --org="metabase-AnD9iS4Wfu4eBYMsG2DZ5N" --remote-repo-url="https://github.com/metabase/metabase" --file=${{ matrix.project.file }} --package-manager=${{ matrix.project.package-manager }} --sarif-file-output=snyk-${{ matrix.project.category }}.sarif
|
||||
- name: Upload results
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
with:
|
||||
sarif_file: snyk-${{ matrix.project.category }}.sarif
|
||||
category: ${{ matrix.project.category }}
|
||||
|
||||
# Maven projects that need pom files
|
||||
maven-projects:
|
||||
needs: generate-poms
|
||||
if: ${{ github.event_name != 'schedule' || github.repository == 'metabase/metabase' }}
|
||||
name: ${{ matrix.project.name }}
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
strategy:
|
||||
matrix:
|
||||
project:
|
||||
- name: Main Backend (maven)
|
||||
file: pom.xml
|
||||
category: main-backend-maven
|
||||
- name: Driver - Athena
|
||||
file: modules/drivers/athena/pom.xml
|
||||
category: driver-athena
|
||||
- name: Driver - BigQuery Cloud SDK
|
||||
file: modules/drivers/bigquery-cloud-sdk/pom.xml
|
||||
category: driver-bigquery-cloud-sdk
|
||||
- name: Driver - ClickHouse
|
||||
file: modules/drivers/clickhouse/pom.xml
|
||||
category: driver-clickhouse
|
||||
- name: Driver - Databricks
|
||||
file: modules/drivers/databricks/pom.xml
|
||||
category: driver-databricks
|
||||
- name: Driver - Druid JDBC
|
||||
file: modules/drivers/druid-jdbc/pom.xml
|
||||
category: driver-druid-jdbc
|
||||
- name: Driver - Hive-like
|
||||
file: modules/drivers/hive-like/pom.xml
|
||||
category: driver-hive-like
|
||||
- name: Driver - MongoDB
|
||||
file: modules/drivers/mongo/pom.xml
|
||||
category: driver-mongo
|
||||
- name: Driver - Oracle
|
||||
file: modules/drivers/oracle/pom.xml
|
||||
category: driver-oracle
|
||||
- name: Driver - Presto JDBC
|
||||
file: modules/drivers/presto-jdbc/pom.xml
|
||||
category: driver-presto-jdbc
|
||||
- name: Driver - Redshift
|
||||
file: modules/drivers/redshift/pom.xml
|
||||
category: driver-redshift
|
||||
- name: Driver - Snowflake
|
||||
file: modules/drivers/snowflake/pom.xml
|
||||
category: driver-snowflake
|
||||
- name: Driver - SparkSQL
|
||||
file: modules/drivers/sparksql/pom.xml
|
||||
category: driver-sparksql
|
||||
- name: Driver - SQL Server
|
||||
file: modules/drivers/sqlserver/pom.xml
|
||||
category: driver-sqlserver
|
||||
- name: Driver - Starburst
|
||||
file: modules/drivers/starburst/pom.xml
|
||||
category: driver-starburst
|
||||
- name: Driver - Vertica
|
||||
file: modules/drivers/vertica/pom.xml
|
||||
category: driver-vertica
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: snyk/actions/setup@9adf32b1121593767fc3c057af55b55db032dc04 # v1.0.0
|
||||
- name: Download pom files
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: pom-files
|
||||
- name: Run snyk test
|
||||
env:
|
||||
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
|
||||
continue-on-error: true
|
||||
run: snyk test --org="metabase-AnD9iS4Wfu4eBYMsG2DZ5N" --remote-repo-url="https://github.com/metabase/metabase" --file=${{ matrix.project.file }} --package-manager=maven --sarif-file-output=snyk-${{ matrix.project.category }}.sarif
|
||||
|
||||
- name: Upload results
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
with:
|
||||
sarif_file: snyk-${{ matrix.project.category }}.sarif
|
||||
category: ${{ matrix.project.category }}
|
||||
125
.github/workflows/test.bundle-load-stats.yml
vendored
Normal file
@@ -0,0 +1,125 @@
|
||||
name: Bundle Load Stats
|
||||
|
||||
# Reuses the uberjar built on every master merge: boots it, signs in, loads the
|
||||
# app in a throttled headless Chrome across four network and CPU conditions, and
|
||||
# appends the times to eng-stats-importer.
|
||||
#
|
||||
# The instance is blank on purpose. `index.html` is built from settings and the
|
||||
# signed-in user alone, so seeding content would not change a single byte of the
|
||||
# document this measures.
|
||||
|
||||
# Triggered by "Run tests", which is the workflow that builds the uberjar on a
|
||||
# master merge.
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Run tests"]
|
||||
types: [completed]
|
||||
# Master only: a release forms a non-linear line whose times cannot be
|
||||
# compared against the commit before it.
|
||||
branches: [master]
|
||||
workflow_call:
|
||||
inputs:
|
||||
commit:
|
||||
description: "Commit SHA to measure."
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
concurrency:
|
||||
group: bundle-load-stats-${{ inputs.commit || github.event.workflow_run.head_sha }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# "Run tests" concludes `failure` on master more often than `success`, from
|
||||
# jobs that have nothing to do with the jar, so its conclusion is not a usable
|
||||
# gate. The artifact is one: the uberjar job publishes it, and a docs-only
|
||||
# merge that skipped that job publishes none.
|
||||
uberjar-built:
|
||||
if: ${{ github.repository == 'metabase/metabase' }}
|
||||
runs-on: ${{ vars.SLIM_RUNNER_KEY }}
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
found: ${{ steps.probe.outputs.fetch-hit }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- id: probe
|
||||
uses: ./.github/actions/fetch-artifact
|
||||
with:
|
||||
commit: ${{ inputs.commit || github.event.workflow_run.head_sha }}
|
||||
edition: ee
|
||||
download: false
|
||||
|
||||
bundle-load-stats:
|
||||
needs: [uberjar-built]
|
||||
if: ${{ needs.uberjar-built.outputs.found == 'true' }}
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
HEAD_SHA: ${{ inputs.commit || github.event.workflow_run.head_sha }}
|
||||
MB_JETTY_PORT: "4000"
|
||||
SITE: http://localhost:4000
|
||||
# Loads per condition. The first is the cold reading and the rest give the
|
||||
# median, so this trades run time for a steadier number.
|
||||
RUNS: "8"
|
||||
steps:
|
||||
- name: Check out the code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ env.HEAD_SHA }}
|
||||
- uses: ./.github/actions/prepare-node-bun
|
||||
- name: Prepare JDK 25
|
||||
uses: actions/setup-java@v5.2.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
- name: Install Chrome v144
|
||||
uses: browser-actions/setup-chrome@2e1d749697dd1612b833dba4a722266286fbefcd # v2.1.2
|
||||
with:
|
||||
chrome-version: 144
|
||||
id: setup-chrome
|
||||
- name: Download EE uberjar
|
||||
uses: ./.github/actions/fetch-artifact
|
||||
with:
|
||||
commit: ${{ env.HEAD_SHA }}
|
||||
edition: ee
|
||||
extract-path: temp/uberjar
|
||||
- name: Start Metabase
|
||||
run: |
|
||||
mkdir -p artifacts
|
||||
# --add-opens matches how the jar is launched everywhere else
|
||||
# (uberjar.yml, pre-release.yml, bin/docker/run_metabase.sh), so the
|
||||
# benchmark runs the app under the JVM configuration we ship.
|
||||
MB_DB_FILE="${RUNNER_TEMP}/bench.db" \
|
||||
nohup java --add-opens java.base/java.nio=ALL-UNNAMED \
|
||||
-jar temp/uberjar/metabase.jar > artifacts/metabase.log 2>&1 &
|
||||
- name: Sign in
|
||||
run: |
|
||||
# Waits for the instance to migrate and come up, then creates the
|
||||
# first user. Masked because the token authenticates as that user.
|
||||
session=$(bun frontend/test/bench/sign-in.ts "$SITE")
|
||||
echo "::add-mask::$session"
|
||||
echo "SESSION_COOKIE=$session" >> "$GITHUB_ENV"
|
||||
- name: Warm the backend
|
||||
run: |
|
||||
# The first document a fresh JVM renders pays for its own JIT. Spend
|
||||
# that here rather than inside the first condition measured.
|
||||
for _ in 1 2 3 4 5; do curl -sS -o /dev/null "$SITE/"; done
|
||||
- name: Measure load times
|
||||
env:
|
||||
CHROME_PATH: ${{ steps.setup-chrome.outputs.chrome-path }}
|
||||
run: |
|
||||
bun frontend/test/bench/matrix.ts "$SITE/" "$RUNS" \
|
||||
| tee artifacts/load-times.json
|
||||
- name: Show the Metabase log if something went wrong
|
||||
if: failure()
|
||||
run: tail -100 artifacts/metabase.log || true
|
||||
- name: Import load times into Stats
|
||||
if: ${{ inputs.commit == '' }}
|
||||
env:
|
||||
ROWS: artifacts/load-times.json
|
||||
# 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 }}
|
||||
ENG_STATS_URL: ${{ secrets.ENG_STATS_URL }}
|
||||
API_KEY: ${{ secrets.ENG_STATS_API_KEY }}
|
||||
run: bun .github/scripts/upload-bundle-load-stats.ts
|
||||
17
.github/workflows/test.cleanup-dwh-data.yml
vendored
@@ -10,21 +10,15 @@ on:
|
||||
required: false
|
||||
default: 'snowflake,bigquery-cloud-sdk,redshift'
|
||||
type: string
|
||||
temp-data-hours:
|
||||
description: 'TTL in hours for per-run garbage (minimum 2)'
|
||||
hours:
|
||||
description: 'TTL in hours for per-run garbage (minimum 4)'
|
||||
required: false
|
||||
default: '2'
|
||||
type: string
|
||||
fixture-hours:
|
||||
description: 'TTL in hours for datasets runs share (minimum 24)'
|
||||
required: false
|
||||
default: '96'
|
||||
default: '12'
|
||||
type: string
|
||||
|
||||
# `inputs.*` is empty on the scheduled run, so each falls back to the nightly value.
|
||||
env:
|
||||
GC_TEMP_HOURS: ${{ inputs.temp-data-hours || 2 }} # for temp data
|
||||
GC_FIXTURE_HOURS: ${{ inputs.fixture-hours || 96 }} # for reusable data
|
||||
GC_HOURS: ${{ inputs.hours || 12 }} # for temp data
|
||||
GC_DRIVERS: ${{ inputs.drivers || 'snowflake,bigquery-cloud-sdk,redshift' }}
|
||||
|
||||
concurrency:
|
||||
@@ -66,8 +60,7 @@ jobs:
|
||||
run: |
|
||||
clojure -X:dev:drivers:drivers-dev:test metabase.test.data.gc/gc-orphans! \
|
||||
:drivers "\"${{ env.GC_DRIVERS }}\"" \
|
||||
:temp-data-hours "${{ env.GC_TEMP_HOURS }}" \
|
||||
:fixture-hours "${{ env.GC_FIXTURE_HOURS }}" \
|
||||
:hours "${{ env.GC_HOURS }}" \
|
||||
:report-dir '"target/gc-report"'
|
||||
|
||||
# The report is most wanted on the runs that fail, so upload it regardless of the sweep's outcome.
|
||||
|
||||
103
.github/workflows/update-e2e-timings.yml
vendored
@@ -1,103 +0,0 @@
|
||||
name: Update E2E Timings
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
days_back:
|
||||
description: "Number of days to look back for timing artifacts"
|
||||
type: number
|
||||
default: 7
|
||||
required: false
|
||||
schedule:
|
||||
# Run weekly on Sundays at 6 AM UTC
|
||||
- cron: "0 6 * * 0"
|
||||
|
||||
jobs:
|
||||
collect-and-average-timings:
|
||||
if: ${{ github.event_name != 'schedule' || github.repository == 'metabase/metabase' }}
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
DAYS_BACK: ${{ inputs.days_back || 7 }}
|
||||
steps:
|
||||
- name: Generate GitHub App Token
|
||||
uses: actions/create-github-app-token@v1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.METABASE_BOT_APP_ID }}
|
||||
private-key: ${{ secrets.METABASE_BOT_APP_PRIVATE_KEY }}
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Collect and average timing data
|
||||
id: collect-timings
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const { collectAndAverageTimings } = require('${{ github.workspace }}/.github/scripts/average-e2e-timings.js');
|
||||
const result = await collectAndAverageTimings({
|
||||
github,
|
||||
context,
|
||||
token: '${{ steps.app-token.outputs.token }}',
|
||||
daysBack: ${{ env.DAYS_BACK }},
|
||||
existingTimingsPath: 'e2e/support/timings.json'
|
||||
});
|
||||
|
||||
core.setOutput('artifact-count', result.artifactCount);
|
||||
|
||||
- name: Set up git configuration
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
BRANCH_NAME="update-e2e-timings-$(date +%Y%m%d-%H%M%S)"
|
||||
echo "BRANCH_NAME=$BRANCH_NAME" >> "$GITHUB_ENV"
|
||||
echo "Branch name will be: $BRANCH_NAME"
|
||||
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
|
||||
with:
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
branch: ${{ env.BRANCH_NAME }}
|
||||
base: master
|
||||
commit-message: |
|
||||
Update E2E test timings with averaged data
|
||||
|
||||
Collected timing data from ${{ env.DAYS_BACK }} days of test runs.
|
||||
All specs with timing data were included (simple averages).
|
||||
|
||||
🤖 Generated automatically by update-e2e-timings workflow
|
||||
|
||||
Co-Authored-By: GitHub Actions <actions@github.com>
|
||||
committer: "github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>"
|
||||
author: "github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>"
|
||||
title: "[chore] Update E2E test timings with recent data"
|
||||
body: |
|
||||
## Summary
|
||||
This PR updates the E2E test timing data with averaged values from recent test runs.
|
||||
|
||||
## Details
|
||||
- Collected data from the last **${{ env.DAYS_BACK }} days** of test runs
|
||||
- Averaged timing data from **${{ steps.collect-timings.outputs.artifact-count }}** test runs
|
||||
|
||||
## Generated automatically
|
||||
🤖 This PR was created automatically by the `update-e2e-timings` workflow.
|
||||
The timing data helps cypress-split optimize test distribution across parallel jobs.
|
||||
|
||||
Co-Authored-By: GitHub Actions <actions@github.com>
|
||||
labels: |
|
||||
.CI & Tests
|
||||
no-backport
|
||||
add-paths: e2e/support/timings.json
|
||||
team-reviewers: |
|
||||
devexp
|
||||
3
.gitignore
vendored
@@ -135,7 +135,6 @@ dev/serialization_deltas/
|
||||
*.tsbuildinfo
|
||||
/trace-output/
|
||||
/temp-types/
|
||||
/frontend/src/metabase-types/openapi
|
||||
|
||||
**/cypress_sample_database.json
|
||||
**/cypress_sample_instance_data.json
|
||||
@@ -210,3 +209,5 @@ __pycache__/
|
||||
# dependency-cruiser output
|
||||
dependency-graph.json
|
||||
.playwright-cli/
|
||||
|
||||
/.tmp/
|
||||
|
||||
|
Before Width: | Height: | Size: 8.0 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 75 KiB |