# `@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 < 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:` 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/ 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?"