Skip to content

Latest commit

 

History

History
195 lines (149 loc) · 6.82 KB

tutorial.md

File metadata and controls

195 lines (149 loc) · 6.82 KB

Tutorial: from an empty project to a distribution

What follows builds a distribution of a project that does not exist yet. It needs a GitHub repository, a local Julia to create the project with, and nothing else — no credentials, no private infrastructure, and no runner beyond the GitHub-hosted ones.

The project it builds is deliberately close to test/fixtures/TinyProduct, which the Behavior workflow builds a distribution from. Between them they are a shortcut past most of this page: take the fixture as the finished article instead of typing one out, and read a recent run of that workflow in the Actions tab to see what a build of it looks like end to end.

1. Create the project

$ julia -e 'using Pkg; Pkg.generate("MyProduct")'
$ cd MyProduct
$ julia --project=. -e 'using Pkg; Pkg.add("Crayons")'

Pkg.generate leaves a Project.toml and a src/MyProduct.jl. Adding a dependency is what makes the result worth looking at: a distribution of a project with no dependencies still demonstrates very little. Crayons is used here because it is tiny and has no dependencies of its own, so the build stays as short as a distribution build gets.

Give the module something to do:

# src/MyProduct.jl
module MyProduct

using Crayons: Crayon

function greet(name::AbstractString = "world")
    return string(Crayon(; foreground = :green), "hello, ", name, Crayon(; reset = true))
end

end

2. Say what the distribution is meant to ship

Project.toml is where you express that intent, through [compat] entries for the dependency and for Julia itself:

[compat]
Crayons = "=4.2.0"
julia = "=1.12.6"

Written plainly, as Crayons = "4.2.0", a [compat] entry is a lower bound rather than a pin: it admits every later 4.x, and julia = "1.12.6" admits every later 1.x. That is the right intent for a library, which wants to be usable alongside as much as possible. A distribution is the opposite case — you are deciding exactly what ships — so equality bounds express it better, and they say in the project what the workflow's channel would otherwise decide on its own.

Stating the intent is also what gets it checked. Resolving against bounds that cannot be satisfied — a channel installing a Julia the project does not admit, a dependency version that no longer exists — fails then and there, rather than producing a distribution that quietly ships something you did not intend.

Then resolve, which writes the Manifest.toml:

$ julia --project=. -e 'using Pkg; Pkg.resolve()'

Commit the Manifest.toml. A package normally does not, but a distribution is a build artifact rather than a library, and the build does not stop without one: it warns, resolves a manifest of its own, and carries on. That manifest can pick any versions the bounds allow, transitive dependencies included, so what ships is decided at build time rather than by you. Committing it is what makes two builds of the same commit produce the same distribution.

3. Give the build a workload

A distribution whose code is compiled but never exercised leaves the compilation to whoever runs it first. precompile.jl in the project root is run while the system image is built, and is picked up without any input naming it:

# precompile.jl
using MyProduct: greet

greet()

Call what a user reaches first. Anything exercised here is compiled into the system image included with the distribution.

4. Add the workflow

# .github/workflows/distribution.yml
---
name: "Distribution"

on:
  pull_request:

permissions:
  contents: "read"

jobs:
  build-distribution:
    name: "Build the distribution"
    runs-on: "ubuntu-latest"

    # A distribution build is long enough that a wedged one is worth failing
    # rather than leaving to the six-hour default.
    timeout-minutes: 60

    steps:
      - name: "Check out the source"
        uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1" # v7.0.1

      - name: "Install Julia"
        uses: "julia-actions/install-juliaup@1cc1b0ecfb06c04ff09fe24cab1476106460750e" # v3.1.5
        with:
          channel: "1.12.6"

      - name: "Build the distribution"
        id: "build-distribution"
        uses: "JuliaComputing/create-julia-distribution@958799562b82fcabd2f5508f7ee296e1d91de200"
        with:
          distribution-id: "myproduct-linux-x86_64"

      - name: "Check that the shipped Julia runs"
        env:
          DISTRIBUTION_PATH: "${{ steps.build-distribution.outputs.distribution-path }}"
        run: |
          set -euo pipefail
          "${DISTRIBUTION_PATH}/bin/julia" --startup-file=no \
            -e 'using MyProduct: greet; print(greet("distribution"), "\n")'

      - name: "Pack the distribution"
        id: "pack-distribution"
        uses: "JuliaComputing/create-julia-distribution/pack@958799562b82fcabd2f5508f7ee296e1d91de200"
        with:
          distribution-path: "${{ steps.build-distribution.outputs.distribution-path }}"

      - name: "Upload the distribution tarball"
        uses: "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a" # v7.0.1
        with:
          name: "distribution"
          path: "${{ steps.pack-distribution.outputs.distribution-tarball-path }}"

The step between the build and the pack is what turns "the build succeeded" into "the distribution works": it runs the julia that was just built and calls into the package the distribution exists to ship. Doing it before the pack means a tree that cannot do that never becomes a tarball.

5. Open a pull request

Commit everything and open a pull request. Expect the build to take tens of minutes even for a project this small: generating the system image and precompiling the standard libraries around it dominates, and neither scales down with the size of the project.

When the run finishes, the tarball is attached to it as an artifact named distribution.

6. Use it

Artifacts are delivered as zip archives, so downloading from the run's page gives a distribution.zip holding the tarball. gh unpacks that step for you:

$ gh run download --name distribution
$ tar xzf myproduct-linux-x86_64.tar.gz
$ ./myproduct-linux-x86_64/bin/julia
julia> using MyProduct

julia> MyProduct.greet("you")

using MyProduct resolves nothing, installs nothing and compiles nothing: the package is in the system image and shipped as a standard library of this Julia. Extracting that tarball on another machine of the same kind is all that machine needs.

Where to go from here

  • The examples cover dependencies that do not resolve from public registries, signing the tree before it is packed, and the inputs that shape what the build produces.
  • Limits is worth reading before building anything larger than this, because it is where the inputs stop.