Repository: tweag/nickel
Pull request #1569 (2023-09-04 11:43:01)

Add infrastructure for on-demand ARM64 runners on AWS
With this change, ARM64 release artifacts will be built automatically by a GitHub workflow. Since GitHub doesn't offer hosted runners running on ARM64, we're spinning up an EC2 spot instance on demand and run the jobs building ARM64 artifacts there.

As a fun side note, the Terraform infrastructure code is written entirely in Nickel.


Patch:
diff --git a/.github/workflows/release-artifacts.yaml b/.github/workflows/release-artifacts.yaml
index a45dd448..5b933571 100644
--- a/.github/workflows/release-artifacts.yaml
+++ b/.github/workflows/release-artifacts.yaml
@@ -8,13 +8,85 @@ on:
         description: "The release tag to target"
 
 permissions:
+  id-token: write
   contents: write
   packages: write
 
 jobs:
+  start-runner:
+    name: Start EC2 runner
+    runs-on: ubuntu-latest
+    outputs:
+      instance_id: ${{ steps.invoke-start.outputs.INSTANCE_ID }}
+    steps:
+      - uses: aws-actions/configure-aws-credentials@v3
+        with:
+          role-to-assume: ${{ secrets.EC2_ROLE }}
+          aws-region: ${{ vars.EC2_REGION }}
+      - name: Start EC2 instance
+        id: invoke-start
+        env:
+          GH_TOKEN: ${{ secrets.GH_TOKEN_FOR_UPDATES }}
+          EC2_START: ${{ secrets.EC2_START }}
+        run: |
+          RUNNER_TOKEN=$(gh api -X POST -q '.token' /repos/${{ github.repository }}/actions/runners/registration-token)
+          aws lambda invoke \
+            --cli-binary-format raw-in-base64-out \
+            --function-name "$EC2_START" \
+            --payload '{"ref_name":"${{ github.ref_name }}","runner_token":"'"${RUNNER_TOKEN}"'"}' \
+            response.json
+          INSTANCE_ID=$(jq -r '.body.instance_id' < response.json)
+          echo "INSTANCE_ID=${INSTANCE_ID}" >>"$GITHUB_OUTPUT"
+          echo "Got EC2 instance ${INSTANCE_ID}"
+          echo 'Waiting for GitHub runner to start'
+          while [[ -z "$(gh api /repos/${{ github.repository }}/actions/runners | jq '.runners[] | select(.name == "ec2-spot")')" ]]; do
+            sleep 60
+          done
+          echo 'Done 🎉'
+
+  stop-runner:
+    name: Stop EC2 runner
+    runs-on: ubuntu-latest
+    # Ensure that `stop-runner` will always stop the EC2 instance, even if other jobs failed or were canceled
+    if: ${{ always() }}
+    needs:
+      - start-runner
+      - docker-multiplatform-image
+      - static-binary
+    steps:
+      - uses: aws-actions/configure-aws-credentials@v3
+        with:
+          role-to-assume: ${{ secrets.EC2_ROLE }}
+          aws-region: ${{ vars.EC2_REGION }}
+      - name: Delete GitHub Runner
+        env:
+          GH_TOKEN: ${{ secrets.GH_TOKEN_FOR_UPDATES }}
+        run: |
+          RUNNER_ID=$(gh api /repos/${{ github.repository }}/actions/runners | jq '.runners[] | select(.name == "ec2-spot") | .id')
+          if [[ -n "${RUNNER_ID}" ]]; then
+            gh api -X DELETE /repos/${{ github.repository }}/actions/runners/${RUNNER_ID}
+          fi
+      - name: Lambda Invoke Stop
+        env:
+          EC2_STOP: ${{ secrets.EC2_STOP }}
+        run: |
+          aws lambda invoke \
+            --cli-binary-format raw-in-base64-out \
+            --function-name "$EC2_STOP" \
+            --payload '{"instance_id":"${{ needs.start-runner.outputs.instance_id }}"}' \
+            response.json
+          cat response.json
+  
   docker-image:
     name: "Build docker image"
-    runs-on: "ubuntu-latest"
+    strategy:
+      matrix:
+        os:
+          - runs-on: ubuntu-latest
+            architecture: x86_64
+          - runs-on: [EC2, ARM64, Linux]
+            architecture: arm64
+    runs-on: ${{ matrix.os.runs-on }}
     steps:
       - uses: actions/checkout@v3
         with:
@@ -30,7 +102,7 @@ jobs:
         name: "Build docker image"
         run: |
           nix build --print-build-logs .#dockerImage
-          cp ./result nickel-docker-image.tar.gz
+          cp ./result nickel-${{ matrix.os.architecture }}-docker-image.tar.gz
           echo "imageName=$(nix eval --raw .#dockerImage.imageName)" >> "$GITHUB_OUTPUT"
           echo "imageTag=$(nix eval --raw .#dockerImage.imageTag)" >> "$GITHUB_OUTPUT"
       - name: "Upload docker image as release asset"
@@ -38,7 +110,7 @@ jobs:
           GH_TOKEN: ${{ github.token }}
           RELEASE_TAG: ${{ github.event_name == 'release' && github.event.release.tag_name || github.event.inputs.release_tag }}
         run: |
-          gh release upload --clobber $RELEASE_TAG nickel-docker-image.tar.gz
+          gh release upload --clobber $RELEASE_TAG nickel-${{ matrix.os.architecture }}-docker-image.tar.gz
       - name: Log in to registry
         # This is where you will update the personal access token to GITHUB_TOKEN
         run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u $ --password-stdin
@@ -47,13 +119,38 @@ jobs:
           RELEASE_TAG: ${{ github.event_name == 'release' && github.event.release.tag_name || github.event.inputs.release_tag }}
           TARBALL_TAG: ${{ steps.build-image.outputs.imageName }}:${{ steps.build-image.outputs.imageTag }}
         run: |
-          docker load -i nickel-docker-image.tar.gz
-          docker tag "$TARBALL_TAG" ghcr.io/tweag/nickel:$RELEASE_TAG
-          docker push ghcr.io/tweag/nickel:$RELEASE_TAG
+          docker load -i nickel-${{ matrix.os.architecture }}-docker-image.tar.gz
+          docker tag "$TARBALL_TAG" ghcr.io/tweag/nickel:$RELEASE_TAG-${{ matrix.os.architecture}}
+          docker push ghcr.io/tweag/nickel:$RELEASE_TAG-${{ matrix.os.architecture}}
+
+  docker-multiplatform-image:
+    name: "Assemble multi-platform Docker image"
+    runs-on: ubuntu-latest
+    needs: docker-image
+    steps:
+      - name: Log in to registry
+        run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u $ --password-stdin
+      - name: Assemble and push image
+        env:
+          RELEASE_TAG: ${{ github.event_name == 'release' && github.event.release.tag_name || github.event.inputs.release_tag }}
+        run: |
+          docker manifest create \
+            ghcr.io/tweag/nickel:$RELEASE_TAG \
+            --amend ghcr.io/tweag/nickel:$RELEASE_TAG-x86_64 \
+            --amend ghcr.io/tweag/nickel:$RELEASE_TAG-arm64 \
+          docker manifest push ghcr.io/tweag/nickel:$RELEASE_TAG
+        
 
   static-binary:
     name: "Build Nickel release binary"
-    runs-on: "ubuntu-latest"
+    strategy:
+      matrix:
+        os:
+          - runs-on: ubuntu-latest
+            architecture: x86_64
+          - runs-on: [EC2, ARM64, Linux]
+            architecture: arm64
+    runs-on: ${{ matrix.os.runs-on }}
     steps:
       - uses: actions/checkout@v3
         with:
@@ -65,13 +162,13 @@ jobs:
             experimental-features = nix-command flakes
             accept-flake-config = true
           nix_path: "nixpkgs=channel:nixos-unstable"
-      - name: "Build x86_64 static binary"
+      - name: "Build static binary"
         run: |
           nix build --print-build-logs .#nickel-static
-          cp ./result/bin/nickel nickel-x86_64-linux
-      - name: "Upload x86_64 static binary as release asset"
+          cp ./result/bin/nickel nickel-${{ os.matrix.architecture }}-linux
+      - name: "Upload static binary as release asset"
         env:
           GH_TOKEN: ${{ github.token }}
           RELEASE_TAG: ${{ github.event_name == 'release' && github.event.release.tag_name || github.event.inputs.release_tag }}
         run: |
-          gh release upload --clobber $RELEASE_TAG nickel-x86_64-linux
+          gh release upload --clobber $RELEASE_TAG nickel-${{ os.matrix.architecture }}-linux
diff --git a/Cargo.toml b/Cargo.toml
index 8fc24b81..24fab02a 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -8,6 +8,7 @@ members = [
     "wasm-repl",
     "pyckel",
 ]
