diff --git a/404.html b/404.html new file mode 100644 index 0000000..f8414f0 --- /dev/null +++ b/404.html @@ -0,0 +1,3 @@ + +404 Not Found +

404 Not Found

diff --git a/CNAME b/CNAME new file mode 100644 index 0000000..83f461d --- /dev/null +++ b/CNAME @@ -0,0 +1 @@ +toniogela.dev diff --git a/about/discord.png b/about/discord.png new file mode 100644 index 0000000..2f99acc Binary files /dev/null and b/about/discord.png differ diff --git a/about/galileo.png b/about/galileo.png new file mode 100644 index 0000000..6a35ad3 Binary files /dev/null and b/about/galileo.png differ diff --git a/about/index.html b/about/index.html new file mode 100644 index 0000000..22eaf92 --- /dev/null +++ b/about/index.html @@ -0,0 +1,93 @@ + + + + + + + + + + + + About | TonioGela's + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +

About

+

I'm Antonio Gelameris, a + Scala developer currently working at iov42

+

I began this blog mainly for two reasons:

+ +

I'm a former physicist, so I sometimes bother people claiming it, and half Italian half Greek, so I often bother people

+

Fun fact: I knit and crochet 🧢 sometimes nerdy stuff like Haskell hats

+

Also-fun fact: I own a really serious 🐱 called Galileo

+ + +

If you want to get in touch with me, the best places are:

+ +

If you're interested, here's my resume

+

The whole site is built with zola

