Unverified Commit 659c8e5e authored by Minseong Choi's avatar Minseong Choi 💬
Browse files

feat(bootstrap): install the published release build instead of compiling on the host

deploy/bootstrap.sh now resolves the newest published GitHub release, downloads
the binary CI built for that tag, and builds a thin image around it. Compiling
on the target host becomes the fallback and the opt-in, not the default.

The panel is not a separate artifact. The Dockerfile copies panel/dist into
internal/panel/static before the go build, so the control plane — panel and
backend — ships as ONE file. The release channel therefore downloads exactly
one asset, felis-linux-<arch>, and needs no registry, no Go toolchain and no
checkout on the host.

The Minecraft game stack (limbo, lobby, the Velocity plugin) is still always
built locally. game_stack_source now keys on HAVE_PREBUILT_BINARY — the same
flag build_image uses — so on any prebuilt path it unpacks the tar embedded in
that binary instead of trusting a checkout an earlier install left behind.
Trusting the checkout would build the plugin from an old commit against a
freshly downloaded control plane: a silent version skew across the plugin/API
boundary.

Channels:

    (default)                      newest published release, downloaded
    FELIS_VERSION_BOOTSTRAP=dev    clone main and compile
    FELIS_REF=<ref>                pins the tree, forces the source path

The download is best-effort. A tag whose assets are not uploaded yet, an
architecture with no published asset, or an asset that fails validation each
warn and fall back to compiling THE SAME TAG from source — never a different
commit.

The ref is resolved right after install_base, the first point curl exists and
well before docker and k3s, so a missing FELIS_GITHUB_TOKEN or an unpublished
release costs the operator seconds instead of a k3s install they then have to
unwind. It is skipped on exactly the paths that never consume the result: the
TUI, which rebuilds the binary it is already running, and FELIS_SKIP_FETCH,
which builds whatever is staged. Resolving anyway would set FELIS_VERSION to
the newest tag and stamp a staged tree as that release.

The asset is staged next to HOST_BIN rather than in TMPDIR. Validation EXECUTES
it, and /tmp is noexec on CIS-hardened images, where the exec dies 126, the
check reads it as a bad asset, and every such host silently falls back to the
full on-host compile this path exists to avoid. It also keeps a private-repo
artifact out of a world-readable 1777 directory.

git_auth, which supplies the token to git for a private-repo clone, passes an EMPTY
credential.helper before the inline one. credential.helper is multi-valued: a bare
`-c credential.helper=...` APPENDS to whatever the host has configured rather than
replacing it, and an empty value is git's documented list reset. Without it, on a host
with a persistent helper (Git for Windows ships `manager` at SYSTEM scope) two things
go wrong. Git runs `credential approve` automatically after a successful clone and
feeds every helper in the list, so a `store` helper writes the PAT to
~/.git-credentials in cleartext — the token outlives the install, in a file bootstrap
never created and never cleans up. And because the inline helper is LAST, a
pre-existing helper answers `fill` first, so a stale cached credential can win and the
clone authenticates as the wrong account — surfacing as exactly the 404-on-private-repo
the surrounding code works hard to explain. Reproduced both against a real clone, and
confirmed the reset closes both.

internal/panel parses the new stamp. The dev channel now emits "<tag>+g<sha>", which
matched neither describeSuffix ("-N-g<sha>") nor releaseTag, so a dev build fell through
to the default case and the version badge rendered the entire stamp as the release with
no commit. A devSuffix case handles it; the git-describe case stays for hand-rolled
`-ldflags "-X main.version=$(git describe)"` builds. Table test covers both forms plus
the release, dirty and unstamped cases.

CRD application no longer branches on the install path: it is always
`felis bootstrap-assets crd`. That output is byte-identical to deploy/crd/ —
bootstrap_asset.go embeds that very file — and needs no checkout, so one source
replaces a branch whose two arms had to be kept in agreement by hand.

Dockerfile gains a FELIS_VERSION build arg wired into -X main.version, declared
after `go mod download` so a version bump does not invalidate that layer. Both
build stages are pinned to $BUILDPLATFORM so a multi-platform buildx run never
emulates them: the panel's output is architecture-independent and the Go stage
cross-compiles via TARGETARCH. The final stage stays on the target platform and
is COPY-only, which BuildKit performs without QEMU.

