From 249626bbdcfacb75f60ca5674be387ad7ddc5369 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Thu, 4 Jun 2026 15:31:06 +0800 Subject: [PATCH] feat: marketplace publish path (manifest + script + staging/prod workflows) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HotCRM had no way to reach the marketplace — only a GitHub-Release workflow. Adds the full publish path so it can be listed alongside the templates: - objectstack.manifest.json — marketplace metadata (manifestId app.objectstack.hotcrm, category crm, MIT, icon, readme). - scripts/publish-marketplace.mjs — single-package publisher: reads package.json + manifest + dist/objectstack.json, upserts sys_package then creates the version (auto_approve, 409 = no-op). Hardened with per-request timeout + retry (the control plane is a cold singleton) — mirrors the templates repo's publisher. - pnpm publish:marketplace (+ :dry-run). - .github/workflows/publish-staging.yml → https://cloud.objectos.app (OS_CLOUD_API_KEY_STAGING; dispatch + release) .github/workflows/publish-production.yml → https://cloud.objectos.ai (OS_CLOUD_API_KEY_PRODUCTION; manual dispatch only) Same explicit-target / org-level-secret model as the templates repo. Setup: org-level secrets OS_CLOUD_API_KEY_STAGING / _PRODUCTION (already set). Co-Authored-By: Claude Opus 4.8 --- .github/workflows/publish-production.yml | 45 ++++++ .github/workflows/publish-staging.yml | 44 ++++++ objectstack.manifest.json | 24 +++ package.json | 2 + scripts/publish-marketplace.mjs | 190 +++++++++++++++++++++++ 5 files changed, 305 insertions(+) create mode 100644 .github/workflows/publish-production.yml create mode 100644 .github/workflows/publish-staging.yml create mode 100644 objectstack.manifest.json create mode 100644 scripts/publish-marketplace.mjs diff --git a/.github/workflows/publish-production.yml b/.github/workflows/publish-production.yml new file mode 100644 index 00000000..5e730a35 --- /dev/null +++ b/.github/workflows/publish-production.yml @@ -0,0 +1,45 @@ +name: Publish to marketplace (production) + +# Publishes HotCRM to the PRODUCTION marketplace (https://cloud.objectos.ai). +# Target URL is hard-coded; the credential is the production-only org secret +# `OS_CLOUD_API_KEY_PRODUCTION`. +# +# MANUAL ONLY — no release/push trigger. Cut to staging first, verify, then +# dispatch this to promote. +on: + workflow_dispatch: + inputs: + dry_run: + description: 'Dry-run (print payloads, no HTTP)' + type: boolean + default: false + +concurrency: + group: publish-marketplace-production + cancel-in-progress: false + +permissions: + contents: read + +jobs: + publish: + name: Build + publish HotCRM → production + runs-on: ubuntu-latest + if: github.repository == 'objectstack-ai/hotcrm' + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Build + run: pnpm build + - name: Publish to PRODUCTION marketplace + env: + OS_CLOUD_URL: https://cloud.objectos.ai + OS_CLOUD_API_KEY: ${{ secrets.OS_CLOUD_API_KEY_PRODUCTION }} + DRY_RUN: ${{ inputs.dry_run && '1' || '' }} + run: pnpm publish:marketplace diff --git a/.github/workflows/publish-staging.yml b/.github/workflows/publish-staging.yml new file mode 100644 index 00000000..62873b84 --- /dev/null +++ b/.github/workflows/publish-staging.yml @@ -0,0 +1,44 @@ +name: Publish to marketplace (staging) + +# Publishes HotCRM to the STAGING marketplace (https://cloud.objectos.app). +# Target URL is hard-coded; the credential is the staging-only org secret +# `OS_CLOUD_API_KEY_STAGING`. Production is a separate, manual-only workflow. +on: + workflow_dispatch: + inputs: + dry_run: + description: 'Dry-run (print payloads, no HTTP)' + type: boolean + default: false + release: + types: [published] + +concurrency: + group: publish-marketplace-staging + cancel-in-progress: false + +permissions: + contents: read + +jobs: + publish: + name: Build + publish HotCRM → staging + runs-on: ubuntu-latest + if: github.repository == 'objectstack-ai/hotcrm' + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Build + run: pnpm build + - name: Publish to STAGING marketplace + env: + OS_CLOUD_URL: https://cloud.objectos.app + OS_CLOUD_API_KEY: ${{ secrets.OS_CLOUD_API_KEY_STAGING }} + DRY_RUN: ${{ inputs.dry_run && '1' || '' }} + run: pnpm publish:marketplace diff --git a/objectstack.manifest.json b/objectstack.manifest.json new file mode 100644 index 00000000..18461dfa --- /dev/null +++ b/objectstack.manifest.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://schemas.objectstack.dev/template-manifest.json", + "name": "hotcrm", + "specVersion": "^7.7.0", + "manifestId": "app.objectstack.hotcrm", + "displayName": "HotCRM", + "description": "AI-Native CRM for the ObjectStack marketplace — Accounts, Contacts, Leads, Opportunities, Cases, Knowledge, Forecasts, Campaigns, and Contracts in one production-grade workspace.", + "tagline": "Production AI-native CRM — accounts to forecasts.", + "category": "crm", + "isStarter": false, + "publisher": "objectstack", + "license": "MIT", + "iconUrl": "https://raw.githubusercontent.com/objectstack-ai/hotcrm/main/assets/icon.svg", + "homepageUrl": "https://github.com/objectstack-ai/hotcrm", + "tags": ["crm", "sales", "accounts", "leads", "opportunities", "cases", "campaigns"], + "skills": [ + "objectstack-platform", + "objectstack-data", + "objectstack-ui", + "objectstack-automation", + "objectstack-ai" + ], + "readmePath": "README.md" +} diff --git a/package.json b/package.json index 0689677a..f4edf1c7 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,8 @@ "test:qa": "objectstack test", "test:e2e": "playwright test", "verify": "pnpm validate && pnpm typecheck && pnpm build && pnpm test", + "publish:marketplace": "node scripts/publish-marketplace.mjs", + "publish:marketplace:dry-run": "DRY_RUN=1 node scripts/publish-marketplace.mjs", "demo:reset": "rm -rf .objectstack/data && pnpm build && echo '✅ DB reset — start the server with: pnpm dev (or pnpm start). Seed data loads on first boot.'" }, "packageManager": "pnpm@10.33.0", diff --git a/scripts/publish-marketplace.mjs b/scripts/publish-marketplace.mjs new file mode 100644 index 00000000..b6c2feb1 --- /dev/null +++ b/scripts/publish-marketplace.mjs @@ -0,0 +1,190 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack contributors. MIT license. + +/** + * publish-marketplace.mjs + * + * Publish HotCRM (this single root package) to the ObjectStack marketplace + * (cloud control plane). Used by `.github/workflows/publish-{staging,production}.yml` + * and locally via `pnpm publish:marketplace`. + * + * Flow: + * 1. Read `package.json` → version + * 2. Read `objectstack.manifest.json` → marketplace meta + * 3. Read `dist/objectstack.json` → compiled bundle (run `pnpm build` first) + * 4. POST {OS_CLOUD_URL}/api/v1/cloud/packages → idempotent upsert of sys_package + * 5. POST {OS_CLOUD_URL}/api/v1/cloud/packages/:id/versions + * → creates sys_package_version. 409 (duplicate) is treated as a no-op so + * re-running is safe when nothing changed. + * + * Auth: `Authorization: Bearer ${OS_CLOUD_API_KEY}` (service mode). + * + * Required env: + * OS_CLOUD_URL e.g. https://cloud.objectos.app (staging) / .ai (prod) + * OS_CLOUD_API_KEY service token (per-env, org-level secret) + * Optional env: + * DRY_RUN=1 print payloads, don't POST + * PUBLISH_TIMEOUT_MS per-request timeout (default 240000) + * PUBLISH_RETRIES attempts before giving up (default 4) + */ + +import { readFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { resolve, join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(__dirname, '..'); + +const OS_CLOUD_URL = (process.env.OS_CLOUD_URL ?? '').replace(/\/+$/, ''); +const OS_CLOUD_API_KEY = process.env.OS_CLOUD_API_KEY ?? ''; +const DRY_RUN = process.env.DRY_RUN === '1' || process.env.DRY_RUN === 'true'; + +if (!DRY_RUN) { + if (!OS_CLOUD_URL) die('OS_CLOUD_URL is required (e.g. https://cloud.objectos.app)'); + if (!OS_CLOUD_API_KEY) die('OS_CLOUD_API_KEY is required (service token)'); +} + +function die(msg) { + console.error(`✗ ${msg}`); + process.exit(1); +} +function log(msg) { + console.log(msg); +} +async function readJson(path) { + return JSON.parse(await readFile(path, 'utf8')); +} +function sleep(ms) { + return new Promise((r) => setTimeout(r, ms)); +} +// Backoff with a cap: 3s, 6s, 12s, 24s, 30s… — gives a cold singleton time to +// spin up between attempts. +function backoffMs(attempt) { + return Math.min(3000 * 2 ** (attempt - 1), 30_000); +} + +/** + * POST with an explicit timeout + retry. The control plane is a singleton that + * can be cold (it isn't kept warm), so the first heavy POST after idle can take + * far longer than undici's default 5-min headers timeout — which would throw and + * kill the run. We use an AbortController timeout and retry transient failures + * (timeout / network / 5xx). 4xx (incl. 409) returns immediately. + */ +async function postJson(path, body) { + const url = `${OS_CLOUD_URL}${path}`; + if (DRY_RUN) { + log(` [dry-run] POST ${url}`); + log(` [dry-run] body keys: ${Object.keys(body).join(', ')}`); + return { ok: true, status: 200, json: { success: true, data: { id: 'dry-run', created: true } } }; + } + const TIMEOUT_MS = Number(process.env.PUBLISH_TIMEOUT_MS ?? 240_000); + const MAX_ATTEMPTS = Number(process.env.PUBLISH_RETRIES ?? 4); + let lastErr; + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + const ac = new AbortController(); + const timer = setTimeout(() => ac.abort(), TIMEOUT_MS); + try { + const res = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${OS_CLOUD_API_KEY}`, + }, + body: JSON.stringify(body), + signal: ac.signal, + }); + clearTimeout(timer); + const text = await res.text(); + let json; + try { + json = JSON.parse(text); + } catch { + json = { raw: text }; + } + if (res.status >= 500 && attempt < MAX_ATTEMPTS) { + log(` ↻ ${path} → ${res.status}, retry ${attempt}/${MAX_ATTEMPTS - 1}…`); + await sleep(backoffMs(attempt)); + continue; + } + return { ok: res.ok, status: res.status, json }; + } catch (err) { + clearTimeout(timer); + lastErr = err; + const reason = + err?.name === 'AbortError' + ? `timeout after ${TIMEOUT_MS}ms` + : (err?.message ?? String(err)); + if (attempt < MAX_ATTEMPTS) { + log(` ↻ ${path} → ${reason}, retry ${attempt}/${MAX_ATTEMPTS - 1}…`); + await sleep(backoffMs(attempt)); + continue; + } + return { ok: false, status: 0, json: { error: `request failed: ${reason}` } }; + } + } + return { ok: false, status: 0, json: { error: `request failed: ${lastErr?.message ?? 'unknown'}` } }; +} + +async function main() { + const pkg = await readJson(join(ROOT, 'package.json')); + const ver = pkg.version; + + const manifestPath = join(ROOT, 'objectstack.manifest.json'); + if (!existsSync(manifestPath)) die(`missing ${manifestPath}`); + const mp = await readJson(manifestPath); + + const distPath = join(ROOT, 'dist', 'objectstack.json'); + if (!existsSync(distPath)) die(`missing ${distPath} — did you run "pnpm build"?`); + const bundle = await readJson(distPath); + + const manifestId = mp.manifestId ?? bundle?.manifest?.id; + if (!manifestId) die('no manifestId (set manifestId in objectstack.manifest.json or bundle.manifest.id)'); + + log(`── ${mp.displayName ?? pkg.name} (${manifestId}) @ ${ver}`); + if (DRY_RUN) log('DRY_RUN=1 — no HTTP calls will be made.'); + + const readme = existsSync(join(ROOT, mp.readmePath ?? 'README.md')) + ? await readFile(join(ROOT, mp.readmePath ?? 'README.md'), 'utf8') + : undefined; + + // Step 1 — upsert sys_package (marketplace visibility). + const upsertRes = await postJson('/api/v1/cloud/packages', { + manifest_id: manifestId, + visibility: 'marketplace', + display_name: mp.displayName, + description: mp.description, + category: mp.category, + tags: mp.tags, + is_starter: mp.isStarter ?? false, + publisher: mp.publisher, + icon_url: mp.iconUrl, + homepage_url: mp.homepageUrl, + license: mp.license ?? pkg.license, + readme, + }); + if (!upsertRes.ok) die(`upsert failed (${upsertRes.status}): ${JSON.stringify(upsertRes.json).slice(0, 400)}`); + const pkgId = upsertRes.json?.data?.id; + log(` ${upsertRes.json?.data?.created ? '✓ created' : '✓ patched'} sys_package id=${pkgId}`); + + // Step 2 — create sys_package_version (idempotent by 409). auto_approve is + // honoured for service-mode callers (our CI's OS_CLOUD_API_KEY); without it + // the version lands as draft and the public catalog hides it. + const verRes = await postJson(`/api/v1/cloud/packages/${encodeURIComponent(pkgId)}/versions`, { + version: ver, + bundle, + release_notes: mp.releaseNotes, + auto_approve: true, + }); + if (verRes.status === 409) { + log(` = version ${ver} already published — no-op`); + process.exit(0); + } + if (!verRes.ok) die(`version POST failed (${verRes.status}): ${JSON.stringify(verRes.json).slice(0, 400)}`); + log(` ✓ published version ${ver}`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +});