# Hostinger VM โ€” Gitea Actions `act_runner` Setup > ๐Ÿ“‹ **Track progress in the master roadmap:** [`ROADMAP.md`](./ROADMAP.md). The roadmap has the checklist; this doc has the implementation detail. Codex updates checkboxes + commit hashes in the roadmap as each phase completes. > **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: > > - [`ROADMAP.md`](./ROADMAP.md) โ€” **execution tracker (this doc's checklist)**. > - [`PUBLISH_WORKFLOW.md`](./PUBLISH_WORKFLOW.md) โ€” the publish workflow this runner executes. > - [`_PLAN_B_GITHUB_RUNNER.md`](./_PLAN_B_GITHUB_RUNNER.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 [`PUBLISH_WORKFLOW.md`](./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.