diff --git a/docs/devops/GITEA_PACKAGES_PUBLISH_WORKFLOW.md b/docs/devops/GITEA_PACKAGES_PUBLISH_WORKFLOW.md new file mode 100644 index 00000000..0ae51752 --- /dev/null +++ b/docs/devops/GITEA_PACKAGES_PUBLISH_WORKFLOW.md @@ -0,0 +1,364 @@ +# `@bytelyst/*` Package Publish Workflow (Gitea Actions) + +> **Delegation prompt for a coding agent.** +> Implements the canonical `publish-packages.yml` workflow that publishes `@bytelyst/*` packages to the Gitea npm registry on every `v*` tag, and propagates it to all 20+ ByteLyst repos. + +> Companion: [`HOSTINGER_GITEA_ACT_RUNNER_SETUP.md`](./HOSTINGER_GITEA_ACT_RUNNER_SETUP.md) — the runner that executes this workflow. + +--- + +## 1. Goal + +When a `v*` tag is pushed to **either** Gitea instance: + +- Corp Mac local Gitea (`http://localhost:3300`) → corp Gitea Actions runner builds + publishes to corp Gitea. +- Hostinger Gitea (`http://localhost:3300` on the VM) → Hostinger Gitea Actions runner builds + publishes to Hostinger Gitea. + +Because both runners build inside the **same deterministic Docker image** (`node:20-bookworm` pinned by digest) from the **same git tag** using the **same lockfile**, the resulting tarballs are **byte-identical**. SHA512 integrity hashes match → lockfiles portable across both registries. + +The corp Mac never reaches Hostinger; the Hostinger VM never reaches corp Mac. The shared `git tag` IS the synchronization mechanism. + +--- + +## 2. The workflow + +**Location:** `.gitea/workflows/publish-packages.yml` in `learning_ai_common_plat` first; then propagated to every repo that ships `@bytelyst/*` packages. + +```yaml +# .gitea/workflows/publish-packages.yml +name: Publish @bytelyst/* packages + +on: + push: + tags: ['v*'] + workflow_dispatch: + inputs: + dry_run: + description: 'Build + pack but skip publish' + required: false + default: 'false' + +concurrency: + group: publish-${{ github.ref }} + cancel-in-progress: false # NEVER cancel a publish in flight + +jobs: + publish: + runs-on: [ubuntu-latest, bytelyst] + container: + image: node:20-bookworm@sha256:PIN_THIS_DIGEST_FOR_DETERMINISM + options: --network host -v /home/gitea-runner/.gitea_npm_token:/run/secrets/gitea_npm_token:ro + + steps: + - name: Checkout (full history needed for tag context) + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Print build context + run: | + echo "Tag: ${{ github.ref_name }}" + echo "Commit: ${{ github.sha }}" + echo "Runner: $(hostname)" + echo "Image: $(cat /etc/os-release | grep ^PRETTY_NAME)" + node --version + npm --version + + - name: Install pnpm 9 (pinned) + run: npm install -g pnpm@9.12.0 + + - name: Install workspace deps (frozen lockfile = determinism) + run: HUSKY=0 pnpm install --frozen-lockfile + + - name: Build all packages + run: pnpm -r --filter "@bytelyst/*" run build + + - name: Run tests + run: pnpm -r --filter "@bytelyst/*" test + + - name: Configure registry .npmrc + run: | + TOKEN=$(cat /run/secrets/gitea_npm_token) + cat > /tmp/publish.npmrc < manifest.sha512 + cat manifest.sha512 + + - name: Upload tarballs + manifest as Gitea Release assets + if: startsWith(github.ref, 'refs/tags/v') + env: + GITEA_TOKEN: ${{ secrets.GITEA_NPM_TOKEN }} + run: | + TAG="${{ github.ref_name }}" + REPO="${{ github.repository }}" # e.g. bytelyst/learning_ai_common_plat + GITEA_URL="http://localhost:3300" + + # Get release ID (create if doesn't exist) + RELEASE_ID=$(curl -s -H "Authorization: token $GITEA_TOKEN" \ + "$GITEA_URL/api/v1/repos/$REPO/releases/tags/$TAG" \ + | grep -oE '"id":[0-9]+' | head -1 | cut -d: -f2) + if [ -z "$RELEASE_ID" ]; then + RELEASE_ID=$(curl -s -X POST -H "Authorization: token $GITEA_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"body\":\"@bytelyst/* packages published by Gitea Actions\"}" \ + "$GITEA_URL/api/v1/repos/$REPO/releases" \ + | grep -oE '"id":[0-9]+' | head -1 | cut -d: -f2) + fi + echo "Release ID: $RELEASE_ID" + + # Upload each tarball + manifest + for f in /tmp/tarballs/*; do + curl -s -X POST -H "Authorization: token $GITEA_TOKEN" \ + -F "attachment=@$f" \ + "$GITEA_URL/api/v1/repos/$REPO/releases/$RELEASE_ID/assets?name=$(basename $f)" \ + | head -200 + echo + done +``` + +### Key design choices in this workflow + +| Decision | Reason | +| ------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | +| `node:20-bookworm@sha256:` pinned | Byte-identical builds across runners require identical OS/toolchain. Digest pin > floating tag. | +| `pnpm install --frozen-lockfile` | Lockfile is the dependency truth; CI never resolves fresh. | +| `pnpm@9.12.0` pinned | Different pnpm minor versions can produce slightly different `node_modules` layouts; pinning avoids drift. | +| `--network host` for the runner container | Allows `localhost:3300` (host's Gitea) to be reachable from inside the container. | +| Token mounted as a read-only secret file (`/run/secrets/...`) | Better than env var — not leaked to logs, not visible in process list. | +| `concurrency: cancel-in-progress: false` | NEVER cancel a publish mid-flight; would leave the registry in an inconsistent state. | +| Release assets in addition to registry publish | Gives a self-describing audit trail and a backup distribution channel. | +| SHA512 manifest as a release asset | Lets you verify the same tag built byte-identically on the other Gitea instance. | + +### Pin the Node image digest before merging + +```bash +# Get current digest for node:20-bookworm +docker pull node:20-bookworm +docker inspect node:20-bookworm | grep -oE 'sha256:[a-f0-9]{64}' | head -1 +# Update the workflow YAML with that digest, commit. +``` + +Re-pin every ~3 months or when a security patch is needed. + +--- + +## 3. Propagation to all 20+ repos + +Two strategies, choose one: + +### Option A — Reusable workflow (preferred, requires Gitea ≥ 1.22) + +Define the publish workflow as `workflow_call` once in `learning_ai_common_plat`: + +```yaml +# learning_ai_common_plat/.gitea/workflows/_reusable-publish-packages.yml +name: Reusable — Publish @bytelyst/* packages +on: + workflow_call: + inputs: + packages_glob: + type: string + default: '@bytelyst/*' + secrets: + GITEA_NPM_TOKEN: + required: true + +jobs: + publish: + # ... same body as above, parameterized by inputs.packages_glob +``` + +Then each consuming repo gets a 10-line stub: + +```yaml +# any-other-repo/.gitea/workflows/publish-packages.yml +name: Publish @bytelyst/* packages +on: + push: + tags: ['v*'] +jobs: + call-reusable: + uses: bytelyst/learning_ai_common_plat/.gitea/workflows/_reusable-publish-packages.yml@main + secrets: + GITEA_NPM_TOKEN: ${{ secrets.GITEA_NPM_TOKEN }} +``` + +Update once in common-plat → all consumers automatically updated. + +### Option B — Sync script (follows your existing `sync-npmrc.sh` pattern) + +If reusable workflows aren't available or you want explicit per-repo copies, use this script. Stored at `learning_ai_common_plat/scripts/sync-publish-workflow.sh`: + +```bash +#!/usr/bin/env bash +# Propagates publish-packages.yml from common-plat to every repo that ships @bytelyst/* packages. +# +# Usage: +# bash scripts/sync-publish-workflow.sh # dry-run, show diffs +# bash scripts/sync-publish-workflow.sh --apply # actually copy + commit + push +set -euo pipefail + +SOURCE=".gitea/workflows/publish-packages.yml" +WORKSPACE_ROOT=$(cd "$(dirname "$0")/.." && pwd) +SIBLINGS_ROOT=$(cd "$WORKSPACE_ROOT/.." && pwd) + +# Repos that ship @bytelyst/* packages (add new ones here as the ecosystem grows) +TARGETS=( + "learning_ai_common_plat" + # Add others here when they start publishing their own scoped packages. + # Currently common-plat is the only publisher. +) + +APPLY=0 +[ "${1:-}" = "--apply" ] && APPLY=1 + +for repo in "${TARGETS[@]}"; do + TARGET_DIR="$SIBLINGS_ROOT/$repo" + TARGET_FILE="$TARGET_DIR/.gitea/workflows/publish-packages.yml" + + if [ ! -d "$TARGET_DIR" ]; then + echo "SKIP (not cloned locally): $repo" + continue + fi + if [ "$repo" = "learning_ai_common_plat" ]; then + echo "SKIP (source repo): $repo" + continue + fi + + mkdir -p "$(dirname "$TARGET_FILE")" + if [ ! -f "$TARGET_FILE" ] || ! diff -q "$WORKSPACE_ROOT/$SOURCE" "$TARGET_FILE" > /dev/null; then + echo "=== $repo: publish-packages.yml differs from source ===" + diff "$WORKSPACE_ROOT/$SOURCE" "$TARGET_FILE" || true + if [ $APPLY -eq 1 ]; then + cp "$WORKSPACE_ROOT/$SOURCE" "$TARGET_FILE" + (cd "$TARGET_DIR" && git add .gitea/workflows/publish-packages.yml \ + && git commit -m "ci: sync publish-packages.yml from common-plat" \ + && git push origin main && git push gitea main) + echo " → applied + pushed" + fi + else + echo "OK: $repo (in sync)" + fi +done +``` + +Make executable: `chmod +x scripts/sync-publish-workflow.sh`. Run periodically (or on every common-plat change to the workflow). + +--- + +## 4. Releasing a new package version (operator workflow) + +```bash +# In the repo that owns the package(s) you're releasing: +cd ~/code/mygh/learning_ai_common_plat + +# Bump version(s) +pnpm --filter @bytelyst/api-client version patch +# Or, for many packages at once, use changesets / pnpm -r + +# Commit + tag + push to BOTH remotes +git add -A +git commit -m "chore: release @bytelyst/api-client v1.2.4" +git tag v1.2.4 +git push origin main --tags # GitHub gets the tag (code record) +git push gitea main --tags # Local/Hostinger Gitea gets the tag (TRIGGERS CI) + +# Watch the publish on both Giteas: +# - Corp: http://localhost:3300/bytelyst/learning_ai_common_plat/actions +# - Hostinger: http://gitea.bytelyst.com/bytelyst/learning_ai_common_plat/actions +# (only reachable from home network) +``` + +After both publish workflows complete, verify the byte-identical-tarball invariant: + +```bash +HOST_SHA=$(curl -s "http://gitea.bytelyst.com/api/packages/bytelyst/npm/@bytelyst%2Fapi-client/1.2.4" \ + | grep -oE '"shasum":"[^"]*"' | head -1 | cut -d'"' -f4) +CORP_SHA=$(curl -s -H "Authorization: token $(cat ~/.gitea_npm_token)" \ + "http://localhost:3300/api/packages/bytelyst/npm/@bytelyst%2Fapi-client/1.2.4" \ + | grep -oE '"shasum":"[^"]*"' | head -1 | cut -d'"' -f4) + +[ "$HOST_SHA" = "$CORP_SHA" ] && echo "✅ Byte-identical across Giteas" \ + || echo "❌ DIVERGED — check Node/pnpm/lockfile versions" +``` + +--- + +## 5. End-to-end smoke run after first deployment + +1. Commit this workflow file to a branch in `learning_ai_common_plat`. Don't merge yet. +2. From that branch, trigger via `workflow_dispatch` with `dry_run: true`. Confirms parsing + build + pack work without polluting the registry. +3. Merge to `main`. +4. Bump a real package version (or use the throwaway `@bytelyst/_runner-e2e-test` from the `act_runner` setup doc). +5. Tag + push. Watch CI on both Giteas. Verify SHA match. +6. Sanity-check from a consumer: + ```bash + cd ~/code/mygh/learning_ai_clock + pnpm update @bytelyst/ + pnpm install + pnpm typecheck # should compile clean + ``` + +--- + +## 6. Failure modes + remediation + +| Symptom | Likely cause | Fix | +| -------------------------------------------- | ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | +| One Gitea publishes, the other doesn't | Push went only to one remote | `git push gitea --tags && git push origin --tags` | +| Byte-identical check fails | Different pnpm or Node version on the two runners | Pin both in workflow + Dockerfile digest | +| `pnpm publish` says "version already exists" | Re-running a tag | Either bump version or `--force` (not recommended) | +| `localhost:3300` unreachable from container | `container.network` is bridge not host | Set `--network host` in workflow `options:` or `container.network: host` in `act_runner` config.yaml | +| Release asset upload 401 | `GITEA_NPM_TOKEN` secret not configured at repo level | Add it in Settings → Secrets | +| Lockfile drift between machines | Someone committed without running `pnpm install` | Add `pnpm install --frozen-lockfile` as a pre-commit hook | + +--- + +## 7. Deliverables for the agent implementing this + +1. **File:** `learning_ai_common_plat/.gitea/workflows/publish-packages.yml` with the Node image digest pinned (run `docker pull node:20-bookworm && docker inspect node:20-bookworm | grep sha256` to get the digest). +2. **File:** `learning_ai_common_plat/scripts/sync-publish-workflow.sh` if going with Option B. +3. **Secret:** confirm `GITEA_NPM_TOKEN` is set as a Gitea repo secret in `Settings → Secrets`. Document its value source (`~/.gitea_npm_token` on each machine). +4. **Test:** the throwaway-package E2E from `HOSTINGER_GITEA_ACT_RUNNER_SETUP.md` §6 must pass, including the cross-Gitea SHA comparison. +5. **Commit + push** to both `origin` (GitHub) and `gitea` (whichever Gitea is locally reachable). +6. **Add a SKILL doc** at `AI.dev/SKILLS/gitea-package-publishing.md` that references this workflow and the act_runner setup. + +--- + +## 8. Questions for the operator before implementing + +- "Which packages should be auto-published on `v*` tags? Currently I assume all `@bytelyst/*` packages in `packages/`. Are there any to exclude?" +- "Are we using version-tagged releases (e.g., `v1.2.3` triggers everything to that version) or changesets (per-package versions)? The workflow above assumes a single repo-wide tag." +- "Should the workflow also run on `workflow_dispatch` with a specific package filter, or always publish everything that changed since the last tag?" +- "What's the policy if the cross-Gitea SHA comparison fails for a real release? Block consumers from upgrading, or accept divergence with a warning?" +- "Is there a release approval gate before publishing (e.g., human-clicked workflow_dispatch confirmation), or is `git push gitea --tags` enough?" diff --git a/docs/devops/HOSTINGER_GITEA_ACT_RUNNER_SETUP.md b/docs/devops/HOSTINGER_GITEA_ACT_RUNNER_SETUP.md new file mode 100644 index 00000000..5dfc879d --- /dev/null +++ b/docs/devops/HOSTINGER_GITEA_ACT_RUNNER_SETUP.md @@ -0,0 +1,643 @@ +# Hostinger VM — Gitea Actions `act_runner` Setup + +> **Delegation prompt for the Codex agent running on the Hostinger VM.** +> Canonical CI runner setup for the ByteLyst ecosystem. Read top-to-bottom before executing. Stop and ask the human if any pre-flight check fails or any deliverable is unclear. + +> Companion docs: +> +> - [`GITEA_PACKAGES_PUBLISH_WORKFLOW.md`](./GITEA_PACKAGES_PUBLISH_WORKFLOW.md) — the publish workflow this runner executes. +> - [`HOSTINGER_GITHUB_RUNNER_SETUP.md`](./HOSTINGER_GITHUB_RUNNER_SETUP.md) — Plan B (GitHub Actions runner). Kept for reference. + +--- + +## 1. Goal + +Install a `act_runner` daemon on the Hostinger VM that: + +1. Registers with the **local Hostinger Gitea instance** (`http://localhost:3300`) at **instance level** (one registration covers all repos and orgs). +2. Picks up Gitea Actions workflows triggered by push, tag, or `workflow_dispatch` from any `@bytelyst/*` repo in this Gitea. +3. Runs jobs inside a deterministic Linux Docker image so tarball builds are byte-identical to what the corp Mac runner produces from the same git tag. +4. Has access to the Gitea publish token (`~/.gitea_npm_token`) for `pnpm publish` to `http://localhost:3300/api/packages/bytelyst/npm/`. + +Self-hosted Gitea Actions on Hostinger is preferred over GitHub Actions runners because: + +- Workflows already exist at `.gitea/workflows/` across all 20+ repos. +- Zero-latency `pnpm publish` to localhost — no network hop. +- No GitHub Organization migration needed → no Vercel/Netlify pricing entanglements. +- Corp Mac runs an identical `act_runner` against its own local Gitea, so both registries get byte-identical tarballs from the same tag push — **no sync script required**. + +--- + +## 2. Pre-flight checks (run first, do not skip) + +```bash +# 1. Confirm Linux VM +hostname && uname -a # Expected: Linux + +# 2. Detect architecture (for act_runner binary download) +ARCH=$(uname -m) +case "$ARCH" in + x86_64) export RUNNER_ARCH="linux-amd64";; + aarch64) export RUNNER_ARCH="linux-arm64";; + *) echo "Unsupported arch: $ARCH — STOP and report"; ;; +esac +echo "Will install act_runner for: $RUNNER_ARCH" + +# 3. Confirm Gitea is running +curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3300/ # Expected: 200 or 302 + +# 4. Confirm Gitea Actions is enabled (check the API exposes the Actions endpoint) +curl -s http://localhost:3300/api/v1/version | head -c 200; echo +# Expected: a JSON {"version":"1.21+ ..."} — Actions requires Gitea 1.21+ + +# 5. Confirm Gitea npm registry endpoint reachable +curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3300/api/packages/bytelyst/npm/ # Expected: 200 or 401 + +# 6. Confirm Docker is installed and the daemon is running +docker --version 2>/dev/null && docker info >/dev/null 2>&1 && echo "Docker OK" \ + || echo "ERROR: Docker not installed or daemon not running — REQUIRED for act_runner" + +# 7. Disk free +df -h / # Need ~10 GB headroom (Docker images + workspace caches) + +# 8. Confirm no existing act_runner +ls -la ~/act_runner 2>/dev/null && echo "act_runner dir exists — STOP and confirm with human" \ + || echo "No existing act_runner — good" + +# 9. Confirm Gitea admin or an account with admin scope exists (need it for registration token) +# Either look up the admin password the human has, or have them pre-create an admin token. + +# 10. Confirm the Gitea publish token file exists on this VM +sudo find /home /root -maxdepth 3 -name ".gitea_npm_token" 2>/dev/null | head -5 +# Expected: at least one path. Note the owning user — needed in Step 5. +``` + +If any check fails or surprises you, **stop and report back** before proceeding. + +--- + +## 3. What you'll create + +| Item | Path/Identifier | +| -------------------------- | ------------------------------------------------------------------ | +| Dedicated Linux user | `gitea-runner` | +| Binary install path | `/usr/local/bin/act_runner` | +| Runner config + data dir | `/home/gitea-runner/act_runner/` | +| systemd service | `gitea-act-runner.service` | +| Runner labels | `ubuntu-latest:docker://node:20-bookworm,linux,bytelyst,hostinger` | +| Gitea publish token (copy) | `/home/gitea-runner/.gitea_npm_token` (mode 600) | +| Smoke test workflow | `.gitea/workflows/runner-smoke.yml` (on a branch) | +| E2E test workflow | `.gitea/workflows/runner-e2e-publish.yml` (on a branch) | + +The label `ubuntu-latest:docker://node:20-bookworm` is the magic that maps Gitea Actions' `runs-on: ubuntu-latest` to a deterministic Node 20 container image — every job runs in the same image regardless of host, giving byte-identical builds. + +--- + +## 4. Installation + +### Step 1 — Create the dedicated runner user (idempotent) + +```bash +if ! getent passwd gitea-runner >/dev/null; then + sudo useradd -m -s /bin/bash gitea-runner + echo "Created gitea-runner user" +else + echo "gitea-runner user already exists — skipping useradd" +fi + +# Docker access is required — act_runner shells into Docker for every job +sudo usermod -aG docker gitea-runner +id gitea-runner # confirm docker group membership +``` + +### Step 2 — Download and verify `act_runner` + +```bash +# Query latest release from Gitea's own act_runner repo +LATEST=$(curl -s https://gitea.com/api/v1/repos/gitea/act_runner/releases/latest \ + | grep -oE '"tag_name":"v[0-9.]+"' | head -1 | cut -d'"' -f4) +echo "Latest act_runner: $LATEST" +# Sanity check: should be vX.Y.Z with X >= 0 and a recent date. STOP and confirm if version looks wrong. + +# Download binary + checksum +cd /tmp +BIN_URL="https://gitea.com/gitea/act_runner/releases/download/${LATEST}/act_runner-${LATEST#v}-${RUNNER_ARCH}" +SHA_URL="${BIN_URL}.sha256" + +curl -fSL -o act_runner "${BIN_URL}" +curl -fSL -o act_runner.sha256 "${SHA_URL}" + +# Verify SHA256 +EXPECTED_SHA=$(awk '{print $1}' < act_runner.sha256) +ACTUAL_SHA=$(sha256sum act_runner | awk '{print $1}') +if [ "$EXPECTED_SHA" != "$ACTUAL_SHA" ]; then + echo "FAIL: SHA mismatch" + echo " Expected: $EXPECTED_SHA" + echo " Actual: $ACTUAL_SHA" + exit 1 +fi +echo "PASS: act_runner binary SHA verified" + +chmod +x act_runner +sudo mv act_runner /usr/local/bin/act_runner +/usr/local/bin/act_runner --version # confirm +``` + +### Step 3 — Get a registration token from Gitea + +Three places to register a runner in Gitea: + +| Scope | Where the token comes from | Coverage | +| -------------------------------- | ---------------------------------------------------- | ----------------------------------------- | +| **Instance-level** (recommended) | Site Admin → Actions → Runners → "Create new Runner" | All repos in this Gitea, current + future | +| Org-level | Org settings → Actions → Runners | All repos in one org | +| Repo-level | Repo settings → Actions → Runners | One repo only | + +**Recommended: instance-level.** Hand the token off to the codex shell: + +```bash +# Either: have the human paste it +read -s REG_TOKEN + +# Or: via Gitea API (requires admin token in GITEA_ADMIN_TOKEN env) +REG_TOKEN=$(curl -s -H "Authorization: token $GITEA_ADMIN_TOKEN" \ + -X GET "http://localhost:3300/api/v1/admin/runners/registration-token" \ + | grep -oE '"token":"[^"]+"' | cut -d'"' -f4) +echo "Got registration token (length: ${#REG_TOKEN})" +``` + +### Step 4 — Register the runner + +```bash +sudo -u gitea-runner -E -i bash < config.yaml + +# Patch key fields (use sed or rewrite). Important settings: +# - Use Docker mode (NOT host mode) so jobs run in a deterministic image +# - Use a labels map that pins ubuntu-latest -> node:20-bookworm +cat > config.yaml </dev/null | head -1) +echo "Copying from $SRC_TOKEN" + +sudo cp "$SRC_TOKEN" /home/gitea-runner/.gitea_npm_token +sudo chown gitea-runner:gitea-runner /home/gitea-runner/.gitea_npm_token +sudo chmod 600 /home/gitea-runner/.gitea_npm_token + +# Verify +sudo -u gitea-runner bash -c 'wc -c < ~/.gitea_npm_token && stat -c "%a %U:%G" ~/.gitea_npm_token' +``` + +### Step 6 — Install systemd service for auto-start + +```bash +sudo tee /etc/systemd/system/gitea-act-runner.service > /dev/null <<'EOF' +[Unit] +Description=Gitea Actions Runner (act_runner) +After=network-online.target docker.service +Wants=network-online.target +Requires=docker.service + +[Service] +Type=simple +User=gitea-runner +Group=gitea-runner +WorkingDirectory=/home/gitea-runner/act_runner +ExecStart=/usr/local/bin/act_runner daemon --config /home/gitea-runner/act_runner/config.yaml +Restart=always +RestartSec=10 + +# Resource limits — adjust to your VM +CPUQuota=200% +MemoryMax=4G +TasksMax=2048 + +[Install] +WantedBy=multi-user.target +EOF + +sudo systemctl daemon-reload +sudo systemctl enable --now gitea-act-runner.service + +sudo systemctl status gitea-act-runner.service +sudo journalctl -u gitea-act-runner.service -n 50 --no-pager +# Expected: "active (running)" + log line like "Runner registered successfully" + "Polling" +``` + +--- + +## 5. Smoke test (basic — runner picks up jobs) + +```bash +cd ~/code/mygh/learning_ai_common_plat +git checkout -b runner/gitea-smoke +mkdir -p .gitea/workflows +# (paste workflow below into .gitea/workflows/runner-smoke.yml) +git add .gitea/workflows/runner-smoke.yml +git commit -m "ci: add Gitea act_runner smoke-test workflow" +git push gitea runner/gitea-smoke +``` + +```yaml +# .gitea/workflows/runner-smoke.yml +name: Gitea Runner Smoke Test +on: + workflow_dispatch: + push: + branches: [runner/gitea-smoke] + +jobs: + smoke: + runs-on: [ubuntu-latest, bytelyst, hostinger] + steps: + - run: echo "host=$(hostname) user=$(whoami) image=$(cat /etc/os-release | grep ^NAME)" + - run: node --version && npm --version + - run: | + # Install pnpm fresh in the container + npm install -g pnpm@9 + pnpm --version + - run: | + # Use host network because gitea is on localhost:3300 outside the container + # NOTE: container network mode is bridged by default. To reach host's localhost, + # use host.docker.internal:3300 OR set container.network: host in config.yaml. + echo "Gitea health (from inside runner container):" + curl -s -o /dev/null -w " via host.docker.internal:3300 → %{http_code}\n" \ + http://host.docker.internal:3300/ || echo "(host.docker.internal not available; check network mode)" + - run: | + # Check whether the runner-injected token file is mounted (it's not by default; + # we'll mount it in the publish workflow via volumes:, not here) + ls -la / | head -30 +``` + +Trigger from Gitea UI (`Actions` tab → `Gitea Runner Smoke Test` → `Run workflow` on `runner/gitea-smoke`). + +> **If the localhost-from-container step fails:** add `--add-host=host.docker.internal:host-gateway` to your `container.options` in `config.yaml`, OR set `container.network: host` (less isolated but eliminates the issue entirely). For the publish workflow, host networking is the most reliable choice. + +--- + +## 6. End-to-end validation — proves the actual publish pipeline + +Same throwaway-package pattern as the GitHub Actions doc, but in Gitea Actions and with the byte-identical-tarball guarantee verified across both Giteas (the key invariant). + +### Create throwaway test package + +```bash +cd ~/code/mygh/learning_ai_common_plat +git checkout -b runner/gitea-e2e +mkdir -p packages/_runner-e2e-test +cat > packages/_runner-e2e-test/package.json <<'EOF' +{ + "name": "@bytelyst/_runner-e2e-test", + "version": "0.0.1", + "description": "Throwaway package for E2E validating the Hostinger Gitea runner. Safe to delete.", + "main": "index.js", + "files": ["index.js"] +} +EOF +echo "module.exports = { ok: true, builtAt: new Date().toISOString() };" \ + > packages/_runner-e2e-test/index.js +git add packages/_runner-e2e-test/ +git commit -m "test: throwaway package for Gitea runner E2E" +git push gitea runner/gitea-e2e +git push origin runner/gitea-e2e # also push to GitHub for parity +``` + +### E2E workflow + +```yaml +# .gitea/workflows/runner-e2e-publish.yml +name: Gitea Runner E2E — publish + integrity check +on: + workflow_dispatch: + inputs: + version: + description: 'Test version to publish' + required: true + default: '0.0.1-e2e.1' + +jobs: + publish: + runs-on: [ubuntu-latest, bytelyst, hostinger] + + # Mount the host's Gitea token AND use host networking so we can reach + # localhost:3300 directly (the Gitea instance on the Hostinger VM). + container: + image: node:20-bookworm + options: --network host -v /home/gitea-runner/.gitea_npm_token:/run/secrets/gitea_npm_token:ro + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install pnpm + run: npm install -g pnpm@9 + + - name: Set test version + working-directory: packages/_runner-e2e-test + run: | + npm version "${{ inputs.version }}" --no-git-tag-version --allow-same-version + cat package.json + + - name: Configure registry .npmrc + working-directory: packages/_runner-e2e-test + run: | + TOKEN=$(cat /run/secrets/gitea_npm_token) + cat > .npmrc < .npmrc < package.json <//actions` — every workflow run with status, logs, runner name. + +### b. Runner pool health (instance-wide) + +`http://gitea.bytelyst.com/-/admin/actions/runners` (admin only). Shows status (`Idle` / `Active` / `Offline`) and last seen. + +### c. Scripted monitoring + +```bash +# List runners +curl -s -H "Authorization: token $GITEA_ADMIN_TOKEN" \ + "http://localhost:3300/api/v1/admin/runners" | jq . + +# List recent workflow runs in a repo +curl -s -H "Authorization: token $GITEA_NPM_TOKEN" \ + "http://localhost:3300/api/v1/repos/bytelyst/learning_ai_common_plat/actions/tasks" | jq '.workflow_runs[] | {id, status, conclusion, created_at}' +``` + +### d. Host-side + +```bash +# Live tail +sudo journalctl -u gitea-act-runner.service -f + +# Per-job logs (act_runner stores them under workspace dir) +ls -la /home/gitea-runner/act_runner/.cache/ + +# systemd state +sudo systemctl status gitea-act-runner.service +``` + +--- + +## 8. Hardening + +Same systemd `[Service]` directives as in §4 Step 6 (CPUQuota, MemoryMax, TasksMax). Plus: + +```bash +# Log rotation +sudo tee /etc/logrotate.d/gitea-act-runner > /dev/null <<'EOF' +/var/log/journal/*/system.journal { + # journalctl handles its own rotation; nothing custom needed for systemd logs +} +EOF + +# Restrict the runner's Docker access — it should NOT be able to mount arbitrary host paths +# The config.yaml `valid_volumes: []` already restricts this. Verify. + +# Pin Docker image digest in config.yaml (optional but strongly recommended for production) +# Instead of: ubuntu-latest:docker://node:20-bookworm +# Use: ubuntu-latest:docker://node:20-bookworm@sha256: +``` + +--- + +## 9. Deliverables — report back to the human + +1. `sudo systemctl is-active gitea-act-runner.service` → output +2. Gitea UI confirmation: runner shows "Idle" green at `/-/admin/actions/runners` +3. Smoke test workflow URL (passed) +4. E2E test workflow URL (passed, all 6 pass criteria green) +5. Cross-Gitea SHA comparison output (must show ✅ PASS) +6. Installed versions: + ```bash + /usr/local/bin/act_runner --version + docker --version + docker run --rm node:20-bookworm node --version + ``` +7. Log paths: + - systemd: `journalctl -u gitea-act-runner.service` + - act_runner workspaces: `/home/gitea-runner/act_runner/.cache/` +8. Confirmation that cleanup happened: test version deleted from both Giteas, throwaway package removed, branch deleted. + +--- + +## 10. Guardrails + +- **Do not** run `act_runner` as root. +- **Do not** persist the registration token to disk — memory only during `register`. +- **Do not** use `container.network: host` for workflows that run untrusted code (host network gives the container full access to localhost services). For our publish workflow it's intentional and fine — the workflow only runs trusted code on tag pushes. +- **Do not** mount `/home/gitea-runner/.gitea_npm_token` into the smoke workflow; only the publish workflow needs it. +- **Do not** skip the cross-Gitea SHA comparison — it's the architectural invariant. +- **Do not** mark E2E as passed unless all 6 pass criteria succeed. +- **Do not** leave the throwaway `@bytelyst/_runner-e2e-test` package in either Gitea registry. + +--- + +## 11. Rollback + +```bash +sudo systemctl stop gitea-act-runner.service +sudo systemctl disable gitea-act-runner.service +sudo rm /etc/systemd/system/gitea-act-runner.service +sudo systemctl daemon-reload + +# Unregister from Gitea (need an admin token) +RUNNER_ID=$(curl -s -H "Authorization: token $GITEA_ADMIN_TOKEN" \ + "http://localhost:3300/api/v1/admin/runners" | grep -oE '"id":[0-9]+,"name":"hostinger-bytelyst-1"' | head -1 | grep -oE '[0-9]+' | head -1) +curl -X DELETE -H "Authorization: token $GITEA_ADMIN_TOKEN" \ + "http://localhost:3300/api/v1/admin/runners/$RUNNER_ID" + +sudo userdel -r gitea-runner +sudo rm /usr/local/bin/act_runner +``` + +--- + +## 12. Follow-up + +Once this runner passes all E2E criteria: + +1. Implement [`GITEA_PACKAGES_PUBLISH_WORKFLOW.md`](./GITEA_PACKAGES_PUBLISH_WORKFLOW.md) — the actual `publish-packages.yml` workflow for `@bytelyst/*` packages, modeled after the E2E template. +2. Propagate that workflow to all 20+ repos (script-driven). +3. Add a SKILL doc at `AI.dev/SKILLS/gitea-package-publishing.md`. + +--- + +## 13. Questions to ask the human BEFORE starting if anything is ambiguous + +- "Which Gitea instance am I registering with? (default: `http://localhost:3300`, the local Hostinger Gitea)" +- "What is the Gitea admin token, or can you generate a registration token from Site Admin → Actions?" +- "Confirm: registering at **instance level** (one runner, all repos)? Default yes." +- "What user currently owns `~/.gitea_npm_token` on this VM? (pre-flight check #10)" +- "Should I pin the Node image to a specific digest (e.g., `node:20.18.0-bookworm@sha256:...`) for build determinism, or use the floating tag for now?" +- "Are you OK with me creating a throwaway `@bytelyst/_runner-e2e-test` package, publishing it to both Giteas, and then deleting it as part of E2E validation?" + +If any of these are unclear, stop and ask before installing anything. diff --git a/docs/devops/HOSTINGER_GITHUB_RUNNER_SETUP.md b/docs/devops/HOSTINGER_GITHUB_RUNNER_SETUP.md index 0c35880d..20c9d904 100644 --- a/docs/devops/HOSTINGER_GITHUB_RUNNER_SETUP.md +++ b/docs/devops/HOSTINGER_GITHUB_RUNNER_SETUP.md @@ -1,5 +1,14 @@ # Hostinger VM — GitHub Actions Self-Hosted Runner Setup +> ⚠️ **PLAN B — not the recommended path.** +> The canonical path uses **Gitea Actions** (which you already have configured in `.gitea/workflows/` across all repos). See [`HOSTINGER_GITEA_ACT_RUNNER_SETUP.md`](./HOSTINGER_GITEA_ACT_RUNNER_SETUP.md) for the recommended setup, and [`GITEA_PACKAGES_PUBLISH_WORKFLOW.md`](./GITEA_PACKAGES_PUBLISH_WORKFLOW.md) for the publish pipeline. +> +> Why this doc exists: it documents a fully valid alternative — a self-hosted GitHub Actions runner on the Hostinger VM. Keep this if you ever decide to make GitHub Actions the canonical CI driver (e.g., to take advantage of the GitHub Actions ecosystem). For now, Gitea Actions is simpler because: +> +> - Workflows already exist in `.gitea/workflows/` for all 20+ repos. +> - No GitHub Organization migration needed (avoids Vercel/Netlify pricing concerns). +> - Both Gitea instances (corp Mac local + Hostinger) can publish independently from the same git tag, giving byte-identical tarballs without any sync script. +> > **Delegation prompt for the Codex agent running on the Hostinger VM.** > Read top-to-bottom before executing. Stop and ask the human if any pre-flight check fails or any deliverable is unclear.