Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 42 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -149,8 +149,30 @@ jobs:
name: Install worker dependencies
run: bun install --frozen-lockfile --ignore-scripts

# Restore on every run, SAVE ONLY ON MAIN — the split `build-daemon.yml`
# already uses, for a second reason that turned out to matter more.
#
# A combined `actions/cache@v6` writes a ref-scoped copy from every branch
# that misses the exact key, and this entry carries `target/`, so each copy
# is 1.5-2.3 GiB. Five PR refs held one at once (677, 679, 680, 681 and
# main) — ~10.7 GiB of a repo cache that GitHub caps at 10 GiB, which puts
# the store permanently in LRU eviction.
#
# What that evicted was not another cargo build. It was the 13 KB
# translation cache, touched once every 24 hours by the nightly
# `translate-docs` run and therefore always the least-recently-used thing
# in the store. Losing it re-translated all 48 pages into all 14 languages
# the next morning: ~125 runner-minutes and a full LLM pass per language,
# against a 4-minute baseline when the cache survives. Six consecutive days
# of it, Aug 6-11, cost ~750 runner-minutes and six full translation passes
# through the gateway.
#
# Restoring without saving costs a PR whose `Cargo.lock` moved a rebuild
# from a stale-but-close main cache — which is already what `restore-keys`
# hands it today.
- if: steps.crates.outputs.present == 'true'
uses: actions/cache@v6
id: cargo-cache
uses: actions/cache/restore@v6
with:
path: |
~/.cargo/registry/index
Expand All @@ -172,6 +194,25 @@ jobs:
if: steps.crates.outputs.present == 'true'
run: cargo test --workspace

# Paired with the restore above. `cache-hit != 'true'` skips the write when
# the exact key already exists, so a run that changed nothing does not
# re-upload 2 GiB; a push to main whose Cargo.lock moved is the only thing
# that writes here.
- name: Save cargo cache
if: >-
steps.crates.outputs.present == 'true'
&& github.event_name == 'push'
&& github.ref == 'refs/heads/main'
&& steps.cargo-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@v6
with:
path: |
~/.cargo/registry/index
~/.cargo/registry/cache
~/.cargo/git/db
target
key: cargo-${{ runner.os }}-${{ hashFiles('rust-toolchain.toml', 'Cargo.lock', 'crates/*/Cargo.toml') }}

test:
runs-on: ubuntu-latest
strategy:
Expand Down
51 changes: 47 additions & 4 deletions .github/workflows/translate-docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -80,12 +80,32 @@ jobs:
# hook, which builds the full Next.js application once per language.
run: bun install --frozen-lockfile --ignore-scripts

# The old primary key was
# `translation-cache-${{ hashFiles('scripts/translate-docs/.translation-cache.json') }}`,
# which ALWAYS evaluated to the bare literal `translation-cache-`: the file
# is gitignored (.gitignore:68), so it is absent at checkout and
# `hashFiles` returns "". Every restore that ever worked was a
# `restore-keys` prefix match, and a total miss is indistinguishable from a
# hit — nothing fails, nothing warns, the job just spends nine minutes and
# a full LLM pass. Hence the explicit warning step below: a miss is the
# expensive case and it should say so in the run summary.
- name: Restore translation cache
id: restore-cache
uses: actions/cache/restore@v6
with:
path: scripts/translate-docs/.translation-cache.json
key: translation-cache-${{ hashFiles('scripts/translate-docs/.translation-cache.json') }}
restore-keys: translation-cache-
# Per language, newest-first, falling back to the merged entry that
# `consolidate` still writes. `github.run_id` is monotonic, so the
# prefix match returns this language's most recent fragment.
key: translation-cache-${{ matrix.lang }}-${{ github.run_id }}
restore-keys: |
translation-cache-${{ matrix.lang }}-
translation-cache-

- name: Warn on translation cache miss
if: steps.restore-cache.outputs.cache-matched-key == ''
run: |
echo "::warning title=Translation cache MISS::${{ matrix.lang }} will re-translate every page (~9 runner-minutes and one full LLM pass)"
Comment on lines +105 to +108

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1,125p' .github/workflows/translate-docs.yml
rg -n -C 4 'translate-docs\.yml|uses:.*translate-docs|inputs:\s*$|languages:' .github/workflows

Repository: FailproofAI/failproofai

Length of output: 9116


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- workflow remainder ---'
sed -n '120,260p' .github/workflows/translate-docs.yml

