@@ -62,26 +62,76 @@ jobs:
6262 path : dist/
6363 retention-days : 7
6464
65+ # Proves the committed viewer bundle is exactly what building the committed
66+ # source produces. dist/ is 424 KB of minified JS that no reviewer can read,
67+ # so without this the bundle is an unreviewable trust input that any
68+ # contributor could poison independently of the TypeScript they submit. With
69+ # it, dist/ is a derived value CI re-derives from scratch on every PR, and the
70+ # only way into the viewer is through a reviewable source diff.
71+ #
72+ # This job is only meaningful if it cannot be skipped: for `pull_request`,
73+ # GitHub runs the workflow as written in the PR, so a PR may delete this job.
74+ # It must be a REQUIRED status check on main — otherwise a PR that removes it
75+ # merges green. See also CODEOWNERS on package*.json: `npm run build` executes
76+ # vite.config.ts and every plugin in node_modules, which `--ignore-scripts`
77+ # does not cover, so a lockfile change is arbitrary code execution here.
6578 frontend-build :
6679 runs-on : ubuntu-latest
80+ permissions :
81+ contents : read
6782 steps :
6883 - uses : actions/checkout@v4
6984 - name : Setup Node
7085 uses : actions/setup-node@v4
7186 with :
72- node-version : " 20"
73- - name : Build the trace viewer
87+ # Exact pin: this job's output IS the security property, so the
88+ # toolchain producing it should not float. Output was verified
89+ # byte-identical on 20.19.5 and 22.20.0, so a bump is safe.
90+ node-version : " 20.19.5"
91+ - name : Rebuild the viewer bundle from source
7492 working-directory : src/nooa/viewer/frontend-react
7593 run : |
94+ set -euo pipefail
7695 npm ci --ignore-scripts
96+ # Build into an EMPTY dist/. Two reasons, both load-bearing:
97+ # 1. A file that is committed but that the build does not produce
98+ # (i.e. smuggled in) then shows up as deleted rather than silently
99+ # surviving untouched.
100+ # 2. The build must not see the artifact it is being checked against.
101+ # src/index.css carries `@source not '../dist'` so Tailwind skips
102+ # it; wiping dist/ means the check holds even if that regresses.
103+ rm -rf dist
77104 npm run build
78- - name : Fail if dist/ is stale
105+ # Audit trail: the hashes CI derived, readable in the job log.
106+ sha256sum dist/index.html dist/assets/*
107+ - name : Fail if committed dist/ is not exactly what the build produced
79108 run : |
80- if ! git diff --quiet src/nooa/viewer/frontend-react/dist/; then
81- echo "ERROR: frontend-react/dist/ is stale. Run 'npm run build' and commit dist/."
82- git diff --stat src/nooa/viewer/frontend-react/dist/
109+ set -euo pipefail
110+ DIST=src/nooa/viewer/frontend-react/dist
111+ # Guard against a vacuous pass. `git status --porcelain <path>` warns
112+ # on stderr and prints NOTHING when the path does not resolve, which
113+ # reads as success — so a wrong path or a relocated dist/ would report
114+ # a clean build having verified nothing. Same failure mode the
115+ # gitleaks job guards against below. Assert we are looking at real,
116+ # tracked files before trusting an empty result.
117+ test -d "$DIST" \
118+ || { echo "::error::$DIST does not exist — the build did not produce it"; exit 1; }
119+ test "$(git ls-files -- "$DIST" | wc -l)" -gt 0 \
120+ || { echo "::error::no tracked files under $DIST — the check would pass vacuously"; exit 1; }
121+ # `git status --porcelain`, not `git diff`: git diff only reports
122+ # tracked-and-modified files, so it passes a PR that ADDS a file to
123+ # dist/. Porcelain reports ' M' modified, '??' produced-but-uncommitted
124+ # and ' D'/'AD' committed-but-not-produced — all three are failures.
125+ CHANGES=$(git status --porcelain -- "$DIST")
126+ if [ -n "$CHANGES" ]; then
127+ echo "::error::dist/ does not match a clean rebuild of the committed source."
128+ echo "$CHANGES"
129+ echo
130+ echo "If you changed the viewer UI, rebuild and commit the result:"
131+ echo " cd src/nooa/viewer/frontend-react && npm ci --ignore-scripts && rm -rf dist && npm run build"
83132 exit 1
84133 fi
134+ echo "OK — committed dist/ is reproducible from source."
85135
86136 secret-scan :
87137 runs-on : ubuntu-latest
0 commit comments