11defmodule Mix.Tasks.Compile.Appup do
22 @ moduledoc """
33 Compiles appup files into the application's ebin folder.
4+
5+ The `:appup` project key names a file, relative to the project file, that is
6+ evaluated for its value. It must not introduce top-level bindings. Whatever it
7+ returns is written to `<app>.appup` alongside the application's beams.
8+
9+ The output is removed again whenever the project stops asking for it, either
10+ because the source is gone or because the `:appup` key was dropped. Leaving it
11+ in place would let an incremental build - which is what a CI cache produces -
12+ ship upgrade instructions from an earlier version of the application, and
13+ `release_handler` would then apply that obsolete plan during a hot upgrade.
14+
15+ A configured but missing source is a compilation error: the project asked for
16+ an appup and cannot have one, and the alternative is a release that only fails
17+ later, in `:systools.make_relup/4` or during the upgrade itself.
18+
19+ Removal only happens while the compiler is registered. Taking `:appup` out of
20+ `:compilers` stops it running at all, and whatever an earlier build wrote then
21+ stays where it is - as it would for any Mix compiler dropped from the list. To
22+ turn an appup off per environment, leave the compiler registered and let the
23+ `:appup` key be `nil`: that path removes the output and says nothing further.
424 """
525 @ shortdoc "Compiles appup files"
626 use Mix.Task.Compiler
@@ -9,23 +29,128 @@ defmodule Mix.Tasks.Compile.Appup do
929
1030 @ impl true
1131 def run ( _args ) do
12- if src = Mix.Project . config ( ) [ :appup ] do
13- if File . exists? ( src ) do
14- { appup , [ ] } = Code . eval_file ( src )
15- dst = Path . join ( Mix.Project . compile_path ( ) , "#{ Mix.Project . config ( ) [ :app ] } .appup" )
16- File . write ( dst , :io_lib . format ( ~c" ~tp.~n" , [ appup ] ) )
17- else
18- { :ok , diagnostic ( :warning , "Appup file not found: #{ src } " ) }
19- end
32+ dst = destination ( )
33+
34+ case source ( ) do
35+ nil -> discard ( dst )
36+ src -> build ( src , dst )
37+ end
38+ end
39+
40+ # `mix clean` also removes the application's whole build directory, so the
41+ # appup goes with it either way. This is the callback the behaviour asks any
42+ # compiler that writes output to define, and it keeps removing the artefact
43+ # the responsibility of the task that created it.
44+ @ impl true
45+ def clean do
46+ _ = File . rm ( destination ( ) )
47+ :ok
48+ end
49+
50+ # No manifests/0: the appup is regenerated on every build, so its timestamp
51+ # always advances, and advertising it as a manifest would tell everything that
52+ # consults `Mix.Task.Compiler.manifests/1` that the build changed every time.
53+ # Regenerating unconditionally is deliberate - the source is arbitrary code
54+ # whose result need not be a function of the source's own mtime.
55+
56+ defp build ( src , dst ) do
57+ if File . exists? ( src ) do
58+ write ( src , dst )
2059 else
21- { :ok , diagnostic ( :warning , "No appup specified in project" ) }
60+ discard ( dst , report ( :error , "Appup file not found: #{ src } " ) )
61+ end
62+ end
63+
64+ defp write ( src , dst ) do
65+ { appup , [ ] } = Code . eval_file ( src )
66+
67+ case encode ( appup ) do
68+ bytes when is_binary ( bytes ) -> put ( dst , bytes )
69+ _not_encodable -> { :error , report ( :error , "Could not encode the appup in #{ src } as UTF-8" ) }
2270 end
2371 end
2472
25- defp diagnostic ( severity , message , file \\ Mix.Project . project_file ( ) ) do
73+ # `:io_lib.format` with `~tp` returns Unicode chardata, not iodata. A codepoint
74+ # above 255 makes `File.write/2` fail outright; one between 128 and 255 is
75+ # written as a lone byte that `:file.consult/1` - which reads UTF-8 - cannot
76+ # read back, so the build reports success and ships an appup that `systools`
77+ # will not parse. An appup that is not what the build thinks it is happens to
78+ # be the whole failure this compiler exists to prevent.
79+ defp encode ( appup ) , do: :unicode . characters_to_binary ( :io_lib . format ( ~c" ~tp.~n" , [ appup ] ) )
80+
81+ defp put ( dst , bytes ) do
82+ case File . write ( dst , bytes ) do
83+ :ok ->
84+ :ok
85+
86+ { :error , reason } ->
87+ { :error , report ( :error , "Could not write #{ dst } : #{ :file . format_error ( reason ) } " ) }
88+ end
89+ end
90+
91+ # The project has opted out: `:appup` is unset. That is a legitimate state and
92+ # not something to report on every compile, so say nothing unless there is an
93+ # earlier build's output to take away - which happens on exactly one build.
94+ defp discard ( dst ) do
95+ case remove ( dst ) do
96+ :removed -> { :ok , report ( :information , "Removed #{ dst } : no appup is specified" ) }
97+ :absent -> :noop
98+ { :error , diagnostics } -> { :error , diagnostics }
99+ end
100+ end
101+
102+ # The project asked for an appup and cannot have one. Take the previous one
103+ # away regardless - leaving it behind is the whole bug - and fail with the
104+ # diagnostic that explains why.
105+ defp discard ( dst , diagnostics ) do
106+ case remove ( dst ) do
107+ { :error , extra } -> { :error , diagnostics ++ extra }
108+ _removed_or_absent -> { :error , diagnostics }
109+ end
110+ end
111+
112+ # Failing to remove the output is a compilation error in its own right: a
113+ # stale appup that could not be deleted is the bug still present.
114+ defp remove ( dst ) do
115+ case File . rm ( dst ) do
116+ :ok ->
117+ :removed
118+
119+ { :error , :enoent } ->
120+ :absent
121+
122+ { :error , reason } ->
123+ { :error , report ( :error , "Could not remove #{ dst } : #{ :file . format_error ( reason ) } " ) }
124+ end
125+ end
126+
127+ # Relative to the project file rather than the working directory. The compiler
128+ # is recursive, so Mix runs it from each umbrella child's own directory, but
129+ # nothing guarantees the working directory for a `mix compile` invoked from
130+ # elsewhere - and the answer now decides whether the output is deleted.
131+ defp source do
132+ if src = Mix.Project . config ( ) [ :appup ] do
133+ Path . expand ( src , Path . dirname ( Mix.Project . project_file ( ) ) )
134+ end
135+ end
136+
137+ defp destination do
138+ Path . join ( Mix.Project . compile_path ( ) , "#{ Mix.Project . config ( ) [ :app ] } .appup" )
139+ end
140+
141+ # Diagnostics returned from a compiler are for editors to display inline;
142+ # nothing on the command line prints them, and a compiler that fails without
143+ # saying why is worse than one that does not fail at all. Say it on stderr as
144+ # well, the way the rest of Forecastle reports build problems.
145+ defp report ( severity , message ) do
146+ Mix . shell ( ) . error ( "#{ severity } : #{ message } " )
147+ [ diagnostic ( severity , message ) ]
148+ end
149+
150+ defp diagnostic ( severity , message ) do
26151 % Mix.Task.Compiler.Diagnostic {
27152 compiler_name: "Appup" ,
28- file: file ,
153+ file: Mix.Project . project_file ( ) ,
29154 position: nil ,
30155 severity: severity ,
31156 message: message
0 commit comments