fix(build): pin config/relay-assets LF so one release is one relay hash (#19024)

* fix(build): pin config/relay-assets LF so one release is one relay hash

* test(build): correct why the negative fixtures exist

Review measured it: the first assertion checks the eol attribute via
check-attr, not file content, so it fails first without the pin. The
fixtures add over-broadness coverage, they do not carry the test.

* test(build): key the relay line-ending pin off the manifest, not a directory

A path glob proves the directory is non-empty, not that it is still the
directory build-relay reads from. Relocating an asset into config/scripts
(where only **/*.mjs is pinned) reintroduced the CRLF bug with the suite
fully green. RELAY_ARTIFACTS is the right anchor: build-relay refuses to
emit an artifact absent from it, so a relocated or new asset cannot slip
past. Bundles have no tracked source and drop out with zero hits.

---------

Co-authored-by: Orca Worker <orca-worker@localhost>
This commit is contained in:
OrcaWin
2026-09-06 00:02:05 -07:00
committed by GitHub
co-authored by Orca Worker
parent e73f8dfa0f
commit 0f27445789
2 changed files with 85 additions and 0 deletions
+5
View File
@@ -8,6 +8,11 @@
/src/cli/bundled-skill-guides.ts text eol=lf
# Bundled plugin trees are byte-hashed; CRLF checkout would break the pinned hash.
/resources/plugins/** text eol=lf
# Relay assets are copied verbatim into the bundle and hashed byte-for-byte into
# .version, which names the immutable remote install dir. A CRLF checkout makes a
# Windows-built client disagree with a mac/Linux-built one on the same release,
# so one host ends up with two relay trees (#17886 review).
/config/relay-assets/** text eol=lf
# Pin the bytes so a patch reads and diffs identically on every host. It is NOT
# what makes the hash right: pnpm hashes a patch LF-normalized, so a CRLF checkout
# cannot change it. Believing otherwise put a hand-computed raw digest in the
@@ -0,0 +1,80 @@
import { execFileSync } from 'node:child_process'
import { resolve } from 'node:path'
import { RELAY_ARTIFACTS } from '../../src/shared/relay-artifacts.ts'
import { describe, expect, it } from 'vitest'
/**
* Guard the `.gitattributes` pin that keeps `config/relay-assets` on LF.
*
* `core.autocrlf=true` ships in the Git-for-Windows system config, so without a
* pin a Windows runner checks these out as CRLF. build-relay.mjs copies them
* verbatim into the bundle and hashes them byte-for-byte into `.version`, which
* names the immutable remote relay directory -- so a Windows-built client and a
* mac/Linux-built one disagree on the same release, and one SSH host ends up with
* two relay trees, each paying its own remote native-dep compile.
*
* Measured on v1.4.197: master-cloexec-patch.cjs shipped at 11229 bytes from the
* mac runner and 11547 (= 11229 + 318 lines) from the Windows one.
*/
const projectDir = resolve(import.meta.dirname, '../..')
function git(args) {
return execFileSync('git', args, { cwd: projectDir, encoding: 'utf8' })
}
/** `git check-attr -z` emits NUL-separated path/attr/value triples. */
function eolAttributes(paths) {
const fields = git(['check-attr', '-z', 'eol', '--', ...paths]).split('\0')
const found = new Map()
for (let index = 0; index + 2 < fields.length; index += 3) {
found.set(fields[index], fields[index + 2])
}
return found
}
/**
* Keyed off the manifest, not a directory: build-relay refuses to emit an
* artifact absent from RELAY_ARTIFACTS, so relocating an asset cannot slip
* past this the way a path glob would. esbuild bundles have no tracked
* source and contribute no hits, so they need no classifying.
*/
function trackedManifestSources() {
const paths = new Set()
for (const { filename } of RELAY_ARTIFACTS) {
const hits = git(['ls-files', '-z', '--', `*/${filename}`]).split('\0').filter(Boolean)
for (const path of hits) {
paths.add(path)
}
}
return [...paths]
}
describe('config/relay-assets line-ending pin', () => {
it('pins every tracked relay artifact source to LF', () => {
const assets = trackedManifestSources()
expect(assets.length).toBeGreaterThan(0)
const attributes = eolAttributes(assets)
const unpinned = assets.filter((path) => attributes.get(path) !== 'lf')
expect(
unpinned,
'A relay asset left on the platform default gets CRLF on a Windows runner, ' +
'which changes the .version hash and splits one release across two remote ' +
'relay directories. Pin it in .gitattributes.'
).toEqual([])
})
// Why: the assertion above only sees files that exist today. These fix the
// pattern itself -- broad enough to cover a file added tomorrow, narrow enough
// not to claim neighbours.
it.each([
['config/relay-assets/example.cjs', 'lf'],
['config/relay-assets/nested/deeper/example.cjs', 'lf'],
['config/relay-assets/example.txt', 'lf'],
['config/relay-assets-extra/example.cjs', 'unspecified'],
['vendor/config/relay-assets/example.cjs', 'unspecified']
])('resolves %s to eol=%s', (path, expected) => {
expect(eolAttributes([path]).get(path)).toBe(expected)
})
})