Classify nonembedded release-private changes as structural

189b89d935d9 · AtlantisPleb · · parent 6c94ff48cc83

Classify nonembedded release-private changes as structural

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified docs/operations/forge-hot-loop.md
  • modified docs/operations/forge-transactional-deployment.md
  • modified lib/openagents/forge/build_worker.ex
  • modified lib/openagents_web/docs_catalog.ex
  • modified test/openagents/forge/build_worker_test.exs

Diff

5 files changed, +57 -15

docs/operations/forge-hot-loop.md modified +11 -3

@@ -1,12 +1,12 @@

1 1
# Forge hot loop runbook
2 2
3
Date: 2026-08-21
3
Date: 2026-08-22
4 4
5 5
Status: Active in production. The Forge loop is the default deployment path
6 6
for allowlisted code changes. Relup and rolling replacement remain fallbacks.
7 7
8 8
This is the operator procedure for the fast deployment lane: push to the owned
9
forge, promote, and watch a code-only change go live across the fleet in
9
forge, promote, and watch a compatible change go live across the fleet in
10 10
seconds without an image build. It also describes the production relup path,
11 11
the independent GitHub mirror repair worker, and the production activation
12 12
evidence.

@@ -23,11 +23,18 @@ evidence.

23 23
24 24
Two consequences worth stating plainly:
25 25
26
- Code-only changes do **not** require an image roll once the loop is enabled;
26
- BEAM-only changes do **not** require an image roll once the loop is enabled;
27 27
  the whole web layer is allowlisted.
28 28
- Changes to `config/config.exs` or `config/runtime.exs` remain structural.
29 29
  The classifier must refuse them for direct loading and route them to a full
30 30
  release path.
31
- Changes under `priv/` remain structural because a BEAM transaction cannot
32
  install runtime programs, migrations, or other release-private files. The
33
  classifier preserves the more specific `assets_changed` and
34
  `migration_changed` reasons for `priv/static/` and `priv/repo/migrations/`.
35
  `priv/docs/` is the deliberate exception: `DocsCatalog` embeds every page as
36
  an external compiler resource, so its allowlisted BEAM carries the complete
37
  immutable documentation snapshot.
31 38
32 39
## Status reporting
33 40

@@ -148,6 +155,7 @@ this fallback order:

148 155
| --- | --- | --- |
149 156
| Target stalls at `building`, fails `build_timeout` | No sidecar claimed the request | Check the builder container is running and the queue volume is shared |
150 157
| `needs_rolling_replace` with `structural_reasons` | Honest refusal: config, dependencies, assets, or release files changed | Try the packaged relup path; use an operator-directed image rollout when the release is incompatible |
158
| `needs_rolling_replace` with `release_priv_changed` | Nonembedded runtime content under `priv/` changed | Use a packaged release so every node receives the new private files; do not settle the target from a BEAM-only load |
151 159
| `needs_rolling_replace` with `off_allowlist:` reasons | The diff touched modules outside the allowlist | Widen deliberately in config, or route around the change |
152 160
| Artifact verification failure | Digest or manifest mismatch between builder and coordinator | Treat as a builder defect; inspect the retained build output |
153 161
| A node restarts mid-fleet-deploy | Membership recheck pauses phases | Boot convergence holds the node out until it converges |
docs/operations/forge-transactional-deployment.md modified +8 -2

@@ -1,8 +1,9 @@

1 1
# Forge transactional deployment
2 2
3
Date: 2026-08-20
3
Date: 2026-08-22
4 4
5
Status: Implemented locally; keep staging deployment disabled until the Gate 12 distributed staging lane exists
5
Status: Active in production for verified BEAM-only candidates. Use the packaged
6
relup or rolling-replacement lane for structural candidates.
6 7
7 8
## Purpose
8 9

@@ -15,6 +16,11 @@ Use this lane only for artifacts classified as `direct_candidate` whose every

15 16
changed module matches the operator-owned allowlist. Send structural changes to
16 17
the relup or rolling-replacement lanes.
17 18
19
The source classifier treats nonembedded release-private files under `priv/` as
20
structural. Direct loading cannot install those files even when the same commit
21
also changes an allowlisted module. `priv/docs/` is the deliberate exception:
22
`DocsCatalog` compiles every Markdown file into its allowlisted BEAM artifact.
23
18 24
## Preconditions
19 25
20 26
The coordinator refuses deployment unless all of these conditions hold:
lib/openagents/forge/build_worker.ex modified +11 -3

@@ -286,14 +286,20 @@ defmodule OpenAgents.Forge.BuildWorker do

286 286
      reasons =
287 287
        diff
288 288
        |> String.split("\n", trim: true)
289
        |> Enum.flat_map(&structural_reason/1)
290
        |> Enum.uniq()
291
        |> Enum.sort()
289
        |> classify_source_paths()
292 290
293 291
      {:ok, reasons}
294 292
    end
295 293
  end
296 294
295
  @doc false
296
  def classify_source_paths(paths) when is_list(paths) do
