publish-baseline.yml / update-main-baseline.yml Reference¶
These two reusable workflows produce the baseline-sets resolve-baseline
resolves against, one per contract profile: publish-baseline.yml writes an
immutable release-contract archive as a GitHub Release asset;
update-main-baseline.yml refreshes a mutable accepted-main entry in
GitHub Actions cache on every default-branch push. Both implement the baseline lifecycle.
Status. Shipped in G30 P1.6. Neither workflow builds anything itself ("build once, scan many," the same boundary
check-project.ymldraws) — both expect the calling repository's own build job(s) to have already uploaded one<build-output-artifact-prefix><profile-id>build-output.jsonartifact per contract profile earlier in the same workflow run.actions/baselinenow stages bundle-member ELF binaries into abinaries/directory (the gapresolve-baseline.mdpreviously flagged as "not yet") — see "Bundle members" below.
Why two workflows, not one¶
The two channels have genuinely different write targets, trigger contexts,
and freshness semantics (§6): release-contract is immutable and writes to
a GitHub Release (contents: write on a release), while accepted-main is
continuously refreshed and writes to Actions cache (no push at all). Folding
both into one workflow would mean threading a channel-selector input through
every step instead of each file's steps being a direct, linear translation
of its own channel's contract.
How each derives actions/baseline's libraries input¶
Neither workflow re-reads the project's .abicheck.yml. Every library a
contract profile's build produced — and whether it is a release-bundle
member — is already recorded in that profile's own build-output.json
(targets[].id, .binary, .public_header_roots/.generated_header_roots,
.bundle). abicheck.buildsource.baseline_publish.derive_baseline_libraries
projects that straight into actions/baseline's libraries JSON array — one
entry per target, stage_binary: true set exactly for targets whose
bundle field is non-empty.
Not a CLI command. This used to be
abicheck build-output baseline-libraries DIRECTORY; it was removed from the public CLI — it was a wire-format adapter for exactly these two workflows'actions/baselineinput, not a general-purpose operation. Both workflows now call the function directly:
python3 -c "
import json
from pathlib import Path
from abicheck.buildsource.baseline_publish import derive_baseline_libraries
from abicheck.buildsource.build_output import load_build_output
directory = Path('abicheck-build-linux-x86_64-gcc')
build_output = load_build_output(directory)
report = derive_baseline_libraries(build_output, directory)
Path('baseline-libraries.json').write_text(json.dumps(report.to_dict()))
"
{
"ok": true,
"entries": [
{"name": "libpvxs", "artifact": "/…/artifacts/libpvxs.so", "stage_binary": true},
{"name": "libutil", "artifact": "/…/artifacts/libutil.so"}
],
"errors": []
}
report.ok is True when every target resolved; False when one or more
targets could not be resolved (missing/escaping binary or header path — see
report.errors). load_build_output raises FileNotFoundError/ValueError
when DIRECTORY is not a readable build-output.json — both workflows
catch that and skip writing baseline-libraries.json at all, deferring to
their own follow-up step's "was not produced" error.
Bundle members: why stage_binary matters¶
abicheck/bundle.py's build_bundle_snapshot() builds its cross-library
graph from real ELF binaries and explicitly skips non-ELF (including
.abicheck.json snapshot) inputs — a bundle baseline-set containing only
snapshots would silently produce no old-side bundle data. actions/baseline's
libraries[].stage_binary: true (new in G30 P1.6) copies that library's
real binary into <output-dir>/binaries/<name> alongside its snapshot and
records binary/binary_sha256 in manifest.json, closing the gap
resolve-baseline's bundle-scoped resolution
depends on.
Depth scope, unchanged by this item. A bundle-scoped check is still
restricted to requested-depth: binary (abicheck/buildsource/
project_targets.py's BUNDLE_CHECK_DEPTHS) — this predates P1.6 and closes the "binaries only is not the full answer for every requested depth" open gap by construction: since a bundle check never requests header/build/
source depth in the first place, the archive never needs a per-member
headers/ directory or a compare-release snapshot-consuming input path
either. If a future item lifts that restriction, staging old-side headers
per bundle member becomes this workflow's job to add, not a pre-existing gap
to rediscover.
publish-baseline.yml¶
Reusable workflow (workflow_call); wire it to a release: types:
[published] trigger in your own repository — never pull_request/
pull_request_target.
| Input | Default | Meaning |
|---|---|---|
build-output-artifact-prefix |
abicheck-build- |
Each contract profile's build-output.json is downloaded from <this><profile-id>. |
release-tag |
github.ref_name |
Release tag to publish assets to; also recorded as each baseline-set's project-ref. |
asset-name-template |
abicheck-baseline-{profile}.tar.zst |
Release asset filename; {profile} is replaced per profile, and {generation} (optional) with baseline-generation's value — include it to publish each scanner-compatibility generation as its own asset name rather than overwriting the previous generation's asset (see Cache key contract below for the accepted-main equivalent, and docs/use/baseline-management.md's "Scanner upgrades and baseline generations"). Include {profile} when a run has more than one contract profile — omitting it makes every profile in the matrix target the same asset name, and the retry-identity check below rejects (rather than silently discards) a genuine collision between two different profiles. |
build-info |
'' |
Path to a shared build/source facts pack, relative to the downloaded build-output artifact. |
depth |
'' |
Evidence depth passed to every dump call. |
project-config |
build-config, else .abicheck.yml |
(G41 Phase 1) The project config carrying profiles:. Each profile's baseline is dumped under the compile context its candidate cells use: its consumer_compile: overlay when declared, else its compile: overlay (compiler from binding:, flags from standard/stdlib/target/abi_macros/args, frontend), folded into a copy of build-config's compile: block and recorded as manifest.json's extraction_context. A profile with neither overlay dumps exactly as before. Without this, a profile with an overlay produced a baseline the comparability gate refuses against its own candidates. |
toolchain-bindings-path |
'' |
The toolchain-bindings file resolving binding: ids to compiler paths — the same file check-project.yml takes. An unresolvable declared binding fails the step rather than dumping under a different compiler. |
baseline-generation |
'' |
Forwarded to actions/baseline's baseline-generation input, and substituted for {generation} in asset-name-template (see above). Omit to leave the generation unset. |
validation |
strict |
Forwarded to actions/baseline's validation input. |
expected-project-ref |
'' |
Existing-set mode only: what a pre-captured set's own project_ref must equal. ''/commit resolves release-tag through refs/tags/<tag> (peeling an annotated tag) and expects that commit; tag expects the literal tag string, which is what this workflow's own capture path stamps; a full SHA expects exactly that. A closed set — anything else is a usage error, never a fallback. See Publishing a set someone else captured. |
baseline-set-source-run-id |
'' |
Existing-set mode only: take the sets from a different, already completed producer run rather than from this workflow's own run. Turns on real producer verification; see below. |
baseline-set-source-repository |
github.repository |
owner/repo the source run must belong to. |
baseline-set-source-run-attempt |
'' |
Attempt the source run must be. Bind it to the attempt that triggered publication; empty means any. |
baseline-set-expect-workflow |
'' |
Workflow the source run must be, as a file path or a name. Strongly recommended. |
baseline-set-expect-event |
'' |
Event the source run must have been triggered by, narrowing beyond the unconditional pull-request refusal. |
baseline-set-allowed-conclusions |
success |
Conclusions the source run may have; empty allows any. |
snapshot-compression |
none |
Forwarded to actions/baseline's snapshot-compression input — independent of this workflow's own archive packaging of the whole baseline-set directory (encoding chosen from asset-name-template's extension, via actions/stage-baseline); see Storing Baselines. |
Secret: github-token (optional) — falls back to the job's own
GITHUB_TOKEN (permissions: contents: write on the publish job).
For every discovered contract profile: downloads that profile's
build-output artifact, derives libraries from it, dumps the baseline-set
via actions/baseline, packages it as <asset-name> via
actions/stage-baseline (encoding chosen from
the asset name's own extension — .tar.zst/.tar.gz/.tgz/.tar), and
uploads it to release-tag's release. This upload step fails closed on an
immutability violation, it does not --clobber: if no asset of this name
exists yet, it uploads plainly; if one already exists, its manifest.json is
downloaded and checked in two steps. First, a profile-identity check:
the existing asset's own profile field must match this run's profile, or
the upload fails immediately regardless of content — compute_content_digest()
(next paragraph) deliberately excludes profile, so without this check two
different profiles that happen to produce identical library/snapshot/binary
content (or a template missing {profile} routing two profiles to the same
asset name) could otherwise have one profile's baseline silently discarded
as a "safe retry" of the other's. Second, once the profile matches, the
manifest is compared against this run's own baseline-set by normalized
content digest (actions/baseline/build_manifest.py's compute_content_digest()
— library names + per-snapshot and per-staged-binary digests, deliberately
excluding volatile fields like created_at and the archive's own filesystem
metadata, both of which differ on every run even when the underlying
baseline-set is logically identical). Matching digests are treated as a safe
retry (e.g. after a transient failure) and no re-upload happens; a differing
digest hard-fails rather than silently replacing a published
release-contract asset — release-contract is documented
as immutable once published, and a re-run silently overwriting it would
mean an already-resolved consumer's "compatible with v1.0.0" comparison
quietly stopped meaning what it said. To genuinely change a
release-contract baseline-set, delete the existing asset explicitly
(gh release delete-asset <tag> <asset-name>) and re-run, or publish under
a new release tag.
Publishing a set someone else captured¶
baseline-set-artifact-prefix publishes an already-captured baseline-set
instead of capturing one. Two things then need saying that the capture path
never has to ask, because a set this workflow did not build is an input
rather than its own evidence.
Which revision the set must record. A release tag and the revision a
capture recorded are different identifiers. release-tag selects the
release the asset is attached to; a producer's capture records the commit
it actually built. Comparing one against the other rejected every genuine
cross-run capture, so the expectation is stated separately by
expected-project-ref — which defaults to the commit the tag names, read
through refs/tags/<tag> and peeled when the tag is annotated. A name that
only prefix-matches a longer tag, a refs/heads/ ref of the same name, and
a tag object that does not peel to a commit are each refused rather than
resolved. Set expected-project-ref: tag to re-publish a set this
workflow's own capture path produced, since that path stamps the tag string
itself.
Where the bytes come from. By default the sets are downloaded from this
workflow's own run, which is the easy case: the uploading job and the
publishing job share a run, so the artifact's provenance is the caller's
own. A project whose release baselines are published by an automatic
workflow_run job has no such luxury, and sets baseline-set-source-run-id
to name the producer. That turns on real verification:
- the run's repository, workflow identity, event, id, attempt and conclusion are each checked against what was declared;
- a pull-request-triggered producer is refused unconditionally. A baseline-publishing workflow must never trigger on a pull request; a capture taken from one reaches the same immutable channel by a longer route, so the restriction holds for the producer too and is not configurable;
- artifacts are selected by their own ids, and the publication fetches those ids. An artifact can be added to a run between discovery and publication, so a name-resolved fetch could publish bytes no eligibility check ever looked at;
- a declared attempt reads that attempt's own document, so a re-run started after the trigger cannot be published under the first attempt's decision;
- the archives are unpacked under the same size/entry/ratio caps
actions/verify-source-runapplies, because a set from another run is an input whatever its origin turned out to be.
The read-only producer query lives in the discovery job (actions: read, no
write scope at all) and the write-capable publication in the publishing job,
which only fetches an id it was handed. Both acquisition modes then converge
on the same member/schema/path/profile/generation/content validation and the
same immutability chain described above — there is one validator, not two.
The token needs actions: read on baseline-set-source-repository in
addition to contents: write on the publishing repository.
actions/stage-baseline¶
A small composite Action factored out of publish-baseline.yml's own
packaging step: given a baseline-set directory (actions/baseline's
baseline-path output) and an asset-name-template, it produces a single
archive named and encoded per that template's own extension. Exists so a
caller publishing a baseline-set through a different storage backend
(not this repository's release-contract flow — a different Action, a
different CI system, an internal artifact store) doesn't have to
re-implement the suffix-dispatch logic; publish-baseline.yml itself calls
this Action rather than keeping an inline copy, so there is one
implementation, not two that can silently drift apart.
| Input | Default | Meaning |
|---|---|---|
baseline-path |
(required) | Directory containing manifest.json plus per-library snapshots. |
asset-name-template |
abicheck-baseline-{profile}.tar.zst |
Archive filename; {profile} is replaced with the profile input, and {generation} (optional) with the generation input. The extension selects the encoding — .tar.zst (zstd), .tar.gz/.tgz (gzip), or .tar (uncompressed); anything else is a hard usage error. |
profile |
'' |
Substituted for {profile}. |
generation |
'' |
Substituted for {generation} — a no-op unless asset-name-template references the placeholder. |
Outputs asset-name (the resolved filename) and archive-path (identical
to asset-name — the archive is written to the current working
directory). Read-only over its input: it never commits, pushes, or
uploads the archive it produces.
See Storing Baselines for the narrative
picture of where a staged archive fits among abicheck's baseline-storage
backends — this page documents actions/stage-baseline itself as a fact
source, not the concepts around it.
update-main-baseline.yml¶
Reusable workflow (workflow_call); wire it to a push: branches: [<default
branch>] trigger.
| Input | Default | Meaning |
|---|---|---|
build-output-artifact-prefix |
abicheck-build- |
Same as above. |
key-prefix |
abicheck-baseline-main |
Actions-cache key prefix (§10). |
head-sha |
github.sha |
Recorded as project-ref; folded into the cache key. |
baseline-generation |
'' |
Forwarded to actions/baseline's baseline-generation input, and folded into key-prefix as -g<generation> (see Cache key contract below) — two different scanner-compatibility generations never share one cache-key namespace. Omit to leave the generation unset. |
build-info / depth / validation / snapshot-compression / project-config / toolchain-bindings-path |
same as above |
Cache key contract (read before wiring a consumer)¶
A GitHub Actions cache entry is immutable once written — a new version needs
a new key, not an overwrite. update-main-baseline.yml therefore writes a
new key on every run:
A consumer restoring the latest entry (this workflow's own freshness step,
or a future caller wiring accepted-main into check-project.yml/
check-single.yml) must use restore-keys: <key-prefix>-<profile-id>- to
find it. A workflow that instead used one fixed key across refreshes would
silently keep resolving the first entry ever written, forever — the cache
action reports that as a hit, not an error, exactly the silent-shallow-
success failure mode this ADR exists to close.
abicheck.buildsource.baseline_publish.accepted_main_cache_key(key_prefix,
profile_id, head_sha) / accepted_main_cache_restore_prefix(key_prefix,
profile_id) own this format: the workflow's "Compute cache key" step calls
them, so a consumer calling the same functions computes the key the
workflow wrote.
When baseline-generation is set, the key gets an extra segment.
update-main-baseline.yml's "Compute cache key" step folds
-g<generation> into the prefix before building the key above, so two
different scanner-compatibility generations (docs/use/baseline-
management.md#scanner-upgrades-and-baseline-generations) never share one
cache-key namespace:
Both functions accept this as an explicit keyword argument --
accepted_main_cache_key(key_prefix, profile_id, head_sha,
generation=3) / accepted_main_cache_restore_prefix(key_prefix,
profile_id, generation=3) — rather than requiring a consumer to
pre-fold -g3 into key_prefix themselves. The workflow parses the
input with parse_baseline_generation, the same acceptance rule
actions/baseline applies (ASCII digits only), and folds the integer the
manifest records, so baseline-generation: 03 keys as -g3. A consumer restoring this
cache (restore-keys: <key-prefix>-<profile-id>-) must pass the exact
same generation this workflow was run with, or the restore misses
entirely: the un-generation-scoped prefix is a different cache-key
namespace, not a superset of the generation-scoped one.
Each run: computes this run's own key, restores the newest entry matching
the restore-keys prefix into a freshness-comparison staging directory, dumps
the new baseline-set with --previous-manifest pointed at that restored
manifest.json when one was found, then saves the fresh .abicheck-baseline
directory under this run's own key. Note head-sha is unique per commit,
not per run: a rerun/retrigger of the same commit, or an explicit
caller-supplied head-sha input, reuses the same key — the exact-key restore
can hit an entry that commit already wrote, falling through to
restore-keys only on an actual miss. The restore step is written to behave
correctly either way (a hit on this run's own previously written entry is
still the newest matching entry), so this doesn't affect correctness, only
the "always a miss" framing above.
restore-keys prefix matching is for freshness comparison, not for a PR
gate. The prefix restore above resolves to whichever entry is newest
under the profile's prefix, regardless of which commit wrote it — correct
for update-main-baseline.yml's own "what changed since the last
accepted-main snapshot" freshness diff, but wrong for a PR asking "did
this PR introduce a break relative to its own base commit": if main has
advanced past the PR's base SHA since the PR branched, a prefix restore
silently compares the PR against a baseline built from a commit its branch
history never contained. A PR gate must restore the exact key for
github.event.pull_request.base.sha (no restore-keys fallback), and pass
that same SHA as resolve-baseline's expected-project-ref input so a
restore that somehow still lands on the wrong commit is caught as
wrong_project_ref rather than silently resolving — see
resolve-baseline's own "Known gap" section
for the full recipe.
Known gap: nothing restores accepted-main from cache yet¶
check-project.yml's baseline-set staging (P1.4) only ever downloads a
<baseline-artifact-prefix><profile-id>-<channel> artifact — it has no
built-in Actions-cache restore step. A project wiring accepted-main today
must add its own actions/cache/restore step (using the key contract above)
before calling check-project.yml, staging the restored directory at the
same baseline-sets/<profile-id>-<channel> path check-project.yml reads
from. Wiring cache-based staging directly into check-project.yml is
deferred, the same "defines the producer, a later item wires up direct
consumption" scoping build-output.json and
resolve-baseline's bundle path used before their
own producers shipped.
Known gap: restoring immediately after a same-run write can miss¶
A GitHub Actions cache entry saved by one job is not always immediately
restorable via actions/cache/restore from a different job within the
same workflow run — observed directly while building this reusable
workflow's own live test fixture
(.github/workflows/test-baseline-rotation.yml): a restore attempt in a
downstream job missed an entry a sibling job had just finished saving
moments earlier, and update-main-baseline.yml's own unmodified
"Restore previous accepted-main baseline-set" step (used for the
freshness comparison) failed identically when a second same-run
invocation tried to see the first's entry. This did not reproduce as a
simple propagation delay — a direct List Actions Caches API query
confirmed the entry already existed at the moment the restore missed it.
If you wire a custom actions/cache/restore step to consume
accepted-main immediately after calling update-main-baseline.yml in
the same workflow run (rather than in a later, separate run — the
normal case for a day-to-day push-triggered refresh), be aware this
same-run restore can transiently miss even though the entry is really
there. A later run, or a retried restore, resolves it.
See also¶
resolve-baselineAction Reference — consumes what these two workflows produce.check-targetAction Reference- Reusable Workflows Reference —
check-single.yml/check-project.yml.