Compare commits

...

33 Commits

Author SHA1 Message Date
Bryan Maass
6b8829bd58 Spike: sqlc -> Clojure query codegen with Malli schemas
Generate Clojure query fns with Malli result schemas from .sql files, using
sqlc for SQL parsing and type inference and sqlc-gen-json to expose its
analysis as JSON.

Verified end to end: migrations against Postgres 16 (1427 changesets, 190
tables), pg_dump fed to sqlc unmodified, real queries ported from the
glossary/sso HugSQL spike branches, and the generated file compiles with
schemas that accept valid rows and reject bad ones.

See dev/src/dev/sqlc/README.md for open issues (no H2 engine, weak param
type inference, no dynamic SQL).
2026-09-15 23:06:24 -06:00
Chris Vandoodewaard
14fe567687 only auto-patch current version (#82368)
only auto-patch latest
2026-09-11 14:56:29 -04:00
technomancy
daceb36e65 Snowflake schema specified in :additional-options should take precedence (#82364)
Fixes https://github.com/metabase/metabase/issues/65493

If you have a `details.schema` property set in your connection (this was
removed in v42 in favor of schema filters), you can't change it in the
UI in the current Metabase version.  If you wanted to specify a schema
through the "Additional JDBC connection string options", the old
buried schema property would take precedence anyway.

This makes it so the deprecated field is removed when the schema is
set in additional-options.

I've verified this using a real connection and querying `SELECT current_schema()`
as suggested in the bug report.
2026-09-11 11:20:31 -07:00
github-automation-metabase
1c561d056c Tighten ratchets (#82365)
Co-authored-by: github-actions <github-actions@github.com>
2026-09-11 17:42:07 +00:00
Chris Truter
dd9d8d0579 Ratchet module boundary escape hatches (#82300)
Protect against bad things happening because agents and reviewers aren't aware that they're bad.
2026-09-11 17:36:42 +00:00
metamben
d0cbe49c68 Prime app-db connection-pool before use (#81601)
* Prime app-db connection-pool before use

Closes QUE2-833
Closes #81440

* Fix linter warnings
2026-09-11 17:08:38 +00:00
Nick Fitzpatrick
3e83533d06 BE PDFs - target 300dpi for rendered visualizations (#81078) 2026-09-11 13:55:16 -03:00
Chris Truter
eff3209a34 Add hierarchy to the backend module system (#82301)
This will allow us to resolve a lot a cycles in the module graph, and increase encapsulation considerably.
2026-09-11 18:48:15 +02:00
Alex Yarosh
93aabef5d3 docs: expand data modeling overview (#82309)
* data modeling overview

* copy paste strikes again

* Apply suggestions from code review

Co-authored-by: Michael Eby <38187276+michaeleby1@users.noreply.github.com>

---------

Co-authored-by: Michael Eby <38187276+michaeleby1@users.noreply.github.com>
2026-09-11 16:37:49 +00:00
Anderson
1a8883d604 Revert "Apply responsive spacing and typography to dashboard chart cards" (#82346)
* Revert "Apply responsive spacing and typography to dashboard chart cards (#81…"

This reverts commit 13e00d55b9.

* Update Loki Snapshots

---------

Co-authored-by: Metabase Automation <github-automation@metabase.com>
2026-09-11 13:10:36 -03:00
Timofey Kachalov
0cf4be2fd2 Fixed MCP search response schema validation (#82205) 2026-09-11 20:02:54 +04:00
Eric Normand
615cfdb48b Fix race in detached-fetch!-sweeps-stalled-fetches-test (#82347)
The test read `:future-ref` straight off the registry entry as soon as the
fetch signalled it had started. But `detached-fetch!` registers the entry
before the fetch begins and only stores the future just after submitting it,
so the thunk can deliver `started` before the `reset!` lands. When it does,
`stalled-future` is nil and `future-cancelled?` throws an NPE.

Under 300 spinning threads a probe caught the window 1-4 times per 20000
fetches; inserting a 5ms sleep between the `future` and the `reset!` makes
the old test fail every run.

Poll for the future instead. Only the test is affected: the sweep acts on
entries older than *fetch-max-age-ms* (an hour), so it can never observe one
whose `:future-ref` is still unset.


Claude-Session: https://claude.ai/code/session_01J1yFPpYiWH2ik8LB9rs7BT

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 10:56:49 -05:00
Alex Yarosh
19989ab207 docs: cloud migration (#82305)
* Revise migration steps 

Updated migration guide to clarify steps and include images.

* Delete docs/cloud/images/migrate-button.png
2026-09-11 15:36:37 +00:00
Alex Yarosh
efb95e2e18 docs: add glossary article (#82308)
* add glossary article

* Optimized 2 images, saved 219.4 KB (42.5% smaller)

* link

* Update docs/data-modeling/semantic-layer/glossary.md

Co-authored-by: Jeff Bruemmer <jeff.bruemmer@gmail.com>

---------

Co-authored-by: Metabase Automation <github-automation@metabase.com>
Co-authored-by: Jeff Bruemmer <jeff.bruemmer@gmail.com>
2026-09-11 15:29:28 +00:00
Riley Thompson
3ad97e619d Use multiple jvms per runner for bigquery tests (#81204)
* multiple jvms per runner for bigquery

* trigger CI

* add failing test

* lower timeout

* remove debug stuff

* clean up comments

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-11 11:27:34 -04:00
Anna Tomka
5b7c89d8ee GDGT-2478 Translate PasswordInput toggle label at call time (#82338)
* GDGT-2478 Translate PasswordInput toggle label at call time

* Update Loki Snapshots

---------

Co-authored-by: Metabase Automation <github-automation@metabase.com>
2026-09-11 15:26:10 +00:00
Riley Thompson
05d2f186a0 Use calendar days for bigquery datetimeDiff (#82194)
* BigQuery: use calendar days for datetimeDiff day unit

Fixes #82193

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Avoid :raw for date part in BigQuery day diff

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* clean up

* add timezone to test

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 10:53:37 -04:00
Phoomparin Mano
c8f77a0925 Data Apps Permissions (#79822)
* add saved question extraction prototype

* fix additional failure cases

* refactor: reconciler

* revert me: manual skill file edit for source swapping

* set data permissions to view for databases referenced in the query

* rename data app collection and group

* prevent agents from removing savedQuestionSourceId

* docs: clarify data app query location

* chore: cleanup query-sync canonical module

* refactor: split query tests into different files

* Swap query source for data app query hooks and apply dynamic filters on top of it (#79584)

* code improvements and bug-fixes for data apps permissions

* Add data apps model imports

* address reviews on data apps permissions

* run sync queries before vite build

* Add data app resource link (#79878)

add button to view data app resources

* Show permission error when users cannot access a data app (#79874)

show permission error page when user do not have permission to view data apps

* Add data app group link in overflow menu (#79873)

Add data app group link

* Store and reconcile data app entity ids in data_app.yaml (#79833)

* Store data app resource entity IDs

* make entity ids in data_app.yaml authoritative

* add top-level value after path key

* simplify data_app.yaml manifest parser

* only allow changing data app collection if empty

* consolidate backend test helpers and remove redundant tests

* chore: restore README changes

* ci: fix failing tests

* Add support of actions to Data App reconciler (#79842)

Add support of actions to Data App reconciler

* Fix bugs/inconsistency and add more e2e for data apps reconciler/syncing (#80002)

* Update semantic schema skill about preventing segments and measures in dynamic parts of a query in Data Apps (#80288)

* Grant view data access on a table-by-table basis in data app groups (#80189)

* grant view data on table instead of database level

* refactor table scan in reconciler

* update e2e tests for permission grants

* Grant data app view permissions per table

* Skip unchanged data app permissions

* add e2e data app test for sandboxing

* reset host app sources after runs

* Preserve sandboxing for data app users (#80406)

* add e2e data app test for sandboxing

* improve e2e tests for data apps

* Remove sandbox E2E from table permissions change

* preserve existing sandboxing from sandboxed groups in data apps

* assert that we grant non-sandboxed tables

* Explain data app sandbox exclusion

* Add data app sandbox E2E coverage

* Exercise sandbox preservation in data app E2E

* Disable saved question sources in data apps (#80139)

* Disable saved question sources in data apps

* Preserve question-only schema scope

* Remove the saved question schema parameter

* Copy table-sourced metrics into data app collection and do not codegen card-sourced metrics (#80086)

* copy table-sourced metrics into the data app collections

* copy table-sourced metrics into the data app collections

* Address metric sync review feedback

* Accept keyword metric display in query response

* Fix data app metric reconciliation ordering

* Remove old lockfile format

* exclude card-sourced metrics from schema generator

* do not use card terms in metrics to avoid ambiguity with card-sourced metrics

* fix model imports

* Fix data app metric reconciliation

* refactor: cleanup reconciler unit tests

* Fix duplicated metric client helper

* add safe metrics path

* Handle unsupported metric dependencies

* Fix data app CI test regressions

* Fix "Re-adding removed query fails to adopt deleted card" (#80341)

* Clean-up data apps permissions endpoints (#80825)

* Remove entity ids from data_app.yaml (#81317)

Remove data app entity ids from data_app.yaml

* Adjust data apps permissions code (#81093)

* Remove the data app table permission grants (#81584)

* Add permissions_group.is_data_app_group and hide data app permission groups from UI (#81705)

* Add data app user management and access warnings (#81839)

* Address data app user access review feedback

* additional e2e test cases for bugs in manage users page

* fix failing frontend type check on unit test

* fix migration id collision for data apps

* combine migration changes into one file

* fix data app metric sync after trashing copied metrics

* fix cli runtime dependency packaging for embedding sdk

* avoid loading data app bundle during resource setup

* prevent redundant data app collection permission updates

* allow admins to remove data app members after token expires

* update docs and add e2e test for missing resource collection case

* isolate data app sync using transactions

* do not leave user on an empty page when removing the last member on the page

* reserve sandbox-host slug in data app config

* ignore generated data app action fixtures

* move queries into db.clj

* preserve legacy permissions when adding data app members

* regenerate the kondo modules config

The data-apps module reaches for :model/Permissions and :model/DataPermissions,
neither of which was recorded in :model-imports, so metabase.core.modules-test
fails on the current tree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011nNKkhQjjiUSM7qwLReyfP

* reject data app queries whose metrics read another card

Sync copies a referenced metric but rewrites nothing inside the copy, and
copies nothing that copy in turn reads, so those references still point at
the originals. The app publishes successfully and then fails for every
viewer without access to the originals' collections.

Checking while resolving the query, rather than at codegen, also catches a
metric edited into that shape after its schema was generated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011nNKkhQjjiUSM7qwLReyfP

* exclude metrics that read a saved card anywhere from generated schemas

source-card-id only reads stage 0, so a table-sourced metric that joins a
saved question passed the codegen filter. The CLI checks the whole query and
rejects such a metric, aborting sync-resources for any app that uses it.

Checking the whole query here keeps codegen consistent with what sync
supports, and removes the database round trip that asked which of the
referenced cards were metrics -- the step that let a saved-question join
through in the first place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011nNKkhQjjiUSM7qwLReyfP

* resolve copied resources' table dependencies through the server

Numeric source-table entries miss implicit joins: a copied model querying
Orders and reading Products.Category through a foreign key names Products
nowhere, so it was left out of the app's permission warnings.

Run the same metadata-based lookup the query endpoint uses, through a new
endpoint the CLI calls for copied models, actions and metrics, and drop the
JSON walk it replaces.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011nNKkhQjjiUSM7qwLReyfP

* cover the create-queries grant the database defaults leave on app groups

An app group that predates the first non-audit database is given
create-queries :query-builder-and-native by set-new-database-permissions!,
alongside the :blocked view-data it special-cases for app groups. A user with
data access through another group would gain native query authoring just by
joining the app.

The reconciler already resets it, but nothing failed when :perms/create-queries
was dropped from its list. The pairing cannot be produced through
set-database-permission!, which cascades view-data to :unrestricted for any
create-queries value other than :no, so the test writes the row the way the
defaults insert it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011nNKkhQjjiUSM7qwLReyfP

* prevent models based on saved questions from being copied

* return to a valid member page after overlapping removals

* do not resolve query objects when either enabled flag is false

* prevent shorthand properties and spreads from overriding generated ids

* prevent adding duplicate members and retry only members that are failed to add

* define schema for data app query endpoint

* reconcile only the current data app group during resource setup

* expand the schema fingerprint

* add data app group field in permission group schemas

* prevent new databases from granting query creation to data app groups

* throw validation errors when invalid query spec fields are passed

* do not fail validation on columns metadata

* add enabled metadata to query definition schema

* accept more data app specific query metadata and binning options

---------

Co-authored-by: Timofey Kachalov <timofei@metabase.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 15:41:01 +01:00
Timofey Kachalov
1626b31be7 Fix flaky child-frame realm gate in the data-app sandbox (#82324) 2026-09-11 18:07:04 +04:00
metamben
2659b87941 Throw on uncoerceable user attributes (#82118)
Closes QUE2-842
Closes #81821
2026-09-11 14:05:29 +00:00
metamben
b2c923e496 Remove explicit :fields on the last stage of pivot queries (#82111)
Closes QUE2-825
Closes #81203
2026-09-11 17:03:42 +03:00
Chris Vandoodewaard
cc1fa36312 add workflow combining quick and simple PR checks (#82126)
* combine workflows into single pr.checks.yml

* fixes and better checkout

* ignore parallel lint failure

* add filter to checkout to avoid slow checkouts

* remove check
2026-09-11 13:59:23 +00:00
github-automation-metabase
498dfc7435 Tighten ratchets (#82331)
Co-authored-by: github-actions <github-actions@github.com>
2026-09-11 13:16:25 +00:00
Ben Grabow
6780dbbf52 Allow multiple intermediate ns segments (#82125)
* Allow multiple intermediate ns segments

* Detangle llm settings

# Conflicts:
#	src/metabase/llm/provider.clj

* Move settings.providers to providers.settings

* Move providers.settings to provider.settings

* Loosen filter to include all `metabase.llm.*` settings

* Merge from master

* Delete obsolete linter ignore

* Clarify linter message
2026-09-11 08:11:09 -05:00
Jeff Bruemmer
86f64c3d9f docs - update docs job (#82327)
update docs job
2026-09-11 08:57:59 -04:00
Anna Tomka
ca17b89463 GDGT-2478 PasswordInput (#82117)
* test(ui): add PasswordInput Storybook story

* test(ui): add PasswordInput to Loki as baseline for restyle

* feat(ui): restyle PasswordInput from Figma and add input-background token

* test: cover PasswordInput visibility toggle in unit and e2e tests

* fix(PasswordInput): use highlight border color in focused error mode by overriding Input styles

* Update Loki Snapshots

* test(ui): add Loki reference for PasswordInput Overview story

* fix(PasswordInput): address review feedback

- keep border width constant and use an inset ring for focus/error to avoid layout shift
- tie right padding to the toggle button size so text clears it in lg
- restrict size prop to sm | md | lg
- order color keys alphabetically
- explicit visibility assertion in the e2e spec

* Update Loki Snapshots

---------

Co-authored-by: Metabase Automation <github-automation@metabase.com>
2026-09-11 14:18:41 +02:00
Anna Tomka
38b7b8c0b7 GDGT-3212 add missing inner border in dark theme (#82203)
* fix(Pill): add missing inner border in dark theme

* fix(Pill): drop explanatory comment per review
2026-09-11 14:18:11 +02:00
Kevin Kelleher
94e58da90d Fix auto-wiring dashboard filters to dashcards with multiple series (#81913)
* handle auto-wiring dashboard filters to dashcards with multiple series

* review feedback
2026-09-11 07:24:06 -04:00
Oleh K
6a035e234c Use JetBrains Mono as the code font (#82278)
* Use JetBrains Mono as the code font

Ship JetBrains Mono (regular + bold) and point the monospace font family
at it, replacing the hardcoded Monaco/Menlo stacks scattered across the
codebase.

- Add the woff2 files, @font-face declarations, and the type, fallback
  map, docs and backend test entries that keep the admin font picker in
  sync with the fonts directory.
- Route every monospace call site through
  --mb-default-monospace-font-family, and define that variable in the
  embedding SDK's variable set, where it was previously missing. With
  the variable always defined, the per-call-site `, monospace` fallback
  is redundant and is dropped.
- Inline the variable at the CodeMirror theme, which was the only
  consumer of monospaceFontFamily, and delete typography.ts.
- Set the Code component to 13px/22px per the design spec.

* Update Loki Snapshots

---------

Co-authored-by: Metabase Automation <github-automation@metabase.com>
2026-09-11 12:12:15 +02:00
Andrei Simionescu
b75caae5f3 AI settings docs: a provider connection takes credentials, not just an API key (#82026)
Bedrock takes an AWS access key pair and Google Gemini Enterprise a service
account key file, so the AI settings page can't keep calling every provider
connection an API key.
2026-09-11 09:18:39 +01:00
Fraser
a5af036433 Rehome the analytics export machinery to EE audit_app (#79808) 2026-09-11 19:04:08 +12:00
Romeo Van Snick
1c5ba041d6 Stop the metabot viewing-context tests from depending on absent entity ids (#82248)
The tests pass ids like 789 for entities they do not create. format-entity looks the
id up, and the test asserts on the fallback rendering that a 404 produces. When a
parallel test happens to hold that id, read-check gives a 403 instead, which renders
nothing, and the test fails.

Use Integer/MAX_VALUE, which no fixture allocates.


Claude-Session: https://claude.ai/code/session_01GCoSa7L7KrXh42rDRYR3Nk

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 15:11:30 +10:00
Romeo Van Snick
86c23eaf5e Read a card's UI parameters off its question (#82238)
* Read a card's UI parameters off its question

* Drop a tombstone comment

* Pin the agreement between getCardUiParameters and Question.parameters

* Type the unsaved card in the agreement test

* Format the agreement test
2026-09-11 15:11:21 +10:00
418 changed files with 18115 additions and 2941 deletions

View File

@@ -181,7 +181,7 @@
clojure.core.async/take!
clojure.core.async/to-chan!
clojure.core.async/to-chan!!
;; only writes .clj-kondo/ratchets.edn, from a :once fixture that finishes before the parallel tests
;; writes the ratchet files from a :once fixture that finishes before the parallel tests
dev.kondo-ratchet/fix!
metabase.ai-tracing.core/record!
metabase.api.macros.defendpoint.tools-manifest/assert-optional-fields-nullable!
@@ -844,7 +844,7 @@
:name test-namespaces}
{:pattern "^(?!.*-test(?:\\..*)?$)(?!.*test-util)(?!^metabase\\.test.*).*$"
:name source-namespaces}
{:pattern "(?:(?:^metabase\\.cmd.*)|(?:^build.*)|(?:^metabuild-common.*)|(?:^release.*)|(?:^i18n.*)|(?:^lint-migrations-file$))|(?:^mage.*)|(?:^load-namespaces.*)"
{:pattern "(?:(?:^metabase\\.cmd.*)|(?:^build.*)|(?:^metabuild-common.*)|(?:^release.*)|(?:^i18n.*)|(?:^lint-migrations-file$))|(?:^mage.*)|(?:^load-namespaces.*)|(?:^dev\\..*)"
:name printable-namespaces}
{:pattern "^metabase\\.(?:(?:lib)|(?:legacy-mbql))\\..*"
:name metabase-lib}

View File

@@ -1399,7 +1399,6 @@
metabase.metabot.settings
metabase.metabot.tools.entity-details}
:friends #{agent-api
enterprise/metabot
enterprise/metabot-analytics
slackbot}
:uses #{activity-feed
@@ -2388,7 +2387,6 @@
metabase.sso.core
metabase.sso.init
metabase.sso.settings}
:friends #{enterprise/sso}
:uses #{api
auth-identity
config
@@ -2597,8 +2595,7 @@
tracing
transforms-base
util}
:friends #{models
enterprise/transforms-python}
:friends #{enterprise/transforms-python}
:model-exports #{:model/Transform
:model/TransformDagRun
:model/TransformJob
@@ -3273,15 +3270,28 @@
:uses #{premium-features}}
enterprise/data-apps
{:team "Gadget"
{:team "Embedding"
:api #{metabase-enterprise.data-apps.api
metabase-enterprise.data-apps.init
metabase-enterprise.data-apps.sync}
:uses #{api
collections
lib
lib-be
models
permissions
premium-features
request
settings
util}}
util}
:model-imports #{:model/Card
:model/Collection
:model/DataPermissions
:model/Database
:model/Permissions
:model/PermissionsGroup
:model/Table
:model/User}}
enterprise/data-complexity-score
{:team "Metabot"
@@ -3568,9 +3578,6 @@
:api #{metabase-enterprise.metabot.api
metabase-enterprise.metabot.api.routes
metabase-enterprise.metabot.init}
:friends #{slackbot
mcp
metabot}
:uses #{api
lib
lib-be

View File

@@ -0,0 +1,6 @@
;; 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 22
:model-imports-bypass 2
:uses-any 4}

View File

@@ -5,7 +5,7 @@
;; Any ignore outside :comment-exempt needs an explanatory comment directly above or trailing on its line.
;; `./bin/mage kondo-ratchets-shrink` lowers budgets unless `--seed` explicitly adds or raises one.
;; The workflow runs it on master, so feature branches do not need to record reductions.
;; Raising or adding a budget (`--seed` for inline ignores, a manual edit for config) or widening the
;; Raising or adding a budget (`--seed` for inline ignores, a manual edit otherwise) or widening the
;; exemptions must be explained in the PR.
;; :all is the vector-less ignore form, which suppresses every linter on the next form.
{:ignore-counts {:aliased-namespace-symbol 3
@@ -21,7 +21,7 @@
:duplicate-require 1
:equals-true 1
:main-without-gen-class 1
:metabase/defsetting-namespace 2
:metabase/defsetting-namespace 1
:metabase/disallow-hardcoded-driver-names-in-tests 34
:metabase/discourage-millis-duration 1
:metabase/i-like-making-cams-eyes-bleed-with-horrifically-long-tests 13

View File

@@ -92,7 +92,7 @@
(defn- lint-modules [ns-form-node config]
(let [ns-symb (ns-form-node->ns-symb ns-form-node)]
(when-not (modules/ignored-namespace? config ns-symb)
(when-let [current-module (modules/module ns-symb)]
(when-let [current-module (modules/module config ns-symb)]
(let [required-namespace-symb-nodes (-> ns-form-node
ns-form-node->require-node
require-node->namespace-symb-nodes)]

View File

@@ -11,10 +11,10 @@
(every? simple-symbol? required-namespaces)]}
(when-not (modules/ignored-namespace? config current-ns)
;; ignore namespaces outside of the module system
(when-let [current-module (modules/module current-ns)]
(when-let [current-module (modules/module config current-ns)]
(doseq [required-namespace required-namespaces]
;; ignore namespaces outside of the module system.
(when (modules/module required-namespace)
(when (modules/module config required-namespace)
(when-let [error (modules/usage-error config current-module required-namespace)]
(hooks/reg-finding! (assoc (meta node)
:message error
@@ -22,7 +22,7 @@
(defn- lint-dynamic-require* [node current-ns config]
(when (and (not (modules/ignored-namespace? config current-ns))
(modules/module current-ns))
(modules/module config current-ns))
(hooks/reg-finding! (assoc (meta node)
:message "Module dependency cannot be statically determined from a dynamic require"
:type :metabase/modules))))

View File

@@ -194,7 +194,7 @@
(not (contains? @warned-namespaces ns-symb)))
(swap! warned-namespaces conj ns-symb)
(let [known-modules (set (keys (:metabase/modules (modules/config input))))
current-module (modules/module ns-symb)]
current-module (modules/module (modules/config input) ns-symb)]
(when (or (not current-module)
(not (contains? known-modules current-module)))
(hooks/reg-finding! (assoc (meta node)

View File

@@ -1,4 +1,8 @@
(ns hooks.common.modules
"Resolves namespaces to modules and enforces module boundaries.
The Kondo linter, config generator, and CI tooling share this namespace so
they apply the same rules."
(:require
[clojure.string :as str]))
@@ -12,25 +16,130 @@
(merge (get-in config [:linters :metabase/modules])
(select-keys config [:metabase/modules])))
(defn module
;;;; Module tree. These functions take the `:metabase/modules` map, or the prefix map built from it.
;;;;
;;;; Dots express nesting: `lib.schema` is a child of `lib`. A declared
;;;; `enterprise/X` module is also a child of OSS module `X`. A namespace
;;;; belongs to the module with the most specific matching `:ns-prefix`.
(defn default-ns-prefix
"Namespace prefix a module owns unless it sets `:ns-prefix`: `metabase.lib.schema` for `lib.schema`,
`metabase-enterprise.foo` for `enterprise/foo`."
[module]
(str (if (= "enterprise" (namespace module)) "metabase-enterprise." "metabase.") (name module)))
(defn module-ns-prefix
"Namespace prefix `module` owns: its `:ns-prefix`, else [[default-ns-prefix]]."
[modules module]
(or (get-in modules [module :ns-prefix])
(default-ns-prefix module)))
(defn build-prefix->module
"Map each declared namespace prefix to its module, for [[declared-module]]."
[modules]
(into {}
(map (fn [module] [(module-ns-prefix modules module) module]))
(keys modules)))
(defn declared-module
"Resolve `ns-symb` to its declared module, or `nil`.
Chooses the most specific dotted prefix and ignores a trailing `-test`."
[prefix->module ns-symb]
(loop [candidate (str/replace (str ns-symb) #"-test$" "")]
(or (get prefix->module candidate)
(when-let [dot (str/last-index-of candidate ".")]
(recur (subs candidate 0 dot))))))
(defn resolve-module
"E.g.
(module 'metabase.qp.middleware.wow) => 'qp
(module 'metabase-enterprise.whatever.core) => enterprise/whatever"
[ns-symb]
(resolve-module prefix->module 'metabase.qp.middleware.wow) => 'qp
(resolve-module prefix->module 'metabase-enterprise.whatever.core) => enterprise/whatever
An unmatched Metabase namespace resolves to its first segment so the linter can report an undeclared module."
[prefix->module ns-symb]
{:pre [(simple-symbol? ns-symb)]}
;; treat something like `metabase.driver-test` (for a module that hasn't fully been updated to use `.core`
;; namespaces) as being in the `driver` module
(let [ns-symb (if (str/ends-with? (name ns-symb) "-test")
(symbol (str/replace (name ns-symb) #"-test$" ""))
ns-symb)]
(or (some->> (re-find #"^metabase-enterprise\.([^.]+)" (str ns-symb))
(or (declared-module prefix->module ns-symb)
(some->> (re-find #"^metabase-enterprise\.([^.]+)" (str ns-symb))
second
(symbol "enterprise"))
(some-> (re-find #"^metabase\.([^.]+)" (str ns-symb))
second
symbol))))
(defn parent-module
"The direct parent of `module`, or `nil` when it is top-level."
[modules module]
(let [module-name (name module)]
(if-let [dot (str/last-index-of module-name ".")]
(symbol (namespace module) (subs module-name 0 dot))
(when (= "enterprise" (namespace module))
(let [oss (symbol module-name)]
(when (contains? modules oss)
oss))))))
(defn descendant-of?
"Whether `module` is `ancestor` or one of its descendants."
[modules module ancestor]
(boolean (some #{ancestor} (take-while some? (iterate #(parent-module modules %) module)))))
(defn- exports-child?
[modules parent child]
(or (contains? (set (get-in modules [parent :module-exports])) child)
;; OSS module `X` implicitly exports its `enterprise/X` companion.
(and (not= (namespace parent) (namespace child))
(contains? modules child))))
(defn- blocking-export
"The first missing export from `module` toward the root.
Returns `{:ancestor parent, :child child}`, or `nil` when every link is
exported."
[modules module]
(loop [child module]
(when-let [ancestor (parent-module modules child)]
(if (exports-child? modules ancestor child)
(recur ancestor)
{:ancestor ancestor, :child child}))))
(defn namability-error
"Explain why `caller` may not refer to `target` in `:uses`, or return `nil` if it may.
A nested module is private to the nearest ancestor with a missing export.
Exporting every link makes it public."
[modules caller target]
(when-let [{:keys [ancestor child]} (blocking-export modules target)]
(when-not (descendant-of? modules caller ancestor)
(format (str "Module %s is nested and not exported by its ancestors; %s may not use it. Add %s to %s's "
":module-exports, or move the caller into the %s subtree. [:metabase/modules %s :module-exports]")
target caller child ancestor ancestor ancestor))))
;;;; Lint rules. These functions take the full linter config.
(defonce ^:private prefix->module-cache
(atom nil))
(defn- prefix->module
"[[build-prefix->module]] for `config`, cached because Kondo hands every hook call the same `:metabase/modules` map."
[config]
(let [modules (:metabase/modules config)
cached @prefix->module-cache]
(if (identical? modules (:modules cached))
(:prefix->module cached)
(:prefix->module (reset! prefix->module-cache {:modules modules
:prefix->module (build-prefix->module modules)})))))
(defn module
"Resolve `ns-symb` to its module under `config`; see [[resolve-module]]."
[config ns-symb]
(resolve-module (prefix->module config) ns-symb))
(defn- module-api-namespaces
"Set of API namespace symbols for a given module. `:any` means you can use anything, there are no API namespaces for
this module (yet). If unspecified, the default is just the `<module>.core` namespace."
@@ -44,9 +153,7 @@
module-config
:else
(let [ns-prefix (if (= (namespace module) "enterprise")
(str "metabase-enterprise." (name module))
(name module))]
(let [ns-prefix (module-ns-prefix (:metabase/modules config) module)]
#{(symbol (str ns-prefix ".api"))
(symbol (str ns-prefix ".core"))
(symbol (str ns-prefix ".init"))}))))
@@ -68,15 +175,17 @@
(contains? (set allowed-modules) required-module))))
(defn- allowed-module-namespace? [config current-module ns-symb]
(let [module (module ns-symb)
(let [module (module config ns-symb)
module-api-namespaces (module-api-namespaces config module)
module-friends (module-friends config module)]
(or (nil? module-api-namespaces)
(contains? module-api-namespaces ns-symb)
(contains? module-friends current-module))))
(contains? module-friends current-module)
;; a child may use its ancestors' internals; a parent still goes through its child's `:api`
(descendant-of? (:metabase/modules config) current-module module))))
(defn- rest-module? [module]
(str/ends-with? module "-rest"))
(re-find #"[.-]rest$" (str module)))
(defn- routes-module? [module]
(str/ends-with? module "-routes"))
@@ -89,7 +198,7 @@
error type if there is one, otherwise `nil` if there are no errors."
[config current-module required-namespace]
;; ignore stuff not in a module i.e. non-Metabase stuff.
(when-let [required-module (module required-namespace)]
(when-let [required-module (module config required-namespace)]
(when-not (= current-module required-module)
(cond
(not (allowed-module? config current-module required-module))
@@ -113,4 +222,8 @@
required-module
current-module
required-module
(symbol (str/replace required-module #"-rest$" "")))))))
(symbol (str/replace required-module #"[.-]rest$" "")))
;; Config tests check explicit `:uses`; wildcard callers are checked here.
(= :any (allowed-modules config current-module))
(namability-error (:metabase/modules config) current-module required-module)))))

View File

@@ -4,9 +4,9 @@
[hooks.common :as common]))
(defn lint-defsetting-namespace [node context]
(when-not (re-matches #"^metabase(?:-enterprise)?\.[^\.]+\.settings$" (name (:ns context)))
(when-not (re-matches #"^metabase(?:-enterprise)?\.([^\.]+\.)+settings$" (name (:ns context)))
(hooks/reg-finding! (assoc (meta node)
:message "All defsettings should live in metabase[-enterprise].<module>.settings namespaces"
:message "All defsettings should live in a namespace ending in .settings under metabase[-enterprise]"
:type :metabase/defsetting-namespace))))
;;; TODO -- move this into a Kondo config file in `.clj-kondo/config/`

View File

@@ -8,10 +8,20 @@
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]))
[clj-kondo.hooks-api :as hooks]
[hooks.common.modules :as modules]))
(defn- db-namespace? [ns-sym]
(boolean (re-matches #"^metabase(?:-enterprise)?\.(?:driver\.)?[^.]+\.db$" (name ns-sym))))
(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
@@ -24,7 +34,7 @@
namespace."
[{:keys [node ns filename] :as input}]
(when (and ns
(not (db-namespace? ns))
(not (db-namespace? (modules/config input) ns))
(not (test-file? filename)))
(let [fn-node (first (:children node))]
(hooks/reg-finding!

View File

@@ -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))

View File

@@ -5,14 +5,15 @@
[clojure.test :refer :all]
[hooks.metabase.toucan.db-ns :as toucan.db-ns]))
(defn- lint-query-call [form ns-sym & [filename]]
(defn- lint-query-call [form ns-sym & [filename modules]]
(binding [clj-kondo.impl.utils/*ctx* {:config {:linters {:metabase/t2-query-namespace {:level :warning}}}
:ignores (atom nil)
:findings (atom [])
:namespaces (atom {})}]
(let [input {:node (hooks/parse-string (pr-str form))
:ns ns-sym
:filename (or filename "src/metabase/foo/bar.clj")}
: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")
@@ -48,3 +49,25 @@
(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))))))

View File

@@ -14,3 +14,6 @@ paths:
.github/workflows/lint.yml:
ignore:
- 'step must run script with "run" section or run action with "uses" section'
.github/workflows/pr.checks.yml:
ignore:
- 'step must run script with "run" section or run action with "uses" section'

View File

@@ -20,6 +20,30 @@ inputs:
required: false
default: "ee"
description: Edition to test (oss or ee)
runner-index:
required: false
description: >-
The index of this runner among those sharing a partitioned suite. See
`be-tests-bigquery-cloud-sdk-ee` in `.github/workflows/drivers.yml` for an example.
runner-count:
required: false
description: >-
How many runners share the suite. Recommended to set to `strategy.job-total` rather
than a literal so that it doesn't need updating if the `runner-index` matrix changes.
jvms-per-runner:
required: false
default: "1"
description: >-
Concurrent JVMs per runner. Recommended to raise above 1 for drivers whose tests
spend a lot of time waiting on a remote warehouse.
heap-per-jvm:
required: false
default: "4g"
description: >-
`-Xmx` per JVM, applied only when `jvms-per-runner` is more than 1 (otherwise the
the `:ci` alias's `-Xmx12g` is used). To size it: reserve ~1G for the OS and ~2G
of non-heap per JVM, then divide what's left. On a 16G runner that's ~4G each for
two JVMs.
granular-rerun:
required: false
default: "true"
@@ -98,6 +122,7 @@ runs:
# Otherwise run exactly as before. Quote the file so the whole `[ns/var ...]`
# vector reaches clojure -X as a single argument.
run: |
ALIASES="dev:ci:${{ inputs.edition }}:${{ inputs.edition }}-dev:drivers:drivers-dev:test"
BASE="clojure -X:dev:ci:${{ inputs.edition }}:${{ inputs.edition }}-dev:drivers:drivers-dev:test"
# `:profile-tests` overrides `:test`'s exec-fn, so it has to come last. See dev.profile-tests.
# The `profiled` output is what the collection steps below key off, so that the decision is
@@ -115,12 +140,19 @@ runs:
$BASE :only "$ONLY"
elif [ "${{ inputs.granular-rerun }}" = "true" ] && [ "${{ github.run_attempt }}" != "1" ] && [ -s target/failed-tests ]; then
echo "Granular rerun of previously-failed tests: $(cat target/failed-tests)"
$BASE :only "$(cat target/failed-tests)"
clojure -X:"$ALIASES" :only "$(cat target/failed-tests)"
elif [ -n "$TEST_RUNNER_INDEX" ]; then
CLOJURE_ALIASES="$ALIASES" ./.github/scripts/run-backend-test-jvms.sh ${{ inputs.test-args }}
else
$BASE ${{ inputs.test-args }}
clojure -X:"$ALIASES" ${{ inputs.test-args }}
fi
shell: bash
id: run-test
env:
TEST_RUNNER_INDEX: ${{ inputs.runner-index }}
TEST_RUNNER_COUNT: ${{ inputs.runner-count }}
TEST_JVMS_PER_RUNNER: ${{ inputs.jvms-per-runner }}
TEST_HEAP_PER_JVM: ${{ inputs.heap-per-jvm }}
# Keep this step continue-on-error because the quarantine gate below controls whether it
# ultimately fails.
continue-on-error: true
@@ -184,7 +216,9 @@ runs:
uses: actions/upload-artifact@v7
with:
name: ${{ format('test-logs-{0}{1}', github.job, inputs.artifact-suffix && format('-{0}', inputs.artifact-suffix) || '') }}
path: logs/test-log.json
# Glob: a single-JVM run writes `logs/test-log.json`; a multi-JVM run merges one
# `logs/test-log-p<index>.json` per JVM.
path: logs/test-log*.json
retention-days: 1
# Failing to upload the artifact should never affect the outcome of the whole test run.
continue-on-error: true

View File

@@ -115,10 +115,10 @@ backend_sources: &backend_sources
- "src/**"
- "modules/drivers/{*,*/{*,!(test)/**}}"
- "resources/**"
# The ratchets file drives project_ratchet_checks alone, so the automation PR that lowers the budgets
# runs the Project tests job by itself. Patterns are OR-ed, so a separate "!..." entry would match
# Ratchet files drive project_ratchet_checks alone, so automation PRs that lower budgets
# run the Project tests job by themselves. Patterns are OR-ed, so a separate "!..." entry would match
# every other path instead of excluding this one.
- ".clj-kondo/{!(ratchets.edn),*/**}"
- ".clj-kondo/**/!(ratchets.edn)"
backend_specs: &backend_specs
- *shared_specs
@@ -169,6 +169,7 @@ project_backend_checks:
# Only the ratchet check reads the budgets, so a diff that touches nothing else runs that suite alone.
project_ratchet_checks:
- ".clj-kondo/ratchets.edn"
- ".clj-kondo/config/modules/ratchets.edn"
project_migration_checks:
- *project_tests_infra

View File

@@ -29,7 +29,7 @@ describe("file-paths.yaml", () => {
// dorny/paths-filter ORs the patterns in a filter, so a "!..." entry does not subtract from the
// other entries - it matches every path they don't, turning the filter permanently on. Excluding
// a path means writing the exclusion inside one pattern, the way backend_sources keeps
// ratchets.edn out with ".clj-kondo/{!(ratchets.edn),*/**}".
// ratchet files out with ".clj-kondo/**/!(ratchets.edn)".
it("never excludes a path with a standalone negation", () => {
const negated = Object.entries(filters).flatMap(([name, filter]) =>
patterns(filter)
@@ -44,6 +44,9 @@ describe("file-paths.yaml", () => {
"%s skips a ratchets-only change and still follows the rest of .clj-kondo",
(name) => {
expect(matches(name, ".clj-kondo/ratchets.edn")).toBe(false);
expect(
matches(name, ".clj-kondo/config/modules/ratchets.edn"),
).toBe(false);
expect(matches(name, ".clj-kondo/config.edn")).toBe(true);
expect(matches(name, ".clj-kondo/config/modules/config.edn")).toBe(true);
},
@@ -53,5 +56,11 @@ describe("file-paths.yaml", () => {
expect(matches("project_ratchet_checks", ".clj-kondo/ratchets.edn")).toBe(
true,
);
expect(
matches(
"project_ratchet_checks",
".clj-kondo/config/modules/ratchets.edn",
),
).toBe(true);
});
});

160
.github/scripts/run-backend-test-jvms.sh vendored Executable file
View File

@@ -0,0 +1,160 @@
#!/usr/bin/env bash
#
# Run this runner's share of a partitioned backend test suite as one or more concurrent
# JVMs. The caller says how many runners share the suite and how many JVMs each gets.
#
# WHY MULTIPLE JVMS PER RUNNER
# ----------------------------
# Driver tests spend most of their wall time waiting on the remote warehouse, and they
# can't use the idle CPU by using more threads because some tests mutate global state.
# A second JVM gets that isolation for free.
#
# Two paths are NOT safe to share:
# * `target/junit` — hawk deletes the whole directory at `:begin-test-run`, so a JVM
# starting late would wipe an earlier one's results.
# * `logs/test-log.json` — the log4j2 File appender truncates it on open.
# So each JVM runs in its own hardlinked copy of the checkout (inodes, not disk) and
# outputs are merged back into the workspace afterwards.
#
# INPUTS (environment)
# --------------------
# CLOJURE_ALIASES alias string for `clojure -X`, e.g. dev:ci:ee:ee-dev:drivers:drivers-dev:test
# TEST_RUNNER_INDEX index of this runner among those sharing the suite
# TEST_RUNNER_COUNT how many runners share the suite
# TEST_JVMS_PER_RUNNER concurrent JVMs per runner (default 1)
# TEST_HEAP_PER_JVM -Xmx per JVM, applied only when running more than one
#
# Exits non-zero if any JVM fails.
set -uo pipefail
: "${CLOJURE_ALIASES:?CLOJURE_ALIASES is required}"
: "${TEST_RUNNER_INDEX:?TEST_RUNNER_INDEX is required}"
: "${TEST_RUNNER_COUNT:?TEST_RUNNER_COUNT is required}"
TEST_ARGS=("$@")
TEST_JVMS_PER_RUNNER="${TEST_JVMS_PER_RUNNER:-1}"
TEST_HEAP_PER_JVM="${TEST_HEAP_PER_JVM:-4g}"
die() {
echo "::error::run-backend-test-jvms.sh: $1"
exit 1
}
[[ "$TEST_RUNNER_INDEX" =~ ^[0-9]+$ ]] || die "TEST_RUNNER_INDEX must be a non-negative integer, got '$TEST_RUNNER_INDEX'"
[[ "$TEST_RUNNER_COUNT" =~ ^[0-9]+$ ]] || die "TEST_RUNNER_COUNT must be a positive integer, got '$TEST_RUNNER_COUNT'"
[[ "$TEST_JVMS_PER_RUNNER" =~ ^[0-9]+$ ]] || die "TEST_JVMS_PER_RUNNER must be a positive integer, got '$TEST_JVMS_PER_RUNNER'"
[ "$TEST_RUNNER_COUNT" -ge 1 ] || die "TEST_RUNNER_COUNT must be at least 1"
[ "$TEST_JVMS_PER_RUNNER" -ge 1 ] || die "TEST_JVMS_PER_RUNNER must be at least 1"
[ "$TEST_RUNNER_INDEX" -lt "$TEST_RUNNER_COUNT" ] ||
die "runner index $TEST_RUNNER_INDEX is out of range for $TEST_RUNNER_COUNT runner(s) — a matrix entry was added without raising runner-count"
# Partition indexes are split across runners rather than handed out in contiguous
# blocks. E.g. with 2 runners and 2 JVMs the first runner gets partition 0 and 2, and
# the second runner gets partitions 1 and 3. Hawk partitions in namespace order, so
# neighbouring partitions have similar runtimes — contiguous blocks would group the slow
# ones together onto one runner.
PARTITION_TOTAL=$((TEST_RUNNER_COUNT * TEST_JVMS_PER_RUNNER))
INDEXES=()
for ((k = 0; k < TEST_JVMS_PER_RUNNER; k++)); do
INDEXES+=($((TEST_RUNNER_INDEX + k * TEST_RUNNER_COUNT)))
done
# One JVM: run directly in the workspace with no heap override, exactly as a
# partitioned run behaved before this script existed.
if [ "$TEST_JVMS_PER_RUNNER" -eq 1 ]; then
echo "Running partition ${INDEXES[0]} of $PARTITION_TOTAL"
exec clojure -X:"$CLOJURE_ALIASES" "${TEST_ARGS[@]}" \
:partition/total "$PARTITION_TOTAL" \
:partition/index "${INDEXES[0]}"
fi
WORKSPACE="$PWD"
# Sibling of the checkout: same filesystem (so `cp -al` can hardlink) but outside the
# repo, so one JVM's directory never gets copied into another's.
JVM_DIRS_ROOT="$(dirname "$WORKSPACE")/backend-test-jvms"
STATUS_DIR="$JVM_DIRS_ROOT/status"
# Merge every JVM's results into the workspace the rest of the job reads from.
#
# Each partition's junit XML goes into its own subdirectory: `mb_hawk_var_less_errors.xml`
# has a fixed name, so a flat copy would silently overwrite it. Nesting is safe because
# both consumers (dorny/test-reporter and ci-conductor's backend adapter) walk
# `target/junit` recursively and match on filename suffix.
#
# Runs from an EXIT trap so a cancelled or timed-out job still publishes whatever
# finished.
# shellcheck disable=SC2329 # invoked by the EXIT trap below, which shellcheck can't see.
merge_outputs() {
mkdir -p "$WORKSPACE/target/junit" "$WORKSPACE/logs"
local idx jvm_dir
for idx in "${INDEXES[@]}"; do
jvm_dir="$JVM_DIRS_ROOT/partition-$idx"
if [ -d "$jvm_dir/target/junit" ]; then
mkdir -p "$WORKSPACE/target/junit/p$idx"
cp -a "$jvm_dir/target/junit/." "$WORKSPACE/target/junit/p$idx/" || true
fi
# `logs/test-log*.json` is what the action uploads on failure.
if [ -f "$jvm_dir/logs/test-log.json" ]; then
cp -a "$jvm_dir/logs/test-log.json" "$WORKSPACE/logs/test-log-p$idx.json" || true
fi
done
}
trap merge_outputs EXIT
trap 'exit 143' TERM
trap 'exit 130' INT
# Hardlinked copy of the checkout. Skips the paths a run writes to (`target`, `logs`, `.cpcache`)
# and the two most expensive trees nothing on the test classpath reads (`.git`, `node_modules`).
prepare_jvm_dir() {
local jvm_dir="$1" entry
rm -rf "$jvm_dir"
mkdir -p "$jvm_dir"
for entry in "$WORKSPACE"/* "$WORKSPACE"/.[!.]*; do
[ -e "$entry" ] || continue
case "$(basename "$entry")" in
.git | node_modules | target | logs | .cpcache) continue ;;
esac
# Fall back to a real copy if the filesystem refuses the hardlink.
cp -al "$entry" "$jvm_dir/" 2>/dev/null || cp -a "$entry" "$jvm_dir/"
done
}
# Prefix output with the partition index so interleaved JVM logs stay readable.
run_partition() {
local idx="$1" jvm_dir="$2" rc line
(
cd "$jvm_dir" || exit 1
# `-J` options land after the aliases' `:jvm-opts`, overriding the `:ci` alias's
# `-Xms12g -Xmx12g`. Xms stays small so JVMs sharing a runner grow into memory on
# demand rather than each committing its ceiling up front.
exec clojure -J-Xms512m -J-Xmx"$TEST_HEAP_PER_JVM" -X:"$CLOJURE_ALIASES" \
"${TEST_ARGS[@]}" \
:partition/total "$PARTITION_TOTAL" \
:partition/index "$idx"
) 2>&1 | while IFS= read -r line || [ -n "$line" ]; do printf '[p%s] %s\n' "$idx" "$line"; done
rc="${PIPESTATUS[0]}"
echo "$rc" > "$STATUS_DIR/p$idx"
}
rm -rf "$JVM_DIRS_ROOT"
mkdir -p "$STATUS_DIR"
echo "Runner $TEST_RUNNER_INDEX of $TEST_RUNNER_COUNT: running $TEST_JVMS_PER_RUNNER concurrent JVMs for partition(s) ${INDEXES[*]} of $PARTITION_TOTAL (-Xmx$TEST_HEAP_PER_JVM each)"
for idx in "${INDEXES[@]}"; do
prepare_jvm_dir "$JVM_DIRS_ROOT/partition-$idx"
done
for idx in "${INDEXES[@]}"; do
run_partition "$idx" "$JVM_DIRS_ROOT/partition-$idx" &
done
wait
overall=0
for idx in "${INDEXES[@]}"; do
rc="$(cat "$STATUS_DIR/p$idx" 2>/dev/null || echo 1)"
echo "partition $idx exited $rc"
[ "$rc" -eq 0 ] || overall=1
done
exit "$overall"

View File

@@ -1,15 +0,0 @@
name: Block Master Branch PRs
on:
pull_request:
types: [opened, synchronize]
jobs:
check-source-branch:
if: github.head_ref == 'master'
runs-on: ${{ vars.SLIM_RUNNER_KEY }}
timeout-minutes: 5
steps:
- name: Block master branch PRs
run: |
echo "PRs from \`master\` branches are not allowed, please use a new branch name for your PR."
exit 1

View File

@@ -1,21 +0,0 @@
name: Prevent backport.sh from being merged
on:
pull_request:
types: [opened, synchronize, reopened]
jobs:
check-backport-script:
runs-on: ${{ vars.SLIM_RUNNER_KEY }}
timeout-minutes: 5
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Check for backport.sh file
run: |
if [ -f "./backport.sh" ]; then
echo "backport.sh file exists. Please remove it before merging."
exit 1
else
echo "All good."
fi

View File

@@ -77,6 +77,9 @@ jobs:
- name: Generate AI provider documentation
id: generate_ai_provider_docs
# The generator only exists on branches that ship the multi-provider registry (master / 64+), but
# this reusable workflow always runs from master, so skip the step on release branches without it.
if: hashFiles('src/metabase/cmd/ai_provider_dox.clj') != ''
run: |
set -e # Exit on any error
echo "Generating AI provider documentation..."

View File

@@ -168,29 +168,11 @@ jobs:
needs: determine-driver-skips
if: ${{ needs.determine-driver-skips.outputs.bigquery-should-run == 'true' }}
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 70
timeout-minutes: 40
strategy:
fail-fast: false
matrix:
job:
- name: Driver Tests Part 1
test-args: >-
:only-tags [:mb/driver-tests]
:exclude-tags [:mb/transforms-python-test]
:fail-fast? true
:partition/total 2
:partition/index 0
- name: Driver Tests Part 2
test-args: >-
:only-tags [:mb/driver-tests]
:exclude-tags [:mb/transforms-python-test]
:fail-fast? true
:partition/total 2
:partition/index 1
- name: Transforms Python Tests
test-args: >-
:only-tags [:mb/transforms-python-test]
needs-python-runner: true
runner-index: [0, 1]
env:
CI: 'true'
DRIVERS: bigquery-cloud-sdk
@@ -201,11 +183,43 @@ jobs:
MB_BIGQUERY_TEST_REFRESH_TOKEN: ${{ secrets.MB_BIGQUERY_TEST_REFRESH_TOKEN }}
MB_BIGQUERY_CLOUD_SDK_TEST_SERVICE_ACCOUNT_JSON: ${{ secrets.MB_BIGQUERY_CLOUD_SDK_TEST_SERVICE_ACCOUNT_JSON }}
MB_BIGQUERY_CLOUD_SDK_TEST_SERVICE_ACCOUNT_JSON_ROUTING: ${{ secrets.MB_BIGQUERY_CLOUD_SDK_TEST_SERVICE_ACCOUNT_JSON_ROUTING }}
name: BigQuery ${{ matrix.job.name }}
name: BigQuery Driver Tests ${{ matrix.runner-index }}
steps:
- uses: actions/checkout@v6
- name: Test BigQuery Cloud SDK driver
id: test-driver
uses: ./.github/actions/test-driver
with:
junit-name: 'be-tests-bigquery-cloud-sdk-ee'
test-args: >-
:only-tags [:mb/driver-tests]
:exclude-tags [:mb/transforms-python-test]
:fail-fast? true
runner-index: ${{ matrix.runner-index }}
runner-count: ${{ strategy.job-total }}
jvms-per-runner: '2'
heap-per-jvm: '4g'
artifact-suffix: runner-${{ matrix.runner-index }}
be-tests-bigquery-transforms-python-ee:
needs: determine-driver-skips
if: ${{ needs.determine-driver-skips.outputs.bigquery-should-run == 'true' }}
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 30
env:
CI: 'true'
DRIVERS: bigquery-cloud-sdk
MB_BIGQUERY_TEST_PROJECT_ID: ${{ secrets.BIGQUERY_TEST_PROJECT_ID }}
MB_BIGQUERY_TEST_CLIENT_ID: ${{ secrets.MB_BIGQUERY_TEST_CLIENT_ID }}
MB_BIGQUERY_TEST_CLIENT_SECRET: ${{ secrets.MB_BIGQUERY_TEST_CLIENT_SECRET }}
MB_BIGQUERY_TEST_ACCESS_TOKEN: ${{ secrets.MB_BIGQUERY_TEST_ACCESS_TOKEN }}
MB_BIGQUERY_TEST_REFRESH_TOKEN: ${{ secrets.MB_BIGQUERY_TEST_REFRESH_TOKEN }}
MB_BIGQUERY_CLOUD_SDK_TEST_SERVICE_ACCOUNT_JSON: ${{ secrets.MB_BIGQUERY_CLOUD_SDK_TEST_SERVICE_ACCOUNT_JSON }}
MB_BIGQUERY_CLOUD_SDK_TEST_SERVICE_ACCOUNT_JSON_ROUTING: ${{ secrets.MB_BIGQUERY_CLOUD_SDK_TEST_SERVICE_ACCOUNT_JSON_ROUTING }}
name: BigQuery Transforms Python Tests
steps:
- uses: actions/checkout@v6
- name: Setup python-runner
if: ${{ matrix.job.needs-python-runner == true }}
uses: ./.github/actions/setup-python-runner
with:
metabase-automation-token: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
@@ -213,15 +227,17 @@ jobs:
id: test-driver
uses: ./.github/actions/test-driver
with:
# This is the same as the cloud-sdk job above to preserve quarantine history in ci-conductor.
junit-name: 'be-tests-bigquery-cloud-sdk-ee'
test-args: ${{ matrix.job.test-args }}
artifact-suffix: ${{ matrix.job.name }}
test-args: >-
:only-tags [:mb/transforms-python-test]
artifact-suffix: transforms-python
- name: Cleanup python-runner
if: ${{ always() && matrix.job.needs-python-runner == true }}
if: ${{ always() }}
uses: ./.github/actions/cleanup-python-runner
with:
upload-logs: ${{ steps.test-driver.outcome == 'failure' }}
logs-artifact-suffix: ${{ matrix.job.name }}
logs-artifact-suffix: transforms-python
be-tests-clickhouse-ee:
needs: determine-driver-skips
@@ -1257,6 +1273,7 @@ jobs:
# if you add a driver job, you must add it to this list for it to be a required check
- be-tests-athena-ee
- be-tests-bigquery-cloud-sdk-ee
- be-tests-bigquery-transforms-python-ee
- be-tests-clickhouse-ee
- be-tests-databricks-ee
- be-tests-druid-jdbc-ee

View File

@@ -424,15 +424,38 @@ jobs:
if: steps.prebuilt-cljs.outcome != 'success'
run: bun run build-pure:cljs
# The data-app suite synchronizes real actions, and Metabase only permits
# those against a writable warehouse, which comes from the `qa-db`
# snapshot below. That snapshot creator builds every QA database in one
# pass — Mongo and MySQL included — so all three have to be up even though
# only `postgres-writable` is restored. The other host apps read the sample
# database, so they skip the pull entirely.
- name: Prepare Docker containers
if: ${{ matrix.test_suite == 'vite-6-data-app-host-app-e2e' }}
uses: ./.github/actions/e2e-prepare-containers
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
postgres: true
mysql: true
mongo: true
- name: Run Metabase
run: node e2e/runner/run_cypress_ci.js start
- uses: ./.github/actions/resolve-job-id
# Without `--spec` the snapshot config picks up `qa-db.cy.snap.js` too,
# which is what creates the `postgres-writable` snapshot the data-app
# action tests restore.
- name: Generate database snapshots
run: |
node e2e/runner/run_cypress_ci.js snapshot \
--spec "e2e/snapshot-creators/default.cy.snap.js"
if [ "${{ matrix.test_suite }}" = "vite-6-data-app-host-app-e2e" ]; then
node e2e/runner/run_cypress_ci.js snapshot
else
node e2e/runner/run_cypress_ci.js snapshot \
--spec "e2e/snapshot-creators/default.cy.snap.js"
fi
- name: Prepare and launch Host App
run: npx tsx ./e2e/runner/embedding-sdk/host-apps/start-ci.ts ${{ matrix.test_suite }}

View File

@@ -24,6 +24,8 @@ jobs:
# unrelated to embedding (EMB-765)
echo "::remove-matcher owner=tsc::"
bun run build-embedding-sdk-package
- name: Smoke test the packed SDK CLI
run: node bin/embedding-sdk/test-sdk-package.mjs
- name: Prepare Embedding SDK artifact
uses: actions/upload-artifact@v7
with:

View File

@@ -1,9 +1,9 @@
name: Shrink ratchets
# Lowers the budgets in .clj-kondo/ratchets.edn to match master and delivers the result through one
# Lowers the ratchet budgets to match master and delivers the result through one
# automation PR, because the rulesets require a pull request for every change to master. The PR is
# approved by github-actions[bot] and merges itself once the ratchet check passes. A merge of that PR
# touches only ratchets.edn, which matches `.clj-kondo/**` below, so it triggers one more run that finds
# touches only ratchet files, which match `.clj-kondo/**` below, so it triggers one more run that finds
# nothing to lower and stops: a single no-op, not a loop.
on:
@@ -32,7 +32,8 @@ concurrency:
env:
AUTOMATION_BRANCH: automation/shrink-ratchets
RATCHETS_FILE: .clj-kondo/ratchets.edn
KONDO_RATCHETS_FILE: .clj-kondo/ratchets.edn
MODULE_RATCHETS_FILE: .clj-kondo/config/modules/ratchets.edn
jobs:
shrink:
@@ -56,9 +57,10 @@ jobs:
with:
distribution: temurin
java-version: 25
- name: Shrink ignore limits
- name: Shrink ratchets
run: |
cp "$RATCHETS_FILE" "$RUNNER_TEMP/ratchets-before.edn"
cp "$KONDO_RATCHETS_FILE" "$RUNNER_TEMP/kondo-ratchets-before.edn"
cp "$MODULE_RATCHETS_FILE" "$RUNNER_TEMP/module-ratchets-before.edn"
./bin/mage kondo-ratchets-shrink
# Two independent outputs: `push` says the branch needs a new commit, `pr` names the open automation
# PR if there is one. Approval and auto-merge key on `pr` alone, so a run that failed after opening
@@ -77,23 +79,23 @@ jobs:
--jq '.[0].number // empty')
echo "pr=$open_pr" >> "$GITHUB_OUTPUT"
if git diff --quiet -- "$RATCHETS_FILE"; then
if git diff --quiet -- "$KONDO_RATCHETS_FILE" "$MODULE_RATCHETS_FILE"; then
echo "Ratchet budgets already match master $(git rev-parse --short HEAD)"
[ -z "$open_pr" ] || echo "The open automation PR #$open_pr is re-approved below"
echo "push=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# The ratchet file is the whole payload, so an open PR that already carries this result is left
# The ratchet files are the whole payload, so an open PR that already carries this result is left
# alone. Rebasing it onto every master push would restart its CI and churn the diff without
# changing what it delivers. Compare the open PR with the working tree, which contains the
# shrunk ratchets file produced above, to determine whether the PR would deliver the same file.
# shrunk ratchet files produced above, to determine whether the PR would deliver the same result.
#
# A conflicted PR is the exception: only a fresh commit clears it. Mergeability is computed
# lazily, so a pending UNKNOWN reads as not conflicted and the next run settles it.
if [ -n "$open_pr" ] \
&& git fetch --quiet --depth=1 origin "refs/heads/$AUTOMATION_BRANCH" 2>/dev/null \
&& git diff --quiet FETCH_HEAD -- "$RATCHETS_FILE" \
&& git diff --quiet FETCH_HEAD -- "$KONDO_RATCHETS_FILE" "$MODULE_RATCHETS_FILE" \
&& [ "$(gh pr view "$open_pr" --repo "$GITHUB_REPOSITORY" \
--json mergeable --jq .mergeable)" != CONFLICTING ]; then
echo "The automation PR #$open_pr already carries this result"
@@ -102,7 +104,7 @@ jobs:
exit 0
fi
git diff --stat -- "$RATCHETS_FILE"
git diff --stat -- "$KONDO_RATCHETS_FILE" "$MODULE_RATCHETS_FILE"
echo "push=true" >> "$GITHUB_OUTPUT"
- name: Update the automation PR
id: deliver
@@ -116,14 +118,16 @@ jobs:
OPEN_PR: ${{ steps.compare.outputs.pr }}
run: |
./bin/mage -kondo-ratchets-pr-body \
"$RUNNER_TEMP/ratchets-before.edn" \
"$RATCHETS_FILE" \
"$RUNNER_TEMP/kondo-ratchets-before.edn" \
"$KONDO_RATCHETS_FILE" \
"$RUNNER_TEMP/module-ratchets-before.edn" \
"$MODULE_RATCHETS_FILE" \
"${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \
> "$RUNNER_TEMP/ratchets-pr-body.md"
git config user.name github-actions
git config user.email github-actions@github.com
git add -- "$RATCHETS_FILE"
git add -- "$KONDO_RATCHETS_FILE" "$MODULE_RATCHETS_FILE"
git commit --quiet -m "Tighten ratchets"
git push --force --quiet \
"https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" \

View File

@@ -1,25 +0,0 @@
name: Add Issue References to PR
on:
pull_request:
types: [opened, edited, synchronize]
jobs:
add-issue-references:
runs-on: ${{ vars.SLIM_RUNNER_KEY }}
timeout-minutes: 5
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Run the link script
uses: actions/github-script@v9
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }}
PR_NUMBER: ${{ github.event.pull_request.number }}
REPO_OWNER: ${{ github.repository_owner }}
REPO_NAME: ${{ github.event.repository.name }}
with:
script: |
const { link_issues } = require('${{ github.workspace }}/.github/scripts/update-pr-with-issues.js');
await link_issues(github)

52
.github/workflows/pr.checks.yml vendored Normal file
View File

@@ -0,0 +1,52 @@
# Helpful (and quick) checks that run on PRs.
name: PR Checks
on: pull_request
jobs:
checks:
runs-on: ${{ vars.SLIM_RUNNER_KEY }}
timeout-minutes: 10
permissions:
pull-requests: write
issues: read
steps:
- uses: actions/checkout@v6
with:
sparse-checkout: |
/.github/scripts/update-pr-with-issues.js
/backport.sh
sparse-checkout-cone-mode: false
# download repo structure without file contents, fetched on demand since we only need a few files
# NOTE: this gets slow if we start accessing more files, remove this filter in that case.
filter: blob:none
- parallel:
- name: Block master branch PRs (from forks)
if: github.head_ref == 'master'
run: |
echo "PRs from \`master\` branches are not allowed, please use a new branch name for your PR."
exit 1
- name: Link PR and issues
uses: actions/github-script@v9
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }}
PR_NUMBER: ${{ github.event.pull_request.number }}
REPO_OWNER: ${{ github.repository_owner }}
REPO_NAME: ${{ github.event.repository.name }}
with:
script: |
const { link_issues } = require('${{ github.workspace }}/.github/scripts/update-pr-with-issues.js');
await link_issues(github)
- name: Prevent backport.sh from being merged
run: |
if [ -f "./backport.sh" ]; then
echo "backport.sh file exists. Please remove it before merging."
exit 1
else
echo "All good."
fi

View File

@@ -1,45 +0,0 @@
name: Pull Request Reminders
on:
pull_request:
types: [opened]
branches:
- "master"
jobs:
external-reminder:
name: External PR Helper
if: ${{ github.event.pull_request.head.repo.full_name != github.repository }}
runs-on: ${{ vars.SLIM_RUNNER_KEY }}
timeout-minutes: 5
permissions:
pull-requests: write
issues: read
steps:
- uses: actions/github-script@v9
with:
script: | # js
const owner = context.repo.owner;
const repo = context.repo.repo;
const pullNumber = '${{ github.event.pull_request.number }}';
const author = '${{ github.event.pull_request.user.login }}';
await github.rest.issues.addLabels({
owner,
repo,
issue_number: pullNumber,
labels: ["External"],
});
const guideLink = "https://www.notion.so/metabase/Third-Party-PRs-External-contributions-5954061854424c1ea6391d98746646e0";
const message = `@${author} Thanks for your contribution! ❤️ We'll try to review it as soon as we can.\n\n
Reviewers, make sure to check out our [External PR Review Guide](${guideLink}) when reviewing.
`;
await github.rest.issues.createComment({
owner,
repo,
issue_number: pullNumber,
body: message,
});

View File

@@ -382,6 +382,9 @@ jobs:
- name: Generate SDK package.json in the build directory
run: bun run embedding-sdk:generate-package
- name: Smoke test the packed SDK CLI
run: node bin/embedding-sdk/test-sdk-package.mjs
- name: Upload built SDK package as artifact
uses: actions/upload-artifact@v7
with:

View File

@@ -21,7 +21,7 @@ jobs:
// Nightly patches cover the current supported major and the one
// before it. release-auto.yml releases one major per dispatch.
const currentRelease = Number('${{ vars.CURRENT_VERSION }}');
const majors = [currentRelease - 1, currentRelease];
const majors = [currentRelease];
await Promise.all(majors.map(version =>
github.rest.actions.createWorkflowDispatch({

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 212 KiB

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 382 KiB

After

Width:  |  Height:  |  Size: 367 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 161 KiB

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Some files were not shown because too many files have changed in this diff Show More