mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-07-09 17:28:45 +00:00
chore: harden dependency workflows
This commit is contained in:
parent
aa4adac766
commit
17cc86a479
9 changed files with 174 additions and 4 deletions
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
|
|
@ -30,7 +30,7 @@ jobs:
|
||||||
sudo ln -s $(which fdfind) /usr/local/bin/fd
|
sudo ln -s $(which fdfind) /usr/local/bin/fd
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci --ignore-scripts
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
|
||||||
31
.github/workflows/npm-audit.yml
vendored
Normal file
31
.github/workflows/npm-audit.yml
vendored
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
name: npm audit
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '37 7 * * *'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
audit:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Install dependencies without lifecycle scripts
|
||||||
|
run: npm ci --ignore-scripts --no-audit --no-fund
|
||||||
|
|
||||||
|
- name: Audit production vulnerabilities
|
||||||
|
run: npm audit --omit=dev --audit-level=moderate
|
||||||
|
|
||||||
|
- name: Verify registry signatures
|
||||||
|
run: npm audit signatures --omit=dev
|
||||||
|
|
@ -3,6 +3,11 @@
|
||||||
# Get list of staged files before running check
|
# Get list of staged files before running check
|
||||||
STAGED_FILES=$(git diff --cached --name-only)
|
STAGED_FILES=$(git diff --cached --name-only)
|
||||||
|
|
||||||
|
node scripts/check-lockfile-commit.mjs
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
# Run the check script (formatting, linting, and type checking)
|
# Run the check script (formatting, linting, and type checking)
|
||||||
echo "Running formatting, linting, and type checking..."
|
echo "Running formatting, linting, and type checking..."
|
||||||
npm run check
|
npm run check
|
||||||
|
|
|
||||||
2
.npmrc
2
.npmrc
|
|
@ -1,2 +1,2 @@
|
||||||
save-exact=true
|
save-exact=true
|
||||||
min-release-age=14
|
min-release-age=2
|
||||||
|
|
|
||||||
14
README.md
14
README.md
|
|
@ -70,6 +70,20 @@ npm run check # Lint, format, and type check
|
||||||
./pi-test.sh # Run pi from sources (can be run from any directory)
|
./pi-test.sh # Run pi from sources (can be run from any directory)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Supply-chain hardening
|
||||||
|
|
||||||
|
We treat npm dependency changes as reviewed code changes.
|
||||||
|
|
||||||
|
- Direct external dependencies are pinned to exact versions. Internal workspace packages remain version-ranged.
|
||||||
|
- `.npmrc` sets `save-exact=true` and `min-release-age=2` to avoid same-day dependency releases during npm resolution.
|
||||||
|
- `package-lock.json` is the dependency ground truth. Pre-commit blocks accidental lockfile commits unless `PI_ALLOW_LOCKFILE_CHANGE=1` is set.
|
||||||
|
- `npm run check` verifies pinned direct deps, native TypeScript import compatibility, and the generated coding-agent shrinkwrap.
|
||||||
|
- The published CLI package includes `packages/coding-agent/npm-shrinkwrap.json`, generated from the root lockfile, to pin transitive deps for npm users.
|
||||||
|
- Release smoke tests use `npm run release:local` to build, pack, and install outside the repo before publishing.
|
||||||
|
- Local release installs, documented npm installs, and `pi update --self` use `--ignore-scripts` where supported.
|
||||||
|
- CI installs with `npm ci --ignore-scripts`, and a scheduled GitHub workflow runs `npm audit --omit=dev` plus `npm audit signatures --omit=dev`.
|
||||||
|
- Shrinkwrap generation has an explicit allowlist for dependency lifecycle scripts; new lifecycle-script deps fail checks until reviewed.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT
|
MIT
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,7 @@ if [[ -n "$PLATFORM" ]]; then
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "==> Installing dependencies..."
|
echo "==> Installing dependencies..."
|
||||||
npm ci
|
npm ci --ignore-scripts
|
||||||
|
|
||||||
if [[ "$SKIP_DEPS" == "false" ]]; then
|
if [[ "$SKIP_DEPS" == "false" ]]; then
|
||||||
echo "==> Installing cross-platform native bindings..."
|
echo "==> Installing cross-platform native bindings..."
|
||||||
|
|
|
||||||
94
scripts/check-lockfile-commit.mjs
Normal file
94
scripts/check-lockfile-commit.mjs
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
|
||||||
|
const allowValue = process.env.PI_ALLOW_LOCKFILE_CHANGE;
|
||||||
|
const allowed = allowValue === "1" || allowValue === "true" || allowValue === "yes";
|
||||||
|
|
||||||
|
function git(args) {
|
||||||
|
return execFileSync("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJsonFromGit(ref) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(git(["show", ref]));
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function packageNameFromLockPath(lockPath) {
|
||||||
|
const marker = "node_modules/";
|
||||||
|
const index = lockPath.lastIndexOf(marker);
|
||||||
|
if (index === -1) return lockPath || "<root>";
|
||||||
|
const parts = lockPath.slice(index + marker.length).split("/");
|
||||||
|
return parts[0]?.startsWith("@") ? `${parts[0]}/${parts[1]}` : parts[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function packageLabel(lockPath, entry) {
|
||||||
|
const name = entry?.name ?? packageNameFromLockPath(lockPath);
|
||||||
|
return entry?.version ? `${name}@${entry.version}` : name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeLockfileChange() {
|
||||||
|
const before = readJsonFromGit("HEAD:package-lock.json");
|
||||||
|
const after = readJsonFromGit(":package-lock.json");
|
||||||
|
if (!before?.packages || !after?.packages) return [];
|
||||||
|
|
||||||
|
const changes = [];
|
||||||
|
const paths = new Set([...Object.keys(before.packages), ...Object.keys(after.packages)]);
|
||||||
|
for (const lockPath of [...paths].sort()) {
|
||||||
|
if (!lockPath.includes("node_modules/")) continue;
|
||||||
|
const oldEntry = before.packages[lockPath];
|
||||||
|
const newEntry = after.packages[lockPath];
|
||||||
|
if (!oldEntry && newEntry) {
|
||||||
|
changes.push(`added ${packageLabel(lockPath, newEntry)}`);
|
||||||
|
} else if (oldEntry && !newEntry) {
|
||||||
|
changes.push(`removed ${packageLabel(lockPath, oldEntry)}`);
|
||||||
|
} else if (oldEntry?.version !== newEntry?.version) {
|
||||||
|
changes.push(
|
||||||
|
`changed ${packageNameFromLockPath(lockPath)} ${oldEntry?.version ?? "<none>"} -> ${newEntry?.version ?? "<none>"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changes;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stagedFiles = git(["diff", "--cached", "--name-only"])
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
if (!stagedFiles.includes("package-lock.json")) {
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allowed) {
|
||||||
|
console.error("package-lock.json is staged; PI_ALLOW_LOCKFILE_CHANGE is set, allowing commit.");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error("package-lock.json is staged.");
|
||||||
|
console.error("");
|
||||||
|
console.error("Review lockfile changes before committing:");
|
||||||
|
console.error(" - confirm every new/updated package is intentional");
|
||||||
|
console.error(" - confirm npm age gates were active for resolution");
|
||||||
|
console.error(" - review any new lifecycle scripts in the dependency tree");
|
||||||
|
console.error(" - regenerate/check coding-agent shrinkwrap if release deps changed");
|
||||||
|
|
||||||
|
const changes = summarizeLockfileChange();
|
||||||
|
if (changes.length > 0) {
|
||||||
|
console.error("");
|
||||||
|
console.error("Detected package version changes:");
|
||||||
|
for (const change of changes.slice(0, 40)) {
|
||||||
|
console.error(` - ${change}`);
|
||||||
|
}
|
||||||
|
if (changes.length > 40) {
|
||||||
|
console.error(` ... ${changes.length - 40} more`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error("");
|
||||||
|
console.error("If this lockfile change is intentional, commit with:");
|
||||||
|
console.error(" PI_ALLOW_LOCKFILE_CHANGE=1 git commit ...");
|
||||||
|
process.exit(1);
|
||||||
|
|
@ -10,6 +10,11 @@ const codingAgentDir = join(repoRoot, "packages/coding-agent");
|
||||||
const rootLockfilePath = join(repoRoot, "package-lock.json");
|
const rootLockfilePath = join(repoRoot, "package-lock.json");
|
||||||
const shrinkwrapPath = join(codingAgentDir, "npm-shrinkwrap.json");
|
const shrinkwrapPath = join(codingAgentDir, "npm-shrinkwrap.json");
|
||||||
const internalPackagePrefix = "@earendil-works/pi-";
|
const internalPackagePrefix = "@earendil-works/pi-";
|
||||||
|
const allowedInstallScriptPackages = new Map([
|
||||||
|
["@google/genai@1.52.0", "preinstall is a no-op in the published package"],
|
||||||
|
["koffi@2.16.2", "optional native package ships prebuilt modules used without install scripts"],
|
||||||
|
["protobufjs@7.5.9", "postinstall only warns about protobufjs version scheme mismatches"],
|
||||||
|
]);
|
||||||
|
|
||||||
const args = new Set(process.argv.slice(2));
|
const args = new Set(process.argv.slice(2));
|
||||||
const checkOnly = args.has("--check");
|
const checkOnly = args.has("--check");
|
||||||
|
|
@ -221,6 +226,7 @@ function validateShrinkwrap(shrinkwrap, internalNames) {
|
||||||
const errors = [];
|
const errors = [];
|
||||||
const includedPaths = new Set(Object.keys(shrinkwrap.packages));
|
const includedPaths = new Set(Object.keys(shrinkwrap.packages));
|
||||||
const includedPackageNames = new Set();
|
const includedPackageNames = new Set();
|
||||||
|
const seenAllowedInstallScriptPackages = new Set();
|
||||||
|
|
||||||
for (const [lockPath, entry] of Object.entries(shrinkwrap.packages)) {
|
for (const [lockPath, entry] of Object.entries(shrinkwrap.packages)) {
|
||||||
const packageName = packageNameFromLockPath(lockPath);
|
const packageName = packageNameFromLockPath(lockPath);
|
||||||
|
|
@ -233,6 +239,26 @@ function validateShrinkwrap(shrinkwrap, internalNames) {
|
||||||
if (typeof entry.resolved === "string" && /^(file:|link:|workspace:|\.\.?\/|\/)/.test(entry.resolved)) {
|
if (typeof entry.resolved === "string" && /^(file:|link:|workspace:|\.\.?\/|\/)/.test(entry.resolved)) {
|
||||||
errors.push(`${lockPath} has a local resolved value: ${entry.resolved}`);
|
errors.push(`${lockPath} has a local resolved value: ${entry.resolved}`);
|
||||||
}
|
}
|
||||||
|
if (entry.hasInstallScript) {
|
||||||
|
if (!packageName || !entry.version) {
|
||||||
|
errors.push(`${lockPath || "root"} has install scripts but no package name/version`);
|
||||||
|
} else {
|
||||||
|
const packageId = `${packageName}@${entry.version}`;
|
||||||
|
if (allowedInstallScriptPackages.has(packageId)) {
|
||||||
|
seenAllowedInstallScriptPackages.add(packageId);
|
||||||
|
} else {
|
||||||
|
errors.push(
|
||||||
|
`${lockPath} has install scripts (${packageId}). Review it and add it to allowedInstallScriptPackages if intentional.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const packageId of allowedInstallScriptPackages.keys()) {
|
||||||
|
if (!seenAllowedInstallScriptPackages.has(packageId)) {
|
||||||
|
errors.push(`allowed install-script package ${packageId} is no longer present; remove it from the allowlist`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const name of internalNames) {
|
for (const name of internalNames) {
|
||||||
|
|
|
||||||
|
|
@ -167,7 +167,7 @@ if (!options.skipInstall) {
|
||||||
`${JSON.stringify({ private: true, dependencies }, undefined, "\t")}\n`,
|
`${JSON.stringify({ private: true, dependencies }, undefined, "\t")}\n`,
|
||||||
);
|
);
|
||||||
|
|
||||||
run("npm", ["install", "--omit=dev"], { cwd: installDirectory });
|
run("npm", ["install", "--omit=dev", "--ignore-scripts"], { cwd: installDirectory });
|
||||||
mkdirSync(binDirectory, { recursive: true });
|
mkdirSync(binDirectory, { recursive: true });
|
||||||
symlinkSync(join(installDirectory, "node_modules", ".bin", "pi"), join(binDirectory, "pi"));
|
symlinkSync(join(installDirectory, "node_modules", ".bin", "pi"), join(binDirectory, "pi"));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue