Skip to content

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.yml draws) — both expect the calling repository's own build job(s) to have already uploaded one <build-output-artifact-prefix><profile-id> build-output.json artifact per contract profile earlier in the same workflow run. actions/baseline now stages bundle-member ELF binaries into a binaries/ directory (the gap resolve-baseline.md previously 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/baseline input, 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-run applies, 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:

<key-prefix>-<profile-id>-<head-sha>

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:

<key-prefix>-g<generation>-<profile-id>-<head-sha>

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