From 1b3d57ca8aa392431e5e95b9a113f5b8fde7b5f0 Mon Sep 17 00:00:00 2001 From: gongfpp <1360187968@qq.com> Date: Fri, 23 Oct 2026 22:42:58 +0800 Subject: [PATCH] docs(ci): add a repeatable static analysis example (#6496) * docs(ci): add a repeatable static analysis example * fix(ci): isolate example reports and repair the guide link --------- Co-authored-by: gongfpp <22683417+gongfpp@users.noreply.github.com> --- docs/ci-analysis.md | 90 +++++++ docs/cli.md | 2 + examples/github-actions/rea-analysis.yml | 60 +++++ .../boundary/cli/githubActionsExample.test.ts | 242 ++++++++++++++++++ 4 files changed, 394 insertions(+) create mode 100644 docs/ci-analysis.md create mode 100644 examples/github-actions/rea-analysis.yml create mode 100644 tests/boundary/cli/githubActionsExample.test.ts diff --git a/docs/ci-analysis.md b/docs/ci-analysis.md new file mode 100644 index 0000000000..153f2d0db8 --- /dev/null +++ b/docs/ci-analysis.md @@ -0,0 +1,90 @@ +# Static analysis in GitHub Actions + +The [minimal workflow](https://github.com/morluto/rea/blob/main/examples/github-actions/rea-analysis.yml) builds a small +JavaScript application, analyzes its files without executing them, and uploads +machine-readable results. It uses the existing CLI, not a separate GitHub Action +or an MCP client. It does not promise support for every REA provider. + +## Run the example + +1. Copy the example to `.github/workflows/rea-analysis.yml` in your repository and + commit it to the default branch. No existing application or package manifest is + needed for the self-contained demonstration. +2. Enable GitHub Actions if necessary. Select **REA static analysis example** in + the Actions tab and choose **Run workflow**. +3. Download the `rea-analysis` artifact after the run. Its files are: + + | File | Meaning | + | ----------------- | -------------------------------------------------------------------------------------------------- | + | `analysis.json` | Original CLI JSON, including complete Evidence on success or the error report when one is emitted. | + | `analysis.stderr` | Original diagnostics/progress; an empty file is normal when there are none. | + | `exit-code.txt` | Original analysis process exit code. | + +The example pins Node.js 24.22.1, REA 6.7.0, and action commit revisions. It installs +REA into the runner's temporary directory rather than changing the application's +dependencies. This pins the tool version, not every transitive npm dependency; +use a reviewed, committed package lock and `npm ci` if your integration requires +that stronger dependency-reproducibility guarantee. + +## Prerequisites and trust + +The runner is GitHub-hosted Ubuntu 24.04 with Bash, npm, and `ps` from `procps`. +Other Linux images must supply `procps`; REA's supervised process controls use it. +This static JavaScript operation needs no Hopper, Ghidra, IDA, Java, browser, +Docker, GPU, external API key, or user-supplied GitHub secret. The workflow only +requests `contents: read`; artifact upload uses the runner's normal Actions +credentials. + +Replace **Build a known artifact** with your reviewed build, and set +`ARTIFACT_PATH` to its application directory (relative to the checkout) or an ASAR. +The demonstration creates its own tiny application; it deliberately throws if +executed. REA reads it statically. Your replacement build itself can execute code: +review it separately and do not add untrusted pull-request builds to a privileged +`pull_request_target` workflow. The example is manually triggered, not an +untrusted-PR execution policy. + +Reports can contain paths, URLs, source-derived facts, and caller-selected data. +Only analyze artifacts and publish reports you are authorized to share. Do not +rely on REA inferring secrets from their names. Review artifact access and +retention for your repository; the example retains reports for seven days. + +## Failure, partial results, and unsupported capabilities + +The analysis step captures stdout and stderr separately, records `$?`, and exits +with that same status. There is no `continue-on-error`, `|| true`, output filter, +or pipeline that could turn a failed analysis green. Before checkout or other +fallible steps, the workflow creates a fresh report directory under `RUNNER_TEMP`. +Analysis and upload both use that directory, never a checked-in `reports/rea` or +other workspace files. The upload step uses `always()` when that directory was +prepared, so available diagnostics survive an analysis failure. If installation +or the build failed before analysis began, it is empty and upload warns rather +than publishing stale results or inventing a successful result. Runner loss or +cancellation can still prevent upload. + +`--json --full-output` selects a machine-readable envelope; it does **not** mean +that analysis was complete. A successful operation can retain partial analysis, +unknowns, and limitations. Exit zero is not a security verdict, proof of complete +coverage, or a policy gate for those findings. Inspect the saved Evidence and +coverage before making such a decision. See [output and exit status](cli.md#output-and-exit-status). + +A nonzero exit can represent invalid input, unavailable tooling, an unsupported +host/capability, or another operation failure. Keep the original JSON error (when +emitted), stderr, and exit code, and use their category/message to diagnose it. +An early startup failure may leave `analysis.json` empty; do not parse that as +successful analysis. Select a supported operation or explicitly provision its +[documented prerequisites](installation.md); do not silently switch providers, +execute the artifact, or ignore the failure to obtain a green job. + +To exercise the failure path, temporarily set `ARTIFACT_PATH` to +`build/missing-artifact` without changing the demo build. Analysis must fail, +`exit-code.txt` must be nonzero, and the available reports must still upload. +To exercise unsupported input, set `ARTIFACT_PATH` to `build/rea-ci-demo/index.js`: +this operation accepts an application directory or ASAR, not a single source file. +The retained JSON error has category `unsupported_target`; the step exits nonzero. +Restore the correct path before using the example normally. + +To exercise partial analysis, append `export const broken = ;` to the demo's +JavaScript. REA 6.7.0 retains `data.normalized_result.statistics.parse_failures` +and an incomplete-coverage limitation while the operation can still exit zero. +Inspect those facts instead of treating process success as full coverage. The +report JSON and diagnostics remain unfiltered in all three cases. diff --git a/docs/cli.md b/docs/cli.md index 4435272ae7..b8122f7076 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -3,6 +3,8 @@ Use the CLI for a direct inspection or a script. It uses the same application workflows and evidence contracts as REA's MCP server. Each CLI invocation is a separate process; save results when you want to use them in a later command. +For a pinned, noninteractive workflow with retained reports, see +[static analysis in GitHub Actions](ci-analysis.md). ## Run a command diff --git a/examples/github-actions/rea-analysis.yml b/examples/github-actions/rea-analysis.yml new file mode 100644 index 0000000000..53d37a7a4f --- /dev/null +++ b/examples/github-actions/rea-analysis.yml @@ -0,0 +1,60 @@ +# Copy to .github/workflows/rea-analysis.yml on your default branch. +name: REA static analysis example + +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + analyze: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + env: + ARTIFACT_PATH: build/rea-ci-demo + steps: + - name: Prepare isolated report directory + id: reports + shell: bash + run: | + directory=$(mktemp -d "${RUNNER_TEMP:?}/rea-analysis.XXXXXX") + printf 'directory=%s\n' "${directory}" >> "${GITHUB_OUTPUT:?}" + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: "24.22.1" + - name: Install pinned REA + run: | + npm install --prefix "${RUNNER_TEMP}/rea-tool" --no-audit --no-fund --ignore-scripts --save-exact rea-agents@6.7.0 + echo "${RUNNER_TEMP}/rea-tool/node_modules/.bin" >> "${GITHUB_PATH}" + - name: Build a known artifact + id: build + run: | + # Replace this step with your reviewed build, keeping ARTIFACT_PATH in sync. + mkdir -p build/rea-ci-demo + printf '%s\n' '{"name":"rea-ci-demo","version":"1.0.0","type":"module","main":"index.js"}' > build/rea-ci-demo/package.json + printf '%s\n' 'export const message = "hello";' 'throw new Error("This artifact must not be executed.");' > build/rea-ci-demo/index.js + - name: Analyze and retain the original exit status + id: analyze + shell: bash + env: + REPORT_DIR: ${{ steps.reports.outputs.directory }} + run: | + set +e + rea analyze-javascript-application "${GITHUB_WORKSPACE}/${ARTIFACT_PATH}" --json --full-output \ + > "${REPORT_DIR}/analysis.json" 2> "${REPORT_DIR}/analysis.stderr" + status=$? + set -e + printf '%s\n' "${status}" > "${REPORT_DIR}/exit-code.txt" + exit "${status}" + - name: Upload available reports, including after analysis failure + if: ${{ always() && steps.reports.outputs.directory != '' }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: rea-analysis + path: ${{ steps.reports.outputs.directory }} + if-no-files-found: warn + retention-days: 7 diff --git a/tests/boundary/cli/githubActionsExample.test.ts b/tests/boundary/cli/githubActionsExample.test.ts new file mode 100644 index 0000000000..7a895d2e7c --- /dev/null +++ b/tests/boundary/cli/githubActionsExample.test.ts @@ -0,0 +1,242 @@ +import { + appendFile, + mkdir, + readFile, + readdir, + symlink, + writeFile, +} from "node:fs/promises"; +import { delimiter, dirname, join, resolve } from "node:path"; + +import { describe, expect, it } from "vitest"; +import { parse } from "yaml"; +import { z } from "zod"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +const example = async () => + z + .object({ + on: z.record(z.string(), z.null()), + permissions: z.record(z.string(), z.string()), + jobs: z.object({ + analyze: z.object({ + "runs-on": z.string(), + env: z.object({ ARTIFACT_PATH: z.string() }), + steps: z.array( + z.object({ + id: z.string().optional(), + name: z.string().optional(), + run: z.string().optional(), + uses: z.string().optional(), + if: z.string().optional(), + env: z.record(z.string(), z.string()).optional(), + "continue-on-error": z.boolean().optional(), + with: z.record(z.string(), z.unknown()).optional(), + }), + ), + }), + }), + }) + .parse( + parse( + await readFile( + new URL( + "../../../examples/github-actions/rea-analysis.yml", + import.meta.url, + ), + "utf8", + ), + ), + ); + +it("keeps the example manually triggered, least-privileged, pinned and failure-preserving", async () => { + const workflow = await example(); + expect(workflow.on).toEqual({ workflow_dispatch: null }); + expect(workflow.permissions).toEqual({ contents: "read" }); + const job = workflow.jobs.analyze; + expect(job["runs-on"]).toBe("ubuntu-24.04"); + expect( + job.steps.find((step) => step.uses?.startsWith("actions/setup-node@")) + ?.with, + ).toMatchObject({ + "node-version": "24.22.1", + }); + for (const step of job.steps) { + expect(step["continue-on-error"]).toBeUndefined(); + if (step.uses !== undefined) expect(step.uses).toMatch(/@[a-f0-9]{40}$/u); + } + expect( + job.steps.find((step) => step.name === "Install pinned REA")?.run, + ).toContain("--save-exact rea-agents@6.7.0"); + const upload = job.steps.find((step) => + step.uses?.startsWith("actions/upload-artifact@"), + ); + expect(job.steps[0]?.id).toBe("reports"); + expect(job.steps.find((step) => step.id === "analyze")?.env).toEqual({ + REPORT_DIR: "${{ steps.reports.outputs.directory }}", + }); + expect(upload?.if).toBe( + "${{ always() && steps.reports.outputs.directory != '' }}", + ); + expect(upload?.with).toMatchObject({ + path: "${{ steps.reports.outputs.directory }}", + "if-no-files-found": "warn", + "retention-days": 7, + }); +}); + +it("links to the workflow asset outside the documentation site's base", async () => { + const guide = await readFile( + new URL("../../../docs/ci-analysis.md", import.meta.url), + "utf8", + ); + expect(guide).toContain( + "[minimal workflow](https://github.com/morluto/rea/blob/main/examples/github-actions/rea-analysis.yml)", + ); +}); + +// This example explicitly targets an Ubuntu/Bash runner, not Windows shells. +describe.skipIf(process.platform === "win32")( + "GitHub Actions example with the compiled CLI", + () => { + for (const kind of [ + "complete", + "partial", + "missing", + "unsupported", + "pre-analysis-failure", + ] as const) { + cliTest( + `retains the real ${kind} result and exit status`, + async ({ processes, cliCompileCache }) => { + const job = (await example()).jobs.analyze; + const build = job.steps.find((step) => step.id === "build")?.run; + const analyze = job.steps.find((step) => step.id === "analyze")?.run; + if (build === undefined || analyze === undefined) + throw new Error("Example omitted build or analysis"); + const root = await createTestTempDirectory("rea-ci-example-"); + const workspace = join(root, "checkout"); + const runner = join(root, "runner"); + const home = join(root, "home"); + const bin = join(root, "bin"); + const outputs = join(root, "step-outputs"); + for (const directory of [workspace, runner, home, bin]) + await mkdir(directory); + await writeFile(outputs, ""); + // Use the real executable, not a stub that invents a successful report. + await symlink(resolve("scripts/rea.mjs"), join(bin, "rea")); + const env = { + PATH: [bin, dirname(process.execPath), "/usr/bin", "/bin"].join( + delimiter, + ), + HOME: home, + TMPDIR: root, + NODE_COMPILE_CACHE: cliCompileCache, + GITHUB_WORKSPACE: workspace, + GITHUB_OUTPUT: outputs, + RUNNER_TEMP: runner, + REPORT_DIR: "", + ARTIFACT_PATH: job.env.ARTIFACT_PATH, + }; + const run = (script: string) => + processes.run( + "bash", + ["--noprofile", "--norc", "-e", "-o", "pipefail", "-c", script], + { + cwd: workspace, + env, + timeoutMs: 30_000, + }, + ); + const checkoutReports = join(workspace, "reports", "rea"); + await mkdir(checkoutReports, { recursive: true }); + await writeFile( + join(checkoutReports, "analysis.json"), + '{"ok":true,"stale":true}\n', + ); + await writeFile( + join(checkoutReports, "unrelated.txt"), + "Not a run report.\n", + ); + let reports = checkoutReports; + const prepare = job.steps.find((step) => step.id === "reports")?.run; + if (prepare !== undefined) { + const prepared = await run(prepare); + expect(prepared.exitCode, prepared.stdout + prepared.stderr).toBe( + 0, + ); + const output = (await readFile(outputs, "utf8")) + .split("\n") + .find((line) => line.startsWith("directory=")); + if (output === undefined) + throw new Error("Report step omitted its directory output"); + reports = output.slice("directory=".length); + env.REPORT_DIR = reports; + } + if (kind === "pre-analysis-failure") { + const failed = await run("exit 23"); + expect(failed.exitCode).toBe(23); + expect(await readdir(reports)).toEqual([]); + expect( + await readFile(join(checkoutReports, "analysis.json"), "utf8"), + ).toContain('"stale":true'); + return; + } + const built = await run(build); + expect(built.exitCode, built.stdout + built.stderr).toBe(0); + if (kind === "partial") + await appendFile( + join(workspace, env.ARTIFACT_PATH, "index.js"), + "\nexport const broken = ;\n", + ); + if (kind === "missing") env.ARTIFACT_PATH = "build/missing-artifact"; + if (kind === "unsupported") + env.ARTIFACT_PATH = "build/rea-ci-demo/index.js"; + const result = await run(analyze); + expect(result.signal).toBeNull(); + const raw = await readFile(join(reports, "analysis.json"), "utf8"); + expect(await readdir(reports)).toEqual([ + "analysis.json", + "analysis.stderr", + "exit-code.txt", + ]); + const diagnostics = await readFile( + join(reports, "analysis.stderr"), + "utf8", + ); + expect(await readFile(join(reports, "exit-code.txt"), "utf8")).toBe( + `${result.exitCode}\n`, + ); + expect(diagnostics).not.toContain( + "This artifact must not be executed.", + ); + const envelope: unknown = JSON.parse(raw); + if (kind === "complete" || kind === "partial") { + expect(result.exitCode).toBe(0); + expect(envelope).toMatchObject({ + ok: true, + data: { + normalized_result: { + statistics: { parse_failures: kind === "partial" ? 1 : 0 }, + }, + }, + }); + if (kind === "partial") + expect(raw).toContain("Graph coverage is incomplete"); + } else { + expect(result.exitCode).toBe(1); + expect(envelope).toMatchObject({ + ok: false, + error: { + category: + kind === "missing" ? "invalid_input" : "unsupported_target", + }, + }); + } + }, + ); + } + }, +);