Compare commits

...

52 Commits

Author SHA1 Message Date
Ryan Laurie
58f9f4550a release flags for migrations 2026-04-01 13:35:25 -06:00
Ryan Laurie
5a70a6d94e remove frontend runtime flags 2026-04-01 09:02:03 -06:00
Eric Normand
aa6a0be593 Disable put in prod 2026-04-01 06:48:42 -06:00
Eric Normand
d908f243b4 Appease the kondo 2026-04-01 06:48:42 -06:00
Eric Normand
1e3f3617f8 Protect API endpoints
API requires logged in user
Prod mode always returns {} in bootstrap and API
2026-04-01 06:48:42 -06:00
Eric Normand
188f9be1d1 Always return false on prod without hitting the db 2026-04-01 06:48:24 -06:00
Ryan Laurie
b40465eef2 remove failing prod test 2026-04-01 06:48:24 -06:00
Ryan Laurie
e1890313ab cleanup 2026-04-01 06:48:24 -06:00
Ryan Laurie
0a778c7c25 add robot instructions 2026-04-01 06:48:24 -06:00
Ryan Laurie
7db746cf04 add e2e tests 2026-04-01 06:48:23 -06:00
Eric Normand
1ffdd899a1 Bind *blocked-calls* to nil during guard tests 2026-04-01 06:48:23 -06:00
Ryan Laurie
424aa3361c dont show flags page in production 2026-04-01 06:48:23 -06:00
Eric Normand
25d44affe6 Add :model/ReleaseFlag to non-serialized entities list 2026-04-01 06:48:23 -06:00
Eric Normand
5aff089633 Fix test 2026-04-01 06:48:08 -06:00
Eric Normand
27ebc7e874 Make non-existent flags return false 2026-04-01 06:48:08 -06:00
Eric Normand
83c254ac76 Better docs 2026-04-01 06:48:08 -06:00
Ryan Laurie
c29c0dc2a9 hang hasReleaseFlag on the window for clojurescript 2026-04-01 06:48:08 -06:00
Ryan Laurie
00a91d2974 put flags on the window object 2026-04-01 06:48:08 -06:00
Ryan Laurie
770af0c170 cleanup 2026-04-01 06:48:08 -06:00
Eric Normand
ea7c79e2c3 Whitespace 2026-04-01 06:48:08 -06:00
Eric Normand
135869854a Fix tests 2026-04-01 06:48:08 -06:00
Eric Normand
8293580261 Better tests and kondo appeasement 2026-04-01 06:48:08 -06:00
Eric Normand
872d1de89b Add releaseFlagsJSON 2026-04-01 06:48:08 -06:00
Eric Normand
82bd36db7e More tests 2026-04-01 06:48:08 -06:00
Eric Normand
0b9cbc15c4 Fix tests 2026-04-01 06:48:08 -06:00
Eric Normand
9b29bcfe27 Better order for joke-of-the-day 2026-04-01 06:48:07 -06:00
Eric Normand
a64e53c93b Fixes 2026-04-01 06:48:07 -06:00
Eric Normand
4fd42c9580 Add remarks 2026-04-01 06:48:07 -06:00
Eric Normand
503b174228 Change boolean 2026-04-01 06:48:07 -06:00
Eric Normand
2bec3cf42b Better migration 2026-04-01 06:48:07 -06:00
Eric Normand
ce2a0deb44 Whitespace 2026-04-01 06:48:07 -06:00
Eric Normand
85262dc373 Remove kondo errors 2026-04-01 06:48:07 -06:00
Eric Normand
d61e102bf1 Test the jokes without failing 2026-04-01 06:48:07 -06:00
Eric Normand
953f7eb568 Add test bypass guard fixture 2026-04-01 06:48:07 -06:00
Eric Normand
cd8294f54c Move release-flags.json to resources 2026-04-01 06:48:07 -06:00
Eric Normand
fe36f9d6ca Centralize schema and allow keywords 2026-04-01 06:48:07 -06:00
Ryan Laurie
c810b7e0b2 joke frontend 2026-04-01 06:48:07 -06:00
Ryan Laurie
4f28552eae release flag ui toggle 2026-04-01 06:48:07 -06:00
Eric Normand
068bcc4796 Check 404 2026-04-01 06:48:06 -06:00
Eric Normand
7c266cd942 Make it work! 2026-04-01 06:48:06 -06:00
Eric Normand
3a431c8740 Oops, forgot to require 2026-04-01 06:48:06 -06:00
Eric Normand
690c76d931 Jokes api 2026-04-01 06:48:06 -06:00
Eric Normand
607cdfaa6e Guard test 2026-04-01 06:48:06 -06:00
Eric Normand
54dad9a3a3 Doh! Use API endpoint correctly 2026-04-01 06:48:06 -06:00
Ryan Laurie
1418b1aecd hook it up 2026-04-01 06:48:06 -06:00
Eric Normand
9508a9537e Fix Wrong schema 2026-04-01 06:48:06 -06:00
Eric Normand
963e5996cf Fix json 2026-04-01 06:48:06 -06:00
Ryan Laurie
5e87520e72 add frontend flagging
# Conflicts:
#	frontend/src/metabase-types/api/index.ts
2026-04-01 06:48:06 -06:00
Eric Normand
8e3b098d3c Linter (backend) 2026-04-01 06:47:31 -06:00
Eric Normand
6ab33034fc Main entrypoint for release-flags (guard and check) 2026-04-01 06:47:30 -06:00
Eric Normand
12e98c4a87 Initial db, model, api 2026-04-01 06:47:30 -06:00
Ryan Laurie
8b450792c7 add flag file 2026-04-01 06:47:30 -06:00
48 changed files with 1122 additions and 51 deletions

View File

@@ -34,6 +34,10 @@
:use {:level :warning}
:warn-on-reflection {:level :warning}
;; release flag linters
:metabase/unknown-release-flag {:level :warning}
:metabase/non-literal-release-flag {:level :warning}
;;
;; other disabled-by-default linters it would be nice to enable soon
;;
@@ -318,8 +322,7 @@
metabase-enterprise.transforms.interface/execute! {:message "Use metabase-enterprise.transforms.execute/execute! instead of metabase-enterprise.transforms.interface/execute!"}
next.jdbc/active-tx? {:message "Use metabase.app-db.core/in-transaction? to see if we are in a transaction"}
toucan2.core/debug {:message "Don't commit code using t2/debug"}
toucan2.tools.debug/debug {:message "Don't commit code using t2/debug"}
}
toucan2.tools.debug/debug {:message "Don't commit code using t2/debug"}}
:discouraged-namespace
{camel-snake-kebab.core {:message "CSK is not Turkish-safe, use the versions in metabase.util instead."}
@@ -688,6 +691,11 @@
metabase.util.malli.registry/def hooks.metabase.util.malli.registry/def
metabase.util/case-enum hooks.metabase.util/case-enum
metabase.util/format-color hooks.metabase.util/format-color
metabase.release-flags.core/has-release-flag? hooks.metabase.release-flags.models/has-release-flag?
metabase.release-flags.core/guard-namespace! hooks.metabase.release-flags.models/guard-namespace!
metabase.release-flags.guard/bypass-guard-fixture hooks.metabase.release-flags.models/bypass-guard-fixture
metabase.release-flags.guard/guard-namespace! hooks.metabase.release-flags.models/guard-namespace!
metabase.release-flags.models/has-release-flag? hooks.metabase.release-flags.models/has-release-flag?
metabase.xrays.api.automagic-dashboards-test/with-indexed-model! hooks.metabase.xrays.api.automagic-dashboards-test/with-indexed-model!}
:macroexpand

View File

@@ -1009,6 +1009,13 @@
:model/QueryExecution
:model/User}}
joke-of-the-day
{:team "UX West"
:api #{metabase.joke-of-the-day.api}
:uses #{api
release-flags
util}}
legacy-mbql
{:team "Querying Platform"
:api #{metabase.legacy-mbql.normalize
@@ -1747,6 +1754,15 @@
:model/QueryExecution
:model/Table}}
release-flags
{:team "UX West"
:api #{metabase.release-flags.api
metabase.release-flags.core
metabase.release-flags.init}
:uses #{api
task
util}}
remote-sync
{:team "UX West"
:api #{metabase.remote-sync.core
@@ -1765,7 +1781,7 @@
api
config
embedding
permissions ; FIXME
permissions ; FIXME
session
settings
users
@@ -1890,6 +1906,7 @@
oauth-server
premium-features
query-processor
release-flags
request
session
settings
@@ -1905,7 +1922,7 @@
:api #{metabase.session.api
metabase.session.core
metabase.session.init
metabase.session.settings} ; TODO -- shouldn't be exposed as an API namespace
metabase.session.settings} ; TODO -- shouldn't be exposed as an API namespace
:uses #{api
app-db
auth-identity

