docs(devops): pivot to Gitea Actions as canonical CI; mark GitHub-runner doc as Plan B

Adds two new docs and a banner on the existing GitHub-runner doc.

WHY: the user already has Gitea Actions configured across all 20+
repos (.gitea/workflows/ci.yml). Building a parallel GitHub Actions
self-hosted runner pipeline is unnecessary work that also drags in
GitHub Organization migration pressure (with Vercel/Netlify pricing
side-effects on free tiers).

The canonical architecture instead:
- Each Gitea instance (corp Mac local + Hostinger VM) runs its own
  act_runner.
- A single publish-packages.yml workflow lives in every package-
  publishing repo.
- When the same git tag is pushed to both Giteas, each one builds
  inside the same pinned Docker image (node:20-bookworm@sha256:...)
  with the same lockfile, producing BYTE-IDENTICAL tarballs.
- No sync script is needed; the shared git tag IS the sync mechanism.
- Lockfile integrity hashes match across both registries, so corp Mac
  and personal Mac + Hostinger prod all see the same packages.

New: HOSTINGER_GITEA_ACT_RUNNER_SETUP.md
  - Codex-actionable prompt to install act_runner on the Hostinger VM
  - Pre-flight checks (arch detection, Docker daemon, Gitea reachable)
  - Idempotent user creation, SHA-verified binary download
  - Docker mode runner config with labels mapping ubuntu-latest to
    pinned Node image
  - Smoke test + full E2E with throwaway @bytelyst/_runner-e2e-test
    package
  - The architectural invariant check: cross-Gitea SHA comparison —
    same tag pushed to both must produce identical tarballs
  - Monitoring (Gitea UI, API, systemd journal)
  - Hardening, rollback, deliverables, guardrails, questions

New: GITEA_PACKAGES_PUBLISH_WORKFLOW.md
  - The actual publish-packages.yml triggered by v* tags
  - Docker image pinned by digest for build determinism
  - pnpm@9.12.0 pinned, --frozen-lockfile, host-network container
  - Token mounted as read-only secret file (not env var)
  - Concurrency cancel-in-progress: false (never cancel a publish)
  - Pack tarballs + SHA512 manifest as Gitea Release assets for audit
    trail
  - Two propagation strategies: reusable workflow (preferred) vs
    sync-publish-workflow.sh script
  - Operator runbook for cutting a release
  - Failure-mode table + remediation
  - Deliverables checklist

Updated: HOSTINGER_GITHUB_RUNNER_SETUP.md
  - Added 'PLAN B' banner at the top
  - Cross-links to the Gitea Actions docs
  - Kept the doc intact as a valid alternative if priorities ever
    shift to making GitHub Actions the publish driver
This commit is contained in:
saravanakumardb1 2026-05-24 18:15:48 -07:00
parent 6bf15eae7a
commit d1bdcdd9a7
3 changed files with 1016 additions and 0 deletions

View File

