From 8299346fbda24f9b33bbf3e6829396ee554fe0e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 9 Oct 2026 16:33:46 -0400 Subject: [PATCH] CI on Gitea: cap test workers so a shared runner does not time out; tag pushes build and attach Windows, Linux and macOS installers to the Gitea release; version 1.5.0 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release-build.yml | 100 +++++++++++++++------------- package.json | 2 +- scripts/gitea-release.mjs | 53 +++++++++++++++ vitest.config.ts | 2 + 4 files changed, 111 insertions(+), 46 deletions(-) create mode 100644 scripts/gitea-release.mjs diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index 5a5e22eb..13261e7e 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -1,28 +1,38 @@ name: Build Release Installers -# Builds Windows and macOS installers when a release is published and -# attaches them to it. macOS builds are signed and notarized only when the -# runner has the Developer ID identity and the APPLE_API_ISSUER and -# APPLE_API_KEY_P8 secrets are set; otherwise they are ad-hoc signed. +# A pushed v* tag builds Windows, Linux and macOS installers on the Gitea +# runners and attaches them to the tag's release. The tag must equal +# "v" + package.json version. macOS builds are signed and notarized when the +# Developer ID certificate and the APPLE_API_* secrets are present; otherwise +# they are ad-hoc signed. on: - release: - types: [published] - workflow_dispatch: - inputs: - tag: - description: "Release tag to attach installers to (back-fill)" - required: true + push: + tags: ["v*"] permissions: contents: write env: COREPACK_ENABLE_DOWNLOAD_PROMPT: "0" + GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }} jobs: + release: + name: Release for the tag + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 + with: + node-version: 24 + package-manager-cache: false + - name: Check version and create the release + run: node scripts/gitea-release.mjs create + windows: name: Windows installer (NSIS x64) + needs: release runs-on: windows-latest steps: - uses: actions/checkout@v5 @@ -31,27 +41,36 @@ jobs: node-version: 24 package-manager-cache: false - name: Enable pnpm via corepack - run: | - corepack enable pnpm - pnpm --version + run: corepack enable pnpm - name: Install dependencies run: pnpm install --frozen-lockfile - name: Build installer run: pnpm run package:win - - name: List artifacts - run: Get-ChildItem release - - name: Upload installer to release - env: - GH_TOKEN: ${{ github.token }} - TAG: ${{ github.event.release.tag_name || inputs.tag }} - run: | - $exts = @('.exe', '.dmg', '.zip', '.AppImage', '.deb') - $files = Get-ChildItem release -File | Where-Object { $exts -contains $_.Extension } - if (-not $files) { throw "no uploadable artifacts found in release/" } - gh release upload $env:TAG @($files.FullName) --clobber + - name: Upload installer + run: node scripts/gitea-release.mjs upload release + + linux: + name: Linux packages (AppImage + deb x64) + needs: release + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 + with: + node-version: 24 + package-manager-cache: false + - name: Enable pnpm via corepack + run: corepack enable pnpm + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Build packages + run: pnpm run package:linux + - name: Upload packages + run: node scripts/gitea-release.mjs upload release macos: name: macOS installers (dmg + zip, x64 + arm64) + needs: release runs-on: macos-latest steps: - uses: actions/checkout@v5 @@ -60,20 +79,20 @@ jobs: node-version: 24 package-manager-cache: false - name: Enable pnpm via corepack - run: | - corepack enable pnpm - pnpm --version + run: corepack enable pnpm - name: Install dependencies run: pnpm install --frozen-lockfile - name: Detect signing capability id: signing env: + CSC_LINK: ${{ secrets.CSC_LINK }} APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }} run: | - if security find-identity -v -p codesigning | grep -q "Developer ID Application: Yang Zhang (9PDD8M2SMS)" && [ -n "$APPLE_API_ISSUER" ] && [ -n "$APPLE_API_KEY_P8" ]; then + if { [ -n "$CSC_LINK" ] || security find-identity -v -p codesigning | grep -q "Developer ID Application: Yang Zhang (9PDD8M2SMS)"; } && [ -n "$APPLE_API_ISSUER" ] && [ -n "$APPLE_API_KEY_ID" ] && [ -n "$APPLE_API_KEY_P8" ]; then echo "signed=true" >> "$GITHUB_OUTPUT" - echo "Developer ID identity and notarization credentials found: building signed + notarized installers." + echo "Developer ID certificate and notarization credentials found: building signed + notarized installers." else echo "signed=false" >> "$GITHUB_OUTPUT" echo "::warning::Building ad-hoc-signed, un-notarized macOS installers." @@ -81,12 +100,15 @@ jobs: - name: Build installers (signed + notarized) if: steps.signing.outputs.signed == 'true' env: + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} APPLE_API_KEY: ${{ runner.temp }}/apple-api-key.p8 - APPLE_API_KEY_ID: ZDCSZ5IE7071 + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }} run: | printf '%s' "$APPLE_API_KEY_P8" > "$APPLE_API_KEY" + trap 'rm -f "$APPLE_API_KEY"' EXIT pnpm build pnpm exec electron-builder --mac --x64 --arm64 --publish never -c.forceCodeSigning=true - name: Build installers (ad-hoc fallback) @@ -94,17 +116,5 @@ jobs: run: | pnpm build pnpm exec electron-builder --mac --x64 --arm64 --publish never --config electron-builder.unsigned.yml - - name: List artifacts - run: ls -la release - - name: Upload installers to release - env: - GH_TOKEN: ${{ github.token }} - TAG: ${{ github.event.release.tag_name || inputs.tag }} - run: | - shopt -s nullglob - files=(release/*.dmg release/*.zip) - if [ ${#files[@]} -eq 0 ]; then - echo "no uploadable artifacts found in release/" >&2 - exit 1 - fi - gh release upload "$TAG" "${files[@]}" --clobber + - name: Upload installers + run: node scripts/gitea-release.mjs upload release diff --git a/package.json b/package.json index 445e407a..fac0f111 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agentcompany-new", - "version": "1.4.1", + "version": "1.5.0", "private": true, "type": "module", "description": "Enterprise Electron control plane for AI agent companies.", diff --git a/scripts/gitea-release.mjs b/scripts/gitea-release.mjs new file mode 100644 index 00000000..537aa2c6 --- /dev/null +++ b/scripts/gitea-release.mjs @@ -0,0 +1,53 @@ +// Gitea release for the pushed tag: `create` checks the tag against package.json and opens the release; `upload` attaches installers. +import { openAsBlob } from "node:fs"; +import { readdir, readFile } from "node:fs/promises"; +import { basename, join } from "node:path"; + +const INSTALLER = /\.(exe|dmg|zip|AppImage|deb)$/; + +function env(name) { + const value = process.env[name]; + if (!value) throw new Error(`${name} is not set.`); + return value; +} + +const api = `${env("GITHUB_SERVER_URL")}/api/v1/repos/${env("GITHUB_REPOSITORY")}`; +const tag = env("GITHUB_REF_NAME"); +const headers = { Authorization: `token ${env("GITEA_TOKEN")}` }; + +async function call(method, path, body) { + const response = await fetch(`${api}${path}`, { method, headers: body instanceof FormData ? headers : { ...headers, "Content-Type": "application/json" }, body: body instanceof FormData ? body : body && JSON.stringify(body) }); + if (!response.ok) throw new Error(`${method} ${path} failed: ${response.status} ${await response.text()}`); + return response.status === 204 ? null : response.json(); +} + +async function create() { + const { version } = JSON.parse(await readFile("package.json", "utf8")); + if (tag !== `v${version}`) throw new Error(`Tag ${tag} does not match package.json version ${version}; bump the version before tagging.`); + const existing = await fetch(`${api}/releases/tags/${tag}`, { headers }); + if (existing.ok) return console.log(`Release ${tag} already exists.`); + if (existing.status !== 404) throw new Error(`Looking up release ${tag} failed: ${existing.status} ${await existing.text()}`); + await call("POST", "/releases", { tag_name: tag, name: `AgentCompany ${version}`, draft: false, prerelease: false }); + console.log(`Created release ${tag}.`); +} + +async function upload(dir) { + const files = (await readdir(dir)).filter((name) => INSTALLER.test(name)).map((name) => join(dir, name)); + if (files.length === 0) throw new Error(`No installers found in ${dir}.`); + const release = await call("GET", `/releases/tags/${tag}`); + for (const file of files) { + const name = basename(file); + // A re-run replaces the asset instead of failing on the duplicate name. + const old = release.assets.find((asset) => asset.name === name); + if (old) await call("DELETE", `/releases/${release.id}/assets/${old.id}`); + const form = new FormData(); + form.append("attachment", await openAsBlob(file), name); + await call("POST", `/releases/${release.id}/assets?name=${encodeURIComponent(name)}`, form); + console.log(`Uploaded ${name}.`); + } +} + +const [command, dir] = process.argv.slice(2); +if (command === "create") await create(); +else if (command === "upload") await upload(dir ?? "release"); +else throw new Error(`Unknown command ${command}; use create or upload.`); diff --git a/vitest.config.ts b/vitest.config.ts index 32ff0cf4..82397886 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,5 +5,7 @@ export default defineConfig({ environment: "node", include: ["tests/{unit,contract,integration,live}/**/*.test.ts"], testTimeout: 30_000, + // Each worker opens its own databases; a core-count pool thrashes a shared CI machine. + maxWorkers: 8, }, });