+ + +
+
+ + + + \ No newline at end of file diff --git a/about/mastodon.png b/about/mastodon.png new file mode 100644 index 0000000..23cd26f Binary files /dev/null and b/about/mastodon.png differ diff --git a/about/scala.png b/about/scala.png new file mode 100644 index 0000000..8280fd4 Binary files /dev/null and b/about/scala.png differ diff --git a/avatar.webp b/avatar.webp new file mode 100644 index 0000000..da513e3 Binary files /dev/null and b/avatar.webp differ diff --git a/colors.css b/colors.css new file mode 100644 index 0000000..a8a5813 --- /dev/null +++ b/colors.css @@ -0,0 +1 @@ +ο»Ώ@font-face{font-family:"toniogela";src:url("toniogela.woff2") format("woff2"),url("toniogela.woff") format("woff"),url("toniogela.ttf") format("truetype");font-weight:normal;font-style:normal;font-display:swap}i{font-family:"toniogela" !important;font-style:normal;font-weight:normal;font-variant:normal;text-transform:none;text-rendering:auto;line-height:1;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;vertical-align:middle}.icon-linkedin:before{content:"ξ€€"}.icon-rss:before{content:""}.icon-terminal:before{content:""}.icon-github:before{content:""}.icon-twitter:before{content:""}.icon-mastodon:before{content:"ξ€…"}figure.mini_logo{width:30px;height:30px;display:block;background-color:rgba(0,0,0,0);border:2px solid #a53f2b;border-radius:100%;margin-top:2em;margin-left:1.5em;margin-right:.5em;float:left}figure.mini_logo>a{display:block;width:26px;height:26px;border-radius:100%;margin:0 auto;background-size:100%;background-color:#a53f2b;border:2px solid #1a2539;text-decoration:none}figure.mini_logo+h5{float:left;margin:0;display:block;text-transform:uppercase;font-size:16px;margin-top:40px;position:relative;z-index:100}figure.mini_logo+h5>a{text-decoration:none;color:#a53f2b}ul.social_list{display:inline-block;position:relative;padding:0}ul.social_list>li{margin:.1em .1em .1em .1em;list-style:none;display:inline-block;transition:all .35s ease-out}ul.social_list>li>a{text-decoration:none;color:#e0e0e0;transition:all .35s ease-out;line-height:150%}ul.social_list>li>i{margin-left:.25em;transition:opacity .35s ease-out;opacity:.7}ul.social_list>li:hover>a{color:#1a2539}ul.social_list>li:hover>i{opacity:1;transition:opacity .35s ease-in}html,body{background-color:#1a2539;color:#e0e0e0;font-family:system-ui,sans-serif;font-display:swap;font-weight:400;z-index:50;min-height:100vh}h1,h2,h3,h4,h5,h6{font-weight:700;margin-bottom:12px;line-height:1;color:#e0e0e0}h1{color:#a53f2b;font-size:2.4em}h2{font-size:2em}h3{font-size:1.4em}h4,h5,h6{font-size:1.2em}p,li,ul{font-size:1em;color:#e0e0e0;line-height:1.7}ul{padding-left:15px}.code-window{border:1px solid #0e1013;border-radius:3px;margin:1em 0}.code-window .code-title{padding:.65em;border-top-left-radius:.25em;border-top-right-radius:.25em;background-color:#a53f2b;font-size:.8em}.code-window .code-title .dot-red{height:.6em;width:.6em;background-color:#ff605c;border-radius:50%;border:1px solid #0e1013;display:inline-block}.code-window .code-title .dot-yellow{height:.6em;width:.6em;background-color:#ffbd44;border-radius:50%;border:1px solid #0e1013;display:inline-block}.code-window .code-title .dot-green{height:.6em;width:.6em;background-color:#00ca4e;border-radius:50%;border:1px solid #0e1013;display:inline-block}.code-window .code-body pre{margin:0;border-top-left-radius:0;border-top-right-radius:0}pre{font-family:ui-monospace,"Cascadia Code","Source Code Pro",Menlo,Consolas,"DejaVu Sans Mono",monospace;border-radius:.25em;padding:.65em;overflow-x:auto;white-space:pre}pre code{background-color:unset;padding:unset;border-radius:unset;color:unset;border:unset}.site_title{text-align:center}code{background-color:#2e3440;padding:2px 3px 1px;border-radius:3px;color:#a3be8c;border:1px solid #0e1013;overflow-wrap:break-word}@media (max-width: 480px){blockquote{padding-right:0px;margin-right:0px;margin-left:0px}h1,h2,h3,h4,h5,h6{text-align:left}}@media (min-width: 480px){blockquote{padding-right:10px}}blockquote{padding-left:10px;margin-top:20px;margin-bottom:20px;border-left:8px solid #a53f2b;color:#e0e0e0}article a{text-decoration:underline;text-decoration-color:#a3be8c;text-decoration-thickness:1.5px;color:#e0e0e0}article a:link{transition-property:all;transition-duration:.35s}article a:hover{outline:0;text-decoration-color:#a53f2b;opacity:1}details:hover{cursor:pointer}details:focus{outline:none}details[open] summary~*{animation:sweep 400ms ease-in-out;outline:none}table,th,td{border-spacing:0;border-collapse:collapse;border:1px solid #e0e0e0;padding:.33em}th,td{margin:auto}tr{text-align:center;vertical-align:middle}@keyframes sweep{0%{opacity:0;margin-left:-10px}100%{opacity:1;margin-left:0px}}@keyframes slidein{0%{transform:translateY(-200%)}100%{transform:translateY(0)}}.user_logo{width:125px;height:125px;display:block;background-color:rgba(0,0,0,0);border:5px solid #a53f2b;border-radius:100%;margin:0 auto}.user_logo>a{width:115px;height:115px;display:block;margin:0 auto;background-size:100%;border-radius:100%;background-color:#a53f2b;border:5px solid #1a2539}.overlord{display:block;position:absolute;z-index:100;top:0;left:0;right:0;z-index:90}.header{position:relative;display:block;margin:auto;margin-top:140px;padding-bottom:50px;margin-bottom:50px;border-bottom:0px solid #a53f2b;text-align:center}.header>h1,.header h2,.header h3{max-width:600px;margin:auto;margin-bottom:.25em;padding:.25em}.header>h2{font-size:2.5em;font-weight:900;color:#a53f2b}.header>div{opacity:.7;font-weight:300;line-height:1.2;margin:auto;max-width:625px;display:block;text-align:center}.header>div *{max-width:625px}.article_split{width:auto;max-width:100%;height:1px;background-color:#1a2539;margin-top:70px;margin-bottom:50px;display:block;clear:both}.frontmatter{display:block;font-size:1em;padding-left:0;color:#e0e0e0;font-style:bold;list-style-type:none;margin:0 auto;margin-top:-3px;margin-bottom:10px;font-size:14px;font-weight:700;font-family:system-ui,sans-serif;display:block;opacity:.3;transition:opacity .7s ease-out 1.7s}.frontmatter>li{display:inline}.dotDivider{padding-right:.3em;padding-left:.3em;font-size:16px;white-space:nowrap;font-weight:400;display:inline}.dotDivider::after{content:"Β·"}.post_list{padding-bottom:30px;display:block;position:relative}.post_list>article{display:block;text-align:justify}a#article_link,a#article_link:visited{transition:all .35s ease-out;text-decoration:none;color:#e0e0e0}a#article_link:hover{color:#a53f2b;transition:all .35s ease-in}h1.article_title:hover+ul#frontmatter{opacity:1;transition:opacity .3s ease-in}a.anchor{transition:all .35s ease-out;text-decoration:none;color:#e0e0e0}a.anchor:hover{color:#a53f2b;transition:all .35s ease-in}a.anchor:hover+ul#frontmatter{opacity:1;transition:opacity .3s ease-in}h1.article_title{padding-right:5px;margin:0 auto;font-size:2.4em;line-height:1.5em;margin-top:0px;font-weight:700}h1.article_title>a{border-bottom:unset}h1.article_title>a:focus{outline:none}img{max-width:100%}.post_list_item{display:block;text-align:left;margin:auto;margin-bottom:4em;max-width:900px}.post_list_item>ul{max-width:900px}.post_list_item>a{margin-top:.5em}.post_list_item>p{max-width:900px;display:block;margin:auto;color:#e0e0e0}.post_list_item>h1,.post_list_item h2,.post_list_item h3,.post_list_item h4,.post_list_item h5,.post_list_item h6{max-width:900px}.header_list{margin:auto;margin-top:2em}nav.pagination{padding:25px;padding-bottom:50px;margin:0 auto;border-top:1px solid #a3be8c;padding-top:45px;margin-top:75px;display:block;z-index:100;position:relative}nav.pagination>span.prev{display:block;float:left;z-index:100}nav.pagination>span.next{display:block;float:right;z-index:100}.post_container{margin:0 auto;margin-top:130px;padding:0;display:block;text-align:justify;position:relative;max-width:900px}.post_container>article{margin-bottom:30px;position:relative;display:inherit}.frontmatter_page{opacity:.5;transition:opacity .35 ease-in-out 2s;margin:0 auto}.extra_small{font-size:14px;padding:0px;display:inline-block;line-height:20px;padding-left:12px;padding-right:12px;border-radius:15px}.small{font-size:17px;padding:0px;display:inline-block;line-height:25px;padding-left:10px;padding-right:10px;border-radius:20px}.medium{font-size:17px;padding:0px;line-height:35px;display:inline-block;padding-left:25px;padding-right:25px;border-radius:30px}.font_faint{font-weight:100}.button{font-weight:400;text-transform:lowercase;background-color:rgba(0,0,0,0);color:#e0e0e0;border:1px solid rgba(255,255,255,.1215686275);text-decoration:none;transition:all .35s ease-in-out}.button:hover{background-color:#a53f2b;color:#1a2539;border:1px solid #a53f2b}#languageSelector>ul>li{display:inline;font-weight:bold;color:#a53f2b}#languageSelector>ul>li>a{color:#e0e0e0;text-decoration:none;font-size:1em}#languageSelector>ul>li img{max-height:1.5em;height:auto;width:auto}.overlord>#languageSelector>ul{margin-top:50px;transform:translateY(-50%) translateX(20px)}.youtube>iframe{width:100%;height:340px}.vimeo>iframe{width:100%;height:340px} \ No newline at end of file diff --git a/favicon.ico b/favicon.ico new file mode 100644 index 0000000..ef096d6 Binary files /dev/null and b/favicon.ico differ diff --git a/gh-action-in-scala/index.html b/gh-action-in-scala/index.html new file mode 100644 index 0000000..618dfe7 --- /dev/null +++ b/gh-action-in-scala/index.html @@ -0,0 +1,418 @@ + + + + + + + + + + + + Writing a GitHub Action with Scala.js | TonioGela's + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +

Writing a GitHub Action with Scala.js

+ + + +

Some months ago, I discussed with a DevOps colleague the need for a custom GitHub Action at $work. The action we needed had to perform many tasks that weren't present in any action we could find, so we planned to write our own.

+

The chances were limited: there was the evergreen option to embed a gigantic shell script in the ci file (dealing with evergreen problems like escaping, quoting and indentation), the also evergreen option to commit the script, or we could have written our own GitHub action.

+

The last option was the most interesting one. Writing business logic in a more structured language than bash was desirable, but we had to face the fact that, according to the documentation, only two types of actions exist (if you don't consider composite ones): Docker Container Actions and Javascript Actions.

+

Since no one had any intention whatsoever to write javascript code and Docker Container Actions had all the features we needed, we resorted to using one of them (despite their limitations in terms of compatibility).

+

Even though this scarcely interesting success story has a happy ending, a question emerged during the developments: Is it possible to write a Github Action with Scala.js?

+
+

Also, I asked myself Is it still possible to survive as a software developer in 2023 without ever having written a single line of javascript?: you'll find the answer below.

+
+

TLDR: yes and @armanbilge did it in a couple of repositories like this one, so in this post, we'll dissect his approach to create a how-to guide. Thank you, Arman! ❀️

+

Creating a simple action

+

The action we'll create will be a simple adder that will sum up two numbers that can be either defined in the build file or one of the results of one of the previous steps.

+

Metadata

+

According to its metadata syntax page, every action defined in a repository requires an action.yml file that describes your action's inputs, outputs and run configuration.

+

Our action will have two required inputs and a single output, and it will run using node 16:

+
+
+ + action.yml +
+
name: 'Scala.js adder'
+description: 'Summing two numbers, but with Scala.js'
+inputs:
+  number-one:
+    description: 'The first number'
+    required: true
+  number-two:
+    description: 'The second number'
+    required: true
+outputs:
+  result:
+    description: "The sum of the two inputs"
+runs:
+  using: 'node16'
+  main: 'index.js'
+
+
+

Business logic requirements

+

Once the metadata file is defined, we'll have to write the business logic, but we need to address a few issues:

+ +

The most straightforward and potent tool that will produce javascript code from a single Scala file is undoubtedly scala-cli, with its ability to define in a few lines packaging, platform and dependencies setting.

+

Let's create in our repository a scala file with the required settings to produce a js module using a specific js and scala version:

+
+
+ + index.scala +
+
//> using scala "3.2.2"
+//> using platform "js"
+//> using jsVersion "1.13.1"
+//> using jsModuleKind "common"
+
+object index extends App:
+    println("Hello world")
+
+
+
+

Packaging this file is as simple as running the command scala-cli --power package -f index.scala (we'll reuse this command later in our CI). This command will produce an index.js file that can run locally using node ./index.js.

+

Now that we can produce a runnable js file, it's time to create an actual GitHub action. The official documentation for javascript actions recommends using the GitHub Actions Toolkit Node.js module to speed up development (an intelligent person will probably use it,) but the Actions' runtime offers an alternative.

+

Digging deep into the metadata syntax documentation, in the inputs section, you'll find an interesting paragraph:

+
+

When you specify an input in a workflow file or use a default input value, GitHub creates an environment variable for the input with the name INPUT_<VARIABLE_NAME>. The environment variable created converts input names to uppercase letters and replaces spaces with _ characters.

+
+

So to get our input parameters, reading the environment variables INPUT_NUMBER-ONE and INPUT_NUMBER-TWO will be enough.

+

Last but not least, we need to find a way to define our action's output. Picking up the shovel again and digging further into the documentation, we'll discover a section that enlightens us about the existence of a GITHUB_OUTPUT environment variable containing a file's path. This file will serve as an output buffer for the currently running step, and using it is as simple as writing the string <output_variable_name>=<value> in it.

+

In our case, we'll have to write result=<sum of the inputs> in the file at path $GITHUB_OUTPUT, and we'll be done.

+

To sum up, we need a library/framework/stack that offers comfy APIs to read the content of environment variables and write stuff into files that have been compiled for Scala.js.

+

Unluckily the Scala standard library won't be enough even for such a simple task (unless you'll manually call some node.js APIs). If only there was a tech stack offering a resource-safe, referentially transparent way to perform these operations and a nice asynchronous API to call other processes, like other command line tools!

+

Typelevel toolkit

+

Luckily for everybody, such a stack exists. The Typelevel libraries are published for many Scala versions and for every platform Scala supports, including Scala native. Most of them can be used in a node.js action.

+

The most straightforward way to test this stack's fundamental libraries is using the Typelevel toolkit. The toolkit is a meta library that includes (among the others) Cats Effect, fs2-io for streaming, a library to parse command line arguments, a JSON serde that supports automatic Scala 3 derivation and an HTTP client.

+

To use the toolkit, it's enough to declare it as a dependency in our scala-cli script:

+
+
+ + index.scala +
+
//> using scala "3.2.2"
+//> using platform "js"
+//> using jsVersion "1.13.1"
+//> using jsModuleKind "common"
+//> using dep "org.typelevel::toolkit::latest.release"
+
+object index extends App:
+    println("Hello world")
+
+
+
+

Now it's time to write an input reading function: we can use cats.effect.std.Env to access the environment variables

+
import cats.effect.IO
+import cats.effect.std.Env
+
+def getInput(input: String): IO[Option[String]] =
+  Env[IO].get(s"INPUT_${input.toUpperCase.replace(' ', '_')}")
+
+

With the same method, we can get the output file path and write the output in it:

+
import fs2.io.file.{Files, Path}
+import fs2.Stream
+
+def outputFile: IO[Path] =
+  Env[IO].get("GITHUB_OUTPUT").map(_.get).map(Path.apply) // unsafe Option.get
+
+def setOutput(name: String, value: String): IO[Unit] =
+  outputFile.flatMap(path =>
+    Stream[IO, String](s"${name}=${value}")
+      .through(Files[IO].writeUtf8(path))
+      .compile
+      .drain
+  )
+
+

Last but not least, we can write the logic of our application:

+
import cats.effect.IOApp
+
+object index extends IOApp.Simple:
+  def run = for {
+    number1 <- getInput("number-one").map(_.get.toInt) // unsafe
+    number2 <- getInput("number-two").map(_.get.toInt) // unsafe
+    _ <- setOutput("result", s"${number1 + number2}")
+  } yield ()
+
+

The whole action implementation will then be

+
+
+ + index.scala +
+
//> using scala "3.2.2"
+//> using platform "js"
+//> using jsVersion "1.13.1"
+//> using jsModuleKind "common"
+//> using dep "org.typelevel::toolkit::latest.release"
+
+import cats.effect.{ExitCode, IO, IOApp}
+import cats.effect.std.Env
+import fs2.io.file.{Files, Path}
+import fs2.Stream
+
+def getInput(input: String): IO[Option[String]] =
+  Env[IO].get(s"INPUT_${input.toUpperCase.replace(' ', '_')}")
+
+def outputFile: IO[Path] =
+  Env[IO].get("GITHUB_OUTPUT").map(_.get).map(Path.apply) // unsafe Option.get
+
+def setOutput(name: String, value: String): IO[Unit] =
+  outputFile.flatMap(path =>
+    Stream[IO, String](s"${name}=${value}")
+      .through(Files[IO].writeUtf8(path))
+      .compile
+      .drain
+  )
+
+object index extends IOApp.Simple:
+  def run = for {
+    number1 <- getInput("number-one").map(_.get.toInt) // unsafe Option.get
+    number2 <- getInput("number-two").map(_.get.toInt) // unsafe Option.get
+    _ <- setOutput("result", s"${number1 + number2}")
+  } yield ()
+
+
+
+Safer and shorter alternative that uses decline +
+
+ + index.scala +
+
//> using scala "3.2.2"
+//> using platform "js"
+//> using jsVersion "1.13.1"
+//> using jsModuleKind "common"
+//> using dep "org.typelevel::toolkit::latest.release"
+
+import cats.effect.{IO, ExitCode}
+import cats.syntax.all.*
+import fs2.Stream
+import fs2.io.file.{Files, Path}
+import com.monovore.decline.Opts
+import com.monovore.decline.effect.CommandIOApp
+
+val args = (
+  Opts.env[Int]("INPUT_NUMBER-ONE", "The first number"),
+  Opts.env[Int]("INPUT_NUMBER-TWO", "The second number"),
+  Opts.env[String]("GITHUB_OUTPUT", "The file of the output").map(Path.apply)
+)
+
+object index extends CommandIOApp("adder", "Summing two numbers"):
+  def main = args.mapN { (one, two, path) =>
+    Stream(s"result=${one + two}")
+      .through(Files[IO].writeUtf8(path))
+      .compile
+      .drain
+      .as(ExitCode.Success)
+  }
+
+
+
+

Now that the logic is in place, we must produce a .js file and commit it in the repo, as the action runtime won't interpret our Scala code. Scala-cli helps us: running scala-cli --power package -f index.scala produces an index.js file that our action can run.

+

The content of our repository should now be this:

+
.
+β”œβ”€β”€ action.yml
+β”œβ”€β”€ index.js
+└── index.scala
+
+

It's time to check if our action work as intended.

+

Testing never hurts

+

There are a few ways to test if an action you're developing works as intended. The best one is probably using act, as the feedback cycle will be shorter. Sadly, the last time I checked sbt (and possibly scala-cli) was included only in the complete runtime image, requiring you to download the whole ~20GB container image.

+

The quickest way to test the action is to run it directly on the GitHub Runners and set up its CI to test the logic: the only required thing is a workflow file under .github/workflows.

+

As we must commit the transpiled version of our source code, a preliminary check that the .js file corresponds to the source .scala file is a good idea. The easiest way to test that they match is to recompile the .scala file with scala-cli and use the good old git diff:

+
check-js-file:
+  runs-on: ubuntu-latest
+  steps:
+    - uses: actions/checkout@v3                     # Checking out our code
+    - uses: actions/setup-java@v3
+      with:
+        distribution: temurin
+        java-version: 17
+    - uses: coursier/cache-action@v6
+    - uses: VirtusLab/scala-cli-setup@main          # Installing scala-cli
+    - run: scala-cli --power package -f index.scala # Recompiling our code
+    - run: git diff --quiet index.js                # Silently failing if there's any difference
+
+
+

One thing to consider is that we used latest.release as the toolkit version, making our build non reproducible. Pinning the dependencies' versions is usually a good idea. To achieve reproducibility is possible to pin a specific scala-cli version too using --cli-version <version>. Also, pinning each action version (i.e. - uses:VirtusLab/scala-cli-setup@v1.0.0-RC2) might decrease the chances that your CI will produce a different js file (and thus failing) in the future.

+
+

Once sure that the transpiled version of our code is correct, we can run our action and test its output directly in its own CI:

+
test-action-itself:
+  needs: check-js-file                # There's no point in testing the wrong version
+  runs-on: ubuntu-latest
+  steps:
+    - uses: actions/checkout@v3
+    - uses: ./                        # Here we'll use the action itself
+      id: test-gh-action
+      with:
+        number-one: 3
+        number-two: 9
+    - run: test 12 -eq "${{ steps.test-gh-action.outputs.result }}"
+
+

The last action uses the good old test command (aka [) to check the action's output for the specified inputs.

+
+Complete CI file +
+
+ + .github/workflows/ci.yml +
+
name: Continuos Integration
+on:
+  pull_request:
+    branches: ['**']
+  push:
+    branches: ['**', '!update/**', '!pr/**']
+
+jobs:
+  check-js-file:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v3
+      - uses: actions/setup-java@v3
+        with:
+          distribution: temurin
+          java-version: 17
+      - uses: coursier/cache-action@v6
+      - uses: VirtusLab/scala-cli-setup@main
+      - run: scala-cli --power --cli-version 1.0.0-RC2 package -f index.scala
+      - run: git diff --quiet index.js
+
+  test-action-itself:
+    needs: check-js-file
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v3
+      - uses: ./
+        id: test-gh-action
+        with:
+          number-one: 3
+          number-two: 9
+      - run: test 12 -eq "${{ steps.test-gh-action.outputs.result }}"
+
+
+
+

Using the action

+

To let the world use your new and shiny Scala.js-powered GitHub Action, commit every mentioned file in a public repository, let's say TonioGela/test-gh-action, and use the repository slug in every other action on the whole GitHub:

+
# ...
+  - name: Sum numbers with Scala
+    id: this-is-the-id
+    uses: TonioGela/test-gh-action@main # specify a branch name, a version or a commit sha
+    with:
+        number-one: 3
+        number-two: 9
+# ...
+
+

Further considerations

+

The example in this post is meant to show how to use a combination of tools and libraries to create a Github Action and doesn't show the true power of the Typelevel stack. A recent addition to fs2-io that can be handy in the context of an action might be the Processes APIs, with whom you can invoke external commands/tools handling their stdin, stdout, and exit codes:

+
import cats.effect.{Concurrent, MonadCancelThrow}
+import fs2.io.process.{Processes, ProcessBuilder}
+import fs2.text
+
+def helloProcess[F[_]: Concurrent: Processes]: F[String] =
+  ProcessBuilder("echo", "Hello, process!").spawn.use { process =>
+    process.stdout.through(text.utf8.decode).compile.string
+  }
+
+

The toolkit includes the Ember client and its circe integration, with whom you can easily call any external service and deserialize its output in a case class:

+
import cats.effect.IO
+import cats.syntax.all.*
+import io.circe.Decoder
+import org.http4s.circe.jsonOf
+import org.http4s.EntityDecoder
+import org.http4s.ember.client.EmberClientBuilder
+
+case class Foo(bar:String) derives Decoder
+given EntityDecoder[IO, Foo] = jsonOf[IO, Foo]
+
+EmberClientBuilder.default[IO].build.use { client =>
+    client.expect[Foo](s"https://foo.bar").flatMap(foo => IO.println(foo))
+}
+
+

The toolkit's site contains a few examples of what you can do with it. Go take a look πŸ˜„

+

Conclusions

+

Despite being a bit unripe, I find this approach fascinating and easy to use (in particular if you don't know any js in 2023 πŸ˜‡).

+

In the future, I might consider rewriting in Scala.js the actions/toolkit library or a part of it (I might have to learn javascript 🀦). If you want to contribute, feel free to contact me.

+

One thing that's worth exploring is the interaction with Scala-Steward. Can the CI be set up to re-generate the js and commit the result? Probably yes, with postUpdateHooks. Is it desirable? I'm still not sure.

+

You'll find the code written in the post in this repository

+

Enjoy!

+ + +
+ + + + + \ No newline at end of file diff --git a/hello-world/index.html b/hello-world/index.html new file mode 100644 index 0000000..0dc21fd --- /dev/null +++ b/hello-world/index.html @@ -0,0 +1,90 @@ + + + + + + + + + + + + Hello World | TonioGela's + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +

Hello World

+ +
    +
  • + +
  • +
  • +
  • 100 words
  • +
  • +
  • 1 min
  • +
+ +

Hello, hooman; this is the very beginning of this blog. πŸŽ‰

+

There's still plenty of customisation and things to set up, like comments using Utterance. And don't look at the about me page: it's still a lorem ipsum.

+

This blog will be about Scala, a bit of Rust (I'm in on its learning path), many command-line tools to automatise every aspect of a developer's life, cats 🐱 in various forms and much other boring stuff.

+

Cheers!

+

[EDIT] The comment system now uses Giscus πŸ˜„

+ + +
+ + + + + \ No newline at end of file diff --git a/http4s-on-fly-io/certificates-instruction.webp b/http4s-on-fly-io/certificates-instruction.webp new file mode 100644 index 0000000..06a8ac7 Binary files /dev/null and b/http4s-on-fly-io/certificates-instruction.webp differ diff --git a/http4s-on-fly-io/complete.webp b/http4s-on-fly-io/complete.webp new file mode 100644 index 0000000..ae78dda Binary files /dev/null and b/http4s-on-fly-io/complete.webp differ diff --git a/http4s-on-fly-io/custom-title.webp b/http4s-on-fly-io/custom-title.webp new file mode 100644 index 0000000..c71d7b6 Binary files /dev/null and b/http4s-on-fly-io/custom-title.webp differ diff --git a/http4s-on-fly-io/fly-domain.webp b/http4s-on-fly-io/fly-domain.webp new file mode 100644 index 0000000..9cc05ff Binary files /dev/null and b/http4s-on-fly-io/fly-domain.webp differ diff --git a/http4s-on-fly-io/google-domains.webp b/http4s-on-fly-io/google-domains.webp new file mode 100644 index 0000000..0397e33 Binary files /dev/null and b/http4s-on-fly-io/google-domains.webp differ diff --git a/http4s-on-fly-io/index.html b/http4s-on-fly-io/index.html new file mode 100644 index 0000000..fc340b6 --- /dev/null +++ b/http4s-on-fly-io/index.html @@ -0,0 +1,529 @@ + + + + + + + + + + + + Deploy http4s on your domain with fly.io | TonioGela's + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +

Deploy http4s on your domain with fly.io

+ +
    +
  • + +
  • +
  • +
  • 2437 words
  • +
  • +
  • 13 min
  • +
+ +
+

DISCLAIMER: This article assumes some familiarity with the Typelevel's tech stack, http4s in particular.

+

There's plenty of good resources to read online to get started with, some of them being Scala with Cats, Essential Effects and the Cats Effect documentation. +The best and most comprehensive resource you'll find to develop a microservice using this stack is Practical FP in Scala, that I strongly suggest reading.

+

If you need help with any of these resources feel free to contact me or better ask questions in the Typelevel's Discord. You'll find an amazing and kind community of really talented people that will be glad to answer to your questions πŸ˜„

+
+

If you already own a domain, deploying a toy server or any personal server-shaped project on it should not be a complex operation. Using fly.io, scala-cli, http4s and just can help automatise the process and reduce the friction up to the point it might even be fun.

+

Requirements

+

Before starting, we'll need to set up a couple of things. Here's the list:

+
    +
  • Having/buying a custom domain and having access to its DNS settings page: I'm using Google Domains since the domains are cheap (most of them cost 12$ per year), but sadly it lacks support for ALIAS records.
  • +
  • Sign up on fly.io, install its command line tool flyctl and log in using flyctl auth login
  • +
  • Of course, a local installation of scala-cli (Here's me talking about it on the Rock The JVM blog)
  • +
  • Optionally the command line tool just that I recently reviewed in another article
  • +
+

Writing the application

+

Writing a hello-world-spitting server with http4s using its giter8 template and sbt it's a trivial task.

+

Instead, we'll write it manually, using scala-cli and adding a slightly less trivial business logic. To begin, we'll create a file containing a few scala-cli directives to declare the dependencies and the scala version:

+
//> using scala "3.2.1"
+//> using lib "org.http4s::http4s-ember-server::0.23.17"
+//> using lib "org.http4s::http4s-dsl::0.23.17"
+//> using lib "com.monovore::decline-effect::2.4.1"
+//> using lib "ch.qos.logback:logback-classic:1.4.5"
+
+

The server will read two environment variables, a mandatory one for the base URL and one for the title of the HTML pages to return. We'll use decline to define them and use them:

+
import cats.effect.{ExitCode, IO}
+import cats.syntax.all.*
+import com.monovore.decline.Opts
+import com.monovore.decline.effect.CommandIOApp
+import org.http4s.Uri
+
+object Server extends CommandIOApp("helloServer", "Greets you in HTML") {
+
+  val titleOpt: Opts[String] =
+    Opts.env[String]("TITLE", "Page title").withDefault("Hello")
+
+  val baseUrlOpt: Opts[Uri] = Opts
+    .env[String]("BASE_URL", "The base url")
+    .mapValidated(
+      Uri
+        .fromString(_)
+        .leftMap(_.message)
+        .ensure("base url must be absolute")(_.path.addEndsWithSlash.absolute)
+        .map(uri => uri.withPath(uri.path.dropEndsWithSlash))
+        .toValidatedNel
+    )
+
+  def main: Opts[IO[ExitCode]] = (baseUrlOpt, titleOpt).mapN((baseUrl, title) =>
+    IO.println(s"$baseUrl $title").as(ExitCode.Success)
+  )
+}
+
+

The application prints the environment variables' content, validates the base URL's content and adds a default for TITLE.

+

To add some business logic to the soon-to-be server, we'll add a pure function that builds a tiny HTML page, and we'll use it in our routes implementation:

+
import cats.effect.kernel.Async
+import org.http4s.{HttpRoutes, MediaType, Response, Status}
+import org.http4s.dsl.io.*
+import org.http4s.headers.`Content-Type`
+
+def page(uri: Uri, title: String): String =
+  s"""|<html>
+      |<head><title>$title</title></head>
+      |<body>Hello from ${uri.toString}</body>
+      |</html>""".stripMargin
+
+def routes[F[_]: Async](baseUrl: Uri, title: String): HttpRoutes[F] =
+  HttpRoutes.of[F] {
+    case GET -> Root / "health" => Response[F](Status.Ok).pure[F]
+    case GET -> path =>
+        Response[F](Status.Ok)
+          .withEntity(page(baseUrl.withPath(baseUrl.path.merge(path)), title))
+          .withContentType(`Content-Type`(MediaType.text.html))
+          .pure[F]
+  }
+
+

The simple logic consists in printing the absolute URL of the page that was requested to the server, plus a health check endpoint.

+

We'll add some logging to our routes leveraging log4cats and slf4j:

+
 import org.typelevel.log4cats.Logger
+ import org.typelevel.log4cats.slf4j.*
+
++def routes[F[_]: Async: Logger](baseUrl: Uri, title: String): HttpRoutes[F] =
+-def routes[F[_]: Async](baseUrl: Uri, title: String): HttpRoutes[F] =
+   HttpRoutes.of[F] {
+     case GET -> Root / "health" => Response[F](Status.Ok).pure[F]
+     case GET -> path =>
++      Logger[F].info(s"Serving $path") >>
+         Response[F](Status.Ok)
+           .withEntity(page(baseUrl.withPath(baseUrl.path.merge(path)), title))
+           .withContentType(`Content-Type`(MediaType.text.html))
+           .pure[F]
+   }
+
+

Our logging backend will be logback, which we'll configure by adding a logback.xml file in our current directory:

+
+
+ + logback.xml +
+
<?xml version="1.0" encoding="UTF-8"?>
+<configuration debug="false">
+    <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
+        <encoder>
+            <pattern>
+                %d{ISO8601} [%-4level] %logger{0}: %msg%n
+            </pattern>
+        </encoder>
+    </appender>
+
+    <logger name="org.http4s.ember.server" level="ERROR" />
+
+    <root level="INFO">
+        <appender-ref ref="STDOUT" />
+    </root>
+</configuration>
+
+
+
+

What is lacking now is the logger and server instantiation in our main method. Adding it will finally complete our implementation:

+
+
+ + server.scala +
+
//> using scala "3.2.1"
+//> using resourceDir "."
+//> using packaging.packageType "assembly"
+//> using lib "org.http4s::http4s-ember-server::0.23.17"
+//> using lib "org.http4s::http4s-dsl::0.23.17"
+//> using lib "com.monovore::decline-effect::2.4.1"
+//> using lib "ch.qos.logback:logback-classic:1.4.5"
+
+import cats.effect.{ExitCode, IO}
+import cats.effect.kernel.Async
+import cats.syntax.all.*
+import com.comcast.ip4s.{ipv4, port}
+import com.monovore.decline.Opts
+import com.monovore.decline.effect.CommandIOApp
+import org.http4s.{HttpRoutes, MediaType, Response, Status, Uri}
+import org.http4s.dsl.io.*
+import org.http4s.ember.server.EmberServerBuilder
+import org.http4s.headers.`Content-Type`
+import org.http4s.server.middleware.CORS
+import org.typelevel.log4cats.Logger
+import org.typelevel.log4cats.slf4j.*
+
+object Server extends CommandIOApp("helloServer", "Titles you in HTML") {
+
+  val titleOpt: Opts[String] =
+    Opts.env[String]("TITLE", "Page title").withDefault("Hello")
+
+  val baseUrlOpt: Opts[Uri] = Opts
+    .env[String]("BASE_URL", "The base url")
+    .mapValidated(
+      Uri
+        .fromString(_)
+        .leftMap(_.message)
+        .ensure("base url must be absolute")(_.path.addEndsWithSlash.absolute)
+        .map(uri => uri.withPath(uri.path.dropEndsWithSlash))
+        .toValidatedNel
+    )
+
+  def page(uri: Uri, title: String): String =
+    s"""|<html>
+        |<head><title>$title</title></head>
+        |<body>Hello from ${uri.toString}</body>
+        |</html>""".stripMargin
+
+  def routes[F[_]: Async: Logger](baseUrl: Uri, title: String): HttpRoutes[F] =
+    HttpRoutes.of[F] {
+      case GET -> Root / "health" => Response[F](Status.Ok).pure[F]
+      case GET -> path =>
+        Logger[F].info(s"Serving $path") >>
+          Response[F](Status.Ok)
+            .withEntity(page(baseUrl.withPath(baseUrl.path.merge(path)), title))
+            .withContentType(`Content-Type`(MediaType.text.html))
+            .pure[F]
+    }
+
+  def main: Opts[IO[ExitCode]] = (baseUrlOpt, titleOpt).mapN((baseUrl, title) =>
+    for {
+      given Logger[IO] <- Slf4jFactory.create[IO]
+      exitCode <- EmberServerBuilder
+        .default[IO]
+        .withHttp2
+        .withHost(ipv4"0.0.0.0")
+        .withPort(port"8080")
+        .withHttpApp(
+          CORS.policy.withAllowOriginAll(routes[IO](baseUrl, title)).orNotFound
+        )
+        .build
+        .useForever
+        .as(ExitCode.Success)
+    } yield exitCode
+  )
+}
+
+
+
+

We added using resourceDir "." to make the file logback.xml discoverable by logback and using packaging.packageType "assembly" to pack our server with all its dependencies to avoid downloading them at every boot.

+

We can now perform a test running the server locally and visiting localhost:8080/foo:

+
$ BASE_URL="https://toniogela.dev" scala-cli run .
+2023-01-07 23:46:39,183 [INFO] Server: Serving /foo/
+
+

+ +

Packing the server as a docker application

+

Last but not least, since fly.io accepts already-built Docker images to run, we should pack our application in a container. Luckily for us, scala-cli can directly package our server as a docker image using a custom base image:

+
$ scala-cli package server.scala --docker --docker-image-repository hello-server --docker-image-tag 0.1.0 --docker-from eclipse-temurin:11.0.17_8-jre-alpine 
+Compiling project (Scala 3.2.1, JVM)
+Compiled project (Scala 3.2.1, JVM)
+Started building docker image with your application, it might take some time
+Built docker image, run it with
+  docker run hello-server:0.1.0
+$ docker run -e BASE_URL="https://toniogela.dev" -p8080:8080 hello-server:0.1.0
+2023-01-07 23:06:30,524 [INFO] Server: Serving /foo/ciao
+2023-01-07 23:06:30,866 [INFO] Server: Serving /favicon.ico
+
+

Since we'll need to rebuild the app again and the command is quite long, we'll write down a Justfile for ease:

+
+
+ + Justfile +
+
docker_image_name := "hello-server"
+docker_image_tag  := "0.1.0"
+base_image        := "eclipse-temurin:11.0.17_8-jre-alpine"
+
+_default:
+  @just --list --unsorted
+
+# Runs the app on localhost:8080
+run:
+  BASE_URL="https://hello.toniogela.dev" scala-cli run .
+
+# Build the docker image
+build:
+  scala-cli package server.scala --docker \
+    --docker-image-repository {{docker_image_name}} \
+    --docker-image-tag {{docker_image_tag}} \
+    --docker-from {{base_image}}
+
+
+
+

Now rebuilding the app is as simple as running just build

+
$ just
+Available recipes:
+    run                 # Runs the app on localhost:8080
+    build               # Build the docker image
+$ just build
+scala-cli package server.scala --docker --docker-image-repository hello-server --docker-image-tag 0.1.0 --docker-from eclipse-temurin:11.0.17_8-jre-alpine 
+Compiling project (Scala 3.2.1, JVM)
+Compiled project (Scala 3.2.1, JVM)
+Started building docker image with your application, it might take some time
+Built docker image, run it with
+  docker run hello-server:0.1.0
+
+

Deploying the server or fly.io

+

Fly has a free Hobby Plan that includes:

+
    +
  • 3 shared-cpu-1x with 256mb of RAM
  • +
  • 3GB persistent volume storage (in total)
  • +
  • 160GB outbound data transfer
  • +
+

So it's perfectly feasible for small apps like the one we're going to deploy, plus it automatically produces for free the first ten single-hostname HTTPS certificates using Let's Encrypt. Last but not least, fly.io offers Fly Postgres to help you bootstrap and manage a database cluster for your apps. It's important to know that it's not a fully managed database like in other platforms.

+

Creating our app is as simple as launching a command:

+
$ fly launch --image hello-server:0.1.0 
+Creating app in /Users/toniogela/repo/personal/helloServer
+Using image hello-server:0.1.0
+? Choose an app name (leave blank to generate one): hello-toniogela
+? Choose a region for deployment: Frankfurt, Germany (fra)
+Admin URL: https://fly.io/apps/hello-toniogela
+Hostname: hello-toniogela.fly.dev
+Wrote config file fly.toml
+? Would you like to set up a Postgresql database now? No
+? Would you like to set up an Upstash Redis database now? No
+? Would you like to deploy now? No
+Your app is ready! Deploy with `flyctl deploy
+
+

One of the side effects of the last command execution is that fly.toml configuration file for our application gets generated. The default settings are usually fine, but we need at least to add under env our mandatory variable BASE_URL.

+

I removed the [[services.tcp_checks]] in favour of a [[services.http_checks]] that calls our health check API, increased some concurrency limits and forced HTTPS traffic, all by following the configuration reference.

+
+
+ + fly.toml +
+
app = "hello-toniogela"
+kill_signal = "SIGINT"
+kill_timeout = 120
+
+[env]
+  BASE_URL = "https://hello.toniogela.dev"
+
+[build]
+  image = "hello-server:0.1.0"
+
+[[services]]
+  internal_port = 8080
+  processes = ["app"]
+  protocol = "tcp"
+
+  [services.concurrency]
+    hard_limit = 500
+    soft_limit = 250
+    type = "requests"
+
+  [[services.ports]]
+    force_https = true
+    handlers = ["http"]
+    port = 80
+
+  [[services.ports]]
+    handlers = ["tls", "http"]
+    port = 443
+
+  [[services.http_checks]]
+    grace_period = "10s"
+    interval = "5s"
+    method = "get"
+    path = "/health"
+    protocol = "http"
+    restart_limit = 5
+    timeout = "2s"
+
+
+
+

Even deploying is just a matter of running a single command:

+
$ fly deploy --local-only
+==> Verifying app config
+--> Verified app config
+==> Building image
+Searching for image 'hello-server:0.1.0' locally...
+image found: sha256:9ffc712f96bb61eae722619ad0bd21a752e39b2a0cceca1abdb510bec18820cf
+==> Pushing image to fly
+The push refers to repository [registry.fly.io/hello-toniogela]
+6edf61a11a72: Pushed 
+d5ee5e28f5b5: Pushed 
+688df10214b7: Pushed 
+5ab3fbcbc72f: Pushed 
+ded7a220bb05: Pushed 
+deployment-01GP7936X7ZMX5VXDS2MYM1C9D: digest: sha256:99b04cf901b057a10f2526e6f973285ffb09777e497cd6abd6d96c6cd73a6114 size: 1371
+--> Pushing image done
+==> Creating release
+--> release v2 created
+
+--> You can detach the terminal anytime without stopping the deployment
+==> Monitoring deployment
+Logs: https://fly.io/apps/hello-toniogela/monitoring
+
+ 1 desired, 1 placed, 1 healthy, 0 unhealthy [health checks: 1 total, 1 passing]
+--> v0 deployed successfully
+
+

The --local-only flag was used to perform the build only locally using the local docker daemon and pushing the previously built image. We can now check that our app is reachable under https://{appName}.fly.dev:

+

+ +

Secrets

+

Fly supports secret environment variables, and they can be easily set from the command line, triggering a redeploy:

+
$ fly secrets set TITLE="Mommy I'm online"
+Release v1 created
+==> Monitoring deployment
+Logs: https://fly.io/apps/hello-toniogela/monitoring
+
+ 1 desired, 1 placed, 1 healthy, 0 unhealthy [health checks: 1 total, 1 passing]
+--> v1 deployed successfully
+
+

+ +

+

We can save these commands for later reuse in our Justfile, using dependencies between recipes and default arguments:

+
# Deploys on fly.io
+deploy: build
+    flyctl deploy --local-only
+
+# Changes the TITLE secret on fly.io
+title label="Hello":
+    flyctl secrets set TITLE="{{label}}"
+
+# Opens the web UI of fly.io
+open:
+    open "https://fly.io/apps/hello-toniogela/"
+
+

Adding certificates and publishing on our domain

+

Now that we confirmed that the server is up and running, it's time to make fly.io generate an HTTPS certificate and configure the DNS to expose the app on our domain. By default, fly.io assigns to every new app a shared ipv4 and a dedicated ipv6. This is due to a popularity increase and a global IPv4 scarcity, as announced on the Fly.io blog.

+

If we still desire a dedicated IPv4, i.e. for using an A record in our DNS server, we can allocate one:

+
$ fly ips allocate-v4
+VERSION	IP           	TYPE  	REGION	CREATED AT
+v4     	137.66.63.249	public	global	7s ago
+
+

To generate an HTTPS certificate, we can always use the command line:

+
$ fly certs add hello.toniogela.dev
+You are creating a certificate for hello.toniogela.dev
+We are using Let's Encrypt for this certificate.
+
+You can configure your DNS for hello.toniogela.dev by:
+
+1: Adding an CNAME record to your DNS service which reads:
+
+    CNAME hello. hello-toniogela.fly.dev
+
+

To speed up the certificate creation, we can visit the dedicated section on our app dashboard and follow the instructions to confirm the domain ownership:

+

+ +

+

and setup at our domain's vendor the DNS records as requested:

+

+ +

+

After a few minutes, our DNS should be propagated. We can check the status via command line:

+
$ fly certs check hello.toniogela.dev
+The certificate for hello.toniogela.dev has been issued.
+Hostname                  = hello.toniogela.dev
+
+DNS Provider              = googledomains
+
+Certificate Authority     = Let's Encrypt
+
+Issued                    = rsa,ecdsa
+
+Added to App              = 10 minutes ago
+
+Source                    = fly
+
+

Now we can enjoy our app directly from our domain πŸŽ‰πŸŽ‰πŸŽ‰

+

+ +

Conclusions

+

We saw how fast publishing a backend application on a custom domain can be following these instructions.

+

This article is not a comprehensive guide of either http4s, scala-cli or fly.io, but rather a series of TODO steps that might come in handy when you want to prototype an idea and show it to someone else rapidly.

+

Enjoy!

+ + +
+ + + + + \ No newline at end of file diff --git a/http4s-on-fly-io/local-test.webp b/http4s-on-fly-io/local-test.webp new file mode 100644 index 0000000..f6652fb Binary files /dev/null and b/http4s-on-fly-io/local-test.webp differ diff --git a/index.html b/index.html new file mode 100644 index 0000000..d934fc1 --- /dev/null +++ b/index.html @@ -0,0 +1,223 @@ + + + + + + + + + + + + TonioGela's + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +

TonioGela's

+
"In order to understand recursion, one must first understand recursion"
+ + + + +
+ +
+ +
+

+ Integration testing the Typelevel toolkit +

+ +
    +
  • + +
  • +
  • +
  • 2913 words
  • +
  • +
  • 15 min
  • +
+ +

How do you test a meta library that is meant to be used mainly via scala-cli? And also, how do you automatize the tests for every platform that the meta library supports? Here's how we did in a weekend full of sbt-fu.

+

+ Continue Reading β†’ +
+ + + + + +
+

+ Just use just +

+ +
    +
  • + +
  • +
  • +
  • 2131 words
  • +
  • +
  • 11 min
  • +
+ +

Fast, well designed and well documented command-line tool written in Rust. Handy way to save and run project-specific commands that features colored output, variadic arguments, custom interpreters and much more.

+

+ Continue Reading β†’ +
+ + + +
+ + + + \ No newline at end of file diff --git a/instantpage-5.1.0.js b/instantpage-5.1.0.js new file mode 100644 index 0000000..4a08db3 --- /dev/null +++ b/instantpage-5.1.0.js @@ -0,0 +1,2 @@ +/*! instant.page v5.1.0 - (C) 2019-2020 Alexandre Dieulot - https://instant.page/license */ +let t,e;const n=new Set,o=document.createElement("link"),i=o.relList&&o.relList.supports&&o.relList.supports("prefetch")&&window.IntersectionObserver&&"isIntersecting"in IntersectionObserverEntry.prototype,s="instantAllowQueryString"in document.body.dataset,a="instantAllowExternalLinks"in document.body.dataset,r="instantWhitelist"in document.body.dataset,c="instantMousedownShortcut"in document.body.dataset,d=1111;let l=65,u=!1,f=!1,m=!1;if("instantIntensity"in document.body.dataset){const t=document.body.dataset.instantIntensity;if("mousedown"==t.substr(0,"mousedown".length))u=!0,"mousedown-only"==t&&(f=!0);else if("viewport"==t.substr(0,"viewport".length))navigator.connection&&(navigator.connection.saveData||navigator.connection.effectiveType&&navigator.connection.effectiveType.includes("2g"))||("viewport"==t?document.documentElement.clientWidth*document.documentElement.clientHeight<45e4&&(m=!0):"viewport-all"==t&&(m=!0));else{const e=parseInt(t);isNaN(e)||(l=e)}}if(i){const n={capture:!0,passive:!0};if(f||document.addEventListener("touchstart",function(t){e=performance.now();const n=t.target.closest("a");if(!h(n))return;v(n.href)},n),u?c||document.addEventListener("mousedown",function(t){const e=t.target.closest("a");if(!h(e))return;v(e.href)},n):document.addEventListener("mouseover",function(n){if(performance.now()-e{v(o.href),t=void 0},l)},n),c&&document.addEventListener("mousedown",function(t){if(performance.now()-e1||t.metaKey||t.ctrlKey)return;if(!n)return;n.addEventListener("click",function(t){1337!=t.detail&&t.preventDefault()},{capture:!0,passive:!1,once:!0});const o=new MouseEvent("click",{view:window,bubbles:!0,cancelable:!1,detail:1337});n.dispatchEvent(o)},n),m){let t;(t=window.requestIdleCallback?t=>{requestIdleCallback(t,{timeout:1500})}:t=>{t()})(()=>{const t=new IntersectionObserver(e=>{e.forEach(e=>{if(e.isIntersecting){const n=e.target;t.unobserve(n),v(n.href)}})});document.querySelectorAll("a").forEach(e=>{h(e)&&t.observe(e)})})}}function p(e){e.relatedTarget&&e.target.closest("a")==e.relatedTarget.closest("a")||t&&(clearTimeout(t),t=void 0)}function h(t){if(t&&t.href&&(!r||"instant"in t.dataset)&&(a||t.origin==location.origin||"instant"in t.dataset)&&["http:","https:"].includes(t.protocol)&&("http:"!=t.protocol||"https:"!=location.protocol)&&(s||!t.search||"instant"in t.dataset)&&!(t.hash&&t.pathname+t.search==location.pathname+location.search||"noInstant"in t.dataset))return!0}function v(t){if(n.has(t))return;const e=document.createElement("link");e.rel="prefetch",e.href=t,document.head.appendChild(e),n.add(t)} \ No newline at end of file diff --git a/just/index.html b/just/index.html new file mode 100644 index 0000000..6804fe1 --- /dev/null +++ b/just/index.html @@ -0,0 +1,416 @@ + + + + + + + + + + + + Just use just | TonioGela's + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +

Just use just

+ +
    +
  • + +
  • +
  • +
  • 2131 words
  • +
  • +
  • 11 min
  • +
+ +

OMG, the blog is live! 😱 And this is the first article! 😱

+

This first article will be about Just a command-line tool I recently discovered that immediately became essential in many work projects. Since it's a tool written in Rust, it's fast, it's well designed and documented, it features colored output, and it's an essential step in your terminal's hypsterization process!

+

Let's suppose you've just deployed your application via scp (sigh!) on one of your work's machines. Maybe your application was already built using tools like Decline, so it's already capable of parsing command-line options and flags and printing a complete help like:

+
$ foo --help
+Usage:
+    foo schedule
+    foo encrypt
+    foo decrypt
+
+foo tool, it can encrypt and decrypt files and schedule operations
+
+Options and flags:
+    --help
+        Display this help text.
+
+Subcommands:
+    schedule
+        schedules encryptions/decriptions
+    encrypt
+        encrypts files
+    decrypt
+        decrypts files
+
+

But let's add a slow-changing configuration to the scenario, which changes so often that it doesn't justify a refactor to add a library like Ciris to your code. Maybe some non-power users need to change that configuration once a week or month because of reasons.

+

What's missing? Maybe there's a local MySql that needs to be queried for maintenance operations, or perhaps a remote database/storage/service/whatever that requires another command-line tool to be interacted with.

+

This is one of the times in which unmaintained, undocumented, faulty crap like maintenance_script.sh or fix_for_prod.sh begins to spread around. In no time, the situation will look similar to

+
/home/applicative_account/perform_operation.sh
+/home/colleague1/perform_operation_copy.sh
+/home/colleague1/old_version/perform_operation_as_root.sh
+/home/sre_guy/this_should_fix_everything.sh
+/home/random_data_scientist/do_not_run.sh #(ofc it was chmod +x)
+
+

90% of them will have the shebang #!/bin/bash while the 10% #!/bin/sh. Some of them will have zsh commands because there are people around that uses zsh (like me) that forgets that it doesn't share 100% of the syntax with bash (not like me, I swear).

+

Most of them will contain almost the same commands like

+
mysql prod_db < maintenance.sql > maintenance_output.dump
+
+

or templatized commands like

+
"/foo-${VERSION}/bin/foo"
+
+

that depend on environment variables defined in the .profile of a deleted user.

+

The last time you used ShellCheck to check the scripts, the linter exploded, and somewhere in the world, Stephen Bourne suddenly began crying without any apparent reason.

+

Just to the rescue

+

As its Github README states, Just is a handy way to save and run project-specific commands called recipes, stored in a file called justfile with a syntax inspired by Make.

+

Here's a tiny example:

+
build:
+    cc *.c -o main
+
+# test everything
+test-all: build
+    ./test --all
+
+# run a specific test
+test TEST: build
+    ./test --test {{TEST}}
+
+

Just searches for a justfile in the current directory written in its particular syntax, so let's begin creating one with an hello world recipe and let's try to run it:

+
hello-world:
+    echo "Hello World!"
+
+
+ output +
$ just hello-world
+echo "Hello World!"
+Hello World!
+
+
+

As you can see, just shows the command that is about to run before running it, while we can't say the same for global or user-defined aliases in various shells (unless using something like set -x for bash). If you want to suppress this behaviour, you can put a @ in front of the command to hide.

+
hello-world:
+    @echo "Hello World!"
+
+
+ output +
$ just hello-world
+Hello World!
+
+
+

Let's try to create a second recipe with an argument.

+
hello-world:
+    @echo "Hello World!"
+
+salute guy:
+    @echo "Hello {{guy}}!"
+
+
+ output +
$ just salute
+error: Recipe `salute` got 0 arguments but takes 1
+usage:
+    just salute guy
+
+$ just salute Tonio
+Hello Tonio!
+
+$ just --dry-run salute Tonio
+echo "Hello Tonio"
+
+
+

The recipe cannot obviously run without an argument since that argument is referred to in the body of the recipe using just syntax {{ argument_or_variable_name }}. If you want to "debug" the recipe that will run with the provided arguments, you can use the --dry-run command-line flag. This can come in handy if a command is long and complex and you have, for example, to schedule it in your crontab file. Just copy it from there.

+

Arguments are really powerful since they can have default values and can be variadic (both in the form zero or more or one or more):

+
hello target="World":
+    @echo "Hello {{target}}!"
+
+hello-all +targets="Tim": # One or more plus a default value
+    @echo "Hello to everyone: {{targets}}!"
+
+hello-any *targets: # Zero or more
+    @echo "Hello {{targets}}!"
+
+
+ output +
$ just hello
+Hello World!
+
+$ just hello-all
+Hello to everyone: Tim!
+
+$ just hello-all "Tim" "Martha" "Lisa"
+Hello to everyone: Tim Martha Lisa!
+
+$ just hello-any
+Hello !
+
+$ just hello-any "Bob" "Lucas"
+Hello Bob Lucas!
+
+
+

We know enough syntax. Let's try to build a meaningful example for our messed-up work machine and let's try new features just if we need them (no pun intended πŸ˜„).

+

An almost working example

+

If we inspect the history of our machine, we'll notice that most of the commands are foo invocations with nohup and stdin and stderr redirection into a .log file. We should consider refactoring the application, removing all the printlns to replace them with a logger.info, maybe using a logging framework that automatically handles log rotation and similar.

+

In the meantime, we can standardize how foo is called, how the outputs are redirected, and its execution detached to avoid interactive sessions that might early terminate if you close a terminal session.

+
foo_version    := "0.3.0"
+foo_executable := "/home/power_user/foo-" + foo_version + "/bin/foo"
+conf_file      := "/home/power_user/foo.conf"
+log_file       := "/home/power_user/foo.log"
+
+# encrypts 'target' and detaches
+encrypt target:
+    nohup {{foo_executable}} "encrypt" {{target}} {{conf_file}} &>> {{log_file}} &
+
+# decrypts 'target' and detaches
+decrypt target:
+    nohup {{foo_executable}} "decrypt" {{target}} {{conf_file}} &>> {{log_file}} &
+
+# schedules operations formatted like '<cron_expression> <decrypt|encrypt> <target>'
+schedule operation:
+    nohup {{foo_executable}} "schedule" "{{operation}}" {{conf_file}} &>> {{log_file}} &
+
+

(Probably nohup + & is overkilling, but who cares πŸ˜‡? )

+

That's better. We've used variables to avoid repetitions, templatized every recipe and added comments. It would be nice, though, to directly tail the log_file once a recipe is launched and avoid repetitions even more.

+
foo_version    := "0.3.0"
+foo_executable := "/home/power_user/foo-" + foo_version + "/bin/foo"
+conf_file      := "/home/power_user/foo.conf"
+log_file       := "/home/power_user/foo.log"
+
+_default:
+    @just --list --unsorted
+
+# encrypts 'target' and detaches
+encrypt target:
+    @just _run_detached "schedule" "{{target}}"
+    @just tail
+
+# decrypts 'target' and detaches
+decrypt target:
+    @just _run_detached "schedule" "{{target}}"
+    @just tail
+
+# schedules operations formatted like '<cron_expression> <decrypt|encrypt> <target>'
+schedule operation:
+    @just _run_detached "schedule" "{{operation}}"
+    @just tail 20
+
+# Follows the log file
+tail n="200":
+    tail -{{n}}f {{log_file}}
+
+_run_detached command argument:
+    nohup {{foo_executable}} {{command}} {{argument}} {{conf_file}} &>> {{log_file}} &
+
+

Nice, we've used many features of just, in particular recipes whose name begins with an underscore are called hidden recipes. Hidden means that if you run just --list, they won't get printed since they're meant to be used internally. A special recipe was used, the default one, that gets called if you prompt just without any recipe name. [EDIT] (Since the name is not precisely default, just runs the first recipe in the justfile, that has to be a recipe without arguments)

+
$ just
+Available recipes:
+    encrypt target     # encrypts 'target' and detaches
+    decrypt target     # decrypts 'target' and detaches
+    schedule operation # schedules operations formatted like '<cron_expression> <decrypt|encrypt> <target>'
+    tail n="200"       # Follows the log file
+
+

Oh nice, the comments we wrote previously just became documentation! Plus, we called the tail recipe from others, letting just encrypt "something" resemble an interactive command.

+

Let's now set the same interpreter for all the recipes choosing from the available ones: set shell := ["bash", "-uc"]. This way, every recipe line will run in a newly spawned subshell, bash in this case. If it feels like the way the shebang #!/bin/bash works, you're right.

+

In fact, it's possible to define shebang recipes to be able to use local variables in recipes but remember to add set -euxo pipefail like the documentation suggests if you're using Bash to maintain the fail-fast behaviour.

+

Mixing and stirring commands, recipes, just features you'll probably come up with something similar to this prod-like example:

+
+
+ + Justfile +
+
set shell := ["bash", "-uc"]
+
+# Foo
+foo_version    := "0.3.0"
+foo_executable := "/home/power_user/foo-" + foo_version + "/bin/foo"
+conf_file      := "/home/power_user/foo.conf"
+log_file       := "/home/power_user/foo.log"
+
+# Bar
+bar_executable := "/home/power_user/bar"
+sre_victim     := "baz@sre.com"
+
+# MySql
+my_sql_default_user := "random_guy"
+dump_query          := "select 'I have no intention to write queries in this example';"
+now                 := `date -u +"%Y-%m-%dT%H:%M:%SZ"`
+mysql_output_file   := "/home/power_user/mysql_dumps/" + now + ".dump"
+
+# Colors
+RED    := "\\u001b[31m"
+GREEN  := "\\u001b[32m"
+YELLOW := "\\u001b[33m"
+BOLD   := "\\u001b[1m"
+RESET  := "\\u001b[0m"
+
+## Foo Recipes
+
+_default:
+  @just --list --unsorted
+
+# encrypts 'target' and detaches
+encrypt target:
+    @just _run_detached "schedule" "{{target}}"
+    @just tail
+
+# decrypts 'target' and detaches
+decrypt target:
+    @just _run_detached "schedule" "{{target}}"
+    @just tail
+
+# schedules operations formatted like '<cron_expression> <decrypt|encrypt> <target>'
+schedule operation:
+    @just _run_detached "schedule" "{{operation}}"
+    @just tail 20
+
+# Follows the log file
+tail n="200":
+    tail -{{n}}f {{log_file}}
+
+# Unsurprisingly kills foo
+kill:
+    pgrep -f {{foo_executable}}
+
+## Bar Recipes
+
+# Will notify an SRE with a boring mail.
+notify:
+    @just _bold_squares "{{YELLOW}}WARNING"
+    @echo -e "{{BOLD}} A SRE will be notified with an e-mail!{{RESET}}"
+    {{bar_executable}} notify {{sre_victim}}
+
+## MySql Recipes
+
+# runs the dump query
+dump username password:
+    @just kill
+    @just _mysql_command_to {{username}} {{password}} {{dump_query}} > {{mysql_output_file}}
+
+# runs the dump query with default user
+dump-with-default-user password:
+    @just kill
+    @just _mysql_command_to {{my_sql_default_user}} {{password}} {{dump_query}} > {{mysql_output_file}}
+
+## Hidden Recipes
+
+_bold_squares message:
+	@echo -e "{{BOLD}}[{{RESET}}{{message}}{{RESET}}{{BOLD}}]{{RESET}}"
+
+_mysql_command username password query:
+    mysql -u {{username}} -p {{password}} -e {{query}}
+
+_mysql_command_to username password query output_file:
+    _mysql_command {{username}} {{password}} {{query}} > {{output_file}}
+
+_run_detached command argument:
+    nohup {{foo_executable}} {{command}} {{argument}} {{conf_file}} &>> {{log_file}} &
+
+
+
+
+

"It's not enough to enforce people to not mess up production machines with crappy shell scripts!"

+

Obviously, just doesn't automatically solve every problem you might encounter in heavily unmaintained machines with a lot of conflicting shell scripts, mostly because of people, but at least:

+
    +
  • It lets you concentrate every project-related commands in a single file that can be easily tracked by a VCS to become part of the deployment
  • +
  • It declaratively sets the interpreter
  • +
  • It lets you you write a multi-command script without relying on super-verbose and tricky match-case bash syntax with the addition of: + +
  • +
  • It integrates with fzf to choose argument-less recipes interactively
  • +
  • Recipes can depend on other recipes, like tests on build as in the first example
  • +
  • It can generate its own shell completion scripts using just --completions <shell_name>
  • +
  • It can be used as an interpreter, turning justfiles in runnable just script simply prepending #!/usr/bin/env just --justfile (This can be handy if you maybe want to use it with crontab)
  • +
+

and HIPSTER ALERT:

+
    +
  • It has its own Github Action
  • +
  • Syntax Highlight for Vim, Emacs and Visual Studio Code is already available +
      +
    • (I'll try to port it to a sublime-syntax to use it in this page with the syntax highlighting system of Zola)
    • +
    • [EDIT] I ported it! You can see it here
    • +
    +
  • +
+

Creating practical recipes, installing the prebuilt binaries, and the command-line completion scripts can probably convince people to use it. If not, try documenting your software, using examples in the justfile that's sitting in the home of the repo, or try harder using

+
**********************************
+* Run `just` for a complete list *
+* of available commands          *
+**********************************
+
+

as the /etc/motd for the prod machines.

+
+
More Tools!
+

In the following weeks, I'll try to write about other command-line tools I use every day (at least I'll try πŸ˜…), so follow me on Twitter to get updates or subscribe to RSS.

+

[EDIT] I've been mentioned by @casey, just's developer on twitter. Thanks @casey!

+
+ SPOILER: next tool +Zola : the templating engine I'm using for this blog :) +
+ + +
+ + + + + \ No newline at end of file diff --git a/page/1/index.html b/page/1/index.html new file mode 100644 index 0000000..c30b790 --- /dev/null +++ b/page/1/index.html @@ -0,0 +1,6 @@ + + + + +Redirect +

Click here to be redirected.

diff --git a/processed_images/discord.33d2862d50dcee2a.png b/processed_images/discord.33d2862d50dcee2a.png new file mode 100644 index 0000000..d35a00b Binary files /dev/null and b/processed_images/discord.33d2862d50dcee2a.png differ diff --git a/processed_images/galileo.d4ac41d02a748e67.png b/processed_images/galileo.d4ac41d02a748e67.png new file mode 100644 index 0000000..88f2074 Binary files /dev/null and b/processed_images/galileo.d4ac41d02a748e67.png differ diff --git a/processed_images/mastodon.656d637820affd2f.png b/processed_images/mastodon.656d637820affd2f.png new file mode 100644 index 0000000..dad9b30 Binary files /dev/null and b/processed_images/mastodon.656d637820affd2f.png differ diff --git a/processed_images/scala.578658ad571f1340.png b/processed_images/scala.578658ad571f1340.png new file mode 100644 index 0000000..e783e05 Binary files /dev/null and b/processed_images/scala.578658ad571f1340.png differ diff --git a/resume.pdf b/resume.pdf new file mode 100644 index 0000000..d488fc5 Binary files /dev/null and b/resume.pdf differ diff --git a/robots.txt b/robots.txt new file mode 100644 index 0000000..9433325 --- /dev/null +++ b/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Disallow: +Allow: / +Sitemap: https://toniogela.dev/sitemap.xml diff --git a/rss.xml b/rss.xml new file mode 100644 index 0000000..2e72da4 --- /dev/null +++ b/rss.xml @@ -0,0 +1,1644 @@ + + + + TonioGela's + https://toniogela.dev + "In order to understand recursion, one must first understand recursion" + Zola + en + + Wed, 04 Oct 2023 00:00:00 +0000 + + Integration testing the Typelevel toolkit + Wed, 04 Oct 2023 00:00:00 +0000 + Unknown + https://toniogela.dev/testing-typelevel-toolkit/ + https://toniogela.dev/testing-typelevel-toolkit/ + <p>The <a href="https://typelevel.org/toolkit/">Typelevel toolkit</a> is a metalibrary including some <strong>great libraries</strong> by <a href="https://github.com/typelevel/">Typelevel</a>, that was created to speed up the development of cross-platform applications in Scala and that I happily maintain since its creation. It's the Typelevel's flavour of the official <a href="https://github.com/scala/toolkit/">Scala Toolkit</a>, a set of libraries to perform common programming tasks, that has its own section, full of examples, in the <a href="https://docs.scala-lang.org/toolkit/introduction.html">official Scala documentation</a>.</p> +<p>One of the vaunts of the Typelevel's stack is the fact that (almost) every library is published for the all the <strong>three officially supported Scala platforms: JVM, JS and Native</strong>, and for this reason every library is <strong>heavily tested</strong> against every supported platform and Scala version, to ensure a near perfect cross-compatibility.</p> +<p>Since its creation the <a href="https://typelevel.org/toolkit/">Typelevel toolkit</a> was lacking any sort of testing, mainly due to the fact that it is a mere collection of already battle tested libraries, so why bothering writing tests for it? As <a href="https://github.com/typelevel/toolkit/issues/49">this bug</a> promptly reminded us, the main goal of the toolkit is to provide the most seamless experience while using <a href="https://scala-cli.virtuslab.org/">scala-cli</a>.</p> +<p>Ideally you should be able to write:</p> +<div class="code-window"> + <div class="code-title" ><span + class="dot-red"></span> + <span class="dot-yellow"></span> + <span class="dot-green"></span> helloWorld.scala + </div> + <div class="code-body"><pre data-lang="scala" style="background-color:#2b303b;color:#c0c5ce;" class="language-scala "><code class="language-scala" data-lang="scala"><span style="color:#65737e;">//&gt; using toolkit typelevel:latest +</span><span> +</span><span style="color:#b48ead;">import </span><span>cats.effect.* +</span><span> +</span><span style="color:#b48ead;">object </span><span style="color:#ebcb8b;">Hello </span><span style="color:#b48ead;">extends </span><span style="color:#a3be8c;">IOApp</span><span>.</span><span style="color:#ebcb8b;">Simple</span><span>: +</span><span> </span><span style="color:#b48ead;">def </span><span style="color:#8fa1b3;">run </span><span>= </span><span style="color:#ebcb8b;">IO</span><span>.println(&quot;</span><span style="color:#a3be8c;">Hello World!</span><span>&quot;) +</span></code></pre> +</div> +</div> +<p>and calling <code>scala-cli run {,--js,--native} helloWorld.scala</code> should <strong>Just Workβ„’</strong> printing <code>&quot;Hello World!&quot;</code> to the console.</p> +<p>To be 100% sure we needed CI tests indeed.</p> +<h2 id="planning-the-tests"><a class="anchor" href="#planning-the-tests">Planning the tests</a></h2> +<p>What had to be tested though? All the included libraries are already tested, some of them are built using other included libraries, so some sort of <strong>cross testing</strong> was already done. What we were really interested in was always <strong>being sure that scala-cli is always able to compile scripts written using the toolkit</strong>. And what's the best way to ensure that <code>scala-cli</code> can compile a script written with the toolkit if not using <code>scala-cli</code> itself? </p> +<p><code>Pause for dramatic effect</code></p> +<p>The coarse idea that <a href="https://github.com/armanbilge">Arman</a> and I had in mind was to have a CI doing the following:</p> +<ul> +<li><strong>Locally publishing</strong> the toolkit artifact</li> +<li>Passing the artifact's version to a bunch of <strong>pre-baked parametrized scripts</strong></li> +<li><strong>Running</strong> the scripts with <code>scala-cli</code></li> +<li>Be happy if every exit code is <strong>0</strong></li> +</ul> +<p>The <strong>third step</strong> in particular could have been implemented in a couple of ways:</p> +<ol> +<li>Installing <code>scala-cli</code> in the CI image via GitHub Actions, call it from the tests code, and gather the results</li> +<li>Since <code>scala-cli</code> is a <a href="https://scala-cli.virtuslab.org/docs/under-the-hood">native executable generated by GraalVM Native Image</a> and the corresponding jvm artifact <a href="https://repo1.maven.org/maven2/org/virtuslab/scala-cli/cli_3/">is distributed</a>, using it as a dependency and calling its main method in the tests.</li> +</ol> +<p>We decided to follow the latter, as we didn't want to <strong>mangle the GitHub Actions CI file</strong> or relying on the <strong>timely publication of the updated scala-cli GitHub Action</strong>: whenever any continuous integration setting is changed, every developer should apply the same or an equivalent change to its local environment to reflect the testing/building remote environment change. This also means more testing/contributing documentation that needs to be constantly updated (and that risks becoming outdated at every CI setting changed) and that the contributing/developing curve becomes steeper for newcomers (it's easier to ask a Scala developer to have just one build tool installed locally, right?).</p> +<p>Also, <a href="https://www.scala-sbt.org/">sbt</a> is a superb tool for implementing this kind of tests: since it downloads automatically the specified scala-cli artifact we didn't need to have scala-cli installed locally, the version we are testing in particular. The build would be more self-contained, the scala-cli artifact version will be managed as every other dependency by <a href="https://github.com/scala-steward-org/scala-steward">scala-steward</a> and developers and contributors could test locally the repository with ease with a simple <code>sbt test</code>.</p> +<blockquote> +<p><strong>BONUS EXAMPLE</strong>: Using <code>scala-cli</code> in <code>scala-cli</code> to run a <code>scala-cli</code> script that runs itself +<div class="code-window"> + <div class="code-title" style="background-color:#a3be8c;color:#1a2539;" ><span + class="dot-red"></span> + <span class="dot-yellow"></span> + <span class="dot-green"></span> recursiveScalaCli.scala + </div> + <div class="code-body"><pre data-lang="scala" style="background-color:#2b303b;color:#c0c5ce;" class="language-scala "><code class="language-scala" data-lang="scala"><span style="color:#65737e;">//&gt; using dep org.virtuslab.scala-cli::cli::1.0.4 +</span><span> +</span><span style="color:#b48ead;">import </span><span>scala.cli.</span><span style="color:#ebcb8b;">ScalaCli +</span><span> +</span><span style="color:#b48ead;">object </span><span style="color:#ebcb8b;">ScalaCliApp </span><span style="color:#b48ead;">extends </span><span style="color:#a3be8c;">App</span><span>: +</span><span> </span><span style="color:#ebcb8b;">ScalaCli</span><span>.main(</span><span style="color:#ebcb8b;">Array</span><span>(&quot;</span><span style="color:#a3be8c;">run</span><span>&quot;, &quot;</span><span style="color:#a3be8c;">recursiveScalaCli.scala</span><span>&quot;)) +</span></code></pre> +</div> +</div></p> +</blockquote> +<h2 id="first-tentative-using-the-dependency-in-tests"><a class="anchor" href="#first-tentative-using-the-dependency-in-tests">First tentative: using the dependency in tests</a></h2> +<p>In order to publish the artifacts locally before testing we needed a new <code>tests</code> project and to establish this relationship:</p> +<div class="code-window"> + <div class="code-title" ><span + class="dot-red"></span> + <span class="dot-yellow"></span> + <span class="dot-green"></span> build.sbt + </div> + <div class="code-body"><pre data-lang="scala" style="background-color:#2b303b;color:#c0c5ce;" class="language-scala "><code class="language-scala" data-lang="scala"><span style="color:#65737e;">//... +</span><span>lazy </span><span style="color:#b48ead;">val </span><span style="color:#bf616a;">root </span><span>= tlCrossRootProject.aggregate( +</span><span> toolkit, +</span><span> toolkitTest, +</span><span> tests +</span><span>) +</span><span style="color:#65737e;">//... +</span><span>lazy </span><span style="color:#b48ead;">val </span><span style="color:#bf616a;">tests </span><span>= project +</span><span> .in(file(&quot;</span><span style="color:#a3be8c;">tests</span><span>&quot;)) +</span><span> .settings( +</span><span> name := &quot;</span><span style="color:#a3be8c;">tests</span><span>&quot;, +</span><span> </span><span style="color:#ebcb8b;">Test </span><span>/ test := (</span><span style="color:#ebcb8b;">Test </span><span>/ test).dependsOn(toolkit.jvm / publishLocal).value +</span><span> ) +</span><span style="color:#65737e;">//... +</span></code></pre> +</div> +</div> +<p>In this way the <code>test</code> sbt command will always run a <code>publishLocal</code> of the jvm flavor of the toolkit artifact. The project then needed to be set to <strong>not publish its</strong> artifact and to have some dependencies added to actually write the tests. The <code>scala-cli</code> dependency needed some trickery (<code>.cross(CrossVersion.for2_13Use3)</code>) to use the Scala 3 artifact, the only one published, in Scala 2.13 as well.</p> +<div class="code-window"> + <div class="code-title" ><span + class="dot-red"></span> + <span class="dot-yellow"></span> + <span class="dot-green"></span> build.sbt + </div> + <div class="code-body"><pre data-lang="scala" style="background-color:#2b303b;color:#c0c5ce;" class="language-scala "><code class="language-scala" data-lang="scala"><span style="color:#65737e;">//... +</span><span>lazy </span><span style="color:#b48ead;">val </span><span style="color:#bf616a;">tests </span><span>= project +</span><span> .in(file(&quot;</span><span style="color:#a3be8c;">tests</span><span>&quot;)) +</span><span> .settings( +</span><span> name := &quot;</span><span style="color:#a3be8c;">tests</span><span>&quot;, +</span><span> </span><span style="color:#ebcb8b;">Test </span><span>/ test := (</span><span style="color:#ebcb8b;">Test </span><span>/ test).dependsOn(toolkit.jvm / publishLocal).value, +</span><span> </span><span style="color:#65737e;">// Required to use the scala 3 artifact with scala 2.13 +</span><span> scalacOptions ++= { +</span><span> </span><span style="color:#b48ead;">if </span><span>(scalaBinaryVersion.value == &quot;</span><span style="color:#a3be8c;">2.13</span><span>&quot;) </span><span style="color:#ebcb8b;">Seq</span><span>(&quot;</span><span style="color:#a3be8c;">-Ytasty-reader</span><span>&quot;) </span><span style="color:#b48ead;">else </span><span style="color:#ebcb8b;">Nil +</span><span> }, +</span><span> libraryDependencies ++= </span><span style="color:#ebcb8b;">Seq</span><span>( +</span><span> &quot;</span><span style="color:#a3be8c;">org.typelevel</span><span>&quot; %% &quot;</span><span style="color:#a3be8c;">munit-cats-effect</span><span>&quot; % &quot;</span><span style="color:#a3be8c;">2.0.0-M3</span><span>&quot; % </span><span style="color:#ebcb8b;">Test</span><span>, +</span><span> </span><span style="color:#65737e;">// This is needed to write scripts&#39; body into files +</span><span> &quot;</span><span style="color:#a3be8c;">co.fs2</span><span>&quot; %% &quot;</span><span style="color:#a3be8c;">fs2-io</span><span>&quot; % &quot;</span><span style="color:#a3be8c;">3.9.2</span><span>&quot; % </span><span style="color:#ebcb8b;">Test</span><span>, +</span><span> &quot;</span><span style="color:#a3be8c;">org.virtuslab.scala-cli</span><span>&quot; %% &quot;</span><span style="color:#a3be8c;">cli</span><span>&quot; % &quot;</span><span style="color:#a3be8c;">1.0.4</span><span>&quot; % </span><span style="color:#ebcb8b;">Test</span><span> cross (</span><span style="color:#ebcb8b;">CrossVersion</span><span>.for2_13Use3) +</span><span> ) +</span><span> ) +</span><span> .enablePlugins(</span><span style="color:#ebcb8b;">NoPublishPlugin</span><span>) +</span><span style="color:#65737e;">//... +</span></code></pre> +</div> +</div> +<p>The last bit needed was a way to add to the scripts' body <strong>which version of the artifact we were publishing right before the testing step and which Scala version we were running on</strong>, in order to test it properly. The only place were this <strong>(non-static)</strong> information was present was the build itself, but we needed to have them <strong>as an information in the source code</strong>. We definitively needed some sbt trickery to make it happen.</p> +<blockquote> +<p>There is an <strong>unspoken rule</strong> about the Scala community (or in the sbt users community to be precise) that you may already know about: </p> +<p><em>If you need some kind of sbt trickery, <strong><a href="https://github.com/eed3si9n">eed3si9n</a></strong> probably wrote a sbt plugin for that</em>.</p> +</blockquote> +<p>This was our case with <a href="https://github.com/sbt/sbt-buildinfo">sbt-buildinfo</a>, a sbt plugin whose punchline is &quot;<em>I know this because build.sbt knows this</em>&quot;. As you'll discover later, <strong>sbt-buildinfo has been the corner stone of our second and more exhausting approach</strong>, but what briefly does is generating Scala source from your build definitions, and thus makes build information available in the source code too.</p> +<p>As <code>scalaVersion</code> and <code>version</code> are two information that are injected by default, we just needed to add the plugin into <code>project/plugins.sbt</code> and enabling it on <code>tests</code> in the build:</p> +<div class="code-window"> + <div class="code-title" ><span + class="dot-red"></span> + <span class="dot-yellow"></span> + <span class="dot-green"></span> projects&#x2F;plugins.sbt + </div> + <div class="code-body"><pre data-lang="scala" style="background-color:#2b303b;color:#c0c5ce;" class="language-scala "><code class="language-scala" data-lang="scala"><span style="color:#65737e;">//... +</span><span>addSbtPlugin(&quot;</span><span style="color:#a3be8c;">com.eed3si9n</span><span>&quot; % &quot;</span><span style="color:#a3be8c;">sbt-buildinfo</span><span>&quot; % &quot;</span><span style="color:#a3be8c;">0.11.0</span><span>&quot;) +</span></code></pre> +</div> +</div><div class="code-window"> + <div class="code-title" ><span + class="dot-red"></span> + <span class="dot-yellow"></span> + <span class="dot-green"></span> build.sbt + </div> + <div class="code-body"><pre data-lang="scala" style="background-color:#2b303b;color:#c0c5ce;" class="language-scala "><code class="language-scala" data-lang="scala"><span style="color:#65737e;">//... +</span><span>lazy </span><span style="color:#b48ead;">val </span><span style="color:#bf616a;">tests </span><span>= project +</span><span> .in(file(&quot;</span><span style="color:#a3be8c;">tests</span><span>&quot;)) +</span><span> .settings( +</span><span> name := &quot;</span><span style="color:#a3be8c;">tests</span><span>&quot;, +</span><span> </span><span style="color:#ebcb8b;">Test </span><span>/ test := (</span><span style="color:#ebcb8b;">Test </span><span>/ test).dependsOn(toolkit.jvm / publishLocal).value, +</span><span> </span><span style="color:#65737e;">// Required to use the scala 3 artifact with scala 2.13 +</span><span> scalacOptions ++= { +</span><span> </span><span style="color:#b48ead;">if </span><span>(scalaBinaryVersion.value == &quot;</span><span style="color:#a3be8c;">2.13</span><span>&quot;) </span><span style="color:#ebcb8b;">Seq</span><span>(&quot;</span><span style="color:#a3be8c;">-Ytasty-reader</span><span>&quot;) </span><span style="color:#b48ead;">else </span><span style="color:#ebcb8b;">Nil +</span><span> }, +</span><span> libraryDependencies ++= </span><span style="color:#ebcb8b;">Seq</span><span>( +</span><span> &quot;</span><span style="color:#a3be8c;">org.typelevel</span><span>&quot; %% &quot;</span><span style="color:#a3be8c;">munit-cats-effect</span><span>&quot; % &quot;</span><span style="color:#a3be8c;">2.0.0-M3</span><span>&quot; % </span><span style="color:#ebcb8b;">Test</span><span>, +</span><span> </span><span style="color:#65737e;">// This is needed to write scripts&#39; body into files +</span><span> &quot;</span><span style="color:#a3be8c;">co.fs2</span><span>&quot; %% &quot;</span><span style="color:#a3be8c;">fs2-io</span><span>&quot; % &quot;</span><span style="color:#a3be8c;">3.9.2</span><span>&quot; % </span><span style="color:#ebcb8b;">Test</span><span>, +</span><span> &quot;</span><span style="color:#a3be8c;">org.virtuslab.scala-cli</span><span>&quot; %% &quot;</span><span style="color:#a3be8c;">cli</span><span>&quot; % &quot;</span><span style="color:#a3be8c;">1.0.4</span><span>&quot; % </span><span style="color:#ebcb8b;">Test</span><span> cross (</span><span style="color:#ebcb8b;">CrossVersion</span><span>.for2_13Use3) +</span><span> ) +</span><span> ) +</span><span> .enablePlugins(</span><span style="color:#ebcb8b;">NoPublishPlugin</span><span>, </span><span style="color:#ebcb8b;">BuildInfoPlugin</span><span>) +</span><span style="color:#65737e;">//... +</span></code></pre> +</div> +</div> +<p><strong>Time to write the tests!</strong> The first thing that was needed was a way to write on a temporary file the body of the script, including the artifact and Scala version, and then submit the file to scala-cli main method:</p> +<div class="code-window"> + <div class="code-title" ><span + class="dot-red"></span> + <span class="dot-yellow"></span> + <span class="dot-green"></span> ToolkitTests.scala + </div> + <div class="code-body"><pre data-lang="scala" style="background-color:#2b303b;color:#c0c5ce;" class="language-scala "><code class="language-scala" data-lang="scala"><span style="color:#b48ead;">package </span><span>org.typelevel.toolkit +</span><span> +</span><span style="color:#b48ead;">import </span><span>munit.</span><span style="color:#ebcb8b;">CatsEffectSuite +</span><span style="color:#b48ead;">import </span><span>cats.effect.</span><span style="color:#ebcb8b;">IO +</span><span style="color:#b48ead;">import </span><span>fs2.</span><span style="color:#ebcb8b;">Stream +</span><span style="color:#b48ead;">import </span><span>fs2.io.file.</span><span style="color:#ebcb8b;">Files +</span><span style="color:#b48ead;">import </span><span>scala.cli.</span><span style="color:#ebcb8b;">ScalaCli +</span><span style="color:#b48ead;">import </span><span>buildinfo.</span><span style="color:#ebcb8b;">BuildInfo</span><span>.{version, scalaVersion} +</span><span> +</span><span style="color:#b48ead;">class </span><span style="color:#ebcb8b;">ToolkitCompilationTest </span><span style="color:#b48ead;">extends </span><span style="color:#a3be8c;">CatsEffectSuite </span><span>{ +</span><span> +</span><span> testRun(&quot;</span><span style="color:#a3be8c;">Toolkit should compile a simple Hello Cats Effect</span><span>&quot;) { +</span><span> </span><span style="color:#b48ead;">s</span><span>&quot;&quot;&quot;</span><span style="color:#a3be8c;">|import cats.effect._ +</span><span style="color:#a3be8c;"> | +</span><span style="color:#a3be8c;"> |object Hello extends IOApp.Simple { +</span><span style="color:#a3be8c;"> | def run = IO.println(&quot;Hello toolkit!&quot;) +</span><span style="color:#a3be8c;"> |}</span><span>&quot;&quot;&quot; +</span><span> } +</span><span> +</span><span> </span><span style="color:#65737e;">// We&#39;ll describe this method in a later section of the post +</span><span> </span><span style="color:#b48ead;">def </span><span style="color:#8fa1b3;">testRun</span><span>(</span><span style="color:#bf616a;">testName</span><span>: </span><span style="color:#ebcb8b;">String</span><span>)(</span><span style="color:#bf616a;">scriptBody</span><span>: </span><span style="color:#ebcb8b;">String</span><span>): </span><span style="color:#ebcb8b;">Unit </span><span>= test(testName)( +</span><span> </span><span style="color:#ebcb8b;">Files</span><span>[</span><span style="color:#ebcb8b;">IO</span><span>].tempFile(</span><span style="color:#ebcb8b;">None</span><span>, &quot;&quot;, &quot;</span><span style="color:#a3be8c;">-toolkit.scala</span><span>&quot;, </span><span style="color:#ebcb8b;">None</span><span>) +</span><span> .use { path =&gt; +</span><span> </span><span style="color:#b48ead;">val </span><span style="color:#bf616a;">header </span><span>= </span><span style="color:#ebcb8b;">List</span><span>( +</span><span> </span><span style="color:#b48ead;">s</span><span>&quot;</span><span style="color:#a3be8c;">//&gt; using scala </span><span>${</span><span style="color:#ebcb8b;">BuildInfo</span><span>.scalaVersion}&quot;, +</span><span> </span><span style="color:#b48ead;">s</span><span>&quot;</span><span style="color:#a3be8c;">//&gt; using toolkit typelevel:</span><span>${</span><span style="color:#ebcb8b;">BuildInfo</span><span>.version}&quot;, +</span><span> ).mkString(&quot;&quot;, &quot;</span><span style="color:#96b5b4;">\n</span><span>&quot;, &quot;</span><span style="color:#96b5b4;">\n</span><span>&quot;) +</span><span> </span><span style="color:#ebcb8b;">Stream</span><span>(header, scriptBody.stripMargin) +</span><span> .through(</span><span style="color:#ebcb8b;">Files</span><span>[</span><span style="color:#ebcb8b;">IO</span><span>].writeUtf8(path)) +</span><span> .compile +</span><span> .drain &gt;&gt; </span><span style="color:#ebcb8b;">IO</span><span>.delay( +</span><span> </span><span style="color:#ebcb8b;">ScalaCli</span><span>.main(</span><span style="color:#ebcb8b;">Array</span><span>(&quot;</span><span style="color:#a3be8c;">run</span><span>&quot;, path.toString)) +</span><span> ) +</span><span> } +</span><span> ) +</span><span>} +</span></code></pre> +</div> +</div> +<p>And with this easy and lean approach we were finally able to <strong>test the toolkit</strong>! πŸŽ‰πŸŽ‰πŸŽ‰</p> +<p><code>Another pause for dramatic effect</code></p> +<p>Except we weren't really testing everything: the <code>js</code> and <code>native</code> artifact weren't tested by this approach, as the <code>tests</code> project is a jvm only project depending on <code>toolkit.jvm</code>. Also, the <code>toolkit-test</code> artifact wasn't even taken in consideration. We needed a more general/agnostic solution.</p> +<h2 id="second-approach-invoking-java-as-an-external-process"><a class="anchor" href="#second-approach-invoking-java-as-an-external-process">Second approach: Invoking Java as an external process</a></h2> +<p>The first tentative was good but not satisfying at all: we had to find a way to test the <code>js</code> and <code>native</code> artifacts too, but how? The <code>scala-cli</code> artifact is <strong>JVM Scala 3 only</strong>, and there's no way to use it as a dependency on other platforms. The only way to use it is just through the jvm, and that's <strong>precisely what we decided to do</strong>.</p> +<p>Given that:</p> +<ul> +<li>At least a JVM was present in the testing environment</li> +<li><code>fs2.io.process</code> exposes a <strong>cross-platform way to launch and manage external processes</strong></li> +<li>we had the scala-cli artifact on our classpath</li> +</ul> +<p>we knew that was possible, there was just some <code>sbt</code><em>-fu</em> needed. </p> +<p>The thing we needed to intelligently invoke was a mere <code>java -cp &lt;scala-cli + transitive deps classpath&gt; scala.cli.ScalaCli</code>, pass to it <code>run &lt;scriptFilename&gt;.scala</code> and wait for the exit code, for each <code>(scalaVersion,platform)</code> combination.</p> +<h3 id="buildinfo-magic"><a class="anchor" href="#buildinfo-magic">BuildInfo magic</a></h3> +<p>To begin we had to transform the <code>tests</code> project in to a cross project (using <a href="https://github.com/portable-scala/sbt-crossproject">sbt-crossproject</a>, that is embedded in <a href="https://github.com/typelevel/sbt-typelevel">sbt-typelevel</a>) and make every subproject <code>test</code> command depend on the publication of the respective artifacts:</p> +<div class="code-window"> + <div class="code-title" ><span + class="dot-red"></span> + <span class="dot-yellow"></span> + <span class="dot-green"></span> build.sbt + </div> + <div class="code-body"><pre data-lang="scala" style="background-color:#2b303b;color:#c0c5ce;" class="language-scala "><code class="language-scala" data-lang="scala"><span style="color:#65737e;">//... +</span><span>lazy </span><span style="color:#b48ead;">val </span><span style="color:#bf616a;">tests </span><span>= crossProject(</span><span style="color:#ebcb8b;">JVMPlatform</span><span>, </span><span style="color:#ebcb8b;">JSPlatform</span><span>, </span><span style="color:#ebcb8b;">NativePlatform</span><span>) +</span><span> .in(file(&quot;</span><span style="color:#a3be8c;">tests</span><span>&quot;)) +</span><span> .settings( +</span><span> name := &quot;</span><span style="color:#a3be8c;">tests</span><span>&quot;, +</span><span> scalacOptions ++= { +</span><span> </span><span style="color:#b48ead;">if </span><span>(scalaBinaryVersion.value == &quot;</span><span style="color:#a3be8c;">2.13</span><span>&quot;) </span><span style="color:#ebcb8b;">Seq</span><span>(&quot;</span><span style="color:#a3be8c;">-Ytasty-reader</span><span>&quot;) </span><span style="color:#b48ead;">else </span><span style="color:#ebcb8b;">Nil +</span><span> }, +</span><span> libraryDependencies ++= </span><span style="color:#ebcb8b;">Seq</span><span>( +</span><span> &quot;</span><span style="color:#a3be8c;">org.typelevel</span><span>&quot; %%% &quot;</span><span style="color:#a3be8c;">munit-cats-effect</span><span>&quot; % &quot;</span><span style="color:#a3be8c;">2.0.0-M3</span><span>&quot; % </span><span style="color:#ebcb8b;">Test</span><span>, +</span><span> &quot;</span><span style="color:#a3be8c;">co.fs2</span><span>&quot; %%% &quot;</span><span style="color:#a3be8c;">fs2-io</span><span>&quot; % &quot;</span><span style="color:#a3be8c;">3.9.2</span><span>&quot; % </span><span style="color:#ebcb8b;">Test</span><span>, +</span><span> &quot;</span><span style="color:#a3be8c;">org.virtuslab.scala-cli</span><span>&quot; %% &quot;</span><span style="color:#a3be8c;">cli</span><span>&quot; % &quot;</span><span style="color:#a3be8c;">1.0.4</span><span>&quot; cross (</span><span style="color:#ebcb8b;">CrossVersion</span><span>.for2_13Use3) +</span><span> ) +</span><span> ) +</span><span> .jvmSettings( +</span><span> </span><span style="color:#ebcb8b;">Test </span><span>/ test := (</span><span style="color:#ebcb8b;">Test </span><span>/ test).dependsOn(toolkit.jvm / publishLocal, toolkitTest.jvm / publishLocal).value +</span><span> ) +</span><span> .jsSettings( +</span><span> </span><span style="color:#ebcb8b;">Test </span><span>/ test := (</span><span style="color:#ebcb8b;">Test </span><span>/ test).dependsOn(toolkit.js / publishLocal, toolkitTest.js / publishLocal).value +</span><span> scalaJSLinkerConfig ~= { _.withModuleKind(</span><span style="color:#ebcb8b;">ModuleKind</span><span>.</span><span style="color:#ebcb8b;">CommonJSModule</span><span>) } +</span><span> ) +</span><span> .nativeSettings( +</span><span> </span><span style="color:#ebcb8b;">Test </span><span>/ test := (</span><span style="color:#ebcb8b;">Test </span><span>/ test).dependsOn(toolkit.native / publishLocal, toolkitTest.native / publishLocal).value +</span><span> ) +</span><span> .enablePlugins(</span><span style="color:#ebcb8b;">BuildInfoPlugin</span><span>, </span><span style="color:#ebcb8b;">NoPublishPlugin</span><span>) +</span><span style="color:#65737e;">//... +</span></code></pre> +</div> +</div> +<p>One thing to note is that we deliberately made a &quot;mistake&quot;. The <code>munit-cats-effect</code> and <code>fs2-io</code> dependencies are declared using <code>%%%</code> the operator that not only appends <code>_${scalaBinaryVersion}</code> to the end of the artifact name but also the platform name (appending i.e. for a Scala 3 native dependency <code>_native0.4_3</code>), but the <code>scala-cli</code> one was declared using just <code>%%</code> and the <code>% Test</code> modifier was removed. In this way we were sure that, for <strong>every platform</strong>, the <code>Compile / dependencyClasspath</code> would have included just the <strong>jvm version of scala-cli</strong>.</p> +<p>To inject the classpath into the source code we leveraged our beloved friend <a href="https://github.com/sbt/sbt-buildinfo">sbt-buildinfo</a>, that <strong>it's not limited to inject just <code>SettingKey[T]</code>s</strong> and/or static information (computed at project load time), but using its own syntax <strong>can inject <code>TaskKey[T]</code>s after they've been evaluated</strong> (and re-evaluated each time at compile). So in the common <code>.settings</code> we added: </p> +<div class="code-window"> + <div class="code-title" ><span + class="dot-red"></span> + <span class="dot-yellow"></span> + <span class="dot-green"></span> build.sbt + </div> + <div class="code-body"><pre data-lang="scala" style="background-color:#2b303b;color:#c0c5ce;" class="language-scala "><code class="language-scala" data-lang="scala"><span style="color:#65737e;">///... +</span><span> buildInfoKeys += scalaBinaryVersion, +</span><span> buildInfoKeys += </span><span style="color:#ebcb8b;">BuildInfoKey</span><span>.map(</span><span style="color:#ebcb8b;">Compile </span><span>/ dependencyClasspath) { +</span><span> </span><span style="color:#b48ead;">case </span><span>(_, v) =&gt; +</span><span> &quot;</span><span style="color:#a3be8c;">classPath</span><span>&quot; -&gt; v.seq +</span><span> .map(_.data.getAbsolutePath) +</span><span> .mkString(</span><span style="color:#ebcb8b;">File</span><span>.pathSeparator) </span><span style="color:#65737e;">// That&#39;s the way java -cp accepts classpath info +</span><span> }, +</span><span> buildInfoKeys += </span><span style="color:#ebcb8b;">BuildInfoKey</span><span>.action(&quot;</span><span style="color:#a3be8c;">javaHome</span><span>&quot;) { +</span><span> </span><span style="color:#b48ead;">val </span><span style="color:#bf616a;">path </span><span>= sys.env.get(&quot;</span><span style="color:#a3be8c;">JAVA_HOME</span><span>&quot;).orElse(sys.props.get(&quot;</span><span style="color:#a3be8c;">java.home</span><span>&quot;)).get +</span><span> </span><span style="color:#b48ead;">if </span><span>(path.endsWith(&quot;</span><span style="color:#a3be8c;">/jre</span><span>&quot;)) { +</span><span> </span><span style="color:#65737e;">// handle JDK 8 installations +</span><span> path.replace(&quot;</span><span style="color:#a3be8c;">/jre</span><span>&quot;, &quot;&quot;) +</span><span> } </span><span style="color:#b48ead;">else</span><span> path +</span><span> }, +</span><span> buildInfoKeys += &quot;</span><span style="color:#a3be8c;">scala3</span><span>&quot; -&gt; (scalaVersion.value.head == </span><span style="color:#d08770;">&#39;3&#39;</span><span>) +</span><span style="color:#65737e;">///... +</span></code></pre> +</div> +</div> +<p>and in each platform specific section we added to buildInfo the platform's name:</p> +<div class="code-window"> + <div class="code-title" ><span + class="dot-red"></span> + <span class="dot-yellow"></span> + <span class="dot-green"></span> build.sbt + </div> + <div class="code-body"><pre data-lang="scala" style="background-color:#2b303b;color:#c0c5ce;" class="language-scala "><code class="language-scala" data-lang="scala"><span style="color:#65737e;">//... +</span><span> .jvmSettings( +</span><span> </span><span style="color:#65737e;">//... +</span><span> buildInfoKeys += &quot;</span><span style="color:#a3be8c;">platform</span><span>&quot; -&gt; &quot;</span><span style="color:#a3be8c;">jvm</span><span>&quot; +</span><span> ) +</span><span> .jsSettings( +</span><span> </span><span style="color:#65737e;">//... +</span><span> buildInfoKeys += &quot;</span><span style="color:#a3be8c;">platform</span><span>&quot; -&gt; &quot;</span><span style="color:#a3be8c;">js</span><span>&quot;, +</span><span> ) +</span><span> .nativeSettings( +</span><span> </span><span style="color:#65737e;">//... +</span><span> buildInfoKeys += &quot;</span><span style="color:#a3be8c;">platform</span><span>&quot; -&gt; &quot;</span><span style="color:#a3be8c;">native</span><span>&quot; +</span><span> ) +</span><span style="color:#65737e;">//... +</span></code></pre> +</div> +</div> +<p>in this way we could leverage in our source code all the information required to run <code>scala-cli</code> and test our snippets:</p> +<pre data-lang="scala" style="background-color:#2b303b;color:#c0c5ce;" class="language-scala "><code class="language-scala" data-lang="scala"><span style="color:#b48ead;">private val </span><span style="color:#bf616a;">classPath</span><span>: </span><span style="color:#ebcb8b;">String </span><span>= </span><span style="color:#ebcb8b;">BuildInfo</span><span>.classPath +</span><span style="color:#b48ead;">private val </span><span style="color:#bf616a;">javaHome</span><span>: </span><span style="color:#ebcb8b;">String </span><span>= </span><span style="color:#ebcb8b;">BuildInfo</span><span>.javaHome +</span><span style="color:#b48ead;">private val </span><span style="color:#bf616a;">platform</span><span>: </span><span style="color:#ebcb8b;">String </span><span>= </span><span style="color:#ebcb8b;">BuildInfo</span><span>.platform +</span><span style="color:#b48ead;">private val </span><span style="color:#bf616a;">scalaBinaryVersion</span><span>: </span><span style="color:#ebcb8b;">String </span><span>= </span><span style="color:#ebcb8b;">BuildInfo</span><span>.scalaBinaryVersion +</span><span style="color:#b48ead;">private val </span><span style="color:#bf616a;">scala3</span><span>: </span><span style="color:#ebcb8b;">Boolean </span><span>= </span><span style="color:#ebcb8b;">BuildInfo</span><span>.scala3 +</span></code></pre> +<h3 id="invoking-java-via-fs2-process"><a class="anchor" href="#invoking-java-via-fs2-process">Invoking Java via fs2 <code>Process</code></a></h3> +<p>Once we had all the required components, invoking java was easy, we just created and spawned a <a href="https://fs2.io/#/io?id=processes">Process</a> from the package <code>fs2.io.process</code>, that is implemented for every platform under the very same API:</p> +<div class="code-window"> + <div class="code-title" ><span + class="dot-red"></span> + <span class="dot-yellow"></span> + <span class="dot-green"></span> ScalaCliTest.scala + </div> + <div class="code-body"><pre data-lang="scala" style="background-color:#2b303b;color:#c0c5ce;" class="language-scala "><code class="language-scala" data-lang="scala"><span style="color:#b48ead;">import </span><span>buildinfo.</span><span style="color:#ebcb8b;">BuildInfo +</span><span style="color:#b48ead;">import </span><span>cats.effect.kernel.</span><span style="color:#ebcb8b;">Resource +</span><span style="color:#b48ead;">import </span><span>cats.effect.std.</span><span style="color:#ebcb8b;">Console +</span><span style="color:#b48ead;">import </span><span>cats.effect.</span><span style="color:#ebcb8b;">IO +</span><span style="color:#b48ead;">import </span><span>cats.syntax.parallel.* +</span><span style="color:#b48ead;">import </span><span>fs2.</span><span style="color:#ebcb8b;">Stream +</span><span style="color:#b48ead;">import </span><span>fs2.io.file.</span><span style="color:#ebcb8b;">Files +</span><span style="color:#b48ead;">import </span><span>fs2.io.process.</span><span style="color:#ebcb8b;">ProcessBuilder +</span><span style="color:#b48ead;">import </span><span>munit.</span><span style="color:#ebcb8b;">Assertions</span><span>.fail +</span><span> +</span><span style="color:#b48ead;">object </span><span style="color:#ebcb8b;">ScalaCliProcess </span><span>{ +</span><span> +</span><span> </span><span style="color:#b48ead;">private def </span><span style="color:#8fa1b3;">scalaCli</span><span>(</span><span style="color:#bf616a;">args</span><span>: </span><span style="color:#ebcb8b;">List</span><span>[</span><span style="color:#ebcb8b;">String</span><span>]): </span><span style="color:#ebcb8b;">IO</span><span>[</span><span style="color:#ebcb8b;">Unit</span><span>] = </span><span style="color:#ebcb8b;">ProcessBuilder</span><span>( +</span><span> </span><span style="color:#b48ead;">s</span><span>&quot;${</span><span style="color:#ebcb8b;">BuildInfo</span><span>.javaHome}</span><span style="color:#a3be8c;">/bin/java</span><span>&quot;, +</span><span> args.prependedAll(</span><span style="color:#ebcb8b;">List</span><span>(&quot;</span><span style="color:#a3be8c;">-cp</span><span>&quot;, </span><span style="color:#ebcb8b;">BuildInfo</span><span>.classPath, &quot;</span><span style="color:#a3be8c;">scala.cli.ScalaCli</span><span>&quot;)) +</span><span> ).spawn[</span><span style="color:#ebcb8b;">IO</span><span>] +</span><span> .use(process =&gt; +</span><span> ( +</span><span> process.exitValue, +</span><span> process.stdout.through(fs2.text.utf8.decode).compile.string, +</span><span> process.stderr.through(fs2.text.utf8.decode).compile.string +</span><span> ).parFlatMapN { +</span><span> </span><span style="color:#b48ead;">case </span><span>(</span><span style="color:#d08770;">0</span><span>, _, _) =&gt; </span><span style="color:#ebcb8b;">IO</span><span>.unit +</span><span> </span><span style="color:#b48ead;">case </span><span>(exitCode, stdout, stdErr) =&gt; +</span><span> </span><span style="color:#ebcb8b;">IO</span><span>.println(stdout) &gt;&gt; </span><span style="color:#ebcb8b;">Console</span><span>[</span><span style="color:#ebcb8b;">IO</span><span>].errorln(stdErr) &gt;&gt; </span><span style="color:#ebcb8b;">IO</span><span>.delay( +</span><span> fail(</span><span style="color:#b48ead;">s</span><span>&quot;</span><span style="color:#a3be8c;">Non zero exit code (</span><span>$exitCode</span><span style="color:#a3be8c;">) for </span><span>${args.mkString(&quot; &quot;)}&quot;) +</span><span> ) +</span><span> } +</span><span> ) +</span><span> +</span><span> </span><span style="color:#65737e;">//.. +</span><span> +</span><span>} +</span></code></pre> +</div> +</div> +<p>Let's dissect this function:</p> +<ul> +<li><code>ProcessBuilder</code> constructor accepts a <code>String</code> command and a list of <code>String</code> arguments, it can then spawn the subprocess using <code>.spawn[IO]</code>, that will return a <code>Resource[IO, Process[IO]]</code>. Resource is a really useful Cats Effect datatype that deserves its own post, but you can find some information in <a href="https://typelevel.org/cats-effect/docs/std/resource">the official documentation</a>.</li> +<li>The <code>Process[IO]</code> resource is <code>use</code>d, and its exit code is gathered, <strong>in parallel</strong>, together with its stdout and stderr using <code>parFlatMapN</code>. This will prevent deadlocking, as we won't wait for a process' exit code without consuming its stdout and stderr streams.</li> +<li>Once we have the results, if the exit code is 0 we'll simply discard the content of the streams, otherwise we'll print everything that might be useful to debug possible errors, and we'll instruct our testing framework to fail with a specific message.</li> +</ul> +<p>Now we needed a method to write in a temporary file the source of each scala-cli script with all the information needed to correctly test the toolkit. Luckily for us <code>fs2</code> makes it easy:</p> +<div class="code-window"> + <div class="code-title" ><span + class="dot-red"></span> + <span class="dot-yellow"></span> + <span class="dot-green"></span> ScalaCliTest.scala + </div> + <div class="code-body"><pre data-lang="scala" style="background-color:#2b303b;color:#c0c5ce;" class="language-scala "><code class="language-scala" data-lang="scala"><span style="color:#65737e;">//... +</span><span> </span><span style="color:#b48ead;">private def </span><span style="color:#8fa1b3;">writeToFile</span><span>(</span><span style="color:#bf616a;">scriptBody</span><span>: </span><span style="color:#ebcb8b;">String</span><span>)(</span><span style="color:#bf616a;">isTest</span><span>: </span><span style="color:#ebcb8b;">Boolean</span><span>): </span><span style="color:#ebcb8b;">Resource</span><span>[</span><span style="color:#ebcb8b;">IO</span><span>, </span><span style="color:#ebcb8b;">String</span><span>] = +</span><span> </span><span style="color:#ebcb8b;">Files</span><span>[</span><span style="color:#ebcb8b;">IO</span><span>].tempFile(</span><span style="color:#ebcb8b;">None</span><span>,&quot;&quot;,</span><span style="color:#b48ead;">if </span><span>(isTest) &quot;</span><span style="color:#a3be8c;">-toolkit.test.scala</span><span>&quot; </span><span style="color:#b48ead;">else </span><span>&quot;</span><span style="color:#a3be8c;">-toolkit.scala</span><span>&quot;, </span><span style="color:#ebcb8b;">None</span><span>) +</span><span> .evalTap { path =&gt; +</span><span> </span><span style="color:#b48ead;">val </span><span style="color:#bf616a;">header </span><span>= </span><span style="color:#ebcb8b;">List</span><span>( +</span><span> </span><span style="color:#b48ead;">s</span><span>&quot;</span><span style="color:#a3be8c;">//&gt; using scala </span><span>${</span><span style="color:#ebcb8b;">BuildInfo</span><span>.scalaVersion}&quot;, +</span><span> </span><span style="color:#b48ead;">s</span><span>&quot;</span><span style="color:#a3be8c;">//&gt; using toolkit typelevel:</span><span>${</span><span style="color:#ebcb8b;">BuildInfo</span><span>.version}&quot;, +</span><span> </span><span style="color:#b48ead;">s</span><span>&quot;</span><span style="color:#a3be8c;">//&gt; using platform </span><span>${</span><span style="color:#ebcb8b;">BuildInfo</span><span>.platform}&quot; +</span><span> ).mkString(&quot;&quot;, &quot;</span><span style="color:#96b5b4;">\n</span><span>&quot;, &quot;</span><span style="color:#96b5b4;">\n</span><span>&quot;) +</span><span> </span><span style="color:#ebcb8b;">Stream</span><span>(header, scriptBody.stripMargin) +</span><span> .through(</span><span style="color:#ebcb8b;">Files</span><span>[</span><span style="color:#ebcb8b;">IO</span><span>].writeUtf8(path)) +</span><span> .compile +</span><span> .drain +</span><span> } +</span><span> .map(_.toString) +</span><span style="color:#65737e;">//... +</span></code></pre> +</div> +</div> +<p>Dissecting this function too we'll see that:</p> +<ul> +<li><code>Files[IO].tempFile</code> creates a temporary file as a <code>Resource</code>, whose release method will <strong>delete the temporary file</strong>.</li> +<li>The <code>isTest</code> parameter is used to determine the extension that the temp file will have, as <code>scala-cli</code> requires a specific extension for both source and test files.</li> +<li><code>.evalTap</code> will run an effectful side effect but returning the same <code>Resource</code> it was called on. In this case it will write the script content in the newly created temp file. This effect will run <strong>AFTER</strong> the file creation, but <strong>BEFORE</strong> any other effectful action that can be performed in the <code>use</code> method.</li> +<li>In the effect we'll produce a set of <code>scala-cli</code> directives using <code>BuildInfo</code>, we'll prepend them to the script's body and write everything in the temp file.</li> +<li>The path of the freshly baked scala-cli script will then be provided as a <code>Resource[IO, String]</code></li> +</ul> +<p>The only thing we needed to do was to <strong>combine the two methods</strong> into a testing method:</p> +<div class="code-window"> + <div class="code-title" ><span + class="dot-red"></span> + <span class="dot-yellow"></span> + <span class="dot-green"></span> ScalaCliTest.scala + </div> + <div class="code-body"><pre data-lang="scala" style="background-color:#2b303b;color:#c0c5ce;" class="language-scala "><code class="language-scala" data-lang="scala"><span style="color:#65737e;">//... +</span><span> </span><span style="color:#b48ead;">def </span><span style="color:#8fa1b3;">testRun</span><span>(testName:</span><span style="color:#ebcb8b;">String</span><span>)(</span><span style="color:#bf616a;">body</span><span>: </span><span style="color:#ebcb8b;">String</span><span>): </span><span style="color:#ebcb8b;">IO</span><span>[</span><span style="color:#ebcb8b;">Unit</span><span>] = +</span><span> test(testName)(writeToFile(body)(</span><span style="color:#d08770;">false</span><span>).use(f =&gt; scalaCli(&quot;</span><span style="color:#a3be8c;">run</span><span>&quot; :: f :: </span><span style="color:#ebcb8b;">Nil</span><span>))) +</span><span> +</span><span> </span><span style="color:#b48ead;">def </span><span style="color:#8fa1b3;">testTest</span><span>(testName:</span><span style="color:#ebcb8b;">String</span><span>)(</span><span style="color:#bf616a;">body</span><span>: </span><span style="color:#ebcb8b;">String</span><span>): </span><span style="color:#ebcb8b;">IO</span><span>[</span><span style="color:#ebcb8b;">Unit</span><span>] = +</span><span> test(testName)(writeToFile(body)(</span><span style="color:#d08770;">true</span><span>).use(f =&gt; scalaCli(&quot;</span><span style="color:#a3be8c;">test</span><span>&quot; :: f :: </span><span style="color:#ebcb8b;">Nil</span><span>))) +</span><span style="color:#65737e;">//... +</span></code></pre> +</div> +</div> +<p>To recap, each of the two methods will run a munit test that:</p> +<ul> +<li>write the <code>body</code> argument to a temporary file with the correct extension, prepending the correct <code>scala-cli</code> directives</li> +<li>run either the command <code>scala-cli run</code> or <code>scala-cli test</code> against the newly created file</li> +<li>use the exit code of the process to establish if the test is passed or not</li> +<li>delete the temporary file</li> +</ul> +<p>The <strong>produced files</strong> will look, for example, like this:</p> +<pre data-lang="scala" style="background-color:#2b303b;color:#c0c5ce;" class="language-scala "><code class="language-scala" data-lang="scala"><span style="color:#65737e;">//&gt; using scala 3 +</span><span style="color:#65737e;">//&gt; using toolkit typelevel:typelevel:0.1.14-29-d717826-20231004T153011Z-SNAPSHOT +</span><span style="color:#65737e;">//&gt; using platform jvm +</span><span> +</span><span style="color:#b48ead;">import </span><span>cats.effect.* +</span><span> +</span><span style="color:#b48ead;">object </span><span style="color:#ebcb8b;">Hello </span><span style="color:#b48ead;">extends </span><span style="color:#a3be8c;">IOApp</span><span>.</span><span style="color:#ebcb8b;">Simple</span><span>: +</span><span> </span><span style="color:#b48ead;">def </span><span style="color:#8fa1b3;">run </span><span>= </span><span style="color:#ebcb8b;">IO</span><span>.println(&quot;</span><span style="color:#a3be8c;">Hello toolkit!</span><span>&quot;) +</span></code></pre> +<p>where <code>0.1.14-29-d717826-20231004T153011Z-SNAPSHOT</code> is the version of the toolkit that was just <strong>published</strong> locally by sbt.</p> +<h2 id="test-writing"><a class="anchor" href="#test-writing">Test writing</a></h2> +<p>It was then <strong>Time to write and run the actual tests!</strong></p> +<div class="code-window"> + <div class="code-title" ><span + class="dot-red"></span> + <span class="dot-yellow"></span> + <span class="dot-green"></span> ToolkitTests.scala + </div> + <div class="code-body"><pre data-lang="scala" style="background-color:#2b303b;color:#c0c5ce;" class="language-scala "><code class="language-scala" data-lang="scala"><span style="color:#b48ead;">import </span><span>munit.</span><span style="color:#ebcb8b;">CatsEffectSuite +</span><span style="color:#b48ead;">import </span><span>buildinfo.</span><span style="color:#ebcb8b;">BuildInfo</span><span>.scala3 +</span><span style="color:#b48ead;">import </span><span style="color:#ebcb8b;">ScalaCliTest</span><span>.{testRun, testTest} +</span><span> +</span><span style="color:#b48ead;">class </span><span style="color:#ebcb8b;">ToolkitTests </span><span style="color:#b48ead;">extends </span><span style="color:#a3be8c;">CatsEffectSuite </span><span>{ +</span><span> +</span><span> testRun(&quot;</span><span style="color:#a3be8c;">Toolkit should run a simple Hello Cats Effect</span><span>&quot;) { +</span><span> </span><span style="color:#b48ead;">if </span><span>(scala3) +</span><span> &quot;&quot;&quot;</span><span style="color:#a3be8c;">|import cats.effect.* +</span><span style="color:#a3be8c;"> | +</span><span style="color:#a3be8c;"> |object Hello extends IOApp.Simple: +</span><span style="color:#a3be8c;"> | def run = IO.println(&quot;Hello toolkit!&quot;)</span><span>&quot;&quot;&quot; +</span><span> </span><span style="color:#b48ead;">else +</span><span> &quot;&quot;&quot;</span><span style="color:#a3be8c;">|import cats.effect._ +</span><span style="color:#a3be8c;"> | +</span><span style="color:#a3be8c;"> |object Hello extends IOApp.Simple { +</span><span style="color:#a3be8c;"> | def run = IO.println(&quot;Hello toolkit!&quot;) +</span><span style="color:#a3be8c;"> |}</span><span>&quot;&quot;&quot; +</span><span> } +</span><span> +</span><span> testTest(&quot;</span><span style="color:#a3be8c;">Toolkit should execute a simple munit suite</span><span>&quot;) { +</span><span> </span><span style="color:#b48ead;">if </span><span>(scala3) +</span><span> &quot;&quot;&quot;</span><span style="color:#a3be8c;">|import cats.effect.* +</span><span style="color:#a3be8c;"> |import munit.* +</span><span style="color:#a3be8c;"> | +</span><span style="color:#a3be8c;"> |class Test extends CatsEffectSuite: +</span><span style="color:#a3be8c;"> | test(&quot;test&quot;)(IO.unit)</span><span>&quot;&quot;&quot; +</span><span> </span><span style="color:#b48ead;">else +</span><span> &quot;&quot;&quot;</span><span style="color:#a3be8c;">|import cats.effect._ +</span><span style="color:#a3be8c;"> |import munit._ +</span><span style="color:#a3be8c;"> | +</span><span style="color:#a3be8c;"> |class Test extends CatsEffectSuite { +</span><span style="color:#a3be8c;"> | test(&quot;test&quot;)(IO.unit) +</span><span style="color:#a3be8c;"> |}</span><span>&quot;&quot;&quot; +</span><span> } +</span><span> </span><span style="color:#65737e;">//... +</span><span>} +</span></code></pre> +</div> +</div> +<p>The little testing framework we wrote is now capable of both running and testing <code>scala-cli</code> scripts that use the typelevel toolkit, and it will test it in every platform and scala version. <code>sbt test</code> will now publish both the toolkit and the test toolkit, for every platform, right before running the unit tests, achieving in this way a complete coverage and adding reliability to our releases! πŸŽ‰ </p> +<p>And all of this was done without even touching our GitHub Actions, just with some <code>sbt</code><em>-fu</em>, and <strong>just using the libraries that are included in the toolkit itself</strong> 😎</p> + + + + Writing a GitHub Action with Scala.js + Thu, 18 May 2023 00:00:00 +0000 + Unknown + https://toniogela.dev/gh-action-in-scala/ + https://toniogela.dev/gh-action-in-scala/ + <p>Some months ago, I discussed with a DevOps colleague the need for a custom GitHub Action at <code>$work</code>. The action we needed had to perform many tasks that weren't present in any action we could find, so we planned to write our own.</p> +<p>The chances were limited: there was the evergreen option to embed a <strong>gigantic shell script</strong> in the ci file (dealing with evergreen problems like escaping, quoting and indentation), the also evergreen option to <strong>commit the script</strong>, or we could have written our <strong>own GitHub action</strong>.</p> +<p>The last option was the most interesting one. Writing business logic in a more structured language than bash was desirable, but we had to face the fact that, according to the documentation, only two <a href="https://docs.github.com/en/actions/creating-actions/about-custom-actions#types-of-actions">types of actions</a> exist (if you don't consider composite ones): <code>Docker Container Actions</code> and <code>Javascript Actions</code>.</p> +<p>Since no one had any intention whatsoever to write javascript code and <a href="https://docs.github.com/en/actions/creating-actions/creating-a-docker-container-action">Docker Container Actions</a> had all the features we needed, we resorted to using one of them (despite their limitations in terms of compatibility).</p> +<p>Even though this <u>scarcely interesting success story</u> has a happy ending, a question emerged during the developments: <code>Is it possible to write a Github Action with Scala.js?</code></p> +<blockquote> +<p>Also, I asked myself <code>Is it still possible to survive as a software developer in 2023 without ever having written a single line of javascript?</code>: you'll find the answer below.</p> +</blockquote> +<p><strong>TLDR</strong>: yes and <a href="https://github.com/armanbilge">@armanbilge</a> did it in a couple of repositories like <a href="https://github.com/typelevel/await-cirrus">this one</a>, so in this post, we'll dissect his approach to create a how-to guide. Thank you, Arman! ❀️</p> +<h2 id="creating-a-simple-action"><a class="anchor" href="#creating-a-simple-action">Creating a simple action</a></h2> +<p>The action we'll create will be a <strong>simple adder</strong> that will <code>sum up two numbers</code> that can be either defined in the build file or one of the results of one of the previous steps.</p> +<h3 id="metadata"><a class="anchor" href="#metadata">Metadata</a></h3> +<p>According to its <a href="https://docs.github.com/en/actions/creating-actions/metadata-syntax-for-github-actions">metadata syntax</a> page, every action defined in a repository requires an <code>action.yml</code> file that describes your action's inputs, outputs and run configuration.</p> +<p>Our action will have two required inputs and a single output, and it will run using node 16:</p> +<div class="code-window"> + <div class="code-title" style="background-color:#6d98ba;color:black;" ><span + class="dot-red"></span> + <span class="dot-yellow"></span> + <span class="dot-green"></span> action.yml + </div> + <div class="code-body"><pre data-lang="yml" style="background-color:#2b303b;color:#c0c5ce;" class="language-yml "><code class="language-yml" data-lang="yml"><span style="color:#bf616a;">name</span><span>: &#39;</span><span style="color:#a3be8c;">Scala.js adder</span><span>&#39; +</span><span style="color:#bf616a;">description</span><span>: &#39;</span><span style="color:#a3be8c;">Summing two numbers, but with Scala.js</span><span>&#39; +</span><span style="color:#bf616a;">inputs</span><span>: +</span><span> </span><span style="color:#bf616a;">number-one</span><span>: +</span><span> </span><span style="color:#bf616a;">description</span><span>: &#39;</span><span style="color:#a3be8c;">The first number</span><span>&#39; +</span><span> </span><span style="color:#bf616a;">required</span><span>: </span><span style="color:#d08770;">true +</span><span> </span><span style="color:#bf616a;">number-two</span><span>: +</span><span> </span><span style="color:#bf616a;">description</span><span>: &#39;</span><span style="color:#a3be8c;">The second number</span><span>&#39; +</span><span> </span><span style="color:#bf616a;">required</span><span>: </span><span style="color:#d08770;">true +</span><span style="color:#bf616a;">outputs</span><span>: +</span><span> </span><span style="color:#bf616a;">result</span><span>: +</span><span> </span><span style="color:#bf616a;">description</span><span>: &quot;</span><span style="color:#a3be8c;">The sum of the two inputs</span><span>&quot; +</span><span style="color:#bf616a;">runs</span><span>: +</span><span> </span><span style="color:#bf616a;">using</span><span>: &#39;</span><span style="color:#a3be8c;">node16</span><span>&#39; +</span><span> </span><span style="color:#bf616a;">main</span><span>: &#39;</span><span style="color:#a3be8c;">index.js</span><span>&#39; +</span></code></pre> +</div> +</div><h3 id="business-logic-requirements"><a class="anchor" href="#business-logic-requirements">Business logic requirements</a></h3> +<p>Once the metadata file is defined, we'll have to write the business logic, but we need to address a few issues:</p> +<ul> +<li>How do we produce a runnable js file?</li> +<li>How do we read the action's inputs?</li> +<li>How do we write the action's outputs?</li> +</ul> +<p>The most straightforward and potent tool that will produce <u>javascript code from a single Scala file</u> is undoubtedly <a href="https://github.com/VirtusLab/scala-cli"><code>scala-cli</code></a>, with its ability to define in a few lines packaging, platform and dependencies setting.</p> +<p>Let's create in our repository a scala file with the required settings to produce a js module using a specific js and scala version:</p> +<div class="code-window"> + <div class="code-title" ><span + class="dot-red"></span> + <span class="dot-yellow"></span> + <span class="dot-green"></span> index.scala + </div> + <div class="code-body"><pre data-lang="scala" style="background-color:#2b303b;color:#c0c5ce;" class="language-scala "><code class="language-scala" data-lang="scala"><span style="color:#65737e;">//&gt; using scala &quot;3.2.2&quot; +</span><span style="color:#65737e;">//&gt; using platform &quot;js&quot; +</span><span style="color:#65737e;">//&gt; using jsVersion &quot;1.13.1&quot; +</span><span style="color:#65737e;">//&gt; using jsModuleKind &quot;common&quot; +</span><span> +</span><span style="color:#b48ead;">object </span><span style="color:#ebcb8b;">index </span><span style="color:#b48ead;">extends </span><span style="color:#a3be8c;">App</span><span>: +</span><span> println(&quot;</span><span style="color:#a3be8c;">Hello world</span><span>&quot;) +</span></code></pre> +</div> +</div> +<p>Packaging this file is as simple as running the command <code>scala-cli --power package -f index.scala</code> (we'll reuse this command later in our CI). This command will produce an <code>index.js</code> file that can run locally using <code>node ./index.js</code>.</p> +<p>Now that we can produce a runnable js file, it's time to create an actual GitHub action. The <a href="https://docs.github.com/en/actions/creating-actions/creating-a-javascript-action">official documentation for javascript actions</a> recommends using the <a href="https://github.com/actions/toolkit"><code>GitHub Actions Toolkit Node.js module</code></a> to speed up development (an intelligent person will probably use it,) but the Actions' runtime offers an alternative.</p> +<p>Digging deep into the metadata syntax documentation, in the <a href="https://docs.github.com/en/actions/creating-actions/metadata-syntax-for-github-actions#inputs">inputs</a> section, you'll find an interesting paragraph:</p> +<blockquote> +<p>When you specify an input in a workflow file or use a default input value, GitHub creates an environment variable for the input with the name <code>INPUT_&lt;VARIABLE_NAME&gt;</code>. The environment variable created converts input names to uppercase letters and replaces spaces with <code>_</code> characters.</p> +</blockquote> +<p>So to get our input parameters, reading the environment variables <code>INPUT_NUMBER-ONE</code> and <code>INPUT_NUMBER-TWO</code> will be enough.</p> +<p>Last but not least, we need to find a way to define our action's output. Picking up the shovel again and digging further into the documentation, we'll discover <a href="https://docs.github.com/en/actions/using-jobs/defining-outputs-for-jobs#overview">a section</a> that enlightens us about the existence of a <code>GITHUB_OUTPUT</code> environment variable containing a file's path. This file will serve as an output buffer for the currently running step, and using it is as simple as writing the string <code>&lt;output_variable_name&gt;=&lt;value&gt;</code> in it.</p> +<p>In our case, we'll have to write <code>result=&lt;sum of the inputs&gt;</code> in the file at path <code>$GITHUB_OUTPUT</code>, and we'll be done.</p> +<p>To sum up, we need a library/framework/stack that offers comfy APIs to read the content of environment variables and write stuff into files that have been compiled for Scala.js.</p> +<p>Unluckily the Scala standard library won't be enough even for such a simple task (unless you'll manually call some node.js APIs). If only there was <strong>a tech stack offering a resource-safe, referentially transparent way to perform these operations and a nice asynchronous API to call other processes, like other command line tools</strong>!</p> +<h3 id="typelevel-toolkit"><a class="anchor" href="#typelevel-toolkit">Typelevel toolkit</a></h3> +<p>Luckily for everybody, such a stack exists. The <a href="https://typelevel.org/">Typelevel</a> libraries are published for many Scala versions and for every platform Scala supports, including <a href="https://typelevel.org/platforms/native/">Scala native</a>. <a href="https://typelevel.org/platforms/js/">Most of them</a> can be used in a node.js action.</p> +<p>The most straightforward way to test this stack's fundamental libraries is using the Typelevel toolkit. The toolkit is a meta library that includes (among the others) <a href="https://typelevel.org/cats-effect/">Cats Effect</a>, <a href="https://fs2.io/#/io">fs2-io</a> for streaming, <a href="https://ben.kirw.in/decline/effect.html">a library to parse command line arguments</a>, <a href="https://circe.github.io/circe/">a JSON serde that supports automatic Scala 3 derivation</a> and <a href="https://http4s.org/v0.23/docs/client.html">an HTTP client</a>.</p> +<p>To use the toolkit, it's enough to declare it as a dependency in our scala-cli script:</p> +<div class="code-window"> + <div class="code-title" ><span + class="dot-red"></span> + <span class="dot-yellow"></span> + <span class="dot-green"></span> index.scala + </div> + <div class="code-body"><pre data-lang="scala" style="background-color:#2b303b;color:#c0c5ce;" class="language-scala "><code class="language-scala" data-lang="scala"><span style="color:#65737e;">//&gt; using scala &quot;3.2.2&quot; +</span><span style="color:#65737e;">//&gt; using platform &quot;js&quot; +</span><span style="color:#65737e;">//&gt; using jsVersion &quot;1.13.1&quot; +</span><span style="color:#65737e;">//&gt; using jsModuleKind &quot;common&quot; +</span><span style="color:#65737e;">//&gt; using dep &quot;org.typelevel::toolkit::latest.release&quot; +</span><span> +</span><span style="color:#b48ead;">object </span><span style="color:#ebcb8b;">index </span><span style="color:#b48ead;">extends </span><span style="color:#a3be8c;">App</span><span>: +</span><span> println(&quot;</span><span style="color:#a3be8c;">Hello world</span><span>&quot;) +</span></code></pre> +</div> +</div> +<p>Now it's time to write an input reading function: we can use <code>cats.effect.std.Env</code> to access the environment variables</p> +<pre data-lang="scala" style="background-color:#2b303b;color:#c0c5ce;" class="language-scala "><code class="language-scala" data-lang="scala"><span style="color:#b48ead;">import </span><span>cats.effect.</span><span style="color:#ebcb8b;">IO +</span><span style="color:#b48ead;">import </span><span>cats.effect.std.</span><span style="color:#ebcb8b;">Env +</span><span> +</span><span style="color:#b48ead;">def </span><span style="color:#8fa1b3;">getInput</span><span>(</span><span style="color:#bf616a;">input</span><span>: </span><span style="color:#ebcb8b;">String</span><span>): </span><span style="color:#ebcb8b;">IO</span><span>[</span><span style="color:#ebcb8b;">Option</span><span>[</span><span style="color:#ebcb8b;">String</span><span>]] = +</span><span> </span><span style="color:#ebcb8b;">Env</span><span>[</span><span style="color:#ebcb8b;">IO</span><span>].get(</span><span style="color:#b48ead;">s</span><span>&quot;</span><span style="color:#a3be8c;">INPUT_</span><span>${input.toUpperCase.replace(</span><span style="color:#d08770;">&#39; &#39;</span><span>, </span><span style="color:#d08770;">&#39;_&#39;</span><span>)}&quot;) +</span></code></pre> +<p>With the same method, we can get the output file path and write the output in it:</p> +<pre data-lang="scala" style="background-color:#2b303b;color:#c0c5ce;" class="language-scala "><code class="language-scala" data-lang="scala"><span style="color:#b48ead;">import </span><span>fs2.io.file.{</span><span style="color:#ebcb8b;">Files</span><span>, </span><span style="color:#ebcb8b;">Path</span><span>} +</span><span style="color:#b48ead;">import </span><span>fs2.</span><span style="color:#ebcb8b;">Stream +</span><span> +</span><span style="color:#b48ead;">def </span><span style="color:#8fa1b3;">outputFile</span><span>: </span><span style="color:#ebcb8b;">IO</span><span>[</span><span style="color:#ebcb8b;">Path</span><span>] = +</span><span> </span><span style="color:#ebcb8b;">Env</span><span>[</span><span style="color:#ebcb8b;">IO</span><span>].get(&quot;</span><span style="color:#a3be8c;">GITHUB_OUTPUT</span><span>&quot;).map(_.get).map(</span><span style="color:#ebcb8b;">Path</span><span>.apply) </span><span style="color:#65737e;">// unsafe Option.get +</span><span> +</span><span style="color:#b48ead;">def </span><span style="color:#8fa1b3;">setOutput</span><span>(</span><span style="color:#bf616a;">name</span><span>: </span><span style="color:#ebcb8b;">String</span><span>, </span><span style="color:#bf616a;">value</span><span>: </span><span style="color:#ebcb8b;">String</span><span>): </span><span style="color:#ebcb8b;">IO</span><span>[</span><span style="color:#ebcb8b;">Unit</span><span>] = +</span><span> outputFile.flatMap(path =&gt; +</span><span> </span><span style="color:#ebcb8b;">Stream</span><span>[</span><span style="color:#ebcb8b;">IO</span><span>, </span><span style="color:#ebcb8b;">String</span><span>](</span><span style="color:#b48ead;">s</span><span>&quot;${name}</span><span style="color:#a3be8c;">=</span><span>${value}&quot;) +</span><span> .through(</span><span style="color:#ebcb8b;">Files</span><span>[</span><span style="color:#ebcb8b;">IO</span><span>].writeUtf8(path)) +</span><span> .compile +</span><span> .drain +</span><span> ) +</span></code></pre> +<p>Last but not least, we can write the logic of our application:</p> +<pre data-lang="scala" style="background-color:#2b303b;color:#c0c5ce;" class="language-scala "><code class="language-scala" data-lang="scala"><span style="color:#b48ead;">import </span><span>cats.effect.</span><span style="color:#ebcb8b;">IOApp +</span><span> +</span><span style="color:#b48ead;">object </span><span style="color:#ebcb8b;">index </span><span style="color:#b48ead;">extends </span><span style="color:#a3be8c;">IOApp</span><span>.</span><span style="color:#ebcb8b;">Simple</span><span>: +</span><span> </span><span style="color:#b48ead;">def </span><span style="color:#8fa1b3;">run </span><span>= </span><span style="color:#b48ead;">for </span><span>{ +</span><span> number1 &lt;- getInput(&quot;</span><span style="color:#a3be8c;">number-one</span><span>&quot;).map(_.get.toInt) </span><span style="color:#65737e;">// unsafe +</span><span> number2 &lt;- getInput(&quot;</span><span style="color:#a3be8c;">number-two</span><span>&quot;).map(_.get.toInt) </span><span style="color:#65737e;">// unsafe +</span><span> _ &lt;- setOutput(&quot;</span><span style="color:#a3be8c;">result</span><span>&quot;, </span><span style="color:#b48ead;">s</span><span>&quot;${number1 + number2}&quot;) +</span><span> } </span><span style="color:#b48ead;">yield </span><span>() +</span></code></pre> +<p>The whole action implementation will then be</p> +<div class="code-window"> + <div class="code-title" ><span + class="dot-red"></span> + <span class="dot-yellow"></span> + <span class="dot-green"></span> index.scala + </div> + <div class="code-body"><pre data-lang="scala" style="background-color:#2b303b;color:#c0c5ce;" class="language-scala "><code class="language-scala" data-lang="scala"><span style="color:#65737e;">//&gt; using scala &quot;3.2.2&quot; +</span><span style="color:#65737e;">//&gt; using platform &quot;js&quot; +</span><span style="color:#65737e;">//&gt; using jsVersion &quot;1.13.1&quot; +</span><span style="color:#65737e;">//&gt; using jsModuleKind &quot;common&quot; +</span><span style="color:#65737e;">//&gt; using dep &quot;org.typelevel::toolkit::latest.release&quot; +</span><span> +</span><span style="color:#b48ead;">import </span><span>cats.effect.{</span><span style="color:#ebcb8b;">ExitCode</span><span>, </span><span style="color:#ebcb8b;">IO</span><span>, </span><span style="color:#ebcb8b;">IOApp</span><span>} +</span><span style="color:#b48ead;">import </span><span>cats.effect.std.</span><span style="color:#ebcb8b;">Env +</span><span style="color:#b48ead;">import </span><span>fs2.io.file.{</span><span style="color:#ebcb8b;">Files</span><span>, </span><span style="color:#ebcb8b;">Path</span><span>} +</span><span style="color:#b48ead;">import </span><span>fs2.</span><span style="color:#ebcb8b;">Stream +</span><span> +</span><span style="color:#b48ead;">def </span><span style="color:#8fa1b3;">getInput</span><span>(</span><span style="color:#bf616a;">input</span><span>: </span><span style="color:#ebcb8b;">String</span><span>): </span><span style="color:#ebcb8b;">IO</span><span>[</span><span style="color:#ebcb8b;">Option</span><span>[</span><span style="color:#ebcb8b;">String</span><span>]] = +</span><span> </span><span style="color:#ebcb8b;">Env</span><span>[</span><span style="color:#ebcb8b;">IO</span><span>].get(</span><span style="color:#b48ead;">s</span><span>&quot;</span><span style="color:#a3be8c;">INPUT_</span><span>${input.toUpperCase.replace(</span><span style="color:#d08770;">&#39; &#39;</span><span>, </span><span style="color:#d08770;">&#39;_&#39;</span><span>)}&quot;) +</span><span> +</span><span style="color:#b48ead;">def </span><span style="color:#8fa1b3;">outputFile</span><span>: </span><span style="color:#ebcb8b;">IO</span><span>[</span><span style="color:#ebcb8b;">Path</span><span>] = +</span><span> </span><span style="color:#ebcb8b;">Env</span><span>[</span><span style="color:#ebcb8b;">IO</span><span>].get(&quot;</span><span style="color:#a3be8c;">GITHUB_OUTPUT</span><span>&quot;).map(_.get).map(</span><span style="color:#ebcb8b;">Path</span><span>.apply) </span><span style="color:#65737e;">// unsafe Option.get +</span><span> +</span><span style="color:#b48ead;">def </span><span style="color:#8fa1b3;">setOutput</span><span>(</span><span style="color:#bf616a;">name</span><span>: </span><span style="color:#ebcb8b;">String</span><span>, </span><span style="color:#bf616a;">value</span><span>: </span><span style="color:#ebcb8b;">String</span><span>): </span><span style="color:#ebcb8b;">IO</span><span>[</span><span style="color:#ebcb8b;">Unit</span><span>] = +</span><span> outputFile.flatMap(path =&gt; +</span><span> </span><span style="color:#ebcb8b;">Stream</span><span>[</span><span style="color:#ebcb8b;">IO</span><span>, </span><span style="color:#ebcb8b;">String</span><span>](</span><span style="color:#b48ead;">s</span><span>&quot;${name}</span><span style="color:#a3be8c;">=</span><span>${value}&quot;) +</span><span> .through(</span><span style="color:#ebcb8b;">Files</span><span>[</span><span style="color:#ebcb8b;">IO</span><span>].writeUtf8(path)) +</span><span> .compile +</span><span> .drain +</span><span> ) +</span><span> +</span><span style="color:#b48ead;">object </span><span style="color:#ebcb8b;">index </span><span style="color:#b48ead;">extends </span><span style="color:#a3be8c;">IOApp</span><span>.</span><span style="color:#ebcb8b;">Simple</span><span>: +</span><span> </span><span style="color:#b48ead;">def </span><span style="color:#8fa1b3;">run </span><span>= </span><span style="color:#b48ead;">for </span><span>{ +</span><span> number1 &lt;- getInput(&quot;</span><span style="color:#a3be8c;">number-one</span><span>&quot;).map(_.get.toInt) </span><span style="color:#65737e;">// unsafe Option.get +</span><span> number2 &lt;- getInput(&quot;</span><span style="color:#a3be8c;">number-two</span><span>&quot;).map(_.get.toInt) </span><span style="color:#65737e;">// unsafe Option.get +</span><span> _ &lt;- setOutput(&quot;</span><span style="color:#a3be8c;">result</span><span>&quot;, </span><span style="color:#b48ead;">s</span><span>&quot;${number1 + number2}&quot;) +</span><span> } </span><span style="color:#b48ead;">yield </span><span>() +</span></code></pre> +</div> +</div><details> +<summary>Safer and shorter alternative that uses decline</summary> +<div class="code-window"> + <div class="code-title" ><span + class="dot-red"></span> + <span class="dot-yellow"></span> + <span class="dot-green"></span> index.scala + </div> + <div class="code-body"><pre data-lang="scala" style="background-color:#2b303b;color:#c0c5ce;" class="language-scala "><code class="language-scala" data-lang="scala"><span style="color:#65737e;">//&gt; using scala &quot;3.2.2&quot; +</span><span style="color:#65737e;">//&gt; using platform &quot;js&quot; +</span><span style="color:#65737e;">//&gt; using jsVersion &quot;1.13.1&quot; +</span><span style="color:#65737e;">//&gt; using jsModuleKind &quot;common&quot; +</span><span style="color:#65737e;">//&gt; using dep &quot;org.typelevel::toolkit::latest.release&quot; +</span><span> +</span><span style="color:#b48ead;">import </span><span>cats.effect.{</span><span style="color:#ebcb8b;">IO</span><span>, </span><span style="color:#ebcb8b;">ExitCode</span><span>} +</span><span style="color:#b48ead;">import </span><span>cats.syntax.all.* +</span><span style="color:#b48ead;">import </span><span>fs2.</span><span style="color:#ebcb8b;">Stream +</span><span style="color:#b48ead;">import </span><span>fs2.io.file.{</span><span style="color:#ebcb8b;">Files</span><span>, </span><span style="color:#ebcb8b;">Path</span><span>} +</span><span style="color:#b48ead;">import </span><span>com.monovore.decline.</span><span style="color:#ebcb8b;">Opts +</span><span style="color:#b48ead;">import </span><span>com.monovore.decline.effect.</span><span style="color:#ebcb8b;">CommandIOApp +</span><span> +</span><span style="color:#b48ead;">val </span><span style="color:#bf616a;">args </span><span>= ( +</span><span> </span><span style="color:#ebcb8b;">Opts</span><span>.env[</span><span style="color:#ebcb8b;">Int</span><span>](&quot;</span><span style="color:#a3be8c;">INPUT_NUMBER-ONE</span><span>&quot;, &quot;</span><span style="color:#a3be8c;">The first number</span><span>&quot;), +</span><span> </span><span style="color:#ebcb8b;">Opts</span><span>.env[</span><span style="color:#ebcb8b;">Int</span><span>](&quot;</span><span style="color:#a3be8c;">INPUT_NUMBER-TWO</span><span>&quot;, &quot;</span><span style="color:#a3be8c;">The second number</span><span>&quot;), +</span><span> </span><span style="color:#ebcb8b;">Opts</span><span>.env[</span><span style="color:#ebcb8b;">String</span><span>](&quot;</span><span style="color:#a3be8c;">GITHUB_OUTPUT</span><span>&quot;, &quot;</span><span style="color:#a3be8c;">The file of the output</span><span>&quot;).map(</span><span style="color:#ebcb8b;">Path</span><span>.apply) +</span><span>) +</span><span> +</span><span style="color:#b48ead;">object </span><span style="color:#ebcb8b;">index </span><span style="color:#b48ead;">extends </span><span style="color:#a3be8c;">CommandIOApp</span><span>(&quot;</span><span style="color:#a3be8c;">adder</span><span>&quot;, &quot;</span><span style="color:#a3be8c;">Summing two numbers</span><span>&quot;): +</span><span> </span><span style="color:#b48ead;">def </span><span style="color:#8fa1b3;">main </span><span>= args.mapN { (one, two, path) =&gt; +</span><span> </span><span style="color:#ebcb8b;">Stream</span><span>(</span><span style="color:#b48ead;">s</span><span>&quot;</span><span style="color:#a3be8c;">result=</span><span>${one + two}&quot;) +</span><span> .through(</span><span style="color:#ebcb8b;">Files</span><span>[</span><span style="color:#ebcb8b;">IO</span><span>].writeUtf8(path)) +</span><span> .compile +</span><span> .drain +</span><span> .as(</span><span style="color:#ebcb8b;">ExitCode</span><span>.</span><span style="color:#ebcb8b;">Success</span><span>) +</span><span> } +</span></code></pre> +</div> +</div></details> +<p>Now that the logic is in place, we must produce a <code>.js</code> file and <strong>commit it</strong> in the repo, as the action runtime won't interpret our Scala code. Scala-cli helps us: running <code>scala-cli --power package -f index.scala</code> produces an <code>index.js</code> file that our action can run.</p> +<p>The content of our repository should now be this:</p> +<pre data-lang="tree" style="background-color:#2b303b;color:#c0c5ce;" class="language-tree "><code class="language-tree" data-lang="tree"><span style="color:#bf616a;">. +</span><span style="color:#a3be8c;">β”œβ”€β”€</span><span> action.yml +</span><span style="color:#a3be8c;">β”œβ”€β”€</span><span> index.js +</span><span style="color:#a3be8c;">└──</span><span> index.scala +</span></code></pre> +<p>It's time to check if our action work as intended.</p> +<h3 id="testing-never-hurts"><a class="anchor" href="#testing-never-hurts">Testing never hurts</a></h3> +<p>There are a few ways to test if an action you're developing works as intended. The best one is probably using <a href="https://github.com/nektos/act">act</a>, as the feedback cycle will be shorter. Sadly, the last time I checked <code>sbt</code> (and possibly <code>scala-cli</code>) was included only in the complete runtime image, requiring you to download the whole ~20GB container image.</p> +<p>The quickest way to test the action is to run it directly on the GitHub Runners and set up its CI to test the logic: the only required thing is a workflow file under <code>.github/workflows</code>.</p> +<p>As we must commit the transpiled version of our source code, a preliminary check that the <code>.js</code> file corresponds to the source <code>.scala</code> file is a good idea. The easiest way to test that they match is to recompile the <code>.scala</code> file with scala-cli and use the good old <code>git diff</code>:</p> +<pre data-lang="yml" style="background-color:#2b303b;color:#c0c5ce;" class="language-yml "><code class="language-yml" data-lang="yml"><span style="color:#bf616a;">check-js-file</span><span>: +</span><span> </span><span style="color:#bf616a;">runs-on</span><span>: </span><span style="color:#a3be8c;">ubuntu-latest +</span><span> </span><span style="color:#bf616a;">steps</span><span>: +</span><span> - </span><span style="color:#bf616a;">uses</span><span>: </span><span style="color:#a3be8c;">actions/checkout@v3 </span><span style="color:#65737e;"># Checking out our code +</span><span> - </span><span style="color:#bf616a;">uses</span><span>: </span><span style="color:#a3be8c;">actions/setup-java@v3 +</span><span> </span><span style="color:#bf616a;">with</span><span>: +</span><span> </span><span style="color:#bf616a;">distribution</span><span>: </span><span style="color:#a3be8c;">temurin +</span><span> </span><span style="color:#bf616a;">java-version</span><span>: </span><span style="color:#d08770;">17 +</span><span> - </span><span style="color:#bf616a;">uses</span><span>: </span><span style="color:#a3be8c;">coursier/cache-action@v6 +</span><span> - </span><span style="color:#bf616a;">uses</span><span>: </span><span style="color:#a3be8c;">VirtusLab/scala-cli-setup@main </span><span style="color:#65737e;"># Installing scala-cli +</span><span> - </span><span style="color:#bf616a;">run</span><span>: </span><span style="color:#a3be8c;">scala-cli --power package -f index.scala </span><span style="color:#65737e;"># Recompiling our code +</span><span> - </span><span style="color:#bf616a;">run</span><span>: </span><span style="color:#a3be8c;">git diff --quiet index.js </span><span style="color:#65737e;"># Silently failing if there&#39;s any difference +</span></code></pre> +<blockquote> +<p>One thing to consider is that we used <code>latest.release</code> as the toolkit version, making our build non reproducible. Pinning the dependencies' versions is usually a good idea. To achieve reproducibility is possible to pin a specific scala-cli version too using <code>--cli-version &lt;version&gt;</code>. Also, pinning each action version (i.e. <code>- uses:VirtusLab/scala-cli-setup@v1.0.0-RC2</code>) might decrease the chances that your CI will produce a different js file (and thus failing) in the future.</p> +</blockquote> +<p>Once sure that the transpiled version of our code is correct, we can run our action and test its output directly in its own CI:</p> +<pre data-lang="yml" style="background-color:#2b303b;color:#c0c5ce;" class="language-yml "><code class="language-yml" data-lang="yml"><span style="color:#bf616a;">test-action-itself</span><span>: +</span><span> </span><span style="color:#bf616a;">needs</span><span>: </span><span style="color:#a3be8c;">check-js-file </span><span style="color:#65737e;"># There&#39;s no point in testing the wrong version +</span><span> </span><span style="color:#bf616a;">runs-on</span><span>: </span><span style="color:#a3be8c;">ubuntu-latest +</span><span> </span><span style="color:#bf616a;">steps</span><span>: +</span><span> - </span><span style="color:#bf616a;">uses</span><span>: </span><span style="color:#a3be8c;">actions/checkout@v3 +</span><span> - </span><span style="color:#bf616a;">uses</span><span>: </span><span style="color:#a3be8c;">./ </span><span style="color:#65737e;"># Here we&#39;ll use the action itself +</span><span> </span><span style="color:#bf616a;">id</span><span>: </span><span style="color:#a3be8c;">test-gh-action +</span><span> </span><span style="color:#bf616a;">with</span><span>: +</span><span> </span><span style="color:#bf616a;">number-one</span><span>: </span><span style="color:#d08770;">3 +</span><span> </span><span style="color:#bf616a;">number-two</span><span>: </span><span style="color:#d08770;">9 +</span><span> - </span><span style="color:#bf616a;">run</span><span>: </span><span style="color:#a3be8c;">test 12 -eq &quot;${{ steps.test-gh-action.outputs.result }}&quot; +</span></code></pre> +<p>The last action uses the good old <code>test</code> command (aka <code>[</code>) to check the action's output for the specified inputs.</p> +<details> +<summary>Complete CI file</summary> +<div class="code-window"> + <div class="code-title" style="background-color:#a3be8c;color:#1a2539;" ><span + class="dot-red"></span> + <span class="dot-yellow"></span> + <span class="dot-green"></span> .github&#x2F;workflows&#x2F;ci.yml + </div> + <div class="code-body"><pre data-lang="yml" style="background-color:#2b303b;color:#c0c5ce;" class="language-yml "><code class="language-yml" data-lang="yml"><span style="color:#bf616a;">name</span><span>: </span><span style="color:#a3be8c;">Continuos Integration +</span><span style="color:#d08770;">on</span><span>: +</span><span> </span><span style="color:#bf616a;">pull_request</span><span>: +</span><span> </span><span style="color:#bf616a;">branches</span><span>: [&#39;</span><span style="color:#a3be8c;">**</span><span>&#39;] +</span><span> </span><span style="color:#bf616a;">push</span><span>: +</span><span> </span><span style="color:#bf616a;">branches</span><span>: [&#39;</span><span style="color:#a3be8c;">**</span><span>&#39;, &#39;</span><span style="color:#a3be8c;">!update/**</span><span>&#39;, &#39;</span><span style="color:#a3be8c;">!pr/**</span><span>&#39;] +</span><span> +</span><span style="color:#bf616a;">jobs</span><span>: +</span><span> </span><span style="color:#bf616a;">check-js-file</span><span>: +</span><span> </span><span style="color:#bf616a;">runs-on</span><span>: </span><span style="color:#a3be8c;">ubuntu-latest +</span><span> </span><span style="color:#bf616a;">steps</span><span>: +</span><span> - </span><span style="color:#bf616a;">uses</span><span>: </span><span style="color:#a3be8c;">actions/checkout@v3 +</span><span> - </span><span style="color:#bf616a;">uses</span><span>: </span><span style="color:#a3be8c;">actions/setup-java@v3 +</span><span> </span><span style="color:#bf616a;">with</span><span>: +</span><span> </span><span style="color:#bf616a;">distribution</span><span>: </span><span style="color:#a3be8c;">temurin +</span><span> </span><span style="color:#bf616a;">java-version</span><span>: </span><span style="color:#d08770;">17 +</span><span> - </span><span style="color:#bf616a;">uses</span><span>: </span><span style="color:#a3be8c;">coursier/cache-action@v6 +</span><span> - </span><span style="color:#bf616a;">uses</span><span>: </span><span style="color:#a3be8c;">VirtusLab/scala-cli-setup@main +</span><span> - </span><span style="color:#bf616a;">run</span><span>: </span><span style="color:#a3be8c;">scala-cli --power --cli-version 1.0.0-RC2 package -f index.scala +</span><span> - </span><span style="color:#bf616a;">run</span><span>: </span><span style="color:#a3be8c;">git diff --quiet index.js +</span><span> +</span><span> </span><span style="color:#bf616a;">test-action-itself</span><span>: +</span><span> </span><span style="color:#bf616a;">needs</span><span>: </span><span style="color:#a3be8c;">check-js-file +</span><span> </span><span style="color:#bf616a;">runs-on</span><span>: </span><span style="color:#a3be8c;">ubuntu-latest +</span><span> </span><span style="color:#bf616a;">steps</span><span>: +</span><span> - </span><span style="color:#bf616a;">uses</span><span>: </span><span style="color:#a3be8c;">actions/checkout@v3 +</span><span> - </span><span style="color:#bf616a;">uses</span><span>: </span><span style="color:#a3be8c;">./ +</span><span> </span><span style="color:#bf616a;">id</span><span>: </span><span style="color:#a3be8c;">test-gh-action +</span><span> </span><span style="color:#bf616a;">with</span><span>: +</span><span> </span><span style="color:#bf616a;">number-one</span><span>: </span><span style="color:#d08770;">3 +</span><span> </span><span style="color:#bf616a;">number-two</span><span>: </span><span style="color:#d08770;">9 +</span><span> - </span><span style="color:#bf616a;">run</span><span>: </span><span style="color:#a3be8c;">test 12 -eq &quot;${{ steps.test-gh-action.outputs.result }}&quot; +</span></code></pre> +</div> +</div></details> +<h3 id="using-the-action"><a class="anchor" href="#using-the-action">Using the action</a></h3> +<p>To let the world use your new and shiny Scala.js-powered GitHub Action, commit every mentioned file in a public repository, let's say <a href="https://github.com/TonioGela/test-gh-action"><code>TonioGela/test-gh-action</code></a>, and use the repository slug in every other action on the whole GitHub:</p> +<pre data-lang="yml" style="background-color:#2b303b;color:#c0c5ce;" class="language-yml "><code class="language-yml" data-lang="yml"><span style="color:#65737e;"># ... +</span><span> - </span><span style="color:#bf616a;">name</span><span>: </span><span style="color:#a3be8c;">Sum numbers with Scala +</span><span> </span><span style="color:#bf616a;">id</span><span>: </span><span style="color:#a3be8c;">this-is-the-id +</span><span> </span><span style="color:#bf616a;">uses</span><span>: </span><span style="color:#a3be8c;">TonioGela/test-gh-action@main </span><span style="color:#65737e;"># specify a branch name, a version or a commit sha +</span><span> </span><span style="color:#bf616a;">with</span><span>: +</span><span> </span><span style="color:#bf616a;">number-one</span><span>: </span><span style="color:#d08770;">3 +</span><span> </span><span style="color:#bf616a;">number-two</span><span>: </span><span style="color:#d08770;">9 +</span><span style="color:#65737e;"># ... +</span></code></pre> +<h3 id="further-considerations"><a class="anchor" href="#further-considerations">Further considerations</a></h3> +<p>The example in this post is meant to show how to use a combination of tools and libraries to create a Github Action and doesn't show the true power of the Typelevel stack. A recent addition to <a href="https://fs2.io/#/io">fs2-io</a> that can be handy in the context of an action might be the <a href="https://fs2.io/#/io?id=processes">Processes APIs</a>, with whom you can invoke external commands/tools handling their stdin, stdout, and exit codes:</p> +<pre data-lang="scala" style="background-color:#2b303b;color:#c0c5ce;" class="language-scala "><code class="language-scala" data-lang="scala"><span style="color:#b48ead;">import </span><span>cats.effect.{</span><span style="color:#ebcb8b;">Concurrent</span><span>, </span><span style="color:#ebcb8b;">MonadCancelThrow</span><span>} +</span><span style="color:#b48ead;">import </span><span>fs2.io.process.{</span><span style="color:#ebcb8b;">Processes</span><span>, </span><span style="color:#ebcb8b;">ProcessBuilder</span><span>} +</span><span style="color:#b48ead;">import </span><span>fs2.text +</span><span> +</span><span style="color:#b48ead;">def </span><span style="color:#8fa1b3;">helloProcess</span><span>[</span><span style="color:#ebcb8b;">F</span><span>[_]: </span><span style="color:#ebcb8b;">Concurrent</span><span>: </span><span style="color:#ebcb8b;">Processes</span><span>]: </span><span style="color:#ebcb8b;">F</span><span>[</span><span style="color:#ebcb8b;">String</span><span>] = +</span><span> </span><span style="color:#ebcb8b;">ProcessBuilder</span><span>(&quot;</span><span style="color:#a3be8c;">echo</span><span>&quot;, &quot;</span><span style="color:#a3be8c;">Hello, process!</span><span>&quot;).spawn.use { process =&gt; +</span><span> process.stdout.through(text.utf8.decode).compile.string +</span><span> } +</span></code></pre> +<p>The toolkit includes the <code>Ember</code> client and its <code>circe</code> integration, with whom you can easily call any external service and deserialize its output in a case class:</p> +<pre data-lang="scala" style="background-color:#2b303b;color:#c0c5ce;" class="language-scala "><code class="language-scala" data-lang="scala"><span style="color:#b48ead;">import </span><span>cats.effect.</span><span style="color:#ebcb8b;">IO +</span><span style="color:#b48ead;">import </span><span>cats.syntax.all.* +</span><span style="color:#b48ead;">import </span><span>io.circe.</span><span style="color:#ebcb8b;">Decoder +</span><span style="color:#b48ead;">import </span><span>org.http4s.circe.jsonOf +</span><span style="color:#b48ead;">import </span><span>org.http4s.</span><span style="color:#ebcb8b;">EntityDecoder +</span><span style="color:#b48ead;">import </span><span>org.http4s.ember.client.</span><span style="color:#ebcb8b;">EmberClientBuilder +</span><span> +</span><span style="color:#b48ead;">case class </span><span style="color:#ebcb8b;">Foo</span><span>(bar:</span><span style="color:#ebcb8b;">String</span><span>) </span><span style="color:#b48ead;">derives </span><span style="color:#a3be8c;">Decoder +</span><span style="color:#b48ead;">given </span><span style="color:#ebcb8b;">EntityDecoder</span><span>[</span><span style="color:#ebcb8b;">IO</span><span>, </span><span style="color:#ebcb8b;">Foo</span><span>] = jsonOf[</span><span style="color:#ebcb8b;">IO</span><span>, </span><span style="color:#ebcb8b;">Foo</span><span>] +</span><span> +</span><span style="color:#ebcb8b;">EmberClientBuilder</span><span>.default[</span><span style="color:#ebcb8b;">IO</span><span>].build.use { client =&gt; +</span><span> client.expect[</span><span style="color:#ebcb8b;">Foo</span><span>](</span><span style="color:#b48ead;">s</span><span>&quot;</span><span style="color:#a3be8c;">https://foo.bar</span><span>&quot;).flatMap(foo =&gt; </span><span style="color:#ebcb8b;">IO</span><span>.println(foo)) +</span><span>} +</span></code></pre> +<p>The toolkit's site contains a <a href="https://typelevel.org/toolkit/examples.html">few examples</a> of what you can do with it. Go take a look πŸ˜„</p> +<h2 id="conclusions"><a class="anchor" href="#conclusions">Conclusions</a></h2> +<p>Despite being a bit unripe, I find this approach fascinating and easy to use (in particular if you don't know any <code>js</code> in 2023 πŸ˜‡). </p> +<p>In the future, I might consider rewriting in Scala.js the <a href="https://github.com/actions/toolkit">actions/toolkit</a> library or a part of it (I might have to learn javascript 🀦). If you want to contribute, feel free to <a href="https://discord.com/users/372358874243661825">contact me</a>.</p> +<p>One thing that's worth exploring is the interaction with <a href="https://github.com/scala-steward-org/scala-steward">Scala-Steward</a>. Can the CI be set up to re-generate the js and commit the result? Probably yes, with <code>postUpdateHooks</code>. Is it desirable? I'm still not sure.</p> +<p>You'll find the code written in the post in <a href="https://github.com/TonioGela/test-gh-action">this repository</a></p> +<p>Enjoy!</p> + + + + Deploy http4s on your domain with fly.io + Mon, 09 Jan 2023 00:00:00 +0000 + Unknown + https://toniogela.dev/http4s-on-fly-io/ + https://toniogela.dev/http4s-on-fly-io/ + <blockquote> +<p><strong>DISCLAIMER</strong>: This article assumes some familiarity with the <a href="https://typelevel.org/">Typelevel</a>'s tech stack, <a href="https://http4s.org/">http4s</a> in particular.</p> +<p>There's plenty of <strong>good resources</strong> to read online to get started with, some of them being <a href="https://underscore.io/books/scala-with-cats/">Scala with Cats</a>, <a href="https://essentialeffects.dev/">Essential Effects</a> and the <a href="https://typelevel.org/cats-effect/">Cats Effect</a> documentation. +The <strong>best and most comprehensive resource</strong> you'll find to develop a microservice using this stack is <a href="https://leanpub.com/pfp-scala">Practical FP in Scala</a>, that I strongly suggest reading.</p> +<p>If you need <strong>help</strong> with any of these resources feel free to contact me or better ask questions in the <a href="https://discord.com/invite/XF3CXcMzqD">Typelevel's Discord</a>. You'll find <strong>an amazing and kind community</strong> of really <strong>talented</strong> people that will be glad to answer to your questions πŸ˜„</p> +</blockquote> +<p>If you already own a domain, deploying a toy server or any personal <em>server-shaped</em> project on it should not be a complex operation. Using <a href="https://fly.io/">fly.io</a>, <a href="https://scala-cli.virtuslab.org/">scala-cli</a>, <a href="https://http4s.org/">http4s</a> and <a href="https://github.com/casey/just">just</a> can help automatise the process and reduce the friction up to the point it might even be fun.</p> +<h2 id="requirements"><a class="anchor" href="#requirements">Requirements</a></h2> +<p>Before starting, we'll need to set up a couple of things. Here's the list:</p> +<ul> +<li>Having/buying a <strong>custom domain</strong> and having access to its <strong>DNS settings page</strong>: I'm using <a href="https://domains.google/">Google Domains</a> since the domains are cheap (most of them cost 12$ per year), but sadly it lacks support for ALIAS records.</li> +<li>Sign up on <a href="https://fly.io/">fly.io</a>, <a href="https://toniogela.dev/http4s-on-fly-io/(https://fly.io/docs/hands-on/install-flyctl/)">install</a> its command line tool <strong><code>flyctl</code></strong> and log in using <code>flyctl auth login</code></li> +<li><strong>Of course</strong>, a local installation of <a href="https://scala-cli.virtuslab.org/">scala-cli</a> (Here's me talking about it on the <a href="https://blog.rockthejvm.com/scala-cli-and-scala-native/">Rock The JVM blog</a>)</li> +<li>Optionally the command line tool <a href="https://github.com/casey/just"><code>just</code></a> that I <strike>recently</strike> reviewed <a href="https://toniogela.dev/just/">in another article</a></li> +</ul> +<h2 id="writing-the-application"><a class="anchor" href="#writing-the-application">Writing the application</a></h2> +<p>Writing a hello-world-spitting server with http4s using <a href="https://github.com/http4s/http4s.g8">its giter8 template</a> and sbt it's a trivial task.</p> +<p>Instead, we'll write it manually, using scala-cli and adding a slightly less trivial business logic. To begin, we'll create a file containing a few scala-cli directives to declare the dependencies and the scala version:</p> +<pre data-lang="scala" style="background-color:#2b303b;color:#c0c5ce;" class="language-scala "><code class="language-scala" data-lang="scala"><span style="color:#65737e;">//&gt; using scala &quot;3.2.1&quot; +</span><span style="color:#65737e;">//&gt; using lib &quot;org.http4s::http4s-ember-server::0.23.17&quot; +</span><span style="color:#65737e;">//&gt; using lib &quot;org.http4s::http4s-dsl::0.23.17&quot; +</span><span style="color:#65737e;">//&gt; using lib &quot;com.monovore::decline-effect::2.4.1&quot; +</span><span style="color:#65737e;">//&gt; using lib &quot;ch.qos.logback:logback-classic:1.4.5&quot; +</span></code></pre> +<p>The server will read two environment variables, a mandatory one for the base URL and one for the title of the HTML pages to return. We'll use <a href="https://ben.kirw.in/decline/">decline</a> to define them and use them:</p> +<pre data-lang="scala" style="background-color:#2b303b;color:#c0c5ce;" class="language-scala "><code class="language-scala" data-lang="scala"><span style="color:#b48ead;">import </span><span>cats.effect.{</span><span style="color:#ebcb8b;">ExitCode</span><span>, </span><span style="color:#ebcb8b;">IO</span><span>} +</span><span style="color:#b48ead;">import </span><span>cats.syntax.all.* +</span><span style="color:#b48ead;">import </span><span>com.monovore.decline.</span><span style="color:#ebcb8b;">Opts +</span><span style="color:#b48ead;">import </span><span>com.monovore.decline.effect.</span><span style="color:#ebcb8b;">CommandIOApp +</span><span style="color:#b48ead;">import </span><span>org.http4s.</span><span style="color:#ebcb8b;">Uri +</span><span> +</span><span style="color:#b48ead;">object </span><span style="color:#ebcb8b;">Server </span><span style="color:#b48ead;">extends </span><span style="color:#a3be8c;">CommandIOApp</span><span>(&quot;</span><span style="color:#a3be8c;">helloServer</span><span>&quot;, &quot;</span><span style="color:#a3be8c;">Greets you in HTML</span><span>&quot;) { +</span><span> +</span><span> </span><span style="color:#b48ead;">val </span><span style="color:#bf616a;">titleOpt</span><span>: </span><span style="color:#ebcb8b;">Opts</span><span>[</span><span style="color:#ebcb8b;">String</span><span>] = +</span><span> </span><span style="color:#ebcb8b;">Opts</span><span>.env[</span><span style="color:#ebcb8b;">String</span><span>](&quot;</span><span style="color:#a3be8c;">TITLE</span><span>&quot;, &quot;</span><span style="color:#a3be8c;">Page title</span><span>&quot;).withDefault(&quot;</span><span style="color:#a3be8c;">Hello</span><span>&quot;) +</span><span> +</span><span> </span><span style="color:#b48ead;">val </span><span style="color:#bf616a;">baseUrlOpt</span><span>: </span><span style="color:#ebcb8b;">Opts</span><span>[</span><span style="color:#ebcb8b;">Uri</span><span>] = </span><span style="color:#ebcb8b;">Opts +</span><span> .env[</span><span style="color:#ebcb8b;">String</span><span>](&quot;</span><span style="color:#a3be8c;">BASE_URL</span><span>&quot;, &quot;</span><span style="color:#a3be8c;">The base url</span><span>&quot;) +</span><span> .mapValidated( +</span><span> </span><span style="color:#ebcb8b;">Uri +</span><span> .fromString(_) +</span><span> .leftMap(_.message) +</span><span> .ensure(&quot;</span><span style="color:#a3be8c;">base url must be absolute</span><span>&quot;)(_.path.addEndsWithSlash.absolute) +</span><span> .map(uri =&gt; uri.withPath(uri.path.dropEndsWithSlash)) +</span><span> .toValidatedNel +</span><span> ) +</span><span> +</span><span> </span><span style="color:#b48ead;">def </span><span style="color:#8fa1b3;">main</span><span>: </span><span style="color:#ebcb8b;">Opts</span><span>[</span><span style="color:#ebcb8b;">IO</span><span>[</span><span style="color:#ebcb8b;">ExitCode</span><span>]] = (baseUrlOpt, titleOpt).mapN((baseUrl, title) =&gt; +</span><span> </span><span style="color:#ebcb8b;">IO</span><span>.println(</span><span style="color:#b48ead;">s</span><span>&quot;$baseUrl $title&quot;).as(</span><span style="color:#ebcb8b;">ExitCode</span><span>.</span><span style="color:#ebcb8b;">Success</span><span>) +</span><span> ) +</span><span>} +</span></code></pre> +<p>The application prints the environment variables' content, validates the base URL's content and adds a default for <code>TITLE</code>. </p> +<p>To add some business logic to the soon-to-be server, we'll add a pure function that builds a tiny HTML page, and we'll use it in our <code>routes</code> implementation:</p> +<pre data-lang="scala" style="background-color:#2b303b;color:#c0c5ce;" class="language-scala "><code class="language-scala" data-lang="scala"><span style="color:#b48ead;">import </span><span>cats.effect.kernel.</span><span style="color:#ebcb8b;">Async +</span><span style="color:#b48ead;">import </span><span>org.http4s.{</span><span style="color:#ebcb8b;">HttpRoutes</span><span>, </span><span style="color:#ebcb8b;">MediaType</span><span>, </span><span style="color:#ebcb8b;">Response</span><span>, </span><span style="color:#ebcb8b;">Status</span><span>} +</span><span style="color:#b48ead;">import </span><span>org.http4s.dsl.io.* +</span><span style="color:#b48ead;">import </span><span>org.http4s.headers.`Content-Type` +</span><span> +</span><span style="color:#b48ead;">def </span><span style="color:#8fa1b3;">page</span><span>(</span><span style="color:#bf616a;">uri</span><span>: </span><span style="color:#ebcb8b;">Uri</span><span>, </span><span style="color:#bf616a;">title</span><span>: </span><span style="color:#ebcb8b;">String</span><span>): </span><span style="color:#ebcb8b;">String </span><span>= +</span><span> </span><span style="color:#b48ead;">s</span><span>&quot;&quot;&quot;</span><span style="color:#a3be8c;">|&lt;html&gt; +</span><span style="color:#a3be8c;"> |&lt;head&gt;&lt;title&gt;</span><span>$title</span><span style="color:#a3be8c;">&lt;/title&gt;&lt;/head&gt; +</span><span style="color:#a3be8c;"> |&lt;body&gt;Hello from </span><span>${uri.toString}</span><span style="color:#a3be8c;">&lt;/body&gt; +</span><span style="color:#a3be8c;"> |&lt;/html&gt;</span><span>&quot;&quot;&quot;.stripMargin +</span><span> +</span><span style="color:#b48ead;">def </span><span style="color:#8fa1b3;">routes</span><span>[</span><span style="color:#ebcb8b;">F</span><span>[_]: </span><span style="color:#ebcb8b;">Async</span><span>](</span><span style="color:#bf616a;">baseUrl</span><span>: </span><span style="color:#ebcb8b;">Uri</span><span>, </span><span style="color:#bf616a;">title</span><span>: </span><span style="color:#ebcb8b;">String</span><span>): </span><span style="color:#ebcb8b;">HttpRoutes</span><span>[</span><span style="color:#ebcb8b;">F</span><span>] = +</span><span> </span><span style="color:#ebcb8b;">HttpRoutes</span><span>.of[</span><span style="color:#ebcb8b;">F</span><span>] { +</span><span> </span><span style="color:#b48ead;">case </span><span style="color:#ebcb8b;">GET </span><span>-&gt; </span><span style="color:#ebcb8b;">Root </span><span>/ &quot;</span><span style="color:#a3be8c;">health</span><span>&quot; =&gt; </span><span style="color:#ebcb8b;">Response</span><span>[</span><span style="color:#ebcb8b;">F</span><span>](</span><span style="color:#ebcb8b;">Status</span><span>.</span><span style="color:#ebcb8b;">Ok</span><span>).pure[</span><span style="color:#ebcb8b;">F</span><span>] +</span><span> </span><span style="color:#b48ead;">case </span><span style="color:#ebcb8b;">GET </span><span>-&gt; path =&gt; +</span><span> </span><span style="color:#ebcb8b;">Response</span><span>[</span><span style="color:#ebcb8b;">F</span><span>](</span><span style="color:#ebcb8b;">Status</span><span>.</span><span style="color:#ebcb8b;">Ok</span><span>) +</span><span> .withEntity(page(baseUrl.withPath(baseUrl.path.merge(path)), title)) +</span><span> .withContentType(`Content-Type`(</span><span style="color:#ebcb8b;">MediaType</span><span>.text.html)) +</span><span> .pure[</span><span style="color:#ebcb8b;">F</span><span>] +</span><span> } +</span></code></pre> +<p>The simple logic consists in printing the absolute URL of the page that was requested to the server, plus a health check endpoint.</p> +<p>We'll add some logging to our <code>routes</code> leveraging <code>log4cats</code> and <code>slf4j</code>:</p> +<pre data-lang="diff" style="background-color:#2b303b;color:#c0c5ce;" class="language-diff "><code class="language-diff" data-lang="diff"><span> import org.typelevel.log4cats.Logger +</span><span> import org.typelevel.log4cats.slf4j.* +</span><span> +</span><span style="color:#a3be8c;">+def routes[F[_]: Async: Logger](baseUrl: Uri, title: String): HttpRoutes[F] = +</span><span style="color:#bf616a;">-def routes[F[_]: Async](baseUrl: Uri, title: String): HttpRoutes[F] = +</span><span> HttpRoutes.of[F] { +</span><span> case GET -&gt; Root / &quot;health&quot; =&gt; Response[F](Status.Ok).pure[F] +</span><span> case GET -&gt; path =&gt; +</span><span style="color:#a3be8c;">+ Logger[F].info(s&quot;Serving $path&quot;) &gt;&gt; +</span><span> Response[F](Status.Ok) +</span><span> .withEntity(page(baseUrl.withPath(baseUrl.path.merge(path)), title)) +</span><span> .withContentType(`Content-Type`(MediaType.text.html)) +</span><span> .pure[F] +</span><span> } +</span></code></pre> +<p>Our logging backend will be <code>logback</code>, which we'll configure by adding a <code>logback.xml</code> file in our current directory:</p> +<div class="code-window"> + <div class="code-title" style="background-color:#a3be8c;color:#1a2539;" ><span + class="dot-red"></span> + <span class="dot-yellow"></span> + <span class="dot-green"></span> logback.xml + </div> + <div class="code-body"><pre data-lang="xml" style="background-color:#2b303b;color:#c0c5ce;" class="language-xml "><code class="language-xml" data-lang="xml"><span>&lt;?</span><span style="color:#bf616a;">xml </span><span style="color:#d08770;">version</span><span>=&quot;</span><span style="color:#a3be8c;">1.0</span><span>&quot; </span><span style="color:#d08770;">encoding</span><span>=&quot;</span><span style="color:#a3be8c;">UTF-8</span><span>&quot;?&gt; +</span><span>&lt;</span><span style="color:#bf616a;">configuration </span><span style="color:#d08770;">debug</span><span>=&quot;</span><span style="color:#a3be8c;">false</span><span>&quot;&gt; +</span><span> &lt;</span><span style="color:#bf616a;">appender </span><span style="color:#d08770;">name</span><span>=&quot;</span><span style="color:#a3be8c;">STDOUT</span><span>&quot; </span><span style="color:#d08770;">class</span><span>=&quot;</span><span style="color:#a3be8c;">ch.qos.logback.core.ConsoleAppender</span><span>&quot;&gt; +</span><span> &lt;</span><span style="color:#bf616a;">encoder</span><span>&gt; +</span><span> &lt;</span><span style="color:#bf616a;">pattern</span><span>&gt; +</span><span> %d{ISO8601} [%-4level] %logger{0}: %msg%n +</span><span> &lt;/</span><span style="color:#bf616a;">pattern</span><span>&gt; +</span><span> &lt;/</span><span style="color:#bf616a;">encoder</span><span>&gt; +</span><span> &lt;/</span><span style="color:#bf616a;">appender</span><span>&gt; +</span><span> +</span><span> &lt;</span><span style="color:#bf616a;">logger </span><span style="color:#d08770;">name</span><span>=&quot;</span><span style="color:#a3be8c;">org.http4s.ember.server</span><span>&quot; </span><span style="color:#d08770;">level</span><span>=&quot;</span><span style="color:#a3be8c;">ERROR</span><span>&quot; /&gt; +</span><span> +</span><span> &lt;</span><span style="color:#bf616a;">root </span><span style="color:#d08770;">level</span><span>=&quot;</span><span style="color:#a3be8c;">INFO</span><span>&quot;&gt; +</span><span> &lt;</span><span style="color:#bf616a;">appender-ref </span><span style="color:#d08770;">ref</span><span>=&quot;</span><span style="color:#a3be8c;">STDOUT</span><span>&quot; /&gt; +</span><span> &lt;/</span><span style="color:#bf616a;">root</span><span>&gt; +</span><span>&lt;/</span><span style="color:#bf616a;">configuration</span><span>&gt; +</span></code></pre> +</div> +</div> +<p>What is lacking now is the logger and server instantiation in our <code>main</code> method. Adding it will finally complete our implementation:</p> +<div class="code-window"> + <div class="code-title" ><span + class="dot-red"></span> + <span class="dot-yellow"></span> + <span class="dot-green"></span> server.scala + </div> + <div class="code-body"><pre data-lang="scala" style="background-color:#2b303b;color:#c0c5ce;" class="language-scala "><code class="language-scala" data-lang="scala"><span style="color:#65737e;">//&gt; using scala &quot;3.2.1&quot; +</span><span style="color:#65737e;">//&gt; using resourceDir &quot;.&quot; +</span><span style="color:#65737e;">//&gt; using packaging.packageType &quot;assembly&quot; +</span><span style="color:#65737e;">//&gt; using lib &quot;org.http4s::http4s-ember-server::0.23.17&quot; +</span><span style="color:#65737e;">//&gt; using lib &quot;org.http4s::http4s-dsl::0.23.17&quot; +</span><span style="color:#65737e;">//&gt; using lib &quot;com.monovore::decline-effect::2.4.1&quot; +</span><span style="color:#65737e;">//&gt; using lib &quot;ch.qos.logback:logback-classic:1.4.5&quot; +</span><span> +</span><span style="color:#b48ead;">import </span><span>cats.effect.{</span><span style="color:#ebcb8b;">ExitCode</span><span>, </span><span style="color:#ebcb8b;">IO</span><span>} +</span><span style="color:#b48ead;">import </span><span>cats.effect.kernel.</span><span style="color:#ebcb8b;">Async +</span><span style="color:#b48ead;">import </span><span>cats.syntax.all.* +</span><span style="color:#b48ead;">import </span><span>com.comcast.ip4s.{ipv4, port} +</span><span style="color:#b48ead;">import </span><span>com.monovore.decline.</span><span style="color:#ebcb8b;">Opts +</span><span style="color:#b48ead;">import </span><span>com.monovore.decline.effect.</span><span style="color:#ebcb8b;">CommandIOApp +</span><span style="color:#b48ead;">import </span><span>org.http4s.{</span><span style="color:#ebcb8b;">HttpRoutes</span><span>, </span><span style="color:#ebcb8b;">MediaType</span><span>, </span><span style="color:#ebcb8b;">Response</span><span>, </span><span style="color:#ebcb8b;">Status</span><span>, </span><span style="color:#ebcb8b;">Uri</span><span>} +</span><span style="color:#b48ead;">import </span><span>org.http4s.dsl.io.* +</span><span style="color:#b48ead;">import </span><span>org.http4s.ember.server.</span><span style="color:#ebcb8b;">EmberServerBuilder +</span><span style="color:#b48ead;">import </span><span>org.http4s.headers.`Content-Type` +</span><span style="color:#b48ead;">import </span><span>org.http4s.server.middleware.</span><span style="color:#ebcb8b;">CORS +</span><span style="color:#b48ead;">import </span><span>org.typelevel.log4cats.</span><span style="color:#ebcb8b;">Logger +</span><span style="color:#b48ead;">import </span><span>org.typelevel.log4cats.slf4j.* +</span><span> +</span><span style="color:#b48ead;">object </span><span style="color:#ebcb8b;">Server </span><span style="color:#b48ead;">extends </span><span style="color:#a3be8c;">CommandIOApp</span><span>(&quot;</span><span style="color:#a3be8c;">helloServer</span><span>&quot;, &quot;</span><span style="color:#a3be8c;">Titles you in HTML</span><span>&quot;) { +</span><span> +</span><span> </span><span style="color:#b48ead;">val </span><span style="color:#bf616a;">titleOpt</span><span>: </span><span style="color:#ebcb8b;">Opts</span><span>[</span><span style="color:#ebcb8b;">String</span><span>] = +</span><span> </span><span style="color:#ebcb8b;">Opts</span><span>.env[</span><span style="color:#ebcb8b;">String</span><span>](&quot;</span><span style="color:#a3be8c;">TITLE</span><span>&quot;, &quot;</span><span style="color:#a3be8c;">Page title</span><span>&quot;).withDefault(&quot;</span><span style="color:#a3be8c;">Hello</span><span>&quot;) +</span><span> +</span><span> </span><span style="color:#b48ead;">val </span><span style="color:#bf616a;">baseUrlOpt</span><span>: </span><span style="color:#ebcb8b;">Opts</span><span>[</span><span style="color:#ebcb8b;">Uri</span><span>] = </span><span style="color:#ebcb8b;">Opts +</span><span> .env[</span><span style="color:#ebcb8b;">String</span><span>](&quot;</span><span style="color:#a3be8c;">BASE_URL</span><span>&quot;, &quot;</span><span style="color:#a3be8c;">The base url</span><span>&quot;) +</span><span> .mapValidated( +</span><span> </span><span style="color:#ebcb8b;">Uri +</span><span> .fromString(_) +</span><span> .leftMap(_.message) +</span><span> .ensure(&quot;</span><span style="color:#a3be8c;">base url must be absolute</span><span>&quot;)(_.path.addEndsWithSlash.absolute) +</span><span> .map(uri =&gt; uri.withPath(uri.path.dropEndsWithSlash)) +</span><span> .toValidatedNel +</span><span> ) +</span><span> +</span><span> </span><span style="color:#b48ead;">def </span><span style="color:#8fa1b3;">page</span><span>(</span><span style="color:#bf616a;">uri</span><span>: </span><span style="color:#ebcb8b;">Uri</span><span>, </span><span style="color:#bf616a;">title</span><span>: </span><span style="color:#ebcb8b;">String</span><span>): </span><span style="color:#ebcb8b;">String </span><span>= +</span><span> </span><span style="color:#b48ead;">s</span><span>&quot;&quot;&quot;</span><span style="color:#a3be8c;">|&lt;html&gt; +</span><span style="color:#a3be8c;"> |&lt;head&gt;&lt;title&gt;</span><span>$title</span><span style="color:#a3be8c;">&lt;/title&gt;&lt;/head&gt; +</span><span style="color:#a3be8c;"> |&lt;body&gt;Hello from </span><span>${uri.toString}</span><span style="color:#a3be8c;">&lt;/body&gt; +</span><span style="color:#a3be8c;"> |&lt;/html&gt;</span><span>&quot;&quot;&quot;.stripMargin +</span><span> +</span><span> </span><span style="color:#b48ead;">def </span><span style="color:#8fa1b3;">routes</span><span>[</span><span style="color:#ebcb8b;">F</span><span>[_]: </span><span style="color:#ebcb8b;">Async</span><span>: </span><span style="color:#ebcb8b;">Logger</span><span>](</span><span style="color:#bf616a;">baseUrl</span><span>: </span><span style="color:#ebcb8b;">Uri</span><span>, </span><span style="color:#bf616a;">title</span><span>: </span><span style="color:#ebcb8b;">String</span><span>): </span><span style="color:#ebcb8b;">HttpRoutes</span><span>[</span><span style="color:#ebcb8b;">F</span><span>] = +</span><span> </span><span style="color:#ebcb8b;">HttpRoutes</span><span>.of[</span><span style="color:#ebcb8b;">F</span><span>] { +</span><span> </span><span style="color:#b48ead;">case </span><span style="color:#ebcb8b;">GET </span><span>-&gt; </span><span style="color:#ebcb8b;">Root </span><span>/ &quot;</span><span style="color:#a3be8c;">health</span><span>&quot; =&gt; </span><span style="color:#ebcb8b;">Response</span><span>[</span><span style="color:#ebcb8b;">F</span><span>](</span><span style="color:#ebcb8b;">Status</span><span>.</span><span style="color:#ebcb8b;">Ok</span><span>).pure[</span><span style="color:#ebcb8b;">F</span><span>] +</span><span> </span><span style="color:#b48ead;">case </span><span style="color:#ebcb8b;">GET </span><span>-&gt; path =&gt; +</span><span> </span><span style="color:#ebcb8b;">Logger</span><span>[</span><span style="color:#ebcb8b;">F</span><span>].info(</span><span style="color:#b48ead;">s</span><span>&quot;</span><span style="color:#a3be8c;">Serving </span><span>$path&quot;) &gt;&gt; +</span><span> </span><span style="color:#ebcb8b;">Response</span><span>[</span><span style="color:#ebcb8b;">F</span><span>](</span><span style="color:#ebcb8b;">Status</span><span>.</span><span style="color:#ebcb8b;">Ok</span><span>) +</span><span> .withEntity(page(baseUrl.withPath(baseUrl.path.merge(path)), title)) +</span><span> .withContentType(`Content-Type`(</span><span style="color:#ebcb8b;">MediaType</span><span>.text.html)) +</span><span> .pure[</span><span style="color:#ebcb8b;">F</span><span>] +</span><span> } +</span><span> +</span><span> </span><span style="color:#b48ead;">def </span><span style="color:#8fa1b3;">main</span><span>: </span><span style="color:#ebcb8b;">Opts</span><span>[</span><span style="color:#ebcb8b;">IO</span><span>[</span><span style="color:#ebcb8b;">ExitCode</span><span>]] = (baseUrlOpt, titleOpt).mapN((baseUrl, title) =&gt; +</span><span> </span><span style="color:#b48ead;">for </span><span>{ +</span><span> </span><span style="color:#b48ead;">given </span><span style="color:#ebcb8b;">Logger</span><span>[</span><span style="color:#ebcb8b;">IO</span><span>] &lt;- </span><span style="color:#ebcb8b;">Slf4jFactory</span><span>.create[</span><span style="color:#ebcb8b;">IO</span><span>] +</span><span> exitCode &lt;- </span><span style="color:#ebcb8b;">EmberServerBuilder +</span><span> .default[</span><span style="color:#ebcb8b;">IO</span><span>] +</span><span> .withHttp2 +</span><span> .withHost(ipv4&quot;</span><span style="color:#a3be8c;">0.0.0.0</span><span>&quot;) +</span><span> .withPort(</span><span style="color:#b48ead;">port</span><span>&quot;</span><span style="color:#a3be8c;">8080</span><span>&quot;) +</span><span> .withHttpApp( +</span><span> </span><span style="color:#ebcb8b;">CORS</span><span>.policy.withAllowOriginAll(routes[</span><span style="color:#ebcb8b;">IO</span><span>](baseUrl, title)).orNotFound +</span><span> ) +</span><span> .build +</span><span> .useForever +</span><span> .as(</span><span style="color:#ebcb8b;">ExitCode</span><span>.</span><span style="color:#ebcb8b;">Success</span><span>) +</span><span> } </span><span style="color:#b48ead;">yield</span><span> exitCode +</span><span> ) +</span><span>} +</span></code></pre> +</div> +</div> +<p>We added <code>using resourceDir &quot;.&quot;</code> to make the file <code>logback.xml</code> discoverable by logback and <code>using packaging.packageType &quot;assembly&quot;</code> to pack our server with all its dependencies to avoid downloading them at every boot.</p> +<p>We can now perform a test running the server locally and visiting <code>localhost:8080/foo</code>:</p> +<pre data-lang="cli" style="background-color:#2b303b;color:#c0c5ce;" class="language-cli "><code class="language-cli" data-lang="cli"><span style="color:#a3be8c;">$ BASE_URL=&quot;https://toniogela.dev&quot; scala-cli run . +</span><span>2023-01-07 23:46:39,183 [INFO] Server: Serving /foo/ +</span></code></pre> +<p style="text-align:center;line-height:0"> + <img src="local-test.webp" style="width:50%; + border-radius:0.5rem; + " alt=""> +</p><h3 id="packing-the-server-as-a-docker-application"><a class="anchor" href="#packing-the-server-as-a-docker-application">Packing the server as a docker application</a></h3> +<p>Last but not least, since fly.io accepts <a href="https://fly.io/docs/reference/builders/#image">already-built Docker images</a> to run, we should pack our application in a container. Luckily for us, scala-cli can directly package our server as a docker image <a href="https://scala-cli.virtuslab.org/docs/commands/package#building-docker-container-from-base-image">using a custom base image</a>:</p> +<pre data-lang="cli" style="background-color:#2b303b;color:#c0c5ce;" class="language-cli "><code class="language-cli" data-lang="cli"><span style="color:#a3be8c;">$ scala-cli package server.scala --docker --docker-image-repository hello-server --docker-image-tag 0.1.0 --docker-from eclipse-temurin:11.0.17_8-jre-alpine +</span><span>Compiling project (Scala 3.2.1, JVM) +</span><span>Compiled project (Scala 3.2.1, JVM) +</span><span>Started building docker image with your application, it might take some time +</span><span>Built docker image, run it with +</span><span> docker run hello-server:0.1.0 +</span><span style="color:#a3be8c;">$ docker run -e BASE_URL=&quot;https://toniogela.dev&quot; -p8080:8080 hello-server:0.1.0 +</span><span>2023-01-07 23:06:30,524 [INFO] Server: Serving /foo/ciao +</span><span>2023-01-07 23:06:30,866 [INFO] Server: Serving /favicon.ico +</span></code></pre> +<p>Since we'll need to rebuild the app again and the command is quite long, we'll write down a <code>Justfile</code> for ease:</p> +<div class="code-window"> + <div class="code-title" style="background-color:#6d98ba;color:black;" ><span + class="dot-red"></span> + <span class="dot-yellow"></span> + <span class="dot-green"></span> Justfile + </div> + <div class="code-body"><pre data-lang="just" style="background-color:#2b303b;color:#c0c5ce;" class="language-just "><code class="language-just" data-lang="just"><span style="color:#bf616a;">docker_image_name </span><span>:= &quot;</span><span style="color:#a3be8c;">hello-server</span><span>&quot; +</span><span style="color:#bf616a;">docker_image_tag </span><span>:= &quot;</span><span style="color:#a3be8c;">0.1.0</span><span>&quot; +</span><span style="color:#bf616a;">base_image </span><span>:= &quot;</span><span style="color:#a3be8c;">eclipse-temurin:11.0.17_8-jre-alpine</span><span>&quot; +</span><span> +</span><span style="color:#8fa1b3;">_default</span><span>: +</span><span> </span><span style="color:#b48ead;">@</span><span>just --list --unsorted +</span><span> +</span><span style="color:#65737e;"># Runs the app on localhost:8080 +</span><span style="color:#8fa1b3;">run</span><span>: +</span><span> BASE_URL=&quot;</span><span style="color:#a3be8c;">https://hello.toniogela.dev</span><span>&quot; scala-cli run . +</span><span> +</span><span style="color:#65737e;"># Build the docker image +</span><span style="color:#8fa1b3;">build</span><span>: +</span><span> scala-cli package server.scala --docker \ +</span><span> --docker-image-repository {{</span><span style="color:#bf616a;">docker_image_name</span><span>}} \ +</span><span> --docker-image-tag {{</span><span style="color:#bf616a;">docker_image_tag</span><span>}} \ +</span><span> --docker-from {{</span><span style="color:#bf616a;">base_image</span><span>}} +</span></code></pre> +</div> +</div> +<p>Now rebuilding the app is as simple as running <code>just build</code></p> +<pre data-lang="cli" style="background-color:#2b303b;color:#c0c5ce;" class="language-cli "><code class="language-cli" data-lang="cli"><span style="color:#a3be8c;">$ just +</span><span>Available recipes: +</span><span> run </span><span style="color:#65737e;"># Runs the app on localhost:8080 +</span><span> build </span><span style="color:#65737e;"># Build the docker image +</span><span style="color:#a3be8c;">$ just build +</span><span>scala-cli package server.scala --docker --docker-image-repository hello-server --docker-image-tag 0.1.0 --docker-from eclipse-temurin:11.0.17_8-jre-alpine +</span><span>Compiling project (Scala 3.2.1, JVM) +</span><span>Compiled project (Scala 3.2.1, JVM) +</span><span>Started building docker image with your application, it might take some time +</span><span>Built docker image, run it with +</span><span> docker run hello-server:0.1.0 +</span></code></pre> +<h2 id="deploying-the-server-or-fly-io"><a class="anchor" href="#deploying-the-server-or-fly-io">Deploying the server or fly.io</a></h2> +<p>Fly has a free <a href="https://fly.io/docs/about/pricing/#free-allowances">Hobby Plan</a> that includes: </p> +<ul> +<li>3 shared-cpu-1x with 256mb of RAM</li> +<li>3GB persistent volume storage (in total)</li> +<li>160GB outbound data transfer</li> +</ul> +<p>So it's perfectly feasible for small apps like the one we're going to deploy, plus it automatically produces for free <a href="https://fly.io/docs/about/pricing/#managed-ssl-certificates">the first ten</a> single-hostname HTTPS certificates using <a href="https://letsencrypt.org/">Let's Encrypt</a>. Last but not least, fly.io offers <a href="https://fly.io/docs/postgres/">Fly Postgres</a> to help you bootstrap and manage a database cluster for your apps. It's important to know that <a href="https://fly.io/docs/postgres/getting-started/what-you-should-know/">it's not a fully managed database</a> like in other platforms.</p> +<p>Creating our app is as simple as launching a command:</p> +<pre data-lang="cli" style="background-color:#2b303b;color:#c0c5ce;" class="language-cli "><code class="language-cli" data-lang="cli"><span style="color:#a3be8c;">$ fly launch --image hello-server:0.1.0 +</span><span>Creating app in /Users/toniogela/repo/personal/helloServer +</span><span>Using image hello-server:0.1.0 +</span><span>? Choose an app name (leave blank to generate one): hello-toniogela +</span><span>? Choose a region for deployment: Frankfurt, Germany (fra) +</span><span>Admin URL: https://fly.io/apps/hello-toniogela +</span><span>Hostname: hello-toniogela.fly.dev +</span><span>Wrote config file fly.toml +</span><span>? Would you like to set up a Postgresql database now? No +</span><span>? Would you like to set up an Upstash Redis database now? No +</span><span>? Would you like to deploy now? No +</span><span>Your app is ready! Deploy with `flyctl deploy +</span></code></pre> +<p>One of the side effects of the last command execution is that <code>fly.toml</code> configuration file for our application gets generated. The default settings are usually fine, but we need at least to add under <code>env</code> our mandatory variable <code>BASE_URL</code>. </p> +<p>I removed the <code>[[services.tcp_checks]]</code> in favour of a <code>[[services.http_checks]]</code> that calls our health check API, increased some concurrency limits and <strong>forced HTTPS traffic</strong>, all by following the <a href="https://fly.io/docs/reference/configuration">configuration reference</a>.</p> +<div class="code-window"> + <div class="code-title" style="background-color:#a3be8c;color:#1a2539;" ><span + class="dot-red"></span> + <span class="dot-yellow"></span> + <span class="dot-green"></span> fly.toml + </div> + <div class="code-body"><pre data-lang="toml" style="background-color:#2b303b;color:#c0c5ce;" class="language-toml "><code class="language-toml" data-lang="toml"><span style="color:#bf616a;">app </span><span>= &quot;</span><span style="color:#a3be8c;">hello-toniogela</span><span>&quot; +</span><span style="color:#bf616a;">kill_signal </span><span>= &quot;</span><span style="color:#a3be8c;">SIGINT</span><span>&quot; +</span><span style="color:#bf616a;">kill_timeout </span><span>= </span><span style="color:#d08770;">120 +</span><span> +</span><span>[env] +</span><span> </span><span style="color:#bf616a;">BASE_URL </span><span>= &quot;</span><span style="color:#a3be8c;">https://hello.toniogela.dev</span><span>&quot; +</span><span> +</span><span>[build] +</span><span> </span><span style="color:#bf616a;">image </span><span>= &quot;</span><span style="color:#a3be8c;">hello-server:0.1.0</span><span>&quot; +</span><span> +</span><span>[[services]] +</span><span> </span><span style="color:#bf616a;">internal_port </span><span>= </span><span style="color:#d08770;">8080 +</span><span> </span><span style="color:#bf616a;">processes </span><span>= [&quot;</span><span style="color:#a3be8c;">app</span><span>&quot;] +</span><span> </span><span style="color:#bf616a;">protocol </span><span>= &quot;</span><span style="color:#a3be8c;">tcp</span><span>&quot; +</span><span> +</span><span> [services.concurrency] +</span><span> </span><span style="color:#bf616a;">hard_limit </span><span>= </span><span style="color:#d08770;">500 +</span><span> </span><span style="color:#bf616a;">soft_limit </span><span>= </span><span style="color:#d08770;">250 +</span><span> </span><span style="color:#bf616a;">type </span><span>= &quot;</span><span style="color:#a3be8c;">requests</span><span>&quot; +</span><span> +</span><span> [[services.ports]] +</span><span> </span><span style="color:#bf616a;">force_https </span><span>= </span><span style="color:#d08770;">true +</span><span> </span><span style="color:#bf616a;">handlers </span><span>= [&quot;</span><span style="color:#a3be8c;">http</span><span>&quot;] +</span><span> </span><span style="color:#bf616a;">port </span><span>= </span><span style="color:#d08770;">80 +</span><span> +</span><span> [[services.ports]] +</span><span> </span><span style="color:#bf616a;">handlers </span><span>= [&quot;</span><span style="color:#a3be8c;">tls</span><span>&quot;, &quot;</span><span style="color:#a3be8c;">http</span><span>&quot;] +</span><span> </span><span style="color:#bf616a;">port </span><span>= </span><span style="color:#d08770;">443 +</span><span> +</span><span> [[services.http_checks]] +</span><span> </span><span style="color:#bf616a;">grace_period </span><span>= &quot;</span><span style="color:#a3be8c;">10s</span><span>&quot; +</span><span> </span><span style="color:#bf616a;">interval </span><span>= &quot;</span><span style="color:#a3be8c;">5s</span><span>&quot; +</span><span> </span><span style="color:#bf616a;">method </span><span>= &quot;</span><span style="color:#a3be8c;">get</span><span>&quot; +</span><span> </span><span style="color:#bf616a;">path </span><span>= &quot;</span><span style="color:#a3be8c;">/health</span><span>&quot; +</span><span> </span><span style="color:#bf616a;">protocol </span><span>= &quot;</span><span style="color:#a3be8c;">http</span><span>&quot; +</span><span> </span><span style="color:#bf616a;">restart_limit </span><span>= </span><span style="color:#d08770;">5 +</span><span> </span><span style="color:#bf616a;">timeout </span><span>= &quot;</span><span style="color:#a3be8c;">2s</span><span>&quot; +</span></code></pre> +</div> +</div> +<p>Even deploying is just a matter of running a single command:</p> +<pre data-lang="cli" style="background-color:#2b303b;color:#c0c5ce;" class="language-cli "><code class="language-cli" data-lang="cli"><span style="color:#a3be8c;">$ fly deploy --local-only +</span><span>==&gt; Verifying app config +</span><span>--&gt; Verified app config +</span><span>==&gt; Building image +</span><span>Searching for image &#39;hello-server:0.1.0&#39; locally... +</span><span>image found: sha256:9ffc712f96bb61eae722619ad0bd21a752e39b2a0cceca1abdb510bec18820cf +</span><span>==&gt; Pushing image to fly +</span><span>The push refers to repository [registry.fly.io/hello-toniogela] +</span><span>6edf61a11a72: Pushed +</span><span>d5ee5e28f5b5: Pushed +</span><span>688df10214b7: Pushed +</span><span>5ab3fbcbc72f: Pushed +</span><span>ded7a220bb05: Pushed +</span><span>deployment-01GP7936X7ZMX5VXDS2MYM1C9D: digest: sha256:99b04cf901b057a10f2526e6f973285ffb09777e497cd6abd6d96c6cd73a6114 size: 1371 +</span><span>--&gt; Pushing image done +</span><span>==&gt; Creating release +</span><span>--&gt; release v2 created +</span><span> +</span><span>--&gt; You can detach the terminal anytime without stopping the deployment +</span><span>==&gt; Monitoring deployment +</span><span>Logs: https://fly.io/apps/hello-toniogela/monitoring +</span><span> +</span><span> 1 desired, 1 placed, 1 healthy, 0 unhealthy [health checks: 1 total, 1 passing] +</span><span>--&gt; v0 deployed successfully +</span></code></pre> +<p>The <code>--local-only</code> flag was used to perform the build only locally using the local docker daemon and pushing the previously built image. We can now check that our app is reachable under <code>https://{appName}.fly.dev</code>:</p> +<p style="text-align:center;line-height:0"> + <img src="fly-domain.webp" style="width:50%; + border-radius:0.5rem; + " alt=""> +</p><h3 id="secrets"><a class="anchor" href="#secrets">Secrets</a></h3> +<p>Fly supports secret environment variables, and they can be easily set from the command line, triggering a redeploy:</p> +<pre data-lang="cli" style="background-color:#2b303b;color:#c0c5ce;" class="language-cli "><code class="language-cli" data-lang="cli"><span style="color:#a3be8c;">$ fly secrets set TITLE=&quot;Mommy I&#39;m online&quot; +</span><span>Release v1 created +</span><span>==&gt; Monitoring deployment +</span><span>Logs: https://fly.io/apps/hello-toniogela/monitoring +</span><span> +</span><span> 1 desired, 1 placed, 1 healthy, 0 unhealthy [health checks: 1 total, 1 passing] +</span><span>--&gt; v1 deployed successfully +</span></code></pre> +<p style="text-align:center;line-height:0"> + <img src="custom-title.webp" style="width:50%; + border-radius:0.5rem; + " alt=""> +</p> +<p>We can save these commands for later reuse in our <code>Justfile</code>, using dependencies between recipes and default arguments:</p> +<pre data-lang="just" style="background-color:#2b303b;color:#c0c5ce;" class="language-just "><code class="language-just" data-lang="just"><span style="color:#65737e;"># Deploys on fly.io +</span><span style="color:#8fa1b3;">deploy</span><span>: build +</span><span> flyctl deploy --local-only +</span><span> +</span><span style="color:#65737e;"># Changes the TITLE secret on fly.io +</span><span style="color:#8fa1b3;">title</span><span> label=&quot;Hello&quot;: +</span><span> flyctl secrets set TITLE=&quot;{{</span><span style="color:#bf616a;">label</span><span>}}&quot; +</span><span> +</span><span style="color:#65737e;"># Opens the web UI of fly.io +</span><span style="color:#8fa1b3;">open</span><span>: +</span><span> open &quot;</span><span style="color:#a3be8c;">https://fly.io/apps/hello-toniogela/</span><span>&quot; +</span></code></pre> +<h2 id="adding-certificates-and-publishing-on-our-domain"><a class="anchor" href="#adding-certificates-and-publishing-on-our-domain">Adding certificates and publishing on our domain</a></h2> +<p>Now that we confirmed that the server is up and running, it's time to make fly.io generate an HTTPS certificate and configure the DNS to expose the app on our domain. By default, fly.io assigns to every new app a shared ipv4 and a dedicated ipv6. This is due to a popularity increase and a global IPv4 scarcity, as <a href="https://community.fly.io/t/announcement-shared-anycast-ipv4/9384">announced on the Fly.io blog</a>.</p> +<p>If we still desire a dedicated IPv4, i.e. for using an A record in our DNS server, we can allocate one:</p> +<pre data-lang="cli" style="background-color:#2b303b;color:#c0c5ce;" class="language-cli "><code class="language-cli" data-lang="cli"><span style="color:#a3be8c;">$ fly ips allocate-v4 +</span><span>VERSION IP TYPE REGION CREATED AT +</span><span>v4 137.66.63.249 public global 7s ago +</span></code></pre> +<p>To generate an HTTPS certificate, we can always use the command line:</p> +<pre data-lang="cli" style="background-color:#2b303b;color:#c0c5ce;" class="language-cli "><code class="language-cli" data-lang="cli"><span style="color:#a3be8c;">$ fly certs add hello.toniogela.dev +</span><span>You are creating a certificate for hello.toniogela.dev +</span><span>We are using Let&#39;s Encrypt for this certificate. +</span><span> +</span><span>You can configure your DNS for hello.toniogela.dev by: +</span><span> +</span><span>1: Adding an CNAME record to your DNS service which reads: +</span><span> +</span><span> CNAME hello. hello-toniogela.fly.dev +</span></code></pre> +<p>To speed up the certificate creation, we can visit the dedicated section on our app dashboard and follow the instructions to confirm the domain ownership:</p> +<p style="text-align:center;line-height:0"> + <img src="certificates-instruction.webp" style="width:90%; + border-radius:0.5rem; + " alt=""> +</p> +<p>and setup at our domain's vendor the DNS records as requested:</p> +<p style="text-align:center;line-height:0"> + <img src="google-domains.webp" style="width:90%; + border-radius:0.5rem; + " alt=""> +</p> +<p>After a few minutes, our DNS should be propagated. We can check the status via command line:</p> +<pre data-lang="cli" style="background-color:#2b303b;color:#c0c5ce;" class="language-cli "><code class="language-cli" data-lang="cli"><span style="color:#a3be8c;">$ fly certs check hello.toniogela.dev +</span><span>The certificate for hello.toniogela.dev has been issued. +</span><span>Hostname = hello.toniogela.dev +</span><span> +</span><span>DNS Provider = googledomains +</span><span> +</span><span>Certificate Authority = Let&#39;s Encrypt +</span><span> +</span><span>Issued = rsa,ecdsa +</span><span> +</span><span>Added to App = 10 minutes ago +</span><span> +</span><span>Source = fly +</span></code></pre> +<p>Now we can enjoy our app directly from our domain πŸŽ‰πŸŽ‰πŸŽ‰</p> +<p style="text-align:center;line-height:0"> + <img src="complete.webp" style="width:50%; + border-radius:0.5rem; + " alt=""> +</p><h2 id="conclusions"><a class="anchor" href="#conclusions">Conclusions</a></h2> +<p>We saw how fast publishing a backend application on a custom domain can be following these instructions.</p> +<p>This article is not a comprehensive guide of either <a href="https://http4s.org/">http4s</a>, <a href="https://scala-cli.virtuslab.org/">scala-cli</a> or <a href="https://fly.io/">fly.io</a>, but rather a series of TODO steps that might come in handy when you want to prototype an idea and show it to someone else rapidly.</p> +<p>Enjoy!</p> + + + + Just use just + Wed, 02 Jun 2021 00:00:00 +0000 + Unknown + https://toniogela.dev/just/ + https://toniogela.dev/just/ + <p>OMG, the blog is live! 😱 And this is the first article! 😱</p> +<p>This first article will be about <a href="https://github.com/casey/just">Just</a> a <strong>command-line</strong> tool I recently discovered that immediately became essential in many work projects. Since it's a tool written in <strong>Rust</strong>, it's fast, it's well designed and documented, it features colored output, and it's an essential step in your terminal's <em>hypsterization</em> process!</p> +<p>Let's suppose you've just deployed your application via <code>scp</code> (<em>sigh!</em>) on one of your work's machines. Maybe your application was already built using tools like <a href="https://ben.kirw.in/decline/">Decline</a>, so it's already capable of parsing command-line options and flags and printing a complete help like:</p> +<pre data-lang="cli" style="background-color:#2b303b;color:#c0c5ce;" class="language-cli "><code class="language-cli" data-lang="cli"><span style="color:#a3be8c;">$ foo --help +</span><span>Usage: +</span><span> foo schedule +</span><span> foo encrypt +</span><span> foo decrypt +</span><span> +</span><span>foo tool, it can encrypt and decrypt files and schedule operations +</span><span> +</span><span>Options and flags: +</span><span> --help +</span><span> Display this help text. +</span><span> +</span><span>Subcommands: +</span><span> schedule +</span><span> schedules encryptions/decriptions +</span><span> encrypt +</span><span> encrypts files +</span><span> decrypt +</span><span> decrypts files +</span></code></pre> +<p>But let's add a <strong>slow-changing configuration</strong> to the scenario, which changes so often that it doesn't justify a refactor to add a library like <a href="https://cir.is/">Ciris</a> to your code. Maybe some <strong>non-power users</strong> need to change that configuration once a week or month <em>because of reasons</em>.</p> +<p>What's missing? Maybe there's a local MySql that needs to be queried for maintenance operations, or perhaps a remote database/storage/service/whatever that requires another command-line tool to be interacted with.</p> +<p>This is one of the times in which unmaintained, undocumented, faulty crap like <strong>maintenance_script.sh</strong> or <strong>fix_for_prod.sh</strong> begins to spread around. In no time, the situation will look similar to</p> +<pre data-lang="bash" style="background-color:#2b303b;color:#c0c5ce;" class="language-bash "><code class="language-bash" data-lang="bash"><span style="color:#bf616a;">/home/applicative_account/perform_operation.sh +</span><span style="color:#bf616a;">/home/colleague1/perform_operation_copy.sh +</span><span style="color:#bf616a;">/home/colleague1/old_version/perform_operation_as_root.sh +</span><span style="color:#bf616a;">/home/sre_guy/this_should_fix_everything.sh +</span><span style="color:#bf616a;">/home/random_data_scientist/do_not_run.sh </span><span style="color:#65737e;">#(ofc it was chmod +x) +</span></code></pre> +<p>90% of them will have the shebang <code>#!/bin/bash</code> while the 10% <code>#!/bin/sh</code>. Some of them will have <code>zsh</code> commands because there are people around that uses <code>zsh</code> (like me) that forgets that it doesn't share 100% of the syntax with <code>bash</code> (not like me, I swear).</p> +<p>Most of them will contain almost the same commands like </p> +<pre style="background-color:#2b303b;color:#c0c5ce;"><code><span>mysql prod_db &lt; maintenance.sql &gt; maintenance_output.dump +</span></code></pre> +<p>or templatized commands like </p> +<pre style="background-color:#2b303b;color:#c0c5ce;"><code><span>&quot;/foo-${VERSION}/bin/foo&quot; +</span></code></pre> +<p>that depend on environment variables defined in the <code>.profile</code> of a deleted user.</p> +<p>The last time you used <a href="https://www.shellcheck.net/">ShellCheck</a> to check the scripts, the linter exploded, and somewhere in the world, <a href="https://en.wikipedia.org/wiki/Stephen_R._Bourne">Stephen Bourne</a> suddenly began crying without any apparent reason.</p> +<h2 id="just-to-the-rescue"><a class="anchor" href="#just-to-the-rescue">Just to the rescue</a></h2> +<p>As its Github <a href="https://github.com/casey/just#just">README</a> states, Just <em>is a handy way to save and run project-specific commands</em> called <strong>recipes</strong>, stored in a file called <code>justfile</code> with a syntax inspired by <strong>Make</strong>.</p> +<p>Here's a tiny example:</p> +<pre data-lang="just" style="background-color:#2b303b;color:#c0c5ce;" class="language-just "><code class="language-just" data-lang="just"><span style="color:#8fa1b3;">build</span><span>: +</span><span> cc *.c -o main +</span><span> +</span><span style="color:#65737e;"># test everything +</span><span style="color:#8fa1b3;">test-all</span><span>: build +</span><span> ./test --all +</span><span> +</span><span style="color:#65737e;"># run a specific test +</span><span style="color:#8fa1b3;">test</span><span> TEST: build +</span><span> ./test --test {{</span><span style="color:#bf616a;">TEST</span><span>}} +</span></code></pre> +<p>Just searches for a <code>justfile</code> in the current directory written in its particular syntax, so let's begin creating one with an hello world recipe and let's try to run it:</p> +<pre data-lang="just" style="background-color:#2b303b;color:#c0c5ce;" class="language-just "><code class="language-just" data-lang="just"><span style="color:#8fa1b3;">hello-world</span><span>: +</span><span> echo &quot;</span><span style="color:#a3be8c;">Hello World!</span><span>&quot; +</span></code></pre> +<details> + <summary>output</summary> +<pre data-lang="cli" style="background-color:#2b303b;color:#c0c5ce;" class="language-cli "><code class="language-cli" data-lang="cli"><span style="color:#a3be8c;">$ just hello-world +</span><span>echo &quot;Hello World!&quot; +</span><span>Hello World! +</span></code></pre> +</details> +<p>As you can see, just <strong>shows the command</strong> that is about to run before running it, while we can't say the same for global or user-defined <code>alias</code>es in various shells (unless using something like <code>set -x</code> for bash). If you want to suppress this behaviour, you can put a <code>@</code> in front of the command to hide.</p> +<pre data-lang="just" style="background-color:#2b303b;color:#c0c5ce;" class="language-just "><code class="language-just" data-lang="just"><span style="color:#8fa1b3;">hello-world</span><span>: +</span><span> </span><span style="color:#b48ead;">@</span><span>echo &quot;</span><span style="color:#a3be8c;">Hello World!</span><span>&quot; +</span></code></pre> +<details> + <summary>output</summary> +<pre style="background-color:#2b303b;color:#c0c5ce;"><code><span>$ just hello-world +</span><span>Hello World! +</span></code></pre> +</details> +<p>Let's try to create a second recipe with an argument.</p> +<pre data-lang="just" style="background-color:#2b303b;color:#c0c5ce;" class="language-just "><code class="language-just" data-lang="just"><span style="color:#8fa1b3;">hello-world</span><span>: +</span><span> </span><span style="color:#b48ead;">@</span><span>echo &quot;</span><span style="color:#a3be8c;">Hello World!</span><span>&quot; +</span><span> +</span><span style="color:#8fa1b3;">salute</span><span> guy: +</span><span> </span><span style="color:#b48ead;">@</span><span>echo &quot;</span><span style="color:#a3be8c;">Hello </span><span>{{</span><span style="color:#bf616a;">guy</span><span>}}</span><span style="color:#a3be8c;">!</span><span>&quot; +</span></code></pre> +<details> + <summary>output</summary> +<pre data-lang="cli" style="background-color:#2b303b;color:#c0c5ce;" class="language-cli "><code class="language-cli" data-lang="cli"><span style="color:#a3be8c;">$ just salute +</span><span>error: Recipe `salute` got 0 arguments but takes 1 +</span><span>usage: +</span><span> just salute guy +</span><span> +</span><span style="color:#a3be8c;">$ just salute Tonio +</span><span>Hello Tonio! +</span><span> +</span><span style="color:#a3be8c;">$ just --dry-run salute Tonio +</span><span>echo &quot;Hello Tonio&quot; +</span></code></pre> +</details> +<p>The recipe cannot obviously run without an argument since that argument is referred to in the body of the recipe using just syntax <code>{{ argument_or_variable_name }}</code>. If you want to &quot;debug&quot; the recipe that will run with the provided arguments, you can use the <code>--dry-run</code> command-line flag. This can come in handy if a command is long and complex and you have, for example, to schedule it in your crontab file. Just copy it from there.</p> +<p>Arguments are really powerful since they can have <strong>default values</strong> and can be <strong>variadic</strong> (both in the form <code>zero or more</code> or <code>one or more</code>):</p> +<pre data-lang="just" style="background-color:#2b303b;color:#c0c5ce;" class="language-just "><code class="language-just" data-lang="just"><span style="color:#8fa1b3;">hello</span><span> target=&quot;World&quot;: +</span><span> </span><span style="color:#b48ead;">@</span><span>echo &quot;</span><span style="color:#a3be8c;">Hello </span><span>{{</span><span style="color:#bf616a;">target</span><span>}}</span><span style="color:#a3be8c;">!</span><span>&quot; +</span><span> +</span><span style="color:#8fa1b3;">hello-all</span><span> +targets=&quot;Tim&quot;: </span><span style="color:#65737e;"># One or more plus a default value +</span><span> </span><span style="color:#b48ead;">@</span><span>echo &quot;</span><span style="color:#a3be8c;">Hello to everyone: </span><span>{{</span><span style="color:#bf616a;">targets</span><span>}}</span><span style="color:#a3be8c;">!</span><span>&quot; +</span><span> +</span><span style="color:#8fa1b3;">hello-any</span><span> *targets: </span><span style="color:#65737e;"># Zero or more +</span><span> </span><span style="color:#b48ead;">@</span><span>echo &quot;</span><span style="color:#a3be8c;">Hello </span><span>{{</span><span style="color:#bf616a;">targets</span><span>}}</span><span style="color:#a3be8c;">!</span><span>&quot; +</span></code></pre> +<details> + <summary>output</summary> +<pre data-lang="cli" style="background-color:#2b303b;color:#c0c5ce;" class="language-cli "><code class="language-cli" data-lang="cli"><span style="color:#a3be8c;">$ just hello +</span><span>Hello World! +</span><span> +</span><span style="color:#a3be8c;">$ just hello-all +</span><span>Hello to everyone: Tim! +</span><span> +</span><span style="color:#a3be8c;">$ just hello-all &quot;Tim&quot; &quot;Martha&quot; &quot;Lisa&quot; +</span><span>Hello to everyone: Tim Martha Lisa! +</span><span> +</span><span style="color:#a3be8c;">$ just hello-any +</span><span>Hello ! +</span><span> +</span><span style="color:#a3be8c;">$ just hello-any &quot;Bob&quot; &quot;Lucas&quot; +</span><span>Hello Bob Lucas! +</span></code></pre> +</details> +<p>We know enough syntax. Let's try to build a meaningful example for our <strong>messed-up work machine</strong> and let's try new features <strong>just</strong> if we need them (no pun intended πŸ˜„).</p> +<h3 id="an-almost-working-example"><a class="anchor" href="#an-almost-working-example">An almost working example</a></h3> +<p>If we inspect the history of our machine, we'll notice that most of the commands are <code>foo</code> invocations with <code>nohup</code> and stdin and stderr redirection into a <code>.log</code> file. We should consider refactoring the application, removing all the <code>println</code>s to replace them with a <code>logger.info</code>, maybe using a logging framework that automatically handles log rotation and similar. </p> +<p>In the meantime, we can standardize how <code>foo</code> is called, how the outputs are redirected, and its execution detached to avoid interactive sessions that might early terminate if you close a terminal session.</p> +<pre data-lang="just" style="background-color:#2b303b;color:#c0c5ce;" class="language-just "><code class="language-just" data-lang="just"><span style="color:#bf616a;">foo_version </span><span>:= &quot;</span><span style="color:#a3be8c;">0.3.0</span><span>&quot; +</span><span style="color:#bf616a;">foo_executable </span><span>:= &quot;</span><span style="color:#a3be8c;">/home/power_user/foo-</span><span>&quot; + </span><span style="color:#bf616a;">foo_version </span><span>+ &quot;</span><span style="color:#a3be8c;">/bin/foo</span><span>&quot; +</span><span style="color:#bf616a;">conf_file </span><span>:= &quot;</span><span style="color:#a3be8c;">/home/power_user/foo.conf</span><span>&quot; +</span><span style="color:#bf616a;">log_file </span><span>:= &quot;</span><span style="color:#a3be8c;">/home/power_user/foo.log</span><span>&quot; +</span><span> +</span><span style="color:#65737e;"># encrypts &#39;target&#39; and detaches +</span><span style="color:#8fa1b3;">encrypt</span><span> target: +</span><span> nohup {{</span><span style="color:#bf616a;">foo_executable</span><span>}} &quot;</span><span style="color:#a3be8c;">encrypt</span><span>&quot; {{</span><span style="color:#bf616a;">target</span><span>}} {{</span><span style="color:#bf616a;">conf_file</span><span>}} &amp;&gt;&gt; {{</span><span style="color:#bf616a;">log_file</span><span>}} &amp; +</span><span> +</span><span style="color:#65737e;"># decrypts &#39;target&#39; and detaches +</span><span style="color:#8fa1b3;">decrypt</span><span> target: +</span><span> nohup {{</span><span style="color:#bf616a;">foo_executable</span><span>}} &quot;</span><span style="color:#a3be8c;">decrypt</span><span>&quot; {{</span><span style="color:#bf616a;">target</span><span>}} {{</span><span style="color:#bf616a;">conf_file</span><span>}} &amp;&gt;&gt; {{</span><span style="color:#bf616a;">log_file</span><span>}} &amp; +</span><span> +</span><span style="color:#65737e;"># schedules operations formatted like &#39;&lt;cron_expression&gt; &lt;decrypt|encrypt&gt; &lt;target&gt;&#39; +</span><span style="color:#8fa1b3;">schedule</span><span> operation: +</span><span> nohup {{</span><span style="color:#bf616a;">foo_executable</span><span>}} &quot;</span><span style="color:#a3be8c;">schedule</span><span>&quot; &quot;{{</span><span style="color:#bf616a;">operation</span><span>}}&quot; {{</span><span style="color:#bf616a;">conf_file</span><span>}} &amp;&gt;&gt; {{</span><span style="color:#bf616a;">log_file</span><span>}} &amp; +</span></code></pre> +<p>(Probably <code>nohup</code> + <code>&amp;</code> is overkilling, but who cares πŸ˜‡? )</p> +<p>That's better. We've used <strong>variables</strong> to avoid repetitions, templatized every recipe and added comments. It would be nice, though, to directly tail the <code>log_file</code> once a recipe is launched and avoid repetitions even more.</p> +<pre data-lang="just" style="background-color:#2b303b;color:#c0c5ce;" class="language-just "><code class="language-just" data-lang="just"><span style="color:#bf616a;">foo_version </span><span>:= &quot;</span><span style="color:#a3be8c;">0.3.0</span><span>&quot; +</span><span style="color:#bf616a;">foo_executable </span><span>:= &quot;</span><span style="color:#a3be8c;">/home/power_user/foo-</span><span>&quot; + </span><span style="color:#bf616a;">foo_version </span><span>+ &quot;</span><span style="color:#a3be8c;">/bin/foo</span><span>&quot; +</span><span style="color:#bf616a;">conf_file </span><span>:= &quot;</span><span style="color:#a3be8c;">/home/power_user/foo.conf</span><span>&quot; +</span><span style="color:#bf616a;">log_file </span><span>:= &quot;</span><span style="color:#a3be8c;">/home/power_user/foo.log</span><span>&quot; +</span><span> +</span><span style="color:#8fa1b3;">_default</span><span>: +</span><span> </span><span style="color:#b48ead;">@</span><span>just --list --unsorted +</span><span> +</span><span style="color:#65737e;"># encrypts &#39;target&#39; and detaches +</span><span style="color:#8fa1b3;">encrypt</span><span> target: +</span><span> </span><span style="color:#b48ead;">@</span><span>just _run_detached &quot;</span><span style="color:#a3be8c;">schedule</span><span>&quot; &quot;{{</span><span style="color:#bf616a;">target</span><span>}}&quot; +</span><span> </span><span style="color:#b48ead;">@</span><span>just tail +</span><span> +</span><span style="color:#65737e;"># decrypts &#39;target&#39; and detaches +</span><span style="color:#8fa1b3;">decrypt</span><span> target: +</span><span> </span><span style="color:#b48ead;">@</span><span>just _run_detached &quot;</span><span style="color:#a3be8c;">schedule</span><span>&quot; &quot;{{</span><span style="color:#bf616a;">target</span><span>}}&quot; +</span><span> </span><span style="color:#b48ead;">@</span><span>just tail +</span><span> +</span><span style="color:#65737e;"># schedules operations formatted like &#39;&lt;cron_expression&gt; &lt;decrypt|encrypt&gt; &lt;target&gt;&#39; +</span><span style="color:#8fa1b3;">schedule</span><span> operation: +</span><span> </span><span style="color:#b48ead;">@</span><span>just _run_detached &quot;</span><span style="color:#a3be8c;">schedule</span><span>&quot; &quot;{{</span><span style="color:#bf616a;">operation</span><span>}}&quot; +</span><span> </span><span style="color:#b48ead;">@</span><span>just tail 20 +</span><span> +</span><span style="color:#65737e;"># Follows the log file +</span><span style="color:#8fa1b3;">tail</span><span> n=&quot;200&quot;: +</span><span> tail -{{</span><span style="color:#bf616a;">n</span><span>}}f {{</span><span style="color:#bf616a;">log_file</span><span>}} +</span><span> +</span><span style="color:#8fa1b3;">_run_detached</span><span> command argument: +</span><span> nohup {{</span><span style="color:#bf616a;">foo_executable</span><span>}} {{</span><span style="color:#bf616a;">command</span><span>}} {{</span><span style="color:#bf616a;">argument</span><span>}} {{</span><span style="color:#bf616a;">conf_file</span><span>}} &amp;&gt;&gt; {{</span><span style="color:#bf616a;">log_file</span><span>}} &amp; +</span></code></pre> +<p>Nice, we've used many features of just, in particular recipes whose name begins with an underscore are called <em>hidden recipes</em>. Hidden means that if you run <code>just --list</code>, they won't get printed since they're meant to be used internally. A special recipe was used, the <code>default</code> one, that gets called if you prompt <code>just</code> without any recipe name. [EDIT] (Since the name is not precisely <code>default</code>, just runs the first recipe in the justfile, that has to be a recipe without arguments)</p> +<pre data-lang="cli" style="background-color:#2b303b;color:#c0c5ce;" class="language-cli "><code class="language-cli" data-lang="cli"><span style="color:#a3be8c;">$ just +</span><span>Available recipes: +</span><span> encrypt target </span><span style="color:#65737e;"># encrypts &#39;target&#39; and detaches +</span><span> decrypt target </span><span style="color:#65737e;"># decrypts &#39;target&#39; and detaches +</span><span> schedule operation </span><span style="color:#65737e;"># schedules operations formatted like &#39;&lt;cron_expression&gt; &lt;decrypt|encrypt&gt; &lt;target&gt;&#39; +</span><span> tail n=&quot;200&quot; </span><span style="color:#65737e;"># Follows the log file +</span></code></pre> +<p>Oh nice, the <strong>comments</strong> we wrote previously just became documentation! Plus, we called the <code>tail</code> recipe from others, letting <code>just encrypt &quot;something&quot;</code> resemble an interactive command.</p> +<p>Let's now set the same interpreter for all the recipes choosing from the <a href="https://github.com/casey/just#shell">available ones</a>: <code>set shell := [&quot;bash&quot;, &quot;-uc&quot;]</code>. This way, every recipe line will run in a newly spawned sub<code>shell</code>, <code>bash</code> in this case. If it feels like the way the shebang <code>#!/bin/bash</code> works, you're right.</p> +<p>In fact, it's possible to define <a href="https://github.com/casey/just#safer-bash-shebang-recipes">shebang recipes</a> to be able to use <a href="https://github.com/casey/just#setting-variables-in-a-recipe">local variables in recipes</a> but remember to add <code>set -euxo pipefail</code> like the documentation suggests if you're using Bash to maintain the fail-fast behaviour.</p> +<p>Mixing and stirring <em>commands</em>, <em>recipes</em>, <em>just features</em> you'll probably come up with something similar to this <strong>prod-like example</strong>:</p> +<div class="code-window"> + <div class="code-title" style="background-color:#6d98ba;color:black;" ><span + class="dot-red"></span> + <span class="dot-yellow"></span> + <span class="dot-green"></span> Justfile + </div> + <div class="code-body"><pre data-lang="just" style="background-color:#2b303b;color:#c0c5ce;" class="language-just "><code class="language-just" data-lang="just"><span style="color:#b48ead;">set </span><span>shell := [&quot;</span><span style="color:#a3be8c;">bash</span><span>&quot;, &quot;</span><span style="color:#a3be8c;">-uc</span><span>&quot;] +</span><span> +</span><span style="color:#65737e;"># Foo +</span><span style="color:#bf616a;">foo_version </span><span>:= &quot;</span><span style="color:#a3be8c;">0.3.0</span><span>&quot; +</span><span style="color:#bf616a;">foo_executable </span><span>:= &quot;</span><span style="color:#a3be8c;">/home/power_user/foo-</span><span>&quot; + </span><span style="color:#bf616a;">foo_version </span><span>+ &quot;</span><span style="color:#a3be8c;">/bin/foo</span><span>&quot; +</span><span style="color:#bf616a;">conf_file </span><span>:= &quot;</span><span style="color:#a3be8c;">/home/power_user/foo.conf</span><span>&quot; +</span><span style="color:#bf616a;">log_file </span><span>:= &quot;</span><span style="color:#a3be8c;">/home/power_user/foo.log</span><span>&quot; +</span><span> +</span><span style="color:#65737e;"># Bar +</span><span style="color:#bf616a;">bar_executable </span><span>:= &quot;</span><span style="color:#a3be8c;">/home/power_user/bar</span><span>&quot; +</span><span style="color:#bf616a;">sre_victim </span><span>:= &quot;</span><span style="color:#a3be8c;">baz@sre.com</span><span>&quot; +</span><span> +</span><span style="color:#65737e;"># MySql +</span><span style="color:#bf616a;">my_sql_default_user </span><span>:= &quot;</span><span style="color:#a3be8c;">random_guy</span><span>&quot; +</span><span style="color:#bf616a;">dump_query </span><span>:= &quot;</span><span style="color:#a3be8c;">select &#39;I have no intention to write queries in this example&#39;;</span><span>&quot; +</span><span style="color:#bf616a;">now </span><span>:= `</span><span style="color:#a3be8c;">date -u +&quot;%Y-%m-%dT%H:%M:%SZ&quot;</span><span>` +</span><span style="color:#bf616a;">mysql_output_file </span><span>:= &quot;</span><span style="color:#a3be8c;">/home/power_user/mysql_dumps/</span><span>&quot; + </span><span style="color:#bf616a;">now </span><span>+ &quot;</span><span style="color:#a3be8c;">.dump</span><span>&quot; +</span><span> +</span><span style="color:#65737e;"># Colors +</span><span style="color:#bf616a;">RED </span><span>:= &quot;</span><span style="color:#96b5b4;">\\</span><span style="color:#a3be8c;">u001b[31m</span><span>&quot; +</span><span style="color:#bf616a;">GREEN </span><span>:= &quot;</span><span style="color:#96b5b4;">\\</span><span style="color:#a3be8c;">u001b[32m</span><span>&quot; +</span><span style="color:#bf616a;">YELLOW </span><span>:= &quot;</span><span style="color:#96b5b4;">\\</span><span style="color:#a3be8c;">u001b[33m</span><span>&quot; +</span><span style="color:#bf616a;">BOLD </span><span>:= &quot;</span><span style="color:#96b5b4;">\\</span><span style="color:#a3be8c;">u001b[1m</span><span>&quot; +</span><span style="color:#bf616a;">RESET </span><span>:= &quot;</span><span style="color:#96b5b4;">\\</span><span style="color:#a3be8c;">u001b[0m</span><span>&quot; +</span><span> +</span><span style="color:#65737e;">## Foo Recipes +</span><span> +</span><span style="color:#8fa1b3;">_default</span><span>: +</span><span> </span><span style="color:#b48ead;">@</span><span>just --list --unsorted +</span><span> +</span><span style="color:#65737e;"># encrypts &#39;target&#39; and detaches +</span><span style="color:#8fa1b3;">encrypt</span><span> target: +</span><span> </span><span style="color:#b48ead;">@</span><span>just _run_detached &quot;</span><span style="color:#a3be8c;">schedule</span><span>&quot; &quot;{{</span><span style="color:#bf616a;">target</span><span>}}&quot; +</span><span> </span><span style="color:#b48ead;">@</span><span>just tail +</span><span> +</span><span style="color:#65737e;"># decrypts &#39;target&#39; and detaches +</span><span style="color:#8fa1b3;">decrypt</span><span> target: +</span><span> </span><span style="color:#b48ead;">@</span><span>just _run_detached &quot;</span><span style="color:#a3be8c;">schedule</span><span>&quot; &quot;{{</span><span style="color:#bf616a;">target</span><span>}}&quot; +</span><span> </span><span style="color:#b48ead;">@</span><span>just tail +</span><span> +</span><span style="color:#65737e;"># schedules operations formatted like &#39;&lt;cron_expression&gt; &lt;decrypt|encrypt&gt; &lt;target&gt;&#39; +</span><span style="color:#8fa1b3;">schedule</span><span> operation: +</span><span> </span><span style="color:#b48ead;">@</span><span>just _run_detached &quot;</span><span style="color:#a3be8c;">schedule</span><span>&quot; &quot;{{</span><span style="color:#bf616a;">operation</span><span>}}&quot; +</span><span> </span><span style="color:#b48ead;">@</span><span>just tail 20 +</span><span> +</span><span style="color:#65737e;"># Follows the log file +</span><span style="color:#8fa1b3;">tail</span><span> n=&quot;200&quot;: +</span><span> tail -{{</span><span style="color:#bf616a;">n</span><span>}}f {{</span><span style="color:#bf616a;">log_file</span><span>}} +</span><span> +</span><span style="color:#65737e;"># Unsurprisingly kills foo +</span><span style="color:#8fa1b3;">kill</span><span>: +</span><span> pgrep -f {{</span><span style="color:#bf616a;">foo_executable</span><span>}} +</span><span> +</span><span style="color:#65737e;">## Bar Recipes +</span><span> +</span><span style="color:#65737e;"># Will notify an SRE with a boring mail. +</span><span style="color:#8fa1b3;">notify</span><span>: +</span><span> </span><span style="color:#b48ead;">@</span><span>just _bold_squares &quot;{{</span><span style="color:#bf616a;">YELLOW</span><span>}}</span><span style="color:#a3be8c;">WARNING</span><span>&quot; +</span><span> </span><span style="color:#b48ead;">@</span><span>echo -e &quot;{{</span><span style="color:#bf616a;">BOLD</span><span>}}</span><span style="color:#a3be8c;"> A SRE will be notified with an e-mail!</span><span>{{</span><span style="color:#bf616a;">RESET</span><span>}}&quot; +</span><span> {{</span><span style="color:#bf616a;">bar_executable</span><span>}} notify {{</span><span style="color:#bf616a;">sre_victim</span><span>}} +</span><span> +</span><span style="color:#65737e;">## MySql Recipes +</span><span> +</span><span style="color:#65737e;"># runs the dump query +</span><span style="color:#8fa1b3;">dump</span><span> username password: +</span><span> </span><span style="color:#b48ead;">@</span><span>just kill +</span><span> </span><span style="color:#b48ead;">@</span><span>just _mysql_command_to {{</span><span style="color:#bf616a;">username</span><span>}} {{</span><span style="color:#bf616a;">password</span><span>}} {{</span><span style="color:#bf616a;">dump_query</span><span>}} &gt; {{</span><span style="color:#bf616a;">mysql_output_file</span><span>}} +</span><span> +</span><span style="color:#65737e;"># runs the dump query with default user +</span><span style="color:#8fa1b3;">dump-with-default-user</span><span> password: +</span><span> </span><span style="color:#b48ead;">@</span><span>just kill +</span><span> </span><span style="color:#b48ead;">@</span><span>just _mysql_command_to {{</span><span style="color:#bf616a;">my_sql_default_user</span><span>}} {{</span><span style="color:#bf616a;">password</span><span>}} {{</span><span style="color:#bf616a;">dump_query</span><span>}} &gt; {{</span><span style="color:#bf616a;">mysql_output_file</span><span>}} +</span><span> +</span><span style="color:#65737e;">## Hidden Recipes +</span><span> +</span><span style="color:#8fa1b3;">_bold_squares</span><span> message: +</span><span> </span><span style="color:#b48ead;">@</span><span>echo -e &quot;{{</span><span style="color:#bf616a;">BOLD</span><span>}}</span><span style="color:#a3be8c;">[</span><span>{{</span><span style="color:#bf616a;">RESET</span><span>}}{{</span><span style="color:#bf616a;">message</span><span>}}{{</span><span style="color:#bf616a;">RESET</span><span>}}{{</span><span style="color:#bf616a;">BOLD</span><span>}}</span><span style="color:#a3be8c;">]</span><span>{{</span><span style="color:#bf616a;">RESET</span><span>}}&quot; +</span><span> +</span><span style="color:#8fa1b3;">_mysql_command</span><span> username password query: +</span><span> mysql -u {{</span><span style="color:#bf616a;">username</span><span>}} -p {{</span><span style="color:#bf616a;">password</span><span>}} -e {{</span><span style="color:#bf616a;">query</span><span>}} +</span><span> +</span><span style="color:#8fa1b3;">_mysql_command_to</span><span> username password query output_file: +</span><span> _mysql_command {{</span><span style="color:#bf616a;">username</span><span>}} {{</span><span style="color:#bf616a;">password</span><span>}} {{</span><span style="color:#bf616a;">query</span><span>}} &gt; {{</span><span style="color:#bf616a;">output_file</span><span>}} +</span><span> +</span><span style="color:#8fa1b3;">_run_detached</span><span> command argument: +</span><span> nohup {{</span><span style="color:#bf616a;">foo_executable</span><span>}} {{</span><span style="color:#bf616a;">command</span><span>}} {{</span><span style="color:#bf616a;">argument</span><span>}} {{</span><span style="color:#bf616a;">conf_file</span><span>}} &amp;&gt;&gt; {{</span><span style="color:#bf616a;">log_file</span><span>}} &amp; +</span></code></pre> +</div> +</div> +<hr /> +<h2 id="it-s-not-enough-to-enforce-people-to-not-mess-up-production-machines-with-crappy-shell-scripts"><a class="anchor" href="#it-s-not-enough-to-enforce-people-to-not-mess-up-production-machines-with-crappy-shell-scripts">&quot;It's not enough to enforce people to not mess up production machines with crappy shell scripts!&quot;</a></h2> +<p>Obviously, <a href="https://github.com/casey/just">just</a> doesn't automatically solve every problem you might encounter in <strong>heavily unmaintained machines</strong> with a lot of conflicting shell scripts, mostly because of people, but at least:</p> +<ul> +<li>It lets you concentrate every <strong>project-related</strong> commands in a <strong>single file</strong> that can be easily tracked by a VCS to become part of the deployment</li> +<li>It <strong>declaratively</strong> sets the interpreter</li> +<li>It lets you you write a multi-command script without relying on super-verbose and tricky <code>match-case</code> bash syntax with the addition of: +<ul> +<li><strong>default arguments</strong></li> +<li><strong>easy string templating</strong></li> +<li><a href="https://github.com/casey/just#command-evaluation-using-backticks">command evaluation</a> using backticks (see the <code>now</code> variable in the previous example)</li> +<li><a href="https://github.com/casey/just#conditional-expressions">conditional expressions</a> that are evaluated before the command execution</li> +<li><code>get_or_else</code> syntax for <a href="https://github.com/casey/just#environment-variables">environment variables</a></li> +</ul> +</li> +<li>It integrates with <code>fzf</code> to <a href="https://github.com/casey/just#conditional-expressions">choose</a> argument-less recipes interactively</li> +<li>Recipes can depend on other recipes, like <code>tests</code> on <code>build</code> as in the <a href="https://toniogela.dev/just/#just-to-the-rescue">first example</a></li> +<li>It can generate its own shell completion scripts using <code>just --completions &lt;shell_name&gt;</code></li> +<li>It can be used as <a href="https://github.com/casey/just#just-scripts">an interpreter</a>, turning <code>justfile</code>s in runnable just script simply prepending <code>#!/usr/bin/env just --justfile</code> (This can be handy if you maybe want to use it with <code>crontab</code>)</li> +</ul> +<p>and <strong>HIPSTER ALERT</strong>:</p> +<ul> +<li>It has its own <a href="https://github.com/extractions/setup-just">Github Action</a></li> +<li><a href="https://github.com/casey/just#editor-support">Syntax Highlight</a> for Vim, Emacs and Visual Studio Code is already available +<ul> +<li>(I'll try to port it to a <code>sublime-syntax</code> to use it in this page with the <a href="https://www.getzola.org/documentation/content/syntax-highlighting/">syntax highlighting</a> system of Zola)</li> +<li><code>[EDIT]</code> I ported it! You can see it <a href="https://github.com/casey/just#sublime-text">here</a></li> +</ul> +</li> +</ul> +<p>Creating practical recipes, installing the prebuilt binaries, and the command-line completion scripts can probably convince people to use it. If not, try documenting your software, using examples in the <code>justfile</code> that's sitting in the home of the repo, or try harder using</p> +<pre style="background-color:#2b303b;color:#c0c5ce;"><code><span>********************************** +</span><span>* Run `just` for a complete list * +</span><span>* of available commands * +</span><span>********************************** +</span></code></pre> +<p>as the <code>/etc/motd</code> for the prod machines.</p> +<hr /> +<h5 id="more-tools"><a class="anchor" href="#more-tools">More Tools!</a></h5> +<p>In the following weeks, I'll try to write about other <strong>command-line</strong> tools I use every day (at least I'll try πŸ˜…), so follow me on <a href="https://twitter.com/toniogela">Twitter</a> to get updates or subscribe to <a href="https://toniogela.dev/atom.xml">RSS</a>.</p> +<p><code>[EDIT]</code> I've been mentioned by <a href="https://github.com/casey">@casey</a>, just's developer <a href="https://twitter.com/rodarmor/status/1402822761639153668">on twitter</a>. Thanks @casey!</p> +<details> + <summary>SPOILER: next tool</summary> +<a href="https://www.getzola.org/">Zola</a> : the templating engine I'm using for this blog :) +</details> + + + + Hello World + Mon, 24 May 2021 00:00:00 +0000 + Unknown + https://toniogela.dev/hello-world/ + https://toniogela.dev/hello-world/ + <p>Hello, hooman; this is the very beginning of this blog. πŸŽ‰</p> +<p>There's still plenty of customisation and things to set up, <strike>like comments using <a href="https://utteranc.es/">Utterance</a></strike>. And don't look at the <strong>about me</strong> page: it's still a lorem ipsum.</p> +<p>This blog will be about <a href="https://www.scala-lang.org">Scala</a>, a bit of <a href="https://www.rust-lang.org/">Rust</a> (I'm in on its learning path), many command-line tools to automatise every aspect of a developer's life, <a href="https://typelevel.org/cats">cats</a> 🐱 in various forms and much other boring stuff. </p> +<p>Cheers!</p> +<p>[EDIT] The comment system now uses <a href="https://giscus.app/">Giscus</a> πŸ˜„</p> + + + + diff --git a/sitemap.xml b/sitemap.xml new file mode 100644 index 0000000..553535d --- /dev/null +++ b/sitemap.xml @@ -0,0 +1,32 @@ + + + + https://toniogela.dev/ + + + https://toniogela.dev/about/ + + + https://toniogela.dev/gh-action-in-scala/ + 2023-05-18 + + + https://toniogela.dev/hello-world/ + 2021-05-24 + + + https://toniogela.dev/http4s-on-fly-io/ + 2023-01-09 + + + https://toniogela.dev/just/ + 2021-06-02 + + + https://toniogela.dev/page/1/ + + + https://toniogela.dev/testing-typelevel-toolkit/ + 2023-10-04 + + diff --git a/testing-typelevel-toolkit/index.html b/testing-typelevel-toolkit/index.html new file mode 100644 index 0000000..09b8ca5 --- /dev/null +++ b/testing-typelevel-toolkit/index.html @@ -0,0 +1,564 @@ + + + + + + + + + + + + Integration testing the Typelevel toolkit | TonioGela's + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +

Integration testing the Typelevel toolkit

+ +
    +
  • + +
  • +
  • +
  • 2913 words
  • +
  • +
  • 15 min
  • +
+ +

The Typelevel toolkit is a metalibrary including some great libraries by Typelevel, that was created to speed up the development of cross-platform applications in Scala and that I happily maintain since its creation. It's the Typelevel's flavour of the official Scala Toolkit, a set of libraries to perform common programming tasks, that has its own section, full of examples, in the official Scala documentation.

+

One of the vaunts of the Typelevel's stack is the fact that (almost) every library is published for the all the three officially supported Scala platforms: JVM, JS and Native, and for this reason every library is heavily tested against every supported platform and Scala version, to ensure a near perfect cross-compatibility.

+

Since its creation the Typelevel toolkit was lacking any sort of testing, mainly due to the fact that it is a mere collection of already battle tested libraries, so why bothering writing tests for it? As this bug promptly reminded us, the main goal of the toolkit is to provide the most seamless experience while using scala-cli.

+

Ideally you should be able to write:

+
+
+ + helloWorld.scala +
+
//> using toolkit typelevel:latest
+
+import cats.effect.*
+
+object Hello extends IOApp.Simple:
+  def run = IO.println("Hello World!")
+
+
+
+

and calling scala-cli run {,--js,--native} helloWorld.scala should Just Workβ„’ printing "Hello World!" to the console.

+

To be 100% sure we needed CI tests indeed.

+

Planning the tests

+

What had to be tested though? All the included libraries are already tested, some of them are built using other included libraries, so some sort of cross testing was already done. What we were really interested in was always being sure that scala-cli is always able to compile scripts written using the toolkit. And what's the best way to ensure that scala-cli can compile a script written with the toolkit if not using scala-cli itself?

+

Pause for dramatic effect

+

The coarse idea that Arman and I had in mind was to have a CI doing the following:

+
    +
  • Locally publishing the toolkit artifact
  • +
  • Passing the artifact's version to a bunch of pre-baked parametrized scripts
  • +
  • Running the scripts with scala-cli
  • +
  • Be happy if every exit code is 0
  • +
+

The third step in particular could have been implemented in a couple of ways:

+
    +
  1. Installing scala-cli in the CI image via GitHub Actions, call it from the tests code, and gather the results
  2. +
  3. Since scala-cli is a native executable generated by GraalVM Native Image and the corresponding jvm artifact is distributed, using it as a dependency and calling its main method in the tests.
  4. +
+

We decided to follow the latter, as we didn't want to mangle the GitHub Actions CI file or relying on the timely publication of the updated scala-cli GitHub Action: whenever any continuous integration setting is changed, every developer should apply the same or an equivalent change to its local environment to reflect the testing/building remote environment change. This also means more testing/contributing documentation that needs to be constantly updated (and that risks becoming outdated at every CI setting changed) and that the contributing/developing curve becomes steeper for newcomers (it's easier to ask a Scala developer to have just one build tool installed locally, right?).

+

Also, sbt is a superb tool for implementing this kind of tests: since it downloads automatically the specified scala-cli artifact we didn't need to have scala-cli installed locally, the version we are testing in particular. The build would be more self-contained, the scala-cli artifact version will be managed as every other dependency by scala-steward and developers and contributors could test locally the repository with ease with a simple sbt test.

+
+

BONUS EXAMPLE: Using scala-cli in scala-cli to run a scala-cli script that runs itself +

+
+ + recursiveScalaCli.scala +
+
//> using dep org.virtuslab.scala-cli::cli::1.0.4
+
+import scala.cli.ScalaCli
+
+object ScalaCliApp extends App:
+    ScalaCli.main(Array("run", "recursiveScalaCli.scala"))
+
+
+

+
+

First tentative: using the dependency in tests

+

In order to publish the artifacts locally before testing we needed a new tests project and to establish this relationship:

+
+
+ + build.sbt +
+
//...
+lazy val root = tlCrossRootProject.aggregate(
+  toolkit, 
+  toolkitTest,
+  tests
+)
+//...
+lazy val tests = project
+  .in(file("tests"))
+  .settings(
+    name := "tests",
+    Test / test := (Test / test).dependsOn(toolkit.jvm / publishLocal).value
+  )
+//...
+
+
+
+

In this way the test sbt command will always run a publishLocal of the jvm flavor of the toolkit artifact. The project then needed to be set to not publish its artifact and to have some dependencies added to actually write the tests. The scala-cli dependency needed some trickery (.cross(CrossVersion.for2_13Use3)) to use the Scala 3 artifact, the only one published, in Scala 2.13 as well.

+
+
+ + build.sbt +
+
//...
+lazy val tests = project
+  .in(file("tests"))
+  .settings(
+    name := "tests",
+    Test / test := (Test / test).dependsOn(toolkit.jvm / publishLocal).value,
+    // Required to use the scala 3 artifact with scala 2.13
+    scalacOptions ++= {
+      if (scalaBinaryVersion.value == "2.13") Seq("-Ytasty-reader") else Nil
+    },
+    libraryDependencies ++= Seq(
+      "org.typelevel" %% "munit-cats-effect" % "2.0.0-M3" % Test,
+      // This is needed to write scripts' body into files
+      "co.fs2" %% "fs2-io" % "3.9.2" % Test,
+      "org.virtuslab.scala-cli" %% "cli" % "1.0.4" % Test cross (CrossVersion.for2_13Use3)
+    )
+  )
+  .enablePlugins(NoPublishPlugin)
+//...
+
+
+
+

The last bit needed was a way to add to the scripts' body which version of the artifact we were publishing right before the testing step and which Scala version we were running on, in order to test it properly. The only place were this (non-static) information was present was the build itself, but we needed to have them as an information in the source code. We definitively needed some sbt trickery to make it happen.

+
+

There is an unspoken rule about the Scala community (or in the sbt users community to be precise) that you may already know about:

+

If you need some kind of sbt trickery, eed3si9n probably wrote a sbt plugin for that.

+
+

This was our case with sbt-buildinfo, a sbt plugin whose punchline is "I know this because build.sbt knows this". As you'll discover later, sbt-buildinfo has been the corner stone of our second and more exhausting approach, but what briefly does is generating Scala source from your build definitions, and thus makes build information available in the source code too.

+

As scalaVersion and version are two information that are injected by default, we just needed to add the plugin into project/plugins.sbt and enabling it on tests in the build:

+
+
+ + projects/plugins.sbt +
+
//...
+addSbtPlugin("com.eed3si9n" % "sbt-buildinfo" % "0.11.0")
+
+
+
+
+ + build.sbt +
+
//...
+lazy val tests = project
+  .in(file("tests"))
+  .settings(
+    name := "tests",
+    Test / test := (Test / test).dependsOn(toolkit.jvm / publishLocal).value,
+    // Required to use the scala 3 artifact with scala 2.13
+    scalacOptions ++= {
+      if (scalaBinaryVersion.value == "2.13") Seq("-Ytasty-reader") else Nil
+    },
+    libraryDependencies ++= Seq(
+      "org.typelevel" %% "munit-cats-effect" % "2.0.0-M3" % Test,
+      // This is needed to write scripts' body into files
+      "co.fs2" %% "fs2-io" % "3.9.2" % Test,
+      "org.virtuslab.scala-cli" %% "cli" % "1.0.4" % Test cross (CrossVersion.for2_13Use3)
+    )
+  )
+  .enablePlugins(NoPublishPlugin, BuildInfoPlugin)
+//...
+
+
+
+

Time to write the tests! The first thing that was needed was a way to write on a temporary file the body of the script, including the artifact and Scala version, and then submit the file to scala-cli main method:

+
+
+ + ToolkitTests.scala +
+
package org.typelevel.toolkit
+
+import munit.CatsEffectSuite
+import cats.effect.IO
+import fs2.Stream
+import fs2.io.file.Files
+import scala.cli.ScalaCli
+import buildinfo.BuildInfo.{version, scalaVersion}
+
+class ToolkitCompilationTest extends CatsEffectSuite {
+
+  testRun("Toolkit should compile a simple Hello Cats Effect") {
+    s"""|import cats.effect._
+        |
+        |object Hello extends IOApp.Simple {
+        |  def run = IO.println("Hello toolkit!")
+        |}"""
+  }
+
+  // We'll describe this method in a later section of the post
+  def testRun(testName: String)(scriptBody: String): Unit = test(testName)(
+    Files[IO].tempFile(None, "", "-toolkit.scala", None)
+      .use { path =>
+          val header = List(
+            s"//> using scala ${BuildInfo.scalaVersion}",
+            s"//> using toolkit typelevel:${BuildInfo.version}",
+          ).mkString("", "\n", "\n")
+        Stream(header, scriptBody.stripMargin)
+          .through(Files[IO].writeUtf8(path))
+          .compile
+          .drain >> IO.delay(
+          ScalaCli.main(Array("run", path.toString))
+        )
+      }
+  )
+}
+
+
+
+

And with this easy and lean approach we were finally able to test the toolkit! πŸŽ‰πŸŽ‰πŸŽ‰

+

Another pause for dramatic effect

+

Except we weren't really testing everything: the js and native artifact weren't tested by this approach, as the tests project is a jvm only project depending on toolkit.jvm. Also, the toolkit-test artifact wasn't even taken in consideration. We needed a more general/agnostic solution.

+

Second approach: Invoking Java as an external process

+

The first tentative was good but not satisfying at all: we had to find a way to test the js and native artifacts too, but how? The scala-cli artifact is JVM Scala 3 only, and there's no way to use it as a dependency on other platforms. The only way to use it is just through the jvm, and that's precisely what we decided to do.

+

Given that:

+
    +
  • At least a JVM was present in the testing environment
  • +
  • fs2.io.process exposes a cross-platform way to launch and manage external processes
  • +
  • we had the scala-cli artifact on our classpath
  • +
+

we knew that was possible, there was just some sbt-fu needed.

+

The thing we needed to intelligently invoke was a mere java -cp <scala-cli + transitive deps classpath> scala.cli.ScalaCli, pass to it run <scriptFilename>.scala and wait for the exit code, for each (scalaVersion,platform) combination.

+

BuildInfo magic

+

To begin we had to transform the tests project in to a cross project (using sbt-crossproject, that is embedded in sbt-typelevel) and make every subproject test command depend on the publication of the respective artifacts:

+
+
+ + build.sbt +
+
//...
+lazy val tests = crossProject(JVMPlatform, JSPlatform, NativePlatform)
+  .in(file("tests"))
+  .settings(
+    name := "tests",
+    scalacOptions ++= {
+      if (scalaBinaryVersion.value == "2.13") Seq("-Ytasty-reader") else Nil
+    },
+    libraryDependencies ++= Seq(
+      "org.typelevel" %%% "munit-cats-effect" % "2.0.0-M3" % Test,
+      "co.fs2" %%% "fs2-io" % "3.9.2" % Test,
+      "org.virtuslab.scala-cli" %% "cli" % "1.0.4" cross (CrossVersion.for2_13Use3)
+    )
+  )
+  .jvmSettings(
+    Test / test := (Test / test).dependsOn(toolkit.jvm / publishLocal, toolkitTest.jvm / publishLocal).value
+  )
+  .jsSettings(
+    Test / test := (Test / test).dependsOn(toolkit.js / publishLocal, toolkitTest.js / publishLocal).value
+    scalaJSLinkerConfig ~= { _.withModuleKind(ModuleKind.CommonJSModule) }
+  )
+  .nativeSettings(
+    Test / test := (Test / test).dependsOn(toolkit.native / publishLocal, toolkitTest.native / publishLocal).value
+  )
+  .enablePlugins(BuildInfoPlugin, NoPublishPlugin)
+//...
+
+
+
+

One thing to note is that we deliberately made a "mistake". The munit-cats-effect and fs2-io dependencies are declared using %%% the operator that not only appends _${scalaBinaryVersion} to the end of the artifact name but also the platform name (appending i.e. for a Scala 3 native dependency _native0.4_3), but the scala-cli one was declared using just %% and the % Test modifier was removed. In this way we were sure that, for every platform, the Compile / dependencyClasspath would have included just the jvm version of scala-cli.

+

To inject the classpath into the source code we leveraged our beloved friend sbt-buildinfo, that it's not limited to inject just SettingKey[T]s and/or static information (computed at project load time), but using its own syntax can inject TaskKey[T]s after they've been evaluated (and re-evaluated each time at compile). So in the common .settings we added:

+
+
+ + build.sbt +
+
///...
+  buildInfoKeys += scalaBinaryVersion,
+  buildInfoKeys += BuildInfoKey.map(Compile / dependencyClasspath) {
+      case (_, v) =>
+        "classPath" -> v.seq
+          .map(_.data.getAbsolutePath)
+          .mkString(File.pathSeparator) // That's the way java -cp accepts classpath info
+    },
+    buildInfoKeys += BuildInfoKey.action("javaHome") {
+      val path = sys.env.get("JAVA_HOME").orElse(sys.props.get("java.home")).get
+      if (path.endsWith("/jre")) {
+        // handle JDK 8 installations
+        path.replace("/jre", "")
+      } else path
+    },
+    buildInfoKeys += "scala3" -> (scalaVersion.value.head == '3')
+///...
+
+
+
+

and in each platform specific section we added to buildInfo the platform's name:

+
+
+ + build.sbt +
+
//...
+  .jvmSettings(
+    //...
+    buildInfoKeys += "platform" -> "jvm"
+  )
+  .jsSettings(
+    //...
+    buildInfoKeys += "platform" -> "js",
+  )
+  .nativeSettings(
+    //...
+    buildInfoKeys += "platform" -> "native"
+  )
+//...
+
+
+
+

in this way we could leverage in our source code all the information required to run scala-cli and test our snippets:

+
private val classPath: String          = BuildInfo.classPath
+private val javaHome: String           = BuildInfo.javaHome
+private val platform: String           = BuildInfo.platform
+private val scalaBinaryVersion: String = BuildInfo.scalaBinaryVersion
+private val scala3: Boolean            = BuildInfo.scala3
+
+

Invoking Java via fs2 Process

+

Once we had all the required components, invoking java was easy, we just created and spawned a Process from the package fs2.io.process, that is implemented for every platform under the very same API:

+
+
+ + ScalaCliTest.scala +
+
import buildinfo.BuildInfo
+import cats.effect.kernel.Resource
+import cats.effect.std.Console
+import cats.effect.IO
+import cats.syntax.parallel.*
+import fs2.Stream
+import fs2.io.file.Files
+import fs2.io.process.ProcessBuilder
+import munit.Assertions.fail
+
+object ScalaCliProcess {
+
+  private def scalaCli(args: List[String]): IO[Unit] = ProcessBuilder(
+    s"${BuildInfo.javaHome}/bin/java",
+    args.prependedAll(List("-cp", BuildInfo.classPath, "scala.cli.ScalaCli"))
+  ).spawn[IO]
+    .use(process =>
+      (
+        process.exitValue,
+        process.stdout.through(fs2.text.utf8.decode).compile.string,
+        process.stderr.through(fs2.text.utf8.decode).compile.string
+      ).parFlatMapN {
+        case (0, _, _) => IO.unit
+        case (exitCode, stdout, stdErr) =>
+          IO.println(stdout) >> Console[IO].errorln(stdErr) >> IO.delay(
+            fail(s"Non zero exit code ($exitCode) for ${args.mkString(" ")}")
+          )
+      }
+    )
+
+  //..
+
+}
+
+
+
+

Let's dissect this function:

+
    +
  • ProcessBuilder constructor accepts a String command and a list of String arguments, it can then spawn the subprocess using .spawn[IO], that will return a Resource[IO, Process[IO]]. Resource is a really useful Cats Effect datatype that deserves its own post, but you can find some information in the official documentation.
  • +
  • The Process[IO] resource is used, and its exit code is gathered, in parallel, together with its stdout and stderr using parFlatMapN. This will prevent deadlocking, as we won't wait for a process' exit code without consuming its stdout and stderr streams.
  • +
  • Once we have the results, if the exit code is 0 we'll simply discard the content of the streams, otherwise we'll print everything that might be useful to debug possible errors, and we'll instruct our testing framework to fail with a specific message.
  • +
+

Now we needed a method to write in a temporary file the source of each scala-cli script with all the information needed to correctly test the toolkit. Luckily for us fs2 makes it easy:

+
+
+ + ScalaCliTest.scala +
+
//...
+  private def writeToFile(scriptBody: String)(isTest: Boolean): Resource[IO, String] =
+    Files[IO].tempFile(None,"",if (isTest) "-toolkit.test.scala" else "-toolkit.scala", None)
+      .evalTap { path =>
+        val header = List(
+          s"//> using scala ${BuildInfo.scalaVersion}",
+          s"//> using toolkit typelevel:${BuildInfo.version}",
+          s"//> using platform ${BuildInfo.platform}"
+        ).mkString("", "\n", "\n")
+        Stream(header, scriptBody.stripMargin)
+          .through(Files[IO].writeUtf8(path))
+          .compile
+          .drain
+      }
+      .map(_.toString)
+//...
+
+
+
+

Dissecting this function too we'll see that:

+
    +
  • Files[IO].tempFile creates a temporary file as a Resource, whose release method will delete the temporary file.
  • +
  • The isTest parameter is used to determine the extension that the temp file will have, as scala-cli requires a specific extension for both source and test files.
  • +
  • .evalTap will run an effectful side effect but returning the same Resource it was called on. In this case it will write the script content in the newly created temp file. This effect will run AFTER the file creation, but BEFORE any other effectful action that can be performed in the use method.
  • +
  • In the effect we'll produce a set of scala-cli directives using BuildInfo, we'll prepend them to the script's body and write everything in the temp file.
  • +
  • The path of the freshly baked scala-cli script will then be provided as a Resource[IO, String]
  • +
+

The only thing we needed to do was to combine the two methods into a testing method:

+
+
+ + ScalaCliTest.scala +
+
//...
+  def testRun(testName:String)(body: String): IO[Unit] = 
+   test(testName)(writeToFile(body)(false).use(f => scalaCli("run" :: f :: Nil)))
+
+  def testTest(testName:String)(body: String): IO[Unit] = 
+    test(testName)(writeToFile(body)(true).use(f => scalaCli("test" :: f :: Nil)))
+//...
+
+
+
+

To recap, each of the two methods will run a munit test that:

+
    +
  • write the body argument to a temporary file with the correct extension, prepending the correct scala-cli directives
  • +
  • run either the command scala-cli run or scala-cli test against the newly created file
  • +
  • use the exit code of the process to establish if the test is passed or not
  • +
  • delete the temporary file
  • +
+

The produced files will look, for example, like this:

+
//> using scala 3
+//> using toolkit typelevel:typelevel:0.1.14-29-d717826-20231004T153011Z-SNAPSHOT
+//> using platform jvm
+
+import cats.effect.*
+
+object Hello extends IOApp.Simple:
+  def run = IO.println("Hello toolkit!")
+
+

where 0.1.14-29-d717826-20231004T153011Z-SNAPSHOT is the version of the toolkit that was just published locally by sbt.

+

Test writing

+

It was then Time to write and run the actual tests!

+
+
+ + ToolkitTests.scala +
+
import munit.CatsEffectSuite
+import buildinfo.BuildInfo.scala3
+import ScalaCliTest.{testRun, testTest}
+
+class ToolkitTests extends CatsEffectSuite {
+
+  testRun("Toolkit should run a simple Hello Cats Effect") {
+    if (scala3)
+      """|import cats.effect.*
+         |
+         |object Hello extends IOApp.Simple:
+         |  def run = IO.println("Hello toolkit!")"""
+    else
+      """|import cats.effect._
+         |
+         |object Hello extends IOApp.Simple {
+         |  def run = IO.println("Hello toolkit!")
+         |}"""
+  }
+
+  testTest("Toolkit should execute a simple munit suite") {
+    if (scala3)
+      """|import cats.effect.*
+         |import munit.*
+         |
+         |class Test extends CatsEffectSuite:
+         |  test("test")(IO.unit)"""
+    else
+      """|import cats.effect._
+         |import munit._
+         |
+         |class Test extends CatsEffectSuite {
+         |  test("test")(IO.unit)
+         |}"""
+  }
+  //...
+}
+
+
+
+

The little testing framework we wrote is now capable of both running and testing scala-cli scripts that use the typelevel toolkit, and it will test it in every platform and scala version. sbt test will now publish both the toolkit and the test toolkit, for every platform, right before running the unit tests, achieving in this way a complete coverage and adding reliability to our releases! πŸŽ‰

+

And all of this was done without even touching our GitHub Actions, just with some sbt-fu, and just using the libraries that are included in the toolkit itself 😎

+ + +
+ + + + + \ No newline at end of file diff --git a/thesis.pdf b/thesis.pdf new file mode 100644 index 0000000..5cc9998 Binary files /dev/null and b/thesis.pdf differ diff --git a/toniogela.ttf b/toniogela.ttf new file mode 100644 index 0000000..97374a6 Binary files /dev/null and b/toniogela.ttf differ diff --git a/toniogela.woff b/toniogela.woff new file mode 100644 index 0000000..ae95ecd Binary files /dev/null and b/toniogela.woff differ diff --git a/toniogela.woff2 b/toniogela.woff2 new file mode 100644 index 0000000..04ffa83 Binary files /dev/null and b/toniogela.woff2 differ