From 957ca6658e6fe26b7d7cf6b4a3dbd1b3c77f08a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= Date: Sun, 30 Aug 2026 00:24:50 +0200 Subject: [PATCH 1/2] ci(wave): the platform bake reads Plugins with an App credential, and ONE fan-out fires after the Plugins publication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first push-driven release wave (main-cd run 33276813173) failed three ways at once: - plugins-modules died at checkout: 'repository Systemorph/MeshWeaver.Plugins not found'. The reusable module-pack and publish-bake checked out content-repository with the run's GITHUB_TOKEN, which is scoped to the calling repo. Both now take content-app-id / content-app-private-key, assert them RED when content-repository is set, mint a Contents:Read installation token and check out with it. main-cd passes the dispatch App (installed on Plugins) as that credential. - Two fan-outs existed: an inline dispatch-dependents job (#2696) and the reusable notify-dependents.yml built in parallel. The reusable fired straight after promote — BEFORE the Plugins bake — with a payload of {version, reason} that the receivers cannot use (Manufacturing refuses without a sha256: digest; Education bakes against client_payload.image). The inline job is deleted; notify-dependents now needs plugins-bake success and carries sha/image/digest/source/chain, asserted RED for a platform release. - The verdict's LEGS name notify-dependents; PlatformReleaseNotifyGuard pins the single job, its gate on plugins-bake, the payload, and the reusable's RED-on-failure. Co-Authored-By: Claude Fable 5 --- .github/workflows/main-cd.yml | 101 +++++------------- .github/workflows/node-repo-module-pack.yml | 69 ++++++++++++ .github/workflows/node-repo-publish-bake.yml | 39 +++++++ .github/workflows/notify-dependents.yml | 40 ++++++- ...-wakes-the-fleet-once-and-after-plugins.md | 31 ++++++ .../PlatformReleaseNotifyGuard.cs | 35 ++++-- 6 files changed, 230 insertions(+), 85 deletions(-) create mode 100644 src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-30-a-platform-release-wakes-the-fleet-once-and-after-plugins.md diff --git a/.github/workflows/main-cd.yml b/.github/workflows/main-cd.yml index 58e9b5f391..e9995037c4 100644 --- a/.github/workflows/main-cd.yml +++ b/.github/workflows/main-cd.yml @@ -1635,7 +1635,7 @@ jobs: # inbox watcher that would have run the pin updater is armed on an on-demand hub). The maintainer's # decision: "it should be github actions trigger — no in mesh baking — plugins is top level, built # with platform — on platform update ⇒ update all". The dispatch now happens HERE, in the - # `dispatch-dependents` job below, after `plugins-bake` has sealed the Plugins publication for + # `notify-dependents` job below, after `plugins-bake` has sealed the Plugins publication for # this run's identity; the mesh notify below stays for the portals' own self-update only. # (Historical, superseded:) There is deliberately no job here that dispatches to the node repos — # but the wave is now BROADCAST, not only pulled. The broadcast happens ONE HOP AWAY, in memex, @@ -1718,14 +1718,21 @@ jobs: # memex's broadcast stays; this does not replace it. The two are independent paths to the same # event, and the failure that motivated this is that a wave depending on ONE hop through a mesh # is a wave that stops when that hop is unreachable — which is not observable from here. + # 🚨 AFTER `plugins-bake`, never merely after `promote`: Plugins is built WITH the platform and + # every satellite's bake seeds the Plugins publication for the released identity — woken before + # it is sealed, each one fails `upstream 'plugins' has no SEALED publication … for identity`. + # The payload is what the receivers read: image + digest (+ sha, version); see notify-dependents.yml. notify-dependents: name: "Push the release wave to dependent repos" - needs: [preflight, gate, portal-image, plugin-test-image, promote] - if: needs.gate.outputs.publish == 'true' && needs.promote.result == 'success' + needs: [preflight, gate, portal-image, plugin-test-image, promote, plugins-bake, plugins-bake-image] + if: needs.gate.outputs.publish == 'true' && needs.promote.result == 'success' && needs.plugins-bake.result == 'success' uses: ./.github/workflows/notify-dependents.yml with: version: ${{ needs.plugin-test-image.outputs.version }} reason: platform + sha: ${{ needs.gate.outputs.sha }} + image: ${{ needs.plugins-bake-image.outputs.image }} + digest: ${{ needs.plugins-bake-image.outputs.digest }} secrets: app-id: ${{ secrets.DEPENDENT_DISPATCH_APP_ID }} private-key: ${{ secrets.DEPENDENT_DISPATCH_APP_PRIVATE_KEY }} @@ -1836,6 +1843,12 @@ jobs: # package changes; a platform release changes no package content. The bundles packed here # are the bake's inputs (artifacts), nothing more. publish: false + secrets: + # MeshWeaver.Plugins is PRIVATE: this run's GITHUB_TOKEN cannot check it out (the first wave + # died on `repository … not found`, run 33276813173). The dispatch App is installed on it + # with Contents: Read, so it doubles as the content credential; preflight asserts it. + content-app-id: ${{ secrets.DEPENDENT_DISPATCH_APP_ID }} + content-app-private-key: ${{ secrets.DEPENDENT_DISPATCH_APP_PRIVATE_KEY }} plugins-bake-image: name: "Plugins: resolve this run's tester image digest" @@ -1900,75 +1913,19 @@ jobs: azure-client-id: ${{ secrets.AZURE_CLIENT_ID }} azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }} azure-subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + # The content checkout of the private Plugins repo — see plugins-modules. + content-app-id: ${{ secrets.DEPENDENT_DISPATCH_APP_ID }} + content-app-private-key: ${{ secrets.DEPENDENT_DISPATCH_APP_PRIVATE_KEY }} # ────────────────── DISPATCH DEPENDENTS: "on platform update ⇒ update all" ────────────────── - # Fires ONLY after the Plugins publication is sealed for this identity. Every repo the dispatch - # App is installed on, except this one and Plugins (built above, not a subscriber), receives - # `meshweaver-framework-released`; their CI already answers it by baking against the released - # image and refusing unless every upstream is sealed. Package-only publishes take the other - # path — the reusable bake's own `dispatch-dependents` job sends `meshweaver-upstream-published` - # to dependents only, and recurses. - # - # 🚨 No hand-maintained subscriber list and no skip-trapdoor: the set IS the App's installation - # (install the App on a repo = subscribe it), preflight asserts the credential RED, an empty set - # is RED, and a failed dispatch is RED naming the repo. A release that told nobody is not delivered. - dispatch-dependents: - name: "Dispatch meshweaver-framework-released to every dependent repo" - needs: [preflight, gate, plugin-test-image, promote, plugins-bake, plugins-bake-image] - if: needs.gate.outputs.publish == 'true' && needs.promote.result == 'success' && needs.plugins-bake.result == 'success' - runs-on: ubuntu-latest - timeout-minutes: 10 - permissions: - contents: read - steps: - - name: Mint an installation token for the dependents - id: app - uses: actions/create-github-app-token@v2 - with: - app-id: ${{ secrets.DEPENDENT_DISPATCH_APP_ID }} - private-key: ${{ secrets.DEPENDENT_DISPATCH_APP_PRIVATE_KEY }} - owner: ${{ github.repository_owner }} - - name: Dispatch to every installed repo (except the platform and Plugins) - env: - GH_TOKEN: ${{ steps.app.outputs.token }} - SHA: ${{ needs.gate.outputs.sha }} - VERSION: ${{ needs.plugin-test-image.outputs.version }} - IMAGE: ${{ needs.plugins-bake-image.outputs.image }} - DIGEST: ${{ needs.plugins-bake-image.outputs.digest }} - SELF: ${{ github.repository }} - PLUGINS: Systemorph/MeshWeaver.Plugins - run: | - set -euo pipefail - repos=$(gh api --paginate /installation/repositories --jq '.repositories[].full_name') - [ -n "$repos" ] || { echo "::error::the dispatch App is installed on NO repository — nobody is subscribed. Install DEPENDENT_DISPATCH's App on the node repos."; exit 1; } - payload=$(jq -cn --arg sha "$SHA" --arg v "$VERSION" --arg i "$IMAGE" --arg d "$DIGEST" \ - '{sha:$sha, version:$v, image:$i, digest:$d, source:"platform", chain:["platform","plugins"]}') - dispatched=(); failed=() - for repo in $repos; do - case "$repo" in "$SELF"|"$PLUGINS") continue ;; esac - code=$(curl -sS -o /tmp/resp -w '%{http_code}' -X POST \ - -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" \ - "https://api.github.com/repos/$repo/dispatches" \ - -d "$(jq -cn --argjson p "$payload" '{event_type:"meshweaver-framework-released", client_payload:$p}')") || code=000 - case "$code" in - 2*) dispatched+=("$repo") ;; - *) failed+=("$repo (HTTP $code: $(head -c 200 /tmp/resp | tr '\n' ' '))") ;; - esac - done - if [ ${#dispatched[@]} -eq 0 ] && [ ${#failed[@]} -eq 0 ]; then - echo "::error::the App is installed only on $SELF / $PLUGINS — no dependent to dispatch. Install it on the node repos."; exit 1 - fi - { - echo "### 📣 Platform ${VERSION} (${SHA:0:9}) released — dependents dispatched" - echo "- image: \`$IMAGE\`" - for r in "${dispatched[@]}"; do echo "- ✅ $r"; done - for r in "${failed[@]}"; do echo "- ❌ $r"; done - } >> "$GITHUB_STEP_SUMMARY" - for r in "${dispatched[@]}"; do echo "dispatched: $r"; done - if [ ${#failed[@]} -gt 0 ]; then - for r in "${failed[@]}"; do echo "::error::dispatch failed: $r"; done - exit 1 - fi + # ONE fan-out, and it is `notify-dependents` above — gated on `plugins-bake`, so a satellite is + # woken only once the Plugins publication is sealed for this identity (its bake seeds that + # publication and refuses an unsealed one). Two sessions built this leg twice on 2026-08-29 + # (an inline job here and the reusable notify-dependents.yml); the first wave then dispatched + # from the reusable BEFORE the bake and with a payload the receivers could not use. The guard + # (PlatformReleaseNotifyGuard) now pins a single job carrying the full payload. + # Package-only publishes take the other path — the reusable bake's own `dispatch-dependents` + # job sends `meshweaver-upstream-published` to dependents only, and recurses. verify-images: name: "Verify every image shipped" @@ -2046,7 +2003,7 @@ jobs: # publish actually happened while it can only see two of the nine jobs that make it happen. needs: [preflight, gate, portal-image, migration-image, plugin-test-image, promote, publish-bake, notify-platform-update, verify-images, - plugins-modules, plugins-bake-image, plugins-bake, dispatch-dependents] + plugins-modules, plugins-bake-image, plugins-bake, notify-dependents] if: always() runs-on: ubuntu-latest timeout-minutes: 5 @@ -2186,7 +2143,7 @@ jobs: plugins-modules=${{ needs.plugins-modules.result }} plugins-bake-image=${{ needs.plugins-bake-image.result }} plugins-bake=${{ needs.plugins-bake.result }} - dispatch-dependents=${{ needs.dispatch-dependents.result }} + notify-dependents=${{ needs.notify-dependents.result }} run: | set -euo pipefail bad="" diff --git a/.github/workflows/node-repo-module-pack.yml b/.github/workflows/node-repo-module-pack.yml index 91bfef46dc..aa86e38696 100644 --- a/.github/workflows/node-repo-module-pack.yml +++ b/.github/workflows/node-repo-module-pack.yml @@ -167,6 +167,15 @@ on: acr-password: description: Pull credential for platform-image's registry. Required when platform-image-digest is set. required: false + content-app-id: + description: >- + App id of a GitHub App with Contents: Read on content-repository. Required whenever + content-repository is set (the caller's GITHUB_TOKEN cannot read another repository); + asserted RED before the checkout, never skipped. + required: false + content-app-private-key: + description: Private key (PEM) of that App. + required: false jobs: # ─────────────────────── WHICH BUNDLES THIS DIFF CAN REACH ─────────────────────── @@ -187,10 +196,40 @@ jobs: # selector is one `index.json` away from treating the framework as a plugin of this repo. # gen-manifests.py already carries "meshweaver" in its SKIP set for exactly this reason; # keeping the two trees apart is the version that cannot rot. + # 🚨 A content checkout from ANOTHER repository needs a token that can read it. The caller's + # GITHUB_TOKEN is scoped to the calling repository alone, so `repository: + # Systemorph/MeshWeaver.Plugins` (private) answers 404 under it — the platform's first release + # wave died on exactly that (main-cd run 33276813173, 2026-08-29). When content-repository is + # set, the App credential is ASSERTED red, never fallen back on: a fallback to github.token + # would 404 anyway, one step later and blaming the checkout. + - name: Resolve the content repository's credential + id: content-repo + if: inputs.content-repository != '' + env: + REPO: ${{ inputs.content-repository }} + APP_ID: ${{ secrets.content-app-id }} + APP_KEY: ${{ secrets.content-app-private-key }} + run: | + set -euo pipefail + [ -n "$APP_ID" ] || { echo "::error::content-repository is '$REPO' but secrets.content-app-id is empty — the caller's GITHUB_TOKEN cannot read another repository, so the checkout would 404. Pass a GitHub App installed on '$REPO' with Contents: Read."; exit 1; } + [ -n "$APP_KEY" ] || { echo "::error::content-repository is '$REPO' but secrets.content-app-private-key is empty — see content-app-id."; exit 1; } + echo "owner=${REPO%%/*}" >> "$GITHUB_OUTPUT" + echo "name=${REPO#*/}" >> "$GITHUB_OUTPUT" + - name: Mint a read token for the content repository + id: content-token + if: inputs.content-repository != '' + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.content-app-id }} + private-key: ${{ secrets.content-app-private-key }} + owner: ${{ steps.content-repo.outputs.owner }} + repositories: ${{ steps.content-repo.outputs.name }} + permission-contents: read - uses: actions/checkout@v7 with: repository: ${{ inputs.content-repository || github.repository }} ref: ${{ inputs.content-ref || github.sha }} + token: ${{ steps.content-token.outputs.token || github.token }} path: repo # 🚨 The diff is taken against the PR base, so the base commits must be present. A # shallow clone would make every range unresolvable — which the scope script turns into @@ -260,10 +299,40 @@ jobs: matrix: entry: ${{ fromJson(needs.select.outputs.modules) }} steps: + # 🚨 A content checkout from ANOTHER repository needs a token that can read it. The caller's + # GITHUB_TOKEN is scoped to the calling repository alone, so `repository: + # Systemorph/MeshWeaver.Plugins` (private) answers 404 under it — the platform's first release + # wave died on exactly that (main-cd run 33276813173, 2026-08-29). When content-repository is + # set, the App credential is ASSERTED red, never fallen back on: a fallback to github.token + # would 404 anyway, one step later and blaming the checkout. + - name: Resolve the content repository's credential + id: content-repo + if: inputs.content-repository != '' + env: + REPO: ${{ inputs.content-repository }} + APP_ID: ${{ secrets.content-app-id }} + APP_KEY: ${{ secrets.content-app-private-key }} + run: | + set -euo pipefail + [ -n "$APP_ID" ] || { echo "::error::content-repository is '$REPO' but secrets.content-app-id is empty — the caller's GITHUB_TOKEN cannot read another repository, so the checkout would 404. Pass a GitHub App installed on '$REPO' with Contents: Read."; exit 1; } + [ -n "$APP_KEY" ] || { echo "::error::content-repository is '$REPO' but secrets.content-app-private-key is empty — see content-app-id."; exit 1; } + echo "owner=${REPO%%/*}" >> "$GITHUB_OUTPUT" + echo "name=${REPO#*/}" >> "$GITHUB_OUTPUT" + - name: Mint a read token for the content repository + id: content-token + if: inputs.content-repository != '' + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.content-app-id }} + private-key: ${{ secrets.content-app-private-key }} + owner: ${{ steps.content-repo.outputs.owner }} + repositories: ${{ steps.content-repo.outputs.name }} + permission-contents: read - uses: actions/checkout@v7 with: repository: ${{ inputs.content-repository || github.repository }} ref: ${{ inputs.content-ref || github.sha }} + token: ${{ steps.content-token.outputs.token || github.token }} - name: Check out Systemorph/MeshWeaver at the pinned ref uses: actions/checkout@v7 with: diff --git a/.github/workflows/node-repo-publish-bake.yml b/.github/workflows/node-repo-publish-bake.yml index e723e1f386..d249e51696 100644 --- a/.github/workflows/node-repo-publish-bake.yml +++ b/.github/workflows/node-repo-publish-bake.yml @@ -322,6 +322,15 @@ on: dispatch-app-private-key: description: Private key (PEM) of that App. required: false + content-app-id: + description: >- + App id of a GitHub App with Contents: Read on content-repository. Required whenever + content-repository is set (the caller's GITHUB_TOKEN cannot read another repository); + asserted RED before the checkout, never skipped. + required: false + content-app-private-key: + description: Private key (PEM) of that App. + required: false jobs: publish-bake: @@ -417,12 +426,42 @@ jobs: # resolve it. bake-scope.sh falls back to a full bake rather than guessing when it cannot — # correct, but it would narrow NOTHING, ever. The string forms are deliberate: GitHub # expressions treat the NUMBER 0 as falsy, so `inputs.x && 0 || 1` yields 1 in both branches. + # 🚨 A content checkout from ANOTHER repository needs a token that can read it. The caller's + # GITHUB_TOKEN is scoped to the calling repository alone, so `repository: + # Systemorph/MeshWeaver.Plugins` (private) answers 404 under it — the platform's first release + # wave died on exactly that (main-cd run 33276813173, 2026-08-29). When content-repository is + # set, the App credential is ASSERTED red, never fallen back on: a fallback to github.token + # would 404 anyway, one step later and blaming the checkout. + - name: Resolve the content repository's credential + id: content-repo + if: inputs.content-repository != '' + env: + REPO: ${{ inputs.content-repository }} + APP_ID: ${{ secrets.content-app-id }} + APP_KEY: ${{ secrets.content-app-private-key }} + run: | + set -euo pipefail + [ -n "$APP_ID" ] || { echo "::error::content-repository is '$REPO' but secrets.content-app-id is empty — the caller's GITHUB_TOKEN cannot read another repository, so the checkout would 404. Pass a GitHub App installed on '$REPO' with Contents: Read."; exit 1; } + [ -n "$APP_KEY" ] || { echo "::error::content-repository is '$REPO' but secrets.content-app-private-key is empty — see content-app-id."; exit 1; } + echo "owner=${REPO%%/*}" >> "$GITHUB_OUTPUT" + echo "name=${REPO#*/}" >> "$GITHUB_OUTPUT" + - name: Mint a read token for the content repository + id: content-token + if: inputs.content-repository != '' + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.content-app-id }} + private-key: ${{ secrets.content-app-private-key }} + owner: ${{ steps.content-repo.outputs.owner }} + repositories: ${{ steps.content-repo.outputs.name }} + permission-contents: read - uses: actions/checkout@v7 with: # Empty inputs fall through to the caller's own repo + commit — byte-identical to the # default checkout for every node repo; only the platform's CD points these elsewhere. repository: ${{ inputs.content-repository || github.repository }} ref: ${{ inputs.content-ref || github.sha }} + token: ${{ steps.content-token.outputs.token || github.token }} fetch-depth: ${{ inputs.narrow-by-affected && '0' || '1' }} # 🚨 The CONTENT commit, not $GITHUB_SHA. They differ exactly when content-repository is set # (the platform baking Plugins), and the publication records the content's commit — the diff --git a/.github/workflows/notify-dependents.yml b/.github/workflows/notify-dependents.yml index 0cb7671daf..b737a5981e 100644 --- a/.github/workflows/notify-dependents.yml +++ b/.github/workflows/notify-dependents.yml @@ -50,6 +50,25 @@ on: description: 'What released — "platform" (images) or "packages" (NuGet)' required: true type: string + # The image the receivers rebuild AGAINST. Every satellite's bake resolves the released + # image from these (client_payload.image / .digest / .sha) — MeshWeaver.Manufacturing refuses + # a platform event whose payload carries no `sha256:` digest, and MeshWeaver.Education bakes + # against `client_payload.image` verbatim. Required for reason "platform"; asserted RED. + sha: + description: 'The platform commit the images were built from' + required: false + default: '' + type: string + image: + description: 'The tester image reference, pinned by digest (registry/mw-plugin-test@sha256:…)' + required: false + default: '' + type: string + digest: + description: 'That image''s digest (sha256:…)' + required: false + default: '' + type: string secrets: app-id: required: true @@ -103,9 +122,24 @@ jobs: VERSION: ${{ inputs.version }} REASON: ${{ inputs.reason || 'manual-check' }} DRY_RUN: ${{ inputs.dry-run }} + SHA: ${{ inputs.sha }} + IMAGE: ${{ inputs.image }} + DIGEST: ${{ inputs.digest }} run: | set -euo pipefail + # 🚨 A platform release names the image it released, or it is not a release event. The + # receivers gate against client_payload.image / .digest, and an event without them makes + # every one of them either refuse (Manufacturing) or bake against nothing (Education). + if [ "$REASON" = "platform" ]; then + [ -n "$IMAGE" ] || { echo "::error::reason=platform but inputs.image is empty — the receivers cannot know what to rebuild against."; exit 1; } + case "$DIGEST" in sha256:*) ;; *) echo "::error::reason=platform but inputs.digest is '$DIGEST', not a sha256: digest."; exit 1 ;; esac + [ -n "$SHA" ] || { echo "::error::reason=platform but inputs.sha is empty."; exit 1; } + fi + payload=$(jq -cn --arg v "$VERSION" --arg r "$REASON" --arg sha "$SHA" --arg i "$IMAGE" --arg d "$DIGEST" \ + '{version:$v, reason:$r, sha:$sha, image:$i, digest:$d, source:"platform", chain:["platform","plugins"]}') + echo "payload: $payload" + # 🚨 INSTALLATION IS NOT SUBSCRIPTION. The App is installed org-wide for OTHER reasons # (GitSync), so its installation list is 33 repositories — customer and solution repos # included. Measured on the first dry run (run 33275415742): 33 installed, and exactly @@ -152,10 +186,8 @@ jobs: # partial state is invisible. Notify everyone, then report. failed="" for repo in $repos; do - if gh api -X POST "/repos/$repo/dispatches" \ - -f "event_type=meshweaver-framework-released" \ - -F "client_payload[version]=$VERSION" \ - -F "client_payload[reason]=$REASON" 2>dispatch.err; then + if jq -cn --argjson p "$payload" '{event_type:"meshweaver-framework-released", client_payload:$p}' \ + | gh api -X POST "/repos/$repo/dispatches" --input - 2>dispatch.err; then echo " dispatched → $repo" else # A repo with no `repository_dispatch:` receiver still ACCEPTS the POST (204) and diff --git a/src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-30-a-platform-release-wakes-the-fleet-once-and-after-plugins.md b/src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-30-a-platform-release-wakes-the-fleet-once-and-after-plugins.md new file mode 100644 index 0000000000..e1a0822e17 --- /dev/null +++ b/src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-30-a-platform-release-wakes-the-fleet-once-and-after-plugins.md @@ -0,0 +1,31 @@ +--- +Name: A platform release wakes the fleet once — and only after Plugins is published +Category: Fix +Description: The first push-driven release wave dispatched to every repo before the Plugins publication was sealed, with a payload the receivers could not use, and the Plugins content it needed to bake could not even be checked out. One fan-out now fires after the Plugins bake, carrying the released image, and the bake reads Plugins with a credential that can. +Icon: ArrowSync +Order: -20260830 +--- + +# A platform release wakes the fleet once — and only after Plugins is published + +When the platform publishes an image, every node repository — Crm, Reinsurance, SocialMedia, +Education, Manufacturing — is meant to rebuild against it automatically, and Plugins is meant to be +built *with* the platform so those rebuilds find its publication already sealed. The first release +that tried to do this on its own did three things wrong at once, and the delivery verdict correctly +refused to call it delivered. + +- **It could not read Plugins.** The platform's release run bakes Plugins' modules, but the job + checked the private Plugins repository out with the run's own token, which cannot see it. The + bake died on "repository not found" before packing anything. +- **It woke the fleet twice, and too early.** Two fan-out jobs had been built side by side; the + one that fired ran straight after the images were tagged, before the Plugins bake, so every + repository it woke would have looked for a Plugins publication that did not exist yet. +- **It said which version, not which image.** The receivers rebuild against an image reference + and digest; the event carried a version string, which one repository refuses outright and + another cannot bake from. + +Now the platform's bake reads Plugins through the release App's credential — asserted red before +the checkout, never silently replaced by the token that cannot — and there is one fan-out. It runs +only once the Plugins publication for the released identity is sealed, and its payload names the +image, its digest and the platform commit, which is what every subscribed repository already reads. +A guard pins that shape so the second job cannot grow back. diff --git a/test/MeshWeaver.Documentation.Test/PlatformReleaseNotifyGuard.cs b/test/MeshWeaver.Documentation.Test/PlatformReleaseNotifyGuard.cs index 785871babd..e55e421bdf 100644 --- a/test/MeshWeaver.Documentation.Test/PlatformReleaseNotifyGuard.cs +++ b/test/MeshWeaver.Documentation.Test/PlatformReleaseNotifyGuard.cs @@ -166,25 +166,42 @@ public void TheDispatchIsDiscoveredAppCredentialedAndAssertedRed() Assert.DoesNotContain("BAKE_SUBSCRIBER_REPOS", body, StringComparison.Ordinal); Assert.DoesNotContain("DEPENDENT_DISPATCH_TOKEN", body, StringComparison.Ordinal); - var job = JobBlock(body, "dispatch-dependents:"); - Assert.Contains("actions/create-github-app-token", job, StringComparison.Ordinal); + // ONE fan-out. Two sessions built this leg twice on 2026-08-29 (an inline job and the + // reusable notify-dependents.yml); the reusable then fired BEFORE the Plugins bake with a + // payload the receivers could not use (run 33276813173). A second job key here is the + // duplication coming back. + Assert.DoesNotContain("\n dispatch-dependents:", body, StringComparison.Ordinal); + + var job = JobBlock(body, "notify-dependents:"); + Assert.Contains("uses: ./.github/workflows/notify-dependents.yml", job, StringComparison.Ordinal); Assert.Contains("secrets.DEPENDENT_DISPATCH_APP_ID", job, StringComparison.Ordinal); Assert.Contains("secrets.DEPENDENT_DISPATCH_APP_PRIVATE_KEY", job, StringComparison.Ordinal); - Assert.Contains("/installation/repositories", job, StringComparison.Ordinal); - Assert.Contains("meshweaver-framework-released", job, StringComparison.Ordinal); - Assert.DoesNotContain("continue-on-error", job, StringComparison.Ordinal); - Assert.DoesNotContain("::warning", job, StringComparison.Ordinal); - Assert.Contains("exit 1", job, StringComparison.Ordinal); // Plugins is built WITH the platform (plugins-bake), never told to rebuild — and the wave - // fires only after that publication is sealed. + // fires only after that publication is sealed: a satellite woken earlier seeds nothing. Assert.Contains("needs.plugins-bake.result == 'success'", job, StringComparison.Ordinal); + // The receivers read image + digest (+ sha); a version alone is not a release event. + Assert.Contains("image: ${{ needs.plugins-bake-image.outputs.image }}", job, StringComparison.Ordinal); + Assert.Contains("digest: ${{ needs.plugins-bake-image.outputs.digest }}", job, StringComparison.Ordinal); + Assert.Contains("sha: ${{ needs.gate.outputs.sha }}", job, StringComparison.Ordinal); + + // The reusable itself: discovered subscribers, an App token minted per run, RED on any + // failed dispatch (collected, then exit 1 — never continue-on-error), and a platform event + // without its image refused rather than sent. + var reusable = File.ReadAllText(Path.Combine(FindRepoRoot(), ".github", "workflows", "notify-dependents.yml")); + Assert.Contains("actions/create-github-app-token", reusable, StringComparison.Ordinal); + Assert.Contains("/installation/repositories", reusable, StringComparison.Ordinal); + Assert.Contains("meshweaver-framework-released", reusable, StringComparison.Ordinal); + Assert.Contains("client_payload:$p", reusable, StringComparison.Ordinal); + Assert.Contains("reason=platform but inputs.image is empty", reusable, StringComparison.Ordinal); + Assert.DoesNotContain("continue-on-error", reusable, StringComparison.Ordinal); + Assert.Contains("exit 1", reusable, StringComparison.Ordinal); var preflight = SectionAfter(JobBlock(body, "preflight:"), "missing=()"); Assert.Contains("DEPENDENT_DISPATCH_APP_ID", preflight, StringComparison.Ordinal); Assert.Contains("DEPENDENT_DISPATCH_APP_PRIVATE_KEY", preflight, StringComparison.Ordinal); var legs = SectionAfter(JobBlock(body, "delivery-verdict:"), "LEGS: >-"); - Assert.Contains("dispatch-dependents=", legs, StringComparison.Ordinal); + Assert.Contains("notify-dependents=", legs, StringComparison.Ordinal); Assert.Contains("plugins-bake=", legs, StringComparison.Ordinal); } From 7f0a11808243cab7a0ed8b6077fdc5a837b2e47e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= Date: Sun, 30 Aug 2026 00:30:12 +0200 Subject: [PATCH 2/2] =?UTF-8?q?ci(notify-dependents):=20source=20and=20cha?= =?UTF-8?q?in=20follow=20the=20reason=20=E2=80=94=20a=20packages=20release?= =?UTF-8?q?=20is=20not=20a=20platform=20event?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit release-packages.yml calls the same reusable with reason: packages; hard-coding source:"platform" / chain:[platform, plugins] would label a NuGet release as an image release and seed the receivers' cycle guard with a Plugins publication that was never made. source is now the reason, and only a platform release carries Plugins in its chain. Co-Authored-By: Claude Fable 5 --- .github/workflows/notify-dependents.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/notify-dependents.yml b/.github/workflows/notify-dependents.yml index b737a5981e..9bb03a65d3 100644 --- a/.github/workflows/notify-dependents.yml +++ b/.github/workflows/notify-dependents.yml @@ -136,8 +136,15 @@ jobs: case "$DIGEST" in sha256:*) ;; *) echo "::error::reason=platform but inputs.digest is '$DIGEST', not a sha256: digest."; exit 1 ;; esac [ -n "$SHA" ] || { echo "::error::reason=platform but inputs.sha is empty."; exit 1; } fi + # `source` and `chain` are what the receivers' cycle guard reads (client_payload.chain in + # node-repo-publish-bake.yml): a platform release carries Plugins in its chain because + # Plugins is baked WITH the platform, so no dependent may dispatch back to it. Any other + # reason (release-packages.yml sends "packages") is its own root — labelling it "platform" + # would claim an image release that did not happen and seed the chain with a Plugins + # publication that was never made. payload=$(jq -cn --arg v "$VERSION" --arg r "$REASON" --arg sha "$SHA" --arg i "$IMAGE" --arg d "$DIGEST" \ - '{version:$v, reason:$r, sha:$sha, image:$i, digest:$d, source:"platform", chain:["platform","plugins"]}') + '{version:$v, reason:$r, sha:$sha, image:$i, digest:$d, source:$r, + chain:(if $r == "platform" then ["platform","plugins"] else [$r] end)}') echo "payload: $payload" # 🚨 INSTALLATION IS NOT SUBSCRIPTION. The App is installed org-wide for OTHER reasons