id: dependency-upgrade-readiness-gate
namespace: company.team
description: |
Dependency Upgrade Readiness Gate for npm. Answers one question before a dependency upgrade is merged: can this
repository move package X from its locked version to version B? It runs the project's own tests on the current
lockfile (baseline), re-resolves the lockfile with the requested version (candidate), runs the same tests offline
on the candidate, compares the two dependency trees, and returns PASS, WARN, BLOCK or INCONCLUSIVE.
inputs:
- id: repo_url
type: STRING
displayName: GitHub repository
description: Public GitHub repository, https://github.com/<owner>/<repo>. No
other host, scheme, port or credentials.
defaults: https://github.com/AyushVUpadhye/kestra-dependency-upgrade-demo
validator: '^https://github\.com/[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?/[A-Za-z0-9_][A-Za-z0-9._-]{0,99}$'
- id: commit
type: STRING
displayName: Commit SHA
description: Full 40-character commit SHA to test. Branch names are not accepted.
defaults: c355a5618384a75cf4ded4828a093a3de9e762a0
validator: '^[0-9a-f]{40}$'
- id: project_path
type: STRING
displayName: Project path
description: Folder holding package.json and package-lock.json, relative to the
repository root, or "." for the root.
defaults: npm/app
validator: '^(?:\.|(?!.*(?:^|/)\.\.?(?:/|$))[A-Za-z0-9._][A-Za-z0-9._-]{0,99}(?:/[A-Za-z0-9._][A-Za-z0-9._-]{0,99}){0,19})$'
- id: package
type: STRING
displayName: Package
description: npm package to upgrade. It must already be a direct dependency of
the project.
defaults: lodash
validator: '^(?:@[a-z0-9][a-z0-9._~-]{0,213}/)?[a-z0-9][a-z0-9._~-]{0,213}$'
- id: target_version
type: STRING
displayName: Target version
description: Exact version to move to, for example 4.17.21. Ranges, tags, URLs
and git specifiers are rejected.
defaults: 4.17.21
validator: '^(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})(-[0-9A-Za-z][0-9A-Za-z.-]{0,63})?$'
- id: pr_number
type: INT
displayName: Pull request number
description: Optional. When set, the verdict is posted on this pull request of
the same repository, and kept up to date on later runs.
required: false
min: 1
tasks:
- id: gate
type: io.kestra.plugin.core.flow.WorkingDirectory
description: One workspace for the checkout, the lockfiles and the npm cache.
node_modules never leaves the container that created it.
tasks:
- id: clone
type: io.kestra.plugin.git.Clone
description: Checks out the exact commit; submodules are never fetched.
url: "{{ inputs.repo_url }}"
commit: "{{ inputs.commit }}"
cloneSubmodules: false
directory: repo
- id: verify
type: io.kestra.plugin.scripts.shell.Commands
description: Runs on the Kestra worker before any container starts. Checks the
checkout is the requested commit, refuses repositories containing
symbolic links, and keeps project_path inside the clone.
taskRunner:
type: io.kestra.plugin.core.runner.Process
env:
REQUESTED_COMMIT: "{{ inputs.commit }}"
PROJECT_PATH: "{{ inputs.project_path }}"
commands:
- |
set -eu
HEAD_SHA=$(cat repo/.git/HEAD)
if [ "$HEAD_SHA" != "$REQUESTED_COMMIT" ]; then echo "Checked-out HEAD '$HEAD_SHA' does not match the requested commit $REQUESTED_COMMIT."; exit 1; fi
LINKS=$(find repo -type l -printf '%P -> %l\n')
if [ -n "$LINKS" ]; then printf 'The repository contains symbolic links, which this gate refuses to process:\n%s\n' "$LINKS"; exit 1; fi
ROOT=$(realpath -e repo)
TARGET=$(realpath -m -- "repo/$PROJECT_PATH")
case "$TARGET/" in "$ROOT"/*) ;; *) echo "project_path resolves outside the repository."; exit 1 ;; esac
if [ ! -f "repo/$PROJECT_PATH/package.json" ]; then echo "No package.json in $PROJECT_PATH."; exit 1; fi
echo "Verified checkout $HEAD_SHA: no symbolic links, project_path stays inside the repository."
- id: prepare
type: io.kestra.plugin.scripts.shell.Commands
description: Network on, scripts off. Removes the repository's .npmrc, refuses
lockfiles that download from anywhere but the npm registry, resolves
the candidate lockfile, compares it with the baseline, and fills an
npm cache for both so the tests can run offline.
timeout: PT15M
taskRunner:
type: io.kestra.plugin.scripts.runner.docker.Docker
cpu:
cpus: 2
memory:
memory: 2GB
containerImage: node@sha256:c3de60bf2f9dd0ac6370e6117950ff62d6e339527e7472301c9c78a017978392
env:
PROJECT_PATH: "{{ inputs.project_path }}"
PKG: "{{ inputs.package }}"
VER: "{{ inputs.target_version }}"
inputFiles:
gate.js: |
const fs = require("fs");
const path = require("path");
const semver = require("/usr/local/lib/node_modules/npm/node_modules/semver");
const REGISTRY = "https://registry.npmjs.org/";
const CAP = 25;
const read = f => JSON.parse(fs.readFileSync(f, "utf8"));
const nameOf = key => key.slice(key.lastIndexOf("node_modules/") + "node_modules/".length);
const entries = lock => Object.entries(lock.packages || {}).filter(([k]) => k !== "");
const nonRegistry = lock => entries(lock).filter(([, v]) => v.resolved && !v.resolved.startsWith(REGISTRY)).map(([k, v]) => ({package: nameOf(k), resolved: v.resolved}));
const tail = (f, n) => { try { return fs.readFileSync(f, "utf8").slice(-n); } catch (e) { return ""; } };
function precheck(dir, pkg) {
const pj = read(path.join(dir, "package.json"));
const test = (pj.scripts || {}).test;
const out = {lockfile: false, lockfileVersion: 0, workspaces: Boolean(pj.workspaces), links: 0, nonRegistry: [], direct: false, section: "", declared: "", locked: ""};
out.hasTest = typeof test === "string" && test.trim() !== "" && !/no test specified/.test(test);
for (const s of ["dependencies", "devDependencies", "optionalDependencies"]) {
if (pj[s] && Object.prototype.hasOwnProperty.call(pj[s], pkg)) {
Object.assign(out, {direct: true, section: s, declared: String(pj[s][pkg])});
break;
}
}
const lockPath = path.join(dir, "package-lock.json");
if (!fs.existsSync(lockPath)) return out;
const lock = read(lockPath);
const own = (lock.packages || {})["node_modules/" + pkg];
Object.assign(out, {lockfile: true, lockfileVersion: lock.lockfileVersion || 0, locked: own && own.version || ""});
out.links = entries(lock).filter(([, v]) => v.link).length;
out.nonRegistry = nonRegistry(lock).slice(0, CAP);
return out;
}
function diff(baseDir, candDir, fillLog, pkg) {
const A = Object.fromEntries(entries(read(path.join(baseDir, "package-lock.json"))));
const B = Object.fromEntries(entries(read(path.join(candDir, "package-lock.json"))));
const added = [], removed = [], changed = [];
for (const [k, v] of Object.entries(B)) {
if (!(k in A)) added.push({path: k, package: nameOf(k), version: v.version});
else if (A[k].version !== v.version) changed.push({path: k, package: nameOf(k), from: A[k].version, to: v.version});
}
for (const [k, v] of Object.entries(A)) if (!(k in B)) removed.push({path: k, package: nameOf(k), version: v.version});
const directKey = "node_modules/" + pkg;
const touched = added.concat(changed).map(x => x.path);
const id = k => nameOf(k) + "@" + B[k].version;
const log = tail(fillLog, 5000000);
const deprecatedInCandidate = new Set([...log.matchAll(/npm warn deprecated (@?[^@\s]+)@([^:\s]+):/g)].map(m => m[1] + "@" + m[2]));
const strip = list => list.slice(0, CAP).map(x => { const c = Object.assign({}, x); delete c.path; return c; });
return {
direct: {package: pkg, from: A[directKey] ? A[directKey].version : null, to: B[directKey] ? B[directKey].version : null},
counts: {added: added.length, removed: removed.length, changed: changed.filter(c => c.path !== directKey).length},
added: strip(added),
removed: strip(removed),
changed: strip(changed.filter(c => c.path !== directKey)),
deprecated: touched.map(id).filter(x => deprecatedInCandidate.has(x)).sort(),
installScripts: touched.filter(k => B[k].hasInstallScript).map(id).sort(),
engineConflicts: touched.filter(k => B[k].engines && typeof B[k].engines.node === "string" && !semver.satisfies(process.versions.node, B[k].engines.node)).map(k => ({package: id(k), requires: B[k].engines.node})),
installScriptsInCandidateGraph: Object.values(B).filter(v => v.hasInstallScript).length
};
}
function emit() {
const e = process.env;
const json = s => (s && s !== "null") ? JSON.parse(s) : null;
const pre = json(e.PRE), cand = json(e.CANDPRE);
const resolutionOk = e.RC_RES === "0" && cand !== null && cand.nonRegistry.length === 0;
const out = {
stage: e.STAGE, npmrcRemoved: e.NPMRC === "true", node: process.versions.node, npm: e.NPM_VERSION,
precheck: pre, candidatePrecheck: cand,
resolution: {exitCode: Number(e.RC_RES), errorCode: e.RES_CODE || "", logTail: tail(e.G + "/resolve.log", 1200)},
cacheFill: {baseline: {exitCode: Number(e.RC_FB), errorCode: e.FB_CODE || ""}, candidate: {exitCode: Number(e.RC_FC), errorCode: e.FC_CODE || ""}},
diff: json(e.DIFF),
hashes: {basePackageJson: e.H_BPJ || "", baseLockfile: e.H_BL || "", candidatePackageJson: e.H_CPJ || "", candidateLockfile: e.H_CL || ""},
runBaseline: e.STAGE === "ok",
runCandidate: e.STAGE === "ok" && resolutionOk && e.RC_FC !== "-1"
};
process.stdout.write("::" + JSON.stringify({outputs: out}) + "::\n");
}
const [cmd, ...args] = process.argv.slice(2);
if (cmd === "precheck") process.stdout.write(JSON.stringify(precheck(args[0], args[1])));
else if (cmd === "diff") process.stdout.write(JSON.stringify(diff(args[0], args[1], args[2], args[3])));
else if (cmd === "field") {
const v = args[1].split(".").reduce((o, k) => (o == null ? o : o[k]), JSON.parse(args[0]));
process.stdout.write(Array.isArray(v) ? String(v.length) : String(v));
} else emit();
commands:
- |
set -u
W=$PWD; G="$W/gate"; mkdir -p "$G/base" "$G/cand"
: > /tmp/npmrc-user; : > /tmp/npmrc-global
export npm_config_update_notifier=false npm_config_fund=false npm_config_audit=false npm_config_loglevel=warn
NPM="--ignore-scripts --registry=https://registry.npmjs.org/ --userconfig=/tmp/npmrc-user --globalconfig=/tmp/npmrc-global --cache $G/npm-cache"
code() { grep -o 'npm error code [A-Za-z0-9_]*' "$1" 2>/dev/null | head -1 | cut -d' ' -f4; }
h() { if [ -f "$1" ]; then sha256sum "$1" | cut -c1-64; fi; }
field() { node "$W/gate.js" field "$1" "$2"; }
cd "$W/repo/$PROJECT_PATH"
NPMRC=false; if [ -e .npmrc ]; then NPMRC=true; rm -f .npmrc; fi
cp package.json "$G/base/"; [ -f package-lock.json ] && cp package-lock.json "$G/base/"
PRE=$(node "$W/gate.js" precheck . "$PKG"); CANDPRE=null; DIFF=null
RC_RES=-1; RES_CODE=""; RC_FB=-1; FB_CODE=""; RC_FC=-1; FC_CODE=""
STAGE=ok
if [ "$(field "$PRE" lockfile)" != true ]; then STAGE=lockfile_missing
elif [ "$(field "$PRE" lockfileVersion)" -lt 2 ] || [ "$(field "$PRE" workspaces)" = true ] || [ "$(field "$PRE" links)" != 0 ]; then STAGE=unsupported
elif [ "$(field "$PRE" nonRegistry)" != 0 ]; then STAGE=non_registry
fi
if [ "$STAGE" = ok ]; then
RC_FB=0; timeout 600 npm ci $NPM > "$G/fill-base.log" 2>&1 || RC_FB=$?; FB_CODE=$(code "$G/fill-base.log"); rm -rf node_modules
if [ "$(field "$PRE" direct)" = true ]; then
RC_RES=0; timeout 300 npm install "$PKG@$VER" --package-lock-only --save-exact $NPM > "$G/resolve.log" 2>&1 || RC_RES=$?; RES_CODE=$(code "$G/resolve.log")
if [ "$RC_RES" = 0 ]; then
cp package.json package-lock.json "$G/cand/"
CANDPRE=$(node "$W/gate.js" precheck "$G/cand" "$PKG")
if [ "$(field "$CANDPRE" nonRegistry)" = 0 ]; then
RC_FC=0; timeout 600 npm ci $NPM > "$G/fill-cand.log" 2>&1 || RC_FC=$?; FC_CODE=$(code "$G/fill-cand.log"); rm -rf node_modules
fi
DIFF=$(node "$W/gate.js" diff "$G/base" "$G/cand" "$G/fill-cand.log" "$PKG")
fi
cp "$G/base/package.json" package.json; cp "$G/base/package-lock.json" package-lock.json
fi
fi
rm -rf node_modules
H_BPJ=$(h "$G/base/package.json"); H_BL=$(h "$G/base/package-lock.json"); H_CPJ=$(h "$G/cand/package.json"); H_CL=$(h "$G/cand/package-lock.json")
NPM_VERSION=$(npm --version)
echo "stage=$STAGE npmrcRemoved=$NPMRC baselineCacheFill=$RC_FB${FB_CODE:+ ($FB_CODE)} resolution=$RC_RES${RES_CODE:+ ($RES_CODE)} candidateCacheFill=$RC_FC${FC_CODE:+ ($FC_CODE)}"
export G STAGE NPMRC PRE CANDPRE DIFF RC_RES RES_CODE RC_FB FB_CODE RC_FC FC_CODE H_BPJ H_BL H_CPJ H_CL NPM_VERSION
node "$W/gate.js" emit
- id: test
type: io.kestra.plugin.scripts.shell.Commands
description: One offline container for both runs, so node_modules never crosses
a task boundary. Lockfiles are checked against the hashes recorded by
prepare, installs come only from the prepared cache, and the
repository's tests run as an unprivileged user with no network and no
secrets.
timeout: PT30M
taskRunner:
type: io.kestra.plugin.scripts.runner.docker.Docker
networkMode: none
cpu:
cpus: 2
memory:
memory: 2GB
containerImage: node@sha256:c3de60bf2f9dd0ac6370e6117950ff62d6e339527e7472301c9c78a017978392
env:
PROJECT_PATH: "{{ inputs.project_path }}"
RUN_BASELINE: "{{ outputs.prepare.vars.runBaseline }}"
RUN_CANDIDATE: "{{ outputs.prepare.vars.runCandidate }}"
HAS_TEST: "{{ outputs.prepare.vars.precheck.hasTest }}"
H_BPJ: "{{ outputs.prepare.vars.hashes.basePackageJson }}"
H_BL: "{{ outputs.prepare.vars.hashes.baseLockfile }}"
H_CPJ: "{{ outputs.prepare.vars.hashes.candidatePackageJson }}"
H_CL: "{{ outputs.prepare.vars.hashes.candidateLockfile }}"
commands:
- |
set -u
W=$PWD; G="$W/gate"; P="$W/repo/$PROJECT_PATH"
: > /tmp/npmrc-user; : > /tmp/npmrc-global
export npm_config_update_notifier=false npm_config_fund=false npm_config_audit=false npm_config_loglevel=warn
NPM="--offline --ignore-scripts --registry=https://registry.npmjs.org/ --userconfig=/tmp/npmrc-user --globalconfig=/tmp/npmrc-global --cache $G/npm-cache"
code() { grep -o 'npm error code [A-Za-z0-9_]*' "$1" 2>/dev/null | head -1 | cut -d' ' -f4; }
h() { if [ -f "$1" ]; then sha256sum "$1" | cut -c1-64; fi; }
now() { date +%s%3N; }
chmod -R go-w "$W"
mkdir -p /tmp/testhome && chown node:node /tmp/testhome
run() {
side=$1; pj=$2; lock=$3
HASH=true; INSTALL=-1; ICODE=""; IMS=0; TEST=-1; TMS=0
if [ "$(h "$G/$side/package.json")" != "$pj" ] || [ "$(h "$G/$side/package-lock.json")" != "$lock" ]; then HASH=false; return; fi
cd "$P"; rm -rf node_modules .npmrc
cp "$G/$side/package.json" "$G/$side/package-lock.json" .
t0=$(now); INSTALL=0; timeout 600 npm ci $NPM > "$G/$side-install.log" 2>&1 || INSTALL=$?; IMS=$(( $(now) - t0 )); ICODE=$(code "$G/$side-install.log")
if [ "$HAS_TEST" = true ] && [ "$INSTALL" = 0 ]; then
chown -R node:node "$P"
t0=$(now); TEST=0
timeout 900 setpriv --reuid=1000 --regid=1000 --clear-groups env -i PATH="$PATH" HOME=/tmp/testhome CI=true npm test --ignore-scripts > "$G/$side-test.log" 2>&1 || TEST=$?
TMS=$(( $(now) - t0 ))
fi
rm -rf "$P/node_modules"
}
B_HASH=true; B_INSTALL=-1; B_ICODE=""; B_IMS=0; B_TEST=-1; B_TMS=0
C_HASH=true; C_INSTALL=-1; C_ICODE=""; C_IMS=0; C_TEST=-1; C_TMS=0
if [ "$RUN_BASELINE" = true ]; then
run base "$H_BPJ" "$H_BL"; B_HASH=$HASH; B_INSTALL=$INSTALL; B_ICODE=$ICODE; B_IMS=$IMS; B_TEST=$TEST; B_TMS=$TMS
fi
if [ "$RUN_CANDIDATE" = true ]; then
run cand "$H_CPJ" "$H_CL"; C_HASH=$HASH; C_INSTALL=$INSTALL; C_ICODE=$ICODE; C_IMS=$IMS; C_TEST=$TEST; C_TMS=$TMS
fi
rm -rf "$G/npm-cache" "$P/node_modules"
LEFT=$(find "$W" -name node_modules -type d | wc -l)
echo "baseline: hash=$B_HASH install=$B_INSTALL${B_ICODE:+ ($B_ICODE)} test=$B_TEST in ${B_TMS}ms | candidate: hash=$C_HASH install=$C_INSTALL${C_ICODE:+ ($C_ICODE)} test=$C_TEST in ${C_TMS}ms | node_modules left: $LEFT"
export G B_HASH B_INSTALL B_ICODE B_IMS B_TEST B_TMS C_HASH C_INSTALL C_ICODE C_IMS C_TEST C_TMS LEFT
node -e '
const fs = require("fs"), e = process.env;
const tail = f => { try { return fs.readFileSync(f, "utf8").slice(-1500); } catch (x) { return ""; } };
const side = (p, s) => ({hashVerified: e[p + "_HASH"] === "true", install: {exitCode: Number(e[p + "_INSTALL"]), errorCode: e[p + "_ICODE"] || "", durationMs: Number(e[p + "_IMS"])}, test: {exitCode: Number(e[p + "_TEST"]), durationMs: Number(e[p + "_TMS"]), logTail: tail(e.G + "/" + s + "-test.log")}});
process.stdout.write("::" + JSON.stringify({outputs: {baseline: side("B", "base"), candidate: side("C", "cand"), nodeModulesLeft: Number(e.LEFT), network: "none", testUser: "node (uid 1000)"}}) + "::\n");
'
- id: classify
type: io.kestra.plugin.scripts.shell.Commands
description: Turns the signals into a verdict with deterministic reason codes,
and writes report.json and the pull request comment. Runs offline.
taskRunner:
type: io.kestra.plugin.scripts.runner.docker.Docker
networkMode: none
containerImage: node@sha256:c3de60bf2f9dd0ac6370e6117950ff62d6e339527e7472301c9c78a017978392
env:
PREPARE_JSON: "{{ outputs.prepare.vars | toJson }}"
TEST_JSON: "{{ outputs.test.vars | toJson }}"
REPO_URL: "{{ inputs.repo_url }}"
COMMIT: "{{ inputs.commit }}"
PROJECT_PATH: "{{ inputs.project_path }}"
PKG: "{{ inputs.package }}"
VER: "{{ inputs.target_version }}"
EXECUTION_ID: "{{ execution.id }}"
inputFiles:
classify.js: |
const fs = require("fs");
const e = process.env;
const p = JSON.parse(e.PREPARE_JSON), t = JSON.parse(e.TEST_JSON);
const NETWORK = /^(ECONNREFUSED|ECONNRESET|ENOTFOUND|ETIMEDOUT|EAI_AGAIN|ENETUNREACH|EHOSTUNREACH|ERR_SOCKET_TIMEOUT|EPIPE|E429|E5[0-9][0-9])$/;
const pre = p.precheck || {}, d = p.diff || null, b = t.baseline, c = t.candidate;
const ran = x => x.exitCode >= 0;
const infra = x => x.exitCode === 124 || (x.exitCode > 0 && NETWORK.test(x.errorCode));
const inconclusive = [], block = [], warn = [], notes = [];
if (p.stage === "lockfile_missing") inconclusive.push("LOCKFILE_MISSING");
if (p.stage === "unsupported") inconclusive.push("UNSUPPORTED_PROJECT");
if (infra(p.resolution) || infra(p.cacheFill.baseline) || infra(p.cacheFill.candidate)) inconclusive.push("INFRASTRUCTURE_ERROR");
if (b.hashVerified && (ran(b.install) && b.install.exitCode !== 0 || ran(b.test) && b.test.exitCode !== 0)) inconclusive.push("BASELINE_FAILED");
if (p.stage === "non_registry" || (p.candidatePrecheck && p.candidatePrecheck.nonRegistry.length)) block.push("NON_REGISTRY_DEPENDENCY");
if (p.stage === "ok" && !pre.direct) block.push("PACKAGE_NOT_DIRECT");
if (p.resolution.exitCode > 0 && !infra(p.resolution)) block.push("RESOLUTION_FAILED");
if (!b.hashVerified || !c.hashVerified) block.push("LOCKFILE_HASH_MISMATCH");
if (ran(c.install) && c.install.exitCode !== 0) block.push("CANDIDATE_INSTALL_FAILED");
if (ran(c.test) && c.test.exitCode !== 0) block.push("CANDIDATE_TEST_FAILED");
if (d && d.engineConflicts.length) block.push("NODE_ENGINE_CONFLICT");
if (d && d.deprecated.length) warn.push("DEPRECATED_PACKAGE");
if (d && d.installScripts.length) {
warn.push("INSTALL_SCRIPT_PRESENT");
if (block.includes("CANDIDATE_TEST_FAILED") || block.includes("CANDIDATE_INSTALL_FAILED")) notes.push("The candidate adds packages with install scripts, which this gate never runs. The failure may come from a skipped install script rather than from the code.");
}
if (p.stage === "ok" && !pre.hasTest) warn.push("NO_TEST_SCRIPT");
const verdict = inconclusive.length ? "INCONCLUSIVE" : block.length ? "BLOCK" : warn.length ? "WARN" : "PASS";
const reasonCodes = verdict === "INCONCLUSIVE" ? inconclusive : verdict === "BLOCK" ? block : verdict === "WARN" ? warn : [];
const repository = e.REPO_URL.replace("https://github.com/", "").replace(/\.git$/, "");
const report = {
contract: "kestra-dependency-upgrade-readiness/1.0",
verdict, reasonCodes,
warnings: warn, notes,
repository: {url: e.REPO_URL, slug: repository, commit: e.COMMIT, projectPath: e.PROJECT_PATH},
upgrade: {package: e.PKG, from: pre.locked || null, declared: pre.declared || null, to: e.VER, dependencySection: pre.section || null},
baseline: Object.assign({lockfileSha256: p.hashes.baseLockfile}, b),
candidate: Object.assign({lockfileSha256: p.hashes.candidateLockfile, resolution: p.resolution}, c),
dependencyDiff: d,
security: {
registry: "https://registry.npmjs.org/", repositoryNpmrcRemoved: p.npmrcRemoved, installScriptsExecuted: false,
nonRegistryDependencies: (p.candidatePrecheck || pre).nonRegistry || [],
testNetwork: t.network, testUser: t.testUser, nodeModulesCrossedTaskBoundary: t.nodeModulesLeft !== 0
},
runtime: {node: p.node, npm: p.npm, execution: e.EXECUTION_ID}
};
fs.writeFileSync("report.json", JSON.stringify(report, null, 2) + "\n");
const icon = {PASS: "PASS", WARN: "WARN", BLOCK: "BLOCK", INCONCLUSIVE: "INCONCLUSIVE"}[verdict];
const tests = x => !ran(x.install) ? "not run" : x.install.exitCode !== 0 ? "install failed (" + (x.install.errorCode || "exit " + x.install.exitCode) + ")" : !ran(x.test) ? "installed, no test script" : (x.test.exitCode === 0 ? "passed" : "failed (exit " + x.test.exitCode + ")") + " in " + (x.test.durationMs / 1000).toFixed(1) + "s";
const list = (title, items) => items.length ? "\n**" + title + "**\n" + items.map(x => "- " + x).join("\n") + "\n" : "";
const describe = code => ({
BASELINE_FAILED: "The current lockfile already fails install or tests, so the upgrade cannot be judged.",
INFRASTRUCTURE_ERROR: "The registry could not be reached or a step timed out.",
LOCKFILE_MISSING: "No package-lock.json in the project.",
UNSUPPORTED_PROJECT: "Lockfile v1, npm workspaces or linked packages are not supported.",
RESOLUTION_FAILED: "npm could not resolve the requested version (" + (p.resolution.errorCode || "exit " + p.resolution.exitCode) + ").",
PACKAGE_NOT_DIRECT: e.PKG + " is not a direct dependency of the project.",
CANDIDATE_INSTALL_FAILED: "The candidate lockfile does not install (" + (c.install.errorCode || "exit " + c.install.exitCode) + ").",
CANDIDATE_TEST_FAILED: "The project's tests fail with the upgrade and pass without it.",
NODE_ENGINE_CONFLICT: "A changed package does not support Node " + p.node + ": " + (d ? d.engineConflicts.map(x => x.package + " needs " + x.requires).join(", ") : ""),
NON_REGISTRY_DEPENDENCY: "The lockfile downloads packages from outside https://registry.npmjs.org/.",
LOCKFILE_HASH_MISMATCH: "A lockfile changed between preparation and installation.",
DEPRECATED_PACKAGE: "Deprecated: " + (d ? d.deprecated.join(", ") : ""),
INSTALL_SCRIPT_PRESENT: "Adds install scripts (not executed by this gate): " + (d ? d.installScripts.join(", ") : ""),
NO_TEST_SCRIPT: "The project has no test script, so the upgrade was only installed, not tested."
})[code];
const body = [
"### Dependency Upgrade Readiness Gate: " + icon,
"",
"| | |", "|---|---|",
"| Repository | " + repository + " @ `" + e.COMMIT.slice(0, 12) + "` (`" + e.PROJECT_PATH + "`) |",
"| Upgrade | `" + e.PKG + "` " + (pre.locked || "?") + " → " + e.VER + " |",
"| Baseline | " + tests(b) + " |",
"| Candidate | " + tests(c) + " |",
"| Dependency changes | " + (d ? "direct " + (d.direct.from || "?") + " → " + (d.direct.to || "?") + "; " + d.counts.added + " added, " + d.counts.removed + " removed, " + d.counts.changed + " other changed" : "not resolved") + " |",
list(verdict === "INCONCLUSIVE" ? "Why the result is inconclusive" : "Blocking reasons", (verdict === "INCONCLUSIVE" ? inconclusive : block).map(describe)),
list("Warnings", warn.map(describe)),
list("Notes", notes),
"<sub>Kestra execution " + e.EXECUTION_ID + ". Install scripts are never run; tests run offline as an unprivileged user. This is not a malware scanner.</sub>"
].join("\n");
fs.writeFileSync("comment.md", body);
process.stdout.write("Verdict " + verdict + (reasonCodes.length ? ": " + reasonCodes.join(", ") : "") + (warn.length && verdict !== "WARN" ? " (warnings: " + warn.join(", ") + ")" : "") + "\n");
process.stdout.write("::" + JSON.stringify({outputs: {verdict, reasonCodes, warnings: warn, repository, commentBody: body}}) + "::\n");
outputFiles:
- report.json
commands:
- node classify.js
- id: comment_on_pr
type: io.kestra.plugin.core.flow.If
description: Only touches GitHub when a pull request number was given.
condition: "{{ (inputs.pr_number ?? 0) > 0 }}"
then:
- id: pr_comment
type: io.kestra.plugin.github.issues.Comment
description: One comment per pull request, project and package. Later runs
update it in place instead of adding a new one.
oauthToken: "{{ secret('GITHUB_TOKEN') }}"
repository: "{{ outputs.classify.vars.repository }}"
issueNumber: "{{ inputs.pr_number }}"
updateTag: "dependency-upgrade-readiness:{{ inputs.project_path }}:{{
inputs.package }}"
body: "{{ outputs.classify.vars.commentBody }}"
- id: verdict
type: io.kestra.plugin.core.flow.Switch
description: BLOCK fails the execution so a pipeline waiting on it stops;
INCONCLUSIVE ends in WARNING; PASS and WARN succeed.
value: "{{ outputs.classify.vars.verdict }}"
cases:
BLOCK:
- id: block
type: io.kestra.plugin.core.execution.Fail
errorMessage: "Upgrade of {{ inputs.package }} to {{ inputs.target_version }} is
blocked: {{ outputs.classify.vars.reasonCodes | join(', ') }}"
INCONCLUSIVE:
- id: inconclusive
type: io.kestra.plugin.core.flow.AllowFailure
description: Ends the execution in WARNING rather than FAILED, and still
publishes the flow outputs.
tasks:
- id: not_comparable
type: io.kestra.plugin.core.execution.Fail
errorMessage: "Upgrade of {{ inputs.package }} to {{ inputs.target_version }}
could not be judged: {{ outputs.classify.vars.reasonCodes |
join(', ') }}"
defaults:
- id: ready
type: io.kestra.plugin.core.log.Log
message: "Upgrade of {{ inputs.package }} to {{ inputs.target_version }}: {{
outputs.classify.vars.verdict }}{{ (outputs.classify.vars.warnings |
length) > 0 ? ' (' ~ (outputs.classify.vars.warnings | join(', ')) ~
')' : '' }}"
outputs:
- id: verdict
type: STRING
description: PASS, WARN, BLOCK or INCONCLUSIVE.
value: "{{ outputs.classify.vars.verdict ?? 'ERROR' }}"
- id: reason_codes
type: JSON
description: Reason codes behind the verdict, for example ["CANDIDATE_TEST_FAILED"].
value: "{{ (outputs.classify.vars.reasonCodes ?? []) | toJson }}"
- id: report
type: FILE
description: report.json, contract kestra-dependency-upgrade-readiness/1.0.
value: "{{ outputs.classify.outputFiles['report.json'] ?? '' }}"
triggers:
- id: upgrade_webhook
type: io.kestra.plugin.core.trigger.Webhook
description: 'POST {"repo_url", "commit", "project_path", "package",
"target_version", "pr_number"} from CI, for example when Dependabot or
Renovate opens a pull request. Values go through the same validators as a
manual run; an invalid request gets HTTP 422 and no execution.'
key: "{{ secret('DEPENDENCY_GATE_WEBHOOK_KEY') }}"
inputs:
repo_url: "{{ trigger.body.repo_url }}"
commit: "{{ trigger.body.commit }}"
project_path: "{{ trigger.body.project_path ?? '.' }}"
package: "{{ trigger.body.package }}"
target_version: "{{ trigger.body.target_version }}"
pr_number: "{{ trigger.body.pr_number ?? '' }}"