Reusable Workflows Reference: check-single.yml / check-project.yml¶
Two workflow_call reusable workflows (G30 P1.4,
ADR-047 §4/§5)
built on top of actions/check-target:
check-single.yml— a thin wrapper around onecheck-targetinvocation, for a caller that wants exactly one check without generating arun-plan.json.check-project.yml— the full multi-target flow: generaterun-plan.json, fan it out over a matrix (onecheck-targetinvocation per cell), then a trailingaggregatejob that projectsrun-plan.jsontoabicheck aggregate --manifest's wire shape and computes the fan-in gate decision.
Status. Shipped in G30 P1.4. The artifact-staging convention
check-project.ymlexpects from its caller (below) is new with this workflow and has not yet been exercised against a real external-consumer run — no second repository was available in the session that built this to validate cross-repo artifact staging end to end. Treat it as reviewed- but-unverified-in-a-live-CI-run, the same honesty this plan's own status notes use elsewhere for parts that could only be validated against a hand-authored fixture.
check-single.yml¶
Every input mirrors actions/check-target/action.yml's
own input surface 1:1 (same names, same defaults) — see that page for the
full rationale behind each one. Outputs are check-target's own six
outputs, forwarded unchanged, plus report-artifact-name.
report-path is a path inside this job's own ephemeral runner workspace —
not reachable by the calling workflow directly (a workflow_call job runs
on a separate runner, same caveat as the artifact-staging inputs below).
The job unconditionally uploads the report (if: always() &&
steps.run.outputs.report-path != '', same condition check-project.yml
uses for each matrix cell) under <inputs.report-artifact-prefix><sanitized
check-id> (prefix default abicheck-check-single-report-) — the same
prefix-plus-sanitized-check-id convention check-project.yml uses for each
matrix cell's own report artifact, so a caller invoking check-single.yml
more than once in one workflow run (a matrix, or several single-check jobs)
doesn't collide on actions/upload-artifact's per-run name-uniqueness
requirement. The full computed name is echoed back as the
report-artifact-name output so a caller can download-artifact it
without re-deriving the sanitization.
This job always runs in its own fresh, isolated runner — unlike
check-target itself (a composite Action a caller can nest as one step
inside their own existing job, sharing that job's filesystem), a
workflow_call reusable workflow's job never shares a filesystem with the
caller's own build job. A new-library/baseline-path/candidate-build-output
path only exists here if it's checked into git (present after this job's
own checkout) or explicitly staged as a download-artifact step — which is
exactly what the three optional *-artifact-name inputs below do, mirroring
check-project.yml's own artifact-staging convention:
jobs:
check-libfoo:
uses: abicheck/abicheck/.github/workflows/check-single.yml@c9e135a3233b6d45e9571533f71293fde458a469 # not yet in a tagged release; pin main or newer
with:
name: libfoo
profile: linux-x86_64-gcc13
baseline-channel: accepted-main
baseline-path: ./restored-baseline
baseline-artifact-name: abicheck-baseline-accepted-main
requested-depth: headers
candidate-artifact-name: my-build-output
new-library: candidate/lib/libfoo.so
candidate-artifact-name/baseline-artifact-name/build-output-artifact-name
are all optional (default empty, meaning "no download, use the path as
given") — a caller whose new-library/baseline-path/candidate-build-output
already point at a checked-in fixture doesn't need any of them.
check-project.yml¶
Three jobs, always in this order:
plan— generatesrun-plan.json(abicheck project plan --allow-empty, deferring the empty-plan guard to theno-checksjob below rather than the command's own fail-closed default) frominputs.config-path(default.abicheck.yml) plus every downloaded<build-output-artifact-prefix><profile-id>artifact, uploads it underinputs.run-plan-artifact-name, and exposes itschecks[]as a matrixinclude:list (job outputmatrix) plus ahas-checksflag.check(matrix,needs: plan,fail-fast: false) — onecheck-targetinvocation perrun-plan.jsoncell. Downloads that cell's candidate artifact and (unlessbaseline_channel: none) that channel's baseline-set artifact, resolves the candidate binary/binaries frombinary_pattern/member_binary_patternsvia a glob against the downloaded candidate tree, runscheck-target, and — unconditionally (if: always()) — uploads the resulting report under<report-artifact-prefix><check_id>.aggregate(needs: [plan, check],if: always()) — downloads every report artifact and runsabicheck aggregate reports --run-plan run-plan.json ..., which projectsrun-plan.jsonto the expected-target set internally (no separate projection step or intermediate manifest file, ADR-054).
The two required if: always() placements¶
ADR-047 §4
flags two specific places this workflow must use always() (or
!cancelled()), not a bare needs:/no condition — both because plain
GitHub Actions semantics skip a dependent job or step when an earlier
one in its chain fails, and a skipped job/step reports success:
- The
aggregatejob itself. Withoutif: always(), one matrix leg operationally failing undergate-mode: deferred(exactly the case where that leg is expected to fail its own job — that visibility is the point) would skipaggregateentirely, and a skipped job reporting success would silently green a branch-protection-required status past a missing target — the exact failure mode ADR-047 exists to close. - The matrix job's
Upload reportstep.check-target's own exit (after its finalize step already wrote the report) can still fail the step calling it, and a step in a job whose earlier step failed is skipped by default unless it too carriesalways(). Without this, the report for exactly the failing cellsaggregatemost needs to see would never upload.
The Run check-target step deliberately carries no continue-on-error:
letting its natural failure propagate is what makes the matrix job's own
conclusion correctly reflect a real gate-mode: local break or an
operational error — steps.run.outputs.* stay populated even for a failed
step (they were written by check-target's internal finalize step before
its own exit code was returned), so the always()-conditioned Upload report
step still sees them.
Pre-check failures (candidate resolution, build-output download)¶
Before check-target ever runs, the matrix job resolves this cell's
candidate binary/binaries (binary_pattern/member_binary_patterns, a
glob against the downloaded candidate/ artifact) and, when a baseline or a
wrapper/clang-plugin evidence pack is needed, downloads that cell's
build-output.json. Either can genuinely fail — no candidate matched, an
ambiguous/escaping pattern, a missing bundle member, or a required
build-output download error. A "Synthesize pre-check operational-error
report" step catches exactly this: when either fails, it writes a full
operational-error report envelope (verdict: "ERROR",
operational_errors: [{"kind": "ambiguous", ...}]) by calling
actions/check-target/report_envelope.py --mode operational-error directly
— the same script check-target's own finalize step drives for a real
resolve-baseline failure — so aggregate sees a typed, per-cell failure
here too, rather than a cell that silently vanished from the report set (as
if it had never been required at all). Run check-target itself is gated
to skip whenever candidate resolution didn't succeed, and the downstream
Sanitize check-id for artifact name/Upload report steps pick up whichever
of the two report-producing steps actually ran.
Required artifact-staging convention¶
check-project.yml never builds anything and never fetches from a baseline
channel's storage backend itself — the same "this Action never fetches"
boundary actions/resolve-baseline and
actions/baseline already draw (ADR-047 §10). The calling workflow's own
job(s) must upload, before this reusable workflow's jobs need them:
| Artifact name | One per | Contents |
|---|---|---|
<build-output-artifact-prefix><profile-id> |
contract profile | that profile's abicheck-build-<profile>/ directory (build-output.json + whatever it references) — G30 P1.1. |
<candidate-artifact-prefix><profile-id> |
contract profile | the tree each target's binary_pattern/consumer_binary_pattern globs against for this run's candidate side. |
<baseline-artifact-prefix><profile-id>-<channel> |
(contract profile, baseline channel) pair with any non-none check on that profile |
that pair's staged baseline-set (manifest.json + snapshots, actions/baseline's own output shape). Keyed by profile as well as channel — a baseline-set is itself profile-specific (actions/baseline's manifest records exactly one profile; resolve-baseline rejects a mismatch as wrong_profile), so two profiles sharing one channel each need their own artifact. |
All three prefixes are workflow inputs (defaults abicheck-build-,
abicheck-candidate-, abicheck-baseline-) — rename them if they collide
with artifacts your own workflow already produces for another purpose.
Every uploaded build-output.json must set profile.id. The plan
job identifies which downloaded build-output.json belongs to which
profile by reading each file's own profile.id field, not the artifact or
directory name (actions/download-artifact flattens a single-artifact
match with no subdirectory, so the name is ambiguous by construction) — see
the schema reference
for the full requirement. A build-output.json with no profile.id set
fails the plan job outright, even though that field is optional in the
schema generally.
jobs:
build-linux:
runs-on: ubuntu-latest
steps:
# ... your existing build, producing abicheck-build-linux/build-output.json ...
- uses: actions/upload-artifact@v7
with:
name: abicheck-build-linux
path: abicheck-build-linux/
- uses: actions/upload-artifact@v7
with:
name: abicheck-candidate-linux
path: build/lib/
fetch-accepted-main-baseline:
runs-on: ubuntu-latest
steps:
# ... restore from actions/cache, a release asset, or git, per ADR-047 §10 ...
- uses: actions/upload-artifact@v7
with:
name: abicheck-baseline-linux-accepted-main
path: restored-baseline/
check:
needs: [build-linux, fetch-accepted-main-baseline]
uses: abicheck/abicheck/.github/workflows/check-project.yml@c9e135a3233b6d45e9571533f71293fde458a469 # not yet in a tagged release; pin main or newer
with:
config-path: .abicheck.yml
Shared analysis options¶
check-project.yml accepts one project-wide value for every analysis option
check-target supports (header/policy/severity-preset/gcc-*/...),
forwarded unchanged to every matrix cell. A per-cell override of any of
these is out of scope for this first version — if different targets need
different policy/suppression files, run them through separate
check-project.yml calls (one per differing option set) until a later
iteration extends run-plan.json's schema to carry per-cell overrides.
Exception: gcc-path/gcc-options do get a per-cell override, from
that cell's .abicheck.yml profiles.<id>.compile overlay (P1
toolchain-profile audit — project-targets-schema.md),
when toolchain-bindings-path is set. abicheck project plan
--toolchain-bindings <path> (run by the plan job) resolves each
profile's declared compile.binding logical id (e.g. "gcc14") against
that trusted, separately-managed mapping file into an exact executable
path, and composes compile.standard/stdlib/target/abi_macros/args
into one extra-flags string — both land on the generated cell as
compile_gcc_path/compile_gcc_options
(run-plan-schema.md) and
replace this workflow's own global gcc-path/gcc-options inputs for
that cell only — not merge with them. If the project relies on a flag set
via the workflow's global gcc-options input (e.g. a universal -fPIC)
and wants a per-profile compile: overlay too, that global flag will not
carry over to overlaid cells; repeat it inside the overlay's args if it
still needs to apply there. A profile with no compile: overlay (or a run
with toolchain-bindings-path left empty, the default) falls back to the
global inputs unchanged — no behavior change for a project that doesn't use
this. compiler_family/compiler_version are validated shape-wise but not
yet projected into any forwarded flag (see run-plan-schema.md's field
table for why).
Exception: ast-frontend also gets a per-cell override, on the same
precedence rule (G34 Phase B). A profile's compile.frontend:
(project-targets-schema.md)
reaches its cells as compile_ast_frontend and replaces this workflow's own
ast-frontend input there — so a GCC profile's cell can parse headers with
castxml while a Clang/DPC++ profile's cell in the same run uses
clang -ast-dump=json, which one workflow-global value cannot express. A
profile that sets no frontend: falls back to the global input unchanged.
kind: bundle cells are excluded from this override. A bundle cell's
operand is the bundle-staging directory it stages its members into, and
the root Action rejects every non-auto ast-frontend for a
directory/package operand outright — the per-library fan-out never threads an
L2 compile context to each pair's header dump, so the requested frontend
could not be applied and silently dropping it would parse headers under the
wrong one. A bundle cell therefore keeps resolving the workflow-global
ast-frontend input exactly as it did before this override existed.
The sibling consumer_compile.frontend is not forwarded at all: it
describes the consumer half of a two-pass extraction that does not exist yet,
so there is only one dump invocation per cell for it to steer.
Every other analysis option above stays global-only, unaffected by these three exceptions.
Exception: dependency-source also gets a per-cell override, on the same
precedence rule (G34 Phase C). A profile's own dependency_source:
(project-targets-schema.md)
wins over this workflow's dependency-source input for that profile's cells
only, so a GCC-profile cell and a Clang-profile cell in one run can each
provision a matching conda environment instead of sharing whatever the
workflow-level value said. With both unset the legacy install-deps boolean
still decides, exactly as before.
Each cell is scheduled on its profile's own runner (G34 Phase C), rather
than the ubuntu-latest every cell used to hardcode: the plan job derives
runs_on from each profile's os:, so an os: windows profile's cell lands
on windows-latest. A profile with no os: — every profile written before
this phase — still resolves to ubuntu-latest, so no existing project's
scheduling moves. Note this makes os: load-bearing: a value naming no
schedulable platform now fails project validate instead of being ignored.
An actual native windows-latest lane running a real MSVC profile end to end
through this workflow is separate, still-open work — this phase lands the
scheduling mechanism, not a validated MSVC fixture project.
Give each parallel call its own artifact names. actions/upload-artifact
requires unique names within one workflow run — two check-project.yml
calls in the same run that both leave run-plan-artifact-name /
aggregate-artifact-name at their shared defaults will fail at the upload
step before either finishes any check (Codex review); leaving
report-artifact-prefix shared is worse and silently wrong rather than
failing loud — the aggregate job downloads by
pattern: '<report-artifact-prefix>*', so it would pull in the other
call's per-cell reports too and either misreport coverage or hit
aggregate's duplicate-target-id rejection. Set distinct values for
run-plan-artifact-name, aggregate-artifact-name, and
report-artifact-prefix on every parallel call (a per-call suffix, e.g. the
target/option-set name, is enough); do the same for
build-output-artifact-prefix/candidate-artifact-prefix/
baseline-artifact-prefix too unless the calls intentionally share the same
profile/channel artifacts (harmless when they do — same content, downloaded
twice).
Outputs¶
| Output | Meaning |
|---|---|
gate-exit-code |
abicheck aggregate's own exit code (0 pass / 1 coverage-or-policy gap / 2 API break / 4 ABI break). |
run-plan-artifact-name |
Echoes inputs.run-plan-artifact-name, for a caller that wants to download it too. |
Self-checkout: how the nested Actions actually resolve¶
Both workflows' steps reference check-target (and, transitively,
resolve-baseline/collect-facts/the root Action) via a relative
uses: ./x path. A relative path inside a reusable workflow's own steps
resolves against the caller's checkout, never against the repository
that defines the reusable workflow — the identical limitation
check-target's own composite-Action nesting already had
to work around, confirmed for reusable workflows specifically via GitHub
Community Discussion #107558 ("How can callable workflows in a dedicated
repo use its local actions with relative paths?").
The fix mirrors check-target's own: check out this exact repository/ref
into a side directory first, then reference every nested uses: relative to
that directory. The reusable-workflow equivalent of check-target's
github.action_repository/github.action_ref (which describe the
composite Action about to run) is job.workflow_ref/job.workflow_sha
(part of the job context, populated specifically so a reusable workflow
can identify itself independent of the calling workflow's own github.*
context) — always the fully-qualified owner/repo/.github/workflows/
check-single.yml@ref form. Not github.workflow_ref/
github.workflow_sha: GitHub's docs are explicit that "when a reusable
workflow is triggered by a caller workflow, the github context is always
associated with the caller workflow," so those fields resolve to the
calling repository/ref for any external consumer — the opposite of what
a self-checkout needs. Both workflows fall back to github.repository/
github.sha if workflow_ref is ever empty, matching check-target's own
defense-in-depth pattern for the equivalent local-same-repository case.