Skip to content

Commit c9f4581

Browse files
MariefayTrigger.dev RepoOps
authored andcommitted
docs: add rollout status callouts for features that aren't generally available
Docs pages for features that are in private beta or rolling out can now show a status callout, and a check makes sure each callout references an existing feature flag. Mono-RevId: 65fe0273a31f568f7731682172d8eb400d4efaea
1 parent 092421d commit c9f4581

8 files changed

Lines changed: 305 additions & 0 deletions

File tree

‎.claude/rules/docs-writing.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,9 @@ paths:
1212
- Always import from `@trigger.dev/sdk`, never `@trigger.dev/sdk/v3`.
1313
- Keep paragraphs short. Use headers to break up content.
1414
- Link to related pages using relative paths (e.g., `[Tasks](/tasks/overview)`).
15+
- Features behind a flag that isn't fully on:
16+
- Dark (nobody outside the team has it): don't write docs yet.
17+
- Private beta or rolling out: put the docs in the feature PR with `<PrivateBeta flag="<key>" />` or `<RollingOut flag="<key>" />` (import from `/snippets/feature-status.jsx`), at the top of the page or the section. Use `flag="env:<NAME>"` for env-var gates. Keep it on one line with string values.
18+
- The PR that removes the flag also removes the callout. CI fails while a callout points at a missing flag.
19+
- Avoid two callouts in a row. A `<PrivateBeta>` or `<RollingOut>` callout next to another callout is the one exception, because it's temporary. Leave the existing callout as it is.
20+
- Only for flags that cloud rolls out. Never add it to self-host config docs.

‎.oxlintrc.json‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,12 @@
205205
"rules": {
206206
"react/jsx-no-constructed-context-values": "off"
207207
}
208+
},
209+
{
210+
"files": ["docs/snippets/**/*.jsx"],
211+
"rules": {
212+
"react/jsx-no-undef": "off"
213+
}
208214
}
209215
]
210216
}

‎docs/deploy-environment-variables.mdx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ title: "Environment Variables"
33
description: "Any environment variables used in your tasks need to be added so the deployed code will run successfully."
44
---
55

6+
import { RollingOut } from "/snippets/feature-status.jsx";
7+
68
An environment variable in Node.js is accessed in your code using `process.env.MY_ENV_VAR`.
79

810
We deploy your tasks and scale them up and down when they are triggered. So any environment variables you use in your tasks need to accessible to us so your code will run successfully.
@@ -43,6 +45,8 @@ When creating an environment variable, you can mark it as a **Secret**. Secret v
4345

4446
You can edit an environment variable's values. You cannot edit the key name, you must delete and create a new one.
4547

48+
<RollingOut flag="allowEmptyEnvironmentVariableValues" />
49+
4650
<Note>
4751
An empty string is a valid value. Saving a variable with an empty value stores it as an empty
4852
string (your task sees the variable set to `""`), which is different from deleting the variable

‎docs/management/runs/retrieve-trace.mdx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ title: "Retrieve run trace"
33
openapi: "v3-openapi GET /api/v1/runs/{runId}/trace"
44
---
55

6+
import { RollingOut } from "/snippets/feature-status.jsx";
7+
68
Returns the OpenTelemetry trace subtree for the run you request. The response `trace.rootSpan` is that run's span — not necessarily the trace-wide root — with its descendant spans nested under `children`.
79

810
For a child or nested run inside a large trace, this endpoint scopes the tree to that run so you still get a useful subtree even when the full trace has more spans than the platform can return in one response.
@@ -11,6 +13,8 @@ For a finished run, spans that finish more than 7 days after the run are returne
1113

1214
## Page large traces
1315

16+
<RollingOut flag="publicTracePagingEnabled" />
17+
1418
The tree response stops at a size limit. To get every span of a large trace, page it: send `page[size]` (1,000–10,000) on the first request, then pass each response's `pagination.next` back as `page[after]` until it's absent.
1519