@ -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 <<NPMRC
@bytelyst:registry=http://localhost:3300/api/packages/bytelyst/npm/
//localhost:3300/api/packages/bytelyst/npm/:_authToken=$TOKEN
NPMRC
echo "Configured registry:"
sed 's|_authToken=.*|_authToken=***|' /tmp/publish.npmrc
- name: Publish changed packages (dry-run if input flag set)
env:
DRY_RUN: ${{ inputs.dry_run }}
run: |
PUBLISH_FLAGS="--no-git-checks --userconfig /tmp/publish.npmrc"
if [ "$DRY_RUN" = "true" ]; then
echo "DRY RUN — not publishing"
pnpm -r --filter "@bytelyst/*" publish $PUBLISH_FLAGS --dry-run
else
pnpm -r --filter "@bytelyst/*" publish $PUBLISH_FLAGS
fi
- name: Pack tarballs for tag asset upload
run: |
mkdir -p /tmp/tarballs
for pkg in packages/*/; do
if [ -f "$pkg/package.json" ] && grep -q '"name": "@bytelyst/' "$pkg/package.json"; then
(cd "$pkg" && pnpm pack --pack-destination /tmp/tarballs)
fi
done
ls -la /tmp/tarballs
- name: Compute SHA512 manifest (for cross-Gitea verification)
run: |
cd /tmp/tarballs
sha512sum *.tgz > 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:<digest>` 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/<package>
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?"

View File

@ -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 <<EOF
mkdir -p ~/act_runner && cd ~/act_runner
# Generate default config and customize
act_runner generate-config > 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 <<YAML
log:
level: info
runner:
file: .runner
capacity: 2
envs:
NODE_OPTIONS: "--max-old-space-size=4096"
labels:
- "ubuntu-latest:docker://node:20-bookworm"
- "linux:docker://node:20-bookworm"
- "bytelyst:docker://node:20-bookworm"
- "hostinger:docker://node:20-bookworm"
cache:
enabled: true
dir: ".cache"
host: ""
port: 0
container:
network: bridge
privileged: false
options: ""
workdir_parent: ""
valid_volumes: []
docker_host: "unix:///var/run/docker.sock"
force_pull: false
host:
workdir_parent: ""
YAML
# Register
act_runner register --no-interactive \
--instance http://localhost:3300 \
--token "$REG_TOKEN" \
--name hostinger-bytelyst-1 \
--labels "ubuntu-latest:docker://node:20-bookworm,linux:docker://node:20-bookworm,bytelyst,hostinger" \
--config config.yaml
ls -la .runner config.yaml
EOF
unset REG_TOKEN
```
Verify in Gitea UI: `Site Admin → Actions → Runners`. New runner shows "Idle".
### Step 5 — Give the runner the Gitea publish token
```bash
SRC_TOKEN=$(sudo find /home /root -maxdepth 3 -name ".gitea_npm_token" 2>/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
@bytelyst:registry=http://localhost:3300/api/packages/bytelyst/npm/
//localhost:3300/api/packages/bytelyst/npm/:_authToken=$TOKEN
NPMRC
echo ".npmrc (token masked):"
sed 's|_authToken=.*|_authToken=***|' .npmrc
- name: Publish to local Gitea
working-directory: packages/_runner-e2e-test
run: pnpm publish --no-git-checks --registry http://localhost:3300/api/packages/bytelyst/npm/
- name: Pack tarball for hash comparison
working-directory: packages/_runner-e2e-test
run: pnpm pack --pack-destination /tmp
- name: Verify package returned by Gitea
run: |
TOKEN=$(cat /run/secrets/gitea_npm_token)
RESP=$(curl -s -H "Authorization: Bearer $TOKEN" \
"http://localhost:3300/api/packages/bytelyst/npm/@bytelyst%2F_runner-e2e-test")
echo "$RESP" | head -300
echo "$RESP" | grep -q '"${{ inputs.version }}"' || { echo "FAIL: version missing from Gitea"; exit 1; }
echo "PASS: version in Gitea"
- name: Verify pnpm install + require works from a clean dir
run: |
TOKEN=$(cat /run/secrets/gitea_npm_token)
mkdir -p /tmp/runner-e2e-consumer && cd /tmp/runner-e2e-consumer
cat > .npmrc <<NPMRC
@bytelyst:registry=http://localhost:3300/api/packages/bytelyst/npm/
//localhost:3300/api/packages/bytelyst/npm/:_authToken=$TOKEN
NPMRC
cat > package.json <<JSON
{ "name": "consumer", "version": "1.0.0", "dependencies": { "@bytelyst/_runner-e2e-test": "${{ inputs.version }}" } }
JSON
pnpm install --no-frozen-lockfile
node -e "const m=require('@bytelyst/_runner-e2e-test'); console.log(m); process.exit(m.ok?0:1);"
echo "PASS: install + require works"
- name: Compute tarball SHA512 (will be compared against corp Gitea after sync)
run: |
TARBALL=$(ls /tmp/bytelyst-_runner-e2e-test-*.tgz)
echo "Tarball: $TARBALL"
sha512sum "$TARBALL" | tee /tmp/hostinger-tarball.sha512
# ALSO record the SHA512 reported by Gitea registry — these must match
TOKEN=$(cat /run/secrets/gitea_npm_token)
GITEA_SHA=$(curl -s -H "Authorization: Bearer $TOKEN" \
"http://localhost:3300/api/packages/bytelyst/npm/@bytelyst%2F_runner-e2e-test/${{ inputs.version }}" \
| grep -oE '"shasum":"[^"]*"' | head -1 | cut -d'"' -f4)
echo "Gitea-reported shasum (SHA1): $GITEA_SHA"
# Verify the locally-packed tarball matches what Gitea stored
LOCAL_SHA=$(sha1sum "$TARBALL" | awk '{print $1}')
[ "$LOCAL_SHA" = "$GITEA_SHA" ] || { echo "FAIL: pnpm pack tarball doesn't match Gitea-stored tarball"; exit 1; }
echo "PASS: byte-identical tarball stored in Gitea"
```
### Cross-Gitea byte-identical-tarball verification
This is the **architectural invariant** that makes the dual-Gitea model work. After Hostinger publishes `0.0.1-e2e.1`, the same tag should land on corp Mac's Gitea and produce **the same tarball SHA**.
```bash
# After Hostinger E2E completes, push the same branch to corp Gitea
# (assumes you're on a Mac that has both `gitea` (corp) and is connected to home network OR the human runs this on corp Mac)
cd ~/code/mygh/learning_ai_common_plat
git checkout runner/gitea-e2e
git push gitea runner/gitea-e2e # triggers corp Gitea Actions
# Wait for corp Mac Gitea Actions to finish publishing
# Then compare:
HOSTINGER_SHA=$(curl -s "http://gitea.bytelyst.com/api/packages/bytelyst/npm/@bytelyst%2F_runner-e2e-test/0.0.1-e2e.1" \
| 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%2F_runner-e2e-test/0.0.1-e2e.1" \
| grep -oE '"shasum":"[^"]*"' | head -1 | cut -d'"' -f4)
echo "Hostinger: $HOSTINGER_SHA"
echo "Corp: $CORP_SHA"
[ "$HOSTINGER_SHA" = "$CORP_SHA" ] && echo "✅ PASS: cross-Gitea byte-identical guarantee holds" \
|| echo "❌ FAIL: builds diverged — investigate Node/pnpm/lockfile version differences"
```
### E2E pass criteria — ALL must succeed
| Check | Where it lives |
| ---------------------------------------------------- | -------------------------------------------------------------- |
| Hostinger runner picks up the job | Gitea Actions UI, run logs show `runner: hostinger-bytelyst-1` |
| `pnpm publish` to Hostinger Gitea succeeds | Workflow step exits 0 |
| Gitea registry returns the new version | `Verify package returned by Gitea` step |
| Consumer `pnpm install` works | `Verify pnpm install + require works` step |
| Tarball stored in Gitea matches local pack | `Compute tarball SHA512` step's SHA1 match |
| **Hostinger tarball SHA1 = corp Gitea tarball SHA1** | Cross-Gitea comparison above |
The last one is the killer feature: same git tag → byte-identical tarballs on both Giteas → lockfiles work everywhere with no sync mechanism.
### Cleanup after E2E passes
```bash
# Delete the test version from BOTH Gitea instances (via UI: Packages → @bytelyst/_runner-e2e-test → Delete)
# Or via API:
curl -X DELETE -H "Authorization: token $(cat ~/.gitea_npm_token)" \
"http://localhost:3300/api/packages/bytelyst/npm/@bytelyst%2F_runner-e2e-test/-/@bytelyst%2F_runner-e2e-test-0.0.1-e2e.1.tgz"
# Delete the test branch
git checkout main
git push gitea --delete runner/gitea-e2e
git push origin --delete runner/gitea-e2e
rm -rf packages/_runner-e2e-test
git checkout main
```
Leave both workflow files (`runner-smoke.yml`, `runner-e2e-publish.yml`) on `main` — idempotent re-validation tools.
---
## 7. Monitoring + observability
### a. Gitea Actions UI (per repo)
`http://gitea.bytelyst.com/<owner>/<repo>/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:<exact-digest>
```
---
## 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 `<gitea>/-/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.

View File

@ -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.