297
    paths
298
    |> Enum.flat_map(&structural_reason/1)
299
    |> Enum.uniq()
300
    |> Enum.sort()
301
  end
302
297 303
  defp structural_reason("mix.lock"), do: ["dependency_lock_changed"]
298 304
  defp structural_reason("mix.exs"), do: ["dependency_definition_changed"]
299 305
  defp structural_reason("Dockerfile"), do: ["runtime_image_changed"]

@@ -302,6 +308,8 @@ defmodule OpenAgents.Forge.BuildWorker do

302 308
  defp structural_reason("assets/" <> _path), do: ["assets_changed"]
303 309
  defp structural_reason("priv/static/" <> _path), do: ["assets_changed"]
304 310
  defp structural_reason("priv/repo/migrations/" <> _path), do: ["migration_changed"]
311
  defp structural_reason("priv/docs/" <> _path), do: []
312
  defp structural_reason("priv/" <> _path), do: ["release_priv_changed"]
305 313
  defp structural_reason("rel/" <> _path), do: ["release_changed"]
306 314
  defp structural_reason("native/" <> _path), do: ["nif_changed"]
307 315
  defp structural_reason("c_src/" <> _path), do: ["nif_changed"]
lib/openagents_web/docs_catalog.ex modified +17 -7

@@ -2,10 +2,13 @@ defmodule OpenAgentsWeb.DocsCatalog do

2 2
  @moduledoc """
3 3
  The documentation's table of contents, and the loader for its pages.
4 4
5
  Pages are Markdown files under `priv/docs`, read at runtime and rendered
6
  through `OpenAgents.Markdown`, which is the same safe CommonMark path the
7
  chat surface uses. Documentation is content, not code, so it lives in files
8
  a writer can edit rather than in HEEx a writer cannot.
5
  Pages are Markdown files under `priv/docs`, embedded as one immutable
6
  compile-time snapshot, and rendered through `OpenAgents.Markdown`, which is
7
  the same safe CommonMark path the chat surface uses. Each source is an
8
  external compiler resource, so editing Markdown recompiles this allowlisted
9
  module and the Forge can deploy the complete snapshot transactionally.
10
  Documentation remains content that a writer can edit rather than HEEx that a
11
  writer cannot.
9 12
10 13
  Every page here documents something a visitor can actually reach today. A
11 14
  documentation site that describes features that do not exist is worse than

@@ -14,6 +17,14 @@ defmodule OpenAgentsWeb.DocsCatalog do

14 17
  asserts every one of them resolves in the router.
15 18
  """
16 19
20
  @docs_dir Path.expand("../../priv/docs", __DIR__)
21
  @doc_files Path.wildcard(Path.join(@docs_dir, "*.md"))
22
  for path <- @doc_files, do: @external_resource(path)
23
24
  @pages Map.new(@doc_files, fn path ->
25
           {path |> Path.basename() |> Path.rootname(), File.read!(path)}
26
         end)
27
17 28
  @sections [
18 29
    %{
19 30
      title: "Getting started",

@@ -175,7 +186,7 @@ defmodule OpenAgentsWeb.DocsCatalog do

175 186
  end
176 187
177 188
  @doc """
178
  Read and render one page.
189
  Render one page from the compiled documentation snapshot.
179 190
180 191
  Returns the rendered HTML, the headings found in it, and the Markdown source
181 192
  it came from, so a page, its table of contents, and the text the copy button

@@ -183,8 +194,7 @@ defmodule OpenAgentsWeb.DocsCatalog do

183 194
  """
184 195
  def render(slug) do
185 196
    with %{} = item <- fetch(slug),
186
         path = Path.join(source_dir(), "#{slug}.md"),
187
         {:ok, markdown} <- File.read(path) do
197
         {:ok, markdown} <- Map.fetch(@pages, slug) do
188 198
      toc = headings(markdown)
189 199
      # Authored prose, not a message: the source is wrapped for editing, and
190 200
      # those wraps are not line breaks the reader should see.
test/openagents/forge/build_worker_test.exs modified +10

@@ -194,6 +194,16 @@ defmodule OpenAgents.Forge.BuildWorkerTest do

194 194
    assert File.read!(Path.join([cache.deps, "sample", "mix.exs"])) == "dep"
195 195
  end
196 196
197
  test "classifies nonembedded release-private files as structural" do
198
    assert BuildWorker.classify_source_paths(["priv/docs/cli-api.md"]) == []
199
200
    assert BuildWorker.classify_source_paths([
201
             "priv/programs/sarah/program.md",
202
             "priv/static/app.css",
203
             "priv/repo/migrations/20260822000000_add_example.exs"
204
           ]) == ["assets_changed", "migration_changed", "release_priv_changed"]
205
  end
206
197 207
  test "expired request IDs cannot be revived by a later attempt", context do
198 208
    expired_id = Ecto.UUID.generate()
199 209
    fresh_id = Ecto.UUID.generate()

This page updates live while a promote is in flight · changelog