378 lines
20 KiB
YAML
378 lines
20 KiB
YAML
# Release lane — tag push only (REQ-8-001, D-063, R-14/R-15).
|
|
#
|
|
# Fires on `v*` tag pushes. Builds the x64 SEA binary stamped with the tag,
|
|
# generates the sha256 sidecar, and attaches both to the tag's Gitea
|
|
# release via the REST API (the same mechanics as scripts/release-assets.sh
|
|
# — that script stays untouched as the MANUAL fallback with its
|
|
# .env-only token resolution, per D-064/R-14).
|
|
#
|
|
# Within-phase proof (G-203): at verify time push a temporary
|
|
# `v0.7.1-rc1`-style tag to the phase-branch head, observe this workflow
|
|
# build + attach green (release immediately deleted or pre-release), then
|
|
# delete the tag. The phase ship tag remains the real artifact.
|
|
#
|
|
# Token precedence (R-13): the automatic job token (auto-provided as the
|
|
# GITHUB_TOKEN env / `${{ github.token }}` / `${{ gitea.token }}`) HAS
|
|
# releases scope and can create releases + attach assets, but it CANNOT
|
|
# publish packages (upstream-confirmed). The repo secret GITEA_TOKEN (a
|
|
# founder-set PAT), when present, WINS (lowest level wins). This workflow
|
|
# uses `${{ secrets.GITEA_TOKEN }}` when set, else the automatic token.
|
|
#
|
|
# Extension points (P2/P3, G-202: this workflow is the shared rail):
|
|
# [P2] after "Build binary": docker build + login + push to
|
|
# git.coreci.dev/coreci/nextcraft:$TAG + :latest with
|
|
# secrets.REGISTRY_TOKEN, plus the five-assertion smoke gate (G-204).
|
|
# [P3] arm64 cross-build (G-201 ladder) + both .deb packages (R-22/R-23)
|
|
# join the attach step below — full asset matrix per D-064.
|
|
name: Release
|
|
|
|
on:
|
|
push:
|
|
tags: ['v*']
|
|
|
|
jobs:
|
|
release:
|
|
# Fleet label `release` -> container node:24-bookworm (R-3: node +
|
|
# dpkg-deb + git + curl; the deb toolchain ships with bookworm).
|
|
runs-on: release
|
|
env:
|
|
# Token precedence per R-13: repo secret GITEA_TOKEN (PAT) wins when
|
|
# present; else the automatic job token (releases scope is enough for
|
|
# release-id resolution + asset upload — R-14). Never echo this.
|
|
RELEASE_TOKEN: ${{ secrets.GITEA_TOKEN || github.token }}
|
|
steps:
|
|
- name: Checkout tag
|
|
uses: actions/checkout@v4
|
|
|
|
# P2: the release lane builds/pushes the docker image. The job
|
|
# container (node:24-bookworm) has no docker CLI and no daemon — the
|
|
# host daemon is bind-mounted into this job via the runner's
|
|
# container.options (deploy/ci/config-runner-release.yaml, sibling
|
|
# mode); only the CLI is missing. Bookworm main ships docker.io
|
|
# (engine+cli — the engine stays dormant; the socket is the host's).
|
|
- name: Install docker CLI (host daemon is socket-mounted)
|
|
run: |
|
|
apt-get update -qq && apt-get install -y -qq docker.io >/dev/null
|
|
docker version --format 'client {{.Client.Version}} / server {{.Server.Version}}'
|
|
|
|
- name: Enable pnpm
|
|
run: corepack enable pnpm
|
|
|
|
- name: Install dependencies
|
|
run: pnpm install --frozen-lockfile
|
|
|
|
# build:binary stamps NEXTCRAFT_VERSION_STAMP from the tag argument
|
|
# (build-binary.mjs validates it; `--version` reports it — G-102).
|
|
- name: Build x64 binary (SEA)
|
|
run: pnpm --filter @nextcraft/cli build:binary ${{ gitea.ref_name }} --arch x64
|
|
|
|
# P3 (REQ-8-003, R-25/G-201): arm64 is QEMU-free — the ONLY
|
|
# arch-specific step (the node binary copy) uses the official arm64
|
|
# node tarball; esbuild + postject are file surgery, nothing arm64
|
|
# executes on the x64 runner. The G-201 fallback ladder lives in
|
|
# PLAN MH-3a: if the probe ever fails, binfmt-full then x64-only
|
|
# deferral — never a red lane.
|
|
- name: Build arm64 binary (SEA, QEMU-free cross-build)
|
|
run: pnpm --filter @nextcraft/cli build:binary ${{ gitea.ref_name }} --arch arm64
|
|
|
|
# P3 (R-22/R-23, D-067): deb packages for both arches — binary +
|
|
# docs, advisory postinst, Version = tag minus v.
|
|
- name: Build debs (x64 + arm64)
|
|
run: |
|
|
bash scripts/build-deb.sh ${{ gitea.ref_name }} x64
|
|
bash scripts/build-deb.sh ${{ gitea.ref_name }} arm64
|
|
|
|
# Deb smoke (MH-3b): the amd64 deb installs in a fresh debian
|
|
# container and its binary speaks (the arm64 deb cannot exec on this
|
|
# x64 runner — its checksum + structure are the release-side proof).
|
|
# The deb enters via STDIN tar — sibling-mode bind mounts resolve on
|
|
# the HOST, and the job workspace path does not exist there (R-7's
|
|
# documented gotcha; docker build works because it TARs the context).
|
|
- name: Deb smoke — fresh-container install + advisory doctor
|
|
run: |
|
|
set -euo pipefail
|
|
TAG="${{ gitea.ref_name }}"
|
|
VER="${TAG#v}"
|
|
DEB="apps/cli/dist/deb/nextcraft_${VER}_amd64.deb"
|
|
[ -f "$DEB" ] || { echo "missing $DEB"; exit 1; }
|
|
tar -cf - -C "$(dirname "$DEB")" "$(basename "$DEB")" \
|
|
| docker run --rm -i --rm debian:bookworm bash -c '
|
|
set -e
|
|
mkdir -p /deb && tar -xf - -C /deb
|
|
DEB_FILE="/deb/$(basename /deb/nextcraft_*.deb)"
|
|
dpkg -i "$DEB_FILE" >/tmp/dpkg.log 2>&1 || true # advisory postinst: exit 0
|
|
/usr/bin/nextcraft --version
|
|
dpkg-deb --field "$DEB_FILE" Package Version Architecture
|
|
'
|
|
|
|
- name: Verify artifacts
|
|
run: |
|
|
set -euo pipefail
|
|
for A in x64 arm64; do
|
|
BIN="apps/cli/dist/nextcraft-linux-$A"
|
|
SUM="$BIN.sha256"
|
|
[ -f "$BIN" ] || { echo "missing $BIN"; exit 1; }
|
|
[ -f "$SUM" ] || { echo "missing $SUM"; exit 1; }
|
|
# The sidecar names the bare file — verify from the dist dir.
|
|
(cd "$(dirname "$BIN")" && sha256sum -c "$(basename "$SUM")")
|
|
echo "$A size: $(stat -c%s "$BIN") bytes"
|
|
done
|
|
for D in apps/cli/dist/deb/*.deb; do
|
|
[ -f "$D.sha256" ] || { echo "missing $D.sha256"; exit 1; }
|
|
(cd "$(dirname "$D")" && sha256sum -c "$(basename "$D.sha256")")
|
|
done
|
|
|
|
# Resolve the release id for the tag (the release is auto-created by
|
|
# Gitea on tag push if the repo setting is on; if not, create it
|
|
# first). Same call as scripts/release-assets.sh:32-49 (R-14).
|
|
- name: Resolve release id
|
|
run: |
|
|
set -euo pipefail
|
|
TAG="${{ gitea.ref_name }}"
|
|
BASE="https://git.coreci.dev/api/v1/repos/coreci/nextcraft"
|
|
RESPONSE="$(curl -sS -w '\n%{http_code}' \
|
|
-H "Authorization: token $RELEASE_TOKEN" \
|
|
"$BASE/releases/tags/$TAG")"
|
|
BODY="$(echo "$RESPONSE" | sed '$d')"
|
|
CODE="$(echo "$RESPONSE" | tail -1)"
|
|
if [ "$CODE" = "404" ]; then
|
|
echo "release for $TAG not found — creating it"
|
|
BODY="$(curl -sS -f -X POST \
|
|
-H "Authorization: token $RELEASE_TOKEN" \
|
|
-H 'Content-Type: application/json' \
|
|
-d "{\"tag_name\": \"$TAG\", \"name\": \"$TAG\"}" \
|
|
"$BASE/releases")"
|
|
else
|
|
echo "$CODE" | grep -q '^2' || { echo "release lookup failed: $CODE"; echo "$BODY"; exit 1; }
|
|
fi
|
|
RELEASE_ID="$(echo "$BODY" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')"
|
|
echo "release id: $RELEASE_ID"
|
|
echo "RELEASE_ID=$RELEASE_ID" >> "$GITHUB_ENV"
|
|
|
|
# Attach the FULL asset matrix (D-064): 2 binaries + 2 checksums +
|
|
# 2 debs + 2 deb checksums. actions/upload-artifact is a DIFFERENT
|
|
# mechanism (job artifacts via the cache server, not release
|
|
# downloads) — do NOT use it here (R-14). curl multipart is the API.
|
|
- name: Attach asset matrix to release
|
|
run: |
|
|
set -euo pipefail
|
|
BASE="https://git.coreci.dev/api/v1/repos/coreci/nextcraft"
|
|
upload() {
|
|
local file="$1" name="$2"
|
|
curl -sS -f -X POST \
|
|
-H "Authorization: token $RELEASE_TOKEN" \
|
|
-F "attachment=@$file" \
|
|
"$BASE/releases/$RELEASE_ID/assets?name=$name" >/dev/null
|
|
echo "attached $name"
|
|
}
|
|
for A in x64 arm64; do
|
|
upload "apps/cli/dist/nextcraft-linux-$A" "nextcraft-linux-$A"
|
|
upload "apps/cli/dist/nextcraft-linux-$A.sha256" "nextcraft-linux-$A.sha256"
|
|
done
|
|
TAG="${{ gitea.ref_name }}"
|
|
VER="${TAG#v}"
|
|
for D in amd64 arm64; do
|
|
upload "apps/cli/dist/deb/nextcraft_${VER}_${D}.deb" "nextcraft_${VER}_${D}.deb"
|
|
upload "apps/cli/dist/deb/nextcraft_${VER}_${D}.deb.sha256" "nextcraft_${VER}_${D}.deb.sha256"
|
|
done
|
|
|
|
# -- P2 extension (REQ-8-002, R-20, D-065): build + push the docker
|
|
# image to the Gitea registry on every phase release ----------------
|
|
# The job token cannot publish packages (R-13, upstream-confirmed);
|
|
# REGISTRY_TOKEN (repo Actions secret, self-served at P1: G-205) is
|
|
# the PAT with package-write.
|
|
- name: Build docker image
|
|
run: |
|
|
set -euo pipefail
|
|
TAG="${{ gitea.ref_name }}"
|
|
docker build \
|
|
--build-arg NEXTCRAFT_VERSION="$TAG" \
|
|
-t "git.coreci.dev/coreci/nextcraft:$TAG" \
|
|
-t "git.coreci.dev/coreci/nextcraft:latest" \
|
|
.
|
|
|
|
- name: Push image to the Gitea registry
|
|
run: |
|
|
set -euo pipefail
|
|
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.coreci.dev \
|
|
-u coreci --password-stdin
|
|
TAG="${{ gitea.ref_name }}"
|
|
docker push "git.coreci.dev/coreci/nextcraft:$TAG"
|
|
docker push "git.coreci.dev/coreci/nextcraft:latest"
|
|
|
|
# Smoke gate (G-204, five pinned assertions): a red image = red
|
|
# release. Probes run via docker exec INSIDE the container — the
|
|
# job container's 127.0.0.1 is not the host, and the smoke container
|
|
# publishes no ports (the admin-facing port map is the admin's call,
|
|
# not the gate's).
|
|
- name: Smoke gate — unprivileged run proves the honest degrade
|
|
run: |
|
|
set -euo pipefail
|
|
TAG="${{ gitea.ref_name }}"
|
|
CID=""
|
|
cleanup() { [ -n "$CID" ] && docker rm -f "$CID" >/dev/null 2>&1 || true; }
|
|
trap cleanup EXIT
|
|
CID="$(docker run -d "git.coreci.dev/coreci/nextcraft:$TAG")"
|
|
echo "container $CID up"
|
|
|
|
# 1. /health 200 within the window (in-container urllib — slim
|
|
# has no curl; the HEALTHCHECK does the same)
|
|
ok=""
|
|
for i in $(seq 1 30); do
|
|
if docker exec "$CID" python3 -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8420/health', timeout=3).status == 200 else 1)" 2>/dev/null; then ok=1; break; fi
|
|
sleep 2
|
|
done
|
|
[ -n "$ok" ] || { echo "FAIL: /health never returned 200"; docker logs "$CID" 2>&1 | tail -30; exit 1; }
|
|
echo "assert 1/5: /health 200"
|
|
|
|
# 2. GET / is HTML with a real document marker (not just non-JSON
|
|
# — the v0.4.6 lesson; G-204). One-line python: checks the
|
|
# doctype/html prefix on the FULL lowered head, not a 6-char
|
|
# slice (rc6 finding — the slice truncated the marker).
|
|
docker exec "$CID" python3 -c "import urllib.request,sys; body = urllib.request.urlopen('http://127.0.0.1:8420/', timeout=5).read(); head = body.lstrip()[:32].lower(); sys.exit(0 if (head.startswith(b'<!doctype') or head.startswith(b'<html')) else 1)" \
|
|
|| { echo "FAIL: / is not HTML"; exit 1; }
|
|
echo "assert 2/5: / serves HTML"
|
|
|
|
# 3. degrade boot notice present in container logs (unprivileged
|
|
# default → seccomp blocks userns → loud notice, D-068).
|
|
# Retry window: the /health probe can pass in the same instant
|
|
# the boot log is still flushing (rc4 finding — 60ms race);
|
|
# the notice check polls up to 15s, it does not trust timing.
|
|
ok=""
|
|
for i in $(seq 1 15); do
|
|
if docker logs "$CID" 2>&1 | grep -q "SANDBOX FABRIC DEGRADED"; then ok=1; break; fi
|
|
sleep 1
|
|
done
|
|
[ -n "$ok" ] \
|
|
|| { echo "FAIL: degrade boot notice absent"; docker logs "$CID" 2>&1 | tail -30; exit 1; }
|
|
echo "assert 3/5: degrade notice logged"
|
|
|
|
# 4. fabric env-create is refused BEFORE the fabric (503 degraded
|
|
# or 403 gate-first — both prove the degrade path short-circuits
|
|
# learner env creation; a 201 would prove the fabric ran, which
|
|
# an unprivileged container cannot allow). The probe python is
|
|
# written to a file first (a yaml block scalar cannot embed
|
|
# unindented command substitution continuations).
|
|
cat > /tmp/smoke_probe.py << 'PYEOF'
|
|
import urllib.request, urllib.error, json, sys
|
|
req = urllib.request.Request('http://127.0.0.1:8420/v1/sandboxes',
|
|
data=json.dumps({'learner_id':'smoke','task_id':'t'}).encode(),
|
|
headers={'Content-Type':'application/json'}, method='POST')
|
|
try:
|
|
print(urllib.request.urlopen(req, timeout=5).status)
|
|
except urllib.error.HTTPError as e:
|
|
print(e.code)
|
|
except Exception:
|
|
print('ERR')
|
|
sys.stdout.flush()
|
|
PYEOF
|
|
docker cp /tmp/smoke_probe.py "$CID":/tmp/smoke_probe.py
|
|
STATUS="$(docker exec "$CID" python3 /tmp/smoke_probe.py)"
|
|
[ "$STATUS" = "503" ] || [ "$STATUS" = "403" ] \
|
|
|| { echo "FAIL: env-create was $STATUS (expected 503 degraded or 403 gate)"; exit 1; }
|
|
echo "assert 4/5: degraded env-create refused ($STATUS)"
|
|
|
|
# 5. in-container CLI speaks (embedded doctor reports the docker
|
|
# channel — D-066) + clean stop
|
|
docker exec "$CID" nextcraft --version \
|
|
|| { echo "FAIL: embedded CLI does not run"; exit 1; }
|
|
echo "assert 5/5: embedded CLI alive + clean stop"
|
|
docker rm -f "$CID" >/dev/null && CID=""
|
|
# -- R1 deploy leg (REQ-v1-003 machinery set c18619; hep APPROVE c18623;
|
|
# vehicle call c18634: the last mile is THIS pipeline, not an SRE infra
|
|
# detour). Pipeline-only host refresh of the product host - no manual
|
|
# host pokes. Provenance = this job's run id + the post-deploy identity
|
|
# read below. The footer is retired as a freshness marker (it is the
|
|
# code's own label - c18624 correction, confirmed by hep): freshness
|
|
# reads are machine-read (/health + / serving HTML + the /v1/placements/
|
|
# admin route registration in /openapi.json).
|
|
#
|
|
# One-time plumbing preconditions (declared for the MR review; NOT a
|
|
# routing of the refresh to SRE - SRE keeps the R3 re-probe lane only):
|
|
# * DEPLOY_SSH_KEY - repo Actions secret: the least-scope deploy key
|
|
# authorized on the product host's deploy account (deploy@10.0.0.105
|
|
# per deploy/README.md topology). One-time grant via the SRE/console
|
|
# precedent (deploy/ci README G-206 pattern; self-serve secret PUT
|
|
# precedent: REGISTRY_TOKEN, HTTP 201 on 2026-09-27).
|
|
# * (optional hardening) DEPLOY_HOSTKEY - repo Actions secret holding
|
|
# the product host's complete ssh public host-key line; when set it
|
|
# is PINNED (StrictHostKeyChecking=yes), else accept-new.
|
|
# A missing DEPLOY_SSH_KEY at run time fails this leg LOUDLY - a red
|
|
# Actions tab on a tag is a red deploy by design (deploy/README.md);
|
|
# it is never silently skipped (set R1: the gate stays clearable on
|
|
# evidence). The release/asset path is untouched: a deploy-leg
|
|
# precondition failure cannot damage artifact shipping.
|
|
deploy:
|
|
needs: release
|
|
runs-on: release
|
|
steps:
|
|
- name: Preflight - fail loud when the leg is unconfigured
|
|
env:
|
|
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
|
run: |
|
|
set -euo pipefail
|
|
echo "deploy preflight: run id $GITHUB_RUN_ID / run #$GITHUB_RUN_NUMBER / job $GITHUB_JOB_ID / sha $GITHUB_SHA"
|
|
[ -n "$DEPLOY_SSH_KEY" ] || { echo "FATAL: DEPLOY_SSH_KEY unset - deploy leg unconfigured; host NOT refreshed this release; release artifacts stand (release job already green)"; exit 1; }
|
|
|
|
- name: Refresh the product host over the declared ssh channel
|
|
env:
|
|
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
|
DEPLOY_HOSTKEY: ${{ secrets.DEPLOY_HOSTKEY }}
|
|
run: |
|
|
set -euo pipefail
|
|
trap 'rm -f /tmp/dk /tmp/kh' EXIT
|
|
apt-get update -qq && apt-get install -y -qq openssh-client >/dev/null
|
|
install -m 600 /dev/null /tmp/dk
|
|
printf '%s\n' "$DEPLOY_SSH_KEY" > /tmp/dk
|
|
unset DEPLOY_SSH_KEY
|
|
if [ -n "${DEPLOY_HOSTKEY:-}" ]; then
|
|
printf '%s\n' "$DEPLOY_HOSTKEY" > /tmp/kh
|
|
SSH_PIN="-oUserKnownHostsFile=/tmp/kh -oStrictHostKeyChecking=yes"
|
|
else
|
|
SSH_PIN="-oStrictHostKeyChecking=accept-new"
|
|
fi
|
|
TAG="${{ gitea.ref_name }}"
|
|
ssh -i /tmp/dk -o BatchMode=yes $SSH_PIN deploy@10.0.0.105 bash -s "$TAG" << 'REMOTE'
|
|
set -euo pipefail
|
|
TAG="$1"
|
|
if command -v nextcraft >/dev/null 2>&1; then
|
|
echo "channel: binary/deb - the one-command upgrade (idempotent, self-verifying per deploy/README.md)"
|
|
nextcraft deploy --service --ref "$TAG"
|
|
elif [ -n "$(docker ps --format '{{.Names}}' | grep -x nextcraft || true)" ]; then
|
|
echo "channel: docker - README upgrade (pull + recreate; nextcraft-state persists)"
|
|
docker pull git.coreci.dev/coreci/nextcraft:"$TAG"
|
|
docker rm -f nextcraft
|
|
docker run -d --name nextcraft -p 8420:8420 -v nextcraft-state:/home/nextcraft/.nextcraft git.coreci.dev/coreci/nextcraft:"$TAG"
|
|
else
|
|
echo "FATAL: no nextcraft channel on $(hostname) (no nextcraft on PATH; no docker container named nextcraft) - failing loud"
|
|
exit 1
|
|
fi
|
|
echo "--- channel verify (advisory; the identity read below is canonical)"
|
|
command -v nextcraft >/dev/null 2>&1 && nextcraft --version || true
|
|
REMOTE
|
|
RC=$?
|
|
echo "host refresh done (rc=$RC); provenance: run id $GITHUB_RUN_ID / run #$GITHUB_RUN_NUMBER / job $GITHUB_JOB_ID / tag $TAG / sha $GITHUB_SHA"
|
|
exit $RC
|
|
|
|
- name: Post-deploy identity read - the R1 provenance surface
|
|
run: |
|
|
set -euo pipefail
|
|
TAG="${{ gitea.ref_name }}"
|
|
echo "waiting for the stack to settle..."
|
|
OK=""
|
|
for i in $(seq 1 12); do
|
|
CODE_H="$(curl -sS -o /tmp/h -m 15 -w '%{http_code}' https://nextcraft.coreci.dev/health || true)"
|
|
if [ "$CODE_H" = "200" ] && grep -q '"status"' /tmp/h; then OK=1; break; fi
|
|
sleep 5
|
|
done
|
|
[ -n "$OK" ] || { echo "FAIL: /health never returned 200 within the window"; exit 1; }
|
|
echo "assert 1/3: /health 200 - $(head -c 200 /tmp/h)"
|
|
CODE_R="$(curl -sS -o /tmp/r -m 15 -w '%{http_code}' https://nextcraft.coreci.dev/)"
|
|
head -c 64 /tmp/r | tr -d '[:space:]' | head -c 9 | grep -qiE '^<!doctype|^<html' || { echo "FAIL: / is not HTML (code $CODE_R)"; exit 1; }
|
|
echo "assert 2/3: / serves HTML ($CODE_R)"
|
|
CODE_O="$(curl -sS -o /tmp/o -m 25 -w '%{http_code}' https://nextcraft.coreci.dev/openapi.json)"
|
|
grep -q '"/v1/placements/admin' /tmp/o || { echo "FAIL: counting surface NOT registered on the served build (/openapi.json has no /v1/placements/admin routes) - honest red; R2 root-cause owns the diagnosis per c18619"; exit 1; }
|
|
echo "assert 3/3: /v1/placements/admin registered in /openapi.json"
|
|
echo "(advisory served version: $(grep -o '"version":"[^"]*"' /tmp/o | head -1))"
|
|
echo "PROVENANCE (R1): deploy run id $GITHUB_RUN_ID / run #$GITHUB_RUN_NUMBER / job $GITHUB_JOB_ID / tag $TAG / head $GITHUB_SHA - identity reads green"
|