Skip to main content
Version: V3

Qualifying an internal candidate

Qualifying an internal candidate​

This developer procedure tells a teammate how to obtain an unpublished pre-release Infrahub Sync candidate from a GitHub Actions run and qualify it on their own host. The artifacts it names are unpublished pre-release bytes; there is no registry, package index, or tagged release behind them yet.

Written for someone who did not build this.

What the host needs​

NeedsWhy
Linux on amd64The qualified platform. arm64 is built and smoked under emulation; that is not a qualification.
Docker, and Docker Compose 2.17.3 or laterThe deployment. The entry point refuses an older Compose.
gh, authenticated against opsmill/infrahub-syncThe artifacts are unpublished, so the Actions API is the only way to them.
jqEvery check below reads JSON the run produced.
sha256sum, awk, dateCoreutils. date -u -d is used to read expiry windows.
An Infrahub you are authorised to write to, and can throw awayStep 8 applies a real write.

It does not need Python, uv, or a checkout of this repository. Nothing below installs an interpreter; the Sync CLI runs from the candidate image.

Set these once, and run every command below from $WORK. The paths are absolute deliberately: the procedure changes directory twice — into the extracted bundle, and back — and a relative inventory.tsv written before the first of those is not the file the later steps read.

REPO=opsmill/infrahub-sync
WORK=$HOME/candidate-qualification
INVENTORY=$WORK/inventory.tsv
RECORDED=$WORK/recorded.tsv

mkdir -p "$WORK"
cd "$WORK"

Two things are being tested at once. One is the candidate. The other is this page: you are the first person to follow it, so record where it was wrong. What to record is the last section.

1. Choose a run, and separate the two commits it refers to​

A candidate is built by a manual dispatch of workflow-candidate.yml against an exact merged commit. A pull-request run is not a candidate: GitHub tests the merge commit while the run builds the pull request's head, so its artifacts describe bytes nobody will ship. A pull-request run also deletes what it built before it finishes.

A dispatched run refers to two different commits, and they are not interchangeable.

What it isWhere to read it
Workflow revisionThe tip of the ref the run was started against. It decides which version of the workflow definition ran. It is head_sha.gh run view --json headSha
Candidate commitThe commit the run was told to build, and the only one the artifacts describe. It is the sha input.the run's own title

They are equal only while the branch has not moved since the merge, and the route is built for the case where it has: a window that lapses is answered by rebuilding the same commit from a much later tip. So head_sha is provenance for the workflow, and the candidate commit is the build identity. Never use one where the other is meant.

List the candidate runs:

gh run list --repo "$REPO" --workflow workflow-candidate.yml \
--json databaseId,displayTitle,headSha,status,conclusion,createdAt \
--jq '.[] | [.databaseId, .conclusion, .displayTitle, .headSha] | @tsv'

Pick a successful one and read both commits out of it:

RUN=<the run ID>

CANDIDATE_SHA=$(gh run view "$RUN" --repo "$REPO" --json displayTitle \
--jq '.displayTitle | sub("^Candidate ";"")')
WORKFLOW_REVISION=$(gh run view "$RUN" --repo "$REPO" --json headSha --jq '.headSha')

printf 'candidate commit: %s\nworkflow revision: %s\n' "$CANDIDATE_SHA" "$WORKFLOW_REVISION"

CANDIDATE_SHA must be an exact 40-character commit. Check it rather than assume it — everything below compares against it:

printf '%s' "$CANDIDATE_SHA" | grep -Eq '^[0-9a-f]{40}$' \
&& echo "OK: an exact commit" \
|| echo "STOP: the run title did not yield one"

If the two commits differ, that is normal and not a problem. If they are equal, that is also fine. Neither tells you anything is wrong.

Now confirm the run really qualified something:

gh run view "$RUN" --repo "$REPO" --json conclusion,jobs \
--jq '{conclusion, jobs: [.jobs[] | {name, conclusion}]}'

The run must be success and both jobs must have succeeded. The candidate job builds and retains the bytes; the clean-host job is what qualified them on a host with no checkout. A run whose clean-host job failed, was skipped, or is still going has uploads but no qualification — retained artifacts alone are not a result.