.github/workflows/release.yml publishes on a vX.Y.Z tag: vet, tests, then one
buildx run producing both architectures through the repo Dockerfile. Not a bare
`go build` — internal/panel/static holds a tracked placeholder index.html so the
//go:embed compiles without node, which means a direct build succeeds and
quietly ships a release whose panel is that placeholder.

The stamp is asserted end to end, because it fails silently: an unstamped binary
reports "dev", which the updater refuses to compare, disabling update reporting
for every install built from that release. The arm64 artifact is checked by ELF
machine type rather than by running it — runners have binfmt registered, so
executing an amd64 binary misnamed arm64 would succeed.

Prerelease tags are flagged explicitly. The trigger glob is v*, gh does not read
semver out of a tag name, and an RC published as a full release becomes
/releases/latest — the single endpoint the default channel installs from and
`felis update` polls.

No SHA256SUMS. A checksum fetched over the same TLS session, with the same
credential, from the same host as the binary adds no trust root; signing is the
real answer and is a separate decision.

Not verified: the download -> validate -> image -> k3s path has never run on a
host against a real published release, because no tag exists yet. The shell
logic around it is verified out of tree; the network and exec behaviour is not.
parent d146f1cc
Loading
Loading
Loading
Loading
+96 −0
Changes for .github/workflows/release.yml: 96 added lines, 0 removed lines.
Original line number Diff line number Diff line
# Publishes a Felis release when a vX.Y.Z tag is pushed.
#
# Two things depend on this job. /repos/{repo}/releases/latest must ANSWER — that endpoint is
# what `felis update` polls (internal/updater/github.go) and what deploy/bootstrap.sh's default
# "release" channel resolves its ref from. And the binaries below are what that channel then
# INSTALLS: bootstrap downloads felis-linux-<arch> instead of compiling on the target host, so
# these are the shipped artifact, not a convenience.
#
# The asset NAME is a contract with deploy/bootstrap.sh (download_release_binary builds
# "felis-linux-${arch}"). It is deliberately a plain literal on both sides: a GitHub Actions
# YAML and a go:embed'ed shell script have no honest way to share a constant, and the failure
# mode is benign — bootstrap warns and falls back to a source build of the same tag.
#
# The binary is built through the repo Dockerfile rather than a plain `go build`.
# internal/panel/static holds a tracked PLACEHOLDER index.html so the //go:embed
# compiles in a checkout without node; a direct `go build` therefore succeeds and
# quietly ships a release whose panel is that placeholder. The Dockerfile runs the npm
# build first, and is the same recipe bootstrap uses, so there is one way to build felis
# rather than two that can drift.
name: release

on:
  push:
    tags: ['v*']

permissions:
  contents: write # gh release create/upload

jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-go@v5
        with:
          go-version-file: go.mod

      # A tag that ships red is worse than a tag that fails to ship.
      - run: go vet ./...
      - run: go test ./...

      # Both architectures, because bootstrap's default release channel DOWNLOADS these
      # rather than compiling on the target host — an arm64 host with no asset silently
      # falls back to a slow source build. Neither stage is emulated: the Dockerfile pins
      # both build stages to $BUILDPLATFORM and the Go stage cross-compiles via TARGETARCH,
      # so the second architecture costs about a minute.
      - uses: docker/setup-buildx-action@v3

      - name: Build the stamped binaries
        run: |
          docker buildx build --platform linux/amd64,linux/arm64 \
            --build-arg FELIS_VERSION="${GITHUB_REF_NAME}" \
            --output type=local,dest=out .
          mv out/linux_amd64/usr/local/bin/felis ./felis-linux-amd64
          mv out/linux_arm64/usr/local/bin/felis ./felis-linux-arm64
          chmod +x ./felis-linux-amd64 ./felis-linux-arm64

      # The stamp is the whole point and it fails silently: an unstamped binary reports
      # "dev", which `felis update` refuses to compare, disabling update reporting for
      # every install built from it. Assert it end to end instead of trusting the ARG
      # reached the linker.
      - name: Verify the version stamp
        run: |
          got="$(./felis-linux-amd64 version | head -1)"
          echo "reported: ${got}"
          [ "$got" = "felis ${GITHUB_REF_NAME}" ] \
            || { echo "expected 'felis ${GITHUB_REF_NAME}' — the -X main.version stamp did not reach the binary"; exit 1; }
          # The arm64 binary is checked by ELF machine type, NOT by running it. Runners have
          # binfmt/QEMU registered, so `./felis-linux-arm64 version` would happily succeed on
          # an amd64 binary misnamed arm64 — which is exactly the failure the Dockerfile's
          # ${TARGETARCH:-$(go env GOARCH)} fallback produces if buildx did not take. Both
          # binaries come out of one RUN with one -ldflags string, so the stamp is checked once.
          file ./felis-linux-arm64
          file ./felis-linux-arm64 | grep -q 'ARM aarch64' \
            || { echo "felis-linux-arm64 is not an arm64 ELF — TARGETARCH did not reach the go build"; exit 1; }

      # --verify-tag refuses to invent a release for a tag that is not pushed. The upload
      # fallback makes a re-run converge rather than failing on an existing release.
      #
      # The prerelease flag has to be passed explicitly: the trigger glob is v*, so v1.2.3-rc1
      # lands here too, and gh does not read semver out of the tag name. Published as a full
      # release an RC becomes /releases/latest — the single endpoint bootstrap's default
      # channel installs from and `felis update` polls — so every fresh install would get the
      # RC binary and every deployed felis-api would error on the felis component until a
      # stable tag was cut. Flagged, GitHub keeps latest pointing at the last stable release.
      - name: Publish the release
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          flags=""
          case "$GITHUB_REF_NAME" in *-*) flags="--prerelease" ;; esac
          gh release create "$GITHUB_REF_NAME" --verify-tag --generate-notes $flags \
              ./felis-linux-amd64 ./felis-linux-arm64 \
            || gh release upload "$GITHUB_REF_NAME" \
                 ./felis-linux-amd64 ./felis-linux-arm64 --clobber
