diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..f62afb0 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,170 @@ +# Build ISO — builds the installer AND appliance ISOs for every supported +# architecture. +# +# Each arch builds on a native runner; the build itself runs inside the +# official `nixos/nix` container via `docker run`. We deliberately do NOT put +# the whole job in `container:` — the nixos/nix image lacks a standard glibc +# loader, so GitHub's bundled Node can't run there and every JS action +# (checkout, upload-artifact) fails with `exec .../node: no such file or +# directory`. So checkout/upload run on the host runner and only `make` runs in +# the container. +# +# One job per arch builds BOTH kinds in the SAME container (installer first, +# appliance second). `make /iso` resolves to the runner's native +# `builtins.currentSystem` and produces +# out/-iso/iso/coder-box---linux.iso (+ a .sha256 sidecar); +# the container /nix/store is ephemeral, so we dereference the ISOs into a host +# /dist volume and upload from there. +# +# Triggers / what gets built per kind: +# * push to main, workflow_dispatch → always build both full ISOs. +# * pull_request (ready, non-draft) → realise a kind's full ISO only when its +# label (test-installer-iso / test-appliance-iso) is applied; without the +# label that kind is just instantiated (.drv, cheap validation, no image). +# The `labeled` trigger means adding the label kicks off the full build. +# The job title reflects which kinds get a full build. Verification artifacts +# are short-lived (1 day). + +name: Build ISO + +on: + push: + branches: [main] + pull_request: + # `opened`/`reopened` cover a PR created/reopened already non-draft, + # `ready_for_review` a draft promoted to ready, `labeled` so applying a + # test-*-iso label starts the full build, and `synchronize` so pushing new + # commits re-runs the build — re-evaluating the labels so a labelled kind + # is re-built (not just its derivation) on every commit. + types: [opened, reopened, ready_for_review, labeled, synchronize] + workflow_dispatch: + inputs: + ref: + description: "Git ref/commit to build (defaults to the selected branch)" + required: false + type: string + +# Cancel superseded runs on the same ref; a full ISO build is expensive so +# don't waste runners on stale commits. +concurrency: + group: build-${{ github.ref }}-${{ github.event.inputs.ref }} + cancel-in-progress: true + +jobs: + iso: + # Title varies with which kinds get a full build (drv-only kinds aren't + # named): "Installer + Appliance", "Installer", "Appliance", or + # "Derivations" when neither is labelled on a PR. + name: >- + ${{ ( + (github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'test-installer-iso')) + && (github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'test-appliance-iso')) + ) && 'Installer + Appliance' + || (github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'test-installer-iso')) && 'Installer' + || (github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'test-appliance-iso')) && 'Appliance' + || 'Derivations' }} (${{ matrix.system }}) + # Skip draft PRs (opened/reopened/labeled fire for drafts too); always run + # for push and manual dispatch. + if: github.event_name != 'pull_request' || github.event.pull_request.draft == false + runs-on: ${{ matrix.runner }} + env: + # Per kind: realise the full ISO, or (drv-only) just instantiate. Non-PR + # events (push, manual dispatch) build both full; a PR builds a kind's + # full ISO only when its label is applied. Steps below branch on these. + INSTALLER_FULL: ${{ github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'test-installer-iso') }} + APPLIANCE_FULL: ${{ github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'test-appliance-iso') }} + strategy: + fail-fast: false + matrix: + include: + - system: x86_64-linux + runner: ubuntu-24.04 + - system: aarch64-linux + runner: ubuntu-24.04-arm + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + # Empty for push/PR (checks out the event ref); honored for manual + # dispatch to build an arbitrary commit. + ref: ${{ github.event.inputs.ref }} + + - name: Build ISOs + id: build + # INSTALLER_FULL / APPLIANCE_FULL come from the job-level env above. + run: | + # Host /dist volume, bind-mounted into the container, where full builds + # drop the finished ISO + checksum (the container's /nix/store is + # ephemeral, so the in-tree out/ symlink would dangle on the host). + dist="$(mktemp -d)" + echo "dist=$dist" >>"$GITHUB_OUTPUT" + # Both kinds run in ONE container; installer first, appliance later. + docker run --rm \ + -v "$PWD":/work -w /work \ + -v "$dist":/dist \ + -e INSTALLER_FULL -e APPLIANCE_FULL \ + nixos/nix:latest \ + sh -euc ' + # The Makefile builds via "nix build --impure" and needs flakes + + # nix-command, which the nixos/nix image does not enable by default. + export NIX_CONFIG="experimental-features = nix-command flakes" + # Repo is bind-mounted and owned by a different uid; allow git to + # read it so the Makefile can stamp the build rev. + git config --global --add safe.directory /work + + # full build → realise the ISO and copy it (+ its .sha256 sidecar, + # written next to out/) into /dist; otherwise just instantiate the + # derivation. Bare target → native currentSystem (matrix pins the + # runner arch). gnumake provides "make"; git stamps the build rev. + build_kind() { + kind="$1"; full="$2" + if [ "$full" = "true" ]; then + nix-shell -p gnumake git --run "make $kind/iso" + cp -L "out/$kind-iso/iso"/*.iso /dist/ + cp "out/coder-box-$kind"-*.iso.sha256 /dist/ + else + nix-shell -p gnumake git --run "make $kind/drv" + fi + } + + build_kind installer "$INSTALLER_FULL" + build_kind appliance "$APPLIANCE_FULL" + ' + ls -lh "$dist" + + - name: Upload installer ISO artifact + if: env.INSTALLER_FULL == 'true' + uses: actions/upload-artifact@v5 + with: + name: coder-box-installer-${{ matrix.system }} + path: ${{ steps.build.outputs.dist }}/coder-box-installer-*.iso + # Verification build; keep storage cost minimal. + retention-days: 1 + if-no-files-found: error + + - name: Upload installer checksum artifact + if: env.INSTALLER_FULL == 'true' + uses: actions/upload-artifact@v5 + with: + name: coder-box-installer-${{ matrix.system }}-sha256 + path: ${{ steps.build.outputs.dist }}/coder-box-installer-*.iso.sha256 + retention-days: 1 + if-no-files-found: error + + - name: Upload appliance ISO artifact + if: env.APPLIANCE_FULL == 'true' + uses: actions/upload-artifact@v5 + with: + name: coder-box-appliance-${{ matrix.system }} + path: ${{ steps.build.outputs.dist }}/coder-box-appliance-*.iso + retention-days: 1 + if-no-files-found: error + + - name: Upload appliance checksum artifact + if: env.APPLIANCE_FULL == 'true' + uses: actions/upload-artifact@v5 + with: + name: coder-box-appliance-${{ matrix.system }}-sha256 + path: ${{ steps.build.outputs.dist }}/coder-box-appliance-*.iso.sha256 + retention-days: 1 + if-no-files-found: error diff --git a/.github/workflows/eval.yml b/.github/workflows/eval.yml new file mode 100644 index 0000000..a10722d --- /dev/null +++ b/.github/workflows/eval.yml @@ -0,0 +1,66 @@ +# Eval Nix — fast validation that the flake still evaluates and the ISO +# derivations instantiate. +# +# Unlike Build ISO / Build and release ISO (which realise the full multi-GB +# images and only run for ready PRs / tags), this is cheap and runs on EVERY +# commit and pull request. Two checks, neither builds anything: +# 1. `nix flake check --no-build --all-systems` — evaluates every flake +# output (nixosConfigurations, packages, …) for all declared systems. +# 2. The Makefile's `*/drv` targets instantiate the installer + appliance ISO +# derivations (evaluate .drvPath, write the .drv, no realisation). +# A typo, bad reference, or type error in the Nix surfaces here in seconds +# instead of after a long image build. +# +# Both architectures are instantiated on a single x86_64 runner: evaluating an +# aarch64-linux derivation needs no aarch64 builder (only realisation would). +# The eval runs inside the nixos/nix container via `docker run` for the same +# reason as the other workflows — that image lacks a glibc loader for GitHub's +# bundled Node, so JS actions (checkout) stay on the host runner. + +name: Eval Nix + +on: + push: + branches: [main] + pull_request: + # Re-eval on every PR commit too (synchronize), plus open/reopen. This is + # the cheap per-commit gate, so unlike Build ISO we DO want synchronize. + types: [opened, reopened, synchronize] + workflow_dispatch: + +concurrency: + group: eval-${{ github.ref }} + cancel-in-progress: true + +jobs: + eval: + name: instantiate ISO derivations + runs-on: ubuntu-24.04 + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Eval flake + instantiate ISO derivations (in nixos/nix container) + run: | + docker run --rm \ + -v "$PWD":/work -w /work \ + nixos/nix:latest \ + sh -euc ' + export NIX_CONFIG="experimental-features = nix-command flakes" + git config --global --add safe.directory /work + + # 1) Evaluate every flake output (nixosConfigurations, packages, + # etc.) without realising anything: --no-build skips building + # derivations, --all-systems checks outputs for every system + # the flake declares (x86_64 + aarch64). Catches eval errors in + # flake outputs not covered by the ISO drv targets below. + nix flake check --impure --no-build --all-systems + + # 2) Instantiate the ISO derivations via the `*/drv` make targets + # (evaluate .drvPath, no build). Both arches per image; aarch64 + # eval needs no aarch64 builder. + nix-shell -p gnumake git --run " + make installer/drv/x86_64-linux installer/drv/aarch64-linux \ + appliance/drv/x86_64-linux appliance/drv/aarch64-linux + " + ' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..9f96528 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,158 @@ +# Build and release ISO — builds the installer AND appliance ISOs for every +# supported architecture and publishes them as assets on a GitHub Release. +# +# Trigger either way: +# * push a tag matching v* (e.g. `git tag v1.2.0 && git push origin v1.2.0`) +# * run manually from the Actions tab (workflow_dispatch), supplying a tag +# and optionally a ref/commit to build. +# +# Like build.yml, each arch builds natively and the build runs inside the +# `nixos/nix` container via `docker run` (the whole job can't use `container:` +# because that image lacks a glibc loader for GitHub's bundled Node, which +# breaks every JS action). Bare `make ` resolves to the runner's native +# currentSystem; the resulting ISO is copied out of the ephemeral container +# store into a host mktemp dir. The release job gathers all ISOs and attaches +# them (with sha256 checksums) to the release. + +name: Build and release ISO + +on: + push: + tags: ["v*"] + workflow_dispatch: + inputs: + tag: + description: "Release tag to create/publish (e.g. v1.2.0)" + required: true + type: string + ref: + description: "Git ref/commit to build (defaults to the tag/branch)" + required: false + type: string + prerelease: + description: "Mark the release as a pre-release" + required: false + default: false + type: boolean + +# Only one release run per tag at a time. +concurrency: + group: release-${{ github.event.inputs.tag || github.ref }} + cancel-in-progress: false + +jobs: + build: + name: ${{ matrix.kind }} (${{ matrix.system }}) + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: true + matrix: + include: + - kind: Installer + target: installer + system: x86_64-linux + runner: ubuntu-24.04 + - kind: Installer + target: installer + system: aarch64-linux + runner: ubuntu-24.04-arm + - kind: Appliance + target: appliance + system: x86_64-linux + runner: ubuntu-24.04 + - kind: Appliance + target: appliance + system: aarch64-linux + runner: ubuntu-24.04-arm + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + # Empty for tag push (checks out the tag); honored for manual dispatch + # to release an arbitrary commit. + ref: ${{ github.event.inputs.ref }} + + - name: Build ISO + id: build + env: + TARGET: ${{ matrix.target }} + run: | + # Host /dist volume, bind-mounted into the container, where the build + # drops the finished ISO (the container's /nix/store is ephemeral, so + # the in-tree out/ symlink would dangle on the host — we dereference + # the built ISO into /dist instead). + dist="$(mktemp -d)" + echo "dist=$dist" >>"$GITHUB_OUTPUT" + outlink="out/$TARGET-iso/iso" + docker run --rm \ + -v "$PWD":/work -w /work \ + -v "$dist":/dist \ + -e TARGET -e outlink="$outlink" \ + nixos/nix:latest \ + sh -euc ' + export NIX_CONFIG="experimental-features = nix-command flakes" + git config --global --add safe.directory /work + # gnumake provides "make"; git lets the Makefile stamp the rev. The + # ISO targets also emit out/.iso.sha256 sidecar checksums so + # release consumers can verify the download. Bare target → native + # currentSystem (matrix pins the matching native runner). + nix-shell -p gnumake git --run "make $TARGET/iso" + # Dereference the build store symlink into the /dist volume, + # alongside the make-generated checksum sidecar. + cp -L "$outlink"/*.iso /dist/ + cp out/*.iso.sha256 /dist/ + ' + ls -lh "$dist" + + - name: Upload ISO artifact + uses: actions/upload-artifact@v5 + with: + name: coder-box-${{ matrix.target }}-${{ matrix.system }} + path: ${{ steps.build.outputs.dist }}/*.iso + if-no-files-found: error + + - name: Upload ISO checksum artifact + uses: actions/upload-artifact@v5 + with: + name: coder-box-${{ matrix.target }}-${{ matrix.system }}-sha256 + path: ${{ steps.build.outputs.dist }}/*.iso.sha256 + if-no-files-found: error + + release: + name: Publish GitHub Release + needs: build + runs-on: ubuntu-24.04 + permissions: + contents: write + steps: + - name: Determine release tag + id: tag + run: | + if [ -n "${{ github.event.inputs.tag }}" ]; then + tag="${{ github.event.inputs.tag }}" + else + tag="${GITHUB_REF#refs/tags/}" + fi + echo "tag=$tag" >>"$GITHUB_OUTPUT" + echo "Releasing tag: $tag" + + - name: Download built ISOs + uses: actions/download-artifact@v5 + with: + path: dist + merge-multiple: true + + - name: List release assets + run: ls -lhR dist/ + + - name: Create / update GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ steps.tag.outputs.tag }} + name: ${{ steps.tag.outputs.tag }} + generate_release_notes: true + prerelease: ${{ github.event.inputs.prerelease || false }} + files: | + dist/*.iso + dist/*.iso.sha256 + fail_on_unmatched_files: true diff --git a/Makefile b/Makefile index ab207f4..31d5c78 100644 --- a/Makefile +++ b/Makefile @@ -13,6 +13,13 @@ # # make installer/iso # +# For cheap validation (CI, quick "does the Nix evaluate?" checks) there are +# instantiate-only targets that evaluate the derivation and write its .drv to +# the store WITHOUT building the multi-GB image: +# +# make installer/drv +# make appliance/drv +# # Each target also takes an architecture suffix; short names are normalized to # a *-linux triple (e.g. aarch64 -> aarch64-linux): # @@ -63,28 +70,78 @@ norm_arch = $(if $(filter %-linux,$(1)),$(1),$(1)-linux) # native, non-copy way to surface the result in the repo: ./out/ points # straight at the store path, and being a GC root it won't be garbage-collected. # ./out is gitignored. +# The shared flake expression, factored out so box_build and box_instantiate +# can't drift apart. Selects `config.system.build` for the configured system; +# callers append the build attr (and, for instantiate, `.drvPath`). This is the +# per-arch override described above (always-pinned hostPlatform; native arch via +# currentSystem when no token is given; Makefile-injected coderBox.rev). +# $(1) = host (nixosConfigurations.) +# $(2) = arch token (empty = builder's native arch) +# $(3) = extra module fields (nix attrset body, may be empty) +box_cfg = let f = builtins.getFlake (toString ./.); in (f.nixosConfigurations.$(1).extendModules { modules = [ { nixpkgs.hostPlatform = "$(if $(2),$(call norm_arch,$(2)),$${builtins.currentSystem})"; coderBox.rev = "$(GIT_REV)"; $(3) } ]; }).config.system.build + define box_build @mkdir -p out $(NIX) build --impure --no-write-lock-file --print-out-paths \ --out-link 'out/$(subst /,-,$@)' --expr \ - 'let f = builtins.getFlake (toString ./.); in (f.nixosConfigurations.$(1).extendModules { modules = [ { nixpkgs.hostPlatform = "$(if $(4),$(call norm_arch,$(4)),$${builtins.currentSystem})"; coderBox.rev = "$(GIT_REV)"; $(3) } ]; }).config.system.build.$(2)' + '$(call box_cfg,$(1),$(4),$(3)).$(2)' +endef + +# ISO build helper: box_build, then emit a SHA-256 sidecar for each produced +# ISO. iso-image.nix lands the image under /iso/.iso, which +# lives in the read-only /nix/store; we write the checksum into the writable +# ./out dir as out/.iso.sha256, with the bare basename (no store path) so +# `sha256sum -c` verifies against the ISO sitting next to it. Same arg shape as +# box_build (always $(2)=isoImage here). +define box_iso + $(call box_build,$(1),$(2),$(3),$(4)) + @link='out/$(subst /,-,$@)'; \ + for iso in "$$link"/iso/*.iso; do \ + base=$$(basename "$$iso"); \ + ( cd "$$link/iso" && sha256sum "$$base" ) > "out/$$base.sha256"; \ + echo "out/$$base.sha256"; \ + done +endef + +# Instantiate-only counterpart to box_build: same flake expr, but evaluates +# `.drvPath` so Nix fully evaluates the config and writes the .drv to the store +# WITHOUT realising the (multi-GB) image. Cheap CI validation that the Nix is +# sound. Prints the resulting store .drv path. No ./out GC-root link: there's no +# built output to anchor, and the .drv itself is a GC root until next gc. +# $(1) = host $(2) = system.build. $(3) = extra module fields $(4) = arch token +define box_instantiate + $(NIX) eval --impure --no-write-lock-file --raw --expr \ + '$(call box_cfg,$(1),$(4),$(3)).$(2).drvPath' + @echo endef -.PHONY: installer/iso appliance/iso appliance/qcow2 appliance/raw +.PHONY: installer/iso installer/drv appliance/iso appliance/drv appliance/qcow2 appliance/raw # installer/iso is listed first so it's the default goal (bare `make`). # ── installer/iso — installer ISO (hosts/_installer-iso); ISO only ──────────── installer/iso: - $(call box_build,_installer-iso,isoImage,,) + $(call box_iso,_installer-iso,isoImage,,) installer/iso/%: - $(call box_build,_installer-iso,isoImage,,$*) + $(call box_iso,_installer-iso,isoImage,,$*) + +# ── installer/drv — instantiate the installer ISO derivation (no build) ─────── +installer/drv: + $(call box_instantiate,_installer-iso,isoImage,,) +installer/drv/%: + $(call box_instantiate,_installer-iso,isoImage,,$*) # ── appliance/iso — ephemeral appliance ISO (hosts/_appliance_iso) ─────────── appliance/iso: - $(call box_build,_appliance_iso,isoImage,,) + $(call box_iso,_appliance_iso,isoImage,,) appliance/iso/%: - $(call box_build,_appliance_iso,isoImage,,$*) + $(call box_iso,_appliance_iso,isoImage,,$*) + +# ── appliance/drv — instantiate the appliance ISO derivation (no build) ─────── +appliance/drv: + $(call box_instantiate,_appliance_iso,isoImage,,) +appliance/drv/%: + $(call box_instantiate,_appliance_iso,isoImage,,$*) # ── appliance/qcow2 — persistent disk image (hosts/_appliance-disk) ────────── appliance/qcow2: