-
Notifications
You must be signed in to change notification settings - Fork 0
feat: Source Discovery v0.1 boundary + source intake integration #24
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| Feature: Source Discovery | ||
| As a TraceMap investigation user | ||
| I want TraceMap to discover likely source candidates from my research topic | ||
| So that investigations can proceed even when I do not paste URLs | ||
|
|
||
| Scenario: Source discovery is disabled by default | ||
| Given source discovery provider is not configured | ||
| When a user starts an investigation | ||
| Then the existing manual URL intake behavior should be preserved | ||
| And the run should not require discovered sources to complete | ||
|
|
||
| Scenario: Mock source discovery returns deterministic source candidates | ||
| Given the source discovery provider is set to mock | ||
| When a user starts an investigation without URLs | ||
| Then TraceMap should request discovered source candidates for the research topic | ||
| And discovered source URLs should be normalized and deduplicated | ||
| And valid discovered sources should be passed to the answer graph provider as source candidates | ||
|
|
||
| Scenario: Manual URLs are prioritized over discovered URLs | ||
| Given the research topic contains manual URLs | ||
| And source discovery also returns URLs | ||
| When source candidates are built | ||
| Then manual URL candidates should appear before discovered candidates | ||
| And duplicate URLs should appear only once | ||
|
|
||
| Scenario: Discovery failures do not fail the investigation | ||
| Given the source discovery provider returns a failure | ||
| When a user starts an investigation | ||
| Then the investigation should continue with any manually supplied URL candidates | ||
| And the discovery failure should be recorded as ignored source information |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,88 @@ | ||
| # Source Discovery v0.1 | ||
|
|
||
| ## Purpose | ||
| Enable TraceMap to discover source candidates from a research topic even when the user does not provide manual URLs, while preserving the existing Source Intake / Fetch / Cache / Provider pipeline. | ||
|
|
||
| ## User value | ||
| - Users can start an investigation from a plain research topic and still get evidence candidates. | ||
| - Manual URLs remain first-class and prioritized when present. | ||
| - Discovery can evolve from mock to real search providers without breaking provider integration. | ||
|
|
||
| ## Scope | ||
| - Add `SourceDiscoveryProvider` boundary for pluggable source discovery. | ||
| - Add `disabled` and `mock` discovery providers. | ||
| - Add discovery hook from research topic (`question`) in source intake. | ||
| - Merge manual URLs and discovered URLs into one normalized, deduplicated intake list. | ||
| - Reuse existing source cache / fetch pipeline (`resolveSourceCacheForUrl`). | ||
| - Pass resulting `sourceCandidates` to answer graph providers. | ||
|
|
||
| ## Non-goals | ||
| - Production external search API integration. | ||
| - RAG / embeddings / reranking. | ||
| - Background job orchestration. | ||
| - DB schema changes or Prisma migrations. | ||
| - Large UI redesign. | ||
| - Major OpenAI answer graph schema changes. | ||
| - Full-text crawling. | ||
| - Dedicated persistence table for search result history. | ||
|
|
||
| ## Existing implementation constraints | ||
| - Keep `AnalysisRun.question` and form field `question` unchanged. | ||
| - Preserve existing Source Intake behavior for manual URLs. | ||
| - Keep run completion flow valid even when source discovery fails or yields no candidates. | ||
| - Do not bypass existing URL safety validation. | ||
|
|
||
| ## Provider strategy | ||
| - Environment variable switch: `TRACEMAP_SOURCE_DISCOVERY_PROVIDER=disabled|mock`. | ||
| - Default is `disabled`. | ||
| - `mock` provider must return deterministic results from the research topic. | ||
| - Provider boundary is designed to allow future providers (e.g. web search backends) without changing intake contracts. | ||
|
|
||
| ## Source candidate flow | ||
| 1. Extract manual URLs from research topic. | ||
| 2. Resolve source discovery provider. | ||
| 3. If provider is enabled, discover additional URLs from the same topic. | ||
| 4. Merge manual + discovered URLs (manual first). | ||
| 5. Normalize, safety-check, dedupe by normalized URL. | ||
| 6. Resolve cache/fetch metadata with `resolveSourceCacheForUrl`. | ||
| 7. Build `SourceCandidate[]` and pass to answer graph provider input. | ||
|
|
||
| ## Deduplication rules | ||
| - Deduplicate by normalized URL. | ||
| - Manual URL candidates are evaluated before discovered candidates. | ||
| - When duplicates exist, keep first occurrence (manual precedence). | ||
| - Do not fetch or process the same normalized URL more than once. | ||
|
|
||
| ## Error handling | ||
| - Discovery provider failures do not fail the run. | ||
| - Per-URL cache/fetch failures do not fail the run. | ||
| - Discovery and URL failures are captured in `ignoredUrls` with reasons. | ||
| - Avoid excessive logging; never log secrets. | ||
|
|
||
| ## Security constraints | ||
| - Discovery outputs are treated as untrusted input. | ||
| - All discovered URLs must pass existing normalization and safety checks. | ||
| - Unsafe URLs are ignored and recorded, not fetched. | ||
| - Existing SSRF guard behavior remains authoritative. | ||
|
|
||
| ## Cost constraints | ||
| - `DEFAULT_DISCOVERY_MAX_RESULTS = 5`. | ||
| - `DEFAULT_SOURCE_CANDIDATE_MAX_RESULTS = 5`. | ||
| - Keep provider source context compact (no raw full HTML). | ||
| - Keep excerpt truncation behavior unchanged in answer graph providers. | ||
|
|
||
| ## Test requirements | ||
| - Provider resolution defaults to disabled. | ||
| - Disabled provider yields no discovered candidates. | ||
| - Mock provider is deterministic and respects maxResults. | ||
| - Intake integration preserves manual URL-only behavior. | ||
| - Discovery can produce candidates when no manual URLs exist. | ||
| - Manual URLs are prioritized over discovered URLs. | ||
| - Duplicate URLs are deduplicated. | ||
| - Discovery failure does not fail intake. | ||
| - Unsafe discovered URLs are ignored. | ||
| - Provider integration remains valid when sourceCandidates is empty or discovered. | ||
|
|
||
| ## Acceptance references | ||
| - `acceptance/source-discovery.feature` | ||
| - `acceptance/source-intake-and-fetching.feature` |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| import type { SourceDiscoveryProvider } from "@/server/analysis/source-discovery/source-discovery-provider"; | ||
|
|
||
| function toTopicSlug(topic: string): string { | ||
| const normalized = topic.trim().toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, ""); | ||
| return normalized.slice(0, 48) || "general-investigation"; | ||
| } | ||
|
|
||
| export const mockSourceDiscoveryProvider: SourceDiscoveryProvider = { | ||
| id: "mock", | ||
| async discoverSources(input) { | ||
| const slug = toTopicSlug(input.researchTopic); | ||
| const base = [ | ||
| { | ||
| title: `Mission dossier: ${slug}`, | ||
| url: `https://example.com/tracemap/mock-source-1?topic=${slug}`, | ||
| snippet: `Initial mission dossier for ${slug} with baseline facts and terminology.`, | ||
| sourceKind: "report" as const, | ||
| }, | ||
| { | ||
| title: `Primary evidence registry: ${slug}`, | ||
| url: `https://example.com/tracemap/mock-source-2?topic=${slug}`, | ||
| snippet: `Cross-checkable references and claims matrix for ${slug}.`, | ||
| sourceKind: "documentation" as const, | ||
| }, | ||
| { | ||
| title: `Risk and unknown map briefing: ${slug}`, | ||
| url: `https://example.com/tracemap/mock-source-3?topic=${slug}`, | ||
| snippet: `Potential uncertainties and unresolved questions related to ${slug}.`, | ||
| sourceKind: "news" as const, | ||
| }, | ||
| ].slice(0, Math.max(0, input.maxResults)); | ||
|
|
||
| return { kind: "success", candidates: base.map((c) => ({ ...c, discoveredBy: "mock" as const })) }; | ||
| }, | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| import { mockSourceDiscoveryProvider } from "@/server/analysis/source-discovery/mock-source-discovery-provider"; | ||
| import type { SourceDiscoveryProvider } from "@/server/analysis/source-discovery/source-discovery-provider"; | ||
|
|
||
| const disabledProvider: SourceDiscoveryProvider = { | ||
| id: "disabled", | ||
| async discoverSources() { | ||
| return { kind: "success", candidates: [] }; | ||
| }, | ||
| }; | ||
|
|
||
| export function resolveSourceDiscoveryProvider(): SourceDiscoveryProvider { | ||
| const configured = process.env.TRACEMAP_SOURCE_DISCOVERY_PROVIDER?.trim().toLowerCase() ?? "disabled"; | ||
| if (configured === "mock") return mockSourceDiscoveryProvider; | ||
| return disabledProvider; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| export type SourceDiscoveryCandidate = { | ||
| title: string; | ||
| url: string; | ||
| snippet?: string | null; | ||
| sourceKind?: "official" | "news" | "documentation" | "report" | "paper" | "unknown"; | ||
| discoveredBy: "mock" | "manual_url" | "search_provider"; | ||
| }; | ||
|
|
||
| export type SourceDiscoveryInput = { | ||
| researchTopic: string; | ||
| maxResults: number; | ||
| }; | ||
|
|
||
| export type SourceDiscoveryResult = | ||
| | { kind: "success"; candidates: SourceDiscoveryCandidate[] } | ||
| | { kind: "failure"; errorMessage: string }; | ||
|
|
||
| export type SourceDiscoveryProvider = { | ||
| id: "disabled" | "mock" | string; | ||
| discoverSources(input: SourceDiscoveryInput): Promise<SourceDiscoveryResult>; | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| export const DEFAULT_DISCOVERY_MAX_RESULTS = 5; | ||
| export const DEFAULT_SOURCE_CANDIDATE_MAX_RESULTS = 5; |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,32 +1,53 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import type { SourceCandidate, SourceIntakeResult } from "@/types/source-intake"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { resolveSourceCacheForUrl } from "@/server/analysis/source-cache-service"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { extractUrls } from "@/server/analysis/source-intake/extract-urls"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { resolveSourceDiscoveryProvider } from "@/server/analysis/source-discovery/resolve-source-discovery-provider"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { DEFAULT_DISCOVERY_MAX_RESULTS, DEFAULT_SOURCE_CANDIDATE_MAX_RESULTS } from "@/server/analysis/source-discovery/source-discovery-service"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export async function buildSourceIntakeFromQuestion(question: string): Promise<SourceIntakeResult> { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const rawUrls = extractUrls(question); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const candidates: SourceCandidate[] = []; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const manualUrls = extractUrls(question); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const discoveryProvider = resolveSourceDiscoveryProvider(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const ignoredUrls: SourceIntakeResult["ignoredUrls"] = []; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let discoveredUrls: string[] = []; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (discoveryProvider.id !== "disabled") { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const discovery = await discoveryProvider.discoverSources({ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| researchTopic: question, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| maxResults: DEFAULT_DISCOVERY_MAX_RESULTS, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (discovery.kind === "failure") { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ignoredUrls.push({ url: "[source_discovery]", reason: discovery.errorMessage }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| discoveredUrls = discovery.candidates.map((candidate) => candidate.url); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+13
to
+23
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
現在は 修正例 let discoveredUrls: string[] = [];
if (discoveryProvider.id !== "disabled") {
- const discovery = await discoveryProvider.discoverSources({
- researchTopic: question,
- maxResults: DEFAULT_DISCOVERY_MAX_RESULTS,
- });
- if (discovery.kind === "failure") {
- ignoredUrls.push({ url: "[source_discovery]", reason: discovery.errorMessage });
- } else {
- discoveredUrls = discovery.candidates.map((candidate) => candidate.url);
- }
+ try {
+ const discovery = await discoveryProvider.discoverSources({
+ researchTopic: question,
+ maxResults: DEFAULT_DISCOVERY_MAX_RESULTS,
+ });
+ if (discovery.kind === "failure") {
+ ignoredUrls.push({ url: "[source_discovery]", reason: discovery.errorMessage });
+ } else {
+ discoveredUrls = discovery.candidates.map((candidate) => candidate.url);
+ }
+ } catch (error) {
+ ignoredUrls.push({
+ url: "[source_discovery]",
+ reason: error instanceof Error ? error.message : String(error),
+ });
+ }
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const merged = [ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ...manualUrls.map((url) => ({ url, origin: "manual_url" as const })), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ...discoveredUrls.map((url) => ({ url, origin: "discovered" as const })), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ]; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const candidates: SourceCandidate[] = []; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const seen = new Set<string>(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| for (const rawUrl of rawUrls) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| for (const item of merged) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let result; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| result = await resolveSourceCacheForUrl(rawUrl); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| result = await resolveSourceCacheForUrl(item.url); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } catch (error) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ignoredUrls.push({ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| url: rawUrl, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| url: item.url, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| reason: error instanceof Error ? error.message : String(error), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| continue; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (result.kind === "invalid") { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ignoredUrls.push({ url: rawUrl, reason: result.errorMessage }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| continue; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (seen.has(result.normalizedUrl)) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ignoredUrls.push({ url: item.url, reason: result.errorMessage }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| continue; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (seen.has(result.normalizedUrl)) continue; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| seen.add(result.normalizedUrl); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| candidates.push({ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| normalizedUrl: result.normalizedUrl, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| originalUrl: result.originalUrl, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -39,7 +60,10 @@ export async function buildSourceIntakeFromQuestion(question: string): Promise<S | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| sourceCacheEntryId: result.sourceCacheEntryId, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| sourceFetchSnapshotId: result.sourceFetchSnapshotId, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| fetchErrorMessage: result.verificationStatus !== "verified" ? `verification_status:${result.verificationStatus}` : null, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| origin: item.origin, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (candidates.length >= DEFAULT_SOURCE_CANDIDATE_MAX_RESULTS) break; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return { candidates, ignoredUrls }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| import { describe, expect, it } from "vitest"; | ||
|
|
||
| import { mockSourceDiscoveryProvider } from "@/server/analysis/source-discovery/mock-source-discovery-provider"; | ||
| import { resolveSourceDiscoveryProvider } from "@/server/analysis/source-discovery/resolve-source-discovery-provider"; | ||
|
|
||
| describe("source discovery provider", () => { | ||
| it("defaults to disabled and returns empty candidates", async () => { | ||
| delete process.env.TRACEMAP_SOURCE_DISCOVERY_PROVIDER; | ||
| const provider = resolveSourceDiscoveryProvider(); | ||
| expect(provider.id).toBe("disabled"); | ||
| await expect(provider.discoverSources({ researchTopic: "x", maxResults: 5 })).resolves.toEqual({ kind: "success", candidates: [] }); | ||
| }); | ||
|
|
||
| it("mock provider is deterministic", async () => { | ||
| const a = await mockSourceDiscoveryProvider.discoverSources({ researchTopic: "Acme Revenue", maxResults: 3 }); | ||
| const b = await mockSourceDiscoveryProvider.discoverSources({ researchTopic: "Acme Revenue", maxResults: 3 }); | ||
| expect(a).toEqual(b); | ||
| }); | ||
|
|
||
| it("respects maxResults", async () => { | ||
| const result = await mockSourceDiscoveryProvider.discoverSources({ researchTopic: "Acme", maxResults: 2 }); | ||
| expect(result.kind).toBe("success"); | ||
| if (result.kind === "success") expect(result.candidates).toHaveLength(2); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
cat -n .github/workflows/ci.yml | head -200Repository: sasazaki1994/TraceMap
Length of output: 5740
🌐 Web query:
pnpm/action-setup v4 Node.js runtime requirements💡 Result:
The pnpm/action-setup v4 GitHub Action does not have strict Node.js runtime requirements specified in its documentation. It explicitly states: "This action does not setup Node.js for you, use actions/setup-node yourself."[1][2] You must separately configure Node.js using actions/setup-node@v4 (or equivalent), and pnpm/action-setup will use whatever Node.js runtime is available in the environment.[1][3] Key points from official sources: - v2 had compatibility issues with newer Node.js versions, prompting upgrade to v3+ (including v4).[1][2] - Recent v4 releases (v4.3.0, v4.4.0) updated the action's internal runtime to Node.js 24.[4][5] - Older v4 versions likely used Node.js 20 or earlier, but since v4.4.0, it runs on Node.js 24.[4] - Dependencies include
@types/node^20.x, indicating development against Node 20+.[6] - Example workflows use Node.js 20: uses: actions/setup-node@v4 with node-version: 20.[3][7] - Standalone mode (standalone: true) installs@pnpm/exe, a self-bundled executable that does not require Node.js.[1][2] - For pnpm versions like 11.x, Node.js 22+ is recommended due to pnpm requirements, but this is for the installed pnpm, not the action itself.[8] In practice, v4 works with Node.js 20+ in user workflows, with updates ensuring compatibility up to Node.js 24.[4][9] No minimum Node.js version is enforced by the action; compatibility depends on the pnpm version installed and GitHub Actions runner (ubuntu-latest supports Node 20-24 as of 2026).[9]Citations:
🌐 Web query:
GitHub Actions Node.js 20 deprecation Node.js 24 June 2026💡 Result:
As of today (2026-05-07), GitHub Actions is deprecating Node.js 20 and migrating the default JavaScript runtime to Node.js 24. 1) Key deprecation/migration dates (Node.js 20 -> Node.js 24) - GitHub’s published deprecation notice says that starting June 2, 2026, GitHub Actions runners begin using Node.js 24 by default. [1] - In that same notice, GitHub also states you can temporarily keep Node.js 20 after June 2, 2026 by setting ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION=true, but it will only work until Node 20 is removed later in the fall of 2026. [1] - GitHub notes that this deprecation process was started because Node.js 20 reaches end-of-life (EOL) in April 2026. [1] 2) How to react in your workflows - If you use JavaScript actions without pinning to a version that supports Node 24, you should upgrade the action versions. GitHub’s notice explicitly recommends updating actions to run on Node 24 and checking whether newer versions are available. [1] - A common way to control your app/test Node.js version (separate from the runtime used by the GitHub-provided “JavaScript actions” on the runner) is to use actions/setup-node with node-version. [2] 3) What your workflows might be telling you right now - Users commonly see a warning that Node.js 20 actions are deprecated and that actions will be forced to run with Node.js 24 by default starting June 2, 2026; the warning also mentions how to opt into Node 24 early (FORCE_JAVASCRIPT_ACTIONS_TO_NODE24=true) and how to opt out of the default using ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION=true. [3] If you tell me which specific GitHub Actions (e.g., actions/checkout@..., actions/setup-node@..., github/codeql-action@...) your workflows use, I can map them to what you should upgrade to avoid Node 20 runtime warnings.
Citations:
🌐 Web query:
pnpm/action-setup v6 current version recommended💡 Result:
Recommended: use pnpm/action-setup@v6, pinned to the latest v6 patch release: pnpm/action-setup@v6.0.5 (published 2026-05-02). [1] Example (best practice: pin to an exact v6.x.y): uses: pnpm/action-setup@v6.0.5 Or, to always stay on the latest v6 line (un-pinned): uses: pnpm/action-setup@v6 Notes: - The action’s README shows usage with pnpm/action-setup@v6, and documents that you can omit the version input to use the version from the package.json packageManager field. [1] - There is a known issue where specifying package_json_file and relying on packageManager in that file may not work as expected on some v6 versions; the issue suggests a workaround of passing version explicitly. [2]
Citations:
package_json_filepnpm/action-setup#227pnpm/action-setupを v4 から v6 へアップグレードしてくださいpnpm/action-setup@v6が現在の推奨バージョンです(公式リポジトリのREADMEで明記されており、2026年5月2日に v6.0.5 がリリースされています)。3つのジョブすべてで同じアップグレードが必要です。🔧 推奨される修正
3箇所(Line 36、Line 95、Line 154)すべてで同様の変更が必要です。
📝 Committable suggestion
🤖 Prompt for AI Agents