mirror of
https://github.com/luxfi/explore.git
synced 2026-09-24 00:28:18 +00:00
One label per runner, spelled <os>-<arch>. The aliases these jobs named were removed, and a job naming a label no runner carries waits instead of failing. Co-authored-by: Hanzo Dev <[email protected]>
271 lines
12 KiB
YAML
271 lines
12 KiB
YAML
name: Release
|
|
|
|
# Builds explore on the native runners (git.hanzo.ai → git-runner), then copies
|
|
# that one image into every brand's registry — see the fanout job for why a
|
|
# release is one build and N copies rather than N builds.
|
|
#
|
|
# This replaces .github/workflows/build-lux.yml, which could never publish from
|
|
# this forge for two independent reasons:
|
|
#
|
|
# 1. It authenticated to ghcr.io with `secrets.GITHUB_TOKEN`. On Hanzo Git that
|
|
# token is a Hanzo Git token; ghcr.io has never heard of it. Every run died
|
|
# at the login step with `Get "https://ghcr.io/v2/": denied: denied` — the
|
|
# build ran, took a minute, and failed. The credential that works here is the
|
|
# GHCR_USER / GHCR_TOKEN org secret pair, sourced from KMS, exactly as
|
|
# luxfi/node uses.
|
|
#
|
|
# 2. Its second job deployed by driving kubectl straight at the cluster. Those
|
|
# objects are reconciled by hanzo-cd (the live Deployment carries
|
|
# apps.hanzo.ai/installation-id: hanzo-cd), so an imperative apply is drift
|
|
# by construction — it wins until the next sync and then silently loses.
|
|
# Deployment is a tag bump in luxfi/universe
|
|
# (deploy/lux-mainnet/explore-fe-lux.yaml) plus a CD sync. CI's job ends at a
|
|
# published, resolvable image.
|
|
#
|
|
# A tag push vX.Y.Z is a release: the image carries the semver tags plus the sha
|
|
# tag; a main push carries only the sha tag.
|
|
|
|
on:
|
|
push:
|
|
branches: [main]
|
|
tags: ['v*']
|
|
workflow_dispatch:
|
|
|
|
concurrency:
|
|
group: release-${{ github.ref }}
|
|
cancel-in-progress: false
|
|
|
|
permissions:
|
|
contents: read
|
|
|
|
jobs:
|
|
build:
|
|
runs-on: [linux-amd64]
|
|
# Next.js production build of the full explorer. Measured runs sit well
|
|
# under this; the ceiling is here so a hung build releases the runner.
|
|
timeout-minutes: 90
|
|
outputs:
|
|
probe: ${{ steps.meta.outputs.probe }}
|
|
git_tag: ${{ steps.meta.outputs.git_tag }}
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
|
|
- uses: actions/setup-node@v4
|
|
with:
|
|
node-version: '22.14.0'
|
|
|
|
- name: Install pnpm
|
|
run: |
|
|
set -euo pipefail
|
|
corepack enable
|
|
corepack prepare [email protected] --activate
|
|
pnpm --version
|
|
|
|
- name: Install dependencies
|
|
run: pnpm install --frozen-lockfile
|
|
|
|
# The gate. The search crash this repo exists to fix is a unit-testable
|
|
# contract — /search/quick answers with a paginated envelope, the frontend
|
|
# used to iterate it as a bare list — and the suite covers it directly
|
|
# (lib/api/unwrapItems.spec.ts, ui/snippets/searchBar/useQuickSearchQuery.spec.tsx).
|
|
# An image that cannot pass this must not become a tag.
|
|
- name: Unit tests
|
|
run: pnpm vitest run
|
|
|
|
- name: Compute tags
|
|
id: meta
|
|
run: |
|
|
set -euo pipefail
|
|
# The registry org follows the repo the workflow is running in, so a
|
|
# brand that forks this repo and fast-forwards to an upstream tag
|
|
# publishes to ITS OWN org and nowhere else. Upstream is luxfi, and
|
|
# only upstream fans out (see the fanout job). Defaulted rather than
|
|
# bare so an unset owner cannot silently build `ghcr.io//explore`.
|
|
REPO="ghcr.io/${GITHUB_REPOSITORY_OWNER:-luxfi}/explore"
|
|
echo "repo=${REPO}" >> "$GITHUB_OUTPUT"
|
|
SHA_TAG="${REPO}:sha-${GITHUB_SHA:0:7}"
|
|
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
|
|
VERSION="${GITHUB_REF#refs/tags/v}"
|
|
{
|
|
echo "tags<<EOF"
|
|
echo "${REPO}:v${VERSION}"
|
|
echo "$SHA_TAG"
|
|
echo "EOF"
|
|
} >> "$GITHUB_OUTPUT"
|
|
echo "probe=${REPO}:v${VERSION}" >> "$GITHUB_OUTPUT"
|
|
echo "git_tag=v${VERSION}" >> "$GITHUB_OUTPUT"
|
|
else
|
|
{
|
|
echo "tags<<EOF"
|
|
echo "$SHA_TAG"
|
|
echo "EOF"
|
|
} >> "$GITHUB_OUTPUT"
|
|
echo "probe=$SHA_TAG" >> "$GITHUB_OUTPUT"
|
|
# No tag on a branch push, so the footer's version string comes from
|
|
# package.json — the single source for "which release is this".
|
|
echo "git_tag=v$(node -p 'require("./package.json").version')" >> "$GITHUB_OUTPUT"
|
|
fi
|
|
|
|
- uses: docker/setup-buildx-action@v3
|
|
with:
|
|
driver: docker-container
|
|
driver-opts: network=host
|
|
|
|
- uses: docker/login-action@v3
|
|
with:
|
|
registry: ghcr.io
|
|
username: ${{ secrets.GHCR_USER }}
|
|
password: ${{ secrets.GHCR_TOKEN }}
|
|
|
|
- name: Build (load for smoke, not yet pushed)
|
|
uses: docker/build-push-action@v6
|
|
with:
|
|
context: .
|
|
file: Dockerfile
|
|
platforms: linux/amd64
|
|
load: true
|
|
push: false
|
|
provenance: false
|
|
tags: ${{ steps.meta.outputs.tags }}
|
|
build-args: |
|
|
GIT_COMMIT_SHA=${{ github.sha }}
|
|
GIT_TAG=${{ steps.meta.outputs.git_tag }}
|
|
NEXT_PUBLIC_BRAND=lux
|
|
# Which code is actually in this artifact, answerable from the registry
|
|
# alone via `docker buildx imagetools inspect` — without pulling it.
|
|
labels: |
|
|
org.opencontainers.image.revision=${{ github.sha }}
|
|
org.opencontainers.image.ref.name=${{ github.ref_name }}
|
|
org.opencontainers.image.source=https://git.hanzo.ai/luxfi/explore
|
|
|
|
- name: Smoke — the image is a runnable build, and its stamp is not a lie
|
|
# v1.1.14 and v1.1.15 exist as package.json bumps with no image behind
|
|
# them, because CI has been unable to publish since July. The inverse
|
|
# failure — an image whose footer reports a version other than its own
|
|
# tag — is the one that misleads in production, since the footer is how
|
|
# anyone reads which build they are looking at. Both are checkable here
|
|
# from the image config, without booting: the entrypoint downloads
|
|
# external assets and validates runtime envs, so a bare `docker run`
|
|
# proves nothing about the build and fails for reasons of its own.
|
|
run: |
|
|
set -euo pipefail
|
|
img="${{ steps.meta.outputs.probe }}"
|
|
|
|
# The standalone server the CMD names must exist.
|
|
docker run --rm --entrypoint sh "$img" -c 'test -f server.js' \
|
|
|| { echo "::error::server.js missing — not a usable standalone build"; exit 1; }
|
|
|
|
# Client chunks must have been emitted; an empty .next/static means the
|
|
# browser bundle never built and every page would render blank.
|
|
chunks=$(docker run --rm --entrypoint sh "$img" -c 'ls -1 .next/static/chunks 2>/dev/null | wc -l')
|
|
echo "client chunks: ${chunks}"
|
|
if [ "${chunks}" -lt 1 ]; then
|
|
echo "::error::no client chunks in .next/static — the browser bundle is missing"
|
|
exit 1
|
|
fi
|
|
|
|
# The version baked at build time must be the version being published.
|
|
baked=$(docker run --rm --entrypoint sh "$img" -c 'printenv NEXT_PUBLIC_GIT_TAG' || true)
|
|
want='${{ steps.meta.outputs.git_tag }}'
|
|
echo "baked NEXT_PUBLIC_GIT_TAG=${baked:-<unset>} want=${want}"
|
|
if [ "${baked}" != "${want}" ]; then
|
|
echo "::error::image stamps ${baked:-<unset>} but is published as ${want} — the footer would misreport the build"
|
|
exit 1
|
|
fi
|
|
|
|
- name: Push
|
|
run: |
|
|
set -euo pipefail
|
|
while IFS= read -r t; do
|
|
[ -n "$t" ] && docker push "$t"
|
|
done <<< "${{ steps.meta.outputs.tags }}"
|
|
|
|
- name: Verify the pushed manifest resolves
|
|
# A tag that names nothing pullable is how a Deployment ends up pinned to
|
|
# a phantom: the pin applies, the pod never starts, and the tag looks like
|
|
# a release. Prove it before the job is green.
|
|
run: |
|
|
set -euo pipefail
|
|
img="${{ steps.meta.outputs.probe }}"
|
|
for i in 1 2 3 4 5 6; do
|
|
if docker manifest inspect "$img" >/dev/null 2>&1; then
|
|
echo "$img is resolvable"; exit 0
|
|
fi
|
|
echo "manifest not visible yet (attempt ${i}/6) — retrying"; sleep 5
|
|
done
|
|
echo "::error::$img not pullable after push"; exit 1
|
|
|
|
# ONE BUILD, N COPIES.
|
|
#
|
|
# The four ghcr.io/{luxfi,hanzoai,zooai,parsdao}/explore tags are the same
|
|
# bytes: brand comes from the explore-env-<brand> ConfigMap the pod reads at
|
|
# runtime, not from anything baked in. So a brand release is a registry copy,
|
|
# never a rebuild — four builds of one tree would be four chances to differ.
|
|
#
|
|
# This step was a person, and the evidence that a person is not a mechanism is
|
|
# that v1.1.25 and v1.1.26 were never copied at all: hanzo, pars and zoo sat on
|
|
# v1.1.22 — four releases and eleven days behind — with no image in their
|
|
# registries to advance to even if someone had edited the pins.
|
|
#
|
|
# Registries do not mix. Each brand's image goes to that brand's org and the
|
|
# copy is verified by digest, because a copy that changed the bytes would mean
|
|
# the brands are no longer running the build that passed the gate above.
|
|
fanout:
|
|
needs: build
|
|
# Only releases fan out — a main push is a sha tag for the lux fleet alone.
|
|
# Only upstream fans out: a brand running its own fork publishes to its own
|
|
# org from its own run of this workflow, and must not publish for others.
|
|
if: startsWith(github.ref, 'refs/tags/v') && github.repository_owner == 'luxfi'
|
|
runs-on: [linux-amd64]
|
|
timeout-minutes: 20
|
|
steps:
|
|
- name: Install crane
|
|
# crane copy is a registry-to-registry blob copy that preserves the
|
|
# manifest digest. `buildx imagetools create` would republish the
|
|
# manifest and can change the digest — a different identity for
|
|
# identical content, which defeats the check below.
|
|
run: |
|
|
set -euo pipefail
|
|
ver=v0.21.7
|
|
curl -fsSL "https://github.com/google/go-containerregistry/releases/download/${ver}/go-containerregistry_Linux_x86_64.tar.gz" \
|
|
| tar -xz -C /usr/local/bin crane
|
|
crane version
|
|
|
|
- name: Copy the release into each brand registry
|
|
env:
|
|
GHCR_USER: ${{ secrets.GHCR_USER }}
|
|
GHCR_TOKEN: ${{ secrets.GHCR_TOKEN }}
|
|
run: |
|
|
set -euo pipefail
|
|
SRC='${{ needs.build.outputs.probe }}'
|
|
TAG='${{ needs.build.outputs.git_tag }}'
|
|
# --password-stdin, not -p: a token in argv is readable from the
|
|
# process list for as long as the command runs.
|
|
printf '%s' "$GHCR_TOKEN" | crane auth login ghcr.io -u "$GHCR_USER" --password-stdin
|
|
|
|
WANT="$(crane digest "$SRC")"
|
|
echo "source ${SRC} is ${WANT}"
|
|
|
|
for org in hanzoai zooai parsdao; do
|
|
crane copy "$SRC" "ghcr.io/${org}/explore:${TAG}"
|
|
done
|
|
|
|
# Assert the copies ARE the source, per org. A green copy command is
|
|
# not an artifact — this is the positive control that each brand
|
|
# registry now answers for this tag, with the bytes that were tested.
|
|
fail=0
|
|
for org in hanzoai zooai parsdao; do
|
|
got="$(crane digest "ghcr.io/${org}/explore:${TAG}" 2>/dev/null || echo MISSING)"
|
|
if [ "$got" = "$WANT" ]; then
|
|
echo "ok ghcr.io/${org}/explore:${TAG} ${got}"
|
|
else
|
|
echo "::error::ghcr.io/${org}/explore:${TAG} is ${got}, expected ${WANT}"
|
|
fail=1
|
|
fi
|
|
done
|
|
[ "$fail" = 0 ] || exit 1
|
|
|
|
echo
|
|
echo "Pin all five brands to ${TAG} in luxfi/universe deploy/lux-mainnet/"
|
|
echo "(explore-fe-{lux,spc,hanzo,pars,zoo}.yaml) — CD applies within ~90s."
|