From c7b8461f48409e5116a07353182502c2c24b54b3 Mon Sep 17 00:00:00 2001 From: jonahgabriel Date: Tue, 15 Sep 2026 21:17:11 -0400 Subject: [PATCH 1/2] feat(OMN-17057): a sibling merge delivers its own named revision to staging An omnimarket merge could not cause a delivery at all. It rode along on whatever the next omnibase_infra push happened to build, and that candidate clones every sibling at branch dev at that instant -- so the sibling revision in the image was never CHOSEN, could not be predicted, and no sibling merge could prove delivery of its own sha. Measured on delivery run 35013603978: the candidate vendored omnimarket 3038d1da, simply where that branch pointed 18 minutes after an unrelated commit merged here. Twelve omnimarket commits had landed since by the time this was read. The primitive already existed and CI was not reaching it. deploy_source_ref.py has taken --ref plus --fallback-ref since OMN-17135, resolving the primary in whichever repository carries it and taking the fallback everywhere else. The candidate build passed the same branch name as both. Trigger. runtime-rebuild-trigger-reusable.yml gains a terminal job that needs the existing convergence job, so it fires only after the .201 dev lane was proven to vendor the merged sibling sha and its compose-dev lab-pass receipt was emitted. It sends one repository_dispatch carrying that repo and revision. Because it lives in the reusable, every repository the runtime image vendors -- omnimarket, omnibase_core, omnibase_compat -- acquires the path with no per-repo code. omniclaude is deliberately outside the family: it is not vendored into the image, so there is nothing for it to pin. Naming. build-workspace-candidate-runtime.yml gains pinned_repo and pinned_revision, both empty by default so the push path is unchanged. When set, DEPLOY_REF becomes the commit and DEPLOY_SIBLING_FALLBACK_REF the branch. The pinned commit is fetched by sha into its clone, and after staging the vendored sha in sibling-vcs-provenance.json is compared against the requested one -- the same file the image bakes and the staging deploy later reads out of a running pod (OMN-17926). The build manifest records pinned_sibling so a chosen revision is distinguishable from a branch that drifted to the same commit. Gating. lab-pass-gate keeps asserting a PASS receipt for this repo's sha, and when a pin is present additionally reads the sibling repository's own compose-dev receipt with a token scoped actions:read on that one repository. dispatch-to-staging already needs lab-pass-gate under default success(), so a missing, unreadable, malformed, name/payload-disagreeing or FAIL sibling receipt leaves the announcement unfired. Fail-closed throughout: a half-specified pin, a non-sha revision, a repository the build does not stage, a commit its source does not contain, a vendored sha that disagrees with the request, an absent sibling receipt and absent announcement credentials are each a refusal. No force input, no skip input and no if: override is added anywhere, and a test over the parsed inputs refuses one later. Concurrency. The delivery group gains a segment naming the pinned repo. github.ref on a repository_dispatch resolves to the default branch, so without it the next ordinary push would silently cancel a sibling's delivery. The announcement payload carries pinned_sibling to omninode_infra, which already resolves what each running image actually vendors; what it never had was the other half of that comparison -- which revision was intended. Tests parse the workflow graph and pin properties, not spellings. Positive control: 12 of the 16 fail against the pre-change workflows, and the 4 that pass are invariants that were already true. --- .../build-workspace-candidate-runtime.yml | 191 +++++++++- .../deliver-dev-candidate-to-staging.yml | 112 +++++- .../runtime-rebuild-trigger-reusable.yml | 124 +++++++ ...amed_revision_sibling_delivery_omn17057.py | 333 ++++++++++++++++++ 4 files changed, 757 insertions(+), 3 deletions(-) create mode 100644 tests/ci/test_named_revision_sibling_delivery_omn17057.py diff --git a/.github/workflows/build-workspace-candidate-runtime.yml b/.github/workflows/build-workspace-candidate-runtime.yml index 33bf07c488..471c604d97 100644 --- a/.github/workflows/build-workspace-candidate-runtime.yml +++ b/.github/workflows/build-workspace-candidate-runtime.yml @@ -45,6 +45,45 @@ on: required: false default: "dev" type: string + pinned_repo: + # OMN-17057. `sibling_ref` is ONE value applied to every sibling, which + # is why its own comments insist it stay a branch name: a commit SHA + # from omnimarket does not exist in omnibase_core, so cloning all four + # at it fails outright. That constraint is real, and it is also the + # whole defect -- it means the vendored sibling revision is never + # CHOSEN. Whichever commit each repo's `dev` happens to point at when + # some OTHER repository's merge fires a rebuild is the one the lane and + # then staging run, and no sibling merge can cause or prove delivery of + # its own sha. + # + # These two inputs name ONE repository and ONE revision. Everything + # else keeps taking `sibling_ref`, so the axis `sibling_ref` was + # protecting is untouched. The mechanism underneath already exists and + # has since OMN-17135: `deploy_source_ref.py` takes `--ref` plus + # `--fallback-ref` and resolves the primary in whichever repository + # carries it while taking the fallback everywhere else, recording + # `fallback_from`. What CI did until now was pass the same branch name + # as both, throwing that capability away. + # + # Family-general by construction: `omnimarket`, `omnibase_core`, + # `omnibase_compat` and `omnibase_spi` are all staged by the same loop, + # so each names itself here with no per-repo code anywhere. `omniclaude` + # is deliberately outside the family -- it is not in SIBLING_REPOS and + # is not vendored into the image, so there is nothing for it to pin. + description: >- + Sibling repository whose revision is pinned exactly (e.g. omnimarket). + Empty means every sibling takes sibling_ref, which is the pre-OMN-17057 + behaviour. + required: false + default: "" + type: string + pinned_revision: + description: >- + 40-character commit SHA in pinned_repo. Refused unless pinned_repo is + also set, and refused if the staged tree does not end up at it. + required: false + default: "" + type: string no-cache: # Same bare-boolean rule as the workflow_call block below. GitHub # tolerates a quoted default on a dispatch-only input, which is exactly @@ -61,6 +100,45 @@ on: required: false default: "dev" type: string + pinned_repo: + # OMN-17057. `sibling_ref` is ONE value applied to every sibling, which + # is why its own comments insist it stay a branch name: a commit SHA + # from omnimarket does not exist in omnibase_core, so cloning all four + # at it fails outright. That constraint is real, and it is also the + # whole defect -- it means the vendored sibling revision is never + # CHOSEN. Whichever commit each repo's `dev` happens to point at when + # some OTHER repository's merge fires a rebuild is the one the lane and + # then staging run, and no sibling merge can cause or prove delivery of + # its own sha. + # + # These two inputs name ONE repository and ONE revision. Everything + # else keeps taking `sibling_ref`, so the axis `sibling_ref` was + # protecting is untouched. The mechanism underneath already exists and + # has since OMN-17135: `deploy_source_ref.py` takes `--ref` plus + # `--fallback-ref` and resolves the primary in whichever repository + # carries it while taking the fallback everywhere else, recording + # `fallback_from`. What CI did until now was pass the same branch name + # as both, throwing that capability away. + # + # Family-general by construction: `omnimarket`, `omnibase_core`, + # `omnibase_compat` and `omnibase_spi` are all staged by the same loop, + # so each names itself here with no per-repo code anywhere. `omniclaude` + # is deliberately outside the family -- it is not in SIBLING_REPOS and + # is not vendored into the image, so there is nothing for it to pin. + description: >- + Sibling repository whose revision is pinned exactly (e.g. omnimarket). + Empty means every sibling takes sibling_ref, which is the pre-OMN-17057 + behaviour. + required: false + default: "" + type: string + pinned_revision: + description: >- + 40-character commit SHA in pinned_repo. Refused unless pinned_repo is + also set, and refused if the staged tree does not end up at it. + required: false + default: "" + type: string no-cache: # OMN-16906: BARE `false`, never `"false"`. GitHub type-checks this # default while compiling the CALLER's graph, so a quoted default here @@ -144,10 +222,53 @@ jobs: # branch vendor the dev-HEAD siblings. Each clone is shallow at the ref but # carries full enough history for HEAD SHA / branch / status probes that # stage_workspace.sh records as per-repo VCS provenance (OMN-13030). + # OMN-17057: refuse a half-specified pin before anything is cloned. A + # revision with no repository names nothing; a repository with no + # revision would silently fall through to the branch and produce a build + # that LOOKS pinned. Both are refusals, not warnings. + - name: Validate the sibling pin + env: + PINNED_REPO: ${{ inputs.pinned_repo }} + PINNED_REVISION: ${{ inputs.pinned_revision }} + SIBLING_REPOS_ALL: ${{ env.SIBLING_REPOS }} + run: | + set -euo pipefail + if [ -z "${PINNED_REPO}" ] && [ -z "${PINNED_REVISION}" ]; then + echo "no sibling pin requested; every sibling takes sibling_ref" + exit 0 + fi + if [ -z "${PINNED_REPO}" ] || [ -z "${PINNED_REVISION}" ]; then + echo "::error::pinned_repo='${PINNED_REPO}' and" \ + "pinned_revision='${PINNED_REVISION}' must be set together." \ + "A half-specified pin would build from the branch while reading" \ + "as pinned." + exit 1 + fi + if ! [[ "${PINNED_REVISION}" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::pinned_revision='${PINNED_REVISION}' is not a" \ + "40-character lowercase commit sha. A branch or tag is not a" \ + "named revision -- that is the defect this input closes." + exit 1 + fi + FOUND=0 + for repo in ${SIBLING_REPOS_ALL}; do + [ "${repo}" = "${PINNED_REPO}" ] && FOUND=1 + done + if [ "${FOUND}" -ne 1 ]; then + echo "::error::pinned_repo='${PINNED_REPO}' is not one of the" \ + "repositories this image vendors (${SIBLING_REPOS_ALL})." \ + "Pinning a repository the build does not stage would change" \ + "nothing while reading as a pin." + exit 1 + fi + echo "pinning ${PINNED_REPO} at ${PINNED_REVISION}; other siblings take sibling_ref" + - name: Stage sibling repos at the requested ref id: stage-siblings env: SIBLING_REF: ${{ inputs.sibling_ref }} + PINNED_REPO: ${{ inputs.pinned_repo }} + PINNED_REVISION: ${{ inputs.pinned_revision }} run: | set -euo pipefail WORKSPACE_ROOT="${RUNNER_TEMP}/omni_workspace" @@ -164,6 +285,23 @@ jobs: git clone --filter=blob:none --branch "${SIBLING_REF}" \ "https://github.com/OmniNode-ai/${repo}.git" \ "${WORKSPACE_ROOT}/${repo}" + # OMN-17057: the pinned commit is a MERGE commit on the branch just + # cloned, so it is already reachable -- but only if the branch tip + # is at or after it. A pin that raced its own merge landing on the + # remote would otherwise surface much later, inside RT-1, as an + # unresolvable ref with no clue which repository moved. Fetch it by + # sha explicitly and fail here, where the message can name the repo + # and the commit. + if [ "${repo}" = "${PINNED_REPO:-}" ]; then + if ! git -C "${WORKSPACE_ROOT}/${repo}" \ + fetch --filter=blob:none origin "${PINNED_REVISION}"; then + echo "::error::${repo} does not carry ${PINNED_REVISION}." \ + "Refusing to build a candidate that claims a revision its" \ + "source does not contain." + exit 1 + fi + echo " ${repo} carries the pinned revision ${PINNED_REVISION}" + fi echo " ${repo} HEAD: $(git -C "${WORKSPACE_ROOT}/${repo}" rev-parse HEAD)" echo "::endgroup::" done @@ -188,7 +326,18 @@ jobs: # above were cloned fresh AT sibling_ref, so arming RT-1 with the same # ref makes the checkout a no-op and turns the vendored SHA into an # asserted fact instead of an unfalsifiable claim. - DEPLOY_REF: ${{ inputs.sibling_ref }} + # + # OMN-17057: when a pin is requested, RT-1's primary ref becomes the + # pinned COMMIT and sibling_ref becomes the fallback. That is exactly + # the pair deploy_source_ref.py was built for in OMN-17135 -- the + # primary resolves in the one repository that carries it, every other + # repository takes the fallback and records `fallback_from`. Both + # values are still refs, so RT-1's own refusals (unresolvable ref, + # vendored SHA not equal to the expected one) apply unchanged. + DEPLOY_REF: ${{ inputs.pinned_revision || inputs.sibling_ref }} + DEPLOY_SIBLING_FALLBACK_REF: origin/${{ inputs.sibling_ref }} + PINNED_REPO: ${{ inputs.pinned_repo }} + PINNED_REVISION: ${{ inputs.pinned_revision }} run: | set -euo pipefail bash scripts/runtime_build/stage_workspace.sh @@ -197,6 +346,28 @@ jobs: echo "Per-repo VCS provenance:" cat workspace/sibling-vcs-provenance.json + # OMN-17057: the pin is a VERIFIED fact or it is not a pin. + # stage_workspace.sh already refuses a vendored SHA that disagrees + # with what RT-1 checked out, but that assertion is about RT-1's own + # bookkeeping. This one is about the provenance file the image will + # BAKE and the staging deploy will later read out of a running pod + # (OMN-17926) -- the artefact the ticket's done-proof probe names. If + # those two ever diverge, the image would ship a claim nothing + # checked. + if [ -n "${PINNED_REPO}" ]; then + VENDORED="$(jq -r --arg repo "${PINNED_REPO}" \ + '.siblings[$repo].vcs_ref // ""' \ + workspace/sibling-vcs-provenance.json)" + if [ "${VENDORED}" != "${PINNED_REVISION}" ]; then + echo "::error::${PINNED_REPO} was pinned at ${PINNED_REVISION} but" \ + "the staged tree vendors '${VENDORED}'. Refusing to publish a" \ + "candidate whose baked provenance would not name the revision" \ + "it was asked to carry." + exit 1 + fi + echo "verified: ${PINNED_REPO} vendored at ${PINNED_REVISION}" + fi + - name: Set up Python for AWS CLI uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: @@ -413,6 +584,12 @@ jobs: id: manifest env: SIBLING_REF: ${{ inputs.sibling_ref }} + # OMN-17057: which sibling revision was CHOSEN, as distinct from + # per_sibling_vcs_provenance, which records what each tree happened + # to be at. A reader has to be able to tell a deliberate pin from a + # branch that drifted to the same commit. + PINNED_REPO: ${{ inputs.pinned_repo }} + PINNED_REVISION: ${{ inputs.pinned_revision }} run: | set -euo pipefail mkdir -p ops/digests @@ -425,6 +602,8 @@ jobs: --arg tag "${{ steps.meta.outputs.candidate_tag }}" \ --arg date "${{ steps.timestamp.outputs.build_date }}" \ --arg sibling_ref "${SIBLING_REF}" \ + --arg pinned_repo "${PINNED_REPO}" \ + --arg pinned_revision "${PINNED_REVISION}" \ --arg run_id "$GITHUB_RUN_ID" \ --argjson siblings "$SIBLINGS" \ '{ @@ -439,6 +618,9 @@ jobs: image_ref: $ref, image_tag: $tag, sibling_ref: $sibling_ref, + pinned_sibling: (if $pinned_repo == "" then null + else {repo: $pinned_repo, revision: $pinned_revision} + end), per_sibling_vcs_provenance: $siblings.siblings, build_date: $date, workflow_run_id: $run_id, @@ -458,6 +640,8 @@ jobs: if: always() env: SIBLING_REF: ${{ inputs.sibling_ref }} + PINNED_REPO: ${{ inputs.pinned_repo }} + PINNED_REVISION: ${{ inputs.pinned_revision }} run: | { echo "# Workspace Stability-Candidate Build (OMN-13656)" @@ -469,6 +653,11 @@ jobs: echo "| non_main_lineage | true |" echo "| pinnable lanes | dev, stability-test (NEVER prod) |" echo "| sibling ref | \`${SIBLING_REF}\` |" + if [ -n "${PINNED_REPO}" ]; then + echo "| pinned sibling | \`${PINNED_REPO}@${PINNED_REVISION}\` |" + else + echo "| pinned sibling | none (every sibling at \`${SIBLING_REF}\`) |" + fi echo "| digest | \`${{ steps.resolve-digest.outputs.digest }}\` |" echo "| candidate tag | \`${{ steps.meta.outputs.candidate_tag }}\` |" echo "" diff --git a/.github/workflows/deliver-dev-candidate-to-staging.yml b/.github/workflows/deliver-dev-candidate-to-staging.yml index 048e0fb03b..fba8deb107 100644 --- a/.github/workflows/deliver-dev-candidate-to-staging.yml +++ b/.github/workflows/deliver-dev-candidate-to-staging.yml @@ -111,6 +111,24 @@ on: required: false default: "dev" type: string + # OMN-17057: a SIBLING repository's merge, announcing its own revision. + # + # Until this existed, an omnimarket (or omnibase_core, or omnibase_compat) + # merge could not cause a delivery at all. It rode along on whatever the next + # push to THIS repository happened to build, and the candidate cloned it at + # branch `dev` at that instant -- so the vendored sibling revision was never + # chosen and could not be predicted. Measured on delivery run 35013603978: + # the candidate vendored omnimarket 3038d1da, which is simply where that + # branch was pointing 18 minutes after an unrelated commit merged here. + # + # This is a `repository_dispatch` and not a `workflow_dispatch` on purpose. + # A bare `workflow_dispatch` of a delivery workflow is a person or an agent + # asserting a delivery; this carries the sibling's merge sha, is sent only by + # the reusable rebuild trigger AFTER that repository's compose-dev lab pass + # converged on that sha, and is gated again on this side against the sibling + # repository's own receipt before anything is announced. + repository_dispatch: + types: [sibling-dev-candidate] concurrency: # A candidate build runs up to 75 minutes. Under ordinary merge traffic, @@ -118,7 +136,14 @@ concurrency: # superseded run would be delivering code that is already stale. Collapsing # the burst makes the delivered candidate dev HEAD by construction; the # cancelled runs' commits are contained in the surviving one. - group: deliver-dev-candidate-${{ github.ref }} + # OMN-17057: `github.ref` on a repository_dispatch resolves to the default + # branch, so without the second segment an omnimarket-triggered delivery and + # an ordinary omnibase_infra push delivery would share one group -- and the + # next push would cancel the sibling's delivery silently, which is the exact + # class of invisible non-delivery this workflow exists to end. Two pinned + # deliveries of the SAME sibling still collapse, which is wanted: the later + # revision contains the earlier one. + group: deliver-dev-candidate-${{ github.ref }}-${{ github.event.client_payload.pinned_repo || 'self' }} cancel-in-progress: true permissions: @@ -137,6 +162,14 @@ jobs: # a branch name, never this repo's commit SHA. This repo's own revision # comes from the called workflow's default checkout of `github.sha`. sibling_ref: ${{ inputs.sibling_ref || 'dev' }} + # OMN-17057: exactly ONE sibling may be named at an exact commit. Every + # other sibling still takes `sibling_ref` above, so the constraint that + # input's comments describe -- one value cannot be a commit in four + # repositories -- is preserved rather than worked around. Empty on a push + # or a manual dispatch, which is the pre-OMN-17057 behaviour byte for + # byte. + pinned_repo: ${{ github.event.client_payload.pinned_repo || '' }} + pinned_revision: ${{ github.event.client_payload.pinned_revision || '' }} build-migrate-bundle: name: Build migration bundle @@ -954,6 +987,54 @@ jobs: --repo "${GITHUB_REPOSITORY}" \ | tee /tmp/lab-pass-gate.txt + # OMN-17057. The gate above asks about THIS repository's sha. On a + # sibling-triggered delivery the revision under test is the sibling's, + # and its receipt lives in the SIBLING's artifact surface -- the reusable + # rebuild trigger runs as that repository's workflow, so + # `upload-artifact` publishes there and this repository's `GITHUB_TOKEN` + # cannot read it. A short-lived App installation token scoped + # `actions: read` on exactly that one repository is the whole additional + # capability this adds, and it is the same App identity the announcement + # job below already uses. + # + # Requested only when a pin is present, so the push path gains nothing. + # Absent App credentials fail the job rather than skipping it, for the + # same reason `dispatch-to-staging` below refuses on them: this check IS + # the premise, and a green run that checked nothing is the failure mode + # rule 24(b) exists to remove. + - name: Obtain a read-only Actions token for the pinned sibling repository + id: sibling-token + if: github.event.client_payload.pinned_repo != '' + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ secrets.ONEXBOT_OCC_APP_ID }} + private-key: ${{ secrets.ONEXBOT_OCC_PRIVATE_KEY }} + owner: OmniNode-ai + repositories: ${{ github.event.client_payload.pinned_repo }} + permission-actions: read + + - name: Read the compose-dev lab-pass receipt for the pinned sibling revision + if: github.event.client_payload.pinned_repo != '' + env: + GH_TOKEN: ${{ steps.sibling-token.outputs.token }} + PINNED_REPO: ${{ github.event.client_payload.pinned_repo }} + PINNED_REVISION: ${{ github.event.client_payload.pinned_revision }} + run: | + set -euo pipefail + # --lane compose-dev and not the default "every lane": the sibling + # repositories run no boot gate and no k3s overlay apply, so asking + # about lanes nothing emits for them would report a missing receipt + # for a lane that never claimed to cover the sha. The compose dev lane + # is the one that IS proven to vendor the sibling revision, by + # check_lane_sibling_revision.py, immediately before the receipt is + # written. + uv run --no-project --with pydantic \ + python3 scripts/ci/lab_pass_receipt.py gate \ + --sha "${PINNED_REVISION}" \ + --repo "OmniNode-ai/${PINNED_REPO}" \ + --lane compose-dev \ + | tee /tmp/lab-pass-gate-sibling.txt + - name: Summarise if: always() run: | @@ -963,6 +1044,15 @@ jobs: echo '```' cat /tmp/lab-pass-gate.txt 2>/dev/null || echo "(the gate produced no output)" echo '```' + if [ -n "${{ github.event.client_payload.pinned_repo }}" ]; then + echo "" + echo "### Sibling lab-pass gate (OMN-17057)" + echo "" + echo '```' + cat /tmp/lab-pass-gate-sibling.txt 2>/dev/null \ + || echo "(the sibling gate produced no output)" + echo '```' + fi } >> "$GITHUB_STEP_SUMMARY" dispatch-to-staging: @@ -1030,6 +1120,16 @@ jobs: # readback can answer "which commit is running", not merely "did the # digest change". SOURCE_SHA: ${{ github.sha }} + # OMN-17057: the sibling revision this candidate was BUILT to carry, + # announced alongside it. The receiving deploy already resolves what + # each running image actually vendors, out of the pod's own baked + # build-provenance.json (OMN-17926's freshness gate reads + # per_repo_vcs_provenance.siblings..vcs_ref). What it has never + # had is the other half of that comparison: which sibling revision + # was INTENDED. Empty on a push delivery, where no sibling was chosen + # and there is therefore nothing honest to claim. + PINNED_REPO: ${{ github.event.client_payload.pinned_repo }} + PINNED_REVISION: ${{ github.event.client_payload.pinned_revision }} run: | set -euo pipefail @@ -1063,13 +1163,18 @@ jobs: --arg migrate "${MIGRATE_IMAGE_REF}" \ --arg repo "${GITHUB_REPOSITORY}" \ --arg sha "${SOURCE_SHA}" \ + --arg pinned_repo "${PINNED_REPO:-}" \ + --arg pinned_revision "${PINNED_REVISION:-}" \ '{ event_type: "omnibase-infra-dev-candidate", client_payload: { runtime_image_ref: $runtime, migrate_image_ref: $migrate, source_repo: $repo, - source_sha: $sha + source_sha: $sha, + pinned_sibling: (if $pinned_repo == "" then null + else {repo: $pinned_repo, revision: $pinned_revision} + end) } }' \ | gh api --method POST repos/OmniNode-ai/omninode_infra/dispatches --input - @@ -1080,6 +1185,9 @@ jobs: echo "| field | value |" echo "| --- | --- |" echo "| source commit | \`${SOURCE_SHA}\` |" + if [ -n "${PINNED_REPO:-}" ]; then + echo "| pinned sibling | \`${PINNED_REPO}@${PINNED_REVISION}\` |" + fi echo "| runtime image | \`${RUNTIME_IMAGE_REF}\` |" echo "| migrate image | \`${MIGRATE_IMAGE_REF}\` |" echo "" diff --git a/.github/workflows/runtime-rebuild-trigger-reusable.yml b/.github/workflows/runtime-rebuild-trigger-reusable.yml index 3a0a9f9e55..579188a713 100644 --- a/.github/workflows/runtime-rebuild-trigger-reusable.yml +++ b/.github/workflows/runtime-rebuild-trigger-reusable.yml @@ -104,6 +104,19 @@ on: required: true KAFKA_SASL_PASSWORD: required: true + # OMN-17057. The announcement job below sends a repository_dispatch to + # omnibase_infra, which is gated on Contents:write on the TARGET + # repository -- the same org App the delivery workflow already uses for + # the same class of cross-repo announcement (OMN-16373). Declared + # `required: false` so an existing caller that has not yet passed them + # keeps compiling; the job itself refuses rather than skipping when they + # turn out to be absent, because in that state the delivery IS the + # deliverable and a green run that announced nothing is the failure this + # whole path exists to remove. + ONEXBOT_OCC_APP_ID: + required: false + ONEXBOT_OCC_PRIVATE_KEY: + required: false jobs: trigger-rebuild: @@ -383,3 +396,114 @@ jobs: path: /tmp/lab-pass-receipt retention-days: 90 if-no-files-found: error + + # OMN-17057 -- the sibling half of rule 24: the lab is where a change first + # runs, and staging is the promotion that follows a lab proof. + # + # WHAT WAS MISSING. `verify-sibling-converged` above proves the `.201` dev + # lane vendors THIS repository's merged sha and writes a compose-dev lab-pass + # receipt for it. Nothing then carried that sha any further. A sibling merge + # reached staging only by riding whatever candidate the NEXT push to + # omnibase_infra happened to build, which clones every sibling at branch + # `dev` at that instant -- so the vendored sibling revision was never chosen, + # could not be predicted, and no sibling merge could prove its own delivery. + # Measured on delivery run 35013603978: the candidate vendored omnimarket + # 3038d1da, simply where that branch pointed 18 minutes after an unrelated + # omnibase_infra commit merged. + # + # WHY IT LIVES HERE AND NOT IN A CALLER. Every repository the runtime image + # vendors -- omnimarket, omnibase_core, omnibase_compat, omnibase_spi -- + # calls this one file, so each acquires the delivery path with no per-repo + # code and no per-repo configuration. A copy in each caller is how the org + # ended up with two divergent trigger_rebuild_on_merge.py, which this file's + # own header records. + # + # WHY IT NEEDS the convergence job. `needs:` plus GitHub's default success() + # means the announcement fires only after the lane was PROVEN to vendor the + # merged sha and the receipt for it was emitted. A convergence that fails, is + # skipped or is cancelled leaves this unfired, so "skipped" can never read as + # "delivered". The receiving side then reads that receipt again from this + # repository's artifact surface before announcing anything to staging, so the + # premise is checked on both sides of the boundary rather than trusted across + # it. + deliver-sibling-candidate: + name: Announce the merged revision for delivery to staging + needs: [trigger-rebuild, verify-sibling-converged] + runs-on: >- + ${{ fromJSON(vars.OMNI_TRUSTED_CI_RUNS_ON_JSON || '["self-hosted","omnibase-ci"]') }} + timeout-minutes: 10 + steps: + - name: Check the announcement credentials are configured + env: + APP_ID: ${{ secrets.ONEXBOT_OCC_APP_ID }} + APP_PRIVATE_KEY: ${{ secrets.ONEXBOT_OCC_PRIVATE_KEY }} + run: | + set -euo pipefail + if [ -z "$APP_ID" ] || [ -z "$APP_PRIVATE_KEY" ]; then + echo "::error::the announcement App credentials are not configured" \ + "for this caller. The lab pass converged and its receipt was" \ + "published, but nothing can carry this revision to the delivery" \ + "workflow -- failing rather than reporting a delivery that did" \ + "not happen." + exit 1 + fi + + - name: Obtain a write token for omnibase_infra + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ secrets.ONEXBOT_OCC_APP_ID }} + private-key: ${{ secrets.ONEXBOT_OCC_PRIVATE_KEY }} + owner: OmniNode-ai + repositories: omnibase_infra + permission-contents: write + + - name: Announce the merged sibling revision to the delivery workflow + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + SOURCE_REPO: ${{ needs.trigger-rebuild.outputs.source_repo }} + SIBLING_SHA: ${{ needs.trigger-rebuild.outputs.sibling_sha }} + run: | + set -euo pipefail + + # Refuse a malformed announcement on the side that can see what + # produced it. The receiving workflow validates this again, because + # it also accepts dispatches this job did not send, but a 40-hex + # check here names the broken producer instead of the consumer. + if ! [[ "${SIBLING_SHA}" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::sibling_sha='${SIBLING_SHA}' is not a 40-character" \ + "lowercase commit sha. A delivery pinned to anything else is the" \ + "floating ref this path replaces." + exit 1 + fi + if [ -z "${SOURCE_REPO}" ]; then + echo "::error::source_repo is empty -- refusing to announce a" \ + "revision with no repository to resolve it in." + exit 1 + fi + + jq -n \ + --arg repo "${SOURCE_REPO}" \ + --arg revision "${SIBLING_SHA}" \ + '{ + event_type: "sibling-dev-candidate", + client_payload: { + pinned_repo: $repo, + pinned_revision: $revision + } + }' \ + | gh api --method POST repos/OmniNode-ai/omnibase_infra/dispatches --input - + + { + echo "### Sibling revision announced for delivery (OMN-17057)" + echo "" + echo "| field | value |" + echo "| --- | --- |" + echo "| pinned repository | \`${SOURCE_REPO}\` |" + echo "| pinned revision | \`${SIBLING_SHA}\` |" + echo "" + echo "omnibase_infra's deliver-dev-candidate-to-staging.yml builds a" + echo "candidate vendoring this exact revision, re-reads this run's" + echo "compose-dev lab-pass receipt for it, and announces the bundle" + echo "to omninode_infra only if both hold." + } >> "$GITHUB_STEP_SUMMARY" diff --git a/tests/ci/test_named_revision_sibling_delivery_omn17057.py b/tests/ci/test_named_revision_sibling_delivery_omn17057.py new file mode 100644 index 0000000000..c815737a75 --- /dev/null +++ b/tests/ci/test_named_revision_sibling_delivery_omn17057.py @@ -0,0 +1,333 @@ +# SPDX-FileCopyrightText: 2025 OmniNode.ai Inc. +# SPDX-License-Identifier: MIT +"""OMN-17057: a sibling merge delivers ITS OWN revision, named, to staging. + +WHAT WAS BROKEN, measured live 2026-09-16. + +``deliver-dev-candidate-to-staging.yml`` fires on a runtime-path push to THIS +repository's ``dev``, and its candidate build clones every sibling at branch +``dev`` at that instant. So a merge to ``omnimarket`` could not cause a +delivery at all -- it rode along on whatever the next ``omnibase_infra`` push +happened to build, and the sibling revision that ended up in the image was +never CHOSEN. + + delivery run 35013603978 (2026-09-15T19:26Z, success) + build manifest per_sibling_vcs_provenance.omnimarket.vcs_ref + = 3038d1dabadea4973707f0228862b4aef4e09242 + +which is simply where ``omnimarket`` ``dev`` was pointing eighteen minutes +after an unrelated commit merged here. Twelve omnimarket commits had landed by +the time this was read. + +The primitive to fix it already existed and CI was not reaching it. +``deploy_source_ref.py`` has taken ``--ref`` plus ``--fallback-ref`` since +OMN-17135, resolving the primary in whichever repository carries it and taking +the fallback everywhere else. The candidate build passed the same branch name +as both. + +WHAT IS PINNED HERE. + +The properties, parsed out of the workflow graph -- never a text match, because +every one of these would survive a rename: + + * the delivery workflow accepts a sibling announcement, and only that type + * the candidate build declares the pin on BOTH of its trigger blocks, with + empty defaults, so the push path is unchanged + * the delivery workflow passes the pin through from the announcement and + from nowhere else + * the sibling receipt is read before anything is announced, and that read is + conditioned on a pin being present + * the concurrency group separates a sibling delivery from a push delivery, + so the next push cannot silently cancel one + * the announcement job needs the convergence job, so an unproven revision is + never announced + * nothing on the new path carries a force, skip or override input + +The last is the one worth stating plainly: rule 24(b) fails delivery closed on +a missing lab receipt, and a path with an override is not fail-closed. A test +that reads the parsed inputs is how "somebody adds one later" becomes a red +test rather than a review catch. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest +import yaml + +REPO_ROOT = Path(__file__).resolve().parents[2] +WORKFLOWS = REPO_ROOT / ".github" / "workflows" + +DELIVER = WORKFLOWS / "deliver-dev-candidate-to-staging.yml" +BUILD = WORKFLOWS / "build-workspace-candidate-runtime.yml" +REUSABLE = WORKFLOWS / "runtime-rebuild-trigger-reusable.yml" + +#: The announcement this path introduces. Named once, here. +SIBLING_EVENT = "sibling-dev-candidate" + +#: Vocabulary that would turn a fail-closed gate into an advisory one. +OVERRIDE_TOKENS = ("force", "skip", "override", "bypass", "ignore_lab") + + +def _load(path: Path) -> dict[str, Any]: + return yaml.safe_load(path.read_text(encoding="utf-8")) + + +def _triggers(doc: dict[str, Any]) -> dict[str, Any]: + """`on:` parses as the boolean True in YAML 1.1 — hence the two lookups.""" + raw = doc.get("on", doc.get(True)) + assert isinstance(raw, dict), "workflow declares no mapping of triggers" + return raw + + +def _steps(doc: dict[str, Any], job: str) -> list[dict[str, Any]]: + return list(doc["jobs"][job].get("steps") or []) + + +def _step_text(step: dict[str, Any]) -> str: + """Everything a step says, flattened — `run:`, `with:`, `env:`, `if:`.""" + return json.dumps(step, sort_keys=True) + + +# --------------------------------------------------------------------------- +# the trigger +# --------------------------------------------------------------------------- +@pytest.mark.unit +def test_the_delivery_workflow_accepts_a_sibling_announcement() -> None: + triggers = _triggers(_load(DELIVER)) + dispatch = triggers.get("repository_dispatch") + assert dispatch, ( + "deliver-dev-candidate-to-staging.yml declares no repository_dispatch, " + "so a sibling merge still has no way to cause a delivery of its own sha" + ) + assert dispatch.get("types") == [SIBLING_EVENT], ( + "the sibling announcement must be the only accepted dispatch type; an " + f"unfiltered repository_dispatch accepts anything, got {dispatch!r}" + ) + + +@pytest.mark.unit +def test_the_delivery_workflow_still_delivers_its_own_pushes() -> None: + """The push path is the one that works today — a regression here is an outage.""" + triggers = _triggers(_load(DELIVER)) + push = triggers.get("push") or {} + assert push.get("branches") == ["dev"], push + + +# --------------------------------------------------------------------------- +# the pin, on the candidate build +# --------------------------------------------------------------------------- +@pytest.mark.unit +@pytest.mark.parametrize("trigger", ["workflow_dispatch", "workflow_call"]) +def test_the_candidate_build_declares_the_pin_with_an_empty_default( + trigger: str, +) -> None: + """Empty defaults are what keep the push path byte-identical in behaviour.""" + inputs = _triggers(_load(BUILD))[trigger]["inputs"] + for name in ("pinned_repo", "pinned_revision"): + assert name in inputs, f"{trigger} does not declare {name}" + declared = inputs[name] + assert declared.get("type") == "string", declared + assert declared.get("required") is False, declared + assert declared.get("default") == "", ( + f"{trigger}.{name} defaults to {declared.get('default')!r}; a " + "non-empty default would silently pin every push-triggered build" + ) + + +@pytest.mark.unit +def test_sibling_ref_is_unchanged_so_the_branch_axis_is_preserved() -> None: + """The pin ADDS an axis. Repurposing sibling_ref would break the other three.""" + inputs = _triggers(_load(BUILD))["workflow_call"]["inputs"] + assert inputs["sibling_ref"]["default"] == "dev", inputs["sibling_ref"] + + +@pytest.mark.unit +def test_the_pin_reaches_the_staging_step_as_the_primary_ref() -> None: + """The whole point: DEPLOY_REF must become the commit, not the branch.""" + doc = _load(BUILD) + staging = [ + s + for s in _steps(doc, "build-workspace-candidate") + if "stage_workspace.sh" in str(s.get("run") or "") + ] + assert staging, "the candidate build no longer runs stage_workspace.sh" + env = staging[0].get("env") or {} + deploy_ref = str(env.get("DEPLOY_REF", "")) + assert "pinned_revision" in deploy_ref, ( + f"DEPLOY_REF is {deploy_ref!r}; with no pinned_revision in it the " + "staged sibling tree is still resolved from a branch" + ) + assert "sibling_ref" in deploy_ref, ( + f"DEPLOY_REF is {deploy_ref!r}; it must fall back to sibling_ref so an " + "unpinned push build is unchanged" + ) + fallback = str(env.get("DEPLOY_SIBLING_FALLBACK_REF", "")) + assert "sibling_ref" in fallback, ( + f"DEPLOY_SIBLING_FALLBACK_REF is {fallback!r}; the repositories that " + "are NOT pinned must still resolve from the branch" + ) + + +@pytest.mark.unit +def test_the_candidate_build_verifies_what_it_actually_vendored() -> None: + """A pin nobody checks is a claim, and the image bakes the claim.""" + doc = _load(BUILD) + body = " ".join( + str(s.get("run") or "") for s in _steps(doc, "build-workspace-candidate") + ) + assert "sibling-vcs-provenance.json" in body + assert "PINNED_REVISION" in body, ( + "no step compares the staged tree against the requested revision, so a " + "candidate could ship provenance naming a commit it does not contain" + ) + + +@pytest.mark.unit +def test_the_build_manifest_records_which_revision_was_chosen() -> None: + """A deliberate pin must be distinguishable from a branch that drifted there.""" + doc = _load(BUILD) + manifest = [ + s + for s in _steps(doc, "build-workspace-candidate") + if "build-manifest.json" in str(s.get("run") or "") + ] + assert manifest, "the candidate build no longer writes a build manifest" + assert "pinned_sibling" in str(manifest[0]["run"]), ( + "the manifest records per_sibling_vcs_provenance but not the pin, so a " + "reader cannot tell a chosen revision from an incidental one" + ) + + +# --------------------------------------------------------------------------- +# the gate +# --------------------------------------------------------------------------- +@pytest.mark.unit +def test_the_delivery_passes_the_pin_from_the_announcement_only() -> None: + doc = _load(DELIVER) + with_block = doc["jobs"]["build-runtime-candidate"]["with"] + for name in ("pinned_repo", "pinned_revision"): + expression = str(with_block[name]) + assert "client_payload" in expression, ( + f"{name} is passed as {expression!r}; a pin sourced from anywhere " + "but the announcement is a hand-asserted delivery" + ) + + +@pytest.mark.unit +def test_the_sibling_receipt_is_read_before_anything_is_announced() -> None: + doc = _load(DELIVER) + gate_steps = _steps(doc, "lab-pass-gate") + sibling = [ + s + for s in gate_steps + if "lab_pass_receipt.py gate" in " ".join(str(s.get("run") or "").split()) + and "client_payload" in _step_text(s) + ] + assert sibling, ( + "lab-pass-gate never reads the sibling repository's receipt, so a " + "pinned revision could be delivered having been exercised nowhere" + ) + step = sibling[0] + assert "pinned_repo" in str(step.get("if", "")), ( + "the sibling read is unconditional; on a push delivery there is no " + "sibling sha to ask about and it would fail every ordinary merge" + ) + assert "compose-dev" in str(step["run"]), ( + "the sibling read must name the lane that actually covers the sha; " + "asking about lanes nothing emits for a sibling reports a missing " + "receipt for a lane that never claimed to cover it" + ) + + +@pytest.mark.unit +def test_the_announcement_still_depends_on_the_lab_gate() -> None: + doc = _load(DELIVER) + dispatch = doc["jobs"]["dispatch-to-staging"] + assert "lab-pass-gate" in dispatch["needs"], dispatch["needs"] + assert "if" not in dispatch, ( + "dispatch-to-staging carries an `if:`; GitHub's default success() is " + "what makes a failed, skipped or cancelled gate leave it unfired" + ) + + +@pytest.mark.unit +def test_a_sibling_delivery_is_not_cancelled_by_the_next_push() -> None: + doc = _load(DELIVER) + group = str(doc["concurrency"]["group"]) + assert "pinned_repo" in group, ( + f"the concurrency group is {group!r}. On a repository_dispatch " + "github.ref resolves to the default branch, so without a segment " + "naming the pinned repo the next ordinary push cancels the sibling's " + "delivery silently -- the invisible non-delivery this path removes" + ) + + +@pytest.mark.unit +def test_nothing_on_the_new_path_carries_an_override() -> None: + """Rule 24(b) is fail-closed; an override input is how that stops being true.""" + for path in (DELIVER, BUILD, REUSABLE): + doc = _load(path) + for trigger, block in _triggers(doc).items(): + if not isinstance(block, dict): + continue + for name in block.get("inputs") or {}: + assert not any(token in name.lower() for token in OVERRIDE_TOKENS), ( + f"{path.name} {trigger} declares input {name!r}, which reads " + "as an override of a fail-closed gate" + ) + + +# --------------------------------------------------------------------------- +# the announcement +# --------------------------------------------------------------------------- +@pytest.mark.unit +def test_the_reusable_announces_only_after_convergence() -> None: + doc = _load(REUSABLE) + jobs = doc["jobs"] + announce = [name for name in jobs if "deliver" in name] + assert announce, ( + "the sibling rebuild trigger declares no delivery job, so a sibling " + "merge's proven revision goes nowhere" + ) + job = jobs[announce[0]] + assert "verify-sibling-converged" in job["needs"], job["needs"] + assert "if" not in job, ( + "the announcement carries an `if:`; default success() is what makes a " + "failed or skipped convergence leave it unfired" + ) + + +@pytest.mark.unit +def test_the_announcement_names_the_merged_sha_and_refuses_anything_else() -> None: + doc = _load(REUSABLE) + job = next(j for name, j in doc["jobs"].items() if "deliver" in name) + body = " ".join(str(s.get("run") or "") for s in job["steps"]) + assert SIBLING_EVENT in body, body[:400] + assert "sibling_sha" in json.dumps(job), ( + "the announcement does not carry the merged sibling sha" + ) + assert "[0-9a-f]{40}" in body, ( + "the announcement does not refuse a non-sha revision; a branch name " + "here is the floating ref this whole path replaces" + ) + + +@pytest.mark.unit +def test_the_announcement_refuses_rather_than_skips_without_credentials() -> None: + """A green run that announced nothing is the failure mode, not the safe state.""" + doc = _load(REUSABLE) + job = next(j for name, j in doc["jobs"].items() if "deliver" in name) + guard = [ + s + for s in job["steps"] + if "exit 1" in str(s.get("run") or "") and "APP_ID" in _step_text(s) + ] + assert guard, ( + "nothing in the announcement job fails on absent credentials, so a " + "caller that has not been granted them would report a delivery that " + "did not happen" + ) From eb43debdedc64b33245bdf10129d4ffd4cb61030 Mon Sep 17 00:00:00 2001 From: jonahgabriel Date: Tue, 15 Sep 2026 21:42:39 -0400 Subject: [PATCH 2/2] test(OMN-17057): bring the three reusable-workflow guards onto the new delivery job Three existing guards asserted properties of a two-job workflow and failed on the third. Each is extended by deciding what the new job needs, never by excluding it. * the secrets guard now asserts the App pair alongside the SASL pair, and asserts the REQUIREDNESS of each: SASL required, the App pair optional at the interface so an existing caller keeps compiling, and refused at run time by the announcement job itself. Marking them required would break every caller in the commit that adds them; skipping on their absence would report a delivery that did not happen. * the self-checkout guard now maps the new job to None -- it runs gh and jq and nothing from the tree, so a checkout would be ceremony that later reads as a dependency. Listed rather than excluded, so adding a job still forces the decision the test exists to force, and a None job is asserted to reference no local action and no repository script. * the CI-python-environment guard now scopes the setup-action requirement to jobs that RUN Python. The pip-install prohibition stays universal. The discriminating half is the else branch: a job with no setup step is asserted to invoke no Python at all, so "forgot the setup action" cannot pass as "does not need it". Verified against the live graph -- all six pre-existing jobs across the three workflows classify as Python jobs with setup present, and only the new job classifies as neither. --- tests/ci/test_ci_workflow_resilience.py | 61 +++++++++++++------ ...ntime_rebuild_sibling_workflow_omn18268.py | 44 +++++++++++-- 2 files changed, 83 insertions(+), 22 deletions(-) diff --git a/tests/ci/test_ci_workflow_resilience.py b/tests/ci/test_ci_workflow_resilience.py index 71e8fc38f2..9c60bb7752 100644 --- a/tests/ci/test_ci_workflow_resilience.py +++ b/tests/ci/test_ci_workflow_resilience.py @@ -888,32 +888,59 @@ def test_webhook_workflows_use_ci_python_environment() -> None: continue steps = job["steps"] - setup_steps = [ - step - for step in steps - if step.get("uses") == "./.github/actions/setup-python-uv" - ] - assert setup_steps, ( - f"{workflow_path.name}:{job_name} must use setup-python-uv" - ) - assert all( - step["with"]["install-args"] == "--frozen" for step in setup_steps - ) - assert all(step["with"]["cache-enabled"] == "false" for step in setup_steps) - assert all( - step["with"]["shared-env-enabled"] == "true" for step in setup_steps - ) - run_scripts = [ step.get("run", "") for step in steps if isinstance(step.get("run"), str) ] + + # Universal, whether or not this job runs Python: resolving deps + # imperatively is the thing being forbidden. assert not any( re.search(r"(^|\n)\s*(?:python -m )?pip install\b", script) for script in run_scripts ), f"{workflow_path.name} must not run pip install directly" - assert any("uv run python scripts/" in script for script in run_scripts) + + setup_steps = [ + step + for step in steps + if step.get("uses") == "./.github/actions/setup-python-uv" + ] + + # OMN-17057: the assertion is about jobs that RUN Python. A job that + # runs none satisfies "does not resolve Python deps outside CI env + # setup" trivially, and requiring the setup action there would + # install a toolchain nothing uses -- ceremony that later reads as a + # dependency. deliver-sibling-candidate is the first such job: it + # runs `gh` and `jq` only. + # + # The discriminating half is the else branch. A job with no setup + # step is asserted to invoke no Python at all, so "forgot the setup + # action" cannot pass as "does not need it". + invokes_python = any( + re.search(r"(^|\n|\|\s*)\s*(uv run )?python3?\b", script) + for script in run_scripts + ) + if invokes_python: + assert setup_steps, ( + f"{workflow_path.name}:{job_name} runs Python and must use " + "setup-python-uv" + ) + assert all( + step["with"]["install-args"] == "--frozen" for step in setup_steps + ) + assert all( + step["with"]["cache-enabled"] == "false" for step in setup_steps + ) + assert all( + step["with"]["shared-env-enabled"] == "true" for step in setup_steps + ) + assert any("uv run python scripts/" in script for script in run_scripts) + else: + assert not setup_steps, ( + f"{workflow_path.name}:{job_name} sets up the CI Python " + "environment but invokes no Python" + ) def test_codeql_uses_repo_config_that_ignores_github_metadata() -> None: diff --git a/tests/ci/test_runtime_rebuild_sibling_workflow_omn18268.py b/tests/ci/test_runtime_rebuild_sibling_workflow_omn18268.py index 85add40499..5a0b42c3dd 100644 --- a/tests/ci/test_runtime_rebuild_sibling_workflow_omn18268.py +++ b/tests/ci/test_runtime_rebuild_sibling_workflow_omn18268.py @@ -41,13 +41,27 @@ def test_is_callable_only_and_declares_the_facts_the_caller_must_supply() -> Non "pr_number", ): assert inputs[required]["required"] is True, required - assert set(triggers["workflow_call"]["secrets"]) == { + secrets = triggers["workflow_call"]["secrets"] + assert set(secrets) == { "KAFKA_SASL_USERNAME", "KAFKA_SASL_PASSWORD", + "ONEXBOT_OCC_APP_ID", + "ONEXBOT_OCC_PRIVATE_KEY", }, ( "the lane declares SASL and the publisher refuses to downgrade it; no " - "HMAC secret is a precondition of this producer" + "HMAC secret is a precondition of this producer. The App pair is the " + "OMN-17057 delivery announcement, which crosses a repository boundary " + "and so cannot use the caller's own token" ) + for name in ("KAFKA_SASL_USERNAME", "KAFKA_SASL_PASSWORD"): + assert secrets[name]["required"] is True, name + for name in ("ONEXBOT_OCC_APP_ID", "ONEXBOT_OCC_PRIVATE_KEY"): + # Optional at the INTERFACE so an existing caller keeps compiling, and + # refused at RUN TIME by the announcement job itself. Making them + # required here would break every caller in the same commit that adds + # them; making the job skip on their absence would report a delivery + # that did not happen, which is the failure this ticket removes. + assert secrets[name]["required"] is False, name def test_the_publisher_passes_both_the_sibling_sha_and_a_primary_ref() -> None: @@ -80,18 +94,38 @@ def test_the_build_context_repo_is_checked_out_at_the_workspace_root() -> None: run 35019423922. """ workflow = _load() - expected_ref = { + # None means "this job deliberately has no self-checkout". A job only needs + # one to reach `./.github/actions/...` or a script in this repository; + # deliver-sibling-candidate (OMN-17057) runs `gh` and `jq` and nothing from + # the tree, so a checkout would be ceremony that reads as a dependency. + # It is listed rather than excluded so that adding a job still forces the + # decision this test exists to force. + expected_ref: dict[str, str | None] = { "trigger-rebuild": "dev", "verify-sibling-converged": ( "${{ inputs.infra_ref || github.job_workflow_sha }}" ), + "deliver-sibling-candidate": None, } assert set(workflow["jobs"]) == set(expected_ref), ( - "a job was added or renamed; decide which of the two refs above it needs " + "a job was added or renamed; decide which of the refs above it needs " "rather than letting it default to an unasserted one" ) for job_id, job in workflow["jobs"].items(): - first = job["steps"][0] + steps = job["steps"] + if expected_ref[job_id] is None: + assert not any( + str(step.get("uses", "")).startswith("./") for step in steps + ), ( + f"{job_id} declares no self-checkout but references a local " + "action, which resolves against the CALLER's workspace" + ) + assert not any("scripts/" in str(step.get("run", "")) for step in steps), ( + f"{job_id} declares no self-checkout but invokes a repository " + "script, which is not present on disk" + ) + continue + first = steps[0] assert first["with"]["repository"] == "OmniNode-ai/omnibase_infra" assert first["with"]["ref"] == expected_ref[job_id], job_id assert "path" not in first["with"]