1620
```ts Your backend code

‎docs/snippets/feature-status.jsx‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
export const RollingOut = () => (
2+
<Warning>
3+
<strong>Rolling out.</strong> This feature is being enabled gradually and may not be available
4+
for your organization yet.
5+
</Warning>
6+
);
7+
8+
export const PrivateBeta = () => (
9+
<Warning>
10+
<strong>Private beta.</strong> This feature is only available to some organizations right now.
11+
</Warning>
12+
);

‎knip.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ export default {
9696
ignoreDependencies: ["ai-v7", "react"],
9797
},
9898
docs: {
99+
entry: ["snippets/**/*.jsx"],
99100
ignoreFiles: ["style.css"],
100101
},
101102
},
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import { readFileSync, readdirSync } from "node:fs";
2+
import { join, relative, resolve } from "node:path";
3+
import { fileURLToPath, pathToFileURL } from "node:url";
4+
5+
export const COMPONENTS = ["RollingOut", "PrivateBeta"];
6+
7+
const TAG_PATTERN = new RegExp(`<(${COMPONENTS.join("|")})\\b`, "g");
8+
const FEATURE_FLAG_MODULE = "apps/webapp/app/v3/featureFlags.ts";
9+
const ENV_MODULE = "apps/webapp/app/env.server.ts";
10+
11+
export function extractFeatureStatusTags(source, file) {
12+
const tags = [];
13+
let inFence = false;
14+
15+
source.split("\n").forEach((text, index) => {
16+
if (text.trimStart().startsWith("```")) {
17+
inFence = !inFence;
18+
return;
19+
}
20+
if (inFence) return;
21+
22+
for (const match of text.matchAll(TAG_PATTERN)) {
23+
tags.push(parseTag(text.slice(match.index), match[1], file, index + 1));
24+
}
25+
});
26+
27+
return tags;
28+
}
29+
30+
function parseTag(text, component, file, line) {
31+
const end = text.indexOf("/>");
32+
if (end === -1) {
33+
return { file, line, component, error: "must be a single-line, self-closing tag" };
34+
}
35+
36+
const body = text.slice(component.length + 1, end);
37+
if (body.includes("{")) {
38+
return { file, line, component, error: "attributes must be plain string literals" };
39+
}
40+
41+
const flag = body.match(/\bflag="([^"]*)"/)?.[1];
42+
return { file, line, component, flag };
43+
}
44+
45+
export function extractFeatureFlagKeys(source) {
46+
const start = source.indexOf("export const FEATURE_FLAG = {");
47+
if (start === -1) return new Set();
48+
49+
const rest = source.slice(start);
50+
const end = rest.search(/^\}( as const)?;/m);
51+
if (end === -1) return new Set();
52+
53+
const block = rest.slice(0, end);
54+
const keys = [...block.matchAll(/^\s*(\w+):\s*"(\w+)",?\s*$/gm)]
55+
.filter(([, key, value]) => key === value)
56+
.map(([, key]) => key);
57+
return new Set(keys);
58+
}
59+
60+
export function extractEnvKeys(source) {
61+
return new Set([...source.matchAll(/^\s+([A-Z][A-Z0-9_]*):\s/gm)].map(([, key]) => key));
62+
}
63+
64+
export function findProblems(tags, flagKeys, envKeys) {
65+
const problems = [];
66+
67+
for (const tag of tags) {
68+
const at = `${tag.file}:${tag.line}`;
69+
const usage = `<${tag.component} flag="..." />`;
70+
71+
if (tag.error) {
72+
problems.push({
73+
at,
74+
message: `<${tag.component}> ${tag.error}`,
75+
fix: `write it as ${usage}`,
76+
});
77+
continue;
78+
}
79+
80+
if (!tag.flag) {
81+
problems.push({
82+
at,
83+
message: `<${tag.component}> is missing flag`,
84+
fix: `add flag="<key in ${FEATURE_FLAG_MODULE}>" or flag="env:<NAME in ${ENV_MODULE}>"`,
85+
});
86+
continue;
87+
}
88+
89+
if (tag.flag.startsWith("env:")) {
90+
const name = tag.flag.slice("env:".length);
91+
if (!envKeys.has(name)) {
92+
problems.push({
93+
at,
94+
message: `env var "${name}" isn't declared in ${ENV_MODULE}`,
95+
fix: "fix the name, or remove the callout if the env gate was removed",
96+
});
97+
}
98+
continue;
99+
}
100+
101+
if (!flagKeys.has(tag.flag)) {
102+
problems.push({
103+
at,
104+
message: `flag "${tag.flag}" isn't in ${FEATURE_FLAG_MODULE}`,
105+
fix: "fix the key, or remove the callout if the flag was removed (the feature is generally available)",
106+
});
107+
}
108+
}
109+
110+
return problems;
111+
}
112+
113+
function main() {
114+
const root = resolve(fileURLToPath(import.meta.url), "../..");
115+
const docsDir = join(root, "docs");
116+
117+
const flagKeys = extractFeatureFlagKeys(readFileSync(join(root, FEATURE_FLAG_MODULE), "utf8"));
118+
const envKeys = extractEnvKeys(readFileSync(join(root, ENV_MODULE), "utf8"));
119+
if (flagKeys.size === 0 || envKeys.size === 0) {
120+
console.error(
121+
`Found ${flagKeys.size} feature flags and ${envKeys.size} env vars. The file format probably changed; update scripts/check-docs-feature-status.mjs.`
122+
);
123+
process.exit(1);
124+
}
125+
126+
const files = readdirSync(docsDir, { recursive: true }).filter((file) => file.endsWith(".mdx"));
127+
const tags = files.flatMap((file) =>
128+
extractFeatureStatusTags(
129+
readFileSync(join(docsDir, file), "utf8"),
130+
relative(root, join(docsDir, file))
131+
)
132+
);
133+
134+
const problems = findProblems(tags, flagKeys, envKeys);
135+
if (problems.length === 0) {
136+
console.log(`Checked ${tags.length} feature status callouts in ${files.length} docs pages.`);
137+
return;
138+
}
139+
140+
for (const { at, message, fix } of problems) {
141+
console.error(`${at} ${message}\n fix: ${fix}`);
142+
}
143+
console.error(`\n${problems.length} feature status callout problem(s) found.`);
144+
process.exit(1);
145+
}
146+
147+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
148+
main();
149+
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import assert from "node:assert/strict";
2+
import { test } from "node:test";
3+
import {
4+
extractEnvKeys,
5+
extractFeatureFlagKeys,
6+
extractFeatureStatusTags,
7+
findProblems,
8+
} from "./check-docs-feature-status.mjs";
9+
10+
const flagKeys = new Set(["queueArchivingEnabled"]);
11+
const envKeys = new Set(["RUN_ENGINE_CONCURRENCY_KEY_LIMITS_ENABLED"]);
12+
13+
function problemsFor(mdx) {
14+
return findProblems(extractFeatureStatusTags(mdx, "docs/page.mdx"), flagKeys, envKeys);
15+
}
16+
17+
test("known catalog flag passes", () => {
18+
assert.deepEqual(problemsFor('<RollingOut flag="queueArchivingEnabled" />'), []);
19+
});
20+
21+
test("known env gate passes", () => {
22+
assert.deepEqual(
23+
problemsFor('<PrivateBeta flag="env:RUN_ENGINE_CONCURRENCY_KEY_LIMITS_ENABLED" />'),
24+
[]
25+
);
26+
});
27+
28+
test("typo in flag fails with file, line and value", () => {
29+
const [problem] = problemsFor('intro\n\n<RollingOut flag="queueArchiveEnabled" />');
30+
assert.equal(problem.at, "docs/page.mdx:3");
31+
assert.match(problem.message, /"queueArchiveEnabled"/);
32+
});
33+
34+
test("removed flag fails", () => {
35+
const problems = findProblems(
36+
extractFeatureStatusTags('<RollingOut flag="queueArchivingEnabled" />', "docs/page.mdx"),
37+
new Set(),
38+
envKeys
39+
);
40+
assert.equal(problems.length, 1);
41+
});
42+
43+
test("unknown env var fails", () => {
44+
assert.equal(problemsFor('<RollingOut flag="env:NOPE_ENABLED" />').length, 1);
45+
});
46+
47+
test("missing flag fails", () => {
48+
assert.match(problemsFor("<PrivateBeta />")[0].message, /missing flag/);
49+
});
50+
51+
test("multi-line tag is reported, not skipped", () => {
52+
const problems = problemsFor('<RollingOut\n flag="queueArchivingEnabled"\n/>');
53+
assert.match(problems[0].message, /single-line/);
54+
});
55+
56+
test("expression attribute is reported", () => {
57+
assert.match(problemsFor("<RollingOut flag={FLAG} />")[0].message, /string literals/);
58+
});
59+
60+
test("tags inside code fences and the import line are ignored", () => {
61+
const mdx = [
62+
'import { RollingOut } from "/snippets/feature-status.jsx";',
63+
"```mdx",
64+
'<RollingOut flag="madeUp" />',
65+
"```",
66+
].join("\n");
67+
assert.deepEqual(extractFeatureStatusTags(mdx, "docs/page.mdx"), []);
68+
});
69+
70+
test("finds two tags on one line", () => {
71+
const mdx = '<RollingOut flag="a" /> <PrivateBeta flag="b" />';
72+
assert.equal(extractFeatureStatusTags(mdx, "docs/page.mdx").length, 2);
73+
});
74+
75+
test("reads FEATURE_FLAG keys and ignores the catalog below it", () => {
76+
const source = [
77+
'import { z } from "zod";',
78+
"export const FEATURE_FLAG = {",
79+
' queueArchivingEnabled: "queueArchivingEnabled",',
80+
" // a comment",
81+
' hasSso: "hasSso",',
82+
"} as const;",
83+
"export const OTHER = {",
84+
' oldFeature: "oldFeature",',
85+
"};",
86+
].join("\n");
87+
assert.deepEqual([...extractFeatureFlagKeys(source)], ["queueArchivingEnabled", "hasSso"]);
88+
});
89+
90+
test("reads FEATURE_FLAG keys when the object ends without as const", () => {
91+
const source = [
92+
"export const FEATURE_FLAG = {",
93+
' hasSso: "hasSso",',
94+
"};",
95+
"export const OTHER = {",
96+
' oldFeature: "oldFeature",',
97+
"};",
98+
].join("\n");
99+
assert.deepEqual([...extractFeatureFlagKeys(source)], ["hasSso"]);
100+
});
101+
102+
test("changed catalog format yields no keys", () => {
103+
assert.equal(extractFeatureFlagKeys("export const FLAGS = { a: 'a' };").size, 0);
104+
});
105+
106+
test("reads env schema keys", () => {
107+
const source = [
108+
"const EnvironmentSchema = z.object({",
109+
' RUN_ENGINE_CONCURRENCY_KEY_LIMITS_ENABLED: z.string().default("0"),',
110+
" API_RATE_LIMIT_METRICS_ENABLED: z",
111+
" .string()",
112+
"});",
113+
].join("\n");
114+
assert.deepEqual(
115+
[...extractEnvKeys(source)],
116+
["RUN_ENGINE_CONCURRENCY_KEY_LIMITS_ENABLED", "API_RATE_LIMIT_METRICS_ENABLED"]
117+
);
118+
});
119+
120+
test("other components and similar names are ignored", () => {
121+
const mdx = '<Warning>x</Warning>\n<RollingOutBanner flag="madeUp" />';
122+
assert.deepEqual(extractFeatureStatusTags(mdx, "docs/page.mdx"), []);
123+
});

0 commit comments

Comments
 (0)