From d331b8c5cf3ae8c3b2f1655411fb83abc702983a Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Thu, 24 Sep 2026 12:54:55 +0000 Subject: [PATCH] Publish our extensions to our own store, from CI We built a store with one-click install and Chromium auto-update -- crx.ts stamps our update_url into the manifest before signing, precisely so Chromium polls /api/store/updates.xml rather than Google. Then every listing was created by hand, which in practice meant almost none were: the store holds exactly one extension, and our own MarkSyncr is not in it. That is the gap worth closing. An extension in the store auto-updates for anyone who installed it. An extension outside the store reaches TronBrowser only by being rebuilt into the bundle, and reaches everyone else only when Google approves it. MarkSyncr has three releases sitting in Chrome review right now. So publishing runs from CI. scripts/store-publish.mjs resolves the listing by slug, creates it on first run, and posts the version with the manifest and the artifact URL. The reusable workflow wraps it so an extension repo adds six lines rather than its own half of the job. Two behaviours worth stating. A version that is already published exits clean instead of failing, so re-running a tag is safe. And the artifact is polled before submitting, because the store fetches it itself and a release asset can lag its tag by a few seconds -- submitting into that window fails for a reason that has nothing to do with the extension. Verified against the live store: an existing listing resolves by slug (coinpay-wallet), an unknown name takes the create path, and a manifest that is not MV3 is refused before anything is sent. Nothing publishes until TRONBROWSER_STORE_TOKEN exists. The API deliberately refuses to mint a publisher token from a token -- it has to come from a signed-in browser session -- so that one step is Anthony's and cannot be automated away. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/store-publish.yml | 104 +++++++++++++++++++ scripts/store-publish.mjs | 155 ++++++++++++++++++++++++++++ 2 files changed, 259 insertions(+) create mode 100644 .github/workflows/store-publish.yml create mode 100644 scripts/store-publish.mjs diff --git a/.github/workflows/store-publish.yml b/.github/workflows/store-publish.yml new file mode 100644 index 0000000..6ac282a --- /dev/null +++ b/.github/workflows/store-publish.yml @@ -0,0 +1,104 @@ +name: Publish to the TronBrowser Store + +# A reusable workflow, so every extension we own publishes the same way instead +# of each repo growing its own half of the job. +# +# Why it exists: every listing was created by hand, which in practice meant none +# were — the store had a single extension and our own MarkSyncr was not in it. An +# extension that is not in the store is one TronBrowser cannot auto-update to, +# which is the whole point of having our own store rather than waiting on Google. +# +# Call it from an extension repo's release workflow: +# +# publish-to-store: +# needs: build +# uses: profullstack/tronbrowser.dev/.github/workflows/store-publish.yml@main +# secrets: inherit +# with: +# name: MarkSyncr +# manifest: apps/extension/src/manifest.chrome.json +# bundle_url: https://github.com/${{ github.repository }}/releases/download/v${{ needs.build.outputs.version }}/marksyncr-chrome.zip +# +# The artifact has to be a URL the store can fetch, which is what publishing the +# built ZIP as a release asset is for. +on: + workflow_call: + inputs: + name: + description: 'Listing name. Created on first publish.' + required: true + type: string + manifest: + description: 'Path to the MV3 manifest.json in the calling repo.' + required: true + type: string + bundle_url: + description: 'Public URL of the .zip bundle.' + required: false + type: string + default: '' + crx_url: + description: 'Public URL of a signed .crx, if the project builds one.' + required: false + type: string + default: '' + slug: + description: 'Look the listing up by this slug instead of by name.' + required: false + type: string + default: '' + dry_run: + description: 'Resolve and validate, write nothing.' + required: false + type: boolean + default: false + secrets: + TRONBROWSER_STORE_TOKEN: + description: 'A tbpub_ publisher token, minted from a signed-in session.' + required: true + +jobs: + publish: + name: Publish to the store + runs-on: ubuntu-latest + steps: + - name: Check out the calling repo + uses: actions/checkout@v5 + + - name: Check out the publisher script + uses: actions/checkout@v5 + with: + repository: profullstack/tronbrowser.dev + path: .tronbrowser-store + sparse-checkout: scripts/store-publish.mjs + sparse-checkout-cone-mode: false + + - name: Wait for the release asset + if: ${{ inputs.bundle_url != '' || inputs.crx_url != '' }} + env: + URL: ${{ inputs.crx_url != '' && inputs.crx_url || inputs.bundle_url }} + run: | + # The store fetches the artifact itself, so it has to be reachable + # before we submit. A release asset can lag the tag by a few seconds. + for i in $(seq 1 20); do + if curl -fsSLI "$URL" >/dev/null 2>&1; then + echo "artifact is up: $URL" + exit 0 + fi + echo "waiting for $URL ($i/20)" + sleep 6 + done + echo "::error::artifact never became reachable: $URL" + exit 1 + + - name: Publish + env: + TRONBROWSER_STORE_TOKEN: ${{ secrets.TRONBROWSER_STORE_TOKEN }} + run: | + node .tronbrowser-store/scripts/store-publish.mjs \ + --name "${{ inputs.name }}" \ + --manifest "${{ inputs.manifest }}" \ + ${{ inputs.slug != '' && format('--slug "{0}"', inputs.slug) || '' }} \ + ${{ inputs.bundle_url != '' && format('--bundle-url "{0}"', inputs.bundle_url) || '' }} \ + ${{ inputs.crx_url != '' && format('--crx-url "{0}"', inputs.crx_url) || '' }} \ + ${{ inputs.dry_run && '--dry-run' || '' }} diff --git a/scripts/store-publish.mjs b/scripts/store-publish.mjs new file mode 100644 index 0000000..8093ba2 --- /dev/null +++ b/scripts/store-publish.mjs @@ -0,0 +1,155 @@ +#!/usr/bin/env node +/** + * Publish one extension version to the TronBrowser store. + * + * Every extension we own was published by hand, which meant in practice that + * none of them were: the store had one listing and our own MarkSyncr was not in + * it. A release that does not reach the store is a release TronBrowser cannot + * auto-update to, so this runs from CI on every tag. + * + * Usage: + * node scripts/store-publish.mjs \ + * --name "MarkSyncr" \ + * --manifest path/to/manifest.json \ + * --bundle-url https://github.com/.../marksyncr-chrome.zip + * + * Optional: + * --slug look the listing up by slug instead of by name + * --crx-url a signed .crx, if the project builds one + * --store defaults to https://tronbrowser.dev + * --dry-run resolve and validate, write nothing + * + * Auth: TRONBROWSER_STORE_TOKEN, a `tbpub_` publisher token. Minted once from a + * signed-in browser session at tronbrowser.dev — the API refuses to mint one + * from a token, deliberately, so a leaked CI token cannot mint more. + */ + +import { readFileSync } from 'node:fs'; + +const args = process.argv.slice(2); +function arg(name, fallback = '') { + const i = args.indexOf(`--${name}`); + return i >= 0 && args[i + 1] ? args[i + 1] : fallback; +} +const has = (name) => args.includes(`--${name}`); + +const STORE = (arg('store') || process.env.TRONBROWSER_STORE || 'https://tronbrowser.dev').replace(/\/$/, ''); +const TOKEN = process.env.TRONBROWSER_STORE_TOKEN || ''; +const DRY = has('dry-run'); + +const name = arg('name'); +const slugArg = arg('slug'); +const manifestPath = arg('manifest'); +const bundleUrl = arg('bundle-url'); +const crxUrl = arg('crx-url'); + +function die(message) { + console.error(`store-publish: ${message}`); + process.exit(1); +} + +if (!name && !slugArg) die('--name or --slug is required'); +if (!manifestPath) die('--manifest is required'); +if (!bundleUrl && !crxUrl) die('--bundle-url or --crx-url is required'); +if (!TOKEN && !DRY) die('TRONBROWSER_STORE_TOKEN is not set'); + +let manifest; +try { + manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); +} catch (err) { + die(`could not read ${manifestPath}: ${err.message}`); +} +if (Number(manifest.manifest_version) !== 3) { + die(`${manifestPath} is manifest_version ${manifest.manifest_version}; the store takes MV3 only`); +} + +const headers = { + authorization: `Bearer ${TOKEN}`, + 'content-type': 'application/json', +}; + +async function api(path, init = {}) { + const res = await fetch(`${STORE}/api/store${path}`, { ...init, headers }); + const text = await res.text(); + let body; + try { + body = text ? JSON.parse(text) : {}; + } catch { + body = { raw: text }; + } + return { ok: res.ok, status: res.status, body }; +} + +/** The listing for this extension, creating it the first time. */ +async function resolveListing() { + const slug = slugArg || slugify(name); + const found = await api(`/extensions/${encodeURIComponent(slug)}`); + // GET /extensions/:slug returns the listing itself, not {extension: ...}. + const existing = found.body?.extension ?? found.body; + if (found.ok && existing?.id) { + console.log(` listing ${existing.slug} (${existing.id})`); + return existing; + } + if (found.status !== 404) { + die(`looking up ${slug} failed with ${found.status}: ${JSON.stringify(found.body).slice(0, 300)}`); + } + + if (DRY) { + console.log(` [dry-run] would create the listing "${name}"`); + return { id: '(dry-run)', slug }; + } + const created = await api('/extensions', { + method: 'POST', + body: JSON.stringify({ name }), + }); + if (!created.ok) { + die(`creating the listing failed with ${created.status}: ${JSON.stringify(created.body)}`); + } + const ext = created.body.extension || created.body; + console.log(` created listing ${ext.slug} (${ext.id})`); + return ext; +} + +/** Mirrors the server's slugify closely enough to find an existing listing. */ +function slugify(value) { + return String(value) + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, ''); +} + +const listing = await resolveListing(); + +const payload = { + manifest, + ...(bundleUrl ? { bundleUrl } : {}), + ...(crxUrl ? { crxUrl } : {}), +}; + +if (DRY) { + console.log(` [dry-run] would publish ${manifest.name} ${manifest.version}`); + console.log(` [dry-run] artifact: ${crxUrl || bundleUrl}`); + process.exit(0); +} + +const published = await api(`/extensions/${listing.id}/versions`, { + method: 'POST', + body: JSON.stringify(payload), +}); + +if (!published.ok) { + // A version that is already published is not a failure: a re-run of the same + // tag should be safe, and the alternative is a red release for work that is + // already done. + const message = JSON.stringify(published.body); + if (published.status === 409 || /already/i.test(message)) { + console.log(` ${manifest.version} is already published, nothing to do`); + process.exit(0); + } + die(`publishing ${manifest.version} failed with ${published.status}: ${message}`); +} + +console.log(` published ${manifest.name} ${manifest.version} to ${STORE}/store/`); +if (published.body?.scan) { + console.log(` scan: ${JSON.stringify(published.body.scan)}`); +}