Skip to content

Stop dev-script changes from rebuilding + recreating the prod NAS image

Why this exists

Editing a top-level scripts/* file (e.g. scripts/sync-taskboard.sh) was triggering a full production NAS image rebuild + eop-app container recreate β€” for tooling the running app never uses. Spotted while deploying T-047's chop fix: a one-line taskboard-helper change kicked off a ~5-minute image build and a prod container restart. Two causes, both "scripts/ isn't treated as dev-only":

  1. .github/workflows/nas-image.yml runs on push to main with paths-ignore: ['docs/**', '**.md'] β€” scripts/** was not ignored, so a scripts-only push started a build.
  2. .dockerignore did not exclude top-level scripts/ (only services/bank-login-service/scripts), so COPY . . baked it into the image β†’ the image digest changed β†’ the deploy step (which recreates only when OLD_ID != NEW_ID) recreated the container.

Investigation β€” is any scripts/* file needed at build time? NO.

  • package.json: build = next build, start = next start; no postinstall/prepare/prebuild hook. The node scripts/* entries (dev:ocbc, ocbc:proxy, scan:footers, embed:fonts, …) are manual dev utilities, never run by build or start.
  • No app code imports from scripts/ (grep of app/ lib/ pages/ components/ for from '…scripts/' / require('…scripts/') β†’ nothing), and no runtime disk read of scripts/.
  • The ONE build-time reference β€” next.config.ts:146 webpack loader path.resolve(__dirname, "scripts/replaceImportMetaLoader.js") β€” points at a file that is untracked and absent on disk (git ls-files + find β†’ nothing). Its rule only matches …/antd/node_modules/classnames/index.js and …/rc-pagination/node_modules/classnames/index.js, and both are absent (npm hoists classnames to the root node_modules/classnames), so the rule never matches and the loader is never invoked. path.resolve only builds a string (doesn't read the file), so next build succeeds without it β€” which it already does, since CI (actions/checkout) never had the untracked file. Conclusion: scripts/ has zero present build dependency.

The fix (both halves, kept in lock-step)

  • .dockerignore β€” exclude the dev tooling, re-include the one loader path for safety:
    /scripts/*
    !/scripts/replaceImportMetaLoader.js
    
    β†’ dev-script edits no longer change the image digest β†’ deploy skips the recreate.
  • nas-image.yml paths-ignore β€” add scripts/** (and !scripts/replaceImportMetaLoader.js so a real loader change still rebuilds) β†’ a scripts-only push doesn't build at all.
  • The loader is re-included in BOTH so the two never disagree: if it's ever restored as a real build input, it ships in the image AND its changes trigger a build.

Reasoning-pass verification

  • docs-only push β†’ matches docs/** β†’ skip build β†’ no recreate. βœ“ (unchanged)
  • scripts-only push (e.g. sync-taskboard.sh) β†’ matches scripts/** β†’ skip build; and even if forced via workflow_dispatch, the image digest is unchanged (scripts/ excluded) β†’ skip recreate. βœ“
  • app-code push β†’ no ignore match β†’ build runs β†’ image changes β†’ recreate. βœ“ (unchanged)
  • mixed app+script push β†’ the app file doesn't match any ignore β†’ build runs normally. βœ“
  • loader change (if it's ever created) β†’ !scripts/replaceImportMetaLoader.js un-ignores it β†’ build runs, and the re-include keeps it in the image. βœ“
  • One-time cost: the commit that lands this change touches .dockerignore (not ignored), so it rebuilds once and recreates once (the new image no longer contains scripts/); from then on, scripts-only pushes are free.

Landed + empirically confirmed (2026-06-24)

  • Commit ad9be2dc β†’ main. Its nas-image.yml run all green, incl. step 6 build-push and step 7 "Deploy to NAS (pre-pull + recreate)". This is the empirical proof of the core claim: the image was built with scripts/ EXCLUDED from the Docker context (the new .dockerignore) and the build still succeeded β€” so nothing in scripts/ is a build dependency, exactly as analysed. The one-time recreate ran; prod now serves a scripts/-free image.
  • The remaining behaviour (a scripts-only push triggers no Build NAS app image run, and a forced workflow_dispatch would skip the recreate on an unchanged digest) is now in effect; it'll be visible on the next real scripts-only change.

Decision record

  • βœ… Attestation (EOP Local Assistance (fork)): read AGENTS.md; checked the board by scope β€” T-073/T-091/T-082 (the deploy tasks) are all done, no open task covers this, so opened T-104. Source: EOP Local Assistance (fork) Β· session e527df5f-d3ea-4716-8076-b46319d9d830 (local Claude Code).
  • Proposed: EOP Local Assistance (fork) (spotted during the T-047 chop-fix deploy β€” a sync-taskboard.sh edit triggered a prod rebuild).
  • Approved: the owner started this as a background task (the chip spun off from that observation).
  • Owner's framing (verbatim, 2026-06-24): "make dev-tooling script changes NOT churn the prod image" … "Preferred: add scripts/ … to nas-image.yml's paths-ignore … BEFORE doing this, VERIFY no scripts/* file is needed at Docker build time … And/or add scripts/ to .dockerignore." Did exactly that, plus the loader-path verification the owner asked for.
  • Blast radius: .dockerignore + nas-image.yml only β€” pure build/deploy config; no app code, no runtime behaviour change. The first deploy after this drops scripts/ from the image (slightly smaller image; nothing runtime reads it). Cross-cuts the deploy pipeline (T-091 auto-deploy, T-073 layer caching) β€” agents touching those should know scripts/ is now image-excluded + build-skipped.

Follow-up β€” DONE (2026-06-24)

next.config.ts referenced the untracked/vestigial webpack loader scripts/replaceImportMetaLoader.js β€” a latent trap (if a dep bump gave antd/rc-pagination a nested classnames, the rule would match and the build would fail on the missing loader). Resolved by removing the dead rule (the loader file, the CLASSNAMES_FRAGMENTS test list, and the now-unused path import), keeping the standalone module.parser.javascript.importMeta = true flag β€” which is the real import.meta handler (the loader never ran: it was never committed, and its rule never matched with classnames hoisted). tsc + eslint clean; next build re-verified by the CI image build on the landing commit. Owner approved ("proceed with that", 2026-06-24).