From 67c78b370d973d2d5b4ae872cef8035b964a0f5b Mon Sep 17 00:00:00 2001 From: tianma-if Date: Mon, 12 Oct 2026 11:33:10 +0800 Subject: [PATCH] ci: submit web clipper asynchronously after stable releases --- .github/workflows/extension-store.yml | 22 ++++++- apps/extension/README.md | 8 +++ scripts/publish-web-clipper.mjs | 32 +++++++---- scripts/publish-web-clipper.test.mjs | 83 ++++++++++++++++++++++++--- 4 files changed, 123 insertions(+), 22 deletions(-) diff --git a/.github/workflows/extension-store.yml b/.github/workflows/extension-store.yml index 06b0c2c4..3f70c4f3 100644 --- a/.github/workflows/extension-store.yml +++ b/.github/workflows/extension-store.yml @@ -1,8 +1,16 @@ name: Submit Web Clipper on: + release: + types: + - published workflow_dispatch: inputs: + release_tag: + description: "Release tag to build or retry. Leave empty to build the selected workflow ref." + required: false + default: "" + type: string platform: description: "Store to submit. Chrome also covers the Edge install link." required: true @@ -32,13 +40,21 @@ concurrency: jobs: submit: - if: github.repository == 'tianma-if/edgeever' + if: github.repository == 'tianma-if/edgeever' && (github.event_name == 'workflow_dispatch' || (!github.event.release.draft && !github.event.release.prerelease && startsWith(github.event.release.tag_name, 'v'))) name: Submit Chrome and Firefox runs-on: ubuntu-latest timeout-minutes: 30 steps: + - name: Validate formal release tag + if: github.event_name == 'release' + env: + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: '[[ "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]' + - name: Checkout uses: actions/checkout@v5 + with: + ref: ${{ github.event.release.tag_name || inputs.release_tag || github.sha }} - name: Set up Bun uses: oven-sh/setup-bun@v2 @@ -54,8 +70,8 @@ jobs: - name: Submit stores id: submit env: - PLATFORM: ${{ inputs.platform }} - DRY_RUN: ${{ inputs.dry_run }} + PLATFORM: ${{ inputs.platform || 'both' }} + DRY_RUN: ${{ inputs.dry_run || false }} CHROME_WEB_STORE_SERVICE_ACCOUNT_JSON: ${{ secrets.CHROME_WEB_STORE_SERVICE_ACCOUNT_JSON }} CHROME_WEB_STORE_CLIENT_ID: ${{ secrets.CHROME_WEB_STORE_CLIENT_ID }} CHROME_WEB_STORE_CLIENT_SECRET: ${{ secrets.CHROME_WEB_STORE_CLIENT_SECRET }} diff --git a/apps/extension/README.md b/apps/extension/README.md index 650370a2..b726a06c 100644 --- a/apps/extension/README.md +++ b/apps/extension/README.md @@ -35,6 +35,14 @@ User-visible strings live in `public/_locales//messages.json`. Add or up ## Development +### Store delivery + +The official repository's **Submit Web Clipper** workflow runs asynchronously after a stable `vX.Y.Z` GitHub Release is published. It builds from that release tag, runs the extension tests, and packages Chromium and Firefox before contacting the stores. Store delivery does not block or withdraw the GitHub Release. The extension keeps its independent version in `apps/extension/package.json`; increase it when preparing an extension update, rather than for every application release. + +Each store is checked separately. A version already published (or superseded by a newer public version) is skipped, and the same version awaiting review is treated as already submitted. If another version is awaiting review, the workflow summary reports `waiting-review` without submitting a replacement. This is not an automatic retry: after review completes, manually run **Submit Web Clipper**, enter the original `release_tag`, and select the affected platform. Credential or submission failures remain visible as workflow failures, while the other store is still attempted. Store approval is separate from successful submission. + +Manual runs retain platform selection and `dry_run`. Leave `release_tag` empty to build the selected workflow ref. Firefox notes default to a one-line extension version note; custom notes should describe only extension changes. + ### Chromium From the repository root: diff --git a/scripts/publish-web-clipper.mjs b/scripts/publish-web-clipper.mjs index 65f27f06..6d3011f4 100644 --- a/scripts/publish-web-clipper.mjs +++ b/scripts/publish-web-clipper.mjs @@ -123,21 +123,18 @@ export const chromeSubmissionPlan = ({ packageVersion, status }) => { const submittedState = status?.submittedItemRevisionStatus?.state ?? ""; const pending = submittedState === "PENDING_REVIEW" || submittedState === "STAGED"; + if (published && compareVersions(packageVersion, published) <= 0) { + return { action: "already-published", published }; + } if (submitted && compareVersions(packageVersion, submitted) === 0 && pending) { return { action: "already-submitted", published, submitted, submittedState }; } if (submitted && pending) { return { - action: "blocked", + action: "waiting-review", reason: `Chrome ${submitted} is ${submittedState}. Wait for that review, or cancel it, before submitting ${packageVersion}.`, }; } - if (published && compareVersions(packageVersion, published) <= 0) { - return { - action: "blocked", - reason: `Chrome already published ${published}. Increase apps/extension/package.json before submitting again.`, - }; - } return { action: "upload", published, submitted, submittedState }; }; @@ -146,6 +143,9 @@ export const firefoxSubmissionPlan = ({ packageVersion, versions }) => { const same = listed.find((version) => version.version === packageVersion); if (same) { const status = same.file?.status ?? ""; + if (status === "public") { + return { action: "already-published", version: same.version, status }; + } if (status === "unreviewed" && !same.source) { return { action: "attach-source", version: same.version, status }; } @@ -158,10 +158,14 @@ export const firefoxSubmissionPlan = ({ packageVersion, versions }) => { }; } + const newerPublic = listed.find((version) => version.file?.status === "public" && compareVersions(version.version, packageVersion) > 0); + if (newerPublic) { + return { action: "already-published", version: newerPublic.version, status: "public" }; + } const awaiting = listed.find((version) => version.file?.status === "unreviewed"); if (awaiting) { return { - action: "blocked", + action: "waiting-review", reason: `Firefox ${awaiting.version} is still awaiting review.`, }; } @@ -690,7 +694,7 @@ const chromeAccessToken = async (env, fetchImpl) => { return payload.access_token; }; -const submitChrome = async ({ version, chromiumZip, env, fetchImpl }) => { +export const submitChrome = async ({ version, chromiumZip, env, fetchImpl }) => { const client = createChromeClient({ fetchImpl, accessToken: await chromeAccessToken(env, fetchImpl), @@ -729,7 +733,7 @@ const submitChrome = async ({ version, chromiumZip, env, fetchImpl }) => { } }; -const submitFirefox = async ({ version, firefoxZip, sourceZip, releaseNotes, env, fetchImpl }) => { +export const submitFirefox = async ({ version, firefoxZip, sourceZip, releaseNotes, env, fetchImpl }) => { const client = createFirefoxClient({ fetchImpl, issuer: env.AMO_JWT_ISSUER, @@ -744,7 +748,7 @@ const submitFirefox = async ({ version, firefoxZip, sourceZip, releaseNotes, env packageVersion: version, versions: await client.listVersions(), }); - if (plan.action === "blocked" || plan.action === "already-submitted") { + if (plan.action !== "upload" && plan.action !== "attach-source") { return plan; } if (plan.action === "attach-source") { @@ -788,7 +792,6 @@ const main = async () => { } if (!options.dryRun) { assertOfficialRepository(process.env.GITHUB_REPOSITORY); - assertCredentials(options.platform, process.env); } const version = readExtensionVersion(); @@ -823,6 +826,7 @@ const main = async () => { for (const platform of selectedPlatforms(options.platform)) { const store = platform === "chrome" ? "Chrome" : "Firefox"; try { + assertCredentials(platform, process.env); const result = platform === "chrome" ? await submitChrome({ ...packages, env: process.env, fetchImpl: fetch }) : await submitFirefox({ @@ -835,6 +839,9 @@ const main = async () => { if (result.action === "blocked") { failures.push(result.reason); } + if (result.action === "waiting-review") { + console.warn(`${store}: ${result.reason} Retry Submit Web Clipper with the same release_tag after review completes.`); + } } catch (error) { const reason = error instanceof Error ? error.message : String(error); results.push([store, { action: "failed", reason }]); @@ -847,6 +854,7 @@ const main = async () => { ...results.map(([store, result]) => `${store}: ${result.action}${result.reason ? ` — ${result.reason}` : ""}`), "Chrome and Edge use the same Chrome Web Store submission. Review still has to approve it.", "Firefox review still has to approve and sign the package.", + "waiting-review: no package was submitted to that store. After review completes, manually rerun Submit Web Clipper with the original release_tag and that platform.", ].join("\n")); if (failures.length > 0) { throw new Error(failures.join(" ")); diff --git a/scripts/publish-web-clipper.test.mjs b/scripts/publish-web-clipper.test.mjs index e892eb08..29d92378 100644 --- a/scripts/publish-web-clipper.test.mjs +++ b/scripts/publish-web-clipper.test.mjs @@ -24,6 +24,8 @@ import { parsePublishArgs, resolveReleaseNotes, shouldCopyExtensionSource, + submitChrome, + submitFirefox, workspacePackageManifests, } from "./publish-web-clipper.mjs"; @@ -51,7 +53,7 @@ describe("web clipper store command", () => { expect(() => compareVersions("1", "0.1.8")).toThrow("X.Y.Z"); }); - test("blocks a Chrome package that is not newer than the public version", () => { + test("skips a Chrome package that is already public", () => { const plan = chromeSubmissionPlan({ packageVersion: "0.1.8", status: { @@ -62,8 +64,8 @@ describe("web clipper store command", () => { }, }); - expect(plan.action).toBe("blocked"); - expect(plan.reason).toContain("0.1.8"); + expect(plan.action).toBe("already-published"); + expect(plan.published).toBe("0.1.8"); }); test("uploads a newer Chrome package when nothing is pending", () => { @@ -92,7 +94,7 @@ describe("web clipper store command", () => { }).action).toBe("already-submitted"); }); - test("blocks a different Chrome version that is already pending", () => { + test("defers a different Chrome version that is already pending", () => { expect(chromeSubmissionPlan({ packageVersion: "0.2.0", status: { @@ -101,7 +103,7 @@ describe("web clipper store command", () => { distributionChannels: [{ crxVersion: "0.1.9" }], }, }, - }).action).toBe("blocked"); + }).action).toBe("waiting-review"); }); test("uses the highest Chrome channel version", () => { @@ -124,16 +126,78 @@ describe("web clipper store command", () => { expect(firefoxSubmissionPlan({ packageVersion: "0.1.8", versions: [{ version: "0.1.8", file: { status: "public" }, source: "https://example.test/source" }], - }).action).toBe("blocked"); + }).action).toBe("already-published"); }); - test("blocks a second Firefox upload while another version awaits review", () => { + test("defers a second Firefox upload while another version awaits review", () => { expect(firefoxSubmissionPlan({ packageVersion: "0.1.10", versions: [{ version: "0.1.9", file: { status: "unreviewed" }, source: "https://example.test/source" }], }).reason).toContain("0.1.9"); }); + test("skips older release packages after a newer version is public", () => { + expect(chromeSubmissionPlan({ + packageVersion: "0.1.8", + status: { publishedItemRevisionStatus: { distributionChannels: [{ crxVersion: "0.1.9" }] } }, + }).action).toBe("already-published"); + expect(firefoxSubmissionPlan({ + packageVersion: "0.1.8", + versions: [{ version: "0.1.9", file: { status: "public" } }], + }).action).toBe("already-published"); + }); + + test("does not hide rejected Firefox versions as successful skips", () => { + expect(firefoxSubmissionPlan({ + packageVersion: "0.1.9", + versions: [{ version: "0.1.9", file: { status: "disabled" } }], + }).action).toBe("blocked"); + }); + + test("Firefox skips public and pending packages without any write request", async () => { + for (const [status, version, action] of [ + ["public", "0.1.9", "already-published"], + ["unreviewed", "0.1.9", "already-submitted"], + ["unreviewed", "0.1.8", "waiting-review"], + ]) { + const calls = []; + const result = await submitFirefox({ + version: "0.1.9", + env: { AMO_JWT_ISSUER: "test", AMO_JWT_SECRET: "test" }, + fetchImpl: async (url, options) => { + calls.push({ url, method: options.method ?? "GET" }); + return jsonResponse({ results: [{ version, file: { status }, source: "source.zip" }] }); + }, + }); + expect(result.action).toBe(action); + expect(calls).toHaveLength(1); + expect(calls[0].method).toBe("GET"); + } + }); + + test("Chrome skips public and pending packages without upload or publish", async () => { + for (const [revision, version, action] of [ + ["publishedItemRevisionStatus", "0.1.9", "already-published"], + ["submittedItemRevisionStatus", "0.1.9", "already-submitted"], + ["submittedItemRevisionStatus", "0.1.8", "waiting-review"], + ]) { + const calls = []; + const result = await submitChrome({ + version: "0.1.9", + env: {}, + fetchImpl: async (url) => { + calls.push(String(url)); + return jsonResponse(String(url).endsWith("/token") + ? { access_token: "test" } + : { [revision]: { state: "PENDING_REVIEW", distributionChannels: [{ crxVersion: version }] } }); + }, + }); + expect(result.action).toBe(action); + expect(calls).toHaveLength(2); + expect(calls[1]).toEndWith(":fetchStatus"); + } + }); + test("reads a finished Chrome upload without treating every status item as finished", () => { expect(chromeUploadFinished({ itemId: "abc" })).toMatchObject({ done: true, ok: true }); expect(chromeUploadFinished({ uploadState: "IN_PROGRESS" }).done).toBe(false); @@ -287,6 +351,11 @@ describe("web clipper store command", () => { test("keeps store submission on the official repository workflow", () => { expect(workflow).toContain("github.repository == 'tianma-if/edgeever'"); expect(workflow).toContain("workflow_dispatch:"); + expect(workflow).toContain("- published"); + expect(workflow).toContain("!github.event.release.prerelease"); + expect(workflow).toContain("!github.event.release.draft"); + expect(workflow).toContain("ref: ${{ github.event.release.tag_name || inputs.release_tag || github.sha }}"); + expect(workflow).toContain("inputs.platform || 'both'"); expect(workflow).toContain("CHROME_WEB_STORE_SERVICE_ACCOUNT_JSON"); expect(workflow).toContain("AMO_JWT_ISSUER"); expect(workflow).toContain("AMO_JWT_SECRET");