Package only application resource modules into build artifacts

bb2226b8b5be · Devin AI · · parent 40c292246811

Package only application resource modules into build artifacts

The builder sidecar packaged every cached BEAM under the persistent
build cache, so a stale BEAM from an earlier revision could fail
artifact validation with invalid_module_name even when the promoted
source tree was clean. Read the modules list from the generated
openagents.app resource and package exactly those BEAMs, failing
closed when the resource or a listed BEAM is missing.

Co-Authored-By: Christopher David <chris@openagents.com>
Co-Authored-By
Christopher David <chris@openagents.com>

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/production-deploy-runbook.md
  • modified lib/openagents/forge/build_worker.ex
  • modified test/openagents/forge/build_worker_test.exs

Diff

3 files changed, +65 -9

docs/operations/production-deploy-runbook.md modified +1

@@ -206,4 +206,5 @@ Do not report success without all of the following:

206 206
| `gate.sh --verify` fails in `build-image.sh` | Receipt is for a different SHA | Rerun the gate on the exact candidate |
207 207
| App boots without a new variable | Name missing from `ENV_NAMES` in the startup script | Add the export and the `ENV_NAMES` entry, re-apply metadata |
208 208
| Target build fails with `invalid_module_name` | A compiled module falls outside the artifact allowlist in `OpenAgents.Forge.BuildArtifact` (`OpenAgents.*`, `OpenAgentsWeb.*`, allowlisted protocol implementations, `Mix.Tasks.Openagents.*`) | Rename the module into an allowlisted namespace, or extend the pattern for a new generated-implementation family. `test/openagents/forge/build_artifact_namespace_test.exs` catches this in precommit |
209
| Target build fails with `invalid_module_name` even though every source module is allowlisted | The build cache at `$OPENAGENTS_FORGE_BUILD_DIR/cache/_build/prod` persists across builds, and a stale BEAM for a renamed or deleted module is still in `lib/openagents/ebin` | Since the application-resource packaging fix, `BuildWorker.read_candidate_beams/1` packages only modules listed in the generated `openagents.app`, so stale BEAMs are ignored. On an older build, delete the stale `.beam` from the cache `ebin` on the node that ran the build and re-promote |
209 210
| Intermittent git-over-HTTP `500` on push and fetch | One fleet node has a structurally invalid bare-repository cache (for example `HEAD` present but `refs/` missing), and `Repos.ensure_repo_at!` crashed on it. The load balancer alternates between healthy nodes and the broken one | Since the quarantine fix in `OpenAgents.Forge.Repos`, the node moves the invalid cache to `<repo>.git.corrupt-<n>` and re-materializes from the WAL on the next read. On an older build, find the node whose log shows `fatal: not a git repository` with a `MatchError` from `Repos.set_default_branch!/2`, move the cache directory aside inside the container, and let WAL replay rebuild it (`docs/operations/forge-cache-recovery.md`) |
lib/openagents/forge/build_worker.ex modified +30 -9

@@ -321,28 +321,49 @@ defmodule OpenAgents.Forge.BuildWorker do

321 321
    end
322 322
  end
323 323
324
  defp read_candidate_beams(build_path) do
325
    paths = Path.wildcard(Path.join(build_path, "lib/openagents/ebin/*.beam"))
324
  @doc """
325
  Read the compiled application BEAMs for packaging.
326 326
327
    if paths == [] do
328
      {:error, :application_beams_missing}
329
    else
330
      paths
331
      |> Enum.reduce_while({:ok, []}, fn path, {:ok, acc} ->
332
        module = Path.basename(path, ".beam")
327
  Reads only the modules listed in the generated `openagents.app` resource.
328
  The build cache persists across builds, so `ebin` can hold stale BEAMs for
329
  modules that no longer exist at the candidate source. Packaging from the
330
  application resource keeps those out of the artifact.
331
  """
332
  def read_candidate_beams(build_path) do
333
    ebin = Path.join(build_path, "lib/openagents/ebin")
333 334
334
        case File.read(path) do
335
    with {:ok, modules} <- application_modules(Path.join(ebin, "openagents.app")) do
336
      modules
337
      |> Enum.reduce_while({:ok, []}, fn module, {:ok, acc} ->
338
        case File.read(Path.join(ebin, module <> ".beam")) do
335 339
          {:ok, binary} -> {:cont, {:ok, [%{module: module, binary: binary} | acc]}}
336 340
          {:error, reason} -> {:halt, {:error, {:beam_read_failed, reason}}}
337 341
        end
338 342
      end)
339 343
      |> case do
344
        {:ok, []} -> {:error, :application_beams_missing}
340 345
        {:ok, beams} -> {:ok, Enum.sort_by(beams, & &1.module)}
341 346
        error -> error
342 347
      end
343 348
    end
344 349
  end
345 350
351
  defp application_modules(app_file) do
352
    case :file.consult(String.to_charlist(app_file)) do
353
      {:ok, [{:application, :openagents, properties}]} ->
354
        case Keyword.get(properties, :modules) do
355
          modules when is_list(modules) and modules != [] ->
356
            {:ok, Enum.map(modules, &Atom.to_string/1)}
357
358
          _other ->
359
            {:error, :application_beams_missing}
360
        end
361
362
      _other ->
363
        {:error, :application_resource_invalid}
364
    end
365
  end
366
346 367
  defp run_ok(executable, args, cwd, env, output) do
347 368
    case run_command(executable, args, cwd, env, output) do
348 369
      {:ok, _excerpt} -> :ok
test/openagents/forge/build_worker_test.exs modified +34

@@ -242,6 +242,40 @@ defmodule OpenAgents.Forge.BuildWorkerTest do

242 242
    assert fresh_response["build_id"] != expired_response["build_id"]
243 243
  end
244 244
245
  test "candidate packaging reads only modules in the application resource", %{builds: builds} do
246
    ebin = Path.join(builds, "lib/openagents/ebin")
247
    File.mkdir_p!(ebin)
248
249
    File.write!(
250
      Path.join(ebin, "openagents.app"),
251
      "{application, openagents, [{modules, ['Elixir.OpenAgents.Current']}]}."
252
    )
253
254
    File.write!(Path.join(ebin, "Elixir.OpenAgents.Current.beam"), "current")
255
    File.write!(Path.join(ebin, "Elixir.Mix.Tasks.Stale.Leftover.beam"), "stale")
256
257
    assert {:ok, [%{module: "Elixir.OpenAgents.Current", binary: "current"}]} =
258
             BuildWorker.read_candidate_beams(builds)
259
  end
260
261
  test "candidate packaging fails when a listed module has no BEAM", %{builds: builds} do
262
    ebin = Path.join(builds, "lib/openagents/ebin")
263
    File.mkdir_p!(ebin)
264
265
    File.write!(
266
      Path.join(ebin, "openagents.app"),
267
      "{application, openagents, [{modules, ['Elixir.OpenAgents.Missing']}]}."
268
    )
269
270
    assert {:error, {:beam_read_failed, :enoent}} = BuildWorker.read_candidate_beams(builds)
271
  end
272
273
  test "candidate packaging fails without an application resource", %{builds: builds} do
274
    File.mkdir_p!(Path.join(builds, "lib/openagents/ebin"))
275
276
    assert {:error, :application_resource_invalid} = BuildWorker.read_candidate_beams(builds)
277
  end
278
245 279
  defp request(build_id, baseline, expires_at \\ DateTime.add(DateTime.utc_now(), 300, :second)) do
246 280
    BuildProtocol.request!(%{
247 281
      build_id: build_id,

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