printf '%s\n' '--- language-related implementation ---'
rg -n -C 5 'languages|language|lang' scripts/translate-docs package.json .github/workflows/translate-docs.yml \
  -g '*.ts' -g '*.js' -g '*.json' -g '*.yml' -g '*.yaml' -g 'package.json' \
  | head -n 500

printf '%s\n' '--- workflow references and permissions ---'
rg -n -C 5 'translate-docs|workflow_call|workflow_dispatch|permissions:|ANTHROPIC_API_KEY|ANTHROPIC_AUTH_TOKEN' .github README.md scripts \
  -g '*.yml' -g '*.yaml' -g '*.md' -g '*.ts' -g '*.js' -g 'package.json' \
  | head -n 500

printf '%s\n' '--- candidate files ---'
git ls-files scripts/translate-docs .github/workflows/translate-docs.yml

Repository: FailproofAI/failproofai

Length of output: 50379


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path

workflow = Path(".github/workflows/translate-docs.yml").read_text()
cases = [
    'en',
    'en; echo INJECTED',
    "en' ; echo INJECTED; #",
    'en", "ja"]',
]
needles = [
    "if [ -n \"${{ inputs.languages }}\" ]; then",
    "echo '${{ inputs.languages }}' | jq -Rc",
    "echo \"::warning title=Translation cache MISS::${{ matrix.lang }}",
    "bun run translate --languages ${{ matrix.lang }}",
]
for value in cases:
    print(f"=== input: {value!r} ===")
    expanded = workflow.replace("${{ inputs.languages }}", value).replace("${{ matrix.lang }}", value)
    for needle in needles:
        pos = expanded.find(needle.split("${{")[0])
        if pos >= 0:
            print(expanded[pos:expanded.find("\n", pos)])
PY

Repository: FailproofAI/failproofai

Length of output: 1652


Validate and quote inputs.languages before shell use

A workflow_dispatch user can set inputs.languages. The value reaches prepare shell source and the unquoted translation command. For example, en;id runs id as a separate command while ANTHROPIC_API_KEY is set. Validate each language against the supported allowlist in prepare, then pass the validated value through a quoted environment variable.

🧰 Tools
🪛 zizmor (1.29.0)

[warning] 108-108: code injection via template expansion (template-injection): may expand into attacker-controllable code

(template-injection)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/translate-docs.yml around lines 105 - 108, Update the
workflow’s prepare logic to validate every language from inputs.languages
against the supported language allowlist and reject any invalid value before
shell execution. Pass only the validated language value to translation steps
through an environment variable, and quote that variable wherever the
translation command uses it so shell metacharacters cannot execute commands.

Source: Linters/SAST tools


- name: Translate ${{ matrix.lang }}
run: bun run translate --languages ${{ matrix.lang }} ${{ inputs.force == true && '--force' || '' }}
Expand All @@ -99,22 +119,45 @@ jobs:
- name: Validate translated pages parse and images resolve
run: bun run validate:mdx

# Save HERE, per language, in the job that produced the work and directly
# after the step that proved it good.
#
# The only save used to be `consolidate`'s, downstream of BOTH the matrix
# gate (`if: needs.translate.result == 'success'`) and `mintlify validate`.
# So one page failing validation in one language threw away the cache for
# all fourteen — Aug 6 lost ~110 minutes of completed translation to a
# single `ko` page — and a nav mismatch in consolidate did the same on
# Aug 12. Each fragment is already authoritative for its own language, so
# there is nothing a merge has to happen first for.
#
# The `cache-hit` guard is the same one `build-daemon.yml:137` carries, and
# it is load-bearing here for a specific reason: the key embeds
# `github.run_id`, which is REUSED when someone re-runs a failed job. On
# that second attempt the primary key already exists, so the restore above
# scores an exact hit and this save would collide with itself.
- name: Save translation cache fragment
if: steps.restore-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@v6
with:
path: scripts/translate-docs/.translation-cache.json
key: translation-cache-${{ matrix.lang }}-${{ github.run_id }}

- name: Upload translated files
uses: actions/upload-artifact@v7
with:
name: translations-${{ matrix.lang }}
path: |
docs/${{ matrix.lang }}/
docs/i18n/README.${{ matrix.lang }}.md
retention-days: 1
retention-days: 7
if-no-files-found: error