View File

@@ -0,0 +1,6 @@
;; Source of truth for release flags.
;; Auto-loaded by clj-kondo via :config-paths, and read by metabase.release-flags.init at startup.
;; See docs/developers-guide/release-flags.md for usage.
{:metabase/release-flags
{"joke-of-the-day" {:description "Whether to show the joke of the day on the homepage."
:start-date "2026-02-24"}}}

View File

@@ -0,0 +1,62 @@
(ns hooks.metabase.release-flags.models
(:require
[clj-kondo.hooks-api :as api]
[clojure.string :as str]))
(defn- keyword->flag-name
"Converts a keyword to its full string representation, including namespace if present.
:foo => \"foo\", :ns/foo => \"ns/foo\""
[kw]
(if (namespace kw)
(str (namespace kw) "/" (name kw))
(name kw)))
(defn- check-flag-name
"Checks if a flag name is in the valid set and registers a finding if not."
[valid-flags flag-name flag-node]
(when (and (seq valid-flags) (not (contains? valid-flags flag-name)))
(api/reg-finding! (assoc (meta flag-node)
:message (str "Unknown release flag: " flag-name
". Valid flags: " (str/join ", " (sort valid-flags)))
:type :metabase/unknown-release-flag))))
(defn- check-flag-arg
"Shared lint check for a flag argument node. Warns if not a literal keyword/string
or if the flag is not in release-flags.json."
[valid-flags flag-node]
(cond
(api/keyword-node? flag-node)
(check-flag-name valid-flags (keyword->flag-name (api/sexpr flag-node)) flag-node)
(api/string-node? flag-node)
(check-flag-name valid-flags (api/sexpr flag-node) flag-node)
:else
(api/reg-finding! (assoc (meta flag-node)
:message "Release flag should be a literal keyword or string, not a variable."
:type :metabase/non-literal-release-flag))))
(defn- get-valid-flags
"Reads the valid release flag names from the kondo config.
Loaded from .clj-kondo/config/release-flags/config.edn via :config-paths."
[config]
(set (keys (get config :metabase/release-flags {}))))
(defn has-release-flag?
"Lint hook for metabase.release-flags.models/has-release-flag?."
[{:keys [node config]}]
(let [[_ flag-node] (:children node)]
(check-flag-arg (get-valid-flags config) flag-node)))
(defn guard-namespace!
"Lint hook for metabase.release-flags.guard/guard-namespace!."
[{:keys [node config]}]
(let [[_ flag-node] (:children node)]
(check-flag-arg (get-valid-flags config) flag-node)))
(defn bypass-guard-fixture
"Lint hook for metabase.release-flags.guard/bypass-guard-fixture."
[{:keys [node config]}]
(let [valid-flags (get-valid-flags config)]
(doseq [flag-node (rest (:children node))]
(check-flag-arg valid-flags flag-node))))

View File

@@ -52,4 +52,8 @@ When reviewing code, use these emoji prefixes to categorize feedback:
- For non-critical issues, link to the specific line from the summary instead
- Delete all stale comments and summaries on each new push or CI run
## Release flags
All code adding or using release flags should follow the best practices outlined in `docs/developers-guide/release-flags.md`.
Language-specific rules are in `.github/instructions/*.instructions.md`.

View File

@@ -93,6 +93,7 @@
"dayjs": "^1.11.18",
"detect-package-manager": "^3.0.2",
"diff": "^5.2.0",
"dotenv": "^17.3.1",
"echarts": "6.0.0",
"events": "^3.3.0",
"fast-text-encoding": "^1.0.6",
@@ -2792,7 +2793,7 @@
"dot-prop": ["dot-prop@5.3.0", "", { "dependencies": { "is-obj": "^2.0.0" } }, "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q=="],
"dotenv": ["dotenv@16.4.7", "", {}, "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ=="],
"dotenv": ["dotenv@17.3.1", "", {}, "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
@@ -5618,6 +5619,8 @@
"@redocly/cli/ajv": ["@redocly/ajv@8.17.4", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-BieiCML/IgP6x99HZByJSt7fJE4ipgzO7KAFss92Bs+PEI35BhY7vGIysFXLT+YmS7nHtQjZjhOQyPPEf7xGHA=="],
"@redocly/cli/dotenv": ["dotenv@16.4.7", "", {}, "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ=="],
"@redocly/cli/glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="],
"@redocly/cli/react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
@@ -6956,6 +6959,8 @@
"typedoc/yaml": ["yaml@2.8.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw=="],
"typescript-plugin-css-modules/dotenv": ["dotenv@16.4.7", "", {}, "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ=="],
"unbox-primitive/which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="],
"unified/@types/unist": ["@types/unist@3.0.2", "", {}, "sha512-dqId9J8K/vGi5Zr7oo212BGii5m3q5Hxlkwy3WpYuKPklmBEvsbMYYyLxAQpSffdLl/gdW0XUpKWFvYmyoWCoQ=="],

View File

@@ -0,0 +1,75 @@
# Release Flags
The purpose of Metabase release flags is to allow developers to progressively merge good, tested, but incomplete code to master and avoid large, long-running feature branches. Release flags should be temporary, and should be removed at the completion of a project. Release flags are not required. You can do a traditional feature branch approach with just a PR, or even better, merge small, independently and immediately useful code to master. But release flags are available as an option to avoid long-running feature branches when necessary.
### Lifecycle
1. Create a PR which adds a new, unique flag to `release-flags/config.edn` and update `metabase-types/api/release-flg.ts` to include the new key. Have it reviewed and merged.
2. Subsequent PRs for the feature should have all code behind the release flag. The code should be disabled at the highest possible level to reduce the number of places where the flag is invoked.
3. When you are ready to launch your feature, create a PR which removes the flag from `release-flags/config.edn` and `metabase-types/api/release-flg.ts`. This will trigger linting and TypeScript errors. Remove the conditionals using the flag. Have it reviewed and merged. You’re done!
## Protecting Code
Develop your code as normal. Require it from namespaces. This will load your code as normal. However, you should protect the call sites of the functions in your code.
Throughout development, all code that should be behind the release flag should be disabled at the highest possible level in the calling code. Good encapsulation principles should be used to minimize the number of needed release flag conditionals. Nesting release flags should be avoided wherever possible. Care should be taken that release flagging code should not diminish the maintainability or readability of code. During development, you can toggle release flags from the ui at `/release-flags` or via the API at `PUT /api/release-flags`.
💡 If you find yourself needing to use the same flag in lots of places, you may need to:
1. Reconsider whether a release flag is appropriate for this feature, or
2. Consider whether your feature code could be better encapsulated
### Clojure
The main way to protect code at a call site is with `metabase.release-flags.core/has-release-flag?`.
Example:
```clojure
(when (release-flags/has-release-flag? :my-new-feature)
...)
```
A new feature often requires new namespaces to be created to support the feature. That code will be committed to master much more quickly than with a feature branch approach. However, the code is not ready to be called normally. This creates a conflict: There is code in master that is not ready to be called. In order to prevent/detect code that incorrectly calls this code, a namespace should also add a single line to the end.
```clojure
(release-flags/guard-namespace! :my-new-feature)
```
This function will instrument the functions in the namespace with code that tacks whether the release flag was enabled. If the code was called but the release flag wasn’t enabled, it will fail a test, preventing it from merging to master. This line should be removed when the feature is released.
A clj-kondo rule will check that `has-release-flag?` and `guard-namespace!` is only called with flags listed in `release-flags/config.edn`.
When you are writing new tests for your feature, you don't want them to fail. You can tell the release flag system to allow calls to your functions by adding this line to the top of the test namespace:
```clojure
(use-fixtures :once (release-flags/bypass-guard-fixture <my-release-flag>))
```
API endpoints that you want to protect behind a release flag should use `check-404` to fail requests as if the route were not even there. Here's an example:
```clojure
(api.macros/defendpoint :get "/" :- [:map
["id" :int]
["type" :string]
["setup" :string]
["punchline" :string]]
"Return the joke of the day."
[]
(api/check-404 (flags/has-release-flag? :joke-of-the-day)) ;; this line at the top of the endpoint
(rand-nth (jokes/jokes)))
```
### TypeScript
TypeScript code is toggled with `hasReleaseFlag("flag-name")`.
```ts
import { hasReleaseFlag } from "metabase/lib/release-flags";
...
if (hasReleaseFlag("cool-feature")) {
return <CoolFeature />;
}
```

