name: Release on: push: tags: - 'v*' permissions: contents: write jobs: build-release: name: Build ${{ matrix.target }} runs-on: ${{ matrix.os }} continue-on-error: ${{ matrix.experimental }} strategy: fail-fast: false matrix: # v0.22.3 fix: aarch64-linux moved from ubuntu-latest + cross-linker # install (apt gcc-aarch64-linux-gnu consistently failed in CI) to # ubuntu-24.04-arm NATIVE ARM runner. No cross-compile, rustc builds # the target host-native. `experimental: false` — native path is # reliable. include: - os: ubuntu-latest target: x86_64-unknown-linux-gnu experimental: false - os: ubuntu-24.04-arm target: aarch64-unknown-linux-gnu experimental: false - os: macos-latest target: x86_64-apple-darwin experimental: false - os: macos-latest target: aarch64-apple-darwin experimental: false steps: # v0.19.1 supply-chain hardening (H5): all actions pinned by full # commit SHA; a floating tag like @v4 can be re-pointed by a # compromised maintainer (CVE-2025-30066 class). Version comment next # to each SHA is for human readability only — the SHA is load-bearing. - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 0 - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable # exception to SHA-pin: named-branch convention (validator V-2026-04-22) with: targets: ${{ matrix.target }} - name: Install mold linker (Linux only) if: contains(matrix.target, 'linux') run: | sudo apt-get update sudo apt-get install -y mold clang - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 with: workspaces: _primitives/_rust # v0.22.3: cross-linker step removed — aarch64-linux now builds # natively on ubuntu-24.04-arm. No cross-compile, no gcc-aarch64-linux-gnu. - name: Build workspace (release) working-directory: _primitives/_rust run: cargo build --workspace --release --target ${{ matrix.target }} - name: Package binaries id: package working-directory: _primitives/_rust/target/${{ matrix.target }}/release shell: bash run: | set -euo pipefail # Collect every Cargo-built executable (Linux + macOS: no ext, mode +x). # Portable across GNU + BSD find: iterate, test executability in shell. BINS=() for f in *; do [ -f "$f" ] || continue case "$f" in *.d|*.rlib|*.rmeta|*.so|*.dylib|*.dSYM) continue ;; esac if [ -x "$f" ]; then BINS+=("$f") fi done if [ "${#BINS[@]}" -eq 0 ]; then echo "::error::no release binaries produced for ${{ matrix.target }}" exit 1 fi echo "Binaries found: ${BINS[*]}" ARCHIVE="keisei-${{ matrix.target }}.tar.gz" tar czf "$GITHUB_WORKSPACE/$ARCHIVE" "${BINS[@]}" cd "$GITHUB_WORKSPACE" if command -v sha256sum >/dev/null 2>&1; then sha256sum "$ARCHIVE" > "$ARCHIVE.sha256" else shasum -a 256 "$ARCHIVE" > "$ARCHIVE.sha256" fi echo "archive=$ARCHIVE" >> "$GITHUB_OUTPUT" - name: Upload artifact uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: binaries-${{ matrix.target }} path: | keisei-${{ matrix.target }}.tar.gz keisei-${{ matrix.target }}.tar.gz.sha256 if-no-files-found: error # v0.18 Phase 1 (exobrain): compile @keisei/mcp-server to a single static # binary for 5 platforms via `bun build --compile`. Runs in parallel with # build-release; the release job below `needs:` both. Linux arm64 is kept # `continue-on-error` because the ubuntu arm runner pool is newer and # occasionally flaky — a missing linux-arm64 asset must NOT block release. build-mcp-binary: # v0.22.2 fix: `macos-13` Intel runners were deprecated by GitHub and the # pool is dry — `darwin-x64` jobs sit in queued for hours and block the # final `release` job (needs: build-mcp-binary). bun supports # cross-compile to every target from any host, so we consolidate every # bun build onto ubuntu-latest. Faster, no macOS quota cost, no runner # starvation. Binaries are still native per-target (bun produces the # correct Mach-O / ELF / PE format via --target). name: Build mcp-server ${{ matrix.target.platform }}-${{ matrix.target.arch }} runs-on: ubuntu-latest strategy: fail-fast: false matrix: target: - { platform: linux, arch: x64, bun_target: bun-linux-x64, ext: '' } - { platform: linux, arch: arm64, bun_target: bun-linux-arm64, ext: '' } - { platform: darwin, arch: x64, bun_target: bun-darwin-x64, ext: '' } - { platform: darwin, arch: arm64, bun_target: bun-darwin-arm64, ext: '' } - { platform: windows, arch: x64, bun_target: bun-windows-x64, ext: '.exe' } steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Install bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: latest # v0.19.1 supply-chain hardening (H4): lockfile is REQUIRED — the # `|| bun install` fallback was removed so a missing bun.lock fails # the build instead of resolving deps fresh against the live npm # registry (tainted-binary window). bun.lock lives at workspace # root (_ts_packages/bun.lock) — bun is a monorepo tool and tracks # all packages/* from one lockfile. See BUILD.md §Lockfile. - name: Install mcp-server deps shell: bash working-directory: _ts_packages run: bun install --frozen-lockfile - name: Compile single-binary shell: bash env: BIN_NAME: kei-mcp-server-${{ matrix.target.platform }}-${{ matrix.target.arch }}${{ matrix.target.ext }} run: | set -euo pipefail mkdir -p dist bun build \ --compile \ --target=${{ matrix.target.bun_target }} \ _ts_packages/packages/mcp-server/src/index.ts \ --outfile "dist/${BIN_NAME}" ls -la "dist/${BIN_NAME}" - name: Compute sha256 shell: bash env: BIN_NAME: kei-mcp-server-${{ matrix.target.platform }}-${{ matrix.target.arch }}${{ matrix.target.ext }} run: | set -euo pipefail cd dist if command -v sha256sum >/dev/null 2>&1; then sha256sum "${BIN_NAME}" > "${BIN_NAME}.sha256" else shasum -a 256 "${BIN_NAME}" > "${BIN_NAME}.sha256" fi cat "${BIN_NAME}.sha256" - name: Upload artifact uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: kei-mcp-server-${{ matrix.target.platform }}-${{ matrix.target.arch }} path: | dist/kei-mcp-server-${{ matrix.target.platform }}-${{ matrix.target.arch }}${{ matrix.target.ext }} dist/kei-mcp-server-${{ matrix.target.platform }}-${{ matrix.target.arch }}${{ matrix.target.ext }}.sha256 if-no-files-found: error release: name: Publish GitHub Release needs: [build-release, build-mcp-binary] runs-on: ubuntu-latest steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 0 - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable # exception to SHA-pin: named-branch convention (validator V-2026-04-22) - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 with: workspaces: _primitives/_rust - name: Build kei-changelog working-directory: _primitives/_rust run: cargo build --release -p kei-changelog - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: path: dist/ - name: Flatten artifacts run: | set -euo pipefail mkdir -p release-assets # Rust tarballs + sha256 sums from build-release matrix. # MCP-server bare binaries (+ .exe on windows) + sha256 sums from # build-mcp-binary matrix. Bare binaries need a stable name to stay # USB-drive-droppable, so no archive — we ship them raw alongside # the tarballs. find dist -type f \( \ -name '*.tar.gz' \ -o -name '*.sha256' \ -o -name 'kei-mcp-server-*' \ \) -exec mv {} release-assets/ \; ls -la release-assets - name: Generate release notes (kei-changelog) id: notes run: | set -euo pipefail TAG="${GITHUB_REF_NAME}" PREV="$(git tag --sort=-creatordate | grep -v "^${TAG}$" | head -n1 || true)" echo "Current tag: ${TAG}" echo "Previous tag: ${PREV:-}" if [ -n "${PREV}" ]; then NOTES="$(./_primitives/_rust/target/release/kei-changelog \ --from "${PREV}" --to "${TAG}" --version "${TAG}")" else NOTES="$(./_primitives/_rust/target/release/kei-changelog \ --to "${TAG}" --version "${TAG}")" fi if [ -z "${NOTES}" ]; then NOTES="Release ${TAG}. No conventional-commit entries found in range." fi { echo 'notes<> "$GITHUB_OUTPUT" # v0.22.3 fix: softprops/action-gh-release v2.6.2 exited with failure # on v0.22.2 due to a metadata-update race (asset uploaded to blob # store but Releases metadata API returned 404 on the subsequent # PATCH — eventual-consistency window). All 15 assets WERE uploaded, # but the action exited 1 and left the Release in Draft state. # # Replaced with `gh release create` (bundled on all GitHub runners). # CLI is idempotent: if the release already exists it updates it; if # assets already exist `--clobber` replaces them. No metadata-PATCH # race. Retry loop on transient upload failures. - name: Publish GitHub Release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAG: ${{ github.ref_name }} NOTES: ${{ steps.notes.outputs.notes }} shell: bash run: | set -euo pipefail # Create the release if missing; `|| true` absorbs "already exists" # on workflow re-run. gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1 || \ gh release create "$TAG" \ --repo "$GITHUB_REPOSITORY" \ --title "$TAG" \ --notes "$NOTES" # Upload all assets with --clobber so re-runs replace cleanly. # Retry each asset up to 3 times on transient network errors. shopt -s nullglob for f in release-assets/*.tar.gz release-assets/*.sha256 release-assets/kei-mcp-server-*; do [ -f "$f" ] || continue for try in 1 2 3; do if gh release upload "$TAG" --repo "$GITHUB_REPOSITORY" --clobber "$f"; then break elif [ "$try" -eq 3 ]; then echo "::error::failed to upload $f after 3 tries" >&2 exit 1 else echo "upload of $f failed (attempt $try/3), retrying in 5s..." >&2 sleep 5 fi done done echo "✓ Release $TAG published with all assets" # ───────────────────────────────────────────────────────────────────── # npm publish — две независимые job'ы. # # PRIMARY: keigit.com (наш приватный Forgejo). Активируется когда # установлен secret KEIGIT_NPM_TOKEN. Forgejo требует # Basic-auth (`Authorization: Basic base64(user:token)`), # поэтому публикация через прямой curl PUT с manual payload — # npm CLI не умеет Basic для Forgejo packages API. # # FUTURE: registry.npmjs.org. Активируется когда установлен secret # NPM_TOKEN. Сейчас не подключено (secret не задан) — job # gracefully скипается. Оставлен для будущего публичного # хостинга когда захотим. # ───────────────────────────────────────────────────────────────────── npm-publish-keigit: name: Publish to keigit.com (primary) needs: release runs-on: ubuntu-latest steps: - name: Check KEIGIT_NPM_TOKEN presence id: have_token env: KEIGIT_NPM_TOKEN: ${{ secrets.KEIGIT_NPM_TOKEN }} run: | if [ -n "${KEIGIT_NPM_TOKEN:-}" ]; then echo "present=1" >> "$GITHUB_OUTPUT" else echo "present=0" >> "$GITHUB_OUTPUT" echo "::notice::KEIGIT_NPM_TOKEN not set — skipping keigit publish gracefully" fi - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 if: steps.have_token.outputs.present == '1' - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 if: steps.have_token.outputs.present == '1' with: node-version: '20' - name: Install deps if: steps.have_token.outputs.present == '1' working-directory: _ts_packages run: npm ci - name: Build workspaces if: steps.have_token.outputs.present == '1' working-directory: _ts_packages run: npm run build --workspaces --if-present - name: Publish each package via curl PUT if: steps.have_token.outputs.present == '1' working-directory: _ts_packages env: KEIGIT_NPM_TOKEN: ${{ secrets.KEIGIT_NPM_TOKEN }} KEIGIT_NPM_USER: ${{ secrets.KEIGIT_NPM_USER }} run: | set -euo pipefail : "${KEIGIT_NPM_USER:?KEIGIT_NPM_USER secret required (e.g. 'Parfionovich')}" B64_AUTH=$(printf '%s' "${KEIGIT_NPM_USER}:${KEIGIT_NPM_TOKEN}" | base64 -w0) for pkg in packages/*/; do [ -f "$pkg/package.json" ] || continue pkgname=$(jq -r '.name' "$pkg/package.json") version=$(jq -r '.version' "$pkg/package.json") short=$(echo "$pkgname" | cut -d/ -f2) echo "::group::publish $pkgname@$version → keigit" ( cd "$pkg" npm pack >/dev/null tarball="keisei-${short}-${version}.tgz" [ -f "$tarball" ] || { echo "::warning::tarball $tarball missing"; exit 0; } data=$(base64 -w0 "$tarball") shasum=$(sha1sum "$tarball" | awk '{print $1}') integrity="sha512-$(sha512sum "$tarball" | awk '{print $1}' | xxd -r -p | base64 -w0)" size=$(stat -c '%s' "$tarball") jq -n \ --arg name "$pkgname" --arg version "$version" \ --arg tarball "https://keigit.com/api/packages/keisei/npm/%40keisei%2F${short}/-/${version}/${short}-${version}.tgz" \ --arg shasum "$shasum" --arg integrity "$integrity" \ --arg data "$data" --argjson length "$size" \ --arg attach "${short}-${version}.tgz" --slurpfile pkg package.json \ '{ _id: $name, name: $name, "dist-tags": {latest: $version}, versions: { ($version): ($pkg[0] + {_id: ($name + "@" + $version), dist: {tarball: $tarball, shasum: $shasum, integrity: $integrity}}) }, _attachments: ({} | .[$attach] = { content_type:"application/octet-stream", data:$data, length:$length }) }' > payload.json http=$(curl -sS -X PUT "https://keigit.com/api/packages/keisei/npm/@keisei%2F${short}" \ -H "Authorization: Basic ${B64_AUTH}" -H "Content-Type: application/json" \ --data-binary @payload.json -o resp.txt -w "%{http_code}") if [ "$http" = "201" ]; then echo "$pkgname@$version → keigit OK" elif [ "$http" = "409" ] || grep -q "already exists" resp.txt 2>/dev/null; then echo "::warning::$pkgname@$version already published (skipping)" else echo "::error::$pkgname@$version → HTTP $http" cat resp.txt exit 1 fi rm -f "$tarball" payload.json resp.txt ) echo "::endgroup::" done npm-publish-npmjs: name: Publish to registry.npmjs.org (future, gracefully skipped) needs: release runs-on: ubuntu-latest # FUTURE: добавит публичный хостинг через npmjs параллельно keigit. # Сейчас secret NPM_TOKEN не установлен → job просто скипается. # Когда захотим подключить — добавить secret NPM_TOKEN с # https://www.npmjs.com/settings//tokens, scope=Automation. steps: - name: Check NPM_TOKEN presence id: have_token env: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} run: | if [ -n "${NPM_TOKEN:-}" ]; then echo "present=1" >> "$GITHUB_OUTPUT" else echo "present=0" >> "$GITHUB_OUTPUT" echo "::notice::NPM_TOKEN not set — skipping npmjs publish gracefully (keigit publish is primary)" fi - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 if: steps.have_token.outputs.present == '1' - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 if: steps.have_token.outputs.present == '1' with: node-version: '20' registry-url: 'https://registry.npmjs.org' - name: Install deps if: steps.have_token.outputs.present == '1' working-directory: _ts_packages run: npm ci - name: Build workspaces if: steps.have_token.outputs.present == '1' working-directory: _ts_packages run: npm run build --workspaces --if-present - name: Publish each package via npm CLI (override registry) if: steps.have_token.outputs.present == '1' working-directory: _ts_packages env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: | set -euo pipefail for pkg in packages/*/; do if [ -f "$pkg/package.json" ]; then echo "::group::publish $pkg → npmjs" # --registry overrides publishConfig.registry (keigit) for this run. ( cd "$pkg" && npm publish --access public --registry=https://registry.npmjs.org ) \ || echo "::warning::npmjs publish failed for $pkg (continuing)" echo "::endgroup::" fi done