Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,10 @@ jobs:
run: |
FBTOOLS=git+https://github.com/flatpak/flatpak-builder-tools
# Keep in sync with scripts/flatpakPnpmStoreVersion.mjs FLATPAK_NODE_GENERATOR_COMMIT.
pip3 install "${FBTOOLS}@ac5a296ac6111aa2319daf532f609a067b88d8a9#subdirectory=node"
# --force-reinstall: same 0.1.0 version across commits; plain pip is a no-op on
# preinstalled images and leaves the storeDir= generator.
pip3 install --force-reinstall --no-cache-dir \
"${FBTOOLS}@ac5a296ac6111aa2319daf532f609a067b88d8a9#subdirectory=node"

- name: Check Flatpak offline pnpm sources
run: pnpm run check:flatpak-offline-pnpm
Expand Down
17 changes: 16 additions & 1 deletion .github/workflows/flatpak.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -90,13 +90,28 @@ jobs:
run: |
FBTOOLS=git+https://github.com/flatpak/flatpak-builder-tools
# ac5a296a+: YAML storeDir: for pnpm v11 (storeDir= breaks pnpm-workspace.yaml).
pip3 install "${FBTOOLS}@ac5a296ac6111aa2319daf532f609a067b88d8a9#subdirectory=node"
# --force-reinstall: image may preinstall flatpak_node_generator==0.1.0; same
# version makes plain pip install a no-op (leaves storeDir= generator).
pip3 install --force-reinstall --no-cache-dir \
"${FBTOOLS}@ac5a296ac6111aa2319daf532f609a067b88d8a9#subdirectory=node"
# pnpm 11+ uses store v11; flatpak-node-generator defaults to v10 for lockfile 9.
PNPM_MAJOR="$(node -p "require('./package.json').packageManager.match(/^pnpm@(\\d+)/)[1]")"
STORE_VERSION="v${PNPM_MAJOR}"
flatpak-node-generator pnpm pnpm-lock.yaml \
--pnpm-store-version "$STORE_VERSION" \
-o flatpak/generated-sources.json
# Fail fast if a stale generator still emitted npmrc-style storeDir=.
node --input-type=module <<'EOF'
import fs from 'node:fs';
import { generatedSourcesStoreDirYamlViolations } from './scripts/flatpakPnpmStoreVersion.mjs';
const sources = JSON.parse(fs.readFileSync('flatpak/generated-sources.json', 'utf8'));
const violations = generatedSourcesStoreDirYamlViolations(sources);
if (violations.length) {
for (const v of violations) console.error(v.message);
process.exit(1);
}
console.log('generated-sources storeDir YAML: ok');
EOF

# flatpak/flatpak-github-actions v6 appends -${arch} to the artifact name on upload;
# keep bundle arch-agnostic here to avoid org.coloradomesh.MeshClient-aarch64-aarch64.flatpak.
Expand Down
5 changes: 4 additions & 1 deletion docs/development-environment.md
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,10 @@ flatpak install --user -y flathub org.electronjs.Electron2.BaseApp//24.08
**3. Generate offline pnpm sources** (re-run whenever `pnpm-lock.yaml` changes)

