Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,5 @@ experiments/**/output/
app-icon.png
# Tauri regenerates these schemas on every build
examples/**/src-tauri/gen/
# the CLI's default test-root directory (release-qa designate/status use it when --root is not given)
# the CLI's default test root and run state (.release-qa, .release-qa/runs) when --root/--state are not given
.release-qa/
39 changes: 23 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,35 +12,42 @@ The first targets are Tauri applications on Windows and Linux, with Dot X as the
## Status

Stage 0 (proving the assumptions) is complete. The runner (environment checks, scenario execution, the durable run
journal) and a first slice of the CLI (`doctor`, `designate`, `status`) exist; running an actual scenario from the
CLI (`run`, `resume`), the Tauri driver, the dashboard and the GitHub integration do not yet.
journal) and the CLI's local commands (`doctor`, `designate`, `status`, `reset`, `run`, `resume`) exist. The Tauri
driver adapter and a runnable sample consumer, the dashboard and the GitHub integration do not exist yet.

- [Native automation](docs/decisions/native-automation.md): unchanged packaged Tauri apps can be driven on Windows and Ubuntu. The Dot X feasibility check is still open.
- [GitHub merge gate](docs/decisions/github-gate.md): a no-service required check works, with documented design changes and unproven items.
- [Tool layout and defaults](docs/decisions/tool-layout.md): runtime, package manager, baselines and repository structure.
- [Local runs](docs/decisions/local-runs.md): the local candidate manifest, run state, resume rules and exit codes.

## CLI

Run it straight from a checkout; no build step (see [tool layout](docs/decisions/tool-layout.md)). `designate` and
`status` work as they stand, against any directory:
Run it straight from a checkout; no build step (see [tool layout](docs/decisions/tool-layout.md)):

```sh
node packages/qa/src/cli/main.ts designate [--root <path>] [--json] # marks a directory safe to install and delete into
node packages/qa/src/cli/main.ts status [--root <path>] [--json] # reports what a designated root holds, dirty or clean
node packages/qa/src/cli/main.ts reset [--root <path>] [--json] # reaps what a crashed run left, clears the dirty marker
node packages/qa/src/cli/main.ts doctor --project <qa/project.json> --profile <id> [--json]
node packages/qa/src/cli/main.ts run --project <qa/project.json> --candidate <candidate.json> --profile <id> --suite <id> [--root <path>] [--state <dir>] [--json]
node packages/qa/src/cli/main.ts resume --run <run id> [--state <dir>] [--json]
```

