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>
This commit is contained in:
gongfppandgongfpp authored and GitHub committed 2026-10-23 23:42:58 +09:00
1 parent fd782cf2b1
commit 1b3d57ca8a
4 files changed
+394

No files matched your search

+90
View File
@@ -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.
+2
View File
@@ -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
+60
View File
@@ -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
@@ -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",
},
});
}
},
);
}
},
);