```bash
pip install flatpak-node-generator
# Prefer the CI pin with --force-reinstall (images may preinstall an older 0.1.0).
# See scripts/flatpakPnpmStoreVersion.mjs FLATPAK_NODE_GENERATOR_GIT.
pip install --force-reinstall --no-cache-dir \
"git+https://github.com/flatpak/flatpak-builder-tools@ac5a296ac6111aa2319daf532f609a067b88d8a9#subdirectory=node"
# Must match package.json packageManager major (pnpm 11 → v11). Generator defaults to v10.
PNPM_MAJOR="$(node -p "require('./package.json').packageManager.match(/^pnpm@(\\d+)/)[1]")"
STORE_VERSION="v${PNPM_MAJOR}"
Expand Down
6 changes: 4 additions & 2 deletions org.coloradomesh.MeshClient.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,11 @@ modules:
env:
PNPM_HOME: /run/build/mesh-client/.pnpm
pnpm_config_cache: /run/build/mesh-client/.npm
# Values must be YAML strings (quoted). Unquoted true/false become JSON
# booleans and flatpak-builder drops the entire env map (GStrv warning).
# pnpm 11 supply-chain / verifyDepsBeforeRun must not hit the registry offline.
PNPM_CONFIG_TRUST_LOCKFILE: true
PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN: false
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
Expand Down
2 changes: 1 addition & 1 deletion scripts/check-flatpak-offline-pnpm.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ function generateOfflineSources(expectedStoreVersion) {
`flatpak-node-generator not found on PATH (and FLATPAK_NODE_GENERATOR unset).\n` +
` Install the CI pin, then re-run:\n` +
` python3 -m venv .cache/flatpak-node-venv\n` +
` .cache/flatpak-node-venv/bin/pip install '${FLATPAK_NODE_GENERATOR_GIT}'\n` +
` .cache/flatpak-node-venv/bin/pip install --force-reinstall --no-cache-dir '${FLATPAK_NODE_GENERATOR_GIT}'\n` +
` export PATH="$PWD/.cache/flatpak-node-venv/bin:$PATH"\n` +
` pnpm run check:flatpak-offline-pnpm`,
};
Expand Down
14 changes: 13 additions & 1 deletion scripts/check-flatpak.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import path from 'path';
import { fileURLToPath } from 'url';
import { offlinePnpmEnvContractViolations } from './flatpakOfflinePnpmEnv.mjs';
import {
flatpakWorkflowGeneratorInstallViolations,
flatpakWorkflowStoreVersionViolations,
storeVersionFromPackageManager,
} from './flatpakPnpmStoreVersion.mjs';
Expand All @@ -15,6 +16,7 @@ const METAINFO = path.join(ROOT, 'flatpak', 'org.coloradomesh.MeshClient.metainf
const DESKTOP = path.join(ROOT, 'flatpak', 'org.coloradomesh.MeshClient.desktop');
const MANIFEST = path.join(ROOT, 'org.coloradomesh.MeshClient.yml');
const FLATPAK_WORKFLOW = path.join(ROOT, '.github/workflows/flatpak.yaml');
const CI_WORKFLOW = path.join(ROOT, '.github/workflows/ci.yaml');
const WRAPPER = path.join(ROOT, 'flatpak', 'mesh-client-wrapper.sh');
const PKG = path.join(ROOT, 'package.json');
const EXPECTED_APP_ID = 'org.coloradomesh.MeshClient';
Expand Down Expand Up @@ -189,7 +191,17 @@ function checkFlatpakWorkflowStoreVersion(pkg) {
if (!expected) return violations;

const yaml = fs.readFileSync(FLATPAK_WORKFLOW, 'utf8');
return flatpakWorkflowStoreVersionViolations(yaml, expected);
violations.push(...flatpakWorkflowStoreVersionViolations(yaml, expected));

// CI installs the same pin for check:flatpak-offline-pnpm — must also force-reinstall.
if (fs.existsSync(CI_WORKFLOW)) {
const ciYaml = fs.readFileSync(CI_WORKFLOW, 'utf8');
violations.push(
...flatpakWorkflowGeneratorInstallViolations(ciYaml, '.github/workflows/ci.yaml'),
);
}

return violations;
}

function checkManifestBranchAndElectronPayload(pkg) {
Expand Down
23 changes: 13 additions & 10 deletions scripts/flatpak-pnpm-bin.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,33 +20,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', () => {
it('requires quoted offline pnpm env strings for flatpak-builder GStrv', () => {
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(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 = `
it('rejects unquoted YAML booleans (GStrv drops the whole env map)', () => {
const unquoted = `
modules:
- name: mesh-client
build-options:
env:
PNPM_CONFIG_TRUST_LOCKFILE: 'true'
PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN: 'false'
PNPM_CONFIG_TRUST_LOCKFILE: true
PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN: false
`;
expect(offlinePnpmEnvContractViolations(quoted).length).toBe(2);
expect(offlinePnpmEnvContractViolations(unquoted).length).toBe(2);
expect(offlinePnpmEnvContractViolations(unquoted)[0].message).toMatch(/GStrv|quoted/);
});