2. Check what the service is holding, before downloading any of it​

The run retains seven artifact groups. Read the service's own inventory first: it is the only place the identifiers and the granted expiry exist, and the qualification record cannot contain its own.

gh api --paginate "repos/$REPO/actions/runs/$RUN/artifacts" \
--jq '.artifacts[] | [.name, (.id|tostring), .digest, .created_at, .expires_at, (.expired|tostring)] | @tsv' \
| sort > "$INVENTORY"
cat "$INVENTORY"

Check that all seven are present, none has expired, and each was granted exactly 30 days. Asking for a window is not being given one, so this reads what the service actually returned:

for name in \
infrahub-sync-candidate-image \
infrahub-sync-candidate-identity \
infrahub-sync-candidate-distributions \
infrahub-sync-candidate-bundle \
infrahub-sync-candidate-sboms \
infrahub-sync-qualification-kit \
infrahub-sync-qualification-record
do
entry=$(awk -F'\t' -v n="$name" '$1 == n {print; exit}' "$INVENTORY")
if [ -z "$entry" ]; then
printf 'MISSING %s\n' "$name"
continue
fi
created=$(printf '%s' "$entry" | cut -f4)
expires=$(printf '%s' "$entry" | cut -f5)
expired=$(printf '%s' "$entry" | cut -f6)
granted=$(( ( $(date -u -d "$expires" +%s) - $(date -u -d "$created" +%s) ) / 86400 ))
if [ "$granted" -eq 30 ] && [ "$expired" = "false" ]; then
printf 'OK %-42s granted %sd, expires %s\n' "$name" "$granted" "$expires"
else
printf 'WRONG %-42s granted %sd, expired=%s\n' "$name" "$granted" "$expired"
fi
done

Anything other than seven OK lines means this run is not an acceptable candidate. Record what you saw and stop.

3. Download what the host needs​

Four of the seven are what a host runs:

cd "$WORK"

gh run download "$RUN" --repo "$REPO" --name infrahub-sync-candidate-image --dir image
gh run download "$RUN" --repo "$REPO" --name infrahub-sync-candidate-bundle --dir bundle
gh run download "$RUN" --repo "$REPO" --name infrahub-sync-qualification-record --dir record
gh run download "$RUN" --repo "$REPO" --name infrahub-sync-candidate-identity --dir identity

The other three — infrahub-sync-candidate-distributions, infrahub-sync-candidate-sboms, and infrahub-sync-qualification-kit — are the wheel and source distribution, the bills of materials and scan reports, and the gate's own driver. You do not need them to run the candidate, but they are part of the inventory you just checked.

infrahub-sync-candidate-image keeps the build's directory layout, so the archive you want is image/archives/image-linux-amd64.tar.

4. Bind what arrived to the commit you chose​

identity.json names the revision the artifacts were built from. It must equal the candidate commit, not the workflow revision:

BUILT_FROM=$(jq -r '.revision' identity/identity.json)
jq -r '"version " + .version + ", bundle " + .bundle' identity/identity.json

test "$BUILT_FROM" = "$CANDIDATE_SHA" \
&& echo "OK: these bytes were built from the candidate commit" \
|| printf 'STOP: built from %s, not the candidate %s\n' "$BUILT_FROM" "$CANDIDATE_SHA"

If that fails, stop. You are holding bytes from another commit.

Three digests, and they are not the same thing​

Confusing them is the easiest way to believe a check passed that did not.

DigestNamesRead fromChecked with
Service transport digestthe artifact as the Actions service stores it$INVENTORY, and .artifacts[].digest in the recordthe comparison below
Bundle file digestthe bundle archive's own bytes.bundle.sha256 in the recordsha256sum
Image configuration digestthe loaded image's configuration.image.platforms["linux/amd64"].config in the recorddocker image inspect

Compare the six the record stores against what the service holds. The record's digests may or may not carry a sha256: prefix depending on which side wrote them, so both are normalised before comparison:

The comparison is one command that succeeds or fails. A mismatch, a missing group, or one the record should not be describing all make it fail, so it can be run from a script as well as read: && the next step onto it and nothing continues past a disagreement.

