Skip to content

Commit 4a08d0f

Browse files
committed
Store typed ReleaseKit manifests
1 parent 8bda60c commit 4a08d0f

6 files changed

Lines changed: 51 additions & 56 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# Changelog
22

3+
## v0.2.1 - 2026-06-23
4+
5+
### Changed
6+
7+
- Changed ETF manifests to store the typed `%ReleaseKit.Manifest{}` contract directly.
8+
- Added `ReleaseKit.Manifest.read!/1` and validation for typed manifest consumers.
9+
310
## v0.2.0 - 2026-06-23
411

512
### Added

README.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -164,10 +164,10 @@ MIX_ENV=prod mix release_kit.artifact --out-dir _build/prod/artifacts
164164

165165
## Manifest shape
166166

167-
The manifest is an ETF-encoded map:
167+
The manifest is an ETF-encoded `%ReleaseKit.Manifest{}` struct:
168168

169169
```elixir
170-
%{
170+
%ReleaseKit.Manifest{
171171
tool: "release_kit",
172172
format: :beam_release_artifact,
173173
format_version: 2,
@@ -176,7 +176,7 @@ The manifest is an ETF-encoded map:
176176
version: "20260620-abcdef0",
177177
mix_env: "prod",
178178
tarball: "/absolute/path/to/my_app-20260620-abcdef0.tar.gz",
179-
artifact: %{
179+
artifact: %ReleaseKit.ArtifactInfo{
180180
path: "/absolute/path/to/my_app-20260620-abcdef0.tar.gz",
181181
size: 1_234_567,
182182
compression: :gzip,
@@ -186,16 +186,16 @@ The manifest is an ETF-encoded map:
186186
sidecar: "/absolute/path/to/my_app-20260620-abcdef0.tar.gz.sha256"
187187
}
188188
},
189-
target: %{os: "linux", arch: "x86_64", libc: "glibc"},
190-
build: %{
189+
target: %ReleaseKit.Target{os: "linux", arch: "x86_64", libc: "glibc"},
190+
build: %ReleaseKit.BuildInfo{
191191
release_kit_version: "0.2.0",
192192
elixir_version: "1.20.0",
193193
otp_version: "28",
194194
git_revision: "abcdef0",
195195
git_dirty?: false
196196
},
197197
assets: [
198-
%{
198+
%ReleaseKit.AssetInfo{
199199
tool: :volt,
200200
profile: :my_app_web,
201201
outdir: "priv/static/assets",

lib/release_kit/manifest.ex

Lines changed: 24 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -72,21 +72,6 @@ defmodule ReleaseKit.Manifest do
7272
}
7373
end
7474

75-
@doc """
76-
Converts a manifest struct to the stable external map stored in ETF files.
77-
"""
78-
@spec to_external_map(t()) :: map()
79-
def to_external_map(%__MODULE__{} = manifest) do
80-
manifest
81-
|> Map.from_struct()
82-
|> Map.update!(:artifact, &external_artifact/1)
83-
|> Map.update!(:target, &external_target/1)
84-
|> Map.update!(:build, &external_build/1)
85-
|> Map.update!(:assets, &external_assets/1)
86-
|> Enum.reject(fn {_key, value} -> is_nil(value) or value == [] end)
87-
|> Map.new()
88-
end
89-
9075
@doc """
9176
Writes a manifest as ETF.
9277
"""
@@ -96,29 +81,35 @@ defmodule ReleaseKit.Manifest do
9681
|> Path.dirname()
9782
|> File.mkdir_p!()
9883

99-
File.write!(path, :erlang.term_to_binary(to_external_map(manifest)))
84+
File.write!(path, :erlang.term_to_binary(manifest))
10085
end
10186

102-
defp external_artifact(nil), do: nil
103-
104-
defp external_artifact(%ReleaseKit.ArtifactInfo{} = artifact),
105-
do: ReleaseKit.ArtifactInfo.to_external_map(artifact)
106-
107-
defp external_target(nil), do: nil
108-
109-
defp external_target(%ReleaseKit.Target{} = target),
110-
do: ReleaseKit.Target.to_external_map(target)
87+
@doc """
88+
Reads a typed manifest from ETF.
89+
"""
90+
@spec read!(Path.t()) :: t()
91+
def read!(path) when is_binary(path) do
92+
path
93+
|> File.read!()
94+
|> :erlang.binary_to_term()
95+
|> validate!()
96+
end
11197

112-
defp external_build(nil), do: nil
98+
@doc """
99+
Validates a typed manifest contract.
100+
"""
101+
@spec validate!(term()) :: t()
102+
def validate!(%__MODULE__{format: :beam_release_artifact, format_version: 2} = manifest) do
103+
manifest
104+
end
113105

114-
defp external_build(%ReleaseKit.BuildInfo{} = build),
115-
do: ReleaseKit.BuildInfo.to_external_map(build)
106+
def validate!(%__MODULE__{} = manifest) do
107+
raise ArgumentError,
108+
"unsupported ReleaseKit manifest #{inspect(manifest.format)} v#{inspect(manifest.format_version)}"
109+
end
116110

117-
defp external_assets(assets) do
118-
Enum.map(assets, fn
119-
%ReleaseKit.AssetInfo{} = asset -> ReleaseKit.AssetInfo.to_external_map(asset)
120-
asset -> asset
121-
end)
111+
def validate!(other) do
112+
raise ArgumentError, "expected %ReleaseKit.Manifest{}, got: #{inspect(other)}"
122113
end
123114

124115
defp env(opts) do

mix.exs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ defmodule ReleaseKit.MixProject do
44
def project do
55
[
66
app: :release_kit,
7-
version: "0.2.0",
7+
version: "0.2.1",
88
elixir: "~> 1.20",
99
start_permanent: Mix.env() == :prod,
1010
deps: deps(),

test/mix/tasks/release_kit.artifact_test.exs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,8 @@ defmodule Mix.Tasks.ReleaseKitArtifactTest do
4141
assert File.regular?(Path.join(out_dir, "release_kit-task-version.tar.gz.sha256"))
4242
assert File.regular?(Path.join(out_dir, "release_kit.etf"))
4343

44-
manifest = out_dir |> Path.join("release_kit.etf") |> File.read!() |> :erlang.binary_to_term()
45-
assert manifest.target == %{os: "linux", arch: "x86_64", libc: "glibc"}
44+
manifest = ReleaseKit.Manifest.read!(Path.join(out_dir, "release_kit.etf"))
45+
assert manifest.target == %ReleaseKit.Target{os: "linux", arch: "x86_64", libc: "glibc"}
4646

4747
assert_receive {:mix_shell, :info, ["Release tarball: " <> _]}
4848
assert_receive {:mix_shell, :info, ["ReleaseKit manifest: " <> _]}

test/release_kit/manifest_test.exs

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ defmodule ReleaseKit.ManifestTest do
33

44
alias ReleaseKit.Manifest
55

6-
test "builds deployment-neutral manifest map" do
6+
test "builds typed deployment-neutral manifest" do
77
manifest =
88
Manifest.new(
99
app: "demo_app",
@@ -17,7 +17,7 @@ defmodule ReleaseKit.ManifestTest do
1717
env_secret: ["SECRET_KEY_BASE"]
1818
)
1919

20-
assert Manifest.to_external_map(manifest) == %{
20+
assert %Manifest{
2121
tool: "release_kit",
2222
format: :beam_release_artifact,
2323
format_version: 2,
@@ -36,7 +36,7 @@ defmodule ReleaseKit.ManifestTest do
3636
port: 4000,
3737
url: "http://127.0.0.1:4000/health"
3838
}
39-
}
39+
} = manifest
4040
end
4141

4242
test "includes artifact, target, build, and asset metadata when provided" do
@@ -68,22 +68,20 @@ defmodule ReleaseKit.ManifestTest do
6868
]
6969
)
7070

71-
external = Manifest.to_external_map(manifest)
71+
assert %ReleaseKit.ArtifactInfo{checksum: %{value: "abc123"}} = manifest.artifact
72+
assert %ReleaseKit.Target{os: "linux", arch: "x86_64", libc: "glibc"} = manifest.target
73+
assert %ReleaseKit.BuildInfo{git_revision: "abcdef0"} = manifest.build
7274

73-
assert external.artifact.checksum.value == "abc123"
74-
assert external.target == %{os: "linux", arch: "x86_64", libc: "glibc"}
75-
assert external.build.git_revision == "abcdef0"
76-
77-
assert external.assets == [
78-
%{
75+
assert [
76+
%ReleaseKit.AssetInfo{
7977
tool: :volt,
8078
profile: :demo_web,
8179
outdir: "priv/static/assets",
8280
manifest: "priv/static/assets/manifest.json",
8381
asset_url_prefix: "/assets",
8482
tailwind?: true
8583
}
86-
]
84+
] = manifest.assets
8785
end
8886

8987
test "writes ETF manifest" do
@@ -103,8 +101,7 @@ defmodule ReleaseKit.ManifestTest do
103101

104102
assert :ok = Manifest.write!(manifest, path)
105103

106-
loaded = path |> File.read!() |> :erlang.binary_to_term()
107-
assert loaded.release == "demo"
108-
refute Map.has_key?(loaded, :health_check)
104+
loaded = Manifest.read!(path)
105+
assert %Manifest{release: "demo", health_check: nil} = loaded
109106
end
110107
end

0 commit comments

Comments
 (0)