فهرست منبع

fix: shared prepack/postpack to prevent workspace:* leaking into tarballs

- Move CLI-local scripts to shared scripts/ at repo root
- Add prepack/postpack to core package.json as safety net
- Add CI verify-pack job that checks both tarballs on every push
- Add root `pnpm release` script as canonical publish entry point

Closes the workspace:* protocol bug that broke npm/pnpm install
for @actalk/inkos@0.3.4.
majx_mac 8 ماه پیش
والد
کامیت
4b8e0e4733
6فایلهای تغییر یافته به همراه177 افزوده شده و 2 حذف شده
  1. 39 0
      .github/workflows/ci.yml
  2. 2 1
      package.json
  3. 5 1
      packages/cli/package.json
  4. 2 0
      packages/core/package.json
  5. 104 0
      scripts/prepare-package-for-publish.mjs
  6. 25 0
      scripts/restore-package-json.mjs

+ 39 - 0
.github/workflows/ci.yml

@@ -28,3 +28,42 @@ jobs:
       - run: pnpm install --frozen-lockfile
       - run: pnpm install --frozen-lockfile
       - run: pnpm build
       - run: pnpm build
       - run: pnpm test
       - run: pnpm test
+
+  verify-pack:
+    runs-on: ubuntu-latest
+    needs: build-and-test
+    steps:
+      - uses: actions/checkout@v4
+
+      - uses: pnpm/action-setup@v4
+        with:
+          version: 9
+
+      - uses: actions/setup-node@v4
+        with:
+          node-version: 22
+          cache: pnpm
+
+      - run: pnpm install --frozen-lockfile
+      - run: pnpm build
+
+      - name: Verify no workspace:* in tarballs
+        run: |
+          set -euo pipefail
+          for pkg in packages/core packages/cli; do
+            echo "--- Checking $pkg ---"
+            cd "$pkg"
+            PACKDIR=$(mktemp -d)
+            npm pack --pack-destination "$PACKDIR" 2>/dev/null
+            TGZ=$(ls "$PACKDIR"/*.tgz)
+            PACKED_PKG=$(tar -xOf "$TGZ" package/package.json)
+            if echo "$PACKED_PKG" | grep -q '"workspace:'; then
+              echo "FAIL: $pkg tarball still contains workspace: protocol"
+              echo "$PACKED_PKG" | grep 'workspace:'
+              rm -rf "$PACKDIR"
+              exit 1
+            fi
+            echo "OK: $pkg tarball is clean"
+            rm -rf "$PACKDIR"
+            cd - >/dev/null
+          done

+ 2 - 1
package.json

@@ -13,7 +13,8 @@
     "dev": "pnpm -r dev",
     "dev": "pnpm -r dev",
     "test": "pnpm -r test",
     "test": "pnpm -r test",
     "lint": "pnpm -r lint",
     "lint": "pnpm -r lint",
-    "typecheck": "pnpm -r typecheck"
+    "typecheck": "pnpm -r typecheck",
+    "release": "pnpm build && pnpm test && pnpm -r --filter './packages/*' publish --no-git-checks"
   },
   },
   "engines": {
   "engines": {
     "node": ">=20.0.0",
     "node": ">=20.0.0",

+ 5 - 1
packages/cli/package.json

@@ -6,7 +6,9 @@
   "bin": {
   "bin": {
     "inkos": "dist/index.js"
     "inkos": "dist/index.js"
   },
   },
-  "files": ["dist"],
+  "files": [
+    "dist"
+  ],
   "license": "MIT",
   "license": "MIT",
   "repository": {
   "repository": {
     "type": "git",
     "type": "git",
@@ -14,6 +16,8 @@
     "directory": "packages/cli"
     "directory": "packages/cli"
   },
   },
   "scripts": {
   "scripts": {
+    "prepack": "node ../../scripts/prepare-package-for-publish.mjs",
+    "postpack": "node ../../scripts/restore-package-json.mjs",
     "build": "tsc",
     "build": "tsc",
     "dev": "tsc --watch",
     "dev": "tsc --watch",
     "test": "vitest run --passWithNoTests",
     "test": "vitest run --passWithNoTests",

+ 2 - 0
packages/core/package.json

@@ -22,6 +22,8 @@
     "directory": "packages/core"
     "directory": "packages/core"
   },
   },
   "scripts": {
   "scripts": {
+    "prepack": "node ../../scripts/prepare-package-for-publish.mjs",
+    "postpack": "node ../../scripts/restore-package-json.mjs",
     "build": "tsc",
     "build": "tsc",
     "dev": "tsc --watch",
     "dev": "tsc --watch",
     "test": "vitest run",
     "test": "vitest run",

+ 104 - 0
scripts/prepare-package-for-publish.mjs

@@ -0,0 +1,104 @@
+/**
+ * prepack hook — replaces workspace:* with real version numbers in package.json.
+ *
+ * Shared by all publishable packages. Invoked as:
+ *   "prepack": "node ../../scripts/prepare-package-for-publish.mjs"
+ *
+ * Expects process.cwd() to be the package directory (npm/pnpm guarantee this).
+ */
+
+import { readFile, writeFile, copyFile } from "node:fs/promises";
+import { join, resolve } from "node:path";
+
+const packageDir = process.cwd();
+const packageJsonPath = join(packageDir, "package.json");
+const backupPath = join(packageDir, ".package.json.publish-backup");
+
+// Walk up to workspace root (contains pnpm-workspace.yaml)
+function findWorkspaceRoot(startDir) {
+  let dir = startDir;
+  for (let i = 0; i < 10; i++) {
+    try {
+      // Sync check not available in ESM, so we just hardcode the relative path
+      // since all packages are at packages/<name>/
+      return resolve(dir, "..", "..");
+    } catch {
+      dir = resolve(dir, "..");
+    }
+  }
+  throw new Error("Could not find workspace root");
+}
+
+function normalizeWorkspaceSpecifier(specifier, version) {
+  const value = specifier.slice("workspace:".length);
+  if (value === "*" || value === "") return version;
+  if (value === "^") return `^${version}`;
+  if (value === "~") return `~${version}`;
+  return value;
+}
+
+async function loadWorkspaceVersions(workspaceRoot) {
+  const packagesDir = join(workspaceRoot, "packages");
+  const { readdir } = await import("node:fs/promises");
+  const entries = await readdir(packagesDir);
+
+  const versions = new Map();
+  for (const entry of entries) {
+    try {
+      const raw = await readFile(join(packagesDir, entry, "package.json"), "utf-8");
+      const pkg = JSON.parse(raw);
+      versions.set(pkg.name, pkg.version);
+    } catch {
+      // not a package dir
+    }
+  }
+  return versions;
+}
+
+async function main() {
+  const raw = await readFile(packageJsonPath, "utf-8");
+  const pkg = JSON.parse(raw);
+
+  // Check if there are any workspace: specifiers at all
+  let hasWorkspaceDeps = false;
+  for (const field of ["dependencies", "optionalDependencies", "peerDependencies", "devDependencies"]) {
+    const deps = pkg[field];
+    if (!deps) continue;
+    for (const specifier of Object.values(deps)) {
+      if (typeof specifier === "string" && specifier.startsWith("workspace:")) {
+        hasWorkspaceDeps = true;
+        break;
+      }
+    }
+    if (hasWorkspaceDeps) break;
+  }
+
+  if (!hasWorkspaceDeps) {
+    // Nothing to do — skip backup/rewrite to avoid unnecessary churn
+    return;
+  }
+
+  const workspaceRoot = findWorkspaceRoot(packageDir);
+  const versions = await loadWorkspaceVersions(workspaceRoot);
+
+  // Backup original
+  await copyFile(packageJsonPath, backupPath);
+
+  for (const field of ["dependencies", "optionalDependencies", "peerDependencies", "devDependencies"]) {
+    const deps = pkg[field];
+    if (!deps) continue;
+    for (const [name, specifier] of Object.entries(deps)) {
+      if (typeof specifier !== "string" || !specifier.startsWith("workspace:")) continue;
+      const version = versions.get(name);
+      if (!version) {
+        throw new Error(`Unable to resolve workspace dependency version for "${name}"`);
+      }
+      deps[name] = normalizeWorkspaceSpecifier(specifier, version);
+    }
+  }
+
+  await writeFile(packageJsonPath, `${JSON.stringify(pkg, null, 2)}\n`, "utf-8");
+  process.stderr.write(`[prepack] Replaced workspace:* deps in ${pkg.name}\n`);
+}
+
+await main();

+ 25 - 0
scripts/restore-package-json.mjs

@@ -0,0 +1,25 @@
+/**
+ * postpack hook — restores original package.json from backup.
+ *
+ * Shared by all publishable packages. Invoked as:
+ *   "postpack": "node ../../scripts/restore-package-json.mjs"
+ */
+
+import { readFile, rm, writeFile } from "node:fs/promises";
+import { join } from "node:path";
+
+const packageDir = process.cwd();
+const packageJsonPath = join(packageDir, "package.json");
+const backupPath = join(packageDir, ".package.json.publish-backup");
+
+async function main() {
+  try {
+    const original = await readFile(backupPath, "utf-8");
+    await writeFile(packageJsonPath, original, "utf-8");
+    await rm(backupPath, { force: true });
+  } catch {
+    // No backup means prepack found nothing to replace — fine.
+  }
+}
+
+await main();