+resolver = "2"
 
 [workspace.package]
 version = "1.1.1"
diff --git a/cli/src/export.rs b/cli/src/export.rs
index b329f448..4c13af5c 100644
--- a/cli/src/export.rs
+++ b/cli/src/export.rs
@@ -36,6 +36,9 @@ const OVERRIDES_LIST_MAX_COUNT: usize = 15;
 /// a value.
 const NICKEL_VALUE_NAME: &str = "NICKEL EXPRESSION";
 
+const EXPERIMENTAL_MSG: &str =
+    "[WARNING] Customize mode is experimental. Its interface is subject to breaking changes.";
+
 #[derive(clap::Parser, Debug)]
 pub struct ExportCommand {
     #[arg(long, value_enum, default_value_t)]
@@ -48,10 +51,11 @@ pub struct ExportCommand {
     #[command(flatten)]
     pub evaluation: EvalCommand,
 
-    /// Enters customize mode. This turns the nickel invocation into a new CLI based on the
-    /// configuration to be exported, where the value of fields can be set directly through CLI
-    /// arguments. Print the corresponding help (`nickel export -f <file.ncl> -- --help`) to see
-    /// available options.
+    /// \[WARNING\] Customize mode is experimental. Its interface is subject to breaking changes.
+    ///
+    /// Customize mode turns the nickel invocation into a new CLI based on the configuration to be
+    /// exported, where the value of fields can be set directly through CLI arguments. Print the
+    /// corresponding help (`nickel export -f <file.ncl> -- --help`) to see available options.
     #[arg(last = true)]
     pub customize_mode: Vec<OsString>,
 }
@@ -114,32 +118,37 @@ impl TermInterface {
     /// In addition to the updated command, `build_cmd` returns a mapping from clap argument ids to
     /// their corresponding full field path as an array of fields.
     fn build_cmd(&self) -> TermCommand {
-        let mut paths = HashMap::new();
-        let mut ovd_interfaces = BTreeMap::new();
+        let clap_cmd = Command::new("customize-mode")
+            .about("Customize a Nickel configuration through the command line before exporting")
+            .after_help(EXPERIMENTAL_MSG)
+            .no_binary_name(true);
 
-        let mut cmd = Command::new("customize-mode").no_binary_name(true);
+        let mut term_cmd = TermCommand::new(clap_cmd);
 
         for (id, field) in &self.fields {
-            cmd = field.add_args(cmd, vec![id.to_string()], &mut paths, &mut ovd_interfaces)
+            term_cmd = field.add_args(term_cmd, vec![id.to_string()])
         }
 
-        let mut overrides_list: Vec<String> = ovd_interfaces
+        let mut overrides_list: Vec<String> = term_cmd
+            .overrides
             .keys()
             .take(OVERRIDES_LIST_MAX_COUNT)
             .map(|field_path| format!("- {field_path}"))
             .collect();
 
-        if ovd_interfaces.len() > OVERRIDES_LIST_MAX_COUNT {
+        if term_cmd.overrides.len() > OVERRIDES_LIST_MAX_COUNT {
             overrides_list.push("- ...".into());
         }
 
-        //TODO: what happens if there's a field named override?
+        let has_override = term_cmd.inputs.contains_key("override");
+        let has_help = term_cmd.inputs.contains_key("help");
+
         let override_arg_label = "override";
         let override_help = format!(
             "Override any field of the configuration with a valid Nickel expression provided as \
             a string. The new value will be merged with the configuration with a `force` \
-            priority.\
-            \n\nOverridable fields:\n{}\n",
+            priority.\n\n\
+            Overridable fields:\n{}\n\n",
             overrides_list.join("\n")
         );
         let override_arg = clap::Arg::new(override_arg_label)
@@ -151,13 +160,35 @@ impl TermInterface {
             .required(false)
             .help(override_help);
 
-        cmd = cmd.arg(override_arg);
+        term_cmd.clap_cmd = term_cmd.clap_cmd.arg(override_arg);
 
-        TermCommand {
-            clap_cmd: cmd,
-            args: paths,
-            overrides: ovd_interfaces,
+        if has_help || has_override {
+            let conflict_field = if has_override {
+                "override"
+            } else if has_help {
+                "help"
+            } else {
+                // We tested in the parent `if` that one of those two booleans must be set
+                unreachable!()
+            };
+
+            let extra = if has_help && has_override {
+                ". The same applies to the conflicting `help` field"
+            } else {
+                ""
+            };
+
+            term_cmd.clap_cmd = term_cmd.clap_cmd.after_long_help(format!(
+                "\
+            [CONFLICT] This configuration has a field named `{conflict_field}` which conflicts \
+            with the built-in `--{conflict_field}` argument. To set this field to e.g. \"some_value\", \
+            use `--override {conflict_field} \"some_value\"` instead of `--{conflict_field} \"some_value\"`\
+            {extra}\n\n\
+            {EXPERIMENTAL_MSG}"
+            ));
         }
+
+        term_cmd
     }
 }
 
@@ -314,39 +345,32 @@ impl FieldInterface {
     ///
     /// `add_args` updates `paths` as well, which maps clap argument ids to the corresponding field
     /// path represented as an array of string names.
-    fn add_args(
-        &self,
-        mut cmd: clap::Command,
-        path: Vec<String>,
-        paths: &mut HashMap<clap::Id, Vec<String>>,
-        ovd_interfaces: &mut BTreeMap<String, OverrideInterface>,
-    ) -> clap::Command {
-        let id = path.join(".");
-        let prev = paths.insert(clap::Id::from(&id), path.clone());
-        debug_assert!(prev.is_none());
-
-        // this is a terminal field, which gives rise to an argument or an overridable value.
+    fn add_args(&self, mut term_cmd: TermCommand, path: Vec<String>) -> TermCommand {
+        // This is a terminal field, which gives rise to an argument or an overridable value.
         if !self.has_subfields() {
             if self.is_input() {
-                cmd = self.add_arg(cmd, id);
+                match path.as_slice() {
+                    [ref field] if field == "override" || field == "help" => {
+                        term_cmd.register_input(path.clone())
+                    }
+                    _ => {
+                        term_cmd.register_arg(path.clone());
+                        term_cmd.clap_cmd = self.add_arg(term_cmd.clap_cmd, path.join("."))
+                    }
+                };
             } else {
-                ovd_interfaces.insert(
-                    id,
-                    OverrideInterface {
-                        path: path.clone(),
-                        _help: self.help(),
-                    },
-                );
+                term_cmd.register_override(path.clone(), self.help());
             }
         }
 
         for (id, field) in self.subfields.iter().flat_map(|intf| intf.fields.iter()) {
             let mut path = path.clone();
             path.push(id.to_string());
-            cmd = field.add_args(cmd, path, paths, ovd_interfaces);
+
+            term_cmd = field.add_args(term_cmd, path);
         }
 
-        cmd
+        term_cmd
     }
 
     /// Add a single argument to the CLI `cmd`, based on the interface of this field, and return
@@ -454,10 +478,56 @@ struct TermCommand {
     clap_cmd: clap::Command,
     /// A mapping between clap argument ids and the corresponding field path (for inputs).
     args: HashMap<clap::Id, Vec<String>>,
+    /// Mapping between existing input fields (as a dot-separated path string) and their path
+    /// represented as a vector. This is used both as a `HashSet` to check the existence of an
+    /// input field, and to sidestep the issue of parsing a path again when reading an input
+    /// argument.
+    inputs: HashMap<String, Vec<String>>,
     /// A mapping between a field path and the corresponding override data.
     overrides: BTreeMap<String, OverrideInterface>,
 }
 
+impl TermCommand {
+    fn new(command: clap::Command) -> Self {
+        TermCommand {
+            clap_cmd: command,
+            overrides: BTreeMap::new(),
+            inputs: HashMap::new(),
+            args: HashMap::new(),
+        }
+    }
+
+    /// Register a new argument corresponding to an input. Add the paths and the argument's id to
+    /// the relevant maps.
+    fn register_arg(&mut self, path: Vec<String>) {
+        let path_string = path.join(".");
+        let arg_id = clap::Id::from(path_string.clone());
+
+        debug_assert!(!self.args.contains_key(&arg_id));
+        debug_assert!(!self.inputs.contains_key(&path_string));
+
+        self.args.insert(arg_id, path.clone());
+        self.inputs.insert(path_string, path);
+    }
+
+    /// Register an input, but don't register the corresponding CLI argument. Used for `override`
+    /// and `help` which conflict with the built-in `--override` and `--help` arguments.
+    fn register_input(&mut self, path: Vec<String>) {
+        let path_string = path.join(".");
+
+        debug_assert!(!self.inputs.contains_key(&path_string));
+
+        self.inputs.insert(path_string, path);
+    }
+
+    fn register_override(&mut self, path: Vec<String>, help: Option<String>) {
+        let path_string = path.join(".");
+
+        self.overrides
+            .insert(path_string, OverrideInterface { path, _help: help });
+    }
+}
+
 impl ExportCommand {
     pub fn run(mut self, global: GlobalOptions) -> CliResult<()> {
         let mut program = self.evaluation.prepare(&global)?;
@@ -488,8 +558,13 @@ impl ExportCommand {
 
                         cmd.overrides
                             .get(path)
-                            .map(|intf| FieldOverride {
-                                path: intf.path.clone(),
+                            .map(|intf| intf.path.clone())
+                            // We allow --override to set inputs as well, in particular as an
+                            // escape mechanism to set an input field named `override` or `help`,
+                            // which would conflict with the corresponding builtin flags.
+                            .or(cmd.inputs.get(path).cloned())
+                            .map(|path| FieldOverride {
+                                path,
                                 value: value.clone(),
                                 priority: MergePriority::Top,
                             })
diff --git a/core/src/parser/grammar.lalrpop b/core/src/parser/grammar.lalrpop
index c5d487c5..bdaff9cb 100644
--- a/core/src/parser/grammar.lalrpop
+++ b/core/src/parser/grammar.lalrpop
@@ -583,7 +583,7 @@ ChunkLiteral : String =
     <parts: ChunkLiteralPart+> => {
         parts.into_iter().fold(String::new(), |mut acc, part| {
             match part {
-                ChunkLiteralPart::Str(s) => acc.push_str(s),
+                ChunkLiteralPart::Str(s) => acc.push_str(&s),
                 ChunkLiteralPart::Char(c) => acc.push(c),
             };
 
@@ -623,7 +623,7 @@ EnumTag: LocIdent = {
   <StringEnumTag> => <>.into(),
 };
 
-ChunkLiteralPart: ChunkLiteralPart<'input> = {
+ChunkLiteralPart: ChunkLiteralPart = {
     "str literal" => ChunkLiteralPart::Str(<>),
     "multstr literal" => ChunkLiteralPart::Str(<>),
     "str esc char" => ChunkLiteralPart::Char(<>),
@@ -946,9 +946,9 @@ extern {
 
     enum Token<'input> {
         "identifier" => Token::Normal(NormalToken::Identifier(<&'input str>)),
-        "str literal" => Token::Str(StringToken::Literal(<&'input str>)),
+        "str literal" => Token::Str(StringToken::Literal(<String>)),
         "str esc char" => Token::Str(StringToken::EscapedChar(<char>)),
-        "multstr literal" => Token::MultiStr(MultiStringToken::Literal(<&'input str>)),
+        "multstr literal" => Token::MultiStr(MultiStringToken::Literal(<String>)),
         "num literal" => Token::Normal(NormalToken::NumLiteral(<Number>)),
 
         "raw enum tag" => Token::Normal(NormalToken::RawEnumTag(<&'input str>)),
diff --git a/core/src/parser/lexer.rs b/core/src/parser/lexer.rs
index c9e1e2e6..490e4379 100644
--- a/core/src/parser/lexer.rs
+++ b/core/src/parser/lexer.rs
@@ -56,10 +56,12 @@ fn symbolic_string_prefix_and_length<'input>(
 /// The tokens in normal mode.
 #[derive(Logos, Debug, PartialEq, Clone)]
 pub enum NormalToken<'input> {
-    #[regex("[ \r\t\n]+", logos::skip)]
+    #[regex("((\r\n)+|[ \t\n]+)", logos::skip)]
     // multiline strings cannot be used as enum tags, so we explicitly
     // disallow that pattern.
     #[regex("'m(%)+\"")]
+    // We forbid lone carriage returns for sanity
+    #[regex("\r[^\n]")]
     #[error]
     Error,
 
@@ -389,13 +391,15 @@ pub struct SymbolicStringStart<'input> {
 /// The tokens in string mode.
 #[derive(Logos, Debug, PartialEq, Eq, Clone)]
 pub enum StringToken<'input> {
+    // We forbid lone carriage returns for sanity
+    #[regex("\r[^\n]")]
     #[error]
     Error,
 
-    #[regex("[^\"%\\\\]+")]
+    #[regex("[^\"%\\\\]+", |lex| normalize_line_endings(lex.slice()))]
     // Has lower matching priority than `Interpolation` according to Logos' rules.
-    #[token("%")]
-    Literal(&'input str),
+    #[token("%", |lex| String::from(lex.slice()))]
+    Literal(String),
 
     #[token("\"")]
     DoubleQuote,
@@ -411,16 +415,18 @@ pub enum StringToken<'input> {
 /// The tokens in multiline string mode.
 #[derive(Logos, Debug, PartialEq, Eq, Clone)]
 pub enum MultiStringToken<'input> {
+    // We forbid lone carriage returns for sanity
+    #[regex("\r[^\n]")]
     #[error]
     Error,
 
-    #[regex("[^\"%]+")]
+    #[regex("[^\"%]+", |lex| normalize_line_endings(lex.slice()))]
     // A token that starts as a multiline end delimiter or an interpolation sequence but is not
     // one.  These ones should have lowest matching priority according to Logos' rules, and
     // CandidateEnd and CandidateInterpolation should be matched first.
-    #[token("\"")]
-    #[regex("%+")]
-    Literal(&'input str),
+    #[token("\"", |lex| String::from(lex.slice()))]
+    #[regex("%+", |lex| String::from(lex.slice()))]
+    Literal(String),
 
     /// A candidate end. A multiline string starting delimiter `MultiStringStart` can have a
     /// variable number of `%` character, so the lexer matches candidate end delimiter, compare the
@@ -692,7 +698,7 @@ impl<'input> Lexer<'input> {
         };
         self.bufferize(next_token, next_span);
 
-        let token = Token::MultiStr(MultiStringToken::Literal(&s[0..split_at]));
+        let token = Token::MultiStr(MultiStringToken::Literal(s[0..split_at].to_owned()));
         let span = Range {
             start: span.start,
             end: span.start + split_at,
@@ -850,7 +856,7 @@ impl<'input> Lexer<'input> {
             // `Literal` one
             MultiStringToken::CandidateInterpolation(s)
             | MultiStringToken::QuotesCandidateInterpolation(s) => {
-                Token::MultiStr(MultiStringToken::Literal(s))
+                Token::MultiStr(MultiStringToken::Literal(s.to_owned()))
             }
             // Strictly speaking, a candidate end delimiter with more than the required count of
             // `%` should be split between multistring end token, plus a variable number of `%`
@@ -876,7 +882,9 @@ impl<'input> Lexer<'input> {
             }
             // Otherwise, it is just part of the string, so we transform the token into a
             // `Literal` one
-            MultiStringToken::CandidateEnd(s) => Token::MultiStr(MultiStringToken::Literal(s)),
+            MultiStringToken::CandidateEnd(s) => {
+                Token::MultiStr(MultiStringToken::Literal(s.to_owned()))
+            }
             // Early report errors for now. This could change in the future
             MultiStringToken::Error => {
                 return Some(Err(ParseError::Lexical(LexicalError::Generic(span))))
@@ -978,3 +986,14 @@ fn escape_ascii(code: &str) -> Option<char> {
         Some(code as char)
     }
 }
+
+/// Normalize the line endings in `s` to only `\n` and, in debug mode, check
+/// for lone `\r` without an accompanying `\n`.
+pub fn normalize_line_endings(s: impl AsRef<str>) -> String {
+    let normalized = s.as_ref().replace("\r\n", "\n");
+    debug_assert!(
+        normalized.find('\r').is_none(),
+        "The lexer throws an error when it finds a lone carriage return"
+    );
+    normalized
+}
diff --git a/core/src/parser/utils.rs b/core/src/parser/utils.rs
index 48341e58..445a01d0 100644
--- a/core/src/parser/utils.rs
+++ b/core/src/parser/utils.rs
@@ -96,8 +96,8 @@ pub type FieldPath = Vec<FieldPathElem>;
 /// Because of the way the lexer handles escaping and interpolation, a contiguous static string
 /// `"Some \\ \%{escaped} string"` will be lexed as a sequence of such atoms.
 #[derive(Clone, Debug, Eq, PartialEq)]
-pub enum ChunkLiteralPart<'input> {
-    Str(&'input str),
+pub enum ChunkLiteralPart {
+    Str(String),
     Char(char),
 }
 
@@ -839,20 +839,6 @@ pub fn strip_indent(mut chunks: Vec<StrChunk<RichTerm>>) -> Vec<StrChunk<RichTer
     chunks
 }
 
-/// Strip the indentation of a doc metadata. Wrap it as a literal string chunk and call
-/// [`strip_indent`].
-pub fn strip_indent_doc(doc: String) -> String {
-    let chunk = vec![StrChunk::Literal(doc)];
-    strip_indent(chunk)
-        .into_iter()
-        .map(|chunk| match chunk {
-            StrChunk::Literal(s) => s,
-            _ => panic!("expected literal string after indentation of documentation"),
-        })
-        .next()
-        .expect("expected non-empty chunks after indentation of documentation")
-}
-
 #[cfg(test)]
 mod tests {
     use crate::typ::TypeF;
diff --git a/core/src/pretty.rs b/core/src/pretty.rs
index 217f5880..7cfad8a5 100644
--- a/core/src/pretty.rs
+++ b/core/src/pretty.rs
@@ -55,6 +55,7 @@ fn escape(s: &str) -> String {
         .replace("%{", "\\%{")
         .replace('\"', "\\\"")
         .replace('\n', "\\n")
+        .replace('\r', "\\r")
 }
 
 static QUOTING_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new("^_?[a-zA-Z][_a-zA-Z0-9-]*$").unwrap());
@@ -116,6 +117,15 @@ fn contains_newline<T>(chunks: &[StrChunk<T>]) -> bool {
     })
 }
 
+/// Does a sequence of `StrChunk`s contain a carriage return? Lone carriage
+/// returns are forbidden in Nickel's surface syntax.
+fn contains_carriage_return<T>(chunks: &[StrChunk<T>]) -> bool {
+    chunks.iter().any(|chunk| match chunk {
+        StrChunk::Literal(str) => str.contains('\r'),
+        StrChunk::Expr(_, _) => false,
+    })
+}
+
 pub fn fmt_pretty<'a, T>(value: &T, f: &mut fmt::Formatter) -> fmt::Result
 where
     T: Pretty<'a, pretty::BoxAllocator, ()> + Clone,
@@ -143,7 +153,9 @@ where
         chunks: &[StrChunk<RichTerm>],
         string_style: StringRenderStyle,
     ) -> DocBuilder<'a, Self, A> {
-        let multiline = string_style == StringRenderStyle::Multiline && contains_newline(chunks);
+        let multiline = string_style == StringRenderStyle::Multiline
+            && contains_newline(chunks)
+            && !contains_carriage_return(chunks);
 
         let nb_perc = if multiline {
             chunks
@@ -195,9 +207,7 @@ where
                                 // about whether a trailing newline appears at
                                 // the end of the last line.
                                 s.split_inclusive('\n').map(|line| {
-                                    if let Some(s) = line.strip_suffix("\r\n") {
-                                        self.text(s.to_owned()).append(self.hardline())
-                                    } else if let Some(s) = line.strip_suffix('\n') {
+                                    if let Some(s) = line.strip_suffix('\n') {
                                         self.text(s.to_owned()).append(self.hardline())
                                     } else {
                                         self.text(line.to_owned())
diff --git a/flake.lock b/flake.lock
index 3b07a038..82760c82 100644
--- a/flake.lock
+++ b/flake.lock
@@ -26,11 +26,11 @@
         "rust-overlay": "rust-overlay"
       },
       "locked": {
-        "lastModified": 1692750383,
-        "narHash": "sha256-n5P5HOXuu23UB1h9PuayldnRRVQuXJLpoO+xqtMO3ws=",
+        "lastModified": 1693608196,
+        "narHash": "sha256-qs1rDvXXjrKdobPvTdn9qKjV0/RE2uqCCTHD/c6AAo8=",
         "owner": "ipetkov",
         "repo": "crane",
-        "rev": "ef5d11e3c2e5b3924eb0309dba2e1fea2d9062ae",
+        "rev": "80432e15452e55a72403da3bc91837508a4ccae3",
         "type": "github"
       },
       "original": {
@@ -239,11 +239,11 @@
     },
     "nixpkgs": {
       "locked": {
-        "lastModified": 1693003285,
-        "narHash": "sha256-5nm4yrEHKupjn62MibENtfqlP6pWcRTuSKrMiH9bLkc=",
+        "lastModified": 1693565476,
+        "narHash": "sha256-ya00zHt7YbPo3ve/wNZ/6nts61xt7wK/APa6aZAfey0=",
         "owner": "NixOS",
         "repo": "nixpkgs",
-        "rev": "5690c4271f2998c304a45c91a0aeb8fb69feaea7",
+        "rev": "aa8aa7e2ea35ce655297e8322dc82bf77a31d04b",
         "type": "github"
       },
       "original": {
@@ -355,11 +355,11 @@
         ]
       },
       "locked": {
-        "lastModified": 1693015707,
-        "narHash": "sha256-SFr93DYn502sVT9nB5U8/cKg1INyEk/jCeq8tHioz7Y=",
+        "lastModified": 1693620498,
+        "narHash": "sha256-GPhAI2YayaSs3WYeVVbGN3K4mvRTbui/ii7YGoABZBs=",
         "owner": "oxalica",
         "repo": "rust-overlay",
-        "rev": "e90223633068a44f0fb62374e0fa360ccc987292",
+        "rev": "cdf3b15af70f2db17d5f47822f12016f1a89bd73",
         "type": "github"
       },
       "original": {
diff --git a/flake.nix b/flake.nix
index fd2aec59..6f0dec4c 100644
--- a/flake.nix
+++ b/flake.nix
@@ -172,6 +172,8 @@
           snapFilter = mkFilter ".*snap$";
           scmFilter = mkFilter ".*scm$";
           importsFilter = mkFilter ".*/core/tests/integration/imports/imported/.*$"; # include all files that are imported in tests
+
+          infraFilter = mkFilter ".*/infra/.*$";
         in
         pkgs.lib.cleanSourceWith {
           src = pkgs.lib.cleanSource ./.;
@@ -187,7 +189,9 @@
               scmFilter
               filterCargoSources
               importsFilter
-            ];
+            ] && !(builtins.any (filter: filter path type) [
+              infraFilter
+            ]);
         };
 
       # Given a rust toolchain, provide Nickel's Rust dependencies, Nickel, as
@@ -466,6 +470,35 @@
           '';
         };
 
+      infraShell = nickel:
+        let
+          terraform = pkgs.terraform.withPlugins (p: with p; [
+            archive
+            aws
+            github
+          ]);
+          ec2-region = "eu-north-1";
+          ec2-ami = (import "${nixpkgs}/nixos/modules/virtualisation/amazon-ec2-amis.nix").latest.${ec2-region}.aarch64-linux.hvm-ebs;
+          run-terraform = pkgs.writeShellScriptBin "run-terraform" ''
+            set -e
+            ${nickel}/bin/nickel export > main.tf.json <<EOF
+              ((import "main.ncl") & {
+                region = "${ec2-region}",
+                nixos-ami = "${ec2-ami}",
+              }).config
+            EOF
+            ${terraform}/bin/terraform "$@"
+          '';
+
+          update-infra = pkgs.writeShellScriptBin "update-infra" ''
+            set -e
+            ${run-terraform}/bin/run-terraform init
+            GITHUB_TOKEN="$(${pkgs.gh}/bin/gh auth token)" ${run-terraform}/bin/run-terraform apply
+          '';
+        in
+        pkgs.mkShell {
+          buildInputs = [ terraform run-terraform update-infra ];
+        };
     in
     rec {
       packages = {
@@ -502,6 +535,7 @@
         value = makeDevShell { rust = mkRust { inherit channel; rustProfile = "default"; targets = [ "wasm32-unknown-unknown" ]; }; };
       })) // {
         default = devShells.stable;
+        infra = infraShell packages.nickel-lang-cli;
       };
 
       checks = {
diff --git a/infra/.gitignore b/infra/.gitignore
new file mode 100644
index 00000000..356bd190
--- /dev/null
+++ b/infra/.gitignore
@@ -0,0 +1,3 @@
+.terraform*
+build
+main.tf.json
diff --git a/infra/README.md b/infra/README.md
new file mode 100644
index 00000000..0fb79a57
--- /dev/null
+++ b/infra/README.md
@@ -0,0 +1,56 @@
+# GitHub Runner Infrastructure
+
+If you make any changes to the infrastructure code in this directory, you will
+have to redeploy it. Do the following:
+
+1. Make sure you're logged into AWS. You can check using `awscli2`:
+
+   ```console
+   ❯ nix run nixpkgs#awscli2 -- sts get-caller-identity
+   {
+     # CENSORED
+   }
+   ```
+
+   If this fails, log in with AWS SSO credentials, following [their guide][aws-sso-guide].
+
+2. Make sure you're logged into GitHub. You can check using `gh`:
+
+   ```console
+   ❯ nix run github:nixos/nixpkgs#gh -- auth status
+   github.com
+     # CENSORED
+     ✓ Token scopes: gist, read:org, repo
+   ```
+
+   If this fails, log in using `nix run nixpkgs#gh -- auth login` and follow
+   the instructions.
+
+3. Update the infrastructure using
+
+   ```console
+   nix develop ..#infra -c update-infra
+   ```
+
+## Architecture
+
+The code in this subdirectory provisions AWS infrastucture for starting an
+ARM64 GitHub Actions runner on demand. The workflow for producing ARM64 release
+artifacts is as follows:
+
+- the release workflow is triggered automatically when a release is created or
+  manually for testing
+- the workflow requests a runner registration token `$TOKEN` from the GitHub
+  API. For this, it needs a personal access token with `repo` scope for the Nickel
+  repository.
+- the workflow invokes the `$EC2_START` AWS Lambda and provides `$TOKEN` as
+  input
+- the AWS Lambda stores `$TOKEN` as a parameter in the AWS SSM and requests an
+  appropriate EC2 spot instance
+- the spot instance boots up, retrieves `$TOKEN` from AWS SSM and starts a
+  GitHub Actions runner
+- GitHub Actions schedules the ARM64 jobs on the spot instance
+- when the jobs building the release artifact have finished, the workflow
+  invokes the `$EC2_STOP` AWS Lambda which terminates the EC2 instance
+
+[aws-sso-guide]: https://docs.aws.amazon.com/cli/latest/userguide/sso-configure-profile-token.html
diff --git a/infra/github-oidc.ncl b/infra/github-oidc.ncl
new file mode 100644
index 00000000..1469dcfa
--- /dev/null
+++ b/infra/github-oidc.ncl
@@ -0,0 +1,49 @@
+{
+  naming_prefix | String,
+  github = {
+    owner | String,
+    repo | String,
+    ec2_role = "${resource.aws_iam_role.invoke_lambda_role.arn}",
+  },
+  lambda.invoke_policy | String,
+  config = {
+    resource.aws_iam_openid_connect_provider.github_oidc = {
+      url = "https://token.actions.githubusercontent.com",
+      client_id_list = [
+        "sts.amazonaws.com",
+      ],
+      thumbprint_list
+        | doc m%"
+          Thumbprints are provided by GitHub, see
+          [https://github.blog/changelog/2023-06-27-github-actions-update-on-oidc-integration-with-aws/]
+          This should be kept sorted to prevent apparent Terraform drift
+        "%
+        = [
+          "1c58a3a8518e8759bf075b76b750d4f2df264fcd",
+          "6938fd4d98bab03faadb97b34396831e3780aea1",
+        ],
+    },
+
+    resource.aws_iam_role.invoke_lambda_role = {
+      name = "%{naming_prefix}-invoke-lambda-role",
+      managed_policy_arns = [lambda.invoke_policy],
+      assume_role_policy =
+        std.serialize
+          'Json
+          {
+            Version = "2012-10-17",
+            Statement = [
+              {
+                Principal.Federated = "${resource.aws_iam_openid_connect_provider.github_oidc.id}",
+                Action = "sts:AssumeRoleWithWebIdentity",
+                Condition = {
+                  StringLike."token.actions.githubusercontent.com:sub" = "repo:%{github.owner}/%{github.repo}:ref:refs/tags/*",
+                  StringEquals."token.actions.githubusercontent.com:aud" = "sts.amazonaws.com",
+                },
+                Effect = "Allow",
+              }
+            ],
+          },
+    },
+  }
+}
diff --git a/infra/github-runner.nix b/infra/github-runner.nix
new file mode 100644
index 00000000..8797e729
--- /dev/null
+++ b/infra/github-runner.nix
@@ -0,0 +1,62 @@
+### https://nixos.org/channels/nixos-unstable nixos
+{ pkgs, lib, ... }:
+let
+  runner-name = "ec2-spot";
+in
+{
+  imports = [ <nixpkgs/nixos/modules/virtualisation/amazon-image.nix> ];
+  ec2.hvm = true;
+
+  services.github-runner = {
+    enable = true;
+    replace = true;
+    name = runner-name;
+    package = pkgs.github-runner.override {
+      # nodejs v16 was marked as EOL ahead of its EOL in https://github.com/NixOS/nixpkgs/pull/229910
+      # It will reach EOL only on 2023-09-11
+      nodejs_16 = pkgs.nodejs_16.overrideAttrs (orig: {
+        meta =
+          orig.meta
+          // {
+            knownVulnerabilities =
+              lib.filter
+                (x: x != "This NodeJS release has reached its end of life. See https://nodejs.org/en/about/releases/.")
+                orig.meta.knownVulnerabilities;
+          };
+      });
+    };
+    extraPackages = with pkgs; [
+      gawk
+      nix
+    ];
+    url = "${url}";
+    tokenFile = "/run/secrets/github-runner.token";
+    extraLabels = [
+      "EC2"
+    ];
+  };
+
+  systemd.services.github-runner-init = {
+    description = "Setup tasks before trying to start the GitHub runner";
+
+    before = [ "github-runner-$${runner-name}.service" ];
+    requiredBy = [ "github-runner-$${runner-name}.service" ];
+
+    script = ''
+      #!$${pkgs.runtimeShell} -eu
+      umask 077
+      mkdir -p /run/secrets
+      while ! $${pkgs.awscli2}/bin/aws sts get-caller-identity; do
+        sleep 10
+      done
+      $${pkgs.awscli2}/bin/aws ssm get-parameter --name '${token}' --with-decryption --output json --query 'Parameter.Value' \
+        | $${pkgs.jq}/bin/jq -rj > /run/secrets/github-runner.token
+      $${pkgs.awscli2}/bin/aws ssm delete-parameter --name '${token}'
+    '';
+
+    serviceConfig = {
+      Type = "oneshot";
+      RemainAfterExit = true;
+    };
+  };
+}
diff --git a/infra/github-variables.ncl b/infra/github-variables.ncl
new file mode 100644
index 00000000..937404c7
--- /dev/null
+++ b/infra/github-variables.ncl
@@ -0,0 +1,37 @@
+{
+  region | String,
+  github
+    | {
+      owner | String,
+      repo | String,
+      ec2_role | String,
+      ec2_start | String,
+      ec2_stop | String,
+      ..
+    },
+  config = {
+    resource.github_actions_variable."ec2_region" = {
+      repository = "%{github.repo}",
+      variable_name = "EC2_REGION",
+      value = region,
+    },
+
+    resource.github_actions_secret."ec2_role" = {
+      repository = "%{github.repo}",
+      secret_name = "EC2_ROLE",
+      plaintext_value = github.ec2_role,
+    },
+
+    resource.github_actions_secret."ec2_start" = {
+      repository = "%{github.repo}",
+      secret_name = "EC2_START",
+      plaintext_value = github.ec2_start,
+    },
+
+    resource.github_actions_secret."ec2_stop" = {
+      repository = "%{github.repo}",
+      secret_name = "EC2_STOP",
+      plaintext_value = github.ec2_stop,
+    },
+  }
+}
diff --git a/infra/lambdas.ncl b/infra/lambdas.ncl
new file mode 100644
index 00000000..a0a22e51
--- /dev/null
+++ b/infra/lambdas.ncl
@@ -0,0 +1,188 @@
+{
+  naming_prefix | String,
+  region | String,
+  account-id | String,
+  vpc.subnet_id | String,
+  runner
+    | {
+      launch_template | String,
+      launch_template_arn | String,
+      role_arn | String,
+      instance_type | String,
+      instance_tag = naming_prefix,
+      ..
+    },
+  lambda.invoke_policy = "${resource.aws_iam_policy.lambda_invoke.arn}",
+  ssm
+    | {
+      parameter-path | String,
+      parameter-arn | String,
+    },
+  github = {
+    ec2_start = "${resource.aws_lambda_function.spot_start.function_name}",
+    ec2_stop = "${resource.aws_lambda_function.spot_stop.function_name}",
+  },
+  config = {
+    data.archive_file.lambda = {
+      type = "zip",
+      source_dir = "${path.module}/spot_lambdas/",
+      output_path = "${path.module}/build/lambda.zip",
+    },
+
+    resource.aws_lambda_function.spot_start = {
+      function_name = "%{naming_prefix}-start",
+      filename = config.data.archive_file.lambda.output_path,
+      source_code_hash = "${data.archive_file.lambda.output_base64sha256}",
+      role = "${resource.aws_iam_role.lambda_execution_role.arn}",
+      runtime = "python3.11",
+      handler = "start.lambda_handler",
+      timeout = 120,
+
+      environment.variables = {
+        LAUNCH_TEMPLATE = runner.launch_template,
+        SSM_PARAMETER = ssm.parameter-path,
+        TAG_KEY = runner.instance_tag,
+      }
+    },
+
+    resource.aws_lambda_function.spot_stop = {
+      function_name = "%{naming_prefix}-stop",
+      filename = config.data.archive_file.lambda.output_path,
+      source_code_hash = "${data.archive_file.lambda.output_base64sha256}",
+      role = "${resource.aws_iam_role.lambda_execution_role.arn}",
+      runtime = "python3.11",
+      handler = "stop.lambda_handler",
+      timeout = 120,
+      environment.variables = {
+        TAG_KEY = runner.instance_tag,
+      }
+    },
+
+    resource.aws_iam_role.lambda_execution_role = {
+      name = "%{naming_prefix}-lambda-execution-role",
+      assume_role_policy =
+        std.serialize
+          'Json
+          {
+            Version = "2012-10-17",
+            Statement = [
+              {
+                Action = "sts:AssumeRole",
+                Principal.Service = "lambda.amazonaws.com",
+                Effect = "Allow",
+              }
+            ]
+          },
+    },
+
+    resource.aws_iam_role_policy.lambda_ssm_parameters = {
+      name = "%{naming_prefix}-lambda-ssm-parameter-policy",
+      role = "${resource.aws_iam_role.lambda_execution_role.name}",
+      policy =
+        std.serialize
+          'Json
+          {
+            Version = "2012-10-17",
+            Statement = [
+              {
+                Action = [
+                  "ssm:PutParameter"
+                ],
+                Resource = ssm.parameter-arn,
+                Effect = "Allow",
+              }
+            ],
+          }
+    },
+
+    resource.aws_iam_role_policy.lambda_ec2_policy = {
+      name = "%{naming_prefix}-lambda-ec2-policy",
+      role = "${resource.aws_iam_role.lambda_execution_role.name}",
+      policy =
+        std.serialize
+          'Json
+          {
+            Version = "2012-10-17",
+            Statement = [
+              {
+                Action = [
+                  "ec2:DescribeInstances",
+                ],
+                Resource = "*",
+                Effect = "Allow",
+              },
+              {
+                Action = [
+                  "ec2:RunInstances"
+                ],
+                Condition = {
+                  StringEquals."ec2:LaunchTemplate" = runner.launch_template_arn,
+                  StringEqualsIfExists."ec2:InstanceType" = runner.instance_type,
+                  "Bool"."ec2:IsLaunchTemplateResource" = "true",
+                },
+                Resource = "*",
+                Effect = "Allow",
+              },
+              {
+                Action = [
+                  "ec2:RunInstances"
+                ],
+                Condition = {
+                  "ForAllValues:StringNotEquals"."aws:TagKeys" = runner.instance_tag,
+                },
+                Resource = "arn:aws:ec2:*:*:instance/*",
+                Effect = "Deny",
+              },
+              {
+                Action = [
+                  "ec2:TerminateInstances"
+                ],
+                Condition = {
+                  StringLike."aws:ResourceTag/%{runner.instance_tag}" = "*"
+                },
+                Resource = ["arn:aws:ec2:%{region}:%{account-id}:instance/*"],
+                Effect = "Allow",
+              },
+              {
+                Action = [
+                  "iam:PassRole"
+                ],
+                Resource = [runner.role_arn],
+                Effect = "Allow",
+              },
+              {
+                Action = [
+                  "ec2:CreateTags"
+                ],
+                Condition = {
+                  StringEquals."ec2:CreateAction" = "RunInstances",
+                  StringLike."aws:RequestTag/%{runner.instance_tag}" = "*",
+                },
+                Resource = ["arn:*:ec2:%{region}:%{account-id}:*/*"],
+                Effect = "Allow",
+              }
+            ],
+          }
+    },
+
+    resource.aws_iam_policy.lambda_invoke = {
+      name = "%{naming_prefix}-lambda-invoke-policy",
+      policy =
+        std.serialize
+          'Json
+          {
+            Version = "2012-10-17",
+            Statement = [
+              {
+                Action = "lambda:InvokeFunction",
+                Resource = [
+                  "${resource.aws_lambda_function.spot_start.arn}",
+                  "${resource.aws_lambda_function.spot_stop.arn}",
+                ],
+                Effect = "Allow",
+              }
+            ],
+          }
+    },
+  }
+}
diff --git a/infra/main.ncl b/infra/main.ncl
new file mode 100644
index 00000000..d0744f94
--- /dev/null
+++ b/infra/main.ncl
@@ -0,0 +1,40 @@
+{
+  naming_prefix = "tweag-nickel-release-infra",
+  github = {
+    owner = "tweag",
+    repo = "nickel",
+  },
+  repo_url = "https://github.com/%{github.owner}/%{github.repo}",
+  runner.nix_config = "github-runner.nix",
+  region,
+  account-id = "${data.aws_caller_identity.current.account_id}",
+  config = {
+    terraform = {
+      required_providers = {
+        archive.source = "registry.terraform.io/hashicorp/archive",
+        aws.source = "registry.terraform.io/hashicorp/aws",
+        github.source = "registry.terraform.io/integrations/github",
+      },
+    },
+    provider.aws =
+      let region' = region in
+      {
+        region = region',
+        default_tags.tags = {
+          owner = naming_prefix,
+        }
+      },
+
+    provider.github = {
+      owner = github.owner,
+    },
+
+    data.aws_caller_identity.current = {},
+  }
+}
+& (import "state.ncl")
+& (import "lambdas.ncl")
+& (import "vpc.ncl")
+& (import "runner.ncl")
+& (import "github-oidc.ncl")
+& (import "github-variables.ncl")
diff --git a/infra/runner.ncl b/infra/runner.ncl
new file mode 100644
index 00000000..2775b86d
--- /dev/null
+++ b/infra/runner.ncl
@@ -0,0 +1,104 @@
+{
+  naming_prefix | String,
+  nixos-ami | String,
+  vpc.subnet_id,
+  region,
+  account-id,
+  runner = {
+    role = "${resource.aws_iam_role.runner.name}",
+    role_arn = "${resource.aws_iam_role.runner.arn}",
+    instance_type = "c6g.8xlarge",
+    nix_config | String,
+    launch_template = "${resource.aws_launch_template.nixos-runner.id}",
+    launch_template_arn = "${resource.aws_launch_template.nixos-runner.arn}",
+  },
+  repo_url | String,
+  ssm = {
+    parameter-path = "/%{naming_prefix}/runner-registration-token",
+    parameter-arn = "arn:aws:ssm:%{region}:%{account-id}:parameter%{parameter-path}"
+  },
+  config = {
+    resource.aws_iam_role.runner = {
+      name = "%{naming_prefix}-runner-role",
+      assume_role_policy =
+        std.serialize
+          'Json
+          {
+            Version = "2012-10-17",
+            Statement = [
+              {
+                Action = "sts:AssumeRole",
+                Principal.Service = "ec2.amazonaws.com",
+                Effect = "Allow",
+              }
+            ]
+          },
+    },
+
+    resource.aws_iam_instance_profile.runner = {
+      name = "%{naming_prefix}-runner-profile",
+      role = runner.role,
+    },
+
+    resource.aws_iam_role_policy.runner_ssm_parameters = {
+      name = "%{naming_prefix}-ssm_paramters-policy",
+      role = runner.role,
+      policy =
+        std.serialize
+          'Json
+          {
+            Version = "2012-10-17",
+            Statement = [
+              {
+                Action = [
+                  "ssm:GetParameter",
+                  "ssm:DeleteParameter",
+                ],
+                Resource = ssm.parameter-arn,
+                Effect = "Allow",
+              }
+            ],
+          }
+    },
+
+    resource.aws_security_group.allow_egress = {
+      name = "%{naming_prefix}-allow-egress",
+      vpc_id = "${resource.aws_vpc.runner_vpc.id}",
+    },
+
+    resource.aws_security_group_rule.allow_egress = {
+      type = "egress",
+      from_port = 0,
+      to_port = 0,
+      protocol = "-1",
+      cidr_blocks = ["0.0.0.0/0"],
+      ipv6_cidr_blocks = ["::/0"],
+      security_group_id = "${resource.aws_security_group.allow_egress.id}",
+    },
+
+    resource.aws_launch_template.nixos-runner = {
+      name = "%{naming_prefix}-nixos-runner",
+      block_device_mappings = [
+        {
+          device_name = "/dev/xvda",
+          ebs = {
+            delete_on_termination = true,
+            volume_size = 100,
+          },
+        }
+      ],
+      image_id = nixos-ami,
+      instance_initiated_shutdown_behavior = "terminate",
+      instance_type = runner.instance_type,
+      network_interfaces = [
+        {
+          subnet_id = vpc.subnet_id,
+          security_groups = ["${resource.aws_security_group.allow_egress.id}"],
+          associate_public_ip_address = true,
+        }
+      ],
+      iam_instance_profile.arn = "${resource.aws_iam_instance_profile.runner.arn}",
+      user_data = m%"${base64encode(templatefile(%{"\""}%{runner.nix_config}", { url = %{"\""}%{repo_url}", token = %{"\""}%{ssm.parameter-path}" }))}"%,
+    },
+  }
+}
diff --git a/infra/spot_lambdas/start.py b/infra/spot_lambdas/start.py
new file mode 100644
index 00000000..be29d667
--- /dev/null
+++ b/infra/spot_lambdas/start.py
@@ -0,0 +1,52 @@
+import os
+import boto3
+
+ssm = boto3.client('ssm')
+ec2 = boto3.client('ec2')
+
+def lambda_handler(event, context):
+    tag_key = os.environ.get('TAG_KEY')
+    launch_template_id = os.environ.get('LAUNCH_TEMPLATE')
+    ssm_parameter = os.environ.get('SSM_PARAMETER')
+
+    ssm.put_parameter(
+        Name=ssm_parameter,
+        Value=event['runner_token'],
+        Type="SecureString",
+        Overwrite=True,
+    )
+    response = ec2.run_instances(
+        MinCount=1,
+        MaxCount=1,
+        LaunchTemplate={
+            "LaunchTemplateId": launch_template_id,
+            "Version": "$Latest"
+        },
+        InstanceMarketOptions={
+            "MarketType": "spot",
+            "SpotOptions": {
+                "SpotInstanceType": "one-time",
+                "InstanceInterruptionBehavior": "terminate",
+            },
+        },
+        TagSpecifications=[
+            {
+                "ResourceType": "instance",
+                "Tags": [
+                    {
+                        "Key": tag_key,
+                        "Value": event['ref_name']
+                    }
+                ]
+            }
+        ]
+    )
+
+    instance_id = response['Instances'][0]['InstanceId'] 
+
+    return {
+        'statusCode': 200,
+        'body': {
+            "instance_id": instance_id,
+        }
+    }
\ No newline at end of file
diff --git a/infra/spot_lambdas/stop.py b/infra/spot_lambdas/stop.py
new file mode 100644
index 00000000..d01b9112
--- /dev/null
+++ b/infra/spot_lambdas/stop.py
@@ -0,0 +1,53 @@
+import os
+import boto3
+
+ec2 = boto3.client('ec2')
+
+def has_runner_tag(instance, tag_key):
+    return any(tag['Key'] == tag_key for tag in instance['Tags'])
+
+def bad_request(reason):
+    return {
+        'statusCode': 400,
+        'body': {
+            'reason': reason
+        }
+    }
+    
+def lambda_handler(event, context):
+    tag_key = os.environ.get('TAG_KEY')
+
+    if 'instance_id' not in event:
+        return bad_request("No instance_id provided")
+
+    instances = ec2.describe_instances(
+        InstanceIds=[event['instance_id']],
+        Filters=[
+            {
+                'Name': 'instance-state-name',
+                'Values': ['running'],
+            }
+        ],
+    )
+
+    if not len(instances['Reservations']) == 1:
+        return bad_request("Too many or no reservations")
+
+    if not len(instances['Reservations'][0]['Instances']) == 1:
+        return bad_request("Too many or no instances")
+
+    instance = instances['Reservations'][0]['Instances'][0]
+
+    if not has_runner_tag(instance, tag_key):
+        return bad_request("Instance is not tagged")
+
+    ec2.terminate_instances(
+        InstanceIds=[instance['InstanceId']]
+    )
+
+    return {
+        'statusCode': 200,
+        'body': {
+            "instance_id": instance['InstanceId']
+        }
+    }
\ No newline at end of file
diff --git a/infra/state.ncl b/infra/state.ncl
new file mode 100644
index 00000000..8eeb80ce
--- /dev/null
+++ b/infra/state.ncl
@@ -0,0 +1,41 @@
+{
+  naming_prefix | String,
+  region | String,
+  state = {
+    bucket | String = "%{naming_prefix}-tfstate",
+    key | String = "%{naming_prefix}.tfstate",
+    dynamodb | String = "%{naming_prefix}-tfstate-lock",
+  },
+  config = {
+    terraform.backend.s3 =
+      let region' = region in
+      {
+        bucket = state.bucket,
+        key = state.key,
+        region = region',
+        dynamodb_table = state.dynamodb,
+      },
+
+    resource.aws_s3_bucket.tfstate = {
+      bucket = state.bucket,
+    },
+
+    resource.aws_s3_bucket_versioning.tfstate-versioning = {
+      bucket = "${aws_s3_bucket.tfstate.id}",
+      versioning_configuration.status = "Enabled",
+    },
+
+    resource.aws_dynamodb_table.tfstate-lock = {
+      name = state.dynamodb,
+      read_capacity = 5,
+      write_capacity = 5,
+      hash_key = "LockID",
+      attribute = [
+        {
+          name = "LockID",
+          type = "S"
+        }
+      ]
+    },
+  }
+}
diff --git a/infra/vpc.ncl b/infra/vpc.ncl
new file mode 100644
index 00000000..baf58aed
--- /dev/null
+++ b/infra/vpc.ncl
@@ -0,0 +1,49 @@
+{
+  naming_prefix | String,
+  vpc = {
+    id = "${resource.aws_vpc.runner_vpc.id}",
+    subnet_id = "${resource.aws_subnet.runner_subnet.id}",
+  },
+  config = {
+    resource.aws_vpc.runner_vpc = {
+      cidr_block = "10.0.0.0/16",
+      assign_generated_ipv6_cidr_block = true,
+    },
+
+    resource.aws_subnet.runner_subnet = {
+      vpc_id = vpc.id,
+      cidr_block = "${cidrsubnet(resource.aws_vpc.runner_vpc.cidr_block, 8, 0)}",
+      ipv6_cidr_block = "${cidrsubnet(resource.aws_vpc.runner_vpc.ipv6_cidr_block, 8, 0)}",
+      assign_ipv6_address_on_creation = true,
+    },
+
+    resource.aws_internet_gateway.runner_gw = {
+      vpc_id = vpc.id,
+    },
+
+    resource.aws_egress_only_internet_gateway.runner_gw6 = {
+      vpc_id = vpc.id,
+    },
+
+    resource.aws_route_table.runner_vpc_internet_route_table = {
+      vpc_id = vpc.id,
+    },
+
+    resource.aws_route.runner_vpc_default_route = {
+      route_table_id = "${resource.aws_route_table.runner_vpc_internet_route_table.id}",
+      destination_cidr_block = "0.0.0.0/0",
+      gateway_id = "${resource.aws_internet_gateway.runner_gw.id}",
+    },
+
+    resource.aws_route.runner_vpc_default6_route = {
+      route_table_id = "${resource.aws_route_table.runner_vpc_internet_route_table.id}",
+      destination_ipv6_cidr_block = "::/0",
+      egress_only_gateway_id = "${resource.aws_egress_only_internet_gateway.runner_gw6.id}",
+    },
+
+    resource.aws_route_table_association.runner_vpc_internet_route_table_association = {
+      subnet_id = vpc.subnet_id,
+      route_table_id = "${resource.aws_route_table.runner_vpc_internet_route_table.id}"
+    },
+  }
+}


Test patch:
diff --git a/cli/tests/snapshot/inputs/customize-mode/has_help.ncl b/cli/tests/snapshot/inputs/customize-mode/has_help.ncl
new file mode 100644
index 00000000..289a4dcb
--- /dev/null
+++ b/cli/tests/snapshot/inputs/customize-mode/has_help.ncl
@@ -0,0 +1,8 @@
+# capture = 'stdout'
+# command = ['export', '--', '--help']
+{
+  help | String,
+  input | Number,
+
+  output = if input == 0 then help else "",
+}
diff --git a/cli/tests/snapshot/inputs/customize-mode/has_override.ncl b/cli/tests/snapshot/inputs/customize-mode/has_override.ncl
new file mode 100644
index 00000000..832cbc2d
--- /dev/null
+++ b/cli/tests/snapshot/inputs/customize-mode/has_override.ncl
@@ -0,0 +1,8 @@
+# capture = 'stdout'
+# command = ['export', '--', '--help']
+{
+  override | String,
+  input | Number,
+
+  output = if input == 0 then override else "",
+}
diff --git a/cli/tests/snapshot/inputs/freeform/help.ncl b/cli/tests/snapshot/inputs/customize-mode/help.ncl
similarity index 100%
rename from cli/tests/snapshot/inputs/freeform/help.ncl
rename to cli/tests/snapshot/inputs/customize-mode/help.ncl
diff --git a/cli/tests/snapshot/inputs/freeform/not_an_input.ncl b/cli/tests/snapshot/inputs/customize-mode/not_an_input.ncl
similarity index 100%
rename from cli/tests/snapshot/inputs/freeform/not_an_input.ncl
rename to cli/tests/snapshot/inputs/customize-mode/not_an_input.ncl
diff --git a/cli/tests/snapshot/inputs/freeform/simple_adder.ncl b/cli/tests/snapshot/inputs/customize-mode/simple_adder.ncl
similarity index 100%
rename from cli/tests/snapshot/inputs/freeform/simple_adder.ncl
rename to cli/tests/snapshot/inputs/customize-mode/simple_adder.ncl
diff --git a/cli/tests/snapshot/inputs/freeform/unknown_override.ncl b/cli/tests/snapshot/inputs/customize-mode/unknown_override.ncl
similarity index 100%
rename from cli/tests/snapshot/inputs/freeform/unknown_override.ncl
rename to cli/tests/snapshot/inputs/customize-mode/unknown_override.ncl
diff --git a/cli/tests/snapshot/snapshots/snapshot__export_stdout_has_help.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__export_stdout_has_help.ncl.snap
new file mode 100644
index 00000000..feaf5025
--- /dev/null
+++ b/cli/tests/snapshot/snapshots/snapshot__export_stdout_has_help.ncl.snap
@@ -0,0 +1,28 @@
+---
+source: cli/tests/snapshot/main.rs
+expression: out
+---
+Customize a Nickel configuration through the command line before exporting
+
+Usage: customize-mode [OPTIONS] --input <Number>
+
+Options:
+      --input <Number>
+          
+
+      --override <field> <value>
+          Override any field of the configuration with a valid Nickel expression provided as a
+          string. The new value will be merged with the configuration with a `force` priority.
+          
+          Overridable fields:
+          - output
+
+  -h, --help
+          Print help (see a summary with '-h')
+
+[CONFLICT] This configuration has a field named `help` which conflicts with the built-in `--help`
+argument. To set this field to e.g. "some_value", use `--override help "some_value"` instead of
+`--help "some_value"`
+
+[WARNING] Customize mode is experimental. Its interface is subject to breaking changes.
+
diff --git a/cli/tests/snapshot/snapshots/snapshot__export_stdout_has_override.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__export_stdout_has_override.ncl.snap
new file mode 100644
index 00000000..78ef1191
--- /dev/null
+++ b/cli/tests/snapshot/snapshots/snapshot__export_stdout_has_override.ncl.snap
@@ -0,0 +1,28 @@
+---
+source: cli/tests/snapshot/main.rs
+expression: out
+---
+Customize a Nickel configuration through the command line before exporting
+
+Usage: customize-mode [OPTIONS] --input <Number>
+
+Options:
+      --input <Number>
+          
+
+      --override <field> <value>
+          Override any field of the configuration with a valid Nickel expression provided as a
+          string. The new value will be merged with the configuration with a `force` priority.
+          
+          Overridable fields:
+          - output
+
+  -h, --help
+          Print help (see a summary with '-h')
+
+[CONFLICT] This configuration has a field named `override` which conflicts with the built-in
+`--override` argument. To set this field to e.g. "some_value", use `--override override
+"some_value"` instead of `--override "some_value"`
+
+[WARNING] Customize mode is experimental. Its interface is subject to breaking changes.
+
diff --git a/cli/tests/snapshot/snapshots/snapshot__export_stdout_help.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__export_stdout_help.ncl.snap
index b8d65b4b..34a3453b 100644
--- a/cli/tests/snapshot/snapshots/snapshot__export_stdout_help.ncl.snap
+++ b/cli/tests/snapshot/snapshots/snapshot__export_stdout_help.ncl.snap
@@ -2,6 +2,8 @@
 source: cli/tests/snapshot/main.rs
 expression: out
 ---
+Customize a Nickel configuration through the command line before exporting
+
 Usage: customize-mode [OPTIONS] --input.foo.bar <String> --input.foo.baz <Array Number>
 
 Options:
@@ -23,3 +25,5 @@ Options:
   -h, --help
           Print help
 
+[WARNING] Customize mode is experimental. Its interface is subject to breaking changes.
+
diff --git a/core/src/parser/tests.rs b/core/src/parser/tests.rs
index b7980fb1..77b93d00 100644
--- a/core/src/parser/tests.rs
+++ b/core/src/parser/tests.rs
@@ -347,10 +347,10 @@ fn string_lexing() {
             r#""Good" "strings""#,
             vec![
                 Token::Normal(NormalToken::DoubleQuote),
-                Token::Str(StringToken::Literal("Good")),
+                Token::Str(StringToken::Literal("Good".to_owned())),
                 Token::Normal(NormalToken::DoubleQuote),
                 Token::Normal(NormalToken::DoubleQuote),
-                Token::Str(StringToken::Literal("strings")),
+                Token::Str(StringToken::Literal("strings".to_owned())),
                 Token::Normal(NormalToken::DoubleQuote),
             ],
         ),
@@ -359,9 +359,9 @@ fn string_lexing() {
             r#""Good\nEscape\t\"""#,
             vec![
                 Token::Normal(NormalToken::DoubleQuote),
-                Token::Str(StringToken::Literal("Good")),
+                Token::Str(StringToken::Literal("Good".to_owned())),
                 Token::Str(StringToken::EscapedChar('\n')),
-                Token::Str(StringToken::Literal("Escape")),
+                Token::Str(StringToken::Literal("Escape".to_owned())),
                 Token::Str(StringToken::EscapedChar('\t')),
                 Token::Str(StringToken::EscapedChar('\"')),
                 Token::Normal(NormalToken::DoubleQuote),
@@ -372,11 +372,11 @@ fn string_lexing() {
             r#""1 + %{ 1 } + 2""#,
             vec![
                 Token::Normal(NormalToken::DoubleQuote),
-                Token::Str(StringToken::Literal("1 + ")),
+                Token::Str(StringToken::Literal("1 + ".to_owned())),
                 Token::Str(StringToken::Interpolation),
                 Token::Normal(NormalToken::NumLiteral(Number::from(1))),
                 Token::Normal(NormalToken::RBrace),
-                Token::Str(StringToken::Literal(" + 2")),
+                Token::Str(StringToken::Literal(" + 2".to_owned())),
                 Token::Normal(NormalToken::DoubleQuote),
             ],
         ),
@@ -385,7 +385,7 @@ fn string_lexing() {
             r#""1 + %{ "%{ 1 }" } + 2""#,
             vec![
                 Token::Normal(NormalToken::DoubleQuote),
-                Token::Str(StringToken::Literal("1 + ")),
+                Token::Str(StringToken::Literal("1 + ".to_owned())),
                 Token::Str(StringToken::Interpolation),
                 Token::Normal(NormalToken::DoubleQuote),
                 Token::Str(StringToken::Interpolation),
@@ -393,7 +393,7 @@ fn string_lexing() {
                 Token::Normal(NormalToken::RBrace),
                 Token::Normal(NormalToken::DoubleQuote),
                 Token::Normal(NormalToken::RBrace),
-                Token::Str(StringToken::Literal(" + 2")),
+                Token::Str(StringToken::Literal(" + 2".to_owned())),
                 Token::Normal(NormalToken::DoubleQuote),
             ],
         ),
@@ -402,7 +402,7 @@ fn string_lexing() {
             r#"m%%""%"%%"#,
             vec![
                 Token::Normal(NormalToken::MultiStringStart(4)),
-                Token::MultiStr(MultiStringToken::Literal("\"%")),
+                Token::MultiStr(MultiStringToken::Literal("\"%".to_owned())),
                 Token::MultiStr(MultiStringToken::End),
             ],
         ),
@@ -425,11 +425,11 @@ fn string_lexing() {
                     prefix: "foo",
                     length: 3,
                 })),
-                Token::MultiStr(MultiStringToken::Literal("text ")),
+                Token::MultiStr(MultiStringToken::Literal("text ".to_owned())),
                 Token::MultiStr(MultiStringToken::Interpolation),
                 Token::Normal(NormalToken::NumLiteral(Number::from(1))),
                 Token::Normal(NormalToken::RBrace),
-                Token::MultiStr(MultiStringToken::Literal(" etc.")),
+                Token::MultiStr(MultiStringToken::Literal(" etc.".to_owned())),
                 Token::MultiStr(MultiStringToken::End),
             ],
         ),
@@ -469,6 +469,12 @@ fn str_escape() {
     );
 }
 
+#[test]
+fn carriage_returns() {
+    assert_eq!(parse_without_pos("\"\\r\""), mk_single_chunk("\r"),);
+    assert_matches!(parse("foo\rbar"), Err(ParseError::UnexpectedToken(..)))
+}
+
 #[test]
 fn ascii_escape() {
     assert_matches!(