From 3128b1be108399b25f8bd73f4b99d15537158970 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Mon, 15 Jun 2026 19:11:48 +0200 Subject: [PATCH 1/2] Add CI/CD workflows and rewrite README (ported from halotukozak/made) - ci.yml: split into scalafmt lint + test job with Scala 3 coverage, Codecov upload, and doc check - publish.yml: publish to Maven Central on v* tags - docs.yaml: deploy scaladoc to GitHub Pages - .scoverage/report.sc: coverage report generator (HTML + Cobertura) - .gitignore: keep report.sc, ignore scala-doc and measurement output - README: GenCodec-style overview, install, quickstart, acknowledgements Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 43 ++++++++++++---- .github/workflows/docs.yaml | 52 +++++++++++++++++++ .github/workflows/publish.yml | 29 +++++++++++ .gitignore | 5 ++ .scoverage/report.sc | 36 +++++++++++++ README.md | 95 +++++++++++++++++++++++++++++++++-- 6 files changed, 247 insertions(+), 13 deletions(-) create mode 100644 .github/workflows/docs.yaml create mode 100644 .github/workflows/publish.yml create mode 100644 .scoverage/report.sc diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ddf6498..f744c1f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,18 +1,43 @@ -name: CI +name: Run CI on: push: - branches: [main] + branches: [ main ] pull_request: + branches: [ main ] jobs: - build: + lint: + name: Scalafmt runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: coursier/cache-action@v6 - - uses: VirtusLab/scala-cli-setup@v1.14.0 + - uses: actions/checkout@v6 + - name: Setup coursier cache + uses: coursier/cache-action@v8.1 + - uses: VirtusLab/scala-cli-setup@v1 + - name: Check formatting + run: scala-cli --power fmt --check . + + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - name: Setup coursier cache + uses: coursier/cache-action@v8.1 + - uses: VirtusLab/scala-cli-setup@v1 + - name: Run tests + # Coverage is recorded only for production sources; test sources are + # excluded so they don't inflate the reported number. + run: scala-cli --power test . -O -coverage-out:./.scoverage -O '-coverage-exclude-files:.*test/.*' + - name: Generate coverage + run: scala-cli .scoverage/report.sc + - uses: codecov/codecov-action@v7 with: - scala-cli-version: 1.14.0 - - run: scala-cli --power compile . - - run: scala-cli --power test . + fail_ci_if_error: true + files: .scoverage/report/cobertura.xml + token: ${{ secrets.CODECOV_TOKEN }} + - name: Test documentation + run: scala-cli --power doc . diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml new file mode 100644 index 0000000..8eb2f34 --- /dev/null +++ b/.github/workflows/docs.yaml @@ -0,0 +1,52 @@ +name: Deploy documentation to Pages + +on: + push: + tags: + - 'v*' + branches: [ "main" ] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: "pages" + cancel-in-progress: true + +jobs: + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + - name: Setup coursier cache + uses: coursier/cache-action@v8.1 + - name: Setup scala-cli + uses: VirtusLab/scala-cli-setup@v1 + - name: Generate documentation + run: | + scala-cli --power doc . -- \ + -project "mcodec" \ + -project-version ${{ github.ref_name }} \ + -project-footer "made with ❤️ and coffee" \ + -social-links:github::https://github.com/halotukozak/mcodec \ + -snippet-compiler:compile \ + -source-links:"src=github://halotukozak/mcodec?tag=${{ github.ref_name }}" \ + -revision:${{ github.ref_name }} + - name: Setup Pages + uses: actions/configure-pages@v6 + - name: Upload artifact + uses: actions/upload-pages-artifact@v5 + with: + path: 'scala-doc' + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v5 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..64238e1 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,29 @@ +name: Publish +on: + push: + tags: + - 'v*' + workflow_dispatch: + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - name: Setup coursier cache + uses: coursier/cache-action@v8.1 + - uses: VirtusLab/scala-cli-setup@v1 + - name: Publish + env: + SONATYPE_USERNAME: ${{ secrets.SONATYPE_USERNAME }} + SONATYPE_PASSWORD: ${{ secrets.SONATYPE_PASSWORD }} + PGP_PRIVATE_KEY: ${{ secrets.PGP_PRIVATE_KEY }} + PGP_PASSPHRASE: ${{ secrets.PGP_PASSPHRASE }} + run: | + scala-cli --power publish . --verbose \ + --user "env:SONATYPE_USERNAME" \ + --password "env:SONATYPE_PASSWORD" \ + --secret-key "env:PGP_PRIVATE_KEY" \ + --secret-key-password "env:PGP_PASSPHRASE" diff --git a/.gitignore b/.gitignore index 760eaec..7817c71 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,8 @@ # scala-cli build artifacts .scala-build/ .bsp/ +scala-doc + +# coverage (keep the report generator script) +.scoverage/* +!.scoverage/report.sc diff --git a/.scoverage/report.sc b/.scoverage/report.sc new file mode 100644 index 0000000..0f959b1 --- /dev/null +++ b/.scoverage/report.sc @@ -0,0 +1,36 @@ +//> using scala 3.8.4 +//> using dep org.scoverage::scalac-scoverage-reporter:2.5.2 +//> using dep org.scoverage::scalac-scoverage-domain:2.5.2 +//> using dep org.scoverage::scalac-scoverage-serializer:2.5.2 + +import scoverage.reporter.ScoverageHtmlWriter +import scoverage.reporter.CoberturaXmlWriter +import scoverage.reporter.IOUtils +import java.io.File +import scoverage.serialize.Serializer + +val coverageFile = new File(".scoverage/scoverage.coverage") +val sourceDir = new File(".") +val measurementDir = new File(".scoverage") +val outDir = new File(".scoverage/report") + +if !coverageFile.exists() then + println(s"Error: Coverage file not found at ${coverageFile.getAbsolutePath}") + sys.exit(1) + +val coverage = Serializer.deserialize(coverageFile, sourceDir) + +val measurementFiles = IOUtils.findMeasurementFiles(measurementDir) + +val measurements = IOUtils.invoked(measurementFiles.toIndexedSeq) +coverage.apply(measurements) + +outDir.mkdirs() + +val htmlReporter = new ScoverageHtmlWriter(Seq(sourceDir), outDir, None) +htmlReporter.write(coverage) + +val xmlReporter = new CoberturaXmlWriter(Seq(sourceDir), outDir, None) +xmlReporter.write(coverage) + +println(s"Statement coverage: ${coverage.statementCoverageFormatted}%") diff --git a/README.md b/README.md index a2b75c3..0c32548 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,101 @@ # mcodec -GenCodec-style serialization/deserialization for Scala 3, with derivation built on -[Made](https://github.com/halotukozak/made). Format-agnostic streaming `Input`/`Output` -core; JSON backend; JVM. +**GenCodec-style serialization for Scala 3** — a format-agnostic, streaming `Input`/`Output` core with type class +derivation built on [M&DE](https://github.com/halotukozak/made). Ships with a JSON backend. -> **Experimental.** Pinned to Scala **3.8.4** (Scala Next), required by Made (`made_3:0.1.2`). +> **Experimental.** Pinned to Scala **3.8.4** (Scala Next), required by Made (`made_3:0.1.2`). JVM only. + +## Overview + +mcodec is a serialization library in the spirit of [AVSystem GenCodec](https://github.com/AVSystem/scala-commons): a +single `MCodec[T]` type class both reads and writes, and the wire format is decoupled from the codecs. Codecs talk to +an abstract streaming `Input`/`Output`, so the same `MCodec[T]` works across any backend that implements those. + +- **One type class for read and write** — `MCodec[T]` with `read(input)` / `write(output, value)` +- **Format-agnostic core** — codecs target a streaming `Input`/`Output`; the JSON backend is just one implementation +- **Automatic derivation** — `derives MCodec` for case classes, enums, and sealed hierarchies, powered by M&DE mirrors +- **Annotation-aware** — `@name` to rename fields and ADT cases on the wire, `@flatten` / `@defaultCase` for ADTs +- **Combinators** — `transform`, `transformed`, `nullable`, and `makeLazy` for building codecs from existing ones +- **Explicit nulls** — compiled with `-Yexplicit-nulls`; nullability is expressed in the types + +Built-in codecs cover the primitives, `BigInt`/`BigDecimal`, `UUID`, `Option`, `Either`, tuples, and the common +collections (`List`, `Vector`, `Seq`, `Set`, `Map`). + +## Installation + +> mcodec has not had its first release yet. The coordinates below are how it will be published to Maven Central under +> `io.github.halotukozak` once tagged. Until then, build from source (see [Build](#build)). + +### scala-cli + +```scala +//> using scala 3.8.4 +//> using dep io.github.halotukozak::mcodec:: +``` + +### sbt + +```scala +scalaVersion := "3.8.4" +libraryDependencies += "io.github.halotukozak" %% "mcodec" % "" +``` + +### mill + +```scala +def scalaVersion = "3.8.4" +def mvDeps = Seq(mvn"io.github.halotukozak::mcodec::") +``` + +## Quickstart + +Derive an `MCodec` for a case class and round-trip it through JSON. + +```scala +import mcodec.* + +case class User(name: String, age: Int) derives MCodec + +val json = Json.write(User("Alice", 30)) // {"name":"Alice","age":30} +val user = Json.read[User](json) // User("Alice", 30) +``` + +Rename fields and ADT cases on the wire with `@name`: + +```scala +import mcodec.* +import made.annotation.name + +case class User(@name("user_name") name: String, age: Int) derives MCodec + +enum Shape derives MCodec: + @name("circ") case Circle(radius: Double) + +Json.write(User("bob", 30)) // {"user_name":"bob","age":30} +Json.write[Shape](Shape.Circle(2.0)) // {"circ":{"radius":2.0}} +``` + +Build codecs from existing ones with the combinators on `MCodec`: + +```scala +import mcodec.* + +opaque type Email = String +given MCodec[Email] = MCodec[String].transform(identity, identity) + +val nullableInt: MCodec[Int | Null] = MCodec[Int].nullable +``` ## Build ```sh scala-cli --power compile . scala-cli --power test . +scala-cli --power fmt . ``` + +## Acknowledgements + +mcodec is inspired by the [**AVSystem commons**](https://github.com/AVSystem/scala-commons) by [**ghik +**](https://github.com/ghik), whose +`GenCodec` is the model for the codec design From 139cf0161c4beac80c4efb99c9ce5e1bcf39a40c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Mon, 15 Jun 2026 19:36:34 +0200 Subject: [PATCH 2/2] Simplify README formatting in acknowledgements section --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index 0c32548..bbd5170 100644 --- a/README.md +++ b/README.md @@ -97,5 +97,4 @@ scala-cli --power fmt . ## Acknowledgements mcodec is inspired by the [**AVSystem commons**](https://github.com/AVSystem/scala-commons) by [**ghik -**](https://github.com/ghik), whose -`GenCodec` is the model for the codec design +**](https://github.com/ghik), whose `GenCodec` is the model for the codec design