-
Notifications
You must be signed in to change notification settings - Fork 22
Fix plugin build reproducibility and generated scaffold validation #2052
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| import { mkdtemp, readFile, rm } from "node:fs/promises"; | ||
| import { tmpdir } from "node:os"; | ||
| import { join } from "node:path"; | ||
| import { afterEach, describe, expect, it } from "vitest"; | ||
| import { createPlugin } from "./create-plugin"; | ||
|
|
||
| const generatedDirectories: string[] = []; | ||
|
|
||
| afterEach(async () => { | ||
| await Promise.all( | ||
| generatedDirectories.splice(0).map((directory) => | ||
| rm(directory, { recursive: true, force: true }), | ||
| ), | ||
| ); | ||
| }); | ||
|
|
||
| describe("createPlugin", () => { | ||
| it("generates a self-contained scaffold with build and validation commands", async () => { | ||
| const targetDirectory = await mkdtemp(join(tmpdir(), "unraid-plugin-generator-")); | ||
| generatedDirectories.push(targetDirectory); | ||
|
|
||
| const pluginDirectory = await createPlugin("example-plugin", targetDirectory); | ||
| const packageJson = JSON.parse( | ||
| await readFile(join(pluginDirectory, "package.json"), "utf8"), | ||
| ); | ||
| const tsconfig = JSON.parse( | ||
| await readFile(join(pluginDirectory, "tsconfig.json"), "utf8"), | ||
| ); | ||
| const indexSource = await readFile(join(pluginDirectory, "src", "index.ts"), "utf8"); | ||
| const generatedFiles = await Promise.all( | ||
| ["config.entity.ts", "index.ts", "example-plugin.resolver.ts"].map((file) => | ||
| readFile(join(pluginDirectory, "src", file), "utf8"), | ||
| ), | ||
| ); | ||
|
|
||
| expect(packageJson.scripts.validate).toBe("npm run build && npm pack --dry-run"); | ||
| expect(packageJson.devDependencies.graphql).toBeDefined(); | ||
| expect(packageJson.peerDependencies.graphql).toBeDefined(); | ||
| expect(tsconfig.compilerOptions.skipLibCheck).toBe(true); | ||
| expect(indexSource).not.toContain("ConfigPersister"); | ||
| expect(generatedFiles.join("\n")).not.toContain("@unraid/shared"); | ||
| }); | ||
| }); | ||
This file was deleted.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,7 +1,7 @@ | ||
| import { join } from "path"; | ||
| import { $, cd } from "zx"; | ||
| import { existsSync } from "node:fs"; | ||
| import { readdir, writeFile } from "node:fs/promises"; | ||
| import { readdir } from "node:fs/promises"; | ||
| import { getTxzName, pluginName, startingDir } from "./utils/consts"; | ||
| import { ensureNodeJs } from "./utils/nodejs-helper"; | ||
|
|
||
|
|
@@ -11,6 +11,7 @@ import { apiDir } from "./utils/paths"; | |
| import { getVendorBundleName, getVendorFullPath } from "./build-vendor-store"; | ||
| import { getAssetUrl } from "./utils/bucket-urls"; | ||
| import { validateStandaloneManifest, getStandaloneManifestPath } from "./utils/manifest-validator"; | ||
| import { writeVendorArchiveInfo } from "./utils/vendor-archive"; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
if rg -nP 'from "\./(?:utils/)?vendor-archive";' \
plugin/builder/build-txz.ts \
plugin/builder/utils/vendor-archive.test.ts; then
exit 1
fiRepository: unraid/api Length of output: 349 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf 'Tracked files:\n'
git ls-files plugin/builder/build-txz.ts plugin/builder/utils/vendor-archive.ts plugin/builder/utils/vendor-archive.test.ts
printf '\nRelevant imports (with file context):\n'
for f in plugin/builder/build-txz.ts plugin/builder/utils/vendor-archive.test.ts; do
echo "--- $f"
sed -n '1,30p' "$f"
done
printf '\nPackage / TS config hints:\n'
for f in package.json tsconfig.json; do
if [ -f "$f" ]; then
echo "--- $f"
sed -n '1,220p' "$f"
fi
doneRepository: unraid/api Length of output: 6134 Add These imports follow the repository’s ESM convention, where TypeScript imports should include the
📍 Affects 2 files
🤖 Prompt for AI AgentsSource: Coding guidelines |
||
|
|
||
|
|
||
| // Check for manifest files in expected locations | ||
|
|
@@ -89,7 +90,7 @@ const storeVendorArchiveInfo = async (version: string, vendorUrl: string, vendor | |
| }); | ||
|
|
||
| const configPath = join(configDir, "vendor_archive.json"); | ||
| await writeFile(configPath, JSON.stringify(configData, null, 2)); | ||
| await writeVendorArchiveInfo(configPath, configData); | ||
|
|
||
| console.log(`Vendor archive information stored in ${configPath}`); | ||
| console.log(`API Version: ${version}`); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| import { mkdtemp, readFile, stat } from "node:fs/promises"; | ||
| import { tmpdir } from "node:os"; | ||
| import { join } from "node:path"; | ||
| import { describe, expect, it } from "vitest"; | ||
| import { writeVendorArchiveInfo } from "./vendor-archive"; | ||
|
|
||
| describe("writeVendorArchiveInfo", () => { | ||
| it("keeps generated content and archive metadata deterministic", async () => { | ||
| const directory = await mkdtemp(join(tmpdir(), "vendor-archive-")); | ||
| const configPath = join(directory, "config", "vendor_archive.json"); | ||
|
Comment on lines
+9
to
+10
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win Remove the temporary directory after the test.
🤖 Prompt for AI Agents |
||
| const config = { | ||
| vendor_store_path: "/boot/config/plugins/dynamix.my.servers/vendor-4.36.0.tar.zst", | ||
| api_version: "4.36.0", | ||
| }; | ||
|
|
||
| await writeVendorArchiveInfo(configPath, config); | ||
| const firstContents = await readFile(configPath); | ||
| const firstStat = await stat(configPath); | ||
|
|
||
| await writeVendorArchiveInfo(configPath, config); | ||
| const secondContents = await readFile(configPath); | ||
| const secondStat = await stat(configPath); | ||
|
|
||
| expect(secondContents).toEqual(firstContents); | ||
|
Comment on lines
+16
to
+24
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Exercise the existing-file update path. Both writes use the same configuration, so a regression that fails to update stale metadata still passes. Add a second configuration with a changed version/path and assert that the existing file is updated. 🤖 Prompt for AI Agents |
||
| expect(firstStat.mtimeMs).toBe(0); | ||
| expect(secondStat.mtimeMs).toBe(0); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| import { mkdir, readFile, utimes, writeFile } from "node:fs/promises"; | ||
| import { dirname } from "node:path"; | ||
|
|
||
| export interface VendorArchiveInfo { | ||
| vendor_store_path: string; | ||
| api_version: string; | ||
| } | ||
|
|
||
| // vendor_archive.json is generated during every TXZ build. A wall-clock mtime | ||
| // on that one file made otherwise unchanged package trees produce different | ||
| // tar streams and hashes. Epoch is valid for tar and gives every builder the | ||
| // same metadata without depending on checkout or invocation time. | ||
| const DETERMINISTIC_MTIME = new Date(0); | ||
|
|
||
| export async function writeVendorArchiveInfo( | ||
| configPath: string, | ||
| configData: VendorArchiveInfo, | ||
| ): Promise<void> { | ||
| const contents = `${JSON.stringify(configData, null, 2)}\n`; | ||
|
|
||
| await mkdir(dirname(configPath), { recursive: true }); | ||
|
|
||
| let currentContents: string | undefined; | ||
| try { | ||
| currentContents = await readFile(configPath, "utf8"); | ||
| } catch (error) { | ||
| if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) { | ||
| throw error; | ||
| } | ||
| } | ||
|
|
||
| if (currentContents !== contents) { | ||
| await writeFile(configPath, contents, "utf8"); | ||
| } | ||
|
|
||
| await utimes(configPath, DETERMINISTIC_MTIME, DETERMINISTIC_MTIME); | ||
|
Comment on lines
+33
to
+36
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
rg -n -C3 'writeFile|chmod|mode|makepkg|chown|tar' plugin/builder apiRepository: unraid/api Length of output: 50367 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "== target file =="
wc -l plugin/builder/utils/vendor-archive.ts
cat -n plugin/builder/utils/vendor-archive.ts
echo "== focused plugin/builder files =="
git ls-files 'plugin/builder/**' | sed -n '1,200p'
rg -n "vendor_archive|vendor-archive|txz|tar|cpio|compress|makepkg|chmod|chmod|utimes|writeFile|appendFile|mkdir" plugin/builder -S --glob '!**/node_modules/**' --glob '!**/dist/**'
echo "== focused api scripts references =="
rg -n "vendor_archive|vendor-archive|txz|makepkg|plugin.*builder|builder/utils/vendor-archive" api -S --max-count 100Repository: unraid/api Length of output: 25756 🌐 Web query:
💡 Result: In Node.js, the fs.promises.writeFile method uses a default file mode of 0o666 when creating a new file [1][2][3]. This mode specifies that the file is readable and writable by the owner, group, and others [4]. Important considerations regarding this mode include: 1. Interaction with umask: The mode you specify (or the default 0o666) is not necessarily the final permission set for the file [4]. When a file is created, the operating system applies the process umask, which removes specific permission bits [4]. For example, if the process umask is 0o022, a file created with the default 0o666 mode will result in final permissions of 0o644 (read/write for the owner, and read-only for the group and others) [4]. 2. Scope of the mode option: The mode option only affects the file if it is newly created [1][3]. If the file already exists and is being overwritten by writeFile, the existing file permissions remain unchanged [1][3]. 3. Thread-safety warning: Relying on the current process umask or modifying it using process.umask can be problematic [4]. Node.js has deprecated the no-argument form of process.umask (DEP0139) because it is not thread-safe; calling it involves a temporary change to the process mask that could affect concurrent file operations in a multi-threaded or asynchronous environment [4]. Instead of modifying the umask, it is generally recommended to explicitly pass the desired mode in the options object if specific permissions are required for the new file [4]. Citations:
Normalize Normalize the file permissions after writing, for example by setting the mode to 🤖 Prompt for AI Agents |
||
| } | ||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: unraid/api
Length of output: 1150
🏁 Script executed:
Repository: unraid/api
Length of output: 10016
🏁 Script executed:
Repository: unraid/api
Length of output: 463
Use the
.jsextension for the local ESM import.packages/unraid-api-plugin-generator/tsconfig.jsonusesmodule: NodeNext/moduleResolution: nodenext, so relative imports insrc/**/*.test.tsneed file extensions.Proposed fix
📝 Committable suggestion
🤖 Prompt for AI Agents
Source: Coding guidelines