+33 −1
Changes for CONTRIBUTING.md: 33 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -186,11 +186,43 @@ Useful overrides:

```bash
export FELIS_REPO_URL=<your fork url>
export FELIS_REF=<your branch>
export FELIS_REF=<your branch>          # pins the build; overrides the channel below
export FELIS_IMAGE=felis:dev
export FELIS_ROOT_DOMAIN=<node-ip>.nip.io
```

By default the installer builds the newest **published GitHub release**. While this
repository is private that lookup — and the clone itself — needs a token, and building
the development tip needs an opt-in:

```bash
export FELIS_GITHUB_TOKEN=<token with read access to the repo>
export FELIS_VERSION_BOOTSTRAP=dev      # build main instead of the newest release
```

`dev` is also the escape hatch before the first `vX.Y.Z` tag exists: with no published
release the default channel has nothing to resolve and stops with that instruction.

The two channels differ in more than the version they pick. `release` **downloads** the
`felis-linux-<arch>` binary that CI published for that tag and builds only a thin image
around it, so the host needs neither a Go toolchain nor a checkout — the panel rides along
inside that same binary (`internal/panel` embeds it). `dev` clones and compiles. Either way
the Minecraft game stack (limbo, lobby, the Velocity plugin) is always built locally.

The download is best-effort by design: if the tag's assets are not uploaded yet — the release
workflow runs vet, tests and a full image build first — or this architecture has no published
asset, the installer warns and compiles **the same tag** from source. It never silently
switches you to a different commit. Setting `FELIS_REF` also forces the source path, since
naming a ref asks for that tree rather than a published artifact.

The channel decides the version stamp linked into the binary (`felis version`), which is
what `felis update` compares against upstream — release builds stamp the tag, dev builds
stamp `<latest-tag>+g<short-sha>`, and a pinned `FELIS_REF` stamps `v0.0.0+g<short-sha>`
because skipping channel resolution also skips the tag lookup. An unstamped build reports
`dev` and update reporting
is disabled for it, so build through `bootstrap.sh` (or the Dockerfile's `FELIS_VERSION`
build arg) rather than a bare `go build` when testing that path.

The setup flow wraps the host bootstrap, then continues to Owner account setup
and optional Cloudflare edge setup in the same command. The raw
`deploy/bootstrap.sh` script remains available for low-level host provisioning
+24 −3
Changes for Dockerfile: 24 added lines, 3 removed lines.
Original line number Diff line number Diff line
@@ -15,14 +15,20 @@
# deploy/bootstrap.sh also extracts this same binary onto the host (docker cp)
# so `felis migrate up` and the `felis setup` TUI run with the identical build.

