From e2a8f447ac7f2728eec70b5f9ed119f8ddfd31a9 Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Wed, 12 Aug 2026 14:43:20 +0200 Subject: [PATCH 01/10] trigger --- .github/workflows/inspectStickyDisk.yml | 90 +++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 .github/workflows/inspectStickyDisk.yml diff --git a/.github/workflows/inspectStickyDisk.yml b/.github/workflows/inspectStickyDisk.yml new file mode 100644 index 000000000000..1f6afad23fa3 --- /dev/null +++ b/.github/workflows/inspectStickyDisk.yml @@ -0,0 +1,90 @@ +name: Inspect sticky disk + +on: + workflow_dispatch: + inputs: + hybrid: + description: Also inspect the OldDot (Mobile-Expensify) node_modules disk + type: boolean + default: false + push: + paths: + - '.github/workflows/inspectStickyDisk.yml' + +jobs: + inspect: + runs-on: blacksmith-2vcpu-ubuntu-2404 + steps: + - name: Checkout + uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 + with: + submodules: ${{ inputs.hybrid || false }} + + # Must match setupNode exactly, otherwise the key hash differs and a fresh empty disk is mounted + - name: Remove E/App version from package-lock.json + run: jq 'del(.version, .packages[""].version)' package-lock.json > normalized-package-lock.json + + - name: Mount NewDot node_modules sticky disk + uses: useblacksmith/stickydisk@6d373c96a74cbde0c99fedc5ea5d3a7ba66ba494 # v1.4.0 + with: + key: ${{ format('{0}-{1}-{2}-node-modules-{3}', github.repository, runner.os, runner.arch, inputs.hybrid == true && hashFiles('normalized-package-lock.json', 'patches/**', 'Mobile-Expensify/patches/**') || hashFiles('normalized-package-lock.json', 'patches/**')) }} + path: node_modules + + - name: Mount OldDot node_modules sticky disk + if: inputs.hybrid == true + uses: useblacksmith/stickydisk@6d373c96a74cbde0c99fedc5ea5d3a7ba66ba494 # v1.4.0 + with: + key: ${{ format('{0}-{1}-{2}-old-dot-node-modules-{3}', github.repository, runner.os, runner.arch, hashFiles('Mobile-Expensify/package-lock.json', 'Mobile-Expensify/patches/**')) }} + path: Mobile-Expensify/node_modules + + - name: Mount npm cache sticky disk + uses: useblacksmith/stickydisk@6d373c96a74cbde0c99fedc5ea5d3a7ba66ba494 # v1.4.0 + with: + key: ${{ format('{0}-{1}-{2}-npm', github.repository, runner.os, runner.arch) }} + path: ~/.npm + + - name: Inspect disks + shell: bash + run: | + inspect() { + local label="$1" path="$2" + echo "::group::$label ($path)" + if [[ ! -d "$path" ]]; then + echo "not mounted / does not exist" + echo "::endgroup::" + return + fi + echo "=== df: real allocated blocks (what Blacksmith bills) ===" + df -h "$path" || true + echo + echo "=== du: logical file size ===" + du -sh "$path" 2>/dev/null || true + echo + echo "=== top-25 heaviest entries ===" + du -sh "$path"/* "$path"/.[!.]* 2>/dev/null | sort -rh | head -25 + echo "::endgroup::" + } + + inspect "NewDot node_modules" node_modules + inspect "NewDot node_modules/.cache" node_modules/.cache + inspect "npm cache" ~/.npm + if [[ -d Mobile-Expensify/node_modules ]]; then + inspect "OldDot node_modules" Mobile-Expensify/node_modules + fi + + - name: Summary + shell: bash + run: | + { + echo "## Sticky disk usage" + echo + echo "| Disk | df (billed) | du (logical) |" + echo "|---|---|---|" + for entry in "NewDot node_modules:node_modules" "npm cache:$HOME/.npm" "OldDot node_modules:Mobile-Expensify/node_modules"; do + label="${entry%%:*}"; path="${entry##*:}" + [[ -d "$path" ]] || continue + billed=$(df -h --output=used "$path" 2>/dev/null | tail -1 | tr -d ' ') + logical=$(du -sh "$path" 2>/dev/null | cut -f1) + echo "| $label | $billed | $logical |" + done + } >> "$GITHUB_STEP_SUMMARY" From 71443321a90d5c1a180e7592feccdcd5dc283d51 Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Wed, 12 Aug 2026 14:47:31 +0200 Subject: [PATCH 02/10] fix --- .github/workflows/inspectStickyDisk.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/inspectStickyDisk.yml b/.github/workflows/inspectStickyDisk.yml index 1f6afad23fa3..c8f451425e3a 100644 --- a/.github/workflows/inspectStickyDisk.yml +++ b/.github/workflows/inspectStickyDisk.yml @@ -10,6 +10,9 @@ on: push: paths: - '.github/workflows/inspectStickyDisk.yml' + pull_request: + paths: + - '.github/workflows/inspectStickyDisk.yml' jobs: inspect: From f7c5e692ae057410d79d05d7325b3807cb1e06f8 Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Wed, 12 Aug 2026 14:54:59 +0200 Subject: [PATCH 03/10] trigger --- .github/workflows/inspectStickyDisk.yml | 32 +++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/.github/workflows/inspectStickyDisk.yml b/.github/workflows/inspectStickyDisk.yml index c8f451425e3a..76843800bf9c 100644 --- a/.github/workflows/inspectStickyDisk.yml +++ b/.github/workflows/inspectStickyDisk.yml @@ -7,6 +7,10 @@ on: description: Also inspect the OldDot (Mobile-Expensify) node_modules disk type: boolean default: false + raw_key: + description: 'Exact sticky disk key to inspect (e.g. Expensify/App-Linux-X64-node-modules-0bf21fdb...). Overrides the computed keys.' + type: string + default: '' push: paths: - '.github/workflows/inspectStickyDisk.yml' @@ -17,6 +21,9 @@ on: jobs: inspect: runs-on: blacksmith-2vcpu-ubuntu-2404 + env: + # On push there are no inputs, so fall back to the hardcoded key under investigation + RAW_KEY: ${{ inputs.raw_key || 'Expensify/App-Linux-X64-node-modules-ebc680713b61d9a80ed52a0d3e4dc41b4c7d5393baa5db8b3929c01bbfb9675f' }} steps: - name: Checkout uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 @@ -27,7 +34,15 @@ jobs: - name: Remove E/App version from package-lock.json run: jq 'del(.version, .packages[""].version)' package-lock.json > normalized-package-lock.json + - name: Mount sticky disk by raw key + if: env.RAW_KEY != '' + uses: useblacksmith/stickydisk@6d373c96a74cbde0c99fedc5ea5d3a7ba66ba494 # v1.4.0 + with: + key: ${{ env.RAW_KEY }} + path: raw-disk + - name: Mount NewDot node_modules sticky disk + if: env.RAW_KEY == '' uses: useblacksmith/stickydisk@6d373c96a74cbde0c99fedc5ea5d3a7ba66ba494 # v1.4.0 with: key: ${{ format('{0}-{1}-{2}-node-modules-{3}', github.repository, runner.os, runner.arch, inputs.hybrid == true && hashFiles('normalized-package-lock.json', 'patches/**', 'Mobile-Expensify/patches/**') || hashFiles('normalized-package-lock.json', 'patches/**')) }} @@ -41,6 +56,7 @@ jobs: path: Mobile-Expensify/node_modules - name: Mount npm cache sticky disk + if: env.RAW_KEY == '' uses: useblacksmith/stickydisk@6d373c96a74cbde0c99fedc5ea5d3a7ba66ba494 # v1.4.0 with: key: ${{ format('{0}-{1}-{2}-npm', github.repository, runner.os, runner.arch) }} @@ -49,13 +65,14 @@ jobs: - name: Inspect disks shell: bash run: | + shopt -s nullglob dotglob inspect() { local label="$1" path="$2" echo "::group::$label ($path)" if [[ ! -d "$path" ]]; then echo "not mounted / does not exist" echo "::endgroup::" - return + return 0 fi echo "=== df: real allocated blocks (what Blacksmith bills) ===" df -h "$path" || true @@ -64,10 +81,21 @@ jobs: du -sh "$path" 2>/dev/null || true echo echo "=== top-25 heaviest entries ===" - du -sh "$path"/* "$path"/.[!.]* 2>/dev/null | sort -rh | head -25 + local entries=("$path"/*) + if (( ${#entries[@]} )); then + du -sh "${entries[@]}" 2>/dev/null | sort -rh | head -25 || true + else + echo "(empty)" + fi echo "::endgroup::" } + if [[ -d raw-disk ]]; then + inspect "Raw disk ($RAW_KEY)" raw-disk + for sub in raw-disk/*/; do + inspect "Raw disk: ${sub}" "${sub%/}" + done + fi inspect "NewDot node_modules" node_modules inspect "NewDot node_modules/.cache" node_modules/.cache inspect "npm cache" ~/.npm From 094b7a2d58ab4ff02fa87945da7724f9533d4418 Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Wed, 12 Aug 2026 15:10:29 +0200 Subject: [PATCH 04/10] trigger --- .github/workflows/inspectStickyDisk.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.github/workflows/inspectStickyDisk.yml b/.github/workflows/inspectStickyDisk.yml index 76843800bf9c..8857b925aec2 100644 --- a/.github/workflows/inspectStickyDisk.yml +++ b/.github/workflows/inspectStickyDisk.yml @@ -103,6 +103,19 @@ jobs: inspect "OldDot node_modules" Mobile-Expensify/node_modules fi + - name: Clean build artifacts and trim raw disk + if: env.RAW_KEY != '' + shell: bash + run: | + echo "=== before cleanup ===" + df -h raw-disk + find raw-disk -type d -name '.cxx' -prune -exec rm -rf {} + 2>/dev/null || true + find raw-disk -type d -path '*/android/build' -prune -exec rm -rf {} + 2>/dev/null || true + find raw-disk -type d -path '*/android/.gradle' -prune -exec rm -rf {} + 2>/dev/null || true + echo "=== after cleanup ===" + df -h raw-disk + sudo fstrim -v raw-disk || echo "::warning::fstrim failed (discard may be unsupported)" + - name: Summary shell: bash run: | From e9469271112c93bf91b5abef331705a2b1b10bba Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Wed, 12 Aug 2026 16:39:00 +0200 Subject: [PATCH 05/10] trigger + fix sticky disks --- .../actions/composite/setupNode/action.yml | 17 +++++++ .github/workflows/inspectStickyDisk.yml | 13 +++++ .github/workflows/seedStickyDisks.yml | 51 +++++++++++++++++++ 3 files changed, 81 insertions(+) create mode 100644 .github/workflows/seedStickyDisks.yml diff --git a/.github/actions/composite/setupNode/action.yml b/.github/actions/composite/setupNode/action.yml index aeade96bb623..d9bb77a3dc52 100644 --- a/.github/actions/composite/setupNode/action.yml +++ b/.github/actions/composite/setupNode/action.yml @@ -7,6 +7,13 @@ inputs: description: 'Indicates if node is set up for hybrid app' required: false default: 'false' + SEED_STICKY_DISKS: + description: | + 'true' only in the dedicated seed workflow: commits the sticky disk snapshots after installing. + Everywhere else the disks are mounted read-through (commit: false) so no job can ever pollute + the snapshot with build artifacts or tool caches. + required: false + default: 'false' outputs: cache-hit: @@ -30,12 +37,17 @@ runs: shell: bash run: echo "use-sticky-disk=${{ runner.os == 'Linux' && contains(runner.name, 'blacksmith') }}" >> "$GITHUB_OUTPUT" + # Snapshot commits are restricted to the dedicated seed workflow (SEED_STICKY_DISKS=true), which + # commits only when no snapshot exists yet for the key (if-missing). Every other consumer mounts + # with commit: false — whatever a job writes into the clone (gradle/CMake outputs, tool caches in + # node_modules/.cache) is discarded, so the billed snapshot always stays a clean npm ci result. - id: stickydisk-node-modules if: steps.runner-info.outputs.use-sticky-disk == 'true' uses: useblacksmith/stickydisk@6d373c96a74cbde0c99fedc5ea5d3a7ba66ba494 # v1.4.0 with: key: ${{ format('{0}-{1}-{2}-node-modules-{3}', github.repository, runner.os, runner.arch, inputs.IS_HYBRID_BUILD == 'true' && hashFiles('normalized-package-lock.json', 'patches/**', 'Mobile-Expensify/patches/**') || hashFiles('normalized-package-lock.json', 'patches/**')) }} path: node_modules + commit: ${{ inputs.SEED_STICKY_DISKS == 'true' && 'if-missing' || 'false' }} - id: stickydisk-old-dot-node-modules if: steps.runner-info.outputs.use-sticky-disk == 'true' && inputs.IS_HYBRID_BUILD == 'true' @@ -43,13 +55,18 @@ runs: with: key: ${{ format('{0}-{1}-{2}-old-dot-node-modules-{3}', github.repository, runner.os, runner.arch, hashFiles('Mobile-Expensify/package-lock.json', 'Mobile-Expensify/patches/**')) }} path: Mobile-Expensify/node_modules + commit: ${{ inputs.SEED_STICKY_DISKS == 'true' && 'if-missing' || 'false' }} + # The npm cache disk has no hash in its key and barely grows (tarballs are content-addressed), + # so it isn't gated behind the seed workflow: on-change commits whenever npm ci actually wrote + # new tarballs and skips the many runs that never touch ~/.npm. - id: stickydisk-npm if: steps.runner-info.outputs.use-sticky-disk == 'true' uses: useblacksmith/stickydisk@6d373c96a74cbde0c99fedc5ea5d3a7ba66ba494 # v1.4.0 with: key: ${{ format('{0}-{1}-{2}-npm', github.repository, runner.os, runner.arch) }} path: ~/.npm + commit: on-change - id: cache-node-modules if: steps.runner-info.outputs.use-sticky-disk != 'true' diff --git a/.github/workflows/inspectStickyDisk.yml b/.github/workflows/inspectStickyDisk.yml index 8857b925aec2..8d5f664507fe 100644 --- a/.github/workflows/inspectStickyDisk.yml +++ b/.github/workflows/inspectStickyDisk.yml @@ -11,6 +11,10 @@ on: description: 'Exact sticky disk key to inspect (e.g. Expensify/App-Linux-X64-node-modules-0bf21fdb...). Overrides the computed keys.' type: string default: '' + delete_key: + description: 'Exact sticky disk key to DELETE (destructive: next run on this key pays a full npm ci / native rebuild).' + type: string + default: '' push: paths: - '.github/workflows/inspectStickyDisk.yml' @@ -24,6 +28,9 @@ jobs: env: # On push there are no inputs, so fall back to the hardcoded key under investigation RAW_KEY: ${{ inputs.raw_key || 'Expensify/App-Linux-X64-node-modules-ebc680713b61d9a80ed52a0d3e4dc41b4c7d5393baa5db8b3929c01bbfb9675f' }} + # Set to a key (or hardcode one here for push runs) to delete that disk. fstrim/discard is not + # supported on sticky disks, so deletion is the only way to reclaim already-allocated blocks. + DELETE_KEY: ${{ inputs.delete_key || '' }} steps: - name: Checkout uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 @@ -116,6 +123,12 @@ jobs: df -h raw-disk sudo fstrim -v raw-disk || echo "::warning::fstrim failed (discard may be unsupported)" + - name: Delete sticky disk by key + if: env.DELETE_KEY != '' + uses: useblacksmith/stickydisk-delete@b41313d28b8647d72114c9ba3c96bb04061562b6 # v1 + with: + delete-key: ${{ env.DELETE_KEY }} + - name: Summary shell: bash run: | diff --git a/.github/workflows/seedStickyDisks.yml b/.github/workflows/seedStickyDisks.yml new file mode 100644 index 000000000000..1218fcddf16a --- /dev/null +++ b/.github/workflows/seedStickyDisks.yml @@ -0,0 +1,51 @@ +name: Seed sticky disks + +# The only workflow allowed to commit node_modules sticky disk snapshots (see setupNode's +# SEED_STICKY_DISKS input). It runs whenever the files that make up the disk keys change, +# installs dependencies once, and commits a clean snapshot for every other workflow to consume. +# Consumers that race ahead of the seed on a fresh key simply pay their own npm ci (commit: false), +# exactly like a regular cache miss. + +# Runs on every push and pull request, not just changes to the files the keys hash: a seed job whose +# key already has a snapshot mounts it and exits (commit: if-missing makes it a no-op), so the steady +# state costs one short 2vCPU job per run and guarantees no consumer ever races a missing snapshot. +on: + push: + branches: + - main + - staging + - production + pull_request: + types: [opened, synchronize] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + seedStandalone: + name: Seed standalone node_modules + runs-on: blacksmith-2vcpu-ubuntu-2404 + steps: + - name: Checkout + uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 + + - name: Setup Node (seed) + uses: ./.github/actions/composite/setupNode + with: + SEED_STICKY_DISKS: 'true' + + seedHybrid: + name: Seed hybrid node_modules + runs-on: blacksmith-2vcpu-ubuntu-2404 + steps: + - name: Checkout + uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 + with: + submodules: true + + - name: Setup Node (seed) + uses: ./.github/actions/composite/setupNode + with: + IS_HYBRID_BUILD: 'true' + SEED_STICKY_DISKS: 'true' From 78ef3cef98afa2bdfe653c39c08ed8be01f6a082 Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Wed, 12 Aug 2026 16:45:27 +0200 Subject: [PATCH 06/10] fix --- .github/workflows/seedStickyDisks.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/seedStickyDisks.yml b/.github/workflows/seedStickyDisks.yml index 1218fcddf16a..81e89e560b70 100644 --- a/.github/workflows/seedStickyDisks.yml +++ b/.github/workflows/seedStickyDisks.yml @@ -37,12 +37,16 @@ jobs: seedHybrid: name: Seed hybrid node_modules + # Mobile-Expensify is private, so the submodule checkout needs OS_BOTIFY_TOKEN. Fork pull + # requests don't get secrets, and their hybrid builds are skipped anyway (see verifyHybridApp). + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }} runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - name: Checkout uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 with: submodules: true + token: ${{ secrets.OS_BOTIFY_TOKEN }} - name: Setup Node (seed) uses: ./.github/actions/composite/setupNode From 99c315d2ddf542ea9f0548546850d7b92bf70f50 Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Thu, 13 Aug 2026 10:25:14 +0200 Subject: [PATCH 07/10] make minimal fix --- .../actions/composite/setupNode/action.yml | 24 +-- .github/workflows/inspectStickyDisk.yml | 147 ------------------ .github/workflows/seedStickyDisks.yml | 55 ------- 3 files changed, 8 insertions(+), 218 deletions(-) delete mode 100644 .github/workflows/inspectStickyDisk.yml delete mode 100644 .github/workflows/seedStickyDisks.yml diff --git a/.github/actions/composite/setupNode/action.yml b/.github/actions/composite/setupNode/action.yml index d9bb77a3dc52..f92f528150c9 100644 --- a/.github/actions/composite/setupNode/action.yml +++ b/.github/actions/composite/setupNode/action.yml @@ -7,13 +7,6 @@ inputs: description: 'Indicates if node is set up for hybrid app' required: false default: 'false' - SEED_STICKY_DISKS: - description: | - 'true' only in the dedicated seed workflow: commits the sticky disk snapshots after installing. - Everywhere else the disks are mounted read-through (commit: false) so no job can ever pollute - the snapshot with build artifacts or tool caches. - required: false - default: 'false' outputs: cache-hit: @@ -37,17 +30,17 @@ runs: shell: bash run: echo "use-sticky-disk=${{ runner.os == 'Linux' && contains(runner.name, 'blacksmith') }}" >> "$GITHUB_OUTPUT" - # Snapshot commits are restricted to the dedicated seed workflow (SEED_STICKY_DISKS=true), which - # commits only when no snapshot exists yet for the key (if-missing). Every other consumer mounts - # with commit: false — whatever a job writes into the clone (gradle/CMake outputs, tool caches in - # node_modules/.cache) is discarded, so the billed snapshot always stays a clean npm ci result. + # if-missing: whichever job first mounts a fresh key commits its snapshot when the job ends; + # once a snapshot exists every later mount is read-through (the clone is discarded). The snapshot + # can carry one job's worth of build artifacts (whoever wins the initial race), but it can never + # grow after that, and the key rotates with every lockfile/patch change anyway. - id: stickydisk-node-modules if: steps.runner-info.outputs.use-sticky-disk == 'true' uses: useblacksmith/stickydisk@6d373c96a74cbde0c99fedc5ea5d3a7ba66ba494 # v1.4.0 with: key: ${{ format('{0}-{1}-{2}-node-modules-{3}', github.repository, runner.os, runner.arch, inputs.IS_HYBRID_BUILD == 'true' && hashFiles('normalized-package-lock.json', 'patches/**', 'Mobile-Expensify/patches/**') || hashFiles('normalized-package-lock.json', 'patches/**')) }} path: node_modules - commit: ${{ inputs.SEED_STICKY_DISKS == 'true' && 'if-missing' || 'false' }} + commit: if-missing - id: stickydisk-old-dot-node-modules if: steps.runner-info.outputs.use-sticky-disk == 'true' && inputs.IS_HYBRID_BUILD == 'true' @@ -55,11 +48,10 @@ runs: with: key: ${{ format('{0}-{1}-{2}-old-dot-node-modules-{3}', github.repository, runner.os, runner.arch, hashFiles('Mobile-Expensify/package-lock.json', 'Mobile-Expensify/patches/**')) }} path: Mobile-Expensify/node_modules - commit: ${{ inputs.SEED_STICKY_DISKS == 'true' && 'if-missing' || 'false' }} + commit: if-missing - # The npm cache disk has no hash in its key and barely grows (tarballs are content-addressed), - # so it isn't gated behind the seed workflow: on-change commits whenever npm ci actually wrote - # new tarballs and skips the many runs that never touch ~/.npm. + # The npm cache disk has no hash in its key and barely grows (tarballs are content-addressed): + # on-change commits whenever npm ci actually wrote new tarballs and skips runs that never touch ~/.npm. - id: stickydisk-npm if: steps.runner-info.outputs.use-sticky-disk == 'true' uses: useblacksmith/stickydisk@6d373c96a74cbde0c99fedc5ea5d3a7ba66ba494 # v1.4.0 diff --git a/.github/workflows/inspectStickyDisk.yml b/.github/workflows/inspectStickyDisk.yml deleted file mode 100644 index 8d5f664507fe..000000000000 --- a/.github/workflows/inspectStickyDisk.yml +++ /dev/null @@ -1,147 +0,0 @@ -name: Inspect sticky disk - -on: - workflow_dispatch: - inputs: - hybrid: - description: Also inspect the OldDot (Mobile-Expensify) node_modules disk - type: boolean - default: false - raw_key: - description: 'Exact sticky disk key to inspect (e.g. Expensify/App-Linux-X64-node-modules-0bf21fdb...). Overrides the computed keys.' - type: string - default: '' - delete_key: - description: 'Exact sticky disk key to DELETE (destructive: next run on this key pays a full npm ci / native rebuild).' - type: string - default: '' - push: - paths: - - '.github/workflows/inspectStickyDisk.yml' - pull_request: - paths: - - '.github/workflows/inspectStickyDisk.yml' - -jobs: - inspect: - runs-on: blacksmith-2vcpu-ubuntu-2404 - env: - # On push there are no inputs, so fall back to the hardcoded key under investigation - RAW_KEY: ${{ inputs.raw_key || 'Expensify/App-Linux-X64-node-modules-ebc680713b61d9a80ed52a0d3e4dc41b4c7d5393baa5db8b3929c01bbfb9675f' }} - # Set to a key (or hardcode one here for push runs) to delete that disk. fstrim/discard is not - # supported on sticky disks, so deletion is the only way to reclaim already-allocated blocks. - DELETE_KEY: ${{ inputs.delete_key || '' }} - steps: - - name: Checkout - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 - with: - submodules: ${{ inputs.hybrid || false }} - - # Must match setupNode exactly, otherwise the key hash differs and a fresh empty disk is mounted - - name: Remove E/App version from package-lock.json - run: jq 'del(.version, .packages[""].version)' package-lock.json > normalized-package-lock.json - - - name: Mount sticky disk by raw key - if: env.RAW_KEY != '' - uses: useblacksmith/stickydisk@6d373c96a74cbde0c99fedc5ea5d3a7ba66ba494 # v1.4.0 - with: - key: ${{ env.RAW_KEY }} - path: raw-disk - - - name: Mount NewDot node_modules sticky disk - if: env.RAW_KEY == '' - uses: useblacksmith/stickydisk@6d373c96a74cbde0c99fedc5ea5d3a7ba66ba494 # v1.4.0 - with: - key: ${{ format('{0}-{1}-{2}-node-modules-{3}', github.repository, runner.os, runner.arch, inputs.hybrid == true && hashFiles('normalized-package-lock.json', 'patches/**', 'Mobile-Expensify/patches/**') || hashFiles('normalized-package-lock.json', 'patches/**')) }} - path: node_modules - - - name: Mount OldDot node_modules sticky disk - if: inputs.hybrid == true - uses: useblacksmith/stickydisk@6d373c96a74cbde0c99fedc5ea5d3a7ba66ba494 # v1.4.0 - with: - key: ${{ format('{0}-{1}-{2}-old-dot-node-modules-{3}', github.repository, runner.os, runner.arch, hashFiles('Mobile-Expensify/package-lock.json', 'Mobile-Expensify/patches/**')) }} - path: Mobile-Expensify/node_modules - - - name: Mount npm cache sticky disk - if: env.RAW_KEY == '' - uses: useblacksmith/stickydisk@6d373c96a74cbde0c99fedc5ea5d3a7ba66ba494 # v1.4.0 - with: - key: ${{ format('{0}-{1}-{2}-npm', github.repository, runner.os, runner.arch) }} - path: ~/.npm - - - name: Inspect disks - shell: bash - run: | - shopt -s nullglob dotglob - inspect() { - local label="$1" path="$2" - echo "::group::$label ($path)" - if [[ ! -d "$path" ]]; then - echo "not mounted / does not exist" - echo "::endgroup::" - return 0 - fi - echo "=== df: real allocated blocks (what Blacksmith bills) ===" - df -h "$path" || true - echo - echo "=== du: logical file size ===" - du -sh "$path" 2>/dev/null || true - echo - echo "=== top-25 heaviest entries ===" - local entries=("$path"/*) - if (( ${#entries[@]} )); then - du -sh "${entries[@]}" 2>/dev/null | sort -rh | head -25 || true - else - echo "(empty)" - fi - echo "::endgroup::" - } - - if [[ -d raw-disk ]]; then - inspect "Raw disk ($RAW_KEY)" raw-disk - for sub in raw-disk/*/; do - inspect "Raw disk: ${sub}" "${sub%/}" - done - fi - inspect "NewDot node_modules" node_modules - inspect "NewDot node_modules/.cache" node_modules/.cache - inspect "npm cache" ~/.npm - if [[ -d Mobile-Expensify/node_modules ]]; then - inspect "OldDot node_modules" Mobile-Expensify/node_modules - fi - - - name: Clean build artifacts and trim raw disk - if: env.RAW_KEY != '' - shell: bash - run: | - echo "=== before cleanup ===" - df -h raw-disk - find raw-disk -type d -name '.cxx' -prune -exec rm -rf {} + 2>/dev/null || true - find raw-disk -type d -path '*/android/build' -prune -exec rm -rf {} + 2>/dev/null || true - find raw-disk -type d -path '*/android/.gradle' -prune -exec rm -rf {} + 2>/dev/null || true - echo "=== after cleanup ===" - df -h raw-disk - sudo fstrim -v raw-disk || echo "::warning::fstrim failed (discard may be unsupported)" - - - name: Delete sticky disk by key - if: env.DELETE_KEY != '' - uses: useblacksmith/stickydisk-delete@b41313d28b8647d72114c9ba3c96bb04061562b6 # v1 - with: - delete-key: ${{ env.DELETE_KEY }} - - - name: Summary - shell: bash - run: | - { - echo "## Sticky disk usage" - echo - echo "| Disk | df (billed) | du (logical) |" - echo "|---|---|---|" - for entry in "NewDot node_modules:node_modules" "npm cache:$HOME/.npm" "OldDot node_modules:Mobile-Expensify/node_modules"; do - label="${entry%%:*}"; path="${entry##*:}" - [[ -d "$path" ]] || continue - billed=$(df -h --output=used "$path" 2>/dev/null | tail -1 | tr -d ' ') - logical=$(du -sh "$path" 2>/dev/null | cut -f1) - echo "| $label | $billed | $logical |" - done - } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/seedStickyDisks.yml b/.github/workflows/seedStickyDisks.yml deleted file mode 100644 index 81e89e560b70..000000000000 --- a/.github/workflows/seedStickyDisks.yml +++ /dev/null @@ -1,55 +0,0 @@ -name: Seed sticky disks - -# The only workflow allowed to commit node_modules sticky disk snapshots (see setupNode's -# SEED_STICKY_DISKS input). It runs whenever the files that make up the disk keys change, -# installs dependencies once, and commits a clean snapshot for every other workflow to consume. -# Consumers that race ahead of the seed on a fresh key simply pay their own npm ci (commit: false), -# exactly like a regular cache miss. - -# Runs on every push and pull request, not just changes to the files the keys hash: a seed job whose -# key already has a snapshot mounts it and exits (commit: if-missing makes it a no-op), so the steady -# state costs one short 2vCPU job per run and guarantees no consumer ever races a missing snapshot. -on: - push: - branches: - - main - - staging - - production - pull_request: - types: [opened, synchronize] - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - seedStandalone: - name: Seed standalone node_modules - runs-on: blacksmith-2vcpu-ubuntu-2404 - steps: - - name: Checkout - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 - - - name: Setup Node (seed) - uses: ./.github/actions/composite/setupNode - with: - SEED_STICKY_DISKS: 'true' - - seedHybrid: - name: Seed hybrid node_modules - # Mobile-Expensify is private, so the submodule checkout needs OS_BOTIFY_TOKEN. Fork pull - # requests don't get secrets, and their hybrid builds are skipped anyway (see verifyHybridApp). - if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }} - runs-on: blacksmith-2vcpu-ubuntu-2404 - steps: - - name: Checkout - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 - with: - submodules: true - token: ${{ secrets.OS_BOTIFY_TOKEN }} - - - name: Setup Node (seed) - uses: ./.github/actions/composite/setupNode - with: - IS_HYBRID_BUILD: 'true' - SEED_STICKY_DISKS: 'true' From b01a28887ae32855cfbeac09730913d6731eb2fd Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Thu, 13 Aug 2026 11:00:07 +0200 Subject: [PATCH 08/10] add seedStickyDisks action --- .../actions/composite/setupNode/action.yml | 17 ++++---- .github/workflows/seedStickyDisks.yml | 41 +++++++++++++++++++ 2 files changed, 50 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/seedStickyDisks.yml diff --git a/.github/actions/composite/setupNode/action.yml b/.github/actions/composite/setupNode/action.yml index f92f528150c9..91cb2fb06426 100644 --- a/.github/actions/composite/setupNode/action.yml +++ b/.github/actions/composite/setupNode/action.yml @@ -7,6 +7,13 @@ inputs: description: 'Indicates if node is set up for hybrid app' required: false default: 'false' + SEED_STICKY_DISKS: + description: | + 'true' only in the seedStickyDisks workflow, which installs dependencies and nothing else. + Everywhere else the disks are mounted read-through so no job can persist build artifacts or + tool caches into the snapshot. + required: false + default: 'false' outputs: cache-hit: @@ -30,17 +37,13 @@ runs: shell: bash run: echo "use-sticky-disk=${{ runner.os == 'Linux' && contains(runner.name, 'blacksmith') }}" >> "$GITHUB_OUTPUT" - # if-missing: whichever job first mounts a fresh key commits its snapshot when the job ends; - # once a snapshot exists every later mount is read-through (the clone is discarded). The snapshot - # can carry one job's worth of build artifacts (whoever wins the initial race), but it can never - # grow after that, and the key rotates with every lockfile/patch change anyway. - id: stickydisk-node-modules if: steps.runner-info.outputs.use-sticky-disk == 'true' uses: useblacksmith/stickydisk@6d373c96a74cbde0c99fedc5ea5d3a7ba66ba494 # v1.4.0 with: key: ${{ format('{0}-{1}-{2}-node-modules-{3}', github.repository, runner.os, runner.arch, inputs.IS_HYBRID_BUILD == 'true' && hashFiles('normalized-package-lock.json', 'patches/**', 'Mobile-Expensify/patches/**') || hashFiles('normalized-package-lock.json', 'patches/**')) }} path: node_modules - commit: if-missing + commit: ${{ inputs.SEED_STICKY_DISKS == 'true' && 'if-missing' || 'false' }} - id: stickydisk-old-dot-node-modules if: steps.runner-info.outputs.use-sticky-disk == 'true' && inputs.IS_HYBRID_BUILD == 'true' @@ -48,10 +51,8 @@ runs: with: key: ${{ format('{0}-{1}-{2}-old-dot-node-modules-{3}', github.repository, runner.os, runner.arch, hashFiles('Mobile-Expensify/package-lock.json', 'Mobile-Expensify/patches/**')) }} path: Mobile-Expensify/node_modules - commit: if-missing + commit: ${{ inputs.SEED_STICKY_DISKS == 'true' && 'if-missing' || 'false' }} - # The npm cache disk has no hash in its key and barely grows (tarballs are content-addressed): - # on-change commits whenever npm ci actually wrote new tarballs and skips runs that never touch ~/.npm. - id: stickydisk-npm if: steps.runner-info.outputs.use-sticky-disk == 'true' uses: useblacksmith/stickydisk@6d373c96a74cbde0c99fedc5ea5d3a7ba66ba494 # v1.4.0 diff --git a/.github/workflows/seedStickyDisks.yml b/.github/workflows/seedStickyDisks.yml new file mode 100644 index 000000000000..470f7ec12479 --- /dev/null +++ b/.github/workflows/seedStickyDisks.yml @@ -0,0 +1,41 @@ +name: Seed sticky disks + +on: + push: + branches: + - main + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + seedStandalone: + name: Seed standalone node_modules + runs-on: blacksmith-2vcpu-ubuntu-2404 + steps: + - name: Checkout + uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 + + - name: Setup Node (seed) + uses: ./.github/actions/composite/setupNode + with: + SEED_STICKY_DISKS: 'true' + + seedHybrid: + name: Seed hybrid node_modules + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }} + runs-on: blacksmith-2vcpu-ubuntu-2404 + steps: + - name: Checkout + uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 + with: + submodules: true + token: ${{ secrets.OS_BOTIFY_TOKEN }} + + - name: Setup Node (seed) + uses: ./.github/actions/composite/setupNode + with: + IS_HYBRID_BUILD: 'true' + SEED_STICKY_DISKS: 'true' From 0f37a9c35575fc5d484d58e03a9c8ccaf93747fb Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Fri, 14 Aug 2026 10:40:22 +0200 Subject: [PATCH 09/10] we don't need pull_request trigger because of enabled branch protection --- .github/workflows/seedStickyDisks.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/seedStickyDisks.yml b/.github/workflows/seedStickyDisks.yml index 470f7ec12479..3c2623171967 100644 --- a/.github/workflows/seedStickyDisks.yml +++ b/.github/workflows/seedStickyDisks.yml @@ -4,7 +4,6 @@ on: push: branches: - main - pull_request: concurrency: group: ${{ github.workflow }}-${{ github.ref }} From 2321dfa048328df1d7e5515a2dc5c0ad78821647 Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Fri, 14 Aug 2026 10:54:18 +0200 Subject: [PATCH 10/10] remove condition --- .github/workflows/seedStickyDisks.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/seedStickyDisks.yml b/.github/workflows/seedStickyDisks.yml index 3c2623171967..39246bb81e94 100644 --- a/.github/workflows/seedStickyDisks.yml +++ b/.github/workflows/seedStickyDisks.yml @@ -24,7 +24,6 @@ jobs: seedHybrid: name: Seed hybrid node_modules - if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }} runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - name: Checkout