jq -r '.artifacts | to_entries[]
| [.key, (.value.id|tostring), (.value.digest|sub("^sha256:";""))] | @tsv' \
record/qualification.json | sort > "$RECORDED"

if (
set -eu

# Exactly these six, no more and no fewer. The record is written before its own
# upload exists, so a seventh entry means it describes something it cannot have
# seen, and a sixth missing means a group it should have named went unrecorded.
expected=$(printf '%s\n' \
infrahub-sync-candidate-image \
infrahub-sync-candidate-identity \
infrahub-sync-candidate-distributions \
infrahub-sync-candidate-bundle \
infrahub-sync-candidate-sboms \
infrahub-sync-qualification-kit | sort)
present=$(cut -f1 "$RECORDED" | sort)

if [ "$expected" != "$present" ]; then
echo "the record describes the wrong set of groups:" >&2
diff <(printf '%s\n' "$expected") <(printf '%s\n' "$present") >&2 || true
exit 1
fi

wrong=0
while IFS=$'\t' read -r name id digest; do
entry=$(awk -F'\t' -v n="$name" '$1 == n {print; exit}' "$INVENTORY")
if [ -z "$entry" ]; then
printf 'ABSENT %-42s the service holds no artifact of this name\n' "$name" >&2
wrong=$((wrong + 1))
continue
fi
held_id=$(printf '%s' "$entry" | cut -f2)
held_digest=$(printf '%s' "$entry" | cut -f3 | sed 's/^sha256://')
if [ "$id" = "$held_id" ] && [ "$digest" = "$held_digest" ]; then
printf 'OK %-42s id %s\n' "$name" "$id"
else
printf 'WRONG %-42s record %s/%s, service %s/%s\n' "$name" "$id" "$digest" "$held_id" "$held_digest" >&2
wrong=$((wrong + 1))
fi
done < "$RECORDED"

[ "$wrong" -eq 0 ] || { echo "$wrong recorded group(s) do not match the service" >&2; exit 1; }
)
then
echo "all six recorded groups match the service inventory"
else
echo "STOP: the record and the service disagree about what this run produced" >&2
false
fi

That block exits nonzero when they disagree, and the failure is what stops you: an image loaded from bytes the record cannot account for qualifies nothing. Do not go on to step 5 until it succeeds.

That covers six groups. The seventh — the qualification record itself — cannot appear in its own artifacts map, because a document cannot carry the digest of the upload that contains it. Take its identifiers from the service and record them by hand:

awk -F'\t' '$1 == "infrahub-sync-qualification-record" {printf "record artifact id %s digest %s\n", $2, $3}' \
"$INVENTORY"

Then the bundle's own bytes​

Two checks, and they fail for different reasons: a corrupted transfer breaks the first, a bundle that is not the one the record describes breaks the second.

