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.
$ 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
endProject.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.
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.
# .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.
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.
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.