diff --git a/action.yml b/action.yml index c54de83..f80b342 100644 --- a/action.yml +++ b/action.yml @@ -104,7 +104,10 @@ runs: annotations_source="$json_file" rating_source="$json_file" if [ "${MESH_DIFF:-false}" = "true" ] && [ "${GITHUB_EVENT_NAME:-}" = "pull_request" ]; then - base_sha="$(jq -r '.pull_request.base.sha // empty' "$GITHUB_EVENT_PATH")" + # Read the PR base SHA from the event payload with Node (always + # present — this is a Node action) rather than jq, which is not + # guaranteed on self-hosted or minimal runners. + base_sha="$(node -e 'try{const fs=require("node:fs");const e=JSON.parse(fs.readFileSync(process.env.GITHUB_EVENT_PATH,"utf8"));process.stdout.write(e.pull_request?.base?.sha ?? "")}catch{process.stdout.write("")}')" if [ -n "$base_sha" ]; then git fetch --no-tags --depth=1 origin "$base_sha" 2>/dev/null || true base_dir="${RUNNER_TEMP:-.}/policymesh-base-worktree" @@ -145,29 +148,37 @@ runs: # pull_request event. Uses an HTML marker to find-and-update the # existing comment in place rather than spam a new one per push. if [ -n "${MESH_GITHUB_TOKEN:-}" ] && [ "${GITHUB_EVENT_NAME:-}" = "pull_request" ]; then - pr_number="$(jq -r '.pull_request.number // empty' "$GITHUB_EVENT_PATH")" - if [ -n "$pr_number" ]; then - marker='' - comment_file="${RUNNER_TEMP:-.}/policymesh-comment.md" - { - printf '%s\n\n' "$marker" - cat "$report_file" - } > "$comment_file" - - existing="$(GH_TOKEN="$MESH_GITHUB_TOKEN" gh api \ - "repos/${GITHUB_REPOSITORY}/issues/${pr_number}/comments" \ - --paginate \ - --jq ".[] | select(.body != null and (.body | contains(\"$marker\"))) | .id" \ - | head -n 1)" - - if [ -n "$existing" ]; then - GH_TOKEN="$MESH_GITHUB_TOKEN" gh api -X PATCH \ - "repos/${GITHUB_REPOSITORY}/issues/comments/${existing}" \ - -F body=@"$comment_file" > /dev/null - else - GH_TOKEN="$MESH_GITHUB_TOKEN" gh api -X POST \ + if ! command -v gh >/dev/null 2>&1; then + # gh is preinstalled on GitHub-hosted runners but may be absent + # on self-hosted or minimal images. Degrade gracefully rather + # than failing the step — the Markdown report is already in the + # step summary. + echo "::notice::PolicyMesh: sticky PR comment skipped because the GitHub CLI (gh) is not available on this runner. The Markdown report is still in the step summary." + else + pr_number="$(node -e 'try{const fs=require("node:fs");const e=JSON.parse(fs.readFileSync(process.env.GITHUB_EVENT_PATH,"utf8"));process.stdout.write(String(e.pull_request?.number ?? ""))}catch{process.stdout.write("")}')" + if [ -n "$pr_number" ]; then + marker='' + comment_file="${RUNNER_TEMP:-.}/policymesh-comment.md" + { + printf '%s\n\n' "$marker" + cat "$report_file" + } > "$comment_file" + + existing="$(GH_TOKEN="$MESH_GITHUB_TOKEN" gh api \ "repos/${GITHUB_REPOSITORY}/issues/${pr_number}/comments" \ - -F body=@"$comment_file" > /dev/null + --paginate \ + --jq ".[] | select(.body != null and (.body | contains(\"$marker\"))) | .id" \ + | head -n 1)" + + if [ -n "$existing" ]; then + GH_TOKEN="$MESH_GITHUB_TOKEN" gh api -X PATCH \ + "repos/${GITHUB_REPOSITORY}/issues/comments/${existing}" \ + -F body=@"$comment_file" > /dev/null + else + GH_TOKEN="$MESH_GITHUB_TOKEN" gh api -X POST \ + "repos/${GITHUB_REPOSITORY}/issues/${pr_number}/comments" \ + -F body=@"$comment_file" > /dev/null + fi fi fi fi diff --git a/package-lock.json b/package-lock.json index 963bbc4..5e16887 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,9 @@ "devDependencies": { "@types/node": "^24.0.0", "typescript": "^5.9.3" + }, + "engines": { + "node": ">=20" } }, "node_modules/@types/node": { diff --git a/package.json b/package.json index 514f4d2..11b82c6 100644 --- a/package.json +++ b/package.json @@ -38,5 +38,8 @@ "@types/node": "^24.0.0", "typescript": "^5.9.3" }, + "engines": { + "node": ">=20" + }, "license": "MIT" } diff --git a/test/workflow.test.mjs b/test/workflow.test.mjs index aee1f66..b48e6ef 100644 --- a/test/workflow.test.mjs +++ b/test/workflow.test.mjs @@ -153,6 +153,28 @@ test('published Action runs the bundled CLI without installing or rebuilding its assert.ok(trackedDistFiles.includes('dist/audit.js')); }); +test('action.yml reads the PR event with Node and guards the GitHub CLI', async () => { + const action = await readFile(join(packageRoot, 'action.yml'), 'utf8'); + + // No dependency on the standalone jq binary for parsing the event payload + // — minimal / self-hosted runners may not have it. + assert.doesNotMatch(action, /jq -r '\.pull_request/); + // PR base SHA + number are read with Node straight from the event JSON. + assert.match(action, /process\.env\.GITHUB_EVENT_PATH/); + assert.match(action, /pull_request\?\.base\?\.sha/); + // gh is guarded so a runner without it degrades to a notice, not a failure. + assert.match(action, /command -v gh/); +}); + +test('package.json and lockfile declare a supported Node engine', async () => { + const packageJson = JSON.parse(await readFile(join(packageRoot, 'package.json'), 'utf8')); + const packageLock = JSON.parse(await readFile(join(packageRoot, 'package-lock.json'), 'utf8')); + + assert.deepEqual(packageJson.engines, { node: '>=20' }); + // Keep the lockfile's root entry in sync so `npm ci` does not warn on drift. + assert.deepEqual(packageLock.packages[''].engines, { node: '>=20' }); +}); + test('CI workflow builds and tests PolicyMesh', async () => { const workflow = await readFile(join(packageRoot, '.github', 'workflows', 'ci.yml'), 'utf8');