mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
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:
1 parent
fd782cf2b1
commit
1b3d57ca8a
4 files changed
+394
No files matched your search
@@ -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.
|
||||||
@@ -3,6 +3,8 @@
|
|||||||
Use the CLI for a direct inspection or a script. It uses the same application
|
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
|
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.
|
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
|
## Run a command
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
Reference in new issue
Block a user