diff --git a/.github/workflows/pr-check.yaml b/.github/workflows/pr-check.yaml index ede32a2..afc85e3 100644 --- a/.github/workflows/pr-check.yaml +++ b/.github/workflows/pr-check.yaml @@ -21,6 +21,9 @@ jobs: version: 11 run_install: true + - name: Test install and cache ownership + run: pnpm test + - name: Build dist/index.js run: pnpm run build diff --git a/README.md b/README.md index db55866..d7a1cb5 100644 --- a/README.md +++ b/README.md @@ -220,6 +220,11 @@ the upload: pnpm refuses to run them unless the repository allow-lists the package through `allowBuilds`, and a package on that list can already run code in the job. +Only a successful action-owned install can publish that verdict. Its upload +window ends there even if another job already reserved the key, the upload +failed, or the log was rejected. The post step never retries that log after +later job steps have run. Skipped and failed installs do not publish it. + Before uploading, the action checks that the log grew the way an install grows it: every record that predated the install still there, and no more new records than installs it ran. A dependency's script that slips an extra record in is diff --git a/dist/index.js b/dist/index.js index 6e77ed0..3c3bc6e 100644 --- a/dist/index.js +++ b/dist/index.js @@ -270,7 +270,7 @@ Please specify it by one of the following ways: - in the package.json with the key "devEngines.packageManager"`):new Error(`No workspace is found. If you've intended to let pnpm/setup read preferred pnpm version from the "packageManager" field in the package.json file, please run the actions/checkout before pnpm/setup. -Otherwise, please specify the pnpm version in the action configuration.`)}function sSe(t){return new Promise((e,r)=>{let n=(0,f8.spawn)(t,["--version"],{stdio:["ignore","pipe","inherit"]}),s="";n.stdout.on("data",i=>{s+=i}),n.on("error",r),n.on("close",i=>{i===0?e(s.trim()):r(new Error(`"${t} --version" exited with code ${i}`))})})}var y8=rSe;async function iSe(t){Es("Installing pnpm...");try{return await y8(t)}finally{Is()}}var C8=iSe;var xT=require("child_process"),b8=require("fs"),WI=b(require("path")),N8=b(require("util")),x8=b(ET());var I8=require("fs"),NT=b(require("path")),oSe=new Set(["node","nodejs"]),aSe=[".node-version",".nvmrc",".tool-versions"];function VI(t){if(t.nodeVersionFile===!1)return;let{GITHUB_WORKSPACE:e}=process.env;if(!e){if(!t.nodeVersionFile)return;throw new Error("GITHUB_WORKSPACE is not set; unable to resolve `node-version-file`.")}let r=t.nodeVersionFile?[t.nodeVersionFile]:aSe;for(let n of r){let s=NT.default.resolve(e,t.workingDirectory,n),i;try{i=(0,I8.readFileSync)(s,"utf8")}catch(a){if(a instanceof Error&&"code"in a&&a.code==="ENOENT"){if(!t.nodeVersionFile)continue;throw new Error(`The specified Node version file does not exist: ${s}`)}throw a}let o=NT.default.basename(s);if(!(!t.nodeVersionFile&&o===".tool-versions"&&!B8(Q8(i))))return cSe(i,o)}}function cSe(t,e="Node version file"){let r=Q8(t);if(e===".tool-versions"){let s=B8(r);if(!s)throw new Error(`${e} does not declare a Node.js version with \`node\` or \`nodejs\`.`);let[,i]=s.split(/\s+/);if(!i)throw new Error(`${e} declares Node.js without a version.`);return E8(i,e)}let n=r.filter(s=>!/^[A-Za-z][A-Za-z0-9_-]*\s*=/.test(s));if(n.length!==1)throw new Error(`${e} must contain exactly one Node.js version selector.`);return E8(n[0],e)}function B8(t){return t.find(e=>oSe.has(e.split(/\s+/,1)[0]))}function Q8(t){return t.replace(/^\uFEFF/,"").split(/\r?\n/).map(e=>e.replace(/\s*#.*$/,"").trim()).filter(Boolean)}function E8(t,e){if(t.includes(","))throw new Error(`${e} contains an invalid Node.js version selector: ${t}`);let r=t.toLowerCase();if(r==="node"||r==="stable")return"latest";if(r==="lts/*")return"lts";if(r.startsWith("lts/")){let s=t.slice(4);if(!s||s.includes("/"))throw new Error(`${e} contains an invalid Node.js version selector: ${t}`);return s}if(["system","current","iojs","unstable"].includes(r)||/^(?:path|ref):/i.test(t))throw new Error(`${e} uses a Node.js version selector that pnpm cannot install: ${t}`);return/^v\d/.test(t)?t.slice(1):t}var ASe=new Set(["node","bun","deno"]),w8=["PNPM_CONFIG_GLOBAL_SHIMS","pnpm_config_global_shims"];function S8(t){if(t.runtime){let{name:n}=t.runtime;t.nodeVersionFile&&(n!=="node"||t.runtime.version)&&Me(n==="node"?"`node-version-file` is ignored because `runtime` already includes a Node.js version.":`\`node-version-file\` is ignored because \`runtime\` explicitly selects ${n}.`);let s=t.runtime.version??(n==="node"&&t.nodeVersionFile?VI(t):void 0)??mSe(t,n)??(n==="node"?VI(t):void 0)??lSe(n);return[{name:n,version:s}]}let e=v8(t);if(!t.nodeVersionFile&&e.some(n=>n.name==="node"))return e;let r=VI(t);return r?[{name:"node",version:r},...e.filter(n=>n.name!=="node")]:e}async function R8(t,e){Es(`Installing runtime ${t.name}@${t.version}...`);let r;try{r=await fSe(e,["runtime","set",t.name,t.version,"-g"])}catch(n){An(`pnpm runtime set ${t.name} ${t.version} -g failed: ${n instanceof Error?n.message:String(n)}`);return}finally{Is()}if(r!==0){An(`pnpm runtime set ${t.name} ${t.version} -g exited with code ${r}`);return}return{name:t.name,version:t.version}}async function D8(t,e){let r=new Map;if(t.length===0)return r;try{let n=await pSe(e,["list","--global","--json","--depth","0"]),s=JSON.parse(n);for(let i of t){let o=s[0]?.dependencies?.[i]?.version;o?r.set(i,o):Me(`Unable to determine the installed ${i} version from "pnpm list --global"`)}}catch(n){Me(`Unable to determine the installed runtime versions: ${n instanceof Error?n.message:String(n)}`)}return r}function k8(t){if(t.length===0)return;let e=w8.find(r=>process.env[r]);if(e){Z(`\`${e}\` is already set; leaving pnpm's context-aware shims as configured.`);return}Wh(w8[0],JSON.stringify(Object.fromEntries(t.map(r=>[r.name,!1]))))}function P8(){Z("No runtime requested or Node.js version file detected. Skipping runtime install.")}function lSe(t){return t==="node"?"lts":"latest"}function uSe(t){let{GITHUB_WORKSPACE:e}=process.env;if(e)try{let r=(0,b8.readFileSync)(WI.default.resolve(e,t.packageJsonFile),"utf8");return t.packageJsonFile.endsWith(".yaml")?(0,x8.parse)(r,{merge:!0}):JSON.parse(r)}catch(r){if(N8.default.types.isNativeError(r)&&"code"in r&&r.code==="ENOENT")return;throw r}}function dSe(t){let r=uSe(t)?.devEngines?.runtime;return r?Array.isArray(r)?r:[r]:[]}function mSe(t,e){return v8(t).find(r=>r.name===e)?.version}function v8(t){let e=new Map;for(let r of dSe(t)){if(!r.name||!r.version||!ASe.has(r.name))continue;let n=r.name,s=e.get(n);s&&Me(`Duplicate ${n} runtime versions declared in devEngines.runtime (${s.version} and ${r.version}); using the last declared version ${r.version}.`),e.set(n,{name:n,version:r.version})}return[...e.values()]}function fSe(t,e){let r=WI.default.join(t,process.platform==="win32"?"pnpm.exe":"pnpm");return new Promise((n,s)=>{let i=(0,xT.spawn)(r,e,{stdio:["pipe","inherit","inherit"]});i.on("error",s),i.on("close",n)})}function pSe(t,e){let r=WI.default.join(t,process.platform==="win32"?"pnpm.exe":"pnpm");return new Promise((n,s)=>{let i=(0,xT.spawn)(r,e,{stdio:["ignore","pipe","inherit"]}),o="";i.stdout.setEncoding("utf8"),i.stdout.on("data",a=>{o+=a}),i.on("error",s),i.on("close",a=>{a===0?n(o):s(new Error(`pnpm ${e.join(" ")} exited with code ${a}`))})})}function hSe(t,e,r){let n=r[0];Co("dest",t.dest),Co("bin-dest",e),Co("runtime-name",n?.name??""),Co("runtime-version",n?.version??""),Co("runtimes",JSON.stringify(r))}var T8=hSe;var O8=require("child_process"),ST=require("fs"),iu=b(require("path"));var M8=require("child_process"),L8=b(require("path"));function F8(t,e,r){let{status:n,stdout:s}=(0,M8.spawnSync)(e,r,{cwd:t,encoding:"utf8"});return n===0?s?.trim():void 0}function gSe(t,e){let r=F8(t,e,["root","-w"]);return r?L8.default.dirname(r):void 0}function ySe(t,e){return F8(t,e,["config","get","sharedWorkspaceLockfile"])==="false"}function CSe(t,e,r){return e&&!r?e:t}function U8(t,e){let r=gSe(t,e),n=r!==void 0&&ySe(t,e);return CSe(t,r,n)}function ESe(t,e=!!t.runtime){let r=["install"];t.requireLockfile&&r.push("--frozen-lockfile"),e&&r.push("--no-runtime");let n=`pnpm ${r.join(" ")}`,{GITHUB_WORKSPACE:s}=process.env;if(!s){Z(`GITHUB_WORKSPACE is not set; skipping \`${n}\`.`);return}let i=iu.default.resolve(s,t.packageJsonFile);if(!(0,ST.existsSync)(i)){Z(`No ${t.packageJsonFile} found in workspace; skipping \`${n}\`.`);return}let o=iu.default.resolve(s,t.workingDirectory),a=iu.default.join(t.dest,process.platform==="win32"?"pnpm.exe":"pnpm");if(t.requireLockfile){let u=U8(o,a);if(!(0,ST.existsSync)(iu.default.join(u,"pnpm-lock.yaml"))){let d=iu.default.relative(s,u)||".";An(`\`require-lockfile\` is set but no pnpm-lock.yaml was found in ${d}, which is where an install in ${t.workingDirectory} reads one. Commit the lockfile, or unset \`require-lockfile\` to let pnpm resolve and write one.`);return}}Es(`Running ${n}...`);let{error:c,status:A,signal:l}=(0,O8.spawnSync)(a,r,{stdio:"inherit",cwd:o});if(Is(),c){An(c);return}if(l){An(`${n} was terminated by ${l}`);return}A!==0&&An(`${n} exited with status ${A}`)}var H8=ESe;async function ISe(t){if(t.cache){Es("Running pnpm store prune...");try{await Fa("pnpm",["store","prune"])}catch(e){Me(e instanceof Error?e:String(e))}finally{Is()}}}var q8=ISe;async function BSe(){un("is_post")==="true"?await wSe():await QSe()}async function QSe(){let t=o3();ln("inputs",t),ln("is_post","true");let e=await C8(t);console.log("Installation Completed!");let r=S8(t),n=await zj(t,r),s=[];for(let a of r){let c=await R8(a,e.binDest);if(c===void 0)return;s.push(c)}s.length>0?k8(s):P8();let i=await D8(s.map(a=>a.name),e.binDest),o=s.map(a=>({name:a.name,version:i.get(a.name)??a.version}));n&&UP(n,o),T8(t,e.binDest,o),Pj(),t.install&&(H8(t,s.length>0),await FP(1))}async function wSe(){let t=JSON.parse(un("inputs"));await FP(),await q8(t),await Jj(t)}BSe().catch(t=>{console.error(t),An(t)}); +Otherwise, please specify the pnpm version in the action configuration.`)}function sSe(t){return new Promise((e,r)=>{let n=(0,f8.spawn)(t,["--version"],{stdio:["ignore","pipe","inherit"]}),s="";n.stdout.on("data",i=>{s+=i}),n.on("error",r),n.on("close",i=>{i===0?e(s.trim()):r(new Error(`"${t} --version" exited with code ${i}`))})})}var y8=rSe;async function iSe(t){Es("Installing pnpm...");try{return await y8(t)}finally{Is()}}var C8=iSe;var xT=require("child_process"),b8=require("fs"),WI=b(require("path")),N8=b(require("util")),x8=b(ET());var I8=require("fs"),NT=b(require("path")),oSe=new Set(["node","nodejs"]),aSe=[".node-version",".nvmrc",".tool-versions"];function VI(t){if(t.nodeVersionFile===!1)return;let{GITHUB_WORKSPACE:e}=process.env;if(!e){if(!t.nodeVersionFile)return;throw new Error("GITHUB_WORKSPACE is not set; unable to resolve `node-version-file`.")}let r=t.nodeVersionFile?[t.nodeVersionFile]:aSe;for(let n of r){let s=NT.default.resolve(e,t.workingDirectory,n),i;try{i=(0,I8.readFileSync)(s,"utf8")}catch(a){if(a instanceof Error&&"code"in a&&a.code==="ENOENT"){if(!t.nodeVersionFile)continue;throw new Error(`The specified Node version file does not exist: ${s}`)}throw a}let o=NT.default.basename(s);if(!(!t.nodeVersionFile&&o===".tool-versions"&&!B8(Q8(i))))return cSe(i,o)}}function cSe(t,e="Node version file"){let r=Q8(t);if(e===".tool-versions"){let s=B8(r);if(!s)throw new Error(`${e} does not declare a Node.js version with \`node\` or \`nodejs\`.`);let[,i]=s.split(/\s+/);if(!i)throw new Error(`${e} declares Node.js without a version.`);return E8(i,e)}let n=r.filter(s=>!/^[A-Za-z][A-Za-z0-9_-]*\s*=/.test(s));if(n.length!==1)throw new Error(`${e} must contain exactly one Node.js version selector.`);return E8(n[0],e)}function B8(t){return t.find(e=>oSe.has(e.split(/\s+/,1)[0]))}function Q8(t){return t.replace(/^\uFEFF/,"").split(/\r?\n/).map(e=>e.replace(/\s*#.*$/,"").trim()).filter(Boolean)}function E8(t,e){if(t.includes(","))throw new Error(`${e} contains an invalid Node.js version selector: ${t}`);let r=t.toLowerCase();if(r==="node"||r==="stable")return"latest";if(r==="lts/*")return"lts";if(r.startsWith("lts/")){let s=t.slice(4);if(!s||s.includes("/"))throw new Error(`${e} contains an invalid Node.js version selector: ${t}`);return s}if(["system","current","iojs","unstable"].includes(r)||/^(?:path|ref):/i.test(t))throw new Error(`${e} uses a Node.js version selector that pnpm cannot install: ${t}`);return/^v\d/.test(t)?t.slice(1):t}var ASe=new Set(["node","bun","deno"]),w8=["PNPM_CONFIG_GLOBAL_SHIMS","pnpm_config_global_shims"];function S8(t){if(t.runtime){let{name:n}=t.runtime;t.nodeVersionFile&&(n!=="node"||t.runtime.version)&&Me(n==="node"?"`node-version-file` is ignored because `runtime` already includes a Node.js version.":`\`node-version-file\` is ignored because \`runtime\` explicitly selects ${n}.`);let s=t.runtime.version??(n==="node"&&t.nodeVersionFile?VI(t):void 0)??mSe(t,n)??(n==="node"?VI(t):void 0)??lSe(n);return[{name:n,version:s}]}let e=v8(t);if(!t.nodeVersionFile&&e.some(n=>n.name==="node"))return e;let r=VI(t);return r?[{name:"node",version:r},...e.filter(n=>n.name!=="node")]:e}async function R8(t,e){Es(`Installing runtime ${t.name}@${t.version}...`);let r;try{r=await fSe(e,["runtime","set",t.name,t.version,"-g"])}catch(n){An(`pnpm runtime set ${t.name} ${t.version} -g failed: ${n instanceof Error?n.message:String(n)}`);return}finally{Is()}if(r!==0){An(`pnpm runtime set ${t.name} ${t.version} -g exited with code ${r}`);return}return{name:t.name,version:t.version}}async function D8(t,e){let r=new Map;if(t.length===0)return r;try{let n=await pSe(e,["list","--global","--json","--depth","0"]),s=JSON.parse(n);for(let i of t){let o=s[0]?.dependencies?.[i]?.version;o?r.set(i,o):Me(`Unable to determine the installed ${i} version from "pnpm list --global"`)}}catch(n){Me(`Unable to determine the installed runtime versions: ${n instanceof Error?n.message:String(n)}`)}return r}function k8(t){if(t.length===0)return;let e=w8.find(r=>process.env[r]);if(e){Z(`\`${e}\` is already set; leaving pnpm's context-aware shims as configured.`);return}Wh(w8[0],JSON.stringify(Object.fromEntries(t.map(r=>[r.name,!1]))))}function P8(){Z("No runtime requested or Node.js version file detected. Skipping runtime install.")}function lSe(t){return t==="node"?"lts":"latest"}function uSe(t){let{GITHUB_WORKSPACE:e}=process.env;if(e)try{let r=(0,b8.readFileSync)(WI.default.resolve(e,t.packageJsonFile),"utf8");return t.packageJsonFile.endsWith(".yaml")?(0,x8.parse)(r,{merge:!0}):JSON.parse(r)}catch(r){if(N8.default.types.isNativeError(r)&&"code"in r&&r.code==="ENOENT")return;throw r}}function dSe(t){let r=uSe(t)?.devEngines?.runtime;return r?Array.isArray(r)?r:[r]:[]}function mSe(t,e){return v8(t).find(r=>r.name===e)?.version}function v8(t){let e=new Map;for(let r of dSe(t)){if(!r.name||!r.version||!ASe.has(r.name))continue;let n=r.name,s=e.get(n);s&&Me(`Duplicate ${n} runtime versions declared in devEngines.runtime (${s.version} and ${r.version}); using the last declared version ${r.version}.`),e.set(n,{name:n,version:r.version})}return[...e.values()]}function fSe(t,e){let r=WI.default.join(t,process.platform==="win32"?"pnpm.exe":"pnpm");return new Promise((n,s)=>{let i=(0,xT.spawn)(r,e,{stdio:["pipe","inherit","inherit"]});i.on("error",s),i.on("close",n)})}function pSe(t,e){let r=WI.default.join(t,process.platform==="win32"?"pnpm.exe":"pnpm");return new Promise((n,s)=>{let i=(0,xT.spawn)(r,e,{stdio:["ignore","pipe","inherit"]}),o="";i.stdout.setEncoding("utf8"),i.stdout.on("data",a=>{o+=a}),i.on("error",s),i.on("close",a=>{a===0?n(o):s(new Error(`pnpm ${e.join(" ")} exited with code ${a}`))})})}function hSe(t,e,r){let n=r[0];Co("dest",t.dest),Co("bin-dest",e),Co("runtime-name",n?.name??""),Co("runtime-version",n?.version??""),Co("runtimes",JSON.stringify(r))}var T8=hSe;var O8=require("child_process"),ST=require("fs"),iu=b(require("path"));var M8=require("child_process"),L8=b(require("path"));function F8(t,e,r){let{status:n,stdout:s}=(0,M8.spawnSync)(e,r,{cwd:t,encoding:"utf8"});return n===0?s?.trim():void 0}function gSe(t,e){let r=F8(t,e,["root","-w"]);return r?L8.default.dirname(r):void 0}function ySe(t,e){return F8(t,e,["config","get","sharedWorkspaceLockfile"])==="false"}function CSe(t,e,r){return e&&!r?e:t}function U8(t,e){let r=gSe(t,e),n=r!==void 0&&ySe(t,e);return CSe(t,r,n)}function ESe(t,e=!!t.runtime){let r=["install"];t.requireLockfile&&r.push("--frozen-lockfile"),e&&r.push("--no-runtime");let n=`pnpm ${r.join(" ")}`,{GITHUB_WORKSPACE:s}=process.env;if(!s)return Z(`GITHUB_WORKSPACE is not set; skipping \`${n}\`.`),!1;let i=iu.default.resolve(s,t.packageJsonFile);if(!(0,ST.existsSync)(i))return Z(`No ${t.packageJsonFile} found in workspace; skipping \`${n}\`.`),!1;let o=iu.default.resolve(s,t.workingDirectory),a=iu.default.join(t.dest,process.platform==="win32"?"pnpm.exe":"pnpm");if(t.requireLockfile){let u=U8(o,a);if(!(0,ST.existsSync)(iu.default.join(u,"pnpm-lock.yaml"))){let d=iu.default.relative(s,u)||".";return An(`\`require-lockfile\` is set but no pnpm-lock.yaml was found in ${d}, which is where an install in ${t.workingDirectory} reads one. Commit the lockfile, or unset \`require-lockfile\` to let pnpm resolve and write one.`),!1}}Es(`Running ${n}...`);let{error:c,status:A,signal:l}=(0,O8.spawnSync)(a,r,{stdio:"inherit",cwd:o});return Is(),c?(An(c),!1):l?(An(`${n} was terminated by ${l}`),!1):A!==0?(An(`${n} exited with status ${A}`),!1):!0}var H8=ESe;async function ISe(t){if(t.cache){Es("Running pnpm store prune...");try{await Fa("pnpm",["store","prune"])}catch(e){Me(e instanceof Error?e:String(e))}finally{Is()}}}var q8=ISe;async function BSe(){un("is_post")==="true"?await wSe():await QSe()}async function QSe(){let t=o3();ln("inputs",t),ln("is_post","true");let e=await C8(t);console.log("Installation Completed!");let r=S8(t),n=await zj(t,r),s=[];for(let a of r){let c=await R8(a,e.binDest);if(c===void 0)return;s.push(c)}s.length>0?k8(s):P8();let i=await D8(s.map(a=>a.name),e.binDest),o=s.map(a=>({name:a.name,version:i.get(a.name)??a.version}));n&&UP(n,o),T8(t,e.binDest,o),Pj(),t.install&&H8(t,s.length>0)&&await FP(1)}async function wSe(){let t=JSON.parse(un("inputs"));t.install||await FP(),await q8(t),await Jj(t)}BSe().catch(t=>{console.error(t),An(t)}); /*! Bundled license information: undici/lib/web/fetch/body.js: diff --git a/package.json b/package.json index be4f5fe..971c759 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "build:bundle": "esbuild src/index.ts --bundle --platform=node --target=node24 --format=cjs --minify --outfile=dist/index.js", "build": "pnpm run build:bundle", "start": "pnpm run build && sh ./run.sh", - "test": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --experimental-strip-types --test src/cache-restore/*.test.mjs src/install-runtime/*.test.mjs src/pnpm-install/*.test.mjs src/pnpm-commands.test.mjs" + "test": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --experimental-strip-types --test src/cache-restore/*.test.mjs src/install-runtime/*.test.mjs src/pnpm-install/*.test.mjs src/lockfile-verification-cache/*.test.mjs src/pnpm-commands.test.mjs" }, "dependencies": { "@actions/cache": "^6.2.0", diff --git a/src/index.ts b/src/index.ts index 5d45662..e7db351 100644 --- a/src/index.ts +++ b/src/index.ts @@ -52,10 +52,10 @@ async function runMain() { // the version that actually landed, so read it back once and use it for // both. Fall back to the selector if the installed version cannot be read. const installedVersions = await getInstalledRuntimeVersions( - runtimes.map(runtime => runtime.name), + runtimes.map((runtime) => runtime.name), result.binDest, ) - const installed = runtimes.map(runtime => ({ + const installed = runtimes.map((runtime) => ({ name: runtime.name, version: installedVersions.get(runtime.name) ?? runtime.version, })) @@ -71,26 +71,26 @@ async function runMain() { snapshotVerificationLog() if (inputs.install) { - pnpmInstall(inputs, runtimes.length > 0) // Uploaded here rather than in the post step so that whatever the job runs // next cannot alter what later jobs restore. When `install` is false the // log is not complete yet — the job installs in a step of its own, and the // post step is the first moment it is known to be done. - await saveVerificationCache(1) + if (pnpmInstall(inputs, runtimes.length > 0)) await saveVerificationCache(1) } } async function runPost() { const inputs = JSON.parse(getState('inputs')) as Inputs - // Covers a job that installs in a later step of its own; when this action - // installed, the log was already saved then. Runs before the prune because - // pnpm versions before pnpm/pnpm#13893 delete the log during one. - await saveVerificationCache() + // Only a later-step install owns post publication. An action-owned install + // already had its one bounded attempt, including a miss, collision, failure + // or rejected log. Never reopen that window after other job steps ran. + // Runs before pruning because older pnpm versions delete the log during one. + if (!inputs.install) await saveVerificationCache() await pruneStore(inputs) await saveCache(inputs) } -main().catch(error => { +main().catch((error) => { console.error(error) setFailed(error) }) diff --git a/src/lockfile-verification-cache/ownership.test.mjs b/src/lockfile-verification-cache/ownership.test.mjs new file mode 100644 index 0000000..0359857 --- /dev/null +++ b/src/lockfile-verification-cache/ownership.test.mjs @@ -0,0 +1,153 @@ +import { build } from 'esbuild' +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { after, test } from 'node:test' +import { fileURLToPath } from 'node:url' + +// Main/post really run in separate processes. Only external setup/cache services +// are replaced; the entry point, install exit handling and verification log are real. +const temporary = mkdtempSync(join(tmpdir(), 'pnpm-setup-owner-')) +after(() => rmSync(temporary, { recursive: true, force: true })) +const source = fileURLToPath(new URL('..', import.meta.url)) +const mocks = { + '@actions/core': ` + import { existsSync, readFileSync, writeFileSync } from 'node:fs' + const path = process.env.TEST_STATE + const incoming = existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : {} + const outgoing = { ...incoming } + export const getState = key => incoming[key] ?? '' + export const saveState = (key, value) => { + outgoing[key] = typeof value === 'string' ? value : JSON.stringify(value) + writeFileSync(path, JSON.stringify(outgoing)) + } + export const setFailed = message => { process.exitCode = 1; console.error(message) } + export const debug = () => {} + export const info = () => {} + export const warning = message => console.error(message) + export const startGroup = () => {} + export const endGroup = () => {} + `, + '@actions/cache': ` + import { appendFileSync, readFileSync } from 'node:fs' + export const restoreCache = async (_paths, key) => process.env.TEST_HIT === 'true' ? key : undefined + export const saveCache = async ([path], key) => { + appendFileSync(process.env.TEST_SAVES, JSON.stringify({ key, log: readFileSync(path, 'utf8') }) + '\\n') + if (process.env.TEST_SAVE === 'error') throw new Error('cache transport unavailable') + return process.env.TEST_SAVE === 'collision' ? -1 : 1 + } + `, + '@actions/exec': `export const getExecOutput = async () => ({ exitCode: 0, stdout: process.env.GITHUB_WORKSPACE })`, + './inputs': `export default () => JSON.parse(process.env.TEST_INPUTS)`, + './install-pnpm': `export default async () => ({ binDest: process.env.GITHUB_WORKSPACE })`, + './install-runtime': ` + export const resolveRuntimeRequests = () => [] + export const getInstalledRuntimeVersions = async () => new Map() + export const installRuntime = async () => undefined + export const keepInstalledRuntimesAuthoritative = () => {} + export const logSkippedRuntime = () => {} + `, + './outputs': `export default () => {}`, + './cache-restore': ` + import { restoreVerificationCache } from ${JSON.stringify(join(source, 'lockfile-verification-cache/index.ts'))} + export default async () => { await restoreVerificationCache('same-lockfile') } + export const finalizeCache = () => {} + `, + './cache-save': `export default async () => {}`, + './pnpm-store-prune': `export default async () => {}`, +} +const bundle = join(temporary, 'action.cjs') +await build({ + entryPoints: [join(source, 'index.ts')], + outfile: bundle, + bundle: true, + platform: 'node', + format: 'cjs', + plugins: [ + { + name: 'external-action-services', + setup(builder) { + builder.onResolve({ filter: /.*/ }, (args) => { + if (Object.hasOwn(mocks, args.path)) return { path: args.path, namespace: 'mock' } + }) + builder.onLoad({ filter: /.*/, namespace: 'mock' }, (args) => ({ + contents: mocks[args.path], + resolveDir: source, + })) + }, + }, + ], +}) + +for (const scenario of [ + { name: 'successful immediate publication', saves: 1 }, + { name: 'reservation collision', save: 'collision', saves: 1 }, + { name: 'cache transport failure', save: 'error', saves: 1 }, + { name: 'exact restored verdict', hit: true, saves: 0 }, + { name: 'failed install', status: 3, saves: 0 }, + { name: 'terminated install', signal: true, saves: 0 }, + { name: 'missing required lockfile', missingLockfile: true, saves: 0 }, + { name: 'missing manifest', missingManifest: true, saves: 0 }, + { name: 'rejected verification growth', records: 2, saves: 0 }, + { name: 'later-step install owns post publication', later: true, saves: 1 }, +]) { + test( + scenario.name, + { skip: process.platform === 'win32' ? 'POSIX executable fixture' : false }, + () => { + const root = mkdtempSync(join(temporary, 'workspace-')) + const log = join(root, 'lockfile-verified.jsonl') + const saves = join(root, 'saves.jsonl') + writeFileSync(log, '{"before":"verified"}\n') + writeFileSync(saves, '') + if (!scenario.missingManifest) writeFileSync(join(root, 'package.json'), '{}') + writeFileSync( + join(root, 'pnpm'), + `#!/usr/bin/env node + if (process.argv[2] !== 'install') process.exit(0) + require('node:fs').appendFileSync(${JSON.stringify(log)}, '{"install":"verified"}\\n'.repeat(${scenario.records ?? 1})) + ${scenario.signal ? "process.kill(process.pid, 'SIGTERM')" : `process.exit(${scenario.status ?? 0})`} + `, + { mode: 0o755 }, + ) + const env = { + ...process.env, + GITHUB_WORKSPACE: root, + TEST_STATE: join(root, 'state.json'), + TEST_SAVES: saves, + TEST_SAVE: scenario.save ?? '', + TEST_HIT: String(scenario.hit ?? false), + TEST_INPUTS: JSON.stringify({ + install: !scenario.later, + requireLockfile: !!scenario.missingLockfile, + dest: root, + packageJsonFile: 'package.json', + workingDirectory: '.', + }), + } + const main = spawnSync(process.execPath, [bundle], { env, encoding: 'utf8' }) + assert.equal( + main.status, + scenario.status || scenario.signal || scenario.missingLockfile ? 1 : 0, + main.stderr, + ) + const immediate = readFileSync(saves, 'utf8') + assert.equal( + immediate.trim().split('\n').filter(Boolean).length, + scenario.later ? 0 : scenario.saves, + ) + + // Later job steps can append records. For action-owned installation, even a + // collision/error/growth rejection must not reopen the publication window. + writeFileSync(log, '{"before":"verified"}\n{"later":"verified"}\n{"another":"verified"}\n') + const post = spawnSync(process.execPath, [bundle], { env, encoding: 'utf8' }) + assert.equal(post.status, 0, post.stderr) + const final = readFileSync(saves, 'utf8') + assert.equal(final.trim().split('\n').filter(Boolean).length, scenario.saves) + if (scenario.later) assert.equal(JSON.parse(final).log, readFileSync(log, 'utf8')) + else assert.equal(final, immediate) + }, + ) +} diff --git a/src/pnpm-install/index.ts b/src/pnpm-install/index.ts index 80b05a1..30b5ec4 100644 --- a/src/pnpm-install/index.ts +++ b/src/pnpm-install/index.ts @@ -5,7 +5,10 @@ import path from 'path' import { Inputs } from '../inputs' import { lockfileDir } from './lockfile' -export function runPnpmInstall(inputs: Inputs, runtimeInstalled = Boolean(inputs.runtime)) { +export function runPnpmInstall( + inputs: Inputs, + runtimeInstalled = Boolean(inputs.runtime), +): boolean { const args = ['install'] if (inputs.requireLockfile) { args.push('--frozen-lockfile') @@ -27,12 +30,12 @@ export function runPnpmInstall(inputs: Inputs, runtimeInstalled = Boolean(inputs const { GITHUB_WORKSPACE } = process.env if (!GITHUB_WORKSPACE) { info(`GITHUB_WORKSPACE is not set; skipping \`${command}\`.`) - return + return false } const manifestPath = path.resolve(GITHUB_WORKSPACE, inputs.packageJsonFile) if (!existsSync(manifestPath)) { info(`No ${inputs.packageJsonFile} found in workspace; skipping \`${command}\`.`) - return + return false } const workingDirectory = path.resolve(GITHUB_WORKSPACE, inputs.workingDirectory) @@ -47,11 +50,11 @@ export function runPnpmInstall(inputs: Inputs, runtimeInstalled = Boolean(inputs const searched = path.relative(GITHUB_WORKSPACE, lockfileDirectory) || '.' setFailed( '`require-lockfile` is set but no pnpm-lock.yaml was found in ' + - `${searched}, which is where an install in ${inputs.workingDirectory} ` + - 'reads one. Commit the lockfile, or unset `require-lockfile` to let ' + - 'pnpm resolve and write one.', + `${searched}, which is where an install in ${inputs.workingDirectory} ` + + 'reads one. Commit the lockfile, or unset `require-lockfile` to let ' + + 'pnpm resolve and write one.', ) - return + return false } } @@ -64,17 +67,19 @@ export function runPnpmInstall(inputs: Inputs, runtimeInstalled = Boolean(inputs if (error) { setFailed(error) - return + return false } // A process killed by a signal reports `status: null` with no `error`, so a // truthiness check on `status` alone would let that pass as a success. if (signal) { setFailed(`${command} was terminated by ${signal}`) - return + return false } if (status !== 0) { setFailed(`${command} exited with status ${status}`) + return false } + return true } export default runPnpmInstall