Compare commits
2 Commits
generate-c
...
nodejs
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ecffd0c9b | ||
|
|
0a96d3d9dd |
@@ -149,7 +149,8 @@ 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/OpaqueJSONObject ;; JSON object whose keys are not ours to declare; string-keyed
|
||||
ms/Map ;; Any map
|
||||
ms/JSONString ;; JSON-encoded string
|
||||
ms/PositiveNum ;; Positive number
|
||||
ms/IntGreaterThanOrEqualToZero ;; 0 or positive
|
||||
```
|
||||
|
||||
@@ -37,16 +37,14 @@ First check if snapshots exist:
|
||||
ls e2e/snapshots/default.sql 2>/dev/null && echo "snapshots exist" || echo "snapshots missing"
|
||||
```
|
||||
|
||||
Always pass the spec path via `--spec`. The runner feeds its arguments to Cypress's CLI parser (`cypress.cli.parseRunArguments`), which silently drops bare positional paths and then runs the entire suite. A leading `run` argument is harmless, but the path itself must follow `--spec`. Sanity-check that the first `Running:` line says `(1 of 1)`.
|
||||
|
||||
If snapshots exist, skip regeneration for speed:
|
||||
```bash
|
||||
MB_EDITION=oss CYPRESS_VIDEO=false CYPRESS_RETRIES=0 CYPRESS_GUI=false GENERATE_SNAPSHOTS=false bun test-cypress --spec path/to/spec.cy.spec.js
|
||||
MB_EDITION=oss CYPRESS_VIDEO=false CYPRESS_RETRIES=0 CYPRESS_GUI=false GENERATE_SNAPSHOTS=false bun test-cypress $ARGUMENTS
|
||||
```
|
||||
|
||||
If snapshots are missing (first run), let the runner generate them:
|
||||
```bash
|
||||
MB_EDITION=oss CYPRESS_VIDEO=false CYPRESS_RETRIES=0 CYPRESS_GUI=false bun test-cypress --spec path/to/spec.cy.spec.js
|
||||
MB_EDITION=oss CYPRESS_VIDEO=false CYPRESS_RETRIES=0 CYPRESS_GUI=false bun test-cypress $ARGUMENTS
|
||||
```
|
||||
|
||||
When running enterprise tests, replace `MB_EDITION=oss` with `MB_EDITION=ee` in the commands above.
|
||||
@@ -69,11 +67,9 @@ GREP="test name here" MB_EDITION=oss bun test-cypress --spec path/to/spec.js
|
||||
When the user asks to verify a test is not flaky:
|
||||
|
||||
```bash
|
||||
MB_EDITION=oss bin/e2e-stress-test --spec path/to/spec.cy.spec.js
|
||||
MB_EDITION=oss bin/e2e-stress-test $ARGUMENTS
|
||||
```
|
||||
|
||||
(The stress script forwards its arguments to `bun test-cypress`, so the same `--spec` rule applies.)
|
||||
|
||||
Set `E2E_STRESS_RUNS=N` for more iterations (default: 5). Stops on first failure and prints screenshot paths. Read those screenshots to analyze the failure.
|
||||
|
||||
## Analyzing failures
|
||||
|
||||
@@ -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, 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.
|
||||
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.
|
||||
2. Compliance with `frontend/CLAUDE.md`.
|
||||
3. Readability and maintainability.
|
||||
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.
|
||||
4. Appropriate test coverage.
|
||||
|
||||
## Blind spots — act as the missing reviewer
|
||||
|
||||
@@ -31,4 +31,3 @@ 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,27 +20,20 @@ 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`).
|
||||
- **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.
|
||||
- **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.
|
||||
- **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";
|
||||
@@ -52,30 +45,20 @@ 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, 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.
|
||||
- **`readonly` / immutability where mutation isn't intended** — component props, shared constants, exported config. Prefer `readonly T[]` / `ReadonlyArray<T>` for inputs you don't mutate.
|
||||
- **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.
|
||||
- **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.
|
||||
- **Lists should be filtered** before being used in a map or other iteration.
|
||||
- **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`).
|
||||
|
||||
@@ -72,12 +72,6 @@
|
||||
: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/table-or-field-query {:level :warning}
|
||||
;; App-DB query hygiene: a value reaching a value slot unmarked, and a query written as
|
||||
;; `:column value` pairs rather than a map. Off by default and enabled per namespace under
|
||||
;; `:config-in-ns`; flip it on globally once every `db.clj` has been converted.
|
||||
:metabase/unsafe-app-db-query {:level :off}
|
||||
:metabase/toucan-model-ns {:level :warning}
|
||||
:metabase/require-shape-checker {:level :warning}
|
||||
:metabase/tests-must-live-in-known-modules {:level :warning}
|
||||
@@ -186,19 +180,10 @@
|
||||
clojure.core.async/take!
|
||||
clojure.core.async/to-chan!
|
||||
clojure.core.async/to-chan!!
|
||||
;; writes the ratchet files from a :once fixture that finishes before the parallel tests
|
||||
;; only writes .clj-kondo/ratchets.edn, from a :once fixture that finishes before the parallel tests
|
||||
dev.kondo-ratchet/fix!
|
||||
;; builds Babashka's classpath cache from a :once fixture, before the parallel tests start, so that
|
||||
;; they don't each race to create it; the side effect is process-external and idempotent
|
||||
metabase.core.modules-consistency-test/warm-babashka-classpath!
|
||||
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!
|
||||
@@ -206,8 +191,6 @@
|
||||
metabase.channel.render.pdf.font/load-fonts!
|
||||
metabase.warehouses.models.database/assert-not-h2!
|
||||
metabase.driver.sql-jdbc.execute/execute-prepared-statement!
|
||||
;; only inspects a details map and throws; the `!` is for the throw, not for a mutation
|
||||
metabase.driver/validate-db-details!
|
||||
metabase.mcp.usage/record-mcp-session!
|
||||
metabase.mcp.usage/record-mcp-session-end!
|
||||
metabase.mcp.usage/record-mcp-tool-call!
|
||||
@@ -232,9 +215,7 @@
|
||||
;; Safe in tests, where we typically use a private, temporary index per thread.
|
||||
metabase.search.core/reindex!
|
||||
metabase.util/run-count!
|
||||
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!}}
|
||||
next.jdbc/execute!}}
|
||||
|
||||
:metabase/i-like-making-cams-eyes-bleed-with-horrifically-long-tests
|
||||
{:level :warning
|
||||
@@ -297,6 +278,10 @@
|
||||
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)
|
||||
@@ -482,7 +467,6 @@
|
||||
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
|
||||
@@ -507,13 +491,11 @@
|
||||
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
|
||||
metabase.notification.test-util/with-system-event-notification! clojure.core/let
|
||||
metabase.notification.test-util/with-temp-notification clojure.core/let
|
||||
metabase.oauth-server.test-util/with-oauth-client clojure.core/fn
|
||||
metabase.osi.ai-context.api-test/with-test-entry clojure.core/let
|
||||
metabase.premium-features.core/defenterprise-schema clj-kondo.lint-as/def-catch-all
|
||||
metabase.premium-features.defenterprise/defenterprise-schema clj-kondo.lint-as/def-catch-all
|
||||
@@ -595,7 +577,6 @@
|
||||
clojure.core/defprotocol hooks.clojure.core.def/lint-def
|
||||
clojure.core/defrecord hooks.clojure.core.def/lint-def
|
||||
clojure.core/deftype hooks.clojure.core.def/lint-def
|
||||
clojure.core/ex-info hooks.metabase.agent-message/lint-ex-info
|
||||
clojure.core/ns hooks.clojure.core.ns/lint-ns
|
||||
clojure.core/require hooks.clojure.core.require/lint-require
|
||||
clojure.core/requiring-resolve hooks.clojure.core.require/lint-requiring-resolve
|
||||
@@ -611,6 +592,7 @@
|
||||
metabase-enterprise.cache.config-test/with-temp-persist-models hooks.common/with-seven-bindings
|
||||
metabase-enterprise.dependencies.api-test/with-dependents-test! hooks.common/with-two-bindings
|
||||
metabase-enterprise.metabot-v3.tools.dependencies-test/with-dependent-transforms! hooks.common/with-two-bindings
|
||||
metabase-enterprise.metabot.tools.dependencies-test/with-dependent-transforms! hooks.common/with-two-bindings
|
||||
metabase-enterprise.semantic-search.db.connection/with-migrate-tx hooks.common/let-one-with-optional-value
|
||||
metabase-enterprise.serialization.api-test/with-serialization-test-data! hooks.common/with-three-bindings
|
||||
metabase-enterprise.serialization.test-util/with-temp-dpc hooks.toucan2.tools.with-temp/with-temp
|
||||
@@ -647,59 +629,10 @@
|
||||
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.transport/jsonrpc-error hooks.metabase.agent-message/lint-jsonrpc-error
|
||||
metabase.mcp.v2.common/ellipsize hooks.metabase.agent-message/lint-ellipsize
|
||||
metabase.mcp.v2.common/error-content hooks.metabase.agent-message/lint-teaching-exit
|
||||
metabase.mcp.v2.common/list-content hooks.metabase.agent-message/lint-list-content
|
||||
metabase.mcp.v2.common/success-content hooks.metabase.agent-message/lint-success-content
|
||||
metabase.mcp.v2.common/throw-teaching-error hooks.metabase.agent-message/lint-teaching-exit
|
||||
metabase.mcp.v2.dashboard-ops/op-error! hooks.metabase.agent-message/lint-op-error
|
||||
metabase.mcp.v2.message/msg hooks.metabase.agent-message/lint-msg
|
||||
metabase.mcp.v2.tools.browse/paged-list-content hooks.metabase.agent-message/lint-list-content
|
||||
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
|
||||
@@ -799,7 +732,6 @@
|
||||
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
|
||||
@@ -868,7 +800,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.*)|(?:^dev\\..*)"
|
||||
{:pattern "(?:(?:^metabase\\.cmd.*)|(?:^build.*)|(?:^metabuild-common.*)|(?:^release.*)|(?:^i18n.*)|(?:^lint-migrations-file$))|(?:^mage.*)|(?:^load-namespaces.*)"
|
||||
:name printable-namespaces}
|
||||
{:pattern "^metabase\\.(?:(?:lib)|(?:legacy-mbql))\\..*"
|
||||
:name metabase-lib}
|
||||
@@ -905,40 +837,16 @@
|
||||
{:pattern "^metabase(?:((?:-enterprise\\.sandbox)?\\.query-processor)|(?:\\.driver\\.sql(?!server)(?!ite))).*-test$"
|
||||
:name general-qp-and-driver-test-namespaces}
|
||||
{:pattern "(?:^mage.*)"
|
||||
: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}
|
||||
;;
|
||||
;; MCP source namespaces, OSS and enterprise, whose prose reaches an LLM agent, excluding test namespaces.
|
||||
;;
|
||||
{:pattern "^(?!.*-test(?:\\..*)?$)(?!.*test-util)metabase(?:-enterprise)?\\.mcp\\..*"
|
||||
:name agent-message-namespaces}
|
||||
;;
|
||||
;; MCP source namespaces whose caller-facing errors are MCP exits: `metabase.mcp.transport` and `metabase.mcp.v2.*`,
|
||||
;; and their enterprise counterparts. Other MCP namespaces' 4xx `ex-info`s are admin API and middleware errors, not
|
||||
;; MCP exits.
|
||||
;;
|
||||
{:pattern "^(?!.*-test(?:\\..*)?$)(?!.*test-util)metabase(?:-enterprise)?\\.mcp\\.(?:transport$|v2\\..*)"
|
||||
:name agent-exit-namespaces}]
|
||||
:name mage-namespaces}]
|
||||
|
||||
:config-in-ns
|
||||
{metabase.app-db.custom-migrations
|
||||
{:linters
|
||||
;; a migration reads the rows as they were when it ran; applying today's user settings would make it drift
|
||||
{:metabase/table-or-field-query {:level :off}}}
|
||||
|
||||
test-namespaces
|
||||
{test-namespaces
|
||||
{:linters
|
||||
{:inline-def {:level :off}
|
||||
:missing-docstring {:level :off}
|
||||
: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
|
||||
@@ -946,28 +854,12 @@
|
||||
metabase.lib.convert/->legacy-MBQL {:level :off}
|
||||
metabase.lib.core/->legacy-MBQL {:level :off}}}}
|
||||
|
||||
app-db-namespaces
|
||||
{:linters {:metabase/t2-query-namespace {:level :off}}}
|
||||
|
||||
agent-message-namespaces
|
||||
{:linters {:metabase/agent-message-lines {:level :warning}}}
|
||||
|
||||
agent-exit-namespaces
|
||||
{:linters {:metabase/agent-message-exit {:level :warning}}}
|
||||
|
||||
;; Namespaces whose values are marked. Adding one here is what keeps it that way; see
|
||||
;; `metabase.app-db.value-guard`.
|
||||
metabase.content-translation.db
|
||||
{:linters {:metabase/unsafe-app-db-query {:level :warning}}}
|
||||
|
||||
;; .cljc tests run in both Clojure and ClojureScript — `with-dynamic-fn-redefs` is JVM-only,
|
||||
;; so leave these alone.
|
||||
metabase.lib.aggregation-test
|
||||
{:linters {:metabase/prefer-with-dynamic-fn-redefs {:level :off}}}
|
||||
metabase.util.time-test
|
||||
{:linters {:metabase/prefer-with-dynamic-fn-redefs {:level :off}}}
|
||||
metabase.lib.metadata.calculation-test
|
||||
{:linters {:metabase/prefer-with-dynamic-fn-redefs {:level :off}}}
|
||||
|
||||
;; classloader sits below `util` in the module graph, so it can't use `metabase.util.log` without creating
|
||||
;; a cycle; it gets to use tools.logging directly.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +0,0 @@
|
||||
;; 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}
|
||||
@@ -58,7 +58,6 @@
|
||||
metabase.app-db.core/finish-db-setup!
|
||||
metabase.app-db.core/migrate!
|
||||
metabase.app-db.core/setup-db!
|
||||
metabase.app-db.encryption-test-util/do-with-encrypted-app-db!
|
||||
metabase.app-db.schema-migrations-test.impl/run-migrations-in-range!
|
||||
metabase.app-db.setup/migrate!
|
||||
metabase.app-db.setup/setup-db!
|
||||
|
||||
@@ -1,82 +1,48 @@
|
||||
;; Budgets for kondo suppressions: inline `:clj-kondo/ignore` forms per linter (:ignore-counts), and
|
||||
;; config-level waivers in .clj-kondo/config.edn (:config-counts -- :off switches and :exclude entries).
|
||||
;; Each :ignore-counts value is a non-negative integer budget, or :unlimited for no ceiling.
|
||||
;; Checks fail when a count exceeds its numeric budget; unused budget is allowed.
|
||||
;; 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 otherwise) or widening the
|
||||
;; exemptions must be explained in the PR.
|
||||
;; Per-linter budgets for inline `:clj-kondo/ignore` forms.
|
||||
;; metabase.core.kondo-ratchet-test fails when the budgets drift from the actual counts.
|
||||
;; `./bin/mage fix-kondo-ratchets` lowers budgets to match the tree; local test runs do it
|
||||
;; automatically. Raising a budget, or adding one for a new linter (`--seed`), is a hand edit
|
||||
;; to defend in your PR.
|
||||
;; :all is the vector-less ignore form, which suppresses every linter on the next form.
|
||||
{:ignore-counts {:aliased-namespace-symbol 3
|
||||
:case-symbol-test 1
|
||||
:clojure-lsp/unused-public-var 9
|
||||
:consistent-alias 2
|
||||
:def-fn 1
|
||||
:deprecated-namespace 95
|
||||
:deprecated-var 95
|
||||
:discouraged-java-method 4
|
||||
:discouraged-namespace 70
|
||||
:discouraged-var 130
|
||||
:duplicate-require 1
|
||||
:equals-true 1
|
||||
:main-without-gen-class 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 24
|
||||
:metabase/namespace-name 4
|
||||
:metabase/prefer-with-dynamic-fn-redefs :unlimited
|
||||
:metabase/test-helpers-use-non-thread-safe-functions 18
|
||||
: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 9
|
||||
:missing-docstring 12
|
||||
:missing-protocol-method 3
|
||||
:non-arg-vec-return-type-hint 1
|
||||
:reduce-without-init 3
|
||||
:redundant-do 1
|
||||
:syntax 2
|
||||
:type-mismatch 8
|
||||
:uninitialized-var 1
|
||||
:unquote-not-syntax-quoted 1
|
||||
:unresolved-namespace 3
|
||||
:unresolved-symbol 11
|
||||
:unsorted-required-namespaces :unlimited
|
||||
:unused-alias 2
|
||||
:unused-binding 9
|
||||
:unused-excluded-var 2
|
||||
:unused-namespace 2
|
||||
:unused-private-var 8
|
||||
:unused-value 1}
|
||||
:config-counts {:constant-condition 1
|
||||
:deprecated-namespace 7
|
||||
:deprecated-var 16
|
||||
:discouraged-namespace 14
|
||||
:discouraged-var 14
|
||||
:inline-def 1
|
||||
:main-without-gen-class 1
|
||||
:metabase/defsetting-must-specify-export 1
|
||||
:metabase/defsetting-namespace 1
|
||||
:metabase/prefer-with-dynamic-fn-redefs 4
|
||||
:metabase/t2-query-namespace 2
|
||||
:metabase/table-or-field-query 1
|
||||
:metabase/unsafe-app-db-query 1
|
||||
:missing-docstring 1
|
||||
:private-call 1
|
||||
:redundant-ignore 1
|
||||
:redundant-primitive-coercion 1
|
||||
:refer-all 1
|
||||
:type-mismatch 1
|
||||
:unexpected-recur 1
|
||||
:unresolved-symbol 15
|
||||
:unresolved-var 1
|
||||
:unused-referred-var 4}
|
||||
:comment-exempt #{:deprecated-namespace
|
||||
:discouraged-namespace
|
||||
:discouraged-var
|
||||
:metabase/modules
|
||||
:metabase/validate-defendpoint-has-response-schema
|
||||
:metabase/validate-defendpoint-query-params-use-kebab-case
|
||||
:metabase/validate-defendpoint-route-uses-kebab-case}}
|
||||
{:ignore-counts {:aliased-namespace-symbol 3
|
||||
:all 48
|
||||
:case-symbol-test 1
|
||||
:clojure-lsp/unused-public-var 9
|
||||
:def-fn 1
|
||||
:deprecated-namespace 95
|
||||
:deprecated-var 84
|
||||
:discouraged-java-method 3
|
||||
:discouraged-namespace 81
|
||||
:discouraged-var 111
|
||||
:equals-true 1
|
||||
:main-without-gen-class 1
|
||||
:metabase/defsetting-namespace 2
|
||||
: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 7
|
||||
:metabase/namespace-name 4
|
||||
:metabase/prefer-with-dynamic-fn-redefs 28
|
||||
:metabase/test-helpers-use-non-thread-safe-functions 17
|
||||
:metabase/validate-defendpoint-has-response-schema 440
|
||||
:metabase/validate-defendpoint-query-params-use-kebab-case 50
|
||||
:metabase/validate-defendpoint-route-uses-kebab-case 44
|
||||
:metabase/validate-deftest 9
|
||||
:missing-docstring 10
|
||||
:missing-protocol-method 3
|
||||
:non-arg-vec-return-type-hint 1
|
||||
:reduce-without-init 3
|
||||
:redundant-fn-wrapper 1
|
||||
:syntax 2
|
||||
:uninitialized-var 1
|
||||
:unquote-not-syntax-quoted 1
|
||||
:unresolved-namespace 3
|
||||
:unresolved-require 1
|
||||
:unresolved-symbol 4
|
||||
:unsorted-required-namespaces 1
|
||||
:unused-alias 1
|
||||
:unused-binding 9
|
||||
:unused-excluded-var 2
|
||||
:unused-namespace 1
|
||||
:unused-private-var 9
|
||||
:unused-value 1}}
|
||||
|
||||
@@ -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 config ns-symb)]
|
||||
(when-let [current-module (modules/module ns-symb)]
|
||||
(let [required-namespace-symb-nodes (-> ns-form-node
|
||||
ns-form-node->require-node
|
||||
require-node->namespace-symb-nodes)]
|
||||
|
||||
@@ -11,32 +11,15 @@
|
||||
(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 config current-ns)]
|
||||
(when-let [current-module (modules/module current-ns)]
|
||||
(doseq [required-namespace required-namespaces]
|
||||
;; ignore namespaces outside of the module system.
|
||||
(when (modules/module config required-namespace)
|
||||
(when (modules/module required-namespace)
|
||||
(when-let [error (modules/usage-error config current-module required-namespace)]
|
||||
(hooks/reg-finding! (assoc (meta node)
|
||||
:message error
|
||||
:type :metabase/modules))))))))
|
||||
|
||||
(defn- lint-dynamic-require* [node current-ns config]
|
||||
(when (and (not (modules/ignored-namespace? config 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))))
|
||||
|
||||
(defn- quoted-require? [node]
|
||||
(or (= (hooks/tag node) :quote)
|
||||
(and (= (hooks/tag node) :list)
|
||||
(let [[x] (:children node)]
|
||||
(and (hooks/token-node? x)
|
||||
(= (hooks/sexpr x) 'quote))))))
|
||||
|
||||
(defn- keyword-node? [node]
|
||||
(keyword? (hooks/sexpr node)))
|
||||
|
||||
(defn- unwrap-require [node]
|
||||
(case (hooks/tag node)
|
||||
;; some.namespace of some.namespace/some-var
|
||||
@@ -59,11 +42,7 @@
|
||||
|
||||
;; something like [some.namespace :as whatever]
|
||||
:vector
|
||||
(recur (first (:children node)))
|
||||
|
||||
;; anything else -- e.g. a runtime expression like `(requiring-resolve (:failing-test-var opts))` --
|
||||
;; has no statically-known namespace
|
||||
nil))
|
||||
(recur (first (:children node)))))
|
||||
|
||||
(comment
|
||||
(unwrap-require (-> "(requiring-resolve 'metabase.notification.payload.execute/execute-dashboard)"
|
||||
@@ -72,24 +51,20 @@
|
||||
second)))
|
||||
|
||||
(defn- lint-require* [node current-ns config]
|
||||
(let [[_require & args] (:children node)
|
||||
;; Keywords such as `:reload` and `:reload-all` are options, not namespace dependencies.
|
||||
namespace-nodes (remove keyword-node? args)
|
||||
static-nodes (filter quoted-require? namespace-nodes)
|
||||
required-namespaces (->> (keep unwrap-require static-nodes)
|
||||
(filter simple-symbol?))]
|
||||
(lint* node current-ns config required-namespaces)
|
||||
(when (or (not= (count static-nodes) (count namespace-nodes))
|
||||
(not= (count required-namespaces) (count static-nodes)))
|
||||
(lint-dynamic-require* node current-ns config))))
|
||||
(let [[_require & args] (:children node)
|
||||
required-namespaces (->> (keep unwrap-require args)
|
||||
;; `require` runs through `cc/load-libs` which allows
|
||||
;; #{:as :reload :reload-all :require :use :verbose :refer :as-alias}
|
||||
;; as args. If you use a REPL tool that applies clj-kondo linting (e.g., Clojure MCP)
|
||||
;; you may wish to `:reload` or `:reload-all` a namespace. So we shouldn't assume all
|
||||
;; args are keywords:
|
||||
(remove keyword?))]
|
||||
(lint* node current-ns config required-namespaces)))
|
||||
|
||||
(defn- lint-requiring-resolve* [node current-ns config]
|
||||
(let [[_requiring-resolve symb-node] (:children node)
|
||||
required-namespace (unwrap-require symb-node)]
|
||||
(if (and (quoted-require? symb-node)
|
||||
(simple-symbol? required-namespace))
|
||||
(lint* node current-ns config [required-namespace])
|
||||
(lint-dynamic-require* node current-ns config))))
|
||||
(lint* node current-ns config [required-namespace])))
|
||||
|
||||
(defn lint-require [x]
|
||||
(lint-require* (:node x) (:ns x) (modules/config x))
|
||||
|
||||
@@ -171,7 +171,6 @@
|
||||
(str/starts-with? (name ns-symb) prefix))
|
||||
["build-drivers."
|
||||
"build."
|
||||
"hooks." ; clj-kondo hook tests, outside the module system
|
||||
"i18n." ; bin/i18n
|
||||
"lint-migrations-file-test"
|
||||
"load-namespaces." ; bin/load-namespaces
|
||||
@@ -194,7 +193,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 (modules/config input) ns-symb)]
|
||||
current-module (modules/module ns-symb)]
|
||||
(when (or (not current-module)
|
||||
(not (contains? known-modules current-module)))
|
||||
(hooks/reg-finding! (assoc (meta node)
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
(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]))
|
||||
|
||||
@@ -16,146 +12,30 @@
|
||||
(merge (get-in config [:linters :metabase/modules])
|
||||
(select-keys config [:metabase/modules])))
|
||||
|
||||
;;;; 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
|
||||
(defn module
|
||||
"E.g.
|
||||
|
||||
(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]
|
||||
(module 'metabase.qp.middleware.wow) => 'qp
|
||||
(module 'metabase-enterprise.whatever.core) => enterprise/whatever"
|
||||
[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 (declared-module prefix->module ns-symb)
|
||||
(some->> (re-find #"^metabase-enterprise\.([^.]+)" (str ns-symb))
|
||||
(or (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 module-team
|
||||
"Team owning `module`: its own `:team`, else its nearest ancestor's."
|
||||
[modules module]
|
||||
(some #(get-in modules [% :team])
|
||||
(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
|
||||
(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 defaults are `<prefix>.api`, `<prefix>.core`, and `<prefix>.init`."
|
||||
[modules module]
|
||||
(let [module-config (get-in modules [module :api])]
|
||||
this module (yet). If unspecified, the default is just the `<module>.core` namespace."
|
||||
[config module]
|
||||
(let [module-config (get-in config [:metabase/modules module :api])]
|
||||
(cond
|
||||
(= module-config :any)
|
||||
nil
|
||||
@@ -164,7 +44,9 @@
|
||||
module-config
|
||||
|
||||
:else
|
||||
(let [ns-prefix (module-ns-prefix modules module)]
|
||||
(let [ns-prefix (if (= (namespace module) "enterprise")
|
||||
(str "metabase-enterprise." (name module))
|
||||
(name module))]
|
||||
#{(symbol (str ns-prefix ".api"))
|
||||
(symbol (str ns-prefix ".core"))
|
||||
(symbol (str ns-prefix ".init"))}))))
|
||||
@@ -186,14 +68,15 @@
|
||||
(contains? (set allowed-modules) required-module))))
|
||||
|
||||
(defn- allowed-module-namespace? [config current-module ns-symb]
|
||||
(let [module (module config ns-symb)
|
||||
module-api-namespaces (module-api-namespaces (:metabase/modules config) module)
|
||||
(let [module (module 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)
|
||||
;; a child may use its ancestors' internals; a parent still goes through its child's `:api`
|
||||
(descendant-of? (:metabase/modules config) current-module module))))
|
||||
(contains? module-friends current-module))))
|
||||
|
||||
(defn- rest-module? [module]
|
||||
(str/ends-with? module "-rest"))
|
||||
|
||||
(defn- routes-module? [module]
|
||||
(str/ends-with? module "-routes"))
|
||||
@@ -206,7 +89,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 config required-namespace)]
|
||||
(when-let [required-module (module required-namespace)]
|
||||
(when-not (= current-module required-module)
|
||||
(cond
|
||||
(not (allowed-module? config current-module required-module))
|
||||
@@ -230,8 +113,4 @@
|
||||
required-module
|
||||
current-module
|
||||
required-module
|
||||
(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)))))
|
||||
(symbol (str/replace required-module #"-rest$" "")))))))
|
||||
|
||||
@@ -1,375 +0,0 @@
|
||||
(ns hooks.metabase.agent-message
|
||||
"Hooks linting agent-facing prose built with `metabase.mcp.v2.message/msg` (`:metabase/agent-message-lines`) and the
|
||||
exits and helpers that carry it to the agent (`:metabase/agent-message-exit`). Each hook checks only when its
|
||||
linter's level is configured and not `:off`, and returns its input unchanged."
|
||||
(:require
|
||||
[clj-kondo.hooks-api :as hooks]
|
||||
[clojure.string :as str]))
|
||||
|
||||
(defn- level-on?
|
||||
[config linter-key]
|
||||
(not= :off (get-in config [:linters linter-key :level] :off)))
|
||||
|
||||
(defn- call-name
|
||||
"The symbol at the head of list `node`, or nil."
|
||||
[node]
|
||||
(when (hooks/list-node? node)
|
||||
(let [head (first (:children node))]
|
||||
(when (hooks/token-node? head)
|
||||
(let [v (hooks/sexpr head)]
|
||||
(when (symbol? v) v))))))
|
||||
|
||||
(defn- calls?
|
||||
"Whether `node` is a call to a var named `var-name` in one of `ns-names` (nil for an unqualified call)."
|
||||
[node ns-names var-name]
|
||||
(when-let [sym (call-name node)]
|
||||
(and (= var-name (name sym))
|
||||
(contains? ns-names (namespace sym)))))
|
||||
|
||||
(def ^:private core-ns #{nil "clojure.core"})
|
||||
(def ^:private string-ns #{"str" "string" "clojure.string"})
|
||||
|
||||
(defn- literal-string
|
||||
"The string value of `node` when it is a string literal or a `str` call over string literals, else nil."
|
||||
[node]
|
||||
(cond
|
||||
(hooks/string-node? node)
|
||||
(hooks/sexpr node)
|
||||
|
||||
(and (calls? node core-ns "str")
|
||||
(every? hooks/string-node? (rest (:children node))))
|
||||
(apply str (map hooks/sexpr (rest (:children node))))))
|
||||
|
||||
(defn- digits->long
|
||||
"The value of a string of decimal digits."
|
||||
[s]
|
||||
;; Kondo's hook interpreter has no `parse-long`.
|
||||
(reduce (fn [n c] (+ (* 10 n) (- (int c) (int \0)))) 0 s))
|
||||
|
||||
(def ^:private format-specifier
|
||||
"A `java.util.Formatter` specifier; groups are explicit index, flags, width, precision, and conversion. The index is
|
||||
capped at nine digits, so a longer one reads as malformed rather than a number too large to work with."
|
||||
#"%(?:(\d{1,9})\$)?([-#+ 0,(<]*)(\d+)?(?:\.(\d+))?([tT]?[a-zA-Z%])")
|
||||
|
||||
(defn- consumed-args
|
||||
"`[index conversion]` for each argument-consuming specifier in `fmt`, with zero-based argument indexes. An index of
|
||||
-1 marks a reference no argument can satisfy: an explicit `0$`, or a `<` with no specifier before it to repeat."
|
||||
[fmt]
|
||||
(loop [[[_ explicit flags _width _precision conversion] & more] (re-seq format-specifier fmt)
|
||||
next-index 0
|
||||
last-index nil
|
||||
acc []]
|
||||
(if-not conversion
|
||||
acc
|
||||
(let [consumes? (not (contains? #{"%" "n"} conversion))
|
||||
index (cond
|
||||
(not consumes?) nil
|
||||
explicit (dec (digits->long explicit))
|
||||
(str/includes? flags "<") (or last-index -1)
|
||||
:else next-index)
|
||||
implicit? (and consumes? (not explicit) (not (str/includes? flags "<")))]
|
||||
(recur more
|
||||
(cond-> next-index implicit? inc)
|
||||
(or index last-index)
|
||||
(cond-> acc
|
||||
index (conj [index conversion])))))))
|
||||
|
||||
(def ^:private msg-linter :metabase/agent-message-lines)
|
||||
|
||||
(defn- reg-msg-finding!
|
||||
[node message]
|
||||
(hooks/reg-finding! (assoc (meta node) :message message :type msg-linter)))
|
||||
|
||||
(defn- line-break?
|
||||
"Whether format string `line` contains a line-breaking, control, or private-use character, or a `%n` specifier."
|
||||
[line]
|
||||
;; Private-use characters delimit the argument markers `render` formats with, so a line holding one could fake one.
|
||||
(boolean (or (re-find #"[\p{Cc}\p{Cf}\p{Co}\p{Zl}\p{Zp}]" line)
|
||||
(some #(= "n" (nth % 5)) (re-seq format-specifier line)))))
|
||||
|
||||
(defn- allowed-specifier?
|
||||
"Whether specifier match `specifier` renders its value as `render` passes it: a bare `%%`, a `%s` with no flags but
|
||||
`<`, or a `%d` with any flags and width but no precision. An explicit argument index is allowed on each."
|
||||
[[_ explicit flags width precision conversion]]
|
||||
(case conversion
|
||||
"%" (not (or explicit (seq flags) width precision))
|
||||
"s" (not (or width precision (seq (str/replace flags "<" ""))))
|
||||
"d" (not precision)
|
||||
false))
|
||||
|
||||
(defn- disallowed-specifier
|
||||
"The first specifier in format string `line` that isn't [[allowed-specifier?]], as it is written, or nil."
|
||||
[line]
|
||||
(some #(when-not (allowed-specifier? %) (first %)) (re-seq format-specifier line)))
|
||||
|
||||
(def ^:private valid-conversion
|
||||
"The conversions `java.util.Formatter` accepts, with each date/time conversion's required suffix."
|
||||
#"[bBhHsScCdoxXeEfgGaA%n]|[tT][HIklMSLNpzZsQBbhAaCYyjmdeRTrDFc]")
|
||||
|
||||
(defn- malformed-specifier?
|
||||
"Whether format string `line` has a `%` that doesn't begin a valid `java.util.Formatter` specifier."
|
||||
[line]
|
||||
(let [valid-removed (str/replace line format-specifier
|
||||
(fn [[specifier & groups]]
|
||||
(if (re-matches valid-conversion (last groups)) "" specifier)))]
|
||||
(str/includes? valid-removed "%")))
|
||||
|
||||
(defn- line-problem
|
||||
"The finding message for the first problem with a `msg` line, given its literal string or nil; nil when it has none."
|
||||
[line]
|
||||
(cond
|
||||
(nil? line)
|
||||
(str "Each line of a `msg` must be a string literal or a `str` of string literals; "
|
||||
"pass values as arguments after the vector.")
|
||||
|
||||
(line-break? line)
|
||||
(str "A `msg` line can't contain a line break, `%n`, or a control or private-use character; "
|
||||
"put each line in its own string.")
|
||||
|
||||
(malformed-specifier? line)
|
||||
"A `%` in a `msg` line must begin a format specifier; write a literal percent sign as `%%`."
|
||||
|
||||
:else
|
||||
(when-let [specifier (disallowed-specifier line)]
|
||||
(str "A `msg` line may only use `%s`, `%d`, or `%%`; `" specifier "` would alter or hide the value."))))
|
||||
|
||||
(defn- lint-lines!
|
||||
"Flag each of `line-nodes` whose literal string, the matching element of `lines`, has a problem."
|
||||
[line-nodes lines]
|
||||
(doseq [[line-node line] (map vector line-nodes lines)]
|
||||
(when-let [problem (line-problem line)]
|
||||
(reg-msg-finding! line-node problem))))
|
||||
|
||||
(defn- plural
|
||||
[n word]
|
||||
(str n " " word (when-not (= 1 n) "s")))
|
||||
|
||||
(defn- lint-arg-count!
|
||||
"Flag `msg` call `node` when a literal line refers to an argument that can't exist, or when its `args` don't number
|
||||
the arguments its literal `lines` consume."
|
||||
[node lines args]
|
||||
(let [indexes (map first (consumed-args (str/join "\n" lines)))
|
||||
expected (if (seq indexes) (inc (apply max indexes)) 0)
|
||||
given (count args)]
|
||||
(cond
|
||||
;; `String/format` throws on these, so the message would render with every part quoted instead.
|
||||
(some neg? indexes)
|
||||
(reg-msg-finding! node (str "A `msg` line refers to an argument that can't exist; "
|
||||
"argument indexes start at `1$`, and `%<` repeats the specifier before it."))
|
||||
|
||||
(not= expected given)
|
||||
(reg-msg-finding! node (format "The `msg` lines take %s but %d %s given."
|
||||
(plural expected "argument") given (if (= 1 given) "is" "are"))))))
|
||||
|
||||
(defn lint-msg
|
||||
"Flag `msg` calls whose lines aren't a literal vector of single-line literal strings, that hold a malformed
|
||||
specifier or a conversion other than `%s`, `%d`, or `%%`, or whose arguments don't match the lines' specifiers."
|
||||
[{:keys [node config] :as input}]
|
||||
(when (level-on? config msg-linter)
|
||||
(let [[fn-node lines-node & args] (:children node)]
|
||||
(if-not (and lines-node (hooks/vector-node? lines-node))
|
||||
(reg-msg-finding! (or lines-node fn-node)
|
||||
"`msg` takes a literal vector of line strings, one string per line of the message.")
|
||||
(let [line-nodes (:children lines-node)
|
||||
lines (map literal-string line-nodes)]
|
||||
(lint-lines! line-nodes lines)
|
||||
;; A malformed specifier can't be counted, and its line already has a finding.
|
||||
(when (and (every? some? lines) (not-any? malformed-specifier? lines))
|
||||
(lint-arg-count! node lines args))))))
|
||||
input)
|
||||
|
||||
(def ^:private exit-linter :metabase/agent-message-exit)
|
||||
|
||||
(defn- exit-enabled?
|
||||
[{:keys [config]}]
|
||||
(level-on? config exit-linter))
|
||||
|
||||
(defn- reg-exit-finding!
|
||||
[node message]
|
||||
(hooks/reg-finding! (assoc (meta node) :message message :type exit-linter)))
|
||||
|
||||
(defn- calls-core?
|
||||
"Whether `node` is a call to a `clojure.core` var or special form named in `var-names`."
|
||||
[node var-names]
|
||||
(when-let [sym (call-name node)]
|
||||
(and (contains? var-names (name sym))
|
||||
(contains? core-ns (namespace sym)))))
|
||||
|
||||
(defn- calls-i18n?
|
||||
[node]
|
||||
(when-let [sym (call-name node)]
|
||||
(contains? #{"tru" "trs" "deferred-tru" "deferred-trs"} (name sym))))
|
||||
|
||||
(defn- calls-json-encode?
|
||||
"Whether `node` calls `encode` in a namespace or alias whose name ends in `json`."
|
||||
[node]
|
||||
(when-let [sym (call-name node)]
|
||||
(and (= "encode" (name sym))
|
||||
(boolean (some-> (namespace sym) (str/ends-with? "json"))))))
|
||||
|
||||
(defn- builds-string?
|
||||
"Whether `node` is a string literal or a call that builds a string."
|
||||
[node]
|
||||
(or (hooks/string-node? node)
|
||||
(calls-core? node #{"str" "format" "pr-str" "ex-message"})
|
||||
(calls-i18n? node)
|
||||
(calls? node string-ns "join")
|
||||
(calls-json-encode? node)))
|
||||
|
||||
(defn- clause-results
|
||||
"The result nodes of `clauses`, alternating test and result nodes, with a trailing default when their count is odd."
|
||||
[clauses]
|
||||
(concat (take-nth 2 (rest clauses))
|
||||
(when (odd? (count clauses)) [(last clauses)])))
|
||||
|
||||
(defn- result-fn-marker?
|
||||
[node]
|
||||
(and (hooks/keyword-node? node) (= :>> (hooks/sexpr node))))
|
||||
|
||||
(defn- condp-results
|
||||
"The result nodes of `condp` `clauses`: each test's result, skipping `:>>` result functions, and any default."
|
||||
[clauses]
|
||||
(loop [[_test result & more :as clauses] clauses
|
||||
acc []]
|
||||
(cond
|
||||
(empty? clauses) acc
|
||||
(nil? (next clauses)) (conj acc (first clauses))
|
||||
(result-fn-marker? result) (recur (next more) acc)
|
||||
:else (recur more (conj acc result)))))
|
||||
|
||||
(defn- branch-results
|
||||
"The nodes branching form `node` can evaluate to, or nil when it isn't one."
|
||||
[node]
|
||||
(let [args (rest (:children node))]
|
||||
(cond
|
||||
(calls-core? node #{"if" "if-not" "if-let"}) (rest args)
|
||||
(calls-core? node #{"or"}) args
|
||||
(calls-core? node #{"cond"}) (clause-results args)
|
||||
(calls-core? node #{"case"}) (clause-results (rest args))
|
||||
(calls-core? node #{"condp"}) (condp-results (drop 2 args)))))
|
||||
|
||||
(defn- step-call
|
||||
"Threading step `node` as the call it stands for: a list as is, anything else as a call with no written arguments."
|
||||
[node]
|
||||
(if (hooks/list-node? node) node (hooks/list-node [node])))
|
||||
|
||||
(defn- threading-results
|
||||
"The nodes whose text-ness decides what threading form `node` evaluates to, or nil when it isn't one."
|
||||
[node]
|
||||
(let [[init & steps] (rest (:children node))]
|
||||
(cond
|
||||
(calls-core? node #{"->" "->>" "some->" "some->>"}) [(if steps (step-call (last steps)) init)]
|
||||
(calls-core? node #{"cond->" "cond->>"}) (cons init (map step-call (take-nth 2 (rest steps))))
|
||||
(calls-core? node #{"as->"}) [(or (last (rest steps)) init)]
|
||||
(calls-core? node #{"doto"}) [init])))
|
||||
|
||||
(defn- tail-result
|
||||
"The node body form `node` evaluates to, or nil when it isn't one."
|
||||
[node]
|
||||
(let [args (rest (:children node))]
|
||||
(cond
|
||||
(calls-core? node #{"when" "when-not" "when-let" "let"}) (when (next args) (last args))
|
||||
(calls-core? node #{"do"}) (last args))))
|
||||
|
||||
(defn- stringy?
|
||||
"Whether `node` syntactically evaluates to text: a string literal, a call that builds a string, or a threading,
|
||||
branching, or body form whose result is one."
|
||||
[node]
|
||||
(boolean
|
||||
(or (builds-string? node)
|
||||
(some stringy? (branch-results node))
|
||||
(some stringy? (threading-results node))
|
||||
(some-> (tail-result node) stringy?))))
|
||||
|
||||
(defn- lint-exit-text!
|
||||
"Flag `arg`, the message passed to `target` (a description of where it goes), when it is text rather than a `msg`."
|
||||
[arg target]
|
||||
(when (and arg (stringy? arg))
|
||||
(reg-exit-finding! arg (format (str "`%s` passes text to %s; "
|
||||
"build agent-facing text with `msg` so interpolated values are cleaned.")
|
||||
(pr-str (hooks/sexpr arg))
|
||||
target))))
|
||||
|
||||
(defn- lint-exit-arg!
|
||||
"Flag the argument at child `index` of call `node` when it is text rather than a `msg`."
|
||||
[node index]
|
||||
(lint-exit-text! (nth (:children node) index nil) (str "`" (name (call-name node)) "`")))
|
||||
|
||||
(defn lint-teaching-exit
|
||||
"Flag a call throwing or returning a teaching error whose message, the first argument, is text rather than a `msg`."
|
||||
[{:keys [node] :as input}]
|
||||
(when (exit-enabled? input)
|
||||
(lint-exit-arg! node 1))
|
||||
input)
|
||||
|
||||
(defn lint-jsonrpc-error
|
||||
"Flag a `jsonrpc-error` whose message, the third argument, is text rather than a `msg`."
|
||||
[{:keys [node] :as input}]
|
||||
(when (exit-enabled? input)
|
||||
(lint-exit-arg! node 3))
|
||||
input)
|
||||
|
||||
(defn lint-op-error
|
||||
"Flag an `op-error!` whose detail message, the third argument, is text rather than a `msg`."
|
||||
[{:keys [node] :as input}]
|
||||
(when (exit-enabled? input)
|
||||
(lint-exit-arg! node 3))
|
||||
input)
|
||||
|
||||
(defn lint-ellipsize
|
||||
"Flag an `ellipsize` whose message, the first argument, is text rather than a `msg`."
|
||||
[{:keys [node] :as input}]
|
||||
(when (exit-enabled? input)
|
||||
(lint-exit-arg! node 1))
|
||||
input)
|
||||
|
||||
(defn- map-value
|
||||
"The value node of keyword `k` in map literal `node`, or nil."
|
||||
[node k]
|
||||
(when (hooks/map-node? node)
|
||||
(some (fn [[key-node value-node]]
|
||||
(when (and (hooks/keyword-node? key-node) (= k (hooks/sexpr key-node)))
|
||||
value-node))
|
||||
(partition 2 (:children node)))))
|
||||
|
||||
(defn lint-list-content
|
||||
"Flag a `list-content`, or a wrapper taking its options map as the third argument too, whose literal options map
|
||||
has an `:empty-hint` that is text rather than a `msg`."
|
||||
[{:keys [node] :as input}]
|
||||
(when (exit-enabled? input)
|
||||
(lint-exit-text! (map-value (nth (:children node) 3 nil) :empty-hint)
|
||||
(str "`" (name (call-name node)) "`'s `:empty-hint`")))
|
||||
input)
|
||||
|
||||
(defn lint-success-content
|
||||
"Flag a `success-content` whose first argument is text rather than a payload or a `msg`."
|
||||
[{:keys [node] :as input}]
|
||||
(when (exit-enabled? input)
|
||||
(let [text (second (:children node))]
|
||||
(when (and text (stringy? text))
|
||||
(reg-exit-finding! text (str "`success-content` takes data to JSON-encode or a `msg`; "
|
||||
"pass the payload itself, or build the text with `msg`.")))))
|
||||
input)
|
||||
|
||||
(defn- caller-facing-data?
|
||||
"Whether `node` is a map literal with a 4xx `:status-code` literal or an `error-code` key."
|
||||
[node]
|
||||
(and (hooks/map-node? node)
|
||||
(some (fn [[k v]]
|
||||
(when (hooks/keyword-node? k)
|
||||
(let [kw (hooks/sexpr k)]
|
||||
(or (= "error-code" (name kw))
|
||||
(and (= :status-code kw)
|
||||
(hooks/token-node? v)
|
||||
(let [status (hooks/sexpr v)]
|
||||
(and (number? status) (<= 400 status 499))))))))
|
||||
(partition 2 (:children node)))))
|
||||
|
||||
(defn lint-ex-info
|
||||
"Flag an `ex-info` whose data marks it as a caller-facing error."
|
||||
[{:keys [node] :as input}]
|
||||
(when (exit-enabled? input)
|
||||
(when (caller-facing-data? (nth (:children node) 2 nil))
|
||||
(reg-exit-finding! node (str "Caller-facing errors must be thrown with `throw-teaching-error` and a `msg`, "
|
||||
"so their text is rendered and cleaned."))))
|
||||
input)
|
||||
@@ -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 a namespace ending in .settings under metabase[-enterprise]"
|
||||
:message "All defsettings should live in metabase[-enterprise].<module>.settings namespaces"
|
||||
:type :metabase/defsetting-namespace))))
|
||||
|
||||
;;; TODO -- move this into a Kondo config file in `.clj-kondo/config/`
|
||||
@@ -286,7 +286,6 @@
|
||||
(update context :node update-node)))
|
||||
|
||||
(comment
|
||||
(require '[clojure.pprint])
|
||||
(defn- defsetting* [form]
|
||||
(hooks/sexpr
|
||||
(:node
|
||||
@@ -294,6 +293,7 @@
|
||||
{:node
|
||||
(hooks/parse-string
|
||||
(with-out-str
|
||||
#_{:clj-kondo/ignore [:unresolved-namespace]}
|
||||
(clojure.pprint/pprint
|
||||
form)))}))))
|
||||
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
(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))))
|
||||
|
||||
(def ^:private value-operators
|
||||
"Operators whose second argument is a value rather than a column."
|
||||
'#{= not= < > <= >= like not-like ilike not-ilike in not-in between})
|
||||
|
||||
(defn- marked?
|
||||
"Whether `node` is an `[:auto/param v]` marker."
|
||||
[node]
|
||||
(and (hooks/vector-node? node)
|
||||
(= :auto/param (some-> (first (:children node)) hooks/sexpr))))
|
||||
|
||||
(defn- value-nodes
|
||||
"The nodes sitting in a value slot of `node`, following the clause shapes a query map uses."
|
||||
[node]
|
||||
(cond
|
||||
;; A query map -- walk its clause values, not its keys.
|
||||
(hooks/map-node? node)
|
||||
(mapcat value-nodes (take-nth 2 (rest (:children node))))
|
||||
|
||||
(hooks/vector-node? node)
|
||||
(let [[head & args] (:children node)
|
||||
op (some-> head hooks/sexpr)]
|
||||
(cond
|
||||
(contains? #{:and :or :not} op)
|
||||
(mapcat value-nodes args)
|
||||
|
||||
;; `[:= col v]`, `[:in col vs]`, `[:between col lo hi]` -- the first argument is the
|
||||
;; column and the rest are values, whatever the arity.
|
||||
(and (keyword? op) (contains? value-operators (symbol (name op))))
|
||||
(rest args)
|
||||
|
||||
:else
|
||||
(mapcat value-nodes (:children node))))
|
||||
|
||||
:else nil))
|
||||
|
||||
(def ^:private write-fns
|
||||
"Calls whose values are written rather than filtered on. A value here is not a where-clause value
|
||||
an attacker can turn into SQL structure, and a `define-before-insert` hook may read it before the
|
||||
query compiles, so a marker would break it rather than protect anything."
|
||||
'#{insert! insert-returning-instance! insert-returning-instances! insert-returning-pk!
|
||||
insert-returning-pks! save!})
|
||||
|
||||
(defn- read-call?
|
||||
"Whether `node` is a query call whose values are filtered on rather than written."
|
||||
[node]
|
||||
(let [f (some-> (first (:children node)) hooks/sexpr)]
|
||||
(not (contains? write-fns (some-> f name symbol)))))
|
||||
|
||||
(defn- kv-arg-pair
|
||||
"The first `:column value` pair a query call passes after its model, if any.
|
||||
|
||||
`(t2/select :model/X :locale locale)`. Several of these fns take an argument before the model --
|
||||
`(t2/select-one-fn :value :model/X :key k)` -- so the pairs do not start at a fixed offset; they
|
||||
start after the `:model/...` keyword. A call that does not name a literal model is not checked."
|
||||
[args]
|
||||
(let [after-model (->> args
|
||||
(drop-while #(not (and (hooks/keyword-node? %)
|
||||
(= "model" (namespace (hooks/sexpr %))))))
|
||||
rest)
|
||||
[k v] after-model]
|
||||
(when (and k v (hooks/keyword-node? k))
|
||||
k)))
|
||||
|
||||
(defn- lint-kv-args!
|
||||
"Register a finding for a query written as `:column value` pairs.
|
||||
|
||||
Toucan builds those into the where clause itself, so the query's shape is not on the page: there
|
||||
is nothing for a reader, this linter, or the `.sql` extraction that follows this project to read.
|
||||
The same call takes a map -- `{:where [:= :locale v]}` for a select, `{:locale v}` for an
|
||||
`update!` -- which is the statement, near enough to read off."
|
||||
[node]
|
||||
(when-let [k (kv-arg-pair (rest (:children node)))]
|
||||
(hooks/reg-finding!
|
||||
(assoc (meta k)
|
||||
:message (format "Pass this query a map rather than `%s ...` pairs, so its shape is on the page."
|
||||
(hooks/sexpr k))
|
||||
:type :metabase/unsafe-app-db-query))))
|
||||
|
||||
(defn- lint-unmarked-values!
|
||||
"Register a finding for each argument of the enclosing function that reaches a value slot unmarked.
|
||||
|
||||
Only a symbol is reported. A literal cannot carry a request value, and a value built inside the
|
||||
function is out of reach of a check that does not follow it across a call."
|
||||
[node]
|
||||
(doseq [value (mapcat value-nodes (rest (:children node)))
|
||||
:when (and (hooks/token-node? value)
|
||||
(symbol? (hooks/sexpr value))
|
||||
(not (marked? value)))]
|
||||
(hooks/reg-finding!
|
||||
(assoc (meta value)
|
||||
:message (format "`%s` reaches a SQL value slot unmarked. Write it as [:auto/param %s] so it is bound as a parameter."
|
||||
(hooks/sexpr value) (hooks/sexpr value))
|
||||
:type :metabase/unsafe-app-db-query))))
|
||||
|
||||
(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))))
|
||||
((requiring-resolve 'hooks.metabase.warehouse-schema-overlay.table-or-field-query/lint-read) input)
|
||||
(when (and ns
|
||||
(db-namespace? (modules/config input) ns)
|
||||
(not (test-file? filename))
|
||||
(read-call? node))
|
||||
(lint-unmarked-values! node)
|
||||
(lint-kv-args! node))
|
||||
input)
|
||||
@@ -124,25 +124,6 @@
|
||||
(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)
|
||||
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
(ns hooks.metabase.warehouse-schema-overlay.table-or-field-query
|
||||
"Lint that a query over `:model/Field` or `:model/Table` names its source with `field-query`/`table-query` from
|
||||
`metabase.warehouse-schema-overlay.core`, or reads sync's values with `{:user-settings? false}`."
|
||||
(:require
|
||||
[clj-kondo.hooks-api :as hooks]))
|
||||
|
||||
(def ^:private read-fn?
|
||||
"The Toucan 2 query functions that read rows. Writes are not affected: they go to `metabase_field` itself."
|
||||
#{"select" "select-one" "select-fn->fn" "select-fn->pk" "select-fn-reducible" "select-fn-set" "select-fn-vec"
|
||||
"select-one-fn" "select-one-pk" "select-pk->fn" "select-pks-set" "select-pks-vec" "select-reducible"
|
||||
"reducible-select" "reducible-query" "query" "query-one" "count" "exists?"})
|
||||
|
||||
(def ^:private model->helper
|
||||
{":model/Field" "field-query"
|
||||
":model/Table" "table-query"})
|
||||
|
||||
(defn- test-file? [filename]
|
||||
(boolean (and filename (re-find #"(?:^|/)test/" filename))))
|
||||
|
||||
(defn- tokens
|
||||
"Every token in `node`'s tree, as strings."
|
||||
[node]
|
||||
(tree-seq :children :children node))
|
||||
|
||||
(defn- mentions?
|
||||
[node pred]
|
||||
(boolean (some (fn [n] (when-let [s (some-> (hooks/sexpr n) pr-str)] (pred s)))
|
||||
(filter (complement :children) (tokens node)))))
|
||||
|
||||
(defn- writes?
|
||||
"Whether a `t2/query` form is really a write spelled as a query."
|
||||
[node]
|
||||
(mentions? node #{":update" ":delete-from" ":insert-into"}))
|
||||
|
||||
(defn lint-read
|
||||
"Register a `:metabase/table-or-field-query` finding when a read of `:model/Field` or `:model/Table` does not name
|
||||
its source with the matching helper."
|
||||
[{:keys [node filename]}]
|
||||
(let [fn-node (first (:children node))
|
||||
fn-name (some-> (hooks/sexpr fn-node) name)]
|
||||
(when (and fn-name
|
||||
(read-fn? fn-name)
|
||||
(not (test-file? filename))
|
||||
(not (writes? node)))
|
||||
(doseq [[model helper] model->helper
|
||||
:when (mentions? node #(= % model))
|
||||
:when (not (mentions? node #(re-find (re-pattern (str "\\b" helper "$")) %)))]
|
||||
(hooks/reg-finding!
|
||||
(assoc (meta fn-node)
|
||||
:message (format "A read of %s must name its source: add {:from [(%s)]} (or {:user-settings? false} for sync's own values)"
|
||||
model helper)
|
||||
:type :metabase/table-or-field-query))))))
|
||||
@@ -1,65 +0,0 @@
|
||||
(ns hooks.clojure.core.require-test
|
||||
(:require
|
||||
[clj-kondo.hooks-api :as hooks]
|
||||
[clj-kondo.impl.utils]
|
||||
[clojure.test :refer :all]
|
||||
[hooks.clojure.core.require]))
|
||||
|
||||
(def ^:private config
|
||||
'{:linters {:metabase/modules {:level :warning}}
|
||||
:metabase/modules {search {:uses #{}}
|
||||
task {:api #{metabase.task.core}}}})
|
||||
|
||||
(defn- lint [hook form current-ns]
|
||||
(binding [clj-kondo.impl.utils/*ctx* {:config config
|
||||
:ignores (atom nil)
|
||||
:findings (atom [])
|
||||
:namespaces (atom {})}]
|
||||
(hook {:node (hooks/parse-string (pr-str form))
|
||||
:ns current-ns
|
||||
:config config})
|
||||
@(:findings clj-kondo.impl.utils/*ctx*)))
|
||||
|
||||
(defn- lint-require [form current-ns]
|
||||
(lint hooks.clojure.core.require/lint-require form current-ns))
|
||||
|
||||
(defn- lint-requiring-resolve [form current-ns]
|
||||
(lint hooks.clojure.core.require/lint-requiring-resolve form current-ns))
|
||||
|
||||
(def ^:private dynamic-require-finding
|
||||
{:message "Module dependency cannot be statically determined from a dynamic require"
|
||||
:type :metabase/modules})
|
||||
|
||||
(def ^:private cross-module-finding
|
||||
{:message "Module task should not be used in the search module. [:metabase/modules search :uses]"
|
||||
:type :metabase/modules})
|
||||
|
||||
(deftest ^:parallel dynamic-requiring-resolve-test
|
||||
(testing "outside the module system"
|
||||
(is (= [] (lint-requiring-resolve '(requiring-resolve (:failing-test-var opts)) 'dev))))
|
||||
(testing "inside a module"
|
||||
(are [form] (=? [dynamic-require-finding]
|
||||
(lint-requiring-resolve form 'metabase.search.core))
|
||||
'(requiring-resolve (:failing-test-var opts))
|
||||
'(requiring-resolve @some-atom)
|
||||
'(requiring-resolve some-local)
|
||||
'(requiring-resolve "not-a-symbol"))))
|
||||
|
||||
(deftest ^:parallel dynamic-require-test
|
||||
(testing "outside the module system"
|
||||
(is (= [] (lint-require '(require some-local) 'dev))))
|
||||
(testing "inside a module"
|
||||
(is (=? [dynamic-require-finding]
|
||||
(lint-require '(require some-local) 'metabase.search.core)))))
|
||||
|
||||
(deftest ^:parallel static-requiring-resolve-still-linted-test
|
||||
(is (=? [cross-module-finding]
|
||||
(lint-requiring-resolve '(requiring-resolve 'metabase.task.impl/go)
|
||||
'metabase.search.core))
|
||||
"a statically-known cross-module violation is still reported"))
|
||||
|
||||
(deftest ^:parallel static-require-still-linted-test
|
||||
(is (=? [cross-module-finding]
|
||||
(lint-require '(require 'metabase.task.impl :reload)
|
||||
'metabase.search.core))
|
||||
"a statically-known cross-module violation is still reported while keyword flags are ignored"))
|
||||
@@ -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))
|
||||
|
||||
@@ -1,334 +0,0 @@
|
||||
(ns hooks.metabase.agent-message-test
|
||||
(:require
|
||||
[clj-kondo.hooks-api :as hooks]
|
||||
[clj-kondo.impl.utils]
|
||||
[clojure.edn :as edn]
|
||||
[clojure.test :refer :all]
|
||||
[hooks.metabase.agent-message :as agent-message]))
|
||||
|
||||
(defn- findings
|
||||
"Run `hook-fn` over `form`, a form or its source string, with the linters at `level` (unconfigured when nil) in
|
||||
namespace `ns` of file `filename`; return the findings' messages."
|
||||
([hook-fn form]
|
||||
(findings hook-fn form :warning))
|
||||
([hook-fn form level]
|
||||
(findings hook-fn form level 'metabase.mcp.v2.tools.browse "src/metabase/mcp/v2/tools/browse.clj"))
|
||||
([hook-fn form level ns filename]
|
||||
(let [config (if level
|
||||
{:linters {:metabase/agent-message-lines {:level level}
|
||||
:metabase/agent-message-exit {:level level}}}
|
||||
{})]
|
||||
(binding [clj-kondo.impl.utils/*ctx* {:config config
|
||||
:ignores (atom nil)
|
||||
:findings (atom [])
|
||||
:namespaces (atom {})}]
|
||||
(let [input {:node (hooks/parse-string (if (string? form) form (pr-str form)))
|
||||
:ns ns
|
||||
:filename filename
|
||||
:config config}
|
||||
output (hook-fn input)]
|
||||
(is (identical? (:node input) (:node output))
|
||||
"the hook must return the node unchanged so Kondo's normal analysis still runs")
|
||||
(mapv :message @(:findings clj-kondo.impl.utils/*ctx*)))))))
|
||||
|
||||
(def ^:private hook-cases
|
||||
"`[hook form]` pairs that each report one finding when their linter is enabled."
|
||||
[[agent-message/lint-msg '(msg lines x)]
|
||||
[agent-message/lint-teaching-exit '(throw-teaching-error "x")]
|
||||
[agent-message/lint-jsonrpc-error '(jsonrpc-error id -32602 "x")]
|
||||
[agent-message/lint-success-content '(success-content "x")]
|
||||
[agent-message/lint-ex-info "(ex-info \"x\" {:status-code 404})"]
|
||||
[agent-message/lint-op-error '(op-error! idx "add_link" "x")]
|
||||
[agent-message/lint-ellipsize '(ellipsize "x" 300)]
|
||||
[agent-message/lint-list-content '(list-content data total {:empty-hint "x"})]])
|
||||
|
||||
(defn- linter-enabled-in-ns?
|
||||
"Whether `config` enables `linter` in namespace `ns-name` through an ns-group's `:config-in-ns` entry."
|
||||
[config linter ns-name]
|
||||
(boolean
|
||||
(some (fn [{:keys [pattern name]}]
|
||||
(and (string? pattern)
|
||||
(re-find (re-pattern pattern) ns-name)
|
||||
(not= :off (get-in config [:config-in-ns name :linters linter :level] :off))))
|
||||
(:ns-groups config))))
|
||||
|
||||
(deftest ^:parallel config-scope-test
|
||||
(let [config (edn/read-string (slurp ".clj-kondo/config.edn"))]
|
||||
(testing "GHY-4544: `msg` lines are checked in every OSS and enterprise MCP source namespace, and no test one"
|
||||
(doseq [[ns-name enabled?] {"metabase.mcp.ui-resource" true
|
||||
"metabase.mcp.transport" true
|
||||
"metabase.mcp.v2.tools.browse" true
|
||||
"metabase-enterprise.mcp.usage" true
|
||||
"metabase-enterprise.mcp.v2.tools.browse" true
|
||||
"metabase.mcp.ui-resource-test" false
|
||||
"metabase.mcp.v2.tools.browse-test" false
|
||||
"metabase.mcp.test-util" false
|
||||
"metabase-enterprise.mcp.usage-test" false
|
||||
"metabase-enterprise.mcp.v2.tools.browse-test" false
|
||||
"metabase.queries.models.card" false}]
|
||||
(testing ns-name
|
||||
(is (= enabled? (linter-enabled-in-ns? config :metabase/agent-message-lines ns-name))))))
|
||||
(testing "GHY-4544: exits are checked in MCP transport and v2 source namespaces, whose 4xx errors reach the agent"
|
||||
(doseq [[ns-name enabled?] {"metabase.mcp.transport" true
|
||||
"metabase.mcp.v2.common" true
|
||||
"metabase.mcp.v2.tools.browse" true
|
||||
"metabase-enterprise.mcp.v2.common" true
|
||||
"metabase.mcp.settings" false
|
||||
"metabase.mcp.validation" false
|
||||
"metabase-enterprise.mcp.usage" false
|
||||
"metabase.mcp.transport-test" false
|
||||
"metabase.mcp.v2.tools.browse-test" false
|
||||
"metabase-enterprise.mcp.v2.tools.browse-test" false
|
||||
"metabase.queries.models.card" false}]
|
||||
(testing ns-name
|
||||
(is (= enabled? (linter-enabled-in-ns? config :metabase/agent-message-exit ns-name))))))
|
||||
(testing "GHY-4544: `:empty-hint` is checked on `list-content` and on wrappers passing their options map to it"
|
||||
(doseq [fn-sym '[metabase.mcp.v2.common/list-content
|
||||
metabase.mcp.v2.tools.browse/paged-list-content]]
|
||||
(testing fn-sym
|
||||
(is (= 'hooks.metabase.agent-message/lint-list-content
|
||||
(get-in config [:hooks :analyze-call fn-sym]))))))
|
||||
(testing (str "GHY-4544: `check-execute-sql-enabled!` isn't checked, since it interpolates its subject into "
|
||||
"its own `msg` and a plain string is the intended way to name the tool")
|
||||
(is (nil? (get-in config [:hooks
|
||||
:analyze-call
|
||||
(symbol "metabase.mcp.v2.queries" "check-execute-sql-enabled!")]))))))
|
||||
|
||||
(deftest ^:parallel scope-test
|
||||
(testing "GHY-4544: the hooks leave scoping to config, reporting in any namespace where their linter is enabled"
|
||||
(doseq [[hook form] hook-cases
|
||||
[ns filename] [['metabase.mcp.v2.tools.browse "src/metabase/mcp/v2/tools/browse.clj"]
|
||||
['metabase.queries.models.card "src/metabase/queries/models/card.clj"]
|
||||
['metabase.mcp.v2.tools.browse-test "test/metabase/mcp/v2/tools/browse_test.clj"]]]
|
||||
(testing (str ns " " (pr-str form))
|
||||
(is (= 1 (count (findings hook form :warning ns filename)))))))
|
||||
(testing "nothing is reported when the linter is off or unconfigured"
|
||||
(doseq [[hook form] hook-cases
|
||||
level [:off nil]]
|
||||
(testing (str level " " (pr-str form))
|
||||
(is (empty? (findings hook form level)))))))
|
||||
|
||||
(deftest ^:parallel msg-test
|
||||
(testing "GHY-4544: a vector of single-line string literals with matching arguments is accepted"
|
||||
(is (empty? (findings agent-message/lint-msg
|
||||
'(msg ["Table %s: %d of %d fields." "Continue with `offset: %d`."] t n total next)))))
|
||||
(testing "specifiers that consume no argument aren't counted, and an explicit index can repeat an argument"
|
||||
(is (empty? (findings agent-message/lint-msg '(msg ["100%% done."]))))
|
||||
(is (empty? (findings agent-message/lint-msg '(msg ["%1$s, then %1$s again, then %<s"] x)))))
|
||||
(testing "the lines must be a literal vector"
|
||||
(is (=? [#".*vector.*"]
|
||||
(findings agent-message/lint-msg '(msg lines x))))
|
||||
(is (=? [#".*vector.*"]
|
||||
(findings agent-message/lint-msg '(msg "one line" x)))))
|
||||
(testing "a line may be split across a `str` of string literals"
|
||||
(is (empty? (findings agent-message/lint-msg '(msg [(str "Table %s has " "%d fields.")] t n))))
|
||||
(is (=? [#".*1 argument.*2.*"]
|
||||
(findings agent-message/lint-msg '(msg [(str "only " "%s")] a b)))))
|
||||
(testing "each line must be a string literal or a `str` of string literals"
|
||||
(is (=? [#".*string literal or a `str` of string literals.*"]
|
||||
(findings agent-message/lint-msg '(msg ["ok" line]))))
|
||||
(is (=? [#".*string literal or a `str` of string literals.*"]
|
||||
(findings agent-message/lint-msg '(msg [(str "split " x)])))))
|
||||
(testing "a line can't contain a line break, control, or private-use character; each line is its own string"
|
||||
(is (=? [#".*own string.*"]
|
||||
(findings agent-message/lint-msg '(msg ["first\nsecond"]))))
|
||||
(is (=? [#".*own string.*"]
|
||||
(findings agent-message/lint-msg '(msg [(str "first\n" "second")]))))
|
||||
(is (=? [#".*own string.*"]
|
||||
(findings agent-message/lint-msg (list 'msg [(str "first" (char 0x2028) "second")]))))
|
||||
(is (=? [#".*own string.*"]
|
||||
(findings agent-message/lint-msg '(msg ["tab\tseparated"]))))
|
||||
(is (=? [#".*own string.*"]
|
||||
(findings agent-message/lint-msg '(msg ["done.%n"]))))
|
||||
(testing "GHY-4544: a private-use character, which the truncation markers use, is flagged too"
|
||||
(is (=? [#".*own string.*"]
|
||||
(findings agent-message/lint-msg (list 'msg [(str "first" (char 0xE000) "second")]))))
|
||||
(is (=? [#".*own string.*"]
|
||||
(findings agent-message/lint-msg (list 'msg [(str "marked" (char 0xF8FF))]))))))
|
||||
(testing "the argument count must match the specifiers across all lines"
|
||||
(is (=? [#".*2 arguments.*1.*"]
|
||||
(findings agent-message/lint-msg '(msg ["%s and" "%s"] a))))
|
||||
(is (=? [#".*1 argument.*2.*"]
|
||||
(findings agent-message/lint-msg '(msg ["only %s"] a b)))))
|
||||
(testing (str "GHY-4544: a reference `String/format` can't resolve is flagged, since the runtime would fall back "
|
||||
"to rendering every part quoted")
|
||||
(testing "`%<` with no specifier before it to repeat, which counts as no argument at all"
|
||||
(is (=? [#".*can't exist.*"]
|
||||
(findings agent-message/lint-msg '(msg ["Value: %<s"]))))
|
||||
(is (=? [#".*can't exist.*"]
|
||||
(findings agent-message/lint-msg '(msg ["100%% done," "then %<d"])))))
|
||||
(testing "an explicit index of zero, which is below the first argument"
|
||||
(is (=? [#".*can't exist.*"]
|
||||
(findings agent-message/lint-msg '(msg ["Value: %0$s"]))))
|
||||
(is (=? [#".*can't exist.*"]
|
||||
(findings agent-message/lint-msg '(msg ["Value: %0$s"] x)))))
|
||||
(testing "a `%<` that repeats the first argument is still accepted"
|
||||
(is (empty? (findings agent-message/lint-msg '(msg ["%s then %<s"] x)))))))
|
||||
|
||||
(deftest ^:parallel msg-specifier-test
|
||||
(testing "GHY-4544: only `%s` with no flags but `<`, `%d` with flags and width, and `%%` are allowed"
|
||||
(doseq [line ["%s" "%d" "%1$s" "%,d" "%05d" "%-8d" "%(d" "%1$,10d"
|
||||
"%s again %<s" "%d again %<d" "%s (100%%)"]]
|
||||
(testing line
|
||||
(is (empty? (findings agent-message/lint-msg (list 'msg [(str "Value: " line)] 'x)))))))
|
||||
(testing "GHY-4544: any other conversion, or a `%s` or `%d` that reshapes its value, is flagged"
|
||||
(doseq [line ["%b" "%h" "%c" "%x" "%X" "%f" "%e" "%o" "%g" "%a" "%tY" "%TY" "%B"
|
||||
"%S" "%.20s" "%10s" "%-5s" "%#s" "%1$.3s" "%-10S" "%.1f" "%.2d" "%1$.2d"]]
|
||||
(testing line
|
||||
(is (=? [#"A `msg` line may only use `%s`, `%d`, or `%%`; .* would alter or hide the value\."]
|
||||
(findings agent-message/lint-msg (list 'msg [(str "Value: " line)] 'x)))))))
|
||||
(testing "GHY-4544: a `%` that doesn't begin a valid format specifier is flagged, since `String/format` rejects it"
|
||||
(doseq [line ["Braces %}" "Dot %." "Trailing %" "Space % " "Unknown %q" "Bad date %tq" "%" "%1$"
|
||||
;; GHY-4544: an argument index too large to be one is malformed, not an arithmetic overflow
|
||||
"%999999999999999999999999$s"]]
|
||||
(testing (pr-str line)
|
||||
(is (=? [#"A `%` in a `msg` line must begin a format specifier.*`%%`.*"]
|
||||
(findings agent-message/lint-msg (list 'msg [line]))))))
|
||||
(testing "an index at the digit cap still parses, and is reported as an argument count"
|
||||
(is (=? [#".*999999999 arguments.*"]
|
||||
(findings agent-message/lint-msg '(msg ["%999999999$s"] x))))))
|
||||
(testing "valid specifiers, including an escaped percent sign, aren't flagged as malformed"
|
||||
(doseq [form ['(msg ["100%% done"])
|
||||
'(msg ["%,d rows"] n)
|
||||
'(msg ["%1$s, then %<s"] x)
|
||||
'(msg [(str "50%" "% of %s")] x)]]
|
||||
(testing (pr-str form)
|
||||
(is (empty? (findings agent-message/lint-msg form)))))))
|
||||
|
||||
(def ^:private stringy-texts
|
||||
"Message arguments built as text rather than with `msg`."
|
||||
['"Query failed."
|
||||
'(str "Query failed: " err)
|
||||
'(format "Table %s not found" (pr-str t))
|
||||
'(cond-> (json/encode body) message (str "\n" message))
|
||||
'(if x "a" (str "b" y))
|
||||
'(if x (msg ["a"]) (str "b" y))
|
||||
'(when-let [e (:error r)] (str/join ", " e))
|
||||
'(or (:message r) (tru "Unknown error"))
|
||||
'(let [s (pr-str x)] (str "Bad value " s))
|
||||
'(cond (:a x) (msg ["a"]) :else "b")
|
||||
'(condp = x :a (str "a" y) (msg ["b"]))
|
||||
'(condp = x :a (msg ["a"]) "b")
|
||||
'(case x :a (msg ["a"]) "b")
|
||||
'(case x :a "a" :b (msg ["b"]))
|
||||
'(->> bad (map name) (str/join ", "))
|
||||
'(-> x (str " suffix"))
|
||||
'(-> x name str)
|
||||
'(some-> e ex-message)
|
||||
'(some->> xs (map name) (clojure.string/join ", "))
|
||||
'(cond->> xs x (map name) y (str/join ", "))
|
||||
'(cond-> "x" y (subs 1))
|
||||
'(as-> x v (assoc v :a 1) (json/encode v))
|
||||
'(doto (str "a" b) println)
|
||||
'(let [s x] (-> s (format y)))])
|
||||
|
||||
(def ^:private message-texts
|
||||
"Message arguments that aren't syntactically text."
|
||||
['(msg ["Table %s not found."] t)
|
||||
'message
|
||||
'(if x (msg ["a"]) (msg ["b"]))
|
||||
'(render-error e)
|
||||
'(cond (str x) (msg ["a"]) :else (msg ["b"]))
|
||||
'(condp = x "a" (msg ["a"]) (msg ["b"]))
|
||||
'(condp get x "a" :>> render-error (msg ["b"]))
|
||||
'(case x "a" (msg ["a"]) ("b" "c") (msg ["b"]))
|
||||
'(-> "x" count)
|
||||
'(->> "x" (map str) set)
|
||||
'(some-> (str "x") render-error)
|
||||
'(-> x)
|
||||
'(-> e :message)
|
||||
'(cond-> {:a 1} x (assoc :b "c"))
|
||||
'(as-> "x" v (count v))
|
||||
'(doto x (println "a"))])
|
||||
|
||||
(deftest ^:parallel teaching-exit-test
|
||||
(doseq [fn-name ["throw-teaching-error" "error-content"]]
|
||||
(testing (str "GHY-4544: text passed to `" fn-name "` is flagged")
|
||||
(doseq [text stringy-texts]
|
||||
(testing (pr-str text)
|
||||
(is (=? [(re-pattern (str ".*passes text to `" fn-name "`.*`msg`.*"))]
|
||||
(findings agent-message/lint-teaching-exit (list (symbol "common" fn-name) text)))))))
|
||||
(testing (str "`" fn-name "` accepts a `msg`, a symbol, and other calls")
|
||||
(doseq [text message-texts]
|
||||
(testing (pr-str text)
|
||||
(is (empty? (findings agent-message/lint-teaching-exit
|
||||
(list (symbol fn-name) text {:status-code 404}))))))))
|
||||
(testing "only the message argument is checked"
|
||||
(is (empty? (findings agent-message/lint-teaching-exit '(error-content (msg ["x"]) (str "code"))))))
|
||||
(testing "nothing is reported when the linter is off"
|
||||
(is (empty? (findings agent-message/lint-teaching-exit '(throw-teaching-error "x") :off)))))
|
||||
|
||||
(def ^:private helper-cases
|
||||
"`[hook fn-name form-fn]`: a message-taking helper's hook, the name its finding cites, and a function of a message
|
||||
returning a call that passes it to the helper."
|
||||
[[agent-message/lint-op-error "op-error!" #(list 'dashboard-ops/op-error! 'idx "add_link" %)]
|
||||
[agent-message/lint-ellipsize "ellipsize" #(list 'common/ellipsize % 300)]
|
||||
[agent-message/lint-list-content "list-content" #(list 'common/list-content 'rows 0 {:offset 0 :empty-hint %})]
|
||||
[agent-message/lint-list-content "paged-list-content"
|
||||
#(list 'paged-list-content 'args 'rows {:empty-hint %} 'identity)]])
|
||||
|
||||
(deftest ^:parallel message-helper-test
|
||||
(doseq [[hook fn-name form-fn] helper-cases]
|
||||
(testing (str "GHY-4544: text passed as `" fn-name "`'s message is flagged")
|
||||
(doseq [text stringy-texts]
|
||||
(testing (pr-str text)
|
||||
(is (=? [(re-pattern (str ".*passes text to `" fn-name "`.*`msg`.*"))]
|
||||
(findings hook (form-fn text)))))))
|
||||
(testing (str "`" fn-name "` accepts a `msg`, a symbol, and other calls as its message")
|
||||
(doseq [text message-texts]
|
||||
(testing (pr-str text)
|
||||
(is (empty? (findings hook (form-fn text))))))))
|
||||
(testing "only the message argument is checked"
|
||||
(is (empty? (findings agent-message/lint-op-error '(op-error! (str "0") (str "op") (msg ["x"])))))
|
||||
(is (empty? (findings agent-message/lint-ellipsize '(ellipsize (msg ["x"]) (str "300")))))
|
||||
(is (empty? (findings agent-message/lint-list-content '(list-content (str "data") 0 {:param (str "x")}))))
|
||||
(is (empty? (findings agent-message/lint-list-content '(list-content data 0 opts))))
|
||||
(is (empty? (findings agent-message/lint-list-content '(list-content data 0))))))
|
||||
|
||||
(deftest ^:parallel jsonrpc-error-test
|
||||
(testing "GHY-4544: text passed as the JSON-RPC error message is flagged"
|
||||
(doseq [text stringy-texts]
|
||||
(testing (pr-str text)
|
||||
(is (=? [#".*passes text to `jsonrpc-error`.*`msg`.*"]
|
||||
(findings agent-message/lint-jsonrpc-error (list 'transport/jsonrpc-error 'id -32602 text)))))))
|
||||
(testing "a `msg`, symbol, or other call as the message is accepted"
|
||||
(doseq [text message-texts]
|
||||
(testing (pr-str text)
|
||||
(is (empty? (findings agent-message/lint-jsonrpc-error (list 'jsonrpc-error 'id -32602 text)))))))
|
||||
(testing "only the third argument is the message"
|
||||
(is (empty? (findings agent-message/lint-jsonrpc-error '(jsonrpc-error "id" (str "code") (msg ["x"])))))))
|
||||
|
||||
(deftest ^:parallel success-content-test
|
||||
(testing "GHY-4544: text passed to `success-content` is flagged"
|
||||
(doseq [text stringy-texts]
|
||||
(testing (pr-str text)
|
||||
(is (=? [#"`success-content` takes data to JSON-encode or a `msg`.*"]
|
||||
(findings agent-message/lint-success-content (list 'common/success-content text)))))))
|
||||
(testing "a payload, a `msg`, or a symbol is accepted"
|
||||
(doseq [text (conj message-texts '{:data rows} '[a b])]
|
||||
(testing (pr-str text)
|
||||
(is (empty? (findings agent-message/lint-success-content (list 'success-content text))))))
|
||||
(is (empty? (findings agent-message/lint-success-content
|
||||
'(success-content {:data rows} (str "structured")))))))
|
||||
|
||||
(deftest ^:parallel ex-info-test
|
||||
(testing "GHY-4544: an ex-info carrying a 4xx status code or an error code is flagged"
|
||||
(doseq [form ["(ex-info \"Card not found\" {:status-code 404})"
|
||||
"(ex-info (str \"Bad \" x) {:status-code 400 :field f})"
|
||||
"(ex-info \"Nope\" {::common/error-code c})"
|
||||
"(ex-info \"Nope\" {:error-code c})"
|
||||
"(ex-info (msg [\"Nope\"]) {:error-code c})"]]
|
||||
(testing form
|
||||
(is (=? [#".*`throw-teaching-error` and a `msg`.*"]
|
||||
(findings agent-message/lint-ex-info form))))))
|
||||
(testing "other ex-info calls aren't flagged"
|
||||
(doseq [form ["(ex-info \"boom\" {:status-code 500})"
|
||||
"(ex-info \"boom\" {:status-code code})"
|
||||
"(ex-info \"boom\" {:foo 1})"
|
||||
"(ex-info \"boom\" data)"
|
||||
"(ex-info \"boom\" (merge {:status-code 400} data))"
|
||||
"(ex-info \"boom\")"]]
|
||||
(testing form
|
||||
(is (empty? (findings agent-message/lint-ex-info form))))))
|
||||
(testing "nothing is reported when the linter is off"
|
||||
(is (empty? (findings agent-message/lint-ex-info "(ex-info \"x\" {:status-code 404})" :off)))))
|
||||
@@ -1,138 +0,0 @@
|
||||
(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- confinement-findings
|
||||
"Only the `t2-query-namespace` findings, so these tests are not affected by the query-hygiene
|
||||
linter that shares this hook."
|
||||
[findings]
|
||||
(filter #(= :metabase/t2-query-namespace (:type %)) findings))
|
||||
|
||||
(defn- lint-query-call [form ns-sym & [filename modules]]
|
||||
(binding [clj-kondo.impl.utils/*ctx* {:config {:linters {:metabase/t2-query-namespace {:level :warning}
|
||||
:metabase/unsafe-app-db-query {: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? (confinement-findings (lint-query-call '(t2/select-one :model/Card :id 1) 'metabase.queries.db)))))
|
||||
(testing "metabase-enterprise.<module>.db is allowed"
|
||||
(is (empty? (confinement-findings (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? (confinement-findings (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))))))
|
||||
|
||||
(deftest ^:parallel unmarked-value-in-a-db-namespace-test
|
||||
(testing "a symbol reaching a value slot is flagged"
|
||||
(is (=? [{:type :metabase/unsafe-app-db-query
|
||||
:message #"`locale` reaches a SQL value slot unmarked.*"}]
|
||||
(lint-query-call '(t2/select :model/X {:where [:= :locale locale]}) 'metabase.foo.db))))
|
||||
(testing "a marked value is not flagged"
|
||||
(is (empty? (lint-query-call '(t2/select :model/X {:where [:= :locale [:auto/param locale]]})
|
||||
'metabase.foo.db))))
|
||||
(testing "a literal cannot carry a request value and is not flagged"
|
||||
(is (empty? (lint-query-call '(t2/select :model/X {:where [:= :locale "de"]}) 'metabase.foo.db))))
|
||||
(testing "a column reference is not a value"
|
||||
(is (empty? (lint-query-call '(t2/select :model/X {:where [:= :a.id :b.id]}) 'metabase.foo.db))))
|
||||
(testing "values nested under a boolean connective are reached"
|
||||
(is (=? [{:message #"`b`.*"}]
|
||||
(lint-query-call '(t2/select :model/X {:where [:and [:= :x [:auto/param a]] [:= :y b]]})
|
||||
'metabase.foo.db))))
|
||||
(testing "only db namespaces are linted for unmarked values"
|
||||
(is (empty? (filter #(= :metabase/unsafe-app-db-query (:type %))
|
||||
(lint-query-call '(t2/select :model/X {:where [:= :locale locale]})
|
||||
'metabase.foo.models.thing)))))
|
||||
(testing "a test source tree is exempt"
|
||||
(is (empty? (lint-query-call '(t2/select :model/X {:where [:= :locale locale]})
|
||||
'metabase.foo.db "test/metabase/foo/db_test.clj")))))
|
||||
|
||||
(deftest ^:parallel kv-arg-style-test
|
||||
(testing "a query written as :column value pairs is flagged whatever the values are"
|
||||
(are [form] (=? [{:type :metabase/unsafe-app-db-query, :message #"Pass this query a map.*"}]
|
||||
(lint-query-call form 'metabase.foo.db))
|
||||
'(t2/select :model/X :locale locale)
|
||||
'(t2/select :model/X :archived false)
|
||||
'(t2/select :model/X :locale [:auto/param locale])
|
||||
'(t2/select-one :model/X :id (long id))))
|
||||
(testing "one finding per call, not one per pair"
|
||||
(is (= 1 (count (lint-query-call '(t2/select :model/X :locale locale :msgid msgid)
|
||||
'metabase.foo.db)))))
|
||||
(testing "a fn with an argument before the model is still recognised"
|
||||
(is (=? [{:message #"Pass this query a map.*"}]
|
||||
(lint-query-call '(t2/select-one-fn :value :model/Setting :key k) 'metabase.foo.db))))
|
||||
(testing "a map query is not flagged"
|
||||
(are [form] (empty? (lint-query-call form 'metabase.foo.db))
|
||||
'(t2/select :model/X {:where [:= :locale [:auto/param locale]]})
|
||||
'(t2/update! :model/X {:locale [:auto/param locale]} {:msgstr "x"}))))
|
||||
|
||||
(deftest ^:parallel write-calls-are-not-linted-for-values-test
|
||||
(testing "an insert's values are written, not filtered on, so they are not flagged"
|
||||
(are [form] (empty? (filter #(= :metabase/unsafe-app-db-query (:type %))
|
||||
(lint-query-call form 'metabase.foo.db)))
|
||||
'(t2/insert! :model/X :key k :value v)
|
||||
'(t2/insert-returning-instances! :model/X :key k :value v)))
|
||||
(testing "a select's values are still flagged"
|
||||
(is (=? [{:type :metabase/unsafe-app-db-query}]
|
||||
(lint-query-call '(t2/select :model/X :key k) 'metabase.foo.db)))))
|
||||
|
||||
(deftest ^:parallel operator-arity-test
|
||||
(testing "a value operator with an unexpected arity still has its args examined"
|
||||
(is (seq (lint-query-call '(t2/select :model/X {:where [:= :a b c]}) 'metabase.foo.db)))
|
||||
(is (seq (lint-query-call '(t2/select :model/X {:where [:between :a lo hi]}) 'metabase.foo.db)))))
|
||||
@@ -16,7 +16,7 @@ skip =
|
||||
frontend/src/metabase/querying/expressions/tokenizer/lezer.js,
|
||||
frontend/src/metabase/query_builder/components/NativeQueryEditor/CodeMirrorEditor/language.ts,
|
||||
frontend/src/metabase/static-viz/constants/char-sizes.ts,
|
||||
frontend/src/metabase/viz-core/lib/mapping_codes.ts,
|
||||
frontend/src/metabase/visualizations/lib/mapping_codes.ts,
|
||||
frontend/*/mocks/*,
|
||||
modules/drivers/mongo/src/metabase/driver/mongo/operators.clj,
|
||||
src/metabase/analyze/classifiers/name.clj,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// imported, vs the *allowed* edges in module-boundaries.mjs). Consumed by
|
||||
// .github/scripts/create-test-plan.ts.
|
||||
//
|
||||
// bunx depcruise frontend/src enterprise/frontend/src .storybook/preview.tsx \
|
||||
// bunx depcruise frontend/src enterprise/frontend/src \
|
||||
// --config .dependency-cruiser.cjs --output-type json > dependency-graph.json
|
||||
|
||||
/** @type {import('dependency-cruiser').IConfiguration} */
|
||||
@@ -10,13 +10,10 @@ module.exports = {
|
||||
options: {
|
||||
// Keep only edges between our own source files (drops node_modules etc.),
|
||||
// which keeps the JSON small.
|
||||
includeOnly: "^(frontend/src|enterprise/frontend/src|\\.storybook)/",
|
||||
includeOnly: "^(frontend/src|enterprise/frontend/src)/",
|
||||
|
||||
doNotFollow: { path: "node_modules" },
|
||||
|
||||
// No rules here, so the cycle and orphan analysis has no consumer and only costs time.
|
||||
skipAnalysisNotInRules: true,
|
||||
|
||||
// Resolve the `"*": ["./frontend/src/*", ...]` aliases so bare imports like
|
||||
// `metabase/lib/foo` map back to a real file.
|
||||
tsConfig: { fileName: "tsconfig.json" },
|
||||
|
||||
853
.github/CODEOWNERS
vendored
853
.github/CODEOWNERS
vendored
@@ -42,7 +42,6 @@ docs/api.json # auto-generated
|
||||
docs/configuring-metabase/environment-variables.md # auto-generated
|
||||
docs/configuring-metabase/config-template.md # auto-generated
|
||||
docs/installation-and-operation/commands.md # auto-generated
|
||||
docs/ai/providers.md # auto-generated
|
||||
docs/embedding/sdk/api # auto-generated
|
||||
docs/embedding/eajs/snippets # auto-generated
|
||||
|
||||
@@ -55,855 +54,3 @@ src/metabase/analytics/prometheus.clj @metabase/cloud-ops
|
||||
# @metabase/metabot-frontend
|
||||
enterprise/frontend/src/metabase-enterprise/metabot @metabase/metabot-frontend
|
||||
frontend/src/metabase/metabot @metabase/metabot-frontend
|
||||
|
||||
# @metabase/core-frontend-gadget
|
||||
bin/custom-viz @metabase/core-frontend-gadget
|
||||
docs/developers-guide/custom-visualizations.md @metabase/core-frontend-gadget
|
||||
enterprise/frontend/src/custom-viz @metabase/core-frontend-gadget
|
||||
enterprise/frontend/src/metabase-enterprise/api/custom-viz-plugin.ts @metabase/core-frontend-gadget
|
||||
enterprise/frontend/src/metabase-enterprise/custom_viz @metabase/core-frontend-gadget
|
||||
frontend/src/metabase-types/api/custom-viz-plugin.ts @metabase/core-frontend-gadget
|
||||
frontend/src/metabase/plugins/oss/custom-viz.ts @metabase/core-frontend-gadget
|
||||
frontend/src/metabase/visualizations/custom-visualizations @metabase/core-frontend-gadget
|
||||
frontend/test/__support__/custom-viz-fixtures @metabase/core-frontend-gadget
|
||||
.github/workflows/release-custom-viz.yml @metabase/devexp @metabase/core-frontend-gadget
|
||||
|
||||
# ===== BEGIN generated module ownership (./bin/mage update-codeowners) =====
|
||||
#
|
||||
# Generated by `./bin/mage update-codeowners`. Do not edit by hand.
|
||||
# Ownership comes from each module's :team (.clj-kondo/config/modules/config.edn) and that
|
||||
# team's :assignee handle (.github/team.json). A stanza is live when its team has an :assignee
|
||||
# and the module is not :suppress-codeowners; otherwise it is commented out, ready to enable.
|
||||
#
|
||||
|
||||
# actions (Gadget, no :assignee in team.json)
|
||||
# enterprise/backend/src/metabase_enterprise/action_v2
|
||||
# enterprise/backend/test/metabase_enterprise/action_v2
|
||||
|
||||
# advanced-config (UX West, no :assignee in team.json)
|
||||
# enterprise/backend/src/metabase_enterprise/advanced_config
|
||||
# enterprise/backend/test/metabase_enterprise/advanced_config
|
||||
|
||||
# permissions (UX West, no :assignee in team.json)
|
||||
# enterprise/backend/src/metabase_enterprise/advanced_permissions
|
||||
# enterprise/backend/test/metabase_enterprise/advanced_permissions
|
||||
|
||||
# metabot.agent-api (Metabot)
|
||||
enterprise/backend/src/metabase_enterprise/agent_api @metabase/metabot
|
||||
enterprise/backend/test/metabase_enterprise/agent_api @metabase/metabot
|
||||
|
||||
# analytics (UX West, no :assignee in team.json)
|
||||
# enterprise/backend/src/metabase_enterprise/analytics
|
||||
# enterprise/backend/test/metabase_enterprise/analytics
|
||||
|
||||
# api (UX West, no :assignee in team.json)
|
||||
# enterprise/backend/src/metabase_enterprise/api
|
||||
# enterprise/backend/test/metabase_enterprise/api
|
||||
|
||||
# api-routes (UX West, no :assignee in team.json)
|
||||
# enterprise/backend/src/metabase_enterprise/api_routes
|
||||
|
||||
# audit-app (UX West, no :assignee in team.json)
|
||||
# enterprise/backend/src/metabase_enterprise/audit_app
|
||||
# enterprise/backend/test/metabase_enterprise/audit_app
|
||||
|
||||
# sso.auth-identity (UX West, no :assignee in team.json)
|
||||
# enterprise/backend/src/metabase_enterprise/auth_identity
|
||||
# enterprise/backend/test/metabase_enterprise/auth_identity
|
||||
|
||||
# auth-provider (UX West, no :assignee in team.json)
|
||||
# enterprise/backend/src/metabase_enterprise/auth_provider
|
||||
# enterprise/backend/test/metabase_enterprise/auth_provider
|
||||
|
||||
# billing (UX West, no :assignee in team.json)
|
||||
# enterprise/backend/src/metabase_enterprise/billing
|
||||
# enterprise/backend/test/metabase_enterprise/billing
|
||||
|
||||
# cache (UX West, no :assignee in team.json)
|
||||
# enterprise/backend/src/metabase_enterprise/cache
|
||||
# enterprise/backend/test/metabase_enterprise/cache
|
||||
|
||||
# cloud.add-ons (Cloud, no :assignee in team.json)
|
||||
# enterprise/backend/src/metabase_enterprise/cloud_add_ons
|
||||
# enterprise/backend/test/metabase_enterprise/cloud_add_ons
|
||||
|
||||
# cloud.proxy (Cloud, no :assignee in team.json)
|
||||
# enterprise/backend/src/metabase_enterprise/cloud_proxy
|
||||
# enterprise/backend/test/metabase_enterprise/cloud_proxy
|
||||
|
||||
# connection-overlays (Graphy)
|
||||
enterprise/backend/src/metabase_enterprise/connection_overlays @metabase/graphy
|
||||
|
||||
# content-translation (UX West, no :assignee in team.json)
|
||||
# enterprise/backend/src/metabase_enterprise/content_translation
|
||||
# enterprise/backend/test/metabase_enterprise/content_translation
|
||||
|
||||
# content-verification (UX West, no :assignee in team.json)
|
||||
# enterprise/backend/src/metabase_enterprise/content_verification
|
||||
# enterprise/backend/test/metabase_enterprise/content_verification
|
||||
|
||||
# core (DevEx, no :assignee in team.json)
|
||||
# enterprise/backend/src/metabase_enterprise/core
|
||||
|
||||
# custom-viz-plugin (Gadget, no :assignee in team.json)
|
||||
# enterprise/backend/src/metabase_enterprise/custom_viz_plugin
|
||||
# enterprise/backend/test/metabase_enterprise/custom_viz_plugin
|
||||
|
||||
# pulse (Graphy)
|
||||
enterprise/backend/src/metabase_enterprise/dashboard_subscription_filters @metabase/graphy
|
||||
enterprise/backend/test/metabase_enterprise/dashboard_subscription_filters @metabase/graphy
|
||||
|
||||
# data-apps (Embedding, no :assignee in team.json)
|
||||
# enterprise/backend/src/metabase_enterprise/data_apps
|
||||
# enterprise/backend/test/metabase_enterprise/data_apps
|
||||
|
||||
# metabot.data-complexity-score (Metabot)
|
||||
enterprise/backend/src/metabase_enterprise/data_complexity_score @metabase/metabot
|
||||
enterprise/backend/test/metabase_enterprise/data_complexity_score @metabase/metabot
|
||||
|
||||
# data-studio (UX West, no :assignee in team.json)
|
||||
# enterprise/backend/src/metabase_enterprise/data_studio
|
||||
# enterprise/backend/test/metabase_enterprise/data_studio
|
||||
|
||||
# cloud.database-replication (Cloud, no :assignee in team.json)
|
||||
# enterprise/backend/src/metabase_enterprise/database_replication
|
||||
# enterprise/backend/test/metabase_enterprise/database_replication
|
||||
|
||||
# database-routing (UX West, no :assignee in team.json)
|
||||
# enterprise/backend/src/metabase_enterprise/database_routing
|
||||
# enterprise/backend/test/metabase_enterprise/database_routing
|
||||
|
||||
# dependencies (Graphy)
|
||||
enterprise/backend/src/metabase_enterprise/dependencies @metabase/graphy
|
||||
enterprise/backend/test/metabase_enterprise/dependencies @metabase/graphy
|
||||
|
||||
# email (UX West, no :assignee in team.json)
|
||||
# enterprise/backend/src/metabase_enterprise/email
|
||||
# enterprise/backend/test/metabase_enterprise/email
|
||||
|
||||
# embedding (Embedding, no :assignee in team.json)
|
||||
# enterprise/backend/src/metabase_enterprise/embedding_hub
|
||||
# enterprise/backend/test/metabase_enterprise/embedding_hub
|
||||
|
||||
# search.embeddings (Metabot)
|
||||
enterprise/backend/src/metabase_enterprise/embeddings @metabase/metabot
|
||||
enterprise/backend/test/metabase_enterprise/embeddings @metabase/metabot
|
||||
|
||||
# search.entity-retrieval (Metabot)
|
||||
enterprise/backend/src/metabase_enterprise/entity_retrieval @metabase/metabot
|
||||
enterprise/backend/test/metabase_enterprise/entity_retrieval @metabase/metabot
|
||||
|
||||
# warehouses (Gadget, no :assignee in team.json)
|
||||
# enterprise/backend/src/metabase_enterprise/erd
|
||||
# enterprise/backend/test/metabase_enterprise/erd
|
||||
|
||||
# cloud.gsheets (Cloud, no :assignee in team.json)
|
||||
# enterprise/backend/src/metabase_enterprise/gsheets
|
||||
# enterprise/backend/test/metabase_enterprise/gsheets
|
||||
|
||||
# cloud (Cloud, no :assignee in team.json)
|
||||
# enterprise/backend/src/metabase_enterprise/harbormaster
|
||||
# enterprise/backend/test/metabase_enterprise/harbormaster
|
||||
|
||||
# permissions.impersonation (Graphy)
|
||||
enterprise/backend/src/metabase_enterprise/impersonation @metabase/graphy
|
||||
enterprise/backend/test/metabase_enterprise/impersonation @metabase/graphy
|
||||
|
||||
# analytics.internal-stats (UX West, no :assignee in team.json)
|
||||
# enterprise/backend/src/metabase_enterprise/internal_stats
|
||||
# enterprise/backend/test/metabase_enterprise/internal_stats
|
||||
|
||||
# library (UX West, no :assignee in team.json)
|
||||
# enterprise/backend/src/metabase_enterprise/library
|
||||
# enterprise/backend/test/metabase_enterprise/library
|
||||
|
||||
# mcp (Metabot)
|
||||
enterprise/backend/src/metabase_enterprise/mcp @metabase/metabot
|
||||
enterprise/backend/test/metabase_enterprise/mcp @metabase/metabot
|
||||
|
||||
# memoize-monitor (Graphy)
|
||||
enterprise/backend/src/metabase_enterprise/memoize_monitor @metabase/graphy
|
||||
enterprise/backend/test/metabase_enterprise/memoize_monitor @metabase/graphy
|
||||
|
||||
# metabot (Metabot)
|
||||
enterprise/backend/src/metabase_enterprise/metabot @metabase/metabot
|
||||
enterprise/backend/test/metabase_enterprise/metabot @metabase/metabot
|
||||
|
||||
# metabot.analytics (Metabot)
|
||||
enterprise/backend/src/metabase_enterprise/metabot_analytics @metabase/metabot
|
||||
enterprise/backend/test/metabase_enterprise/metabot_analytics @metabase/metabot
|
||||
|
||||
# mfa (UX West, no :assignee in team.json)
|
||||
# enterprise/backend/src/metabase_enterprise/mfa
|
||||
# enterprise/backend/test/metabase_enterprise/mfa
|
||||
|
||||
# permissions.debug (UX West, no :assignee in team.json)
|
||||
# enterprise/backend/src/metabase_enterprise/permission_debug
|
||||
# enterprise/backend/test/metabase_enterprise/permission_debug
|
||||
|
||||
# premium-features (UX West, no :assignee in team.json)
|
||||
# enterprise/backend/src/metabase_enterprise/premium_features
|
||||
# enterprise/backend/test/metabase_enterprise/premium_features
|
||||
|
||||
# remote-sync (UX West, no :assignee in team.json)
|
||||
# enterprise/backend/src/metabase_enterprise/remote_sync
|
||||
# enterprise/backend/test/metabase_enterprise/remote_sync
|
||||
|
||||
# dependencies.replacement (Graphy)
|
||||
enterprise/backend/src/metabase_enterprise/replacement @metabase/graphy
|
||||
enterprise/backend/test/metabase_enterprise/replacement @metabase/graphy
|
||||
|
||||
# permissions.sandbox (UX West, no :assignee in team.json)
|
||||
# enterprise/backend/src/metabase_enterprise/sandbox
|
||||
# enterprise/backend/test/metabase_enterprise/sandbox
|
||||
|
||||
# sso.scim (UX West, no :assignee in team.json)
|
||||
# enterprise/backend/src/metabase_enterprise/scim
|
||||
# enterprise/backend/test/metabase_enterprise/scim
|
||||
|
||||
# search (UX West, no :assignee in team.json)
|
||||
# enterprise/backend/src/metabase_enterprise/search
|
||||
# enterprise/backend/test/metabase_enterprise/search
|
||||
|
||||
# security-center (Gadget, no :assignee in team.json)
|
||||
# enterprise/backend/src/metabase_enterprise/security_center
|
||||
# enterprise/backend/test/metabase_enterprise/security_center
|
||||
|
||||
# search.semantic (Metabot)
|
||||
enterprise/backend/src/metabase_enterprise/semantic_search @metabase/metabot
|
||||
enterprise/backend/test/metabase_enterprise/semantic_search @metabase/metabot
|
||||
|
||||
# serialization (Graphy)
|
||||
enterprise/backend/src/metabase_enterprise/serialization @metabase/graphy
|
||||
enterprise/backend/test/metabase_enterprise/serialization @metabase/graphy
|
||||
|
||||
# native-query-snippets (Graphy)
|
||||
enterprise/backend/src/metabase_enterprise/snippet_collections @metabase/graphy
|
||||
enterprise/backend/test/metabase_enterprise/snippet_collections @metabase/graphy
|
||||
|
||||
# sso (UX West, no :assignee in team.json)
|
||||
# enterprise/backend/src/metabase_enterprise/sso
|
||||
# enterprise/backend/test/metabase_enterprise/sso
|
||||
|
||||
# staleness (UX West, no :assignee in team.json)
|
||||
# enterprise/backend/src/metabase_enterprise/stale
|
||||
# enterprise/backend/test/metabase_enterprise/stale
|
||||
|
||||
# support-access-grants (UX West, no :assignee in team.json)
|
||||
# enterprise/backend/src/metabase_enterprise/support_access_grants
|
||||
# enterprise/backend/test/metabase_enterprise/support_access_grants
|
||||
|
||||
# tenants (Admin Webapp, no :assignee in team.json)
|
||||
# enterprise/backend/src/metabase_enterprise/tenants
|
||||
# enterprise/backend/test/metabase_enterprise/tenants
|
||||
|
||||
# transforms (Graphy)
|
||||
enterprise/backend/src/metabase_enterprise/transforms @metabase/graphy
|
||||
enterprise/backend/test/metabase_enterprise/transforms @metabase/graphy
|
||||
|
||||
# transforms.inspector (Gadget, no :assignee in team.json)
|
||||
# enterprise/backend/src/metabase_enterprise/transforms_inspector
|
||||
# enterprise/backend/test/metabase_enterprise/transforms_inspector
|
||||
|
||||
# transforms.python (Graphy)
|
||||
enterprise/backend/src/metabase_enterprise/transforms_python @metabase/graphy
|
||||
enterprise/backend/test/metabase_enterprise/transforms_python @metabase/graphy
|
||||
|
||||
# upload (UX West, no :assignee in team.json)
|
||||
# enterprise/backend/src/metabase_enterprise/upload_management
|
||||
# enterprise/backend/test/metabase_enterprise/upload_management
|
||||
|
||||
# actions (Gadget, no :assignee in team.json)
|
||||
# src/metabase/actions
|
||||
# test/metabase/actions
|
||||
|
||||
# actions.rest (Gadget, no :assignee in team.json)
|
||||
# src/metabase/actions_rest
|
||||
# test/metabase/actions_rest
|
||||
|
||||
# activity-feed (UX West, no :assignee in team.json)
|
||||
# src/metabase/activity_feed
|
||||
# test/metabase/activity_feed
|
||||
|
||||
# metabot.agent-api (Metabot)
|
||||
src/metabase/agent_api @metabase/metabot
|
||||
test/metabase/agent_api @metabase/metabot
|
||||
|
||||
# lib.agent-lib (Metabot)
|
||||
src/metabase/agent_lib @metabase/metabot
|
||||
test/metabase/agent_lib @metabase/metabot
|
||||
|
||||
# ai-tracing (Metabot)
|
||||
src/metabase/ai_tracing @metabase/metabot
|
||||
test/metabase/ai_tracing @metabase/metabot
|
||||
|
||||
# analytics (UX West, no :assignee in team.json)
|
||||
# src/metabase/analytics
|
||||
# test/metabase/analytics
|
||||
|
||||
# analytics.interface (Querying Platform)
|
||||
src/metabase/analytics_interface @metabase/core-backend-querying-platform
|
||||
test/metabase/analytics_interface @metabase/core-backend-querying-platform
|
||||
|
||||
# sync.analyze (Graphy)
|
||||
src/metabase/analyze @metabase/graphy
|
||||
test/metabase/analyze @metabase/graphy
|
||||
|
||||
# api (DevEx, no :assignee in team.json)
|
||||
# src/metabase/api
|
||||
# test/metabase/api
|
||||
|
||||
# api-keys (UX West, no :assignee in team.json)
|
||||
# src/metabase/api_keys
|
||||
# test/metabase/api_keys
|
||||
|
||||
# api-routes (UX West, no :assignee in team.json)
|
||||
# src/metabase/api_routes
|
||||
# test/metabase/api_routes
|
||||
|
||||
# api-scope (Metabot)
|
||||
src/metabase/api_scope @metabase/metabot
|
||||
|
||||
# app-db (UX West, no :assignee in team.json)
|
||||
# src/metabase/app_db
|
||||
# test/metabase/app_db
|
||||
|
||||
# appearance (UX West, no :assignee in team.json)
|
||||
# src/metabase/appearance
|
||||
# test/metabase/appearance
|
||||
|
||||
# audit-app (UX West, no :assignee in team.json)
|
||||
# src/metabase/audit_app
|
||||
# test/metabase/audit_app
|
||||
|
||||
# sso.auth-identity (UX West, no :assignee in team.json)
|
||||
# src/metabase/auth_identity
|
||||
# test/metabase/auth_identity
|
||||
|
||||
# auth-provider (UX West, no :assignee in team.json)
|
||||
# src/metabase/auth_provider
|
||||
# test/metabase/auth_provider
|
||||
|
||||
# batch-processing (UX West, no :assignee in team.json)
|
||||
# src/metabase/batch_processing
|
||||
# test/metabase/batch_processing
|
||||
|
||||
# bookmarks (UX West, no :assignee in team.json)
|
||||
# src/metabase/bookmarks
|
||||
# test/metabase/bookmarks
|
||||
|
||||
# bug-reporting (UX West, no :assignee in team.json)
|
||||
# src/metabase/bug_reporting
|
||||
# test/metabase/bug_reporting
|
||||
|
||||
# cache (UX West, no :assignee in team.json)
|
||||
# src/metabase/cache
|
||||
# test/metabase/cache
|
||||
|
||||
# channel (Gadget, no :assignee in team.json)
|
||||
# src/metabase/channel
|
||||
# test/metabase/channel
|
||||
|
||||
# util.classloader (DevEx, no :assignee in team.json)
|
||||
# src/metabase/classloader
|
||||
# test/metabase/classloader
|
||||
|
||||
# cloud-migration (UX West, no :assignee in team.json)
|
||||
# src/metabase/cloud_migration
|
||||
# test/metabase/cloud_migration
|
||||
|
||||
# core.cmd (UX West, no :assignee in team.json)
|
||||
# src/metabase/cmd
|
||||
# test/metabase/cmd
|
||||
|
||||
# collections (UX West, no :assignee in team.json)
|
||||
# src/metabase/collections
|
||||
# test/metabase/collections
|
||||
|
||||
# collections.rest (UX West, no :assignee in team.json)
|
||||
# src/metabase/collections_rest
|
||||
# test/metabase/collections_rest
|
||||
|
||||
# comments (UX West, no :assignee in team.json)
|
||||
# src/metabase/comments
|
||||
# test/metabase/comments
|
||||
|
||||
# util.config (DevEx, no :assignee in team.json)
|
||||
# src/metabase/config
|
||||
|
||||
# content-translation (UX West, no :assignee in team.json)
|
||||
# src/metabase/content_translation
|
||||
# test/metabase/content_translation
|
||||
|
||||
# content-verification (UX West, no :assignee in team.json)
|
||||
# src/metabase/content_verification
|
||||
# test/metabase/content_verification
|
||||
|
||||
# explorations.contextual-interestingness (UX West, no :assignee in team.json)
|
||||
# src/metabase/contextual_interestingness
|
||||
# test/metabase/contextual_interestingness
|
||||
|
||||
# core (UX West, no :assignee in team.json)
|
||||
# src/metabase/core
|
||||
# test/metabase/core
|
||||
|
||||
# dashboards (UX West, no :assignee in team.json)
|
||||
# src/metabase/dashboards
|
||||
# test/metabase/dashboards
|
||||
|
||||
# dashboards.rest (UX West, no :assignee in team.json)
|
||||
# src/metabase/dashboards_rest
|
||||
# test/metabase/dashboards_rest
|
||||
|
||||
# data-studio (Gadget, no :assignee in team.json)
|
||||
# src/metabase/data_studio
|
||||
# test/metabase/data_studio
|
||||
|
||||
# database-routing (UX West, no :assignee in team.json)
|
||||
# src/metabase/database_routing
|
||||
|
||||
# documents (UX West, no :assignee in team.json)
|
||||
# src/metabase/documents
|
||||
# test/metabase/documents
|
||||
|
||||
# driver (Querying Platform)
|
||||
src/metabase/driver @metabase/core-backend-querying-platform
|
||||
test/metabase/driver @metabase/core-backend-querying-platform
|
||||
|
||||
# query-processor.driver-api (Querying Platform)
|
||||
src/metabase/driver_api @metabase/core-backend-querying-platform
|
||||
test/metabase/driver_api @metabase/core-backend-querying-platform
|
||||
|
||||
# api.eid-translation (UX West, no :assignee in team.json)
|
||||
# src/metabase/eid_translation
|
||||
# test/metabase/eid_translation
|
||||
|
||||
# embedding (Embedding, no :assignee in team.json)
|
||||
# src/metabase/embedding
|
||||
# test/metabase/embedding
|
||||
|
||||
# embedding-hub (Embedding, no :assignee in team.json)
|
||||
# src/metabase/embedding_hub
|
||||
# test/metabase/embedding_hub
|
||||
|
||||
# embedding.rest (Embedding, no :assignee in team.json)
|
||||
# src/metabase/embedding_rest
|
||||
# test/metabase/embedding_rest
|
||||
|
||||
# search.embeddings (Metabot)
|
||||
src/metabase/embeddings @metabase/metabot
|
||||
test/metabase/embeddings @metabase/metabot
|
||||
|
||||
# search.entity-retrieval (Metabot)
|
||||
src/metabase/entity_retrieval @metabase/metabot
|
||||
test/metabase/entity_retrieval @metabase/metabot
|
||||
|
||||
# events (DevEx, no :assignee in team.json)
|
||||
# src/metabase/events
|
||||
# test/metabase/events
|
||||
|
||||
# explorations (UX West, no :assignee in team.json)
|
||||
# src/metabase/explorations
|
||||
# test/metabase/explorations
|
||||
|
||||
# formatter (UX West, no :assignee in team.json)
|
||||
# src/metabase/formatter
|
||||
# test/metabase/formatter
|
||||
|
||||
# frontend-errors (UX West, no :assignee in team.json)
|
||||
# src/metabase/frontend_errors
|
||||
# test/metabase/frontend_errors
|
||||
|
||||
# geojson (UX West, no :assignee in team.json)
|
||||
# src/metabase/geojson
|
||||
# test/metabase/geojson
|
||||
|
||||
# glossary (Gadget, no :assignee in team.json)
|
||||
# src/metabase/glossary
|
||||
# test/metabase/glossary
|
||||
|
||||
# util.graph (Graphy)
|
||||
src/metabase/graph @metabase/graphy
|
||||
test/metabase/graph @metabase/graphy
|
||||
|
||||
# health-inspector (Querying Platform)
|
||||
src/metabase/health_inspector @metabase/core-backend-querying-platform
|
||||
test/metabase/health_inspector @metabase/core-backend-querying-platform
|
||||
|
||||
# indexed-entities (UX West, no :assignee in team.json)
|
||||
# src/metabase/indexed_entities
|
||||
# test/metabase/indexed_entities
|
||||
|
||||
# transforms.indexes (Gadget, no :assignee in team.json)
|
||||
# src/metabase/indexes
|
||||
# test/metabase/indexes
|
||||
|
||||
# transforms.indexes.rest (Gadget, no :assignee in team.json)
|
||||
# src/metabase/indexes_rest
|
||||
# test/metabase/indexes_rest
|
||||
|
||||
# core.initialization-status (UX West, no :assignee in team.json)
|
||||
# src/metabase/initialization_status
|
||||
|
||||
# interestingness (UX West, no :assignee in team.json)
|
||||
# src/metabase/interestingness
|
||||
# test/metabase/interestingness
|
||||
|
||||
# analytics.internal-stats (UX West, no :assignee in team.json)
|
||||
# src/metabase/internal_stats
|
||||
# test/metabase/internal_stats
|
||||
|
||||
# lib.legacy-mbql (Querying Platform)
|
||||
src/metabase/legacy_mbql @metabase/core-backend-querying-platform
|
||||
test/metabase/legacy_mbql @metabase/core-backend-querying-platform
|
||||
|
||||
# lib (Querying Platform)
|
||||
src/metabase/lib @metabase/core-backend-querying-platform
|
||||
test/metabase/lib @metabase/core-backend-querying-platform
|
||||
|
||||
# lib.metadata (Querying Platform)
|
||||
src/metabase/lib/metadata @metabase/core-backend-querying-platform
|
||||
test/metabase/lib/metadata @metabase/core-backend-querying-platform
|
||||
|
||||
# lib.schema (Querying Platform)
|
||||
src/metabase/lib/schema @metabase/core-backend-querying-platform
|
||||
test/metabase/lib/schema @metabase/core-backend-querying-platform
|
||||
|
||||
# lib.be (Querying Platform)
|
||||
src/metabase/lib_be @metabase/core-backend-querying-platform
|
||||
test/metabase/lib_be @metabase/core-backend-querying-platform
|
||||
|
||||
# lib.metric (UX West, no :assignee in team.json)
|
||||
# src/metabase/lib_metric
|
||||
# test/metabase/lib_metric
|
||||
|
||||
# metabot.llm (Metabot)
|
||||
src/metabase/llm @metabase/metabot
|
||||
test/metabase/llm @metabase/metabot
|
||||
|
||||
# logger (DevEx, no :assignee in team.json)
|
||||
# src/metabase/logger
|
||||
# test/metabase/logger
|
||||
|
||||
# session.login-history (UX West, no :assignee in team.json)
|
||||
# src/metabase/login_history
|
||||
# test/metabase/login_history
|
||||
|
||||
# mcp (Metabot)
|
||||
src/metabase/mcp @metabase/metabot
|
||||
test/metabase/mcp @metabase/metabot
|
||||
|
||||
# measures (Graphy)
|
||||
src/metabase/measures @metabase/graphy
|
||||
test/metabase/measures @metabase/graphy
|
||||
|
||||
# metabot (Metabot)
|
||||
src/metabase/metabot @metabase/metabot
|
||||
test/metabase/metabot @metabase/metabot
|
||||
|
||||
# metrics (UX West, no :assignee in team.json)
|
||||
# src/metabase/metrics
|
||||
# test/metabase/metrics
|
||||
|
||||
# mfa (UX West, no :assignee in team.json)
|
||||
# src/metabase/mfa
|
||||
|
||||
# model-persistence (Graphy)
|
||||
src/metabase/model_persistence @metabase/graphy
|
||||
test/metabase/model_persistence @metabase/graphy
|
||||
|
||||
# models (DevEx, no :assignee in team.json)
|
||||
# src/metabase/models
|
||||
# test/metabase/models
|
||||
|
||||
# mq (UX West, no :assignee in team.json)
|
||||
# src/metabase/mq
|
||||
# test/metabase/mq
|
||||
|
||||
# native-query-snippets (Graphy)
|
||||
src/metabase/native_query_snippets @metabase/graphy
|
||||
test/metabase/native_query_snippets @metabase/graphy
|
||||
|
||||
# notification (Gadget, no :assignee in team.json)
|
||||
# src/metabase/notification
|
||||
# test/metabase/notification
|
||||
|
||||
# mcp.oauth-server (UX West, no :assignee in team.json)
|
||||
# src/metabase/oauth_server
|
||||
# test/metabase/oauth_server
|
||||
|
||||
# osi (Metabot)
|
||||
src/metabase/osi @metabase/metabot
|
||||
test/metabase/osi @metabase/metabot
|
||||
|
||||
# parameters (Graphy)
|
||||
src/metabase/parameters @metabase/graphy
|
||||
test/metabase/parameters @metabase/graphy
|
||||
|
||||
# permissions (UX West, no :assignee in team.json)
|
||||
# src/metabase/permissions
|
||||
# test/metabase/permissions
|
||||
|
||||
# permissions.rest (UX West, no :assignee in team.json)
|
||||
# src/metabase/permissions_rest
|
||||
# test/metabase/permissions_rest
|
||||
|
||||
# query-processor.pivot (UX West, no :assignee in team.json)
|
||||
# src/metabase/pivot
|
||||
# test/metabase/pivot
|
||||
|
||||
# plugins (DevEx, no :assignee in team.json)
|
||||
# src/metabase/plugins
|
||||
# test/metabase/plugins
|
||||
|
||||
# premium-features (UX West, no :assignee in team.json)
|
||||
# src/metabase/premium_features
|
||||
# test/metabase/premium_features
|
||||
|
||||
# product-feedback (UX West, no :assignee in team.json)
|
||||
# src/metabase/product_feedback
|
||||
# test/metabase/product_feedback
|
||||
|
||||
# public-sharing (UX West, no :assignee in team.json)
|
||||
# src/metabase/public_sharing
|
||||
|
||||
# public-sharing.rest (UX West, no :assignee in team.json)
|
||||
# src/metabase/public_sharing_rest
|
||||
# test/metabase/public_sharing_rest
|
||||
|
||||
# pulse (Gadget, no :assignee in team.json)
|
||||
# src/metabase/pulse
|
||||
# test/metabase/pulse
|
||||
|
||||
# queries (Graphy)
|
||||
src/metabase/queries @metabase/graphy
|
||||
test/metabase/queries @metabase/graphy
|
||||
|
||||
# queries.rest (Querying Platform)
|
||||
src/metabase/queries_rest @metabase/core-backend-querying-platform
|
||||
test/metabase/queries_rest @metabase/core-backend-querying-platform
|
||||
|
||||
# query-permissions (UX West, no :assignee in team.json)
|
||||
# src/metabase/query_permissions
|
||||
# test/metabase/query_permissions
|
||||
|
||||
# query-processor (Querying Platform)
|
||||
src/metabase/query_processor @metabase/core-backend-querying-platform
|
||||
test/metabase/query_processor @metabase/core-backend-querying-platform
|
||||
|
||||
# query-processor.cache-backend (Querying Platform)
|
||||
src/metabase/query_processor/middleware/cache_backend @metabase/core-backend-querying-platform
|
||||
test/metabase/query_processor/middleware/cache_backend @metabase/core-backend-querying-platform
|
||||
|
||||
# remote-sync (UX West, no :assignee in team.json)
|
||||
# src/metabase/remote_sync
|
||||
# test/metabase/remote_sync
|
||||
|
||||
# request (UX West, no :assignee in team.json)
|
||||
# src/metabase/request
|
||||
# test/metabase/request
|
||||
|
||||
# revisions (UX West, no :assignee in team.json)
|
||||
# src/metabase/revisions
|
||||
# test/metabase/revisions
|
||||
|
||||
# run-tracking (Gadget, no :assignee in team.json)
|
||||
# src/metabase/run_tracking
|
||||
# test/metabase/run_tracking
|
||||
|
||||
# sample-data (UX West, no :assignee in team.json)
|
||||
# src/metabase/sample_data
|
||||
# test/metabase/sample_data
|
||||
|
||||
# search (UX West, no :assignee in team.json)
|
||||
# src/metabase/search
|
||||
# test/metabase/search
|
||||
|
||||
# warehouses.secrets (Graphy)
|
||||
src/metabase/secrets @metabase/graphy
|
||||
test/metabase/secrets @metabase/graphy
|
||||
|
||||
# security-center (Gadget, no :assignee in team.json)
|
||||
# src/metabase/security_center
|
||||
# test/metabase/security_center
|
||||
|
||||
# segments (Graphy)
|
||||
src/metabase/segments @metabase/graphy
|
||||
test/metabase/segments @metabase/graphy
|
||||
|
||||
# server (UX West, no :assignee in team.json)
|
||||
# src/metabase/server
|
||||
# test/metabase/server
|
||||
|
||||
# session (UX West, no :assignee in team.json)
|
||||
# src/metabase/session
|
||||
# test/metabase/session
|
||||
|
||||
# settings (DevEx, no :assignee in team.json)
|
||||
# src/metabase/settings
|
||||
# test/metabase/settings
|
||||
|
||||
# settings.rest (DevEx, no :assignee in team.json)
|
||||
# src/metabase/settings_rest
|
||||
# test/metabase/settings_rest
|
||||
|
||||
# setup (UX West, no :assignee in team.json)
|
||||
# src/metabase/setup
|
||||
# test/metabase/setup
|
||||
|
||||
# setup.rest (UX West, no :assignee in team.json)
|
||||
# src/metabase/setup_rest
|
||||
# test/metabase/setup_rest
|
||||
|
||||
# metabot.slackbot (Metabot)
|
||||
src/metabase/slackbot @metabase/metabot
|
||||
test/metabase/slackbot @metabase/metabot
|
||||
|
||||
# lib.source-swap (Graphy)
|
||||
src/metabase/source_swap @metabase/graphy
|
||||
test/metabase/source_swap @metabase/graphy
|
||||
|
||||
# sql-tools.parsing (DevEx, no :assignee in team.json)
|
||||
# src/metabase/sql_parsing
|
||||
# test/metabase/sql_parsing
|
||||
|
||||
# sql-tools (DevEx, no :assignee in team.json)
|
||||
# src/metabase/sql_tools
|
||||
# test/metabase/sql_tools
|
||||
|
||||
# sso (UX West, no :assignee in team.json)
|
||||
# src/metabase/sso
|
||||
# test/metabase/sso
|
||||
|
||||
# staleness (UX West, no :assignee in team.json)
|
||||
# src/metabase/staleness
|
||||
# test/metabase/staleness
|
||||
|
||||
# startup (DevEx, no :assignee in team.json)
|
||||
# src/metabase/startup
|
||||
# test/metabase/startup
|
||||
|
||||
# store-api (Cloud, no :assignee in team.json)
|
||||
# src/metabase/store_api
|
||||
|
||||
# sync (Graphy)
|
||||
src/metabase/sync @metabase/graphy
|
||||
test/metabase/sync @metabase/graphy
|
||||
|
||||
# system (UX West, no :assignee in team.json)
|
||||
# src/metabase/system
|
||||
# test/metabase/system
|
||||
|
||||
# task (UX West, no :assignee in team.json)
|
||||
# src/metabase/task
|
||||
# test/metabase/task
|
||||
|
||||
# task.history (UX West, no :assignee in team.json)
|
||||
# src/metabase/task_history
|
||||
# test/metabase/task_history
|
||||
|
||||
# users.tenants (Admin Webapp, no :assignee in team.json)
|
||||
# src/metabase/tenants
|
||||
# test/metabase/tenants
|
||||
|
||||
# testing-api (DevEx, no :assignee in team.json)
|
||||
# src/metabase/testing_api
|
||||
# test/metabase/testing_api
|
||||
|
||||
# tiles (UX West, no :assignee in team.json)
|
||||
# src/metabase/tiles
|
||||
# test/metabase/tiles
|
||||
|
||||
# timeline (UX West, no :assignee in team.json)
|
||||
# src/metabase/timeline
|
||||
# test/metabase/timeline
|
||||
|
||||
# tracing (DevEx, no :assignee in team.json)
|
||||
# src/metabase/tracing
|
||||
# test/metabase/tracing
|
||||
|
||||
# transforms (Graphy)
|
||||
src/metabase/transforms @metabase/graphy
|
||||
test/metabase/transforms @metabase/graphy
|
||||
|
||||
# transforms.base (Graphy)
|
||||
src/metabase/transforms_base @metabase/graphy
|
||||
test/metabase/transforms_base @metabase/graphy
|
||||
|
||||
# transforms.inspector (Gadget, no :assignee in team.json)
|
||||
# src/metabase/transforms_inspector
|
||||
# test/metabase/transforms_inspector
|
||||
|
||||
# transforms.rest (Gadget, no :assignee in team.json)
|
||||
# src/metabase/transforms_rest
|
||||
# test/metabase/transforms_rest
|
||||
|
||||
# typed-schemas (Embedding, no :assignee in team.json)
|
||||
# src/metabase/typed_schemas
|
||||
# test/metabase/typed_schemas
|
||||
|
||||
# typed-schemas.rest (Embedding, no :assignee in team.json)
|
||||
# src/metabase/typed_schemas_rest
|
||||
# test/metabase/typed_schemas_rest
|
||||
|
||||
# lib.types (Querying Platform)
|
||||
src/metabase/types @metabase/core-backend-querying-platform
|
||||
test/metabase/types @metabase/core-backend-querying-platform
|
||||
|
||||
# upload (Graphy)
|
||||
src/metabase/upload @metabase/graphy
|
||||
test/metabase/upload @metabase/graphy
|
||||
|
||||
# usage-metadata (UX West, no :assignee in team.json)
|
||||
# src/metabase/usage_metadata
|
||||
# test/metabase/usage_metadata
|
||||
|
||||
# user-key-value (UX West, no :assignee in team.json)
|
||||
# src/metabase/user_key_value
|
||||
# test/metabase/user_key_value
|
||||
|
||||
# users (UX West, no :assignee in team.json)
|
||||
# src/metabase/users
|
||||
# test/metabase/users
|
||||
|
||||
# users.rest (UX West, no :assignee in team.json)
|
||||
# src/metabase/users_rest
|
||||
# test/metabase/users_rest
|
||||
|
||||
# util (DevEx, no :assignee in team.json)
|
||||
# src/metabase/util
|
||||
# test/metabase/util
|
||||
|
||||
# core.version (UX West, no :assignee in team.json)
|
||||
# src/metabase/version
|
||||
# test/metabase/version
|
||||
|
||||
# audit-app.view-log (UX West, no :assignee in team.json)
|
||||
# src/metabase/view_log
|
||||
# test/metabase/view_log
|
||||
|
||||
# warehouses.schema (UX West, no :assignee in team.json)
|
||||
# src/metabase/warehouse_schema
|
||||
# test/metabase/warehouse_schema
|
||||
|
||||
# warehouse-schema-overlay (UX West, no :assignee in team.json)
|
||||
# src/metabase/warehouse_schema_overlay
|
||||
# test/metabase/warehouse_schema_overlay
|
||||
|
||||
# warehouses.schema.rest (UX West, no :assignee in team.json)
|
||||
# src/metabase/warehouse_schema_rest
|
||||
# test/metabase/warehouse_schema_rest
|
||||
|
||||
# warehouses (UX West, no :assignee in team.json)
|
||||
# src/metabase/warehouses
|
||||
# test/metabase/warehouses
|
||||
|
||||
# warehouses.rest (UX West, no :assignee in team.json)
|
||||
# src/metabase/warehouses_rest
|
||||
# test/metabase/warehouses_rest
|
||||
|
||||
# xrays (Graphy)
|
||||
src/metabase/xrays @metabase/graphy
|
||||
test/metabase/xrays @metabase/graphy
|
||||
|
||||
# ===== END generated module ownership =====
|
||||
|
||||
6
.github/actionlint.yaml
vendored
6
.github/actionlint.yaml
vendored
@@ -8,12 +8,6 @@ paths:
|
||||
# GitHub added parallel steps (`parallel`, `background`, `wait`, `wait-all`,
|
||||
# `cancel`) in June 2026, and actionlint 1.7.12 does not know the schema yet:
|
||||
# https://github.blog/changelog/2026-06-25-actions-steps-can-now-be-run-in-parallel/
|
||||
.github/workflows/backend.yml:
|
||||
ignore:
|
||||
- 'step must run script with "run" section or run action with "uses" section'
|
||||
.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'
|
||||
|
||||
12
.github/actions/collect-profile/action.yml
vendored
12
.github/actions/collect-profile/action.yml
vendored
@@ -18,10 +18,6 @@ runs:
|
||||
steps:
|
||||
# Unlike a JUnit failure report, these run on a green build too — a suite that passes slowly is
|
||||
# exactly what profiling is for.
|
||||
#
|
||||
# `awk 'NR<=25'` rather than `head -25`: the runner ignores SIGPIPE, so `head` exiting early
|
||||
# leaves `sort` with an EPIPE write error and `pipefail` fails the step. `|| true` covers `grep`
|
||||
# finding no junit files.
|
||||
- name: Summarize test wall times
|
||||
if: ${{ inputs.profiled == 'true' }}
|
||||
continue-on-error: true
|
||||
@@ -32,21 +28,18 @@ runs:
|
||||
echo '```'
|
||||
grep -h '<testsuite' target/junit/*.xml 2>/dev/null \
|
||||
| sed -E 's|.*<testsuite name="([^"]*)" time="([^"]*)".*|\2 \1|' \
|
||||
| sort -rn | awk 'NR<=25' || true
|
||||
| sort -rn | head -25
|
||||
echo '```'
|
||||
echo "### Slowest tests (seconds)"
|
||||
echo '```'
|
||||
grep -h '<testcase' target/junit/*.xml 2>/dev/null \
|
||||
| sed -E 's|.*classname="([^"]*)" name="([^"]*)" time="([^"]*)".*|\3 \1/\2|' \
|
||||
| sort -rn | awk 'NR<=25' || true
|
||||
| sort -rn | head -25
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# Only the rendered flamegraph is kept: the raw collapsed stacks it was built from run to
|
||||
# hundreds of MB on a long suite, and are reproducible locally from the same test selection.
|
||||
#
|
||||
# The info-level test log rides along on a green profiled run too. The failure-only log artifact
|
||||
# the test jobs upload is not written when nothing failed, which is exactly the run being profiled.
|
||||
- name: Upload profile
|
||||
if: ${{ inputs.profiled == 'true' }}
|
||||
continue-on-error: true
|
||||
@@ -56,6 +49,5 @@ runs:
|
||||
path: |
|
||||
target/clj-async-profiler/results/*flamegraph.html
|
||||
target/junit/*.xml
|
||||
logs/test-log.json
|
||||
retention-days: 14
|
||||
if-no-files-found: warn
|
||||
|
||||
27
.github/actions/create-backport/action.yml
vendored
27
.github/actions/create-backport/action.yml
vendored
@@ -25,8 +25,6 @@ runs:
|
||||
COMMIT: ${{ inputs.backport-commit }}
|
||||
shell: bash
|
||||
run: | # bash
|
||||
source "${{ github.action_path }}/kondo-ratchets.sh"
|
||||
|
||||
git config --global user.email "metabase-bot@metabase.com"
|
||||
git config --global user.name "Metabase bot"
|
||||
|
||||
@@ -36,7 +34,7 @@ runs:
|
||||
git checkout ${TARGET_BRANCH}
|
||||
git fetch --all
|
||||
git checkout -b ${BACKPORT_BRANCH}
|
||||
cherry_pick_backport "${COMMIT}"
|
||||
git cherry-pick ${COMMIT} || true
|
||||
|
||||
CONFLICT_LIST=$(git ls-files -u)
|
||||
|
||||
@@ -48,7 +46,10 @@ runs:
|
||||
echo "has-conflicts=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Add a shell script for resolving conflicts
|
||||
write_backport_script "${COMMIT}"
|
||||
echo "git reset HEAD~1" > ./backport.sh
|
||||
echo "rm ./backport.sh" >> ./backport.sh
|
||||
echo "git cherry-pick ${COMMIT}" >> ./backport.sh
|
||||
echo "echo 'Resolve conflicts and force push this branch.\n\nTo backport translations run: bin/i18n/merge-translations <release-branch>'" >> ./backport.sh
|
||||
chmod +x ./backport.sh
|
||||
git add ./backport.sh
|
||||
git commit -m "Add backport resolution script"
|
||||
@@ -72,7 +73,7 @@ runs:
|
||||
|
||||
git push -u origin ${BACKPORT_BRANCH}
|
||||
|
||||
PR_ARGS=(--base "${TARGET_BRANCH}" --head "${BACKPORT_BRANCH}" --label "was-backported" --assignee "${{ github.event.pull_request.user.login }}" --title "🤖 v${TARGET_VERSION} backported \"${ORIGINAL_TITLE}\"" --body "${PR_BODY}")
|
||||
PR_ARGS=(--base "${TARGET_BRANCH}" --head "${BACKPORT_BRANCH}" --label "was-backported" --assignee "${{ github.event.pull_request.user.login }}" --title "🤖 backported \"${ORIGINAL_TITLE}\"" --body "${PR_BODY}")
|
||||
if [ "$CONFLICTS" -gt 0 ]; then
|
||||
PR_ARGS+=(--draft)
|
||||
fi
|
||||
@@ -85,15 +86,19 @@ runs:
|
||||
|
||||
git checkout ${{ github.sha }}
|
||||
|
||||
- name: Approve and Merge the PR
|
||||
- name: Auto approve backport PR
|
||||
if: ${{ steps.create_backport_pull_request.outputs.has-conflicts == 'false' }}
|
||||
run: |
|
||||
gh pr review --approve "$PR_NUMBER"
|
||||
gh pr merge --squash --auto "$PR_NUMBER"
|
||||
uses: juliangruber/approve-pull-request-action@68fcc9a5a73b5641cadf757cf99d73720dcb05d0 # v2.1.0
|
||||
with:
|
||||
github-token: ${{ inputs.github-token-2 }}
|
||||
number: ${{ steps.create_backport_pull_request.outputs.backport_pr_number }}
|
||||
|
||||
- name: Enable Pull Request Automerge
|
||||
if: ${{ steps.create_backport_pull_request.outputs.has-conflicts == 'false' }}
|
||||
run: gh pr merge --squash --auto "$PR_NUMBER"
|
||||
shell: bash
|
||||
env:
|
||||
# uses a different token than the token that created the PR so we can approve and merge it
|
||||
GH_TOKEN: ${{ inputs.github-token-2 }}
|
||||
GH_TOKEN: ${{ inputs.github-token }}
|
||||
PR_NUMBER: ${{ steps.create_backport_pull_request.outputs.backport_pr_number }}
|
||||
|
||||
- uses: ./.github/actions/notify-pull-request
|
||||
|
||||
@@ -1,375 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
action_dir=$(cd "$(dirname "$0")" && pwd)
|
||||
cut_release_workflow="$action_dir/../../workflows/cut-release-branch.yml"
|
||||
|
||||
# shellcheck source=.github/actions/create-backport/kondo-ratchets.sh
|
||||
source "$action_dir/kondo-ratchets.sh"
|
||||
|
||||
# Any editor launch fails the run, so a continuation that waits on an editor cannot pass.
|
||||
export GIT_EDITOR=false
|
||||
|
||||
test_root=
|
||||
trap '[ -z "$test_root" ] || rm -rf -- "$test_root"' EXIT
|
||||
trap 'exit 129' HUP
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
test_root=$(mktemp -d)
|
||||
|
||||
active_ratchets='{:ignore-counts {:example 1}}'
|
||||
disabled_ratchets=';; Kondo ignore ratchets apply only to master; this release branch opts out.
|
||||
;; The test and fixer recognize this explicit opt-out.
|
||||
{:disabled true}'
|
||||
|
||||
# Coverage:
|
||||
#
|
||||
# Target state Incoming change Other conflict Expected result
|
||||
# ----------------- -------------------- --------------- ------------------------------------------
|
||||
# active ratchets unrelated file none leave ratchets unchanged
|
||||
# active ratchets ratchets none install the release opt-out
|
||||
# disabled ratchets ratchets none keep the opt-out; finish with an empty commit
|
||||
# divergent ratchets ratchets none resolve ratchets and finish the cherry-pick
|
||||
# divergent ratchets ratchets (on a pty) none finish without opening an editor
|
||||
# active ratchets unrelated file app.txt leave the conflict for manual resolution
|
||||
# divergent ratchets ratchets + app.txt app.txt resolve only ratchets
|
||||
# release cut ratchets none cut and backport commit the same opt-out
|
||||
# n/a cut-release workflow n/a the workflow calls the shared writer
|
||||
# any invalid commit n/a propagate the cherry-pick failure
|
||||
# divergent ratchets generated backport.sh app.txt resolve only ratchets through the manual script
|
||||
#
|
||||
# Known gaps: an older target with no ratchets file, and an incoming commit that
|
||||
# deletes the ratchets file.
|
||||
|
||||
test_unrelated_backport_preserves_target_ratchets() (
|
||||
local repo="$test_root/unrelated"
|
||||
init_repo "$repo"
|
||||
local commit
|
||||
commit=$(feature_commit "$repo" change_app)
|
||||
git -C "$repo" checkout -q release
|
||||
|
||||
cd "$repo"
|
||||
cherry_pick_backport "$commit"
|
||||
|
||||
assert_eq feature "$(cat app.txt)" "the unrelated change was not cherry-picked"
|
||||
assert_ratchets "$active_ratchets"
|
||||
assert_cherry_pick_completed
|
||||
assert_worktree_clean
|
||||
)
|
||||
|
||||
test_clean_ratchet_backport_installs_release_opt_out() (
|
||||
local repo="$test_root/clean-ratchet"
|
||||
init_repo "$repo"
|
||||
local commit
|
||||
commit=$(feature_commit "$repo" change_ratchets)
|
||||
git -C "$repo" checkout -q release
|
||||
|
||||
cd "$repo"
|
||||
cherry_pick_backport "$commit"
|
||||
|
||||
assert_ratchets_disabled
|
||||
assert_cherry_pick_completed
|
||||
assert_worktree_clean
|
||||
)
|
||||
|
||||
test_disabled_release_absorbs_ratchet_only_backport() (
|
||||
local repo="$test_root/disabled-release"
|
||||
init_repo "$repo"
|
||||
local commit
|
||||
commit=$(feature_commit "$repo" change_ratchets)
|
||||
git -C "$repo" checkout -q release
|
||||
commit_release_change "$repo" disable_release_ratchets "Disable release ratchets"
|
||||
|
||||
cd "$repo"
|
||||
cherry_pick_backport "$commit"
|
||||
|
||||
assert_ratchets_disabled
|
||||
assert_cherry_pick_completed
|
||||
assert_empty_commit
|
||||
assert_worktree_clean
|
||||
)
|
||||
|
||||
test_ratchet_only_conflict_is_resolved() (
|
||||
local repo="$test_root/ratchet-conflict"
|
||||
init_repo "$repo"
|
||||
local commit
|
||||
commit=$(conflicting_ratchet_commit "$repo")
|
||||
|
||||
cd "$repo"
|
||||
cherry_pick_backport "$commit"
|
||||
|
||||
assert_ratchets_disabled
|
||||
assert_cherry_pick_completed
|
||||
assert_worktree_clean
|
||||
)
|
||||
|
||||
test_continuation_never_opens_an_editor() (
|
||||
local repo="$test_root/editor"
|
||||
init_repo "$repo"
|
||||
local commit
|
||||
commit=$(conflicting_ratchet_commit "$repo")
|
||||
|
||||
cd "$repo"
|
||||
# Git only opens an editor for the continuation when stdin is a terminal, so run it on a pty. The
|
||||
# exported GIT_EDITOR=false turns any editor launch into a failure. The single quotes are deliberate:
|
||||
# $1 and $2 are for the inner bash, not this shell.
|
||||
# shellcheck disable=SC2016
|
||||
python3 -c 'import os, pty, sys; sys.exit(os.waitstatus_to_exitcode(pty.spawn(sys.argv[1:])))' \
|
||||
bash -c 'source "$1/kondo-ratchets.sh" && cherry_pick_backport "$2"' bash "$action_dir" "$commit" >/dev/null ||
|
||||
fail "the cherry-pick continuation opened an editor"
|
||||
|
||||
assert_ratchets_disabled
|
||||
assert_cherry_pick_completed
|
||||
)
|
||||
|
||||
test_non_ratchet_conflict_remains_manual() (
|
||||
local repo="$test_root/non-ratchet-conflict"
|
||||
init_repo "$repo"
|
||||
local commit
|
||||
commit=$(feature_commit "$repo" change_app)
|
||||
git -C "$repo" checkout -q release
|
||||
commit_release_change "$repo" change_release_app "Change release app"
|
||||
|
||||
cd "$repo"
|
||||
cherry_pick_backport "$commit"
|
||||
|
||||
assert_ratchets "$active_ratchets"
|
||||
assert_only_conflicts app.txt
|
||||
assert_cherry_pick_pending
|
||||
)
|
||||
|
||||
test_mixed_conflict_resolves_only_ratchets() (
|
||||
local repo="$test_root/mixed-conflict"
|
||||
init_repo "$repo"
|
||||
local commit
|
||||
commit=$(feature_commit "$repo" change_app_and_ratchets)
|
||||
git -C "$repo" checkout -q release
|
||||
commit_release_change "$repo" change_release_app_and_ratchets "Change release app and ratchets"
|
||||
|
||||
cd "$repo"
|
||||
cherry_pick_backport "$commit"
|
||||
|
||||
assert_ratchets_disabled
|
||||
assert_only_conflicts app.txt
|
||||
assert_cherry_pick_pending
|
||||
)
|
||||
|
||||
test_release_cut_and_backport_commit_the_same_opt_out() (
|
||||
local cut_repo="$test_root/release-cut"
|
||||
init_repo "$cut_repo"
|
||||
cd "$cut_repo"
|
||||
git checkout -qb release-x.99.x
|
||||
write_disabled_ratchets
|
||||
git add --sparse .clj-kondo/ratchets.edn
|
||||
git commit -q --allow-empty --no-verify -m "Cut release-x.99.x branch"
|
||||
git show HEAD:.clj-kondo/ratchets.edn > "$test_root/release-cut.edn"
|
||||
|
||||
local backport_repo="$test_root/release-cut-backport"
|
||||
init_repo "$backport_repo"
|
||||
local commit
|
||||
commit=$(feature_commit "$backport_repo" change_ratchets)
|
||||
git -C "$backport_repo" checkout -q release
|
||||
cd "$backport_repo"
|
||||
cherry_pick_backport "$commit"
|
||||
git show HEAD:.clj-kondo/ratchets.edn > "$test_root/backport.edn"
|
||||
|
||||
cmp -s "$test_root/release-cut.edn" "$test_root/backport.edn" ||
|
||||
fail "the release cut and the backport committed different opt-out files"
|
||||
assert_eq "$disabled_ratchets" "$(cat "$test_root/release-cut.edn")" \
|
||||
"the release cut did not install the expected opt-out"
|
||||
)
|
||||
|
||||
test_cut_release_workflow_uses_the_shared_writer() (
|
||||
grep -Fq 'source .github/actions/create-backport/kondo-ratchets.sh' "$cut_release_workflow" ||
|
||||
fail "the cut-release workflow does not source kondo-ratchets.sh"
|
||||
grep -Fq 'write_disabled_ratchets' "$cut_release_workflow" ||
|
||||
fail "the cut-release workflow does not call write_disabled_ratchets"
|
||||
! grep -Fq ':disabled' "$cut_release_workflow" ||
|
||||
fail "the cut-release workflow carries its own copy of the opt-out"
|
||||
)
|
||||
|
||||
test_invalid_commit_failure_is_propagated() (
|
||||
local repo="$test_root/invalid"
|
||||
init_repo "$repo"
|
||||
cd "$repo"
|
||||
|
||||
if cherry_pick_backport deadbeef; then
|
||||
fail "an invalid commit succeeded"
|
||||
fi
|
||||
)
|
||||
|
||||
test_generated_script_resolves_ratchets_end_to_end() (
|
||||
local repo="$test_root/generated-script"
|
||||
init_repo "$repo"
|
||||
local commit
|
||||
commit=$(feature_commit "$repo" change_app_and_ratchets)
|
||||
git -C "$repo" checkout -q release
|
||||
commit_release_change "$repo" change_release_app_and_ratchets "Change release app and ratchets"
|
||||
|
||||
cd "$repo"
|
||||
cherry_pick_backport "$commit"
|
||||
git cherry-pick --abort
|
||||
|
||||
local release_commit
|
||||
release_commit=$(git rev-parse HEAD)
|
||||
write_backport_script "$commit"
|
||||
chmod +x backport.sh
|
||||
git add backport.sh
|
||||
git commit -qm "Add manual backport script"
|
||||
|
||||
bash -n backport.sh
|
||||
grep -Fq 'write_disabled_ratchets ()' backport.sh || fail "the generated script omitted write_disabled_ratchets"
|
||||
./backport.sh
|
||||
|
||||
assert_ratchets_disabled
|
||||
assert_only_conflicts app.txt
|
||||
assert_cherry_pick_pending
|
||||
assert_eq "$release_commit" "$(git rev-parse HEAD)" "the generated script did not return HEAD to the release commit"
|
||||
[ ! -e backport.sh ] || fail "the generated script did not remove itself"
|
||||
! git ls-files --error-unmatch backport.sh >/dev/null 2>&1 || fail "the generated script is still tracked"
|
||||
! git status --porcelain | grep -q backport.sh || fail "the generated script left a status entry"
|
||||
)
|
||||
|
||||
# Test harness
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
assert_eq() {
|
||||
local expected=$1
|
||||
local actual=$2
|
||||
local message=$3
|
||||
[ "$expected" = "$actual" ] || fail "$message (expected '$expected', got '$actual')"
|
||||
}
|
||||
|
||||
assert_ratchets() {
|
||||
assert_eq "$1" "$(cat .clj-kondo/ratchets.edn)" "unexpected ratchet state"
|
||||
}
|
||||
|
||||
assert_ratchets_disabled() {
|
||||
assert_ratchets "$disabled_ratchets"
|
||||
}
|
||||
|
||||
assert_cherry_pick_completed() {
|
||||
! git rev-parse -q --verify CHERRY_PICK_HEAD >/dev/null || fail "the cherry-pick is still pending"
|
||||
}
|
||||
|
||||
assert_cherry_pick_pending() {
|
||||
git rev-parse -q --verify CHERRY_PICK_HEAD >/dev/null || fail "the cherry-pick state was lost"
|
||||
}
|
||||
|
||||
assert_only_conflicts() {
|
||||
local expected
|
||||
local actual
|
||||
expected=$(printf '%s\n' "$@" | sort)
|
||||
actual=$(git diff --name-only --diff-filter=U | sort)
|
||||
assert_eq "$expected" "$actual" "unexpected unresolved files"
|
||||
}
|
||||
|
||||
assert_empty_commit() {
|
||||
git diff --quiet HEAD^ HEAD || fail "the backport commit was not empty"
|
||||
}
|
||||
|
||||
assert_worktree_clean() {
|
||||
[ -z "$(git status --porcelain)" ] || fail "the backport left worktree changes"
|
||||
}
|
||||
|
||||
init_repo() {
|
||||
local repo=$1
|
||||
git init -q -b release "$repo"
|
||||
git -C "$repo" config user.email test@example.com
|
||||
git -C "$repo" config user.name "Backport test"
|
||||
printf 'base\n' > "$repo/app.txt"
|
||||
mkdir -p "$repo/.clj-kondo"
|
||||
printf '%s\n' "$active_ratchets" > "$repo/.clj-kondo/ratchets.edn"
|
||||
git -C "$repo" add .
|
||||
git -C "$repo" commit -qm "Base"
|
||||
}
|
||||
|
||||
feature_commit() {
|
||||
local repo=$1
|
||||
shift
|
||||
git -C "$repo" checkout -qb feature
|
||||
"$@" "$repo"
|
||||
git -C "$repo" add .
|
||||
git -C "$repo" commit -qm "Feature"
|
||||
git -C "$repo" rev-parse HEAD
|
||||
}
|
||||
|
||||
# Sets up a release branch whose ratchets conflict with the feature commit, and prints that commit.
|
||||
conflicting_ratchet_commit() {
|
||||
local repo=$1
|
||||
local commit
|
||||
commit=$(feature_commit "$repo" change_ratchets)
|
||||
git -C "$repo" checkout -q release
|
||||
commit_release_change "$repo" change_release_ratchets "Change release ratchets"
|
||||
echo "$commit"
|
||||
}
|
||||
|
||||
change_app() {
|
||||
printf 'feature\n' > "$1/app.txt"
|
||||
}
|
||||
|
||||
change_ratchets() {
|
||||
printf '{:ignore-counts {:example 2}}\n' > "$1/.clj-kondo/ratchets.edn"
|
||||
}
|
||||
|
||||
change_app_and_ratchets() {
|
||||
change_app "$1"
|
||||
change_ratchets "$1"
|
||||
}
|
||||
|
||||
change_release_app() {
|
||||
printf 'release\n' > "$1/app.txt"
|
||||
}
|
||||
|
||||
change_release_ratchets() {
|
||||
printf '{:ignore-counts {:release 1}}\n' > "$1/.clj-kondo/ratchets.edn"
|
||||
}
|
||||
|
||||
change_release_app_and_ratchets() {
|
||||
change_release_app "$1"
|
||||
change_release_ratchets "$1"
|
||||
}
|
||||
|
||||
# Setup writes the opt-out through the shared writer; the only copy is the expected text at the top.
|
||||
disable_release_ratchets() {
|
||||
(cd "$1" && write_disabled_ratchets)
|
||||
}
|
||||
|
||||
commit_release_change() {
|
||||
local repo=$1
|
||||
local change=$2
|
||||
local message=$3
|
||||
"$change" "$repo"
|
||||
git -C "$repo" add .
|
||||
git -C "$repo" commit -qm "$message"
|
||||
}
|
||||
|
||||
run_test() {
|
||||
local description=$1
|
||||
local test=${description// /_}
|
||||
test=${test//-/_}
|
||||
test="test_$test"
|
||||
|
||||
declare -F "$test" >/dev/null || fail "missing test function: $test"
|
||||
|
||||
printf 'TEST: %s\n' "$description"
|
||||
"$test"
|
||||
printf 'PASS: %s\n' "$description"
|
||||
}
|
||||
|
||||
run_test "unrelated backport preserves target ratchets"
|
||||
run_test "clean ratchet backport installs release opt-out"
|
||||
run_test "disabled release absorbs ratchet-only backport"
|
||||
run_test "ratchet-only conflict is resolved"
|
||||
run_test "continuation never opens an editor"
|
||||
run_test "non-ratchet conflict remains manual"
|
||||
run_test "mixed conflict resolves only ratchets"
|
||||
run_test "release cut and backport commit the same opt-out"
|
||||
run_test "cut-release workflow uses the shared writer"
|
||||
run_test "invalid commit failure is propagated"
|
||||
run_test "generated script resolves ratchets end-to-end"
|
||||
@@ -1,74 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# The only place that knows what a release branch's opt-out looks like. The cut-release workflow, the
|
||||
# backport helper, and the tests all go through this function.
|
||||
write_disabled_ratchets() {
|
||||
mkdir -p .clj-kondo
|
||||
printf '%s\n' \
|
||||
';; Kondo ignore ratchets apply only to master; this release branch opts out.' \
|
||||
';; The test and fixer recognize this explicit opt-out.' \
|
||||
'{:disabled true}' \
|
||||
> .clj-kondo/ratchets.edn
|
||||
}
|
||||
|
||||
disable_ratchets() {
|
||||
local commit=$1
|
||||
local file=.clj-kondo/ratchets.edn
|
||||
[ -n "$(git diff-tree --root --no-commit-id --name-only -r "$commit" -- "$file")" ] || return 0
|
||||
|
||||
echo "$file: disabling kondo ratchets on the release branch"
|
||||
write_disabled_ratchets
|
||||
git add -- "$file"
|
||||
|
||||
if ! git rev-parse -q --verify CHERRY_PICK_HEAD >/dev/null; then
|
||||
if ! git diff --cached --quiet HEAD; then
|
||||
git commit --amend --no-edit
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ -z "$(git ls-files -u)" ]; then
|
||||
echo "$file was the only conflict, finishing the cherry-pick"
|
||||
if git diff --cached --quiet HEAD; then
|
||||
echo "nothing left to apply, committing the backport empty"
|
||||
git commit --allow-empty --no-edit
|
||||
else
|
||||
# GIT_EDITOR outranks core.editor, VISUAL, and EDITOR, so the continuation can never wait on
|
||||
# an editor; the repository's own editor configuration is left alone.
|
||||
GIT_EDITOR=true git cherry-pick --continue
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
cherry_pick_backport() {
|
||||
local commit=$1
|
||||
local status=0
|
||||
git cherry-pick "$commit" || status=$?
|
||||
if [ "$status" -ne 0 ] && ! git rev-parse -q --verify CHERRY_PICK_HEAD >/dev/null; then
|
||||
echo "cherry-pick failed without entering a resolvable conflict state" >&2
|
||||
return "$status"
|
||||
fi
|
||||
|
||||
disable_ratchets "$commit"
|
||||
if [ "$status" -ne 0 ] &&
|
||||
git rev-parse -q --verify CHERRY_PICK_HEAD >/dev/null &&
|
||||
[ -z "$(git ls-files -u)" ]; then
|
||||
echo "cherry-pick stopped without a file conflict to resolve" >&2
|
||||
return "$status"
|
||||
fi
|
||||
}
|
||||
|
||||
write_backport_script() {
|
||||
local commit=$1
|
||||
{
|
||||
echo "#!/usr/bin/env bash"
|
||||
echo "set -euo pipefail"
|
||||
echo "git reset HEAD~1"
|
||||
echo "rm ./backport.sh"
|
||||
declare -f write_disabled_ratchets disable_ratchets cherry_pick_backport
|
||||
printf 'cherry_pick_backport %q\n' "$commit"
|
||||
printf '%s\n' "printf 'Resolve conflicts and force push this branch.\\n\\nTo backport translations run: bin/i18n/merge-translations <release-branch>\\n'"
|
||||
} > ./backport.sh
|
||||
}
|
||||
47
.github/actions/create-test-plan/action.yml
vendored
47
.github/actions/create-test-plan/action.yml
vendored
@@ -1,5 +1,5 @@
|
||||
name: Create test plan
|
||||
description: Compute the affected-tests plan for the changed files.
|
||||
description: Cruise the frontend import graph and compute the affected-tests plan.
|
||||
|
||||
inputs:
|
||||
changed-files:
|
||||
@@ -28,15 +28,21 @@ outputs:
|
||||
stats:
|
||||
description: FE Affected Tests stats row (JSON).
|
||||
value: ${{ steps.compute.outputs.stats }}
|
||||
artifact-id:
|
||||
description: Artifact containing test-plan.json for this run.
|
||||
value: ${{ steps.upload.outputs.artifact-id }}
|
||||
fe_unit_specs_to_run:
|
||||
description: Unit specs to run (JSON array).
|
||||
value: ${{ steps.compute.outputs.fe_unit_specs_to_run }}
|
||||
loki_stories_to_run:
|
||||
description: Loki stories to run (JSON array).
|
||||
value: ${{ steps.compute.outputs.loki_stories_to_run }}
|
||||
e2e_specs_to_run:
|
||||
description: E2E specs to run (JSON array).
|
||||
value: ${{ steps.compute.outputs.e2e_specs_to_run }}
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Prepare front-end environment
|
||||
uses: ./.github/actions/prepare-frontend
|
||||
- name: Prepare Node + Bun
|
||||
uses: ./.github/actions/prepare-node-bun
|
||||
- name: Resolve closest-ancestor e2e coverage manifest
|
||||
id: manifest
|
||||
uses: actions/github-script@v9
|
||||
@@ -55,6 +61,16 @@ runs:
|
||||
headSha: context.payload.pull_request?.head?.sha ?? context.sha,
|
||||
});
|
||||
core.setOutput("path", result?.path ?? "");
|
||||
- name: Build dependency graph
|
||||
shell: bash
|
||||
run: |
|
||||
# Quiet on success; surface output only if install fails.
|
||||
bun install --frozen-lockfile > /tmp/bun-install.log 2>&1 \
|
||||
|| { echo "bun install issues (continuing):"; tail -20 /tmp/bun-install.log; }
|
||||
bunx depcruise frontend/src enterprise/frontend/src \
|
||||
--config .dependency-cruiser.cjs \
|
||||
--output-type json > dependency-graph.json || true
|
||||
|
||||
- name: Create test plan
|
||||
id: compute
|
||||
shell: bash
|
||||
@@ -66,20 +82,15 @@ runs:
|
||||
LOKI_INFRA_TOUCHED: ${{ inputs.loki-infra-touched }}
|
||||
E2E_INFRA_TOUCHED: ${{ inputs.e2e-infra-touched }}
|
||||
SHARED_SOURCES_TOUCHED: ${{ inputs.shared-sources-touched }}
|
||||
DEP_GRAPH_JSON: dependency-graph.json
|
||||
E2E_SPEC_MANIFEST: ${{ steps.manifest.outputs.path }}
|
||||
run: |
|
||||
# stdout is the plan JSON; the planner's own info logs go to stderr.
|
||||
# The planner writes this step's GITHUB_OUTPUT entries.
|
||||
bun .github/scripts/create-test-plan.ts > test-plan.json
|
||||
|
||||
- name: Upload test plan
|
||||
id: upload
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: test-plan-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: test-plan.json
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
echo "stats=$(jq -c '.stats' test-plan.json)" >> "$GITHUB_OUTPUT"
|
||||
echo "fe_unit_specs_to_run=$(jq -c '.fe_unit_specs_to_run' test-plan.json)" >> "$GITHUB_OUTPUT"
|
||||
echo "loki_stories_to_run=$(jq -c '.loki_stories_to_run' test-plan.json)" >> "$GITHUB_OUTPUT"
|
||||
echo "e2e_specs_to_run=$(jq -c '.e2e_specs_to_run' test-plan.json)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Report test plan
|
||||
shell: bash
|
||||
@@ -99,10 +110,6 @@ runs:
|
||||
echo
|
||||
echo "Changed: $(jq '.stats.fe_files_changed' "$plan") FE / $(jq '.stats.be_files_changed' "$plan") BE file(s) · $(jq '.stats.fe_modules_affected_usage' "$plan") FE module(s) affected"
|
||||
echo
|
||||
if [ "$(jq '.stats.loki_preview_touched' "$plan")" = "true" ]; then
|
||||
echo "**Loki: full run**: a file the Storybook preview imports changed."
|
||||
echo
|
||||
fi
|
||||
e2e_run=$(jq '.stats.e2e_specs_to_run_usage' "$plan")
|
||||
e2e_all=$(jq '.stats.e2e_specs_all' "$plan")
|
||||
if [ "$e2e_run" -eq "$e2e_all" ]; then
|
||||
|
||||
@@ -15,10 +15,6 @@ inputs:
|
||||
description: OpenLDAP container
|
||||
required: true
|
||||
default: false
|
||||
mocksaml:
|
||||
description: Mock SAML container
|
||||
required: true
|
||||
default: false
|
||||
postgres:
|
||||
description: Postgres container
|
||||
required: true
|
||||
@@ -77,7 +73,6 @@ 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)
|
||||
|
||||
@@ -98,8 +93,8 @@ runs:
|
||||
|
||||
if ${{ inputs.maildev }}; then
|
||||
echo -e "${Y}checking maildev container..." &&
|
||||
while ! nc -z localhost 1180; do sleep 1; done &&
|
||||
while ! nc -z localhost 1125; do sleep 1; done &&
|
||||
while ! nc -z localhost 1080; do sleep 1; done &&
|
||||
while ! nc -z localhost 1025; do sleep 1; done &&
|
||||
echo -e "${G}Maildev is up and running!"
|
||||
|
||||
echo -e "${Y}checking maildev-ssl container..." &&
|
||||
@@ -114,12 +109,6 @@ 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 &&
|
||||
|
||||
29
.github/actions/fetch-artifact/action.yml
vendored
29
.github/actions/fetch-artifact/action.yml
vendored
@@ -20,15 +20,6 @@ inputs:
|
||||
description: Directory path where the jar should be extracted (defaults to current directory)
|
||||
required: false
|
||||
default: "."
|
||||
download:
|
||||
description: Default is true. Set to false to only look the artifact up and report `fetch-hit`.
|
||||
required: false
|
||||
default: "true"
|
||||
|
||||
outputs:
|
||||
fetch-hit:
|
||||
description: Boolean indicating if an artifact was successfully found and downloaded.
|
||||
value: ${{ steps.find_release_artifact.outputs.fetch-hit }}
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
@@ -37,12 +28,11 @@ runs:
|
||||
id: find_release_artifact
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
result-encoding: string
|
||||
script: | # js
|
||||
const fs = require('fs');
|
||||
|
||||
const isRelease = "${{ inputs.release }}" === "true";
|
||||
const failOnMiss = "${{ inputs.fail-on-fetch-miss }}" !== "false";
|
||||
const shouldDownload = "${{ inputs.download }}" !== "false";
|
||||
const prefix = isRelease ? "metabase-release" : "metabase";
|
||||
const artifactName = `${prefix}-${{ inputs.edition }}-${{ inputs.commit }}-uberjar`;
|
||||
console.log(`Looking for artifact: ${artifactName}`);
|
||||
@@ -54,19 +44,13 @@ runs:
|
||||
per_page: 1,
|
||||
});
|
||||
|
||||
const artifact_id = artifacts.data?.artifacts?.[0]?.id;
|
||||
|
||||
if (!artifact_id) {
|
||||
core.setOutput('fetch-hit', 'false');
|
||||
} else {
|
||||
core.setOutput('fetch-hit', 'true');
|
||||
}
|
||||
|
||||
if (!shouldDownload) {
|
||||
console.log(`skipping download (download: false)`);
|
||||
if (!artifacts.data?.artifacts?.[0]?.id) {
|
||||
core.setFailed(`No artifacts found for ${artifactName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const artifact_id = artifacts.data.artifacts[0].id;
|
||||
|
||||
let download;
|
||||
const maxAttempts = 3;
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
@@ -90,7 +74,6 @@ runs:
|
||||
fs.writeFileSync(`${process.env.GITHUB_WORKSPACE}/mb.zip`, Buffer.from(download.data));
|
||||
|
||||
- name: Extract and rename JAR
|
||||
if: ${{ steps.find_release_artifact.outputs.fetch-hit == 'true' && inputs.download != 'false' }}
|
||||
shell: bash
|
||||
run: | # bash
|
||||
OUTPUT_NAME="${{ inputs.output-name }}"
|
||||
@@ -136,14 +119,12 @@ runs:
|
||||
fi
|
||||
|
||||
- name: Verify that this is a valid JAR file
|
||||
if: ${{ steps.find_release_artifact.outputs.fetch-hit == 'true' && inputs.download != 'false' }}
|
||||
shell: bash
|
||||
run: |
|
||||
echo "Verifying JAR at: $JAR_PATH"
|
||||
file --mime-type "$JAR_PATH"
|
||||
|
||||
- name: Reveal its version.properties
|
||||
if: ${{ steps.find_release_artifact.outputs.fetch-hit == 'true' && inputs.download != 'false' }}
|
||||
shell: bash
|
||||
run: |
|
||||
echo "Extracting version.properties from: $JAR_PATH"
|
||||
|
||||
92
.github/actions/get-docker-info/action.yml
vendored
92
.github/actions/get-docker-info/action.yml
vendored
@@ -1,19 +1,7 @@
|
||||
name: Get Docker Info
|
||||
description: Determines the registry, repository, and tag a container image is pushed to.
|
||||
description: Determines Docker repository and tag based on branch and edition
|
||||
|
||||
inputs:
|
||||
registry:
|
||||
description: Registry the image is pushed to, either "docker" or "ecr".
|
||||
required: false
|
||||
default: docker
|
||||
repo:
|
||||
description: Repository name, e.g. "metabase-enterprise". Overrides a branch derived repo.
|
||||
required: false
|
||||
default: ''
|
||||
tag:
|
||||
description: Image tag, e.g. v1.87.2. Overrides a branch derived tag.
|
||||
required: false
|
||||
default: ''
|
||||
branch:
|
||||
description: Branch name (defaults to current branch)
|
||||
required: false
|
||||
@@ -22,22 +10,14 @@ inputs:
|
||||
description: Edition (ee or oss)
|
||||
required: false
|
||||
default: ee
|
||||
aws-account-id:
|
||||
description: AWS account id hosting the ECR registry. Required for the ecr registry.
|
||||
owner:
|
||||
description: Repository owner (defaults to current repo owner)
|
||||
required: false
|
||||
default: ''
|
||||
aws-region:
|
||||
description: Region of the ECR registry.
|
||||
required: false
|
||||
default: us-east-1
|
||||
dockerhub-owner:
|
||||
description: DockerHub owner.
|
||||
required: false
|
||||
default: metabase
|
||||
default: ${{ github.repository_owner }}
|
||||
|
||||
outputs:
|
||||
repo:
|
||||
description: Docker repository, including the registry host (ecr) or the owner (docker)
|
||||
description: Docker repository name
|
||||
value: ${{ steps.docker-info.outputs.repo }}
|
||||
tag:
|
||||
description: Docker tag
|
||||
@@ -45,6 +25,9 @@ outputs:
|
||||
full_image:
|
||||
description: Full Docker image name (repo:tag)
|
||||
value: ${{ steps.docker-info.outputs.full_image }}
|
||||
editions:
|
||||
description: JSON array of editions to build
|
||||
value: ${{ steps.docker-info.outputs.editions }}
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
@@ -52,49 +35,40 @@ runs:
|
||||
- name: Determine Docker info
|
||||
id: docker-info
|
||||
uses: actions/github-script@v9
|
||||
env:
|
||||
REGISTRY: ${{ inputs.registry }}
|
||||
REPO: ${{ inputs.repo }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
BRANCH: ${{ inputs.branch }}
|
||||
EDITION: ${{ inputs.edition }}
|
||||
AWS_ACCOUNT_ID: ${{ inputs.aws-account-id }}
|
||||
AWS_REGION: ${{ inputs.aws-region }}
|
||||
DOCKERHUB_OWNER: ${{ inputs.dockerhub-owner }}
|
||||
with:
|
||||
script: | # js
|
||||
const { REGISTRY, OWNER, BRANCH, EDITION, AWS_ACCOUNT_ID, AWS_REGION, DOCKERHUB_OWNER } = process.env;
|
||||
const branch = '${{ inputs.branch }}';
|
||||
const edition = '${{ inputs.edition }}';
|
||||
const owner = '${{ inputs.owner }}';
|
||||
|
||||
let tag = process.env.TAG;
|
||||
if (!tag) {
|
||||
tag = BRANCH === 'master' ? 'latest' : `${BRANCH.replaceAll('/', '-')}-${EDITION}`;
|
||||
}
|
||||
console.log(`Determining Docker info for branch: ${branch}, edition: ${edition}`);
|
||||
|
||||
let name = process.env.REPO;
|
||||
if (!name) {
|
||||
if (BRANCH === 'master') {
|
||||
name = EDITION === 'ee' ? 'metabase-enterprise-head' : 'metabase-head';
|
||||
let repo, tag, editions;
|
||||
|
||||
if (branch === 'master') {
|
||||
// Master builds both editions with specific repos
|
||||
editions = ['ee', 'oss'];
|
||||
tag = 'latest';
|
||||
|
||||
if (edition === 'ee') {
|
||||
repo = `${owner}/metabase-enterprise-head`;
|
||||
} else {
|
||||
name = 'metabase-dev';
|
||||
repo = `${owner}/metabase-head`;
|
||||
}
|
||||
}
|
||||
|
||||
let repo;
|
||||
if (REGISTRY === 'ecr') {
|
||||
if (!AWS_ACCOUNT_ID) {
|
||||
core.setFailed('Pushing to AWS ECR requires aws-account-id to be set.');
|
||||
return;
|
||||
}
|
||||
repo = `${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/${name}`;
|
||||
} else if (REGISTRY === 'docker') {
|
||||
repo = `${DOCKERHUB_OWNER}/${name}`;
|
||||
} else {
|
||||
core.setFailed(`Unknown registry "${REGISTRY}", expected "docker" or "ecr".`);
|
||||
return;
|
||||
// All other branches only build EE to metabase-dev
|
||||
editions = ['ee'];
|
||||
tag = branch.replaceAll('/', '-'); // slashes make docker sad
|
||||
repo = `${owner}/metabase-dev`;
|
||||
}
|
||||
|
||||
const fullImage = `${repo}:${tag}`;
|
||||
|
||||
// Set outputs
|
||||
core.setOutput('repo', repo);
|
||||
core.setOutput('tag', tag);
|
||||
core.setOutput('full_image', `${repo}:${tag}`);
|
||||
core.setOutput('full_image', fullImage);
|
||||
core.setOutput('editions', JSON.stringify(editions));
|
||||
|
||||
console.log(`Resolved: registry = ${REGISTRY}, repo = ${repo}, tag = ${tag}`);
|
||||
console.log(`Docker image: ${fullImage}`);
|
||||
console.log(`Editions: ${JSON.stringify(editions)}`);
|
||||
|
||||
40
.github/actions/prepare-backend/action.yml
vendored
40
.github/actions/prepare-backend/action.yml
vendored
@@ -5,6 +5,9 @@ inputs:
|
||||
java-version:
|
||||
required: true
|
||||
default: "25"
|
||||
clojure-version:
|
||||
required: true
|
||||
default: "1.12.0.1488"
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
@@ -14,40 +17,12 @@ runs:
|
||||
with:
|
||||
java-version: ${{ inputs.java-version }}
|
||||
distribution: "temurin"
|
||||
- name: Cache spec
|
||||
id: cache-spec
|
||||
shell: bash
|
||||
run: .github/scripts/cache-keys.sh | tee -a "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Restore Clojure CLI
|
||||
uses: actions/cache/restore@v5
|
||||
with:
|
||||
path: ${{ steps.cache-spec.outputs.clojure-path }}
|
||||
key: ${{ steps.cache-spec.outputs.clojure-key }}
|
||||
|
||||
- name: Install Clojure CLI
|
||||
shell: bash
|
||||
env:
|
||||
CLOJURE_PREFIX: ${{ steps.cache-spec.outputs.clojure-path }}
|
||||
CLOJURE_VERSION: ${{ steps.cache-spec.outputs.clojure-version }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
echo "$CLOJURE_PREFIX/bin" >> "$GITHUB_PATH"
|
||||
export PATH="$CLOJURE_PREFIX/bin:$PATH"
|
||||
|
||||
# Whether the prefix already holds the right install is decided by running it, not by the
|
||||
# cache-hit flag. The installer bakes the prefix into the clj/clojure shims, so a tree restored
|
||||
# under a different $HOME points at a directory that is not there; and on a runner that is not
|
||||
# ephemeral, a previous version's prefix outlives the cache entry that stopped matching it.
|
||||
if clojure --version | grep -qF "$CLOJURE_VERSION"; then
|
||||
clojure --version
|
||||
exit 0
|
||||
fi
|
||||
|
||||
cd "$RUNNER_TEMP"
|
||||
for attempt in 1 2 3; do
|
||||
curl --retry 3 --retry-delay 5 --fail -O "https://download.clojure.org/install/linux-install-$CLOJURE_VERSION.sh" \
|
||||
&& bash "./linux-install-$CLOJURE_VERSION.sh" --prefix "$CLOJURE_PREFIX" \
|
||||
curl --retry 3 --retry-delay 5 --fail -O https://download.clojure.org/install/linux-install-${{ inputs.clojure-version }}.sh \
|
||||
&& sudo bash ./linux-install-${{ inputs.clojure-version }}.sh \
|
||||
&& break
|
||||
echo "::warning::Clojure install attempt $attempt failed, retrying in 10s..."
|
||||
sleep 10
|
||||
@@ -64,6 +39,11 @@ runs:
|
||||
version: "0.10.2" # keep in sync with mise.toml
|
||||
github-token: ${{ github.token }}
|
||||
|
||||
- name: Cache spec
|
||||
id: cache-spec
|
||||
shell: bash
|
||||
run: .github/scripts/cache-keys.sh | tee -a "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Restore M2 cache
|
||||
uses: actions/cache/restore@v5
|
||||
with:
|
||||
|
||||
31
.github/actions/prepare-shoppy-e2e/action.yml
vendored
31
.github/actions/prepare-shoppy-e2e/action.yml
vendored
@@ -14,6 +14,21 @@ 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
|
||||
@@ -78,6 +93,20 @@ 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
|
||||
@@ -86,6 +115,8 @@ 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"
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
name: Prepare test selection
|
||||
description: Download this run's test plan and write the suite's selected test paths.
|
||||
|
||||
inputs:
|
||||
suite:
|
||||
description: The suite to select tests for (unit, loki or e2e).
|
||||
required: true
|
||||
test-plan-artifact-id:
|
||||
description: ID of this run's test-plan.json artifact.
|
||||
required: true
|
||||
require-download:
|
||||
description: Fail the step when the test plan download fails, instead of running the full suite.
|
||||
required: false
|
||||
default: "false"
|
||||
|
||||
outputs:
|
||||
paths-file:
|
||||
description: JSON file listing the selected test paths, or '' when the suite runs in full.
|
||||
value: ${{ steps.selection.outputs.paths-file }}
|
||||
selection:
|
||||
description: full, narrowed or empty, or '' when the plan is unavailable and the suite runs in full.
|
||||
value: ${{ steps.selection.outputs.selection }}
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Download test plan
|
||||
id: test-plan
|
||||
continue-on-error: ${{ inputs.require-download != 'true' }}
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
artifact-ids: ${{ inputs.test-plan-artifact-id }}
|
||||
path: ${{ runner.temp }}/test-plan
|
||||
- name: Prepare test selection
|
||||
id: selection
|
||||
shell: bash
|
||||
env:
|
||||
PLAN_DOWNLOADED: ${{ steps.test-plan.outcome }}
|
||||
run: bun .github/scripts/prepare-test-selection.ts "${{ inputs.suite }}"
|
||||
42
.github/actions/test-driver/action.yml
vendored
42
.github/actions/test-driver/action.yml
vendored
@@ -20,30 +20,8 @@ inputs:
|
||||
required: false
|
||||
default: "ee"
|
||||
description: Edition to test (oss or ee)
|
||||
runner-index:
|
||||
quarantine-engine:
|
||||
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"
|
||||
@@ -122,7 +100,6 @@ 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
|
||||
@@ -140,19 +117,12 @@ 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)"
|
||||
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 }}
|
||||
$BASE :only "$(cat target/failed-tests)"
|
||||
else
|
||||
clojure -X:"$ALIASES" ${{ inputs.test-args }}
|
||||
$BASE ${{ 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
|
||||
@@ -216,9 +186,7 @@ runs:
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ format('test-logs-{0}{1}', github.job, inputs.artifact-suffix && format('-{0}', inputs.artifact-suffix) || '') }}
|
||||
# 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
|
||||
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
|
||||
@@ -231,4 +199,4 @@ runs:
|
||||
with:
|
||||
adapter: backend
|
||||
test-suite: ${{ inputs.junit-name }}
|
||||
enforce-quarantine: "true"
|
||||
enforce-quarantine: ${{ inputs.quarantine-engine == 'ci-conductor' }}
|
||||
|
||||
106
.github/file-paths.yaml
vendored
106
.github/file-paths.yaml
vendored
@@ -7,8 +7,9 @@ default: &default
|
||||
- "locales/**"
|
||||
- "bin/**/!(*.md)"
|
||||
|
||||
workflows:
|
||||
- added|modified: ".github/workflows/*.{yml,yaml}"
|
||||
actionlint:
|
||||
- added|modified: ".github/workflows/*.yml"
|
||||
- added|modified: ".github/workflows/*.yaml"
|
||||
|
||||
shared_sources: &shared_sources
|
||||
- "src/**/*.cljc"
|
||||
@@ -63,10 +64,9 @@ frontend_specs: &frontend_specs
|
||||
- "frontend/**/tests/**"
|
||||
- "frontend/**/*.unit.*"
|
||||
- "enterprise/frontend/**/*.unit.*"
|
||||
- ".github/**/*.unit.spec.*"
|
||||
- "jest.config.js"
|
||||
- "jest.base.conf.js"
|
||||
- "jest.esm-packages.js"
|
||||
- "jest.tz.unit.conf.js"
|
||||
- "jest.tz.unit.conf.json"
|
||||
|
||||
release_source: &release_sources
|
||||
- "release/**"
|
||||
@@ -78,7 +78,6 @@ frontend_all: &frontend_all
|
||||
|
||||
frontend_loki_ci: &frontend_loki_ci
|
||||
- ".github/workflows/loki.yml"
|
||||
- "loki.config.js"
|
||||
- ".loki/**"
|
||||
- ".storybook/**"
|
||||
|
||||
@@ -86,52 +85,14 @@ frontend_unit_infra: &frontend_unit_infra
|
||||
- *frontend_configs
|
||||
- *frontend_ci
|
||||
- "jest.config.js"
|
||||
- "jest.base.conf.js"
|
||||
- "jest.esm-packages.js"
|
||||
- "jest.tz.unit.conf.js"
|
||||
- "jest.tz.unit.conf.json"
|
||||
- "frontend/test/**"
|
||||
- "frontend/**/__mocks__/**"
|
||||
- "frontend/src/embedding-sdk-shared/jest/**"
|
||||
|
||||
frontend_loki_infra: &frontend_loki_infra
|
||||
- *frontend_configs
|
||||
- *shared_ci
|
||||
- *frontend_loki_ci
|
||||
- "frontend/build/shared/**"
|
||||
- "frontend/test/__support__/**"
|
||||
- "patches/**"
|
||||
# dependency-cruiser doesn't follow CSS @import, so global styles must run every story.
|
||||
- "frontend/src/metabase/css/**/*.css"
|
||||
# Storybook serves resources/frontend_client as static files, and the stories bundle the app's SVGs.
|
||||
- "resources/frontend_client/**"
|
||||
- "frontend/**/*.svg"
|
||||
- "enterprise/frontend/**/*.svg"
|
||||
|
||||
frontend_loki_all:
|
||||
- *frontend_sources
|
||||
- *frontend_loki_infra
|
||||
|
||||
# Everything the ci-scripts jest project runs or depends on.
|
||||
# test-unit ignores that project, so this filter is the only thing that reruns it.
|
||||
ci_scripts:
|
||||
- *frontend_ci
|
||||
- ".nvmrc"
|
||||
# file-paths.unit.spec.ts asserts what this file's own patterns match, so a change to it has to
|
||||
# run the specs that guard it.
|
||||
- ".github/file-paths.yaml"
|
||||
- ".github/scripts/**"
|
||||
- ".github/actions/**"
|
||||
- ".github/workflows/loki.yml"
|
||||
- ".storybook/story-files.cjs"
|
||||
- "frontend/lint/module-boundaries.mjs"
|
||||
- "frontend/lint/shared-tiers.mjs"
|
||||
- "frontend/test/jest-test-paths-filter.ts"
|
||||
- ".dependency-cruiser.cjs"
|
||||
- "jest.config.js"
|
||||
- "jest.base.conf.js"
|
||||
- "jest.esm-packages.js"
|
||||
- "package.json"
|
||||
- "bun.lock"
|
||||
|
||||
# e2e harness/support code (commands, support file, runner). Not in the
|
||||
# instrumented bundle, so it maps to no covered module — a change here forces a
|
||||
@@ -152,15 +113,11 @@ backend_sources: &backend_sources
|
||||
- "src/**"
|
||||
- "modules/drivers/{*,*/{*,!(test)/**}}"
|
||||
- "resources/**"
|
||||
# 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/**"
|
||||
|
||||
backend_specs: &backend_specs
|
||||
- *shared_specs
|
||||
- "test/**"
|
||||
- "dev/test/**"
|
||||
- "enterprise/backend/test/**"
|
||||
- "test_modules/**"
|
||||
- "modules/drivers/*/test/**"
|
||||
@@ -173,48 +130,6 @@ backend_all: &backend_all
|
||||
- *backend_sources
|
||||
- *backend_specs
|
||||
|
||||
# Project checks have narrower triggers than the backend matrix.
|
||||
# Changes to their shared CI entry points exercise every suite; otherwise each
|
||||
# suite follows only the files that can affect its result.
|
||||
project_tests_infra: &project_tests_infra
|
||||
- ".github/actions/prepare-backend/**"
|
||||
- ".github/file-paths.yaml"
|
||||
- ".github/workflows/backend.yml"
|
||||
- ".github/workflows/run-tests.yml"
|
||||
- "bb.edn"
|
||||
- "bin/mage"
|
||||
- "bin/mage.bb"
|
||||
- "mage/src/mage/color.clj"
|
||||
- "mage/src/mage/project_tests.clj"
|
||||
- "mage/src/mage/shell.clj"
|
||||
- "mage/src/mage/util.clj"
|
||||
|
||||
project_backend_checks:
|
||||
- *project_tests_infra
|
||||
- "{src,test,enterprise,dev,bin,mage}/**/*.{clj,cljc,cljs}"
|
||||
- "modules/**/*.clj"
|
||||
- "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"
|
||||
- "dev/resources/dev/modules_config_test/**"
|
||||
|
||||
# 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
|
||||
- "bin/lint-migrations-file/**"
|
||||
- "resources/migrations/**/*.{yaml,yml}"
|
||||
- "src/metabase/app_db/custom_migrations.clj"
|
||||
- "src/metabase/app_db/custom_migrations/**"
|
||||
|
||||
sources: &sources
|
||||
- *shared_sources
|
||||
- *frontend_sources
|
||||
@@ -307,13 +222,6 @@ cljs:
|
||||
- ".github/workflows/cljs.yml"
|
||||
- "shadow-cljs.edn"
|
||||
|
||||
build_scripts:
|
||||
- ".github/actions/prepare-backend/**"
|
||||
- ".github/file-paths.yaml"
|
||||
- ".github/workflows/build-scripts.yml"
|
||||
- "bin/**"
|
||||
- "deps.edn"
|
||||
|
||||
embedder_plugin:
|
||||
- ".github/actions/prepare-backend/**"
|
||||
- ".github/file-paths.yaml"
|
||||
|
||||
22
.github/scripts/affected-modules.ts
vendored
22
.github/scripts/affected-modules.ts
vendored
@@ -105,28 +105,6 @@ export function getAffectedFiles(
|
||||
return affected;
|
||||
}
|
||||
|
||||
/** The file plus every file it transitively imports. */
|
||||
export function getTransitiveDependencies(
|
||||
fileDependencies: FileDependency[],
|
||||
file: string,
|
||||
): Set<string> {
|
||||
const dependenciesOf = new Map(
|
||||
fileDependencies.map(({ source, dependencies }) => [source, dependencies]),
|
||||
);
|
||||
const reached = new Set([file]);
|
||||
const queue = [file];
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift()!;
|
||||
for (const dependency of dependenciesOf.get(current) ?? []) {
|
||||
if (!reached.has(dependency)) {
|
||||
reached.add(dependency);
|
||||
queue.push(dependency);
|
||||
}
|
||||
}
|
||||
}
|
||||
return reached;
|
||||
}
|
||||
|
||||
export type ModuleGraph = {
|
||||
nodes: ModuleNode[];
|
||||
/**
|
||||
|
||||
26
.github/scripts/affected-modules.unit.spec.ts
vendored
26
.github/scripts/affected-modules.unit.spec.ts
vendored
@@ -6,7 +6,6 @@ import {
|
||||
getAffectedFiles,
|
||||
getAffectedModules,
|
||||
getChangedModules,
|
||||
getTransitiveDependencies,
|
||||
mapFileToModule,
|
||||
parseCruiseModules,
|
||||
} from "./affected-modules";
|
||||
@@ -124,31 +123,6 @@ describe("affected modules", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTransitiveDependencies", () => {
|
||||
it("should return the file and everything it transitively imports", () => {
|
||||
expect(
|
||||
[...getTransitiveDependencies(FILE_DEPS, "src/app.js")].sort(),
|
||||
).toEqual(["src/app.js", "src/foo/foo.tsx", "src/utils/colors.ts"]);
|
||||
});
|
||||
|
||||
it("should return only the file when it imports nothing", () => {
|
||||
expect([
|
||||
...getTransitiveDependencies(FILE_DEPS, "src/utils/colors.ts"),
|
||||
]).toEqual(["src/utils/colors.ts"]);
|
||||
});
|
||||
|
||||
it("should stop at an import cycle", () => {
|
||||
const cyclic = [
|
||||
{ source: "src/a.ts", dependencies: ["src/b.ts"] },
|
||||
{ source: "src/b.ts", dependencies: ["src/a.ts"] },
|
||||
];
|
||||
|
||||
expect([...getTransitiveDependencies(cyclic, "src/a.ts")].sort()).toEqual(
|
||||
["src/a.ts", "src/b.ts"],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildUsageModuleGraph + getAffectedModules", () => {
|
||||
const usageGraph = buildUsageModuleGraph(ELEMENTS, FILE_DEPS);
|
||||
|
||||
|
||||
45
.github/scripts/affected-tests.ts
vendored
45
.github/scripts/affected-tests.ts
vendored
@@ -11,7 +11,6 @@ import {
|
||||
buildUsageModuleGraph,
|
||||
getAffectedModules,
|
||||
getChangedModules,
|
||||
getTransitiveDependencies,
|
||||
mapFileToModule,
|
||||
} from "./affected-modules";
|
||||
|
||||
@@ -22,7 +21,6 @@ export type TestPlanStats = {
|
||||
be_files_total: number;
|
||||
unit_infra_touched: boolean;
|
||||
loki_infra_touched: boolean;
|
||||
loki_preview_touched: boolean;
|
||||
shared_sources_touched: boolean;
|
||||
fe_modules_total: number;
|
||||
fe_modules_changed: number;
|
||||
@@ -50,12 +48,9 @@ export type CreateTestPlanInput = {
|
||||
elements: ModuleDef[];
|
||||
rules: Rule[];
|
||||
changedFiles: string[];
|
||||
// Loads the dependency-cruiser edges, called only when some suite can be narrowed.
|
||||
// Null falls back to the rules graph.
|
||||
loadFileDependencies: () => FileDependency[] | null;
|
||||
// Parsed dependency-cruiser edges, or null to fall back to the rules graph.
|
||||
fileDependencies: FileDependency[] | null;
|
||||
testFilesBySuite: { unit: string[]; loki: string[]; e2e: string[] };
|
||||
// Storybook's preview file, whose imports wrap every story.
|
||||
lokiPreviewFile: string;
|
||||
e2eSpecFiles: Record<string, string[]> | null;
|
||||
unitInfraTouched: boolean;
|
||||
lokiInfraTouched: boolean;
|
||||
@@ -68,7 +63,6 @@ export type CreateTestPlanInput = {
|
||||
};
|
||||
|
||||
// Tests whose owning module is affected.
|
||||
// A spec outside the module tree always runs, because the graph cannot scope it.
|
||||
export function filterAffectedTests(
|
||||
nodes: ModuleNode[],
|
||||
affected: Set<string>,
|
||||
@@ -76,7 +70,7 @@ export function filterAffectedTests(
|
||||
): string[] {
|
||||
return testFiles.filter((file) => {
|
||||
const module = mapFileToModule(nodes, file);
|
||||
return module === null || affected.has(module);
|
||||
return module !== null && affected.has(module);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -136,9 +130,8 @@ export function createTestPlan({
|
||||
elements,
|
||||
rules,
|
||||
changedFiles,
|
||||
loadFileDependencies,
|
||||
fileDependencies,
|
||||
testFilesBySuite,
|
||||
lokiPreviewFile,
|
||||
e2eSpecFiles,
|
||||
unitInfraTouched,
|
||||
lokiInfraTouched,
|
||||
@@ -150,11 +143,16 @@ export function createTestPlan({
|
||||
beFilesTotal,
|
||||
}: CreateTestPlanInput): TestPlan {
|
||||
const rulesGraph = buildModuleGraph(elements, rules);
|
||||
const usageGraph = fileDependencies
|
||||
? buildUsageModuleGraph(elements, fileDependencies)
|
||||
: rulesGraph;
|
||||
|
||||
const nodes = rulesGraph.nodes;
|
||||
// Distinct module types (an element type can span several patterns).
|
||||
const totalModules = new Set(elements.map((el) => el.type)).size;
|
||||
const changedModules = getChangedModules(nodes, changedFiles);
|
||||
const rulesAffected = getAffectedModules(rulesGraph, changedFiles);
|
||||
const usageAffected = getAffectedModules(usageGraph, changedFiles);
|
||||
|
||||
// The coarse "feature" tier is the only set the e2e manifest is ever
|
||||
// collapsed to (see filterAffectedE2eSpecs).
|
||||
@@ -163,7 +161,7 @@ export function createTestPlan({
|
||||
// cljc/cljs compile into the FE bundle, so they force a full run that module
|
||||
// selection can't narrow — same effect as a suite's own infra changing.
|
||||
const unitForceAll = unitInfraTouched || sharedSourcesTouched;
|
||||
const lokiInfraForceAll = lokiInfraTouched || sharedSourcesTouched;
|
||||
const lokiForceAll = lokiInfraTouched || sharedSourcesTouched;
|
||||
// e2e is integration-level, so anything the FE-coverage manifest can't see
|
||||
// forces a full run: cljc/cljs in the bundle (sharedSourcesTouched), a backend
|
||||
// change that can break the UI (beFilesChanged), an e2e harness/support change
|
||||
@@ -174,28 +172,6 @@ export function createTestPlan({
|
||||
beFilesChanged > 0 ||
|
||||
e2eSpecFiles === null;
|
||||
|
||||
// The cruise takes seconds, so it is skipped when every suite runs in full anyway.
|
||||
const edges =
|
||||
unitForceAll && lokiInfraForceAll && e2eForceAll
|
||||
? null
|
||||
: loadFileDependencies();
|
||||
const usageGraph = edges
|
||||
? buildUsageModuleGraph(elements, edges)
|
||||
: rulesGraph;
|
||||
const usageAffected = getAffectedModules(usageGraph, changedFiles);
|
||||
|
||||
// The preview wraps every story, so a change to a file it imports can change any screenshot.
|
||||
const previewDependencies = edges
|
||||
? getTransitiveDependencies(edges, lokiPreviewFile)
|
||||
: new Set<string>();
|
||||
const lokiPreviewTouched = changedFiles.some((file) =>
|
||||
previewDependencies.has(file),
|
||||
);
|
||||
// Without the cruise, the rules graph cannot account for the preview's
|
||||
// global dependencies, so it cannot safely narrow Loki.
|
||||
const lokiForceAll =
|
||||
lokiInfraForceAll || lokiPreviewTouched || edges === null;
|
||||
|
||||
const select = (forceAll: boolean, affected: Set<string>, files: string[]) =>
|
||||
forceAll ? files : filterAffectedTests(nodes, affected, files);
|
||||
|
||||
@@ -232,7 +208,6 @@ export function createTestPlan({
|
||||
be_files_total: beFilesTotal,
|
||||
unit_infra_touched: unitInfraTouched,
|
||||
loki_infra_touched: lokiInfraTouched,
|
||||
loki_preview_touched: lokiPreviewTouched,
|
||||
shared_sources_touched: sharedSourcesTouched,
|
||||
fe_modules_total: totalModules,
|
||||
fe_modules_changed: changedModules.size,
|
||||
|
||||
107
.github/scripts/affected-tests.unit.spec.ts
vendored
107
.github/scripts/affected-tests.unit.spec.ts
vendored
@@ -26,9 +26,8 @@ const E2E_FILES = ["e2e/test/scenarios/a.cy.spec.ts"];
|
||||
const baseInput = {
|
||||
elements: ELEMENTS,
|
||||
rules: RULES,
|
||||
loadFileDependencies: () => null,
|
||||
fileDependencies: null,
|
||||
testFilesBySuite: { unit: UNIT_FILES, loki: LOKI_FILES, e2e: E2E_FILES },
|
||||
lokiPreviewFile: ".storybook/preview.tsx",
|
||||
e2eSpecFiles: null,
|
||||
unitInfraTouched: false,
|
||||
lokiInfraTouched: false,
|
||||
@@ -42,10 +41,7 @@ const baseInput = {
|
||||
|
||||
describe("createTestPlan", () => {
|
||||
it("runs only specs in affected modules (rules graph)", () => {
|
||||
const plan = createTestPlan({
|
||||
...baseInput,
|
||||
changedFiles: ["src/foo/x.ts"],
|
||||
});
|
||||
const plan = createTestPlan({ ...baseInput, changedFiles: ["src/foo/x.ts"] });
|
||||
|
||||
expect(plan.stats.fe_modules_changed).toBe(1);
|
||||
expect(plan.stats.fe_modules_affected_rules).toBe(1);
|
||||
@@ -54,8 +50,8 @@ describe("createTestPlan", () => {
|
||||
"src/foo/foo.unit.spec.ts",
|
||||
]);
|
||||
expect(plan.stats.unit_specs_to_run_rules).toBe(2);
|
||||
expect(plan.loki_stories_to_run).toEqual(LOKI_FILES);
|
||||
// With no file deps, unit usage falls back to rules, but Loki runs in full.
|
||||
expect(plan.stats.loki_stories_to_run_rules).toBe(1);
|
||||
// With no file deps, usage falls back to rules.
|
||||
expect(plan.stats.unit_specs_to_run_usage).toBe(2);
|
||||
});
|
||||
|
||||
@@ -64,7 +60,7 @@ describe("createTestPlan", () => {
|
||||
...baseInput,
|
||||
changedFiles: ["src/utils/colors.ts"],
|
||||
// Only feature/foo actually imports lib/utils.
|
||||
loadFileDependencies: () => [
|
||||
fileDependencies: [
|
||||
{ source: "src/foo/foo.tsx", dependencies: ["src/utils/colors.ts"] },
|
||||
],
|
||||
});
|
||||
@@ -95,69 +91,14 @@ describe("createTestPlan", () => {
|
||||
...baseInput,
|
||||
changedFiles: ["docs/readme.md"],
|
||||
unitInfraTouched: true,
|
||||
loadFileDependencies: () => [],
|
||||
});
|
||||
|
||||
expect(plan.stats.unit_specs_to_run_rules).toBe(UNIT_FILES.length);
|
||||
expect(plan.stats.loki_stories_to_run_rules).toBe(0);
|
||||
});
|
||||
|
||||
// The preview imports the theme, which imports the colors.
|
||||
const PREVIEW_DEPS = [
|
||||
{ source: ".storybook/preview.tsx", dependencies: ["src/utils/theme.ts"] },
|
||||
{ source: "src/utils/theme.ts", dependencies: ["src/utils/colors.ts"] },
|
||||
{ source: "src/foo/foo.tsx", dependencies: ["src/foo/x.ts"] },
|
||||
];
|
||||
|
||||
it("forces a full Loki run when the Storybook preview imports a changed file", () => {
|
||||
const plan = createTestPlan({
|
||||
...baseInput,
|
||||
changedFiles: ["src/utils/colors.ts"],
|
||||
loadFileDependencies: () => PREVIEW_DEPS,
|
||||
e2eSpecFiles: { "e2e/test/scenarios/a.cy.spec.ts": ["src/bar/b.ts"] },
|
||||
});
|
||||
|
||||
expect(plan.stats.loki_preview_touched).toBe(true);
|
||||
expect(plan.stats.loki_stories_to_run_rules).toBe(LOKI_FILES.length);
|
||||
expect(plan.loki_stories_to_run).toEqual(LOKI_FILES);
|
||||
// Unit and e2e still narrow to what the usage graph reaches from the change.
|
||||
expect(plan.fe_unit_specs_to_run).toEqual(["src/utils/utils.unit.spec.ts"]);
|
||||
expect(plan.e2e_specs_to_run).toEqual([]);
|
||||
});
|
||||
|
||||
it("narrows Loki when the Storybook preview does not import the changed file", () => {
|
||||
const plan = createTestPlan({
|
||||
...baseInput,
|
||||
changedFiles: ["src/foo/x.ts"],
|
||||
loadFileDependencies: () => PREVIEW_DEPS,
|
||||
});
|
||||
|
||||
expect(plan.stats.loki_preview_touched).toBe(false);
|
||||
expect(plan.loki_stories_to_run).toEqual(["src/foo/Foo.stories.tsx"]);
|
||||
});
|
||||
|
||||
it("runs all Loki stories when the cruise fails even if rules select fewer", () => {
|
||||
const plan = createTestPlan({
|
||||
...baseInput,
|
||||
changedFiles: ["src/foo/x.ts"],
|
||||
loadFileDependencies: () => null,
|
||||
e2eSpecFiles: { "e2e/test/scenarios/a.cy.spec.ts": ["src/bar/b.ts"] },
|
||||
});
|
||||
|
||||
expect(plan.stats.loki_preview_touched).toBe(false);
|
||||
expect(plan.loki_stories_to_run).toEqual(LOKI_FILES);
|
||||
expect(plan.stats.loki_stories_to_run_rules).toBe(LOKI_FILES.length);
|
||||
expect(plan.stats.loki_stories_to_run_usage).toBe(LOKI_FILES.length);
|
||||
// Unit and E2E keep their rules-graph fallback.
|
||||
expect(plan.fe_unit_specs_to_run).toEqual(UNIT_FILES.slice(0, 2));
|
||||
expect(plan.e2e_specs_to_run).toEqual([]);
|
||||
});
|
||||
|
||||
it("runs the full e2e suite when no coverage manifest is available", () => {
|
||||
const plan = createTestPlan({
|
||||
...baseInput,
|
||||
changedFiles: ["src/foo/x.ts"],
|
||||
});
|
||||
const plan = createTestPlan({ ...baseInput, changedFiles: ["src/foo/x.ts"] });
|
||||
|
||||
expect(plan.stats.e2e_specs_to_run_rules).toBe(E2E_FILES.length);
|
||||
expect(plan.stats.e2e_specs_to_run_usage).toBe(E2E_FILES.length);
|
||||
@@ -288,53 +229,21 @@ describe("createTestPlan", () => {
|
||||
expect(plan.stats.fe_files_changed).toBe(5);
|
||||
expect(plan.stats.be_files_changed).toBe(3);
|
||||
});
|
||||
|
||||
it("does not build the usage graph when every suite runs in full", () => {
|
||||
const loadFileDependencies = jest.fn(() => null);
|
||||
createTestPlan({
|
||||
...baseInput,
|
||||
changedFiles: ["src/foo/x.ts"],
|
||||
sharedSourcesTouched: true,
|
||||
loadFileDependencies,
|
||||
});
|
||||
|
||||
expect(loadFileDependencies).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("builds the usage graph when any suite can be narrowed", () => {
|
||||
const loadFileDependencies = jest.fn(() => null);
|
||||
createTestPlan({
|
||||
...baseInput,
|
||||
changedFiles: ["src/foo/x.ts"],
|
||||
unitInfraTouched: true, // loki can still be narrowed
|
||||
loadFileDependencies,
|
||||
});
|
||||
|
||||
expect(loadFileDependencies).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterAffectedTests", () => {
|
||||
it("keeps tests inside affected modules, drops tests in unaffected ones", () => {
|
||||
it("keeps only tests inside affected modules", () => {
|
||||
const nodes = buildNodes(ELEMENTS);
|
||||
const affected = new Set(["feature/foo", "lib/utils"]);
|
||||
const tests = [
|
||||
"src/foo/foo.unit.spec.ts",
|
||||
"src/utils/colors.unit.spec.ts",
|
||||
"src/bar/bar.unit.spec.ts",
|
||||
"docs/unrelated.unit.spec.ts",
|
||||
];
|
||||
expect(filterAffectedTests(nodes, affected, tests)).toEqual([
|
||||
"src/foo/foo.unit.spec.ts",
|
||||
"src/utils/colors.unit.spec.ts",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a test that maps to no module (unknown scope)", () => {
|
||||
const nodes = buildNodes(ELEMENTS);
|
||||
const affected = new Set(["feature/foo"]);
|
||||
const tests = ["docs/unrelated.unit.spec.ts", "src/bar/bar.unit.spec.ts"];
|
||||
expect(filterAffectedTests(nodes, affected, tests)).toEqual([
|
||||
"docs/unrelated.unit.spec.ts",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
4
.github/scripts/bundle-size-stats-prepare.ts
vendored
4
.github/scripts/bundle-size-stats-prepare.ts
vendored
@@ -200,9 +200,7 @@ export function main() {
|
||||
// The last plotted point, as slim rows: [{ bundle, kind, rawBytes, gzipBytes, brotliBytes }].
|
||||
previous: readJson<CacheRow[]>(env.LAST) || [],
|
||||
threshold: Number(env.MIN_DELTA_PERCENT ?? 1),
|
||||
// The commit's own time rather than the day it was measured, so the merges
|
||||
// of one day keep their order on the chart.
|
||||
date: new Date(env.COMMIT_TIMESTAMP || Date.now()).toISOString(),
|
||||
date: new Date().toISOString().slice(0, 10), // YYYY-MM-DD
|
||||
commit: (env.HEAD_SHA || "").slice(0, 12),
|
||||
commitMessage: (env.COMMIT_MESSAGE || "").split("\n")[0], // subject line only
|
||||
version: readVersion(env.VERSION_PROPS),
|
||||
|
||||
19
.github/scripts/cache-keys.sh
vendored
19
.github/scripts/cache-keys.sh
vendored
@@ -50,11 +50,7 @@ digest() {
|
||||
}
|
||||
|
||||
shopt -s nullglob
|
||||
DEPS_FILES=(deps.edn
|
||||
bin/lint-migrations-file/deps.edn
|
||||
bin/load-namespaces/deps.edn
|
||||
modules/drivers/deps.edn
|
||||
modules/drivers/*/deps.edn)
|
||||
DEPS_FILES=(deps.edn modules/drivers/deps.edn modules/drivers/*/deps.edn)
|
||||
shopt -u nullglob
|
||||
|
||||
DEPS_HASH="$(digest "${DEPS_FILES[@]}")"
|
||||
@@ -74,10 +70,6 @@ if [ "$CYPRESS_MATCHES" != "1" ]; then
|
||||
fi
|
||||
CYPRESS_VERSION="$CYPRESS_VERSIONS"
|
||||
|
||||
# The Clojure CLI version CI installs. It lives here rather than in prepare-backend so that the version
|
||||
# and the key that identifies its cache entry cannot drift apart.
|
||||
CLOJURE_VERSION="1.12.0.1488"
|
||||
|
||||
# An incremental lint cache is only ever an accelerator, so it keys on the commit and always falls back
|
||||
# to the newest entry on the prefix. The exact key never pre-exists, which keeps the entry current.
|
||||
ESLINT_SHA="${GITHUB_SHA:-$(git rev-parse HEAD)}"
|
||||
@@ -104,15 +96,6 @@ spec() {
|
||||
echo "bun-store-key=bun-store-$OS-$LOCK_HASH"
|
||||
echo "bun-store-restore-key=bun-store-$OS-"
|
||||
|
||||
# No restore-key: an entry from another version installs a working `clojure` of the wrong version, and
|
||||
# the freshness check in prepare-backend - running the binary - would accept it.
|
||||
#
|
||||
# The installer bakes this prefix into the clj/clojure shims, so the tree only works when restored to
|
||||
# the same absolute path it was built at; prepare-backend reinstalls when it is not.
|
||||
echo "clojure-path=$HOME/.clojure-cli"
|
||||
echo "clojure-key=clojure-cli-$OS-$CLOJURE_VERSION"
|
||||
echo "clojure-version=$CLOJURE_VERSION"
|
||||
|
||||
echo "cypress-path=$HOME/.cache/Cypress"
|
||||
echo "cypress-key=cypress-$OS-$CYPRESS_VERSION"
|
||||
echo "cypress-restore-key=cypress-$OS-"
|
||||
|
||||
71
.github/scripts/check-job-timeouts.sh
vendored
71
.github/scripts/check-job-timeouts.sh
vendored
@@ -1,71 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Fails when a job in .github/workflows/ does not set `timeout-minutes`.
|
||||
#
|
||||
# Without it a job inherits GitHub's 6-hour default, so a hung job burns six hours of runner time -
|
||||
# and holds up everything queued behind it - before anyone notices. actionlint cannot check this: its
|
||||
# rule set is compiled in and .github/actionlint.yaml only takes `self-hosted-runner`,
|
||||
# `config-variables`, and `paths` (which suppresses findings rather than adding them). Hence this
|
||||
# script.
|
||||
#
|
||||
# The rule is per job: a `timeout-minutes` on a step does not satisfy it, and steps are not required
|
||||
# to carry one of their own.
|
||||
#
|
||||
# Jobs that call a reusable workflow (`uses:` at the job level) are skipped - GitHub rejects
|
||||
# `timeout-minutes` there. The called workflow's own jobs are checked when it lives in this repo.
|
||||
#
|
||||
# .github/scripts/check-job-timeouts.sh [workflow-file ...]
|
||||
#
|
||||
# With no arguments it checks every workflow; CI passes the files that changed, like actionlint does.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Arguments are used as given, so the paths in the annotations match what the caller passed. Without
|
||||
# them the full scan has to run from the repo root.
|
||||
if [ $# -gt 0 ]; then
|
||||
workflows=("$@")
|
||||
else
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
|
||||
shopt -s nullglob
|
||||
workflows=(.github/workflows/*.yml .github/workflows/*.yaml)
|
||||
|
||||
if [ ${#workflows[@]} -eq 0 ]; then
|
||||
echo "::error::check-job-timeouts.sh: no workflow files found under .github/workflows/" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
status=0
|
||||
|
||||
for file in "${workflows[@]}"; do
|
||||
# `awk NF` drops the blank line yq emits when a file has nothing to report.
|
||||
missing="$(
|
||||
# --header-preprocess=false: yq otherwise slurps any leading comment block before parsing, which
|
||||
# shifts every line number it reports by the size of that block.
|
||||
yq --header-preprocess=false '
|
||||
.jobs
|
||||
| to_entries
|
||||
| .[]
|
||||
| select(((.value | has("timeout-minutes")) or (.value | has("uses"))) | not)
|
||||
| [(.key | line), .key]
|
||||
| @tsv
|
||||
' "$file" | awk NF
|
||||
)"
|
||||
|
||||
[ -n "$missing" ] || continue
|
||||
|
||||
while IFS=$'\t' read -r line job; do
|
||||
echo "::error file=${file},line=${line}::Job \"${job}\" does not set a job-level timeout-minutes" >&2
|
||||
done <<< "$missing"
|
||||
|
||||
status=1
|
||||
done
|
||||
|
||||
if [ "$status" -ne 0 ]; then
|
||||
echo >&2
|
||||
echo "Every job must set an explicit timeout-minutes; without one it inherits GitHub's 6-hour default." >&2
|
||||
echo "Pick a value with headroom over the job's normal runtime." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "All jobs in the ${#workflows[@]} workflow file(s) checked set timeout-minutes."
|
||||
68
.github/scripts/check-preresolve-aliases.sh
vendored
68
.github/scripts/check-preresolve-aliases.sh
vendored
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# Fails when a Clojure classpath used by CI is not pre-resolved by cache-warm.
|
||||
# Fails when a `clojure` invocation under .github/ uses an alias combination that cache-warm does not
|
||||
# pre-resolve.
|
||||
#
|
||||
# tools.deps selects dependency versions globally per classpath, so two alias combinations can resolve
|
||||
# the same transitive library to different versions. A combination the warm job never resolved is
|
||||
@@ -29,18 +30,10 @@ EOF
|
||||
# :deps - tools.deps built-in used by write-poms.sh; contributes no dependencies of its own.
|
||||
# :outdated - scheduled dependency-bump workflow only; a cold resolve there costs nothing anyone waits on.
|
||||
|
||||
# Dependency roots and alias combinations the warm job pre-resolves.
|
||||
declared="$({
|
||||
sed -n '/^[[:space:]]*combos=(/,/^ *)/p' "$WARM" \
|
||||
| grep -oE '":[^"]*"' \
|
||||
| tr -d '"' \
|
||||
| sed 's#^#.|#'
|
||||
sed -n '/^[[:space:]]*nested_combos=(/,/^ *)/p' "$WARM" \
|
||||
| grep -oE '"[^"]+\|:[^"]+"' \
|
||||
| tr -d '"'
|
||||
} | sort -u)"
|
||||
# Alias combinations the warm job pre-resolves.
|
||||
declared="$(sed -n '/combos=(/,/^ *)/p' "$WARM" | grep -oE '":[^"]*"' | tr -d '"' | sort -u)"
|
||||
if [ -z "$declared" ]; then
|
||||
echo "::error::check-preresolve-aliases.sh: found no pre-resolved classpaths in $WARM" >&2
|
||||
echo "::error::check-preresolve-aliases.sh: found no combos=( ... ) list in $WARM" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -50,60 +43,27 @@ unknown="$(grep -rhoE '\$\{\{[^}]*\}\}' --include='*' \
|
||||
--exclude='*.png' --exclude='*.jpg' .github/ 2>/dev/null \
|
||||
| sort -u | grep -v 'edition' || true)"
|
||||
|
||||
scan() { # scan <edition>: dependency root and aliases used by .github Clojure invocations
|
||||
scan() { # scan <edition>: alias strings used by clojure invocations, with expressions substituted
|
||||
grep -rhE 'clojure[[:space:]]+(-P[[:space:]]+)?-[XMATP]?:' \
|
||||
--include='*' --exclude='*.png' --exclude='*.jpg' .github/ 2>/dev/null \
|
||||
| sed "s/\\\${{[^}]*edition[^}]*}}/$1/g" \
|
||||
| grep -oE 'clojure[[:space:]]+(-P[[:space:]]+)?-[XMATP]?:[A-Za-z0-9:_./-]+' \
|
||||
| grep -oE ':[A-Za-z0-9:_./-]+' \
|
||||
| sed -e 's/:$//' -e 's#^#.|#'
|
||||
| grep -oE 'clojure[[:space:]]+(-P[[:space:]]+)?-[XMATP]?:[A-Za-z0-9:_.-]+' \
|
||||
| grep -oE ':[A-Za-z0-9:_.-]+' \
|
||||
| sed 's/:$//'
|
||||
}
|
||||
|
||||
scan_project_tests() { # scan_project_tests <function> <dependency-root>
|
||||
# Mage shell commands are vectors split across lines, so flatten each function body before
|
||||
# extracting its alias.
|
||||
sed -n "/(defn- $1 /,/^(defn/p" mage/src/mage/project_tests.clj \
|
||||
| tr '\n' ' ' \
|
||||
| grep -oE '"clojure"[[:space:]]+("-P"[[:space:]]+)?"-[XMATP]?:[A-Za-z0-9:_./-]+' \
|
||||
| grep -oE ':[A-Za-z0-9:_./-]+' \
|
||||
| sed -e 's/:$//' -e "s#^#$2|#"
|
||||
}
|
||||
found="$( { scan ee; scan oss; } | sort -u )"
|
||||
|
||||
# Check each scan separately. One scan still finding commands would otherwise conceal that another has
|
||||
# stopped matching, which silently drops everything it used to cover.
|
||||
require_scan() { # require_scan <what> <result>
|
||||
[ -n "$2" ] && return
|
||||
echo "::error::check-preresolve-aliases.sh: found no Clojure invocation in $1; the scan pattern no longer matches" >&2
|
||||
exit 1
|
||||
}
|
||||
workflow_checks=$( { scan ee; scan oss; } || true)
|
||||
clojure_checks=$(scan_project_tests run-clojure-checks! . || true)
|
||||
migration_checks=$(scan_project_tests run-migration-checks! bin/lint-migrations-file || true)
|
||||
require_scan ".github/" "$workflow_checks"
|
||||
require_scan "run-clojure-checks! (mage/src/mage/project_tests.clj)" "$clojure_checks"
|
||||
require_scan "run-migration-checks! (mage/src/mage/project_tests.clj)" "$migration_checks"
|
||||
|
||||
scanned="$(printf '%s\n' "$workflow_checks" "$clojure_checks" "$migration_checks" | sort -u)"
|
||||
|
||||
found="$( {
|
||||
printf '%s\n' "$scanned"
|
||||
# build-scripts.yml runs this classpath from its own dependency root.
|
||||
printf '%s\n' 'bin/load-namespaces|:test'
|
||||
} | sort -u )"
|
||||
|
||||
covered="$( {
|
||||
printf '%s\n' "$declared"
|
||||
printf '%s\n' "$WAIVED" | sed 's#^#.|#'
|
||||
} | sort -u)"
|
||||
covered="$(printf '%s\n%s\n' "$declared" "$WAIVED" | sort -u)"
|
||||
missing="$(comm -23 <(printf '%s\n' "$found") <(printf '%s\n' "$covered") || true)"
|
||||
|
||||
if [ -n "$missing" ]; then
|
||||
echo "::error::Clojure classpaths used by CI that cache-warm does not pre-resolve:" >&2
|
||||
echo "::error::Alias combinations used under .github/ that cache-warm does not pre-resolve:" >&2
|
||||
printf '%s\n' "$missing" | sed 's/^/ /' >&2
|
||||
echo >&2
|
||||
echo "Add each to the matching root or nested combo list in $WARM, or waive it here with a reason." >&2
|
||||
echo "Add each to the combos list in $WARM, or waive it in this script with a reason." >&2
|
||||
echo "Leaving it uncovered means those jobs re-fetch their version deltas from Maven on every run." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "All $(printf '%s\n' "$found" | grep -c .) Clojure classpaths used by CI are pre-resolved."
|
||||
echo "All $(printf '%s\n' "$found" | grep -c .) clojure alias combinations under .github/ are pre-resolved."
|
||||
|
||||
182
.github/scripts/check-unit-test-results.ts
vendored
182
.github/scripts/check-unit-test-results.ts
vendored
@@ -1,182 +0,0 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { readFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const TEST_UNIT_COMMAND = ["bun", "run", "test-unit-keep-cljs"];
|
||||
|
||||
type SuiteResult = {
|
||||
name: string;
|
||||
status: string;
|
||||
assertionResults: unknown[];
|
||||
};
|
||||
|
||||
type JestResults = {
|
||||
wasInterrupted: boolean;
|
||||
testResults: SuiteResult[];
|
||||
};
|
||||
|
||||
function isSuiteResult(value: unknown): value is SuiteResult {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"name" in value &&
|
||||
typeof value.name === "string" &&
|
||||
"status" in value &&
|
||||
typeof value.status === "string" &&
|
||||
"assertionResults" in value &&
|
||||
Array.isArray(value.assertionResults)
|
||||
);
|
||||
}
|
||||
|
||||
function readResults(file: string): JestResults | null {
|
||||
const value = readJson(file);
|
||||
if (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"wasInterrupted" in value &&
|
||||
typeof value.wasInterrupted === "boolean" &&
|
||||
"testResults" in value &&
|
||||
Array.isArray(value.testResults) &&
|
||||
value.testResults.every(isSuiteResult)
|
||||
) {
|
||||
return {
|
||||
wasInterrupted: value.wasInterrupted,
|
||||
testResults: value.testResults,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseJson(text: string): unknown {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readJson(file: string): unknown {
|
||||
try {
|
||||
return parseJson(readFileSync(file, "utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isStringArray(value: unknown): value is string[] {
|
||||
return (
|
||||
Array.isArray(value) && value.every((item) => typeof item === "string")
|
||||
);
|
||||
}
|
||||
|
||||
function hasContent(file: string): boolean {
|
||||
try {
|
||||
return statSync(file).size > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function findRunnerFailure(env: NodeJS.ProcessEnv): string | null {
|
||||
const {
|
||||
UNIT_TEST_RESULTS_FILE,
|
||||
JEST_JUNIT_OUTPUT_DIR,
|
||||
JEST_JUNIT_OUTPUT_NAME,
|
||||
} = env;
|
||||
if (
|
||||
!UNIT_TEST_RESULTS_FILE ||
|
||||
!JEST_JUNIT_OUTPUT_DIR ||
|
||||
!JEST_JUNIT_OUTPUT_NAME
|
||||
) {
|
||||
throw new Error(
|
||||
"UNIT_TEST_RESULTS_FILE, JEST_JUNIT_OUTPUT_DIR and JEST_JUNIT_OUTPUT_NAME are required",
|
||||
);
|
||||
}
|
||||
|
||||
const results = readResults(UNIT_TEST_RESULTS_FILE);
|
||||
if (results === null || results.wasInterrupted) {
|
||||
return "Jest did not finish the run.";
|
||||
}
|
||||
if (!hasContent(join(JEST_JUNIT_OUTPUT_DIR, JEST_JUNIT_OUTPUT_NAME))) {
|
||||
return "Jest did not write a JUnit report.";
|
||||
}
|
||||
|
||||
const failedSuites = results.testResults.filter(
|
||||
(suite) => suite.status === "failed",
|
||||
);
|
||||
// jest-junit leaves out suites with no test results, so the quarantine gate never sees them.
|
||||
const suitesWithoutResults = failedSuites.filter(
|
||||
(suite) => suite.assertionResults.length === 0,
|
||||
);
|
||||
if (suitesWithoutResults.length > 0) {
|
||||
const names = suitesWithoutResults.map((suite) => suite.name).join(", ");
|
||||
return `Test suites failed to run: ${names}`;
|
||||
}
|
||||
if (failedSuites.length === 0) {
|
||||
return "Jest failed without a failing test suite.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function listTests(
|
||||
command: readonly string[],
|
||||
env: NodeJS.ProcessEnv,
|
||||
): string[] | null {
|
||||
const [program, ...args] = command;
|
||||
const result = spawnSync(program, [...args, "--listTests", "--json"], {
|
||||
env,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "inherit"],
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
return null;
|
||||
}
|
||||
const listed = parseJson(result.stdout);
|
||||
return isStringArray(listed) ? listed : null;
|
||||
}
|
||||
|
||||
// --passWithNoTests lets every shard pass when the selection matches nothing Jest discovers.
|
||||
// A single empty shard is normal, so only an unsharded listing that is also empty warns.
|
||||
export function findEmptySelection(
|
||||
env: NodeJS.ProcessEnv,
|
||||
command: readonly string[] = TEST_UNIT_COMMAND,
|
||||
): string | null {
|
||||
const { UNIT_TEST_RESULTS_FILE, JEST_TEST_PATHS_FILE } = env;
|
||||
if (!UNIT_TEST_RESULTS_FILE || !JEST_TEST_PATHS_FILE) {
|
||||
return null;
|
||||
}
|
||||
const results = readResults(UNIT_TEST_RESULTS_FILE);
|
||||
const selection = readJson(JEST_TEST_PATHS_FILE);
|
||||
if (
|
||||
results === null ||
|
||||
results.testResults.length > 0 ||
|
||||
!isStringArray(selection) ||
|
||||
selection.length === 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const listed = listTests(command, env);
|
||||
if (listed === null) {
|
||||
return "Could not list Jest's unit tests to check the selection.";
|
||||
}
|
||||
if (listed.length > 0) {
|
||||
return null;
|
||||
}
|
||||
return `None of the ${selection.length} selected unit specs match a test Jest discovers, so no unit tests ran.`;
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
if (process.env.TEST_OUTCOME === "failure") {
|
||||
const failure = findRunnerFailure(process.env);
|
||||
if (failure !== null) {
|
||||
console.error(`::error::${failure}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
} else {
|
||||
const warning = findEmptySelection(process.env);
|
||||
if (warning !== null) {
|
||||
console.warn(`::warning::${warning}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
101
.github/scripts/create-test-plan.ts
vendored
101
.github/scripts/create-test-plan.ts
vendored
@@ -1,45 +1,37 @@
|
||||
// I/O entrypoint: gather inputs (env vars, the test-file lists, the cruise graph on request),
|
||||
// hand them to createTestPlan, which does the computing, and publish its small stats output.
|
||||
// I/O entrypoint: gather inputs (env vars, the cruise graph, the test-file
|
||||
// lists) and hand them to createTestPlan, which does the computing.
|
||||
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import { appendFileSync, existsSync, readFileSync } from "node:fs";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
|
||||
import micromatch from "micromatch";
|
||||
|
||||
import { MAIN_APP_STORY_GLOBS } from "../../.storybook/story-files.cjs";
|
||||
import { elements, rules } from "../../frontend/lint/module-boundaries.mjs";
|
||||
|
||||
import { type FileDependency, parseCruiseModules } from "./affected-modules";
|
||||
import { createTestPlan } from "./affected-tests";
|
||||
import { listSpecFiles } from "./e2e-spec-globs.mjs";
|
||||
|
||||
// The specs `bun run test-unit` runs.
|
||||
const UNIT_ROOTS = ["."];
|
||||
const UNIT_ROOTS = ["frontend/src", "enterprise/frontend/src"];
|
||||
const UNIT_GLOBS = [
|
||||
"**/*.unit.spec.{js,jsx,ts,tsx}",
|
||||
"!.github/**", // the ci-scripts project, which test-unit ignores
|
||||
"!release/**", // has its own jest config
|
||||
"!enterprise/frontend/src/custom-viz/**", // modulePathIgnorePatterns in jest.config.js
|
||||
"!**/*.leak.unit.spec.{js,jsx,ts,tsx}", // run only by jest.memory.conf.js
|
||||
"frontend/src/**/*.unit.spec.{js,jsx,ts,tsx}",
|
||||
"enterprise/frontend/src/**/*.unit.spec.{js,jsx,ts,tsx}",
|
||||
];
|
||||
|
||||
const FRONTEND_ROOTS = ["frontend", "enterprise/frontend"];
|
||||
|
||||
const LOKI_PREVIEW_FILE = ".storybook/preview.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;
|
||||
const STORY_ROOTS = ["frontend", "enterprise/frontend"];
|
||||
const STORY_GLOBS = [
|
||||
"frontend/**/*.stories.{js,jsx,ts,tsx}",
|
||||
"enterprise/frontend/**/*.stories.{js,jsx,ts,tsx}",
|
||||
];
|
||||
|
||||
// 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", "-z", "--", ...roots], {
|
||||
const tracked = execFileSync("git", ["ls-files", "--", ...roots], {
|
||||
encoding: "utf8",
|
||||
maxBuffer: LS_FILES_MAX_BUFFER,
|
||||
})
|
||||
.split("\0")
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
return micromatch(tracked, globs, { dot: true });
|
||||
}
|
||||
@@ -51,43 +43,26 @@ const csvToList = (csv: string | undefined) =>
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
// Runs dependency-cruiser over the frontend sources and the Storybook preview and parses its edges.
|
||||
// Null falls back to the rules graph, so a failed cruise never breaks the plan.
|
||||
function loadFileDependencies(): FileDependency[] | null {
|
||||
const output = "dependency-graph.json";
|
||||
process.stderr.write("Building usage graph with dependency-cruiser.\n");
|
||||
try {
|
||||
// stdout is the plan JSON, so the cruise's own output goes to stderr.
|
||||
const result = spawnSync(
|
||||
"bunx",
|
||||
[
|
||||
"depcruise",
|
||||
"frontend/src",
|
||||
"enterprise/frontend/src",
|
||||
LOKI_PREVIEW_FILE,
|
||||
"--config",
|
||||
".dependency-cruiser.cjs",
|
||||
"--output-type",
|
||||
"json",
|
||||
"--output-to",
|
||||
output,
|
||||
],
|
||||
{ stdio: ["ignore", 2, 2] },
|
||||
);
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
// Reads the dependency-cruiser graph (DEP_GRAPH_JSON). Null falls back to the
|
||||
// rules graph, so a missing or unparseable file never breaks the plan.
|
||||
function readFileDependencies(): FileDependency[] | null {
|
||||
const path = process.env.DEP_GRAPH_JSON;
|
||||
if (path && existsSync(path)) {
|
||||
try {
|
||||
const { modules } = JSON.parse(readFileSync(path, "utf8"));
|
||||
process.stderr.write(`Using usage graph from ${path}.\n`);
|
||||
return parseCruiseModules(modules);
|
||||
} catch (error) {
|
||||
process.stderr.write(
|
||||
`Failed to read ${path}; falling back to rules graph: ${error}\n`,
|
||||
);
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`depcruise exited with status ${result.status}`);
|
||||
}
|
||||
const { modules } = JSON.parse(readFileSync(output, "utf8"));
|
||||
return parseCruiseModules(modules);
|
||||
} catch (error) {
|
||||
} else {
|
||||
process.stderr.write(
|
||||
`Failed to build usage graph; falling back to rules graph: ${error}\n`,
|
||||
"No DEP_GRAPH_JSON found; falling back to rules graph.\n",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Reads the nightly coverage manifest (E2E_SPEC_MANIFEST): { builtAt, specs:
|
||||
@@ -117,13 +92,12 @@ const testPlan = createTestPlan({
|
||||
elements,
|
||||
rules,
|
||||
changedFiles: csvToList(process.env.CHANGED_FILES),
|
||||
loadFileDependencies,
|
||||
fileDependencies: readFileDependencies(),
|
||||
testFilesBySuite: {
|
||||
unit: listFiles(UNIT_ROOTS, UNIT_GLOBS),
|
||||
loki: listFiles(FRONTEND_ROOTS, MAIN_APP_STORY_GLOBS),
|
||||
loki: listFiles(STORY_ROOTS, STORY_GLOBS),
|
||||
e2e: listSpecFiles(),
|
||||
},
|
||||
lokiPreviewFile: LOKI_PREVIEW_FILE,
|
||||
e2eSpecFiles: readE2eSpecFiles(),
|
||||
unitInfraTouched: process.env.UNIT_INFRA_TOUCHED === "true",
|
||||
lokiInfraTouched: process.env.LOKI_INFRA_TOUCHED === "true",
|
||||
@@ -131,15 +105,8 @@ const testPlan = createTestPlan({
|
||||
sharedSourcesTouched: process.env.SHARED_SOURCES_TOUCHED === "true",
|
||||
feFilesChanged: csvToList(process.env.FE_CHANGED_FILES).length,
|
||||
beFilesChanged: csvToList(process.env.BE_CHANGED_FILES).length,
|
||||
feFilesTotal: listFiles(FRONTEND_ROOTS, ["**"]).length,
|
||||
feFilesTotal: listFiles(["frontend", "enterprise/frontend"], ["**"]).length,
|
||||
beFilesTotal: listFiles(["src", "enterprise/backend"], ["**"]).length,
|
||||
});
|
||||
|
||||
process.stdout.write(JSON.stringify(testPlan) + "\n");
|
||||
|
||||
if (process.env.GITHUB_OUTPUT) {
|
||||
appendFileSync(
|
||||
process.env.GITHUB_OUTPUT,
|
||||
`stats=${JSON.stringify(testPlan.stats)}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
142
.github/scripts/file-paths.unit.spec.ts
vendored
142
.github/scripts/file-paths.unit.spec.ts
vendored
@@ -1,142 +0,0 @@
|
||||
import { readFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
|
||||
import { load } from "js-yaml";
|
||||
import { isMatch } from "micromatch";
|
||||
|
||||
const FILTERS = join(__dirname, "..", "file-paths.yaml");
|
||||
|
||||
type Filter = string | Filter[] | { [changeType: string]: Filter };
|
||||
|
||||
// A filter is a list of patterns, and an entry may name the change types it applies to as
|
||||
// { "added|modified": pattern }. Either way the patterns are what matters here.
|
||||
const patterns = (value: Filter): string[] => {
|
||||
if (typeof value === "string") {
|
||||
return [value];
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.flatMap(patterns);
|
||||
}
|
||||
return Object.values(value).flatMap(patterns);
|
||||
};
|
||||
|
||||
describe("file-paths.yaml", () => {
|
||||
// js-yaml returns an untyped value, and this repository file defines named path filters.
|
||||
const filters = load(readFileSync(FILTERS, "utf8")) as Record<string, Filter>;
|
||||
|
||||
// dorny/paths-filter matches with picomatch, which micromatch wraps, so this asks the same
|
||||
// question CI asks of a changed path.
|
||||
const matches = (name: string, path: string) =>
|
||||
patterns(filters[name]).some((pattern) =>
|
||||
isMatch(path, pattern, { dot: true }),
|
||||
);
|
||||
|
||||
// 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
|
||||
// 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)
|
||||
.filter((pattern) => pattern.startsWith("!"))
|
||||
.map((pattern) => `${name}: ${pattern}`),
|
||||
);
|
||||
|
||||
expect(negated).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(["backend_sources", "backend_all"])(
|
||||
"%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);
|
||||
},
|
||||
);
|
||||
|
||||
it("runs CI script tests when the Node version changes", () => {
|
||||
expect(matches("ci_scripts", ".nvmrc")).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"jest.config.js",
|
||||
"jest.base.conf.js",
|
||||
"jest.esm-packages.js",
|
||||
"jest.tz.unit.conf.js",
|
||||
])("runs every frontend unit test when %s changes", (path) => {
|
||||
expect(matches("frontend_specs", path)).toBe(true);
|
||||
expect(matches("frontend_unit_infra", path)).toBe(true);
|
||||
});
|
||||
|
||||
it("runs every frontend unit test when an SDK jest setup file changes", () => {
|
||||
expect(
|
||||
matches(
|
||||
"frontend_unit_infra",
|
||||
"frontend/src/embedding-sdk-shared/jest/setup-env.ts",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"jest.config.js",
|
||||
"jest.base.conf.js",
|
||||
"jest.esm-packages.js",
|
||||
".storybook/story-files.cjs",
|
||||
".github/workflows/loki.yml",
|
||||
])("runs CI script tests when %s changes", (path) => {
|
||||
expect(matches("ci_scripts", path)).toBe(true);
|
||||
});
|
||||
|
||||
it("runs the ratchet check on the ratchets file", () => {
|
||||
expect(matches("project_ratchet_checks", ".clj-kondo/ratchets.edn")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
matches(
|
||||
"project_ratchet_checks",
|
||||
".clj-kondo/config/modules/ratchets.edn",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"loki.config.js",
|
||||
".storybook/story-files.cjs",
|
||||
".storybook/preview.tsx",
|
||||
".loki/reference/chrome.laptop-Button.png",
|
||||
".github/workflows/loki.yml",
|
||||
".github/workflows/run-tests.yml",
|
||||
".github/actions/prepare-frontend/action.yml",
|
||||
"frontend/build/shared/rspack/css-config.js",
|
||||
"frontend/test/__support__/custom-viz-fixtures/calendar-heatmap/index.js",
|
||||
"patches/@loki+browser+0.35.0.patch",
|
||||
"frontend/src/metabase/css/index.module.css",
|
||||
"frontend/src/metabase/css/core/base.module.css",
|
||||
"resources/frontend_client/app/fonts/Lato/lato-v16-latin-regular.woff2",
|
||||
"resources/frontend_client/app/assets/img/no_results.svg",
|
||||
"frontend/src/metabase/ui/components/icons/Icon/icons/warning.svg",
|
||||
"enterprise/frontend/src/metabase-enterprise/google_drive/database-error.svg",
|
||||
])("runs all Loki stories when %s changes", (file) => {
|
||||
expect(matches("frontend_loki_all", file)).toBe(true);
|
||||
expect(matches("frontend_loki_infra", file)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"frontend/src/metabase/ui/Button.stories.tsx",
|
||||
"frontend/src/metabase/ui/components/feedback/Alert/Alert.module.css",
|
||||
"frontend/build/embedding-sdk/rspack.config.js",
|
||||
])("allows a change to %s to narrow the Loki run", (file) => {
|
||||
expect(matches("frontend_loki_all", file)).toBe(true);
|
||||
expect(matches("frontend_loki_infra", file)).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
".github/workflows/frontend.yml",
|
||||
"frontend/test/metabase/scenarios/Button.unit.spec.tsx",
|
||||
])("does not run Loki stories when %s changes", (file) => {
|
||||
expect(matches("frontend_loki_all", file)).toBe(false);
|
||||
});
|
||||
});
|
||||
237
.github/scripts/frontend-test-plan.unit.spec.ts
vendored
237
.github/scripts/frontend-test-plan.unit.spec.ts
vendored
@@ -1,237 +0,0 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
realpathSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
import { load } from "js-yaml";
|
||||
|
||||
import {
|
||||
findEmptySelection,
|
||||
findRunnerFailure,
|
||||
} from "./check-unit-test-results";
|
||||
import { prepareTestSelection } from "./prepare-test-selection";
|
||||
|
||||
type Step = { name: string; run?: string; "continue-on-error"?: boolean };
|
||||
// js-yaml returns an untyped value, and this repository workflow defines the job's steps.
|
||||
const workflow = load(
|
||||
readFileSync(resolve(__dirname, "../workflows/frontend.yml"), "utf8"),
|
||||
) as { jobs: { "fe-tests-unit": { steps: Step[] } } };
|
||||
const steps = workflow.jobs["fe-tests-unit"].steps;
|
||||
const checkScript = resolve(__dirname, "check-unit-test-results.ts");
|
||||
const filter = resolve(
|
||||
__dirname,
|
||||
"../../frontend/test/jest-test-paths-filter.ts",
|
||||
);
|
||||
|
||||
describe("frontend test plan handoff", () => {
|
||||
let dir: string;
|
||||
let env: NodeJS.ProcessEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.spyOn(console, "warn").mockImplementation(() => {});
|
||||
// Jest reports real paths, and macOS's temporary directory is behind a symlink.
|
||||
dir = realpathSync(mkdtempSync(join(tmpdir(), "frontend-test-plan-")));
|
||||
mkdirSync(join(dir, "test-plan"));
|
||||
writeFileSync(join(dir, "output"), "");
|
||||
env = {
|
||||
...process.env,
|
||||
NODE_OPTIONS: "",
|
||||
JEST_TEST_PATHS_FILE: "",
|
||||
RUNNER_TEMP: dir,
|
||||
GITHUB_OUTPUT: join(dir, "output"),
|
||||
PLAN_DOWNLOADED: "success",
|
||||
JEST_JUNIT_OUTPUT_DIR: dir,
|
||||
JEST_JUNIT_OUTPUT_NAME: "junit.xml",
|
||||
UNIT_TEST_RESULTS_FILE: join(dir, "unit-test-results.json"),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function plan(files: string[], total = 2) {
|
||||
return {
|
||||
fe_unit_specs_to_run: files,
|
||||
stats: { unit_specs_all: total, unit_specs_to_run_usage: files.length },
|
||||
};
|
||||
}
|
||||
|
||||
function writePlan(value: unknown) {
|
||||
writeFileSync(join(dir, "test-plan/test-plan.json"), JSON.stringify(value));
|
||||
}
|
||||
|
||||
function jestCommand(useFilter: boolean) {
|
||||
return [
|
||||
process.execPath,
|
||||
require.resolve("jest/bin/jest"),
|
||||
"--config",
|
||||
JSON.stringify({
|
||||
rootDir: dir,
|
||||
testMatch: ["**/*.spec.cjs"],
|
||||
reporters: ["default", require.resolve("jest-junit")],
|
||||
...(useFilter && { filter }),
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
function runJest(
|
||||
files: Record<string, string>,
|
||||
useFilter = false,
|
||||
args: string[] = [],
|
||||
) {
|
||||
for (const [name, source] of Object.entries(files)) {
|
||||
writeFileSync(join(dir, name), source);
|
||||
}
|
||||
const [program, ...commandArgs] = jestCommand(useFilter);
|
||||
return spawnSync(
|
||||
program,
|
||||
[
|
||||
...commandArgs,
|
||||
"--runInBand",
|
||||
"--watch=false",
|
||||
"--json",
|
||||
`--outputFile=${env.UNIT_TEST_RESULTS_FILE}`,
|
||||
...(useFilter ? ["--passWithNoTests"] : []),
|
||||
...args,
|
||||
],
|
||||
{ cwd: dir, env, encoding: "utf8" },
|
||||
);
|
||||
}
|
||||
|
||||
function readResults() {
|
||||
return JSON.parse(
|
||||
readFileSync(join(dir, "unit-test-results.json"), "utf8"),
|
||||
);
|
||||
}
|
||||
|
||||
function writeSelection(files: string[]) {
|
||||
env.JEST_TEST_PATHS_FILE = join(dir, "unit-specs.json");
|
||||
writeFileSync(env.JEST_TEST_PATHS_FILE, JSON.stringify(files));
|
||||
}
|
||||
|
||||
it("lets ordinary assertion failures reach quarantine", () => {
|
||||
expect(
|
||||
runJest({ "fail.spec.cjs": "test('fails', () => expect(1).toBe(2));" })
|
||||
.status,
|
||||
).toBe(1);
|
||||
expect(findRunnerFailure(env)).toBeNull();
|
||||
});
|
||||
|
||||
it("lets suite hook failures reach quarantine", () => {
|
||||
expect(
|
||||
runJest({
|
||||
"hook.spec.cjs":
|
||||
"test('passes', () => {}); afterAll(() => { throw new Error('hook'); });",
|
||||
}).status,
|
||||
).toBe(1);
|
||||
expect(readFileSync(join(dir, "junit.xml"), "utf8")).toContain(
|
||||
"Test execution failure",
|
||||
);
|
||||
expect(findRunnerFailure(env)).toBeNull();
|
||||
});
|
||||
|
||||
it("fails a filter crash even though the test step permits errors", () => {
|
||||
env.JEST_TEST_PATHS_FILE = join(dir, "broken.json");
|
||||
writeFileSync(env.JEST_TEST_PATHS_FILE, "[");
|
||||
expect(
|
||||
runJest({ "pass.spec.cjs": "test('passes', () => {});" }, true).status,
|
||||
).toBe(1);
|
||||
env.TEST_OUTCOME = "failure";
|
||||
const result = spawnSync("bun", [checkScript], { env, encoding: "utf8" });
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain("::error::");
|
||||
const step = steps.find((step) =>
|
||||
step.run?.includes("check-unit-test-results.ts"),
|
||||
);
|
||||
expect(step?.["continue-on-error"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("fails suite startup errors even alongside ordinary assertion failures", () => {
|
||||
expect(
|
||||
runJest({
|
||||
"crash.spec.cjs": "throw new Error('startup crash');",
|
||||
"fail.spec.cjs": "test('fails', () => expect(1).toBe(2));",
|
||||
}).status,
|
||||
).toBe(1);
|
||||
expect(readFileSync(join(dir, "junit.xml"), "utf8")).not.toContain(
|
||||
"crash.spec.cjs",
|
||||
);
|
||||
expect(findRunnerFailure(env)).toContain("crash.spec.cjs");
|
||||
});
|
||||
|
||||
it("fails when JUnit results are missing", () => {
|
||||
runJest({ "fail.spec.cjs": "test('fails', () => expect(1).toBe(2));" });
|
||||
rmSync(join(dir, "junit.xml"));
|
||||
expect(findRunnerFailure(env)).not.toBeNull();
|
||||
});
|
||||
|
||||
it.each([true, false])(
|
||||
"loads the TypeScript filter natively with an empty selection: %s",
|
||||
(empty) => {
|
||||
writePlan(plan(empty ? [] : [join(dir, "pass.spec.cjs")]));
|
||||
prepareTestSelection("unit", env);
|
||||
env.JEST_TEST_PATHS_FILE = join(dir, "unit-specs.json");
|
||||
const result = runJest(
|
||||
{
|
||||
"pass.spec.cjs": "test('passes', () => {});",
|
||||
"fail.spec.cjs":
|
||||
"test('excluded', () => { throw new Error('must not run'); });",
|
||||
},
|
||||
true,
|
||||
);
|
||||
expect(result.status === 0 ? "" : result.stderr).toBe("");
|
||||
expect(result.status).toBe(0);
|
||||
expect(readResults()).toMatchObject({
|
||||
numTotalTestSuites: empty ? 0 : 1,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("warns when a narrowed run matches none of Jest's test paths", () => {
|
||||
writeSelection([join(dir, "renamed.spec.cjs")]);
|
||||
expect(
|
||||
runJest({ "pass.spec.cjs": "test('passes', () => {});" }, true).status,
|
||||
).toBe(0);
|
||||
expect(findEmptySelection(env, jestCommand(true))).toContain(
|
||||
"None of the 1 selected unit specs",
|
||||
);
|
||||
});
|
||||
|
||||
it("warns through Bun when the unit test step passed", () => {
|
||||
writeSelection([join(dir, "renamed.spec.cjs")]);
|
||||
runJest({ "pass.spec.cjs": "test('passes', () => {});" }, true);
|
||||
env.TEST_OUTCOME = "success";
|
||||
const result = spawnSync("bun", [checkScript], { env, encoding: "utf8" });
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toContain(
|
||||
"::warning::None of the 1 selected unit specs",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not warn for a shard the selection leaves empty", () => {
|
||||
writeSelection([join(dir, "pass.spec.cjs")]);
|
||||
const result = runJest(
|
||||
{ "pass.spec.cjs": "test('passes', () => {});" },
|
||||
true,
|
||||
["--shard=2/2"],
|
||||
);
|
||||
expect(result.status).toBe(0);
|
||||
expect(readResults()).toMatchObject({ numTotalTestSuites: 0 });
|
||||
expect(findEmptySelection(env, jestCommand(true))).toBeNull();
|
||||
});
|
||||
|
||||
it("does not list tests for an empty selection", () => {
|
||||
writeSelection([]);
|
||||
runJest({ "pass.spec.cjs": "test('passes', () => {});" }, true);
|
||||
expect(findEmptySelection(env, ["false"])).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,44 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- Match Jest's direct CommonJS loading contract.
|
||||
const filter = require("../../frontend/test/jest-test-paths-filter");
|
||||
|
||||
describe("jest-test-paths-filter", () => {
|
||||
const originalPathsFile = process.env.JEST_TEST_PATHS_FILE;
|
||||
let tempDir: string;
|
||||
let pathsFile: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "jest-test-paths-"));
|
||||
pathsFile = path.join(tempDir, "paths.json");
|
||||
process.env.JEST_TEST_PATHS_FILE = pathsFile;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
if (originalPathsFile === undefined) {
|
||||
delete process.env.JEST_TEST_PATHS_FILE;
|
||||
} else {
|
||||
process.env.JEST_TEST_PATHS_FILE = originalPathsFile;
|
||||
}
|
||||
});
|
||||
|
||||
it("should keep the listed paths and drop the rest", () => {
|
||||
const one = path.resolve("frontend/one.unit.spec.ts");
|
||||
const two = path.resolve("frontend/two.unit.spec.ts");
|
||||
const three = path.resolve("frontend/three.unit.spec.ts");
|
||||
fs.writeFileSync(pathsFile, JSON.stringify([one, two]));
|
||||
|
||||
expect(filter([one, three, two])).toEqual({ filtered: [one, two] });
|
||||
});
|
||||
|
||||
it("should resolve relative paths against the working directory", () => {
|
||||
fs.writeFileSync(pathsFile, JSON.stringify(["frontend/one.unit.spec.ts"]));
|
||||
const one = path.resolve("frontend/one.unit.spec.ts");
|
||||
const two = path.resolve("frontend/two.unit.spec.ts");
|
||||
|
||||
expect(filter([one, two])).toEqual({ filtered: [one] });
|
||||
});
|
||||
});
|
||||
20
.github/scripts/measure-bundle-sizes.js
vendored
20
.github/scripts/measure-bundle-sizes.js
vendored
@@ -24,15 +24,6 @@ const SDK_ROOT = "resources/frontend_client/app/embedding-sdk";
|
||||
const SDK_RUNTIME_RE = /embedding-sdk-chunk-runtime\./;
|
||||
const notRuntime = name => !SDK_RUNTIME_RE.test(name);
|
||||
|
||||
// One locale catalogue per chunk, named by the `webpackChunkName: "locale-[request]"`
|
||||
// comment in localization.ts. A session loads exactly one and an English session
|
||||
// loads none, so counting all 36 in a reachable total overstates it by 35x: 8.5 MB
|
||||
// gzip against the SDK's 3.1 MB. Their size also tracks the translation process
|
||||
// rather than any code change, so counting them makes every catalogue update look
|
||||
// like a bundle regression.
|
||||
const LOCALE_CHUNK_RE = /(^|\/)locale-/;
|
||||
const notLocale = name => !LOCALE_CHUNK_RE.test(name);
|
||||
|
||||
const assetFiles = (root, names) => names.map(name => path.join(root, name));
|
||||
const unique = files => [...new Set(files)];
|
||||
|
||||
@@ -125,8 +116,7 @@ function selectAppAssets(stats) {
|
||||
if (!initialAssets) {
|
||||
throw new Error('app: stats-main.json / "app-main" entrypoint missing');
|
||||
}
|
||||
const reachableAssets = entrypointJsAssets(stats, "app-main", "reachableAssets");
|
||||
return { initialAssets, reachableAssets: reachableAssets?.filter(notLocale) ?? null };
|
||||
return { initialAssets, reachableAssets: entrypointJsAssets(stats, "app-main", "reachableAssets") };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -146,9 +136,7 @@ function selectLegacySdkAssets(stats, { sdkAsyncChunksLoadable }) {
|
||||
const initial = unique(initialNames).filter(notRuntime);
|
||||
return {
|
||||
initialNames: initial,
|
||||
reachableNames: sdkAsyncChunksLoadable
|
||||
? unique(reachableNames).filter(notRuntime).filter(notLocale)
|
||||
: initial,
|
||||
reachableNames: sdkAsyncChunksLoadable ? unique(reachableNames).filter(notRuntime) : initial,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -173,9 +161,7 @@ function selectChunkedSdkAssets(stats, { sdkAsyncChunksLoadable }) {
|
||||
const bootstrapReachable = entrypointJsAssets(stats, "embedding-sdk-bootstrap", "reachableAssets") || bootstrapInitial;
|
||||
return {
|
||||
initialNames: unique([...bootstrapInitial, ...chunkedInitial]).filter(notRuntime),
|
||||
reachableNames: unique([...bootstrapReachable, ...chunkedReachable])
|
||||
.filter(notRuntime)
|
||||
.filter(notLocale),
|
||||
reachableNames: unique([...bootstrapReachable, ...chunkedReachable]).filter(notRuntime),
|
||||
includesAsyncChunks: chunkedReachableAssets != null && sdkAsyncChunksLoadable,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ const {
|
||||
} = require("./measure-bundle-sizes");
|
||||
|
||||
const RUNTIME = "embedding-sdk-chunk-runtime.deadbeef.js";
|
||||
const LOCALE = "chunks/locale-fr-json.deadbeef.js";
|
||||
|
||||
const stats = entrypoints => ({ entrypoints });
|
||||
|
||||
@@ -31,13 +30,6 @@ describe("selectAppAssets", () => {
|
||||
it("throws when the app-main entrypoint is missing", () => {
|
||||
expect(() => selectAppAssets(stats({}))).toThrow("app-main");
|
||||
});
|
||||
|
||||
it("leaves the locale catalogues out of the reachable total", () => {
|
||||
const s = stats({
|
||||
"app-main": { assets: ["main.js"], reachableAssets: ["main.js", "locale-fr-json.abc.js"] },
|
||||
});
|
||||
expect(selectAppAssets(s).reachableAssets).toEqual(["main.js"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectChunkedSdkAssets", () => {
|
||||
@@ -58,15 +50,6 @@ describe("selectChunkedSdkAssets", () => {
|
||||
expect(result.includesAsyncChunks).toBe(false);
|
||||
});
|
||||
|
||||
it("leaves the locale catalogues out of the reachable total", () => {
|
||||
const withLocale = {
|
||||
...base,
|
||||
"embedding-sdk-chunked": { assets: ["a.js", RUNTIME], reachableAssets: ["a.js", "b.js", LOCALE, RUNTIME] },
|
||||
};
|
||||
const result = selectChunkedSdkAssets(stats(withLocale), { sdkAsyncChunksLoadable: true });
|
||||
expect(result.reachableNames).toEqual(["boot.js", "a.js", "b.js"]);
|
||||
});
|
||||
|
||||
it("collapses total to initial when the stats carry no reachable graph", () => {
|
||||
const noGraph = { "embedding-sdk-chunked": { assets: ["a.js"] } };
|
||||
const result = selectChunkedSdkAssets(stats(noGraph), { sdkAsyncChunksLoadable: true });
|
||||
|
||||
122
.github/scripts/prepare-test-selection.ts
vendored
122
.github/scripts/prepare-test-selection.ts
vendored
@@ -1,122 +0,0 @@
|
||||
import { appendFileSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import type { TestPlan, TestPlanStats } from "./affected-tests";
|
||||
|
||||
type NumberStat = {
|
||||
[K in keyof TestPlanStats]: TestPlanStats[K] extends number ? K : never;
|
||||
}[keyof TestPlanStats];
|
||||
|
||||
type Suite = {
|
||||
name: string;
|
||||
filesKey: Exclude<keyof TestPlan, "stats">;
|
||||
totalKey: NumberStat;
|
||||
selectedKey: NumberStat;
|
||||
pathsFile: string;
|
||||
};
|
||||
|
||||
export const SUITES = {
|
||||
unit: {
|
||||
name: "unit",
|
||||
filesKey: "fe_unit_specs_to_run",
|
||||
totalKey: "unit_specs_all",
|
||||
selectedKey: "unit_specs_to_run_usage",
|
||||
pathsFile: "unit-specs.json",
|
||||
},
|
||||
loki: {
|
||||
name: "Loki",
|
||||
filesKey: "loki_stories_to_run",
|
||||
totalKey: "loki_stories_all",
|
||||
selectedKey: "loki_stories_to_run_usage",
|
||||
pathsFile: "loki-stories.json",
|
||||
},
|
||||
e2e: {
|
||||
name: "E2E",
|
||||
filesKey: "e2e_specs_to_run",
|
||||
totalKey: "e2e_specs_all",
|
||||
selectedKey: "e2e_specs_to_run_usage",
|
||||
pathsFile: "e2e-specs.json",
|
||||
},
|
||||
} satisfies Record<string, Suite>;
|
||||
|
||||
function isSuiteName(name: string): name is keyof typeof SUITES {
|
||||
return Object.hasOwn(SUITES, name);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function isStringArray(value: unknown): value is string[] {
|
||||
return (
|
||||
Array.isArray(value) && value.every((item) => typeof item === "string")
|
||||
);
|
||||
}
|
||||
|
||||
function isCount(value: unknown): value is number {
|
||||
return typeof value === "number" && Number.isInteger(value) && value >= 0;
|
||||
}
|
||||
|
||||
// null means a full run, and [] is an explicit selection of no tests.
|
||||
function readSelection(planFile: string, suite: Suite): string[] | null {
|
||||
const plan: unknown = JSON.parse(readFileSync(planFile, "utf8"));
|
||||
const files = isRecord(plan) ? plan[suite.filesKey] : undefined;
|
||||
const stats = isRecord(plan) && isRecord(plan.stats) ? plan.stats : {};
|
||||
const total = stats[suite.totalKey];
|
||||
if (
|
||||
!isStringArray(files) ||
|
||||
files.some((file) => file.length === 0) ||
|
||||
!isCount(total) ||
|
||||
files.length !== stats[suite.selectedKey] ||
|
||||
files.length > total
|
||||
) {
|
||||
throw new Error(`Invalid ${suite.name} test selection`);
|
||||
}
|
||||
return files.length === total ? null : files;
|
||||
}
|
||||
|
||||
export function prepareTestSelection(
|
||||
suiteName: keyof typeof SUITES,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
) {
|
||||
const suite: Suite = SUITES[suiteName];
|
||||
const { RUNNER_TEMP, GITHUB_OUTPUT, PLAN_DOWNLOADED } = env;
|
||||
if (!RUNNER_TEMP || !GITHUB_OUTPUT) {
|
||||
throw new Error("RUNNER_TEMP and GITHUB_OUTPUT are required");
|
||||
}
|
||||
|
||||
let files: string[] | null;
|
||||
try {
|
||||
if (PLAN_DOWNLOADED !== "success") {
|
||||
throw new Error("Test plan download failed");
|
||||
}
|
||||
files = readSelection(join(RUNNER_TEMP, "test-plan/test-plan.json"), suite);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`::warning::Test plan unavailable or invalid, running the full ${suite.name} suite: ${error}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (files === null) {
|
||||
appendFileSync(GITHUB_OUTPUT, "selection=full\n");
|
||||
return;
|
||||
}
|
||||
|
||||
const pathsFile = join(RUNNER_TEMP, suite.pathsFile);
|
||||
writeFileSync(pathsFile, JSON.stringify(files));
|
||||
appendFileSync(
|
||||
GITHUB_OUTPUT,
|
||||
`paths-file=${pathsFile}\nselection=${files.length === 0 ? "empty" : "narrowed"}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
const suiteName = process.argv[2] ?? "";
|
||||
if (!isSuiteName(suiteName)) {
|
||||
throw new Error(
|
||||
`Unknown test suite "${suiteName}", expected one of: ${Object.keys(SUITES).join(", ")}`,
|
||||
);
|
||||
}
|
||||
prepareTestSelection(suiteName);
|
||||
}
|
||||
268
.github/scripts/prepare-test-selection.unit.spec.ts
vendored
268
.github/scripts/prepare-test-selection.unit.spec.ts
vendored
@@ -1,268 +0,0 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
import { load } from "js-yaml";
|
||||
|
||||
import { SUITES, prepareTestSelection } from "./prepare-test-selection";
|
||||
|
||||
type Suite = (typeof SUITES)[keyof typeof SUITES];
|
||||
|
||||
type Step = {
|
||||
id?: string;
|
||||
if?: string;
|
||||
uses?: string;
|
||||
run?: string;
|
||||
env?: Record<string, string>;
|
||||
with?: Record<string, string | boolean>;
|
||||
};
|
||||
|
||||
const SCRIPT = resolve(__dirname, "prepare-test-selection.ts");
|
||||
const SUITE_NAMES = ["unit", "loki", "e2e"] satisfies (keyof typeof SUITES)[];
|
||||
|
||||
function loadWorkflow(file: string) {
|
||||
// js-yaml returns an untyped value, and these repository workflows define jobs with steps.
|
||||
return load(
|
||||
readFileSync(resolve(__dirname, "../workflows", file), "utf8"),
|
||||
) as {
|
||||
jobs: Record<
|
||||
string,
|
||||
{ if?: string; outputs?: Record<string, string>; steps?: Step[] }
|
||||
>;
|
||||
};
|
||||
}
|
||||
|
||||
function loadAction() {
|
||||
// js-yaml returns an untyped value, and this repository action defines outputs and steps.
|
||||
return load(
|
||||
readFileSync(
|
||||
resolve(__dirname, "../actions/prepare-test-selection/action.yml"),
|
||||
"utf8",
|
||||
),
|
||||
) as {
|
||||
outputs: Record<string, { value: string }>;
|
||||
runs: { steps: Step[] };
|
||||
};
|
||||
}
|
||||
|
||||
describe("prepareTestSelection", () => {
|
||||
const { loki } = SUITES;
|
||||
let dir: string;
|
||||
let env: NodeJS.ProcessEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.spyOn(console, "warn").mockImplementation(() => {});
|
||||
dir = mkdtempSync(join(tmpdir(), "test-selection-"));
|
||||
mkdirSync(join(dir, "test-plan"));
|
||||
writeFileSync(join(dir, "output"), "");
|
||||
env = {
|
||||
...process.env,
|
||||
RUNNER_TEMP: dir,
|
||||
GITHUB_OUTPUT: join(dir, "output"),
|
||||
PLAN_DOWNLOADED: "success",
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function plan(files: unknown, total = 2, suite: Suite = loki) {
|
||||
return {
|
||||
[suite.filesKey]: files,
|
||||
stats: {
|
||||
[suite.totalKey]: total,
|
||||
[suite.selectedKey]: Array.isArray(files) ? files.length : 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function writePlan(value: unknown) {
|
||||
writeFileSync(join(dir, "test-plan/test-plan.json"), JSON.stringify(value));
|
||||
}
|
||||
|
||||
const output = () => readFileSync(join(dir, "output"), "utf8");
|
||||
|
||||
it.each([{ files: ["one.spec.cjs"] }, { files: [] }])(
|
||||
"preserves an explicit selection: $files",
|
||||
({ files }) => {
|
||||
writePlan(plan(files));
|
||||
prepareTestSelection("loki", env);
|
||||
expect(output()).toBe(
|
||||
`paths-file=${join(dir, loki.pathsFile)}\nselection=${files.length > 0 ? "narrowed" : "empty"}\n`,
|
||||
);
|
||||
expect(
|
||||
JSON.parse(readFileSync(join(dir, loki.pathsFile), "utf8")),
|
||||
).toEqual(files);
|
||||
},
|
||||
);
|
||||
|
||||
it("writes the selection when invoked through Bun in CI", () => {
|
||||
writePlan(plan(["one.spec.cjs"]));
|
||||
const result = spawnSync("bun", [SCRIPT, "loki"], {
|
||||
env,
|
||||
encoding: "utf8",
|
||||
});
|
||||
expect(result.status === 0 ? "" : result.stderr).toBe("");
|
||||
expect(result.status).toBe(0);
|
||||
expect(output()).toBe(
|
||||
`paths-file=${join(dir, loki.pathsFile)}\nselection=narrowed\n`,
|
||||
);
|
||||
});
|
||||
|
||||
it("writes no paths file for a full selection", () => {
|
||||
writePlan(plan(["one.spec.cjs", "two.spec.cjs"]));
|
||||
prepareTestSelection("loki", env);
|
||||
expect(output()).toBe("selection=full\n");
|
||||
});
|
||||
|
||||
it.each([
|
||||
null,
|
||||
{},
|
||||
plan("[]"),
|
||||
plan([null]),
|
||||
{ ...plan([]), [loki.filesKey]: ["one.spec.cjs"] },
|
||||
plan([""]),
|
||||
plan(["one.spec.cjs"], 0),
|
||||
])("runs in full for an invalid plan: %j", (value) => {
|
||||
writePlan(value);
|
||||
prepareTestSelection("loki", env);
|
||||
expect(console.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining("::warning::"),
|
||||
);
|
||||
expect(output()).toBe("");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ scenario: "the test plan is missing", setup: () => {} },
|
||||
{
|
||||
scenario: "the test plan is truncated",
|
||||
setup: () => {
|
||||
writeFileSync(
|
||||
join(dir, "test-plan/test-plan.json"),
|
||||
`{"${loki.filesKey}":[`,
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
scenario: "the download failed",
|
||||
setup: () => {
|
||||
writePlan(plan([])); // Even a partial download must not be trusted.
|
||||
env.PLAN_DOWNLOADED = "failure";
|
||||
},
|
||||
},
|
||||
])("runs in full when $scenario", ({ setup }) => {
|
||||
setup();
|
||||
prepareTestSelection("loki", env);
|
||||
expect(console.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining("::warning::"),
|
||||
);
|
||||
expect(output()).toBe("");
|
||||
});
|
||||
|
||||
it.each(SUITE_NAMES)(
|
||||
"reads the %s selection from its own plan keys",
|
||||
(suiteName) => {
|
||||
const suite = SUITES[suiteName];
|
||||
writePlan(plan(["one.spec.cjs"], 2, suite));
|
||||
prepareTestSelection(suiteName, env);
|
||||
expect(output()).toBe(
|
||||
`paths-file=${join(dir, suite.pathsFile)}\nselection=narrowed\n`,
|
||||
);
|
||||
expect(
|
||||
JSON.parse(readFileSync(join(dir, suite.pathsFile), "utf8")),
|
||||
).toEqual(["one.spec.cjs"]);
|
||||
},
|
||||
);
|
||||
|
||||
it("ignores the other suites' selections", () => {
|
||||
writePlan({
|
||||
...plan(["one.spec.cjs", "two.spec.cjs"]),
|
||||
[SUITES.unit.filesKey]: [],
|
||||
[SUITES.e2e.filesKey]: [],
|
||||
});
|
||||
prepareTestSelection("loki", env);
|
||||
expect(console.warn).not.toHaveBeenCalled();
|
||||
expect(output()).toBe("selection=full\n");
|
||||
});
|
||||
|
||||
it("rejects an unknown suite", () => {
|
||||
const result = spawnSync("bun", [SCRIPT, "toString"], {
|
||||
env,
|
||||
encoding: "utf8",
|
||||
});
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.stderr).toContain('Unknown test suite "toString"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("test selection workflow steps", () => {
|
||||
const ACTION = "./.github/actions/prepare-test-selection";
|
||||
const CONSUMERS: Record<string, { suite: keyof typeof SUITES }> = {
|
||||
"frontend.yml": { suite: "unit" },
|
||||
"loki.yml": { suite: "loki" },
|
||||
};
|
||||
|
||||
it("skips Loki visual tests only for an empty story selection", () => {
|
||||
const { jobs } = loadWorkflow("loki.yml");
|
||||
expect(jobs["story-selection"].outputs?.selection).toBe(
|
||||
"${{ steps.loki-selection.outputs.selection }}",
|
||||
);
|
||||
expect(jobs["visual-test"].if).toContain(
|
||||
"needs.story-selection.outputs.selection != 'empty'",
|
||||
);
|
||||
expect(jobs["visual-test"].if).not.toMatch(/outputs\.[\w-]+ *[!=]= *'\d+'/);
|
||||
});
|
||||
|
||||
it.each(Object.entries(CONSUMERS))(
|
||||
"selects tests through the composite action in %s",
|
||||
(file, { suite, ...inputs }) => {
|
||||
const steps = Object.values(loadWorkflow(file).jobs).flatMap(
|
||||
({ steps = [] }) => steps.filter((step) => step.uses === ACTION),
|
||||
);
|
||||
|
||||
expect(steps.length).toBeGreaterThan(0);
|
||||
for (const step of steps) {
|
||||
expect(step.with).toEqual({
|
||||
suite,
|
||||
"test-plan-artifact-id": expect.stringMatching(/^\$\{\{ .+ \}\}$/),
|
||||
...inputs,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("downloads the test plan before preparing the selection", () => {
|
||||
const { outputs, runs } = loadAction();
|
||||
const [download, prepare] = runs.steps;
|
||||
|
||||
expect(download).toMatchObject({
|
||||
uses: expect.stringMatching(/^actions\/download-artifact@/),
|
||||
"continue-on-error": "${{ inputs.require-download != 'true' }}",
|
||||
with: {
|
||||
"artifact-ids": "${{ inputs.test-plan-artifact-id }}",
|
||||
path: "${{ runner.temp }}/test-plan",
|
||||
},
|
||||
});
|
||||
expect(prepare).toMatchObject({
|
||||
env: { PLAN_DOWNLOADED: `\${{ steps.${download.id}.outcome }}` },
|
||||
run: expect.stringContaining(
|
||||
'prepare-test-selection.ts "${{ inputs.suite }}"',
|
||||
),
|
||||
});
|
||||
expect(outputs).toMatchObject({
|
||||
"paths-file": {
|
||||
value: `\${{ steps.${prepare.id}.outputs.paths-file }}`,
|
||||
},
|
||||
selection: { value: `\${{ steps.${prepare.id}.outputs.selection }}` },
|
||||
});
|
||||
});
|
||||
});
|
||||
160
.github/scripts/run-backend-test-jvms.sh
vendored
160
.github/scripts/run-backend-test-jvms.sh
vendored
@@ -1,160 +0,0 @@
|
||||
#!/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"
|
||||
226
.github/scripts/story-files.unit.spec.ts
vendored
226
.github/scripts/story-files.unit.spec.ts
vendored
@@ -1,226 +0,0 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
import micromatch, { isMatch } from "micromatch";
|
||||
import { normalizeStories } from "storybook/internal/common";
|
||||
|
||||
import {
|
||||
MAIN_APP_STORY_GLOBS,
|
||||
getStories,
|
||||
hasExplicitTitle,
|
||||
} from "../../.storybook/story-files.cjs";
|
||||
|
||||
const ROOT = resolve(__dirname, "../..");
|
||||
|
||||
const tracked = execFileSync(
|
||||
"git",
|
||||
["ls-files", "-z", "--", "frontend", "enterprise/frontend"],
|
||||
{ cwd: ROOT, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 },
|
||||
)
|
||||
.split("\0")
|
||||
.filter(Boolean);
|
||||
const planned = micromatch(tracked, MAIN_APP_STORY_GLOBS, { dot: true }).sort();
|
||||
|
||||
describe("getStories", () => {
|
||||
let tempDir: string;
|
||||
let pathsFile: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "story-files-"));
|
||||
pathsFile = join(tempDir, "stories.json");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("loads the main app stories and docs when there is no plan", () => {
|
||||
const patterns = getStories({ pathsFile: "", filter: "" });
|
||||
|
||||
expect(
|
||||
isMatch("../frontend/src/metabase/ui/Button.stories.tsx", patterns),
|
||||
).toBe(true);
|
||||
expect(isMatch("../frontend/src/metabase/ui/Button.mdx", patterns)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
isMatch(
|
||||
"../frontend/src/embedding-sdk-shared/Button.stories.tsx",
|
||||
patterns,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("builds only the files in the JSON plan", () => {
|
||||
const files = planned.slice(0, 2);
|
||||
writeFileSync(pathsFile, JSON.stringify(files));
|
||||
|
||||
expect(getStories({ pathsFile })).toEqual(
|
||||
files.map((file) => `../${file}`),
|
||||
);
|
||||
});
|
||||
|
||||
it("builds a planned story with an explicit title", () => {
|
||||
const story = join(tempDir, "Titled.stories.tsx");
|
||||
writeFileSync(
|
||||
story,
|
||||
'const meta = { title: "Components/Titled" };\nexport default meta;\n',
|
||||
);
|
||||
writeFileSync(pathsFile, JSON.stringify([story]));
|
||||
|
||||
expect(getStories({ pathsFile })).toEqual([`../${story}`]);
|
||||
});
|
||||
|
||||
it("rejects a planned story without an explicit title", () => {
|
||||
const story = join(tempDir, "Untitled.stories.tsx");
|
||||
writeFileSync(story, "export default { component: Untitled };\n");
|
||||
writeFileSync(pathsFile, JSON.stringify([story]));
|
||||
|
||||
expect(() => getStories({ pathsFile })).toThrow(story);
|
||||
});
|
||||
|
||||
it("rejects an untitled Alert story even though its args have a title", () => {
|
||||
const source = readFileSync(
|
||||
join(
|
||||
ROOT,
|
||||
"frontend/src/metabase/ui/components/feedback/Alert/Alert.stories.tsx",
|
||||
),
|
||||
"utf8",
|
||||
).replace(' title: "Components/Feedback/Alert",\n', "");
|
||||
const story = join(tempDir, "Alert.stories.tsx");
|
||||
writeFileSync(story, source);
|
||||
writeFileSync(pathsFile, JSON.stringify([story]));
|
||||
|
||||
expect(() => getStories({ pathsFile })).toThrow(story);
|
||||
});
|
||||
|
||||
it("rejects an empty plan even when the CSV filter is set", () => {
|
||||
writeFileSync(pathsFile, "[]");
|
||||
|
||||
expect(() =>
|
||||
getStories({ pathsFile, filter: "frontend/src/Unselected.stories.tsx" }),
|
||||
).toThrow("No stories selected");
|
||||
});
|
||||
|
||||
it("preserves the stress test's CSV filter", () => {
|
||||
expect(
|
||||
getStories({
|
||||
filter: "frontend/One.stories.tsx,frontend/Two.stories.tsx",
|
||||
}),
|
||||
).toEqual(["../frontend/One.stories.tsx", "../frontend/Two.stories.tsx"]);
|
||||
});
|
||||
|
||||
it.each(["{", "null", '""', "{}", "[null]"])(
|
||||
"rejects an invalid plan: %s",
|
||||
(contents) => {
|
||||
writeFileSync(pathsFile, contents);
|
||||
|
||||
expect(() => getStories({ pathsFile })).toThrow();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("hasExplicitTitle", () => {
|
||||
it.each([
|
||||
{
|
||||
form: "double quotes",
|
||||
source: 'export default {\n title: "Components/Button",\n};\n',
|
||||
},
|
||||
{
|
||||
form: "single quotes",
|
||||
source: "export default {\n title: 'Components/Button',\n};\n",
|
||||
},
|
||||
{
|
||||
form: "a title constant",
|
||||
source: 'const title = "Components/Button"; export default { title };',
|
||||
},
|
||||
{
|
||||
form: "a meta constant",
|
||||
source:
|
||||
'const meta: Meta<typeof Button> = {\n title: "Components/Button",\n};\n\nexport default meta;\n',
|
||||
},
|
||||
{
|
||||
form: "a satisfies expression",
|
||||
source:
|
||||
'const meta = { title: "Components/Button" } satisfies Meta<typeof Button>; export default meta;',
|
||||
},
|
||||
])("accepts a title in $form", ({ source }) => {
|
||||
expect(hasExplicitTitle(source)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
form: "no title",
|
||||
source: "export default {\n component: Button,\n};\n",
|
||||
},
|
||||
{
|
||||
form: "only a title control",
|
||||
source:
|
||||
'const argTypes = {\n title: {\n control: { type: "text" },\n },\n};\n\nexport default { component: Button, argTypes };\n',
|
||||
},
|
||||
{
|
||||
form: "only a title variable",
|
||||
source:
|
||||
"export default {\n component: Button,\n args: {\n title: defaultTitle,\n },\n};\n",
|
||||
},
|
||||
{
|
||||
form: "only a string-valued args title",
|
||||
source: 'export default { args: { title: "Button label" } };',
|
||||
},
|
||||
{
|
||||
form: "only another object's title",
|
||||
source:
|
||||
'const other = { title: "Other" }; export default { component: Button };',
|
||||
},
|
||||
{
|
||||
form: "only a commented title",
|
||||
source:
|
||||
'export default {\n // title: "Components/Button",\n component: Button };',
|
||||
},
|
||||
{
|
||||
form: "an empty title",
|
||||
source: 'export default { title: "" };',
|
||||
},
|
||||
{
|
||||
form: "a template literal unsupported by Storybook",
|
||||
source: "export default { title: `Components/Button` };",
|
||||
},
|
||||
])("rejects $form", ({ source }) => {
|
||||
expect(hasExplicitTitle(source)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MAIN_APP_STORY_GLOBS", () => {
|
||||
it("matches the same tracked files as Storybook's own story matcher", () => {
|
||||
const specifiers = normalizeStories(getStories(), {
|
||||
configDir: join(ROOT, ".storybook"),
|
||||
workingDir: ROOT,
|
||||
});
|
||||
const built = tracked
|
||||
.filter(
|
||||
(file) =>
|
||||
!file.endsWith(".mdx") &&
|
||||
specifiers.some((specifier) =>
|
||||
specifier.importPathMatcher.test(`./${file}`),
|
||||
),
|
||||
)
|
||||
.sort();
|
||||
|
||||
expect(planned.length).toBeGreaterThan(0);
|
||||
expect(planned).toEqual(built);
|
||||
});
|
||||
|
||||
it("matches no docs entries", () => {
|
||||
expect(planned.filter((file) => file.endsWith(".mdx"))).toEqual([]);
|
||||
});
|
||||
|
||||
it("matches only stories with an explicit title", () => {
|
||||
const untitled = planned.filter(
|
||||
(file) => !hasExplicitTitle(readFileSync(join(ROOT, file), "utf8")),
|
||||
);
|
||||
|
||||
expect(untitled).toEqual([]);
|
||||
});
|
||||
});
|
||||
114
.github/scripts/upload-bundle-load-stats.ts
vendored
114
.github/scripts/upload-bundle-load-stats.ts
vendored
@@ -1,114 +0,0 @@
|
||||
// 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;
|
||||
warmLargestPaintMs: number;
|
||||
steadyLargestPaintMs: number;
|
||||
steadyPageReadyMs: number;
|
||||
locale: string;
|
||||
scripts: number;
|
||||
scriptKb: number;
|
||||
cssKb: number;
|
||||
totalKb: number;
|
||||
runs: number;
|
||||
}
|
||||
|
||||
interface CommitStamp {
|
||||
sha: string;
|
||||
subject: string;
|
||||
/** ISO 8601, in UTC. */
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
type LoadTimeRow = Record<string, string | number>;
|
||||
|
||||
function buildRows(
|
||||
conditions: Condition[],
|
||||
{ sha, subject, timestamp }: CommitStamp,
|
||||
): LoadTimeRow[] {
|
||||
return conditions.map((condition) => ({
|
||||
// The commit's own time rather than the day it was measured, so the merges
|
||||
// of one day keep their order and a backfilled row lands on its commit.
|
||||
Date: timestamp,
|
||||
// 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,
|
||||
"Warm largest paint ms": condition.warmLargestPaintMs,
|
||||
"Steady largest paint ms": condition.steadyLargestPaintMs,
|
||||
"Steady page ready ms": condition.steadyPageReadyMs,
|
||||
Locale: condition.locale,
|
||||
Scripts: condition.scripts,
|
||||
"Script kb": condition.scriptKb,
|
||||
"Css kb": condition.cssKb,
|
||||
"Total kb": condition.totalKb,
|
||||
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 || "",
|
||||
timestamp: new Date(process.env.COMMIT_TIMESTAMP || Date.now()).toISOString(),
|
||||
});
|
||||
|
||||
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();
|
||||
73
.github/scripts/upload-e2e-test-timings.ts
vendored
73
.github/scripts/upload-e2e-test-timings.ts
vendored
@@ -1,73 +0,0 @@
|
||||
/**
|
||||
* Collect some stats about our e2e tests:
|
||||
* - total number of spec files
|
||||
* - total time to run all specs
|
||||
* - average spec runtime
|
||||
* - max spec runtime
|
||||
*
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import { importStats, StatsRow } from "./stats-import";
|
||||
|
||||
const TABLE = "e2e_test_timings";
|
||||
|
||||
export interface Timings {
|
||||
durations?: { spec?: string; duration?: unknown }[];
|
||||
}
|
||||
|
||||
export const buildTestTimingsRow = (
|
||||
timestamp: string,
|
||||
timings: Timings,
|
||||
): StatsRow => {
|
||||
const durations = timings.durations ?? [];
|
||||
const specCount = durations.length;
|
||||
|
||||
if (specCount === 0) {
|
||||
throw new Error("No durations found.");
|
||||
}
|
||||
|
||||
let totalRunTime = 0;
|
||||
let maxSpecRuntime = 0;
|
||||
for (const { duration } of durations) {
|
||||
if (typeof duration !== "number") {
|
||||
continue;
|
||||
}
|
||||
|
||||
totalRunTime += duration;
|
||||
maxSpecRuntime = duration > maxSpecRuntime ? duration : maxSpecRuntime;
|
||||
}
|
||||
|
||||
const averageSpecRuntime = Math.round(totalRunTime / specCount);
|
||||
|
||||
return {
|
||||
date: timestamp,
|
||||
spec_count: specCount,
|
||||
total_runtime: totalRunTime,
|
||||
avg_spec_runtime: averageSpecRuntime,
|
||||
max_spec_runtime: maxSpecRuntime,
|
||||
};
|
||||
};
|
||||
|
||||
export async function main() {
|
||||
const path = process.env.TIMINGS_PATH || "e2e/support/timings.json";
|
||||
|
||||
try {
|
||||
const timings = JSON.parse(readFileSync(path, "utf-8")) as Timings;
|
||||
const row = buildTestTimingsRow(new Date().toISOString(), timings);
|
||||
console.log(row);
|
||||
|
||||
await importStats({ table: TABLE, rows: [row] });
|
||||
console.log(`Successfully uploaded e2e test timings to ${TABLE}.`);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.log(
|
||||
`::warning::Uploading e2e test timings from ${path} failed; leaving run green: ${message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if ((import.meta as ImportMeta & { main?: boolean }).main) {
|
||||
main();
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import { buildTestTimingsRow } from "./upload-e2e-test-timings";
|
||||
|
||||
const timestamp = "2026-06-30T00:00:00.000Z";
|
||||
|
||||
describe("buildTestTimingsRow", () => {
|
||||
it("summarizes spec durations", () => {
|
||||
const row = buildTestTimingsRow(timestamp, {
|
||||
durations: [
|
||||
{ spec: "a.cy.spec.js", duration: 1000 },
|
||||
{ spec: "b.cy.spec.js", duration: 3000 },
|
||||
{ spec: "c.cy.spec.js", duration: 2000 },
|
||||
],
|
||||
});
|
||||
|
||||
expect(row).toEqual({
|
||||
date: timestamp,
|
||||
spec_count: 3,
|
||||
total_runtime: 6000,
|
||||
avg_spec_runtime: 2000,
|
||||
max_spec_runtime: 3000,
|
||||
});
|
||||
});
|
||||
|
||||
it("rounds the average runtime", () => {
|
||||
const row = buildTestTimingsRow(timestamp, {
|
||||
durations: [
|
||||
{ spec: "a.cy.spec.js", duration: 1000 },
|
||||
{ spec: "b.cy.spec.js", duration: 1001 },
|
||||
],
|
||||
});
|
||||
|
||||
expect(row.avg_spec_runtime).toBe(1001);
|
||||
});
|
||||
|
||||
it("counts specs with a non-numeric duration but ignores their value", () => {
|
||||
const row = buildTestTimingsRow(timestamp, {
|
||||
durations: [
|
||||
{ spec: "a.cy.spec.js", duration: 1000 },
|
||||
{ spec: "b.cy.spec.js", duration: null },
|
||||
{ spec: "c.cy.spec.js" },
|
||||
],
|
||||
});
|
||||
|
||||
expect(row).toEqual({
|
||||
date: timestamp,
|
||||
spec_count: 3,
|
||||
total_runtime: 1000,
|
||||
avg_spec_runtime: 333,
|
||||
max_spec_runtime: 1000,
|
||||
});
|
||||
});
|
||||
|
||||
it("throws when there are no durations", () => {
|
||||
expect(() => buildTestTimingsRow(timestamp, { durations: [] })).toThrow(
|
||||
"No durations found.",
|
||||
);
|
||||
expect(() => buildTestTimingsRow(timestamp, {})).toThrow(
|
||||
"No durations found.",
|
||||
);
|
||||
});
|
||||
});
|
||||
11
.github/team.json
vendored
11
.github/team.json
vendored
@@ -3,7 +3,6 @@
|
||||
{
|
||||
"name": "Querying Platform",
|
||||
"label": ".Team/QueryingPlatform",
|
||||
"assignee": "@metabase/core-backend-querying-platform",
|
||||
"members": [
|
||||
"metamben",
|
||||
"rileythomp",
|
||||
@@ -14,7 +13,6 @@
|
||||
{
|
||||
"name": "Graphy",
|
||||
"label": ".Team/Graphy",
|
||||
"assignee": "@metabase/graphy",
|
||||
"members": [
|
||||
"ranquild",
|
||||
"ericnormand",
|
||||
@@ -56,7 +54,6 @@
|
||||
{
|
||||
"name": "Metabot",
|
||||
"label": ".Team/Metabot",
|
||||
"assignee": "@metabase/metabot",
|
||||
"members": [
|
||||
"appleby",
|
||||
"retro",
|
||||
@@ -66,8 +63,7 @@
|
||||
"crisptrutski",
|
||||
"undrfined",
|
||||
"andreis",
|
||||
"prascuna",
|
||||
"krever"
|
||||
"prascuna"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -114,10 +110,7 @@
|
||||
"uladzimirdev",
|
||||
"egorgrushin",
|
||||
"metatyb",
|
||||
"olehk-mb",
|
||||
"annatomka",
|
||||
"brybacki",
|
||||
"BillyBobFry"
|
||||
"olehk-mb"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
258
.github/workflows/ai-benchmark.yml
vendored
258
.github/workflows/ai-benchmark.yml
vendored
@@ -1,5 +1,5 @@
|
||||
name: AI Benchmark
|
||||
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 }}"
|
||||
run-name: "dispatch evals · ${{ github.event_name == 'pull_request' && format('smoke PR#{0}', github.event.pull_request.number) || (inputs.ref != '' && inputs.ref || (inputs.pr != '' && format('PR#{0}', inputs.pr) || 'master')) }}"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
@@ -18,16 +18,7 @@ on:
|
||||
description: "Scope"
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- smoke
|
||||
- full
|
||||
- embarrassing-plain
|
||||
- embarrassing-human
|
||||
- embarrassing-all
|
||||
- goldens-plain
|
||||
- goldens-human
|
||||
- goldens-all
|
||||
- "all (EXPENSIVE)"
|
||||
options: [smoke, full, "all (EXPENSIVE)"]
|
||||
default: smoke
|
||||
sample:
|
||||
description: "Sample (0 = full suite)"
|
||||
@@ -38,110 +29,71 @@ on:
|
||||
default: main
|
||||
|
||||
concurrency:
|
||||
group: evals-dispatch-${{ github.event_name == 'pull_request' && format('{0}-{1}', github.event.pull_request.number, github.event.label.name) || github.run_id }}
|
||||
group: evals-dispatch-${{ (github.event.label.name == 'evals/smoke' && github.event.pull_request.number) || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
unsupported:
|
||||
if: >-
|
||||
(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'
|
||||
github.event_name == 'pull_request'
|
||||
&& github.event.label.name == 'evals/smoke'
|
||||
&& github.event.pull_request.head.repo.full_name != github.repository
|
||||
runs-on: ${{ vars.SLIM_RUNNER_KEY }}
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
outputs:
|
||||
pr: ${{ steps.prepare.outputs.pr }}
|
||||
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 }}
|
||||
comment_id: ${{ steps.post.outputs.comment_id }}
|
||||
sha: ${{ steps.sha.outputs.sha }}
|
||||
steps:
|
||||
- name: Prepare
|
||||
id: prepare
|
||||
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 }}
|
||||
INPUT_SAMPLE: ${{ inputs.sample }}
|
||||
INPUT_EVALS_BRANCH: ${{ inputs.evals_branch }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# The two paths are mutually exclusive, and `on:` admits no other trigger.
|
||||
if [[ "$EVENT_NAME" == 'pull_request' ]]; then
|
||||
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 LABEL EVALS_BRANCH; do
|
||||
if [[ "${!v}" == *$'\n'* ]]; then
|
||||
echo "::error::${v} must be a single line"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
{
|
||||
echo "pr=$PR"
|
||||
echo "ref=$REF"
|
||||
echo "scope=$SCOPE"
|
||||
echo "sample=$SAMPLE"
|
||||
echo "label=$LABEL"
|
||||
echo "evals_branch=$EVALS_BRANCH"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
- run: |
|
||||
echo "::error::evals/smoke only runs on same-repo PRs; this PR's head is on a fork"
|
||||
exit 1
|
||||
|
||||
announce:
|
||||
if: >-
|
||||
github.event_name == 'pull_request'
|
||||
&& github.event.label.name == 'evals/smoke'
|
||||
&& github.event.pull_request.head.repo.full_name == github.repository
|
||||
runs-on: ${{ vars.SLIM_RUNNER_KEY }}
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
comment_id: ${{ steps.post.outputs.comment_id }}
|
||||
steps:
|
||||
- name: Comment (building)
|
||||
if: github.event_name == 'pull_request'
|
||||
id: post
|
||||
env:
|
||||
SCOPE: ${{ steps.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 prefix = `🤖 **Evals (${process.env.SCOPE})**`;
|
||||
const body = `${prefix} — ⏳ building the image… ([logs](${runUrl}))`;
|
||||
const body = `🤖 **Evals smoke** — ⏳ building the image… ([logs](${runUrl}))`;
|
||||
const { owner, repo } = context.repo;
|
||||
const issue_number = context.payload.pull_request.number;
|
||||
// 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.
|
||||
// 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.
|
||||
const existing = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number });
|
||||
// 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)) {
|
||||
for (const c of existing.filter(c => c.body.startsWith('🤖 **Evals'))) {
|
||||
await github.rest.issues.deleteComment({ owner, repo, comment_id: c.id }).catch(() => {});
|
||||
}
|
||||
const { data } = await github.rest.issues.createComment({ owner, repo, issue_number, body });
|
||||
core.setOutput('comment_id', String(data.id));
|
||||
|
||||
resolve:
|
||||
# Collapse the dispatch inputs into the one full sha everything downstream needs: it keys the
|
||||
# uberjar artifact, tags the ECR image, and tells eval-run which commit was measured. `pr` was
|
||||
# not consulted at all before, so `pr: 12345` built the tip of whatever branch the run was
|
||||
# launched from and then reported the result against the PR.
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
outputs:
|
||||
sha: ${{ steps.sha.outputs.sha }}
|
||||
steps:
|
||||
- name: Resolve pr/ref to a full commit sha
|
||||
id: sha
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR: ${{ steps.prepare.outputs.pr }}
|
||||
REF: ${{ steps.prepare.outputs.ref }}
|
||||
PR: ${{ inputs.pr }}
|
||||
REF: ${{ inputs.ref }}
|
||||
DEFAULT_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -167,70 +119,87 @@ jobs:
|
||||
echo "sha=${SHA}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
image:
|
||||
needs: prepare
|
||||
needs: [resolve]
|
||||
# `resolve` is workflow_dispatch-only, so tolerate it being skipped. The label guard is not
|
||||
# optional: the trigger is `pull_request: [labeled]` with no filter of its own, so without it
|
||||
# any label on any same-repo PR builds ee+oss and pushes to ECR — and since announce/smoke/
|
||||
# finalize all skip in that case, a failure shows up as a bare red check with nothing
|
||||
# explaining it.
|
||||
if: >-
|
||||
!cancelled()
|
||||
&& (needs.resolve.result == 'success' || needs.resolve.result == 'skipped')
|
||||
&& (github.event_name == 'workflow_dispatch'
|
||||
|| (github.event_name == 'pull_request'
|
||||
&& github.event.label.name == 'evals/smoke'
|
||||
&& github.event.pull_request.head.repo.full_name == github.repository))
|
||||
uses: ./.github/workflows/build-private-ee-docker-image.yml
|
||||
secrets:
|
||||
PR_ENV_IAM_ROLE: ${{ secrets.PR_ENV_IAM_ROLE }}
|
||||
PR_ENV_AWS_ACCOUNT_ID: ${{ secrets.PR_ENV_AWS_ACCOUNT_ID }}
|
||||
with:
|
||||
commit: ${{ needs.prepare.outputs.sha }}
|
||||
# Empty on the label path on purpose — the image workflow then falls back to the PR head,
|
||||
# which is the artifact run-tests has already built and cached under that sha.
|
||||
commit: ${{ needs.resolve.outputs.sha }}
|
||||
|
||||
dispatch:
|
||||
needs: [prepare, image]
|
||||
if: always() && needs.prepare.result != 'skipped'
|
||||
timeout-minutes: 5
|
||||
smoke:
|
||||
needs: [announce, image]
|
||||
# `!cancelled()` is load-bearing, not decoration: on the label path `resolve` is skipped by
|
||||
# design, and `image` only runs at all because it opts back in with its own `!cancelled()`.
|
||||
# GitHub propagates that skip *transitively*, so a plain boolean `if` here — carrying the
|
||||
# implicit `success()` — skips smoke even though image succeeded and the image is in ECR.
|
||||
# Opting out of the implicit check means the needs' results have to be asserted by hand.
|
||||
if: >-
|
||||
!cancelled()
|
||||
&& needs.announce.result == 'success'
|
||||
&& needs.image.result == 'success'
|
||||
&& github.event_name == 'pull_request'
|
||||
&& github.event.label.name == 'evals/smoke'
|
||||
&& github.event.pull_request.head.repo.full_name == github.repository
|
||||
runs-on: ${{ vars.SLIM_RUNNER_KEY }}
|
||||
timeout-minutes: 5
|
||||
env:
|
||||
COMMENT_ID: ${{ needs.announce.outputs.comment_id }}
|
||||
steps:
|
||||
- name: Dispatch eval-run
|
||||
id: dispatch
|
||||
timeout-minutes: 2
|
||||
if: >-
|
||||
!cancelled()
|
||||
&& needs.prepare.result == 'success'
|
||||
&& needs.image.result == 'success'
|
||||
- name: Dispatch smoke eval-run
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
|
||||
EVALS_BRANCH: ${{ needs.prepare.outputs.evals_branch }}
|
||||
PR: ${{ needs.prepare.outputs.pr }}
|
||||
SHA: ${{ needs.prepare.outputs.sha }}
|
||||
SCOPE: ${{ needs.prepare.outputs.scope }}
|
||||
SAMPLE: ${{ needs.prepare.outputs.sample }}
|
||||
LABEL: ${{ needs.prepare.outputs.label }}
|
||||
COMMENT_ID: ${{ needs.prepare.outputs.comment_id }}
|
||||
PR: ${{ github.event.pull_request.number }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
EVALS_BRANCH: main
|
||||
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" \
|
||||
-f "inputs[ref]=$SHA" \
|
||||
-f "inputs[scope]=$SCOPE" \
|
||||
-f "inputs[ref]=$HEAD_SHA" \
|
||||
-f "inputs[scope]=smoke" \
|
||||
-f "inputs[surfaces]=all" \
|
||||
-f "inputs[sample]=$SAMPLE" \
|
||||
-f "inputs[label]=$LABEL" \
|
||||
-f "inputs[sample]=5" \
|
||||
-f "inputs[evals_branch]=$EVALS_BRANCH" \
|
||||
-f "inputs[comment_id]=$COMMENT_ID"
|
||||
|
||||
finalize:
|
||||
# If the build or dispatch fell over, eval-run never took over the comment — leave a
|
||||
# failure verdict instead of a comment stuck at "building the image…".
|
||||
needs: [announce, image, smoke]
|
||||
if: always() && needs.announce.result == 'success' && needs.smoke.result != 'success'
|
||||
runs-on: ${{ vars.SLIM_RUNNER_KEY }}
|
||||
timeout-minutes: 5
|
||||
env:
|
||||
COMMENT_ID: ${{ needs.announce.outputs.comment_id }}
|
||||
SMOKE_RESULT: ${{ needs.smoke.result }}
|
||||
IMAGE_RESULT: ${{ needs.image.result }}
|
||||
steps:
|
||||
- name: Comment (build/dispatch failed)
|
||||
uses: actions/github-script@v9
|
||||
timeout-minutes: 2
|
||||
if: always() && needs.prepare.outputs.comment_id != '' && steps.dispatch.outcome != 'success'
|
||||
env:
|
||||
COMMENT_ID: ${{ needs.prepare.outputs.comment_id }}
|
||||
SCOPE: ${{ needs.prepare.outputs.scope }}
|
||||
LABEL: ${{ needs.prepare.outputs.label }}
|
||||
NEEDS_JSON: ${{ toJSON(needs) }}
|
||||
DISPATCH_OUTCOME: ${{ steps.dispatch.outcome }}
|
||||
with:
|
||||
github-token: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
|
||||
script: |
|
||||
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')
|
||||
|| process.env.DISPATCH_OUTCOME === 'cancelled';
|
||||
const prefix = `🤖 **Evals (${process.env.SCOPE})**`;
|
||||
// A concurrency-cancelled retrigger lands here too; don't stamp it as an infra failure.
|
||||
const cancelled = [process.env.SMOKE_RESULT, process.env.IMAGE_RESULT].includes('cancelled');
|
||||
const body = cancelled
|
||||
? `${prefix} — ⚪ cancelled before evals started ([logs](${runUrl}))`
|
||||
: `${prefix} — ❌ build or dispatch failed before evals started ([logs](${runUrl}))`;
|
||||
? `🤖 **Evals smoke** — ⚪ cancelled before evals started ([logs](${runUrl}))`
|
||||
: `🤖 **Evals smoke** — ❌ 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({
|
||||
@@ -243,5 +212,32 @@ jobs:
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner, repo: context.repo.repo,
|
||||
issue_number: context.payload.pull_request.number,
|
||||
name: process.env.LABEL,
|
||||
name: 'evals/smoke',
|
||||
}).catch(() => {});
|
||||
|
||||
dispatch:
|
||||
needs: [resolve, image]
|
||||
# Deliberately no `always()`: benchmarking without the image this run was supposed to produce
|
||||
# would measure a stale build or nothing at all. A failed `image` fails the run, which is the
|
||||
# signal here — unlike the PR path there is no comment for `finalize` to stamp.
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
runs-on: ${{ vars.SLIM_RUNNER_KEY }}
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Dispatch eval-run
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
|
||||
EVALS_BRANCH: ${{ github.event.inputs.evals_branch }}
|
||||
PR: ${{ inputs.pr }}
|
||||
# The sha the image was actually built and tagged with, not the raw `ref` input.
|
||||
REF: ${{ needs.resolve.outputs.sha }}
|
||||
SCOPE: ${{ inputs.scope }}
|
||||
SAMPLE: ${{ inputs.sample }}
|
||||
run: |
|
||||
gh api repos/metabase/evals/actions/workflows/eval-run.yml/dispatches \
|
||||
-f ref="$EVALS_BRANCH" \
|
||||
-f "inputs[pr]=$PR" \
|
||||
-f "inputs[ref]=$REF" \
|
||||
-f "inputs[scope]=$SCOPE" \
|
||||
-f "inputs[sample]=${SAMPLE:-5}" \
|
||||
-f "inputs[evals_branch]=$EVALS_BRANCH"
|
||||
|
||||
10
.github/workflows/app-db.yml
vendored
10
.github/workflows/app-db.yml
vendored
@@ -93,9 +93,9 @@ jobs:
|
||||
],
|
||||
mysql: [
|
||||
{
|
||||
name: 'MySQL 8',
|
||||
image: 'mysql:8.4',
|
||||
'junit-name': 'be-tests-mysql-8-ee',
|
||||
name: 'MySQL 8.0',
|
||||
image: 'mysql:8.0',
|
||||
'junit-name': 'be-tests-mysql-8-0-ee',
|
||||
env: { 'enable-ssl-tests': 'false', 'enable-aws-iam-tests': 'false' },
|
||||
latest: false
|
||||
},
|
||||
@@ -217,6 +217,7 @@ 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
|
||||
@@ -310,6 +311,7 @@ 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
|
||||
@@ -330,6 +332,7 @@ jobs:
|
||||
build-static-viz: true
|
||||
test-args: >-
|
||||
:only '"test"'
|
||||
:exclude-tags [:mb/driver-tests]
|
||||
:partition/total 2
|
||||
:partition/index 0
|
||||
exclude-tag: ':mb/driver-tests :mb/transforms-python-test'
|
||||
@@ -386,6 +389,7 @@ 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:
|
||||
|
||||
118
.github/workflows/backend.yml
vendored
118
.github/workflows/backend.yml
vendored
@@ -10,18 +10,6 @@ on:
|
||||
description: Run the full suite regardless of the diff (master / release branches).
|
||||
type: boolean
|
||||
default: false
|
||||
run-project-backend-checks:
|
||||
description: Run project-level backend checks.
|
||||
type: boolean
|
||||
default: false
|
||||
run-project-migration-checks:
|
||||
description: Run project-level migration checks.
|
||||
type: boolean
|
||||
default: false
|
||||
run-project-ratchet-checks:
|
||||
description: Run the ratchet check for a diff that touches nothing but the ratchets file.
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
# Run the backend test suite on demand, against the ref you dispatch on, optionally under a
|
||||
# profiler. Everything else about the jobs -- matrix legs, tag filters, quarantine gating -- is
|
||||
@@ -58,95 +46,6 @@ env:
|
||||
ONLY_TESTS: ${{ inputs.tests }}
|
||||
|
||||
jobs:
|
||||
project-checks:
|
||||
name: 'Project tests: Suites'
|
||||
if: >-
|
||||
${{ inputs.force-run ||
|
||||
inputs.run-project-backend-checks ||
|
||||
inputs.run-project-migration-checks ||
|
||||
inputs.run-project-ratchet-checks }}
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Prepare back-end environment
|
||||
uses: ./.github/actions/prepare-backend
|
||||
# Bootstrap the shared Mage runtime before concurrent commands try to install it themselves.
|
||||
- name: Prepare Mage
|
||||
run: ./bin/mage
|
||||
- parallel:
|
||||
- name: Run backend checks
|
||||
if: ${{ inputs.force-run || inputs.run-project-backend-checks }}
|
||||
run: ./bin/mage project-tests backend
|
||||
- name: Run migration checks
|
||||
if: ${{ inputs.force-run || inputs.run-project-migration-checks }}
|
||||
run: ./bin/mage project-tests migrations
|
||||
# Babashka, not a JVM test run, so it is cheap enough to run alongside the backend checks.
|
||||
# It still needs the JDK prepare-backend installs, to resolve clj-kondo on a cold cache.
|
||||
- name: Run ratchet checks
|
||||
if: ${{ inputs.force-run || inputs.run-project-backend-checks || inputs.run-project-ratchet-checks }}
|
||||
run: ./bin/mage kondo-ratchets
|
||||
|
||||
project-tests:
|
||||
name: Project tests
|
||||
needs: project-checks
|
||||
if: ${{ always() && !cancelled() }}
|
||||
runs-on: ${{ vars.SLIM_RUNNER_KEY }}
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check Project tests 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('');
|
||||
console.log(' |>>> |>>> |>>>');
|
||||
console.log(' | | |');
|
||||
console.log(' _ _|_ _ _ _|_ _ _ _|_ _');
|
||||
console.log(' | |_| |_| |______| |_| |_| |______| |_| |_| |');
|
||||
console.log(' | | | | | |');
|
||||
console.log(' | [] [] | | [] [] | | [] [] |');
|
||||
console.log(' | |______| |______| |');
|
||||
console.log(' | ___ |');
|
||||
console.log(' |___________________| |___________________|');
|
||||
console.log(' |___|');
|
||||
console.log('');
|
||||
console.log('Everything is up to code and structurally sound.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log(' .-===========-.____');
|
||||
console.log(' /_______________\\ \\____');
|
||||
console.log(' | .-----------. | \\___');
|
||||
console.log(' | | x x | | \\__');
|
||||
console.log(' | | ~ | | _\\_');
|
||||
console.log(" | `-----------' | /___\\");
|
||||
console.log(' | o o |');
|
||||
console.log(" `-----------------'");
|
||||
console.log(' /=============\\');
|
||||
console.log(' / O O O O O O O \\');
|
||||
console.log(' \\_______________/');
|
||||
console.log('');
|
||||
console.log('One or more project checks are off the blueprint. Hard hats required.');
|
||||
|
||||
jobs.forEach((job) => {
|
||||
if (job.result !== 'success' && job.result !== 'skipped') {
|
||||
console.log(`${job.name} - ${job.result}`);
|
||||
}
|
||||
});
|
||||
|
||||
process.exit(1);
|
||||
|
||||
be-linter-clj-kondo:
|
||||
if: ${{ !inputs.skip }}
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
@@ -181,7 +80,7 @@ jobs:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Prepare back-end environment
|
||||
uses: ./.github/actions/prepare-backend
|
||||
- run: clojure -X:dev:dev/test:ee:ee-dev:drivers:drivers-dev:test:eastwood/test
|
||||
- run: clojure -X:dev:ee:ee-dev:drivers:drivers-dev:test:eastwood/test
|
||||
name: Run Eastwood linter on our backend tests
|
||||
|
||||
be-linter-eastwood-test-changed:
|
||||
@@ -209,14 +108,13 @@ jobs:
|
||||
# last synced don't widen our scope. Falls back to BASE_SHA if older than fetched depth.
|
||||
git fetch --no-tags --depth=200 origin "$BASE_SHA"
|
||||
DIFF_BASE=$(git merge-base "$BASE_SHA" "$HEAD_SHA" 2>/dev/null || echo "$BASE_SHA")
|
||||
# Test roots we lint: core, EE, dev/test, and each driver module's test tree.
|
||||
# Test roots we lint: core, EE, and each driver module's test tree.
|
||||
# :eastwood/test derives matching :source-paths from the classpath, so any
|
||||
# namespace we name here is reachable. Include renames (R) so a test that
|
||||
# was just renamed still gets linted under its new name.
|
||||
FILES=$(git diff --name-only --diff-filter=AMR "$DIFF_BASE" "$HEAD_SHA" \
|
||||
-- 'test/**/*.clj' 'test/**/*.cljc' \
|
||||
'enterprise/backend/test/**/*.clj' 'enterprise/backend/test/**/*.cljc' \
|
||||
'dev/test/**/*.clj' 'dev/test/**/*.cljc' \
|
||||
'modules/drivers/**/test/**/*.clj' 'modules/drivers/**/test/**/*.cljc')
|
||||
|
||||
if [ -z "$FILES" ]; then
|
||||
@@ -232,14 +130,7 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Convert each test path to a Clojure namespace: strip its known test root and
|
||||
# extension, then translate path separators and underscores. Keep these roots
|
||||
# aligned with FILES above and the Eastwood classpath aliases below.
|
||||
NS=$(sed -E \
|
||||
-e 's#^(test/|enterprise/backend/test/|dev/test/|modules/drivers/[^/]+/test/)##' \
|
||||
-e 's#\.cljc?$##' \
|
||||
-e 's#/#.#g' \
|
||||
-e 's#_#-#g' <<<"$FILES" \
|
||||
NS=$(sed -E 's#^(test/|enterprise/backend/test/|modules/drivers/[^/]+/test/)##; s#\.cljc?$##; s#/#.#g; s#_#-#g' <<<"$FILES" \
|
||||
| sort -u \
|
||||
| paste -sd' ' -)
|
||||
echo "Changed test namespaces: $NS"
|
||||
@@ -248,7 +139,7 @@ jobs:
|
||||
if: steps.changed.outputs.namespaces != ''
|
||||
env:
|
||||
NAMESPACES: ${{ steps.changed.outputs.namespaces }}
|
||||
run: clojure -X:dev:dev/test:ee:ee-dev:drivers:drivers-dev:test:eastwood/test :namespaces "[$NAMESPACES]"
|
||||
run: clojure -X:dev:ee:ee-dev:drivers:drivers-dev:test:eastwood/test :namespaces "[$NAMESPACES]"
|
||||
|
||||
be-tests:
|
||||
if: ${{ !inputs.skip }}
|
||||
@@ -477,7 +368,6 @@ jobs:
|
||||
|
||||
be-tests-result:
|
||||
needs:
|
||||
- project-tests
|
||||
- be-linter-eastwood
|
||||
- be-linter-eastwood-test
|
||||
- be-linter-eastwood-test-changed
|
||||
|
||||
15
.github/workflows/block-master-source-branch-prs.yml
vendored
Normal file
15
.github/workflows/block-master-source-branch-prs.yml
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
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
|
||||
@@ -136,7 +136,5 @@ jobs:
|
||||
context: bin/docker/
|
||||
platforms: linux/amd64
|
||||
network: host
|
||||
build-args: |
|
||||
GIT_COMMIT_SHA=${{ steps.meta.outputs.tag }}
|
||||
tags: ${{ secrets.PR_ENV_AWS_ACCOUNT_ID }}.dkr.ecr.us-east-1.amazonaws.com/metabase-enterprise:${{ steps.meta.outputs.tag }}
|
||||
push: true
|
||||
|
||||
39
.github/workflows/build-scripts.yml
vendored
39
.github/workflows/build-scripts.yml
vendored
@@ -1,19 +1,34 @@
|
||||
name: Build scripts
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
skip-build-scripts:
|
||||
type: boolean
|
||||
default: false
|
||||
skip-embedder-plugin:
|
||||
type: boolean
|
||||
default: false
|
||||
push:
|
||||
branches:
|
||||
- 'master'
|
||||
- 'release-**'
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
|
||||
files-changed:
|
||||
name: Check which files changed
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 3
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
embedder_plugin: ${{ steps.changes.outputs.embedder_plugin }}
|
||||
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
|
||||
|
||||
test-embedder-plugin:
|
||||
if: ${{ !inputs.skip-embedder-plugin }}
|
||||
needs: files-changed
|
||||
if: needs.files-changed.outputs.embedder_plugin == 'true'
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
@@ -63,9 +78,7 @@ jobs:
|
||||
'[metabase-enterprise.semantic-search.appdb-pgvector-mode-test/appdb-pgvector-in-process-embedder-round-trip-test]'
|
||||
|
||||
test-build-scripts:
|
||||
if: ${{ !inputs.skip-build-scripts }}
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 40
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Prepare back-end environment
|
||||
@@ -75,6 +88,10 @@ jobs:
|
||||
- name: Run build and release script tests
|
||||
run: clojure -X:dev:drivers:build:build-dev:build-test:ci
|
||||
timeout-minutes: 15
|
||||
- name: Run Liquibase migrations linter tests
|
||||
run: clojure -M:test
|
||||
working-directory: bin/lint-migrations-file
|
||||
timeout-minutes: 15
|
||||
- name: Run load-namespaces tests
|
||||
run: clojure -M:test
|
||||
working-directory: bin/load-namespaces
|
||||
|
||||
52
.github/workflows/bundle-size-stats.yml
vendored
52
.github/workflows/bundle-size-stats.yml
vendored
@@ -4,60 +4,25 @@ 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: ["Run tests"]
|
||||
workflows: ["Build Uberjar"]
|
||||
types: [completed]
|
||||
# Master only: releases form non-linear lines whose deltas are meaningless,
|
||||
# so their sizes are backfilled separately rather than logged here.
|
||||
branches: [master]
|
||||
workflow_call:
|
||||
inputs:
|
||||
commit:
|
||||
description: "Commit SHA to measure."
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
# A later attempt must not cancel the first one: the concurrency group applies
|
||||
# before any job `if`, so a run that goes on to skip every job still cancels.
|
||||
concurrency:
|
||||
group: bundle-size-stats-${{ inputs.commit || github.event.workflow_run.head_sha }}
|
||||
cancel-in-progress: false
|
||||
group: bundle-size-stats-${{ 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.
|
||||
#
|
||||
# Only the first attempt counts. `rerun-workflows.yml` re-runs a failed "Run
|
||||
# tests", and every attempt that completes fires this again for the same
|
||||
# commit, which would measure it again and write its rows twice.
|
||||
uberjar-built:
|
||||
if: ${{ github.repository == 'metabase/metabase' && (inputs.commit != '' || github.event.workflow_run.run_attempt == 1) }}
|
||||
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:
|
||||
needs: [uberjar-built]
|
||||
if: ${{ needs.uberjar-built.outputs.found == 'true' }}
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' && github.repository == 'metabase/metabase' }}
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
HEAD_SHA: ${{ inputs.commit || github.event.workflow_run.head_sha }}
|
||||
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
# Only record a data point when a bundle's as-served size (brotli where the
|
||||
# build ships .br, else gzip) moved by at least this percent vs the last
|
||||
# plotted point, keeping the chart to real changes.
|
||||
@@ -66,12 +31,12 @@ jobs:
|
||||
- name: Check out the code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ env.HEAD_SHA }}
|
||||
ref: ${{ github.event.workflow_run.head_sha }}
|
||||
- uses: ./.github/actions/prepare-node-bun
|
||||
- name: Download EE uberjar
|
||||
uses: ./.github/actions/fetch-artifact
|
||||
with:
|
||||
commit: ${{ env.HEAD_SHA }}
|
||||
commit: ${{ github.event.workflow_run.head_sha }}
|
||||
edition: ee
|
||||
extract-path: temp/uberjar
|
||||
- name: Extract front-end bundles from uberjar
|
||||
@@ -140,11 +105,10 @@ jobs:
|
||||
# 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 }}
|
||||
COMMIT_TIMESTAMP: ${{ github.event.workflow_run.head_commit.timestamp }}
|
||||
run: bun ./.github/scripts/bundle-size-stats-prepare.ts
|
||||
- name: Import sizes (with deltas) into Stats
|
||||
id: upload
|
||||
if: ${{ steps.prepare.outputs.significant == 'true' && inputs.commit == '' }}
|
||||
if: steps.prepare.outputs.significant == 'true'
|
||||
env:
|
||||
ROWS: artifacts/upload-rows.json
|
||||
ENG_STATS_URL: ${{ secrets.ENG_STATS_URL }}
|
||||
|
||||
40
.github/workflows/cache-warm.yml
vendored
40
.github/workflows/cache-warm.yml
vendored
@@ -1,4 +1,4 @@
|
||||
# Writes the shared dependency caches. Every other job restores them and none of them writes.
|
||||
# Writes the three large dependency caches. Every other job restores them and none of them writes.
|
||||
#
|
||||
# A single writer rather than the usual let-every-job-save arrangement, because a pull request here fans
|
||||
# out to roughly 200 jobs, ~75 of which install Cypress. Letting each of those save would have them all
|
||||
@@ -13,7 +13,7 @@
|
||||
# at it, and master's copy backs up every ref that has none of its own.
|
||||
#
|
||||
# Keys are content-derived (see .github/scripts/cache-keys.sh), so a push whose dependencies already have
|
||||
# an entry does nothing but four lookups. There is no cron: nothing here is keyed by date.
|
||||
# an entry does nothing but three lookups. There is no cron: nothing here is keyed by date.
|
||||
#
|
||||
#
|
||||
# PRINCIPLES:
|
||||
@@ -69,13 +69,6 @@ jobs:
|
||||
path: ${{ steps.cache-spec.outputs.bun-store-path }}
|
||||
key: ${{ steps.cache-spec.outputs.bun-store-key }}
|
||||
lookup-only: true
|
||||
- name: Look up Clojure CLI
|
||||
id: clojure-lookup
|
||||
uses: actions/cache/restore@v5
|
||||
with:
|
||||
path: ${{ steps.cache-spec.outputs.clojure-path }}
|
||||
key: ${{ steps.cache-spec.outputs.clojure-key }}
|
||||
lookup-only: true
|
||||
- name: Look up Cypress binary
|
||||
id: cypress-lookup
|
||||
uses: actions/cache/restore@v5
|
||||
@@ -84,19 +77,10 @@ jobs:
|
||||
key: ${{ steps.cache-spec.outputs.cypress-key }}
|
||||
lookup-only: true
|
||||
|
||||
# Also runs when only the Clojure CLI entry is missing: installing it is what there is to save,
|
||||
# and the pre-resolve below needs it either way.
|
||||
- name: Prepare backend
|
||||
if: steps.m2-lookup.outputs.cache-hit != 'true' || steps.clojure-lookup.outputs.cache-hit != 'true'
|
||||
if: steps.m2-lookup.outputs.cache-hit != 'true'
|
||||
uses: ./.github/actions/prepare-backend
|
||||
|
||||
- name: Save Clojure CLI
|
||||
if: steps.clojure-lookup.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@v5
|
||||
with:
|
||||
path: ${{ steps.cache-spec.outputs.clojure-path }}
|
||||
key: ${{ steps.cache-spec.outputs.clojure-key }}
|
||||
|
||||
# Pre-resolve every Clojure dependency classpath CI actually uses, so the saved cache is a SUPERSET
|
||||
# of what each job resolves at runtime.
|
||||
#
|
||||
@@ -118,9 +102,7 @@ jobs:
|
||||
combos=(
|
||||
":build:dev:ee:ee-dev:drivers:drivers-dev:cljs" # build + uberjar (combined classpath)
|
||||
":cljs" # shadow-cljs (build:cljs); resolves :cljs alone -> covers asm:9.2
|
||||
":dev:ee:ee-dev:drivers:drivers-dev:test:eastwood" # backend.yml (eastwood)
|
||||
":dev:dev/test:ee:ee-dev:drivers:drivers-dev:test:eastwood/test" # backend.yml (eastwood tests)
|
||||
":dev:dev/test:ee:ee-dev:drivers:drivers-dev:test:ci" # run-tests.yml (project tests)
|
||||
":dev:ee:ee-dev:drivers:drivers-dev:test:eastwood" # backend.yml (eastwood + eastwood/test)
|
||||
":dev:ci:ee:ee-dev:drivers:drivers-dev:test" # test-driver.yml (ee) -> covers data.json:2.4.0
|
||||
":dev:ci:oss:oss-dev:drivers:drivers-dev:test" # test-driver.yml (oss)
|
||||
":dev:ci:test:ee:ee-dev" # backend.yml matrix (ee)
|
||||
@@ -142,20 +124,6 @@ jobs:
|
||||
echo "::endgroup::"
|
||||
done
|
||||
|
||||
# These commands use nested tools.deps projects; resolving the same alias from the
|
||||
# repository root does not warm their dependencies.
|
||||
nested_combos=(
|
||||
"bin/lint-migrations-file|:test" # project migration checks
|
||||
"bin/load-namespaces|:test" # build-scripts.yml
|
||||
)
|
||||
for spec in "${nested_combos[@]}"; do
|
||||
project=${spec%%|*}
|
||||
combo=${spec#*|}
|
||||
echo "::group::${project}: clojure -P -M${combo}"
|
||||
(cd "$project" && clojure -P -M"${combo}")
|
||||
echo "::endgroup::"
|
||||
done
|
||||
|
||||
echo "::group::./bin/mage"
|
||||
./bin/mage
|
||||
echo "::endgroup::"
|
||||
|
||||
21
.github/workflows/check-backport-script.yml
vendored
Normal file
21
.github/workflows/check-backport-script.yml
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
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
|
||||
143
.github/workflows/containerize-jar.yml
vendored
143
.github/workflows/containerize-jar.yml
vendored
@@ -1,5 +1,5 @@
|
||||
name: Containerize Uberjar
|
||||
run-name: Containerize ${{ inputs.edition }} ${{ inputs.tag }} Uberjar
|
||||
run-name: Containerize ${{ inputs.tag }} Uberjar
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
@@ -8,15 +8,10 @@ on:
|
||||
description: Metabase edition (ee or oss)
|
||||
type: string
|
||||
required: true
|
||||
registry:
|
||||
description: Registry the image is pushed to, either "docker" or "ecr".
|
||||
type: string
|
||||
default: docker
|
||||
repo:
|
||||
description: Repository name the image is pushed to, e.g. "metabase-enterprise".
|
||||
description: owner and repo like "metabase/metabase"
|
||||
type: string
|
||||
tag:
|
||||
description: Image tag, e.g. v1.87.2.
|
||||
type: string
|
||||
commit:
|
||||
type: string
|
||||
@@ -25,10 +20,6 @@ on:
|
||||
default: false
|
||||
build-args:
|
||||
type: string
|
||||
platforms:
|
||||
description: Platforms the image is built for.
|
||||
type: string
|
||||
default: linux/amd64,linux/arm64
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
edition:
|
||||
@@ -38,18 +29,11 @@ on:
|
||||
- ee
|
||||
- oss
|
||||
required: true
|
||||
registry:
|
||||
description: Registry the image is pushed to
|
||||
type: choice
|
||||
options:
|
||||
- docker
|
||||
- ecr
|
||||
default: docker
|
||||
repo:
|
||||
description: Repository name the image is pushed to, e.g. "metabase-enterprise".
|
||||
description: docker owner and repo like "metabase/metabase"
|
||||
type: string
|
||||
tag:
|
||||
description: Image tag, e.g. v1.87.2.
|
||||
description: usually a version like v1.87.2
|
||||
type: string
|
||||
commit:
|
||||
description: the full commit hash
|
||||
@@ -58,115 +42,82 @@ on:
|
||||
description: Whether this is a release artifact (uses metabase-release- prefix)
|
||||
type: boolean
|
||||
default: false
|
||||
platforms:
|
||||
description: Platforms the image is built for.
|
||||
type: string
|
||||
default: linux/amd64,linux/arm64
|
||||
|
||||
jobs:
|
||||
containerize:
|
||||
name: containerize (${{ inputs.edition }})
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 15
|
||||
outputs:
|
||||
full_image: ${{ steps.image.outputs.full_image }}
|
||||
env:
|
||||
AWS_REGION: us-east-1
|
||||
platforms: ${{ steps.platforms.outputs.result }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
# we want the dockerfile from the commit we're building
|
||||
- run: |
|
||||
git fetch --depth=1 origin ${{ inputs.commit }}
|
||||
git checkout ${{ inputs.commit }} -- bin/docker
|
||||
|
||||
- name: Retrieve uberjar artifact
|
||||
uses: ./.github/actions/fetch-artifact
|
||||
with:
|
||||
commit: ${{ inputs.commit }}
|
||||
edition: ${{ inputs.edition }}
|
||||
release: ${{ inputs.release }}
|
||||
extract-path: bin/docker
|
||||
|
||||
- name: Resolve image repo and tag
|
||||
id: image
|
||||
uses: ./.github/actions/get-docker-info
|
||||
- name: Prepare release scripts
|
||||
uses: ./.github/actions/prepare-release-scripts
|
||||
- name: Get platforms
|
||||
id: platforms
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
registry: ${{ inputs.registry }}
|
||||
repo: ${{ inputs.repo }}
|
||||
tag: ${{ inputs.tag }}
|
||||
edition: ${{ inputs.edition }}
|
||||
aws-account-id: ${{ secrets.PR_ENV_AWS_ACCOUNT_ID }}
|
||||
aws-region: ${{ env.AWS_REGION }}
|
||||
dockerhub-owner: ${{ vars.DOCKERHUB_OWNER }}
|
||||
|
||||
- name: Configure AWS credentials
|
||||
if: ${{ inputs.registry == 'ecr' }}
|
||||
uses: aws-actions/configure-aws-credentials@d979d5b3a71173a29b74b5b88418bfda9437d885 # v6.1.1
|
||||
with:
|
||||
role-to-assume: ${{ secrets.PR_ENV_IAM_ROLE }}
|
||||
role-session-name: GitHub_to_AWS_via_FederatedOIDC
|
||||
aws-region: ${{ env.AWS_REGION }}
|
||||
|
||||
- name: Login to Amazon ECR
|
||||
if: ${{ inputs.registry == 'ecr' }}
|
||||
uses: aws-actions/amazon-ecr-login@183a1442edf41672e66566b7fc560e297a290896 # v2
|
||||
with:
|
||||
registries: "${{ secrets.PR_ENV_AWS_ACCOUNT_ID }}"
|
||||
result-encoding: string
|
||||
script: | # js
|
||||
const { getBuildRequirements, isValidVersionString} = require('${{ github.workspace }}/release/dist/index.cjs');
|
||||
const version = "${{ inputs.tag }}".split('-')[0];
|
||||
|
||||
if (!isValidVersionString(version)) {
|
||||
return "linux/amd64,linux/arm64";
|
||||
}
|
||||
// versions before 53 need to be handled differently
|
||||
const buildInfo = getBuildRequirements(version);
|
||||
return buildInfo.platforms;
|
||||
- name: Move the Uberjar to the context dir
|
||||
run: mv ./metabase.jar bin/docker/.
|
||||
- name: Login to Docker Hub
|
||||
if: ${{ inputs.registry != 'ecr' }}
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
username: ${{ inputs.release && secrets.DOCKERHUB_RELEASE_USERNAME || secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ inputs.release && secrets.DOCKERHUB_RELEASE_TOKEN || secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Set up QEMU
|
||||
if: contains(steps.platforms.outputs.result, 'arm')
|
||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
if: contains(steps.platforms.outputs.result, 'arm')
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
|
||||
- name: Build and Push Images
|
||||
- name: Build multi-arch container
|
||||
if: contains(steps.platforms.outputs.result, 'arm')
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: bin/docker/.
|
||||
platforms: ${{ inputs.platforms }}
|
||||
platforms: ${{ steps.platforms.outputs.result }}
|
||||
network: host
|
||||
tags: ${{ steps.image.outputs.full_image }}
|
||||
tags: ${{ inputs.repo }}:${{ inputs.tag }}
|
||||
no-cache: true
|
||||
push: true
|
||||
build-args: ${{ inputs.build-args }}
|
||||
|
||||
- name: Post link to image on DockerHub
|
||||
if: ${{ inputs.registry != 'ecr' && github.event_name == 'pull_request' }}
|
||||
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
|
||||
with:
|
||||
header: docker-build-status-${{ inputs.edition }}
|
||||
message: |
|
||||
✅ Docker build completed for commit ${{ inputs.commit || github.event.pull_request.head.sha || github.sha }}
|
||||
# we need to do this to get the nice fallback behavior of the legacy manifest file
|
||||
- name: Build container (x86 only)
|
||||
if: contains(steps.platforms.outputs.result, 'arm') == false
|
||||
run: | # bash
|
||||
docker build \
|
||||
-t ${{ inputs.repo }}:${{ inputs.tag }} \
|
||||
--network=host \
|
||||
--push \
|
||||
bin/docker/.
|
||||
|
||||
The uberjar has been built and containerized successfully.
|
||||
|
||||
**Branch:** `${{ github.head_ref || github.ref_name }}`
|
||||
**Docker Image:** `${{ steps.image.outputs.full_image }}`
|
||||
**Docker Hub:** [View on Docker Hub](https://hub.docker.com/r/${{ steps.image.outputs.repo }}/tags?name=${{ steps.image.outputs.tag }})
|
||||
**Triggered by:** @${{ github.actor }}
|
||||
|
||||
Pull the image:
|
||||
```bash
|
||||
docker pull ${{ steps.image.outputs.full_image }}
|
||||
```
|
||||
|
||||
verify-docker-pull-amd64:
|
||||
needs: containerize
|
||||
if: >-
|
||||
inputs.release &&
|
||||
inputs.registry != 'ecr'
|
||||
name: verify-docker-pull-amd64 (${{ inputs.edition }})
|
||||
verify-docker-pull:
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
needs: containerize
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Login to Docker Hub
|
||||
@@ -175,22 +126,18 @@ jobs:
|
||||
username: ${{ inputs.release && secrets.DOCKERHUB_RELEASE_USERNAME || secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ inputs.release && secrets.DOCKERHUB_RELEASE_TOKEN || secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Pull the container image
|
||||
run: docker pull ${{ needs.containerize.outputs.full_image }}
|
||||
run: docker pull ${{ inputs.repo }}:${{ inputs.tag }}
|
||||
- name: Launch container
|
||||
run: docker run --rm -dp 3000:3000 ${{ needs.containerize.outputs.full_image }}
|
||||
run: docker run --rm -dp 3000:3000 ${{ inputs.repo }}:${{ inputs.tag }}
|
||||
timeout-minutes: 5
|
||||
- name: Wait for Metabase to start
|
||||
run: while ! curl -s 'http://localhost:3000/api/health' | grep '{"status":"ok"}'; do sleep 1; done
|
||||
timeout-minutes: 5
|
||||
|
||||
verify-docker-pull-arm:
|
||||
needs: containerize
|
||||
if: >-
|
||||
inputs.release &&
|
||||
inputs.registry != 'ecr' &&
|
||||
contains(inputs.platforms, 'linux/arm64')
|
||||
name: verify-docker-pull-arm (${{ inputs.edition }})
|
||||
runs-on: arm-ubuntu-24-2core
|
||||
needs: containerize
|
||||
if: contains(needs.containerize.outputs.platforms, 'linux/arm64')
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Login to Docker Hub
|
||||
@@ -199,9 +146,9 @@ jobs:
|
||||
username: ${{ inputs.release && secrets.DOCKERHUB_RELEASE_USERNAME || secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ inputs.release && secrets.DOCKERHUB_RELEASE_TOKEN || secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Pull the container image
|
||||
run: docker pull ${{ needs.containerize.outputs.full_image }}
|
||||
run: docker pull ${{ inputs.repo }}:${{ inputs.tag }}
|
||||
- name: Launch container
|
||||
run: docker run --rm -dp 3000:3000 ${{ needs.containerize.outputs.full_image }}
|
||||
run: docker run --rm -dp 3000:3000 ${{ inputs.repo }}:${{ inputs.tag }}
|
||||
timeout-minutes: 5
|
||||
- name: Wait for Metabase to start
|
||||
run: while ! curl -s 'http://localhost:3000/api/health' | grep '{"status":"ok"}'; do sleep 1; done
|
||||
|
||||
102
.github/workflows/containerize-uberjar.yml
vendored
Normal file
102
.github/workflows/containerize-uberjar.yml
vendored
Normal file
@@ -0,0 +1,102 @@
|
||||
name: Containerize Uberjar
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
commit:
|
||||
description: the full commit hash to containerize (defaults to the triggering commit)
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
get-container-info:
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 5
|
||||
name: Get container info
|
||||
outputs:
|
||||
editions: ${{ steps.ee-info.outputs.editions }}
|
||||
ee: ${{ toJson(fromJson(steps.build-matrix.outputs.result).ee) }}
|
||||
oss: ${{ toJson(fromJson(steps.build-matrix.outputs.result).oss) }}
|
||||
steps:
|
||||
- name: Check out the code
|
||||
uses: actions/checkout@v6
|
||||
- name: Get EE Docker info
|
||||
id: ee-info
|
||||
uses: ./.github/actions/get-docker-info
|
||||
with:
|
||||
edition: ee
|
||||
- name: Get OSS Docker info
|
||||
id: oss-info
|
||||
uses: ./.github/actions/get-docker-info
|
||||
with:
|
||||
edition: oss
|
||||
- name: Build matrix info
|
||||
id: build-matrix
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: | # js
|
||||
const editions = JSON.parse('${{ steps.ee-info.outputs.editions }}');
|
||||
const result = {
|
||||
editions: editions,
|
||||
ee: {
|
||||
repo: '${{ steps.ee-info.outputs.repo }}',
|
||||
tag: '${{ steps.ee-info.outputs.tag }}'
|
||||
},
|
||||
oss: {
|
||||
repo: '${{ steps.oss-info.outputs.repo }}',
|
||||
tag: '${{ steps.oss-info.outputs.tag }}'
|
||||
}
|
||||
};
|
||||
console.log('Build matrix:', result);
|
||||
return result;
|
||||
|
||||
containerize:
|
||||
needs: [get-container-info]
|
||||
if: needs.get-container-info.result == 'success'
|
||||
strategy:
|
||||
matrix:
|
||||
edition: ${{ fromJson(needs.get-container-info.outputs.editions) }}
|
||||
uses: ./.github/workflows/containerize-jar.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
edition: ${{ matrix.edition }}
|
||||
commit: ${{ github.event.inputs.commit || github.event.pull_request.head.sha || github.sha }}
|
||||
repo: ${{ fromJson(needs.get-container-info.outputs[matrix.edition]).repo }}
|
||||
tag: ${{ fromJson(needs.get-container-info.outputs[matrix.edition]).tag }}
|
||||
release: false
|
||||
build-args: |
|
||||
GIT_COMMIT_SHA=${{ github.event.inputs.commit || github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
post-comment:
|
||||
needs: [get-container-info, containerize]
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
name: Post PR comment
|
||||
steps:
|
||||
- name: Check out the code
|
||||
uses: actions/checkout@v6
|
||||
- name: Get Docker info for comment
|
||||
id: docker-info
|
||||
uses: ./.github/actions/get-docker-info
|
||||
with:
|
||||
branch: ${{ github.head_ref || github.ref_name }}
|
||||
edition: ee
|
||||
|
||||
- name: Post PR comment
|
||||
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
|
||||
with:
|
||||
header: docker-build-status
|
||||
message: |
|
||||
✅ Docker build completed for commit ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
The uberjar has been built and containerized successfully.
|
||||
|
||||
**Branch:** `${{ github.head_ref || github.ref_name }}`
|
||||
**Docker Image:** `${{ steps.docker-info.outputs.full_image }}`
|
||||
**Docker Hub:** [View on Docker Hub](https://hub.docker.com/r/${{ steps.docker-info.outputs.repo }}/tags?name=${{ steps.docker-info.outputs.tag }})
|
||||
**Triggered by:** @${{ github.actor }}
|
||||
|
||||
Pull the image:
|
||||
```bash
|
||||
docker pull ${{ steps.docker-info.outputs.full_image }}
|
||||
```
|
||||
9
.github/workflows/cut-release-branch.yml
vendored
9
.github/workflows/cut-release-branch.yml
vendored
@@ -59,12 +59,6 @@ jobs:
|
||||
# Pushing the new branch triggers set-milestone.yml, which does the
|
||||
# full milestone backfill and then dispatches release-notes-preview
|
||||
# to upload the markdown to #writing.
|
||||
#
|
||||
# Kondo ignore ratchets apply only to master. The cut commit replaces the
|
||||
# budgets with an explicit opt-out so future budget changes are not applied
|
||||
# to the release branch. Keeping the opt-out in the canonical file avoids
|
||||
# introducing a separate marker that could drift out of sync, and the
|
||||
# backport helper writes the same opt-out so both paths stay identical.
|
||||
- name: Cut the branch
|
||||
id: cut
|
||||
run: |
|
||||
@@ -72,9 +66,6 @@ jobs:
|
||||
git config user.name github-actions
|
||||
git config user.email github-actions@github.com
|
||||
git checkout -b "$BRANCH"
|
||||
source .github/actions/create-backport/kondo-ratchets.sh
|
||||
write_disabled_ratchets
|
||||
git add --sparse .clj-kondo/ratchets.edn
|
||||
git commit --allow-empty --no-verify -m "Cut $BRANCH branch"
|
||||
git push -u origin "$BRANCH"
|
||||
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
12
.github/workflows/docs-generate-base.yml
vendored
12
.github/workflows/docs-generate-base.yml
vendored
@@ -75,17 +75,6 @@ jobs:
|
||||
clojure -M:ee:doc command-documentation
|
||||
continue-on-error: false
|
||||
|
||||
- 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..."
|
||||
clojure -M:ee:doc ai-providers-documentation
|
||||
continue-on-error: false
|
||||
|
||||
- name: Generate usage analytics documentation
|
||||
run: ./bin/generate-usage-analytics-docs.bb
|
||||
|
||||
@@ -175,7 +164,6 @@ jobs:
|
||||
- \`clojure -M:ee:doc config-template\`
|
||||
- \`clojure -M:ee:doc api-documentation\`
|
||||
- \`clojure -M:ee:doc command-documentation\`
|
||||
- \`clojure -M:ee:doc ai-providers-documentation\`
|
||||
- \`./bin/generate-usage-analytics-docs.bb\`
|
||||
- \`bun run embedding-sdk:docs:generate\`
|
||||
- \`bun run embedding-eajs:docs:generate\`
|
||||
|
||||
6
.github/workflows/docs-links.yml
vendored
6
.github/workflows/docs-links.yml
vendored
@@ -32,9 +32,3 @@ 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
|
||||
|
||||
229
.github/workflows/drivers.yml
vendored
229
.github/workflows/drivers.yml
vendored
@@ -74,7 +74,6 @@ 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 }}
|
||||
@@ -163,16 +162,35 @@ 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: 40
|
||||
timeout-minutes: 70
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
runner-index: [0, 1]
|
||||
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
|
||||
env:
|
||||
CI: 'true'
|
||||
DRIVERS: bigquery-cloud-sdk
|
||||
@@ -183,43 +201,11 @@ 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 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
|
||||
name: BigQuery ${{ matrix.job.name }}
|
||||
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 }}
|
||||
@@ -227,17 +213,16 @@ 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: >-
|
||||
:only-tags [:mb/transforms-python-test]
|
||||
artifact-suffix: transforms-python
|
||||
test-args: ${{ matrix.job.test-args }}
|
||||
artifact-suffix: ${{ matrix.job.name }}
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
- name: Cleanup python-runner
|
||||
if: ${{ always() }}
|
||||
if: ${{ always() && matrix.job.needs-python-runner == true }}
|
||||
uses: ./.github/actions/cleanup-python-runner
|
||||
with:
|
||||
upload-logs: ${{ steps.test-driver.outcome == 'failure' }}
|
||||
logs-artifact-suffix: transforms-python
|
||||
logs-artifact-suffix: ${{ matrix.job.name }}
|
||||
|
||||
be-tests-clickhouse-ee:
|
||||
needs: determine-driver-skips
|
||||
@@ -294,6 +279,7 @@ 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
|
||||
@@ -327,6 +313,7 @@ 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
|
||||
@@ -342,45 +329,34 @@ jobs:
|
||||
:only-tags [:mb/driver-tests]
|
||||
:exclude-tags [:mb/transforms-python-test]
|
||||
:fail-fast? true
|
||||
:partition/total 2
|
||||
:partition/total 3
|
||||
:partition/index 0
|
||||
- name: Driver Tests Part 2
|
||||
test-args: >-
|
||||
:only-tags [:mb/driver-tests]
|
||||
:exclude-tags [:mb/transforms-python-test]
|
||||
:fail-fast? true
|
||||
:partition/total 2
|
||||
:partition/total 3
|
||||
:partition/index 1
|
||||
- name: Driver Tests Part 3
|
||||
test-args: >-
|
||||
:only-tags [:mb/driver-tests]
|
||||
:exclude-tags [:mb/transforms-python-test]
|
||||
:fail-fast? true
|
||||
:partition/total 3
|
||||
:partition/index 2
|
||||
env:
|
||||
CI: 'true'
|
||||
DRIVERS: databricks
|
||||
MB_DATABRICKS_TEST_HOST: ${{ secrets.MB_DATABRICKS_JDBC_TEST_HOST }}
|
||||
MB_DATABRICKS_TEST_HTTP_PATH: ${{ secrets.MB_DATABRICKS_JDBC_TEST_HTTP_PATH }}
|
||||
MB_DATABRICKS_TEST_TOKEN: ${{ secrets.MB_DATABRICKS_JDBC_TEST_TOKEN }}
|
||||
MB_DATABRICKS_TEST_CATALOG: 'metabase_ci'
|
||||
MB_DATABRICKS_TEST_CATALOG_ROUTING: 'metabase_routing_catalog'
|
||||
MB_DATABRICKS_TEST_CLIENT_ID: ${{ secrets.MB_DATABRICKS_JDBC_TEST_CLIENT_ID }}
|
||||
MB_DATABRICKS_TEST_OAUTH_SECRET: ${{ secrets.MB_DATABRICKS_JDBC_TEST_OAUTH_SECRET }}
|
||||
name: Databricks
|
||||
steps:
|
||||
- name: Select Databricks workspace
|
||||
shell: bash
|
||||
env:
|
||||
TOTAL_SHARDS: '2'
|
||||
HOST_0: ${{ secrets.MB_DATABRICKS_JDBC_TEST_HOST }}
|
||||
TOKEN_0: ${{ secrets.MB_DATABRICKS_JDBC_TEST_TOKEN }}
|
||||
HTTP_PATH_0: ${{ secrets.MB_DATABRICKS_JDBC_TEST_HTTP_PATH }}
|
||||
HOST_1: ${{ secrets.MB_DATABRICKS_JDBC_TEST_HOST_2 }}
|
||||
TOKEN_1: ${{ secrets.MB_DATABRICKS_JDBC_TEST_TOKEN_2 }}
|
||||
HTTP_PATH_1: ${{ secrets.MB_DATABRICKS_JDBC_TEST_HTTP_PATH_2 }}
|
||||
run: | # sh
|
||||
shard=$(( GITHUB_RUN_NUMBER % TOTAL_SHARDS ))
|
||||
echo "Using Databricks workspace shard $(( shard + 1 )) of $TOTAL_SHARDS"
|
||||
for name in HOST HTTP_PATH TOKEN; do
|
||||
var="${name}_${shard}"
|
||||
if [ -z "${!var}" ]; then
|
||||
echo "::error::Secret $var is empty - is the shard $shard Databricks workspace configured?"
|
||||
exit 1
|
||||
fi
|
||||
echo "MB_DATABRICKS_TEST_${name}=${!var}" >> "$GITHUB_ENV"
|
||||
done
|
||||
- uses: actions/checkout@v6
|
||||
- name: Test Databricks driver
|
||||
id: test-driver
|
||||
@@ -389,6 +365,60 @@ 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-databricks-multi-catalog-ee:
|
||||
needs: determine-driver-skips
|
||||
if: ${{ needs.determine-driver-skips.outputs.databricks-should-run == 'true' }}
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 50
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
job:
|
||||
- name: Driver Tests Part 1
|
||||
test-args: >-
|
||||
:only-tags [:mb/driver-tests]
|
||||
:exclude-tags [:mb/transforms-python-test]
|
||||
:fail-fast? true
|
||||
:partition/total 3
|
||||
:partition/index 0
|
||||
- name: Driver Tests Part 2
|
||||
test-args: >-
|
||||
:only-tags [:mb/driver-tests]
|
||||
:exclude-tags [:mb/transforms-python-test]
|
||||
:fail-fast? true
|
||||
:partition/total 3
|
||||
:partition/index 1
|
||||
- name: Driver Tests Part 3
|
||||
test-args: >-
|
||||
:only-tags [:mb/driver-tests]
|
||||
:exclude-tags [:mb/transforms-python-test]
|
||||
:fail-fast? true
|
||||
:partition/total 3
|
||||
:partition/index 2
|
||||
env:
|
||||
CI: 'true'
|
||||
DRIVERS: databricks
|
||||
MB_DATABRICKS_TEST_MULTI_LEVEL_SCHEMA: true
|
||||
MB_DATABRICKS_TEST_HOST: ${{ secrets.MB_DATABRICKS_JDBC_TEST_HOST }}
|
||||
MB_DATABRICKS_TEST_HTTP_PATH: ${{ secrets.MB_DATABRICKS_JDBC_TEST_HTTP_PATH }}
|
||||
MB_DATABRICKS_TEST_TOKEN: ${{ secrets.MB_DATABRICKS_JDBC_TEST_TOKEN }}
|
||||
MB_DATABRICKS_TEST_CATALOG: 'metabase_ci'
|
||||
MB_DATABRICKS_TEST_CATALOG_ROUTING: 'metabase_routing_catalog'
|
||||
MB_DATABRICKS_TEST_CLIENT_ID: ${{ secrets.MB_DATABRICKS_JDBC_TEST_CLIENT_ID }}
|
||||
MB_DATABRICKS_TEST_OAUTH_SECRET: ${{ secrets.MB_DATABRICKS_JDBC_TEST_OAUTH_SECRET }}
|
||||
name: Databricks (Multi-Catalog)
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Test Databricks driver with multi-catalog
|
||||
id: test-driver
|
||||
uses: ./.github/actions/test-driver
|
||||
with:
|
||||
junit-name: 'be-tests-databricks-multi-catalog-ee'
|
||||
artifact-suffix: ${{ matrix.job.name }}
|
||||
test-args: ${{ matrix.job.test-args }}
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
|
||||
be-tests-h2:
|
||||
needs: determine-driver-skips
|
||||
@@ -408,6 +438,7 @@ 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
|
||||
@@ -428,6 +459,7 @@ 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
|
||||
@@ -450,9 +482,9 @@ jobs:
|
||||
env:
|
||||
enable-ssl-tests: 'false'
|
||||
enable-aws-iam-tests: 'false'
|
||||
- name: MySQL 8
|
||||
junit-name: be-tests-mysql-8-ee
|
||||
image: mysql:8.4
|
||||
- name: MySQL 8.0
|
||||
junit-name: be-tests-mysql-8-0-ee
|
||||
image: mysql:8.0
|
||||
env:
|
||||
enable-ssl-tests: 'false'
|
||||
enable-aws-iam-tests: 'false'
|
||||
@@ -546,6 +578,7 @@ 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
|
||||
@@ -605,6 +638,7 @@ 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
|
||||
@@ -666,6 +700,7 @@ 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
|
||||
@@ -690,6 +725,7 @@ 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
|
||||
@@ -754,6 +790,7 @@ 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
|
||||
@@ -777,20 +814,9 @@ jobs:
|
||||
enable-ssl-tests: 'true'
|
||||
enable-aws-iam-tests: 'true'
|
||||
job:
|
||||
# Split because the unpartitioned Driver Tests job was running ~40 minutes of tests against
|
||||
# `timeout-minutes: 50` (checkout and prepare-backend take the rest), and Postgres Latest was
|
||||
# timing out.
|
||||
- name: Driver Tests Part 1
|
||||
- name: Driver Tests
|
||||
test-args: >-
|
||||
:only-tags [:mb/driver-tests]
|
||||
:partition/total 2
|
||||
:partition/index 0
|
||||
exclude-tag: ':mb/transforms-python-test'
|
||||
- name: Driver Tests Part 2
|
||||
test-args: >-
|
||||
:only-tags [:mb/driver-tests]
|
||||
:partition/total 2
|
||||
:partition/index 1
|
||||
exclude-tag: ':mb/transforms-python-test'
|
||||
- name: Transforms Python Tests
|
||||
test-args: >-
|
||||
@@ -846,6 +872,7 @@ 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
|
||||
@@ -912,6 +939,7 @@ 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
|
||||
@@ -927,44 +955,17 @@ jobs:
|
||||
test-args: >-
|
||||
:only-tags [:mb/upload-tests]
|
||||
:fail-fast? true
|
||||
# `:partition/*` balances on test-var count, so it cannot break up a block of namespaces that is
|
||||
# cheap in vars and expensive in seconds. The `metabase-enterprise.*` driver tests are exactly that:
|
||||
# ~10% of the vars and ~40% of the wall clock, and they sort ahead of `metabase.*`, so a plain 2-way
|
||||
# split hands one job the whole block and times out. Splitting on the namespace pattern first puts
|
||||
# them in their own pair of jobs, and each pair then partitions by count within a comparable cost.
|
||||
# The two patterns are exhaustive over the default `:namespace-pattern` -- every namespace lands in
|
||||
# exactly one of them -- so nothing stops running when a namespace is added.
|
||||
- name: Redshift EE (Excluding Upload Tests) Part 1
|
||||
- name: Redshift (Excluding Upload Tests) Part 1
|
||||
skip: false
|
||||
test-args: >-
|
||||
:namespace-pattern '"^metabase-enterprise[.].*"'
|
||||
:only-tags [:mb/driver-tests]
|
||||
:exclude-tags '[:mb/upload-tests :mb/transforms-python-test]'
|
||||
:fail-fast? true
|
||||
:partition/total 2
|
||||
:partition/index 0
|
||||
- name: Redshift EE (Excluding Upload Tests) Part 2
|
||||
- name: Redshift (Excluding Upload Tests) Part 2
|
||||
skip: false
|
||||
test-args: >-
|
||||
:namespace-pattern '"^metabase-enterprise[.].*"'
|
||||
:only-tags [:mb/driver-tests]
|
||||
:exclude-tags '[:mb/upload-tests :mb/transforms-python-test]'
|
||||
:fail-fast? true
|
||||
:partition/total 2
|
||||
:partition/index 1
|
||||
- name: Redshift OSS (Excluding Upload Tests) Part 1
|
||||
skip: false
|
||||
test-args: >-
|
||||
:namespace-pattern '"^(?:metabase[.]|hooks[.]).*"'
|
||||
:only-tags [:mb/driver-tests]
|
||||
:exclude-tags '[:mb/upload-tests :mb/transforms-python-test]'
|
||||
:fail-fast? true
|
||||
:partition/total 2
|
||||
:partition/index 0
|
||||
- name: Redshift OSS (Excluding Upload Tests) Part 2
|
||||
skip: false
|
||||
test-args: >-
|
||||
:namespace-pattern '"^(?:metabase[.]|hooks[.]).*"'
|
||||
:only-tags [:mb/driver-tests]
|
||||
:exclude-tags '[:mb/upload-tests :mb/transforms-python-test]'
|
||||
:fail-fast? true
|
||||
@@ -1002,6 +1003,7 @@ 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
|
||||
@@ -1064,6 +1066,7 @@ 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
|
||||
@@ -1095,6 +1098,7 @@ 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,6 +1119,7 @@ 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
|
||||
@@ -1170,6 +1175,7 @@ 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
|
||||
@@ -1266,6 +1272,7 @@ jobs:
|
||||
test-args: >-
|
||||
:only-tags [:mb/driver-tests]
|
||||
:exclude-tags [:mb/transforms-python-test]
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
|
||||
drivers-tests-result:
|
||||
needs:
|
||||
@@ -1273,9 +1280,9 @@ 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-databricks-multi-catalog-ee
|
||||
- be-tests-druid-jdbc-ee
|
||||
- be-tests-h2
|
||||
- be-tests-h2-oss
|
||||
|
||||
2
.github/workflows/e2e-coverage-shard.yml
vendored
2
.github/workflows/e2e-coverage-shard.yml
vendored
@@ -97,7 +97,7 @@ jobs:
|
||||
CYPRESS_BROWSER: ${{ steps.cypress-prep.outputs.chrome-path }}
|
||||
run: |
|
||||
node e2e/runner/run_cypress_ci.js e2e \
|
||||
--expose grepTags="-@mongo+-@python+-@OSS"
|
||||
--expose grepTags="-@mongo+-@python+-@OSS+-@skip"
|
||||
# Don't let a single failing/flaky spec abort the shard — we still want
|
||||
# coverage from the specs that did run.
|
||||
continue-on-error: true
|
||||
|
||||
2
.github/workflows/e2e-matrix-builder.yml
vendored
2
.github/workflows/e2e-matrix-builder.yml
vendored
@@ -35,7 +35,7 @@ jobs:
|
||||
sparse-checkout-cone-mode: false
|
||||
- uses: actions/setup-node@v6.4.0
|
||||
with:
|
||||
node-version: lts/Jod # 22.x.x
|
||||
node-version: lts/Krypton # 24.x.x
|
||||
- name: Build e2e matrix
|
||||
uses: actions/github-script@v9
|
||||
id: matrix
|
||||
|
||||
36
.github/workflows/e2e-test.yml
vendored
36
.github/workflows/e2e-test.yml
vendored
@@ -64,7 +64,6 @@ 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
|
||||
@@ -102,7 +101,6 @@ jobs:
|
||||
postgres: true
|
||||
mysql: true
|
||||
mongo: true
|
||||
mocksaml: true
|
||||
|
||||
- name: Retrieve uberjar artifact for ${{ inputs.edition }}
|
||||
uses: ./.github/actions/fetch-artifact
|
||||
@@ -121,28 +119,17 @@ 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
|
||||
- name: Download failed test artifacts
|
||||
uses: actions/download-artifact@v8
|
||||
if: github.run_attempt != '1'
|
||||
continue-on-error: true
|
||||
id: failed-specs
|
||||
with:
|
||||
pattern: ${{ env.RERUN_ARTIFACT_BASE }}-attempt-${{ env.RERUN_PREV_ATTEMPT }}
|
||||
path: target/rerun
|
||||
name: failed-tests-${{ inputs.name }}-${{ inputs.edition }}
|
||||
|
||||
- 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
|
||||
if: github.run_attempt != '1'
|
||||
run: echo "specs=$(cat failed-specs)" >> "$GITHUB_ENV"
|
||||
|
||||
# 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.
|
||||
@@ -194,7 +181,7 @@ jobs:
|
||||
run: |
|
||||
echo "Running tests with specs: $SPEC_PATH"
|
||||
node e2e/runner/run_cypress_ci.js e2e \
|
||||
--expose grepTags="-@mongo+-@python+-@OSS" \
|
||||
--expose grepTags="-@mongo+-@python+-@OSS+-@skip" \
|
||||
--spec "$SPEC_PATH"
|
||||
# Keep this step continue-on-error because the quarantine logic controls whether it ultimately fails.
|
||||
continue-on-error: true
|
||||
@@ -211,7 +198,7 @@ jobs:
|
||||
run: |
|
||||
echo "Running tests with specs: $SPEC_PATH"
|
||||
node e2e/runner/run_cypress_ci.js e2e \
|
||||
--expose grepTags="${{ inputs.tags || '-@mongo+-@python+-@OSS' }}" \
|
||||
--expose grepTags="${{inputs.tags && format('{0}+-@skip', inputs.tags) || '-@mongo+-@python+-@OSS+-@skip'}}" \
|
||||
--spec "$SPEC_PATH"
|
||||
# Keep this step continue-on-error because the quarantine logic controls whether it ultimately fails.
|
||||
continue-on-error: true
|
||||
@@ -245,7 +232,7 @@ jobs:
|
||||
if: |
|
||||
!cancelled() && (steps.split-e2e.outcome == 'failure' || steps.tagged-e2e.outcome == 'failure')
|
||||
with:
|
||||
name: cypress-recording-${{ inputs.name }}-${{ inputs.edition }}-attempt-${{ github.run_attempt }}
|
||||
name: cypress-recording-${{ inputs.name }}-${{ inputs.edition }}
|
||||
path: |
|
||||
./cypress
|
||||
./logs/test.log
|
||||
@@ -255,9 +242,8 @@ jobs:
|
||||
uses: actions/upload-artifact@v7
|
||||
if: |
|
||||
!cancelled() && (steps.split-e2e.outcome == 'failure' || steps.tagged-e2e.outcome == 'failure')
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: ${{ env.RERUN_ARTIFACT_BASE }}-attempt-${{ github.run_attempt }}
|
||||
name: failed-tests-${{ inputs.name }}-${{ inputs.edition }}
|
||||
path: ./cypress/test-results
|
||||
if-no-files-found: ignore
|
||||
|
||||
|
||||
118
.github/workflows/embedding-sdk-component-tests.yml
vendored
118
.github/workflows/embedding-sdk-component-tests.yml
vendored
@@ -26,9 +26,6 @@ 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)
|
||||
@@ -76,45 +73,6 @@ 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.
|
||||
- name: Download pre-built CLJS
|
||||
id: prebuilt-cljs
|
||||
uses: actions/download-artifact@v8
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: e2e-cljs-${{ github.event.pull_request.head.sha || github.sha }}
|
||||
path: target/cljs_dev
|
||||
|
||||
- name: Compile CLJS
|
||||
if: steps.prebuilt-cljs.outcome != 'success'
|
||||
run: bun run build-pure:cljs
|
||||
|
||||
- name: Run Metabase
|
||||
run: node e2e/runner/run_cypress_ci.js start
|
||||
|
||||
@@ -134,18 +92,9 @@ 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 "${SPEC_ARGS[@]}"
|
||||
run: node e2e/runner/run_cypress_ci.js component
|
||||
# Keep this step continue-on-error because the quarantine logic controls whether it ultimately fails.
|
||||
continue-on-error: true
|
||||
|
||||
@@ -176,21 +125,12 @@ jobs:
|
||||
uses: actions/upload-artifact@v7
|
||||
if: ${{ !cancelled() && steps.run-tests.outcome == 'failure' }}
|
||||
with:
|
||||
name: cypress-recording-embedding-sdk-react-${{ matrix.react-version }}-attempt-${{ github.run_attempt }}
|
||||
name: cypress-recording-embedding-sdk-react-${{ matrix.react-version }}
|
||||
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
|
||||
@@ -240,7 +180,6 @@ 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)
|
||||
@@ -307,37 +246,6 @@ 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,
|
||||
# so compile from source instead of downloading.
|
||||
- name: Compile CLJS
|
||||
run: bun run build-pure:cljs
|
||||
|
||||
- name: Run Metabase
|
||||
run: node e2e/runner/run_cypress_ci.js start
|
||||
|
||||
@@ -353,20 +261,11 @@ 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" \
|
||||
"${SPEC_ARGS[@]}"
|
||||
--expose grepTags="-@skip-backward-compatibility"
|
||||
# Keep this step continue-on-error because the quarantine logic controls whether it ultimately fails.
|
||||
continue-on-error: true
|
||||
|
||||
@@ -397,21 +296,12 @@ jobs:
|
||||
uses: actions/upload-artifact@v7
|
||||
if: ${{ !cancelled() && steps.run-tests.outcome == 'failure' }}
|
||||
with:
|
||||
name: cypress-recording-embedding-sdk-cross-version-attempt-${{ github.run_attempt }}
|
||||
name: cypress-recording-embedding-sdk-cross-version
|
||||
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
|
||||
|
||||
@@ -233,6 +233,11 @@ 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 }}
|
||||
@@ -296,6 +301,11 @@ 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 }}
|
||||
@@ -409,53 +419,15 @@ jobs:
|
||||
id: cypress-prep
|
||||
uses: ./.github/actions/prepare-cypress
|
||||
|
||||
# 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.
|
||||
- name: Download pre-built CLJS
|
||||
id: prebuilt-cljs
|
||||
uses: actions/download-artifact@v8
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: e2e-cljs-${{ github.event.pull_request.head.sha || github.sha }}
|
||||
path: target/cljs_dev
|
||||
|
||||
- name: Compile CLJS
|
||||
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: |
|
||||
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
|
||||
node e2e/runner/run_cypress_ci.js snapshot \
|
||||
--spec "e2e/snapshot-creators/default.cy.snap.js"
|
||||
|
||||
- name: Prepare and launch Host App
|
||||
run: npx tsx ./e2e/runner/embedding-sdk/host-apps/start-ci.ts ${{ matrix.test_suite }}
|
||||
|
||||
@@ -24,8 +24,6 @@ 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:
|
||||
|
||||
43
.github/workflows/frontend.yml
vendored
43
.github/workflows/frontend.yml
vendored
@@ -12,13 +12,6 @@ on:
|
||||
skip-custom-viz:
|
||||
type: boolean
|
||||
default: false
|
||||
# ID of this run's test-plan.json artifact, or '' to run the full suite.
|
||||
test-plan-artifact-id:
|
||||
type: string
|
||||
default: ""
|
||||
skip-ci-scripts:
|
||||
type: boolean
|
||||
default: false
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
skip:
|
||||
@@ -107,7 +100,6 @@ jobs:
|
||||
SHARDS: 2 # should match matrix above
|
||||
JEST_JUNIT_OUTPUT_DIR: ./target/junit
|
||||
JEST_JUNIT_OUTPUT_NAME: test-report-frontend-unit.xml
|
||||
UNIT_TEST_RESULTS_FILE: unit-test-results.json
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Prepare front-end environment
|
||||
@@ -118,29 +110,11 @@ jobs:
|
||||
# Resolve JOB_ID before the long test step so the ci-conductor reporter
|
||||
# below can link failures to this exact job. Best-effort.
|
||||
uses: ./.github/actions/resolve-job-id
|
||||
- name: Prepare unit test selection
|
||||
id: unit-selection
|
||||
if: ${{ inputs.test-plan-artifact-id != '' }}
|
||||
uses: ./.github/actions/prepare-test-selection
|
||||
with:
|
||||
suite: unit
|
||||
test-plan-artifact-id: ${{ inputs.test-plan-artifact-id }}
|
||||
- name: Run frontend unit tests
|
||||
id: fe-tests
|
||||
env:
|
||||
JEST_TEST_PATHS_FILE: ${{ steps.unit-selection.outputs.paths-file }}
|
||||
# Jest exits non-zero on a shard with no files unless --passWithNoTests is set.
|
||||
run: |
|
||||
rm -f "$UNIT_TEST_RESULTS_FILE" "$JEST_JUNIT_OUTPUT_DIR/$JEST_JUNIT_OUTPUT_NAME"
|
||||
bun run test-unit --silent --shard=${{ matrix.shard }}/${{ env.SHARDS }} --json --outputFile="$UNIT_TEST_RESULTS_FILE" ${{ steps.unit-selection.outputs.paths-file && '--passWithNoTests' || '' }}
|
||||
# Ordinary assertion failures can be quarantined, but runner failures cannot.
|
||||
run: bun run test-unit --silent --shard=${{ matrix.shard }}/${{ env.SHARDS }}
|
||||
# Keep this step continue-on-error because the quarantine logic controls whether it ultimately fails.
|
||||
continue-on-error: true
|
||||
- name: Check unit test results
|
||||
if: ${{ !cancelled() && (steps.fe-tests.outcome == 'failure' || steps.unit-selection.outputs.paths-file) }}
|
||||
env:
|
||||
TEST_OUTCOME: ${{ steps.fe-tests.outcome }}
|
||||
JEST_TEST_PATHS_FILE: ${{ steps.unit-selection.outputs.paths-file }}
|
||||
run: bun .github/scripts/check-unit-test-results.ts
|
||||
- name: Report failed tests to ci-conductor
|
||||
if: ${{ !cancelled() && ( steps.fe-tests.outcome == 'failure' || github.run_attempt != '1' ) }}
|
||||
# This step should NEVER fail the job!
|
||||
@@ -170,18 +144,6 @@ jobs:
|
||||
- name: Run frontend timezones tests
|
||||
run: bun run test-timezones
|
||||
|
||||
# No CLJS compile: the CI-script specs don't import app code.
|
||||
fe-tests-ci-scripts:
|
||||
if: ${{ !inputs.skip-ci-scripts }}
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Prepare front-end environment
|
||||
uses: ./.github/actions/prepare-frontend
|
||||
- name: Run CI script tests
|
||||
run: bun run test-ci-scripts
|
||||
|
||||
# The custom-viz package is an isolated Bun workspace under
|
||||
# enterprise/frontend/src/custom-viz
|
||||
fe-custom-viz:
|
||||
@@ -212,7 +174,6 @@ jobs:
|
||||
- fe-type-check
|
||||
- fe-tests-unit
|
||||
- fe-tests-timezones
|
||||
- fe-tests-ci-scripts
|
||||
- fe-custom-viz
|
||||
if: always() && !cancelled()
|
||||
runs-on: ${{ vars.SLIM_RUNNER_KEY }}
|
||||
|
||||
87
.github/workflows/kondo-ratchets-self-heal.yml
vendored
Normal file
87
.github/workflows/kondo-ratchets-self-heal.yml
vendored
Normal file
@@ -0,0 +1,87 @@
|
||||
name: Kondo Ratchets Self-Heal
|
||||
|
||||
# Stale budgets in .clj-kondo/ratchets.edn (above the actual ignore counts) fail CI. On PRs
|
||||
# labelled kondo-ratchets-self-healing, this commits the lowered budgets to the branch;
|
||||
# unlabelled PRs rely on local test runs to tighten the file.
|
||||
#
|
||||
# Two jobs so the automation token never shares an environment with PR-controlled code: the
|
||||
# ratchet-down job runs ./bin/mage from the PR branch and only exports the fixed file as an
|
||||
# artifact; the push job starts from a clean runner, executes nothing from the PR, and pushes.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, labeled, unlabeled]
|
||||
paths:
|
||||
- "**.clj"
|
||||
- "**.cljc"
|
||||
- "**.cljs"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
ratchet-down:
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 10
|
||||
# fork PRs don't get the automation token, so we couldn't push to them anyway
|
||||
if: >-
|
||||
contains(github.event.pull_request.labels.*.name, 'kondo-ratchets-self-healing')
|
||||
&& github.event.pull_request.head.repo.full_name == github.repository
|
||||
outputs:
|
||||
changed: ${{ steps.diff.outputs.changed }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.ref }}
|
||||
persist-credentials: false
|
||||
- name: Prepare back-end environment
|
||||
uses: ./.github/actions/prepare-backend
|
||||
- name: Lower ignore budgets to match the source tree
|
||||
run: ./bin/mage fix-kondo-ratchets
|
||||
- name: Check for changes
|
||||
id: diff
|
||||
run: |
|
||||
if git diff --quiet .clj-kondo/ratchets.edn; then
|
||||
echo "budgets already tight"
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: steps.diff.outputs.changed == 'true'
|
||||
with:
|
||||
name: ratchets-edn
|
||||
path: .clj-kondo/ratchets.edn
|
||||
# dot-directories are excluded by default, which would ship an empty artifact
|
||||
include-hidden-files: true
|
||||
|
||||
push:
|
||||
needs: ratchet-down
|
||||
if: needs.ratchet-down.outputs.changed == 'true'
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.ref }}
|
||||
persist-credentials: false
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: ratchets-edn
|
||||
path: .clj-kondo/
|
||||
- name: Commit to the PR branch
|
||||
env:
|
||||
PR_BRANCH: ${{ github.event.pull_request.head.ref }}
|
||||
REPO: ${{ github.repository }}
|
||||
# METABASE_AUTOMATION_USER_TOKEN so the self-healing commit triggers CI
|
||||
# (a GITHUB_TOKEN push would not)
|
||||
AUTOMATION_TOKEN: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
|
||||
run: |
|
||||
git config --global user.name "Metabase Automation"
|
||||
git config --global user.email "github-automation@metabase.com"
|
||||
git add .clj-kondo/ratchets.edn
|
||||
git commit -m "Ratchet down kondo ignore budgets" --no-verify
|
||||
git push "https://x-access-token:${AUTOMATION_TOKEN}@github.com/${REPO}.git" "HEAD:$PR_BRANCH"
|
||||
170
.github/workflows/kondo-ratchets-shrink.yml
vendored
170
.github/workflows/kondo-ratchets-shrink.yml
vendored
@@ -1,170 +0,0 @@
|
||||
name: Shrink ratchets
|
||||
|
||||
# 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 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:
|
||||
push:
|
||||
branches: [master]
|
||||
paths:
|
||||
- "**.clj"
|
||||
- "**.cljc"
|
||||
- "**.cljs"
|
||||
- ".clj-kondo/**"
|
||||
- "bb.edn"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
# checkout and the fetch of the automation branch
|
||||
contents: read
|
||||
# the compare step reads whether the automation PR is still open, and the approve step reviews it as
|
||||
# github-actions[bot]; pushes and the PR itself use the automation user's token
|
||||
pull-requests: write
|
||||
|
||||
# When master is pushed again, the newest run cancels the older one, checks out the latest master it can
|
||||
# see, and force-updates the automation branch. The PR therefore contains one commit on top of master.
|
||||
concurrency:
|
||||
group: kondo-ratchets-shrink
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
AUTOMATION_BRANCH: automation/shrink-ratchets
|
||||
KONDO_RATCHETS_FILE: .clj-kondo/ratchets.edn
|
||||
MODULE_RATCHETS_FILE: .clj-kondo/config/modules/ratchets.edn
|
||||
|
||||
jobs:
|
||||
shrink:
|
||||
name: Shrink ratchets
|
||||
# A fork with Actions enabled has no automation token, so this would only ever fail at the push.
|
||||
if: github.repository == 'metabase/metabase'
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
# `ref: master` checks out the branch head at checkout time rather than the pushed commit, so a run
|
||||
# that queued behind another one starts from the newest master it can see.
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: master
|
||||
# The persisted GITHUB_TOKEN header would override the automation token in the push URL and
|
||||
# get the force-push rejected. Nothing here needs it: the repo is public, so the fetch of the
|
||||
# automation branch is anonymous, and every gh call carries its own token.
|
||||
persist-credentials: false
|
||||
- name: Set up Java for cold dependency resolution
|
||||
uses: actions/setup-java@v5.2.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 25
|
||||
- name: Shrink ratchets
|
||||
run: |
|
||||
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
|
||||
# the PR is repaired by the next run instead of leaving the PR stranded.
|
||||
- name: Compare with master and the automation PR
|
||||
id: compare
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
# The REST `head=owner:branch` filter is applied server-side and only matches this repository's
|
||||
# branch, so a fork carrying a branch of the same name can never hand its PR number to the
|
||||
# approval and auto-merge steps, however many such forks exist. Only an open PR counts; a branch
|
||||
# left behind by a PR closed without merging is overwritten below and gets a new PR, so a closed
|
||||
# one never stops the budgets from being lowered.
|
||||
open_pr=$(gh api "repos/${GITHUB_REPOSITORY}/pulls?state=open&base=master&head=${GITHUB_REPOSITORY_OWNER}:${AUTOMATION_BRANCH}" \
|
||||
--jq '.[0].number // empty')
|
||||
echo "pr=$open_pr" >> "$GITHUB_OUTPUT"
|
||||
|
||||
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 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 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 -- "$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"
|
||||
echo "push=false" >> "$GITHUB_OUTPUT"
|
||||
echo "head=$(git rev-parse FETCH_HEAD)" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git diff --stat -- "$KONDO_RATCHETS_FILE" "$MODULE_RATCHETS_FILE"
|
||||
echo "push=true" >> "$GITHUB_OUTPUT"
|
||||
- name: Update the automation PR
|
||||
id: deliver
|
||||
if: steps.compare.outputs.push == 'true'
|
||||
# The automation user's token pushes the branch and owns the PR: a branch pushed with
|
||||
# GITHUB_TOKEN starts no pull_request workflows, so the PR would never get its checks. The commit
|
||||
# identity is the docs generator's, which merges with one approval under the unattributed-changes
|
||||
# rule. Nothing raises a budget.
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
|
||||
OPEN_PR: ${{ steps.compare.outputs.pr }}
|
||||
run: |
|
||||
./bin/mage -kondo-ratchets-pr-body \
|
||||
"$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 -- "$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" \
|
||||
"HEAD:refs/heads/$AUTOMATION_BRANCH"
|
||||
echo "head=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
if [ -n "$OPEN_PR" ]; then
|
||||
gh pr edit "$OPEN_PR" --body-file "$RUNNER_TEMP/ratchets-pr-body.md"
|
||||
echo "Updated the automation PR #$OPEN_PR"
|
||||
echo "pr=$OPEN_PR" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
url=$(gh pr create \
|
||||
--base master \
|
||||
--head "$AUTOMATION_BRANCH" \
|
||||
--title "Tighten ratchets" \
|
||||
--label "no-backport" \
|
||||
--body-file "$RUNNER_TEMP/ratchets-pr-body.md")
|
||||
echo "Opened the automation PR $url"
|
||||
echo "pr=${url##*/}" >> "$GITHUB_OUTPUT"
|
||||
- name: Approve the automation PR
|
||||
if: (steps.deliver.outputs.pr || steps.compare.outputs.pr) != ''
|
||||
# GITHUB_TOKEN reviews as github-actions[bot], a different identity from the automation user that
|
||||
# owns the PR, so the approval counts; a user cannot approve their own PR. Reviews survive a push,
|
||||
# so approving an already-open PR again is harmless and keeps a reopened one covered.
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ steps.deliver.outputs.pr || steps.compare.outputs.pr }}
|
||||
run: gh pr review --approve "$PR_NUMBER"
|
||||
# Armed only against the commit this run pushed or compared: the branch name is fixed, and anyone
|
||||
# with push access could otherwise collect this run's approval for their own commit.
|
||||
- name: Enable auto-merge
|
||||
if: (steps.deliver.outputs.pr || steps.compare.outputs.pr) != ''
|
||||
&& (steps.deliver.outputs.head || steps.compare.outputs.head) != ''
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
|
||||
PR_NUMBER: ${{ steps.deliver.outputs.pr || steps.compare.outputs.pr }}
|
||||
HEAD_SHA: ${{ steps.deliver.outputs.head || steps.compare.outputs.head }}
|
||||
run: gh pr merge --squash --auto --match-head-commit "$HEAD_SHA" "$PR_NUMBER"
|
||||
25
.github/workflows/linear-pr-links.yml
vendored
Normal file
25
.github/workflows/linear-pr-links.yml
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
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
154
.github/workflows/lint.security.yml
vendored
@@ -36,7 +36,6 @@ jobs:
|
||||
uses: github/codeql-action/analyze@v4
|
||||
|
||||
trivy:
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -69,157 +68,8 @@ 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, snyk-frontend-projects, snyk-maven-projects]
|
||||
needs: [codeql, trivy]
|
||||
if: ${{ failure() && github.repository == 'metabase/metabase' }}
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 5
|
||||
@@ -244,8 +94,6 @@ 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);
|
||||
|
||||
|
||||
15
.github/workflows/lint.yml
vendored
15
.github/workflows/lint.yml
vendored
@@ -16,12 +16,12 @@ jobs:
|
||||
runs-on: ${{ vars.SLIM_RUNNER_KEY }}
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
actionlint: ${{ steps.changes.outputs.actionlint }}
|
||||
actionlint_files: ${{ steps.changes.outputs.actionlint_files }}
|
||||
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 }}
|
||||
yaml: ${{ steps.changes.outputs.yaml }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
uses: ./.github/actions/prepare-backend
|
||||
|
||||
- name: Setup actionlint
|
||||
if: needs.files-changed.outputs.workflows == 'true'
|
||||
if: needs.files-changed.outputs.actionlint == 'true'
|
||||
id: download
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -81,12 +81,8 @@ jobs:
|
||||
|
||||
# Lint Github Actions
|
||||
- name: Run actionlint
|
||||
if: needs.files-changed.outputs.workflows == 'true'
|
||||
run: ${{ steps.download.outputs.executable }} --color --verbose ${{ needs.files-changed.outputs.workflows_files }}
|
||||
|
||||
- name: Check every workflow job sets a timeout
|
||||
if: needs.files-changed.outputs.workflows == 'true'
|
||||
run: .github/scripts/check-job-timeouts.sh ${{ needs.files-changed.outputs.workflows_files }}
|
||||
if: needs.files-changed.outputs.actionlint == 'true'
|
||||
run: ${{ steps.download.outputs.executable }} --color --verbose ${{ needs.files-changed.outputs.actionlint_files }}
|
||||
|
||||
# Generate and Lint OpenAPI spec
|
||||
- name: Generate OpenAPI spec
|
||||
@@ -127,7 +123,6 @@ jobs:
|
||||
needs: files-changed
|
||||
if: needs.files-changed.outputs.snowplow == 'true'
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
108
.github/workflows/loki.yml
vendored
108
.github/workflows/loki.yml
vendored
@@ -1,57 +1,30 @@
|
||||
name: Loki Visual Regression Testing
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
skip:
|
||||
description: "Skip the Loki visual tests."
|
||||
type: boolean
|
||||
default: false
|
||||
test-plan-artifact-id:
|
||||
description: "ID of this run's test-plan.json artifact, or '' to run the full suite."
|
||||
type: string
|
||||
default: ""
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref && github.ref || github.run_id }}-loki
|
||||
cancel-in-progress: true
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
story-selection:
|
||||
if: ${{ !inputs.skip && inputs.test-plan-artifact-id != '' }}
|
||||
files-changed:
|
||||
name: Check which files changed
|
||||
runs-on: ${{ vars.SLIM_RUNNER_KEY }}
|
||||
timeout-minutes: 5
|
||||
timeout-minutes: 3
|
||||
outputs:
|
||||
selection: ${{ steps.loki-selection.outputs.selection }}
|
||||
test-plan-artifact-id: ${{ steps.loki-selection.outputs.paths-file && inputs.test-plan-artifact-id || '' }}
|
||||
frontend_ci: ${{ steps.changes.outputs.frontend_ci }}
|
||||
frontend_sources: ${{ steps.changes.outputs.frontend_sources }}
|
||||
frontend_loki_ci: ${{ steps.changes.outputs.frontend_loki_ci }}
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v6
|
||||
- name: Test which files changed
|
||||
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
||||
id: changes
|
||||
with:
|
||||
sparse-checkout: |
|
||||
.github
|
||||
/.nvmrc
|
||||
/package.json
|
||||
sparse-checkout-cone-mode: false
|
||||
|
||||
- name: Prepare Node + Bun environment
|
||||
uses: ./.github/actions/prepare-node-bun
|
||||
|
||||
- name: Prepare Loki story selection
|
||||
id: loki-selection
|
||||
uses: ./.github/actions/prepare-test-selection
|
||||
with:
|
||||
suite: loki
|
||||
test-plan-artifact-id: ${{ inputs.test-plan-artifact-id }}
|
||||
token: ${{ github.token }}
|
||||
filters: .github/file-paths.yaml
|
||||
|
||||
visual-test:
|
||||
name: loki-run
|
||||
needs: story-selection
|
||||
# A missing job output compares equal to '0' in GitHub expressions,
|
||||
# so an empty selection is marked with a non-numeric value.
|
||||
if: ${{ !cancelled() && !inputs.skip && needs.story-selection.outputs.selection != 'empty' }}
|
||||
needs: files-changed
|
||||
if: needs.files-changed.outputs.frontend_ci == 'true' || needs.files-changed.outputs.frontend_sources == 'true' || needs.files-changed.outputs.frontend_loki_ci == 'true'
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 30
|
||||
services:
|
||||
docker:
|
||||
image: docker:19.03.12
|
||||
@@ -77,22 +50,8 @@ jobs:
|
||||
- name: Compile CLJS
|
||||
run: NODE_ENV=development bun run build-pure:cljs
|
||||
|
||||
- name: Prepare Loki story selection
|
||||
id: loki-selection
|
||||
if: ${{ needs.story-selection.outputs.test-plan-artifact-id != '' }}
|
||||
uses: ./.github/actions/prepare-test-selection
|
||||
with:
|
||||
suite: loki
|
||||
test-plan-artifact-id: ${{ needs.story-selection.outputs.test-plan-artifact-id }}
|
||||
|
||||
- name: Build Storybook
|
||||
env:
|
||||
STORYBOOK_STORY_PATHS_FILE: ${{ steps.loki-selection.outputs.paths-file }}
|
||||
run: bun run build-storybook
|
||||
|
||||
- name: Run Loki Visual Tests
|
||||
# Loki drops stories with parameters.loki.skip, and exits non-zero when that leaves the selection empty.
|
||||
run: bun run test-visual:loki --reactUri file:./storybook-static --verboseRenderer ${{ steps.loki-selection.outputs.paths-file && '--passWithNoStories --requireReference' || '' }}
|
||||
run: bun run test-visual:loki:ci
|
||||
|
||||
- name: Generate Visual Report on Failure
|
||||
if: failure()
|
||||
@@ -107,38 +66,3 @@ jobs:
|
||||
include-hidden-files: true
|
||||
path: .loki/
|
||||
if-no-files-found: ignore
|
||||
|
||||
loki-tests-result:
|
||||
name: visual-test
|
||||
needs: [story-selection, visual-test]
|
||||
if: always() && !cancelled()
|
||||
runs-on: ${{ vars.SLIM_RUNNER_KEY }}
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check Loki 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
|
||||
}));
|
||||
|
||||
// are all jobs skipped or successful?
|
||||
if (jobs.every(job => (job.result === 'skipped' || job.result === 'success'))) {
|
||||
console.log('All jobs are skipped or successful');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// otherwise, something failed
|
||||
console.log('Some Loki jobs failed');
|
||||
jobs.forEach((job) => {
|
||||
if (job.result !== 'success') {
|
||||
console.log(`${job.name} - ${job.result}`);
|
||||
}
|
||||
});
|
||||
process.exit(1);
|
||||
|
||||
4
.github/workflows/mage.yml
vendored
4
.github/workflows/mage.yml
vendored
@@ -16,7 +16,3 @@ jobs:
|
||||
run: ./bin/mage -h
|
||||
- name: Run mage tests
|
||||
run: ./bin/mage -test
|
||||
- name: Lint create-backport scripts
|
||||
run: shellcheck .github/actions/create-backport/{kondo-ratchets,create-backport-test}.sh
|
||||
- name: Run create-backport tests
|
||||
run: ./.github/actions/create-backport/create-backport-test.sh
|
||||
|
||||
133
.github/workflows/notify.rerun-limit.yml
vendored
133
.github/workflows/notify.rerun-limit.yml
vendored
@@ -1,133 +0,0 @@
|
||||
name: Notify On Re-run Limit
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows:
|
||||
- "Run tests"
|
||||
types:
|
||||
- completed
|
||||
|
||||
jobs:
|
||||
notify:
|
||||
name: Send Slack Notification If Workflow Run Attempts Exceed Limit
|
||||
if: ${{ github.event.workflow_run.run_attempt > 5 && github.repository == 'metabase/metabase' }}
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
sparse-checkout: |
|
||||
release
|
||||
.github
|
||||
|
||||
- name: Prepare release scripts
|
||||
uses: ./.github/actions/prepare-release-scripts
|
||||
|
||||
- name: Send Slack Notification
|
||||
uses: actions/github-script@v9
|
||||
env:
|
||||
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
|
||||
ATTEMPT: ${{ github.event.workflow_run.run_attempt }}
|
||||
COMMIT: ${{ github.event.workflow_run.head_sha }}
|
||||
RUN_ID: ${{ github.event.workflow_run.id }}
|
||||
RUN_NAME: ${{ github.event.workflow_run.name }}
|
||||
RUN_URL: ${{ github.event.workflow_run.html_url }}
|
||||
TRIGGERING_ACTOR_LOGIN: ${{ github.event.workflow_run.triggering_actor.login }}
|
||||
with:
|
||||
script: |
|
||||
const { findSlackMessage, mentionUserByGithubLogin, sendSlackMessage, sendSlackReply } = require(`${{ github.workspace }}/release/dist/index.cjs`);
|
||||
|
||||
const CHANNEL_NAME = 'engineering-ci';
|
||||
|
||||
const ATTEMPT = Number(process.env.ATTEMPT);
|
||||
const COMMIT = process.env.COMMIT;
|
||||
const SHORT_COMMIT = COMMIT.slice(0, 7);
|
||||
const RUN_ID = process.env.RUN_ID;
|
||||
const RUN_NAME = process.env.RUN_NAME;
|
||||
const RUN_URL = process.env.RUN_URL;
|
||||
const TRIGGERING_ACTOR_LOGIN = process.env.TRIGGERING_ACTOR_LOGIN;
|
||||
|
||||
// Every pull request in a stack re-runs as the stack is rebased, so re-runs shouldn't trigger notifications.
|
||||
const { data: pullRequests } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
commit_sha: COMMIT,
|
||||
}).catch(error => {
|
||||
core.warning(`Could not fetch pull requests for ${SHORT_COMMIT}: ${error.message}`);
|
||||
return { data: [] };
|
||||
});
|
||||
|
||||
const stackedPullRequest = pullRequests.find(pullRequest => pullRequest.stack);
|
||||
if (stackedPullRequest) {
|
||||
const { number, stack } = stackedPullRequest;
|
||||
core.info(`Skipping notification: PR #${number} is ${stack.position} of ${stack.size} in stack #${stack.number}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean up PR title to avoid breaking the link to the PR in Slack
|
||||
const pullRequest = pullRequests.find(pullRequest => pullRequest.state === 'open') ?? pullRequests[0];
|
||||
const pullRequestLink = pullRequest
|
||||
? ` in <${pullRequest.html_url}|#${pullRequest.number} ${pullRequest.title.replace(/[<>|]/g, '')}>`
|
||||
: '';
|
||||
|
||||
const messageText = `Workflow Retry Limit Exceeded for run ${RUN_ID} of ${RUN_NAME}`;
|
||||
const existingMessage = await findSlackMessage({
|
||||
channelName: CHANNEL_NAME,
|
||||
text: messageText,
|
||||
limit: 200,
|
||||
}).catch(error => {
|
||||
core.warning(`Could not search #${CHANNEL_NAME} for an existing message: ${error.message}`);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (existingMessage) {
|
||||
await sendSlackReply({
|
||||
channelName: CHANNEL_NAME,
|
||||
messageId: existingMessage.id,
|
||||
message: `:repeat: <${RUN_URL}|${RUN_NAME}> ran again: that is ${ATTEMPT - 1} re-runs for this workflow run.`,
|
||||
}).catch(error => {
|
||||
core.warning(`Could not post a reply to #${CHANNEL_NAME}: ${error.message}`);
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await sendSlackMessage({
|
||||
channelName: CHANNEL_NAME,
|
||||
message: messageText,
|
||||
blocks: [
|
||||
{
|
||||
"type": "header",
|
||||
"text": {
|
||||
"type": "plain_text",
|
||||
"text": `:rotating_light: Workflow Rerun Limit Exceeded :rotating_light:`,
|
||||
"emoji": true,
|
||||
}
|
||||
},
|
||||
],
|
||||
attachments: [{
|
||||
color: "#ff4500",
|
||||
blocks: [
|
||||
{
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": `<${RUN_URL}|${RUN_NAME}> has been re-run ${ATTEMPT - 1} times by ${mentionUserByGithubLogin(TRIGGERING_ACTOR_LOGIN)} for <https://github.com/metabase/metabase/commit/${COMMIT}|${SHORT_COMMIT}> commit${pullRequestLink}.`
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": "Repeatedly re-running CI workflows wastes CI resources and may cause more issues. If re-running a workflow a handful of times does not resolve an issue, reach out to the github-admins Slack group to force merge the PR."
|
||||
}
|
||||
}
|
||||
],
|
||||
}],
|
||||
}).catch(error => {
|
||||
core.warning(`Could not post a message to #${CHANNEL_NAME}: ${error.message}`);
|
||||
});
|
||||
38
.github/workflows/pr-env-close-unlabel.yml
vendored
Normal file
38
.github/workflows/pr-env-close-unlabel.yml
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
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
Normal file
33
.github/workflows/pr-env-destroy-manual.yml
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
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 }}
|
||||
@@ -1,68 +1,29 @@
|
||||
name: Cleanup PR Environment
|
||||
run-name: Destroying Dynamic PR Environment for PR ${{ inputs.pr_number || github.event.number }} by @${{ github.actor }}
|
||||
name: CI for PR Review ENV Destroy
|
||||
run-name: Destroying Dynamic PR Environment for ${{ github.ref_name }} by @${{ github.actor }}
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [ closed, unlabeled ]
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
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
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ inputs.pr_number || github.event.number }}
|
||||
cancel-in-progress: false
|
||||
env:
|
||||
PR_NUMBER: ${{ inputs.pr_number || github.event.number }}
|
||||
|
||||
jobs:
|
||||
# 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'))) }}
|
||||
destroy_pr:
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 10
|
||||
name: PR Review ENV Destroy
|
||||
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
|
||||
@@ -108,17 +69,17 @@ jobs:
|
||||
namespace: default
|
||||
user: oidc-token
|
||||
- name: Destroy PR Review ENV
|
||||
if: ${{ env.SELF_HOSTING != 'true' }}
|
||||
if: ${{ !inputs.self_hosting }}
|
||||
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: ${{ env.SELF_HOSTING == 'true' }}
|
||||
if: ${{ inputs.self_hosting }}
|
||||
uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # v5.0.0
|
||||
with:
|
||||
version: 'latest'
|
||||
- name: Destroy PR Review ENV (self-hosting)
|
||||
if: ${{ env.SELF_HOSTING == 'true' }}
|
||||
if: ${{ inputs.self_hosting }}
|
||||
run: |
|
||||
helm uninstall hosting-pr${{ env.PR_NUMBER }} --namespace hosting-pr${{ env.PR_NUMBER }} || true
|
||||
kubectl delete ns hosting-pr${{ env.PR_NUMBER }} || true
|
||||
52
.github/workflows/pr-env-labeled.yml
vendored
Normal file
52
.github/workflows/pr-env-labeled.yml
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
name: PR-Env labeled
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [ labeled ]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
uberjar:
|
||||
if: ${{ 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' }}
|
||||
uses: ./.github/workflows/uberjar.yml
|
||||
secrets: inherit
|
||||
|
||||
pr-env:
|
||||
needs: [uberjar]
|
||||
if: ${{ github.event.label.name == 'PR-Env' }}
|
||||
uses: ./.github/workflows/pr-env.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
self_hosting: false
|
||||
analytics_dev_mode: false
|
||||
|
||||
pr-env-self-hosting:
|
||||
needs: [uberjar]
|
||||
if: ${{ github.event.label.name == 'PR-Env-self-hosting' }}
|
||||
uses: ./.github/workflows/pr-env.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
self_hosting: true
|
||||
analytics_dev_mode: false
|
||||
|
||||
pr-env-analytics-dev:
|
||||
needs: [uberjar]
|
||||
if: ${{ github.event.label.name == 'PR-Env-analytics-dev' }}
|
||||
uses: ./.github/workflows/pr-env.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
self_hosting: false
|
||||
analytics_dev_mode: true
|
||||
|
||||
pr-env-stats-dump:
|
||||
needs: [uberjar]
|
||||
if: ${{ github.event.label.name == 'PR-Env-Stats-Dump' }}
|
||||
uses: ./.github/workflows/pr-env.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
self_hosting: false
|
||||
analytics_dev_mode: false
|
||||
stats_dump: true
|
||||
@@ -1,86 +1,42 @@
|
||||
name: Deploy PR Environment
|
||||
name: CI for PR Review ENV
|
||||
run-name: Building Dynamic PR Environment for ${{ github.ref_name }} by @${{ github.actor }}
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [ opened, labeled, synchronize, reopened ]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
actions: read
|
||||
pull-requests: write
|
||||
|
||||
workflow_call:
|
||||
inputs:
|
||||
self_hosting:
|
||||
description: "Deploy self-hosting stack"
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
analytics_dev_mode:
|
||||
description: "Enable analytics development mode"
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
stats_dump:
|
||||
description: "Load a recent app DB stats dump on first env creation"
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
jobs:
|
||||
build:
|
||||
if: >-
|
||||
${{
|
||||
github.event.pull_request.head.repo.full_name == 'metabase/metabase' &&
|
||||
(
|
||||
github.event.action != 'labeled' ||
|
||||
contains(fromJSON('["PR-Env", "PR-Env-self-hosting", "PR-Env-analytics-dev", "PR-Env-Stats-Dump"]'), github.event.label.name)
|
||||
) &&
|
||||
(
|
||||
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')
|
||||
)
|
||||
}}
|
||||
uses: ./.github/workflows/uberjar.yml
|
||||
secrets: inherit
|
||||
|
||||
containerize:
|
||||
if: github.event.pull_request.head.repo.full_name == github.repository
|
||||
name: Build Metabase Docker image
|
||||
uses: ./.github/workflows/build-private-ee-docker-image.yml
|
||||
secrets:
|
||||
PR_ENV_IAM_ROLE: ${{ secrets.PR_ENV_IAM_ROLE }}
|
||||
PR_ENV_AWS_ACCOUNT_ID: ${{ secrets.PR_ENV_AWS_ACCOUNT_ID }}
|
||||
deploy_pr:
|
||||
needs: [build]
|
||||
uses: ./.github/workflows/containerize-jar.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
edition: ee
|
||||
registry: ecr
|
||||
repo: metabase-enterprise
|
||||
tag: ${{ github.event.pull_request.head.sha }}
|
||||
commit: ${{ github.event.pull_request.head.sha }}
|
||||
platforms: linux/amd64
|
||||
|
||||
deploy:
|
||||
needs: [containerize]
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
name: PR Review ENV
|
||||
timeout-minutes: 60
|
||||
outputs:
|
||||
self_hosting: ${{ steps.label.outputs.self_hosting }}
|
||||
analytics_dev_mode: ${{ steps.label.outputs.analytics_dev_mode }}
|
||||
stats_dump: ${{ steps.label.outputs.stats_dump }}
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v6
|
||||
- name: Resolve Labels
|
||||
id: label
|
||||
env:
|
||||
LABELS: ${{ toJSON(github.event.pull_request.labels.*.name) }}
|
||||
run: | # bash
|
||||
self_hosting=false
|
||||
analytics_dev_mode=false
|
||||
stats_dump=false
|
||||
|
||||
while IFS= read -r label; do
|
||||
case "$label" in
|
||||
PR-Env-self-hosting) self_hosting=true ;;
|
||||
PR-Env-analytics-dev) analytics_dev_mode=true ;;
|
||||
PR-Env-Stats-Dump) stats_dump=true ;;
|
||||
esac
|
||||
done < <(jq -r '.[]' <<< "$LABELS")
|
||||
|
||||
echo "self_hosting=${self_hosting} analytics_dev_mode=${analytics_dev_mode} stats_dump=${stats_dump}"
|
||||
{
|
||||
echo "self_hosting=${self_hosting}"
|
||||
echo "analytics_dev_mode=${analytics_dev_mode}"
|
||||
echo "stats_dump=${stats_dump}"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
- name: Tailscale
|
||||
uses: tailscale/github-action@306e68a486fd2350f2bfc3b19fcd143891a4a2d8 # v4.1.2
|
||||
with:
|
||||
@@ -119,7 +75,7 @@ jobs:
|
||||
PR_ENV_DB_NAME: ${{ secrets.PR_ENV_DB_NAME }}
|
||||
- name: Verify stats dump load status
|
||||
id: verify_stats_dump
|
||||
if: ${{ steps.label.outputs.stats_dump == 'true' }}
|
||||
if: ${{ inputs.stats_dump }}
|
||||
run: |
|
||||
export PGPASSWORD="${PR_ENV_DB_PASSWORD}"
|
||||
ADMIN="psql -h ${PR_ENV_DB_HOST} -U ${PR_ENV_DB_USER} -d ${PR_ENV_DB_NAME}"
|
||||
@@ -142,7 +98,7 @@ jobs:
|
||||
PR_ENV_DB_NAME: ${{ secrets.PR_ENV_DB_NAME }}
|
||||
- name: Locate latest stats dump in S3
|
||||
id: locate_stats_dump
|
||||
if: ${{ steps.label.outputs.stats_dump == 'true' && steps.verify_stats_dump.outputs.should_load == 'true' }}
|
||||
if: ${{ inputs.stats_dump && steps.verify_stats_dump.outputs.should_load == 'true' }}
|
||||
env:
|
||||
STATS_DUMP_BUCKET: metabase-cloud-stats-backups-coredev
|
||||
run: |
|
||||
@@ -157,7 +113,7 @@ jobs:
|
||||
echo "Latest dump: $LATEST_KEY"
|
||||
echo "key=$LATEST_KEY" >> "$GITHUB_OUTPUT"
|
||||
- name: Download stats dump
|
||||
if: ${{ steps.label.outputs.stats_dump == 'true' && steps.verify_stats_dump.outputs.should_load == 'true' }}
|
||||
if: ${{ inputs.stats_dump && steps.verify_stats_dump.outputs.should_load == 'true' }}
|
||||
env:
|
||||
STATS_DUMP_BUCKET: metabase-cloud-stats-backups-coredev
|
||||
run: |
|
||||
@@ -167,7 +123,7 @@ jobs:
|
||||
echo "Downloaded $(stat -c%s /tmp/stats-dump.dump) bytes"
|
||||
file /tmp/stats-dump.dump
|
||||
- name: Prepare app database schema
|
||||
if: ${{ steps.label.outputs.stats_dump == 'true' && steps.verify_stats_dump.outputs.should_load == 'true' }}
|
||||
if: ${{ inputs.stats_dump && steps.verify_stats_dump.outputs.should_load == 'true' }}
|
||||
run: |
|
||||
PGPASSWORD="${PR_ENV_DB_PASSWORD}" psql \
|
||||
-h "${PR_ENV_DB_HOST}" \
|
||||
@@ -180,7 +136,7 @@ jobs:
|
||||
PR_ENV_DB_USER: ${{ secrets.PR_ENV_DB_USER }}
|
||||
PR_ENV_DB_HOST: ${{ secrets.PR_ENV_DB_HOST }}
|
||||
- name: Restore stats dump into app database
|
||||
if: ${{ steps.label.outputs.stats_dump == 'true' && steps.verify_stats_dump.outputs.should_load == 'true' }}
|
||||
if: ${{ inputs.stats_dump && steps.verify_stats_dump.outputs.should_load == 'true' }}
|
||||
run: |
|
||||
set +e
|
||||
echo "::group::pg_restore / pg client versions"
|
||||
@@ -217,7 +173,7 @@ jobs:
|
||||
PR_ENV_DB_HOST: ${{ secrets.PR_ENV_DB_HOST }}
|
||||
PR_ENV_DB_NAME: ${{ secrets.PR_ENV_DB_NAME }}
|
||||
- name: Mark database as stats-dump loaded
|
||||
if: ${{ steps.label.outputs.stats_dump == 'true' && steps.verify_stats_dump.outputs.should_load == 'true' }}
|
||||
if: ${{ inputs.stats_dump && steps.verify_stats_dump.outputs.should_load == 'true' }}
|
||||
run: |
|
||||
PGPASSWORD="${PR_ENV_DB_PASSWORD}" psql \
|
||||
-h "${PR_ENV_DB_HOST}" \
|
||||
@@ -229,22 +185,22 @@ jobs:
|
||||
PR_ENV_DB_USER: ${{ secrets.PR_ENV_DB_USER }}
|
||||
PR_ENV_DB_HOST: ${{ secrets.PR_ENV_DB_HOST }}
|
||||
- name: Download Deployment YAML template
|
||||
if: ${{ steps.label.outputs.self_hosting != 'true' }}
|
||||
if: ${{ !inputs.self_hosting }}
|
||||
run: aws s3 cp s3://metabase-pr-env/metabase.yml.tmpl ./metabase.yml.tmpl
|
||||
- name: Render Deployment YAML
|
||||
if: ${{ steps.label.outputs.self_hosting != 'true' }}
|
||||
if: ${{ !inputs.self_hosting }}
|
||||
uses: nowactions/envsubst@c4b8e3977354d294659a7ec164279ce8fba5c2e1 # v1.0.1
|
||||
with:
|
||||
input: ./metabase.yml.tmpl
|
||||
output: ./metabase.yml
|
||||
env:
|
||||
IMAGE_TAG: ${{ github.event.pull_request.head.sha }}
|
||||
IMAGE_TAG: ${{ needs.build.outputs.tag }}
|
||||
PR_NUMBER: ${{ github.event.number }}
|
||||
MB_PREMIUM_EMBEDDING_TOKEN: ${{ secrets.PR_ENV_MB_PREMIUM_EMBEDDING_TOKEN }}
|
||||
SHA: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
MB_ANALYTICS_DEV_MODE: ${{ steps.label.outputs.analytics_dev_mode }}
|
||||
MB_ANALYTICS_DEV_MODE: ${{ inputs.analytics_dev_mode && 'true' || 'false' }}
|
||||
- name: Inject stats-dump env vars into Deployment YAML
|
||||
if: ${{ steps.label.outputs.self_hosting != 'true' && steps.label.outputs.stats_dump == 'true' }}
|
||||
if: ${{ !inputs.self_hosting && inputs.stats_dump }}
|
||||
env:
|
||||
MB_ENCRYPTION_SECRET_KEY: ${{ secrets.PR_ENV_STATS_DUMP_ENCRYPTION_KEY }}
|
||||
run: |
|
||||
@@ -286,20 +242,20 @@ jobs:
|
||||
namespace: default
|
||||
user: oidc-token
|
||||
- name: Deploy PR Review ENV
|
||||
if: ${{ steps.label.outputs.self_hosting != 'true' }}
|
||||
if: ${{ !inputs.self_hosting }}
|
||||
run: |
|
||||
kubectl apply -f ./metabase.yml
|
||||
- name: Download Helm values template (self-hosting)
|
||||
if: ${{ steps.label.outputs.self_hosting == 'true' }}
|
||||
if: ${{ inputs.self_hosting }}
|
||||
run: aws s3 cp s3://metabase-pr-env/values.yaml.tmpl ./values.yaml.tmpl
|
||||
- name: Render Helm values (self-hosting)
|
||||
if: ${{ steps.label.outputs.self_hosting == 'true' }}
|
||||
if: ${{ inputs.self_hosting }}
|
||||
uses: nowactions/envsubst@c4b8e3977354d294659a7ec164279ce8fba5c2e1 # v1.0.1
|
||||
with:
|
||||
input: ./values.yaml.tmpl
|
||||
output: ./values.yaml
|
||||
env:
|
||||
IMAGE_TAG: ${{ github.event.pull_request.head.sha }}
|
||||
IMAGE_TAG: ${{ needs.build.outputs.tag }}
|
||||
PR_NUMBER: ${{ github.event.number }}
|
||||
MB_PREMIUM_EMBEDDING_TOKEN: ${{ secrets.PR_ENV_MB_PREMIUM_EMBEDDING_TOKEN }}
|
||||
SHA: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
@@ -310,15 +266,15 @@ jobs:
|
||||
DB_USERNAME: ${{ secrets.PR_ENV_DB_USER }}
|
||||
DB_PASSWORD: ${{ secrets.PR_ENV_DB_PASSWORD }}
|
||||
CONFIG: ${{ secrets.PR_ENV_CONFIG }}
|
||||
MB_ANALYTICS_DEV_MODE: ${{ steps.label.outputs.analytics_dev_mode }}
|
||||
MB_ANALYTICS_DEV_MODE: ${{ inputs.analytics_dev_mode && 'true' || 'false' }}
|
||||
|
||||
- name: Setup Helm
|
||||
if: ${{ steps.label.outputs.self_hosting == 'true' }}
|
||||
if: ${{ inputs.self_hosting }}
|
||||
uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # v5.0.0
|
||||
with:
|
||||
version: "latest"
|
||||
- name: Login to Quay.io
|
||||
if: ${{ steps.label.outputs.self_hosting == 'true' }}
|
||||
if: ${{ inputs.self_hosting }}
|
||||
run: |
|
||||
helm registry login quay.io \
|
||||
--username "${QUAY_USERNAME}" \
|
||||
@@ -327,7 +283,7 @@ jobs:
|
||||
QUAY_USERNAME: ${{ secrets.QUAY_USERNAME }}
|
||||
QUAY_PASSWORD: ${{ secrets.QUAY_PASSWORD }}
|
||||
- name: Deploy PR Review ENV (self-hosting)
|
||||
if: ${{ steps.label.outputs.self_hosting == 'true' }}
|
||||
if: ${{ inputs.self_hosting }}
|
||||
run: |
|
||||
helm upgrade --install hosting-pr${{ github.event.number }} \
|
||||
oci://quay.io/enterprisemetabase/chart-metabase-stack \
|
||||
@@ -339,7 +295,7 @@ jobs:
|
||||
preview_links:
|
||||
runs-on: ${{ vars.SLIM_RUNNER_KEY }}
|
||||
timeout-minutes: 5
|
||||
needs: [deploy]
|
||||
needs: [deploy_pr]
|
||||
steps:
|
||||
- uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
|
||||
with:
|
||||
@@ -349,4 +305,4 @@ jobs:
|
||||
✅ Preview:
|
||||
https://pr${{ github.event.number }}.coredev.metabase.com
|
||||
🗒️ [login instructions](https://www.notion.so/metabase/Ephemeral-PR-Environments-c74a3710c87a460dbf4fc0007a001458)
|
||||
📦 Deployment type: ${{ needs.deploy.outputs.self_hosting == 'true' && 'PR-Env-self-hosting' || (needs.deploy.outputs.analytics_dev_mode == 'true' && 'PR-Env-analytics-dev' || (needs.deploy.outputs.stats_dump == 'true' && 'PR-Env-Stats-Dump' || 'PR-Env')) }}
|
||||
📦 Deployment type: ${{ inputs.self_hosting && 'PR-Env-self-hosting' || (inputs.analytics_dev_mode && 'PR-Env-analytics-dev' || (inputs.stats_dump && 'PR-Env-Stats-Dump' || 'PR-Env')) }}
|
||||
52
.github/workflows/pr.checks.yml
vendored
52
.github/workflows/pr.checks.yml
vendored
@@ -1,52 +0,0 @@
|
||||
# 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
|
||||
4
.github/workflows/pre-release.yml
vendored
4
.github/workflows/pre-release.yml
vendored
@@ -252,7 +252,7 @@ jobs:
|
||||
with:
|
||||
edition: oss
|
||||
commit: ${{ inputs.commit }}
|
||||
repo: ${{ vars.DOCKERHUB_STAGING_REPO }}
|
||||
repo: ${{ vars.DOCKERHUB_OWNER }}/${{ vars.DOCKERHUB_STAGING_REPO }}
|
||||
tag: ${{ needs.release-artifact.outputs.oss_version }}-${{ inputs.commit }}
|
||||
release: true
|
||||
|
||||
@@ -263,7 +263,7 @@ jobs:
|
||||
with:
|
||||
edition: ee
|
||||
commit: ${{ inputs.commit }}
|
||||
repo: ${{ vars.DOCKERHUB_STAGING_REPO }}
|
||||
repo: ${{ vars.DOCKERHUB_OWNER }}/${{ vars.DOCKERHUB_STAGING_REPO }}
|
||||
tag: ${{ needs.release-artifact.outputs.ee_version }}-${{ inputs.commit }}
|
||||
release: true
|
||||
|
||||
|
||||
45
.github/workflows/pull-request-reminders.yml
vendored
Normal file
45
.github/workflows/pull-request-reminders.yml
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
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,
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user