( cd bundle && sha256sum -c ./*.tar.gz.sha256 )

BUNDLE_NAME=$(jq -r '.bundle.name' record/qualification.json)
RECORDED_BUNDLE=$(jq -r '.bundle.sha256' record/qualification.json)
ACTUAL_BUNDLE=$(sha256sum "bundle/$BUNDLE_NAME" | cut -d' ' -f1)

test "$RECORDED_BUNDLE" = "$ACTUAL_BUNDLE" \
&& echo "OK: this is the bundle the record names" \
|| printf 'STOP: record %s, file %s\n' "$RECORDED_BUNDLE" "$ACTUAL_BUNDLE"

Do not extract the archive until both pass.

5. Load the image and confirm it is the one the record names​

Read the configuration digest the record names, load the archive, and confirm Docker holds that exact configuration.

Nothing downstream is given this value: the bundle ships image.bind, which already names the same image, and the deployment selects it locally. Exporting it here is what lets the two checks below compare the record and the archive.

INFRAHUB_SYNC_IMAGE=$(jq -r '.image.platforms["linux/amd64"].config' record/qualification.json)
export INFRAHUB_SYNC_IMAGE

LOADED=$(docker load -i image/archives/image-linux-amd64.tar | sed -n 's/^Loaded image[^:]*: //p')
printf 'loaded %s\n' "$LOADED"

test "$(docker image inspect --format '{{.Id}}' "$LOADED")" = "$INFRAHUB_SYNC_IMAGE" \
&& echo "OK: the loaded image is the qualified configuration" \
|| echo "STOP: the loaded image is not the one the record names"

From here on, $INFRAHUB_SYNC_IMAGE is the verified configuration digest and nothing else. A tag can be re-pointed between the qualification that trusted an image and the run that uses it, which is why the bundle refuses one.

6. Extract the bundle and prepare a deployment​

cd "$WORK"
tar -xzf "bundle/$BUNDLE_NAME"
cd "$WORK/${BUNDLE_NAME%.tar.gz}"
./infrahub-sync-compose init

init writes .instance, secrets/postgres-admin-password, and operator.env with generated passwords. There is no image to set: the archive shipped image.bind, and init copied the index reference from it into .instance.

Confirm the record names the image you just loaded. image.bind holds no credential, so it can be read out loud:

cat image.bind

test "$(sed -n 's/^INFRAHUB_SYNC_IMAGE_CONFIG=//p' image.bind)" = "$INFRAHUB_SYNC_IMAGE" \
&& echo "OK: the bundle names the candidate you loaded" \
|| echo "STOP: the bundle names a different image than the record does"

Nothing else is needed to start. The deployment comes up with an empty configuration registry and no destination; the Infrahub token and the declared package are step 8's inputs, not this step's.

Use a disposable Infrahub you are authorised to write to. Step 8 applies a real write. Do not point this at anything you cannot afford to have changed.

Prepare the package you will register in step 8. Copy the example the bundle ships, edit its two url values to name your instance, and leave the credential as a reference — a configuration package holds credential references, never values:

cp configuration/qualification.yaml "$WORK/package.yml"
# Edit both `url` values in $WORK/package.yml to name your Infrahub.
token:
$credential: infrahub-token
credentials:
infrahub-token:
provider: env
identifier: INFRAHUB_API_TOKEN

Nothing loads that file. Step 8 registers it explicitly, and a registered version is immutable: a later edit is registered as the next version rather than replacing one.

The extracted bundle carries OPERATING.md, the same procedure written for whoever runs the deployment. Read it when this page runs out.

7. Preflight, start, and reach READY​

./infrahub-sync-compose preflight

preflight refuses before anything is created, and each refusal is one family name and a fixed sentence. OPERATING.md has the whole table. The ones you are most likely to meet here: image-unresolvable (neither form the record names is loaded on this host), image-platform-unqualified (what resolved is another architecture), image-binding-mismatch (.instance was edited to name something the record does not), credentials-missing (a value in operator.env is empty), and port-occupied (something already holds 127.0.0.1:8000 or :4200). Preflight reaches no destination: a deployment starts without one.

preflight is optional — start runs it — and it is not read-only: it may replace the image recorded in .instance with whichever of the record's two forms this host resolved. It starts no service and changes nothing you own.

When preflight passes:

./infrahub-sync-compose start
./infrahub-sync-compose status

status must print READY and exit 0. READY is endpoint-backed and Sync-owned: dependencies answer, the API answers, and a registered worker is heartbeating. It says nothing about a configuration — there is none registered yet, and no destination has been named. DEGRADED (exit 3) means something owned exists but not all of that is true; STOPPED (exit 4) means no container of this instance is running. Container health alone is not readiness — a hung worker looks healthy to Docker and still reaches DEGRADED.

If start returns not-ready, the deployment came up and no worker registered in time. Take the logs before anything else:

./infrahub-sync-compose logs sync-worker
./infrahub-sync-compose logs sync-api

8. Register a package, plan, review the saved plan, then apply that exact plan​

The CLI ships in the candidate image and the bundle runs it for you, in a container of that same verified digest, on the deployment's own network. It authenticates with the token init generated: one value, written to both INFRAHUB_SYNC_SERVICE_BEARER_TOKENS and INFRAHUB_SYNC_API_TOKEN. Nothing here reads a credential out of a file or puts one on a command line.

./infrahub-sync-compose cli configs list

That answers with nothing: the registry is empty. Give the deployment the Infrahub credential the package references: uncomment INFRAHUB_API_TOKEN in operator.env and set it to your token with an editor, so no value reaches a terminal or a scrollback buffer. Then start again, so the services that resolve it are recreated — restart would replace the processes inside containers holding the old environment:

./infrahub-sync-compose start

Then register the package you prepared in step 6. --package copies that one file into a private directory, mounts it read-only at /input/package.yaml for this call alone, and removes the copy afterwards:

./infrahub-sync-compose cli --package "$WORK/package.yml" -- \
configs register /input/package.yaml --reason "candidate qualification"

Note the config_id and registry_version it prints — the next step needs both. The declared content now lives in PostgreSQL, and no host file is mounted by the API or the worker afterwards.

./infrahub-sync-compose cli configs list
./infrahub-sync-compose cli configs validate <config> <version>

Qualification is one write, taken the managed way: plan, read the saved plan, then apply the plan you read by its checksum. Do not use cli sync here. It is a real capability, and it is a single confirmed write with no reviewed plan in between, so it proves nothing about the admission path this deployment exists to enforce.

Plan first. It writes nothing:

./infrahub-sync-compose cli diff --config-id <config> --version <version> \
--reason "candidate qualification"

That prints a run ID. Read the saved plan and its checksum:

./infrahub-sync-compose cli runs plan <run-id>
./infrahub-sync-compose cli runs plan <run-id> --detail

Read what it proposes before going on. Then apply that exact plan:

./infrahub-sync-compose cli apply <run-id> \
--expected-checksum <the checksum runs plan printed> \
--reason "candidate qualification"

A checksum that no longer matches the saved plan is refused, and that is the property being qualified: it means the plan you read is not the plan that would be applied, and the remedy is a new plan rather than a retry.

Then observe. Confirm at the destination that the change you approved is the change that happened, and that nothing else did.

9. Retrieve the run's evidence​

The run record and what the service recorded for it come back through the same CLI:

RUN_ID=<the run you applied>

./infrahub-sync-compose cli runs show "$RUN_ID"
./infrahub-sync-compose cli runs results "$RUN_ID"

runs show prints the phase, the outcome and the Prefect correlation; runs results prints the recorded results as JSON.

A run's artifacts are held by the deployment, not on a container filesystem, and the CLI has no artifact command yet — so this one step goes over HTTP. Put the token in a private curl configuration rather than on a command line, where it would be visible to every process on the host:

export INFRAHUB_SYNC_API_URL=http://127.0.0.1:8000
umask 077
# The one line curl needs. `sed` reads the token out of the JSON principal
# without sourcing operator.env, which holds every other credential too.
printf 'header = "Authorization: Bearer %s"\n' \
"$(sed -n 's/^INFRAHUB_SYNC_SERVICE_BEARER_TOKENS=.*"token": "\([^"]*\)".*/\1/p' operator.env)" \
> "$WORK/api.curlrc"

curl -sS --config "$WORK/api.curlrc" "$INFRAHUB_SYNC_API_URL/runs/$RUN_ID/artifacts" | jq '.'

Each entry has an artifact_id, a digest, and a size. Fetch one and check what arrived against the digest the list gave you — this is a fourth, separate digest, over the deployment's own artifact, and has nothing to do with the three in step 4:

ARTIFACT=<an artifact_id from the list>
EXPECTED=$(curl -sS --config "$WORK/api.curlrc" \
"$INFRAHUB_SYNC_API_URL/runs/$RUN_ID/artifacts" \
| jq -r --arg a "$ARTIFACT" '.artifacts[] | select(.artifact_id == $a) | .digest')

curl -sS -D headers.txt --config "$WORK/api.curlrc" \
"$INFRAHUB_SYNC_API_URL/runs/$RUN_ID/artifacts/$ARTIFACT" -o artifact.bin
grep -i '^digest:' headers.txt
printf 'expected %s\nactual %s\n' "$EXPECTED" "$(sha256sum artifact.bin | cut -d' ' -f1)"

10. Restart, and confirm it converges​

Replacing the processes must lose nothing, because no run state lives on a container filesystem:

./infrahub-sync-compose restart
./infrahub-sync-compose status

status must return to READY. The worker rejoins under a new Prefect identity; the run you applied in step 8, its plan, and its artifacts must all still be readable through the API afterwards. Check that they are — that is the claim being tested, not the exit code.

Starting an already-started deployment is also safe. Bootstrap converges the two databases and their owners, the product schema, the bucket, the work pool and the installed deployment, and creates none of them twice. It registers nothing, so the configuration you registered in step 8 is left exactly as it was:

./infrahub-sync-compose start

11. When the outcome of a write is uncertain​

If a write ends without proving what reached the destination, the deployment does not retry it. Repeating a write whose outcome is unknown is the one thing that could turn an uncertain state into a wrong one, so the run is left terminal.

Read the run itself. reconciliation_required is a field of the run, so you never have to parse failure evidence to find out whether a run needs attention.

Two records mean an uncertain write:

  • A write execution that ended without reporting records phase interrupted and outcome ambiguous, with reconciliation_required set.
  • An apply that began writing and then failed records phase apply-failed and outcome failed, with summary.may_have_partially_written set and results.apply_failure naming the stage, the error type, the operations already applied, and the one that failed.

reconciliation_required is a write-only verdict — an interrupted plan or verify cannot have written — and nothing sets it back to false.

In both cases: read the record, inspect the destination, and then take a fresh plan run. A new plan reads the destination as it now is, so what it proposes is what is still outstanding. The terminal run is never reopened and no later run inherits its admission.

12. Stop, and reset when you are done​

./infrahub-sync-compose stop # processes down, every volume untouched
./infrahub-sync-compose status # STOPPED, exit 4

stop keeps every volume, so a later start resumes. To remove this instance and its data:

./infrahub-sync-compose reset <the instance identity it displays>

reset makes you repeat the identity it shows, and there is no forcing flag. It refuses any resource carrying another instance's label and stops before its first mutation. It leaves operator.env and secrets/ in place.

Remove the curl configuration step 9 wrote, which holds the API token:

rm -f "$WORK/api.curlrc"

Reset the deployment when you are finished. This alpha promises no in-place state migration and no backup or restore, so a reset is also how it is replaced: reset, then init for a new identity, then start. That start is a cold bootstrap and prior run history, retained plans, and artifacts do not survive it.

Expiry cannot be extended​

The candidate artifacts are retained for exactly 30 days from their upload. That window cannot be extended, and there is no way to refresh it in place.

When it lapses, the remedy is a new dispatch of workflow-candidate.yml at the same exact commit — the CANDIDATE_SHA you recorded, not the branch tip. The new run will almost certainly have a different workflow revision, and that is expected: head_sha moves, the candidate commit does not.

Nothing else carries over. The new run produces new artifacts with new service IDs and new transport digests, so every identifier you recorded belongs to the old run. You accept the new bytes exactly as you accepted these, from step 1, including the seven-group inventory and the granted 30 days.

What to record​

Report all of this, whether or not it went well.

Provenance, both commits:

  • the run ID;
  • the candidate commit (CANDIDATE_SHA) — the commit the artifacts describe;
  • the workflow revision (head_sha) — which workflow definition ran;
  • the revision and version from identity.json, and that the revision equalled the candidate commit.

Transport acceptance, all seven groups:

  • for each of the seven: the service artifact ID, the transport digest, and the granted window in days;
  • that all seven were present, unexpired, and granted exactly 30 days;
  • that the six identifiers in qualification.json matched the service inventory, and the qualification record's own ID and digest, recorded by hand because it cannot contain them.

The three other digests, kept separate:

  • the bundle file digest from the record, and the sha256sum you computed;
  • the image configuration digest from the record, and what docker image inspect reported;
  • any deployment artifact digest you verified in step 9.

The deployment:

  • the endpoint-backed READY result;
  • the run ID of the plan you read, its checksum, and the apply that bound it;
  • what you observed at the destination afterwards.

And the part that matters most:

  • every step where this page was wrong, incomplete, or assumed something you had to work out yourself;
  • anything you had to install, configure, or work around that it does not mention.

This page has to work for someone who did not build the system, and you are the evidence for whether it does.