- name: Upload cache fragment
uses: actions/upload-artifact@v7
with:
name: cache-${{ matrix.lang }}
path: scripts/translate-docs/.translation-cache.json
retention-days: 1
retention-days: 7
if-no-files-found: error
include-hidden-files: true

Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## 1.0.1-beta.0 — 2026-08-12

### Fixes

- Stop the nightly doc translation re-translating everything, most days. Runs cost **4 minutes** on Aug 3-5 and **118-136 minutes** every day from Aug 6-11 — ~750 wasted runner-minutes and six full-corpus passes through the LLM gateway in six days. Three causes compound, and none of them was the translation cache's own logic, which is sound. **First, the cache was being evicted between runs.** `ci.yml` cached `target/` under a combined `actions/cache@v6`, so every PR ref that missed the exact key wrote its own 1.5-2.3 GiB copy; five were live at once (#677, #679, #680, #681 and main), putting the repo at **11.56 GiB against GitHub's 10 GiB cap** and so permanently in LRU eviction. What that evicted was the 13 KB translation cache — touched once every 24 hours, therefore always the least-recently-used thing in the store. The restore/save split is the one `build-daemon.yml:117-144` already uses, and its comment there already gives the second reason to want it. **Second, the cache was saved once, at the end of a serial pipeline.** The only save sat in `consolidate`, downstream of both the matrix gate and `mintlify validate`, so a single page failing in a single language discarded all fourteen languages' work: Aug 6 lost ~110 completed minutes to one `ko` page. Each language now saves its own fragment in the job that produced it, immediately after the step that proved it good; the merged entry stays as a cross-language fallback. **Third, a cache HIT never checked that the translated file exists.** `isCached` is a pure function of the English source hash — it records that a page was translated once, not that it is on disk — and translations land on an auto-translate PR branch. With #682 unmerged, `main` lacked `docs/<lang>/cli/{update,migrate}.mdx` while the cache reported them done, so they were never regenerated, `--update-nav` (which reads the *English* tree) emitted nav entries pointing at them, and `mintlify validate` failed on 28 missing files. That is non-convergent: **a cache hit fails validation and only a full 120-minute miss goes green**, which is exactly what Aug 12 did. Statting the output makes the cache self-healing against any "translated once, never landed" gap. Also: a cache miss is now a visible `::warning` rather than silent — the old restore key always evaluated to the bare literal `translation-cache-`, since the file is gitignored and `hashFiles` returns `""` for an absent path, so every restore that ever worked was a prefix fallback and a total miss looked identical to a hit. Artifact retention goes 1 → 7 days so a run that dies mid-pipeline leaves a manual recovery path. (#685)

## 1.0.0 — 2026-08-12

The first stable release. Everything below this heading shipped across the
Expand Down
38 changes: 38 additions & 0 deletions __tests__/scripts/translate-docs/mdx-translator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
translateMdxPage,
} from "@/scripts/translate-docs/mdx-translator";
import type { TranslationCache } from "@/scripts/translate-docs/types";
import { setCacheEntry } from "@/scripts/translate-docs/cache";