View File

@@ -0,0 +1,9 @@
import type { ReleaseFlag } from "metabase-types/api";
export const setReleaseFlag = (flag: ReleaseFlag, value: boolean) =>
cy.request({
method: "PUT",
url: "/api/release-flags",
body: { [flag]: value },
failOnStatusCode: false,
});

View File

@@ -45,6 +45,7 @@ export * from "./e2e-permissions-helpers";
export * from "./e2e-python-helpers";
export * from "./e2e-qa-databases-helpers";
export * from "./e2e-relative-date-picker-helpers";
export * from "./e2e-release-flag-helpers";
export * from "./e2e-remote-sync-helpers";
export * from "./e2e-request-helpers";
export * from "./e2e-search-helpers";

View File

@@ -0,0 +1,36 @@
const { H } = cy;
describe("Admin - Release Flags", () => {
beforeEach(() => {
H.restore();
cy.signInAsAdmin();
cy.intercept("GET", "/api/session/properties").as("getSessionProperties");
});
it("should allow toggling a release flag", () => {
cy.visit("/");
cy.wait("@getSessionProperties");
cy.findByTestId("home-page").should("be.visible");
cy.findByTestId("joke-of-the-day").should("not.exist");
cy.request({
method: "GET",
url: "/api/joke-of-the-day",
failOnStatusCode: false,
}).then(({ status }) => {
expect(status).to.equal(404);
});
H.setReleaseFlag("joke-of-the-day", true);
cy.visit("/");
cy.wait("@getSessionProperties");
cy.findByTestId("home-page").should("be.visible");
cy.findByTestId("joke-of-the-day").should("be.visible");
cy.request({
method: "GET",
url: "/api/joke-of-the-day",
failOnStatusCode: false,
}).then(({ status }) => {
expect(status).to.equal(200);
});
});
});

View File

@@ -113,6 +113,7 @@
"QueryField"
"QueryTable"
"RecentViews"
"ReleaseFlag"
"ReplacementRun"
"RemoteSyncObject"
"RemoteSyncTask"

View File

@@ -116,6 +116,7 @@
:model/CloudMigration
:model/Comment
:model/CommentReaction
:model/ReleaseFlag
;; TODO (lbrdnk 2025-12-17) -- I've added rest of the workspace models here as Workspace was present. I believe
;; going forward all of that will be available for export. We should revisit this later in the project.
:model/Workspace

View File

@@ -0,0 +1,14 @@
import { Api } from "./api";
export const jokeApi = Api.injectEndpoints({
endpoints: (builder) => ({
getJoke: builder.query<{ setup: string; punchline: string }, void>({
query: () => ({
method: "GET",
url: "/api/joke-of-the-day",
}),
}),
}),
});
export const { useGetJokeQuery } = jokeApi;

View File

