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
112 changes: 7 additions & 105 deletions .github/workflows/main-cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,13 @@ permissions:
# pushing a multi-image set must never be killed, or the set is left incomplete, which is the
# very state the reconciler exists to repair.
concurrency:
group: main-cd-${{ github.ref }}
# 🚨 The RECONCILE (schedule) has its own lane. It is the delivery path — dotnet-test.yml says
# so where it justifies cancel-in-progress on main — and on 2026-08-27 it was STARVED by the
# push path: six all-skipped workflow_run runs fired on ONE sha inside 20 minutes, each holding
# this group's single pending slot, and the cron did not fire at 07:23 or 08:23 at all (#2490).
# Earlier a scheduled run that DID start was evicted with zero jobs by the one-pending-slot
# rule. A no-op that holds the slot is the defect; keying the reconcile separately removes it.
group: main-cd-${{ github.event_name == 'schedule' && 'reconcile' || github.ref }}
cancel-in-progress: false

env:
Expand Down Expand Up @@ -1186,14 +1192,6 @@ jobs:
# one shipping (#1814's class), and its version is the version THIS run computed (#695's
# class — see the publish step). Same repo as portal-image's checkout; PlatformBakeLaneGuard
# asserts it is the only foreign repository in this file.
- name: Check out MeshWeaver.Plugins (every plugin is baked in this run)
uses: actions/checkout@v7
with:
repository: Systemorph/MeshWeaver.Plugins
ref: ${{ vars.MW_PLUGINS_REF || 'main' }}
path: plugins-repo
ssh-key: ${{ secrets.PLUGINS_REPO_SSH_KEY }}
fetch-depth: 1
- name: "Preflight: the publish targets must be provisioned"
run: |
set -euo pipefail
Expand Down Expand Up @@ -1463,109 +1461,13 @@ jobs:
echo "### 🔑 Bake address verified"
echo "- \`$BAKED\` — resolved by BOTH the bake host and the promoted \`memex-portal-ai\` image (linux/amd64)"
} >> "$GITHUB_STEP_SUMMARY"
- name: "BAKE every plugin against the SAME image (compiler-driven, no mesh)"
env:
IMAGE: ${{ env.ACR }}/mw-plugin-test:${{ needs.gate.outputs.image_tag }}
run: |
set -euo pipefail
PLUGINS="$GITHUB_WORKSPACE/plugins-repo"
[ -f "$PLUGINS/plugin-gate.allow" ] || { echo "::error::plugins-repo/plugin-gate.allow is missing — the known-debt ratchet the plugin bake is configured against. Refusing to bake without it."; exit 1; }
PLUGINS_SHA="$(git -C "$PLUGINS" rev-parse HEAD)"
BAKE_PLUGINS="$RUNNER_TEMP/bake-plugins"
rm -rf "$BAKE_PLUGINS"; mkdir -p "$BAKE_PLUGINS"; chmod 777 "$BAKE_PLUGINS"
echo "── baking /plugins (every module) against $IMAGE at plugins $PLUGINS_SHA"
docker run --rm --init --pull never --platform ${{ matrix.docker_platform }} -v "$PLUGINS:/plugins" -v "$BAKE_PLUGINS:/bake" \
--entrypoint /app/mw-plugin-test "$IMAGE" compile /plugins \
--output /bake --allow /plugins/plugin-gate.allow --source-sha "$PLUGINS_SHA"
# Postconditions, not hopes — same as the platform bake above.
if [ ! -s "$BAKE_PLUGINS/framework-mvid.txt" ]; then
echo "::error::the plugin bake ran green but wrote no identity — the bake stage regressed."; exit 1
fi
if ! ls "$BAKE_PLUGINS"/*.zip >/dev/null 2>&1; then
echo "::error::the plugin bake ran green but produced no bundles — nothing could be published."; exit 1
fi
echo "BAKE_PLUGINS_DIR=$BAKE_PLUGINS" >> "$GITHUB_ENV"
echo "PLUGINS_SHA=$PLUGINS_SHA" >> "$GITHUB_ENV"
echo "plugin bake identity: $(cat "$BAKE_PLUGINS/framework-mvid.txt")"
ls "$BAKE_PLUGINS"/*.zip | wc -l | xargs echo "plugin bundles:"
- name: "GATE the plugin bake: render + execute Tests areas on the BAKED bytes"
env:
IMAGE: ${{ env.ACR }}/mw-plugin-test:${{ needs.gate.outputs.image_tag }}
run: |
set -euo pipefail
PLUGINS="$GITHUB_WORKSPACE/plugins-repo"
echo "── gating /plugins against $IMAGE, consuming the bake in /bake"
docker run --rm --init --pull never --platform ${{ matrix.docker_platform }} -v "$PLUGINS:/plugins" -v "$BAKE_PLUGINS_DIR:/bake" \
--entrypoint /app/mw-plugin-test "$IMAGE" /plugins \
--allow /plugins/plugin-gate.allow --seed /bake
# 🚨 The whole point of building plugins HERE: their identity must be the platform's, by
# construction. Assert it rather than trust it — a divergence means the two bakes did not run
# against the same image, and publishing both would ship bundles no portal can adopt together.
- name: "Assert the plugin bake's identity equals the platform bake's"
run: |
set -euo pipefail
PLATFORM="$(tr -d '[:space:]' < "$BAKE_DIR/framework-mvid.txt")"
PLUGINS="$(tr -d '[:space:]' < "$BAKE_PLUGINS_DIR/framework-mvid.txt")"
if [ "$PLATFORM" != "$PLUGINS" ]; then
echo "::error::plugin bake identity ($PLUGINS) differs from the platform bake identity ($PLATFORM) in the SAME run — they did not build against the same image. Refusing to publish either."
exit 1
fi
echo "identities agree: $PLATFORM"
# 🚨 RELEASE_VERSION is REQUIRED here and is the version THIS run computed. It is not read
# from any manifest.lock: a bundle's version must be minted by the run that produced its
# bytes (Plugins#695 — a version that moves on a lock file while the bytes come from
# elsewhere is how an already-fixed defect kept shipping, #2356).
# 🚨 NOT on the reconcile path. RELEASE_VERSION is `3.0.0-ci.<build>` — it embeds the RUN
# NUMBER, so a reconcile would mint a DIFFERENT version from the one the promoted image
# carries, and publish bytes under a version no run produced. That is precisely the failure
# the guard below refuses (Plugins#695: a bundle's version must be minted by the run that
# produced its bytes). The platform CONTENT bake above has no such coupling — its key is the
# framework identity, which the promoted image resolves identically however many times it is
# asked — so the reconcile re-asserts that and stops there.
# 🚨 RE-AUTHENTICATE IMMEDIATELY BEFORE PUBLISHING — the job outruns its own credential.
# The OIDC assertion is minted by the `Azure login` step at the TOP of this job and is valid
# for ~5 minutes. Everything between here and there is the bake: it compiles every shipped
# NodeType twice over (Doc + samples) inside the container, which takes longer than that. So
# the publish below reached Azure with an assertion that had already expired:
#
# ERROR: AADSTS700024: Client assertion is not within its valid time range.
# Current time: 2026-08-27T00:20:39Z, expiry time of assertion: 2026-08-27T00:14:34Z
#
# Measured on CD run 33024861072: promote had succeeded, the image pulled on attempt 1, the
# bake compiled — and the run still published nothing, six minutes past the credential.
#
# 🚨 This failure was LATENT, not new: the login has always been taken at job start, and the
# bake has always been the long part. It surfaces now because #2452 makes this job run in
# situations where it previously did not, so the window is exercised far more often.
#
# A fresh login here costs seconds and removes the coupling between bake duration and
# credential lifetime entirely. It is NOT `continue-on-error`: if the identity cannot be
# obtained, publishing must fail loudly rather than skip — a bake that produced bundles and
# published none is the exact state the bake lane exists to prevent.
- name: "Azure login (OIDC) — refreshed for the publish"
uses: azure/login@v3
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

- name: "Publish the plugin bundles to every portal target (same identity, same version)"
if: needs.gate.outputs.bake_only != 'true'
env:
BAKE_PUBLISH_TARGETS: ${{ vars.BAKE_PUBLISH_TARGETS }}
BAKE_ARCHITECTURE: ${{ matrix.arch }}
RELEASE_VERSION: ${{ needs.portal-image.outputs.version }}
run: |
set -euo pipefail
[ -n "$RELEASE_VERSION" ] || { echo "::error::no release version from portal-image — refusing to publish plugin bundles under an unknown version (Plugins#695)."; exit 1; }
.github/scripts/publish-bake-bundles.sh "$BAKE_PLUGINS_DIR" "plugins" \
"$PLUGINS_SHA" "$RELEASE_VERSION"
{
echo "### 🧩 Plugin bundles published (built in this run)"
echo "- identity: \`$(cat "$BAKE_PLUGINS_DIR/framework-mvid.txt")\` (equal to the platform bake's, asserted)"
echo "- version: \`$RELEASE_VERSION\` (this run's, not manifest.lock)"
echo "- plugins commit: \`$PLUGINS_SHA\`"
} >> "$GITHUB_STEP_SUMMARY"
- name: "Publish bundles to every portal target"
env:
BAKE_PUBLISH_TARGETS: ${{ vars.BAKE_PUBLISH_TARGETS }}
Expand Down
18 changes: 12 additions & 6 deletions src/MeshWeaver.Documentation/Data/Architecture/CiContentBake.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,15 @@ This page describes workstream 1, step 1 — the pieces that exist and how they

CI already compiles the image's shipped content as a *verdict*: the `doc-gate` job runs
`mw-plugin-test` over `src/MeshWeaver.Documentation/Data` (staged as the `Doc` package) and over the
`samples/Graph/Data` trees, and the `plugin-gate` job runs the same tester over the vital modules of
the MeshWeaver.Plugins checkout (synced at build time; the checked-out commit is recorded as each
bundle's `sourceSha`).
`samples/Graph/Data` trees. That is the platform's OWN content, and it is the only content the
platform bakes.

🚨 **The platform never syncs or bakes plugin source.** Until 2026-08-27 core CD checked out
`MeshWeaver.Plugins`, Roslyn-compiled every module, and published the result as the `plugins`
source — the same shelf Plugins' own `publish-bake` writes. Two producers, one shelf, and a full
compile of source the platform does not own on every platform push. Removed: Plugins bakes
Plugins and publishes it; the platform's portals *install* it. See
[The Plugin Build Contract](/Doc/Architecture/PluginBuildContract) → "Three rules that follow".

Each of those lanes runs the bake and the gate as **two steps** (`.github/scripts/bake-then-gate.sh`
— the same split `main-cd` makes, described below): `compile <stage> --output <dir>` produces the
Expand Down Expand Up @@ -57,9 +63,9 @@ N may legitimately EXCEED M: `N` counts adoption events and the gate installs ev

🚨 **What the gates' bake is FOR: proving the bake stage still works, on the PR that breaks it.** It
is *not* the delivery lane. A gate job's bundles are keyed to that job's own binaries, and no
shipped image ever contains those (see "The identity rule" below), so `doc-gate` uploads nothing;
`plugin-gate` still uploads `baked-plugins-<mvid>` for in-run diagnosis. What the portals adopt is
baked **inside the shipped image** — see "The delivery" below.
shipped image ever contains those (see "The identity rule" below), so `doc-gate` uploads nothing.
What the portals adopt is baked **inside the shipped image** for the platform's content, and
**published by each plugin repo's own `publish-bake`** for plugins — see "The delivery" below.

## BAKE is a build step; GATE is a mesh run that CONSUMES one

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,35 @@ in Entra would make it work — and would be one more rule in the wrong place.

---

## Three rules that follow, stated so they cannot be re-derived wrongly

**1. The platform never bakes a plugin.** Core's CD builds the platform and bakes the platform's
OWN shipped content (`meshweaver-content`). It does not check out `MeshWeaver.Plugins` to compile
it, and it does not publish a `plugins` source. Until 2026-08-27 it did both — `main-cd.yml` synced
a Plugins checkout, Roslyn-compiled every module, and published the result to the same
`prebuilt-bundles/<identity>/plugins/` shelf that Plugins' own `publish-bake` writes. Two
producers, one shelf, whichever sealed last winning; and a full compile of source the platform
does not own, on every platform push. Plugins bakes Plugins. (The two checkouts that remain in
core CD fetch the portal HOST and migration WORKER projects, which live in that repo since #2293
— source the image is built FROM, not a plugin being baked.)

**2. Nothing syncs plugin source to bake it.** "Sync the source and compile it" is the pattern
every violation shares: core's `plugin-gate`, the satellites' `stage-repo`, Education's fused
checkout. The consumer of a plugin receives a **publication** — bundles carrying node definitions
and compiled assemblies, sealed under a framework identity — and installs it. If a lane needs a
plugin and reaches for `git clone` of its repo, that lane is wrong.

**3. A source install is git on disk, never rows in a database.** When a mesh installs a plugin
from source (a local dev loop, an e2e mesh, a GitSynced space), the package source is a git
checkout the mesh READS — `GitPackageSource(git, repoPath)`, `git ls-tree` / `git show` at a ref —
so the repo stays the only source of truth and a re-sync rewrites the space from it. An importer
that copies plugin nodes into the store as durable rows creates a second copy that the repo no
longer owns: the live edit that "sticks" until the next sync silently reverts it, the
`MonotonicWriteGuard` refusals when a committed `version` collides with the store's clock. The
mesh reads the tree; it does not ingest it.

---

## The contract, restated as checks

For any plugin repo, these are answerable and should be answered:
Expand Down
Loading