`doctor` needs a `qa/project.json` from a project that has one (this repository does not ship a sample yet β€” that
lands with the CLI's `run`/`resume` commands):

```sh
node packages/qa/src/cli/main.ts doctor --project path/to/qa/project.json --profile windows [--json]
```

`--root` defaults to `.release-qa` under the current directory (gitignored) when not given. Every command prints to
stdout on success and to stderr on failure; `--json` switches both to one line of machine-readable JSON. Exit codes:
`0` passed/ready, `1` a scenario the candidate failed (not reachable yet β€” no command runs a scenario), `2` this
machine does not meet a requested profile, `3` anything else that stopped the command (bad usage, an unreadable or
invalid project file, a profile the project does not declare).
`doctor` and `run` need a consumer's `qa/project.json`; this repository does not ship a runnable sample consumer yet.
`run` also needs a [local candidate manifest](docs/decisions/local-runs.md#the-local-candidate-manifest) naming the
file to test and its SHA-256, which is checked before anything is installed.

`--root` defaults to `.release-qa` and `--state` to `.release-qa/runs`, under the current directory (gitignored).
Results go to stdout, problems and progress to stderr; `--json` makes the result one line of JSON. `run` prints its
run id on stderr before anything runs, so an interrupted run can be continued with `resume`. Interrupting `run` once
(Ctrl+C) cancels it cleanly; a second interrupt exits at once.

Exit codes: `0` passed/ready; `1` a scenario the candidate failed; `2` a missing prerequisite or manual work left (this
machine does not meet the profile, a scenario was blocked, a manual check remains); `3` anything else that stopped the
command or left a run unfinished (bad usage, a file that cannot be read or does not verify, an unknown profile or
suite, an interrupted or cancelled scenario, a cleanup that failed and left the environment dirty, a reset that could not
clean everything). A run takes the highest rule
that applies: any failure is `1` even if something else was also interrupted.

## Development

Expand Down
58 changes: 58 additions & 0 deletions docs/decisions/local-runs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Decision: running a suite from a checkout (Task 2.2, part 3a)

Status: **implemented** for the CLI's `run`, `resume` and `reset`, against fixture consumer projects. Running the real sample (`examples/tauri-smoke`) through a Tauri driver adapter is part 3b.

## The local candidate manifest

`run --candidate <file>` takes a local manifest, not a GitHub candidate record:

```json
{
"schemaVersion": 1,
"id": "local-2026-09-23.1",
"artifacts": [
{ "profile": "windows", "name": "setup.exe", "path": "dist/setup.exe", "sha256": "<64 lower-case hex>" }
]
}
```

- One artifact per profile; `path` is relative to the manifest's own directory and cannot leave it, lexically or through a link: the real location must be inside the manifest's real directory.
- Before anything is installed, the chosen profile's file is hashed at its real location and must match `sha256`; the location is re-checked after hashing. Other profiles' files are never read.
- It has no source or build provenance, so it can never stand in for a GitHub candidate at the merge gate (Stage 3). Hooks receive only `candidate.id` and the verified `artifact` (`name`, its real absolute `path` with no link left in it, `sha256`); a full GitHub candidate record also fits `candidate`.

## Consumer code

`qa/project.json` names a lifecycle module exporting `lifecycle` (`install`, `reset`, `launch`, `cleanup`) and scenario files exporting `scenarios` (`{ id, setup?, steps }[]`). A requirement key `<profile>/<id>` runs the scenario with that id. Running executes the project's code; the CLI does it only for a project the user points it at, and every problem (missing hook, undefined scenario, duplicate id, a module that throws while loading) is reported before anything is installed.

## State

- Default test root: `.release-qa` under the current directory; default state directory: `.release-qa/runs` (both gitignored). Each run is `runs/<run id>/` with `invocation.json` (what was asked, absolute paths, and the tested artifact's SHA-256), `events.jsonl` (the Task 1.3 journal) and `summary.json`.
- The machine is identified in run records by a random token kept in the state directory, never the host name.
- `run` announces `run <id> started` on stderr before anything runs, in every output mode, with the `resume` command to use (including a custom `--state`, quoted so it pastes safely in bash and PowerShell), so a run can be resumed even if the process dies.
- A run id is a run id, never a path: `resume --run` accepts only the journal's id grammar, which has no path separators (`/`, `\`, `:`).

## Journal and resume

Each scenario records a `scenario-started` checkpoint, then an attempt, then a `cleanup-failed` checkpoint if its cleanup failed. `resume --run <id>`:

- re-verifies the candidate: the same manifest id, and bytes whose SHA-256 is the one recorded when the run started (a manifest edited to name new bytes under the same id is refused: that is a different build);
- carries **passed** and **failed** forward (a failure cannot disappear by being run again), with any recorded cleanup failure;
- reruns anything else (blocked, cancelled, interrupted, never reached) as a retry (`retryOf`) of its latest attempt;
- turns a `scenario-started` with no attempt after it (the process died) into an explicit `interrupted` attempt first, so the crash stays in the run's history.

A run whose journal has conflicting or cyclic events, or cannot be read, is not continued.

## Outcomes and exit codes

One scenario failing does not stop the others; cancellation stops the running scenario (its cleanup still runs) and starts nothing further (`not-run`). Exit codes, highest rule first: any **failed** β†’ `1`; any interrupted, cancelled or not-run, or any cleanup that failed (the environment was left dirty, however the scenario went) β†’ `3`; any blocked or manual β†’ `2`; otherwise `0`. Configuration and verification problems are `3` before anything runs.

## Reset

A crash can leave owned resources and a dirty marker in the test root, which blocks later runs. `reset` reaps the ledger and clears the marker only when that fully succeeds; it refuses an undesignated root and a root a run currently holds.

## Not verified

- Cancellation by a real signal is tested on Linux only (CI). On Windows a console Ctrl+C reaches the same handler, but a test cannot send one to another process.
- Evidence files (screenshots, logs) are not collected yet; attempts record `evidence: []`.
- The artifact's contents could still change after verification; only copying it into the run's own storage would close that. Links cannot redirect it, and the install hook consumes it at once.
- Consumer code runs in the CLI's own process: a scenario that calls `process.exit` takes the CLI with it. That is the crash `resume` recovers from, not something prevented.
47 changes: 45 additions & 2 deletions packages/qa/src/cli/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,34 @@ export interface StatusCommand {
json: boolean;
}

export type Command = DoctorCommand | DesignateCommand | StatusCommand;
export interface ResetCommand {
name: 'reset';
root: string | undefined;
json: boolean;
}

export interface RunCommand {
name: 'run';
project: string;
candidate: string;
profile: string;
suite: string;
/** The designated test root; absent means the caller's default. */
root: string | undefined;
/** Where run journals are kept; absent means the caller's default. */
state: string | undefined;
json: boolean;
}

/** Continues a run exactly as it was started: the project, candidate, profile, suite and root are the run's own. */
export interface ResumeCommand {
name: 'resume';
run: string;
state: string | undefined;
json: boolean;
}

export type Command = DoctorCommand | DesignateCommand | StatusCommand | ResetCommand | RunCommand | ResumeCommand;

export type ParsedArgs = { ok: true; command: Command } | { ok: false; error: string; json: boolean };

Expand All @@ -40,6 +67,9 @@ const SPECS: Record<Command['name'], Spec> = {
doctor: { required: ['project', 'profile'], optional: [] },
designate: { required: [], optional: ['root'] },
status: { required: [], optional: ['root'] },
reset: { required: [], optional: ['root'] },
run: { required: ['project', 'candidate', 'profile', 'suite'], optional: ['root', 'state'] },
resume: { required: ['run'], optional: ['state'] },
};

const COMMAND_NAMES = Object.keys(SPECS) as Command['name'][];
Expand All @@ -59,11 +89,24 @@ export function parseArgs(argv: readonly string[]): ParsedArgs {
return { ok: true, command: { name, project: flags.values.project as string, profile: flags.values.profile as string, json } };
case 'designate':
case 'status':
return { ok: true, command: { name, root: flags.values.root as string | undefined, json } };
case 'reset':
return { ok: true, command: { name, root: flags.values.root, json } };
case 'run': {
const { project, candidate, profile, suite, root, state } = flags.values as Record<string, string>;
return { ok: true, command: { name, project: project!, candidate: candidate!, profile: profile!, suite: suite!, root, state, json } };
}
case 'resume': {
// The run id becomes a directory name under the state directory, so it must never be a path.
const run = flags.values.run as string;
if (!RUN_ID.test(run)) return fail(`--run must be a run id (letters, digits, ".", "_" or "-", starting with a letter or digit), not ${JSON.stringify(run)}`, json);
return { ok: true, command: { name, run, state: flags.values.state, json } };
}
}
}

const countJson = (argv: readonly string[]): number => argv.filter((token) => token === '--json').length;
/** The id grammar the journal uses; it has no path separators, so a run id can never climb out of the state directory. */
const RUN_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;

function isCommandName(value: string): value is Command['name'] {
return (COMMAND_NAMES as string[]).includes(value);
Expand Down
81 changes: 81 additions & 0 deletions packages/qa/src/cli/candidate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { createHash } from 'node:crypto';
import { createReadStream } from 'node:fs';
import { readFile, realpath, stat } from 'node:fs/promises';
import { dirname, resolve, sep } from 'node:path';
import { parseLocalCandidate } from '../model/local-candidate.ts';
import type { ArtifactRef, CandidateRef } from '../runner/execute.ts';

export type LoadedCandidate = { ok: true; candidate: CandidateRef; artifact: ArtifactRef } | { ok: false; error: string };

const message = (error: unknown): string => (error instanceof Error ? error.message : String(error));
const fold = (path: string): string => (process.platform === 'win32' ? path.toLowerCase() : path);
const isInside = (directory: string, path: string): boolean => fold(path).startsWith(fold(directory.endsWith(sep) ? directory : directory + sep));

/**
* Reads a local candidate manifest, picks the artifact for `profile`, and checks the file's bytes against the
* manifest's SHA-256 before anything is installed. Never throws. Only the chosen profile's file is read.
*/
export async function loadCandidate(manifestPath: string, profile: string): Promise<LoadedCandidate> {
let text: string;
try {
text = await readFile(manifestPath, 'utf8');
} catch (error) {
return { ok: false, error: `could not read the candidate manifest ${manifestPath}: ${message(error)}` };
}
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch (error) {
return { ok: false, error: `${manifestPath} is not valid JSON: ${message(error)}` };
}
const result = parseLocalCandidate(parsed);
if (!result.ok) return { ok: false, error: `${manifestPath}: ${result.error.message}` };
const manifest = result.value;

const chosen = manifest.artifacts.find((a) => a.profile === profile);
if (chosen === undefined) {
return { ok: false, error: `candidate "${manifest.id}" has no artifact for profile "${profile}"; it has: ${manifest.artifacts.map((a) => a.profile).join(', ')}` };
}

// Relative to the manifest, not to wherever the command happens to be run from.
const path = resolve(dirname(manifestPath), ...chosen.path.split('/'));
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
const info = await stat(path).catch(() => undefined);
if (info === undefined) return { ok: false, error: `the artifact ${path} for profile "${profile}" does not exist` };
if (!info.isFile()) return { ok: false, error: `the artifact ${path} for profile "${profile}" is not a file` };
// The manifest's path check is lexical; a link on the way could still lead elsewhere. Judge the real locations, then
// hash the real file and give hooks that path, so no link is left in it that could be swapped to point elsewhere.
let realDirectory: string;
let realFile: string;
try {
[realDirectory, realFile] = await Promise.all([realpath(dirname(manifestPath)), realpath(path)]);
} catch (error) {
return { ok: false, error: `could not resolve the artifact ${path}: ${message(error)}` };
}
if (!isInside(realDirectory, realFile)) {
return { ok: false, error: `the artifact ${path} for profile "${profile}" leads outside the manifest's directory, to ${realFile}` };
}

let actual: string;
try {
actual = await sha256Of(realFile);
// A link swapped while the file was being hashed would make the check above describe some other file.
if ((await realpath(path)) !== realFile) return { ok: false, error: `the artifact ${path} changed location while it was being verified` };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The new link-swap refusal ("changed location while it was being verified") has no test. The added tests only cover a realpath that throws, a link leading outside the manifest directory, and a link inside it being resolved β€” none trigger this branch, which is the main new defense against a link swapped mid-verification. Add a test that swaps the directory link between two targets (or mocks realpath to return a different path on the re-check call) and asserts the changed location refusal.

Prompt for AI agents
Check if this issue is valid β€” if so, understand the root cause and fix it. At packages/qa/src/cli/candidate.ts, line 62:

<comment>The new link-swap refusal ("changed location while it was being verified") has no test. The added tests only cover a `realpath` that throws, a link leading outside the manifest directory, and a link inside it being resolved β€” none trigger this branch, which is the main new defense against a link swapped mid-verification. Add a test that swaps the directory link between two targets (or mocks `realpath` to return a different path on the re-check call) and asserts the `changed location` refusal.</comment>

<file context>
@@ -42,22 +42,31 @@ export async function loadCandidate(manifestPath: string, profile: string): Prom
-    actual = await sha256Of(path);
+    actual = await sha256Of(realFile);
+    // A link swapped while the file was being hashed would make the check above describe some other file.
+    if ((await realpath(path)) !== realFile) return { ok: false, error: `the artifact ${path} changed location while it was being verified` };
   } catch (error) {
     return { ok: false, error: `could not read the artifact ${path}: ${message(error)}` };
</file context>

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b7143e3. The new test mocks realpath so the artifact resolves to its real location before hashing and somewhere else afterwards, and asserts the 'changed location' refusal and that the re-check ran. I confirmed it fails when the re-check is removed.

} catch (error) {
return { ok: false, error: `could not read the artifact ${path}: ${message(error)}` };
}
if (actual !== chosen.sha256) {
return { ok: false, error: `the artifact ${path} does not match the candidate: expected SHA-256 ${chosen.sha256}, found ${actual}` };
}
return { ok: true, candidate: { id: manifest.id }, artifact: { name: chosen.name, path: realFile, sha256: actual } };
}

/** Streams the file, so an installer of any size is hashed without being held in memory. */
function sha256Of(path: string): Promise<string> {
return new Promise((resolveHash, rejectHash) => {
const hash = createHash('sha256');
createReadStream(path)
.on('data', (chunk) => hash.update(chunk))
.on('error', rejectHash)
.on('end', () => resolveHash(hash.digest('hex')));
});
}
Loading
Loading