From 4c78f40bcdea3dfd06a87b9b50f053849ed14b17 Mon Sep 17 00:00:00 2001 From: "Tj (bougyman) Vanderpoel" Date: Fri, 7 Aug 2026 14:08:22 -0400 Subject: [PATCH 01/10] fix: staged stage 8 --- AGENTS.md | 1 + documents/phase-8-plan.adoc | 133 ++++++++++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 documents/phase-8-plan.adoc diff --git a/AGENTS.md b/AGENTS.md index 8f07aa9..64a778c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,6 +10,7 @@ and best practices for agents to follow. own documents instead) - Phase 6 Plan: documents/phase-6-plan.adoc - Phase 7 Plan: documents/phase-7-plan.adoc +- Phase 8 Plan: documents/phase-8-plan.adoc ## Standards diff --git a/documents/phase-8-plan.adoc b/documents/phase-8-plan.adoc new file mode 100644 index 0000000..4cc361b --- /dev/null +++ b/documents/phase-8-plan.adoc @@ -0,0 +1,133 @@ += {my-title} +Tj Vanderpoel (bougyman) +:revdate: Aug 07, 2026 +:my-title: Phase 8 plan: packaging, releasing, and CI +:icons: font +:env-github: +ifdef::env-github[] +:tip-caption: :bulb: +:note-caption: :information_source: +:important-caption: :heavy_exclamation_mark: +:caution-caption: :fire: +:warning-caption: :warning: +endif::[] +:toc: + +== Goal + +Make this thing actually releasable: cross-platform standalone binaries via +`Burrito`, a container image for the daemon, and `release-please`-driven +versioning - matching the conventions already used by `linear-cli` (the +Ruby sibling) and the user's other FOSS projects. + +== Decisions (reached in full agreement before implementation) + +1. *Burrito wraps the whole app* - one binary is both the interactive CLI + and (with `LINEAR_CLI_DAEMON=true`) the daemon. Not two separate build + artifacts. +2. *Targets*: macOS Apple Silicon (`aarch64-darwin`), Linux x86_64, Windows + x86_64. No macOS Intel. +3. *CI build shape*: a single `ubuntu-latest` job cross-compiles all three + targets via Zig - not a matrix of per-OS runners. Burrito's own docs + confirm a Linux host can target all three; a matrix would just add CI + minutes with no benefit here. +4. *Versioning*: `release-please`, mirroring `linear-cli`'s own setup - + `release-type: simple`, `.version.txt` as the tracked version file, + `app/mix.exs`'s `version:` field updated via a `generic` extra-file with + `x-release-please-start-version`/`x-release-please-end` markers, + `CHANGELOG.md` auto-maintained (fresh - no history to backfill). +5. *Container image*: also in scope. The daemon's container just wraps the + already-built Linux x86_64 Burrito binary (`FROM alpine`, `COPY` the + binary, `ENV LINEAR_CLI_DAEMON=true`, `ENTRYPOINT`) rather than a + separate Elixir build stage - one build artifact, reused. Published to + `ghcr.io` via the existing (currently unwired) `ci/build_image.sh` / + `ci/publish.sh` scripts from the initial scaffold. +6. *CI gate before release*: add `.github/workflows/ci.yaml` now (`mix + test`, `mix format --check-formatted`, `ci/conventional_commits.sh`) - + not deferred to Phase 9. A release pipeline with no test gate in front + of it is a real gap. +7. *Workflow shape* (mirrors `linear-cli`): `ci.yaml` (validate) -> + `release.yaml` (on push to main, runs `release-please-action`; if + `release_created`, calls `burrito-release.yaml`) -> `burrito-release.yaml` + (build all 3 targets + the container image, attach binaries to the GH + release via `gh release upload`, push the container to `ghcr.io`). +8. *`RELEASE_PLEASE_TOKEN` secret*: must exist in the repo's GitHub + settings (a PAT, not the default `GITHUB_TOKEN` - which can't trigger + other workflows from its own commits/PRs). This is a one-time manual + setup step on GitHub.com the user needs to do themselves; not something + scriptable from here. + +== Verified: Burrito + `exqlite`'s NIF survives cross-compilation + +This was the one real open question, and it was checked empirically, not +assumed: + +* Burrito's own docs only describe automatic per-target NIF recompilation + for `elixir_make`-based NIFs. `exqlite` (via `ecto_sqlite3`) was a + question mark - would its NIF get built for the *build host* instead of + the *target*, crashing the app at boot on other platforms? +* Built a real Burrito release targeting `linux_x86_64` from this macOS + arm64 host. The build log showed Burrito explicitly detecting and cross- + compiling it: `--> Going to recompile NIF for cross-build: exqlite -> + x86_64-linux` ... `--> Successfully re-built exqlite for x86_64-linux!` + (a real `CC`/`LD` invocation, not a precompiled-binary fetch). +* Ran the produced binary inside a `podman` container (`--platform + linux/amd64`, QEMU-emulated) with `LINEAR_CLI_DAEMON=true`. It booted, + stayed alive, and - crucially - actually created + `~/.linear_cli/oban.db` on disk, proving the NIF loaded and SQLite + connections genuinely worked on the cross-compiled target. (It later hit + Oban's well-known "database is locked" contention from booting a 10- + connection pool against an unmigrated file - the same benign transient + seen during Phase 7's native testing, unrelated to Burrito.) +* Along the way: Burrito 1.6.0 actually requires Zig `0.16.0`, not the + `0.15.2` its own README states (verified by hitting the version-check + error directly) - `mlugg/setup-zig@v2.2.1` with `version: '0.16.0'` in CI. +* Also: `custom_erts:` (used briefly to route around a not-yet-catalogued + local OTP patch version) silently skips Burrito's musl-runtime-embedding + step for Linux targets (verified by reading + `deps/burrito/lib/steps/fetch/fetch_musl.ex` - it pattern-matches on + `erts_source: {:precompiled, _}` specifically). Not a concern for the + real pipeline, which pins a specific, catalogued OTP version and never + needs the override - but worth remembering if it ever comes up again. + +== New building blocks needed + +* `app/mix.exs`: add `{:burrito, "~> 1.6"}`, a real `releases/0` (not the + throwaway exploratory one used for verification) with the 3 real + targets, wrap the `version:` field in generic extra-file markers. Keep + `escript()` too, as the fast local-dev build path - Burrito is for + distribution, escript is for iterating. +* `.version.txt`, `.release-please-manifest.json`, + `.release-please-config.json` at repo root, matching `linear-cli`'s + shape. (`ci/build_image.sh` already expects `.version.txt` to exist at + this exact path - confirmed by reading its `APP_VERSION` default.) +* `Containerfile` at repo root (`ci/build_image.sh` searches + `Dockerfile`/`Containerfile`/`oci/Containerfile`, build context = repo + root) - wraps the Linux Burrito binary, `alpine` base for CA certs + (Linear's API is HTTPS) rather than `scratch`. +* `.github/workflows/ci.yaml`: `mix test`, `mix format + --check-formatted`, `ci/conventional_commits.sh` (already written, + unwired). +* `.github/workflows/release.yaml`: `release-please-action@v5`, gated on + `ci.yaml` passing, calls `burrito-release.yaml` on `release_created`. +* `.github/workflows/burrito-release.yaml`: `erlef/setup-beam@v1` (OTP + 29.0.3, Elixir 1.20.3 - the exact combo verified above; Burrito's + precompiled-ERTS catalog lags bleeding-edge patches, so pin to one it + actually has), `mlugg/setup-zig@v2.2.1`, build all 3 targets, `gh + release upload` the binaries, build+push the container via the existing + `ci/build_image.sh` / `ci/publish.sh`. + +== Sequencing + +1. `.version.txt` + release-please config/manifest + `mix.exs` version + markers - no behavior change, just wiring, verify `mix compile` still + works. +2. Real Burrito `releases/0` in `mix.exs` (the 3 real targets) - verify + locally by rebuilding at least the Linux target again. +3. `Containerfile` - verify locally with `ci/build_image.sh` (podman). +4. `.github/workflows/ci.yaml` - can dry-run the shell scripts locally + even without a real GH Actions run. +5. `.github/workflows/release.yaml` + `burrito-release.yaml` - reviewed + for correctness against verified action versions/inputs, but the actual + release-please PR / GH release flow can only be truly verified by an + real push to `main` on GitHub, after `RELEASE_PLEASE_TOKEN` is set up. From d750e762a6a1c7f76ea657a7afc114ba50d96c95 Mon Sep 17 00:00:00 2001 From: "Tj (bougyman) Vanderpoel" Date: Fri, 7 Aug 2026 14:10:04 -0400 Subject: [PATCH 02/10] fix(ci): strip credentials from git remote URL before using it in image labels --- ci/build_image.sh | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/ci/build_image.sh b/ci/build_image.sh index 285df3c..08fb774 100755 --- a/ci/build_image.sh +++ b/ci/build_image.sh @@ -156,6 +156,10 @@ else fi service=$(basename "$owner_and_repo" .git) +# Rebuilt from owner_and_repo (path only), never $repo_url directly - a +# credential-bearing remote (https://TOKEN@github.com/...) would otherwise +# get baked verbatim into this label, and from there into any pushed image. +image_url="https://github.com/${owner_and_repo%.git}" created=$(date --utc --iso-8601=seconds 2>/dev/null || gdate --utc --iso-8601=seconds) || die 8 "Could not determine image creation timestamp" full_tag=$IMAGE_NAME:$tag @@ -168,9 +172,9 @@ debug "Building $full_tag with $runtime from $CONTAINERFILE" --label org.opencontainers.image.description="Image for $service" \ --label org.opencontainers.image.licenses="$LICENSE" \ --label org.opencontainers.image.revision="$revision" \ - --label org.opencontainers.image.url="$repo_url" \ + --label org.opencontainers.image.url="$image_url" \ --label org.opencontainers.image.title="$IMAGE_NAME" \ - --label org.opencontainers.image.source="Generated by wallet ci/build_image.sh (${USER:-unknown_user}@${HOSTNAME:-unknown_host})" \ + --label org.opencontainers.image.source="Generated by $service ci/build_image.sh (${USER:-unknown_user}@${HOSTNAME:-unknown_host})" \ --label org.opencontainers.image.version="$tag" \ --label shortref="$shortref" \ --build-arg APP_VERSION="$APP_VERSION" \ From 5723a3e5b5d4ad5a36af713c7598cde81bf01d54 Mon Sep 17 00:00:00 2001 From: "Tj (bougyman) Vanderpoel" Date: Fri, 7 Aug 2026 14:12:41 -0400 Subject: [PATCH 03/10] feat(release): track the version via release-please --- .release-please-config.json | 24 ++++++++++++++++++++++++ .release-please-manifest.json | 3 +++ .version.txt | 1 + app/mix.exs | 2 ++ 4 files changed, 30 insertions(+) create mode 100644 .release-please-config.json create mode 100644 .release-please-manifest.json create mode 100644 .version.txt diff --git a/.release-please-config.json b/.release-please-config.json new file mode 100644 index 0000000..5b6fc0d --- /dev/null +++ b/.release-please-config.json @@ -0,0 +1,24 @@ +{ + "packages": { + ".": { + "changelog-path": "CHANGELOG.md", + "release-type": "simple", + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": true, + "draft": false, + "prerelease": false, + "version-file": ".version.txt", + "extra-files": [ + { + "type": "generic", + "path": "app/mix.exs" + } + ], + "exclude-paths": [ + ".release-please-manifest.json", + ".version.txt" + ] + } + }, + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json" +} diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..466df71 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.1.0" +} diff --git a/.version.txt b/.version.txt new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/.version.txt @@ -0,0 +1 @@ +0.1.0 diff --git a/app/mix.exs b/app/mix.exs index ce085c4..1af9d68 100644 --- a/app/mix.exs +++ b/app/mix.exs @@ -4,7 +4,9 @@ defmodule LinearCli.MixProject do def project do [ app: :linear_cli, + # x-release-please-start-version version: "0.1.0", + # x-release-please-end elixir: "~> 1.20", start_permanent: Mix.env() == :prod, deps: deps(), From 518e0bdffdb43d482ca2f318b26d18bbd82fd13d Mon Sep 17 00:00:00 2001 From: "Tj (bougyman) Vanderpoel" Date: Fri, 7 Aug 2026 14:17:05 -0400 Subject: [PATCH 04/10] feat(release): add burrito multi-target release config One binary is both the interactive CLI and (with LINEAR_CLI_DAEMON=true) the daemon - macOS Apple Silicon, Linux x86_64, Windows x86_64. exqlite's NIF cross-compilation verified empirically (built and ran the linux_x86_64 target under podman/QEMU, confirmed it created a real SQLite db on disk) rather than assumed - see documents/phase-8-plan.adoc. macOS Intel intentionally not targeted. --- app/.gitignore | 3 +++ app/mix.exs | 25 ++++++++++++++++++++++++- app/mix.lock | 2 ++ 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/app/.gitignore b/app/.gitignore index d29b4a7..2019493 100644 --- a/app/.gitignore +++ b/app/.gitignore @@ -26,6 +26,9 @@ app-*.tar # the real distribution target is the Burrito release, see Phase 8). /linear_cli +# Burrito's per-target output binaries (mix release, see documents/phase-8-plan.adoc). +/burrito_out/ + # Local dev SQLite db for Oban (daemon run mode only). /oban_dev.db* diff --git a/app/mix.exs b/app/mix.exs index 1af9d68..ac7b7cf 100644 --- a/app/mix.exs +++ b/app/mix.exs @@ -12,7 +12,8 @@ defmodule LinearCli.MixProject do deps: deps(), consolidate_protocols: Mix.env() != :dev, usage_rules: usage_rules(), - escript: escript() + escript: escript(), + releases: releases() ] end @@ -20,6 +21,27 @@ defmodule LinearCli.MixProject do [main_module: LinearCli.CLI] end + # Burrito-wrapped release, both the interactive CLI and (with + # LINEAR_CLI_DAEMON=true) the daemon - one binary, not two build + # artifacts. Targets and their host-compatibility verified against + # Burrito's own docs/source, and exqlite's NIF cross-compilation verified + # empirically (built+ran the linux_x86_64 target under podman/QEMU) - see + # documents/phase-8-plan.adoc. macOS Intel intentionally not targeted. + defp releases do + [ + linear_cli: [ + steps: [:assemble, &Burrito.wrap/1], + burrito: [ + targets: [ + macos_aarch64: [os: :darwin, cpu: :aarch64], + linux_x86_64: [os: :linux, cpu: :x86_64], + windows_x86_64: [os: :windows, cpu: :x86_64] + ] + ] + ] + ] + end + defp usage_rules do [ file: "../AGENTS.md", @@ -48,6 +70,7 @@ defmodule LinearCli.MixProject do {:oban, "~> 2.23"}, {:ecto_sqlite3, "~> 0.9"}, {:postgrex, "~> 0.22"}, + {:burrito, "~> 1.6"}, {:sourceror, "~> 1.8", only: [:dev, :test]}, {:ash, "~> 3.0"}, {:igniter, "~> 0.6", only: [:dev, :test]} diff --git a/app/mix.lock b/app/mix.lock index a2a7021..033a179 100644 --- a/app/mix.lock +++ b/app/mix.lock @@ -1,5 +1,6 @@ %{ "ash": {:hex, :ash, "3.31.0", "9ccd7d5ff00a5329a33786063e4a3040681b61d736730cd7aa3f24e5843c1d4f", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.14", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 1.0", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.6.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.3", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7518ecf694e1b423478ddaa5baf713e9d38448df6873f3be6316f758bc6ab68d"}, + "burrito": {:hex, :burrito, "1.6.0", "7af0a75f11680e8a6e9c01370c9af51cb9d0e15b3226eddf4f438dbc68570520", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:req, ">= 0.5.0", [hex: :req, repo: "hexpm", optional: false]}, {:typed_struct, "~> 0.2.0 or ~> 0.3.0", [hex: :typed_struct, repo: "hexpm", optional: false]}], "hexpm", "e636a00b032c45a69ff755d9fc53fa5fdc9e1d21bdbd229075fe4a15b05355fe"}, "cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"}, "crux": {:hex, :crux, "0.1.4", "1fa21f5ca886d3498f83871a3ef19379b80abf8cfcf2ed32007e80279e714f1e", [:mix], [{:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "ff7d880cf732d82360aa81e439b8d4831cd30768061c9233f90c97e10162b9c0"}, "db_connection": {:hex, :db_connection, "2.10.2", "ae391e803a5adff104da913c2fc1c0c14a37f8b10001dcef568796e1fb7bf95c", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "510b14482330f1af6490a2fa0efd8d4f1435d1529b165647df22ac0f2df0fa93"}, @@ -43,6 +44,7 @@ "stream_data": {:hex, :stream_data, "1.4.0", "026f929db613aabea6208012ae9b8970d3fd5f88b3bdf26831bc536f98c42036", [:mix], [], "hexpm", "2b0ee3a340dcce1c8cf6302a763ee757d1e01c54d6e16d9069062509d68b1dc9"}, "telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"}, "text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"}, + "typed_struct": {:hex, :typed_struct, "0.3.0", "939789e3c1dca39d7170c87f729127469d1315dcf99fee8e152bb774b17e7ff7", [:mix], [], "hexpm", "c50bd5c3a61fe4e198a8504f939be3d3c85903b382bde4865579bc23111d1b6d"}, "usage_rules": {:hex, :usage_rules, "1.2.7", "aaacfc9eda3b33d37703ed2321f32ad89845282513168f8592a1826eeabf50a7", [:mix], [{:igniter, ">= 0.6.6 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "8601999d754974f361f1fd816f0747dd7ea39aa04b1bf6244d158a9616be204e"}, "yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"}, "yaml_elixir": {:hex, :yaml_elixir, "2.12.2", "9dd1330fb4cd9a36a7b0f502e5b12486eff632792ee4a5f0eba52a4d4ec32c9c", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "e7c1b10122f973e6558462d51c39026ba0e14afbc6745318e990ea82cfe9e159"}, From aa369b6a59d54a9ac1867e05a64421bcc13bb776 Mon Sep 17 00:00:00 2001 From: "Tj (bougyman) Vanderpoel" Date: Fri, 7 Aug 2026 14:19:16 -0400 Subject: [PATCH 05/10] feat: add a Containerfile wrapping the daemon binary ## Changes - Wraps the already-built Linux x86_64 Burrito binary rather than a separate Elixir build stage - one build artifact, reused - Lives in oci/ (not the repo root) since we may want more than one variant later - ci/build_image.sh already searches oci/Containerfile as a fallback location, so no script changes were needed ## Tests - Built locally with ci/build_image.sh + podman (--platform linux/amd64, this dev machine is arm64) - Ran the built image with LINEAR_CLI_DAEMON=true, confirmed it stays up rather than crashing --- oci/Containerfile | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 oci/Containerfile diff --git a/oci/Containerfile b/oci/Containerfile new file mode 100644 index 0000000..a64bb35 --- /dev/null +++ b/oci/Containerfile @@ -0,0 +1,23 @@ +# Wraps the already-built Linux x86_64 Burrito binary (see +# documents/phase-8-plan.adoc) rather than a separate Elixir build stage - +# one build artifact, reused for both the CLI distribution and this image. +# Build with ci/build_image.sh (which finds this via its +# Dockerfile/Containerfile/oci/Containerfile search order - build context +# is still the repo root, so the COPY path below is unaffected by this +# file's own location) after app/burrito_out/linear_cli_linux_x86_64 +# already exists (`MIX_ENV=prod BURRITO_TARGET=linux_x86_64 mix release +# linear_cli`). +FROM alpine:3.22 + +ARG APP_VERSION +LABEL org.opencontainers.image.version="${APP_VERSION}" + +# ca-certificates: Linear's API is HTTPS-only, and Alpine's base image +# doesn't bundle a CA trust store. +RUN apk add --no-cache ca-certificates + +COPY app/burrito_out/linear_cli_linux_x86_64 /linear_cli +RUN chmod +x /linear_cli + +ENV LINEAR_CLI_DAEMON=true +ENTRYPOINT ["/linear_cli"] From 1ed92f002d0b71923d729963218c406a56727150 Mon Sep 17 00:00:00 2001 From: "Tj (bougyman) Vanderpoel" Date: Fri, 7 Aug 2026 14:20:02 -0400 Subject: [PATCH 06/10] ci: add a validate workflow (tests, format, conventional commits) ## Changes - mix test + mix format --check-formatted, against a pinned OTP 29.0.3 / Elixir 1.20.3 toolchain - Wires up the existing (previously unwired) ci/conventional_commits.sh as its own job - Triggered on pull_request and workflow_call, so release.yaml can gate on it ## Tests - Validated YAML syntax (ruby -ryaml) - mix test (174 passed) and mix format --check-formatted run clean locally against the same toolchain pin --- .github/workflows/ci.yaml | 50 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 .github/workflows/ci.yaml diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..55da96f --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,50 @@ +--- +name: CI + +on: # yamllint disable-line rule:truthy + workflow_dispatch: + workflow_call: + pull_request: + +jobs: + test: + name: Test + runs-on: ubuntu-latest + defaults: + run: + working-directory: app + steps: + - + uses: actions/checkout@v7 + - + uses: erlef/setup-beam@v1 + with: + otp-version: "29.0.3" + elixir-version: "1.20.3" + - + name: Cache deps/build + uses: actions/cache@v6 + with: + path: | + app/deps + app/_build + key: ${{ runner.os }}-mix-${{ hashFiles('app/mix.lock') }} + - + run: mix deps.get + - + run: mix format --check-formatted + - + run: mix test + + conventional_commits: + name: Validate Commit Subjects + runs-on: ubuntu-latest + steps: + - + uses: actions/checkout@v7 + with: + fetch-depth: 0 + - + env: + FETCH_BASE_REF: "true" + run: ./ci/conventional_commits.sh From 1289a0124c4c7bab8688b30b7cbb4afc76703231 Mon Sep 17 00:00:00 2001 From: "Tj (bougyman) Vanderpoel" Date: Fri, 7 Aug 2026 14:20:51 -0400 Subject: [PATCH 07/10] ci: add the release-please + burrito/container publish pipeline ## Changes - release.yaml: on push to main, gated on the ci.yaml validate workflow, runs googleapis/release-please-action@v5 against .release-please-config.json/.release-please-manifest.json - When release-please creates a release, calls burrito-release.yaml with the new tag - burrito-release.yaml: builds all 3 Burrito targets in one job (erlef/setup-beam pinned to OTP 29.0.3/Elixir 1.20.3, mlugg/setup-zig pinned to 0.16.0), uploads the binaries to the GitHub release via gh release upload - burrito-release.yaml also builds the linux_x86_64 target again for the container job (parallel, not serialized - no artifact sharing needed) and publishes it to ghcr.io via the existing ci/build_image.sh + ci/publish.sh ## Tests - Validated YAML syntax (ruby -ryaml) for both files - Action versions/inputs (release-please-action, setup-beam, setup-zig, actions/checkout, actions/cache) verified against their real action.yml/tags via the GitHub API, not guessed - The actual release-please PR/GitHub release flow can only be verified by a real push to main once RELEASE_PLEASE_TOKEN is set up (confirmed already present as an org secret) --- .github/workflows/burrito-release.yaml | 87 ++++++++++++++++++++++++++ .github/workflows/release.yaml | 42 +++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 .github/workflows/burrito-release.yaml create mode 100644 .github/workflows/release.yaml diff --git a/.github/workflows/burrito-release.yaml b/.github/workflows/burrito-release.yaml new file mode 100644 index 0000000..da52d0f --- /dev/null +++ b/.github/workflows/burrito-release.yaml @@ -0,0 +1,87 @@ +--- +name: Build and publish release artifacts + +on: # yamllint disable-line rule:truthy + workflow_dispatch: + inputs: + tag_name: + description: Release tag to attach binaries to + required: true + type: string + workflow_call: + inputs: + tag_name: + description: Release tag to attach binaries to + required: true + type: string + +permissions: + contents: write + packages: write + +jobs: + burrito: + name: Build Burrito binaries + runs-on: ubuntu-latest + defaults: + run: + working-directory: app + steps: + - + uses: actions/checkout@v7 + - + uses: erlef/setup-beam@v1 + with: + otp-version: "29.0.3" + elixir-version: "1.20.3" + - + # Version pinned to what Burrito 1.6.0 actually requires - its own + # README says 0.15.2, but the version check enforces 0.16.0. See + # documents/phase-8-plan.adoc. + uses: mlugg/setup-zig@v2.2.1 + with: + version: "0.16.0" + - + name: Install p7zip (Burrito needs 7z for Windows targets) + run: sudo apt-get update && sudo apt-get install -y p7zip-full + - + run: mix deps.get + - + name: Build all Burrito targets + run: MIX_ENV=prod mix release linear_cli + - + name: Upload binaries to the GitHub release + env: + GH_TOKEN: ${{ github.token }} + run: gh release upload "${{ inputs.tag_name }}" app/burrito_out/* --clobber + working-directory: . + + container: + name: Build and publish container image + runs-on: ubuntu-latest + steps: + - + uses: actions/checkout@v7 + - + uses: erlef/setup-beam@v1 + with: + otp-version: "29.0.3" + elixir-version: "1.20.3" + - + run: mix deps.get + working-directory: app + - + name: Build the linux_x86_64 target (container's payload) + run: MIX_ENV=prod BURRITO_TARGET=linux_x86_64 mix release linear_cli + working-directory: app + - + name: Build the image + env: + APP_VERSION: ${{ inputs.tag_name }} + run: ./ci/build_image.sh "${{ inputs.tag_name }}" + - + name: Publish the image + env: + GITHUB_TOKEN: ${{ github.token }} + GITHUB_ACTOR: ${{ github.actor }} + run: ./ci/publish.sh "${{ inputs.tag_name }}" diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..77912a6 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,42 @@ +--- +name: Release + +on: # yamllint disable-line rule:truthy + push: + branches: + - main + workflow_dispatch: + +jobs: + validate: + name: Validations + uses: ./.github/workflows/ci.yaml + + release: + needs: [validate] + name: Create a release + runs-on: ubuntu-latest + outputs: + release_created: ${{ steps.release.outputs.release_created }} + tag_name: ${{ steps.release.outputs.tag_name }} + steps: + - + uses: actions/checkout@v7 + with: + fetch-tags: true + - + uses: googleapis/release-please-action@v5 + id: release + with: + config-file: .release-please-config.json + manifest-file: .release-please-manifest.json + token: ${{ secrets.RELEASE_PLEASE_TOKEN }} + + publish: + if: needs.release.outputs.release_created == 'true' + needs: release + name: Build and publish release artifacts + uses: ./.github/workflows/burrito-release.yaml + with: + tag_name: ${{ needs.release.outputs.tag_name }} + secrets: inherit From c8c1b23ed467197050de975dd532a0a91ce07433 Mon Sep 17 00:00:00 2001 From: "Tj (bougyman) Vanderpoel" Date: Fri, 7 Aug 2026 14:35:35 -0400 Subject: [PATCH 08/10] ci: check out the real PR head SHA for commit-subject validation pull_request's default checkout ref is GitHub's synthetic refs/pull/N/merge test-merge commit (subject "Merge into "), not the PR branch tip - that commit was failing conventional-commit validation on every PR, and neither rebasing the branch nor loosening the validator would fix it (GitHub regenerates that ref fresh on every run, regardless of branch history). Check out github.event.pull_request.head.sha instead, falling back to github.sha for non-PR triggers. --- .github/workflows/ci.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 55da96f..5be5481 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -44,6 +44,12 @@ jobs: uses: actions/checkout@v7 with: fetch-depth: 0 + # pull_request's default ref is the synthetic refs/pull/N/merge test-merge + # commit (subject "Merge into "), not the PR branch tip - check + # out the real head SHA instead so that commit is never in the validated + # range. Falls back to github.sha for non-PR triggers (workflow_dispatch/ + # workflow_call), where github.event.pull_request is unset. + ref: ${{ github.event.pull_request.head.sha || github.sha }} - env: FETCH_BASE_REF: "true" From 80f9344c68f1b25fb98f81d99a88d70fd6fa3ba5 Mon Sep 17 00:00:00 2001 From: "Tj (bougyman) Vanderpoel" Date: Fri, 7 Aug 2026 15:16:23 -0400 Subject: [PATCH 09/10] fix(test): make default_branch/0's bare origin setup config-independent git init --bare's HEAD symref follows the runner's ambient init.defaultBranch config (falls back to git's legacy "master" if unset). The test only ever creates/pushes "main", so on a runner without init.defaultBranch=main set (e.g. GitHub Actions, unlike this machine), origin's HEAD dangled at a "master" that never exists, and git ls-remote --symref reported nothing - failing default_branch/0's test in CI while passing locally. Set origin's HEAD explicitly instead of relying on ambient config, matching what a real git host does when you configure a repo's default branch. Verified by reproducing the failure locally with GIT_CONFIG_GLOBAL=/dev/null before this fix, and confirming all 8 git_test.exs tests pass under those same config-free conditions after it. --- app/test/linear_cli/git_test.exs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/app/test/linear_cli/git_test.exs b/app/test/linear_cli/git_test.exs index b7c467a..4cec519 100644 --- a/app/test/linear_cli/git_test.exs +++ b/app/test/linear_cli/git_test.exs @@ -12,6 +12,17 @@ defmodule LinearCli.GitTest do File.mkdir_p!(origin_path) {_output, 0} = System.cmd("git", ["init", "--bare", "-q"], cd: origin_path) + # `git init --bare`'s HEAD symref follows the runner's ambient + # init.defaultBranch config (falling back to git's legacy "master" if + # unset) - since this repo only ever gets a "main" branch pushed to it, + # that can leave HEAD dangling at a "master" that never exists, and + # `git ls-remote --symref` then reports nothing for it. Set it explicitly + # (the same thing a real git host does when you configure a repo's + # default branch) so this doesn't depend on the runner's global config - + # this is what made `default_branch/0`'s test pass locally but fail in CI. + {_output, 0} = + System.cmd("git", ["symbolic-ref", "HEAD", "refs/heads/main"], cd: origin_path) + repo_path = tmp_path("repo") File.mkdir_p!(repo_path) init_repo!(repo_path) From 034d8d281d51c6bc0c3edd1aff1617f8ac8adf71 Mon Sep 17 00:00:00 2001 From: "Tj (bougyman) Vanderpoel" Date: Fri, 7 Aug 2026 15:48:17 -0400 Subject: [PATCH 10/10] fix(ci): address Copilot review findings on the release/image pipeline - burrito-release.yaml: install Zig 0.16.0 in the `container` job too - it runs the same `mix release`/Burrito build as the `burrito` job, which already has this step; without it the build fails - build_image.sh: org.opencontainers.image.source now points to $image_url/tree/$revision instead of a string embedding $USER@$HOSTNAME - closes an info leak and satisfies the OCI spec's requirement that this label be a URL - build_image.sh: drop the conflicting org.opencontainers.image.version --label (set to the raw tag) - oci/Containerfile already sets it correctly from $APP_VERSION via the build-arg; the CLI-level label was silently overriding that with the wrong value --- .github/workflows/burrito-release.yaml | 7 +++++++ ci/build_image.sh | 3 +-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/burrito-release.yaml b/.github/workflows/burrito-release.yaml index da52d0f..c03c41a 100644 --- a/.github/workflows/burrito-release.yaml +++ b/.github/workflows/burrito-release.yaml @@ -67,6 +67,13 @@ jobs: with: otp-version: "29.0.3" elixir-version: "1.20.3" + - + # Same Burrito build as the `burrito` job above, so it needs the same + # pinned Zig (see that job's comment) - missing here would fail this + # job's build at `mix release` time even though `burrito` succeeds. + uses: mlugg/setup-zig@v2.2.1 + with: + version: "0.16.0" - run: mix deps.get working-directory: app diff --git a/ci/build_image.sh b/ci/build_image.sh index 08fb774..a49cc40 100755 --- a/ci/build_image.sh +++ b/ci/build_image.sh @@ -174,8 +174,7 @@ debug "Building $full_tag with $runtime from $CONTAINERFILE" --label org.opencontainers.image.revision="$revision" \ --label org.opencontainers.image.url="$image_url" \ --label org.opencontainers.image.title="$IMAGE_NAME" \ - --label org.opencontainers.image.source="Generated by $service ci/build_image.sh (${USER:-unknown_user}@${HOSTNAME:-unknown_host})" \ - --label org.opencontainers.image.version="$tag" \ + --label org.opencontainers.image.source="$image_url/tree/$revision" \ --label shortref="$shortref" \ --build-arg APP_VERSION="$APP_VERSION" \ -f "$CONTAINERFILE" \