run-plan.json Schema Reference¶
run-plan.json is the ordered list of concrete checks a project resolves to: one cell per (target-or-bundle, profile, checks[] entry), each
already carrying its own check_id. abicheck project plan derives it
from a project's .abicheck.yml targets:/bundles:/profiles:/
baseline: block (G30 P1.5) plus each contract:
true profile's build-output.json (G30 P1.1).
check-project.yml's matrix and a standalone
check-single.yml invocation both consume it.
Status. This page documents the
run-plan.jsonschema and theabicheck project plancommand shipped in G30 P1.4 (consolidated from a former standalonerun-planCLI group). See the reusable workflows reference for howcheck-project.ymldrives this generator and consumes its output.
Why a separate artifact¶
.abicheck.yml's checks: entries describe policy (which channel, which
depth, required or not) without committing to which profiles actually apply
— an explicit profiles: selector, or (more commonly) "every contract:
true profile that happens to build this target." Resolving that into a
concrete cell list needs each profile's build-output.json, which only
exists after that profile's build has run. Splitting run-plan generation out
as its own artifact means:
- The plan step (which needs
build-output.jsonfrom every profile) and the check step (which fans out over a matrix, potentially across many runners) can be separate CI jobs. - The exact same cell list drives both the matrix (
fromJSON(...)onchecks) and the trailingaggregategate's expected-target manifest — they cannot drift apart, because both read the one file. - A caller can inspect
run-plan.jsonbefore any check actually runs, to confirm coverage looks right.
Never a blind cross-product¶
project_targets.py's own docstring flags the
gap: crossing every checks: entry with every
contract: true profile would produce impossible cells for a target that
doesn't exist on every profile. project plan resolves this as follows,
per checks[] entry:
- Explicit
profiles:selector. Only those profiles are considered — and each one must build the referenced target/library (a matchingbuild-output.jsontargets[]entry), or it's a hard error. A caller who names a profile explicitly is asserting that cell should exist. - No
profiles:selector (implicit sweep). Everycontract: trueprofile is considered, but a profile whosebuild-output.jsondoesn't list the referenced target/library is silently skipped — not an error, since the whole point of the sweep is "run this on every profile where it makes sense."
A profile with no --build-output supplied at all is a hard error,
explicit selector or implicit sweep alike — there is nothing to check the
target against, which is different from an implicit sweep's ordinary "this
profile doesn't build the target" skip above (that skip needs an existing
build-output.json that simply omits the target from its targets[] list;
a profile with no build-output artifact at all never got that far).
The app-consumer/plugin-contract library redirect¶
Neither target-kind: app-consumer nor plugin-contract ever gets its own
build-output.json targets[] entry — build-output.json describes real
build products, and an app-consumer/plugin-contract target is a check, not
a build product. A redirected check's cell existence is gated
on the referenced library's presence on that profile instead, and its
binary_pattern is sourced from that library's own binary_pattern (never
the contract target's, which doesn't have one) — see baseline_target and
binary_pattern in the field table below.
RunPlanCheck fields¶
Field names deliberately mirror
actions/check-target/action.yml's own input names
(kind, target_kind → target-kind, baseline_target →
baseline-target, ...) so a matrix include: entry built from one of these
dicts can forward each field through with no renaming.
| Field | Present for | Meaning |
|---|---|---|
check_id |
always | target@profile#baseline_channel@requested_depth — this cell's own reporting identity. |
kind |
always | target or bundle. |
name |
always | The target or bundle id. |
profile_id |
always | Which profile this cell resolved against. |
baseline_channel |
always | The channel this cell's baseline resolves through, or none. |
requested_depth |
always | binary | headers | build | source. |
required |
always | Whether a missing report for this cell fails aggregate's coverage gate. |
gate_mode |
always | local | deferred | advisory (forwarded to check-target). |
target_kind |
kind: target |
library | app-consumer | plugin-contract. |
baseline_target |
target_kind: app-consumer/plugin-contract |
The referenced kind: library target's id (empty otherwise — check-target's own baseline-target input treats empty as "use name"). |
binary_pattern |
kind: target |
Glob pattern (resolved against the current build's candidate artifacts by the calling workflow, never by this generator) locating the candidate binary. For a redirected check, the referenced library's own pattern. |
header |
kind: target, and the target (or, for a redirected check, the referenced library) declares public_headers: |
That target's public_headers:, newline-joined so a header root containing whitespace survives action/run.sh's add_flag() multi-value input handling intact. Empty (field omitted) when the target declares none — a caller then falls back to its own workflow-global header input. Never set for kind: bundle (no per-bundle-member header staging exists yet — see BUNDLE_CHECK_DEPTHS in project_targets.py). |
public_header_roots |
kind: target, and this profile's build-output.json entry for the target declares public_header_roots |
(G41 Phase 2) That entry's public_header_roots, newline-joined the same way. Distinct from header: header is .abicheck.yml's declared, same-for-every-profile value; this is build-output.json's concrete, per-profile, validated-to-exist-and-non-empty set (the S10 guard) — the real header roots this profile's own build actually produced. A caller should prefer this over header when non-empty. Never set for kind: bundle. |
generated_header_roots |
kind: target, and this profile's build-output.json entry for the target declares generated_header_roots |
(G41 Phase 2) That entry's generated_header_roots (a codegen-produced header root, S10-validated the same way), newline-joined. Has no .abicheck.yml-level counterpart at all. Never set for kind: bundle. |
build_system / build_generator |
this profile's build-output.json declares profile.build_system |
(WS-A) That profile's declared build system and generator (generator empty when it has none), forwarded to check-target's build-system/build-generator inputs and recorded in the report as profile_build_system. Omitted when the profile declares none (unrecorded). kind: target cells only today (bundle cells: not yet). |
consumer_binary_pattern |
target_kind: app-consumer |
The consumer binary/binaries pattern. |
contract_file |
target_kind: plugin-contract |
The .syms contract file path. |
bundle_members |
kind: bundle |
Member target ids. |
member_binary_patterns |
kind: bundle |
Member target id → that member's own binary_pattern, so a caller can stage a member-binaries directory without re-reading .abicheck.yml. |
compile_gcc_path |
this cell's profile declares compile.binding and --toolchain-bindings was given |
That binding, resolved to an exact executable path — forwarded as check-target's gcc-path input. Empty (field omitted) when the profile has no compile: overlay, declares no binding, or --toolchain-bindings was omitted/the binding wasn't found in it — a caller then falls back to its own global gcc-path. |
compile_gcc_options |
this cell's profile's compile overlay sets any of standard/stdlib/target/abi_macros/args |
Those axes composed into one space-joined extra-flags string (-std=<standard> -stdlib=<stdlib> --target=<target> -D<macro>[=<value>] ... <args...>, macros sorted by name, args appended verbatim last) — forwarded as check-target's gcc-options input. Not filtered by compile.compiler_family: the composed string is always consumed by a Clang-based frontend in this pipeline (castxml's internal bundled Clang, or the direct-clang backend), never a literal GCC binary, so -stdlib=/--target= are emitted regardless of the declared family — see _compose_gcc_options's own docstring for why an earlier attempt to drop them for compiler_family: gcc was reverted. |
consumer_compile_gcc_path |
this cell's profile declares consumer_compile.binding and --toolchain-bindings was given |
Same resolution as compile_gcc_path, but from the profile's separate consumer_compile: overlay (G34 Phase 0) — never falls back to compile_gcc_path's own resolved value when the profile has no consumer_compile:. check-project.yml forwards it to check-target's separate consumer-context candidate dump. |
consumer_compile_gcc_options |
this cell's profile's consumer_compile overlay sets any of standard/stdlib/target/abi_macros/args |
Same composition as compile_gcc_options, from the consumer_compile: overlay. |
compile_ast_frontend |
this cell's profile's compile overlay sets frontend |
One of auto/castxml/clang/hybrid (G34 Phase B), overriding the global --ast-frontend default for this profile's cell only. Empty (field omitted) when the profile has no compile: overlay or sets no frontend. |
consumer_compile_ast_frontend |
this cell's profile's consumer_compile overlay sets frontend |
Same resolution as compile_ast_frontend, from the profile's separate consumer_compile: overlay (G34 Phase 0) — never falls back to compile_ast_frontend's own value when the profile has no consumer_compile:. check-project.yml uses it for the distinct consumer-context extraction pass, never the producer comparison pass. |
runs_on |
always | The GitHub-hosted runner this cell must be scheduled on, derived from its profile's os: (G34 Phase C) — check-project.yml reads it as matrix.runs_on. ubuntu-latest for a profile with no os:, which is what every cell hardcoded before this phase. Unlike every other optional field here it is emitted even at its default: a matrix entry missing the key resolves runs-on: to the empty string, scheduling nothing. |
dependency_source |
this cell's profile declares dependency_source: |
How this cell provisions its own system dependencies — one of conda-forge/conda-forge-gcc14/conda-forge-clang20/system/none (G34 Phase C), forwarded as check-target's dependency-source input. Empty (field omitted) when the profile declares none, which leaves the caller's workflow-level default standing. |
allow_new_target |
checks[].allow_new_target: true |
Forwarded as check-target's allow-new-target input — opts this cell into the new_target outcome instead of ambiguous when the resolved baseline-set has no entry for this target yet (e.g. a new library's first release). Field omitted (defaults false) otherwise. kind: bundle never carries it — project_targets.py rejects allow_new_target: true on a bundle check at config-validation time, since a bundle comparison needs one coherent release where every member already coexisted. See Baseline Management → A new library's first release. |
consumer_compile_active |
this cell's profile declares a non-empty consumer_compile: overlay |
true whenever the profile has a consumer_compile: block that sets at least one field, regardless of what its fields resolve to (a binding: with no matching --toolchain-bindings entry still counts). Field omitted (defaults false) both for a profile with no overlay at all AND for one declaring an empty consumer_compile: {} — ProfileCompileSpec.is_empty/ProfileSpec.to_dict() already treat an empty overlay as indistinguishable from an absent one, and this field agrees rather than promising an activation marker a generated plan wouldn't actually contain. check-project.yml gates its consumer_compile_ast_frontend/consumer_compile_gcc_path/consumer_compile_gcc_options → workflow-global-input fallback on this flag, not on whether the resolved field itself is non-empty — otherwise a profile with no (or an empty) overlay would still get a non-empty consumer-ast-frontend/etc. the moment the caller sets any workflow-global ast-frontend/gcc-path/gcc-options input, activating check-target's separate consumer-context dump for every cell instead of only the ones that actually declare a real overlay. |
explicit_id |
checks[].id declared |
(G42) The unqualified checks[].id value — already folded into check_id's ~<explicit_id> tail, carried here too so a caller can read the logical id without re-parsing check_id. Field omitted when no id: was declared. |
analysis_evidence / analysis_policy / analysis_assurance |
checks[].analysis.evidence/.policy/.assurance declared |
(G42) Forwarded verbatim from CheckSpec.analysis_*. Each field omitted when the corresponding analysis: key is absent. |
profiles.<id>.compile reaches the cell (P1 toolchain-profile audit).
project-targets-schema.md's profiles:
section documents the overlay itself; this generator is the "run-plan
consumer" its binding field's docs promised. compiler_family/
compiler_version are validated shape-wise by project validate
but not projected into compile_gcc_path/compile_gcc_options —
compiler_family only selects a toolchain through binding (there is no
separate "pick a family" flag to forward; the composed compile_gcc_options
string is always consumed by a Clang-based frontend in this pipeline, never
a literal GCC binary, so there is nothing correct for compiler_family to
gate there), and compiler_version is a constraint (e.g. ">=14.0,<15"),
not a value; verifying a resolved binding's actual version against it needs
a real toolchain-identity probe, which stays out of this pure, no-subprocess
module by design.
profiles.<id>.consumer_compile reaches the cell the same way (G34 Phase
0). project-targets-schema.md's consumer_compile:
section
documents the config-schema side; this generator projects it into its own
separate consumer_compile_gcc_path/consumer_compile_gcc_options pair,
resolved identically to (but independently of) compile:'s own fields.
check-project.yml applies these fields to a distinct candidate dump that
reads the unchanged producer binary and parses its headers under the consumer
context; comparison consumes the resulting snapshot.
compile.frontend/consumer_compile.frontend reach the cell the same
way (G34 Phase B). project-targets-schema.md's compile.frontend
section
documents the config-schema side; this generator projects each overlay's
frontend: into its own field (compile_ast_frontend/
consumer_compile_ast_frontend), resolved independently.
check-project.yml's check job then forwards compile_ast_frontend into
the cell's real invocation as
${{ matrix.kind != 'bundle' && matrix.compile_ast_frontend || inputs.ast-frontend }}
— the same per-cell-first precedence compile_gcc_path/compile_gcc_options
use, so a GCC profile's cell and a Clang profile's cell in one run genuinely
invoke different frontends. The field is still projected onto a bundle
check (it records what the profile resolved to), but not applied there: a
bundle cell's operand is the bundle-staging directory it stages members
into, and the root Action rejects every non-auto ast-frontend for a
directory/package operand, since the per-library fan-out never threads an L2
compile context to each pair's header dump.
consumer_compile_ast_frontend is forwarded only to check-target's
separate consumer-context candidate dump. It never steers the ordinary
producer-context comparison pass.
A cell schedules itself (G34 Phase C). runs_on and dependency_source
are the two axes check-project.yml previously fixed for the whole run: every
check cell ran on a hardcoded ubuntu-latest, and dependency provisioning came
from one workflow-level install-deps boolean. Deriving both per cell is what
makes a genuine GCC/Clang/MSVC matrix schedulable through the shared reusable
workflow — an os: windows profile's cell lands on windows-latest, and a
GCC-profile cell and a Clang-profile cell in the same run can each provision a
matching conda environment. Unlike the two overlays above, this pair is not
projection-only: check-project.yml consumes both today.
Precedence for dependency_source matches every other per-profile override
here — the profile's own value wins over the workflow-level
dependency-source input, and both empty leaves the legacy install-deps
boolean deciding, exactly as before. An os: naming no schedulable platform
is a hard error at both project validate and project plan time rather than
a silent fallback to Linux: a cell scheduled on the wrong platform reports
success having gated the wrong thing.
No build-output paths are carried through. build-output.json is used
purely as an existence/membership oracle here — the candidate artifact a
real check compares is whatever the current run's build produced,
addressed via binary_pattern/consumer_binary_pattern/
member_binary_patterns glob patterns the calling workflow resolves against
a live filesystem (this generator performs no file I/O beyond reading its
own inputs).
Top-level gate block and the schema discriminator¶
CONFIG's optional aggregate: gate: block (CLI cleanup phase two, PR 2's
original manifest-carried policy, and its PR 2 follow-up moving the source
from a pair of project plan flags into durable project config) stamps an
optional top-level gate block onto the generated run-plan.json — the
same policy shape a hand-authored --manifest carries in its own gate
block (see Aggregate Reports):
# .abicheck.yml
aggregate:
gate:
missing_required: fail # fail | warn
unexpected_target: include # include | warn | fail | ignore
Both sub-keys are independently optional; only present when at least one was
set. A plan generated from a CONFIG with no aggregate: gate: block (or
neither sub-key set) omits gate entirely, and aggregate --manifest then
applies the hard-coded default policy, same as before this option existed.
There is no per-invocation CLI override — project plan's former
--gate-missing-required/--gate-unexpected-target flags were removed (no
CLI alias); set the policy in CONFIG directly.
A plan carrying gate is always stamped "schema": "abicheck.run-plan/v1"
is wrong — it is stamped "schema": "abicheck.run-plan/v2" instead. This
is deliberate, not incidental: an old, pre-gate reader's RunPlan.from_dict()
would otherwise silently ignore the unknown gate key and apply its own
hard-coded default policy — exactly the version-skew inversion
aggregate_manifest_version's 2.0 bump (see
Aggregate Reports) exists to prevent, just one
layer up, in the persisted run-plan.json artifact rather than only in the
manifest aggregate --manifest projects from it in memory. A plan with no
gate keeps the unchanged v1 schema string — the bump is additive-only,
scoped to this one capability. A gate block paired with a declared v1
schema, a missing schema field, or an unrecognized/malformed schema
string is rejected as malformed input, not silently honored.
Top-level skipped block¶
A plan that resolves no checks carries a skipped block saying why, and
is stamped "schema": "abicheck.run-plan/v3":
{
"schema": "abicheck.run-plan/v3",
"skipped": {
"reason": "no_checks_declared",
"declared_checks": 0,
"explanation": "CONFIG declares no targets:/bundles: checks[], ..."
},
"checks": []
}
Two empty plans mean opposite things, and the block is what separates them
(plan slice 7r, which retired project plan --allow-empty — a bypass switch
that existed only because the artifact could not tell them apart):
reason |
What it means | project plan exit |
|---|---|---|
no_checks_declared |
CONFIG declares no checks[] at all — a project bootstrapping .abicheck.yml. Nothing was skipped that was ever asked for; run abicheck project validate CONFIG for the config's own well-formedness. |
0 |
checks_declared_none_resolved |
CONFIG declares checks[] and none resolved to a (target, profile) cell — usually a missing --build-output, or a profile id that does not match its build-output.json. Every downstream matrix/aggregate step would be silently skipped. |
1 |
declared_checks is the evidence the classification rests on, carried so a
consumer can check the label rather than trust it. A plan with at least one
check never carries the block and keeps its v1/v2 schema string: the
bump is additive-only and scoped to this one capability, for the same reason
gate's v2 bump is — a pre-v3 reader sees only checks: [] and cannot
tell a deliberate, explained skip from a plan whose every check failed to
resolve, so it must reject the artifact rather than read it as the other
case. A skipped block paired with a declared pre-v3 schema is rejected as
malformed input, not silently honored.
CLI¶
abicheck project plan [CONFIG] [--build-output PROFILE=DIR ...] \
[--project OWNER/REPO] [--head-sha SHA] \
[-o json=...|text] [-o OUTPUT]
CONFIG defaults to .abicheck.yml. --build-output is repeatable — one
per contract profile referenced by CONFIG's checks:, where DIR is that
profile's abicheck-build-<profile>/ directory (containing
build-output.json). Exit codes:
| Exit | Meaning |
|---|---|
0 |
Generated with no coverage-gap errors (warnings may still exist), and at least one check resolved — or CONFIG declared no checks[] at all, which is an explained skipped plan (see skipped below). |
1 |
A required/explicit check could not be resolved against the supplied --build-output directories, or CONFIG declared checks[] that resolved to zero cells (a fail-closed default, so a consumer with no guard of its own doesn't silently skip every downstream check). There is no flag that accepts this case. |
64 |
Usage error — CONFIG or a --build-output value is unreadable, or CONFIG fails project validate. |
aggregate --manifest (plan slice 7q folded the former separate
run-plan flag into it, recognizing the plan by its own schema rather than
by its filename) projects run-plan.json down to the
expected-target set internally — abicheck aggregate --manifest's
{"targets": [{"id", "required"}]} wire shape — using each check's own check_id as the expected target id,
never the bare target/bundle name. abicheck/workflows/aggregate/'s target
matching is an exact string comparison against each report's own
target_id, and check-target (G30 P1.3) always writes that field as the
identical check_id-shaped string; projecting to a bare name here would
collide S17/S21's multi-profile/multi-channel same-target checks against
each other in aggregate's duplicate-target-id check. There is no separate
projection command — the former standalone run-plan to-aggregate-manifest
step is folded into this option, so a caller never produces (or needs to
know about) an intermediate manifest file.
Example¶
{
"schema": "abicheck.run-plan/v1",
"project": "acme/foo",
"head_sha": "deadbeef",
"checks": [
{
"check_id": "libfoo@linux#release@headers",
"kind": "target",
"name": "libfoo",
"profile_id": "linux",
"baseline_channel": "release",
"requested_depth": "headers",
"required": true,
"gate_mode": "local",
"target_kind": "library",
"binary_pattern": "build/libfoo*.so"
}
]
}
Full worked example with two toolchain profiles (compile_gcc_path/
compile_gcc_options populated, --toolchain-bindings in use):
tests/fixtures/run_plan/toolchain_matrix/README.md.