diff --git a/packages/apps/connector-github/assets/icon.svg b/packages/apps/connector-github/assets/icon.svg
new file mode 100644
index 00000000000..2af96e89df5
--- /dev/null
+++ b/packages/apps/connector-github/assets/icon.svg
@@ -0,0 +1,7 @@
+
diff --git a/packages/apps/connector-github/objectstack.manifest.json b/packages/apps/connector-github/objectstack.manifest.json
index a3b7d7298d3..b1b94b0295d 100644
--- a/packages/apps/connector-github/objectstack.manifest.json
+++ b/packages/apps/connector-github/objectstack.manifest.json
@@ -8,6 +8,7 @@
"category": "devtools",
"tags": ["github", "mcp", "connector", "issues"],
"license": "Apache-2.0",
+ "iconUrl": "https://raw.githubusercontent.com/objectstack-ai/objectstack/main/packages/apps/connector-github/assets/icon.svg",
"homepageUrl": "https://github.com/objectstack-ai/objectstack/tree/main/packages/apps/connector-github",
"readmePath": "README.md"
}
diff --git a/packages/apps/connector-github/test/listing.test.ts b/packages/apps/connector-github/test/listing.test.ts
index 6aad53d236b..cc4daef583d 100644
--- a/packages/apps/connector-github/test/listing.test.ts
+++ b/packages/apps/connector-github/test/listing.test.ts
@@ -3,14 +3,15 @@
/**
* The listing this package publishes as: `objectstack.manifest.json` parses
* against the spec's own schema for that file, names the same package id as
- * the stack, points at a README that exists, and that README's credentials
+ * the stack, points at a README that exists and at the icon the package owns
+ * (a public http(s) `iconUrl`), and that README's credentials
* section names every credential the app's connectors reference — the
* marketplace handbook's "every credential it needs" rule, checked against
* the metadata rather than trusted.
*/
import { describe, it, expect } from 'vitest';
-import { readFileSync, existsSync } from 'node:fs';
+import { readFileSync, existsSync, statSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { TemplateManifestSchema } from '@objectstack/spec/marketplace';
import stack from '../objectstack.config.js';
@@ -18,6 +19,27 @@ import stack from '../objectstack.config.js';
const manifestPath = fileURLToPath(new URL('../objectstack.manifest.json', import.meta.url));
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as Record;
+/** Where the listing icon is served from once this package's tree is on `main`. */
+const ICON_URL = 'https://raw.githubusercontent.com/objectstack-ai/objectstack/main/packages/apps/connector-github/assets/icon.svg';
+
+/**
+ * Why a manifest's `iconUrl` cannot be the listing icon, or `null` when it can:
+ * `os package publish` sends the key as the package's `icon_url`, and the
+ * marketplace handbook asks for a public http(s) URL. `TemplateManifestSchema`
+ * accepts any URL scheme, so the scheme is checked here.
+ */
+function iconUrlProblem(m: Record): string | null {
+ const value = m.iconUrl;
+ if (typeof value !== 'string') return 'iconUrl is absent';
+ let url: URL;
+ try {
+ url = new URL(value);
+ } catch {
+ return `iconUrl is not a URL: ${value}`;
+ }
+ return url.protocol === 'https:' || url.protocol === 'http:' ? null : `iconUrl is not http(s): ${value}`;
+}
+
/** The README's `## Credentials` section, up to the next second-level heading. */
function credentialsSection(readme: string): string {
const start = readme.indexOf('\n## Credentials\n');
@@ -55,6 +77,27 @@ describe('connector-github listing', () => {
expect(section).toContain('https://github.com/settings/personal-access-tokens/new');
});
+ it('carries a public http(s) iconUrl', () => {
+ expect(iconUrlProblem(manifest)).toBeNull();
+ });
+
+ it('refuses an iconUrl that is absent or not http(s)', () => {
+ const withoutIcon = { ...manifest };
+ delete withoutIcon.iconUrl;
+ expect(iconUrlProblem(withoutIcon)).toBe('iconUrl is absent');
+ expect(iconUrlProblem({ ...manifest, iconUrl: 'ftp://example.com/icon.svg' })).toMatch(/not http\(s\)/);
+ expect(iconUrlProblem({ ...manifest, iconUrl: 'assets/icon.svg' })).toMatch(/not a URL/);
+ });
+
+ it('points iconUrl at the icon this package owns', () => {
+ expect(manifest.iconUrl).toBe(ICON_URL);
+ const iconPath = fileURLToPath(new URL('../assets/icon.svg', import.meta.url));
+ expect(existsSync(iconPath)).toBe(true);
+ // `--icon-file` takes up to 256 KB; hold the owned asset to the same bound.
+ expect(statSync(iconPath).size).toBeLessThan(256 * 1024);
+ expect(readFileSync(iconPath, 'utf8')).toMatch(/^