Skip to content

Commit f4ebcac

Browse files
authored
Merge pull request #35 from ausimian/issue/26-baseline-resolver
feat: baseline resolver for rel:, tar: and ref: specs
2 parents a77e97c + 257d834 commit f4ebcac

7 files changed

Lines changed: 3003 additions & 10 deletions

File tree

AGENTS.md

Lines changed: 338 additions & 0 deletions
Large diffs are not rendered by default.

README.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,63 @@ that sprang into existence is how a relup ends up somewhere nothing looks for.
302302
At least one of `--fromto`, `--upfrom` and `--downto` is required: a relup with
303303
no transitions in it is not an upgrade plan.
304304

305+
### Naming the baseline
306+
307+
The value those three switches take is a *baseline spec* - one grammar for the
308+
three places the release being upgraded from can come from:
309+
310+
```shell
311+
# an assembled release, named by its .rel file without the extension
312+
> mix castle.relup --target ... --fromto rel:_build/prod/rel/myapp/releases/1.0.0/myapp
313+
314+
# the artefact that shipped
315+
> mix castle.relup --target ... --fromto tar:artifacts/myapp-1.0.0.tar.gz
316+
317+
# a git ref, checked out into a worktree and built
318+
> mix castle.relup --target ... --fromto ref:v1.0.0
319+
```
320+
321+
A value with no prefix is a `rel:` path, so anything written before specs
322+
existed means what it always meant. The direction stays on the switch name and
323+
the source stays in the value. `--target` is *not* a spec: it names the release
324+
being generated for, which has just been assembled, so it is always a path.
325+
326+
**Prefer `tar:`, for correctness rather than convenience.** `release_handler`
327+
picks a relup entry by from-version *string*, and never checks that the code
328+
running is the code the relup was generated against. A baseline rebuilt from
329+
source gets today's Elixir, today's OTP and today's hex tarballs for anything
330+
the lock does not fully pin - so if the module set that comes out differs from
331+
what is deployed, the relup's instructions miss modules, and the upgrade loads
332+
part of the new code over a system still running the rest of the old. A relup
333+
generated against a rebuilt baseline describes a transition from a release that
334+
never existed.
335+
336+
`ref:` is the right answer for development, for testing an upgrade path before
337+
anything ships, and for the common case where nobody kept the artefact. It says
338+
on every use that what it produced was rebuilt rather than deployed. The commit
339+
is checked out into a git worktree, built, and the worktree removed. A shallow
340+
clone that does not hold the ref is told to `git fetch --tags --unshallow` rather
341+
than being left with an unknown revision from `git worktree add`, and
342+
`CASTLE_BASELINE` is set while a baseline is being built so that a build which
343+
asks for a baseline of its own is refused rather than going round again.
344+
345+
What `tar:` unpacks and what `ref:` builds are both kept under
346+
`_build/castle/baselines` and reused. An entry is written in a staging directory
347+
and renamed into place, so it exists only once it is whole: an interrupted run
348+
leaves nothing behind that a later one would treat as usable, and two runs
349+
resolving the same baseline at once each build their own with the first to finish
350+
winning. A `tar:` entry is keyed on a digest of the artefact's bytes rather than
351+
on its path, so a pipeline that rewrites the same filename is never served the
352+
previous build's release. A `ref:` entry is keyed on the resolved commit together
353+
with the Mix environment and target and the Elixir and ERTS versions it was built
354+
with, so a toolchain upgrade produces a fresh baseline rather than serving one
355+
compiled by the version before it.
356+
357+
Only worktree registrations inside that cache are ever cleaned up.
358+
`git worktree prune` is not used: it clears every stale registration in the
359+
repository, and a checkout on a disk that is not mounted today is
360+
indistinguishable from a dead one.
361+
305362
The task fails if it could not generate the relup, so a build pipeline can tell,
306363
and a failure writes nothing at all - so any earlier relup is still sitting where
307364
post-assembly looks for one, rather than having been replaced by a plan that was

