Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 29 additions & 72 deletions .github/workflows/main-cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 }}
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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=""
Expand Down
69 changes: 69 additions & 0 deletions .github/workflows/node-repo-module-pack.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 ───────────────────────
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down
39 changes: 39 additions & 0 deletions .github/workflows/node-repo-publish-bake.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading