Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
567d69f
Plan step 1: Move `HealthCheckTags` into `NCode.Registration`
polewskm Nov 6, 2026
eaea2f7
Plan step 2: Add `MapHealthProbes()` to `NCode.AspNetCore`
polewskm Nov 6, 2026
2e8d37c
Plan step 3: Map both hosts' probes with `MapHealthProbes()`
polewskm Nov 6, 2026
677c581
Plan step 4: Record the health-probe decision (ADR-1456)
polewskm Nov 6, 2026
f6df6cf
Plan step 5: Add `SinglePageBundle<TIndex>` to `NCode.AspNetCore`
polewskm Nov 6, 2026
fd314cb
Plan step 6: Test `SinglePageBundle<TIndex>`
polewskm Nov 6, 2026
e2b7e83
Plan step 7: Serve the interactive bundle through `SinglePageBundle<I…
polewskm Nov 6, 2026
a4e310d
Plan step 8: Delete the interactive bundle reader
polewskm Nov 6, 2026
ced7f07
Plan step 9: Add `ManagementConsolePage`
polewskm Nov 6, 2026
0fd634a
Plan step 10: Serve the console bundle through `SinglePageBundle<Mana…
polewskm Nov 6, 2026
6ec107d
Plan step 11: Delete the console's bundle reader and asset handler
polewskm Nov 6, 2026
d5c571c
Plan step 12: Record the single-page bundle decision (ADR-1457)
polewskm Nov 6, 2026
6d35582
Plan step 13: Add the console host's Dockerfile
polewskm Nov 6, 2026
7829fcb
Plan step 14: Point the build context and Dependabot at both images
polewskm Nov 6, 2026
b64ce36
Plan step 15: Add the `Console image` workflow
polewskm Nov 6, 2026
9a1fb3c
Plan step 16: Add the chart's `managementConsole` values and schema
polewskm Nov 6, 2026
f60273f
Plan step 17: Add the console's chart helpers
polewskm Nov 6, 2026
5a87dd8
Plan step 18: Add the console's Deployment, Service and ServiceAccount
polewskm Nov 6, 2026
1394fe0
Plan step 19: Add the console's Ingress, PodDisruptionBudget and notes
polewskm Nov 6, 2026
b4362fe
Plan step 20: Check the console component in the `Host chart` workflow
polewskm Nov 6, 2026
a3bc463
Plan step 21: Document the console component in the chart
polewskm Nov 6, 2026
13812af
Plan step 22: Document the console image
polewskm Nov 6, 2026
2a37e9f
Plan step 23: Record the console deployment decision (ADR-1458)
polewskm Nov 6, 2026
ad25f3f
Plan step 24: Catalog the change in CHANGELOG.md
polewskm Nov 6, 2026
bd8afd6
Re-plan step 27: Name the Console image workflow in the console Docke…
polewskm Nov 6, 2026
159c1c6
Plan step 25: Fix the relative bundle-root test for the normalized co…
polewskm Nov 6, 2026
0a157ee
Plan step 26: Give each image workflow its own GitHub Actions cache s…
polewskm Nov 6, 2026
73af707
Plan step 27: Name the `Console image` workflow in the console Docker…
polewskm Nov 6, 2026
851b273
Plan step 28: Merge origin/main in
polewskm Nov 6, 2026
a8da0da
Plan step 29: Encode the console page strictly and document its bundl…
polewskm Nov 6, 2026
5f10dd6
Plan step 30: Narrow the probe test's delegate, assert the asset's la…
polewskm Nov 6, 2026
24900c8
Plan step 31: Check the console Deployment's update strategy in the H…
polewskm Nov 6, 2026
9ec8e47
Plan step 32: Describe the image checks as they are and rewrap the to…
polewskm Nov 6, 2026
b583b7a
Plan step 33: Merge origin/main in
polewskm Nov 7, 2026
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
7 changes: 4 additions & 3 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
# An allow-list: the build context carries only what the host image is built from (ADR-1178). Everything else — .git,
# tests, docs, local settings, and build outputs — never reaches the Docker daemon.
# An allow-list: the build context carries only what the host and management console images are built from (ADR-1178).
# Everything else — .git, tests, docs, local settings, and build outputs — never reaches the Docker daemon.
#
# A file that a root build file imports must be re-admitted here too, as build/ProjectTemplate.props and the files it
# imports are. The Definition of Done does not build the image; only the Host image check finds a missing one.
# imports are. The Definition of Done does not build the image; only the Host image and Console image checks find a
# missing one.
*

!.editorconfig
Expand Down
7 changes: 5 additions & 2 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -107,11 +107,14 @@ updates:
applies-to: security-updates
patterns: ['*']

# The host image's base images (ADR-1249). A digest pin receives operating-system and runtime fixes only through this
# The host and management console images' base images (ADR-1249). Both images share the same base images, so one pull
# request moves both. A digest pin receives operating-system and runtime fixes only through this
# stream, so it runs while issue #36 pauses the other ecosystems. A runtime major moves deliberately, together with
# src/frontend/.node-version or the target framework, so semver-major updates are ignored.
- package-ecosystem: docker
directory: /src/NCode.Identity.OpenId.Host
directories:
- /src/NCode.Identity.OpenId.Host
- /src/NCode.Identity.ManagementConsole.Host
schedule:
interval: weekly
open-pull-requests-limit: 5
Expand Down
11 changes: 8 additions & 3 deletions .github/instructions/csharp-production.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,7 @@ than inlining the rationale.
`ConfigureKestrel(k => k.ConfigureHttpsDefaults(...))` call that holds every HTTPS default, such as the reference
host's `AllowAnyClientCertificate()` for mutual TLS
([ADR-1202](../../docs/decisions/1202-fapi-1-0-advanced-is-a-second-registered-security-profile.md)).
- 👁 **A host maps its probes with `MapHealthProbes()`** (`NCode.AspNetCore`), and a package tags a check a replica cannot serve without with `HealthCheckTags.Readiness` (`NCode.Registration`) ([ADR-1456](../../docs/decisions/1456-the-hosts-map-their-health-probes-through-one-general-helper.md)).
- ✅ **C# 14 `extension` member syntax** — template
[DI Registration and Extension Methods](csharp.instructions.md#di-registration-and-extension-methods)
(`extension(IServiceBuilder<IdentityLibrary> builder) { … }` in `DefaultRegistration.cs`).
Expand Down Expand Up @@ -660,9 +661,13 @@ than inlining the rationale.
[ADR-1253](../../docs/decisions/1253-the-interactive-bundle-mounts-at-once-over-the-page-the-host-rendered.md).
- 👁 **A content-hashed static file is served by a route group that resolves no tenant.**
`InteractiveBundleAssetEndpointGroup` takes the tenant-route prefix and adds no `OpenIdEnvironmentEndpointFilter`. It
reads through the `assets`-rooted `InteractiveBundleReader`, so nothing outside `assets`, the bundle's `index.html`
included, is servable, and it sends a served file `Cache-Control: public, max-age=31536000, immutable`. See
[ADR-1254](../../docs/decisions/1254-the-interactive-bundles-hashed-files-are-served-immutable-without-resolving-the-tenant.md).
reads through `NCode.AspNetCore`'s `SinglePageBundle<InteractiveBundleShell>`, whose file provider is rooted at
`assets`, so nothing outside `assets`, the bundle's `index.html` included, is servable, and `ServeAsset` sends a
served file `Cache-Control: public, max-age=31536000, immutable`. Every surface that serves a built bundle registers
its own `SinglePageBundle<TIndex>`. See
[ADR-1254](../../docs/decisions/1254-the-interactive-bundles-hashed-files-are-served-immutable-without-resolving-the-tenant.md)
and
[ADR-1457](../../docs/decisions/1457-a-single-page-bundle-is-read-and-served-through-one-general-helper.md).
- 👁 **Every HTML page the server writes sets its headers through `SetHtmlPageSecurity`.** Build the page's
`HtmlPageSecurityPolicy` from the strict default (`default-src 'none'`, `base-uri 'none'`, `form-action 'none'`,
`frame-ancestors 'none'` with `X-Frame-Options: DENY`) and list only the sources the page needs — an inline script by
Expand Down
4 changes: 2 additions & 2 deletions .github/instructions/repository.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,8 +205,8 @@ enables auto-merge, because the user self-reviews the draft and that step is the
reaches `main`
([ADR-1263](../../docs/decisions/1263-feature-branches-run-a-fast-gate-and-the-merge-queue-runs-the-full-gate.md)), so it
does **not** run before a local push. Neither gate builds the host image: when a change adds a file that a root
build file imports, re-admit it in [`.dockerignore`](../../.dockerignore) too, or only the `Host image` check on the
pull request finds it.
build file imports, re-admit it in [`.dockerignore`](../../.dockerignore) too, or only the `Host image` and
`Console image` checks on the pull request find it.
- **The frontend has its own gate.** `./build/dod.ps1` never runs the frontend's checks. A change under
`src/frontend` also runs CI's frontend job locally from `src/frontend`, with the pnpm version its `package.json`
names: `pnpm install --frozen-lockfile`, then `pnpm run format:check`, `lint`, `typecheck`, `build` and
Expand Down
94 changes: 94 additions & 0 deletions .github/workflows/console-image.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# Console image — builds the production image of NCode.Identity.ManagementConsole.Host (ADR-1458) for every published
# architecture.
#
# The image is the deployable artifact (ADR-1133), so a change that breaks its build — a project the publish needs, a
# frontend build step, the allow-list .dockerignore — fails here rather than at release. Nothing is pushed: publishing
# to GHCR belongs to the release workflow. Every stage that runs commands runs on the runner's own architecture and
# cross-publishes, so the arm64 image builds without emulation (ADR-1178).
name: Console image

on:
# A push gates only main and the release lines, because a pull request already gates every other branch (see ci.yml).
# The merge queue runs the check on the merged result, so a required check must report on merge_group (ADR-1263).
# Release lines are gated like main.
push:
branches: [main, 'release/**']
pull_request:
branches: [main, 'release/**']
merge_group:
types: [checks_requested]
workflow_dispatch:

permissions:
contents: read

# See the note in ci.yml: on main and the release lines the group includes the SHA so every commit of a protected line
# keeps its own verdict.
concurrency:
group: console-image-${{ github.ref }}-${{ (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/release/')) && github.sha || 'head' }}
cancel-in-progress: ${{ !(github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/release/')) }}

jobs:
build:
name: Build console image
runs-on: ubuntu-latest
timeout-minutes: 30

steps:
- name: Checkout
uses: actions/checkout@v7
with:
# Nothing in this job pushes, so the token stays out of .git/config, where later steps could read it.
persist-credentials: false

# The Dockerfile names Node by a literal tag and digest, which a FROM line cannot read from a file, so its major
# must equal the single pin in src/frontend/.node-version (ADR-1152, ADR-1249).
- name: Check the Node image follows .node-version
run: |
major=$(cat src/frontend/.node-version)
if ! grep -q "node:${major}-trixie-slim@sha256:" src/NCode.Identity.ManagementConsole.Host/Dockerfile; then
echo "::error::The Dockerfile must pin node:${major}-trixie-slim by digest (src/frontend/.node-version)"
exit 1
fi

# Third-party actions, so each is pinned to a commit; Dependabot moves the pin and its version comment together.
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1

# Each image writes its own cache scope, so the two workflows never replace each other's layers.
- name: Build
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
file: src/NCode.Identity.ManagementConsole.Host/Dockerfile
platforms: linux/amd64,linux/arm64
push: false
cache-from: type=gha,scope=console-image
cache-to: type=gha,mode=max,scope=console-image

# A multi-architecture build cannot be loaded into the local Docker daemon, so the amd64 image is built once more,
# from the cache, to be inspected.
- name: Load the amd64 image
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
file: src/NCode.Identity.ManagementConsole.Host/Dockerfile
platforms: linux/amd64
load: true
tags: ncode-identity-management-console:ci
cache-from: type=gha,scope=console-image

# The image carries the log format (ADR-1248), so a change that drops it fails here rather than in production.
- name: Check the image logs one-line JSON and serves its bundle
run: |
env=$(docker image inspect ncode-identity-management-console:ci --format '{{range .Config.Env}}{{println .}}{{end}}')
for setting in \
Logging__Console__FormatterName=json \
Logging__Console__FormatterOptions__UseUtcTimestamp=true \
Logging__Console__FormatterOptions__TimestampFormat=O \
ManagementConsole__BundleRootPath=/app/mgmt; do
if ! grep -qxF "$setting" <<< "$env"; then
echo "::error::The image does not set ${setting}"
exit 1
fi
done
2 changes: 1 addition & 1 deletion .github/workflows/frontend.yml
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ jobs:
working-directory: .
env:
NCODE_MGMT_BUNDLE_INDEX: ${{ github.workspace }}/src/frontend/apps/mgmt/dist/index.html
run: dotnet test tests/NCode.Identity.ManagementConsole.Tests/NCode.Identity.ManagementConsole.Tests.csproj --nologo --filter "FullyQualifiedName~ManagementConsoleBundleReaderTests.RenderIndex_WhenGivenTheBuiltBundlesIndex" -- RunConfiguration.TreatNoTestsAsError=true
run: dotnet test tests/NCode.Identity.ManagementConsole.Tests/NCode.Identity.ManagementConsole.Tests.csproj --nologo --filter "FullyQualifiedName~ManagementConsolePageTests.Render_WhenGivenTheBuiltBundlesIndex" -- RunConfiguration.TreatNoTestsAsError=true

- name: Upload test results
if: always()
Expand Down
48 changes: 47 additions & 1 deletion .github/workflows/host-chart.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
# The chart encodes the host's configuration surface, so it changes with the host and is checked on every change: each
# values file under ci/ must lint strictly and render, and each release the chart must refuse — the host would refuse
# it, or its replicas would not share state — must fail to render. The pre-deploy migration Job must render unless
# disabled (ADR-1180). Publishing the chart belongs to the release workflow.
# disabled (ADR-1180). The chart's management console component is checked too: it must render on a host of its own and
# stay opt-in (ADR-1458). Publishing the chart belongs to the release workflow.
name: Host chart

on:
Expand Down Expand Up @@ -133,3 +134,48 @@ jobs:
echo "::error::A release without Valkey must use the Recreate strategy."
exit 1
fi

- name: Deploy the management console on its own host
run: |
base="$CHART/ci/management-console-values.yaml"
refuse() {
if helm template release "$CHART" --values "$base" "$@" > /dev/null 2>&1; then
echo "::error::The chart rendered a management console release it must refuse: $*"
exit 1
fi
}
refuse --set managementConsole.host=
refuse --set 'managementConsole.host=*.example.com'
refuse --set 'allowedHosts={id.example.com}'
refuse --set managementConsole.authority=http://id.example.com
refuse --set tenantResolution.strategyCode=DynamicByHost --set tenantResolution.staticSingle.issuer=
refuse --set managementConsole.keys.clientKeySecret=
refuse --set managementConsole.keys.dpopKeySecret=
refuse --set managementConsole.replicaCount=2
# /api reaches the server for machine callers and every other path reaches the console (ADR-1437).
ingress=$(helm template release "$CHART" --values "$base" --show-only templates/management-console-ingress.yaml)
if ! grep -q 'path: /api' <<< "$ingress"; then
echo "::error::The management console Ingress does not route /api to the server."
exit 1
fi
if ! grep -Eq 'name: release-ncode-identity-host$' <<< "$ingress"; then
echo "::error::The management console Ingress does not name the server Service."
exit 1
fi
# The console is opt-in: a release that does not enable it renders none of its objects.
if helm template release "$CHART" --values "$CHART/ci/single-replica-values.yaml" | grep -q 'management-console'; then
echo "::error::The chart rendered the management console without managementConsole.enabled."
exit 1
fi
# Without Valkey the console never runs two replicas at once; with Valkey an update never drops below the count.
if ! helm template release "$CHART" --values "$base" \
--show-only templates/management-console-deployment.yaml | grep -q 'type: Recreate'; then
echo "::error::A management console without Valkey must use the Recreate strategy."
exit 1
fi
rolling=$(helm template release "$CHART" --values "$base" --set managementConsole.valkey.existingSecret=console-valkey \
--show-only templates/management-console-deployment.yaml)
if ! grep -q 'maxSurge: 1' <<< "$rolling" || ! grep -q 'maxUnavailable: 0' <<< "$rolling"; then
echo "::error::A management console with Valkey must roll out with maxSurge 1 and maxUnavailable 0."
exit 1
fi
7 changes: 4 additions & 3 deletions .github/workflows/host-image.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,15 +54,16 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1

# Each image writes its own cache scope, so the two workflows never replace each other's layers.
- name: Build
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
file: src/NCode.Identity.OpenId.Host/Dockerfile
platforms: linux/amd64,linux/arm64
push: false
cache-from: type=gha
cache-to: type=gha,mode=max
cache-from: type=gha,scope=host-image
cache-to: type=gha,mode=max,scope=host-image

# A multi-architecture build cannot be loaded into the local Docker daemon, so the amd64 image is built once more,
# from the cache, to be inspected.
Expand All @@ -74,7 +75,7 @@ jobs:
platforms: linux/amd64
load: true
tags: ncode-identity-host:ci
cache-from: type=gha
cache-from: type=gha,scope=host-image

# The image carries the log format (ADR-1248), so a change that drops it fails here rather than in production.
- name: Check the image logs one-line JSON
Expand Down
Loading
Loading