@@ -4,7 +4,9 @@ import { t } from "ttag";
import { LoadingAndErrorWrapper } from "metabase/common/components/LoadingAndErrorWrapper";
import { useHomepageDashboard } from "metabase/common/hooks/use-homepage-dashboard";
import { JokeOfTheDay } from "metabase/jokes";
import { useDispatch, useSelector } from "metabase/lib/redux";
import { hasReleaseFlag } from "metabase/lib/release-flags";
import { updateUserSetting } from "metabase/redux/settings";
import { addUndo } from "metabase/redux/undo";
import { getHasDismissedCustomHomePageToast } from "metabase/selectors/app";
@@ -14,12 +16,14 @@ import { HomeLayout } from "../HomeLayout";
export const HomePage = (): JSX.Element => {
const { isLoadingDash } = useDashboardRedirect();
if (isLoadingDash) {
return <LoadingAndErrorWrapper loading={isLoadingDash} />;
}
return (
<HomeLayout>
{hasReleaseFlag("joke-of-the-day") && <JokeOfTheDay />}
<HomeContent />
</HomeLayout>
);

View File

@@ -0,0 +1,27 @@
import { useGetJokeQuery } from "metabase/api/joke";
import { Box, Card, Flex, Text, Title } from "metabase/ui";
export function JokeOfTheDay() {
const { data: joke, isLoading } = useGetJokeQuery();
if (!joke || isLoading) {
return null;
}
return (
<Card
p="lg"
my="lg"
bd="1px solid var(--mb-color-border)"
data-testid="joke-of-the-day"
>
<Flex align="center" gap="lg">
<Title order={1}>😂</Title>
<Box>
<Text fw="bold">{joke.setup}</Text>
<Text>{joke.punchline}</Text>
</Box>
</Flex>
</Card>
);
}

View File

@@ -0,0 +1 @@
export * from "./JokeOfTheDay";

View File

@@ -0,0 +1,13 @@
export type ReleaseFlag = "joke-of-the-day";
export type ReleaseFlagMap = Record<ReleaseFlag, boolean>;
const MetabaseReleaseFlags: ReleaseFlagMap =
(process.env.RELEASE_FLAGS as unknown as ReleaseFlagMap) ?? {};
export const hasReleaseFlag = (flag: ReleaseFlag): boolean => {
return MetabaseReleaseFlags?.[flag];
};
// hang this on the window so we can call it in clojureScript
window.hasReleaseFlag = hasReleaseFlag;

View File

@@ -1,35 +1,38 @@
interface Window {
MetabaseBootstrap: any;
MetabaseRoot?: string;
MetabaseNonce?: string;
MetabaseUserColorScheme?: string;
import type { ReleaseFlag } from "metabase/lib/release-flags";
declare global {
interface Window {
MetabaseBootstrap: any;
hasReleaseFlag?: (flag: ReleaseFlag) => boolean;
MetabaseRoot?: string;
MetabaseNonce?: string;
MetabaseUserColorScheme?: string;
overrideIsWithinIframe?: boolean; // Mock that we're embedding, so we could test embed components
METABASE?: boolean; // Add a global so we can check if the parent iframe is Metabase
overrideIsWithinIframe?: boolean; // Mock that we're embedding, so we could test embed components
METABASE?: boolean; // Add a global so we can check if the parent iframe is Metabase
// Make iFrameResizer available so that embed users can
// have their embeds autosize to their content
iFrameResizer?: {
autoResize?: boolean;
heightCalculationMethod?: string;
onReady?: () => void;
};
// Make iFrameResizer available so that embed users can
// have their embeds autosize to their content
iFrameResizer?: {
autoResize?: boolean;
heightCalculationMethod?: string;
onReady?: () => void;
};
}
type Nullable<T> = T | null;
// This allows importing static SVGs from TypeScript files
declare module "*.svg" {
const content: any;
// eslint-disable-next-line import/no-default-export -- deprecated usage
export default content;
}
// This allows importing CSS from TypeScript files
declare module "*.css" {
const classes: { [key: string]: string };
// eslint-disable-next-line import/no-default-export -- deprecated usage
export default classes;
}
}
// This allows importing static SVGs from TypeScript files
declare module "*.svg" {
const content: any;
// eslint-disable-next-line import/no-default-export -- deprecated usage
export default content;
}
// This allows importing CSS from TypeScript files
declare module "*.css" {
const classes: { [key: string]: string };
// eslint-disable-next-line import/no-default-export -- deprecated usage
export default classes;
}
declare module "iframe-resizer/js/iframeResizer.contentWindow.js";
type Nullable<T> = T | null;
export {};

View File

@@ -0,0 +1 @@
declare module "iframe-resizer/js/iframeResizer.contentWindow.js";

View File

@@ -98,6 +98,7 @@
"dayjs": "^1.11.18",
"detect-package-manager": "^3.0.2",
"diff": "^5.2.0",
"dotenv": "^17.3.1",
"echarts": "6.0.0",
"events": "^3.3.0",
"fast-text-encoding": "^1.0.6",
@@ -441,7 +442,7 @@
"build-hot": "rm -f target/cljs_dev/cljs.core.js && bun run build:cljs && bun run concurrently -n 'js' -c 'green,cyan' 'bun run build-hot:js'",
"build-hot-be": "rm -f target/cljs_dev/cljs.core.js && bun run concurrently -n 'cljs,js' -c 'yellow,green,cyan' 'bun run build-hot:cljs' 'ENABLE_CLJS_HOT_RELOAD=true bun run build-hot:js-wait'",
"build-hot:cljs": "bun install && shadow-cljs watch app",
"build-hot:js": "bun install && bun run clean-dev:js && NODE_OPTIONS=--max-old-space-size=8192 WEBPACK_BUNDLE=hot rspack serve",
"build-hot:js": "bun install && bun run clean-dev:js && NODE_OPTIONS='--max-old-space-size=8192' WEBPACK_BUNDLE=hot rspack serve",
"build-hot:js-wait": "bun run wait:cljs; bun run build-hot:js",
"build-pure:cljs": "bun run clean-dev:cljs && shadow-cljs compile app",
"build-release": "bun run build-release:cljs && bun run build-release:js",

View File

@@ -27,6 +27,10 @@
{{{bootstrapJSON}}}
</script>
<script type="application/json" id="_metabaseReleaseFlags">
{{{releaseFlagsJSON}}}
</script>
<script type="application/json" id="_metabaseUserLocalization">
{{{userLocalizationJSON}}}
</script>

View File

@@ -1,5 +1,6 @@
(function() {
window.MetabaseBootstrap = JSON.parse(document.getElementById("_metabaseBootstrap").textContent);
window.MetabaseReleaseFlags = JSON.parse(document.getElementById("_metabaseReleaseFlags").textContent);
window.MetabaseUserLocalization = JSON.parse(document.getElementById("_metabaseUserLocalization").textContent);
window.MetabaseSiteLocalization = JSON.parse(document.getElementById("_metabaseSiteLocalization").textContent);
window.MetabaseUserColorScheme = JSON.parse(document.getElementById("_metabaseUserColorScheme").textContent);

62
resources/jokes.json Normal file
View File

@@ -0,0 +1,62 @@
[
{
"id": 1,
"type": "general",
"setup": "What did the fish say when it hit the wall?",
"punchline": "Dam."
},
{
"id": 2,
"type": "general",
"setup": "How do you make a tissue dance?",
"punchline": "You put a little boogie on it."
},
{
"id": 3,
"type": "general",
"setup": "What's Forrest Gump's password?",
"punchline": "1Forrest1"
},
{
"id": 4,
"type": "general",
"setup": "What do you call a belt made out of watches?",
"punchline": "A waist of time."
},
{
"id": 5,
"type": "general",
"setup": "Why can't bicycles stand on their own?",
"punchline": "They are two tired"
},
{
"id": 6,
"type": "general",
"setup": "How does a train eat?",
"punchline": "It goes chew, chew"
},
{
"id": 7,
"type": "general",
"setup": "What do you call a singing Laptop",
"punchline": "A Dell"
},
{
"id": 8,
"type": "general",
"setup": "How many lips does a flower have?",
"punchline": "Tulips"
},
{
"id": 9,
"type": "general",
"setup": "How do you organize an outer space party?",
"punchline": "You planet"
},
{
"id": 10,
"type": "general",
"setup": "What kind of shoes does a thief wear?",
"punchline": "Sneakers"
}
]

View File

@@ -0,0 +1,36 @@
databaseChangeLog:
- objectQuotingStrategy: QUOTE_ALL_OBJECTS
- changeSet:
id: v60.2026-02-24T00:00:00
author: ericnormand
comment: Create release_flag table
changes:
- createTable:
tableName: release_flag
remarks: Stores release flags for feature rollout
columns:
- column:
name: flag
type: varchar(254)
remarks: Unique name identifying the release flag
constraints:
primaryKey: true
nullable: false
- column:
name: description
type: ${text.type}
remarks: Human-readable description of what this flag controls
- column:
name: is_enabled
type: ${boolean.type}
defaultValueBoolean: false
remarks: Whether this release flag is currently enabled
constraints:
nullable: false
- column:
name: start_date
type: date
remarks: Date when this release flag was introduced
constraints:
nullable: false

View File

@@ -0,0 +1,29 @@
databaseChangeLog:
- objectQuotingStrategy: QUOTE_ALL_OBJECTS
- changeSet:
id: vRF.2026-04-01T00:00:00
author: iethree
context: rf-joke-of-the-day
comment: Create jokes table
changes:
- createTable:
tableName: jokes
remarks: stores jokes for the joke of the day feature
columns:
- column:
name: id
remarks: PK
type: int
autoIncrement: true
constraints:
primaryKey: true
nullable: false
- column:
name: setup
type: ${text.type}
remarks: setup for the joke
- column:
name: punchline
type: ${text.type}
remarks: punchline for the joke

View File

@@ -1,6 +1,8 @@
// @ts-check
/* eslint-env node */
/* eslint-disable depend/ban-dependencies -- it is prohibitively difficult to load the env file for all scripts that call rspack */
require("dotenv").config();
const fs = require("fs");
const rspack = require("@rspack/core");
@@ -94,6 +96,17 @@ class OnScriptError {
}
}
/** @type {Record<string, boolean>} */
const initialFlags = {};
const ReleaseFlags = Object.entries(process.env)
.filter(([key]) => key.startsWith("RF_"))
.reduce((flags /** @type Record<string, boolean> **/, [key, value]) => {
const flagName = key.slice(3).toLowerCase().replace(/_/g, "-");
flags[flagName] = value === "true";
return flags;
}, initialFlags);
/** @type {import('@rspack/cli').Configuration} */
const config = {
mode: isDevMode ? "development" : "production",
@@ -297,6 +310,9 @@ const config = {
MB_LOG_ANALYTICS: "false",
ENABLE_CLJS_HOT_RELOAD: process.env.ENABLE_CLJS_HOT_RELOAD ?? "false",
}),
new rspack.DefinePlugin({
"process.env.RELEASE_FLAGS": JSON.stringify(ReleaseFlags),
}),
],
};

View File

@@ -29,6 +29,7 @@
[metabase.geojson.api]
[metabase.glossary.api]
[metabase.indexed-entities.api]
[metabase.joke-of-the-day.api]
[metabase.llm.api]
[metabase.logger.api]
[metabase.login-history.api]
@@ -47,6 +48,7 @@
[metabase.pulse.api]
[metabase.queries-rest.api]
[metabase.query-processor.api]
[metabase.release-flags.api]
[metabase.revisions.api]
[metabase.search.api]
[metabase.segments.api]
@@ -184,6 +186,7 @@
"/geojson" 'metabase.geojson.api
"/glossary" (+auth 'metabase.glossary.api)
"/google" (+auth metabase.sso.api/google-auth-routes)
"/joke-of-the-day" 'metabase.joke-of-the-day.api
"/ldap" (+auth metabase.sso.api/ldap-routes)
"/llm" (+auth metabase.llm.api/routes)
"/logger" (+auth 'metabase.logger.api)
@@ -203,6 +206,7 @@
"/product-feedback" 'metabase.product-feedback.api
"/public" (+public-exceptions metabase.public-sharing-rest.api/routes)
"/pulse" metabase.pulse.api/pulse-routes
"/release-flags" (+auth 'metabase.release-flags.api)
"/revision" (+auth 'metabase.revisions.api)
"/search" (+auth metabase.search.api/routes)
"/segment" (+auth 'metabase.segments.api)

View File

@@ -104,7 +104,9 @@
(not (str/starts-with? latest-migration "v"))
changelog-legacy-file
(< (->> latest-migration (re-find #"v(\d+)\..*") second parse-long) 45)
;; If the ID starts with "v" but doesn't match the versioned pattern (e.g., a release-flag
;; migration ID), some-> short-circuits to nil (falsy) and we fall through to :else.
(some-> (re-find #"v(\d+)\..*" latest-migration) second parse-long (< 45))
changelog-legacy-file
:else
@@ -191,16 +193,20 @@
(.toString writer)))
(defn unrun-migrations
"Returns a list of unrun migrations.
"Returns a list of unrun migrations for the given `contexts` (default: all non-RF migrations).
It's a good idea to check to make sure there's actually something to do before running `(migrate :up)` so we can
skip creating and releasing migration locks, which is both slightly dangerous and a waste of time when we won't be
using them.
IMPORTANT: this function takes `data-source` but not `liquibase` because `.listUnrunChangeSets` is buggy. See #38257."
[^DataSource data-source]
(with-liquibase [liquibase data-source]
(.listUnrunChangeSets liquibase nil (LabelExpression.))))
([^DataSource data-source]
;; Contexts("default") causes Liquibase to run changesets with *no* context (all normal migrations)
;; but skip changesets that have an explicit context (i.e. rf-* release-flag changesets).
(unrun-migrations data-source (Contexts. "default")))
([^DataSource data-source ^Contexts contexts]
(with-liquibase [liquibase data-source]
(.listUnrunChangeSets liquibase contexts (LabelExpression.)))))
(defn- migration-lock-exists?
"Is a migration lock in place for `liquibase`?"
@@ -373,8 +379,8 @@
(let [to-run-migrations (unrun-migrations data-source)
unrun-migrations-count (count to-run-migrations)]
(if (pos? unrun-migrations-count)
(let [^Contexts contexts nil
timer (u/start-timer)]
(let [contexts (Contexts. "default")
timer (u/start-timer)]
(log/infof "Running %s migrations ..." unrun-migrations-count)
(doseq [^ChangeSet change to-run-migrations]
(log/tracef "To run migration %s" (.getId change)))
@@ -388,14 +394,15 @@
([liquibase]
(update-with-change-log liquibase {}))
([^Liquibase liquibase
{:keys [^List change-set-filters exec-listener]
:or {change-set-filters []}}]
{:keys [^List change-set-filters exec-listener ^Contexts contexts]
:or {change-set-filters []
contexts (Contexts. "default")}}]
(assert-locked liquibase)
(let [change-log (.getDatabaseChangeLog liquibase)
database (.getDatabase liquibase)
log-iterator (ChangeLogIterator. change-log ^"[Lliquibase.changelog.filter.ChangeSetFilter;" (into-array ChangeSetFilter change-set-filters))
update-visitor (UpdateVisitor. database ^ChangeExecListener exec-listener)
runtime-env (RuntimeEnvironment. database (Contexts.) nil)]
runtime-env (RuntimeEnvironment. database contexts nil)]
(.run ^ChangeLogIterator log-iterator update-visitor runtime-env))))
(mu/defn force-migrate-up-if-needed!
@@ -447,6 +454,44 @@
(doseq [[^ChangeSet change-set fail-on-error?] fail-on-errors]
(.setFailOnError change-set fail-on-error?))))))))
;;; ---- Release flag migrations ----
(defn- enabled-rf-contexts
"Returns a seq of Liquibase context name strings for each enabled RF_ environment variable.
Only reads from the OS environment — not JVM system properties or app-defaults — so migrations
cannot be accidentally enabled via other config mechanisms.
RF_JOKE_OF_THE_DAY=true -> context \"rf-joke-of-the-day\""
[]
(seq (for [[k v] (System/getenv)
:when (and (str/starts-with? k "RF_")
(= "true" (str/lower-case (str/trim v))))]
(-> k (subs 3) str/lower-case (str/replace "_" "-") (->> (str "rf-"))))))
(defn run-release-flag-migrations-if-needed!
"Run any unrun release-flag migrations for each enabled RF_ environment variable.
Release-flag changesets live in 'migrations/release-flags/' alongside normal migrations and are
protected by a Liquibase context matching their flag name. Normal updates pass Contexts(\"default\")
which skips any changeset with an explicit context, so RF changesets never run accidentally.
Naming convention: a changeset must declare 'context: rf-<flag-name>' where <flag-name> is the
part of the env var after 'RF_', lowercased with underscores replaced by dashes.
Example: RF_JOKE_OF_THE_DAY=true runs all changesets with 'context: rf-joke-of-the-day'."
[^Liquibase liquibase ^DataSource data-source]
(doseq [ctx (enabled-rf-contexts)]
(let [contexts (Contexts. ctx)]
;; Use a fresh Liquibase instance for the pre-lock check to avoid the .listUnrunChangeSets
;; caching bug. See #38257. .update is idempotent so there is no correctness risk here.
(when (seq (unrun-migrations data-source contexts))
(log/infof "Running release-flag migrations for context: %s" ctx)
(with-scope-locked liquibase
(.update liquibase contexts)
(log/infof "Completed release-flag migrations for %s" ctx))))))
;;; ---- Legacy / consolidated migrations ----
(def ^:private legacy-migrations-file "migrations/000_legacy_migrations.yaml")
(def ^:private update001-migrations-file "migrations/001_update_migrations.yaml")

View File

@@ -69,7 +69,11 @@
* `:print` - Just print the SQL for running the migrations, don't actually run them.
* `:release-locks` - Manually release migration locks left by an earlier failed migration.
(This shouldn't be necessary now that we run migrations inside a transaction, but is
available just in case)."
available just in case).
After running standard migrations, also runs any pending release-flag migrations from
'migrations/release-flags/' for each enabled RF_ environment variable.
See [[liquibase/run-release-flag-migrations-if-needed!]] for details."
[data-source :- (ms/InstanceOfClass javax.sql.DataSource)
direction :- :keyword
& args]
@@ -89,8 +93,10 @@
(log/info "Liquibase is ready.")
(case direction
:up (liquibase/migrate-up-if-needed! liquibase data-source)
:force (liquibase/force-migrate-up-if-needed! liquibase data-source)
:up (do (liquibase/migrate-up-if-needed! liquibase data-source)
(liquibase/run-release-flag-migrations-if-needed! liquibase data-source))
:force (do (liquibase/force-migrate-up-if-needed! liquibase data-source)
(liquibase/run-release-flag-migrations-if-needed! liquibase data-source))
:down (apply liquibase/rollback-major-version! conn liquibase false args)
:down-force (apply liquibase/rollback-major-version! conn liquibase true args)
:print (print-migrations-and-quit-if-needed! liquibase data-source)

View File

@@ -50,6 +50,7 @@
[metabase.pulse.init]
[metabase.queries.init]
[metabase.query-processor.init]
[metabase.release-flags.init]
[metabase.remote-sync.init]
[metabase.request.init]
[metabase.revisions.init]

View File

@@ -0,0 +1,17 @@
(ns metabase.joke-of-the-day.api
"/api/joke-of-the-day endpoint."
(:require
[metabase.api.common :as api]
[metabase.api.macros :as api.macros]
[metabase.joke-of-the-day.jokes :as jokes]
[metabase.release-flags.core :as flags]))
(api.macros/defendpoint :get "/" :- [:map
["id" :int]
["type" :string]
["setup" :string]
["punchline" :string]]
"Return the joke of the day."
[]
(api/check-404 (flags/has-release-flag? :joke-of-the-day))
(rand-nth (jokes/jokes)))

View File

@@ -0,0 +1,15 @@
(ns metabase.joke-of-the-day.jokes
(:require
[clojure.java.io :as io]
[metabase.release-flags.core :as release-flags]
[metabase.util.json :as json]))
(defn jokes
"Returns the collection of jokes from jokes.json."
[]
(json/decode (slurp (io/resource "jokes.json"))))
;; guard-namespace! must be called at the end, after all functions are defined.
;; Add functions above this line.
(release-flags/guard-namespace! "joke-of-the-day")

View File

@@ -90,6 +90,7 @@
:model/QueryField metabase.queries.models.query-field
:model/QueryTable metabase.queries.models.query-table
:model/RecentViews metabase.activity-feed.models.recent-views
:model/ReleaseFlag metabase.release-flags.models
:model/RemoteSyncObject metabase-enterprise.remote-sync.models.remote-sync-object
:model/RemoteSyncTask metabase-enterprise.remote-sync.models.remote-sync-task
:model/ReplacementRun metabase-enterprise.replacement.models.replacement-run

View File

@@ -0,0 +1,25 @@
(ns metabase.release-flags.api
"/api/release-flags endpoints."
(:require
[metabase.api.common :as api]
[metabase.api.macros :as api.macros]
[metabase.release-flags.models :as models]
[metabase.release-flags.schema :as schema]))
(api.macros/defendpoint :get "/" :- schema/FlagMap
"Return the status of all feature flags.
Returns an empty map in prod mode."
[]
(if (models/release-flags-enabled?)
(models/all-flags)
{}))
(api.macros/defendpoint :put "/" :- schema/FlagMap
"Modify the release flags."
[_route-params
_query-params
flags :- schema/StatusMap]
(api/check-404 (models/release-flags-enabled?))
(api/check-superuser)
(models/update-statuses! flags)
(models/all-flags))

View File

@@ -0,0 +1,14 @@
(ns metabase.release-flags.core
(:require
[metabase.release-flags.guard]
[metabase.release-flags.models]
[potemkin :as p]))
(p/import-vars
[metabase.release-flags.guard
bypass-guard-fixture
guard-namespace!]
[metabase.release-flags.models
all-flags
has-release-flag?
release-flags-enabled?])

View File

@@ -0,0 +1,90 @@
(ns metabase.release-flags.guard
(:require
[metabase.release-flags.models :as models]
[metabase.util.log :as log]))
(set! *warn-on-reflection* true)
(def ^:dynamic *blocked-calls*
"When bound to an atom, guarded function calls that are blocked by a disabled
release flag will be recorded here. Each entry is a map with :flag, :fn,
and :call-site. Bind to (atom []) in test fixtures to capture blocked calls."
nil)
(def ^:dynamic *bypass-guard*
"When bound to a set of flag name strings, guarded functions for those flags execute
without checking the release flag. Use [[bypass-guard-fixture]] in tests."
#{})
(defn bypass-guard-fixture
"Returns a clojure.test fixture that bypasses the given release flag guards.
Usage: (use-fixtures :once (guard/bypass-guard-fixture :joke-of-the-day :other-flag))"
[& flags]
(let [flag-strs (into #{}
(map #(if (keyword? %)
(if (namespace %)
(str (namespace %) "/" (name %))
(name %))
(str %)))
flags)]
(fn [thunk]
(binding [*bypass-guard* (into *bypass-guard* flag-strs)]
(thunk)))))
(defn- call-site
"Returns the call site of the guarded function as a string, e.g. \"my.ns (my_ns.clj:42)\".
Walks the stack trace to find the first frame outside the guard namespace."
[]
(let [guard-class-prefix "metabase.release_flags.guard"]
(->> (.getStackTrace (Thread/currentThread))
(drop-while #(or (.startsWith (.getClassName ^StackTraceElement %) guard-class-prefix)
(.startsWith (.getClassName ^StackTraceElement %) "clojure.lang")))
first
((fn [^StackTraceElement frame]
(when frame
(format "%s (%s:%d)"
(.getClassName frame)
(.getFileName frame)
(.getLineNumber frame))))))))
(defn guard-namespace!
"Protects all public functions in a namespace behind a release flag.
Call this at the end of a namespace. When a guarded function is called:
- If the flag is enabled, the original function runs normally.
- If the flag is not enabled (or does not exist), the call is recorded in
[[blocked-calls]], a warning is logged, and nil is returned.
If `target-ns` is not provided, defaults to `*ns*`."
([flag]
#_{:clj-kondo/ignore [:metabase/non-literal-release-flag]}
(guard-namespace! flag *ns*))
([flag target-ns]
(let [flag-name (if (keyword? flag)
(if (namespace flag)
(str (namespace flag) "/" (name flag))
(name flag))
(str flag))
ns-name (ns-name target-ns)]
(doseq [[sym v] (ns-interns target-ns)
:when (and (var? v) (fn? @v))]
(let [original-fn @v
qualified (str ns-name "/" sym)]
(alter-var-root
v
(constantly
(fn [& args]
(if (or (contains? *bypass-guard* flag-name)
(try
#_{:clj-kondo/ignore [:metabase/non-literal-release-flag]}
(models/has-release-flag? flag-name)
(catch Exception _
false)))
(apply original-fn args)
(let [site (call-site)
entry {:flag flag-name
:fn qualified
:call-site site}]
(when *blocked-calls*
(swap! *blocked-calls* conj entry))
(log/warnf "Release flag \"%s\" is not enabled. Skipping call to %s (called from %s)"
flag-name qualified site)
nil))))))))))

View File

@@ -0,0 +1,45 @@
(ns metabase.release-flags.init
(:require
[clojure.edn :as edn]
[clojure.set :as set]
[metabase.release-flags.models :as models]
[metabase.task.core :as task]
[metabase.util.log :as log])
(:import
(java.time LocalDate)))
(set! *warn-on-reflection* true)
(defn- read-flags-file
"Reads the release flags EDN config and returns the flag map."
[]
(try
(:metabase/release-flags
(edn/read-string (slurp ".clj-kondo/config/release-flags/config.edn")))
(catch Exception e
(log/warn e "Could not read .clj-kondo/config/release-flags/config.edn")
{})))
(defn sync-flags-from-file!
"Syncs the release_flag table with the release flags EDN config.
Inserts any flags from the file that are missing from the table.
Deletes any flags from the table that are not in the file.
Existing flags are updated with the description and start_date from the file."
[]
(let [file-flags (read-flags-file)
file-keys (set (keys file-flags))
db-keys (set (map name (keys (models/all-flags))))]
;; Delete flags not in the file
(let [to-delete (set/difference db-keys file-keys)]
(when (seq to-delete)
(log/info "Deleting release flags not in file:" to-delete)
(models/delete-flags! to-delete)))
;; Upsert flags from the file
(doseq [[flag-name data] file-flags]
(models/upsert-flag! flag-name
(:description data)
(LocalDate/parse (:start-date data))))))
(defmethod task/init! ::ReleaseFlagSync [_]
(log/info "Syncing release flags from EDN config")
(sync-flags-from-file!))

View File

@@ -0,0 +1,90 @@
(ns metabase.release-flags.models
(:require
[clojure.string :as str]
[environ.core :as env]
[metabase.release-flags.schema :as schema]
[metabase.util.malli :as mu]
[methodical.core :as methodical]
[toucan2.core :as t2]))
(set! *warn-on-reflection* true)
(methodical/defmethod t2/table-name :model/ReleaseFlag [_model] :release_flag)
(methodical/defmethod t2/primary-keys :model/ReleaseFlag [_model] [:flag])
(doto :model/ReleaseFlag
(derive :metabase/model))
(mu/defn all-flags :- schema/FlagMap
"Returns a map of flag name to its data (description, start_date, is_enabled)."
[]
(into {}
(map (fn [{:keys [flag] :as row}]
[flag (dissoc row :flag)]))
(t2/select :model/ReleaseFlag)))
(mu/defn update-statuses!
"Takes a map of flag name to enabled status and updates each flag's is_enabled accordingly."
[statuses :- schema/StatusMap]
(doseq [[flag enabled?] statuses]
(let [flag (if (keyword? flag)
(if (namespace flag)
(str (namespace flag) "/" (name flag))
(name flag))
flag)]
(t2/update! :model/ReleaseFlag :flag flag {:is_enabled enabled?}))))
(mu/defn delete-flags! :- :int
"Deletes the given set of flags from the release_flag table. Returns the number deleted."
[flag-names :- [:set :string]]
(if (seq flag-names)
(t2/delete! :model/ReleaseFlag :flag [:in flag-names])
0))
(mu/defn upsert-flag!
"Inserts or updates a release flag with the given name, description, and start_date."
[flag-name :- :string
description :- [:maybe :string]
start-date :- :any]
(t2/with-transaction [_conn]
(if (t2/exists? :model/ReleaseFlag :flag flag-name)
(t2/update! :model/ReleaseFlag :flag flag-name
{:description description
:start_date start-date})
(t2/insert! :model/ReleaseFlag {:flag flag-name
:description description
:start_date start-date}))))
(defn- env-str
"Looks up a config key from environ (env vars, JVM properties, .lein-env).
Returns the string value or nil."
[k]
(let [v (get env/env k)]
(when-not (str/blank? v)
(str/trim v))))
(mu/defn release-flags-enabled? :- :boolean
"Returns true if the release flags system is active.
This is the case in dev/test mode or when the MB_ENABLE_RELEASE_FLAGS env var is truthy."
[]
(boolean
(or (not= "prod" (or (env-str :mb-run-mode) "prod"))
(let [enable-str (env-str :mb-enable-release-flags)]
(and enable-str
(not= "false" enable-str)
(not= "0" enable-str))))))
(mu/defn has-release-flag? :- :boolean
"Is this release flag enabled?
If the release flag does not exist, always returns false.
If running in prod mode, always returns false."
[flag :- schema/FlagName]
(boolean
(when (release-flags-enabled?)
(let [flag (if (keyword? flag)
(if (namespace flag)
(str (namespace flag) "/" (name flag))
(name flag))
flag)]
(t2/select-one-fn :is_enabled :model/ReleaseFlag :flag flag)))))

View File

@@ -0,0 +1,21 @@
(ns metabase.release-flags.schema
"Malli schemas for the release-flags module.")
(def FlagName
"A release flag name, either a keyword or a string."
[:or :keyword :string])
(def FlagData
"The data for a single release flag."
[:map
[:description [:maybe :string]]
[:start_date :any]
[:is_enabled :boolean]])
(def FlagMap
"A map of flag name strings to their data."
[:map-of FlagName FlagData])
(def StatusMap
"A map of flag names to boolean enabled status, used for updates."
[:map-of FlagName :boolean])

View File

@@ -9,6 +9,7 @@
[metabase.appearance.core :as appearance]
[metabase.config.core :as config]
[metabase.initialization-status.core :as init-status]
[metabase.release-flags.core :as release-flags]
[metabase.settings.core :as setting]
[metabase.system.core :as system]
[metabase.users.settings :as users-settings]
@@ -82,10 +83,14 @@
(defn- template-parameters
[embeddable? {:keys [uri params nonce]}]
(let [{:keys [anon-tracking-enabled google-auth-client-id], :as public-settings} (setting/user-readable-values-map #{:public})
all-release-flags (if (release-flags/release-flags-enabled?)
(release-flags/all-flags)
{})
;; We disable `locale` parameter on static embeds/public links (metabase#50313)
should-load-locale-params? (not embeddable?)]
{:bootstrapJS (load-inline-js "index_bootstrap")
:bootstrapJSON (escape-script (json/encode public-settings))
:releaseFlagsJSON (escape-script (json/encode all-release-flags))
:assetOnErrorJS (load-inline-js "asset_loading_error")
:userLocalizationJSON (escape-script (load-localization (when should-load-locale-params? (:locale params))))
:siteLocalizationJSON (escape-script (load-localization (system/site-locale)))

View File

@@ -42,6 +42,7 @@
:model/QueryExecution
:model/QueryField
:model/QueryTable
:model/ReleaseFlag
:model/RemoteSyncObject
:model/RemoteSyncTask
:model/ReplacementRun

View File

@@ -0,0 +1,19 @@
(ns metabase.joke-of-the-day.jokes-test
(:require
[clojure.test :refer :all]
[metabase.joke-of-the-day.jokes :as jokes]
[metabase.release-flags.guard :as guard]))
(use-fixtures :once (guard/bypass-guard-fixture :joke-of-the-day))
(deftest jokes-test
(testing "jokes returns a non-empty collection"
(let [result (jokes/jokes)]
(is (seq result))
(is (sequential? result))))
(testing "each joke has the expected keys"
(doseq [joke (jokes/jokes)]
(is (contains? joke "id"))
(is (contains? joke "type"))
(is (contains? joke "setup"))
(is (contains? joke "punchline")))))

View File

@@ -0,0 +1,37 @@
(ns metabase.release-flags.api-test
(:require
[clojure.test :refer :all]
[metabase.test :as mt]
[metabase.test.fixtures :as fixtures])
(:import
(java.time LocalDate)))
(set! *warn-on-reflection* true)
(use-fixtures :once (fixtures/initialize :db :web-server :test-users))
(deftest get-flags-test
(testing "GET /api/release-flags"
(testing "requires authentication"
(is (= "Unauthenticated"
(mt/client :get 401 "release-flags"))))
(testing "returns all flags"
(mt/with-temp [:model/ReleaseFlag _ {:flag "get-test" :description "Test" :is_enabled false
:start_date (LocalDate/parse "2026-01-01")}]
(let [resp (mt/user-http-request :rasta :get 200 "release-flags")]
(is (contains? resp :get-test))
(is (= false (get-in resp [:get-test :is_enabled]))))))))
(deftest put-flags-test
(testing "PUT /api/release-flags"
(mt/with-temp [:model/ReleaseFlag _ {:flag "put-flag" :description "Put test" :is_enabled false
:start_date (LocalDate/parse "2026-01-01")}]
(testing "requires superuser"
(is (= "You don't have permissions to do that."
(mt/user-http-request :rasta :put 403 "release-flags" {"put-flag" true}))))
(testing "superuser can update flag status"
(let [resp (mt/user-http-request :crowberto :put 200 "release-flags" {"put-flag" true})]
(is (get-in resp [:put-flag :is_enabled]))))
(testing "returns updated flag map"
(let [resp (mt/user-http-request :crowberto :put 200 "release-flags" {"put-flag" false})]
(is (not (get-in resp ["put-flag" "is_enabled"]))))))))

View File

@@ -0,0 +1,93 @@
#_{:clj-kondo/ignore [:metabase/unknown-release-flag :metabase/non-literal-release-flag]}
(ns metabase.release-flags.guard-test
(:require
[clojure.test :refer :all]
[metabase.release-flags.guard :as guard]
[metabase.test :as mt]
[metabase.test.fixtures :as fixtures]))
(use-fixtures :once (fixtures/initialize :db))
(defn- setup-guard-ns!
"Creates a temporary namespace with a function and guards it. Returns [ns-sym fn-var]."
[flag-name]
(let [ns-sym (symbol (str "metabase.release-flags.guard-test-" (gensym)))]
(create-ns ns-sym)
(intern ns-sym 'test-fn (fn [] :original-value))
#_{:clj-kondo/ignore [:metabase/non-literal-release-flag]}
(guard/guard-namespace! flag-name (the-ns ns-sym))
[ns-sym (ns-resolve (the-ns ns-sym) 'test-fn)]))
(defn- teardown-guard-ns! [ns-sym]
(remove-ns ns-sym))
(deftest guard-namespace-blocks-when-disabled-test
(let [[ns-sym test-fn-var] (setup-guard-ns! "guard-test-flag")]
(try
(testing "guarded function returns nil when flag does not exist"
(binding [guard/*blocked-calls* nil]
(is (nil? (test-fn-var)))))
(finally
(teardown-guard-ns! ns-sym)))))
(deftest guard-namespace-allows-when-enabled-test
(let [[ns-sym test-fn-var] (setup-guard-ns! "guard-enabled-flag")]
(try
(mt/with-temp [:model/ReleaseFlag _ {:flag "guard-enabled-flag" :description "Test" :is_enabled true}]
(testing "guarded function runs normally when flag is enabled"
(is (= :original-value (test-fn-var)))))
(finally
(teardown-guard-ns! ns-sym)))))
(deftest guard-records-blocked-calls-test
(let [[ns-sym test-fn-var] (setup-guard-ns! "blocked-test-flag")]
(try
(testing "blocked calls are recorded in *blocked-calls* when bound"
(binding [guard/*blocked-calls* (atom [])]
(test-fn-var)
(let [calls @guard/*blocked-calls*]
(is (= 1 (count calls)))
(is (= "blocked-test-flag" (:flag (first calls))))
(is (string? (:fn (first calls))))
(is (string? (:call-site (first calls)))))))
(testing "blocked calls are not recorded when *blocked-calls* is nil"
(binding [guard/*blocked-calls* nil]
(is (nil? (test-fn-var)))))
(finally
(teardown-guard-ns! ns-sym)))))
(deftest bypass-guard-fixture-test
(let [[ns-sym test-fn-var] (setup-guard-ns! "bypass-test-flag")]
(try
(testing "bypassed flag allows function to run"
(binding [guard/*bypass-guard* #{"bypass-test-flag"}]
(is (= :original-value (test-fn-var)))))
(testing "non-bypassed flag still blocks"
(binding [guard/*blocked-calls* nil
guard/*bypass-guard* #{"some-other-flag"}]
(is (nil? (test-fn-var)))))
(testing "bypass-guard-fixture returns a working fixture"
(let [fixture #_{:clj-kondo/ignore [:metabase/unknown-release-flag]}
(guard/bypass-guard-fixture :bypass-test-flag)]
(fixture (fn []
(is (= :original-value (test-fn-var)))))))
(finally
(teardown-guard-ns! ns-sym)))))
(deftest guard-namespace-with-explicit-ns-test
(testing "guard-namespace! accepts an explicit namespace argument"
(let [ns-sym (symbol (str "metabase.release-flags.guard-test-explicit-" (gensym)))]
(create-ns ns-sym)
(intern ns-sym 'explicit-fn (fn [] :explicit-value))
(try
#_{:clj-kondo/ignore [:metabase/unknown-release-flag]}
(guard/guard-namespace! "explicit-flag" (the-ns ns-sym))
(let [v (ns-resolve (the-ns ns-sym) 'explicit-fn)]
(testing "function is guarded"
(binding [guard/*blocked-calls* nil]
(is (nil? (v)))))
(testing "bypass works"
(binding [guard/*bypass-guard* #{"explicit-flag"}]
(is (= :explicit-value (v))))))
(finally
(remove-ns ns-sym))))))

View File

@@ -0,0 +1,87 @@
#_{:clj-kondo/ignore [:metabase/unknown-release-flag]}
(ns metabase.release-flags.models-test
(:require
[clojure.test :refer :all]
[metabase.release-flags.models :as models]
[metabase.test :as mt]
[metabase.test.fixtures :as fixtures]
[toucan2.core :as t2])
(:import
(java.time LocalDate)))
(set! *warn-on-reflection* true)
(use-fixtures :once (fixtures/initialize :db))
(defn- get-release-flag [flag]
(t2/select-one :model/ReleaseFlag :flag flag))
(defn- has-release-flag-unchecked? [flag]
(:is_enabled (get-release-flag flag)))
(deftest all-flags-test
(testing "returns flag data keyed by name"
(mt/with-temp [:model/ReleaseFlag _ {:flag "all-flags-test" :description "A test"}]
(let [flags (models/all-flags)]
(is (contains? flags "all-flags-test"))
(is (not (get-in flags ["all-flags-test" :is_enabled])))
(is (= "A test" (get-in flags ["all-flags-test" :description])))))))
(deftest update-statuses!-test
(testing "updates is_enabled for existing flags"
(mt/with-temp [:model/ReleaseFlag _ {:flag "status-a" :description "A" :is_enabled false}
:model/ReleaseFlag _ {:flag "status-b" :description "B" :is_enabled true}]
(models/update-statuses! {"status-a" true "status-b" false})
(is (has-release-flag-unchecked? "status-a"))
(is (not (has-release-flag-unchecked? "status-b")))))
(testing "handles keyword keys"
(mt/with-temp [:model/ReleaseFlag _ {:flag "kw-flag" :description "KW" :is_enabled false}]
(models/update-statuses! {:kw-flag true})
(is (has-release-flag-unchecked? "kw-flag")))))
(deftest delete-flags!-test
(testing "returns 0 for empty set"
(is (= 0 (models/delete-flags! #{}))))
(testing "deletes specified flags and returns count"
(mt/with-temp [:model/ReleaseFlag _ {:flag "del-a" :description "A" :is_enabled false}
:model/ReleaseFlag _ {:flag "del-b" :description "B" :is_enabled false}
:model/ReleaseFlag _ {:flag "keep" :description "C" :is_enabled false}]
(is (= 2 (models/delete-flags! #{"del-a" "del-b"})))
(is (nil? (get-release-flag "del-a")))
(is (some? (get-release-flag "keep"))))))
(deftest upsert-flag!-test
(testing "inserts a new flag"
(let [flag-name (str "upsert-" (random-uuid))]
(try
(models/upsert-flag! flag-name "New description" (LocalDate/parse "2026-03-01"))
(let [row (get-release-flag flag-name)]
(is (some? row))
(is (= "New description" (:description row))))
(finally
(t2/delete! :model/ReleaseFlag :flag flag-name)))))
(testing "updates an existing flag"
(mt/with-temp [:model/ReleaseFlag _ {:flag "upsert-existing" :description "Old" :is_enabled false}]
(models/upsert-flag! "upsert-existing" "Updated" (LocalDate/parse "2026-04-01"))
(is (= "Updated" (:description (get-release-flag "upsert-existing")))))))
(deftest has-release-flag?-test
(testing "returns true when flag is enabled"
(mt/with-temp [:model/ReleaseFlag _ {:flag "enabled-flag" :description "E" :is_enabled true}]
#_{:clj-kondo/ignore [:metabase/unknown-release-flag]}
(is (models/has-release-flag? "enabled-flag"))))
(testing "returns false when flag is disabled"
(mt/with-temp [:model/ReleaseFlag _ {:flag "disabled-flag" :description "D" :is_enabled false}]
#_{:clj-kondo/ignore [:metabase/unknown-release-flag]}
(is (not (models/has-release-flag? "disabled-flag")))))
(testing "throws on missing flag"
(is (not #_{:clj-kondo/ignore [:metabase/unknown-release-flag]}
(models/has-release-flag? "nonexistent"))))
(testing "handles keyword argument"
(mt/with-temp [:model/ReleaseFlag _ {:flag "kw-test" :description "K" :is_enabled true}]
#_{:clj-kondo/ignore [:metabase/unknown-release-flag]}
(is (models/has-release-flag? :kw-test))))
(testing "handles namespaced keyword argument"
(mt/with-temp [:model/ReleaseFlag _ {:flag "my-ns/ns-flag" :description "NS" :is_enabled true}]
#_{:clj-kondo/ignore [:metabase/unknown-release-flag]}
(is (models/has-release-flag? :my-ns/ns-flag)))))

View File

@@ -23,6 +23,7 @@
^{:clj-kondo/ignore [:deprecated-namespace]} [metabase.query-processor.store :as qp.store]
[metabase.query-processor.test :as qp]
[metabase.query-processor.test-util :as qp.test-util]
[metabase.release-flags.guard :as guard]
[metabase.request.core]
[metabase.test-runner.assert-exprs :as test-runner.assert-exprs]
[metabase.test.data :as data]
@@ -372,12 +373,22 @@
"A modified version of `clojure.test/test-var` that:
- logs every toucan2 query we run, with details on the query type, model, args, and resulting query
- adds some context to any logs emitted during the test, so that we have information on what test ran
- fails the test if any guarded release-flag functions were called while the flag was disabled
"
[v]
(let [test-n (-> v meta :name)
test-ns (-> v meta :ns str)]
(log/with-context {:test (str test-ns "/" test-n)}
(original-test-var v))))
(binding [guard/*blocked-calls* (atom [])
clojure.test/*testing-vars* (conj clojure.test/*testing-vars* v)]
(original-test-var v)
;; report when blocked calls are used
(doseq [{:keys [flag fn call-site]} @guard/*blocked-calls*]
(clojure.test/do-report
{:type :fail
:message (str "Called guarded function " fn " while release flag \"" flag "\" is disabled")
:expected (str "Release flag \"" flag "\" to be enabled")
:actual (str "Flag was disabled. Call site: " call-site)}))))))
(alter-var-root #'clojure.test/test-var (constantly test-var-with-context))

View File

@@ -284,6 +284,12 @@
:schedule_type :daily
:schedule_hour 15}))
:model/ReleaseFlag
(fn [_] {:flag (u.random/random-name)
:description "Test release flag"
:start_date (t/local-date)
:is_enabled false})
:model/Revision
(fn [_] {:user_id (rasta-id)
:is_creation false

View File

@@ -10,6 +10,7 @@
[mb.hawk.core :as hawk]
[metabase.config.core :as config]
[metabase.core.bootstrap]
[metabase.test]
[metabase.test-runner.assert-exprs]
[metabase.test.data.env :as tx.env]
[metabase.test.fixtures :as fixtures]