Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
87 KiB
Shared pipeline implementation guide
One shared workflow, .gitea/scoped_workflows/pipeline.yaml, reads .ci.yaml from the exact source revision and runs the same lifecycle for every repository. Shared execution contains no repository-specific routing; repository-local configuration and event facts select work. Adding a repository that uses supported fields needs no change here.
Current scope
- Live-qualified: Maven with Java 21 and npm with Node 22.20 on
linux-amd64. Their images inruntime/catalog.yamlare published and qualified (zero HIGH/CRITICAL findings, no secrets). - Implemented and unit-tested, but blocked at
prepareuntil the operator qualifies the runtime (see "Native capabilities and runtime qualification"): Go, Cargo, Python (all versions), thescripttool (control image unrecorded), .NET, Swift, Xcode andmakebuilds; Windows and macOS host targets; browser and docker runtime profiles. Java 17, Android and the BuildKit tools have published, scanned images recorded in the catalog, but publication is not qualification: none has run live. - This is not fleet-ready. Nothing in the native capability section has run live (see "Remaining work").
Live evidence
- Positive run 4244: workflow
dede29111176238789d07715f0b67aa726fc55a1, helper0353c05187f6313f2186cc741a3cb5c70ec1da16, 62 seconds. Components ran in parallel. Each produced independent manifest and file-hash artifacts (3254 and 3255), and ran exactly one native verify. - Negative run 4252: blocked on a real Maven test failure and a missing Node artifact. The reports were retained.
- Per-command Git trust for the exact workspace is fixed (
git -c safe.directory=<workspace>), not a global setting. - Dependency cache: new code passes 98 tests (97 pass, 1 Windows/POSIX skip). Lint and type checks were clean before the last two narrow fixes; the final checks are being run by root. The cache is not live-qualified. Do not claim cache hits.
Responsibilities
| Job | Does |
|---|---|
prepare |
Validates .ci.yaml, resolves catalog images, decides whether verification is required, emits the plan; its reuse step then decides, before any build, whether proven tested bytes replace verification and emits the final matrices and the evidence |
verify (matrix, one per container target) |
Optional cache restore, one native build and test, packaging, artifact upload, cache save |
verify-native (matrix, one per host target) |
The same build, packaging and upload directly on the runner the catalog names; no container, no cache |
secrets |
One history scan per run |
report |
Compares expected with actual results and fails on any mismatch; each verification job must have succeeded exactly when the plan has targets of its kind and nothing was reused, and be skipped otherwise. When tested bytes were reused it requires both verification jobs skipped AND valid reuse evidence for exactly this plan; the secret scan and preparation must still succeed |
Packaging only collects declared reports and artifacts and writes the manifest. It cannot start a process, so it runs no compiler.
Module map
| Path | Purpose |
|---|---|
platform_ci/build_tools.py |
Data only: one row per native tool (executable, toolchain, platforms, profiles, fixed variables) and per platform |
platform_ci/configuration.py |
Strict .ci.yaml validation; duplicate keys and unknown fields rejected; branch globs |
platform_ci/plan.py |
Deterministic plan, verification reason, container and host matrices, native commands |
platform_ci/runtimes.py |
Catalog loading; resolves toolchain, version, platform and profiles to a runner and image; requires an exact digest, fails if unsupported or unqualified |
platform_ci/execution.py |
Runs tokenized commands without a shell, with a scrubbed environment plus the target's fixed tool variables |
platform_ci/maven_settings.py |
Credential-free public Maven Central settings, written to ~/.m2/settings.xml before a Maven target runs |
platform_ci/artifact_service.py |
One portable check that artifact endpoints are the private forge (host jobs cannot rely on a POSIX shell) |
platform_ci/packaging.py |
Collects outputs, writes manifest.json with SHA-256 for each file |
platform_ci/caching.py |
Dependency-cache key, directory and save eligibility |
platform_ci/reporting.py |
Expected-versus-actual report |
platform_ci/workspace.py |
Commit-hash check, containment, excluded names |
platform_ci/__main__.py |
Commands prepare, verify, cache-metadata, package, scan, report, require-artifact-service |
platform_ci/build_inputs.py, platform_ci/versions.py |
The build-input record stored in the manifest and the declared-version reader, shared by the build and the publisher |
platform_ci/safe_xml.py, platform_ci/pipeline_contract.py, platform_ci/reuse_evidence.py |
The shared declaration-refusing XML parse (JUnit and pom versions), the job and artifact names the workflow and publisher agree on, and the reuse-evidence check the final gate applies |
platform_ci/delivery/ |
The trusted publisher and the reuse decision: promotion (the workflow's reuse step), selection (shared proof code), enrollment, forge, publisher, live, reuse_forge, __main__ (the command), manifest, reuse, provenance, authorization, adapters (Maven, npm, container), gitops (digest pins), legacy (the only importer of publisher_frozen/) |
publisher_frozen/ |
Existing publication primitives, vendored unchanged; excluded from lint and type checks |
runtime/ |
catalog.yaml, control.Dockerfile, toolchain.Dockerfile, TOOL-SECURITY.md |
examples/, tests/ |
Example configs (loaded by tests) and behavior tests |
Configuration
schema: 1 plus components.<name> with directory, platforms, toolchains.<toolchain>, requires (optional runtime profiles browser, docker), build.tool, build.arguments or build.commands, reports, and artifacts.<name>.paths. release.channels.<name>.trigger.push_branches is eligibility data only, and verification.push.branches|tags selects which ordinary pushes verify. Branch entries are exact names or globs (* inside a path segment, ** across segments, as in release/**). Anything else is rejected with its field path. There is no option to skip tests or disable pull request verification.
build.tool |
Starts | Toolchain key | Platforms |
|---|---|---|---|
maven |
mvn |
java |
linux-amd64 |
node |
npm ci, then npm |
node |
linux-amd64 |
gradle |
./gradlew (the project's wrapper) |
java |
linux-amd64 |
android |
./gradlew |
android |
linux-amd64 |
go |
go |
go |
linux-amd64, windows-amd64, macos-arm64 |
cargo |
cargo |
rust |
linux-amd64, windows-amd64, macos-arm64 |
python |
python3 |
python |
linux-amd64 |
dotnet |
dotnet |
dotnet |
windows-amd64, linux-amd64 |
swift, xcodebuild |
swift, xcodebuild |
xcode |
macos-arm64 |
make |
make |
xcode |
macos-arm64 |
nix |
nix (flake check and build; single-user) |
nix |
linux-amd64 |
container |
buildctl |
container |
linux-amd64 |
script |
the checked-in program named by each command (./scripts/check.sh, inside the component directory, no shell) |
control |
linux-amd64 |
source |
node running the platform checkout's unchanged source_checks/check-source.mjs; a closed vocabulary, one subcommand tracked-files |
node |
linux-amd64 |
The source tool lints the repository root's tracked files (JSON and JSONC parse, bash -n, Python compiled in memory and never run, conflict markers) with the reviewed check-source.mjs and jsonc.mjs, vendored byte for byte in source_checks/ (receipt.json records their SHA-256 and git blob ids; they equal the files captured at the reviewed revisions and root's offline runtime proof). Each declared command is exactly ["tracked-files"]; unknown subcommands, extra arguments and paths are rejected at prepare, and the directory must be .. The adapter adds no install step, no download and no CHECK_SOURCE_PYTHON; before it starts the program, execution requires both helpers beside it to match the receipt. It runs on the already qualified Node 22.20.0 image on the standard pool. The helper runs git ls-files, and the builtin checkout's owner can differ from the job's user (Git then reports dubious ownership), so execution gives that one process command-scope Git configuration (GIT_CONFIG_COUNT, safe.directory for the exact resolved checkout path) and nothing else: no wildcard, no global or system file, only for this invocation, and every inherited GIT_* variable is dropped by the environment allow-list. Other tools get none of it (tests/test_git_trust.py).
| source-tools | bash running the receipted helpers of source_tools/scripts/; a closed vocabulary of subcommands (actionlint, shellcheck-orb, make-bats, kustomize-render, blueprint-lint, policy), relative paths only where a subcommand takes them | source-tools | linux-amd64 |
The source-tools tool runs the unchanged source-check helpers vendored byte for byte in source_tools/ (receipt.json records their hashes and the published image platform-ci-source-tools@sha256:f50fdd17…2b63, which equals the digest in root's runtime-qualification/expanded/source-tools-publication.json); execution verifies the receipt before starting a helper. The directory must be ., and a check cannot be declared twice. source_tools/ is excluded from Ruff so the receipted bytes never change. The lightweight source path is unchanged. Git trust applies through the same generic capability flag, set per subcommand: the captured helpers never call git themselves (inspected: only find and text mentions), so only make-bats, which runs the repository's own make and bats programs that may call git in the checkout, gets the command-scope safe.directory for the exact resolved checkout; the other subcommands get none. policy subcommand (repository policy validation): ["policy"], no arguments, directory .. The captured scripts/policy-validate.py of the policy-verify action at c95ea023eae447f482ae6ce4d61efbcb9c79a017 is vendored byte for byte (SHA-256 ad9e230b…b83e, recorded under files, git_blobs and additional_captures in the receipt; it is non-executable in the capture and is only read by python3). Inspected: it imports json, os, pathlib, subprocess, xml.etree and yaml; it runs git ls-files -z and bash -n/shellcheck only when QQQ_POLICY_SHELL=true; and it has no command-line options. policy-verify.sh is a platform-authored launcher (listed in platform_authored) that does what the legacy action did: PYTHONPATH=$PYYAML_DIR/lib python3 -P -s -B policy-validate.py in the repository root, using the PyYAML already in the qualified image (PYYAML_DIR=/opt/blueprint-tools, the one blueprint-lint uses). It unsets QQQ_POLICY_SHELL, as the legacy action never set it, so the runner environment cannot switch the shell checks on, and no shell-check subcommand exists because no captured caller used one. Because the validator calls git ls-files, policy carries inspects_checkout_with_git and gets the same exact-checkout, command-scope safe.directory as make-bats. No new job, runtime, profile or catalog row. Config: examples/source-policy.ci.yaml.
Rendered manifest retention (the fleet worker's rendered-manifest-retention gap): no new adapter is needed. manifest-verify.sh writes the rendered manifests to ci-manifests/ in the repository root, and a component's artifacts already take generic globs, so the converter declares artifacts: {rendered-manifests: {paths: ["ci-manifests/**/*"]}} on the component that runs kustomize-render (examples/source-manifests.ci.yaml; tests/test_source_tools.py packages a nested render through the real collector). Converter rules: put the render in its own component, because after a successful build every declared artifact must match a non-empty file; the files are retained in that target's verified-<target> bundle (14 days, per-file size and SHA-256) and not as a separately named rendered-manifests upload; and a failed render keeps no artifact (the legacy job uploaded whatever existed, with a warning), which is deliberate so a failed build never yields a releasable bundle.
Image packaging in the one verified bundle (integrated from the image-packaging patch; its anchored edits were applied to the current candidate with integrate.py, so the source-tools, policy, Docker environment and Git trust changes are untouched). A component's images: run in the existing pool job after the one native verification: package-images (platform_ci/image_packaging.py, no code path that starts a native build tool) runs the receipted image_checks/image-boundary.sh, hashes the tested artifact, refuses a Dockerfile that recompiles, builds with the pool's rootless BuildKit, scans, and writes ci-image-output/<image>/; the ordinary package step then retains the OCI archive (a container artifact image-<name>) and its evidence (report image-<name>-evidence) in the same verified-<target> bundle, and verify_image_binding proves the bundle lists exactly the tested bytes and the scanned archive. There is no second upload, artifact name or protocol; reuse and the publisher read the same manifest.
Image capability composition (changed from the patch, which made image exclusive): images adds the image profile and keeps every requires entry, so requires: [docker] plus images plans as docker+image. The job and mount separation is unchanged and is decided only by the existing rule (docker in requires selects the docker job with the test-daemon mount, otherwise the browser/standard pool job). The image comes from the catalog per combination: image and docker+image use image_image (the published container image has Maven, BuildKit and Trivy; docker adds only the job's mount and variables); browser+image and browser+docker+image use image_browser_image, which is explicitly null for Java 21 and Node 22.20.0 because no published image holds a browser, BuildKit and Trivy, so those plans fail by name (image+browser runtime is unqualified) and nothing is dropped or substituted. The runner labels are recorded the same way (docker+image: container-build, derived from the two authorized labels and as unproven live as they are; the browser combinations null). Image packaging does not receive the docker contract variables: __main__ removes DOCKER_PROFILE_ENVIRONMENT from the environment it passes to package-images, because the captured boundary check refuses a Docker variable and BuildKit is not the test daemon; the native verification still gets them. image cannot be declared without images.
Scan and SBOM: one trivy image --list-all-pkgs --severity HIGH,CRITICAL --exit-code 1 --format json scan per image (the patch scanned twice), then check-image-evidence.mjs on that report, then trivy convert --format cyclonedx --output sbom.cdx.json <report>; the HIGH/CRITICAL failure, image binding and CycloneDX check are unchanged, and tests/test_image_packaging.py asserts the command identity and count (one --input, one convert of the scan's own report, no --skip-db-update). --list-all-pkgs is explicit; the captured Trivy 0.76.0 scan in voyage-ci.sh already passes it, and root verified trivy convert locally; neither was run here. The converted SBOM can include the HIGH/CRITICAL vulnerabilities of the filtered report, which a separate scan did not.
Scanner database cache: for a target with images, cache-metadata also publishes trivy_key (exact: UTC day plus the runtime image digest, no fallback), trivy_path (<HOME>/.cache/trivy, proven outside the checkout like the dependency directory) and trivy_save (the dependency cache's default-branch push rule). Both pool jobs restore it with the same native cache action and the scan is told --cache-dir for the same directory. Freshness is not weakened: the scan never skips its database update, so a restored database that is stale is replaced, and the daily key bounds how old a restored one can be. Saving is the last step of the job, only after the whole job succeeded on a push to the default branch, so pull requests never write. No new service or credential.
UNTESTED CHANGES (this section and the two below): made while test execution was suspended; nothing was run, formatted or type-checked after them, so the earlier 456 + 63 results do not cover them.
Script toolchain choice (integrated from migration/native-graph-staging/script-toolchain-choice.patch, applied by hand to the current candidate): the generic script tool declares exactly one of control (the default runtime), java or node under toolchains, via BuildTool.toolchain_choices; Component.toolchain and Target["toolchain"] carry the choice, the plan resolves the runtime, display name and image from it, and build_inputs records {<selected toolchain>: version} so the fingerprint and config_digest change with the choice. A java script gets the same credential-free Maven settings as a Maven component. script also accepts the browser, docker and (implied) image profiles, composing through the existing catalog rules, so the image composition, Docker environment, scanner cache and the Node image pins are untouched. Dependency caching follows the declared toolchain (caching.cache_family): a java script uses the Maven repository cache and descriptors, a node script the npm cache, a control script none, and the key carries script-<toolchain>; the script is never treated as Maven. Plans and manifests from before this change lack the toolchain field, so their config_digest differs and they are not reused once (a one-time rebuild). Scripts and configuration belong to the native graph worker. Tests: tests/test_script_toolchain.py (not yet run).
Trusted publisher release lifecycle (what is wired, using the frozen release_version primitive through legacy.py): (1) the version is the one the source declares before compilation and the build records in build_inputs; the publisher never computes, bumps or writes one, and a changed version is a changed input, so old bytes are never relabelled. (2) Tags are decided by the repository's own configuration, not by branch names: a release channel may declare tag_policy in the captured release_tag_policy vocabulary (stable: a final X.Y.Z Maven or npm package needs the existing v<version> tag at the release commit; v-prefix: any non-snapshot package does; none or unset: nothing). publisher._check_release_tag reads the tag through forge.tag_commit in inspection and again before any write; the earlier branch-based main/hotfix/* rule was removed. Unset means no requirement, so a migrated repository whose legacy policy was stable keeps its requirement only if its converted .ci.yaml carries tag_policy (the converter does not emit it yet). (3) npm dist-tags follow the native contract: latest only for a final version on the stable channel (a prerelease there is refused), rc and snapshot for those kinds elsewhere, otherwise the channel name as before. (4) Signature callback: provenance retention and subject coverage are enforced as before; cryptographic verification is the injected signature_verifier, and no frozen primitive verifies attestations, so none is supplied: a binding with require_signature_verification fails closed, by design, until an operator-reviewed verifier exists. No release creation, signing, GitOps tagging or credential was added; credential file names and identities are unchanged.
[SUPERSEDED by the "Trusted tag phase" paragraph below: the signed-tag primitive exists in references/release-lifecycle/b06800a6…/.gitea/scripts/qbit_rc.py; my earlier capture search was incomplete.] Release lifecycle completion gaps (earlier text): (a) Tag creation: no captured nonsecret primitive creates or signs a tag. Every captured path only reads one or requires it to exist: promote_package._check_tag (frozen), qrun-source-version.mjs (publishMavenRelease: "never create a tag to make a release succeed"; it also accepts version-<version>, which this publisher does not), qrun-maven-prepare.mjs (release is triggered BY an existing tag run) and calculate_version.sh ("Please create a release tag before deploying from main"). The only captured tag-adjacent write is the Gitea release record (promote_package._ensure_release, POST /releases with tag_name), which the frozen code calls only after the tag exists. The step that created tags in the old platform is therefore not in the captured sources (it was the CircleCI orb's release step, per release_version.py's header), and no mounted credential has tag or release write scope: writer-token/reader-token are package and registry scoped, gitops-token is confined to one file on one branch, api-token is read-only. Missing existing primitive: a tag-creating release step and its approved credential binding. Nothing was staged to create or sign a tag, and no credential or signer scope was assumed. (b) Release record: _ensure_release is not wired, because it needs the same missing release-write scope. (c) Signature callback: no approved attestation verifier exists in the captured sources or publisher_frozen; the only approved verification is the forge's verification.verified flag on commits (legacy.require_verified_commit, used for GitOps). signature_verifier stays unsupplied, so require_signature_verification fails closed. No crypto was written. (d) Converter: it does not yet emit tag_policy, and its tag-driven release kinds (tag runs) remain a reported cutover gap because channels trigger on branches only. (e) The tag endpoint shape (commit.sha) is confirmed by root's read-only receipt tag-endpoint-shape.json for one existing tag; annotated versus lightweight coverage and any publication are not proven. All of this is untested.
Trusted tag phase (UNTESTED; code staged only, nothing signed, tagged, released or read from a credential). Source of the behavior: qbit_rc.py at platform-ci b06800a6 (receipted in references/release-lifecycle-receipts.json) and the old protected callee's boundary: an isolated job with the job's ephemeral contents: write token and the existing repository secret QRUN_TAG_SIGNING_KEY, pinned platform code only, the application never checked out. Nothing is relocated to the publisher and no new grant is implied.
- Configuration owns it:
release.channels.<name>.tag_policy(stable,v-prefix,none) says which tag a package needs, and the newtag_create: trueopts that channel in to the platform creating it (default false: the owner tags and the publisher only requires it).tag_createneeds a policy. Authorization (which repositories have the secret) stays operator-owned. - One canonical-workflow job,
release-tag(needs: [prepare, report], only when prepare's newrelease_tagoutput is true on a push andreportsucceeded): it alone hascontents: writeand references the secret;tests/test_delivery_contracts.pywas edited to assert exactly that. Its one command,python3 -P -m platform_ci release-tag(delivery/release_tag.py), requires a push to a branch on the trusted forge, the forge's proof that this run is a canonical-workflow run (current_workflow_trust), the protected branch head still equal to the verified release commit, reads.ci.yamland the declared version at that commit through the forge (never a checkout), and thensigned_tag.ensure_signed_tag: create the SSH-signed annotated tagv<version>once, refuse an existing tag at another commit or a lightweight one, read it back, verify the raw object, and delete only the exact object it created if verification fails. It never invents a version and never creates an unsigned tag.delivery/signed_tag.pyis now a thin typed seam: all signing and verification is the unchangedpublisher_frozen/signed_tag_primitives.py(eight exact nodes of the receiptedqbit_rc.py, bound bysigned_tag_primitives.receipt.json), reached only throughlegacy.new_tag_signer/legacy.verify_signed_tag; the module keeps only the forge reads, the bare-Git transport that holds the token, and the order of operations (not yet executed; the receipt'sexecution_testedis false). The publisher's_check_release_taguses the samerequired_tagrule, so what is created is exactly what is later required. - Signature formats, from the captured contracts (three distinct things, none standing in for another): (1) Gitea's server-side
verification.verifiedon COMMITS (GitOps pins and the RC bump commit) vialegacy.require_verified_commit; (2) an SSH signature (namespacegit) on a raw TAG OBJECT, checked against the fingerprint derived from the signing key (verify_raw_tag); (3) DSSE/in-toto attestations retained in a bundle, whose subject coverageprovenance.pychecks and whose cryptographic verification is the injectedsignature_verifier. Item 2 does not satisfy item 3. No captured or enrolled contract needs item 3's verification: no enrollment in the captured sources setsrequire_signature_verification, so it stays unsupported and fails closed only for a binding that sets it; it blocks no repository. - Not integrated (named, not claimed): the RC cut (branch, pending-lock, merge, bump commit),
finalize/recover, the final-release record (create_release) and the Maven Central completion evidence inqbit_rc.pyare product-specific orchestration with different identities; wiring them would be a second orchestrator. The control image'sgitandssh-keygenavailability, that a scoped workflow can read the repository secret, and that the job token'scontents: writecan push a tag are unproven live. The publisher does not itself verify the tag's signature (that needs an operator-owned fingerprint allow-list,tag_fingerprintsin the reference, which does not exist here); it requires the tag's existence and position. - Converter gap for the native graph worker: emit
tag_policy(andtag_createonly where the legacy flow created the tag) in channels, and use the new component event selection below instead of dropping the static and coverage gates.
Component event selection (UNTESTED, minimal, for the native graph's static and coverage gates): a component may declare only_on_channels / not_on_channels, validated against release.channels; it is planned only when the event's branch (the push branch, or a pull request's head branch, now passed as CI_HEAD_REF) does / does not match those channels' push_branches. Only prepare_plan filters; plan_targets (the publisher's equivalence proof) still lists every component. A component excluded from a channel it publishes on will have no bundle for that event, and the publisher fails closed. Not applied: fixed event-fact variables for script targets (CI_EVENT_NAME etc.), since adding them would change every script target's fingerprint.
Fleet-gap contracts (ALL UNTESTED; static edits only; nothing here is shown runnable). Parity with the old behavior is NOT claimed complete: see the open items at the end.
- Event selection (G1).
platform_ci/event_selection.pyis the one rule. A release channel matches an event when itstrigger.push_branchesmatch the event's branch (a push's branch, or a pull request's HEAD branch) or its newtrigger.push_tagsmatch the pushed tag;pull_requestis a reserved name matching every pull request. Components and images takeonly_on_channels/not_on_channelsover those names (unknown names are refused).prepare_planand the trusted publisher both callplan_targets(project, runtimes, facts), so the publisher plans exactly the targetsprepareplanned for a push to the branch it releases (components and images selected, an excluded image's archive and evidence outputs removed) and refuses a publication whose target is not selected. At parse time a publication is refused if its component, or its image (image-<name>artifact), would be excluded on its own channel. Verification treats a tag push matching a channel as release-eligible. A tag-only channel selects and verifies; the publisher still releases from protected branch heads only, so a tag-driven release kind does not publish by itself (open). - Images only where wanted (G3). Each image has its own
only_on_channels/not_on_channels; a repository that built images ondeveloponly declares that selection instead of dropping the image. The build is still once: an image-less event plans the same component without the image, whose different target means a bundle from an image-bearing event is not reused for it (correctly), and reuse of image-bearing bundles is unchanged. - Source scripts (E1). A
scriptcomponent may setevent_facts: true; its target then carries the fixedCI_SOURCE_REVISION,CI_EVENT_NAME,CI_REF_NAMEandCI_REF_TYPE(tagfor a tag push, otherwisebranch, the words ofgithub.ref_type; added for the standards conformance helper's original iteration count) from the authoritative event (not the runner's environment), through the allow-list as target environment. Being part of the target they are part ofconfig_digest, so such a component is not reused across commits or events, which is the truthful consequence; components that do not read them keep reuse. - Scan policy (G4). The one closed image field is
ignore_unfixed(default false), which adds--ignore-unfixedto the single Trivy scan. HIGH and CRITICAL findings still fail, the scan cannot be turned off, and no other flag is accepted. The default is the current gate; nothing stricter was added. - Pinned private checkout (G2).
components.<name>.checkouts.<dependency>={binding, repository, ref (40-hex commit), path}(consumer-owned identity; container platforms only). A matrix entry whose target has checkouts runs, in the three container verify jobs, the steppython3 -P -m platform_ci checkout-dependencies(dependency_checkout.py), which authorizes each dependency against the operator manifestruntime/checkout-bindings.yaml(shipped EMPTY: it authorizes nothing and holds no value), fetches exactly the pinned commit with the credential only in the fetch process's environment (never in a URL or file), verifies the commit and that no credential reached the checkout configuration. The workflow forwards only the existing read-only repository secretPUBLISHER_CHECKOUT_TOKENto that one step; no new credential, grant or service. Operator prerequisites, none created here: that secret is available to the consumer's runs and its token can read the dependency; the binding (secret, consumers, repositories) is added to the manifest in the reviewed platform revision; the consumer pins the commit. The capturedqbit_rc-era secret name is the only value used, and no repository name is branched on. Open (not done, not claimed; "tag-driven kinds do not publish" is superseded by the next paragraph): a repository whose old kind-by-PR-head rules differ from channel matching must be checked per repository; no fixture proves any of this; Website-Content's actual dependency, ref and binding values are the operator's and are unknown here; the dependency checkout is not offered on host platforms; whether a scoped workflow can readPUBLISHER_CHECKOUT_TOKENis unproven live; existing tests that count workflow steps, secrets or Target keys were adjusted or need adjusting.
Tag-driven publication and run identity (ALL UNTESTED, source only; no signing, tagging or live write happened).
- Publisher:
execute_enrollmentnow releases from protected branch heads AND recent tags (forge.release_tags, newest five pages) through one_execute_ref, with the event factspreparewould plan for that push (push_facts/tag_facts), so the same channel rule (event_selection,release.channels.trigger.push_tags) picks the same eligible channels, publications and targets. A tag whose receipts all exist is skipped. A tag is eligible only if, before any write: (1) it matches the format of the channel's owntag_policy(stable= exactlyvX.Y.Z,v-prefix= anyv…; a tag-driven channel that a publication uses without astableorv-prefixpolicy is refused at parse time; a tag channel that only selects work, such as apublish*kind with no publication, needs none and is not forced to carry a policy that does not fit it); (2) it names exactly the commit the verified run built, re-read at publish time; (3) for a package, it equals the tag that policy gives the declared version the verified manifest recorded, so a tag never relabels another version (an image has no version, so only the format applies); (4) its commit is in the history of a protected branch; (5) when the operator's enrollment liststag_fingerprints, it is an annotated tag whose raw object carries one SSH signature of such a key, checked by the unchangedverify_raw_tagthrough a read-onlySubprocessTagGitbuilt from the existing api token (a tag signature, not an artifact attestation). Run, attempt, source tree, manifest, config and platform identity checks are the unchanged ones ofselect_for_query/inspect_run; versioning rules still apply. Withouttag_fingerprintsno signature is claimed.version-<version>is not accepted: the captured prepare rules that decide the release kind never recognize it (only the old release-record lookup did). - No extra tag-triggered build:
release-tagruns only on branch pushes (release_tagis empty for a tag push), so its tag cannot trigger another tag job. The tag's own run is a normal required verification that goes through the existing reuse step, so it reuses the branch run's tested bytes when the exact inputs and workflow trust match (tree, version, toolchain and runtime, platform revision, planned targets); targets that differ (a component selected differently for a tag, or one that opted in to event facts) legitimately build once. - Run identity for event-fact scripts: a component with
event_facts: truegetsCI_RUN_IDandCI_RUN_ATTEMPTfrom the workflow's owngithub.run_id/github.run_attempt(new env on the build step of the four verify jobs), validated positive integers, injected byrun_target_commandsonly when the target already carries the event facts, and deliberately NOT part of the target, soconfig_digest, reuse and the publisher's equivalence proof are unaffected for every component, including this one (its outputs reuse across runs; a script whose output must differ per run should not be reused and is a policy decision for its owner). No other parent variable passes. - Remaining blocking contracts (named, not worked around): enrollment
tag_fingerprints(operator-owned) must be set for signed-tag enforcement, which also needs the signing key's fingerprint from the owners; the tag API response shapes are now observed (root'stag-api-contract-current.json: for five annotated QRun-IO/qqq tags the/tagslist entry hascommit.sha,/git/refs/tags/<tag>returns a list whose exact entry hasobject.type: tag, and/git/tags/<object sha>returns an object whoseobject.shaequals the list commit; for five lightweight qualification tags the exact ref hasobject.type: commitand/git/tags/<commit>answers 400, not 404), which proves shapes only, with no signing, tag creation or publication proof;ForgeTagRefstherefore decides annotated versus lightweight from the exact ref'sobject.typeand never from that endpoint's status, asks the tag-object endpoint only for an annotated tag (where a failed read is an error, not a downgrade), and rejects a lightweight tag wherever an annotated or signed tag is required; tag names are encoded per path segment with/kept (forge.tag_path; empty,.and..segments refused), which assumes the forge takes the tag name as a wildcard path remainder, not yet observed for a tag containing/; a repository that uses lightweight tags and enables signature enforcement is refused by design; the legacy Maven-Central/RC completion evidence and release record are still not integrated; tags older than the newest 250 are never re-released from here; the migration worker must emitpush_tags,tag_policyand the selection fields, and the checkout binding root recorded (content-validator-source, consumerKofTwentyTwo/Website-Content, dependencyKofTwentyTwo/Website-Publisher) is preserved unchanged inruntime/checkout-bindings.yaml.
Contract fixes after reading the native staged configs read-only (UNTESTED): (1) CI_REF_TYPE added to the opt-in event facts, because standards-conformance.ps1 selects 10000 fuzz iterations for CI_EVENT_NAME=schedule or CI_REF_TYPE=tag and the platform passed no ref type; it is in the target like the other facts, so reuse identity stays correct (a component that reads it is not reused between a branch and a tag run), and a forged parent value is not passed. (2) The tag-policy rule was too strict for qqq's feature kind (push_tags: [publish*], no publication, no policy); it now applies only to channels that a publication uses. (3) The tag-publication test fixture now declares the artifact format it needs, and a positive case shows the refusal is the selection rule. Compatibility read, not proven: the qqq, Voyage, mcp-rag, qqq-all, qqq-cli and Website-Content configs use only fields the candidate parses (only_on_channels/not_on_channels with pull_request, push_tags, tag_policy, ignore_unfixed, checkouts, event_facts); the Website-Content binding content-validator-source is preserved in runtime/checkout-bindings.yaml.
Not satisfiable by the candidate today (named, not worked around): schedule events (the canonical workflow's on: has no schedule, so the standards 10000-iteration schedule path never runs; only a tag push can, and the staged standards config verifies no tags: verification.push.tags: [] and no tag channel); pull-request title, body, number and a forge read binding for the standards pr-title and pr-dco gates; a host-platform script for Windows signing; PUBLIC_GITHUB_AUDIT_TOKEN for the workflows phase.
Release completion seam (ALL UNTESTED; source only; nothing was uploaded, recorded, signed or read live). Policy is the repository's own: a release channel may set central_completion: true (Maven publications only) and release_record: true (Maven and npm; needs a tag_policy). After the existing idempotent publication, publisher._finalize collects terminal evidence per such request, and a release is reported complete only when it exists:
- [SUPERSEDED by "Central producer integration" below: the producer and the binding are now identified; the receipt rules described here are the frozen primitive's, and
completion.pywas rewritten] Central completion (delivery/completion.py, the captured consumer rules ofqrun-source-version.mjswaitForCentralCompletionandqbit_rc.pyCentralEvidence): the receiptqrun-publication-completion/1in the generic registry (read with the existing reader-token package client, kindgeneric, pathrepo-<id>-run-<source run>/<build commit>.json) must name exactly this repository, repository id, run, build commit, version and the sorted coordinates of the verified bundle (adapters.maven_coordinates). Absent, or any non-terminal state, is PENDING: the publication receipts stay, no record is written, the summary status ispublished-awaiting-completion, and the tag stays in play for the next invocation.FAILED-class states, any identity or coordinate contradiction, a wrong target for the version, and malformed hashes are errors. Complete means a releasecentral-publisher/PUBLISHEDwith a deployment id and exactly the verified purls, or a snapshotcentral-snapshots/UPLOADEDwith files and no deployment id. Upload acceptance never completes. - Release record: the frozen
promote_package._ensure_releasethroughlegacy.ensure_release_record(GET the record forv<version>; POST once only if absent; an existing non-draft record is returned unchanged; a draft or mismatch is refused; prerelease flag from the version kind). It runs only after Central completion where required, only for the tag the channel's policy gives the verified version, and only after the tag is re-read and still names the verified commit (a release POST for an absent tag would make the forge create one at the default head). The client islegacy.release_client, the frozenrelease_clientover the EXISTINGrelease-tokenmount name of the frozen publisher service (read only on demand bylive.release_client; no new credential or store). - Durable and idempotent:
<commit>.<request>.<channel>.complete.jsonis written once and a replay must match exactly (a conflict is an error); a tag whose publication receipts exist but whose.completerecord does not is re-processed, and every step is idempotent (immutable registry writes, record lookup before post), so a retry duplicates no upload or record and relabels no bytes. No rebuild or retest happens. Exact missing data and code boundaries (not worked around; each fails closed):
- [SUPERSEDED, see "Central producer integration": the producer was found in root's later snapshot
references/central-producer] Producer and hash binding of the completion receipt. Nothing captured producesqrun-publication-completion/1(the capturedqrun_maven_promotesays "no Central, no signing"), and the captured consumer binds itsmanifest_sha256to the legacy reactormanifest.jsonit downloaded, which this pipeline's bundle does not contain. Missing: which job uploads to Central and writes the receipt (and which run id keys it: assumed the verified source run, as in the captured layout), and howmanifest_sha256andarchive_sha256are defined against this bundle. Boundary:PublisherServices.completion_binder(completion.Binder); with none, a terminal receipt is an error, so acentral_completionchannel cannot report complete today. - Release-record credential.
release-token(contents write limited to/repos/<repo>/releases) must be mounted for the publisher; the cron job inrelease_central.pylists only api, writer, reader and gitops tokens. Boundary:PublisherServices.release_client; unmounted, a configuredrelease_recordis an explicit error. The frozen client accepts only tags of the formv[0-9]…. - RC branch cutting, pending lock and version write-back are source-lifecycle writes, deliberately not rebuilt here. Their captured helpers are
qbit_rc.pycut()(merge develop intorelease/X.Y, lock branchautomation/qbit-rc-pending, intent artifactqbit-rc-intent-<merge sha>),finalize()/recover()(completion verification, signed tag viasigned_tag_primitives, release record, lock release) andqrun-source-version.mjspersistVersion(signed staging branch, one-filepom.xmlbump commitBump version to X.Y.Z-RC.n [skip ci], fast-forward push). Integration point: the one trusted write boundary (therelease-tagphase and its token, or a dispatch offinalize=truesent by the publisher's own Actions-write job, which is how the captured callee worked), calling those helpers with their product constants parameterized (LOCK,CUT_WORKFLOW, theqbit_rc_trust.jsonfieldsrelease_workflow_sha256,required_jobs,module_artifact,recovery,CENTRAL_GROUPand artifact pattern, the<revision>rewrite). Missing: a repository-owned configuration for those parameters and the trigger from the publisher to the write phase; neither exists, and no central product handler was added. Also still open: signed-tag enforcement needs enrollmenttag_fingerprints; the artifact-attestation verifier does not exist; whethertests/test_release_completion.pyand the rest pass is unknown.
Central producer integration (UNTESTED; source only). Correction: the producer EXISTS. Root's references/central-producer (a later historical publisher snapshot, files hash-matched by its receipt.json; live deployment not revalidated) holds qrun_maven_promote.py (promote_reactor: publish_central, then the completion receipt), qrun_central.py, qrun_central_transport.py, qrun_completion_receipt.py, qrun_maven_evidence.py, publisher_service.py and forge_transport.py. My earlier "producer not captured" was wrong because publisher_frozen held an older snapshot.
- Integration (
delivery/completion.py, rewritten; the speculativecompletion_binderseam and my own receipt evaluator are removed):ensure_central_completionis the generic, repository-independent part of the capturedpromote_reactor, using the frozen primitives throughlegacy(central_primitives,central_client,gpg_signer, imported lazily frompublisher_frozen/so nothing else breaks until they are vendored). It builds the evidence from the VERIFIED retained files of the tested bundle (adapters.maven_modules, which now also carriespackaging), parents first, so Central gets exactly the bytes the private registry got and nothing is rebuilt; saves the original deployment id durably BEFORE waiting (<commit>.<request>.<channel>.central-upload.json, bound to build commit, archive hash and version; a different binding is an error); treats a pending deployment (CentralPending) as pending, never complete; writes the immutableqrun-publication-completion/1receipt only after Central reports a terminal state (publish_centralreturns PUBLISHED or UPLOADED); and on a repeat reads the existing receipt throughqrun_completion_receipt.read(validated against the tested bundle), re-checks Central (a release still PUBLISHED with the same purls, snapshot files byte-identical) and uploads nothing. A duplicate-deployment response is an error for the operator, never retried. - [SUPERSEDED by "Central contract corrections" below: this binding relabelled the legacy fields and was rejected] Exact bundle binding:
manifest_sha256= the verified manifest's own digest;archive_sha256= SHA-256 of the canonical sorted list of the selected artifact's retained (path, sha256) entries. These are the receipt's fields of those names, but they are defined over THIS pipeline's bundle: the legacy values (a reactor zip's hash and itsmanifest.jsonchecksum) describe an artifact this pipeline does not have, so no new retained evidence and no second build was needed. The consumer is this same code, which recomputes both from the verified bundle. A legacy-format consumer (qrun-source-version.mjswaitForCentralCompletion) would not accept these hashes; it is not part of this flow. - [SUPERSEDED: the
qqqcompany refusal andcentral_allow_privatewere removed] Data, not names: the public-route rule isinfo.privatefrom the forge (a private repository is refused unless the operator's enrollment sets the new optionalcentral_allow_private, replacing the two repositories the snapshot hardcoded); the company must beqqqbecause the frozen completion location is fixed to that company's generic registry owner (so akofCentral route is not available from this primitive). - Mounts (source-named, not proof of any live grant):
/run/publisher-central/{central-username, central-password, gpg-private-key-b64, gpg-passphrase, gpg-keyname}read only on demand bylive.central, plus the existing writer and reader tokens asgenericpackage clients; unmounted or empty, a configuredcentral_completionis an explicit error. Nothing was created or read. Still needed [item (1) is DONE: root vendored the three Central files; see "Central contract corrections"] (source evidence and operator facts, not worked around): (1) the seven files must be copied byte-identically intopublisher_frozen/with a receipt (qrun_central.py,qrun_central_transport.py,qrun_completion_receipt.py;forge_transport.pyandqrun_maven_publication.pyalready exist there, but the snapshot'sforge_transport.pyhas a different hash than the older frozen copy, so whether the newer one is required, for example forNoRedirectuse or other changes, must be decided by diffing; I could not copy or hash); until then Central publication raises "not vendored". (2)qrun_maven_publication.plan_filesmust accept the evidence modules built here; the captured evidence carriedpackagingand per-filedata(present), while the older frozen copy's compatibility withcentral: trueevidence was not diffed. (3) The Central Portal API shapes are marked UNQUALIFIED in the primitive's own docstring (result-followup.md, not in the copied set): needed to truststatusandupload_bundle. (4) Operator facts: the Central credential and GPG key mounts exist for the publisher and are the intended key; GPG qualification against the real key; which enrolled repositories may usecentral_allow_private. (5) The RC cut and version write-back integration point is unchanged from the previous paragraph and remains unimplemented.
Central contract corrections (UNTESTED; source only; root's two mismatches fixed). Root has now vendored qrun_central.py, qrun_central_transport.py and qrun_completion_receipt.py byte-identically with receipts; those bytes are untouched.
- Legacy meaning preserved, no relabelling. The receipt fields keep their legacy meaning:
archive_sha256= hash of the reactor archive,manifest_sha256= hash of itsmanifest.json(the legacyevidence.checksums['manifest.json'], whichqrun-source-version.mjspostpublishcompares with themanifest.jsonit reads frommaven-reactor/).delivery/maven_reactor.pyrebuilds that archive (qrun-maven-reactor/1:files/<artifactId>/<name>,manifest.json,SOURCE_COMMIT,SHA256SUMS) deterministically from the verified retained files, the POM-derived coordinates and packaging, the build commit and the recorded version, so nothing is compiled, tested or newly retained and any holder of the bundle recomputes identical bytes. The one honest difference: the legacy archive was the forge's artifact zip with non-reproducible zip bytes; this zip container is deterministic (sorted, fixed timestamps), soarchive_sha256hashes this representation, not a historical upload. In the captured sources only the hex shape ofarchive_sha256is read;manifest_sha256is the compared, byte-meaningful field.maven_reactor.materializewrites themaven-reactor/tree the legacy source-version consumer reads. Because no field changed meaning, the schemaqrun-publication-completion/1and the storage namespaceqrun-publication-completionare NOT versioned.tests/test_maven_reactor.pyruns the real capturedqbit_rc.verify_reactorover the archive when the reference file is present. - No company or name switch.
CENTRAL_COMPANYand theqqqrefusal are gone. The destination is explicit authorization data: the enrollment's optionalcentralobject{completion_owner, allow_private}(enrollment.CentralAuthorization);completion_ownermust equal the enrolled company's own registry owner (checked at load, like the existing package-owner rule),allow_privaterecords an operator exception for a private source. Nocentralobject means Central publication is refused. The frozen receipt primitive fixes both the registry owner and the repository prefix to one company, sodelivery/completion_receipt.pyis a small extraction of it with explicit provenance (source hash recorded in its docstring): same schema, namespace, field set, validation rules and canonical bytes, with the owner as a parameter (plus type guards raisingValueError); for the owner the frozen file hardcodes it yields identical locations and bytes, which a test checks against the vendored original when present. The frozen file itself is not used by new code, only compared. - Interface compatibility of the vendored files with the existing frozen ones (read, not executed):
qrun_centralimportsplan_filesand_snapshot_metadatafromqrun_maven_publication, and the frozen copy has both with matching signatures and the dict keysqrun_centralreads (module,entry,directory,file_version);qrun_central_transportimportsNoRedirectfromforge_transport, which exists. EXACT MISMATCH found: the recoveredforge_transport.py(sha256 0065be44…) differs from the frozen one (c8172548…) in one place,registry_client's image pattern at line 216: recovered(koftwentytwo|qrun-io|qrun-private)/…, frozen(koftwentytwo|qrun-io)/…; the first 215 lines and everything else I read are identical,package_clientincluded. It does not affect Central, the generic registry or this flow, and it was not overwritten. The recovered snapshot'sqrun_maven_publication.pywas not among the copied files, so any difference there could not be compared. - Consumers (what is and is not wired): the compared field of
qrun-source-version.mjspostpublishand the RC finalizer's reactor verifier read the same fields and layout (provided bymaven_reactor); but both run in the source-lifecycle write phase that is still not implemented:qbit_rc.pymodule_evidencelooks the reactor up as an Actions ARTIFACT named bymodule_artifacton the release run, andqrun-source-version.mjsreads an extractedmaven-reactor/directory, neither of which this pipeline'sverified-<target>bundle provides as such. Exact migration needed when that phase is built: materialize the reactor from the verified bundle (maven_reactor.build_reactor+materialize) for the source-version script, and pointmodule_evidenceat the verified bundle (or the materialized archive) instead offorge.run_artifacts; no parity with those consumers is claimed. Retained reactor captures (Android adapter, UNTESTED): when the verified bundle already retainsmanifest.json,SOURCE_COMMITandSHA256SUMS,completion.reactor_forno longer regenerates anything: those three files are used byte for byte (an indented manifest keeps its exact bytes and hash) andmaven_reactor.from_retainedmakes the deterministic zip of the retained entries under their retained relative paths, somanifest_sha256is the SHA-256 of the existing manifest bytes andarchive_sha256the hash of that zip. It refuses contradictions instead of repairing them: a manifest that is not this reactor's (schema, build commit, version), aSOURCE_COMMITother than the build commit,SHA256SUMSthat does not cover exactly the other members with their hashes, afiles/member the manifest does not describe or describes with another hash or size, a manifest hash different from the one the verified bundle records for those bytes (adapters.reactor_manifest_sha256), incomplete, ambiguous or foreign-root metadata, and any module file the adapter grouped (a Gradle.moduleincluded) that is not one of the retained entries. The.modulefile is part of the Central evidence (the frozen planner accepts any extension) and of the archive. For ordinary Maven files with no captured metadata the deterministic generation is unchanged; it no longer restricts classifier spelling or extension beyond a safe archive member name, since the adapter decides what a module may hold. The retained-capture path uses no company or repository name and no credential. Tests:tests/test_maven_reactor.py(RetainedReactorTest) andtests/test_release_completion.py(RetainedCaptureTest). The realqbit_rc.verify_reactoraccepts onlyjarandpomextensions, so an archive with a.moduleor.aarfile would not pass that consumer: part of the consumer lifecycle boundary below. Remaining consumer lifecycle boundary: the legacy RC finalizer (qbit_rc.pymodule_evidence: an Actions artifact named bymodule_artifact,jar/pomonly) and the source-version script (qrun-source-version.mjspostpublish: an extractedmaven-reactor/directory) still have no feed from theverified-<target>bundle, and both run in the unimplemented source-lifecycle write phase;maven_reactor.materializecan write the directory from the archive's members when that phase exists. Remaining real gaps: Portal API shapes are UNQUALIFIED in the primitive itself (itsresult-followup.mdwas not copied); operator facts (Central and GPG mounts exist for the publisher, the key is the intended one, the writer and reader tokens may write and read the completion namespace) are unconfirmed; the write phase (RC cut, version write-back) is unimplemented; the vendored files' behavior has not been exercised.
Docker profile environment: build_tools.DOCKER_PROFILE_ENVIRONMENT is the fixed pool contract (DOCKER_HOST=unix:///run/test-docker.sock, TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE, TESTCONTAINERS_HOST_OVERRIDE). The plan puts it in target.environment for every target that requires docker (alone or with browser), because execution passes the build only the allow-list plus that environment, so the job-level variables of verify-pool-docker never reached the build process. Plain and browser-only targets get none of it, runner-injected DOCKER_* variables stay filtered, and release/manager credentials remain outside the allow-list; tests/test_docker_environment.py also requires the workflow job's values to equal this data. The browser profile adds no variable: its Chrome and chromedriver are found on the image's PATH, and no recorded requirement names a binary-path variable. Root's publication script must later include the source_tools/ directory in what it publishes with the candidate. The image-packaging worker's patch is not integrated here.
The python tool starts python, never python3, so the control image's own interpreter that runs the pipeline is not shadowed. cargo moves only CARGO_HOME to scratch; the image's RUSTUP_HOME is kept.
Each declared command is one argument list for the tool's own executable, started without a shell; build.commands runs several in order. A tool is one row in platform_ci/build_tools.py, so adding one needs no change to planning or the workflow. platforms defaults to the first platform the tool supports; requires is accepted only for tools that define the profile (maven: browser, docker; node and gradle: browser).
Native verification runs once per component and platform. After a successful build every declared report and artifact must match a nonempty file. After a failed build, existing reports are kept and no artifact is collected.
Maven reports and parity: declare **/surefire-reports/TEST-*.xml and **/failsafe-reports/TEST-*.xml as an optional junit report (zero reports or zero executed tests passes, as the legacy gate allowed, while any failure, error or malformed report fails) and **/target/site/jacoco/jacoco.xml as an optional file report. Raw surefire and failsafe files are retained as collected. One legacy behavior is not reproduced: the per-module concatenation of surefire .txt output into ci-reports/test-output (pure collection; declare **/surefire-reports/*.txt as an optional file report to retain the same text per file).
Maven settings: before the first command of a Maven target, ~/.m2/settings.xml is written with the legacy public-repository layout: one mirrorOf central Google-hosted mirror and an always-active fallback profile with canonical Maven Central (release-only). Maven reads it without a flag, so the declared command line is unchanged (the legacy -s /tmp/circleci/mvn-settings.xml is not needed). An identical existing file is left alone; any other existing file or a link is refused rather than overwritten or merged. Parity note: the document layout was derived by reading the captured adapter's template code (scripts/maven-public-mirror.mjs, --create mode) and is pinned by a literal in the tests; the adapter itself was not executed here, so run it once and diff to confirm. The vendored helper vendor/qqq-orb/setup_maven_settings.sh is now captured (22 supplement files, 10 revisions of it, all byte-identical). It defines only central and central-snapshots <server> entries that read CENTRAL_USERNAME and CENTRAL_PASSWORD from the environment; it defines no private dependency repository or mirror. Those are publication credentials, so they belong to the trusted publisher and are not a prerequisite of ordinary builds, which keep the credential-free public mirror and fallback settings above. This pipeline never reads, writes or names a credential in a build job.
Events
Pull requests, manual and workflow_call runs, pushes to the default branch, and pushes to branches matching a release channel's names or globs always verify once. Other pushes verify only if selected by verification.push. An unselected push reports "NOT REQUESTED" and never claims a pass. Release eligibility and the requested publications are reported only; the workflow never delivers (see "Delivery integration contract").
Dependency cache
- Caches only
$HOME/.m2/repositoryand$HOME/.npm/_cacache. Never build output,node_modules, settings,.npmrcor credentials. Directories that are relative, contain.., are symlinks or overlap the checkout are rejected. - Key:
platform-ci-deps-v1-<tool>-<toolchain>-<platform>-<image hash>-<component>-<descriptor digest>. Descriptors are reactorpom.xmlfiles plus.mvn/maven.config,jvm.config,extensions.xml(Maven), orpackage.jsonfiles plus the lock file (npm). Application source is never an input. There is no restore-key fallback. - Save only after a successful build on a push to the default branch, and not on an exact hit. Everything else only restores.
- A hit never skips the build or tests. Cache steps use
continue-on-error. - Endpoint: the runner-provided native
ACTIONS_CACHE_URL(private HTTP on a pod address once the native cache is enabled), used as given. The workflow names no cache address, allowlist, setting or credential; the native network policy and per-job authorization bound it. The private HTTPS runtime/results gate is unchanged and does not judge this endpoint. - No usable endpoint:
cache-metadataoutputsavailable=falseand a note, both cache actions requireavailable == 'true', so nothing is restored, saved or retried, and the build and tests run as always.
Native capabilities and runtime qualification
The catalog (runtime/catalog.yaml) is the only place a runtime is chosen. A component names a toolchain version, a platform and optional profiles; prepare resolves them to a runner label and, on Linux, an image. Nothing falls back to another image, version or runner.
- Container platform (
linux-amd64): the image comes fromtoolchains.<name>.<version>.image(orbrowser_imagefor the browser profile) and runs on the workflow's fixedlinux-smallpool. Profile pools (browser,docker,browser+docker) may be listed only asnulluntil the workflow has a pool job. - Host platforms (
windows-amd64,macos-arm64):verify-nativeruns onrunners.<platform>.standard, and the toolchain version must list the platform underhosts. Listing it is an operator attestation; the pipeline does not probe the host. The host needs Python 3.13+ with PyYAML aspython3, Git and the artifact-upload action runtime. - Fixed non-secret tool variables (for example
GOPATH,CARGO_HOME,GRADLE_USER_HOME,DOTNET_NOLOGO) travel in the plan, never in the workflow. Container-only ones are not applied on hosts. Host system variables needed to start a tool (SYSTEMROOT,USERPROFILE,PATHEXT,DEVELOPER_DIRand similar) pass the sandbox; runner and release tokens never do.
Shipped state and exactly what remains before each path can run (all currently stop at prepare with a message naming the entry):
| Path | State in the shipped catalog | What qualification needs |
|---|---|---|
Go 1, Rust 1, Python 3.11-3.14 on Linux |
image: null |
Their images are not published or have scan findings. Record a digest only after a clean scan and a published, read-back image. |
Java 17, Android 2026.10.1, BuildKit 0.34.0 on Linux |
Digests recorded from root's publication receipts | Publication only: run each once live. Android instrumented tests also need an emulator-capable pool; BuildKit has not been shown to work on the standard pool. |
Node 20.20.2 and 24.22.2, Nix 2.35.4 on Linux |
Digests recorded from root's publication receipts (platform-ci-node20, platform-ci-node24, platform-ci-nix) |
Publication only: run each once live. Node 18 (three findings), Go, Rust and Python are not enabled. |
control 1 (the script tool) |
The published control image, recorded (authorized) | Run once live. |
| Gradle on Linux | Reuses the qualified java image (the legacy job used a JDK image and the project's wrapper) |
One live run: the wrapper downloads Gradle at run time, so network reach and GRADLE_USER_HOME writability on that image are unproven. No Gradle cache is defined. |
| Browser profile (Maven, Node, Gradle) | Java 21 browser_image recorded (published); Node's is null. The browser pool is recorded as the generic container-build label (root authorized it for DRAFT qualification only); verify-pool mounts the workspace and sets --init --shm-size=256m, with no Docker access |
Root's tests of the mounts and the browser boundary on private fixtures in both organizations before merge or production activation. Not proven live. A custom catalog with a null or unlisted pool still refuses the profile. |
| Docker profile (Maven with Testcontainers) | docker and browser+docker are recorded as the same generic label; verify-pool-docker is the only job that mounts the separate rootless test daemon socket and sets the Testcontainers variables |
The same live proof, including how the socket is granted. Not proven live. |
.NET on Windows (10.0.403) and Xcode on macOS (27) |
hosts: [] |
Operator attestation of the installed SDK or Xcode on the runner. |
| Windows runner label | runners.windows-amd64.standard: null |
Legacy used kof-windows-vm6218 for one organization and qqq-windows-amd64 for the other; one shared catalog cannot name both. Provision one shared label or decide the mapping. |
| macOS runner label | kof-macos-arm64 recorded |
Only the toolchain attestation above. |
| Go and Cargo on Windows and macOS | hosts: [] |
Attest the host toolchain. The legacy release-target cross builds ran through a script whose body was not captured; they are expressible only as declared cargo/go commands. |
Not implemented because their legacy source was not captured or a design decision is open: Nix, pinned-fixture checkouts (the script tool now covers checked-in entrypoints such as the legacy shell-only checks, once the control runtime is recorded), and Python dependency installation policy (declare the install as the first build.commands entry meanwhile).
Delivery integration contract
Delivery runs in the trusted publisher, python3 -P -m platform_ci.delivery, never in the workflow. The workflow has no publish job, no secret reference and no release credential; it only produces verified bundles. The publisher is the existing isolated publisher boundary (same --credentials, --receipts, --spool, --run, --publish, --fleet shape, mounted credential names api-token, writer-token, reader-token, gitops-token, and the same frozen forge clients and publication primitives, vendored byte-for-byte in publisher_frozen/ and checked against receipts.json). It is not a new control plane.
Configuration contracts (requests only)
artifacts.<name>.format is maven, npm or container (a package format needs its build tool). release.channels.<name>.versioning is maven-snapshot, maven-release or declared. release.publications.<name> has component, artifact, destination (a logical name), channels, and optionally platform (required when the component builds for several) and deploy (a logical GitOps destination, container artifacts only). No field can carry an address, owner, credential or signer; unknown fields fail by path. Artifact source stays rejected by name. examples/maven-publication.ci.yaml documents all of it. The plan lists delivery.publications only for channels eligible for the event.
Effective version before the build (UNTESTED; never executed)
Parity is PARTIAL, and no rule substitutes a different lifecycle: where a captured input is not carried the event FAILS naming it. release.channels.<name>.effective_version: {rule, property, rc_baseline} (develop-next-minor | rc-from-branch | patch-bump | stable-from-tag | feature-snapshot; property defaults to revision; rc_baseline is none (default) or reconciled) is the captured calculate_version.sh handler of each branch type as the pure platform_ci/effective_version.py. The captured evidence uses all five: qqq (profile core, every publish kind true, migration/qqq-release-facts.json; channels snapshot, release-candidate, hotfix, release, feature in migration/out/staged-15/staged/QRun-IO/qqq/.ci.yaml:116-137), and qbit-tools (migration/qbit-tools-release-facts.json) and qqq-all use develop and release/**. For a push whose matching channel declares a rule, plan_targets takes the declared version of the unmodified target from an injected reader (checkout in prepare, forge in the publisher, release-tag and finalize-version), calculates the version, appends -D<property>=<version> to the commands of every Maven component the event selects (publication or not, as the captured calculator ran for every eligible kind), and re-reads the target to prove the static resolver now yields exactly that version. The version is therefore part of the plan (config_digest), consumed by the one native build, recorded as build_inputs.version, and recomputed identically by the publisher and tag planner from forge facts (declared version at the release commit, branch or tag); nothing compiles twice. The static resolver is only the fast-fail: Maven's own resolution is proved after the build by the publication adapter, which requires every retained POM's coordinates to equal the recorded version (delivery/adapters.py::_require_manifest_version); a verification-only component has no downstream consumer of the version, so nothing further is checked for it. Pull requests and unmatched pushes are unchanged; .properties version_source components are refused.
Carried and exact: rc-from-branch with a declared RC or rc_baseline: none, patch-bump, stable-from-tag, develop-next-minor from a declared RC or stable version. REFUSED with the exact missing input (each is required by captured evidence, none is an immutable input of the release commit): develop with a declared SNAPSHOT (the "recent release merge" window, git log --since="3 days ago" anchored to the verify job's start, qrun-maven-prepare.mjs:23-26, calculate_version.sh:273); rc_baseline: reconciled with a declared X.Y.0-SNAPSHOT (qbit's reconciledRcBaseline, qrun-maven-prepare.mjs:147-182: commit parents, their root POM revisions, tag ancestry); every feature-snapshot (the originating feature branch of a publish* tag and git's commit abbreviation, calculate_version.sh:35-53,342; no fallback name or fixed-length hash is used and no feature-branch form is accepted, because eventKind never routes one, qrun-maven-prepare.mjs:85). A repository migrating the qbit profile must declare rc_baseline: reconciled; one that declares no rule keeps its declared version and claims nothing. Not carried at all (no captured repository fact enables them, migration/*-release-facts.json: release_on_main: false, release_tag_policy stable or null): release_on_main/latest-tag fallback and the v-prefix RC-tag route. A POM whose project.version does not use the property is refused instead of the calculator's versions:set rewrite.
Version write-back (UNTESTED; never executed; NOT reachable from any workflow)
release.channels.<name>.version_write_back: {file: pom.xml, target_branch, require_push} (configuration.py::WriteBackPolicy; presence is the opt-in, absence writes nothing) is the captured qrun-source-version.mjs persistence. A branch-driven channel writes to the branch it released from (no target_branch); a tag-driven channel names target_branch (the captured helper pushed to main); a channel with both trigger kinds is refused; the channel needs a Maven publication, whose evidence the write waits for. python3 -P -m platform_ci finalize-version (delivery/finalize_version.py, pinned platform code only, never checks out or runs the source, never rebuilds):
- requires
CI_EVENT_NAME=push,CI_EVENT_REF,CI_SOURCE_REVISION,CI_REPOSITORY(_ID),CI_RUN_ID,CI_RUN_ATTEMPT,CI_PLATFORM_REVISION,CI_SCOPED_SOURCE_ID,CI_WORKFLOW_SHA,GITHUB_SERVER_URL= the trusted forge andFORGE_TOKEN(the job's own token); optionalCI_RELEASE_RUN_ID. Proves the repository and that the CURRENT run is the canonical workflow (current_workflow_trust, asrelease-tag); - reads
.ci.yamlat the release commit, finds the matching channels that declare it, selects the VERIFIED bundle with the publisher's ownselect_artifacts(same proofs, same recomputed inputs incl. the effective version), and takesbuild_inputs.versionas the version to write; source_lifecycle.postpublish: reads the source POM, requires every Maven file read back byte-identical from the private registry and, if the channel hascentral_completion, the terminal completion receipt of exactly this reactor (absent =NotYetPublished, nothing written, retry later; different =Fatal); only then a staging branch at the source commit, one server-signedPUTof the root<revision>(revision_only_change), strict re-verification (one file, sole parent, server-verified, exact message and bytes) and a normal non-forced fast-forward push throughVersionGit(the tag boundary'sSubprocessTagGit, token only in the child environment); only exact staging heads this run created are deleted. A push failure is a warning unlessrequire_push.SourceWriterrefuses every forge request outside that one write before sending it.
Exact code that remains: a workflow job (or operator-run step) that calls finalize-version after the publisher finished, tests/test_delivery_contracts.py allowing exactly that job, and the RC finalizer (cut, lock, intent, tag, prerelease record, recover; release-finalization-integration/platform_ci/delivery/rc_bundle_feed.py holds its deferred bundle feed). Activation prerequisites, none established and none created here: (1) a TRIGGER after the publisher finished (the publisher publishes after the release run ends, so no step in that run can wait for it): a workflow_dispatch or schedule trigger plus whoever dispatches holding Actions write, or the operator running it; (2) the write identity may fast-forward push the protected target branch; (3) the forge server-signs PUT contents commits and reports verification.verified; (4) the job token has contents: write (as release-tag) and READ access to the private Maven registry and the generic completion namespace (the publisher uses a separate reader-token; whether the job token can read them is unconfirmed); (5) the release run's platform revision equals the running platform revision (platform_revisions is a single value here, where the publisher's comes from operator enrollment); (6) the registry owner is taken as the repository owner, which enrollment.py enforces for the publisher's destinations.
Build inputs in the manifest
Each target uploads verified-<target name> containing manifest.json (schema 1: source revision, platform revision, run id and attempt, target, component, platform, build_succeeded, each file's kind, name, path, size and SHA-256, and after a successful build build_inputs) and a digest over all fields including build_inputs: source_tree (git tree of the source revision, from prepare), input_digest, toolchain (tool version plus the digest-pinned runtime image or attested runner), platform, config_digest (the whole planned target) and version (read from the component's pom.xml or package.json; unversioned for other tools, undeclared when no literal is declared). A successful build cannot be packaged without the tree id.
Reuse before verification (the no-duplicate-build rule)
The reuse step of prepare runs only pinned platform code with the job's read-only token (job permissions contents: read, actions: read; it never checks out or runs the source). It applies only to a push whose verification is required (default branch, release channel branch, selected branch or tag); a pull request is the original verification and manual or callable runs always verify. It looks for ONE earlier successful run (a push, or the pull request that verified before the merge) that proves, independently of what its artifacts claim: every job succeeded and every retained bundle's manifest and file hashes verify; the run was executed by the canonical pinned workflow, which the forge shows and the artifact cannot (the caller file the run names, read at the run's own head commit, is trigger-only with one job whose uses is exactly the full-SHA self:<owner>/<repo>/.gitea/scoped_workflows/<file>@<sha> reference that THIS run is executing under, read the same way; a custom workflow with steps, more jobs, inputs, secrets or another reference is rejected however well its bundles verify), and each manifest's platform revision agrees; its recorded build_inputs equal the inputs recomputed here from the forge (tree, declared version at this commit, the plan's toolchain, runtime image and commands); the run's head is an ancestor of this commit (bounded commit listing); and the built commit has an identical tree. Every target must be covered by that one run. If so, verify and verify-native skip (their matrices are empty and their flags false), the evidence is a job output, and the report requires it. If anything is missing, changed, unreachable or unproven, the matrices stay as planned and exactly one real verification runs. Reuse replaces only the redundant rebuild: the secret scan (a check of this exact revision's history) and preparation always run, and a failure of either fails the run.
The report re-derives each target's required build inputs from the plan and rejects evidence that is for another revision or tree, covers different targets, lacks any of the five recorded checks, or has inputs that differ. The independent verification itself happens in the reuse step (the report has no artifact access), and the publisher repeats it. A pull request run built on its merge commit is accepted when its recorded tree equals the release tree and its head is an ancestor; build_sha (the commit that was built) is always kept apart from the release SHA. Live qualification must show what commit a pull request run's checkout builds.
What the publisher does for one enrollment
- Verifies the repository (id, name, not fork or archived) and lists its protected branches; the heads are the release SHAs. Nothing about branches comes from the enrollment.
- Reads
.ci.yamlat each head and derives the publications for the channels that branch makes eligible; a branch without.ci.yamlor publications requests nothing. - Finds tested bytes with the same selection code as the reuse step. The release commit's own push run is first if it succeeded; a failed run is never replaced by an older one and an unfinished run publishes nothing. If that run retained no bundles (it reused), earlier completed push and pull request runs follow, newest first, within
max_runs_scanned, each proving equality on its own. - Checks every request against the operator's bindings (identity, destination, format, channel) and the channel's versioning rule, rechecks the branch head, and only then publishes through the frozen primitives with independent read-back, writes a receipt, and pins container digests in one signed GitOps commit. Receipts and results keep
build_shaandrelease_shaseparate and always statedeployment_verified: false.
An unsupported format, destination, target kind or binding fails explicitly (UnsupportedFormatError, AuthorizationError, EnrollmentError); there is no fallback. Without --publish the publisher only inspects and writes nothing.
Operator enrollment (authorization data, never from a repository)
One JSON file per repository in a trusted read-only mount: schema, company (kof|qqq), repository, repository_id, workflow_references (the full-SHA self:<owner>/<repo>/.gitea/scoped_workflows/<file>@<sha> references whose callers the publisher trusts; an empty set trusts no run), platform_revisions (full hashes the publisher may release from), max_artifact_bytes, max_runs_scanned, and destinations: each a logical destination, the formats and channels it may carry (an authorization allow-list), a target (package owner, image, or gitops repository/branch/file/images) kept inside the company, and optional require_provenance and require_signature_verification. Branches, channels, versioning, publications and the required job names are not here: the first four come from the repository's .ci.yaml and the job names from platform_ci/pipeline_contract.py. The repository name appears only as authorization and provenance identity (the enrollment's identity, and the workflow's CI_REPOSITORY for the read-only client's API scope); a test confines it to those uses.
Local quality commands
Run from the candidate/ root:
uv run --with pyyaml==6.0.3 python -m unittest discover -s tests -t . -v
uv run --with ruff ruff format --check
uv run --with ruff ruff check
uv run --with pyright --with pyyaml --with types-pyyaml pyright
Requires Python 3.13+ and PyYAML 6.0.3 (requirements.txt). Config is in ruff.toml and pyrightconfig.json (strict). The tests start no application build.
Promotion
Promotion pins one pair: the source of the helper (platform_ci/ plus runtime/catalog.yaml) and the workflow that names it. PLATFORM_CI_REVISION in the workflow holds the full helper commit hash, so promotion takes two reviewed changes because a revision cannot contain its own hash. Rollback restores the previous pair. Control image digests in prepare and report must match. Toolchain digests live only in runtime/catalog.yaml. Adding a toolchain version means one new image and one catalog entry, with no code change.
Root will publish the source with the forge-verified API signer, because the local SSH key is unknown to the forge. Old PR 64 is a draft and is not activated. QRun pull PR 256 has a verified signer and the same bytes as 255; it is not merged yet.
Live qualification of delivery (not yet run)
Everything above is verified only against local synthetic transports (tests/test_delivery_publisher.py, a real git history, real packaging, in-memory registries). No live forge, registry, GitOps or credential was used. Root runs, in order, with the exact enrollment and credential mounts of the existing publisher (replace <...>):
# 1. The workflow produces manifests with build_inputs: run the shared pipeline on a fixture repository whose .ci.yaml declares a maven or node publication, then download verified-<target> and check manifest.json has build_inputs.
# 2. Inspection only, writes nothing, prints status eligible-inspection-only and the selected run:
python3 -P -m platform_ci.delivery --enrollment <enrollment.json> --credentials <credentials-dir> --spool <scratch-dir>
# 3. Inspection of one explicit run (a reuse test: pick an earlier successful run of an identical tree):
python3 -P -m platform_ci.delivery --enrollment <enrollment.json> --credentials <credentials-dir> --spool <scratch-dir> --run <run-id>
# 4. Publication to the fixture destination only, after root authorizes that exact destination and fixture:
python3 -P -m platform_ci.delivery --enrollment <enrollment.json> --credentials <credentials-dir> --receipts <receipts-dir> --spool <scratch-dir> --publish
# 5. Replay step 4 unchanged: it must republish identical bytes and report the same receipt.
Observe live, and record in the evidence: that the caller file named by run.path (before @) is readable at the run's head commit and its uses is the expected full-SHA reference (it becomes workflow_references; the caller needs actions: read permission for the reuse step); that git/commits/<sha> returns commit.tree.sha; that commits?sha=<sha>&page=<n>&limit=50 lists the full ancestry in pages; that actions/runs/<id>/artifacts lists verified-<target> with a workflow_run object and actions/artifacts/<id>/zip serves the manifest at the archive root; that contents reads at a commit return size, encoding and content.
Remaining work
-
Reuse live qualification (unrun): that the job token (
github.token) can read this repository's runs, jobs, artifacts and commits withactions: read, and thatgithub.event.repository.idis populated; what a pull request run's checkout builds (head or merge commit);commits?sha=paging for ancestry; the artifact zip layout. Until then the reuse step falls back to a normal build and says why in its note. -
Scoped production identity is implemented from two existing server-computed mechanisms (see
platform_ci/delivery/scoped_identity.py): the workflow-file run listing withscoped_workflow_source_repo_id=<canonical source id>(only a run executed from that scoped source appears; the same listing without it holds only repository-level runs and answers 404 for a scoped-only file), and the prepare job's server-evaluated namePrepare plan [workflow ${{ github.workflow_sha }}], which records the source commit. A run is trusted when its (source id, file, commit) triple is the current run's; the required jobs recognize that exact suffix and no job was added. The canonical source id (PLATFORM_CI_SOURCE_REPOSITORY_ID) is an identity constant of the shared workflow, never routing. A repository-level caller is trusted only after the run is shown to be in the unscoped listing, so a consumer's same-named file cannot vouch for a scoped run. Root's live proofs (run 4424 listed only with source 3; a wrong source listed nothing; the unscoped listing answered 404; run 4426's job name equalled its workflow commit) show the mechanisms and the job token'sactions: readpermission work. Still pending live: the canonical scoped values after registration, from inside a real scoped run. Any failure, 404 or missing proof only means a normal build. Reuse across a workflow promotion is refused (a changed workflow commit is a changed input). -
Partial reuse is not implemented on purpose: all targets must come from one earlier run, otherwise everything verifies once.
-
Delivery wiring gaps: signature verification of native provenance (no verifier is injected, so a binding with
require_signature_verificationfails closed; no provenance file is produced by the workflow yet); release records and tags (the frozenrelease_clientflow for main and hotfix is not ported); a push-time or scheduled invocation of the publisher (root owns the CronJob, mounts and enrollments); GitOps ordering across older runs; npmdist-tagis the channel name, orlatestforstable. -
Tests for adapters, reuse selection, manifests, provenance and GitOps text live in
delivery-workspace/tests(pytest) and run against this package; they are not yet ported to unittest here. -
QRun live fixture.
-
Qualification of scoped registration and the manual, schedule, pull request, tag and skip paths.
-
Required shared security checks.
-
Live qualification of everything in "Native capabilities and runtime qualification": a Gitea evaluation of
runs-on: ${{ matrix.runner }}with anifon a matrix-output flag, a host job on a real Windows and macOS runner, the unexecuted Maven settings parity diff, and each runtime in the table. -
Image builds (container build capability) and delivery.
-
Cutover of all fleet configuration.