FROM node:22-bookworm AS panel
# Both BUILD stages are pinned to the BUILDPLATFORM so a multi-platform buildx run never
# emulates them: the panel's output is plain JS and identical on every architecture, and the
# Go stage cross-compiles natively via TARGETARCH below. Under QEMU an `npm ci` alone costs
# minutes. The FINAL stage is deliberately NOT pinned — it must stay on the target platform
# or the published arm64 image would carry amd64 layers. It contains only COPY, which
# BuildKit performs itself, so it needs no QEMU either; adding a RUN there would.
FROM --platform=$BUILDPLATFORM node:22-bookworm AS panel
WORKDIR /panel
COPY panel/package*.json ./
RUN npm ci
COPY panel/ ./
RUN npm run build

FROM golang:1.26 AS build
FROM --platform=$BUILDPLATFORM golang:1.26 AS build
WORKDIR /src
ARG TARGETOS=linux
ARG TARGETARCH
@@ -31,8 +37,23 @@ COPY go.mod go.sum ./
RUN go mod download
COPY . .
COPY --from=panel /panel/dist ./internal/panel/static
# Declared HERE, not beside TARGETOS above: changing it invalidates every layer that
# follows, and the go mod download layer must survive a version bump.
#
# The stamp is what makes `felis version` and `felis update` mean anything — unstamped,
# main.version stays "dev" and the updater refuses to compare rather than treating it as
# 0.0.0. deploy/bootstrap.sh computes the value per channel; see the FELIS_VERSION_BOOTSTRAP
# block there for why the dev channel uses "+" build metadata and not `git describe`.
#
# The ${TARGETARCH:-...} fallback is a trap now that this stage is pinned to BUILDPLATFORM:
# `go env GOARCH` reports the BUILDER's architecture, so an empty TARGETARCH (a legacy
# `docker build`, or a setup-buildx step that quietly did not take) produces a working amd64
# binary that gets published under the arm64 name. .github/workflows/release.yml asserts the
# ELF machine type with file(1) for exactly this reason — an exec-based check cannot catch it,
# because CI runners have binfmt/QEMU registered and will happily run the wrong one.
ARG FELIS_VERSION=dev
RUN CGO_ENABLED=0 GOOS="$TARGETOS" GOARCH="${TARGETARCH:-$(go env GOARCH)}" \
    go build -trimpath -ldflags="-s -w" -o /out/felis ./cmd/felis
    go build -trimpath -ldflags="-s -w -X main.version=${FELIS_VERSION}" -o /out/felis ./cmd/felis

FROM gcr.io/distroless/static-debian12:nonroot
ENV PATH=/usr/local/bin:/usr/bin:/bin
+13 −5
Changes for cmd/felis/version.go: 13 added lines, 5 removed lines.
Original line number Diff line number Diff line
@@ -9,13 +9,21 @@ import (

// version is the build stamp injected at link time via
//
//	-ldflags "-X main.version=<git describe>"
//	-ldflags "-X main.version=v1.2.3"        (release channel: the tag verbatim)
//	-ldflags "-X main.version=v1.2.3+g1a2b3c4" (dev channel: tag + build metadata)
//
// deploy/bootstrap.sh computes it from the checked-out source with
// `git describe --tags --always --dirty`: the release channel builds the newest
// vX.Y.Z tag (a clean name like v1.0.0-earlyAccess), the dev channel builds main
// HEAD (a tag+distance+gSHA string). It stays "dev" for an un-stamped local
// deploy/bootstrap.sh computes it per install channel (FELIS_VERSION_BOOTSTRAP):
// the release channel stamps the resolved tag verbatim (v1.2.3), the dev channel
// stamps "<latest-tag>+g<short-sha>". It stays "dev" for an un-stamped local
// `go build`, where ReadBuildInfo below still surfaces the vcs revision.
//
// NOT `git describe`, for two reasons that both bite. Its "<tag>-<n>-g<sha>" form
// puts the distance in the PRERELEASE field, which sorts BELOW the bare tag, so a
// dev build ahead of v1.2.3 would compare as older than v1.2.3 and `felis update`
// would propose "upgrading" onto the release it already contains — hence "+", which
// is build metadata and ignored for ordering. And bootstrap's primary clone is
// --depth 1, which carries no tags, so describe would fall back to a bare SHA that
// updates.Parse rejects outright.
var version = "dev"

// cmdVersion prints the build stamp. It takes no flags and never touches the
+382 −16

File changed.

Preview size limit exceeded, changes collapsed.

Loading