/** Queue ONE `end_turn` translation response; call once per expected attempt. */
function queueTranslation(text: string): void {
Expand Down Expand Up @@ -522,6 +523,43 @@ describe("translateMdxPage validation gate", () => {
expect(Object.keys(cache.translations)).toHaveLength(0);
});

// A cache entry says a page was TRANSLATED ONCE, never that the file is on
// disk now — and the two came apart in production. Translations land on an
// auto-translate PR branch; while that sits unmerged, `main` lacks the file
// and the cache still reports it done, so the page is never regenerated while
// `--update-nav` (which reads the ENGLISH tree) emits a nav entry pointing at
// it. `mintlify validate` then fails, and because the cache save sat
// downstream of that step, the day's cache was discarded — making a cache HIT
// the failing case and a full 120-minute MISS the only way to a green run.
// These two tests pin both directions of the fix.
it("re-translates a cached page whose output file is missing", async () => {
const cache = emptyCache();
setCacheEntry(cache, REL, "de", EN_SOURCE, 10, 20);
expect(existsSync(outputPath)).toBe(false);

queueTranslation(VALID_DE);
const result = await translateMdxPage(srcPath, "de", { docsDir, cache });

// Cache says done, disk says otherwise — disk wins.
expect(result.cached).toBe(false);
expect(streamMock).toHaveBeenCalledTimes(1);
expect(existsSync(outputPath)).toBe(true);
});

it("still skips a cached page when the output file is present", async () => {
const cache = emptyCache();
setCacheEntry(cache, REL, "de", EN_SOURCE, 10, 20);
mkdirSync(dirname(outputPath), { recursive: true });
writeFileSync(outputPath, VALID_DE);

const result = await translateMdxPage(srcPath, "de", { docsDir, cache });

// The whole point of the cache. If this regresses, every run is a full
// re-translation and the existsSync guard has become a cache bypass.
expect(result.cached).toBe(true);
expect(streamMock).not.toHaveBeenCalled();
});

it("validates the sanitized, link-rewritten bytes rather than the raw model output", async () => {
// Raw output has a stray doubled quote in a JSX attribute (invalid MDX);
// sanitizeJsxAttributes fixes it before validation, so it passes on the
Expand Down
18 changes: 16 additions & 2 deletions scripts/translate-docs/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,18 @@ async function main() {
if (
!isForce &&
!isDryRun &&
isCached(cache, relPath, lang, pageContents.get(page)!)
isCached(cache, relPath, lang, pageContents.get(page)!) &&
// The cache records that a translation was PRODUCED, never that it
// EXISTS. Output lands on an unmerged auto-translate PR branch, so
// until that merges the checked-out tree lacks the file while the
// cache still says "done" — the page is never regenerated, and
// `--update-nav` (which reads the ENGLISH tree) emits a nav entry
// pointing at a file that is not there, so `mintlify validate` fails.
// That is non-convergent: a cache hit fails validation, and only a
// full cache MISS — 120 runner-minutes — produces a green run.
// Statting the output makes the cache self-healing against any
// "translated once, never landed" gap, whatever opened it.
existsSync(join(DOCS_DIR, lang, relPath))
) {
cachedTasks.push(task);
} else {
Expand Down Expand Up @@ -345,7 +356,10 @@ async function main() {
if (
!isForce &&
!isDryRun &&
isCached(cache, "README.md", lang, readmeSource)
isCached(cache, "README.md", lang, readmeSource) &&
// Same reason as the MDX branch above: cached means translated once,
// not present now.
existsSync(join(DOCS_DIR, "i18n", `README.${lang}.md`))
) {
console.log(` README.${lang}.md -> cached`);
results.push({
Expand Down
7 changes: 6 additions & 1 deletion scripts/translate-docs/mdx-translator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,12 @@ export async function translateMdxPage(
// Check cache — use provided cache object or read from disk
if (!options.force && !options.dryRun) {
const cache = options.cache ?? readCache();
if (isCached(cache, relPath, lang, sourceContent)) {
// `&& existsSync(outputPath)` for the same reason as the batch path in
// cli.ts: a cache entry says a translation was produced once, not that the
// file is on disk now. This branch is the single-page path — the batch run
// never reaches it for a cached page — so it is guarded separately or the
// two disagree about what "cached" means.
if (isCached(cache, relPath, lang, sourceContent) && existsSync(outputPath)) {
return {
lang,
sourcePath,
Expand Down
6 changes: 4 additions & 2 deletions scripts/translate-docs/readme-translator.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { LANGUAGES, getLanguageByCode } from "./config";
Expand Down Expand Up @@ -220,7 +220,9 @@ export async function translateReadme(
// Check cache — use provided cache object or read from disk
if (!options.force && !options.dryRun) {
const cache = options.cache ?? readCache();
if (isCached(cache, "README.md", lang, sourceContent)) {
// `&& existsSync(outputPath)` — see the MDX path. Cached records that a
// translation was produced, not that the file is there now.
if (isCached(cache, "README.md", lang, sourceContent) && existsSync(outputPath)) {
Comment on lines +223 to +225

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Add direct tests for the README cache condition.

The supplied tests invoke translateMdxPage only. They do not exercise translateReadme.

Add tests in __tests__/scripts/translate-docs/readme-translator.test.ts for both cases: regenerate when docs/i18n/README.<lang>.md is absent, and skip generation when it exists. As per coding guidelines: “When you add or change logic, add a corresponding test in __tests__/.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/translate-docs/readme-translator.ts` around lines 223 - 225, Add
direct tests for translateReadme covering both cache-condition outcomes:
regenerate the README translation when docs/i18n/README.<lang>.md is missing,
and skip generation when that file exists. Add these cases to
__tests__/scripts/translate-docs/readme-translator.test.ts, using the existing
test setup and mocks to verify translation is invoked only for the missing-file
case.

Source: Coding guidelines

return {
lang,
sourcePath: README_PATH,
Expand Down