it('rejects missing or commented offline pnpm env values', () => {
const commentedOnly = `
modules:
- name: mesh-client
build-options:
env:
# PNPM_CONFIG_TRUST_LOCKFILE: true
# PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN: false
# 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);
Expand Down
66 changes: 66 additions & 0 deletions scripts/flatpak-pnpm-install.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ import { spawnSync } from 'child_process';
import { fileURLToPath } from 'url';
import path from 'path';
import { cleanJsrTempDirs } from './clean-jsr-temp-dirs.mjs';
import {
stripNpmrcStoreDirLines,
stripPnpmWorkspaceStoreDirLines,
} from './flatpakPnpmStoreVersion.mjs';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(__dirname, '..');
Expand All @@ -36,6 +40,66 @@ export const FLATPAK_PNPM_INSTALL_ARGS = [
STORE_DIR,
];

/**
* Remove generator-appended storeDir / store-dir lines so pnpm can parse the
* workspace and `--store-dir` (sandbox path) wins over a host-cache path.
*
* @param {string} [root]
* @returns {{ workspaceRemoved: number, npmrcRemoved: number }}
*/
/**
* Read a UTF-8 file if present. Avoid existsSync→read TOCTOU (CodeQL js/file-system-race).
* @param {string} filePath
* @returns {string | null}
*/
function readUtf8IfPresent(filePath) {
try {
return fs.readFileSync(filePath, 'utf8');
} catch (err) {
if (
err &&
typeof err === 'object' &&
/** @type {{ code?: unknown }} */ (err).code === 'ENOENT'
) {
return null;
}
throw err;
}
}

export function sanitizeFlatpakPnpmStoreDirConfig(root = projectRoot) {
const workspacePath = path.join(root, 'pnpm-workspace.yaml');
const npmrcPath = path.join(root, '.npmrc');
let workspaceRemoved = 0;
let npmrcRemoved = 0;

const workspaceBefore = readUtf8IfPresent(workspacePath);
if (workspaceBefore != null) {
const { yaml, removed } = stripPnpmWorkspaceStoreDirLines(workspaceBefore);
workspaceRemoved = removed;
if (removed > 0) {
fs.writeFileSync(workspacePath, yaml, 'utf8');
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
console.warn(
`[flatpak-pnpm] stripped ${removed} storeDir line(s) from pnpm-workspace.yaml (using --store-dir ${STORE_DIR})`,
);
}
}

const npmrcBefore = readUtf8IfPresent(npmrcPath);
if (npmrcBefore != null) {
const { text, removed } = stripNpmrcStoreDirLines(npmrcBefore);
npmrcRemoved = removed;
if (removed > 0) {
fs.writeFileSync(npmrcPath, text, 'utf8');
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
console.warn(
`[flatpak-pnpm] stripped ${removed} store-dir line(s) from .npmrc (using --store-dir ${STORE_DIR})`,
);
}
}

return { workspaceRemoved, npmrcRemoved };
}

export function runFlatpakPnpmInstall() {
if (!fs.existsSync(STORE_DIR)) {
console.error(
Expand All @@ -44,6 +108,8 @@ export function runFlatpakPnpmInstall() {
process.exit(1);
}

sanitizeFlatpakPnpmStoreDirConfig(projectRoot);

let lastStatus = 1;

for (let attempt = 1; attempt <= maxAttempts; attempt++) {
Expand Down
48 changes: 45 additions & 3 deletions scripts/flatpak-pnpm-install.test.mjs
Original file line number Diff line number Diff line change
@@ -1,15 +1,29 @@
// @vitest-environment node
import { spawnSync } from 'child_process';
import { fileURLToPath } from 'url';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { describe, expect, it } from 'vitest';
import { FLATPAK_PNPM_INSTALL_ARGS } from './flatpak-pnpm-install.mjs';
import { fileURLToPath } from 'url';
import { afterEach, describe, expect, it } from 'vitest';
import {
FLATPAK_PNPM_INSTALL_ARGS,
sanitizeFlatpakPnpmStoreDirConfig,
} from './flatpak-pnpm-install.mjs';

const scriptPath = path.join(
path.dirname(fileURLToPath(import.meta.url)),
'flatpak-pnpm-install.mjs',
);

/** @type {string[]} */
const tempRoots = [];

afterEach(() => {
for (const root of tempRoots.splice(0)) {
fs.rmSync(root, { recursive: true, force: true });
}
});

describe('flatpak-pnpm-install.mjs', () => {
it('exits non-zero when offline store is unavailable outside sandbox', () => {
const result = spawnSync(process.execPath, [scriptPath], {
Expand All @@ -25,5 +39,33 @@ describe('flatpak-pnpm-install.mjs', () => {
expect(FLATPAK_PNPM_INSTALL_ARGS).toContain('--offline');
expect(FLATPAK_PNPM_INSTALL_ARGS).toContain('--frozen-lockfile');
expect(FLATPAK_PNPM_INSTALL_ARGS).toContain('--ignore-scripts');
expect(FLATPAK_PNPM_INSTALL_ARGS).toContain('--store-dir');
});

it('strips generator storeDir= from workspace before install (Flatpak CI regression)', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mesh-flatpak-sanitize-'));
tempRoots.push(root);
fs.writeFileSync(
path.join(root, 'pnpm-workspace.yaml'),
'nodeLinker: hoisted\nstoreDir=/__w/mesh-client/bad-store\n',
'utf8',
);
fs.writeFileSync(path.join(root, '.npmrc'), 'store-dir=/__w/host\n', 'utf8');

const result = sanitizeFlatpakPnpmStoreDirConfig(root);
expect(result).toEqual({ workspaceRemoved: 1, npmrcRemoved: 1 });
expect(fs.readFileSync(path.join(root, 'pnpm-workspace.yaml'), 'utf8')).toBe(
'nodeLinker: hoisted\n',
);
expect(fs.readFileSync(path.join(root, '.npmrc'), 'utf8')).toBe('');
});

it('tolerates missing workspace/.npmrc without existsSync TOCTOU', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mesh-flatpak-sanitize-missing-'));
tempRoots.push(root);
expect(sanitizeFlatpakPnpmStoreDirConfig(root)).toEqual({
workspaceRemoved: 0,
npmrcRemoved: 0,
});
});
});
20 changes: 13 additions & 7 deletions scripts/flatpakOfflinePnpmEnv.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,12 @@
* 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.
* mesh-client module env map. Fallback: parse only that scoped env block.
*
* Failure point: flatpak-builder deserializes `env` as GStrv (string values only).
* Unquoted YAML `true`/`false` become JSON booleans → entire env map is dropped
* (`Failed to deserialize "env" property of type "GStrv"`). Fallback: require
* quoted `'true'` / `'false'` strings.
*/

/**
Expand Down Expand Up @@ -47,7 +51,6 @@ export function parseMeshClientModuleBuildEnv(yaml) {
(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;
Expand Down Expand Up @@ -78,19 +81,22 @@ export function offlinePnpmEnvContractViolations(
return violations;
}

if (env.PNPM_CONFIG_TRUST_LOCKFILE !== true) {
// GStrv requires string values — quoted 'true'/'false', not YAML booleans.
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)',
"manifest mesh-client build-options.env must set PNPM_CONFIG_TRUST_LOCKFILE: 'true' " +
'(quoted string for flatpak-builder GStrv; unquoted true drops the whole env map)',
});
}

if (env.PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN !== false) {
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)',
"manifest mesh-client build-options.env must set PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN: 'false' " +
'(quoted string for flatpak-builder GStrv; unquoted false drops the whole env map)',
});
}

Expand Down
Loading