learning_ai_common_plat/docs/devops/gitea-runner/PUBLISH_WORKFLOW.md
saravanakumardb1 7381d0f5c0 docs(devops): group Gitea runner docs under gitea-runner/ subfolder
Moves 5 related docs into docs/devops/gitea-runner/ to keep this
multi-doc workstream from colliding with future roadmaps and
delegation prompts in docs/devops/.

Renames:
  HOSTINGER_GITEA_RUNNER_ROADMAP.md     -> ROADMAP.md
  HOSTINGER_GITEA_ACT_RUNNER_SETUP.md   -> ACT_RUNNER_SETUP.md
  GITEA_PACKAGES_PUBLISH_WORKFLOW.md    -> PUBLISH_WORKFLOW.md
  HOSTINGER_GITHUB_RUNNER_SETUP.md      -> _PLAN_B_GITHUB_RUNNER.md
  CODEX_DELEGATION_PROMPT.md            -> (same name, moved)

All internal cross-links updated via sed sweep. Verified no stale
references remain.

Adds README.md in the new folder as the index + pattern doc for
future multi-doc workstreams (one-liner handoff, file map,
architecture summary).

Updated one-liner handoff path:
  Read docs/devops/gitea-runner/CODEX_DELEGATION_PROMPT.md ...
2026-05-24 18:33:45 -07:00

16 KiB

@bytelyst/* Package Publish Workflow (Gitea Actions)

📋 Track progress in the master roadmap: ROADMAP.md — Phase 4 references this doc. Codex updates the roadmap checkboxes as each step here completes.

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.

Companions:


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.

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

# 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:

# 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:

# 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:

#!/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)

# 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:

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:
    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 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?"