RELEASE.md

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,93 @@
326326
would wait. The `env.sh` hook is also run directly, over a release-shaped directory,
327327
so that what it selects and what environment it leaves behind are asserted by
328328
observation rather than by reading the script.
329+
- `mix castle.relup`'s `--fromto`, `--upfrom` and `--downto` now take a
330+
*baseline spec*: one grammar naming the three places the release being upgraded
331+
from can come from. `rel:` is an assembled release, `tar:` is a shipped
332+
artefact, and `ref:` is a git ref that is checked out and built. A value with no
333+
prefix is a `rel:` path, so every invocation written before this means exactly
334+
what it meant then. The direction stays on the switch name and the source stays
335+
in the value. `--target` is not a spec: it names the release being generated
336+
for, which has just been assembled, and it says so if it is handed one.
337+
338+
**`tar:` is the source to prefer, and the reason is correctness rather than
339+
convenience.** `release_handler` selects a relup entry by from-version *string*
340+
and never checks that the code actually running is the code the relup was
341+
generated against. A baseline rebuilt from source is built with today's Elixir,
342+
today's OTP and today's hex tarballs for whatever the lock does not fully pin,
343+
so its module set can differ from the one that is deployed — and where it does,
344+
the relup's instructions miss modules and the upgrade loads part of the new
345+
code over a system still running the rest of the old. A relup generated against
346+
a rebuilt baseline describes a transition from a release that never existed.
347+
348+
`ref:` is still the right answer for development, for testing an upgrade path
349+
before anything ships, and for the common case where nobody kept the artefact —
350+
and it says on every use that what it produced was rebuilt rather than
351+
deployed. The commit is checked out into a git worktree, built there, and the
352+
worktree removed; what it built is kept, because none of it was ever inside the
353+
worktree. A shallow clone that does not hold the ref is named as such, with the
354+
`git fetch --tags --unshallow` that fixes it, rather than surfacing as an
355+
unknown revision from `git worktree add`. A project that does not sit at the top
356+
of its repository is built where it actually is. And because building an old
357+
commit runs that commit's own `mix.exs` — which in a project using Castle
358+
configures its release, and may want a relup of its own — `CASTLE_BASELINE`
359+
carries the sha being built, and a resolution that finds it set refuses rather
360+
than recursing.
361+
362+
Both `tar:` and `ref:` keep what they produced under
363+
`_build/castle/baselines`, and every entry there is immutable: the work happens
364+
in a staging directory and the finished thing is renamed into place, so an
365+
entry exists only once it is whole, an interrupted run leaves nothing a later
366+
one would take for a usable baseline, and two runs resolving the same baseline
367+
at once each build their own with the first to finish winning.
368+
369+
What an entry is keyed on is everything that could change its contents. A
370+
`tar:` artefact is keyed on a digest of its bytes rather than on its path, so a
371+
pipeline writing the same filename on every build is never served the previous
372+
build's release — and the artefact is copied before it is unpacked, so the bytes
373+
that were hashed are the bytes that get unpacked. A `ref:` baseline is keyed on
374+
the resolved commit *and* on what it was built with: which project inside the
375+
commit, the Mix environment and target, and the Elixir version, ERTS version
376+
and operating system. Without the toolchain versions, upgrading Elixir would
377+
leave every cached baseline compiled by the old one and the relup would be
378+
generated between module sets from two different compilers, which is the very
379+
drift that makes `tar:` the source to prefer; without the project, an
380+
umbrella's children would share one entry, since they share one `_build`.
381+
382+
What that key cannot cover is stated rather than approximated: a `mix.exs` is
383+
arbitrary code and may read anything to decide what it builds. Where a build
384+
depends on something outside the key, name the artefact with `tar:` or clear
385+
`_build/castle/baselines`.
386+
387+
A `tar:` artefact holding anything unpacking would not reproduce as itself is
388+
refused rather than unpacked in part. Erlang's tar reader writes regular files,
389+
directories and symlinks; a hard link is dropped and a device node or FIFO
390+
becomes an empty file, in both cases while still reporting success. A hard link
391+
is the one that turns up in practice — GNU tar writes one for the second copy
392+
of a file and a release tree has plenty of those — and it would come out as a
393+
missing `.beam` and a relup generated against a release short of modules. Two
394+
members that unpack to the *same* path are refused for the same reason: which
395+
of them survives is decided by the order they appear in rather than by the
396+
archive.
397+
398+
Naming two *different* baselines for the same release version is refused too.
399+
A relup carries one entry per from-version and `release_handler` selects by
400+
version, so only one of them could ever be used and which one would depend on
401+
the order the switches were written in. Specs make that easy to reach without
402+
meaning to — `tar:my_app-1.0.0.tar.gz` beside `ref:v1.0.0` is a natural thing
403+
to write while checking the two agree, and they may not. One release named two
404+
ways is not that, and is not refused: the relative, absolute and symlinked
405+
spellings of one release are recognised as the one file.
406+
407+
Two levels are available to callers: an appup coverage check needs only
408+
`mix compile` in the worktree, while a relup needs `mix release`, and on a real
409+
project that is a large difference.
410+
411+
Nothing in this touches worktree registrations it did not make. `git worktree
412+
prune` would clear a stale one in a word, but it clears every stale one in the
413+
repository, and a checkout on a disk that is not mounted today looks exactly
414+
like a dead one — so only registrations inside the baseline cache are removed,
415+
and never a locked one.
329416

330417
### Changed
331418

0 commit comments

Comments
 (0)