# `@bytelyst/*` Package Publish Workflow (Gitea Actions)

> 📋 **Track progress in the master roadmap:** [`ROADMAP.md`](./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:
>
> - [`ROADMAP.md`](./ROADMAP.md) — execution tracker.
> - [`ACT_RUNNER_SETUP.md`](./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 `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?"
