diff --git a/org.coloradomesh.MeshClient.yml b/org.coloradomesh.MeshClient.yml index 1f5ff9308..1a15fa927 100644 --- a/org.coloradomesh.MeshClient.yml +++ b/org.coloradomesh.MeshClient.yml @@ -38,6 +38,9 @@ modules: env: PNPM_HOME: /run/build/mesh-client/.pnpm pnpm_config_cache: /run/build/mesh-client/.npm + # pnpm 11 supply-chain / verifyDepsBeforeRun must not hit the registry offline. + PNPM_CONFIG_TRUST_LOCKFILE: true + PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN: false build-commands: # Install pnpm standalone from archived release (no network needed). # Archive extracts to pnpm-vendor/ (strip-components: 0) so the root `pnpm` binary is diff --git a/scripts/check-flatpak.mjs b/scripts/check-flatpak.mjs index 3ae4d4699..435a1fb25 100644 --- a/scripts/check-flatpak.mjs +++ b/scripts/check-flatpak.mjs @@ -2,6 +2,7 @@ import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; +import { offlinePnpmEnvContractViolations } from './flatpakOfflinePnpmEnv.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.resolve(__dirname, '..'); @@ -132,6 +133,9 @@ function checkManifestPnpmVersion(pkg) { const rel = path.relative(ROOT, MANIFEST); const pnpmVersion = pnpmVersionFromPackage(pkg); + // Offline Flatpak builds cannot re-verify minimumReleaseAge / trustPolicy against npm. + violations.push(...offlinePnpmEnvContractViolations(yaml, rel)); + if (!pnpmVersion) return violations; const releaseUrlPrefix = `pnpm/pnpm/releases/download/v${pnpmVersion}/`; diff --git a/scripts/flatpak-pnpm-bin.test.mjs b/scripts/flatpak-pnpm-bin.test.mjs index e638cc328..10f3c3717 100644 --- a/scripts/flatpak-pnpm-bin.test.mjs +++ b/scripts/flatpak-pnpm-bin.test.mjs @@ -3,6 +3,10 @@ import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; import { describe, expect, it } from 'vitest'; +import { + offlinePnpmEnvContractViolations, + parseMeshClientModuleBuildEnv, +} from './flatpakOfflinePnpmEnv.mjs'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const MANIFEST = path.join(ROOT, 'org.coloradomesh.MeshClient.yml'); @@ -15,4 +19,36 @@ describe('Flatpak pnpm standalone install', () => { ); expect(yaml).toMatch(/cp -a pnpm-vendor\/dist \/run\/build\/mesh-client\/\.pnpm-bin\/dist/); }); + + it('requires unquoted offline pnpm booleans in mesh-client build-options.env', () => { + const yaml = fs.readFileSync(MANIFEST, 'utf8'); + const env = parseMeshClientModuleBuildEnv(yaml); + expect(env).not.toBeNull(); + expect(env?.PNPM_CONFIG_TRUST_LOCKFILE).toBe(true); + expect(env?.PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN).toBe(false); + expect(offlinePnpmEnvContractViolations(yaml)).toEqual([]); + }); + + it('rejects quoted or commented offline pnpm env values', () => { + const quoted = ` +modules: + - name: mesh-client + build-options: + env: + PNPM_CONFIG_TRUST_LOCKFILE: 'true' + PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN: 'false' +`; + expect(offlinePnpmEnvContractViolations(quoted).length).toBe(2); + + const commentedOnly = ` +modules: + - name: mesh-client + build-options: + env: + # PNPM_CONFIG_TRUST_LOCKFILE: true + # PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN: false + PNPM_HOME: /run/build/mesh-client/.pnpm +`; + expect(offlinePnpmEnvContractViolations(commentedOnly).length).toBe(2); + }); }); diff --git a/scripts/flatpak-pnpm-install.mjs b/scripts/flatpak-pnpm-install.mjs index 7e18fefd8..f00318d5c 100644 --- a/scripts/flatpak-pnpm-install.mjs +++ b/scripts/flatpak-pnpm-install.mjs @@ -8,6 +8,11 @@ * Failure point: host/CI runs without the Flatpak-vendored store at STORE_DIR. * pnpm 11+ may report "Already up to date" from an existing node_modules even when * --store-dir is missing; refuse to proceed so Flatpak always uses the offline store. + * + * Failure point: pnpm 11 re-verifies minimumReleaseAge / trustPolicy against the + * registry for every lockfile entry ("Verifying lockfile against supply-chain + * policies"). Flatpak build has no usable DNS, so those GETs hang on EAI_AGAIN. + * Fallback: --config.trust-lockfile=true (CI already resolved the lockfile online). */ import fs from 'fs'; import { spawnSync } from 'child_process'; @@ -17,40 +22,56 @@ import { cleanJsrTempDirs } from './clean-jsr-temp-dirs.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const projectRoot = path.resolve(__dirname, '..'); -const STORE_DIR = '/run/build/mesh-client/flatpak-node/pnpm-store'; +export const STORE_DIR = '/run/build/mesh-client/flatpak-node/pnpm-store'; const maxAttempts = 3; -if (!fs.existsSync(STORE_DIR)) { - console.error( - `[flatpak-pnpm] offline store missing: ${STORE_DIR} (Flatpak sandbox path required)`, - ); - process.exit(1); -} - -let lastStatus = 1; +/** Args passed to `pnpm` for Flatpak offline install (exported for contract tests). */ +export const FLATPAK_PNPM_INSTALL_ARGS = [ + 'install', + '--frozen-lockfile', + '--offline', + '--ignore-scripts', + '--config.trust-lockfile=true', + '--store-dir', + STORE_DIR, +]; -for (let attempt = 1; attempt <= maxAttempts; attempt++) { - if (attempt > 1) { - cleanJsrTempDirs(path.join(projectRoot, 'node_modules')); +export function runFlatpakPnpmInstall() { + if (!fs.existsSync(STORE_DIR)) { + console.error( + `[flatpak-pnpm] offline store missing: ${STORE_DIR} (Flatpak sandbox path required)`, + ); + process.exit(1); } - const result = spawnSync( - 'pnpm', - ['install', '--frozen-lockfile', '--offline', '--ignore-scripts', '--store-dir', STORE_DIR], - { + let lastStatus = 1; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + if (attempt > 1) { + cleanJsrTempDirs(path.join(projectRoot, 'node_modules')); + } + + const result = spawnSync('pnpm', FLATPAK_PNPM_INSTALL_ARGS, { cwd: projectRoot, stdio: 'inherit', - }, - ); - lastStatus = result.status ?? 1; - if (lastStatus === 0) { - process.exit(0); - } - if (attempt < maxAttempts) { - console.warn( - `[flatpak-pnpm] pnpm install failed (attempt ${attempt}/${maxAttempts}), retrying…`, - ); + }); + lastStatus = result.status ?? 1; + if (lastStatus === 0) { + process.exit(0); + } + if (attempt < maxAttempts) { + console.warn( + `[flatpak-pnpm] pnpm install failed (attempt ${attempt}/${maxAttempts}), retrying…`, + ); + } } + + process.exit(lastStatus); } -process.exit(lastStatus); +const isDirectRun = + process.argv[1] != null && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); + +if (isDirectRun) { + runFlatpakPnpmInstall(); +} diff --git a/scripts/flatpak-pnpm-install.test.mjs b/scripts/flatpak-pnpm-install.test.mjs index a8d421a7d..dac5571f3 100644 --- a/scripts/flatpak-pnpm-install.test.mjs +++ b/scripts/flatpak-pnpm-install.test.mjs @@ -3,6 +3,7 @@ import { spawnSync } from 'child_process'; import { fileURLToPath } from 'url'; import path from 'path'; import { describe, expect, it } from 'vitest'; +import { FLATPAK_PNPM_INSTALL_ARGS } from './flatpak-pnpm-install.mjs'; const scriptPath = path.join( path.dirname(fileURLToPath(import.meta.url)), @@ -18,4 +19,11 @@ describe('flatpak-pnpm-install.mjs', () => { expect(result.status).not.toBe(0); expect(result.stderr + result.stdout).toMatch(/flatpak-pnpm|ERR_PNPM|offline|no such file/i); }); + + it('passes trust-lockfile so offline install skips registry supply-chain re-verify', () => { + expect(FLATPAK_PNPM_INSTALL_ARGS).toContain('--config.trust-lockfile=true'); + expect(FLATPAK_PNPM_INSTALL_ARGS).toContain('--offline'); + expect(FLATPAK_PNPM_INSTALL_ARGS).toContain('--frozen-lockfile'); + expect(FLATPAK_PNPM_INSTALL_ARGS).toContain('--ignore-scripts'); + }); }); diff --git a/scripts/flatpakOfflinePnpmEnv.mjs b/scripts/flatpakOfflinePnpmEnv.mjs new file mode 100644 index 000000000..cf7645103 --- /dev/null +++ b/scripts/flatpakOfflinePnpmEnv.mjs @@ -0,0 +1,98 @@ +/** + * Shared Flatpak manifest contract for offline pnpm 11 install. + * + * Failure point: loose YAML regex can match commented or quoted keys outside the + * mesh-client module env map. Fallback: parse only that scoped env block and + * require exact unquoted YAML booleans. + */ + +/** + * @param {string} yaml + * @returns {Record | null} + */ +export function parseMeshClientModuleBuildEnv(yaml) { + const moduleMatch = yaml.match(/^ {2}- name: mesh-client\s*$/m); + if (!moduleMatch || moduleMatch.index == null) return null; + + const fromModule = yaml.slice(moduleMatch.index); + const nextModuleOffset = fromModule.slice(1).search(/^ {2}- name: /m); + const moduleBlock = + nextModuleOffset === -1 ? fromModule : fromModule.slice(0, nextModuleOffset + 1); + + const envMatch = moduleBlock.match(/^ {6}env:\s*$/m); + if (!envMatch || envMatch.index == null) return null; + + const envIndent = 6; + const afterEnv = moduleBlock.slice(envMatch.index + envMatch[0].length); + /** @type {Record} */ + const env = {}; + + for (const line of afterEnv.split('\n')) { + if (line.trim() === '') continue; + const indent = line.match(/^ */)[0].length; + if (indent <= envIndent) break; + + const trimmed = line.trim(); + if (trimmed.startsWith('#')) continue; + + const m = trimmed.match(/^([A-Za-z0-9_]+):\s*(.+)$/); + if (!m) continue; + + const [, key, raw] = m; + if (raw === 'true') { + env[key] = true; + } else if (raw === 'false') { + env[key] = false; + } else if ( + (raw.startsWith("'") && raw.endsWith("'")) || + (raw.startsWith('"') && raw.endsWith('"')) + ) { + // Quoted values are strings, not YAML booleans — reject for required keys. + env[key] = raw.slice(1, -1); + } else { + env[key] = raw; + } + } + + return env; +} + +/** + * @param {string} yaml + * @param {string} [fileRel] + * @returns {{ file: string, message: string }[]} + */ +export function offlinePnpmEnvContractViolations( + yaml, + fileRel = 'org.coloradomesh.MeshClient.yml', +) { + const env = parseMeshClientModuleBuildEnv(yaml); + /** @type {{ file: string, message: string }[]} */ + const violations = []; + + if (!env) { + violations.push({ + file: fileRel, + message: 'manifest mesh-client module build-options.env is missing', + }); + return violations; + } + + if (env.PNPM_CONFIG_TRUST_LOCKFILE !== true) { + violations.push({ + file: fileRel, + message: + 'manifest mesh-client build-options.env must set PNPM_CONFIG_TRUST_LOCKFILE: true (unquoted boolean; skip registry supply-chain re-verify offline)', + }); + } + + if (env.PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN !== false) { + violations.push({ + file: fileRel, + message: + 'manifest mesh-client build-options.env must set PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN: false (unquoted boolean; pnpm run must not auto-install offline)', + }); + } + + return violations; +}