# 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. --- ## 1. Goal Set up a GitHub Actions self-hosted runner on the Hostinger VM that can: 1. Receive workflow triggers from **all 20+ `@bytelyst/*` repos** (see §8 for the org-vs-repo registration decision). 2. Build `@bytelyst/*` npm packages from tagged releases. 3. Publish them to **the local Gitea instance on this VM** (`http://localhost:3300/api/packages/bytelyst/npm/`). 4. Upload the same tarballs as **GitHub Release assets** so a corp-network Mac can sync them into its own local Gitea (via the separate `bytelyst-sync` script described in a follow-up prompt). Self-hosted on Hostinger beats GitHub-hosted runners because: - No GitHub Actions minute cap. - Gitea is on `localhost` from this VM → zero-latency publish, no public TLS needed. - VM is always on; runner is reachable indefinitely. ### Multi-repo registration decision **Decide before Step 2** whether this runner serves a single repo or all 20+: | Approach | Registration scope | Use when | | ------------------------------------ | ------------------------- | -------------------------------------------------------------------------------------------------------------- | | **Repo-level** (default in this doc) | Single repo URL | You're validating the runner first, or you only have 1–2 repos publishing packages | | **Org-level** (recommended at scale) | A GitHub Organization URL | You've migrated 20+ repos under one org — see §8 for migration steps. One registration, all org repos eligible | The install steps are identical — only the `--url` flag in `config.sh` (Step 3) changes. --- ## 2. Pre-flight checks (run first, do not skip) ```bash # 1. Confirm Linux VM hostname && uname -a # Expected: Linux # 2. Confirm Gitea is running locally curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3300/ # Expected: 200 or 302 # 3. 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 # 4. Confirm Node 20 and pnpm 9 installable node --version 2>/dev/null || echo "Node not installed (will install in Step 5)" pnpm --version 2>/dev/null || echo "pnpm not installed (will install in Step 5)" # 5. Confirm gh CLI exists gh --version 2>/dev/null || echo "gh CLI not installed (will install in Step 5)" # 6a. Detect architecture (used for runner tarball selection in Step 3) ARCH=$(uname -m) case "$ARCH" in x86_64) export RUNNER_ARCH="linux-x64";; aarch64) export RUNNER_ARCH="linux-arm64";; *) echo "Unsupported arch: $ARCH — STOP and report"; ;; esac echo "Will install runner for: $RUNNER_ARCH" # 6b. Disk free df -h / # Need ~5 GB headroom # 7. Confirm no existing runner ls -la ~/actions-runner 2>/dev/null && echo "Runner dir exists — STOP and confirm with human" || echo "No existing runner" # 8. Confirm github.com reachable (both API and download CDN) curl -s -o /dev/null -w "api.github.com: %{http_code}\n" https://api.github.com/ curl -s -o /dev/null -w "objects.githubusercontent.com: %{http_code}\n" -L \ https://objects.githubusercontent.com/ # runner tarball download host # Expected: 200 / 403 (403 from CDN root is normal; what matters is non-network-error) # 9. Confirm the Gitea token file exists somewhere 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 6. # 10. Confirm gh CLI is auth'd as saravanakumardb1 (needed for registration token) gh auth status 2>&1 | grep -E "Logged in to|saravanakumardb1" | head -5 # If not logged in as saravanakumardb1, run: gh auth login (and pick saravanakumardb1) ``` If any check fails or surprises you, **stop and report back** before proceeding. --- ## 3. What you'll create | Item | Path/Identifier | | -------------------------- | -------------------------------------------------------------------------------------- | | Dedicated Linux user | `gha-runner` | | Runner installation | `/home/gha-runner/actions-runner/` | | systemd service | `actions.runner.saravanakumardb1-learning_ai_common_plat.hostinger-bytelyst-1.service` | | Runner labels | `self-hosted, linux, x64, hostinger, bytelyst` | | Gitea publish token (copy) | `/home/gha-runner/.gitea_npm_token` (mode 600) | | Smoke-test workflow | `.github/workflows/runner-smoke.yml` (commit on a branch) | | E2E test workflow | `.github/workflows/runner-e2e-publish.yml` (commit on a branch) | --- ## 4. Installation ### Step 1 — Create the dedicated runner user (idempotent) ```bash if ! getent passwd gha-runner >/dev/null; then sudo useradd -m -s /bin/bash gha-runner echo "Created gha-runner user" else echo "gha-runner user already exists — skipping useradd" fi # Add to docker group only if docker is on this host if getent group docker >/dev/null; then sudo usermod -aG docker gha-runner echo "Added gha-runner to docker group" fi id gha-runner ``` ### Step 2 — Get a GitHub runner registration token (one-time, ~1h TTL) **Preferred (via `gh` CLI auth'd as `saravanakumardb1`):** ```bash gh api -X POST /repos/saravanakumardb1/learning_ai_common_plat/actions/runners/registration-token --jq .token ``` **Alternative (browser):** Open `https://github.com/saravanakumardb1/learning_ai_common_plat/settings/actions/runners/new?arch=x64&os=linux` and copy the token from the `./config.sh` command shown. Hold the token in shell memory only: ```bash read -s RUNNER_TOKEN # paste, press Enter (no echo) ``` ### Step 3 — Download, verify, configure the runner ```bash # Query the latest runner version (don't hardcode) LATEST=$(gh api /repos/actions/runner/releases/latest --jq '.tag_name' | sed 's/^v//') echo "Latest runner version: $LATEST" # As of writing this doc: 2.319.1. If LATEST is wildly different, STOP and confirm with human. sudo -iu gha-runner bash <`. If you can't verify the SHA, STOP and report — don't run unverified binaries. Register the runner. **Choose the URL based on your scope decision (§1):** ```bash # OPTION A — repo-level (default during validation) REGISTRATION_URL="https://github.com/saravanakumardb1/learning_ai_common_plat" # OPTION B — org-level (once you've migrated to an org per §8) # REGISTRATION_URL="https://github.com/" sudo -u gha-runner -E -i bash -c " cd ~/actions-runner && \ ./config.sh \ --url $REGISTRATION_URL \ --token $RUNNER_TOKEN \ --name hostinger-bytelyst-1 \ --labels self-hosted,linux,x64,hostinger,bytelyst \ --work _work \ --replace \ --unattended " unset RUNNER_TOKEN ``` Verify in GitHub UI: runner should show "Idle" green under `Settings → Actions → Runners`. ### Step 4 — Install as a systemd service ```bash sudo bash -c " cd /home/gha-runner/actions-runner && \ ./svc.sh install gha-runner && \ ./svc.sh start " SVC_NAME='actions.runner.saravanakumardb1-learning_ai_common_plat.hostinger-bytelyst-1.service' sudo systemctl status "$SVC_NAME" sudo journalctl -u "$SVC_NAME" -n 30 --no-pager # Expected: "active (running)" + "Listening for Jobs" ``` ### Step 5 — Install Node 20, pnpm 9, gh CLI system-wide ```bash # Node 20 via Nodesource curl -fsSL https://deb.nodesource.com/setup_20.x | sudo bash - sudo apt-get install -y nodejs # gh CLI (if not already) sudo apt-get install -y gh # or follow https://cli.github.com/manual/installation if 'gh' isn't in the apt repos # pnpm sudo npm install -g pnpm@9 # Verify all reachable from the runner user sudo -u gha-runner bash -c 'node --version && pnpm --version && gh --version' ``` ### Step 6 — Give the runner access to the local Gitea publish token ```bash # Identify the source token file (from pre-flight check #9) SRC_TOKEN=/home//.gitea_npm_token # Copy and lock down sudo cp "$SRC_TOKEN" /home/gha-runner/.gitea_npm_token sudo chown gha-runner:gha-runner /home/gha-runner/.gitea_npm_token sudo chmod 600 /home/gha-runner/.gitea_npm_token # Verify sudo -u gha-runner bash -c 'wc -c < ~/.gitea_npm_token && stat -c "%a %U:%G" ~/.gitea_npm_token' # Expected: nonzero byte count, mode 600, owner gha-runner:gha-runner ``` --- ## 5. Smoke test (basic — runner picks up jobs) Create branch `runner/smoke` in `learning_ai_common_plat` with the workflow below. ```bash cd ~/code/mygh/learning_ai_common_plat git checkout -b runner/smoke mkdir -p .github/workflows # (paste workflow below into .github/workflows/runner-smoke.yml) git add .github/workflows/runner-smoke.yml git commit -m "ci: add self-hosted runner smoke-test workflow" git push origin runner/smoke ``` ```yaml # .github/workflows/runner-smoke.yml name: Runner Smoke Test on: workflow_dispatch: push: branches: [runner/smoke] jobs: smoke: runs-on: [self-hosted, linux, hostinger] steps: - run: echo "host=$(hostname) user=$(whoami) cwd=$(pwd)" - run: node --version && pnpm --version && gh --version - run: | echo "Gitea health:" curl -s -o /dev/null -w " http://localhost:3300/ → %{http_code}\n" http://localhost:3300/ curl -s -o /dev/null -w " /api/packages/bytelyst/npm/ → %{http_code}\n" \ http://localhost:3300/api/packages/bytelyst/npm/ - run: | if [ -f ~/.gitea_npm_token ]; then echo "Gitea token present, $(wc -c < ~/.gitea_npm_token) bytes, mode $(stat -c %a ~/.gitea_npm_token)" else echo "ERROR: Gitea token missing" exit 1 fi ``` Trigger from GitHub UI (Actions tab → Runner Smoke Test → Run workflow on `runner/smoke`). All steps must pass. --- ## 6. End-to-end validation (CRITICAL — proves the actual use case) The smoke test only proves the runner can execute. The **E2E test** proves the **whole publish pipeline** works: package builds, publishes to local Gitea, uploads to GitHub Releases, and is installable on a different machine. ### E2E test workflow Create a **temporary test package** in `learning_ai_common_plat` (will be removed after validation): ```bash # On Hostinger or from human's machine — does NOT need to run on the runner 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 runner. Safe to delete after validation.", "main": "index.js", "files": ["index.js"] } EOF echo "module.exports = { ok: true, builtAt: new Date().toISOString() };" \ > packages/_runner-e2e-test/index.js git checkout -b runner/e2e git add packages/_runner-e2e-test/ git commit -m "test: add throwaway package for runner E2E validation" git push origin runner/e2e ``` Create the E2E workflow on the same branch: ```yaml # .github/workflows/runner-e2e-publish.yml name: Runner E2E — publish + release on: workflow_dispatch: inputs: version: description: 'Version to publish (semver)' required: true default: '0.0.1-e2e.1' jobs: publish: runs-on: [self-hosted, linux, hostinger] permissions: contents: write # for GitHub Release creation steps: - uses: actions/checkout@v4 - 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 pnpm registry for Gitea working-directory: packages/_runner-e2e-test run: | cat > .npmrc < .npmrc < package.json < /dev/null <<'EOF' /home/gha-runner/actions-runner/_diag/*.log { weekly rotate 4 compress missingok notifempty copytruncate } EOF ``` ### d. Update mechanism GitHub auto-updates the runner agent unless disabled. Keep auto-update on. Pin a minimum version in workflows if you need a specific feature: ```yaml runs-on: [self-hosted, linux, hostinger] ``` ### e. Auth scope of the runner The runner's `GITHUB_TOKEN` (provided by GitHub Actions automatically) is scoped per-workflow. Verify in `runner-e2e-publish.yml` that `permissions:` is set narrowly (we set `contents: write` only for release creation). --- ## 8. Scaling to all 20+ repos — GitHub Organization migration A single self-hosted runner can serve all 20+ `@bytelyst/*` repos **only if** registered at **GitHub Organization level**. The personal-account path (repo-level registration) doesn't scale beyond 1–3 repos. ### Why migrate to an org | Concern | Personal account today | Under an org | | -------------------------------- | ------------------------------------------ | -------------------------------------------------- | | Self-hosted runner reuse | One registration per repo | One registration covers all org repos | | Secrets management | Per-repo (duplicated) | Org-level secrets inherited by all repos | | Visibility | Per-repo Actions tabs (no cross-repo view) | Org-level Actions dashboard across all repos | | Permissions / team collaboration | Limited | Teams, code owners, etc. | | Cost | Free | Free for unlimited public + private repos | | Move cost | — | ~1–2 hours total for 20 repos (mostly automatable) | ### Migration steps (do these BEFORE Step 3 if going org-level from day 1) ```bash # 1. Create the org via the GitHub UI: # https://github.com/organizations/plan # Choose "Free" plan. Suggested name: bytelyst-platform (or whatever fits). # 2. Transfer each repo to the org (one-time, preserves all history + issues + stars) for repo in learning_ai_common_plat learning_ai_clock learning_ai_notes \ learning_ai_flowmonk learning_ai_trails learning_ai_jarvis_jr \ learning_ai_fastgap learning_ai_peakpulse learning_ai_efforise \ learning_ai_auth_app learning_voice_ai_agent learning_multimodal_memory_agents \ learning_ai_local_memory_gpt learning_ai_local_llms learning_ai_talk2obsidian \ learning_ai_mac_tooling learning_ai_productivity_web learning_ai_smart_auth; do echo "Transferring $repo..." gh api -X POST "/repos/saravanakumardb1/$repo/transfer" -f new_owner="" done # 3. Update your local clones to point to the new owner # (run on each machine, in each repo dir) cd ~/code/mygh/ git remote set-url origin https://github.com//.git ``` GitHub automatically sets up redirects from the old URLs, so external links won't break immediately — but you should update CI references, README badges, and any inter-repo URL references. ### After migration - Get a runner registration token at the **org level**: ```bash gh api -X POST /orgs//actions/runners/registration-token --jq .token ``` - Use the org URL in Step 3's `config.sh` (Option B above). - The runner now picks up jobs from any repo in the org that targets `runs-on: [self-hosted, hostinger, bytelyst]`. ### Workflow propagation across 20+ repos Once the runner is org-level, the next problem is propagating the `publish-packages.yml` workflow file to every repo that publishes packages. Two strategies: 1. **Reusable workflow** (preferred) — define `publish-packages.yml` once as a `workflow_call` reusable workflow in `learning_ai_common_plat/.github/workflows/`, then each consuming repo has a tiny stub that calls it. 2. **Per-repo copy maintained by a sync script** — follow the same pattern as the existing `sync-npmrc.sh` in `scripts/`. Less elegant but works fine for a small repo count. Deliver as a separate follow-up prompt. --- ## 9. Monitoring + observability — how to track this runner The GitHub Actions tab tracks runner state at three levels: ### a. Per-repo (or per-org) workflow runs `https://github.com///actions` (or `/orgs//actions/` after migration) shows every workflow run with live-streaming logs. The "Set up job" step always logs: ``` Runner name: 'hostinger-bytelyst-1' Runner group name: 'Default' Machine name: 'hostinger-vm' ``` This is how you confirm the right runner picked up the job. ### b. Runner pool health - **Repo level:** `Settings → Actions → Runners` - **Org level:** `Org settings → Actions → Runners` Shows: status (`Idle` / `Active` / `Offline`), labels, OS, last connection time. This is where you debug "is my runner alive?". ### c. Scripted monitoring via `gh` CLI ```bash # Watch a specific run live gh run watch --repo / # List recent runs gh run list --repo / --limit 10 # View finished run with full logs gh run view --log --repo / # List runners + their status (admin scope required) gh api /repos///actions/runners \ --jq '.runners[] | {name, status, busy, labels: [.labels[].name]}' # Or at org level: gh api /orgs//actions/runners \ --jq '.runners[] | {name, status, busy}' ``` ### d. Host-side observability (on the Hostinger VM) ```bash SVC_NAME='actions.runner.saravanakumardb1-learning_ai_common_plat.hostinger-bytelyst-1.service' # Live tail sudo journalctl -u "$SVC_NAME" -f # Last 100 lines sudo journalctl -u "$SVC_NAME" -n 100 --no-pager # Per-run diagnostic logs ls -la /home/gha-runner/actions-runner/_diag/ # Current systemd state sudo systemctl status "$SVC_NAME" ``` Use host-side logs when the runner shows "Offline" in GitHub UI but the VM is reachable — typically a daemon crash, expired registration, or network blip. --- ## 10. Deliverables — report back to the human When complete: 1. **Service status:** ```bash sudo systemctl is-active "$SVC_NAME" ``` 2. **GitHub UI confirmation:** screenshot or text "Runner shows 'Idle' green at github.com/saravanakumardb1/learning_ai_common_plat/settings/actions/runners". 3. **Smoke test run URL** — workflow passed. 4. **E2E test run URL** — workflow passed, all 6 pass criteria green. 5. **Installed versions:** ```bash sudo -u gha-runner bash -c 'node --version; pnpm --version; gh --version; docker --version 2>/dev/null || echo "no docker"' ``` 6. **Log paths:** - systemd: `journalctl -u ` - runner diag: `/home/gha-runner/actions-runner/_diag/` 7. **Confirmation that cleanup happened:** test release deleted, test package removed from Gitea, throwaway package deleted from repo. --- ## 11. Guardrails - **Do not** run the runner as root. - **Do not** persist the GitHub registration token to disk — memory only. - **Do not** install under the `gitea` user or any other service user — keep concerns separated. - **Do not** open inbound ports on the VM firewall — the runner is outbound-only long-poll. - **Do not** skip the E2E test. The smoke test alone does not prove the publish pipeline works. - **Do not** mark E2E as passed unless all 6 pass criteria succeed, especially the byte-identical tarball check. - **Do not** leave the throwaway `@bytelyst/_runner-e2e-test` package in the Gitea registry — it pollutes the namespace. --- ## 12. Rollback ```bash SVC_NAME='actions.runner.saravanakumardb1-learning_ai_common_plat.hostinger-bytelyst-1.service' # Stop and uninstall systemd service sudo bash -c "cd /home/gha-runner/actions-runner && ./svc.sh stop && ./svc.sh uninstall" # Unregister from GitHub (need a fresh removal token) REMOVAL_TOKEN=$(gh api -X POST /repos/saravanakumardb1/learning_ai_common_plat/actions/runners/remove-token --jq .token) sudo -u gha-runner bash -c "cd ~/actions-runner && ./config.sh remove --token $REMOVAL_TOKEN" # Remove the user and all its files sudo userdel -r gha-runner ``` --- ## 13. Follow-up prompts (separate tasks) Once this runner is verified end-to-end, the next prompts to issue: 1. **`publish-packages.yml`** — the real production workflow in `learning_ai_common_plat`, modeled on the E2E template above, that triggers on `v*` tags and publishes all changed `@bytelyst/*` packages. 2. **`bytelyst-sync` script** — runs on the corp Mac; downloads GitHub Release tarballs and republishes to the corp local Gitea. Verifies sha256 against Gitea before considering sync successful. 3. **SKILL doc** at `AI.dev/SKILLS/gitea-package-sync.md` — describes the full three-system flow for future contributors. --- ## 14. Questions to ask the human BEFORE starting if anything is ambiguous - "Are we registering this runner at **repo level** (one repo only) or **org level** (after migrating 20+ repos to a GitHub org)? See §1 and §8." - "If org-level: what is the org name? Has the migration in §8 already happened?" - "If repo-level: which repo am I registering for? (default: `saravanakumardb1/learning_ai_common_plat`)" - "Is Docker required on the runner — i.e., does any planned workflow run `docker` commands? (default: no, only Gitea uses Docker)" - "What user currently owns `~/.gitea_npm_token` on this VM? (pre-flight check #9 will tell us)" - "Do you have a runner registration token, or should I fetch one via `gh api`?" - "Are you OK with me creating a throwaway `@bytelyst/_runner-e2e-test` package, publishing it, and then deleting it as part of E2E validation?" If any of these are unclear, stop and ask before installing anything.